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 { MonsterType } from '../../manager/MonsterDataManager'; 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'; /** 怪物视图组件(配合 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; /** 相对 Monster.size 的表现倍率 */ sizeMul = 1; hp = 0; maxHp = 0; IsDead = false; private _tagStatus = new Map(); /** 头顶眩晕特效节点(怪_晕眩) */ 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; /** 点燃火焰特效节点(怪_火) */ 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.anim.animation === '死亡') return; 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; } } /** 当前皮肤预制体名(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.playSpine('待机'); } /** 复活后回到出生点并隐藏,等按出怪间隔跳出再放行 */ 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.playSpine('待机'); } } /** 复活放行:恢复寻路与行走表现 */ 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; 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); } } 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); 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): 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); } /** 对象中心播放受击 Spine 特效(配置来自 Weapon1.hitEffect) */ 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(); }; /** * 显示冰冻覆盖(prefab/冰冻),挂怪体播「怪物冻结」;按时长或 clear 时播「怪物冻结2」再回收。 * @param durationSec 持续秒 */ showFreezeFx(durationSec: number): void { if (!this.node?.isValid || this.IsDead) return; const parent = this.skin?.isValid ? this.skin : this.node; ++this._freezeFxSeq; this._freezeFxThawing = false; this.unschedule(this._onFreezeFxTimeout); 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; } else if (this._freezeFx.parent !== parent) { this._freezeFx.parent = parent; this._freezeFx.setPosition(0, 0, 0); this._freezeFx.active = true; } const sk = this._freezeFx.getComponentInChildren(sp.Skeleton); if (sk?.isValid) { sk.setAnimationCacheMode(sp.SpineAnimationCacheMode.SHARED_CACHE); BattleSpinePlayback.play(sk, FREEZE_FX_ANIM, { loop: true }); } const dur = Math.max(0.05, durationSec); this.scheduleOnce(this._onFreezeFxTimeout, dur); } /** * 解冻:播「怪物冻结2」后回池;无节点则直接结束。 */ clearFreezeFx(): void { this.unschedule(this._onFreezeFxTimeout); 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; const seq = ++this._freezeFxSeq; const ok = BattleSpinePlayback.play(sk, FREEZE_THAW_ANIM, { loop: false, onComplete: this._onFreezeThawComplete, }); if (!ok) { if (seq !== this._freezeFxSeq) return; this._dropFreezeFx(); } } /** 立即回池冰冻特效(死亡/清池,不解冻动画) */ clearFreezeFxImmediate(): void { this._freezeFxSeq++; this.unschedule(this._onFreezeFxTimeout); this._dropFreezeFx(); } private _onFreezeFxTimeout = (): void => { this.clearFreezeFx(); }; private readonly _onFreezeThawComplete = (): void => { if (!this._freezeFxThawing) return; this._dropFreezeFx(); }; /** 回池并清空冰冻节点引用 */ 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); BattleSpinePlayback.resetTracks(sk); gg.res.putNode(node); } /** 显示加速特效(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; this.playSpine('走路', null, speed); } /** 空投飞禽降落:播 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(如白骨精「攻击」/「待机」) */ playSpineAnim(name: string, loop = true, onComplete?: () => void): void { this.playSpine(name, onComplete ?? null, 1, loop); } private playSpine(name: string, callback: Function = null, timeScale = 1, loop = true) { if (!this.node || !this.anim) { return; } if (this.anim.animation == '死亡') return; this._animBaseTimeScale = timeScale; const animName = this._resolveSpineAnimName(name); BattleSpinePlayback.play(this.anim, animName, { loop, timeScale, optional: animName === '待机', onComplete: callback ? () => callback() : undefined, }); } /** 动画不存在时回退 defaultAnimation,避免 Spine 报错且模型不显示 */ private _resolveSpineAnimName(name: string): string { if (!this.anim) return name; if (this.anim.findAnimation(name)) return name; const fallback = '待机'; if (fallback !== name && this.anim.findAnimation(fallback)) { return fallback; } return name; } clear() { this.unscheduleAllCallbacks(); GEvent.Ins.targetOff(this); this.clearStunFx(); this.clearSlowFx(); this.clearFreezeFxImmediate(); 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); } }