You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
AdLike2048/assets/script/battle/presentation/SkillVisualChannel.ts

1867 lines
73 KiB

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;
/**
* 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 = '水弹';
/**
* 水弹预制体「飞行水弹」原图约 322×122,发射代码不放大;缩到约一格可视大小。
* 缩放比 = CELL_SIZE / 原图像素宽。
*/
const WATER_BALL_SCALE = BattleMapSpec.CELL_SIZE / 322;
/** 水弹飞行像素/秒(与弓箭同量级) */
const WATER_BALL_SPEED = BOW_ATK_ARROW_SPEED;
/** 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;
/** 瞄准世界 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<ITableHero>(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 if (this._weaponFlies.length === 0) {
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;
}
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();
if (this._isBowWeapon()) this._weapon.hideBowAtk(false);
this._weapon.setArrowVisible(false);
// 刀/木可立刻复位;弓/枪连击窗内保持瞄准角
if (!this._shouldTweenRestAim() && !this._isStaffWeapon()) {
this._weapon.resetAim();
}
this.syncWeaponPivot(this._caster, true);
this._weapon.setVisible(true);
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;
if (this._shouldTweenRestAim()) 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;
}
/**
* 弓箭出手基准秒:组合弓对齐 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);
if (this._isStaffWeapon()) {
const name = String(this._castAnimParams?.animation ?? '').trim();
const anim = this._weapon.skinAnim;
const dur = name && anim?.isValid ? BattleSpinePlayback.getAnimDuration(anim, name) : null;
if (dur != null && dur > 0) return dur / scale;
console.warn('[SkillVisualChannel] 水弹出手延迟无动画时长', name);
return PRES_HIT_AT_BASE / scale;
}
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 是否发出
*/
launchVolleyShot(caster: EntityId, abilityId: string, target: EntityId, scale: number, buff1: string, buff2: string, shotIndex: number, aimWorld: Readonly<Vec3>): boolean {
if (this._isStaffWeapon()) {
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<Vec3>): 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<Vec3>): 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._applyArrowSkin(clone, this._weapon.skinName);
this._playArrowFly(clone);
this._bowArrows.push(clone);
return clone;
}
/**
* 直线飞向终点。
* @param clone 箭节点
* @param fromWorld 起点
* @param toWorld 终点
* @param duration 秒
*/
private _flyStraight(clone: Node, fromWorld: Readonly<Vec3>, toWorld: Readonly<Vec3>, 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<Vec3>): boolean {
this._weaponCasting = true;
const fromNode = this._weapon.skinAnim?.node ?? this._weapon.aim ?? this._weapon.root;
if (!fromNode?.isValid) {
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(fromNode.worldPosition);
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 _spawnWaterBall(fromWorld: Readonly<Vec3>): 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(), WATER_BALL_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;
// 预制体原图过大;发射侧缩小,回池由 ResourcesManager baseline 还原
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<Vec3>): 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);
this._applyArrowSkin(node, this._weapon.skinName);
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<Vec3>, to: Readonly<Vec3>, 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<Vec3>, to: Readonly<Vec3>, 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 箭根
* @param skinName Weapon.skin
*/
private _applyArrowSkin(node: Node, skinName: string): void {
const name = String(skinName ?? '').trim();
if (!name) {
console.log('[SkillVisualChannel] 弓箭皮肤为空', this._weapon.weaponId);
return;
}
const anim = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton);
if (!anim?.isValid) {
console.log('[SkillVisualChannel] 弓箭克隆缺 Spine');
return;
}
if (anim.skeletonData) ensureSpineSkeletonDataRuntime(anim.skeletonData);
if (!glyphSpineHasSkin(anim, name)) {
console.log('[SkillVisualChannel] 弓箭无此皮肤', name, this._weapon.weaponId);
return;
}
try {
anim.setSkin(name);
anim.setSlotsToSetupPose();
} catch {
console.log('[SkillVisualChannel] 弓箭 setSkin 失败', name, this._weapon.weaponId);
}
}
/**
* 播箭飞行动画。
* @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);
}
}