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.

1444 lines
51 KiB

3 weeks ago
import { _decorator, Color, Component, isValid, Node, Sorting2D, sp, UITransform, v3, Vec3 } from 'cc';
import type { EntityId } from 'db://assets/mx/module/battle';
import { UnitMetaComp } from 'db://assets/mx/module/battle/ecs/Components';
import { GEvent } from 'db://assets/mx/module/event/GEvent';
import { GBundle, GPath } from '../../game/ConfigRes';
import { MonsterDataManager, MonsterType } from '../../manager/MonsterDataManager';
3 weeks ago
import { HitEffectResolver, DEFAULT_HIT_EFFECT } from '../../battle/view/HitEffectResolver';
import { playHitLeanBack } from '../../battle/view/HitLeanBack';
import { playSpineHitFlash } from '../../battle/view/SpineHitFlash';
import { isParatroopBirdModel, MONSTER_ACCELERATE_FX_ANIM, MONSTER_ACCELERATE_FX_PREFAB, PARATROOP_LAND_ANIM } from '../../battle/mechanism/MechanismConstants';
import { ChapterLevelBuilder } from '../../battle/config/ChapterLevelBuilder';
import { traceCombatDamage } from 'db://assets/mx/tools/CombatDamageTrace';
import { MonsterBlood } from './MonsterBlood';
import { MonsterState } from './MonsterState';
import { BattleSpinePlayback } from '../../battle/presentation/BattleSpinePlayback';
import { getBattleTimeScale } from '../../battle/skill/BattlePresentationClock';
import { getMonsterStatusText, STATUS_TEXT_HP_CAP } from '../../battle/view/MonsterStatusText';
const { ccclass } = _decorator;
export { MonsterState } from './MonsterState';
/** 怪物标签(激光等武器占用标记) */
export enum MonsterTag {
IS_BE_CONTROL = 'IS_BE_CONTROL',
IS_BE_ATTACKED_BY_LASER = 'IS_BE_ATTACKED_BY_LASER',
}
/** 血条 Sorting2D 层(与怪物皮肤同层) */
const BLOOD_BAR_SORT_LAYER = 3;
/** 血条底框 sortingOrder;进度子节点 +1,避免底框盖住填充 */
const BLOOD_BAR_SORT_ORDER = 8;
/** 还魂丹复活特效(battleHero/prefab/skill/小怪复活效果) */
const REVIVE_FX_PREFAB = '小怪复活效果';
/** 还魂丹复活 Spine 动画 */
const REVIVE_FX_ANIM = '小怪复活效果';
/** 冰冻覆盖预制体(battleGameMonster/prefab/冰冻) */
const FREEZE_FX_PREFAB = '冰冻';
/** 冰冻起效 Spine */
const FREEZE_FX_ANIM = '怪物冻结';
/** 解冻 Spine */
const FREEZE_THAW_ANIM = '怪物冻结2';
3 weeks ago
/** 怪物视图组件(配合 monsterTemplate.prefab,逻辑层由 MX BattleSession 驱动) */
@ccclass('Monster')
export class Monster extends Component {
private static readonly _hitFx = new HitEffectResolver();
downEff: Node = null;
upEff: Node = null;
skin: Node = null;
bloodPos: Node = null;
anim: sp.Skeleton = null;
boold: MonsterBlood = null;
/** 机制单位:不可移动、不寻路、不攻墙 */
mechanismImmobile = false;
/** MX 实体 id(由 Presenter 赋值) */
entityId: EntityId | null = null;
private _speed = 0;
private _bornPos: Vec3 = null;
private _atkTarget: Node = null;
private _lastAttackTime = 0;
/** 复活回出生点后等待按间隔放行 */
private _reviveHeld = false;
/** 由 MX MovementSystem 驱动位移时为 true */
useMxMovement = false;
state: MonsterState = MonsterState.MOVING_FORWARD;
Data: ITableMonster = null;
2 weeks ago
/** 相对 Monster.size 的表现倍率 */
3 weeks ago
sizeMul = 1;
hp = 0;
maxHp = 0;
IsDead = false;
private _tagStatus = new Map<string, boolean>();
/** 头顶眩晕特效节点(怪_晕眩) */
private _stunFx: Node | null = null;
private _stunFxSeq = 0;
/** 减速冰霜特效节点(怪_冰) */
private _slowFx: Node | null = null;
private _slowFxSeq = 0;
/** 冰冻覆盖特效节点(冰冻) */
private _freezeFx: Node | null = null;
private _freezeFxSeq = 0;
/** 是否正在播解冻动画 */
private _freezeFxThawing = false;
3 weeks ago
/** 点燃火焰特效节点(怪_火) */
private _burnFx: Node | null = null;
private _burnFxSeq = 0;
/** 流血特效节点(怪_流血) */
private _bleedFx: Node | null = null;
private _bleedFxSeq = 0;
/** 恶鬼啃食特效节点(怪_恶鬼) */
private _ghostBiteFx: Node | null = null;
private _ghostBiteFxSeq = 0;
/** 感电特效节点(怪_电) */
private _elecFx: Node | null = null;
private _elecFxSeq = 0;
/** 加速特效节点(怪_加速) */
private _accelerateFx: Node | null = null;
/** 加速特效朝向用:上一帧世界坐标 */
private _accelFxLastX = Number.NaN;
private _accelFxLastY = Number.NaN;
/** playSpine 传入的基础倍速(不含 BattleRuntime.TimeScale) */
private _animBaseTimeScale = 1;
checkTag(tag: MonsterTag | string): boolean {
return this._tagStatus.get(String(tag)) ?? false;
}
setTag(tag: MonsterTag | string, value: boolean): void {
this._tagStatus.set(String(tag), value);
}
protected onLoad(): void {
this.downEff = this.node.find('downEff');
this.upEff = this.node.find('upEff');
this.skin = this.node.find('skin');
this.bloodPos = this.node.find('bloodPos');
}
setData(data: ITableMonster, hp: number) {
this.Data = data;
this.sizeMul = 1;
this._speed = this.Data.speed;
this.IsDead = false;
this.hp = hp;
this.maxHp = hp;
this.useMxMovement = !!gg.game.CurentBattle?.useMxMovement;
this.init();
this._lastAttackTime = 0;
if (this.mechanismImmobile) {
this.state = MonsterState.STOP;
} else {
this.playWalk();
}
}
protected onEnable(): void {
GEvent.Ins.on(GEvent.BattleTimeScaleChanged, this._syncAnimBattleTimeScale, this);
this._syncAnimBattleTimeScale();
}
protected onDisable(): void {
GEvent.Ins.off(GEvent.BattleTimeScaleChanged, this._syncAnimBattleTimeScale, this);
}
protected update(_dt: number): void {
if (!this._accelerateFx?.isValid || !this._accelerateFx.active) return;
const battle = gg.game.CurentBattle;
if (!battle || battle.IsGameOver || battle.IsGamePause) return;
this._tickAccelerateFxAngle();
}
/** 战斗 TimeScale 变化时同步 Spine 播放倍速 */
private _syncAnimBattleTimeScale(): void {
if (!this.anim?.isValid || this._isDeathAnimPlaying()) return;
3 weeks ago
const battleScale = gg.game.CurentBattle?.TimeScale ?? 1;
const want = this._animBaseTimeScale * battleScale;
if (Math.abs(this.anim.timeScale - want) > 1e-4) {
this.anim.timeScale = want;
}
}
/** 当前是否在播死亡轨(旧「死亡」或五行「系小怪2」) */
private _isDeathAnimPlaying(): boolean {
if (this.IsDead) return true;
if (!this.anim?.isValid) return false;
const cur = String(this.anim.animation ?? '');
if (cur === '死亡') return true;
const death = MonsterDataManager.resolveDeathAnim(this.Data);
return !!death && cur === death;
}
3 weeks ago
/** 当前皮肤预制体名(icon / name) */
private _resolveSkinPrefabName(): string {
if (!this.Data) return '';
return ChapterLevelBuilder.resolveMonsterPrefabFromRow(this.Data)
?? String(this.Data.icon ?? this.Data.name ?? '').trim();
}
/** 进入等待复活:停止攻击计时与攻击动画 */
onEnterWaitRevive(): void {
this._lastAttackTime = 0;
this.state = MonsterState.STOP;
if (this.Data?.type === MonsterType.TreasureBox) return;
this.playWalk();
3 weeks ago
}
/** 复活后回到出生点并隐藏,等按出怪间隔跳出再放行 */
holdAtBornAfterRevive(atWorld?: Vec3): void {
const pos = atWorld ?? this._bornPos;
if (!pos) return;
this.node.worldPosition = pos.clone();
this.node.opacity = 0;
this.state = MonsterState.STOP;
this._reviveHeld = true;
this._lastAttackTime = 0;
if (this.Data?.type !== MonsterType.TreasureBox) {
this.playWalk();
3 weeks ago
}
}
/** 复活放行:恢复寻路与行走表现 */
releaseFromReviveQueue(): void {
if (!this._reviveHeld) return;
this._reviveHeld = false;
this.state = MonsterState.PATHFINDING;
this._lastAttackTime = 0;
this.resolveAttackTarget();
this.playWalk();
}
/** 复活后回到出生点(立即寻路,仅供非 MX 旧路径) */
teleportToBornPos(): void {
if (!this._bornPos) return;
this.node.worldPosition = this._bornPos.clone();
this.state = MonsterState.PATHFINDING;
this._lastAttackTime = 0;
this.resolveAttackTarget();
this.playWalk();
}
/** 绑定攻击目标(须在 MapGridsReady 之后) */
bindPathTarget(): void {
if (this.mechanismImmobile) return;
const map = gg.game.CurentBattle?.Map;
if (map?.gridsInitialized) {
this.resolveAttackTarget();
return;
}
const onReady = () => {
GEvent.Ins.off(GEvent.MapGridsReady, onReady, this);
if (!this.node?.isValid || this.IsDead) return;
this.resolveAttackTarget();
};
GEvent.Ins.on(GEvent.MapGridsReady, onReady, this);
}
/** 空投等机制怪:落地后更新复活/芭蕉扇回收点(非出怪口) */
updateLandingBornPos(pos: Vec3): void {
this._bornPos = pos.clone();
}
/** 空投落地出生点(世界坐标);未设置时返回 null */
getLandingBornPos(): Vec3 | null {
return this._bornPos?.clone() ?? null;
}
init() {
this.state = MonsterState.PATHFINDING;
this._bornPos = this.node.worldPosition.clone();
const animNode = this.skin?.find('anim');
this.anim = animNode?.getComponent(sp.Skeleton) || this.skin?.getComponentInChildren(sp.Skeleton);
this.syncBloodPosToSkin();
this.ensureBloodBar();
this.scheduleOnce(() => this.syncBloodPosToSkin(), 0);
}
/** 创建/刷新血条(皮肤异步加载后需再次调用以校正 bloodPos) */
ensureBloodBar(): void {
this.syncBloodPosToSkin();
if (!this.bloodPos?.isValid || !this.Data || this.Data.type === MonsterType.TreasureBox) return;
3 days ago
// 死亡动画期间保持隐藏,禁止受击同步再把血条打开
if (this.IsDead) {
this._hideBloodBar();
return;
}
3 weeks ago
const prefabName = this._bloodPrefabName(this.Data.type);
const poolKey = this._bloodPoolKey(prefabName);
if (this.boold?.isValid) {
const attachedKey = this.boold.node['resKey'] as string | undefined;
if (attachedKey !== poolKey) {
gg.res.putNode(this.boold.node);
this.boold = null;
}
}
const shouldShow = this.Data.type !== MonsterType.SmallMonster
|| this.hp < this.maxHp;
if (!this.boold) {
const booldNode = gg.res.getNode('prefab/blood/', prefabName, GBundle.BattleGameMonster);
if (!booldNode?.isValid) {
console.log('[Monster] blood prefab missing', prefabName);
return;
}
booldNode.active = false;
booldNode.setParent(this.bloodPos);
this._applyBloodBarScale(booldNode);
this.boold = booldNode.getComponent(MonsterBlood);
} else {
this._applyBloodBarScale(this.boold.node);
}
if (this.boold?.node?.isValid) {
this.boold.node.active = shouldShow;
this.boold.Data = this.Data;
this.boold.updateBloodBar(this.hp, this.maxHp);
}
}
3 days ago
/** 隐藏头顶血条(死亡动画起播时调用) */
private _hideBloodBar(): void {
if (this.boold?.node?.isValid) {
this.boold.node.active = false;
}
}
3 weeks ago
private _bloodPoolKey(prefabName: string): string {
return GBundle.BattleGameMonster + 'prefab/blood/' + prefabName;
}
/** 非 Boss 保持 1;Boss 缩小,并抬血条绘制顺序 */
private _applyBloodBarScale(booldNode: Node): void {
if (this.Data?.type === MonsterType.Boss) {
booldNode.setScale(0.7, 0.7, 1);
} else {
booldNode.setScale(1, 1, 1);
}
this._liftBloodBarSorting(booldNode);
}
/** 血条与皮肤同层;进度 order 高于底框,避免编辑器改 layer 后填充被盖住 */
private _liftBloodBarSorting(root: Node): void {
const sorts = root.getComponentsInChildren(Sorting2D);
for (let i = 0; i < sorts.length; i++) {
const s = sorts[i];
s.sortingLayer = BLOOD_BAR_SORT_LAYER;
s.sortingOrder = s.node === root ? BLOOD_BAR_SORT_ORDER : BLOOD_BAR_SORT_ORDER + 1;
}
}
/**
* 用 Sorting2D 按脚底 Y 叠层,避免 setSiblingIndex 打散合批。
* @returns 皮肤 sortingOrder
*/
syncYSortOrder(): number {
let order = Math.round(-this.node.worldPosition.y / 16) * 4;
if (order < -32000) order = -32000;
else if (order > 32000) order = 32000;
if (this.skin?.isValid) {
const sorts = this.skin.getComponentsInChildren(Sorting2D);
for (let i = 0; i < sorts.length; i++) {
sorts[i].sortingOrder = order;
}
}
const blood = this.boold?.node;
if (blood?.isValid) {
const sorts = blood.getComponentsInChildren(Sorting2D);
for (let i = 0; i < sorts.length; i++) {
const s = sorts[i];
s.sortingLayer = BLOOD_BAR_SORT_LAYER;
s.sortingOrder = s.node === blood ? order + 1 : order + 2;
}
}
return order;
}
/** 行军小怪:须在 setSkin 之后调用,进入 PRIVATE_CACHE */
applyMarchSpineCache(): void {
const sk = this.anim;
if (!sk?.isValid) return;
const t = this.Data?.type;
if (t !== MonsterType.SmallMonster && t !== MonsterType.SummonMonster) return;
if (typeof sk.setAnimationCacheMode !== 'function') return;
if (typeof sk.isAnimationCached === 'function' && sk.isAnimationCached()) {
sk.invalidAnimationCache?.();
} else {
sk.setAnimationCacheMode(sp.SpineAnimationCacheMode.PRIVATE_CACHE);
}
const batchSk = sk as sp.Skeleton & { enableBatch?: boolean };
if (batchSk.enableBatch !== undefined) {
batchSk.enableBatch = true;
}
}
private _bloodPrefabName(type: number): string {
if (type === MonsterType.Boss) return 'BOSS血条底框';
if (type === MonsterType.SmallMonster || type === MonsterType.SummonMonster) return '小怪血条底';
return 'MidBOSS血条框';
}
/** 按碰撞盒或 Spine 包围盒顶对齐 bloodPos(皮肤加载后需再调一次) */
syncBloodPosToSkin(): void {
if (!this.bloodPos?.isValid || !this.skin?.isValid) return;
const scaleY = Math.max(0.01, Number(this.Data?.size) || 1);
const useSpine = this._usesSpineBloodAnchor();
const spineTopLocalY = useSpine ? this._resolveSpineTopLocalY() : null;
const paddingWorld = this._bloodTopPaddingWorld(useSpine && spineTopLocalY != null);
let y: number;
if (spineTopLocalY != null) {
y = spineTopLocalY + paddingWorld / scaleY;
} else {
const ut = this.node.getComponent(UITransform);
if (!ut) return;
y = ut.contentSize.height * (1 - ut.anchorPoint.y) + paddingWorld / scaleY;
}
const skinPrefab = this._resolveSkinPrefabName();
if (skinPrefab === '空投怪物箱子' || skinPrefab === '伞兵'
|| skinPrefab === '女巫丧尸(加血)') {
y -= 120;
}
this.bloodPos.setPosition(0, y, 0);
}
/** 召唤怪与 Boss/中期 Boss 用 Spine 顶对齐血条(碰撞盒常高于实际网格) */
private _usesSpineBloodAnchor(): boolean {
const t = this.Data?.type;
return t === MonsterType.SummonMonster
|| t === MonsterType.Boss
|| t === MonsterType.MidBoss
|| t === MonsterType.SilverBoss
|| t === MonsterType.GoldenBoss
|| t === MonsterType.OrangeBoss;
}
/**
* Spine 顶边转到怪物根本地 Y。沿父链用本地坐标,避免出场 scale=0 时世界变换把顶量到脚底。
*/
private _resolveSpineTopLocalY(): number | null {
const anim = this.anim;
if (!anim?.isValid) return null;
const topInAnim = this._spineTopInAnimLocal(anim);
if (topInAnim == null) return null;
return this._toRootLocalY(anim.node, topInAnim);
}
/**
* anim 节点本地顶边 Y(相对节点原点)。UITransform 常小于网格(如哮天犬盒 89、骨骼 126),取较高者。
* @param anim Spine
*/
private _spineTopInAnimLocal(anim: sp.Skeleton): number | null {
const animUt = anim.node.getComponent(UITransform);
if (!animUt) return null;
let top = 0;
const height = animUt.contentSize.height;
if (height > 1) {
top = height * (1 - animUt.anchorPoint.y);
}
const aabbTop = this._skeletonAabbTopLocal(anim);
if (aabbTop != null && aabbTop > top) top = aabbTop;
if (!(top > 0) && !(height > 1)) return null;
return top;
}
/**
* Spine 绑定姿态 AABB 顶(anim 本地 Y)。
* @param anim Spine
*/
private _skeletonAabbTopLocal(anim: sp.Skeleton): number | null {
const data = anim.skeletonData?.getRuntimeData(true);
if (!data || !(data.height > 0)) return null;
return data.y + data.height;
}
/**
* 子节点本地 Y → 怪物根本地 Y(不含根缩放)。
* @param fromNode 子节点
* @param localY 该节点本地 Y
*/
private _toRootLocalY(fromNode: Node, localY: number): number | null {
let y = localY;
let n: Node | null = fromNode;
while (n && n !== this.node) {
y = n.position.y + y * n.scale.y;
n = n.parent;
}
if (n !== this.node) {
console.log('[Monster] 血条 Spine 不在根下', fromNode.name);
return null;
}
return y;
}
/** 血条相对锚点顶的额外间距(世界像素;负值=压进碰撞盒内,拉近与怪的距离) */
private _bloodTopPaddingWorld(onSpineTop: boolean): number {
if (onSpineTop) return 2;
switch (this.Data?.type) {
// 小怪字未占满碰撞盒/格子,贴盒顶会悬空 → 压到字顶附近
case MonsterType.SmallMonster:
return -36;
case MonsterType.Boss:
return 18;
case MonsterType.MidBoss:
case MonsterType.SilverBoss:
case MonsterType.GoldenBoss:
case MonsterType.OrangeBoss:
return 8;
case MonsterType.SummonMonster:
return 8;
default:
return 2;
}
}
/** 解析攻击城墙目标节点(按出怪口阵营匹配对应攻击点) */
resolveAttackTarget(): void {
if (this.mechanismImmobile) {
this._atkTarget = null;
return;
}
const map = gg.game.CurentBattle?.Map;
if (!map || !isValid(map)) return;
let spawnPort: number | undefined;
const battle = gg.game.CurentBattle;
if (this.entityId != null && battle?.session) {
const meta = battle.session.world.getComponent(this.entityId, UnitMetaComp);
if (meta?.spawnPoint && meta.spawnPoint >= 1) {
spawnPort = meta.spawnPoint;
}
}
this._atkTarget = map.getPathShortestTarget(this.node.worldPosition, spawnPort);
}
/** 障碍变更后重新解析攻击目标 */
getAtkTarget(): void {
this.resolveAttackTarget();
}
/** MX 实体剩余路径距离(逻辑像素) */
getRemainPathDistance(): number {
const battle = gg.game.CurentBattle;
if (this.entityId == null || !battle?.session) return 0;
return battle.session.movement.getRemainingPathLength(this.entityId);
}
getMoveSpeed(): number {
return this._speed || this.Data?.speed || 0;
}
getIsDead() {
return this.IsDead;
}
/** 同步 MX 层 HP 并刷新血条(由 CocosBattleMonsterPresenter 在 HitAfter/DamageDealt 时调用) */
syncMxHp(hp: number, maxHp: number): void {
const prevHp = this.hp;
this.hp = Math.max(0, hp);
this.maxHp = Math.max(1, maxHp);
3 days ago
if (this.IsDead) {
this._hideBloodBar();
return;
}
3 weeks ago
if (!this.boold?.isValid) {
this.ensureBloodBar();
}
if (this.boold?.node?.isValid) {
this.boold.Data = this.Data;
this.boold.updateBloodBar(this.hp, this.maxHp);
} else {
traceCombatDamage('syncMxHpNoBlood', {
entityId: this.entityId,
hp,
maxHp,
prevHp,
});
}
}
/**
* 女巫机制回血:播放加血特效并刷新血条(血量须已由 ECS/syncMxHp 同步)
* @param hp 加血值;isCoefficient 为 true 时按 maxHp 系数计算
*/
addHP(hp: number, isCoefficient: boolean): void {
if (isCoefficient) {
hp = Math.floor(this.maxHp * hp);
}
this.hp = Math.min(this.maxHp, this.hp + hp);
if (this.boold) {
this.boold.updateBloodBar(this.hp, this.maxHp);
}
this._playAddHpEffect('加血');
}
/** 加血特效(一份预制体,内含上下层) */
private _addHpEff: Node | null = null;
/** 血上限特效节点(血量上限) */
private _hpCapEff: Node | null = null;
/** 还魂丹复活特效 */
private _reviveFx: Node | null = null;
/**
* 女巫机制回血特效(血量须已由 syncMxHp 同步)。
* @param statusText 状态飘字;默认「加血」
*/
playHealEffect(statusText = '加血'): void {
if (this._hpCapEff?.isValid) return;
this._playAddHpEffect(statusText);
}
/**
* 血上限提升:播 `血量上限` 并飘「血上限」,不播 `怪物加血`。
*/
playHpCapEffect(): void {
if (!this.upEff?.isValid) return;
this.clearAddHpEff();
this.clearHpCapEff();
let node: Node | null = null;
try {
node = gg.res.getNode('prefab/', '血量上限', GBundle.BattleGameMonster);
} catch {
console.warn('[Monster] 血量上限取节点失败');
return;
}
if (!node?.isValid) {
console.warn('[Monster] 血量上限预制体缺失');
return;
}
node.setParent(this.upEff);
node.setPosition(Vec3.ZERO);
node.active = true;
this._hpCapEff = node;
for (const sk of node.getComponentsInChildren(sp.Skeleton)) {
sk.setAnimation(0, '加血量上限', false);
}
this.scheduleOnce(this._onHpCapEffTimeout, 2);
if (this.entityId != null) {
getMonsterStatusText().play(this.entityId, STATUS_TEXT_HP_CAP);
}
}
/** 回收血上限特效 */
clearHpCapEff(): void {
this.unschedule(this._onHpCapEffTimeout);
if (this._hpCapEff?.isValid) gg.res.putNode(this._hpCapEff);
this._hpCapEff = null;
this._sweepEffByName(this.upEff, '血量上限');
}
private _onHpCapEffTimeout = (): void => {
this.clearHpCapEff();
};
/**
* 还魂丹原地复活:在死亡点播 `小怪复活效果`(不跟随怪物)。
* @param fxParent 特效父节点(怪物层)
* @returns 特效动画时长(秒);失败为 0
*/
playReviveEffect(fxParent?: Node): number {
return this._playReviveFx(fxParent ?? null);
}
/**
* 出池 `小怪复活效果`,钉在死亡世界坐标,播完回收。
* @param fxParent 特效父节点
* @returns 动画时长(秒)
*/
private _playReviveFx(fxParent: Node | null): number {
this.clearReviveFx();
if (!fxParent?.isValid) {
console.warn('[Monster] 小怪复活效果缺少父节点');
return 0;
}
if (!this.node?.isValid) {
console.warn('[Monster] 小怪复活效果缺少怪物节点');
return 0;
}
let node: Node | null = null;
try {
node = gg.res.getNode(GPath.HeroSkillPrefab(), REVIVE_FX_PREFAB, GBundle.BattleHero);
} catch {
console.warn('[Monster] 小怪复活效果取节点失败');
return 0;
}
if (!node?.isValid) {
console.warn('[Monster] 小怪复活效果预制体缺失');
return 0;
}
node.setParent(fxParent);
const wp = this.node.worldPosition;
node.setWorldPosition(wp.x, wp.y, wp.z);
node.active = true;
this._reviveFx = node;
let wait = 0;
const sks = node.getComponentsInChildren(sp.Skeleton);
for (let i = 0; i < sks.length; i++) {
const sk = sks[i];
BattleSpinePlayback.play(sk, REVIVE_FX_ANIM, { loop: false });
const dur = BattleSpinePlayback.getAnimDuration(sk, REVIVE_FX_ANIM);
if (dur != null && dur > wait) wait = dur;
}
if (wait <= 0) {
console.warn('[Monster] 小怪复活效果动画时长无效,按 1.5s 回收');
wait = 1.5;
}
const ts = Math.max(0.01, getBattleTimeScale());
this.scheduleOnce(this._onReviveFxTimeout, wait / ts);
return wait;
}
/** 回收还魂复活特效 */
clearReviveFx(): void {
this.unschedule(this._onReviveFxTimeout);
if (this._reviveFx?.isValid) gg.res.putNode(this._reviveFx);
this._reviveFx = null;
this._sweepEffByName(this.downEff, REVIVE_FX_PREFAB);
}
private _onReviveFxTimeout = (): void => {
this.clearReviveFx();
};
/**
* 播加血 Spine 与状态飘字。预制体已含上下层,只出一份,播完回收。
* @param statusText 头顶飘字文案
*/
private _playAddHpEffect(statusText: string): void {
if (!this.upEff?.isValid) return;
this.clearAddHpEff();
let node: Node | null = null;
try {
node = gg.res.getNode('prefab/', '怪物加血', GBundle.BattleGameMonster);
} catch {
console.warn('[Monster] 怪物加血取节点失败');
return;
}
if (!node?.isValid) {
console.warn('[Monster] 怪物加血预制体缺失');
return;
}
node.setParent(this.upEff);
node.setPosition(Vec3.ZERO);
node.active = true;
this._addHpEff = node;
let wait = 0;
const sks = node.getComponentsInChildren(sp.Skeleton);
for (let i = 0; i < sks.length; i++) {
const sk = sks[i];
const anim = String((sk as sp.Skeleton & { defaultAnimation?: string }).defaultAnimation || '').trim();
if (!anim) continue;
sk.setAnimation(0, anim, false);
const dur = BattleSpinePlayback.getAnimDuration(sk, anim);
if (dur != null && dur > wait) wait = dur;
}
this.scheduleOnce(this._onAddHpEffTimeout, wait > 0 ? wait : 2);
if (this.entityId != null) {
getMonsterStatusText().play(this.entityId, statusText);
}
}
/** 回收加血特效,并清掉 upEff 上残留的「怪物加血」 */
clearAddHpEff(): void {
this.unschedule(this._onAddHpEffTimeout);
if (this._addHpEff?.isValid) gg.res.putNode(this._addHpEff);
this._addHpEff = null;
this._sweepEffByName(this.upEff, '怪物加血');
}
private _onAddHpEffTimeout = (): void => {
this.clearAddHpEff();
};
/**
* 回收挂点上指定名的残留特效。
* @param parent 挂点
* @param name 子节点名
*/
private _sweepEffByName(parent: Node | null, name: string): void {
if (!parent?.isValid) return;
const kids = parent.children;
for (let i = kids.length - 1; i >= 0; i--) {
const child = kids[i];
if (!child?.isValid || child.name !== name) continue;
gg.res.putNode(child);
}
}
/** 皮肤基准 tint(受击闪白后还原,避免连击锁色) */
private _baseAnimColor = new Color(255, 255, 255, 255);
/** 记录皮肤基准色(挂皮肤 / 改色后调用) */
setBaseAnimColor(c: Readonly<Color>): void {
this._baseAnimColor.set(c);
if (this.anim?.isValid) this.anim.color = this._baseAnimColor;
}
/** 播放受击反馈:闪白 + 按来击方向斜切后仰;连击不得把闪白中间色当成还原色 */
playHit(_damageType?: string, hitDirX = 0, hitDirY = 0): void {
if (!this.anim || !this.node?.isValid) return;
if (this.IsDead) return;
playSpineHitFlash(this.anim, this._baseAnimColor);
const leanTarget = this.skin?.isValid ? this.skin : this.node;
playHitLeanBack(leanTarget, hitDirX, hitDirY);
}
2 days ago
/** 对象中心播放受击 Spine(`动画|预制|皮肤`,默认五行受击皮肤) */
3 weeks ago
showHitEffect(hitEffectCfg?: string, faceSourceX = 0, faceSourceY = 0): void {
if (!this.upEff?.isValid || this.IsDead || !this.anim?.isValid) return;
const cfg = hitEffectCfg?.trim() || DEFAULT_HIT_EFFECT;
Monster._hitFx.play(cfg, this.upEff, this.getPosByAtkPosIndex(2), faceSourceX, faceSourceY);
}
/**
* 显示眩晕特效(prefab/怪_晕眩),挂在 upEff,按时长自动回收;刷新则重置计时。
*/
showStunFx(durationSec: number): void {
if (!this.node?.isValid || this.IsDead) return;
const parent = this.upEff?.isValid ? this.upEff : this.node;
++this._stunFxSeq;
if (!this._stunFx?.isValid) {
let node: Node | null = null;
try {
node = gg.res.getNode('prefab/', '怪_晕眩', GBundle.BattleGameMonster);
} catch {
console.log('[Monster] stun fx prefab getNode failed');
return;
}
if (!node?.isValid) {
console.log('[Monster] stun fx prefab missing: 怪_晕眩');
return;
}
node.parent = parent;
node.setPosition(0, 0, 0);
node.active = true;
this._stunFx = node;
const sk = node.getComponentInChildren(sp.Skeleton);
if (sk) {
sk.setAnimationCacheMode(sp.SpineAnimationCacheMode.SHARED_CACHE);
sk.setAnimation(0, 'animation', true);
}
}
const dur = Math.max(0.05, durationSec);
this.unschedule(this._onStunFxTimeout);
this.scheduleOnce(this._onStunFxTimeout, dur);
}
clearStunFx(): void {
this._stunFxSeq++;
this.unschedule(this._onStunFxTimeout);
if (this._stunFx?.isValid) {
gg.res.putNode(this._stunFx);
}
this._stunFx = null;
}
private _onStunFxTimeout = (): void => {
this.clearStunFx();
};
/**
* 显示减速特效(prefab/怪_冰),挂在 upEff,按时长自动回收;刷新则重置计时。
*/
showSlowFx(durationSec: number): void {
if (!this.node?.isValid || this.IsDead) return;
const parent = this.upEff?.isValid ? this.upEff : this.node;
++this._slowFxSeq;
if (!this._slowFx?.isValid) {
let node: Node | null = null;
try {
node = gg.res.getNode('prefab/', '怪_冰', GBundle.BattleGameMonster);
} catch {
return;
}
if (!node?.isValid) return;
node.parent = parent;
node.setPosition(0, 0, 0);
node.active = true;
this._slowFx = node;
const sk = node.getComponentInChildren(sp.Skeleton);
if (sk) {
sk.setAnimationCacheMode(sp.SpineAnimationCacheMode.SHARED_CACHE);
sk.setAnimation(0, 'animation', true);
}
}
const dur = Math.max(0.05, durationSec);
this.unschedule(this._onSlowFxTimeout);
this.scheduleOnce(this._onSlowFxTimeout, dur);
}
clearSlowFx(): void {
this._slowFxSeq++;
this.unschedule(this._onSlowFxTimeout);
if (this._slowFx?.isValid) {
gg.res.putNode(this._slowFx);
}
this._slowFx = null;
}
private _onSlowFxTimeout = (): void => {
this.clearSlowFx();
};
/**
1 week ago
* 显示冰冻覆盖(prefab/冰冻):定格「怪物冻结」首帧冰块;刷新时长不重播,避免闪。
* 解冻由 clearFreezeFx 播「怪物冻结2」。
* @param _durationSec 持续秒(表现由 BuffRemoved 驱动结束,参数仅兼容调用方)
*/
1 week ago
showFreezeFx(_durationSec: number): void {
if (!this.node?.isValid || this.IsDead) return;
const parent = this.skin?.isValid ? this.skin : this.node;
1 week ago
if (this._freezeFx?.isValid && !this._freezeFxThawing) {
if (this._freezeFx.parent !== parent) {
this._freezeFx.parent = parent;
this._freezeFx.setPosition(0, 0, 0);
}
this._freezeFx.active = true;
return;
}
++this._freezeFxSeq;
this._freezeFxThawing = false;
if (!this._freezeFx?.isValid) {
let node: Node | null = null;
try {
node = gg.res.getNode('prefab/', FREEZE_FX_PREFAB, GBundle.BattleGameMonster);
} catch {
console.log('[Monster] freeze fx prefab getNode failed');
return;
}
if (!node?.isValid) {
console.log('[Monster] freeze fx prefab missing:', FREEZE_FX_PREFAB);
return;
}
node.parent = parent;
node.setPosition(0, 0, 0);
node.active = true;
this._freezeFx = node;
1 week ago
} else {
this._freezeFx.parent = parent;
this._freezeFx.setPosition(0, 0, 0);
this._freezeFx.active = true;
}
const sk = this._freezeFx.getComponentInChildren(sp.Skeleton);
1 week ago
if (sk?.isValid) this._holdFreezeIcePose(sk);
}
/**
1 week ago
* 解冻:播「怪物冻结2」破冰后回池;死亡则立即回收。
*/
clearFreezeFx(): void {
1 week ago
if (this.IsDead) {
this.clearFreezeFxImmediate();
return;
}
if (this._freezeFxThawing) return;
const node = this._freezeFx;
if (!node?.isValid) {
this._freezeFx = null;
this._freezeFxThawing = false;
return;
}
const sk = node.getComponentInChildren(sp.Skeleton);
if (!sk?.isValid) {
this._dropFreezeFx();
return;
}
this._freezeFxThawing = true;
1 week ago
++this._freezeFxSeq;
// 附件切换动画须 REALTIME;破冰前恢复倍速
sk.setAnimationCacheMode(sp.SpineAnimationCacheMode.REALTIME);
sk.timeScale = BattleSpinePlayback.resolveCombinedTimeScale(1);
const ok = BattleSpinePlayback.play(sk, FREEZE_THAW_ANIM, {
loop: false,
onComplete: this._onFreezeThawComplete,
});
1 week ago
if (!ok) this._dropFreezeFx();
}
/** 立即回池冰冻特效(死亡/清池,不解冻动画) */
clearFreezeFxImmediate(): void {
this._freezeFxSeq++;
this._dropFreezeFx();
}
private readonly _onFreezeThawComplete = (): void => {
if (!this._freezeFxThawing) return;
this._dropFreezeFx();
};
1 week ago
/**
* 「怪物冻结」末帧会卸附件;定格首帧保持冰块,禁止 loop。
* @param sk 冰冻 Spine
*/
private _holdFreezeIcePose(sk: sp.Skeleton): void {
BattleSpinePlayback.clearPendingComplete(sk);
sk.setAnimationCacheMode(sp.SpineAnimationCacheMode.REALTIME);
sk.setAnimation(0, FREEZE_FX_ANIM, false);
sk.timeScale = 0;
}
/** 回池并清空冰冻节点引用 */
private _dropFreezeFx(): void {
this._freezeFxThawing = false;
const node = this._freezeFx;
this._freezeFx = null;
if (!node?.isValid) return;
const sk = node.getComponentInChildren(sp.Skeleton);
BattleSpinePlayback.clearPendingComplete(sk);
1 week ago
if (sk?.isValid) {
sk.timeScale = 1;
BattleSpinePlayback.resetTracks(sk);
}
gg.res.putNode(node);
}
3 weeks ago
/** 显示加速特效(prefab/怪_加速),挂在 upEff,由机制结束时回收 */
showAccelerateFx(): void {
if (!this.node?.isValid || this.IsDead) return;
const parent = this.upEff?.isValid ? this.upEff : this.node;
this._accelFxLastX = Number.NaN;
this._accelFxLastY = Number.NaN;
if (this._accelerateFx?.isValid) {
this._accelerateFx.active = true;
return;
}
let node: Node | null = null;
try {
node = gg.res.getNode('prefab/', MONSTER_ACCELERATE_FX_PREFAB, GBundle.BattleGameMonster);
} catch {
return;
}
if (!node?.isValid) return;
node.parent = parent;
node.setPosition(0, 0, 0);
node.active = true;
this._accelerateFx = node;
const sk = node.getComponentInChildren(sp.Skeleton);
if (sk) {
sk.setAnimationCacheMode(sp.SpineAnimationCacheMode.SHARED_CACHE);
sk.setAnimation(0, MONSTER_ACCELERATE_FX_ANIM, true);
}
}
clearAccelerateFx(): void {
if (this._accelerateFx?.isValid) {
gg.res.putNode(this._accelerateFx);
}
this._accelerateFx = null;
this._accelFxLastX = Number.NaN;
this._accelFxLastY = Number.NaN;
}
/** 怪_加速 按本帧位移转向行进方向(预制体默认朝 +X) */
private _tickAccelerateFxAngle(): void {
const fx = this._accelerateFx;
if (!fx?.isValid || !fx.active || !this.node?.isValid) return;
const wp = this.node.worldPosition;
const x = wp.x;
const y = wp.y;
if (!Number.isFinite(this._accelFxLastX) || !Number.isFinite(this._accelFxLastY)) {
this._accelFxLastX = x;
this._accelFxLastY = y;
return;
}
const dx = x - this._accelFxLastX;
const dy = y - this._accelFxLastY;
this._accelFxLastX = x;
this._accelFxLastY = y;
if (dx * dx + dy * dy < 1e-4) return;
fx.angle = Math.atan2(dy, dx) * 180 / Math.PI;
}
/**
* 显示点燃特效(prefab/怪_火),挂在 downEff(脚下),按时长自动回收;刷新则重置计时。
*/
showBurnFx(durationSec: number): void {
if (!this.node?.isValid || this.IsDead) return;
const parent = this.downEff?.isValid ? this.downEff : this.node;
++this._burnFxSeq;
if (!this._burnFx?.isValid) {
const node = gg.res.getNode('prefab/', '怪_火', GBundle.BattleGameMonster);
if (!node?.isValid) return;
node.parent = parent;
node.setPosition(0, 0, 0);
node.active = true;
this._burnFx = node;
const sk = node.getComponentInChildren(sp.Skeleton);
if (sk) {
sk.setAnimationCacheMode(sp.SpineAnimationCacheMode.SHARED_CACHE);
sk.setAnimation(0, 'animation', true);
}
} else if (this._burnFx.parent !== parent) {
this._burnFx.parent = parent;
this._burnFx.setPosition(0, 0, 0);
}
const dur = Math.max(0.05, durationSec);
this.unschedule(this._onBurnFxTimeout);
this.scheduleOnce(this._onBurnFxTimeout, dur);
}
clearBurnFx(): void {
this._burnFxSeq++;
this.unschedule(this._onBurnFxTimeout);
if (this._burnFx?.isValid) {
gg.res.putNode(this._burnFx);
}
this._burnFx = null;
}
private _onBurnFxTimeout = (): void => {
this.clearBurnFx();
};
/**
* 阎王技能命中:已有流血则保持,否则短时播 `怪_流血`。
*/
playBleedHitFx(): void {
if (this._bleedFx?.isValid) return;
this.showBleedFx(2);
}
/**
* 显示流血特效(prefab/怪_流血),挂在怪物根节点,按时长自动回收;刷新则重置计时。
*/
showBleedFx(durationSec: number): void {
if (!this.node?.isValid || this.IsDead) return;
const parent = this.node;
++this._bleedFxSeq;
if (!this._bleedFx?.isValid) {
let node: Node | null = null;
try {
node = gg.res.getNode('prefab/', '怪_流血', GBundle.BattleGameMonster);
} catch {
return;
}
if (!node?.isValid) return;
node.parent = parent;
node.setPosition(0, 0, 0);
node.active = true;
this._bleedFx = node;
for (const sk of node.getComponentsInChildren(sp.Skeleton)) {
sk.setAnimationCacheMode(sp.SpineAnimationCacheMode.SHARED_CACHE);
const anim = (sk as sp.Skeleton & { defaultAnimation?: string }).defaultAnimation;
if (anim) sk.setAnimation(0, anim, true);
}
} else if (this._bleedFx.parent !== parent) {
this._bleedFx.parent = parent;
this._bleedFx.setPosition(0, 0, 0);
}
const dur = Math.max(0.05, durationSec);
this.unschedule(this._onBleedFxTimeout);
this.scheduleOnce(this._onBleedFxTimeout, dur);
}
clearBleedFx(): void {
this._bleedFxSeq++;
this.unschedule(this._onBleedFxTimeout);
if (this._bleedFx?.isValid) {
gg.res.putNode(this._bleedFx);
}
this._bleedFx = null;
}
private _onBleedFxTimeout = (): void => {
this.clearBleedFx();
};
/**
* 显示恶鬼啃食特效(prefab/怪_恶鬼),挂在 upEff,按时长自动回收;刷新则重置计时。
*/
showGhostBiteFx(durationSec: number): void {
if (!this.node?.isValid || this.IsDead) return;
const parent = this.upEff?.isValid ? this.upEff : this.node;
++this._ghostBiteFxSeq;
if (!this._ghostBiteFx?.isValid) {
let node: Node | null = null;
try {
node = gg.res.getNode('prefab/', '怪_恶鬼', GBundle.BattleGameMonster);
} catch {
return;
}
if (!node?.isValid) return;
node.parent = parent;
node.setPosition(0, 0, 0);
node.active = true;
this._ghostBiteFx = node;
const sk = node.getComponentInChildren(sp.Skeleton);
if (sk) {
sk.setAnimationCacheMode(sp.SpineAnimationCacheMode.SHARED_CACHE);
sk.setAnimation(0, 'animation', true);
}
} else if (this._ghostBiteFx.parent !== parent) {
this._ghostBiteFx.parent = parent;
this._ghostBiteFx.setPosition(0, 0, 0);
}
const dur = Math.max(0.05, durationSec);
this.unschedule(this._onGhostBiteFxTimeout);
this.scheduleOnce(this._onGhostBiteFxTimeout, dur);
}
clearGhostBiteFx(): void {
this._ghostBiteFxSeq++;
this.unschedule(this._onGhostBiteFxTimeout);
if (this._ghostBiteFx?.isValid) {
gg.res.putNode(this._ghostBiteFx);
}
this._ghostBiteFx = null;
}
private _onGhostBiteFxTimeout = (): void => {
this.clearGhostBiteFx();
};
/**
* 显示感电特效(prefab/怪_电),挂在 upEff,按时长自动回收;刷新则重置计时。
*/
showElecFx(durationSec: number): void {
if (!this.node?.isValid || this.IsDead) return;
const parent = this.upEff?.isValid ? this.upEff : this.node;
++this._elecFxSeq;
if (!this._elecFx?.isValid) {
const node = gg.res.getNode('prefab/', '怪_电', GBundle.BattleGameMonster);
if (!node?.isValid) {
console.error('[Monster] 感电特效预制体未加载', '怪_电');
return;
}
node.parent = parent;
node.setPosition(0, 0, 0);
node.active = true;
this._elecFx = node;
const sk = node.getComponentInChildren(sp.Skeleton);
if (sk) {
sk.setAnimationCacheMode(sp.SpineAnimationCacheMode.SHARED_CACHE);
sk.setAnimation(0, 'animation', true);
}
} else if (this._elecFx.parent !== parent) {
this._elecFx.parent = parent;
this._elecFx.setPosition(0, 0, 0);
}
const dur = Math.max(0.05, durationSec);
this.unschedule(this._onElecFxTimeout);
this.scheduleOnce(this._onElecFxTimeout, dur);
}
clearElecFx(): void {
this._elecFxSeq++;
this.unschedule(this._onElecFxTimeout);
if (this._elecFx?.isValid) {
gg.res.putNode(this._elecFx);
}
this._elecFx = null;
}
private _onElecFxTimeout = (): void => {
this.clearElecFx();
};
/** 按 Weapon1.atkPos 取受击点世界坐标(1头 2腰 3脚) */
getPosByAtkPosIndex(atkPosIndex: number): Vec3 {
const ui = this.node.getComponent(UITransform);
if (!ui) {
return this.node.worldPosition.clone();
}
const h = Math.max(1, ui.contentSize.height);
const bottomY = -h * ui.anchorPoint.y;
let localY = bottomY + h * 0.5;
if (atkPosIndex === 1) {
localY = bottomY + h * 0.82;
} else if (atkPosIndex === 2) {
const ratio = this.Data?.type === MonsterType.SmallMonster ? 0.32 : 0.38;
localY = bottomY + h * ratio;
} else if (atkPosIndex === 3) {
localY = bottomY + h * 0.18;
}
return ui.convertToWorldSpaceAR(v3(0, localY, 0));
}
playWalk(speed = 1) {
if (!this.Data || this.Data.type == MonsterType.TreasureBox) return;
if (this._isDeathAnimPlaying()) return;
const anim = MonsterDataManager.resolveMarchAnim(this.Data);
if (!anim) {
console.error('[Monster] 行进动画名为空', this.Data.id, this.Data.icon);
return;
}
this.playSpine(anim, null, speed);
}
/**
* 播契约死亡轨(五行=`{系}小怪2`);无契约轨则失败。
* @param onComplete 播完
* @returns 是否成功起播
*/
playDeath(onComplete?: () => void): boolean {
if (!this.Data || this.Data.type === MonsterType.TreasureBox) {
onComplete?.();
return false;
}
const dataId = this.Data.id;
const dataIcon = this.Data.icon;
const anim = MonsterDataManager.resolveDeathAnim(this.Data);
if (!anim) {
onComplete?.();
return false;
}
this.IsDead = true;
this.state = MonsterState.STOP;
3 days ago
this._hideBloodBar();
if (!this.anim?.isValid) {
console.error('[Monster] 死亡无 Skeleton', dataId, anim);
onComplete?.();
return false;
}
4 days ago
// 行军用 PRIVATE_CACHE;死亡轨含附件切换,缓存模式无法起播,须切 REALTIME
if (typeof this.anim.setAnimationCacheMode === 'function') {
this.anim.setAnimationCacheMode(sp.SpineAnimationCacheMode.REALTIME);
}
// 禁止直接 findAnimation:Spine runtime 未就绪时会抛 Cannot read properties of null (reading 'lv')
this._animBaseTimeScale = 1;
const ok = BattleSpinePlayback.play(this.anim, anim, {
loop: false,
timeScale: 1,
onComplete: onComplete ?? undefined,
});
if (!ok) {
console.error('[Monster] 死亡动画起播失败', dataId, anim, dataIcon);
onComplete?.();
return false;
}
return true;
3 weeks ago
}
/** 空投飞禽降落:播 Spine「降落」(隼 / 鹰) */
playParatroopLand(): void {
if (!this.Data || this.Data.type === MonsterType.TreasureBox) return;
if (isParatroopBirdModel(this._resolveSkinPrefabName())) {
this.playSpine(PARATROOP_LAND_ANIM, null, 1, false);
return;
}
this.playSpine('1降落', null, 1, false);
}
/**
* 机制施法等:对外播放 Spine(如白骨精「攻击」/「待机」)。
* @param name 动画名
* @param loop 是否循环
* @param onComplete 播完
* @param timeScale 用户倍速(默认 1;战斗倍速在 Playback 内叠加)
*/
playSpineAnim(name: string, loop = true, onComplete?: () => void, timeScale = 1): void {
this.playSpine(name, onComplete ?? null, Math.max(0.01, timeScale), loop);
3 weeks ago
}
private playSpine(name: string, callback: Function = null, timeScale = 1, loop = true) {
if (!this.node || !this.anim?.isValid) {
3 weeks ago
return;
}
if (this._isDeathAnimPlaying()) return;
3 weeks ago
this._animBaseTimeScale = timeScale;
const ok = BattleSpinePlayback.play(this.anim, name, {
3 weeks ago
loop,
timeScale,
onComplete: callback ? () => callback() : undefined,
});
if (!ok) {
console.error('[Monster] 动画起播失败', this.Data?.id, name, this.Data?.icon);
callback?.();
}
3 weeks ago
}
clear() {
this.unscheduleAllCallbacks();
GEvent.Ins.targetOff(this);
this.clearStunFx();
this.clearSlowFx();
this.clearFreezeFxImmediate();
3 weeks ago
this.clearBurnFx();
this.clearBleedFx();
this.clearGhostBiteFx();
this.clearElecFx();
this.clearAccelerateFx();
this.clearAddHpEff();
this.clearHpCapEff();
this.clearReviveFx();
if (this.anim?.isValid) {
BattleSpinePlayback.clearPendingComplete(this.anim);
BattleSpinePlayback.resetTracks(this.anim);
}
this.anim = null;
this.entityId = null;
this.Data = null;
this.mechanismImmobile = false;
this.boold = null;
this._recycleDynamicChildren(this.downEff);
this._recycleDynamicChildren(this.upEff);
this._recycleDynamicChildren(this.bloodPos);
this._recycleDynamicChildren(this.skin);
this._resetTemplateNodeState();
}
/** 回收挂点下所有动态子节点(皮肤/血条等),模板锚点本身保留 */
private _recycleDynamicChildren(parent: Node | null): void {
if (!parent?.isValid || parent.children.length <= 0) return;
for (const child of [...parent.children]) {
if (!child?.isValid) continue;
if (child['resKey']) {
gg.res.putNode(child);
} else {
child.destroy();
}
}
}
/** 模板回池前恢复根节点可视/逻辑状态,避免脏 Sprite 在下次 active 时刷新 UV */
private _resetTemplateNodeState(): void {
this.IsDead = false;
this.state = MonsterState.PATHFINDING;
this._tagStatus.clear();
this._reviveHeld = false;
this.useMxMovement = false;
this._atkTarget = null;
this._lastAttackTime = 0;
this.hp = 0;
this.maxHp = 0;
this.sizeMul = 1;
const node = this.node;
if (!node?.isValid) return;
node.setScale(1, 1, 1);
node.opacity = 255;
node.setPosition(0, 0, 0);
}
}