import { Label, Node, Tween, tween, v3, Vec3, sp } from 'cc'; import type { Cell } from 'db://assets/mx/module/battle'; import { AiHeadlessBagHost } from '../../battle/ai/runtime/AiHeadlessBagHost'; import { resolveSpawnJumpVisualPosInto } from '../../battle/bridge/CocosBattleMovementBridge'; import { applyMonsterFootOffset, BattleMapSpec } from '../../battle/map/BattleMapSpec'; import { LOCAL_ENEMY_SPAWN_PORT, resolvePathCellsFromSpawnPort, } from '../../battle/map/MapPathQueries'; import type { MapManager } from '../../battle/map/legacy/MapManager'; import { BattleSpinePlayback } from '../../battle/presentation/BattleSpinePlayback'; import type { ProjectBattleHandle } from '../../battle/runtime/BattleRuntime'; import { schedulePresentation } from '../../battle/skill/BattlePresentationClock'; import { CocosBattleMonsterPresenter, SPAWN_PRESENT_SEC } from '../../battle/view/CocosBattleMonsterPresenter'; import { glyphSpineHasSkin } from '../../battle/view/GlyphSpineSkin'; import { BattleAudio } from '../../battle/audio/BattleAudio'; import { GBundle } from '../../game/ConfigRes'; import { TableNames } from '../../game/ConfigTableData'; import { ensureSpineSkeletonDataRuntime } from 'db://assets/mx/module/lang/spineRuntimePatch'; /** beginMonster 预制体目录 */ const BEGIN_MONSTER_PREFAB_PATH = 'prefab/beginMonster/'; /** 表演怪 anim 节点 */ const ANIM_NODE = 'anim'; /** 表演怪号角节点 */ const HORN_NODE = 'haojiao'; /** 我方开场气泡预制体(battleMap/prefab/地图/) */ const SELF_TALK_PREFAB = 'self'; /** 敌方开场气泡预制体(battleMap/prefab/地图/) */ const OTHER_TALK_PREFAB = 'other'; /** self 气泡相对出怪口偏移 */ const SELF_TALK_OFFSET = { x: -40, y: 20 }; /** other 气泡相对出怪口偏移 */ const OTHER_TALK_OFFSET = { x: 40, y: -20 }; /** 气泡内文本 label */ const LABL_NODE = 'labl'; /** 走路 */ const WALK_ANIM = '走路'; /** 待机 */ const IDLE_ANIM = '待机'; /** 拿出号角 */ const HORN_TAKE_ANIM = '拿出号角'; /** 吹号角 */ const HORN_BLOW_ANIM = '吹号角'; /** 收起号角 */ const HORN_PUT_AWAY_ANIM = '收起号角'; /** 配表:2 格移动基准 */ const MOVE_BASE_GRIDS = 2; /** 吹号角停留(秒) */ const HORN_BLOW_HOLD_SEC = 2; /** 开局后延迟多久再开始表演流程(秒) */ const START_FLOW_DELAY_SEC = 3; /** 出怪口动画先播,再延迟此时间后开始跳出 */ const SPAWN_PORTAL_LEAD_SEC = 0.2; /** 出怪跳出上升段 */ const SPAWN_JUMP_UP_SEC = 0.2; /** 出怪跳出落地段 */ const SPAWN_JUMP_LAND_SEC = 0.28; /** 我方出怪口 */ const ALLY_SPAWN_PORT = 1; /** 表演怪专用层(与 monsterLayer 分离,不参与战斗 targeting) */ const PERFORM_LAYER_NAME = 'startMonsterLayer'; /** 表演节点名前缀 */ const PERFORM_NODE_NAME = 'startMonsterPerform'; /** 单侧表演上下文 */ type StartMonsterSideCtx = { /** 出怪口 */ port: number; /** 防守方 playerId */ defenderPlayerId: number; /** anim 皮肤名 */ skinName: string; /** 沿进路前缀路径 */ path: Cell[]; }; /** 单侧表演运行时 */ type StartMonsterActor = { /** 根节点 */ node: Node; /** 小怪 anim */ anim: sp.Skeleton; /** 号角 anim */ horn: sp.Skeleton; /** 出怪口挂载的开场气泡(self / other 预制体实例) */ talkBubble: Node | null; /** 开场话候选(starttalk 按 | 拆分) */ startTalks: string[]; /** 路径 */ path: Cell[]; /** 移动距离(格) */ distance: number; /** 两格移动时长(秒) */ moveTimeForTwoGrids: number; }; /** * 开局表演:双方出怪口跳出 beginMonster,沿进路前进、吹号角、再退回。 * 表演结束后再开战斗出怪。 */ export class BattleGameStartMonsterPresenter { private _gen = 0; private _battle: ProjectBattleHandle | null = null; private _map: MapManager | null = null; private _monsterPresenter: CocosBattleMonsterPresenter | null = null; private _performLayer: Node | null = null; private _pendingDone: (() => void) | null = null; private _actors: StartMonsterActor[] = []; private _remaining = 0; /** 开局表演是否进行中 */ isPerforming(): boolean { return this._remaining > 0 || this._battle?.isStartMonsterPerforming?.() === true; } /** 中止表演并回收节点 */ unbind(): void { this._gen++; this._battle?.setStartMonsterPerforming(false); this._battle = null; this._map = null; this._monsterPresenter = null; this._performLayer = null; this._pendingDone = null; this._remaining = 0; for (let i = 0; i < this._actors.length; i++) { this._recycleActor(this._actors[i]); } this._actors.length = 0; } /** * 若关卡配置了 startmonster,则播放开局表演;否则直接回调。 * @param battle 战斗句柄 * @param map 地图 * @param displayRoot 地图 UI 根(ui_mapParentNode) * @param monsterPresenter 怪物表现(复用 shadowLayer / 阴影预制体) * @param onComplete 表演怪回到出怪口并回收后 / 跳过后 */ tryPlay( battle: ProjectBattleHandle, map: MapManager, displayRoot: Node, monsterPresenter: CocosBattleMonsterPresenter, onComplete: () => void, ): void { this.unbind(); this._battle = battle; this._monsterPresenter = monsterPresenter; const levelRow = gg.data.table.getTableData(TableNames.Level, battle.levelConfigId); const prefabName = typeof levelRow?.startmonster === 'string' ? levelRow.startmonster.trim() : ''; const distance = Math.max(0, Math.floor(Number(levelRow?.startdistance) || 0)); const moveTimeForTwoGrids = Math.max(0.01, Number(levelRow?.startmovetime) || 0.01); const startTalks = this._parseStartTalks(levelRow); if (!prefabName || distance <= 0) { onComplete(); return; } if (!map?.gridsInitialized) { console.log('[StartMonster] 地图未就绪,跳过表演'); onComplete(); return; } const performLayer = this._ensurePerformLayer(displayRoot); if (!performLayer?.isValid) { console.log('[StartMonster] startMonsterLayer 缺失,跳过表演'); onComplete(); return; } this._performLayer = performLayer; battle.setStartMonsterPerforming(true); const sides: StartMonsterSideCtx[] = [ { port: LOCAL_ENEMY_SPAWN_PORT, defenderPlayerId: 0, skinName: `${prefabName}2`, path: resolvePathCellsFromSpawnPort(map, battle.session, LOCAL_ENEMY_SPAWN_PORT, 0, distance), }, { port: ALLY_SPAWN_PORT, defenderPlayerId: AiHeadlessBagHost.AI_PLAYER_ID, skinName: prefabName, path: resolvePathCellsFromSpawnPort( map, battle.session, ALLY_SPAWN_PORT, AiHeadlessBagHost.AI_PLAYER_ID, distance, ), }, ]; const gen = ++this._gen; this._map = map; this._pendingDone = onComplete; this._remaining = 0; this._actors.length = 0; schedulePresentation(START_FLOW_DELAY_SEC, () => { if (gen !== this._gen) return; this._beginPerformFlow(gen, map, performLayer, prefabName, distance, moveTimeForTwoGrids, sides, startTalks); }); } /** * 延迟结束后:生成表演怪并开始单侧流程。 * @param gen 世代号(unbind 后失效) */ private _beginPerformFlow( gen: number, map: MapManager, performLayer: Node, prefabName: string, distance: number, moveTimeForTwoGrids: number, sides: readonly StartMonsterSideCtx[], startTalks: string[], ): void { if (gen !== this._gen) return; const battle = this._battle; if (!battle || !map?.gridsInitialized || !performLayer?.isValid) { this._abortPerformWithoutActors(); return; } for (let i = 0; i < sides.length; i++) { const side = sides[i]; if (side.path.length < distance + 1) { console.log('[StartMonster] 进路不足', side.port, distance, side.path.length); continue; } const actor = this._spawnActor(performLayer, prefabName, side, distance, moveTimeForTwoGrids, startTalks); if (!actor) continue; this._actors.push(actor); this._remaining++; this._playSide(gen, map, side.port, actor); } if (this._remaining <= 0) { this._abortPerformWithoutActors(); } } /** 延迟后无法开演:解除挂起并回调 */ private _abortPerformWithoutActors(): void { const battle = this._battle; const done = this._pendingDone; this._pendingDone = null; this._map = null; this._performLayer = null; this._monsterPresenter = null; this._battle = null; battle?.setStartMonsterPerforming(false); done?.(); } /** 获取或创建表演专用层 */ private _ensurePerformLayer(displayRoot: Node): Node | null { if (!displayRoot?.isValid) return null; let layer = displayRoot.getChildByName(PERFORM_LAYER_NAME); if (!layer) { layer = new Node(PERFORM_LAYER_NAME); layer.layer = displayRoot.layer; layer.parent = displayRoot; layer.setPosition(0, 0, 0); } return layer; } /** 单侧:出怪口特效 → 跳出 → 前进 → 号角 → 退回 */ private _playSide(gen: number, map: MapManager, port: number, actor: StartMonsterActor): void { map.playSpawnPointEffect(port, SPAWN_PRESENT_SEC); actor.node.opacity = 0; this._syncActorShadow(actor); Tween.stopAllByTarget(actor.node); // 不用节点当 tween 目标:节点被回收时 Cocos 会停掉 tween,回调不走,startCombat 永远等不到 const lead = { t: 0 }; tween(lead) .delay(SPAWN_PORTAL_LEAD_SEC) .call(() => { this._afterPortalLead(gen, map, port, actor); }) .start(); } /** * 出怪口特效过后开始跳出。节点已失效则直接结束该侧,避免卡死开局出怪。 * @param gen 世代号 * @param map 地图 * @param port 出怪口 * @param actor 表演怪 */ private _afterPortalLead(gen: number, map: MapManager, port: number, actor: StartMonsterActor): void { if (gen !== this._gen) return; if (!actor.node?.isValid) { this._finishActor(actor); return; } actor.talkBubble = this._attachTalkBubble(map, port, actor.startTalks); this._showStartTalk(actor); this._playSpawnJump(map, actor, () => { if (gen !== this._gen) return; this._walkPath(actor, true, () => { if (gen !== this._gen) return; this._playHornSequence(actor, () => { if (gen !== this._gen) return; this._walkPath(actor, false, () => { if (gen !== this._gen) return; this._snapActorToSpawn(actor); this._finishActor(actor); }); }); }); }); } /** 实例化 beginMonster 并设置 anim 皮肤 */ private _spawnActor( parent: Node, prefabName: string, side: StartMonsterSideCtx, distance: number, moveTimeForTwoGrids: number, startTalks: string[], ): StartMonsterActor | null { let node: Node | null = null; try { node = gg.res.getNode(BEGIN_MONSTER_PREFAB_PATH, prefabName, GBundle.BattleGameMonster); } catch (err) { console.log('[StartMonster] 预制体加载失败', prefabName, String(err)); return null; } if (!node?.isValid) { console.log('[StartMonster] 预制体缺失', prefabName); return null; } const animNode = node.getChildByName(ANIM_NODE); const hornNode = node.getChildByName(HORN_NODE); const anim = animNode?.getComponent(sp.Skeleton) ?? null; const horn = hornNode?.getComponent(sp.Skeleton) ?? null; if (!anim?.isValid || !horn?.isValid) { console.log('[StartMonster] anim/haojiao 缺失', prefabName); gg.res.putNode(node); return null; } if (!this._applyAnimSkin(anim, side.skinName)) { gg.res.putNode(node); return null; } if (side.port === LOCAL_ENEMY_SPAWN_PORT) { hornNode.setScale(-1, 1, 1); } else { hornNode.setScale(1, 1, 1); } hornNode.active = false; BattleSpinePlayback.resetTracks(anim); BattleSpinePlayback.resetTracks(horn); BattleSpinePlayback.play(anim, IDLE_ANIM, { loop: true, optional: true }); node.name = PERFORM_NODE_NAME; node.setParent(parent); node.active = true; node.setSiblingIndex(parent.children.length - 1); this._monsterPresenter?.attachDecorFootShadow(node, prefabName); return { node, anim, horn, talkBubble: null, startTalks, path: side.path, distance, moveTimeForTwoGrids, }; } /** * 表演怪出来时:在出怪口挂载开场气泡预制体(随后立即显示)。 * spawnPoint_1 → self;spawnPoint_2 → other。 */ private _attachTalkBubble(map: MapManager, port: number, startTalks: string[]): Node | null { const spawnNode = map.spawnPointParent?.getChildByName(`spawnPoint_${port}`); if (!spawnNode?.isValid) { console.log('[StartMonster] 出怪口缺失,无法挂载开场气泡', port); return null; } const prefabName = port === ALLY_SPAWN_PORT ? SELF_TALK_PREFAB : OTHER_TALK_PREFAB; let bubble: Node | null = null; try { bubble = gg.res.getNode(BattleMapSpec.PREFAB_DIR, prefabName, GBundle.BattleMap); } catch (err) { console.log('[StartMonster] 开场气泡预制体加载失败', prefabName, String(err)); return null; } if (!bubble?.isValid) { console.log('[StartMonster] 开场气泡预制体缺失', prefabName); return null; } if (startTalks.length <= 0) { console.log('[StartMonster] 关卡 starttalk 为空', port); } bubble.setParent(spawnNode); const offset = port === ALLY_SPAWN_PORT ? SELF_TALK_OFFSET : OTHER_TALK_OFFSET; bubble.setPosition(offset.x, offset.y, 0); bubble.active = false; return bubble; } /** 同步表演怪脚下阴影 */ private _syncActorShadow(actor: StartMonsterActor): void { const node = actor.node; if (!node?.isValid) return; this._monsterPresenter?.syncDecorFootShadow(node); } /** 退回出怪口后对齐格心脚底 */ private _snapActorToSpawn(actor: StartMonsterActor): void { const foot = this._cellFoot(actor.path[0]); const node = actor.node; if (!foot || !node?.isValid) return; Tween.stopAllByTarget(node); node.setWorldPosition(foot); BattleSpinePlayback.play(actor.anim, IDLE_ANIM, { loop: true, optional: true }); actor.horn.node.active = false; this._syncActorShadow(actor); } /** 写入 anim 皮肤 */ private _applyAnimSkin(anim: sp.Skeleton, skinName: string): boolean { if (!glyphSpineHasSkin(anim, skinName)) { console.log('[StartMonster] 皮肤不存在', skinName); return false; } if (anim.skeletonData) ensureSpineSkeletonDataRuntime(anim.skeletonData); try { anim.setSkin(skinName); (anim as sp.Skeleton & { defaultSkin: string }).defaultSkin = skinName; anim.setSlotsToSetupPose(); return true; } catch (err) { console.log('[StartMonster] setSkin 失败', skinName, String(err)); return false; } } /** 出怪口跳出:落在 path[1] */ private _playSpawnJump(map: MapManager, actor: StartMonsterActor, onLand: () => void): void { const node = actor.node; const spawnCell = actor.path[0]; const landCell = actor.path.length > 1 ? actor.path[1] : spawnCell; const cellSize = map.mapGridSize ?? BattleMapSpec.CELL_SIZE; const start = v3(); const end = v3(); const startOk = resolveSpawnJumpVisualPosInto( map, spawnCell.row, spawnCell.col, landCell.row, landCell.col, 0, start, ); const endOk = resolveSpawnJumpVisualPosInto( map, spawnCell.row, spawnCell.col, landCell.row, landCell.col, 1, end, ); if (!startOk || !endOk) { console.log('[StartMonster] 跳出坐标无效', spawnCell, landCell); onLand(); return; } const peakY = Math.max(start.y, end.y) + cellSize * 0.7; const peak = v3((start.x + end.x) * 0.5, peakY, start.z); const posProxy = { x: start.x, y: start.y }; const scale = 1; const scaleProxy = { x: scale * 0.45, y: scale * 0.25 }; node.active = true; node.setWorldPosition(start.x, start.y, start.z); node.setScale(scaleProxy.x, scaleProxy.y, 1); node.opacity = 255; this._syncActorShadow(actor); BattleSpinePlayback.play(actor.anim, WALK_ANIM, { loop: true, optional: true }); Tween.stopAllByTarget(node); Tween.stopAllByTarget(posProxy); Tween.stopAllByTarget(scaleProxy); tween(scaleProxy) .to(SPAWN_JUMP_UP_SEC, { x: scale * 0.88, y: scale * 1.22 }, { onUpdate: () => { if (!node.isValid) return; node.setScale(scaleProxy.x, scaleProxy.y, 1); }, }) .to(SPAWN_JUMP_LAND_SEC, { x: scale, y: scale }, { onUpdate: () => { if (!node.isValid) return; node.setScale(scaleProxy.x, scaleProxy.y, 1); }, }) .start(); tween(posProxy) .to(SPAWN_JUMP_UP_SEC, { x: peak.x, y: peak.y }, { onUpdate: () => { if (!node.isValid) return; node.setWorldPosition(posProxy.x, posProxy.y, start.z); this._syncActorShadow(actor); }, }) .to(SPAWN_JUMP_LAND_SEC, { x: end.x, y: end.y }, { onUpdate: () => { if (!node.isValid) return; node.setWorldPosition(posProxy.x, posProxy.y, start.z); this._syncActorShadow(actor); }, }) .call(() => { if (!node.isValid) { onLand(); return; } this._syncActorShadow(actor); onLand(); }) .start(); } /** * 沿 path 逐格移动。 * @param forward true=前进至 path[distance];false=退回 path[0] */ private _walkPath(actor: StartMonsterActor, forward: boolean, onDone: () => void): void { const segments = this._buildWalkSegments(actor, forward); if (segments.length <= 0) { onDone(); return; } const secPerGrid = actor.moveTimeForTwoGrids / MOVE_BASE_GRIDS; BattleSpinePlayback.play(actor.anim, WALK_ANIM, { loop: true, optional: true }); actor.horn.node.active = false; this._walkSegment(actor, segments, 0, secPerGrid, onDone); } /** 构建逐格移动段 */ private _buildWalkSegments(actor: StartMonsterActor, forward: boolean): Vec3[] { const out: Vec3[] = []; const path = actor.path; const dist = actor.distance; if (forward) { const startIdx = Math.min(1, path.length - 1); for (let i = startIdx + 1; i <= dist; i++) { const foot = this._cellFoot(path[i]); if (foot) out.push(foot); } return out; } for (let i = dist - 1; i >= 0; i--) { const foot = this._cellFoot(path[i]); if (foot) out.push(foot); } return out; } /** 逐段 tween */ private _walkSegment( actor: StartMonsterActor, segments: Vec3[], index: number, secPerGrid: number, onDone: () => void, ): void { if (index >= segments.length) { BattleSpinePlayback.play(actor.anim, IDLE_ANIM, { loop: true, optional: true }); onDone(); return; } const node = actor.node; if (!node.isValid) { onDone(); return; } const target = segments[index]; const fromX = node.worldPosition.x; this._faceWalkDirection(node, fromX, target.x); const posProxy = { x: node.worldPosition.x, y: node.worldPosition.y }; Tween.stopAllByTarget(posProxy); tween(posProxy) .to(secPerGrid, { x: target.x, y: target.y }, { onUpdate: () => { if (!node.isValid) return; node.setWorldPosition(posProxy.x, posProxy.y, node.worldPosition.z); this._syncActorShadow(actor); }, }) .call(() => { if (!node.isValid) { onDone(); return; } this._syncActorShadow(actor); this._walkSegment(actor, segments, index + 1, secPerGrid, onDone); }) .start(); } /** 号角:拿出 → 吹 2 秒 → 收起 */ private _playHornSequence(actor: StartMonsterActor, onDone: () => void): void { const anim = actor.anim; const horn = actor.horn; const hornNode = horn.node; if (!anim.isValid || !horn.isValid) { this._hideStartTalk(actor); onDone(); return; } BattleSpinePlayback.play(anim, IDLE_ANIM, { loop: true, optional: true }); hornNode.active = true; BattleSpinePlayback.resetTracks(horn); BattleSpinePlayback.play(horn, HORN_TAKE_ANIM, { loop: false, onComplete: () => { if (!horn.isValid) { this._hideStartTalk(actor); onDone(); return; } BattleSpinePlayback.play(horn, HORN_BLOW_ANIM, { loop: true }); BattleAudio.playStartMonsterHorn(); schedulePresentation(HORN_BLOW_HOLD_SEC, () => { if (!horn.isValid) { this._hideStartTalk(actor); onDone(); return; } BattleSpinePlayback.clearPendingComplete(horn); BattleSpinePlayback.play(horn, HORN_PUT_AWAY_ANIM, { loop: false, onComplete: () => { if (hornNode.isValid) hornNode.active = false; this._hideStartTalk(actor); onDone(); }, }); }); }, }); } /** 关卡 starttalk 按 | 拆分为候选话 */ private _parseStartTalks(levelRow: ITableLevel | null): string[] { const raw = typeof levelRow?.starttalk === 'string' ? levelRow.starttalk : ''; return raw.split('|').map(s => s.trim()).filter(s => s.length > 0); } /** 从候选话中随机取一条 */ private _pickStartTalk(talks: readonly string[]): string { if (!talks || talks.length <= 0) return ''; return talks[Math.floor(Math.random() * talks.length)]; } /** 表演怪出来时显示出怪口气泡并写入随机开场话 */ private _showStartTalk(actor: StartMonsterActor): void { const bubble = actor.talkBubble; if (!bubble?.isValid) return; bubble.active = true; const label = bubble.getChildByName(LABL_NODE)?.getComponent(Label) ?? null; if (!label) { console.log('[StartMonster] 气泡 labl 缺失'); return; } label.string = this._pickStartTalk(actor.startTalks); } /** 收起号角后隐藏出怪口开场气泡 */ private _hideStartTalk(actor: StartMonsterActor): void { if (actor.talkBubble?.isValid) actor.talkBubble.active = false; } /** 格心 → 脚底世界坐标 */ private _cellFoot(cell: Cell): Vec3 | null { const map = this._map; if (!map) return null; const grid = map.getMapGrid(cell.row, cell.col); if (!grid?.node?.isValid) return null; return applyMonsterFootOffset(grid.node.worldPosition, undefined, map.mapGridSize); } /** 按移动方向翻转 */ private _faceWalkDirection(node: Node, fromX: number, toX: number): void { const sy = node.scale.y; const absX = Math.abs(node.scale.x) || 1; node.setScale(toX >= fromX ? absX : -absX, sy, 1); } /** 单侧结束并检查全局完成 */ private _finishActor(actor: StartMonsterActor): void { this._recycleActor(actor); for (let i = 0; i < this._actors.length; i++) { if (this._actors[i] === actor) { this._actors.splice(i, 1); break; } } this._remaining = Math.max(0, this._remaining - 1); if (this._remaining > 0) return; const battle = this._battle; const done = this._pendingDone; this._pendingDone = null; this._map = null; this._performLayer = null; this._monsterPresenter = null; this._battle = null; battle?.setStartMonsterPerforming(false); done?.(); } /** 回收节点 */ private _recycleActor(actor: StartMonsterActor): void { const node = actor.node; this._recycleTalkBubble(actor); if (!node?.isValid) return; this._monsterPresenter?.recycleDecorFootShadow(node); Tween.stopAllByTarget(node); if (actor.anim?.isValid) BattleSpinePlayback.clearPendingComplete(actor.anim); if (actor.horn?.isValid) BattleSpinePlayback.clearPendingComplete(actor.horn); gg.res.putNode(node); } /** 回收出怪口开场气泡 */ private _recycleTalkBubble(actor: StartMonsterActor): void { const bubble = actor.talkBubble; actor.talkBubble = null; if (!bubble?.isValid) return; bubble.active = false; bubble.removeFromParent(); gg.res.putNode(bubble); } } let _inst: BattleGameStartMonsterPresenter | null = null; /** 开局表演单例 */ export function getBattleGameStartMonsterPresenter(): BattleGameStartMonsterPresenter { if (!_inst) _inst = new BattleGameStartMonsterPresenter(); return _inst; }