import type { EntityId } from 'db://assets/mx/module/ecs'; import { instantiate, Node, sp, UITransform, Vec3 } from 'cc'; import { TableNames } from '../../game/ConfigTableData'; import { BattleMapSpec } from '../map/BattleMapSpec'; import { getTowerLoadoutService, resolveHeroBattleWeaponId } from '../combat/TowerLoadoutService'; import { SkillPresentationEnv } from '../skill/SkillPresentationEnv'; import { schedulePresentation } from '../skill/BattlePresentationClock'; import { SkillTransformDriver, computeAimAngleDeg } from '../skill/SkillTransformDriver'; import { worldPointAtAimAngle, computeArrowBowAimDeg, ARROW_BOW_OFFSET_DEG } from '../skill/SkillParentAim'; import { HeroSkin } from '../view/HeroSkin'; import { HeroWeaponMount, WEAPON_REST_ANGLE, BOW_ATK_REST_ANGLE, BOW_RAIN_AIM_DEG, WEAPON_LEFT_OFFSET_PX } from '../view/HeroWeaponMount'; import { BattleSpinePlayback } from './BattleSpinePlayback'; import { glyphSpineHasSkin } from '../view/GlyphSpineSkin'; import { ensureSpineSkeletonDataRuntime } from 'db://assets/mx/module/lang/spineRuntimePatch'; import { BOW_ATK_ARROW_SPEED, SCATTER_RANGE_MUL, getBowAtkHost } from '../combat/BowAtkRuntime'; import { GBundle, GPath } from '../../game/ConfigRes'; /** 默认命中相对攻击动画进度(@ timeScale=1) */ export const PRES_HIT_AT_BASE = 0.33; /** 武器弓「攻击」拉满时刻(秒 @ timeScale=1):bone5 拉到最满,早于卸箭 0.4667 / 整段 1.0 */ const BOW_RELEASE_AT_BASE = 0.4333; /** * 法杖发射物出手兜底帧(秒 @ timeScale=1)。 * 对齐骨骼「挥砍」首关键帧(聚能后砸出);优先读 runtime skeletonJson。 */ const STAFF_PROJECTILE_RELEASE_AT = 0.6333; /** 法杖发射物出手下限(动画时间轴秒);配表动作至少蓄力这么久再出弹 */ const STAFF_PROJECTILE_MIN_AT = 0.3; /** 法杖出手特效骨(水弹/冰锥/暴风雪共用「挥砍」) */ const STAFF_RELEASE_BONE = '挥砍'; /** * weaponType4 / 弓类武器+专属技能:「攻击」「攻击2」「攻击3」槽位「箭矢*」附件清空帧。 * 整段动画 1.5s,卸箭=拉完放箭时刻(非动画结束)。 */ const ELEMENT_BOW_ARROW_CLEAR_AT = 0.4667; /** 拉弓表现上限(秒),与英雄弓一致 */ const BOW_ATTACK_MAX_SEC = 0.19; /** 悟空主动武器动画:绕两字椭圆一圈 */ const WUKONG_SKILL_ANIM = '悟空技能'; /** 木吒主动武器动画 */ const MUZHA_SKILL_ANIM = '木吒技能'; /** * 「木吒技能」挥砍特效首帧(@ timeScale=1)。 * 剑挥砍 0.80→0.90,挥砍1–6 贴图 0.8667–1.0667;总长 1.5s。 */ const MUZHA_SKILL_HIT_AT = 0.8667; /** 如意金箍棒武器动画 */ const JINGU_SKILL_ANIM = '金箍棒技能'; /** 「金箍棒技能」拉伸砸出首帧(@ timeScale=1):金箍棒1 / 击中光,总长约 2.33s */ const JINGU_SKILL_HIT_AT = 0.8667; /** 悟空绕圈短半径(半格) */ const WUKONG_ORBIT_RADIUS_Y = BattleMapSpec.CELL_SIZE * 0.5; /** 绕圈时长 = 动画时长减去该秒数 */ const WUKONG_ORBIT_TRIM_SEC = 0.3; /** Weapon.type 刀 / herotype 金 */ const WEAPON_TYPE_SWORD = 1; /** herotype 木 */ const WEAPON_TYPE_WOOD = 2; /** herotype 水 */ const WEAPON_TYPE_STAFF = 3; /** Weapon.type 弓 / herotype 火 */ const WEAPON_TYPE_BOW = 4; /** herotype 土(老项目枪) */ const WEAPON_TYPE_SPEAR = 5; /** 控制点沿出弓切线的路程比例(切线相对目标连线偏 ARROW_BOW_OFFSET_DEG) */ const BOW_ARROW_CTRL_T = 0.42; /** 连发固定三弹道,发次对 3 取余循环 */ const VOLLEY_LANE_COUNT = 3; /** 左右弹道控制点侧偏占射程比例(曲线最大外弯约为一半) */ const VOLLEY_OUT_T = 0.5; /** 左右弹道侧偏下限(像素) */ const VOLLEY_OUT_MIN = 80; /** 箭飞行动画 */ const BOW_ARROW_FLY_ANIM = '飞行箭矢'; /** 武器朝向插值秒(弓/枪/棍等旋转武器,@ timeScale=1) */ const WEAPON_AIM_SEC = 0.2; /** 枪/棍攻击2 末帧定格窗(秒):窗内下一刀不回待机,与英雄枪/弓一致 */ const WEAPON_REST_HOLD_SEC = 1; /** @deprecated 保留别名;请用 WEAPON_AIM_SEC */ const BOW_AIM_SEC = WEAPON_AIM_SEC; /** battleHero/prefab/skill/水弹 */ const WATER_BALL_PREFAB = '水弹'; /** battleHero/prefab/skill/冰锥(水属性冰锥技能发射物) */ const ICE_CONE_PREFAB = '冰锥'; /** * 水弹/冰锥「飞行水弹」原图约 322×122;缩到约一格可视大小。 * 缩放比 = CELL_SIZE / 原图像素宽。 */ const WATER_BALL_SCALE = BattleMapSpec.CELL_SIZE / 322; /** 水弹飞行像素/秒(与弓箭同量级) */ const WATER_BALL_SPEED = BOW_ATK_ARROW_SPEED; /** battleHero/prefab/skill/飞剑(武器主动) */ const FLY_SWORD_PREFAB = '飞剑'; /** 飞剑默认 Spine 动画 */ const FLY_SWORD_ANIM = '飞剑'; /** battleHero/prefab/skill/玉帝飞剑 */ const YUDI_FLY_SWORD_PREFAB = '玉帝飞剑'; /** 飞剑预制体默认动画(带光效) */ const YUDI_FLY_SWORD_ANIM = '玉帝普攻'; /** 玉帝飞剑飞行像素/秒 */ const YUDI_FLY_SWORD_SPEED = 650; /** 飞剑最短飞行秒(需盖过「玉帝普攻」光效起势) */ const YUDI_FLY_SWORD_MIN_SEC = 0.25; /** 射日弓 Weapon.skin */ const SUN_BOW_SKIN = '射日弓'; /** 射日弓飞出投掷物相对模板的缩放;其它弓保持 1 */ const SUN_BOW_FLY_SCALE = 0.6; /** * 连发飞箭落地回收绑定(类方法,避免函数内闭包)。 */ class VolleyFlyRecycle { private static readonly _pool: VolleyFlyRecycle[] = []; private _channel: SkillVisualChannel | null = null; private _node: Node | null = null; /** 落地:回收表现箭并归还绑定 */ readonly onLand = (): void => { const channel = this._channel; const node = this._node; this._channel = null; this._node = null; VolleyFlyRecycle._pool.push(this); if (channel && node) channel.recycleFlyArrow(node); }; /** * 取得绑定。 * @param channel 表现通道 * @param node 飞箭 */ static obtain(channel: SkillVisualChannel, node: Node): VolleyFlyRecycle { const rec = VolleyFlyRecycle._pool.length > 0 ? VolleyFlyRecycle._pool.pop()! : new VolleyFlyRecycle(); rec._channel = channel; rec._node = node; return rec; } } /** * 水弹落地回池(类方法,避免函数内闭包)。 */ class WaterBallRecycle { private static readonly _pool: WaterBallRecycle[] = []; private _channel: SkillVisualChannel | null = null; private _node: Node | null = null; /** 落地:回池水弹 */ readonly onLand = (): void => { const channel = this._channel; const node = this._node; this._channel = null; this._node = null; WaterBallRecycle._pool.push(this); if (channel && node) channel.recycleWaterBall(node); }; /** * 取得绑定。 * @param channel 表现通道 * @param node 水弹 */ static obtain(channel: SkillVisualChannel, node: Node): WaterBallRecycle { const rec = WaterBallRecycle._pool.length > 0 ? WaterBallRecycle._pool.pop()! : new WaterBallRecycle(); rec._channel = channel; rec._node = node; return rec; } } /** 待发射的弓箭 */ interface BowArrowLaunch { /** 代数,过期则丢弃 */ gen: number; /** 主目标 */ target: EntityId; /** 落地任务 id(atk_type=4) */ landId: number; /** 落地回调 */ onLand: ((target: EntityId, landId: number) => void) | null; } /** 在飞普攻箭的落地绑定 */ interface BowLandFlight { /** 飞箭节点 */ node: Node; /** 锁定目标 */ target: EntityId; /** 落地任务 id */ landId: number; /** 落地回调 */ onLand: ((target: EntityId, landId: number) => void) | null; } /** 播动画参数 */ export interface CastAnimParams { /** 施法者 */ caster: EntityId; /** Skill.animation:仅武器释放用;字形固定「攻击」 */ animation: string; /** 是否跟播武器 */ followWeapon: boolean; /** 主目标(瞄准) */ primaryTarget: EntityId | null; /** Ability 槽:normal 跟攻速;hero/weapon/monster 固定 1 */ skillKind?: string; /** 跳过默认单箭(atk_type 12/13 自行发射) */ skipBowArrow?: boolean; /** atk_type=4 非弓:出手立刻飞出玉帝飞剑 */ throwWeapon?: boolean; /** 弓对准目标连线,无抛物线偏角(散射) */ aimDirect?: boolean; /** 覆盖攻速:动画与瞄准倍速 */ animTimeScale?: number; /** 播完不收招待机(连发由外部收招) */ holdCasting?: boolean; /** holdCasting 时武器动画循环(暴风雪等;须 endHoldCast 停) */ loopWeapon?: boolean; /** 瞄准世界 X(优先于 primaryTarget) */ aimWorldX?: number; /** 瞄准世界 Y */ aimWorldY?: number; /** 不朝向目标,保持武器待机角(召唤等) */ keepRestAim?: boolean; /** 弓攻效固定朝向(度);万箭齐发为 90 向上 */ fixedAimDeg?: number; /** atk_type=4 弓箭落地(锁定目标);landId 对应 Skill.armBowProjectileLand */ onProjectileLand?: (target: EntityId, landId: number) => void; /** 落地任务 id;0=无 */ projectileLandId?: number; /** 动画播完 */ onAnimComplete: () => void; } /** 字形释放动画固定名 */ const GLYPH_CAST_ANIM = '攻击'; /** 字形待机动画 */ const GLYPH_IDLE_ANIM = '待机'; /** * 视觉通道:字形 + 可选武器挂载。 * 组合英雄激活后常驻显示当前装备武器;五行数字仅攻击显示武器。 */ export class SkillVisualChannel { private _skin: HeroSkin | null = null; /** 五行数字节点 Spine(无 HeroSkin 时播攻击/待机) */ private _glyphAnim: sp.Skeleton | null = null; private readonly _weapon = new HeroWeaponMount(); private _parent: Node | null = null; private _heroId = 0; private _useWeapon = false; /** 五行数字:仅攻击显示武器,收招后 1 秒无出手再藏 */ private _attackOnlyWeapon = false; /** 五行武器本地 X;null 用 WEAPON_LEFT_OFFSET_PX */ private _weaponLocalX: number | null = null; private _caster: EntityId | null = null; private readonly _weaponWorld = new Vec3(); /** 武器释放动画播放中;命中结算会 onIdle,勿此时打断 */ private _weaponCasting = false; private _bowLaunchGen = 0; private readonly _bowLaunches: BowArrowLaunch[] = []; private readonly _bowArrows: Node[] = []; /** 在飞的玉帝飞剑(atk_type=4 非弓) */ private readonly _weaponFlies: Node[] = []; /** 最近一次玉帝飞剑飞行时长(秒);未发射为 0 */ private _lastThrowFlySec = 0; /** 在飞普攻箭落地绑定(可与下一击重叠) */ private readonly _bowFlights: BowLandFlight[] = []; private readonly _arrowFrom = new Vec3(); private readonly _arrowTo = new Vec3(); private readonly _arrowCtrl = new Vec3(); private readonly _scatterDegs: number[] = []; /** 本段释放弓对准目标连线 */ private _aimDirect = false; /** 当前释放参数(字形+武器都播完后收招) */ private _castAnimParams: CastAnimParams | null = null; /** 悟空技能武器椭圆绕圈中 */ private _weaponOrbiting = false; /** 跟随武器时未结束的释放动画数(字形 / 武器) */ private _castAnimLeft = 0; /** 枪/棍末帧定格窗代数;下一刀/打断时递增作废 */ private _restHoldSeq = 0; /** 枪/棍是否处于末帧定格窗 */ private _restHoldActive = false; /** 定格窗结束后回正用的倍速 */ private _restHoldAspd = 1; /** 瞄准世界点 scratch */ private readonly _aimScratch = new Vec3(); /** 武器挂载 */ get weapon(): HeroWeaponMount { return this._weapon; } /** 是否启用武器通道 */ get useWeapon(): boolean { return this._useWeapon; } /** 五行数字:仅攻击显示武器 */ get attackOnlyWeapon(): boolean { return this._attackOnlyWeapon; } /** 释放中或收招定格窗(对账不得切回待机) */ get isCastOrRestHold(): boolean { return this._weaponCasting || this._restHoldActive; } /** * 五行数字:只在攻击时显示武器。 * @param on 是否开启 */ setAttackOnlyWeapon(on: boolean): void { this._attackOnlyWeapon = !!on; } /** * 绑定五行数字 Spine(播「攻击」/「待机」)。 * @param anim 数字节点 Skeleton */ setGlyphAnim(anim: sp.Skeleton | null): void { this._glyphAnim = anim?.isValid ? anim : null; } /** * 五行武器本地 X(TextGrid.wPosX)。 * @param x 本地坐标;null 回退左偏 */ setWeaponLocalX(x: number | null): void { this._weaponLocalX = x != null && Number.isFinite(x) ? x : null; } /** 最近一次玉帝飞剑飞行时长(秒);未发射为 0 */ get lastThrowFlySec(): number { return this._lastThrowFlySec; } /** 当前武器表现类型(Hero.herotype / Weapon.type) */ private _visType(): number { return this._weapon.weaponType; } /** 金/木:只左右翻转 */ private _isFlipWeapon(): boolean { const t = this._visType(); return t === WEAPON_TYPE_SWORD || t === WEAPON_TYPE_WOOD; } /** 水:法杖不旋转 */ private _isStaffWeapon(): boolean { return this._visType() === WEAPON_TYPE_STAFF; } /** 火:弓 */ private _isBowWeapon(): boolean { return this._visType() === WEAPON_TYPE_BOW; } /** 五行火弓:转 weaponType4 播配表拉弓,不用「武器弓-攻击」 */ private _usesElementBow(): boolean { return this._attackOnlyWeapon && this._isBowWeapon(); } /** * 武器释放段:组合弓走拉弓攻效;五行弓与其它类型走 skin 配表动画。 * @param timeScale 倍速 * @param onComplete 播完 * @param animation Skill.animation */ private _playWeaponCastAnim(timeScale: number, onComplete: () => void, animation: string): boolean { if (this._isBowWeapon() && !this._attackOnlyWeapon) { return this._weapon.playBowDraw(timeScale, onComplete, animation); } return this._weapon.playAttack(timeScale, onComplete, animation); } /** * 释放动画倍速:hero/weapon/monster 固定 1;普攻跟攻速;显式 override 优先。 * @param caster 施法实体 * @param skillKind Ability 槽 * @param override 显式倍速 */ static resolveCastAnimTimeScale(caster: EntityId, skillKind?: string, override?: number): number { if (override != null && override > 0) return override; const kind = String(skillKind ?? ''); if (kind === 'hero' || kind === 'weapon' || kind === 'monster') return 1; return Math.max(0.01, SkillPresentationEnv.getFinalAttackSpeed(caster)); } /** 绑定施法实体(用于首字格定位) */ setCaster(caster: EntityId | null): void { this._caster = caster; } /** 绑定字形与可选武器通道 */ bind(skin: HeroSkin | null, parent: Node | null, heroId: number, useWeapon: boolean): void { this._skin = skin?.isValid ? skin : null; this._parent = parent?.isValid ? parent : null; this._heroId = Math.floor(Number(heroId) || 0); this._useWeapon = !!useWeapon; if (!this._useWeapon) this._weapon.setVisible(false); } /** 按塔 loadout / 归属玩家装备 / startweapon 挂载武器;五行数字按 Hero.herotype */ ensureWeapon(): boolean { if (!this._useWeapon || !this._parent?.isValid || this._heroId <= 0) return false; const hero = gg.data.table.getTableData(TableNames.Hero, this._heroId); const ht = Math.floor(Number(hero?.herotype) || 0); if (ht >= 1 && ht <= 5) return this._weapon.ensureByHeroType(this._parent, ht); let weaponId = 0; if (this._caster != null) { const loadout = getTowerLoadoutService().getLoadout(this._caster); if (loadout && loadout.weaponId > 0) weaponId = loadout.weaponId; } if (weaponId <= 0) { const owner = this._caster != null ? SkillPresentationEnv.getOwnerPlayer(this._caster) : null; weaponId = resolveHeroBattleWeaponId(this._heroId, owner); } if (weaponId <= 0) return false; return this._weapon.ensure(this._parent, weaponId); } /** * 激活待机:组合英雄显示武器;五行数字藏武器并播数字「待机」。 * 武器释放中 / 连击窗内跳过,避免掐断出手。 */ playIdle(): void { // 定格窗内禁止切待机动画:否则 Spine 末帧会被待机姿覆盖,武器角也像被拧回 if (this._weaponCasting || this._restHoldActive) return; if (this._attackOnlyWeapon) { this._playGlyphIdle(); this._weapon.setVisible(false); return; } this._skin?.playIdleAnim(); if (this._useWeapon) this.showIdleWeapon(); else this._weapon.setVisible(false); } /** * 激活后补挂待机武器。施法中或飞剑在途则跳过,避免掐断出手。 * @returns 是否已显示待机武器 */ tryShowIdleWeapon(): boolean { if (this._attackOnlyWeapon) return false; if (!this._useWeapon || this._weaponCasting || this._restHoldActive || this._weaponFlies.length > 0) return false; return this.showIdleWeapon(); } /** 待机武器根节点是否可见 */ hasVisibleIdleWeapon(): boolean { return !!this._weapon.root?.active; } /** 显示武器待机(需已 bind useWeapon);首字格未就绪则先藏,避免贴在组合中心被字形挡住 */ showIdleWeapon(): boolean { if (!this.ensureWeapon()) { this._weapon.setVisible(false); return false; } if (!this.syncWeaponPivot(this._caster, true)) { this._weapon.setVisible(false); return false; } // 刀/木立刻复位左右;法杖保持预制体;枪回正走插值 if (this._isFlipWeapon()) this._weapon.resetAim(); this._weapon.setVisible(this._weaponFlies.length === 0); this._weapon.playIdle(); return !!this._weapon.root?.active; } /** 取消字形/武器上未触发的释放完成回调 */ clearCastCompletes(): void { BattleSpinePlayback.clearPendingComplete(this._weapon.skinAnim); BattleSpinePlayback.clearPendingComplete(this._weapon.drawAnim); BattleSpinePlayback.clearPendingComplete(this._skin?.anim); BattleSpinePlayback.clearPendingComplete(this._glyphAnim); } /** 隐藏武器(失活);须作废未触发的释放完成,避免收掉后续冲锋等长表现 */ hideWeapon(): void { this._weaponCasting = false; this._castAnimParams = null; this._castAnimLeft = 0; this.clearCastCompletes(); this._clearBowLand(); this._cancelBowLaunches(); this._recycleAllBowArrows(); this._recycleAllWeaponFlies(); this._cancelRestHold(); this._cancelWeaponAim(); this._cancelWeaponOrbit(); this._weapon.resetAim(); this._weapon.setVisible(false); if (this._caster != null) getBowAtkHost().cancelCaster(this._caster); } /** 打断武器释放(abort 时调用,允许随后 playIdle) */ interruptCast(): void { this._weaponCasting = false; this._castAnimParams = null; this._castAnimLeft = 0; this._lastThrowFlySec = 0; this._clearBowLand(); this._cancelBowLaunches(); this._recycleAllBowArrows(); this._recycleAllWeaponFlies(); this._cancelRestHold(); this._cancelWeaponAim(); this._cancelWeaponOrbit(); this._weapon.hideBowAtk(true); if (this._useWeapon && !this._attackOnlyWeapon) this._weapon.setVisible(true); else this._weapon.setVisible(false); if (this._caster != null) getBowAtkHost().cancelCaster(this._caster); } /** * 释放表现:字形播「攻击」;有武器时武器播 Skill.animation(缺省「攻击」)。 * 跟随武器时等字形与武器都播完再收招。玉帝飞剑:不播原武器攻击,直接隐藏。 * @returns 本段释放动画倍速(供命中延迟换算;主动技能为 1) */ playCastAnim(params: CastAnimParams): number { const animScale = SkillVisualChannel.resolveCastAnimTimeScale( params.caster, params.skillKind, params.animTimeScale, ); params.animTimeScale = animScale; this._caster = params.caster; this._aimDirect = params.aimDirect === true; this._castAnimParams = params; this._castAnimLeft = 0; this._lastThrowFlySec = 0; const weaponAnim = String(params.animation ?? '').trim(); if (!weaponAnim) { console.warn('[SkillVisualChannel] Skill.animation 为空', params.caster); } const follow = params.followWeapon && this._useWeapon && this.ensureWeapon(); if (follow) { this._cancelRestHold(); this._cancelWeaponAim(); this._cancelWeaponOrbit(); this.syncWeaponPivot(params.caster); if (params.throwWeapon) { this._lastThrowFlySec = this._launchWeaponThrow(params.primaryTarget ?? 0); } else if (this._usesElementBow()) { this._weapon.setVisible(true); this._weapon.setArrowVisible(false); } else if (this._isBowWeapon()) { this._weapon.showBowAtk(); } else { // 五行数字 / 法杖:出手必显。勿因在飞水弹/冰锥占着 _weaponFlies 而跳过, // 否则武器藏着仍按出手帧发射,表现为「无施法动作却出弹」。 this._weapon.setVisible(true); } this._weaponCasting = true; const bowAnimTs = this._isBowWeapon() && !this._attackOnlyWeapon ? Math.max(animScale, BOW_RELEASE_AT_BASE / BOW_ATTACK_MAX_SEC) : animScale; const orbitStaff = this._isWukongSkillOrbit(params); if (params.keepRestAim || orbitStaff) { if (!this._isStaffWeapon()) this._weapon.resetAim(); } else if (this._isBowWeapon()) { this._aimWeapon(params.primaryTarget, bowAnimTs, params.aimWorldX, params.aimWorldY, params.fixedAimDeg); if (!params.skipBowArrow) this._armBowArrow(params.primaryTarget, bowAnimTs); } else if (this._isStaffWeapon()) { /* 法杖不朝向 */ } else if (!params.throwWeapon) { this._aimWeapon(params.primaryTarget, animScale, params.aimWorldX, params.aimWorldY); } if (orbitStaff) this._startWukongStaffOrbit(params.caster, weaponAnim, animScale); if (params.holdCasting) { this._playGlyphAttack(animScale, null); if (params.throwWeapon) { this._onFollowCastAllComplete(); return animScale; } if (params.loopWeapon) { const looped = this._weapon.playAttackLoop(bowAnimTs, weaponAnim); if (!looped) { console.log('[SkillVisualChannel] 武器无循环释放动画', weaponAnim, 'weaponId=', this._weapon.weaponId); this._cancelWeaponOrbit(); } return animScale; } const held = this._playWeaponCastAnim(bowAnimTs, this._onFollowCastAllComplete, weaponAnim); if (!held) { console.log('[SkillVisualChannel] 武器无释放动画', weaponAnim, 'weaponId=', this._weapon.weaponId); this._cancelWeaponOrbit(); } return animScale; } if (this._hasGlyphCastAnim()) { this._castAnimLeft++; this._playGlyphAttack(animScale, this._onCastAnimPartComplete); } if (params.throwWeapon) { if (this._castAnimLeft === 0) this._onFollowCastAllComplete(); return animScale; } this._castAnimLeft++; const played = this._playWeaponCastAnim(bowAnimTs, this._onCastAnimPartComplete, weaponAnim); if (!played) { console.log('[SkillVisualChannel] 武器无释放动画', weaponAnim, 'weaponId=', this._weapon.weaponId); this._cancelBowLaunches(); this._cancelWeaponOrbit(); } return animScale; } this._playGlyphAttack(animScale, params.holdCasting ? null : params.onAnimComplete); if (!this._hasGlyphCastAnim() && !params.holdCasting) params.onAnimComplete(); return animScale; } /** 连发结束:解除施法锁并待机 */ endHoldCast(): void { this._weaponCasting = false; this._castAnimParams = null; this._castAnimLeft = 0; this._cancelWeaponOrbit(); this._cancelRestHold(); if (this._attackOnlyWeapon) { this._enterAttackOnlyRestHold(); return; } if (this._shouldTweenRestAim()) { // 弓/枪:先连击窗保持当前角,超时再回待机 this._enterWeaponRestHold(1); return; } this._weapon.resetAim(); this.playIdle(); } /** 弓/枪回待机须插值;刀/木立刻复位;法杖不转 */ private _shouldTweenRestAim(): boolean { const t = this._visType(); return t === WEAPON_TYPE_BOW || t === WEAPON_TYPE_SPEAR; } /** * 弓/枪:定格末帧并保持当前朝向;1 秒内无下一击再插值回待机角。 * 连击窗内下一击从当前角转向新目标,勿 resetAim。 * @param aspd 攻速系数(回待机插值用) */ private _enterWeaponRestHold(aspd: number): void { this._cancelRestHold(); const skin = this._weapon.skinAnim; if (skin?.isValid) { BattleSpinePlayback.clearPendingComplete(skin); // 定格在「攻击」末帧局部姿;武器朝向角由 weapon / 弓攻效根保持 skin.timeScale = 0; } this._restHoldActive = true; this._restHoldAspd = aspd; const seq = ++this._restHoldSeq; schedulePresentation(WEAPON_REST_HOLD_SEC, () => { if (seq !== this._restHoldSeq) return; if (this._weaponCasting) return; this._restHoldActive = false; if (skin?.isValid) skin.timeScale = 1; this._restWeaponAim(this._restHoldAspd); }); } /** * 五行数字:攻击结束后保持武器可见。 * 弓/枪:定格攻击末帧并保持当前朝向,1 秒无下一击再插值回待机角后藏武器。 * 刀/木等:可立刻复位;超时藏武器并播数字「待机」。 */ private _enterAttackOnlyRestHold(): void { this._cancelRestHold(); this._weapon.setArrowVisible(false); this.syncWeaponPivot(this._caster, true); this._weapon.setVisible(true); // 弓/枪:连击窗内保持攻击姿与瞄准角(对齐组合弓/枪 _enterWeaponRestHold) if (this._shouldTweenRestAim()) { const skin = this._weapon.skinAnim; if (skin?.isValid) { BattleSpinePlayback.clearPendingComplete(skin); skin.timeScale = 0; } this._restHoldActive = true; this._restHoldAspd = 1; const seq = ++this._restHoldSeq; schedulePresentation(WEAPON_REST_HOLD_SEC, () => { if (seq !== this._restHoldSeq) return; if (this._weaponCasting) return; this._restHoldActive = false; if (skin?.isValid) skin.timeScale = 1; this._restAttackOnlyWeaponAim(); }); return; } if (!this._isStaffWeapon()) this._weapon.resetAim(); this._weapon.playIdle(); this._restHoldActive = true; const seq = ++this._restHoldSeq; schedulePresentation(WEAPON_REST_HOLD_SEC, () => { if (seq !== this._restHoldSeq) return; if (this._weaponCasting) return; this._restHoldActive = false; this._weapon.setVisible(false); this._playGlyphIdle(); }); } /** * 五行弓/枪:连击窗结束,从当前瞄准角插值回待机角后再藏武器。 */ private _restAttackOnlyWeaponAim(): void { const duration = WEAPON_AIM_SEC / Math.max(0.01, this._restHoldAspd > 0 ? this._restHoldAspd : 1); this._tweenWeaponAim(WEAPON_REST_ANGLE, duration, this._onAttackOnlyAimRestCompleteBound); } /** 五行弓/枪拧回待机角后:藏武器并播数字待机 */ private readonly _onAttackOnlyAimRestCompleteBound = (): void => { if (this._weaponCasting) return; this._weapon.resetAim(); this._weapon.setVisible(false); this._playGlyphIdle(); }; /** 是否有字形释放 Spine */ private _hasGlyphCastAnim(): boolean { return !!(this._skin?.anim?.isValid || this._resolveGlyphAnim()?.isValid); } /** * 取数字 Spine:已绑定优先;否则在父节点下找当前可见数字格。 * AI 轻量字块常在 Skill 绑定后才打开数字节点,出手前必须补绑。 */ private _resolveGlyphAnim(): sp.Skeleton | null { if (this._glyphAnim?.isValid) return this._glyphAnim; const found = this._findNumberGlyphOnParent(); if (found) this._glyphAnim = found; return found; } /** * 在 host 子节点中找当前激活的数字 Skeleton。 * @param host TextGrid 根或 skin */ private _findNumberGlyphOn(host: Node | null): sp.Skeleton | null { if (!host?.isValid) return null; const children = host.children; for (let i = 0; i < children.length; i++) { const child = children[i]; if (!child?.isValid || !child.active) continue; if (!this._isNumberFaceName(child.name)) continue; const sk = child.getComponent(sp.Skeleton); if (sk?.isValid) return sk; } return null; } /** * 父节点(TextGrid)或 skin 上当前激活的数字 Skeleton。 */ private _findNumberGlyphOnParent(): sp.Skeleton | null { const parent = this._parent; if (!parent?.isValid) return null; return this._findNumberGlyphOn(parent) ?? this._findNumberGlyphOn(parent.getChildByName('skin')); } /** * 子节点名是否为纯数字(对应 Hero.number)。 * @param name 节点名 */ private _isNumberFaceName(name: string): boolean { if (!name) return false; let n = 0; for (let i = 0; i < name.length; i++) { const c = name.charCodeAt(i); if (c < 48 || c > 57) return false; n = n * 10 + (c - 48); } return n > 0; } /** * 播字形「攻击」:优先 HeroSkin,否则五行数字 Spine。 * @param timeScale 倍速 * @param onComplete 播完;null 不等待 */ private _playGlyphAttack(timeScale: number, onComplete: (() => void) | null): void { if (this._skin?.anim?.isValid) { this._skin.playAttackAnim(timeScale, onComplete, GLYPH_CAST_ANIM); return; } const anim = this._resolveGlyphAnim(); if (!anim?.isValid) { onComplete?.(); return; } BattleSpinePlayback.clearPendingComplete(anim); BattleSpinePlayback.play(anim, GLYPH_CAST_ANIM, { loop: false, timeScale, onComplete: onComplete ?? undefined, }); } /** 播字形「待机」 */ private _playGlyphIdle(): void { if (this._skin?.anim?.isValid) { this._skin.playIdleAnim(); return; } const anim = this._resolveGlyphAnim(); if (!anim?.isValid) return; BattleSpinePlayback.play(anim, GLYPH_IDLE_ANIM, { loop: true, optional: true }); } /** 作废弓/枪末帧定格窗 */ private _cancelRestHold(): void { this._restHoldSeq++; this._restHoldActive = false; // 打断/下一刀时恢复倍速,避免 spine 卡在 timeScale=0 const skin = this._weapon.skinAnim; if (skin?.isValid && skin.timeScale === 0) { skin.timeScale = 1; } const draw = this._weapon.drawAnim; if (draw?.isValid && draw.timeScale === 0) { draw.timeScale = 1; } } /** 字形或武器一段释放结束;两段都结束才收招 */ private readonly _onCastAnimPartComplete = (): void => { if (this._castAnimLeft <= 0) return; this._castAnimLeft--; if (this._castAnimLeft > 0) return; this._onFollowCastAllComplete(); }; /** 跟随武器的释放全部结束:hold 时不收招;悟空绕圈须等椭圆走完一圈 */ private readonly _onFollowCastAllComplete = (): void => { this._castAnimLeft = 0; if (!this._castAnimParams) return; if (this._weaponOrbiting) return; this._finishFollowCast(); }; /** 悟空椭圆走完一圈;动画段未完则等收招 */ private readonly _onWukongOrbitComplete = (_node?: Node): void => { this._weaponOrbiting = false; if (this._castAnimLeft > 0) return; if (!this._castAnimParams) return; this.syncWeaponPivot(this._castAnimParams.caster); this._finishFollowCast(); }; /** 复位武器并通知播控结束 */ private _finishFollowCast(): void { const params = this._castAnimParams; this._castAnimParams = null; this._castAnimLeft = 0; if (!params) return; if (!params.holdCasting) { this._weaponCasting = false; if (this._attackOnlyWeapon) { this._enterAttackOnlyRestHold(); } else { const aspd = Math.max(0.01, SkillPresentationEnv.getFinalAttackSpeed(params.caster)); const restScale = params.animTimeScale != null && params.animTimeScale > 0 ? params.animTimeScale : aspd; if (this._isBowWeapon() && this._weapon.bowAtkShowing) { this._weapon.freezeBowDraw(); // 弓与枪相同:连击窗保持当前角,超时再回待机 this._enterWeaponRestHold(restScale); } else if (this._shouldTweenRestAim()) { this._enterWeaponRestHold(restScale); } else { this._weapon.playIdle(); } } } params.onAnimComplete(); } /** * 武器对齐组合首字格中心再向左偏移(写入父节点本地坐标,随 Actor 移动)。 * @param caster 施法实体 * @param quiet 首字格未就绪时不打 warn(激活对账) * @returns 是否已对上首字格 */ syncWeaponPivot(caster?: EntityId | null, quiet = false): boolean { const root = this._weapon.root; const parent = this._parent; if (!root?.isValid || !parent?.isValid) return false; const id = caster ?? this._caster; // 五行数字:挂在字块本地,按 TextGrid.wPosX if (this._weapon.weaponId <= 0) { const x = this._weaponLocalX != null ? this._weaponLocalX : -WEAPON_LEFT_OFFSET_PX; root.setPosition(x, 0, 0); root.angle = 0; if (this._weapon.bowAtkShowing) this._weapon.syncBowAtkPivot(); if (!this._weaponCasting && this._isFlipWeapon()) this._weapon.resetAim(); return true; } const world = this._resolveFirstGlyphWorld(id, quiet); if (!world) return false; this._weaponWorld.set(world.x - WEAPON_LEFT_OFFSET_PX, world.y, world.z); parent.inverseTransformPoint(this._weaponWorld, this._weaponWorld); root.setPosition(this._weaponWorld); root.angle = 0; if (this._weapon.bowAtkShowing) this._weapon.syncBowAtkPivot(); // 仅刀在非施法时立刻复位;枪/棍回正走插值,此处 resetAim 会硬切 if (!this._weaponCasting && this._isFlipWeapon()) { this._weapon.resetAim(); } return true; } /** * 悟空英雄主动「悟空技能」:武器沿两字包围盒中心椭圆绕一圈(不瞄准)。 * @param params 释放参数 */ private _isWukongSkillOrbit(params: CastAnimParams): boolean { if (String(params.skillKind ?? '') !== 'hero') return false; if (String(params.animation ?? '').trim() !== WUKONG_SKILL_ANIM) return false; if (params.throwWeapon) return false; return !this._isBowWeapon(); } /** * 按武器动画时长-0.2s 绕组合中心一圈:长半径=挂点 X 到中心,短半径=半格。 * @param caster 施法实体 * @param animName 武器动画名 * @param animScale 动画倍速 */ private _startWukongStaffOrbit(caster: EntityId, animName: string, animScale: number): void { const root = this._weapon.root; if (!root?.isValid) { console.log('[SkillVisualChannel] 悟空绕圈无武器根节点'); return; } const center = SkillPresentationEnv.getCompositionWorldCenter(caster); if (!center) { console.log('[SkillVisualChannel] 悟空绕圈无组合中心', caster); return; } const sk = this._weapon.skinAnim; const animSec = sk ? BattleSpinePlayback.resolveLogicalCompleteDelay(sk, animName, animScale) : null; if (animSec == null || !(animSec > 0)) { console.log('[SkillVisualChannel] 悟空绕圈读不到动画时长', animName, this._weapon.weaponId); return; } const duration = animSec - WUKONG_ORBIT_TRIM_SEC; if (!(duration > 0)) { console.log('[SkillVisualChannel] 悟空绕圈时长无效', animSec, WUKONG_ORBIT_TRIM_SEC); return; } const wp = root.worldPosition; const radiusX = Math.abs(wp.x - center.x); if (!(radiusX > 1e-3)) { console.log('[SkillVisualChannel] 悟空绕圈长半径为 0', caster); return; } this._weaponOrbiting = true; SkillTransformDriver.animateEllipseOnce({ node: root, centerWorld: center, radiusX, radiusY: WUKONG_ORBIT_RADIUS_Y, duration, onComplete: this._onWukongOrbitComplete, }); } /** 停止悟空武器椭圆绕圈 */ private _cancelWeaponOrbit(): void { this._weaponOrbiting = false; const root = this._weapon.root; if (root?.isValid) SkillTransformDriver.cancelNode(root); } /** 回收武器 */ release(): void { this._weaponCasting = false; this._cancelRestHold(); this._clearBowLand(); this._cancelBowLaunches(); this._recycleAllBowArrows(); this._recycleAllWeaponFlies(); this._cancelWeaponAim(); this._cancelWeaponOrbit(); if (this._caster != null) getBowAtkHost().cancelCaster(this._caster); this._weapon.release(); this._skin = null; this._glyphAnim = null; this._parent = null; this._heroId = 0; this._useWeapon = false; this._attackOnlyWeapon = false; this._weaponLocalX = null; this._caster = null; } /** * 武器挂点:组合左起首字格中心。 * 无 caster / 无 Transform 时返回 null,禁止用 HeroActor 组合中心冒充(多格会贴字)。 * @param caster 施法实体 * @param quiet 未就绪时不打 warn */ private _resolveFirstGlyphWorld(caster: EntityId | null, quiet = false): Vec3 | null { if (caster == null) return null; const c = SkillPresentationEnv.getCompositionFirstGlyphWorld(caster); if (c) return c; if (!quiet) console.warn('[SkillVisualChannel] 无首字格世界坐标 caster=', caster); return null; } /** * 五行弓卸箭/放箭时刻:读 Spine 槽位「箭矢」附件清空帧;无则用 ELEMENT_BOW_ARROW_CLEAR_AT。 * @param animation Skill.animation */ private _resolveElementBowReleaseAt(animation: string): number { const name = String(animation ?? '').trim(); const anim = this._weapon.skinAnim; if (name && anim?.isValid) { const t = BattleSpinePlayback.findFirstAttachmentClearTime(anim, name, '箭矢'); if (t != null && t > 0) return t; } console.warn('[SkillVisualChannel] 五行弓无卸箭帧,用默认', name, ELEMENT_BOW_ARROW_CLEAR_AT); return ELEMENT_BOW_ARROW_CLEAR_AT; } /** * 是否法杖发射类配表动画(水弹 / 冰锥),不依赖 weaponType 是否已挂上。 * @param animation Skill.animation */ private _isStaffProjectileAnim(animation: string): boolean { const name = String(animation ?? '').trim(); return name.indexOf('水弹') >= 0 || name.indexOf('冰锥') >= 0; } /** * 法杖发射物出手时刻(动画时间轴秒):读「挥砍」首关键帧,且不低于 STAFF_PROJECTILE_MIN_AT。 * @param animation Skill.animation */ private _resolveStaffReleaseAt(animation: string): number { const name = String(animation ?? '').trim(); const anim = this._weapon.skinAnim; let releaseAt = STAFF_PROJECTILE_RELEASE_AT; if (name && anim?.isValid) { const t = BattleSpinePlayback.findFirstBoneKeyframeTime(anim, name, STAFF_RELEASE_BONE); if (t != null && t > 0) releaseAt = t; else console.warn('[SkillVisualChannel] 法杖无挥砍关键帧,用默认', name, STAFF_PROJECTILE_RELEASE_AT); } else if (name) { console.warn('[SkillVisualChannel] 法杖无骨架可读,用默认出手帧', name, STAFF_PROJECTILE_RELEASE_AT); } return Math.max(STAFF_PROJECTILE_MIN_AT, releaseAt); } /** * 法杖水弹/冰锥首次出手延迟(逻辑秒):动画关键帧 / 攻速倍速。 * @param animScale playCastAnim 返回的倍速 * @param animation Skill.animation;空则读当前施法参数 */ resolveStaffProjectileDelay(animScale: number, animation?: string): number { const scale = Math.max(0.01, animScale); const name = String(animation ?? this._castAnimParams?.animation ?? '').trim(); return this._resolveStaffReleaseAt(name) / scale; } /** * 弓箭出手基准秒:组合弓对齐 BOW_RELEASE_AT_BASE;五行弓对齐 Spine 卸箭帧(拉完)。 */ private _resolveBowReleaseAtBase(): number { if (!this._isBowWeapon()) return PRES_HIT_AT_BASE; if (!this._attackOnlyWeapon) return BOW_RELEASE_AT_BASE; return this._resolveElementBowReleaseAt(String(this._castAnimParams?.animation ?? '').trim()); } /** * 拉满时刻发射投掷物:组合弓对齐 BOW_RELEASE_AT_BASE;五行弓对齐卸箭帧;非弓对齐 PRES_HIT_AT_BASE。 * @param target 主目标 * @param aspd 攻速系数 */ private _armBowArrow(target: EntityId | null, aspd: number): void { const gen = ++this._bowLaunchGen; const params = this._castAnimParams; this._bowLaunches.push({ gen, target: target ?? 0, landId: params?.projectileLandId ?? 0, onLand: params?.onProjectileLand ?? null, }); this._weapon.setArrowVisible(false); const releaseAt = this._resolveBowReleaseAtBase(); schedulePresentation(releaseAt / Math.max(0.01, aspd), this._onBowLaunchDue); } /** 作废未发射的弓箭;已预约落地的立刻结算,避免丢伤 */ private _cancelBowLaunches(): void { this._weapon.setArrowVisible(false); const gen = this._bowLaunchGen; for (let i = 0; i < this._bowLaunches.length; i++) { const job = this._bowLaunches[i]; if (job.gen !== gen || !job.onLand) continue; job.onLand(job.target, job.landId); } this._bowLaunchGen++; this._bowLaunches.length = 0; } /** 丢掉未落地的普攻箭结算绑定(打断时用;不停飞箭节点,由 recycle 负责) */ private _clearBowLand(): void { this._bowFlights.length = 0; } /** 时钟到点:发射最新一次有效预约;被下一击作废的预约立刻补结算 */ private readonly _onBowLaunchDue = (): void => { if (this._bowLaunches.length === 0) return; const job = this._bowLaunches.shift(); if (!job) return; if (job.gen !== this._bowLaunchGen) { if (job.onLand) job.onLand(job.target, job.landId); return; } if (this._isBowWeapon()) { this._launchBowArrow(job.target, job.landId, job.onLand); } else { this._launchWeaponThrow(job.target); } }; /** * atk_type=12/17:扇出直线散射箭。12 穿透;17 不穿透,首中即回收表现箭。 * @param caster 施法者 * @param abilityId Ability id * @param target 瞄准目标 * @param count 箭数 * @param stepDeg 邻箭夹角 * @param rangePx 初始射程 * @param scale 伤害倍率 * @param buff1 atk_buff1 * @param buff2 atk_buff2 * @param pierce 是否穿透 */ launchScatterArrows(caster: EntityId, abilityId: string, target: EntityId, count: number, stepDeg: number, rangePx: number, scale: number, buff1: string, buff2: string, pierce: boolean): void { const template = this._weapon.arrowTemplate; if (!template?.isValid) { console.log('[SkillVisualChannel] 散射箭模板缺失', this._weapon.weaponId); return; } const to = target > 0 ? SkillPresentationEnv.getTargetWorldPos(target) : null; if (!to) { console.log('[SkillVisualChannel] 散射无目标坐标', target); this._weapon.setArrowVisible(false); return; } const travel = rangePx * SCATTER_RANGE_MUL; if (!(travel > 0) || !(count > 0) || !(stepDeg > 0)) { console.log('[SkillVisualChannel] 散射参数无效', count, stepDeg, rangePx); this._weapon.setArrowVisible(false); return; } this._arrowFrom.set(template.worldPosition); const aimDeg = computeAimAngleDeg(this._arrowFrom, to); if (aimDeg == null) { console.log('[SkillVisualChannel] 散射朝向为零', this._weapon.weaponId); this._weapon.setArrowVisible(false); return; } const ut = template.getComponent(UITransform); const halfLen = ut ? ut.width * 0.5 : 0; const halfWid = ut ? ut.height * 0.5 : 0; if (!(halfLen > 0) || !(halfWid > 0)) { console.log('[SkillVisualChannel] 散射箭体盒无效', this._weapon.weaponId); this._weapon.setArrowVisible(false); return; } const host = getBowAtkHost(); host.fillScatterDegs(aimDeg, count, stepDeg, this._scatterDegs); this._weapon.setArrowVisible(false); const duration = Math.max(0.12, travel / BOW_ATK_ARROW_SPEED); for (let i = 0; i < this._scatterDegs.length; i++) { const deg = this._scatterDegs[i]; const rad = deg * Math.PI / 180; const ux = Math.cos(rad); const uy = Math.sin(rad); this._arrowTo.set(this._arrowFrom.x + ux * travel, this._arrowFrom.y + uy * travel, this._arrowFrom.z); const clone = this._spawnFlyClone(this._arrowFrom); if (!clone) continue; clone.angle = deg; this._flyStraight(clone, this._arrowFrom, this._arrowTo, duration); host.spawnRay({ caster, abilityId, x: this._arrowFrom.x, y: this._arrowFrom.y, ux, uy, speed: BOW_ATK_ARROW_SPEED, left: duration, scale, halfLen, halfWid, buff1, buff2, hitIds: [], pierce, onConsume: pierce ? undefined : this._recycleBowArrow.bind(this, clone), }); } } /** * 瞬时/范围类命中延迟(逻辑秒)。专属技能按武器动画关键帧,其余 PRES_HIT。 * @param animation Skill.animation * @param animScale playCastAnim 返回的倍速 */ resolveSkillHitDelay(animation: string, animScale: number): number { const scale = Math.max(0.01, animScale); const name = String(animation ?? '').trim(); if (name === MUZHA_SKILL_ANIM) return MUZHA_SKILL_HIT_AT / scale; if (name === JINGU_SKILL_ANIM) return JINGU_SKILL_HIT_AT / scale; return PRES_HIT_AT_BASE / scale; } /** * atk_type 12/13/17 首次出手延迟(逻辑秒):弓对齐拉满帧,法杖/水弹冰锥对齐「挥砍」关键帧,其它对齐 PRES_HIT。 * @param animScale playCastAnim 返回的攻速倍速 */ resolveBowShapeLaunchDelay(animScale: number): number { const scale = Math.max(0.01, animScale); const animName = String(this._castAnimParams?.animation ?? '').trim(); if (this._isStaffWeapon() || this._isStaffProjectileAnim(animName)) { return this.resolveStaffProjectileDelay(animScale, animName); } if (this._isBowWeapon()) { if (this._attackOnlyWeapon) { return this._resolveBowReleaseAtBase() / scale; } const bowTs = Math.max(scale, BOW_RELEASE_AT_BASE / BOW_ATTACK_MAX_SEC); return BOW_RELEASE_AT_BASE / bowTs; } return PRES_HIT_AT_BASE / scale; } /** * atk_type=13:一发弹道,落地才伤;固定中/左/右三路循环,目标不变。 * @param caster 施法者 * @param abilityId Ability id * @param target 锁定目标 * @param scale 伤害倍率 * @param buff1 atk_buff1 * @param buff2 atk_buff2 * @param shotIndex 连发序号(从 0;对 3 取余选弹道) * @param aimWorld 瞄准点(目标死后用最后坐标) * @returns 是否发出 */ /** * atk_type=19 冰锥:直线「冰锥」预制体飞向目标,落地结算。 * @param caster 施法者 * @param abilityId Ability id * @param target 锁定目标 * @param scale 伤害倍率 * @param buff1 atk_buff1 * @param buff2 atk_buff2 * @returns 是否发出 */ launchIceCone(caster: EntityId, abilityId: string, target: EntityId, scale: number, buff1: string, buff2: string): boolean { const aim = target > 0 ? SkillPresentationEnv.getTargetWorldPos(target) : null; if (!aim) { console.log('[SkillVisualChannel] 冰锥无目标坐标', target); return false; } this._weaponCasting = true; this._weapon.setVisible(true); const fromNode = this._weapon.skinAnim?.node ?? this._weapon.aim ?? this._weapon.root; const fromWorld = fromNode?.isValid ? fromNode.worldPosition : SkillPresentationEnv.getCasterGridCenter(caster); if (!fromWorld) { console.log('[SkillVisualChannel] 冰锥无起点'); return false; } const live = target > 0 ? SkillPresentationEnv.getTargetWorldPos(target) : null; const to = live ?? aim; if (!to) { console.log('[SkillVisualChannel] 冰锥无目标坐标', target); return false; } this._arrowFrom.set(fromWorld); this._arrowTo.set(to); const dist = Vec3.distance(this._arrowFrom, this._arrowTo); const duration = Math.max(0.12, dist / WATER_BALL_SPEED); const node = this._spawnIceCone(this._arrowFrom); if (!node) return false; SkillTransformDriver.animateParabola({ node, duration, fromWorld: this._arrowFrom, toWorld: this._arrowTo, arcHeight: 0, easing: SkillTransformDriver.easeLinear, faceTangent: true, }); SkillTransformDriver.stepNow(); const land = WaterBallRecycle.obtain(this, node); getBowAtkHost().spawnVolley({ caster, target, abilityId, wait: duration, scale, buff1, buff2, onLand: land.onLand, }); return true; } /** * 飞剑武器主动:实例化「飞剑」直线飞向目标,落地结算。 * @param caster 施法者 * @param abilityId Ability id * @param target 锁定目标 * @param scale 伤害倍率 * @param buff1 atk_buff1 * @param buff2 atk_buff2 * @returns 是否发出 */ launchFlySword(caster: EntityId, abilityId: string, target: EntityId, scale: number, buff1: string, buff2: string): boolean { const fromNode = this._weapon.skinAnim?.node ?? this._weapon.aim ?? this._weapon.root; const fromWorld = fromNode?.isValid ? fromNode.worldPosition : SkillPresentationEnv.getCasterGridCenter(caster); if (!fromWorld) { console.log('[SkillVisualChannel] 飞剑无起点'); return false; } const to = target > 0 ? SkillPresentationEnv.getTargetWorldPos(target) : null; if (!to) { console.log('[SkillVisualChannel] 飞剑无目标坐标', target); return false; } this._arrowFrom.set(fromWorld); this._arrowTo.set(to); const dist = Vec3.distance(this._arrowFrom, this._arrowTo); const duration = Math.max(0.12, dist / WATER_BALL_SPEED); const node = this._spawnFlySwordPrefab(this._arrowFrom); if (!node) return false; SkillTransformDriver.animateParabola({ node, duration, fromWorld: this._arrowFrom, toWorld: this._arrowTo, arcHeight: 0, easing: SkillTransformDriver.easeLinear, faceTangent: true, }); SkillTransformDriver.stepNow(); const land = WaterBallRecycle.obtain(this, node); getBowAtkHost().spawnVolley({ caster, target, abilityId, wait: duration, scale, buff1, buff2, onLand: land.onLand, }); return true; } launchVolleyShot(caster: EntityId, abilityId: string, target: EntityId, scale: number, buff1: string, buff2: string, shotIndex: number, aimWorld: Readonly): boolean { const castAnim = String(this._castAnimParams?.animation ?? '').trim(); if (this._isStaffWeapon() || this._isStaffProjectileAnim(castAnim)) { return this._launchWaterBall(caster, abilityId, target, scale, buff1, buff2, aimWorld); } if (this._usesElementBow()) { this._weapon.setVisible(true); } else if (this._isBowWeapon() && !this._weapon.bowAtkShowing) { this._weapon.showBowAtk(); } this._weaponCasting = true; const template = this._weapon.arrowTemplate; if (!template?.isValid) { console.log('[SkillVisualChannel] 连发箭模板缺失', this._weapon.weaponId); return false; } const live = target > 0 ? SkillPresentationEnv.getTargetWorldPos(target) : null; const to = live ?? aimWorld; if (!to) { console.log('[SkillVisualChannel] 连发无目标坐标', target); this._weapon.setArrowVisible(false); return false; } this._arrowFrom.set(template.worldPosition); this._arrowTo.set(to); const dist = Vec3.distance(this._arrowFrom, this._arrowTo); const duration = Math.max(0.12, dist / BOW_ATK_ARROW_SPEED); if (!this._fillVolleyArrowCtrl(this._arrowFrom, this._arrowTo, dist, shotIndex)) { this._weapon.setArrowVisible(false); return false; } this._weapon.setArrowVisible(false); this._weapon.setProjectileVisible(false); const clone = this._spawnFlyClone(this._arrowFrom); if (!clone) return false; SkillTransformDriver.animateParabola({ node: clone, duration, fromWorld: this._arrowFrom, toWorld: this._arrowTo, controlWorld: this._arrowCtrl, easing: SkillTransformDriver.easeLinear, }); const land = VolleyFlyRecycle.obtain(this, clone); getBowAtkHost().spawnVolley({ caster, target, abilityId, wait: duration, scale, buff1, buff2, onLand: land.onLand, }); return true; } /** * 克隆当前武器箭到飞行层(万箭齐发等)。 * @param fromWorld 起点世界坐标 */ spawnFlyArrow(fromWorld: Readonly): Node | null { return this._spawnFlyClone(fromWorld); } /** * 回收飞行箭。 * @param node 箭节点 */ recycleFlyArrow(node: Node): void { this._recycleBowArrow(node); } /** * 回收飞行水弹。 * @param node 水弹根 */ recycleWaterBall(node: Node): void { this._recycleWeaponFly(node); } /** * 克隆箭到飞行层。 * @param fromWorld 起点世界坐标 * @returns 克隆;失败 null */ private _spawnFlyClone(fromWorld: Readonly): Node | null { const template = this._weapon.arrowTemplate; if (!template?.isValid) { console.log('[SkillVisualChannel] 弓箭模板缺失', this._weapon.weaponId); return null; } const flyLayer = SkillPresentationEnv.flyLayer; if (!flyLayer?.isValid) { console.log('[SkillVisualChannel] 弓箭无飞行层'); return null; } // 模板保持隐藏;仅克隆体激活并飞出 const wasActive = template.active; template.active = true; const clone = instantiate(template); template.active = wasActive; if (!clone?.isValid) { console.log('[SkillVisualChannel] 弓箭克隆失败'); return null; } clone.active = true; flyLayer.active = true; clone.layer = flyLayer.layer; const worldScale = template.worldScale.clone(); if (this._weapon.skinName === SUN_BOW_SKIN) { worldScale.x *= SUN_BOW_FLY_SCALE; worldScale.y *= SUN_BOW_FLY_SCALE; } clone.parent = flyLayer; clone.setWorldScale(worldScale); const flyUt = flyLayer.getComponent(UITransform); if (flyUt) clone.setPosition(flyUt.convertToNodeSpaceAR(fromWorld)); else clone.setWorldPosition(fromWorld); clone.setSiblingIndex(flyLayer.children.length - 1); this._playArrowFly(clone); this._bowArrows.push(clone); return clone; } /** * 直线飞向终点。 * @param clone 箭节点 * @param fromWorld 起点 * @param toWorld 终点 * @param duration 秒 */ private _flyStraight(clone: Node, fromWorld: Readonly, toWorld: Readonly, duration: number): void { const flyLayer = SkillPresentationEnv.flyLayer; const flyUt = flyLayer?.getComponent(UITransform); const fromLocal = flyUt ? flyUt.convertToNodeSpaceAR(fromWorld) : fromWorld; const toLocal = flyUt ? flyUt.convertToNodeSpaceAR(toWorld) : toWorld; SkillTransformDriver.animate({ node: clone, duration, fromPos: fromLocal, toPos: toLocal, easing: SkillTransformDriver.easeLinear, onComplete: this._onBowArrowArrived, }); } /** * 克隆「箭」抛物线飞向目标,皮肤与武器一致。 * @param target 主目标 * @param landId 落地任务 id * @param onLand 落地回调 */ private _launchBowArrow(target: EntityId, landId = 0, onLand: ((target: EntityId, landId: number) => void) | null = null): void { const template = this._weapon.arrowTemplate; if (!template?.isValid) { console.log('[SkillVisualChannel] 弓箭模板缺失', this._weapon.weaponId); this._settleBowLandNow(target, landId, onLand); return; } const to = target > 0 ? SkillPresentationEnv.getTargetWorldPos(target) : null; if (!to) { console.log('[SkillVisualChannel] 弓箭无目标坐标', target); this._weapon.setArrowVisible(false); this._settleBowLandNow(target, landId, onLand); return; } // 模板「箭」保持隐藏,仅作克隆源;起点用 weapon 朝向节点(模板 inactive 时 worldPos 不可靠) const fromNode = this._weapon.aim?.isValid ? this._weapon.aim : template; this._arrowFrom.set(fromNode.worldPosition); this._arrowTo.set(to); this._weapon.setArrowVisible(false); this._weapon.setProjectileVisible(false); const dist = Vec3.distance(this._arrowFrom, this._arrowTo); const duration = Math.max(0.12, dist / BOW_ATK_ARROW_SPEED); if (!this._fillBowArrowCtrl(this._arrowFrom, this._arrowTo, dist)) { this._settleBowLandNow(target, landId, onLand); return; } const clone = this._spawnFlyClone(this._arrowFrom); if (!clone) { this._settleBowLandNow(target, landId, onLand); return; } this._bowFlights.push({ node: clone, target, landId, onLand }); SkillTransformDriver.animateParabola({ node: clone, duration, fromWorld: this._arrowFrom, toWorld: this._arrowTo, controlWorld: this._arrowCtrl, onComplete: this._onBowArrowArrived, }); } /** * 飞箭未能发出时立刻结算落地,避免任务悬挂丢伤。 * @param target 锁定目标 * @param landId 落地任务 id * @param onLand 落地回调 */ private _settleBowLandNow(target: EntityId, landId: number, onLand: ((target: EntityId, landId: number) => void) | null): void { if (onLand) onLand(target, landId); } /** * 法杖普攻:实例化「水弹」直线飞向目标,落地结算。 * @param caster 施法者 * @param abilityId Ability id * @param target 锁定目标 * @param scale 伤害倍率 * @param buff1 atk_buff1 * @param buff2 atk_buff2 * @param aimWorld 瞄准点 * @returns 是否发出 */ private _launchWaterBall(caster: EntityId, abilityId: string, target: EntityId, scale: number, buff1: string, buff2: string, aimWorld: Readonly): boolean { this._weaponCasting = true; // 出手瞬间再确保法杖可见(避免在飞弹道占 _weaponFlies 时藏武器) this._weapon.setVisible(true); const fromNode = this._weapon.skinAnim?.node ?? this._weapon.aim ?? this._weapon.root; const fromWorld = fromNode?.isValid ? fromNode.worldPosition : SkillPresentationEnv.getCasterGridCenter(caster); if (!fromWorld) { console.log('[SkillVisualChannel] 水弹无起点'); return false; } const live = target > 0 ? SkillPresentationEnv.getTargetWorldPos(target) : null; const to = live ?? aimWorld; if (!to) { console.log('[SkillVisualChannel] 水弹无目标坐标', target); return false; } this._arrowFrom.set(fromWorld); this._arrowTo.set(to); const dist = Vec3.distance(this._arrowFrom, this._arrowTo); const duration = Math.max(0.12, dist / WATER_BALL_SPEED); const node = this._spawnWaterBall(this._arrowFrom); if (!node) return false; SkillTransformDriver.animateParabola({ node, duration, fromWorld: this._arrowFrom, toWorld: this._arrowTo, arcHeight: 0, easing: SkillTransformDriver.easeLinear, faceTangent: true, }); SkillTransformDriver.stepNow(); const land = WaterBallRecycle.obtain(this, node); getBowAtkHost().spawnVolley({ caster, target, abilityId, wait: duration, scale, buff1, buff2, onLand: land.onLand, }); return true; } /** * 取出「飞剑」挂到飞行层并播默认动画。 * @param fromWorld 起点 */ private _spawnFlySwordPrefab(fromWorld: Readonly): Node | null { const flyLayer = SkillPresentationEnv.flyLayer; if (!flyLayer?.isValid) { console.log('[SkillVisualChannel] 飞剑无飞行层'); return null; } let node: Node | null = null; try { node = gg.res.getNode(GPath.HeroSkillPrefab(), FLY_SWORD_PREFAB, GBundle.BattleHero); } catch (e) { console.log('[SkillVisualChannel] 飞剑加载异常', String(e)); return null; } if (!node?.isValid) { console.log('[SkillVisualChannel] 飞剑预制体未加载'); return null; } flyLayer.active = true; node.parent = flyLayer; node.active = true; node.layer = flyLayer.layer; node.setScale(1, 1, 1); const flyUt = flyLayer.getComponent(UITransform); if (flyUt) node.setPosition(flyUt.convertToNodeSpaceAR(fromWorld)); else node.setWorldPosition(fromWorld); node.setSiblingIndex(flyLayer.children.length - 1); const sk = node.getComponentInChildren(sp.Skeleton); if (sk?.isValid) { BattleSpinePlayback.play(sk, FLY_SWORD_ANIM, { loop: true }); } this._weaponFlies.push(node); return node; } /** * 取出「水弹」挂到飞行层。 * @param fromWorld 起点 */ private _spawnWaterBall(fromWorld: Readonly): Node | null { return this._spawnScaledSkillFly(fromWorld, WATER_BALL_PREFAB, '水弹'); } /** * 取出「冰锥」挂到飞行层。 * @param fromWorld 起点 */ private _spawnIceCone(fromWorld: Readonly): Node | null { return this._spawnScaledSkillFly(fromWorld, ICE_CONE_PREFAB, '冰锥'); } /** * 取出 skill 目录下等比缩放的飞行预制体。 * @param fromWorld 起点 * @param prefabName 预制体名 * @param logTag 日志标签 */ private _spawnScaledSkillFly(fromWorld: Readonly, prefabName: string, logTag: string): Node | null { const flyLayer = SkillPresentationEnv.flyLayer; if (!flyLayer?.isValid) { console.log(`[SkillVisualChannel] ${logTag}无飞行层`); return null; } let node: Node | null = null; try { node = gg.res.getNode(GPath.HeroSkillPrefab(), prefabName, GBundle.BattleHero); } catch (e) { console.log(`[SkillVisualChannel] ${logTag}加载异常`, String(e)); return null; } if (!node?.isValid) { console.log(`[SkillVisualChannel] ${logTag}预制体未加载`); return null; } flyLayer.active = true; node.parent = flyLayer; node.active = true; node.layer = flyLayer.layer; node.setScale(WATER_BALL_SCALE, WATER_BALL_SCALE, 1); const flyUt = flyLayer.getComponent(UITransform); if (flyUt) node.setPosition(flyUt.convertToNodeSpaceAR(fromWorld)); else node.setWorldPosition(fromWorld); node.setSiblingIndex(flyLayer.children.length - 1); this._weaponFlies.push(node); return node; } /** * 取出玉帝飞剑,根节点沿抛物线切线飞向目标。 * @param target 主目标 * @returns 飞行时长(秒);失败为 0 */ private _launchWeaponThrow(target: EntityId): number { this._lastThrowFlySec = 0; const fromNode = this._weapon.aim ?? this._weapon.root; if (!fromNode?.isValid) { console.log('[SkillVisualChannel] 飞剑无起点', this._weapon.weaponId); return 0; } const to = target > 0 ? SkillPresentationEnv.getTargetWorldPos(target) : null; if (!to) { console.log('[SkillVisualChannel] 飞剑无目标坐标', target); return 0; } this._arrowFrom.set(fromNode.worldPosition); this._arrowTo.set(to); this._weapon.setVisible(false); const fly = this._spawnYuDiFlySword(this._arrowFrom); if (!fly) { this._weapon.setVisible(true); return 0; } const dist = Vec3.distance(this._arrowFrom, this._arrowTo); const duration = Math.max(YUDI_FLY_SWORD_MIN_SEC, dist / YUDI_FLY_SWORD_SPEED); this._lastThrowFlySec = duration; SkillTransformDriver.animateParabola({ node: fly, duration, fromWorld: this._arrowFrom, toWorld: this._arrowTo, arcHeight: Math.max(40, dist * BOW_ARROW_CTRL_T), faceTangent: true, easing: SkillTransformDriver.easeLinear, onComplete: this._onWeaponFlyArrived, }); SkillTransformDriver.stepNow(); return duration; } /** * 加载玉帝飞剑:套 Weapon.skin,播预制体默认「玉帝普攻」。 * @param fromWorld 起点 */ private _spawnYuDiFlySword(fromWorld: Readonly): Node | null { const flyLayer = SkillPresentationEnv.flyLayer; if (!flyLayer?.isValid) { console.log('[SkillVisualChannel] 玉帝飞剑无飞行层'); return null; } let node: Node | null = null; try { node = gg.res.getNode(GPath.HeroSkillPrefab(), YUDI_FLY_SWORD_PREFAB, GBundle.BattleHero); } catch (e) { console.log('[SkillVisualChannel] 玉帝飞剑加载异常', String(e)); return null; } if (!node?.isValid) { console.log('[SkillVisualChannel] 玉帝飞剑预制体未加载'); return null; } flyLayer.active = true; node.parent = flyLayer; node.active = true; node.layer = flyLayer.layer; const flyUt = flyLayer.getComponent(UITransform); if (flyUt) node.setPosition(flyUt.convertToNodeSpaceAR(fromWorld)); else node.setWorldPosition(fromWorld); node.setSiblingIndex(flyLayer.children.length - 1); const sk = node.getChildByName('anim')?.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton); if (!sk?.isValid) { console.log('[SkillVisualChannel] 玉帝飞剑缺 Spine'); } else { sk.paused = false; if (!BattleSpinePlayback.play(sk, YUDI_FLY_SWORD_ANIM, { loop: true })) { console.log('[SkillVisualChannel] 玉帝飞剑无默认动画', YUDI_FLY_SWORD_ANIM); } } this._weaponFlies.push(node); return node; } /** 飞剑落地:回池;仍在出手或还有在飞则不显示原武器 */ private readonly _onWeaponFlyArrived = (node?: Node): void => { if (node) this._recycleWeaponFly(node); if (this._weaponFlies.length > 0 || !this._useWeapon || this._weaponCasting) return; this._weapon.setVisible(true); }; /** * 连发三弹道控制点:0 中间直线,1 左外弯,2 右外弯,按发次循环。 * @param from 起点(武器) * @param to 终点(目标) * @param dist 距离 * @param shotIndex 发次 * @returns 是否写出 */ private _fillVolleyArrowCtrl(from: Readonly, to: Readonly, dist: number, shotIndex: number): boolean { if (!(dist > 1e-4)) { console.log('[SkillVisualChannel] 连发箭距离为零', this._weapon.weaponId); return false; } const dx = (to.x - from.x) / dist; const dy = (to.y - from.y) / dist; const midX = (from.x + to.x) * 0.5; const midY = (from.y + to.y) * 0.5; const lane = ((shotIndex % VOLLEY_LANE_COUNT) + VOLLEY_LANE_COUNT) % VOLLEY_LANE_COUNT; if (lane === 0) { this._arrowCtrl.set(midX, midY, from.z); return true; } const bulge = Math.max(VOLLEY_OUT_MIN, dist * VOLLEY_OUT_T); const sign = lane === 1 ? 1 : -1; this._arrowCtrl.set(midX + (-dy) * bulge * sign, midY + dx * bulge * sign, from.z); return true; } /** * 抛物线控制点:沿偏转后的出弓切线,再弯向目标。 * @param from 起点 * @param to 终点 * @param dist 距离 * @param offsetDeg 出弓相对目标连线偏角;默认普攻偏角 * @param sideBiasPx 控制点相对 from→to 的侧向偏移(像素) * @returns 是否写出控制点 */ private _fillBowArrowCtrl(from: Readonly, to: Readonly, dist: number, offsetDeg = ARROW_BOW_OFFSET_DEG, sideBiasPx = 0): boolean { const launchDeg = computeArrowBowAimDeg(from, to, offsetDeg); if (launchDeg == null) { console.log('[SkillVisualChannel] 弓箭朝向为零', this._weapon.weaponId); return false; } const rad = launchDeg * Math.PI / 180; const ctrlLen = Math.max(40, dist * BOW_ARROW_CTRL_T); let cx = from.x + Math.cos(rad) * ctrlLen; let cy = from.y + Math.sin(rad) * ctrlLen; if (sideBiasPx !== 0) { const dx = to.x - from.x; const dy = to.y - from.y; const len = Math.hypot(dx, dy); if (len > 1e-4) { cx += (-dy / len) * sideBiasPx; cy += (dx / len) * sideBiasPx; } } this._arrowCtrl.set(cx, cy, from.z); return true; } /** * 播箭飞行动画。 * @param node 箭根 */ private _playArrowFly(node: Node): void { const anim = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton); if (!anim?.isValid) return; BattleSpinePlayback.play(anim, BOW_ARROW_FLY_ANIM, { loop: true }); } /** 箭落地:普攻箭先按节点结算锁定目标,再回池 */ private readonly _onBowArrowArrived = (node?: Node): void => { if (node) { const flight = this._takeBowFlight(node); if (flight?.onLand) flight.onLand(flight.target, flight.landId); this._recycleBowArrow(node); } }; /** * 取出并移除在飞落地绑定。 * @param node 飞箭节点 */ private _takeBowFlight(node: Node): BowLandFlight | null { for (let i = 0; i < this._bowFlights.length; i++) { if (this._bowFlights[i].node !== node) continue; const flight = this._bowFlights[i]; this._bowFlights.splice(i, 1); return flight; } return null; } /** 回收全部在飞弓箭 */ private _recycleAllBowArrows(): void { for (let i = this._bowArrows.length - 1; i >= 0; i--) { this._recycleBowArrow(this._bowArrows[i]); } } /** * 停飞并销毁克隆。 * @param node 箭节点 */ private _recycleBowArrow(node: Node): void { for (let i = this._bowArrows.length - 1; i >= 0; i--) { if (this._bowArrows[i] === node) this._bowArrows.splice(i, 1); } if (!node?.isValid) return; SkillTransformDriver.cancelNode(node); const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton); BattleSpinePlayback.clearPendingComplete(sk); BattleSpinePlayback.resetTracks(sk); node.destroy(); const flyLayer = SkillPresentationEnv.flyLayer; if (flyLayer?.isValid && flyLayer.children.length === 0) flyLayer.active = false; } /** 回收全部在飞玉帝飞剑 */ private _recycleAllWeaponFlies(): void { for (let i = this._weaponFlies.length - 1; i >= 0; i--) { this._recycleWeaponFly(this._weaponFlies[i]); } } /** * 停飞并回池玉帝飞剑。 * @param node 飞剑节点 */ private _recycleWeaponFly(node: Node): void { for (let i = this._weaponFlies.length - 1; i >= 0; i--) { if (this._weaponFlies[i] === node) this._weaponFlies.splice(i, 1); } if (!node?.isValid) return; SkillTransformDriver.cancelNode(node); const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton); BattleSpinePlayback.clearPendingComplete(sk); BattleSpinePlayback.resetTracks(sk); if (sk?.isValid) sk.paused = false; gg.res.putNode(node); const flyLayer = SkillPresentationEnv.flyLayer; if (flyLayer?.isValid && flyLayer.children.length === 0) flyLayer.active = false; } /** * 武器朝向主目标或指定世界点。刀只左右翻转;弓/枪/棍走最短弧插值。 * @param primaryTarget 主目标 * @param aspd 攻速系数 * @param aimWorldX 瞄准世界 X * @param aimWorldY 瞄准世界 Y * @param fixedAimDeg 固定朝向(优先) */ private _aimWeapon(primaryTarget: EntityId | null, aspd: number, aimWorldX?: number, aimWorldY?: number, fixedAimDeg?: number): void { if (this._isStaffWeapon()) return; if (this._isBowWeapon() && this._weapon.bowAtkShowing && fixedAimDeg != null && Number.isFinite(fixedAimDeg)) { this._tweenWeaponAim(fixedAimDeg, WEAPON_AIM_SEC / Math.max(0.01, aspd)); return; } const aimNode = this._isBowWeapon() && this._weapon.bowAtkShowing ? (this._weapon.bowAtkRoot ?? this._weapon.aim) : this._weapon.aim; if (!aimNode?.isValid) { console.log('[SkillVisualChannel] 武器缺少朝向节点', this._weapon.weaponId); return; } let targetWorld: Vec3 | null = null; if (aimWorldX != null && aimWorldY != null && Number.isFinite(aimWorldX) && Number.isFinite(aimWorldY)) { this._aimScratch.set(aimWorldX, aimWorldY, aimNode.worldPosition.z); targetWorld = this._aimScratch; } else if (primaryTarget != null) { targetWorld = SkillPresentationEnv.getTargetWorldPos(primaryTarget); } const pivotWorld = aimNode.worldPosition.clone(); if (!targetWorld) { targetWorld = worldPointAtAimAngle(pivotWorld, WEAPON_REST_ANGLE, 200); } if (this._isFlipWeapon()) { this._weapon.aimByTargetX(targetWorld.x); return; } const aim = this._isBowWeapon() && !this._aimDirect ? computeArrowBowAimDeg(pivotWorld, targetWorld) : computeAimAngleDeg(pivotWorld, targetWorld); if (aim == null) { console.log('[SkillVisualChannel] 武器朝向为零', this._weapon.weaponId); return; } // 弓转攻效根;枪转 weapon 节点,均插值,禁止硬切 this._tweenWeaponAim(aim, WEAPON_AIM_SEC / Math.max(0.01, aspd)); } /** * 攻击结束后回待机角。 * 弓:先切回 weaponType4 并继承当前攻效角,再插值到 180°。 * 枪/棍:直接插值到 WEAPON_REST_ANGLE。 * 刀:立刻复位(左右翻转不走角度插值)。 * @param aspd 攻速系数 */ private _restWeaponAim(aspd: number): void { const duration = WEAPON_AIM_SEC / Math.max(0.01, aspd); if (this._isBowWeapon()) { if (this._weapon.bowAtkShowing) { const fromDeg = this._weapon.bowAtkRoot?.angle ?? BOW_ATK_REST_ANGLE; if (this._weapon.bowAtkRoot?.isValid) { SkillTransformDriver.cancelNode(this._weapon.bowAtkRoot); } this._weapon.hideBowAtk(true); this._weapon.setAimAngle(fromDeg); this._tweenWeaponAim(WEAPON_REST_ANGLE, duration, this._onWeaponAimRestCompleteBound); return; } this._tweenWeaponAim(WEAPON_REST_ANGLE, duration, this._onWeaponAimRestCompleteBound); return; } if (this._isFlipWeapon() || this._isStaffWeapon()) { this._weapon.resetAim(); return; } // 枪:从当前瞄准角插值回待机角,到位后再播待机 this._tweenWeaponAim(WEAPON_REST_ANGLE, duration, this._onWeaponAimRestCompleteBound); } /** 待机武器拧回待机角后再播待机动画 */ private readonly _onWeaponAimRestCompleteBound = (): void => { if (this._weaponCasting) return; this.playIdle(); }; /** * 武器朝向最短弧插值。弓攻效显示时转「武器弓-攻击」根。 * @param toDeg 目标瞄准角 * @param duration 秒 * @param onComplete 插值结束 */ private _tweenWeaponAim(toDeg: number, duration: number, onComplete?: () => void): void { const atk = this._weapon.bowAtkShowing ? this._weapon.bowAtkRoot : null; const node = atk?.isValid ? atk : this._weapon.aim; if (!node?.isValid) { onComplete?.(); return; } const toNodeDeg = toDeg; if (!(duration > 0)) { if (atk?.isValid) this._weapon.setBowAtkAim(toDeg); else this._weapon.setAimAngle(toDeg); onComplete?.(); return; } SkillTransformDriver.cancelNode(node); this._aimTweenComplete = onComplete ?? null; SkillTransformDriver.animate({ node, duration, fromAngle: node.angle, toAngle: toNodeDeg, easing: SkillTransformDriver.easeSineInOut, onComplete: onComplete ? this._onAimTweenCompleteBound : undefined, }); } /** 当前朝向插值完成回调 */ private _aimTweenComplete: (() => void) | null = null; /** 朝向插值结束 */ private readonly _onAimTweenCompleteBound = (): void => { const cb = this._aimTweenComplete; this._aimTweenComplete = null; cb?.(); }; /** 停止朝向插值 */ private _cancelWeaponAim(): void { this._aimTweenComplete = null; const atk = this._weapon.bowAtkRoot; if (atk?.isValid) SkillTransformDriver.cancelNode(atk); const node = this._weapon.aim; if (node?.isValid) SkillTransformDriver.cancelNode(node); } }