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