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

256 lines
8.9 KiB

3 weeks ago
import { Node, sp, UITransform, Vec3 } from 'cc';
import type { EntityId } from 'db://assets/mx/module/ecs';
import { GBundle, GPath } from '../../game/ConfigRes';
import { BattleSpinePlayback } from '../presentation/BattleSpinePlayback';
import { PRES_HIT_AT_BASE } from '../presentation/SkillVisualChannel';
import { schedulePresentation } from './BattlePresentationClock';
import { SkillPresentationEnv } from './SkillPresentationEnv';
import type { SkillPresentationContext } from './SkillPresentationTypes';
/** 预兆动画播完后再停留的逻辑秒 */
const HOLD_AFTER_ANIM_SEC = 1;
/** 播完再施加命中(杨戬召唤哮天犬) */
const APPLY_AFTER_ANIM = '召唤哮天犬';
/** 预兆时钟任务种类 */
enum ShowEffectJobKind {
/** 延迟后生成 */
Delay = 0,
/** 起播后施加效果 */
Apply = 1,
/** 停留结束回池 */
Hold = 2,
}
/** Skill.showeffect 解析结果 */
export interface SkillShowEffectSpec {
/** 技能释放后延迟(秒) */
delaySec: number;
/** battleHero/prefab/skill 下的预制体名 */
prefabName: string;
}
/** 带代数的时钟回调,避免过期预约仍生效 */
class ShowEffectSeqJob {
/**
* @param owner 预兆播放器
* @param seq 预约代数
* @param kind 到期后执行的步骤
*/
constructor(private readonly owner: SkillShowEffectPlayer, private readonly seq: number, private readonly kind: ShowEffectJobKind) {}
/** 时钟到期 */
readonly fire = (): void => {
if (this.kind === ShowEffectJobKind.Hold) this.owner.onHoldJob(this.seq);
else if (this.kind === ShowEffectJobKind.Apply) this.owner.onApplyJob(this.seq);
else this.owner.onDelayJob(this.seq);
};
}
/**
* Skill.showeffect:释放后延迟播预兆,挂组合两字中间(英雄坐标)。
* 默认动画起播后 PRES_HIT_AT_BASE(0.33s)回调 onApply;`召唤哮天犬` 等播完再施加。动画继续播完再停 1 秒回池。
*/
export class SkillShowEffectPlayer {
private _seq = 0;
private _caster: EntityId = 0;
private _prefabName = '';
private _node: Node | null = null;
private _animReady = false;
/** 预兆起播后命中时刻施加一次 */
private _onApply: (() => void) | null = null;
/** 本次预约是否已回调(含失败立即施加) */
private _applied = true;
/**
* 按本次施法的 showeffect 预约预兆。空配置返回 false,不打断已在播的预兆。
* @param ctx 表现上下文
* @param onApply 预兆起播后命中时刻回调;生成失败也会 warn 后调用,避免效果丢失
* @returns 是否挂了预兆(调用方应推迟命中与额外效果)
*/
arm(ctx: SkillPresentationContext, onApply: () => void): boolean {
const spec = SkillShowEffectPlayer.parse(String(ctx.params.showeffect ?? ''), Number(ctx.params.skillId ?? ctx.baseAtkId) || 0);
if (!spec) return false;
this.release();
this._applied = false;
this._onApply = onApply;
this._caster = ctx.caster;
this._prefabName = spec.prefabName;
if (spec.delaySec <= 0) {
this._spawn();
return true;
}
schedulePresentation(spec.delaySec, new ShowEffectSeqJob(this, this._seq, ShowEffectJobKind.Delay).fire);
return true;
}
/** 当前预兆节点(暂停时需一并冻 Spine) */
get node(): Node | null {
return this._node?.isValid ? this._node : null;
}
/**
* 延迟到期:代数仍有效才生成。
* @param seq 预约代数
*/
onDelayJob(seq: number): void {
if (seq !== this._seq) return;
this._spawn();
}
/**
* 起播后命中时刻:代数仍有效才施加。
* @param seq 预约代数
*/
onApplyJob(seq: number): void {
if (seq !== this._seq) return;
this._fireApply();
}
/**
* 停留结束:代数仍有效才回池。
* @param seq 预约代数
*/
onHoldJob(seq: number): void {
if (seq !== this._seq) return;
this._recycle();
}
/** 取消预约并回池(不触发 onApply) */
release(): void {
this._seq++;
this._onApply = null;
this._applied = true;
this._recycle();
this._caster = 0;
this._prefabName = '';
this._animReady = false;
}
/**
* 解析 `延迟秒,预制体名`。空串视为未配置。
* @param raw Skill.showeffect
* @param skillId 仅用于 warn
*/
static parse(raw: string, skillId: number): SkillShowEffectSpec | null {
const s = String(raw ?? '').trim();
if (!s) return null;
const split = s.indexOf(',');
if (split <= 0 || split >= s.length - 1) {
console.log('[SkillShowEffect] showeffect 须为「延迟秒,预制体名」', skillId);
return null;
}
const delaySec = Number(s.slice(0, split).trim());
const prefabName = s.slice(split + 1).trim();
if (!Number.isFinite(delaySec) || delaySec < 0 || !prefabName) {
console.log('[SkillShowEffect] showeffect 无效', skillId);
return null;
}
return { delaySec, prefabName };
}
private readonly _onAnimDone = (): void => {
if (!this._animReady) return;
this._fireApply();
schedulePresentation(HOLD_AFTER_ANIM_SEC, new ShowEffectSeqJob(this, this._seq, ShowEffectJobKind.Hold).fire);
};
/** 施加一次;失败路径也走这里,避免效果被吞 */
private _fireApply(): void {
if (this._applied) return;
this._applied = true;
const fn = this._onApply;
this._onApply = null;
fn?.();
}
/** 生成失败:暴露问题并仍施加效果 */
private _failSpawn(reason: string, extra?: unknown): void {
if (extra !== undefined) console.log('[SkillShowEffect]', reason, this._prefabName, extra);
else console.log('[SkillShowEffect]', reason, this._prefabName);
this._fireApply();
}
/** 在组合中心生成预兆 */
private _spawn(): void {
if (!this._prefabName) {
this._failSpawn('预制体名为空');
return;
}
const flyLayer = SkillPresentationEnv.flyLayer;
if (!flyLayer?.isValid) {
this._failSpawn('flyLayer 缺失');
return;
}
const world = this._resolveWorldPos(this._caster);
if (!world) {
this._failSpawn('无组合中心', this._caster);
return;
}
let node: Node | null = null;
try {
node = gg.res.getNode(GPath.HeroSkillPrefab(), this._prefabName, GBundle.BattleHero);
} catch (err) {
this._failSpawn('getNode 失败', err);
return;
}
if (!node?.isValid) {
this._failSpawn('预制体未加载');
return;
}
this._recycle();
flyLayer.active = true;
node.active = true;
node.layer = flyLayer.layer;
node.parent = flyLayer;
const flyUt = flyLayer.getComponent(UITransform);
if (flyUt) node.setPosition(flyUt.convertToNodeSpaceAR(world));
else node.setWorldPosition(world);
node.setSiblingIndex(flyLayer.children.length - 1);
this._node = node;
this._animReady = false;
const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton);
if (!sk?.isValid) {
this._recycle();
this._failSpawn('缺少 Skeleton');
return;
}
const typed = sk as sp.Skeleton & { defaultAnimation?: string };
const name = String(typed.defaultAnimation || sk.animation || '').trim();
if (!name) {
this._recycle();
this._failSpawn('无默认动画');
return;
}
const ok = BattleSpinePlayback.play(sk, name, { loop: false, onComplete: this._onAnimDone });
this._animReady = ok;
if (!ok) {
this._recycle();
this._failSpawn('默认动画起播失败', name);
return;
}
if (this._prefabName === APPLY_AFTER_ANIM) return;
schedulePresentation(PRES_HIT_AT_BASE, new ShowEffectSeqJob(this, this._seq, ShowEffectJobKind.Apply).fire);
}
/**
* 组合占格中心(两字中间,与技能名飘字同一英雄坐标;单格即该格)。
* @param caster 施法者
*/
private _resolveWorldPos(caster: EntityId): Vec3 | null {
return SkillPresentationEnv.getCompositionWorldCenter(caster);
}
/** 回池当前节点 */
private _recycle(): void {
const node = this._node;
this._node = null;
this._animReady = false;
if (!node?.isValid) return;
const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton);
BattleSpinePlayback.clearPendingComplete(sk);
BattleSpinePlayback.resetTracks(sk);
gg.res.putNode(node);
}
}