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.
256 lines
8.9 KiB
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);
|
||
|
|
}
|
||
|
|
}
|