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.

366 lines
14 KiB

3 weeks ago
import { _decorator, Node, sp } 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 { computeAimAngleDeg, SkillTransformDriver } from '../skill/SkillTransformDriver';
import { worldPointAtAimAngle } from '../skill/SkillParentAim';
import { PRES_HIT_AT_BASE } from '../presentation/SkillVisualChannel';
import { HeroSkin } from '../view/HeroSkin';
import { BattleSpinePlayback } from '../presentation/BattleSpinePlayback';
import { HeroActor } from './HeroActor';
const { ccclass } = _decorator;
/** battleHero/prefab/skill/英雄枪-攻击 */
const ATK_PREFAB = '英雄枪-攻击';
const ATK_PREFAB_PATH = 'prefab/skill/';
/** 攻效节点名 */
const NODE_ANIM = 'anim';
/** 攻效攻击段 */
const SPINE_ATK_FX = '攻击2';
/** 攻效定格后:1 秒内没有下一刀则回文字待机 */
const HOLD_WINDOW_SEC = 1;
/** 攻击2 完成回调丢失后的收招宽限(逻辑秒) */
const ATK_COMPLETE_SLACK_SEC = 0.05;
/** 预制体默认朝向(无目标时 fallback) */
const ATK_REST_ANGLE = 0;
/** 瞄准 / 收招插值秒(与武器枪一致) */
const AIM_BLEND_SEC = 0.2;
/** 连击瞄准更短,减少从末帧拧到新目标的等待 */
const COMBO_AIM_BLEND_SEC = 0.1;
type QiangPhase = 'idle' | 'aiming' | 'attacking' | 'engaged' | 'returning';
/**
* 枪:HeroActor 壳;普攻/技能共用「英雄枪-攻击」(播攻击2、瞄准目标)。
* 攻击2 播完定格末帧,开 1 秒连击窗(与弓一致);窗内无下一刀再插值回文字待机。
* 能否出手只由逻辑层 tryCast 决定。
*/
@ccclass('Qiang')
export class Qiang extends HeroActor {
private _atkRoot: Node | null = null;
private _atkAnim: sp.Skeleton | null = null;
private _phase: QiangPhase = 'idle';
/** 连击窗计时序号 */
private _holdTimerSeq = 0;
/** 攻击2 收招监视序号 */
private _atkWatchSeq = 0;
/** 瞄准/收招插值世代 */
private _aimSeq = 0;
/** 瞄准中的施法 ctx:beginPresentationCast 前若被 abort,必须 finish,否则逻辑锁永久卡住 */
private _aimingCtx: SkillPresentationContext | null = null;
detachPooledFx(): void {
this._disposeAtk();
super.detachPooledFx();
}
onIdle(): void {
// 攻击/连击定格中:禁止字形收招把 1 秒窗踩掉
if (this._phase === 'attacking' || this._phase === 'engaged') return;
// idle / aiming / returning:都要能收干净(拖动 abort、插值被取消)
this._forceIdleVisual();
}
abortPresentation(): void {
this._forceIdleVisual();
super.abortPresentation();
if (this.isValid && this.isActive) super.onIdle();
}
isPresentationBusy(): boolean {
return this._phase !== 'idle' || super.isPresentationBusy();
}
/** 立刻收攻效并回字形待机(拖动打断 / 插值被取消时的兜底) */
private _forceIdleVisual(): void {
this._cancelHoldTimer();
this._cancelAtkWatch();
this._cancelAimTween();
const aiming = this._aimingCtx;
this._aimingCtx = null;
this._phase = 'idle';
this._hideAtk();
// 瞄准中的 ctx 从未进 _presCtx,这里必须结算,否则 OnTick 永远等表现结束 → 不再攻击
if (aiming?.isAlive) aiming.finish();
this.abortPresentationCast();
if (this.isValid) super.onIdle();
}
/** skillId Presenter:普攻与技能同一套攻效 */
presentNormAttack(ctx: SkillPresentationContext): void {
this._presentQiangFx(ctx);
}
/** 主动技能与普攻共用预制体(预留入口) */
presentSkill(ctx: SkillPresentationContext): void {
this._presentQiangFx(ctx);
}
private _presentQiangFx(ctx: SkillPresentationContext): void {
if (!ctx.isAlive || !this.isValid || !this.node?.isValid) {
ctx.finish();
return;
}
// 连击:攻效仍在末帧定格,禁止 setToSetupPose(会弹到 setup,像翻到枪字另一侧)
const isCombo = this._phase === 'engaged';
this._cancelHoldTimer();
this._cancelAtkWatch();
this._cancelAimTween();
this._bindChannel();
this._ensureAtk();
this.syncAtkFxSkin(this._atkAnim);
this._phase = 'aiming';
this._aimingCtx = ctx;
this._showAtk();
if (!isCombo && this._atkAnim?.isValid) {
// 首刀/非连击:瞄准期间停在 setup,避免预制体默认动画抢播
BattleSpinePlayback.resetTracks(this._atkAnim);
this._atkAnim.setToSetupPose();
this._atkAnim.timeScale = 0;
}
const aspd = Math.max(0.01, SkillPresentationEnv.getFinalAttackSpeed(ctx.caster));
const aimSec = isCombo ? COMBO_AIM_BLEND_SEC : AIM_BLEND_SEC;
this._aimAtkRoot(ctx.targets[0] ?? null, aimSec, aspd, () => {
this._aimingCtx = null;
if (!this.isValid || this._phase !== 'aiming') {
// 瞄准被 abort/force-idle 抢走:ctx 已在 _forceIdleVisual 结算
return;
}
this._showAtk();
this._phase = 'attacking';
const seq = this.beginPresentationCast(ctx);
const castAspd = this.visualChannel.playCastAnim({
caster: ctx.caster,
animation: String(ctx.params.animation ?? ''),
followWeapon: false,
primaryTarget: ctx.targets[0] ?? null,
onAnimComplete: this.onPresentationAnimCompleteBound,
});
if (this._atkAnim?.isValid) {
BattleSpinePlayback.resetTracks(this._atkAnim);
this.playSpine(this._atkAnim, SPINE_ATK_FX, this._onAtkFxCompleteBound, castAspd, false);
const dur = BattleSpinePlayback.getAnimDuration(this._atkAnim, SPINE_ATK_FX);
if (dur != null) {
this._scheduleAtkWatch(dur / Math.max(0.01, castAspd) + ATK_COMPLETE_SLACK_SEC);
}
} else {
console.log('[Qiang] 攻效预制体未加载', ATK_PREFAB);
this._enterHold();
}
this.schedulePresentationCast(seq, PRES_HIT_AT_BASE / Math.max(0.01, castAspd));
});
}
/** 攻击2 播完:定格末帧并开 1 秒连击窗(与弓一致;勿因字形已收招而跳过) */
private readonly _onAtkFxCompleteBound = (): void => {
if (!this.isValid || this._phase !== 'attacking') return;
this._cancelAtkWatch();
this._enterHold();
};
/** 攻击2 完成回调未到时强制走同一收招 */
private _onAtkWatchExpired(seq: number): void {
if (seq !== this._atkWatchSeq) return;
if (!this.isValid || this.isPresentationTeardown() || this._phase !== 'attacking') return;
console.warn('[Qiang] 攻击2 完成回调未到,强制收招');
this._onAtkFxCompleteBound();
}
/** 攻击2 停在末帧(攻效「攻击待机」时长为 0,再播会把后续攻击2 播不出来) */
private _enterHold(): void {
this._phase = 'engaged';
if (this._atkAnim?.isValid) {
this._showAtk();
BattleSpinePlayback.clearPendingComplete(this._atkAnim);
this._atkAnim.timeScale = 0;
}
this._scheduleHoldReturn();
}
/** 1 秒后收招;下一刀会作废本次序号 */
private _scheduleHoldReturn(): void {
const seq = ++this._holdTimerSeq;
this.schedulePresentation(() => {
if (seq !== this._holdTimerSeq) return;
if (!this.isValid || this.isPresentationTeardown()) return;
this._onHoldWindowExpired();
}, HOLD_WINDOW_SEC);
}
/**
* 攻击2 应在 delay 内结束;超时仍 attacking 则强制收招。
* @param delaySec 逻辑秒
*/
private _scheduleAtkWatch(delaySec: number): void {
const seq = ++this._atkWatchSeq;
this.schedulePresentation(() => {
if (seq !== this._atkWatchSeq) return;
this._onAtkWatchExpired(seq);
}, delaySec);
}
/** 收招窗到点:本窗内没有下一刀则回文字待机 */
private _onHoldWindowExpired(): void {
if (!this.isValid || this.isPresentationTeardown() || this._phase !== 'engaged') return;
this._restoreFullIdle();
}
/** 藏攻效,字形回待机;攻效仍显示时先插值拧回休息角 */
private _restoreFullIdle(): void {
this._cancelHoldTimer();
this._cancelAtkWatch();
if (this._phase === 'idle' || this._phase === 'returning') return;
const atk = this._atkRoot;
if (!atk?.isValid || !atk.active) {
this._forceIdleVisual();
return;
}
this._phase = 'returning';
this._cancelAimTween();
const seq = ++this._aimSeq;
SkillTransformDriver.animate({
node: atk,
duration: AIM_BLEND_SEC,
fromAngle: atk.angle,
toAngle: ATK_REST_ANGLE,
easing: SkillTransformDriver.easeSineInOut,
onComplete: () => {
if (!this.isValid || seq !== this._aimSeq) return;
if (this._phase !== 'returning') return;
this._phase = 'idle';
this._hideAtk();
super.onIdle();
},
});
// 拖动/节点失效时 SkillTransformDriver 丢弃动画且不回调 → 必须兜底,否则字形永久不显示
this.schedulePresentation(() => {
if (!this.isValid || seq !== this._aimSeq) return;
if (this._phase !== 'returning') return;
this._forceIdleVisual();
}, AIM_BLEND_SEC + 0.05);
}
/** 作废未触发的连击窗计时 */
private _cancelHoldTimer(): void {
this._holdTimerSeq++;
}
/** 作废未触发的攻击2 收招监视 */
private _cancelAtkWatch(): void {
this._atkWatchSeq++;
}
private _cancelAimTween(): void {
this._aimSeq++;
if (this._atkRoot?.isValid) SkillTransformDriver.cancelNode(this._atkRoot);
}
/**
* 旋转「英雄枪-攻击」根节点插值朝向主目标。
* @param primaryTarget 主目标
* @param aimSec 插值时长(秒 @ 攻速=1)
* @param aspd 攻速系数
* @param onReady 转到瞄准角后起播攻击2
*/
private _aimAtkRoot(primaryTarget: EntityId | null, aimSec: number, aspd: number, onReady: () => void): void {
const atk = this._atkRoot;
if (!atk?.isValid) {
onReady();
return;
}
let targetWorld = primaryTarget != null
? SkillPresentationEnv.getTargetWorldPos(primaryTarget)
: null;
const pivotWorld = atk.worldPosition.clone();
if (!targetWorld) {
targetWorld = worldPointAtAimAngle(pivotWorld, ATK_REST_ANGLE, 200);
}
const aim = computeAimAngleDeg(pivotWorld, targetWorld);
if (aim == null) {
onReady();
return;
}
this._cancelAimTween();
const seq = ++this._aimSeq;
SkillTransformDriver.animate({
node: atk,
duration: Math.max(0.01, aimSec / aspd),
fromAngle: atk.angle,
toAngle: aim,
easing: SkillTransformDriver.easeSineInOut,
onComplete: () => {
if (seq !== this._aimSeq) return;
onReady();
},
});
}
private _ensureAtk(): void {
if (!this._atkRoot?.isValid) {
const atk = gg.res.getNode(ATK_PREFAB_PATH, ATK_PREFAB, GBundle.BattleHero);
if (!atk?.isValid) {
this._atkRoot = null;
this._atkAnim = null;
return;
}
this._atkRoot = atk;
}
this._attachAtk();
const animNode = this._atkRoot.getChildByName(NODE_ANIM);
this._atkAnim = animNode?.getComponent(sp.Skeleton) ?? null;
if (!this._atkAnim?.isValid) {
console.log('[Qiang] 攻效缺少节点', NODE_ANIM);
}
}
/** 挂在英雄展示根上,与「动画」同级 */
private _attachAtk(): void {
const atk = this._atkRoot;
if (!atk?.isValid) return;
if (!this._heroSkin) {
this._heroSkin = this.node.getComponent(HeroSkin);
}
const display = this._heroSkin?.displayNode;
const parent = display?.isValid ? display : this.node;
if (atk.parent !== parent) {
atk.parent = parent;
}
atk.setPosition(0, 0, 0);
}
private _showAtk(): void {
if (this._atkRoot?.isValid) this._atkRoot.active = true;
}
private _hideAtk(): void {
if (this._atkAnim?.isValid) {
BattleSpinePlayback.clearPendingComplete(this._atkAnim);
this._atkAnim.timeScale = 1;
}
if (this._atkRoot?.isValid) {
this._atkRoot.active = false;
this._atkRoot.angle = ATK_REST_ANGLE;
}
}
private _disposeAtk(): void {
this._cancelHoldTimer();
this._cancelAtkWatch();
this._cancelAimTween();
const aiming = this._aimingCtx;
this._aimingCtx = null;
if (aiming?.isAlive) aiming.finish();
this._phase = 'idle';
this._hideAtk();
if (this._atkRoot?.isValid) {
gg.res.putNode(this._atkRoot);
}
this._atkRoot = null;
this._atkAnim = null;
}
}