import { _decorator, instantiate, Node, sp, UITransform, Vec3 } from 'cc'; import type { EntityId } from 'db://assets/mx/module/battle'; import { GBundle } from '../../game/ConfigRes'; import type { SkillPresentationContext } from '../skill/SkillPresentationTypes'; import { SkillPresentationEnv } from '../skill/SkillPresentationEnv'; import { SkillTransformDriver, computeAimAngleDeg } from '../skill/SkillTransformDriver'; import { computeArrowBowAimDeg, ARROW_BOW_OFFSET_DEG, } from '../skill/SkillParentAim'; import { BattleAudio } from '../audio/BattleAudio'; import { HeroSkin } from '../view/HeroSkin'; import { BattleSpinePlayback } from '../presentation/BattleSpinePlayback'; import { AttrId } from 'db://assets/mx/module/battle/ecs/Components'; import { BOW_ATK_ARROW_SPEED, SCATTER_RANGE_MUL, getBowAtkHost } from '../combat/BowAtkRuntime'; import { HeroActor } from './HeroActor'; const { ccclass } = _decorator; const NODE_GLYPH = '动画'; /** battleHero/prefab/skill/英雄弓-攻击 */ const ATK_PREFAB = '英雄弓-攻击'; const ATK_PREFAB_PATH = 'prefab/skill/'; const NODE_DRAW = '拉弓动画'; const NODE_PROJECTILE = '投掷物'; const SPINE_TEXT_IDLE = '待机'; /** 攻效拉弓(英雄弓-攻击 / 拉弓动画) */ const SPINE_SKILL_ATTACK2 = '攻击2'; const PROJECTILE_FLY_SPEED = 1280; /** 拉弓:从休息角平滑拧到瞄准角的时长(秒) */ const AIM_BLEND_SEC = 0.33; /** 连击时快速转向 */ const COMBO_AIM_BLEND_SEC = 0.1; /** 控制点落在拉弓射线上的路程比例;保证出弓切线 = 拉弓角 */ const LAUNCH_CTRL_T = 0.42; /** * 攻击2 拉满放箭时刻(秒 @ timeScale=1)。 * 弓/玄3 拉到最满 = 0.3667;槽位「弓/弓箭」0.4 才卸;整段动画 0.667 才结束。 */ const RELEASE_AT_BASE = 0.3667; /** 拉弓表现上限(秒),须低于 0.2 */ const BOW_ATTACK_MAX_SEC = 0.19; /** 攻击2 定格后无连击则回到文字待机 */ const COMBO_WINDOW_SEC = 1; /** 散射 17 首中被消耗的表现箭保底可视飞行时长(秒):近距命中箭不凭空消失 */ const SCATTER_CONSUME_MIN_FLY_SEC = 0.08; type BowPhase = 'idle' | 'aiming' | 'engaged' | 'returning'; /** * 弓:HeroActor 壳;字形「弓」只播待机。 * 攻击隐藏字形,加载「英雄弓-攻击」播攻击2;拉满时克隆「投掷物」飞出,攻效定格无箭末帧。 */ @ccclass('Gong') export class Gong extends HeroActor { textAnim: sp.Skeleton = null; skillAnim: sp.Skeleton = null; protected _textNode: Node | null = null; protected _skillParent: Node | null = null; /** 拉弓 Spine(英雄弓-攻击/拉弓动画) */ protected _skillNode: Node | null = null; /** 投掷物模板,仅用于 instantiate */ protected _projectileTemplate: Node | null = null; private _attackSeq = 0; /** 攻击2 播放段代数;作废过期的 complete/hold */ private _drawSeq = 0; private _phase: BowPhase = 'idle'; private _comboTimerActive = false; /** 连击窗口定时代数;递增即作废未触发的 schedulePresentation 回调 */ private _comboTimerSeq = 0; private _pendingCtx: SkillPresentationContext | null = null; private _flyingClones: Node[] = []; detachPooledFx(): void { this._destroyFlyingClones(); this._disposeAtkPrefab(); this._textNode = null; super.detachPooledFx(); } protected onLoad(): void { this._heroSkin = this.node.getComponent(HeroSkin); this._bindNodes(); this.onIdle(); } protected onDisable(): void { this.teardownPresentation(); this.endPresentationTeardown(); } protected onDestroy(): void { this.teardownLifecycle(); } onUpdateActive(active: boolean): void { this._heroSkin = this.node.getComponent(HeroSkin) ?? this._heroSkin; this._bindNodes(); if (!active) { // 失活打断表现;禁止在连击中直接 _applyDefaultIdleState(会字形待机叠拉弓角) if (!this.isPresentationTeardown()) this.abortPresentation(); return; } if (!this.isPresentationBusy()) this.onIdle(); } protected resolveTextNode(): Node | null { const display = this._heroSkin?.displayNode; if (!display?.isValid) return null; return display.getChildByName(NODE_GLYPH) ?? display; } protected resolveTextAnim(): sp.Skeleton | null { return this._heroSkin?.anim ?? null; } protected onBindNodesComplete(): void { this.syncAtkFxSkin(this.skillAnim); this._setProjectileTemplateVisible(false); if (this._phase === 'idle') { if (this._skillParent?.isValid) this._skillParent.active = false; } else { // 换皮/重绑后字形节点可能已换新实例,必须按当前相位重刷显隐 this._showAtkHideGlyph(); } } protected _bindNodes(): void { if (!this.node?.isValid) return; this._heroSkin = this.node.getComponent(HeroSkin) ?? this._heroSkin; this._textNode = this.resolveTextNode(); this.textAnim = this.resolveTextAnim() ?? this._textNode?.getComponent(sp.Skeleton) ?? null; this._ensureAtkPrefab(); this._skillNode = this._skillParent?.getChildByName(NODE_DRAW) ?? this._findDescendant(this._skillParent, NODE_DRAW) ?? null; this._projectileTemplate = this._resolveProjectileTemplate(); this.skillAnim = this._skillNode?.getComponent(sp.Skeleton) ?? null; if (!this._skillParent?.isValid) { console.log('[Gong] 攻效预制体未加载', ATK_PREFAB); } else if (!this._skillNode?.isValid) { console.log('[Gong] 攻效预制体缺少节点', NODE_DRAW); } else if (!this._projectileTemplate?.isValid) { console.log('[Gong] 攻效预制体缺少节点', NODE_PROJECTILE); } this.onBindNodesComplete(); } /** 从 battleHero/prefab/skill 取「英雄弓-攻击」,挂到字形根下(与「动画」同级) */ private _ensureAtkPrefab(): void { if (!this._skillParent?.isValid) { const atk = gg.res.getNode(ATK_PREFAB_PATH, ATK_PREFAB, GBundle.BattleHero); if (!atk?.isValid) { this._skillParent = null; return; } this._skillParent = atk; } this._attachAtkToGlyph(); } /** 攻效挂在英雄展示根上,勿挂进「动画」以免藏字时连弓一起关 */ private _attachAtkToGlyph(): void { const atk = this._skillParent; if (!atk?.isValid) return; const display = this._heroSkin?.displayNode; const parent = display?.isValid ? display : this.node; if (atk.parent !== parent) { atk.parent = parent; } atk.setPosition(0, 0, 0); } /** 回池前拆下攻效,避免嵌进 heroTemplate 池 */ private _disposeAtkPrefab(): void { if (this._skillParent?.isValid) { gg.res.putNode(this._skillParent); } this._skillParent = null; this._skillNode = null; this._projectileTemplate = null; this.skillAnim = null; } /** 投掷物在「英雄弓-攻击」或「拉弓动画」下 */ private _resolveProjectileTemplate(): Node | null { if (!this._skillParent?.isValid) return null; const direct = this._skillParent.getChildByName(NODE_PROJECTILE); if (direct?.isValid) return direct; const underDraw = this._skillNode?.getChildByName(NODE_PROJECTILE) ?? this._findDescendant(this._skillParent, NODE_PROJECTILE); return underDraw?.isValid ? underDraw : null; } /** 深度优先按名查找 */ private _findDescendant(root: Node | null | undefined, name: string): Node | null { if (!root?.isValid) return null; const direct = root.getChildByName(name); if (direct?.isValid) return direct; const children = root.children; for (let i = 0; i < children.length; i++) { const found = this._findDescendant(children[i], name); if (found) return found; } return null; } /** 攻击根是否挂在字形「动画」子树内 */ private _skillNestedUnderText(): boolean { if (!this._textNode?.isValid || !this._skillParent?.isValid) return false; let p: Node | null = this._skillParent.parent; while (p?.isValid) { if (p === this._textNode) return true; p = p.parent; } return false; } onIdle(): void { if (this.isPresentationBusy()) return; this._applyDefaultIdleState(); } /** * 弓攻击全程藏字形;基类伴侣字同步会在 textAnim 上播「攻击→待机」, * 与拉弓叠影/误显字形待机,这里必须空实现。 */ playPartnerAttackVisual(_timeScale = 1): void { } /** 换皮后:先重绑节点,再按相位重刷(连击中仍只显示拉弓) */ onHeroGlyphSkinRefreshed(): void { this._bindNodes(); if (this.isPresentationBusy()) this._showAtkHideGlyph(); } /** 闭环终点:字形显示+待机+角度 0,英雄弓-攻击隐藏+角度 0 */ protected _applyDefaultIdleState(): void { this._drawSeq++; this._phase = 'idle'; this._comboTimerActive = false; this._cancelComboTimer(); this._cancelAimAnimations(); this._setProjectileTemplateVisible(false); if (this._textNode?.isValid) { this._textNode.angle = 0; this._textNode.active = true; } if (this.textAnim?.isValid) this.textAnim.enabled = true; if (this._skillParent?.isValid) { this._skillParent.angle = 0; this._skillParent.active = false; } if (this.textAnim) this.playSpine(this.textAnim, SPINE_TEXT_IDLE); this._restoreAtkTimeScale(); this._heroSkin?.playIdleAnim(); } /** skillId Presenter 入口;普攻形态被覆盖为 12/17 时走散射 */ presentNormAttack(ctx: SkillPresentationContext): void { if (!this.isValid || !this.node?.isValid) { ctx.finish(); return; } const head = Math.floor(Number(ctx.params.atkTypeHead ?? ctx.params.atkType) || 0); if (head === 12 || head === 17) { this.presentScatterAttack(ctx); return; } this._bindNodes(); this._playAttackBow(ctx); } /** atk_type 12/17 散射(BowScatterAtkPresenter 委托):用自身「英雄弓-攻击」投掷物扇出 */ presentScatterAttack(ctx: SkillPresentationContext): void { if (!this.isValid || !this.node?.isValid) { ctx.finish(); return; } const head = Math.floor(Number(ctx.params.atkTypeHead ?? ctx.params.atkType) || 0); this._bindNodes(); this._playAttackBow(ctx, head === 12 ? 12 : 17); } abortPresentation(): void { this.abortPresentationCast(); this._abortPendingPresentation(); } isPresentationBusy(): boolean { return this._phase !== 'idle'; } /** * 弓普攻:只打 ctx.targets[0](路径最前),箭飞到该目标落地才结算。 * 藏字形「弓」,显「英雄弓-攻击」插值瞄准后播攻击2;拉满发射投掷物。 * 1 秒内连击再播攻击2;窗内无下一箭则插值回休息角切文字待机。 * @param ctx 表现上下文 * @param shape 0 单箭;12 穿透散射;17 不穿透散射 */ private _playAttackBow(ctx: SkillPresentationContext, shape = 0) { if (!ctx.isAlive) { ctx.finish(); return; } const shotTargets = ctx.targets.filter(id => SkillPresentationEnv.isPresentTargetValid(id)); if (shotTargets.length === 0) { ctx.finish(); return; } const flyLayer = SkillPresentationEnv.flyLayer; if (!flyLayer || !this._projectileTemplate?.isValid) { ctx.setTargets([shotTargets[0]]); ctx.hit(); ctx.finish(); return; } this._supersedePendingCast(ctx); const seq = ++this._attackSeq; this._pendingCtx = ctx; const aspdScale = Math.max(0.01, SkillPresentationEnv.getFinalAttackSpeed(ctx.caster)); const bowAnimTs = Math.max(aspdScale, RELEASE_AT_BASE / BOW_ATTACK_MAX_SEC); let unlocked = false; const unlockCast = () => { if (unlocked) return; unlocked = true; if (this._pendingCtx === ctx) this._pendingCtx = null; if (ctx.isAlive) ctx.finish(); }; let pendingFlights = 0; let visualDone = false; const finishVisual = () => { if (visualDone) return; visualDone = true; unlockCast(); }; const pivotWorld = this._skillParent?.isValid ? this._skillParent.worldPosition.clone() : SkillPresentationEnv.getCasterGridCenter(ctx.caster); const primaryId = shotTargets[0]; const primaryWorld = SkillPresentationEnv.getTargetWorldPos(primaryId); if (!pivotWorld || !primaryWorld) { console.log('[Gong] 普攻缺少施法点或目标点', ctx.caster, primaryId); unlockCast(); finishVisual(); this._beginReturnToRest(); return; } const bowAimDeg = computeArrowBowAimDeg( pivotWorld, primaryWorld, ARROW_BOW_OFFSET_DEG, ) ?? (computeAimAngleDeg(pivotWorld, primaryWorld) ?? 0); // 连击 = 仍处于拉弓交战态(含攻击2 播放中、已拉满定格)。 // 不可用 _comboTimerActive:拉满后、进 hold 前的窗口若按非连击会 setup 定格并打断攻击2。 const isCombo = this._phase === 'engaged'; this._cancelComboTimer(); if (!isCombo) { this._cancelAimAnimations(); } let released = false; const holdEngagedAfterEmptyCast = () => { if (seq !== this._attackSeq || !this.isValid || !ctx.isAlive) return; // 仅攻击2 已起播(engaged)才定格;aiming 阶段走正常瞄准 if (this._phase !== 'engaged') return; this._enterHold(this._drawSeq); }; const launchVolley = () => { if (released || seq !== this._attackSeq) { if (!released) { unlockCast(); finishVisual(); } return; } const destId = SkillPresentationEnv.isPresentTargetValid(primaryId) ? primaryId : 0; const destWorld = destId > 0 ? SkillPresentationEnv.getTargetWorldPos(destId) : null; if (!ctx.isAlive || destId <= 0 || !destWorld) { unlockCast(); finishVisual(); holdEngagedAfterEmptyCast(); return; } released = true; BattleAudio.playWeaponAttack(ctx.caster); this._setProjectileTemplateVisible(false); unlockCast(); if (shape === 12 || shape === 17) { // 散射(12 穿透 / 17 不穿透):逻辑弹道+表现箭各自回收,不阻塞下一击 this._launchScatterVolley(ctx, destId, shape); finishVisual(); return; } pendingFlights = 1; const onOneFlightDone = () => { pendingFlights--; if (pendingFlights <= 0) finishVisual(); }; const launchDeg = computeArrowBowAimDeg( pivotWorld, destWorld, ARROW_BOW_OFFSET_DEG, ) ?? (computeAimAngleDeg(pivotWorld, destWorld) ?? bowAimDeg); const projectileClone = this._cloneProjectile(); if (!projectileClone) { onOneFlightDone(); return; } this._launchProjectile({ ctx, destId, projectileNode: projectileClone, flyLayer, fromWorld: projectileClone.worldPosition.clone(), toWorld: destWorld, launchDeg, onComplete: onOneFlightDone, }); }; const beginDrawAndRelease = () => { if (seq !== this._attackSeq || !this.isValid) return; this._phase = 'engaged'; this.syncAtkFxSkin(this.skillAnim); this._showAtkHideGlyph(); this._setProjectileTemplateVisible(false); const drawSeq = ++this._drawSeq; if (this.skillAnim) { this.skillAnim.clearTracks(); this.skillAnim.setToSetupPose(); this.playSpine( this.skillAnim, SPINE_SKILL_ATTACK2, () => this._onDrawAnimComplete(drawSeq), bowAnimTs, false, ); } else { console.log('[Gong] 攻效缺少拉弓动画', NODE_DRAW); this._enterHold(drawSeq); } const releaseDelay = RELEASE_AT_BASE / bowAnimTs; this.schedulePresentation(() => { if (seq !== this._attackSeq) { if (!released) { unlockCast(); finishVisual(); } return; } if (released) return; launchVolley(); }, releaseDelay); }; this._showAtkHideGlyph(); this._setProjectileTemplateVisible(false); if (!isCombo) { this._phase = 'aiming'; this._holdAtkSetupPose(); } const fromAngle = this._skillParent?.isValid ? this._skillParent.angle : (this._textNode?.angle ?? 0); const blendSec = isCombo ? COMBO_AIM_BLEND_SEC : AIM_BLEND_SEC; this._animateAimPair(fromAngle, bowAimDeg, blendSec, beginDrawAndRelease); } /** 攻击2 播完:定格无箭末帧,开连击窗(过期 complete 不得再进 hold) */ private _onDrawAnimComplete(drawSeq: number): void { if (!this.isValid || this._phase !== 'engaged') return; if (drawSeq !== this._drawSeq) return; this._enterHold(drawSeq); } /** 攻效定格末帧;1 秒内没有下一箭则回文字待机 */ private _enterHold(drawSeq: number): void { if (drawSeq !== this._drawSeq) return; this._phase = 'engaged'; this._comboTimerActive = true; this._showAtkHideGlyph(); this._setProjectileTemplateVisible(false); if (this.skillAnim?.isValid) { BattleSpinePlayback.clearPendingComplete(this.skillAnim); this.skillAnim.timeScale = 0; } this._scheduleComboReturn(); } /** 藏字形「弓」,显「英雄弓-攻击」(投掷物模板保持隐藏,发射时再克隆) */ private _showAtkHideGlyph(): void { this._setTextVisible(false); this._setSkillParentVisible(true); this._setProjectileTemplateVisible(false); } /** 瞄准前攻效停在 setup,避免预制体默认循环播攻击2 */ private _holdAtkSetupPose(): void { if (!this.skillAnim?.isValid) return; BattleSpinePlayback.resetTracks(this.skillAnim); this.skillAnim.setToSetupPose(); this.skillAnim.timeScale = 0; } /** 解除攻效定格 */ private _restoreAtkTimeScale(): void { if (!this.skillAnim?.isValid) return; BattleSpinePlayback.clearPendingComplete(this.skillAnim); this.skillAnim.timeScale = 1; } private _scheduleComboReturn(): void { const seq = ++this._comboTimerSeq; this.schedulePresentation(() => { if (seq !== this._comboTimerSeq) return; if (!this.isValid || this.isPresentationTeardown()) return; this._onComboWindowExpired(); }, COMBO_WINDOW_SEC); } /** 收招窗到点:本窗内没有下一箭则回文字待机 */ private _onComboWindowExpired = (): void => { if (!this.isValid || this.isPresentationTeardown() || this._phase !== 'engaged') return; this._comboTimerActive = false; this._beginReturnToRest(); }; /** 攻效定格拧回休息角后切文字待机 */ private _beginReturnToRest(): void { if (!this.isValid || this._phase === 'idle' || this._phase === 'returning') return; this._phase = 'returning'; this._comboTimerActive = false; this._cancelComboTimer(); this._setProjectileTemplateVisible(false); if (this.skillAnim?.isValid) BattleSpinePlayback.clearPendingComplete(this.skillAnim); this._animateAimToRest(AIM_BLEND_SEC, () => { if (!this.isValid) return; // 回正动画期间若已被新普攻抢占(aiming/engaged),不得再掀字形 if (this._phase !== 'returning') return; this._applyDefaultIdleState(); }); } /** * 攻效转到瞄准角(箭发射角与此一致)。 * @param fromDeg 当前角 * @param toDeg 目标角 * @param duration 秒 * @param onComplete 转到后 */ private _animateAimPair(fromDeg: number, toDeg: number, duration: number, onComplete: () => void): void { let pending = 0; let finished = false; const finishOne = () => { pending--; if (pending <= 0 && !finished) { finished = true; onComplete(); } }; for (const node of [this._skillParent]) { if (!node?.isValid) continue; pending++; SkillTransformDriver.cancelNode(node); node.angle = fromDeg; SkillTransformDriver.animate({ node, duration, fromAngle: fromDeg, toAngle: toDeg, easing: SkillTransformDriver.easeSineInOut, onComplete: finishOne, }); } if (pending === 0) onComplete(); } /** * 攻效转回休息角。 * @param duration 秒 * @param onComplete 转到后 */ private _animateAimToRest(duration: number, onComplete: () => void): void { let pending = 0; let finished = false; const finishOne = () => { pending--; if (pending <= 0 && !finished) { finished = true; onComplete(); } }; if (this._skillParent?.isValid) { pending++; const fromDeg = this._skillParent.angle; SkillTransformDriver.cancelNode(this._skillParent); SkillTransformDriver.animate({ node: this._skillParent, duration, fromAngle: fromDeg, toAngle: 0, easing: SkillTransformDriver.easeSineInOut, onComplete: finishOne, }); } if (pending === 0) onComplete(); } private _cancelAimAnimations(): void { if (this._textNode?.isValid) SkillTransformDriver.cancelNode(this._textNode); if (this._skillParent?.isValid) SkillTransformDriver.cancelNode(this._skillParent); } private _cancelComboTimer(): void { this._comboTimerSeq++; } private _setTextVisible(visible: boolean): void { if (!this._textNode?.isValid) return; // 攻击根挂在「动画」下时不可关节点,否则弓一并隐藏 if (this._skillNestedUnderText()) { this._textNode.active = true; if (this.textAnim?.isValid) this.textAnim.enabled = visible; return; } this._textNode.active = visible; if (visible && this.textAnim?.isValid) this.textAnim.enabled = true; } private _setSkillParentVisible(visible: boolean): void { if (this._skillParent?.isValid) { this._skillParent.active = visible; } } private _setProjectileTemplateVisible(visible: boolean): void { if (this._projectileTemplate?.isValid) { this._projectileTemplate.active = visible; } } private _cloneProjectile(): Node | null { if (!this._projectileTemplate?.isValid) return null; const clone = instantiate(this._projectileTemplate); clone.parent = this._projectileTemplate.parent; clone.setPosition(this._projectileTemplate.position); clone.setScale(this._projectileTemplate.scale); clone.angle = this._projectileTemplate.angle; clone.active = true; this._flyingClones.push(clone); return clone; } /** 表现箭抛物线飞向锁定目标,落地再结算该目标 */ private _launchProjectile(opts: { ctx: SkillPresentationContext; destId: EntityId; projectileNode: Node; flyLayer: Node; fromWorld: Vec3; toWorld: Vec3; launchDeg: number; onComplete: () => void; }): void { const { ctx, destId, projectileNode, flyLayer, fromWorld, toWorld, launchDeg, onComplete } = opts; if (!projectileNode.isValid) { onComplete(); return; } flyLayer.active = true; const flyUt = flyLayer.getComponent(UITransform); const worldScale = projectileNode.worldScale.clone(); projectileNode.parent = flyLayer; projectileNode.setWorldScale(worldScale); projectileNode.angle = launchDeg; if (flyUt) { projectileNode.setPosition(flyUt.convertToNodeSpaceAR(fromWorld)); } else { projectileNode.setWorldPosition(fromWorld); } const dist = Vec3.distance(fromWorld, toWorld); const duration = Math.max(0.12, dist / PROJECTILE_FLY_SPEED); const controlWorld = this._buildFlightControl(fromWorld, launchDeg, dist); SkillTransformDriver.animateParabola({ node: projectileNode, duration, fromWorld, toWorld, controlWorld, onComplete: () => this._onFlightComplete(ctx, destId, projectileNode, flyLayer, onComplete), }); } private _buildFlightControl( from: Readonly, launchDeg: number, dist: number, ): Vec3 { const rad = launchDeg * Math.PI / 180; const ctrlLen = Math.max(40, dist * LAUNCH_CTRL_T); return new Vec3( from.x + Math.cos(rad) * ctrlLen, from.y + Math.sin(rad) * ctrlLen, from.z, ); } /** * 散射出手(12 穿透 / 17 不穿透):以自身「投掷物」为模板扇出直线箭, * 飞距=出手时射程×倍率;逻辑伤害由 BowAtkHost 体盒结算。 * 17 首中即消耗并当场回收表现箭;没碰怪飞满全程消失。 * @param ctx 表现上下文 * @param destId 瞄准目标(中线对准) * @param shape 12 / 17 */ private _launchScatterVolley(ctx: SkillPresentationContext, destId: EntityId, shape: number): void { const parts = String(ctx.params.atkPara ?? '').split(',').map(Number); const count = Math.floor(parts[0] || 0); const stepDeg = parts[1]; const wan = parts[2]; const rangePx = SkillPresentationEnv.getCasterAttr(ctx.caster, AttrId.Range); const template = this._projectileTemplate; const ut = template?.isValid ? template.getComponent(UITransform) : null; if (!(count > 0) || !(stepDeg > 0) || !(wan > 0) || !(rangePx > 0) || !template?.isValid || !ut) { console.log('[Gong] 散射参数或投掷物模板无效', ctx.params.atkPara, rangePx); return; } const targetWorld = SkillPresentationEnv.getTargetWorldPos(destId); const fromWorld = template.worldPosition.clone(); const aimDeg = targetWorld ? (computeAimAngleDeg(fromWorld, targetWorld) ?? 0) : (this._skillParent?.angle ?? 0); const degs: number[] = []; getBowAtkHost().fillScatterDegs(aimDeg, count, stepDeg, degs); const ws = template.worldScale; const halfLen = ut.width * Math.abs(ws.x) * 0.5; const halfWid = ut.height * Math.abs(ws.y) * 0.5; const dist = rangePx * SCATTER_RANGE_MUL; const duration = dist / PROJECTILE_FLY_SPEED; const flyLayer = SkillPresentationEnv.flyLayer; const spawnAtMs = Date.now(); for (let i = 0; i < degs.length; i++) { const deg = degs[i]; const clone = this._cloneProjectile(); if (!clone) break; // 重挂 flyLayer 会丢世界坐标:先记弓上位置,重挂后按世界坐标摆回 const from = clone.worldPosition.clone(); if (flyLayer?.isValid) { const wsClone = clone.worldScale.clone(); const flyUt = flyLayer.getComponent(UITransform); flyLayer.active = true; clone.parent = flyLayer; clone.setWorldScale(wsClone); if (flyUt) clone.setPosition(flyUt.convertToNodeSpaceAR(from)); else clone.setWorldPosition(from); } clone.angle = deg; const rad = deg * Math.PI / 180; const ux = Math.cos(rad); const uy = Math.sin(rad); const toWorld = new Vec3(from.x + ux * dist, from.y + uy * dist, from.z); this._flyScatterClone(clone, flyLayer, from, toWorld, duration); getBowAtkHost().spawnRay({ caster: ctx.caster, abilityId: ctx.abilityId, x: from.x, y: from.y, ux, uy, speed: BOW_ATK_ARROW_SPEED, left: duration, scale: wan / 10000, halfLen, halfWid, buff1: String(ctx.params.buff1 ?? ''), buff2: String(ctx.params.buff2 ?? ''), hitIds: [], pierce: shape !== 17, onConsume: shape === 17 ? this._consumeScatterClone.bind(this, clone, flyLayer, spawnAtMs) : undefined, }); } } /** 散射表现箭直线飞行(体盒伤害在逻辑层,表现只跟位置) */ private _flyScatterClone(clone: Node, flyLayer: Node | null, fromWorld: Readonly, toWorld: Readonly, duration: number): void { const flyUt = flyLayer?.getComponent(UITransform) ?? null; const fromLocal = flyUt ? flyUt.convertToNodeSpaceAR(fromWorld) : fromWorld.clone(); const toLocal = flyUt ? flyUt.convertToNodeSpaceAR(toWorld) : toWorld.clone(); SkillTransformDriver.animate({ node: clone, duration, fromPos: fromLocal, toPos: toLocal, easing: SkillTransformDriver.easeLinear, onComplete: () => this._recycleScatterClone(clone, flyLayer), }); } /** * 17 首中被消耗:出手瞬间命中(近距首中)时箭还没飞出来就回收会像没发射, * 保底再飞 SCATTER_CONSUME_MIN_FLY_SEC 再回收;已飞够时长则当场回收。 */ private _consumeScatterClone(clone: Node, flyLayer: Node | null, spawnAtMs: number): void { const elapsedSec = (Date.now() - spawnAtMs) / 1000; const waitSec = SCATTER_CONSUME_MIN_FLY_SEC - elapsedSec; if (waitSec <= 0) { this._recycleScatterClone(clone, flyLayer); return; } this.schedulePresentation(() => this._recycleScatterClone(clone, flyLayer), waitSec); } /** 回收散射表现箭(飞到终点或 17 首中被消耗) */ private _recycleScatterClone(clone: Node, flyLayer: Node | null): void { if (clone?.isValid) { SkillTransformDriver.cancelNode(clone); clone.destroy(); } const idx = this._flyingClones.indexOf(clone); if (idx >= 0) this._flyingClones.splice(idx, 1); this._maybeHideFlyLayer(flyLayer); } private _onFlightComplete(ctx: SkillPresentationContext, destId: EntityId, projectileNode: Node, flyLayer: Node, onComplete: () => void): void { this._settleBowLand(ctx, destId); if (projectileNode.isValid) { SkillTransformDriver.cancelNode(projectileNode); projectileNode.destroy(); } if (this.isValid) { const cloneIdx = this._flyingClones.indexOf(projectileNode); if (cloneIdx >= 0) this._flyingClones.splice(cloneIdx, 1); } this._maybeHideFlyLayer(flyLayer); onComplete(); } /** 箭落地:只打锁定目标,已死不结算 */ private _settleBowLand(ctx: SkillPresentationContext, destId: EntityId): void { if (!ctx.isAlive || destId <= 0) return; if (!SkillPresentationEnv.isPresentTargetValid(destId)) return; ctx.setTargets([destId]); ctx.dealDamage(destId); } private _maybeHideFlyLayer(flyLayer: Node | null): void { if (flyLayer?.isValid && flyLayer.children.length === 0) { flyLayer.active = false; } } private _destroyFlyingClones(): void { const flyLayer = SkillPresentationEnv.flyLayer; for (const clone of this._flyingClones) { if (!clone?.isValid) continue; SkillTransformDriver.cancelNode(clone); clone.destroy(); } this._flyingClones.length = 0; this._maybeHideFlyLayer(flyLayer); } /** 新普攻覆盖未 finish 的旧 cast,避免 deferred 施法锁残留 */ private _supersedePendingCast(nextCtx: SkillPresentationContext): void { const prev = this._pendingCtx; if (prev && prev !== nextCtx && prev.isAlive) { prev.cancel('replaced'); } this.unscheduleAllCallbacks(); } private _abortPendingPresentation(): void { this._attackSeq++; this._drawSeq++; this._cancelComboTimer(); this.unscheduleAllCallbacks(); this._comboTimerActive = false; if (this.textAnim?.isValid) BattleSpinePlayback.clearPendingComplete(this.textAnim); if (this.skillAnim?.isValid) BattleSpinePlayback.clearPendingComplete(this.skillAnim); const ctx = this._pendingCtx; this._pendingCtx = null; this._cancelAimAnimations(); this._destroyFlyingClones(); if (ctx?.isAlive) ctx.cancel('aborted'); if (!this.isPresentationTeardown() && this.isValid) this._applyDefaultIdleState(); } }