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.
1985 lines
77 KiB
1985 lines
77 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 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;
|
|
/** 瞄准世界 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 是否发出
|
|
*/
|
|
/**
|
|
* 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;
|
|
}
|
|
return this._launchWaterBall(caster, abilityId, target, scale, buff1, buff2, aim);
|
|
}
|
|
|
|
/**
|
|
* 飞剑武器主动:实例化「飞剑」直线飞向目标,落地结算。
|
|
* @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<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;
|
|
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<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(), 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<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);
|
|
}
|
|
}
|
|
|