import { assetManager, Color, easing, instantiate, isValid, Node, Prefab, Sorting2D, Tween, Vec3, sp, tween, v3, color, view, } from 'cc'; import { createMembershipSet, membershipAdd, membershipClear, membershipDelete, membershipHas, type MembershipSet, } from 'db://assets/mx/tools/MembershipSet'; import type { BattleEvent, EntityId, UnitDefId } from 'db://assets/mx/module/battle'; import { BATTLE_EVENT_ALL, BattleEventType } from 'db://assets/mx/module/battle'; import { logicalWorldToCocos, resolveSpawnJumpVisualPosInto, syncMonsterCombatState, syncMonsterNode, } from 'db://assets/script/battle/bridge/CocosBattleMovementBridge'; import { applyMonsterFootOffset, BattleMapSpec } from 'db://assets/script/battle/map/BattleMapSpec'; import { LOCAL_ENEMY_SPAWN_PORT } from 'db://assets/script/battle/map/MapPathQueries'; import { GBundle } from 'db://assets/script/game/ConfigRes'; import type { MapManager } from 'db://assets/script/battle/map/legacy/MapManager'; import type { ProjectBattleHandle } from 'db://assets/script/battle/runtime/BattleRuntime'; import { EntityTag, HealthComp, MovementComp, TagComp, TeamComp, TransformComp, UnitMetaComp, type BattleSession, } from 'db://assets/mx/module/battle'; import { parseLegacyMonsterId, parseMonsterUnitDefId } from 'db://assets/script/battle/config/MonsterHpCalculator'; import { ChapterLevelBuilder } from 'db://assets/script/battle/config/ChapterLevelBuilder'; import { MonsterDataManager, MonsterType } from 'db://assets/script/manager/MonsterDataManager'; import { MonsterState, } from 'db://assets/script/ui/BattleGame/Monster'; import { BATTLE_MONSTER_CCCLASS, type IBattleMonsterView, } from 'db://assets/script/battle/view/IBattleMonsterView'; import { ensureSpineSkeletonDataRuntime } from 'db://assets/mx/module/lang/spineRuntimePatch'; import { withSkinNameVariants, glyphSpineHasSkin } from 'db://assets/script/battle/view/GlyphSpineSkin'; import { MECHANISM_DROP_DURATION_SEC, MECHANISM_DROP_OFFSET_Y, MECHANISM_FADE_IN_SEC, MECHANISM_PARATROOP_TAG, MECHANISM_WITCH_TAG, PARATROOP_DROP_DURATION_SEC, PARATROOP_DROP_OFFSET_Y_MAX, PARATROOP_DROP_OFFSET_Y_MIN, PARATROOP_LAND_ANIM, WITCH_IDLE_ANIM, } from 'db://assets/script/battle/mechanism/MechanismConstants'; import { finalizeMechanismUnitPlacement } from 'db://assets/script/battle/mechanism/MechanismServices'; import { resolveBagDragMonsterOpacity } from './BagDragVisualPresenter'; import { DeviceProfile } from './DeviceProfile'; import { getBattleTimeScale } from '../skill/BattlePresentationClock'; import { getMonsterStatusText, STATUS_TEXT_REVIVE } from './MonsterStatusText'; import { YingCampAnim, YING_HOLD_BEFORE_JUMP_SEC, YING_SPAWN_GROW_SEC, YING_SPAWN_SCALE } from './YingCampAnim'; import { SuperTrail } from 'db://assets/mx/components/SuperTrail'; /** 营字召唤跳出拖尾预制体 */ const YING_JUMP_TRAIL_NAME = '怪跳出拖尾'; /** 出怪跳出:出怪口格心起跳,落到下一寻路点脚底 */ const SPAWN_JUMP_UP_SEC = 0.2; const SPAWN_JUMP_LAND_SEC = 0.28; /** 出怪口动画先播,再延迟此时间后开始跳出 */ const SPAWN_PORTAL_LEAD_SEC = 0.2; /** 跳出落地后,再保持出生无敌的时长 */ const SPAWN_INVULN_AFTER_LAND_SEC = 0.2; export const SPAWN_JUMP_SEC = SPAWN_JUMP_UP_SEC + SPAWN_JUMP_LAND_SEC; export const SPAWN_PRESENT_SEC = SPAWN_PORTAL_LEAD_SEC + SPAWN_JUMP_SEC; /** 还魂丹复活:特效时长无效时的放大时长(秒,逻辑) */ const PASSIVE_REVIVE_GROW_SEC = 0.4; /** 还魂丹复活:放大完成后停顿再寻路(秒,逻辑) */ const PASSIVE_REVIVE_HOLD_SEC = 1; /** 怪物节点上挂的脚下阴影引用 */ const MONSTER_SHADOW_NODE = '_monsterShadow'; /** 普通阴影预制体名 */ const SHADOW_PREFAB_NORMAL = '阴影'; /** 发光阴影预制体名(营/杨戬等) */ const SHADOW_PREFAB_GLOW = '发光阴影'; /** * 阴影相对预制体默认尺寸的缩放(按皮肤预制体名)。 * 未配置的怪会 warn 并用 1(以龟丞相为基准)。 */ const MONSTER_SHADOW_SCALE: { [prefabName: string]: number } = { 兵: 0.5, 将: 0.5, 帅: 0.55, 猴: 0.55, 虾: 0.55, 魂: 0.5, 猩: 0.75, 猿: 0.8, 哮天犬: 0.65, 龟丞相: 1, 奔波儿灞: 1, 牛魔王: 1, 六耳: 1, 蟹将: 1, 太白: 1, 白无常: 1, 敖广: 1, 千里眼: 1, 顺风耳: 1, 谛听: 1, }; /** 不挂脚下阴影的皮肤预制体(如黑无常) */ const MONSTER_SHADOW_SKIP: { [prefabName: string]: boolean } = { 黑无常: true, 奔波儿灞: true, }; /** * 阴影相对怪脚底再上移(像素)。 * 阴影贴图中心偏下时,与怪同坐标会显得离脚太远。 */ const MONSTER_SHADOW_OFFSET_Y = 18; /** 怪物模板异步皮肤加载世代号(回收时递增,过期回调不得再挂皮肤) */ const MONSTER_SKIN_LOAD_GEN = '_monsterSkinLoadGen'; type MechanismDropKind = 'witch' | 'paratroop'; interface MechanismDropPending { kind: MechanismDropKind; entityId: EntityId; monsterData: ITableMonster; endPos: Vec3; hp: number; onLand?: () => void; } const MECHANISM_DROP_PENDING = '_mechanismDropPending'; /** ???? ? Cocos ??????? MX UnitSpawned? */ export class CocosBattleMonsterPresenter { private _battle: ProjectBattleHandle | null = null; private _map: MapManager | null = null; private _monsterRoot: Node | null = null; /** mapParent 下阴影层(mapLayer 与 monsterLayer 之间) */ private _shadowRoot: Node | null = null; private _entityNodes = new Map(); private _entityShadows = new Map(); private _spawningEntities: MembershipSet = createMembershipSet(); /** 挑飞等占用:EntityKilled 不立刻回收 */ private _heldVisuals: MembershipSet = createMembershipSet(); /** 占用期间已死亡,释放后回收 */ private _pendingRecycle: MembershipSet = createMembershipSet(); /** 落地后延时解除 spawnProtected 的世代号(BattleClock 调度,替代 setTimeout) */ private _spawnInvulnGen = new Map(); private _listener: ((e: BattleEvent) => void) | null = null; private _monsterSortScratch: Node[] = []; /** Y 排序帧计数 */ private _ySortFrame = 0; /** 阴影同步 ID scratch,避免每帧 new 数组 */ private readonly _shadowIdScratch: EntityId[] = []; /** ?????????????????? UI ??? remove ????? */ private _lastKillPos: Vec3 | null = null; private _lastKillEntity: EntityId | null = null; private _lastKillCoin = 0; private _lastKillMonsterType = -1; private _spineLodCounter = 0; get monsterRoot(): Node | null { return this._monsterRoot; } get battle(): ProjectBattleHandle | null { return this._battle; } getEntityNodeMap(): ReadonlyMap { return this._entityNodes; } /** * 非战斗实体装饰节点(开局表演怪等)挂脚下阴影。 * @param hostNode 表现根节点 * @param skinPrefabName 皮肤预制体名(定缩放) */ attachDecorFootShadow(hostNode: Node, skinPrefabName: string): void { this._recycleShadowOnNode(hostNode); const skinName = String(skinPrefabName ?? '').trim(); if (MONSTER_SHADOW_SKIP[skinName]) return; const shadow = gg.res.getNode('prefab/monster/', SHADOW_PREFAB_NORMAL, GBundle.BattleGameMonster); if (!shadow?.isValid) { console.warn('[CocosBattleMonsterPresenter] 阴影预制体未加载', SHADOW_PREFAB_NORMAL); return; } const hs = Math.abs(hostNode.scale.y) || 1; const worldWant = this._resolveShadowScale(skinName); this._attachFootShadowToHost(shadow, hostNode, worldWant / hs, hs); hostNode[MONSTER_SHADOW_NODE] = shadow; } /** * 装饰阴影已挂在宿主下,位移跟随;仅同步显隐。 * @param hostNode 表现根节点 */ syncDecorFootShadow(hostNode: Node): void { const shadow = hostNode[MONSTER_SHADOW_NODE] as Node | undefined; if (!shadow?.isValid || !hostNode?.isValid) return; if (shadow.parent !== hostNode) { const hs = Math.abs(hostNode.scale.y) || 1; this._attachFootShadowToHost(shadow, hostNode, shadow.scale.x, hs); } shadow.active = hostNode.active; } /** * 回收装饰节点脚下阴影。 * @param hostNode 表现根节点 */ recycleDecorFootShadow(hostNode: Node): void { this._recycleShadowOnNode(hostNode); } isSpawning(entityId: EntityId): boolean { return membershipHas(this._spawningEntities, entityId); } /** * 占用怪物节点:击杀/回收不立刻 putNode。 * @param entityId 怪物 */ holdVisual(entityId: EntityId): void { membershipAdd(this._heldVisuals, entityId); } /** * 解除占用;占用期间已死则回收。 * @param entityId 怪物 */ releaseVisual(entityId: EntityId): void { membershipDelete(this._heldVisuals, entityId); if (!membershipHas(this._pendingRecycle, entityId)) return; membershipDelete(this._pendingRecycle, entityId); this.removeVisual(entityId); } /** * 逻辑已入场巡逻但字形偏浅/隐形(出怪 tween 或拖动弱化被打断):拉回目标透明度并重套阵营皮。 * 仅处理 spawnProtected=false 且非 spawning 的实体;回收隐藏仍靠 spawnProtected。 */ ensureMarchVisible(entityId: EntityId, node: Node): void { if (!node?.isValid || !this._battle) return; if (membershipHas(this._spawningEntities, entityId)) return; if (membershipHas(this._pendingRecycle, entityId)) return; const move = this._battle.session.world.getComponent(entityId, MovementComp); if (!move || move.spawnProtected) return; const target = resolveBagDragMonsterOpacity(255); if (node.opacity >= target) return; node.opacity = target; const monster = node.getComponent(BATTLE_MONSTER_CCCLASS) as IBattleMonsterView | null; const data = monster?.Data; if (monster && data) { this._applyCampSkinToMonster(monster, data, entityId); } } /** ?????????????????????? null? */ getEntityWorldPos(entityId: EntityId): Vec3 | null { const node = this._entityNodes.get(entityId); if (node?.isValid) return node.worldPosition.clone(); if (this._lastKillEntity === entityId && this._lastKillPos) { return this._lastKillPos.clone(); } return null; } getLastKillCoin(entityId: EntityId): number { if (this._lastKillEntity === entityId) return this._lastKillCoin; const node = this._entityNodes.get(entityId); const view = node?.getComponent(BATTLE_MONSTER_CCCLASS) as { Data?: { peach?: number } } | null; return Math.max(0, view?.Data?.peach ?? 0); } /** 击杀瞬间缓存的怪物 type(Monster 表 type 字段) */ getKillMonsterType(entityId: EntityId): number { if (this._lastKillEntity === entityId && this._lastKillMonsterType >= 0) { return this._lastKillMonsterType; } const node = this._entityNodes.get(entityId); const view = node?.getComponent(BATTLE_MONSTER_CCCLASS) as { Data?: { type?: number } } | null; return view?.Data?.type ?? -1; } bind(battle: ProjectBattleHandle, mapNode: Node, displayRoot: Node): void { this.unbind(); this._battle = battle; this._map = mapNode.getComponent('MapManager') as MapManager | null; battle.Map = this._map; // 怪挂在 UI 的 displayRoot 下独立层,避免 mapLayer 位移带动怪物 let root = displayRoot.getChildByName('monsterLayer'); if (!root) { root = new Node('monsterLayer'); root.parent = displayRoot; } this._monsterRoot = root; this._shadowRoot = this._ensureShadowLayer(displayRoot, root); battle.setMonsterPresenter(this); battle.attachMonsterRoot(root); this._listener = (e: BattleEvent) => this.onBattleEvent(e); battle.on(BATTLE_EVENT_ALL, this._listener); } unbind(): void { if (this._battle && this._listener) { this._battle.off(BATTLE_EVENT_ALL, this._listener); } this._listener = null; if (this._monsterRoot?.isValid) { const children = [...this._monsterRoot.children]; for (const node of children) { if (!node?.isValid) continue; this.recycleMonsterNode(node); } } this._clearAllShadows(); this._battle = null; this._map = null; this._monsterRoot = null; this._shadowRoot = null; this._entityNodes.clear(); this._entityShadows.clear(); membershipClear(this._heldVisuals); membershipClear(this._pendingRecycle); for (const entityId of this._spawningEntities.keys()) { this._unlockPresentMovement(entityId); this._clearSpawnProtected(entityId); } membershipClear(this._spawningEntities); this._clearAllSpawnInvulnTimers(); } getAllMonsterNodes(): Node[] { if (!this._monsterRoot?.isValid) return []; const scratch = this._monsterSortScratch; scratch.length = 0; for (let i = 0; i < this._monsterRoot.children.length; i++) { const n = this._monsterRoot.children[i]; if (n.isValid && n.getComponent(BATTLE_MONSTER_CCCLASS)) { scratch.push(n); } } return scratch.slice(); } private onBattleEvent(event: BattleEvent): void { switch (event.type) { case BattleEventType.UnitSpawned: this.spawnVisual(event.entity, event.unitDefId, event.spawnPort, event.hp); break; case BattleEventType.EntityKilled: this._cacheKillInfo(event.victim); this.removeVisual(event.victim); break; case BattleEventType.UnitDespawned: this.removeVisual(event.entity); break; } } private _cacheKillInfo(entityId: EntityId): void { const node = this._entityNodes.get(entityId); this._lastKillEntity = entityId; this._lastKillPos = node?.isValid ? node.worldPosition.clone() : null; const view = node?.getComponent(BATTLE_MONSTER_CCCLASS) as { Data?: { peach?: number; type?: number } } | null; this._lastKillCoin = Math.max(0, view?.Data?.peach ?? 0); this._lastKillMonsterType = view?.Data?.type ?? -1; } /** * 出怪表现血量:优先读逻辑层已加成后的 maxHp(补元丹等 UnitSpawned 先于表现)。 * @param entityId 实体 * @param eventHp UnitSpawned 携带的出生血量 */ private _resolveSpawnDisplayHp(entityId: EntityId, eventHp: number): number { const world = this._battle?.session.world; if (!world || !world.isAlive(entityId)) return eventHp; const health = world.getComponent(entityId, HealthComp); if (health && health.maxHp > 0) return health.maxHp; return eventHp; } private spawnVisual(entityId: EntityId, unitDefId: UnitDefId, spawnPort: number, hp: number): void { if (!this._monsterRoot?.isValid || !this._map?.isValid) return; const parsed = parseMonsterUnitDefId(unitDefId); const monsterId = parsed?.monsterId ?? parseLegacyMonsterId(unitDefId); if (monsterId <= 0) { console.log('[CocosBattleMonsterPresenter] invalid unitDefId', unitDefId); return; } const monsterData = gg.data.project.getMonsterDataByID(monsterId); if (!monsterData) { console.log('[CocosBattleMonsterPresenter] monster data missing', monsterId); return; } // 开局 Boss 等强制 EntityTag.Boss 时:血条/表现按 Boss,即使 Monster.type 仍为小怪 const viewData = this._monsterDataForView(entityId, monsterData); const usePortal = spawnPort > 0; const pos = usePortal ? this.resolveSpawnPos(spawnPort) : this._entityGridWorldPos(entityId); if (!pos) { if (!usePortal) { console.log('[CocosBattleMonsterPresenter] 自身位置召唤无坐标', entityId, unitDefId); } return; } const monsterNode = this.createMonsterNode(viewData, pos, this._monsterRoot, entityId, undefined, spawnPort); if (!monsterNode) return; const monsterComp = monsterNode.getComponent(BATTLE_MONSTER_CCCLASS) as IBattleMonsterView | null; if (!monsterComp) { console.error('[CocosBattleMonsterPresenter] Monster component missing on monsterTemplate'); this.recycleMonsterNode(monsterNode); return; } try { if (entityId != null) { this._lockPresentMovement(entityId); } // UnitSpawned.hp 可能早于补元丹等 Host 加成;以逻辑层 HealthComp 为准 const logicHp = this._resolveSpawnDisplayHp(entityId, hp); monsterComp.setData(viewData, logicHp); const monsterView = monsterComp as IBattleMonsterView & { entityId?: EntityId }; if (entityId != null) { monsterView.entityId = entityId; } // setData / 激活节点后 Spine 会按预制体 defaultSkin 重置,须在此后再套阵营皮 this._applyCampSkinToMonster(monsterComp, viewData, entityId, spawnPort); monsterComp.bindPathTarget(); const look = YingCampAnim.peekPendingLook(); const sizeMul = look?.scaleMul != null && look.scaleMul > 0 ? look.scaleMul : 1; monsterComp.sizeMul = sizeMul; const scale = this._resolveMonsterDisplayScale(monsterData, sizeMul); if (this._wantsJumpToPath(entityId)) { this._entityNodes.set(entityId, monsterNode); membershipAdd(this._spawningEntities, entityId); monsterComp.playWalk(); const tf = this._battle?.session.world.getComponent(entityId, TransformComp); if (!tf) { console.log('[CocosBattleMonsterPresenter] 营跳无 Transform', entityId); this._recoverSpawnPortalDelayFailed(entityId, monsterNode); return; } const grass = v3(); let fromGrass = YingCampAnim.takeJumpStart(entityId, grass); if (!fromGrass) { const campNode = this._map.getMapGrid(tf.row, tf.col)?.node ?? null; fromGrass = YingCampAnim.readGrassFromNode(campNode, grass); if (fromGrass) { console.log('[CocosBattleMonsterPresenter] 营跳未收到出生点,用营格上偏', entityId); } else { console.log('[CocosBattleMonsterPresenter] 营跳无出生点', entityId); } } this.playSpawnJump( monsterNode, tf.row, tf.col, scale, entityId, undefined, fromGrass ? grass : undefined, look ? scale * look.scaleFrom : scale * YING_SPAWN_SCALE, look?.holdSec ?? YING_HOLD_BEFORE_JUMP_SEC, look?.growSec ?? YING_SPAWN_GROW_SEC, look?.scaleHold != null ? scale * look.scaleHold : undefined, !!look?.jumpTrail, look?.jumpSpeed, ); } else if (usePortal) { if (this._battle?.useMxMovement && entityId != null && this._map) { alignMxEntityToMapGrid(this._battle.session, this._map, entityId, spawnPort); } membershipAdd(this._spawningEntities, entityId); this._map.playSpawnPointEffect(spawnPort, SPAWN_PRESENT_SEC); monsterNode.opacity = 0; Tween.stopAllByTarget(monsterNode); tween(monsterNode) .delay(SPAWN_PORTAL_LEAD_SEC) .call(() => { if (!this._isMonsterSpawnVisualAlive(entityId, monsterNode)) { this._recoverSpawnPortalDelayFailed(entityId, monsterNode); return; } this._applyCampSkinToMonster(monsterComp, viewData, entityId, spawnPort); monsterComp.playWalk(); const spawnCell = this._battle?.session.topology.getSpawnCell(spawnPort); if (!spawnCell) { console.log('[CocosBattleMonsterPresenter] 出怪口无拓扑格', spawnPort); this._recoverSpawnPortalDelayFailed(entityId, monsterNode); return; } this.playSpawnJump(monsterNode, spawnCell.row, spawnCell.col, scale, entityId); }) .start(); } else { this._presentInPlaceSpawn(entityId, monsterNode, monsterComp, pos, scale); } } catch (err) { console.error('[CocosBattleMonsterPresenter] monster init failed', monsterData.id, String(err)); membershipDelete(this._spawningEntities, entityId); this._unlockPresentMovement(entityId); this._clearSpawnProtected(entityId); this.recycleMonsterNode(monsterNode); return; } this.sortMonstersByY(); this._entityNodes.set(entityId, monsterNode); this._attachMonsterShadow(entityId, monsterNode, viewData); } /** EntityTag.Boss 强制用 Boss 血条/表现(开局 Boss 池 Monster.type 可能仍为小怪) */ private _monsterDataForView(entityId: EntityId, data: ITableMonster): ITableMonster { const tags = this._battle?.session.world.getComponent(entityId, TagComp); if (!tags?.tags.includes(EntityTag.Boss)) return data; if (data.type === MonsterType.Boss) return data; return { ...data, type: MonsterType.Boss }; } /** 机制单位:渐显从天而降(不走出怪口 UnitSpawned 流程) */ spawnMechanismDropIn( entityId: EntityId, monsterData: ITableMonster, endPos: Vec3, hp: number, ): boolean { if (!this._monsterRoot?.isValid) return false; const startPos = endPos.clone().add(v3(0, MECHANISM_DROP_OFFSET_Y, 0)); const pending: MechanismDropPending = { kind: 'witch', entityId, monsterData, endPos, hp, }; const monsterNode = this.createMonsterNode( monsterData, startPos, this._monsterRoot, entityId, pending, ); if (!monsterNode) return false; const monsterComp = monsterNode.getComponent(BATTLE_MONSTER_CCCLASS) as IBattleMonsterView | null; if (!monsterComp) { this.recycleMonsterNode(monsterNode); return false; } monsterComp.mechanismImmobile = true; monsterComp.entityId = entityId; monsterComp.useMxMovement = true; // 禁止在此 stopAll:createMonsterNode 同步挂皮时已启动下落/渐显 tween, // stop 会把 opacity 锁在 0,表现为「白骨精不出现」 const scale = this._resolveMonsterDisplayScale(monsterData); monsterNode.setScale(scale, scale, 1); this._entityNodes.set(entityId, monsterNode); this._attachMonsterShadow(entityId, monsterNode, monsterData); return true; } /** 空投飞禽:从云端降落,播「降落」动画,落地后寻路 */ spawnParatroopDropIn( entityId: EntityId, monsterData: ITableMonster, endPos: Vec3, hp: number, onLand: () => void, ): boolean { if (!this._monsterRoot?.isValid) return false; const dropOffset = PARATROOP_DROP_OFFSET_Y_MIN + Math.random() * (PARATROOP_DROP_OFFSET_Y_MAX - PARATROOP_DROP_OFFSET_Y_MIN); const startPos = endPos.clone().add(v3(0, dropOffset, 0)); const pending: MechanismDropPending = { kind: 'paratroop', entityId, monsterData, endPos, hp, onLand, }; const monsterNode = this.createMonsterNode( monsterData, startPos, this._monsterRoot, entityId, pending, ); if (!monsterNode) return false; const monsterComp = monsterNode.getComponent(BATTLE_MONSTER_CCCLASS) as IBattleMonsterView | null; if (!monsterComp) { this.recycleMonsterNode(monsterNode); return false; } monsterComp.entityId = entityId; monsterComp.useMxMovement = true; // 同白骨精:勿 stopAll,避免掐断已启动的降落 tween const scale = this._resolveMonsterDisplayScale(monsterData); monsterNode.setScale(scale, scale, 1); this._entityNodes.set(entityId, monsterNode); this._attachMonsterShadow(entityId, monsterNode, monsterData); return true; } private _startMechanismDropVisual( monsterNode: Node, monsterComp: IBattleMonsterView & { setData: (data: ITableMonster, hp: number) => void; playSpineAnim?: (name: string, loop: boolean, onComplete?: () => void) => void; playParatroopLand?: () => void; playWalk?: () => void; ensureBloodBar?: () => void; syncBloodPosToSkin?: () => void; bindPathTarget?: () => void; scheduleOnce?: (cb: () => void, delay: number) => void; }, pending: MechanismDropPending, ): void { const { kind, entityId, monsterData, endPos, hp, onLand } = pending; const view = monsterComp as IBattleMonsterView & { entityId?: EntityId; mechanismImmobile?: boolean; useMxMovement?: boolean; }; view.entityId = entityId; view.useMxMovement = true; if (kind === 'witch') { view.mechanismImmobile = true; } if (kind === 'witch') { // 下落与渐显并行:原先先透明下落 2s 再渐显,战场上长时间「看不见」 monsterNode.opacity = 0; tween(monsterNode) .parallel( tween(monsterNode).to( MECHANISM_DROP_DURATION_SEC, { worldPosition: endPos.clone() }, { easing: easing.quadOut }, ), tween(monsterNode).to(MECHANISM_FADE_IN_SEC, { opacity: resolveBagDragMonsterOpacity(255) }), ) .call(() => { if (!isValid(monsterNode)) return; monsterNode.opacity = resolveBagDragMonsterOpacity(255); // 落地坐标强制对齐目标格(防止 tween 被中断时停在起点偏上) monsterNode.setWorldPosition(endPos); monsterComp.setData(monsterData, hp); monsterComp.playSpineAnim?.(WITCH_IDLE_ANIM, true); monsterComp.ensureBloodBar?.(); monsterComp.scheduleOnce?.(() => monsterComp.syncBloodPosToSkin?.(), 0); monsterComp.scheduleOnce?.(() => monsterComp.syncBloodPosToSkin?.(), 0.1); if (this._map?.isValid && this._battle) { finalizeMechanismUnitPlacement( this._battle.session, this._map, entityId, endPos, ); } this.sortMonstersByY(); }) .start(); return; } monsterComp.setData(monsterData, hp); // setData/init 会把 bornPos 写成天空起点;空投回收点必须是落地格 (monsterComp as { updateLandingBornPos?: (pos: Vec3) => void }).updateLandingBornPos?.(endPos); monsterNode.active = true; monsterNode.opacity = resolveBagDragMonsterOpacity(255); monsterComp.playParatroopLand?.(); membershipAdd(this._spawningEntities, entityId); tween(monsterNode) .to(PARATROOP_DROP_DURATION_SEC, { worldPosition: endPos.clone() }, { easing: easing.quadOut }) .call(() => { membershipDelete(this._spawningEntities, entityId); if (!isValid(monsterNode) || !this._battle?.session.world.isAlive(entityId)) { return; } // 强制对齐落点,避免 tween 中断停在天空偏上 monsterNode.setWorldPosition(endPos); monsterNode.opacity = resolveBagDragMonsterOpacity(255); (monsterComp as { updateLandingBornPos?: (pos: Vec3) => void }).updateLandingBornPos?.(endPos); monsterComp.ensureBloodBar?.(); monsterComp.scheduleOnce?.(() => monsterComp.syncBloodPosToSkin?.(), 0); monsterComp.scheduleOnce?.(() => monsterComp.syncBloodPosToSkin?.(), 0.1); onLand?.(); this.sortMonstersByY(); }) .start(); } /** * 复活:移回出怪口并隐藏(保留血量),逻辑层须同步 hold。 * 机制单位留在地图原位,不走出怪口。 * @returns 停留点世界坐标,供 MX Transform 对齐 */ prepareReviveHold(entityId: EntityId): Vec3 | null { const node = this._entityNodes.get(entityId); if (!node?.isValid || !this._battle) return null; const world = this._battle.session.world; const pos = this._isParatroopUnit(entityId) ? this._resolveParatroopLandingWorldPos(entityId, node) : this._holdAtLandingOnRevive(entityId) ? this._resolveMechanismHoldWorldPos(entityId, node) : this._resolveSpawnPortalPos(world, entityId); if (!pos) return null; // 打断出生跳跃:清 spawning 标记与无敌延时,避免表现层一直当「出生中」而不同步/不寻路 membershipDelete(this._spawningEntities, entityId); this._clearSpawnInvulnTimer(entityId); Tween.stopAllByTarget(node); node.active = true; node.setWorldPosition(pos); node.opacity = 0; const comp = node.getComponent(BATTLE_MONSTER_CCCLASS) as { holdAtBornAfterRevive?: (atWorld?: Vec3) => void; } | null; comp?.holdAtBornAfterRevive?.(pos); this._lockPresentMovement(entityId); const move = world.getComponent(entityId, MovementComp); if (move) { move.spawnProtected = true; } return pos.clone(); } /** * 复活放行:常规怪出怪口跳出;女巫原位渐显不寻路;空投怪原位渐显后继续寻路。 */ playReviveRespawn(entityId: EntityId, onJumpLand: () => void): void { const node = this._entityNodes.get(entityId); if (!node?.isValid || !this._battle || !this._map?.isValid) { onJumpLand(); return; } const world = this._battle.session.world; if (!world.isAlive(entityId)) { onJumpLand(); return; } if (this._isMechanismImmobileUnit(entityId)) { this._playMechanismReviveRespawn(entityId, node, world, onJumpLand); return; } if (this._isParatroopUnit(entityId)) { this._playParatroopReviveRespawn(entityId, node, world, onJumpLand); return; } const meta = world.getComponent(entityId, UnitMetaComp); const spawnPort = meta?.spawnPoint > 0 ? meta.spawnPoint : 1; const spawnCell = this._battle?.session.topology.getSpawnCell(spawnPort); if (!spawnCell) { console.log('[CocosBattleMonsterPresenter] 复活跳出无出怪拓扑格', spawnPort); onJumpLand(); return; } const monsterComp = node.getComponent(BATTLE_MONSTER_CCCLASS) as IBattleMonsterView & { playWalk?: () => void; Data?: ITableMonster; releaseFromReviveQueue?: () => void; } | null; this._lockPresentMovement(entityId); const move = world.getComponent(entityId, MovementComp); if (move) { move.spawnProtected = true; move.presentLocked = true; } membershipAdd(this._spawningEntities, entityId); this._map.playSpawnPointEffect(spawnPort, SPAWN_PRESENT_SEC); Tween.stopAllByTarget(node); node.opacity = 0; const scale = this._resolveMonsterDisplayScale(monsterComp?.Data ?? null); tween(node) .delay(SPAWN_PORTAL_LEAD_SEC) .call(() => { if (!this._isMonsterSpawnVisualAlive(entityId, node)) { this._recoverSpawnPortalDelayFailed(entityId, node); onJumpLand(); return; } monsterComp?.releaseFromReviveQueue?.(); monsterComp?.playWalk?.(); this.playSpawnJump(node, spawnCell.row, spawnCell.col, scale, entityId, onJumpLand); }) .start(); } /** * 还魂丹:暂缓击杀,隐藏节点并返回死亡坐标(不回收节点)。 * @param entityId 实体 */ holdForPassiveRevive(entityId: EntityId): Vec3 | null { const node = this._entityNodes.get(entityId); if (node?.isValid) { Tween.stopAllByTarget(node); const pos = node.worldPosition.clone(); node.opacity = 0; membershipAdd(this._heldVisuals, entityId); const move = this._battle?.session.world.getComponent(entityId, MovementComp); if (move) { move.presentLocked = true; move.spawnProtected = true; } return pos; } return this.getEntityWorldPos(entityId); } /** * 还魂丹:原地复活。死亡点播特效、怪从 0 放大到体积,停顿后再寻路。 * @param entityId 实体 * @param hp 当前血量 * @param maxHp 上限 */ releasePassiveRevive(entityId: EntityId, hp: number, maxHp: number): void { membershipDelete(this._heldVisuals, entityId); membershipDelete(this._pendingRecycle, entityId); const battle = this._battle; const node = this._entityNodes.get(entityId); if (!battle || !node?.isValid) return; Tween.stopAllByTarget(node); this._lockPresentMovement(entityId); node.active = true; const view = node.getComponent(BATTLE_MONSTER_CCCLASS) as IBattleMonsterView & { state?: MonsterState; playWalk?: () => void; resolveAttackTarget?: () => void; } | null; const destScale = this._resolveMonsterDisplayScale(view?.Data, view?.sizeMul); getMonsterStatusText().playPinned(entityId, STATUS_TEXT_REVIVE); node.setScale(0, 0, 1); node.opacity = resolveBagDragMonsterOpacity(255); view?.syncMxHp?.(hp, maxHp); if (view) view.state = MonsterState.STOP; if (view?.Data?.type !== MonsterType.TreasureBox) { view?.playSpineAnim?.('待机', true); } const growSec = view?.playReviveEffect?.(this._monsterRoot ?? undefined) ?? 0; if (!(growSec > 0)) { console.warn('[CocosBattleMonsterPresenter] 还魂复活特效时长无效,用放大时长', entityId); } const appear = growSec > 0 ? growSec : PASSIVE_REVIVE_GROW_SEC; const ts = Math.max(0.01, getBattleTimeScale()); tween(node) .to(appear / ts, { scale: v3(destScale, destScale, 1) }, { easing: 'sineOut' }) .delay(PASSIVE_REVIVE_HOLD_SEC / ts) .call(() => this._finishPassiveReviveAppear(entityId, node)) .start(); } /** * 还魂放大与停顿结束:解锁移动并恢复寻路/进攻。 * @param entityId 实体 * @param node 表现节点 */ private _finishPassiveReviveAppear(entityId: EntityId, node: Node): void { if (!this._isMonsterSpawnVisualAlive(entityId, node)) return; const battle = this._battle; if (!battle) return; const view = node.getComponent(BATTLE_MONSTER_CCCLASS) as IBattleMonsterView & { state?: MonsterState; playWalk?: () => void; resolveAttackTarget?: () => void; } | null; const destScale = this._resolveMonsterDisplayScale(view?.Data, view?.sizeMul); node.setScale(destScale, destScale, 1); node.opacity = resolveBagDragMonsterOpacity(255); this._unlockPresentMovement(entityId); this._clearSpawnInvulnTimer(entityId); this._clearSpawnProtected(entityId); const session = battle.session; const move = session.world.getComponent(entityId, MovementComp); if (move) { move.forcePresentSync = true; if (!move.reachedGoal && move.pathIndex >= move.path.length) { session.movement.assignPath(entityId); } } const map = this._map; if (map?.gridsInitialized) { syncMonsterCombatState(session, entityId, node); syncMonsterNode(session, map, entityId, node); } else if (view) { if (move?.reachedGoal) { view.state = MonsterState.ATTACKING; view.resolveAttackTarget?.(); } else { view.state = MonsterState.MOVING_FORWARD; view.playWalk?.(); } } } /** * 占用期间已死、待 releaseVisual 回收。 * @param entityId 怪物 */ isPendingRecycle(entityId: EntityId): boolean { return membershipHas(this._pendingRecycle, entityId); } private removeVisual(entityId: EntityId): void { if (membershipHas(this._heldVisuals, entityId)) { membershipAdd(this._pendingRecycle, entityId); const held = this._entityNodes.get(entityId); if (held?.isValid) { Tween.stopAllByTarget(held); held.opacity = 0; held.active = false; } this._syncMonsterShadow(entityId); return; } membershipDelete(this._pendingRecycle, entityId); const node = this._entityNodes.get(entityId); this._entityNodes.delete(entityId); membershipDelete(this._spawningEntities, entityId); this._unlockPresentMovement(entityId); this._clearSpawnProtected(entityId); this._clearSpawnInvulnTimer(entityId); this._recycleEntityShadow(entityId, node); if (node?.isValid) { Tween.stopAllByTarget(node); this.recycleMonsterNode(node); } } private _isMechanismImmobileUnit(entityId: EntityId): boolean { if (!this._battle) return false; const tags = this._battle.session.world.getComponent(entityId, TagComp); if (tags?.tags.includes(MECHANISM_WITCH_TAG)) return true; const node = this._entityNodes.get(entityId); const view = node?.getComponent(BATTLE_MONSTER_CCCLASS) as { mechanismImmobile?: boolean } | null; return view?.mechanismImmobile ?? false; } private _isParatroopUnit(entityId: EntityId): boolean { if (!this._battle) return false; const tags = this._battle.session.world.getComponent(entityId, TagComp); return tags?.tags.includes(MECHANISM_PARATROOP_TAG) ?? false; } private _holdAtLandingOnRevive(entityId: EntityId): boolean { return this._isMechanismImmobileUnit(entityId) || this._isParatroopUnit(entityId); } private _resolveSpawnPortalPos(world: BattleSession['world'], entityId: EntityId): Vec3 | null { const meta = world.getComponent(entityId, UnitMetaComp); const spawnPort = meta?.spawnPoint > 0 ? meta.spawnPoint : 1; return this.resolveSpawnPos(spawnPort); } private _resolveMechanismHoldWorldPos(entityId: EntityId, node: Node): Vec3 { const session = this._battle?.session; if (!session || !this._map?.isValid) return node.worldPosition.clone(); const tf = session.world.getComponent(entityId, TransformComp); const move = session.world.getComponent(entityId, MovementComp); if (!tf || !move) return node.worldPosition.clone(); return logicalWorldToCocos(session, this._map, tf.row, tf.col, move.worldX, move.worldY); } /** 飞禽:芭蕉扇/复活收回落地格,而不是出怪口或当前格 */ private _resolveParatroopLandingWorldPos(entityId: EntityId, node: Node): Vec3 { const view = node.getComponent(BATTLE_MONSTER_CCCLASS) as { getLandingBornPos?: () => Vec3 | null; } | null; const landing = view?.getLandingBornPos?.(); if (landing) return landing; return this._resolveMechanismHoldWorldPos(entityId, node); } private _playMechanismReviveRespawn( entityId: EntityId, node: Node, world: BattleSession['world'], onJumpLand: () => void, ): void { const holdPos = this._resolveMechanismHoldWorldPos(entityId, node); const monsterComp = node.getComponent(BATTLE_MONSTER_CCCLASS) as IBattleMonsterView & { mechanismImmobile?: boolean; playSpineAnim?: (name: string, loop: boolean) => void; state?: unknown; } | null; this._lockPresentMovement(entityId); const move = world.getComponent(entityId, MovementComp); if (move) { move.spawnProtected = true; move.presentLocked = true; move.reachedGoal = true; } Tween.stopAllByTarget(node); node.setWorldPosition(holdPos); node.opacity = 0; tween(node) .to(MECHANISM_FADE_IN_SEC, { opacity: resolveBagDragMonsterOpacity(255) }) .call(() => { if (!this._isMonsterSpawnVisualAlive(entityId, node)) return; if (monsterComp?.mechanismImmobile) { monsterComp.playSpineAnim?.(WITCH_IDLE_ANIM, true); } else { monsterComp?.playWalk?.(); } if (move) { move.spawnProtected = false; } this._clearSpawnProtected(entityId); onJumpLand(); }) .start(); } /** 空投怪复活:落点渐显后继续寻路攻墙 */ private _playParatroopReviveRespawn( entityId: EntityId, node: Node, world: BattleSession['world'], onJumpLand: () => void, ): void { const holdPos = this._resolveParatroopLandingWorldPos(entityId, node); const monsterComp = node.getComponent(BATTLE_MONSTER_CCCLASS) as IBattleMonsterView & { playWalk?: () => void; releaseFromReviveQueue?: () => void; } | null; this._lockPresentMovement(entityId); const move = world.getComponent(entityId, MovementComp); if (move) { move.spawnProtected = true; move.presentLocked = true; move.reachedGoal = false; } Tween.stopAllByTarget(node); // 对象池回收节点可能是 active=false,不激活则渐显看不到 node.active = true; node.setWorldPosition(holdPos); node.opacity = 0; tween(node) .to(MECHANISM_FADE_IN_SEC, { opacity: resolveBagDragMonsterOpacity(255) }) .call(() => { if (this._isMonsterSpawnVisualAlive(entityId, node)) { node.active = true; node.opacity = resolveBagDragMonsterOpacity(255); node.setWorldPosition(holdPos); monsterComp?.releaseFromReviveQueue?.(); monsterComp?.playWalk?.(); } // 复活相位必须收尾解锁,否则 presentLocked 卡住且可能停在 opacity=0 if (move && world.isAlive(entityId)) { move.spawnProtected = false; move.presentLocked = false; } this._clearSpawnProtected(entityId); onJumpLand(); }) .start(); } /** 营字等:从当前格抛物线跳到 path[0] */ private _wantsJumpToPath(entityId: EntityId): boolean { const meta = this._battle?.session.world.getComponent(entityId, UnitMetaComp); return !!meta?.spawnJumpToPath; } /** * 非出怪口召唤:落在实体当前格,不走出怪口跳跃。 * @param entityId 实体 * @param monsterNode 表现节点 * @param monsterComp 怪物视图 * @param gridPos 格心世界坐标 * @param scale 缩放 */ private _presentInPlaceSpawn( entityId: EntityId, monsterNode: Node, monsterComp: IBattleMonsterView, gridPos: Vec3, scale: number, ): void { const cellSize = this._map?.mapGridSize ?? BattleMapSpec.CELL_SIZE; const foot = applyMonsterFootOffset(gridPos, undefined, cellSize); monsterNode.worldPosition = foot; monsterNode.setScale(scale, scale, 1); monsterNode.opacity = resolveBagDragMonsterOpacity(255); monsterComp.playWalk(); this._unlockPresentMovement(entityId); this._scheduleClearSpawnProtected(entityId); } /** * 实体当前格的地图节点世界坐标(自身周围召唤用)。 * @param entityId 实体 */ private _entityGridWorldPos(entityId: EntityId): Vec3 | null { if (!this._map?.gridsInitialized) { console.error('[CocosBattleMonsterPresenter] map grids not ready, cannot spawn'); return null; } const tf = this._battle?.session.world.getComponent(entityId, TransformComp); if (!tf) { console.log('[CocosBattleMonsterPresenter] 自身位置召唤无 Transform', entityId); return null; } const grid = this._map.getMapGrid(tf.row, tf.col); if (!grid?.node?.isValid) { console.log('[CocosBattleMonsterPresenter] 自身位置召唤格节点缺失', entityId, tf.row, tf.col); return null; } return grid.node.worldPosition.clone(); } /** 出怪口世界坐标(与 WaveSpawner / 拓扑 spawnCell 对齐,避免 worldPos 命中非可走格) */ private resolveSpawnPos(spawnPort: number): Vec3 | null { if (!this._map?.gridsInitialized) { console.error('[CocosBattleMonsterPresenter] map grids not ready, cannot spawn'); return null; } const cell = this._battle?.session.topology.getSpawnCell(spawnPort); if (cell) { const fromTopo = this._map.getMapGrid(cell.row, cell.col); if (fromTopo?.isCanWalk && fromTopo.node?.isValid) { return fromTopo.node.worldPosition.clone(); } console.error('[CocosBattleMonsterPresenter] topology spawn cell not walkable', spawnPort, cell); } const posParent = this._map.spawnPointParent?.getChildByName(`spawnPoint_${spawnPort}`); if (!posParent?.isValid) { console.error('[CocosBattleMonsterPresenter] spawn point missing', spawnPort); return null; } const grid = this._map.getMapGridByWorldPos(posParent.worldPosition); if (!grid?.isCanWalk || !grid.node?.isValid) { console.error( '[CocosBattleMonsterPresenter] spawn point not on walkable grid', spawnPort, posParent.worldPosition, ); return null; } return grid.node.worldPosition.clone(); } private _isMonsterSpawnVisualAlive(entityId: EntityId, monsterNode: Node): boolean { return monsterNode.isValid && this._entityNodes.get(entityId) === monsterNode; } /** * Bridge skips position sync while entity is in _spawningEntities. * 起/落点与 syncMonsterNode 一致:出怪格→下一寻路格 lerp + 脚底 Y 偏移。 * @param onLand 落地并完成 present 解锁后回调(复活放行寻路用) * @param startWorld 非空时从该世界坐标起跳(营草字头) * @param scaleFrom 非空时由此缩放(营出生 / 技能召唤) * @param holdSec 起跳前停顿(营:放大完成后再停) * @param growSec 停在起点时第一段放大;无 scaleHold 时放到默认 * @param scaleHold 非空时第一段放到此值,停顿+跳跃再放到默认 * @param withJumpTrail 营字召唤跳出时挂拖尾预制 * @param jumpSpeed 跳跃速度相对默认;空或 ≤0 则 1 */ private playSpawnJump(node: Node, spawnRow: number, spawnCol: number, scale: number, entityId: EntityId, onLand?: () => void, startWorld?: Vec3, scaleFrom?: number, holdSec?: number, growSec?: number, scaleHold?: number, withJumpTrail?: boolean, jumpSpeed?: number): void { const map = this._map; if (!map) { // 早退必须解锁,否则 Boss/小怪会卡在 presentLocked+spawnProtected this._recoverSpawnPortalDelayFailed(entityId, node); onLand?.(); return; } const next = this._ensureNextPathCell(entityId); const cellSize = map.mapGridSize ?? BattleMapSpec.CELL_SIZE; const start = v3(); const end = v3(); let landRow = -1; let landCol = -1; if (next) { const endOk = resolveSpawnJumpVisualPosInto( map, spawnRow, spawnCol, next.row, next.col, 1, end, ); if (endOk) { landRow = next.row; landCol = next.col; } else { console.log('[CocosBattleMonsterPresenter] 跳出落点格节点缺失', spawnRow, spawnCol, next); } } if (startWorld) { if (!YingCampAnim.followGrass(start)) { start.set(startWorld.x, startWorld.y, startWorld.z); } } else if (next && landRow >= 0) { resolveSpawnJumpVisualPosInto(map, spawnRow, spawnCol, next.row, next.col, 0, start); } if (landRow < 0) { const spawnGrid = map.getMapGrid(spawnRow, spawnCol); if (!spawnGrid?.node?.isValid) { this._recoverSpawnPortalDelayFailed(entityId, node); onLand?.(); return; } if (!startWorld) applyMonsterFootOffset(spawnGrid.node.worldPosition, start, cellSize); end.set(start); } const spanX = Math.abs(end.x - start.x); const extraLift = spanX > cellSize * 1.5 ? Math.min(spanX * 0.25, cellSize * 1.5) : 0; const peakY = Math.max(start.y, end.y) + cellSize * 0.7 + extraLift; const peak = v3((start.x + end.x) * 0.5, peakY, start.z); const posProxy = { x: start.x, y: start.y }; const fromS = scaleFrom != null ? scaleFrom : scale * 0.45; const fromSy = scaleFrom != null ? scaleFrom : scale * 0.25; const scaleProxy = { x: fromS, y: fromSy }; const grow = scaleFrom != null && growSec != null && growSec > 0 ? growSec : 0; const hold = holdSec != null && holdSec > 0 ? holdSec : 0; const wait = grow + hold; const speed = jumpSpeed != null && jumpSpeed > 0 ? jumpSpeed : 1; const upSec = SPAWN_JUMP_UP_SEC / speed; const landSec = SPAWN_JUMP_LAND_SEC / speed; node.active = true; if (node.parent) node.setSiblingIndex(node.parent.children.length - 1); node.setWorldPosition(start.x, start.y, start.z); node.setScale(scaleProxy.x, scaleProxy.y, 1); node.opacity = resolveBagDragMonsterOpacity(255); Tween.stopAllByTarget(node); Tween.stopAllByTarget(posProxy); Tween.stopAllByTarget(scaleProxy); const holdProxy = { t: 0 }; if (wait > 0) { Tween.stopAllByTarget(holdProxy); tween(holdProxy) .to(wait, { t: 1 }, { onUpdate: () => this._stickYingGrassHold(entityId, node, posProxy, start), }) .call(() => { posProxy.x = start.x; posProxy.y = start.y; this._runSpawnJumpArc(node, entityId, posProxy, start, peak, end, landRow, landCol, onLand, withJumpTrail, upSec, landSec); }) .start(); } else { this._runSpawnJumpArc(node, entityId, posProxy, start, peak, end, landRow, landCol, onLand, withJumpTrail, upSec, landSec); } const scaleTw = tween(scaleProxy); if (grow > 0 && scaleHold != null) { scaleTw .to(grow, { x: scaleHold, y: scaleHold }, { easing: 'sineOut', onUpdate: () => this._applySpawnJumpScale(entityId, node, scaleProxy), }) .to(hold + upSec + landSec, { x: scale, y: scale }, { easing: 'sineOut', onUpdate: () => this._applySpawnJumpScale(entityId, node, scaleProxy), }) .start(); } else if (grow > 0) { scaleTw .to(grow, { x: scale, y: scale }, { easing: 'sineOut', onUpdate: () => this._applySpawnJumpScale(entityId, node, scaleProxy), }) .start(); } else if (scaleFrom != null) { if (wait > 0) scaleTw.delay(wait); scaleTw .to(upSec + landSec, { x: scale, y: scale }, { easing: 'sineOut', onUpdate: () => this._applySpawnJumpScale(entityId, node, scaleProxy), }) .start(); } else { if (wait > 0) scaleTw.delay(wait); scaleTw .to(upSec, { x: scale * 0.88, y: scale * 1.22 }, { easing: 'quadOut', onUpdate: () => this._applySpawnJumpScale(entityId, node, scaleProxy), }) .to(landSec, { x: scale, y: scale }, { easing: 'backOut', onUpdate: () => this._applySpawnJumpScale(entityId, node, scaleProxy), }) .start(); } } /** * 出怪跳跃缩放。 * @param entityId 实体 * @param node 表现节点 * @param scaleProxy 当前缩放 */ private _applySpawnJumpScale(entityId: EntityId, node: Node, scaleProxy: { x: number; y: number }): void { if (!this._isMonsterSpawnVisualAlive(entityId, node)) return; node.setScale(scaleProxy.x, scaleProxy.y, 1); } /** 停留时每帧贴草字头附件,避免停在骨轴心被营字挡住 */ private _stickYingGrassHold(entityId: EntityId, node: Node, posProxy: { x: number; y: number }, start: Vec3): void { if (!this._isMonsterSpawnVisualAlive(entityId, node)) return; if (YingCampAnim.followGrass(start)) { posProxy.x = start.x; posProxy.y = start.y; } node.setWorldPosition(posProxy.x, posProxy.y, start.z); } /** * 弦上匀速 + 二次抬升,整段一条抛物线(顶点瞬时 vy=0,无两段折角)。 * @param entityId 实体 * @param node 表现节点 * @param start 起跳 * @param peak 顶点(只用 Y,相对弦中点抬升) * @param end 落地 * @param t 0~1 * @param out 输出 */ private _applySpawnJumpParabola(entityId: EntityId, node: Node, start: Vec3, peak: Vec3, end: Vec3, t: number, out: { x: number; y: number }): void { if (!this._isMonsterSpawnVisualAlive(entityId, node)) return; const lift = 4 * t * (1 - t) * (peak.y - (start.y + end.y) * 0.5); out.x = start.x + (end.x - start.x) * t; out.y = start.y + (end.y - start.y) * t + lift; node.setWorldPosition(out.x, out.y, start.z); } /** * 停留结束后抛物线跳。 * @param upSec 升弧秒(与落弧合计为整段时长) * @param landSec 落弧秒 */ private _runSpawnJumpArc(node: Node, entityId: EntityId, posProxy: { x: number; y: number }, start: Vec3, peak: Vec3, end: Vec3, landRow: number, landCol: number, onLand?: () => void, withJumpTrail?: boolean, upSec = SPAWN_JUMP_UP_SEC, landSec = SPAWN_JUMP_LAND_SEC): void { if (!this._isMonsterSpawnVisualAlive(entityId, node)) { membershipDelete(this._spawningEntities, entityId); this._unlockPresentMovement(entityId); this._scheduleClearSpawnProtected(entityId); return; } if (withJumpTrail) this._attachYingJumpTrail(node); const jumpSec = upSec + landSec; const progress = { t: 0 }; tween(progress) .to(jumpSec, { t: 1 }, { onUpdate: () => this._applySpawnJumpParabola(entityId, node, start, peak, end, progress.t, posProxy), }) .call(() => { this._recycleYingJumpTrail(node); if (!this._isMonsterSpawnVisualAlive(entityId, node)) { membershipDelete(this._spawningEntities, entityId); this._unlockPresentMovement(entityId); this._scheduleClearSpawnProtected(entityId); return; } if (landRow >= 0) { this._commitSpawnJumpLandLogic(entityId, landRow, landCol); } membershipDelete(this._spawningEntities, entityId); this._unlockPresentMovement(entityId); node.setWorldPosition(end.x, end.y, end.z); node.opacity = resolveBagDragMonsterOpacity(255); const monster = node.getComponent(BATTLE_MONSTER_CCCLASS) as IBattleMonsterView | null; const data = monster?.Data; if (monster && data) { this._applyCampSkinToMonster(monster, data, entityId); monster.syncBloodPosToSkin?.(); monster.ensureBloodBar?.(); } this._scheduleClearSpawnProtected(entityId); onLand?.(); }) .start(); } /** 取 path[pathIndex];无路径时尝试 assignPath(复活放行) */ private _ensureNextPathCell(entityId: EntityId): { row: number; col: number } | null { const session = this._battle?.session; if (!session) return null; let move = session.world.getComponent(entityId, MovementComp); if (!move) return null; if (move.pathIndex >= move.path.length) { session.movement.assignPath(entityId); move = session.world.getComponent(entityId, MovementComp); if (!move || move.pathIndex >= move.path.length) return null; } const cell = move.path[move.pathIndex]; return { row: cell.row, col: cell.col }; } /** * 跳出落到下一寻路格后:逻辑坐标对齐并消耗该路点,避免解锁后回抽重走。 * @param entityId 实体 * @param row 落地行 * @param col 落地列 */ private _commitSpawnJumpLandLogic(entityId: EntityId, row: number, col: number): void { const session = this._battle?.session; if (!session) return; const move = session.world.getComponent(entityId, MovementComp); const tf = session.world.getComponent(entityId, TransformComp); if (!move || !tf) return; if (move.pathIndex >= move.path.length) return; const next = move.path[move.pathIndex]; if (next.row !== row || next.col !== col) return; tf.row = row; tf.col = col; const w = session.grid.cellToWorld({ row, col }); move.worldX = w.x; move.worldY = w.y; move.pathIndex += 1; session.spatial.updateEntity(entityId, { row, col }); } private createMonsterNode( monsterData: ITableMonster, pos: Vec3, parent: Node, entityId?: EntityId, mechanismDropPending?: MechanismDropPending, spawnPort?: number, ): Node | null { if (!parent?.isValid) { console.error('[CocosBattleMonsterPresenter] monster parent node invalid'); return null; } const monsterNode = gg.res.getNode('prefab/', 'monsterTemplate', GBundle.BattleGameMonster); if (!monsterNode?.isValid) { console.error('[CocosBattleMonsterPresenter] monsterTemplate not loaded'); return null; } if (mechanismDropPending) { monsterNode[MECHANISM_DROP_PENDING] = mechanismDropPending; } monsterNode.active = false; monsterNode.setParent(parent); monsterNode.worldPosition = pos; Tween.stopAllByTarget(monsterNode); const skinLoadGen = this._bumpMonsterSkinLoadGen(monsterNode); const skinNode = monsterNode.getChildByName('skin'); if (!skinNode) { console.error('[CocosBattleMonsterPresenter] monsterTemplate missing child "skin"'); this.recycleMonsterNode(monsterNode); return null; } const skinPrefabName = this._resolveMonsterSkinPrefabName(monsterData); if (!skinPrefabName) { console.error('[CocosBattleMonsterPresenter] monster skin prefab name missing', monsterData.id); this.recycleMonsterNode(monsterNode); return null; } const skin = gg.res.getNode('prefab/monster/', skinPrefabName, GBundle.BattleGameMonster); if (skin?.isValid) { this._attachMonsterSkin(skin, skinNode, monsterData, monsterNode, skinLoadGen, entityId, spawnPort); return monsterNode; } this._loadMonsterSkinAsync( skinPrefabName, skinNode, monsterData, monsterNode, skinLoadGen, entityId, spawnPort, ); return monsterNode; } /** * 敌方出怪口 = spawnPoint_1(攻本机,终点 wallTarget2)。 * @param spawnPort 出怪口 * @param entityId 回退读 UnitMetaComp.spawnPoint */ private _isEnemyCampMonster(entityId?: EntityId, spawnPort?: number): boolean { let port = spawnPort ?? 0; if (port <= 0 && entityId != null && this._battle) { const meta = this._battle.session.world.getComponent(entityId, UnitMetaComp); port = meta?.spawnPoint ?? 0; } if (port > 0) return port === LOCAL_ENEMY_SPAWN_PORT; if (entityId == null || !this._battle) return false; const team = this._battle.session.world.getComponent(entityId, TeamComp); if (!team) return false; const localId = this._battle.player?.playerId ?? 0; return team.ownerId === localId; } /** * 解析阵营皮肤名并套到已激活的 Monster.anim(须在节点 active 之后调用)。 * @param monster 视图 * @param monsterData 配表 * @param entityId 实体 * @param spawnPort 出怪口 */ private _applyCampSkinToMonster( monster: IBattleMonsterView, monsterData: ITableMonster, entityId?: EntityId, spawnPort?: number, ): void { const anim = monster.anim; if (!anim?.isValid) return; const override = this._resolveSpineSkinOverride(entityId); if (override) { this._setSkeletonCampSkin(anim, override, monsterData.id, spawnPort, this._isEnemyCampMonster(entityId, spawnPort)); monster.applyMarchSpineCache?.(); return; } const enemyCamp = this._isEnemyCampMonster(entityId, spawnPort); const preferred = MonsterDataManager.resolveBattleSpineSkin(monsterData, enemyCamp); if (!preferred) { console.log('[CocosBattleMonsterPresenter] 阵营皮肤名为空', monsterData.id, 'port=', spawnPort); return; } this._setSkeletonCampSkin(anim, preferred, monsterData.id, spawnPort, enemyCamp); monster.applyMarchSpineCache?.(); } /** 读取营召唤等写入的皮肤覆盖 */ private _resolveSpineSkinOverride(entityId?: EntityId): string { if (entityId == null || !this._battle) return ''; const meta = this._battle.session.world.getComponent(entityId, UnitMetaComp); return String(meta?.spineSkinOverride ?? '').trim(); } /** * 写入 defaultSkin 再 setSkin,避免节点激活时被预制体默认皮盖掉。 * @param anim Spine * @param preferred 首选皮肤名 * @param monsterId 配表 id(日志) * @param spawnPort 出怪口(日志) * @param enemyCamp 是否敌方口 */ private _setSkeletonCampSkin( anim: sp.Skeleton, preferred: string, monsterId: number, spawnPort: number | undefined, enemyCamp: boolean, ): void { if (anim.skeletonData) ensureSpineSkeletonDataRuntime(anim.skeletonData); const originalDefault = String((anim as sp.Skeleton & { defaultSkin?: string }).defaultSkin ?? ''); const candidates = withSkinNameVariants(preferred); const glyphSkin = enemyCamp ? '皮肤字2_1' : '皮肤字1_1'; const extra = withSkinNameVariants(glyphSkin); for (let e = 0; e < extra.length; e++) { let exists = false; for (let c = 0; c < candidates.length; c++) { if (candidates[c] === extra[e]) { exists = true; break; } } if (!exists) candidates.push(extra[e]); } if (originalDefault) { let hasDefault = false; for (let c = 0; c < candidates.length; c++) { if (candidates[c] === originalDefault) { hasDefault = true; break; } } if (!hasDefault) candidates.push(originalDefault); } for (let i = 0; i < candidates.length; i++) { const skinName = candidates[i]; if (!glyphSpineHasSkin(anim, skinName)) { if (i === 0) { console.log('[CocosBattleMonsterPresenter] 皮肤不存在', skinName, monsterId); } continue; } try { anim.setSkin(skinName); (anim as sp.Skeleton & { defaultSkin: string }).defaultSkin = skinName; anim.setToSetupPose?.(); anim.setSlotsToSetupPose?.(); if (skinName !== preferred) { console.log( '[CocosBattleMonsterPresenter] 首选皮肤不可用,改用', preferred, '->', skinName, monsterId, ); } return; } catch (err) { (anim as sp.Skeleton & { defaultSkin: string }).defaultSkin = originalDefault; console.log('[CocosBattleMonsterPresenter] setSkin 失败', skinName, monsterId, String(err)); } } (anim as sp.Skeleton & { defaultSkin: string }).defaultSkin = originalDefault; console.log( '[CocosBattleMonsterPresenter] 阵营皮肤全部失败', preferred, 'id=', monsterId, 'enemyCamp=', enemyCamp, 'port=', spawnPort, ); } /** @deprecated 保留给挂皮流程;正式套皮见 _applyCampSkinToMonster */ private _applyMonsterSpineSkin( anim: sp.Skeleton, monsterData: ITableMonster, entityId?: EntityId, spawnPort?: number, ): void { const override = this._resolveSpineSkinOverride(entityId); if (override) { this._setSkeletonCampSkin(anim, override, monsterData.id, spawnPort, this._isEnemyCampMonster(entityId, spawnPort)); return; } const enemyCamp = this._isEnemyCampMonster(entityId, spawnPort); const preferred = MonsterDataManager.resolveBattleSpineSkin(monsterData, enemyCamp); if (!preferred) return; this._setSkeletonCampSkin(anim, preferred, monsterData.id, spawnPort, enemyCamp); } /** 配表 icon → 预制体文件名 */ private _resolveMonsterSkinPrefabName(monsterData: ITableMonster): string | null { const raw = ChapterLevelBuilder.resolveMonsterPrefabFromRow(monsterData as ITableMonster) ?? monsterData.name; return ChapterLevelBuilder.resolveMonsterPrefabName(raw); } private _bumpMonsterSkinLoadGen(monsterNode: Node): number { const next = ((monsterNode[MONSTER_SKIN_LOAD_GEN] as number | undefined) ?? 0) + 1; monsterNode[MONSTER_SKIN_LOAD_GEN] = next; return next; } private _isMonsterSkinLoadCurrent(monsterNode: Node, skinLoadGen: number): boolean { return monsterNode.isValid && monsterNode.parent != null && monsterNode[MONSTER_SKIN_LOAD_GEN] === skinLoadGen; } private _activateMonsterNode(monsterNode: Node, skinLoadGen: number): void { if (!this._isMonsterSkinLoadCurrent(monsterNode, skinLoadGen)) return; monsterNode.active = true; } private _attachMonsterSkin( skin: Node, skinNode: Node, monsterData: ITableMonster, monsterNode: Node, skinLoadGen: number, entityId?: EntityId, spawnPort?: number, ): void { if (!this._isMonsterSkinLoadCurrent(monsterNode, skinLoadGen)) { if (skin['resKey']) { gg.res.putNode(skin); } else { skin.destroy(); } return; } skin.setParent(skinNode); skin.active = true; const animNode = skin.getChildByName('anim'); const anim = animNode?.getComponent(sp.Skeleton); const monsterComp = monsterNode.getComponent(BATTLE_MONSTER_CCCLASS) as IBattleMonsterView | null; if (anim && monsterComp) { anim.enabled = true; monsterComp.anim = anim; Tween.stopAllByTarget(anim); anim.color = color(255, 255, 255, 255); this._applyMonsterSpineSkin(anim, monsterData, entityId, spawnPort); const withBase = monsterComp as IBattleMonsterView & { setBaseAnimColor?: (c: Color) => void; ensureBloodBar?: () => void; }; withBase.setBaseAnimColor?.(anim.color.clone()); const pending = monsterNode[MECHANISM_DROP_PENDING] as MechanismDropPending | undefined; if (pending) { delete monsterNode[MECHANISM_DROP_PENDING]; this._activateMonsterNode(monsterNode, skinLoadGen); // 激活后再套一次,防止 onEnable 用预制体 defaultSkin 盖掉 this._applyMonsterSpineSkin(anim, monsterData, entityId, spawnPort); if (monsterComp) { this._startMechanismDropVisual(monsterNode, monsterComp, pending); } return; } monsterComp.playWalk(); const withBlood = monsterComp as IBattleMonsterView & { ensureBloodBar?: () => void; syncBloodPosToSkin?: () => void; }; if (monsterComp.Data?.id === monsterData.id) { withBlood.ensureBloodBar?.(); } monsterComp.scheduleOnce(() => withBlood.syncBloodPosToSkin?.(), 0); } this._activateMonsterNode(monsterNode, skinLoadGen); // 节点从 inactive→active 时 Spine 会按 defaultSkin 重置,激活后必须再套阵营皮 if (anim?.isValid) { this._applyMonsterSpineSkin(anim, monsterData, entityId, spawnPort); } } /** H5 ????????????? bundle.load ???? */ private _loadMonsterSkinAsync( model: string, skinNode: Node, monsterData: ITableMonster, monsterNode: Node, skinLoadGen: number, entityId?: EntityId, spawnPort?: number, ): void { const modelName = typeof model === 'string' ? model.trim() : ''; if (!modelName) { console.error('[CocosBattleMonsterPresenter] invalid monster model name', model); this._discardFailedSpawn(monsterNode, entityId); return; } const assetPath = `prefab/monster/${modelName}`; const bundleName = GBundle.BattleGameMonster; const resKey = bundleName + 'prefab/monster/' + modelName; const apply = (prefab: Prefab) => { if (!this._isMonsterSkinLoadCurrent(monsterNode, skinLoadGen) || !skinNode.isValid) return; const skin = instantiate(prefab); skin['resKey'] = resKey; this._attachMonsterSkin(skin, skinNode, monsterData, monsterNode, skinLoadGen, entityId, spawnPort); }; const loadFromBundle = (bundle: NonNullable>) => { const cached = bundle.get(assetPath, Prefab) as Prefab | null; if (cached) { apply(cached); return; } bundle.load(assetPath, Prefab, (err, prefab) => { if (err || !prefab) { console.error('[CocosBattleMonsterPresenter] monster skin prefab missing:', modelName, `(monsterId=${monsterData.id}, bundle=${bundleName})`, String(err)); this._discardFailedSpawn(monsterNode, entityId); return; } apply(prefab); }); }; const bundle = assetManager.getBundle(bundleName); if (bundle) { loadFromBundle(bundle); return; } assetManager.loadBundle(bundleName, (err, loaded) => { if (err || !loaded) { console.error('[CocosBattleMonsterPresenter] battleGameMonster bundle load failed', String(err)); this._discardFailedSpawn(monsterNode, entityId); return; } loadFromBundle(loaded); }); } private _discardFailedSpawn(monsterNode: Node, entityId?: EntityId): void { if (entityId != null) { this._entityNodes.delete(entityId); membershipDelete(this._spawningEntities, entityId); this._unlockPresentMovement(entityId); this._clearSpawnInvulnTimer(entityId); this._recycleEntityShadow(entityId, monsterNode); const session = this._battle?.session; if (session?.world.isAlive(entityId)) { session.lifecycle.despawn(entityId); } } else { this._recycleShadowOnNode(monsterNode); } this.recycleMonsterNode(monsterNode); } /** 营字跳出:把拖尾预制挂到怪身上 */ private _attachYingJumpTrail(monster: Node): void { const exist = monster.getChildByName(YING_JUMP_TRAIL_NAME); if (exist?.isValid) { this._resetYingJumpTrail(exist); return; } const trail = gg.res.getNode('prefab/', YING_JUMP_TRAIL_NAME, GBundle.BattleGame); if (!trail?.isValid) { console.error('[CocosBattleMonsterPresenter] 怪跳出拖尾未加载'); return; } trail.setParent(monster); trail.setPosition(0, 0, 0); trail.setSiblingIndex(0); this._resetYingJumpTrail(trail); } /** 清空 SuperTrail 采样后重新跟随 */ private _resetYingJumpTrail(trail: Node): void { trail.active = true; const st = trail.getComponent(SuperTrail); if (!st) { console.error('[CocosBattleMonsterPresenter] 怪跳出拖尾缺少 SuperTrail'); return; } st.clear(); st.resume(); } /** 回收跳出拖尾,避免脏进怪物池 */ private _recycleYingJumpTrail(monster: Node): void { if (!monster?.isValid) return; const trail = monster.getChildByName(YING_JUMP_TRAIL_NAME); if (!trail?.isValid) return; const st = trail.getComponent(SuperTrail); st?.clear(); st?.pause(); if (trail['resKey']) gg.res.putNode(trail); else trail.destroy(); } private recycleMonsterNode(monsterNode: Node): void { if (!monsterNode?.isValid) return; this._recycleYingJumpTrail(monsterNode); this._recycleShadowOnNode(monsterNode); Tween.stopAllByTarget(monsterNode); this._bumpMonsterSkinLoadGen(monsterNode); const view = monsterNode.getComponent(BATTLE_MONSTER_CCCLASS) as IBattleMonsterView & { clear?: () => void; } | null; view?.clear?.(); if (monsterNode['resKey']) { gg.res.putNode(monsterNode); } else { monsterNode.destroy(); } } /** * 每帧同步脚下阴影位置/显隐(跳出生、移动、挑飞隐藏)。 */ syncAllMonsterShadows(): void { const ids = this._shadowIdScratch; ids.length = 0; this._entityShadows.forEach(this._collectShadowId); for (let i = 0; i < ids.length; i++) { this._syncMonsterShadow(ids[i]); } } /** * 收集阴影实体 id。 * @param _shadow 阴影节点 * @param entityId 实体 */ private readonly _collectShadowId = (_shadow: Node, entityId: EntityId): void => { this._shadowIdScratch.push(entityId); }; /** * 确保 shadowLayer 在 mapLayer* 与 monsterLayer 之间。 * @param displayRoot ui_mapParentNode * @param monsterLayer 怪物层 */ private _ensureShadowLayer(displayRoot: Node, monsterLayer: Node): Node { let layer = displayRoot.getChildByName('shadowLayer'); if (!layer) { layer = new Node('shadowLayer'); layer.parent = displayRoot; } this._placeShadowBeforeMonster(layer, monsterLayer); return layer; } /** * 把阴影层插到怪物层正下方。 * @param shadowLayer 阴影层 * @param monsterLayer 怪物层 */ private _placeShadowBeforeMonster(shadowLayer: Node, monsterLayer: Node): void { if (!shadowLayer.isValid || !monsterLayer.isValid) return; const mIdx = monsterLayer.getSiblingIndex(); const sIdx = shadowLayer.getSiblingIndex(); if (sIdx < mIdx) { if (sIdx !== mIdx - 1) { shadowLayer.setSiblingIndex(mIdx - 1); } return; } shadowLayer.setSiblingIndex(mIdx); } /** * 为怪物挂脚下阴影。 * @param entityId 实体 * @param monsterNode 表现节点 * @param monsterData 配表(取皮肤预制体名定缩放) */ private _attachMonsterShadow(entityId: EntityId, monsterNode: Node, monsterData: ITableMonster): void { this._recycleEntityShadow(entityId, monsterNode); const skinName = this._resolveMonsterSkinPrefabName(monsterData) ?? String(monsterData.name ?? ''); if (MONSTER_SHADOW_SKIP[skinName]) { return; } const glow = this._wantsGlowShadow(entityId); const prefabName = glow ? SHADOW_PREFAB_GLOW : SHADOW_PREFAB_NORMAL; const shadow = gg.res.getNode('prefab/monster/', prefabName, GBundle.BattleGameMonster); if (!shadow?.isValid) { console.warn('[CocosBattleMonsterPresenter] 阴影预制体未加载', prefabName, entityId); return; } const view = monsterNode.getComponent(BATTLE_MONSTER_CCCLASS) as IBattleMonsterView | null; const sizeMul = view?.sizeMul != null && view.sizeMul > 0 ? view.sizeMul : 1; const display = this._resolveMonsterDisplayScale(monsterData, sizeMul); const worldWant = this._resolveShadowScale(skinName) * sizeMul; const local = display > 1e-4 ? worldWant / display : worldWant; this._attachFootShadowToHost(shadow, monsterNode, local, display); shadow.active = monsterNode.active; monsterNode[MONSTER_SHADOW_NODE] = shadow; this._entityShadows.set(entityId, shadow); } /** * 基地波次:普通阴影;营/技能额外召唤:发光阴影。 * @param entityId 实体 */ private _wantsGlowShadow(entityId: EntityId): boolean { const meta = this._battle?.session.world.getComponent(entityId, UnitMetaComp); return !!meta?.glowShadow; } /** * 按皮肤预制体名取阴影缩放。 * @param prefabName 怪物皮肤预制体名 */ private _resolveShadowScale(prefabName: string): number { const key = String(prefabName ?? '').trim(); if (!key) { console.warn('[CocosBattleMonsterPresenter] 阴影缩放:预制体名为空,用 1'); return 1; } const scale = MONSTER_SHADOW_SCALE[key]; if (scale == null || !(scale > 0)) { console.warn('[CocosBattleMonsterPresenter] 阴影缩放未配置,用 1', key); return 1; } return scale; } /** * 怪物表现缩放:Monster.size × sizeMul(营召唤 sizeMul=1.3)。 * @param monsterData 配表行 * @param sizeMul 相对体积倍率 */ private _resolveMonsterDisplayScale(monsterData: ITableMonster | null | undefined, sizeMul?: number): number { const size = Number(monsterData?.size); const mul = sizeMul != null && sizeMul > 0 ? sizeMul : 1; if (!(size > 0)) { console.warn('[CocosBattleMonsterPresenter] Monster.size 无效,用 1', monsterData?.id, monsterData?.size); return mul; } return size * mul; } /** * 同步单只怪阴影。子节点已跟随位移,仅处理仍挂在旧层上的遗留引用。 * @param entityId 实体 */ private _syncMonsterShadow(entityId: EntityId): void { const shadow = this._entityShadows.get(entityId); const monsterNode = this._entityNodes.get(entityId); if (!shadow?.isValid) return; if (!monsterNode?.isValid) { this._recycleEntityShadow(entityId, null); return; } if (shadow.parent !== monsterNode) { this._attachFootShadowToHost(shadow, monsterNode, shadow.scale.x, monsterNode.scale.y); } shadow.active = monsterNode.active; } /** * 把脚下阴影挂到怪节点下,位移随父节点。 * @param shadow 阴影 * @param host 怪根节点 * @param localScale 阴影本地缩放(已折算父节点目标缩放) * @param offsetScale 用于脚底偏移的父缩放(用最终体型,避免跳跃过程中偏移被放大) */ private _attachFootShadowToHost(shadow: Node, host: Node, localScale: number, offsetScale: number): void { shadow.setParent(host); shadow.setSiblingIndex(0); shadow.setScale(localScale, localScale, 1); const sy = Math.abs(offsetScale) > 1e-4 ? Math.abs(offsetScale) : 1; shadow.setPosition(0, MONSTER_SHADOW_OFFSET_Y / sy, 0); shadow.active = host.active; } /** * 回收实体阴影并从映射移除。 * @param entityId 实体 * @param monsterNode 表现节点(可空) */ private _recycleEntityShadow(entityId: EntityId, monsterNode: Node | null | undefined): void { const mapped = this._entityShadows.get(entityId); this._entityShadows.delete(entityId); if (monsterNode?.isValid) { const onNode = monsterNode[MONSTER_SHADOW_NODE] as Node | undefined; monsterNode[MONSTER_SHADOW_NODE] = null; if (onNode?.isValid && onNode !== mapped) { this._putShadowNode(onNode); } } if (mapped?.isValid) { this._putShadowNode(mapped); } } /** * 仅按节点上挂的引用回收阴影(无 entityId 路径)。 * @param monsterNode 表现节点 */ private _recycleShadowOnNode(monsterNode: Node): void { const shadow = monsterNode[MONSTER_SHADOW_NODE] as Node | undefined; monsterNode[MONSTER_SHADOW_NODE] = null; if (!shadow?.isValid) return; for (const [id, n] of this._entityShadows) { if (n === shadow) { this._entityShadows.delete(id); break; } } this._putShadowNode(shadow); } /** * 归还阴影节点到对象池。 * @param shadow 阴影节点 */ private _putShadowNode(shadow: Node): void { shadow.removeFromParent(); if (shadow['resKey']) { gg.res.putNode(shadow); } else { shadow.destroy(); } } /** 解绑时清空全部阴影 */ private _clearAllShadows(): void { for (const shadow of this._entityShadows.values()) { if (shadow?.isValid) { this._putShadowNode(shadow); } } this._entityShadows.clear(); const root = this._shadowRoot; if (!root?.isValid) return; const children = [...root.children]; for (const n of children) { if (n?.isValid) this._putShadowNode(n); } } /** * 降频写入 Sorting2D,替代每帧 setSiblingIndex。 */ tickMonsterYSort(): void { this._ySortFrame++; if (this._ySortFrame % DeviceProfile.monsterSortInterval !== 0) return; this.sortMonstersByY(); } /** 怪物按 Y 叠层:Y 小者更靠前。用 Sorting2D,不改 sibling。 */ sortMonstersByY(): void { const parent = this._monsterRoot; if (!parent?.isValid) return; const monsters = this._monsterSortScratch; monsters.length = 0; for (let i = 0; i < parent.children.length; i++) { const n = parent.children[i]; if (n.isValid && n.getComponent(BATTLE_MONSTER_CCCLASS)) { monsters.push(n); } } for (let i = 0; i < monsters.length; i++) { const node = monsters[i]; const view = node.getComponent(BATTLE_MONSTER_CCCLASS) as IBattleMonsterView | null; const order = view?.syncYSortOrder?.() ?? Math.round(-node.worldPosition.y / 16) * 4; const shadow = node[MONSTER_SHADOW_NODE] as Node | undefined; this._applyShadowSortOrder(shadow, order - 1); } } /** * 阴影 Sorting2D 略低于皮肤,保证画在脚底。 * @param shadow 阴影节点 * @param order sortingOrder */ private _applyShadowSortOrder(shadow: Node | undefined, order: number): void { if (!shadow?.isValid) return; const sorts = shadow.getComponentsInChildren(Sorting2D); for (let i = 0; i < sorts.length; i++) { sorts[i].sortingOrder = order; } } /** * 离屏怪物 Spine 停更(低配降 CPU/GPU);进屏自动恢复。 */ tickSpineLod(): void { if (!DeviceProfile.enableSpineLod) return; this._spineLodCounter++; if (this._spineLodCounter % DeviceProfile.spineLodInterval !== 0) return; const root = this._monsterRoot; if (!root?.isValid) return; const visible = view.getVisibleSize(); const halfW = visible.width * 0.55; const halfH = visible.height * 0.6; const cx = root.worldPosition.x; const cy = root.worldPosition.y; for (const [entityId, node] of this._entityNodes) { if (!node?.isValid) continue; if (membershipHas(this._spawningEntities, entityId)) continue; const comp = node.getComponent(BATTLE_MONSTER_CCCLASS) as { anim?: sp.Skeleton } | null; const sk = comp?.anim; if (!sk) continue; const p = node.worldPosition; const inView = Math.abs(p.x - cx) < halfW && Math.abs(p.y - cy) < halfH; sk.paused = !inView; } } /** 出怪口延迟回调失败:勿留 presentLocked + opacity=0 导致隐形巡逻 */ private _recoverSpawnPortalDelayFailed(entityId: EntityId, monsterNode: Node | null): void { membershipDelete(this._spawningEntities, entityId); if (monsterNode?.isValid) { Tween.stopAllByTarget(monsterNode); monsterNode.opacity = resolveBagDragMonsterOpacity(255); } this._unlockPresentMovement(entityId); this._clearSpawnInvulnTimer(entityId); this._clearSpawnProtected(entityId); } private _lockPresentMovement(entityId: EntityId): void { const move = this._battle?.session?.world.getComponent(entityId, MovementComp); if (!move) return; move.presentLocked = true; // 出生即进入保护:跳出中 + 落地后延时,不可索敌/命中 move.spawnProtected = true; } private _unlockPresentMovement(entityId: EntityId): void { const move = this._battle?.session?.world.getComponent(entityId, MovementComp); if (move) move.presentLocked = false; } private _clearSpawnProtected(entityId: EntityId): void { const move = this._battle?.session?.world.getComponent(entityId, MovementComp); if (move) move.spawnProtected = false; } private _scheduleClearSpawnProtected(entityId: EntityId): void { this._bumpSpawnInvulnGen(entityId); const gen = this._spawnInvulnGen.get(entityId)!; const session = this._battle?.session; if (!session) { this._clearSpawnProtected(entityId); return; } session.clock.schedule(SPAWN_INVULN_AFTER_LAND_SEC, () => { if (this._spawnInvulnGen.get(entityId) !== gen) return; this._spawnInvulnGen.delete(entityId); this._clearSpawnProtected(entityId); }); } /** @param keepProtected 为 true 时仅作废待执行的调度,不提前解除 spawnProtected(落地后重新计时用) */ private _clearSpawnInvulnTimer(entityId: EntityId, keepProtected = false): void { this._bumpSpawnInvulnGen(entityId); if (!keepProtected) { this._clearSpawnProtected(entityId); } } private _bumpSpawnInvulnGen(entityId: EntityId): void { const next = (this._spawnInvulnGen.get(entityId) ?? 0) + 1; this._spawnInvulnGen.set(entityId, next); } private _clearAllSpawnInvulnTimers(): void { for (const entityId of this._spawnInvulnGen.keys()) { this._bumpSpawnInvulnGen(entityId); } this._spawnInvulnGen.clear(); } } /** 出怪后对齐 MX 逻辑格:以拓扑出怪口为准,避免 worldPos 反查落到邻格 */ function alignMxEntityToMapGrid( session: BattleSession, map: MapManager, entityId: EntityId, spawnPort: number, ): void { const cell = session.topology.getSpawnCell(spawnPort); if (!cell) { console.log('[CocosBattleMonsterPresenter] alignMxEntity: 出怪口无拓扑格', spawnPort); return; } const grid = map.getMapGrid(cell.row, cell.col); if (!grid) { console.log('[CocosBattleMonsterPresenter] alignMxEntity: 地图格缺失', cell.row, cell.col); return; } const tf = session.world.getComponent(entityId, TransformComp); const move = session.world.getComponent(entityId, MovementComp); if (!tf || !move) return; tf.row = grid.Row; tf.col = grid.Col; const w = session.grid.cellToWorld({ row: grid.Row, col: grid.Col }); move.worldX = w.x; move.worldY = w.y; session.movement.assignPath(entityId); }