You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
749 lines
27 KiB
749 lines
27 KiB
|
3 weeks ago
|
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<ITableLevel>(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;
|
||
|
|
}
|