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.
 
 
 
 
AdLike2048/assets/script/battle/view/CocosBattleMonsterPresenter.ts

2242 lines
89 KiB

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 {
SummonJumpPending,
SUMMON_JUMP_GROW_SEC,
SUMMON_JUMP_HOLD_SEC,
SUMMON_JUMP_SCALE_FROM,
} from './SummonJumpPending';
/** 出怪跳出:出怪口格心起跳,落到下一寻路点脚底 */
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<EntityId, Node>();
private _entityShadows = new Map<EntityId, Node>();
private _spawningEntities: MembershipSet<EntityId> = createMembershipSet();
/** 挑飞等占用:EntityKilled 不立刻回收 */
private _heldVisuals: MembershipSet<EntityId> = createMembershipSet();
/** 占用期间已死亡,释放后回收 */
private _pendingRecycle: MembershipSet<EntityId> = createMembershipSet();
/** 落地后延时解除 spawnProtected 的世代号(BattleClock 调度,替代 setTimeout) */
private _spawnInvulnGen = new Map<EntityId, number>();
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<EntityId, Node> {
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 jumpToPath = this._wantsJumpToPath(entityId);
const look = jumpToPath ? SummonJumpPending.peekPendingLook() : null;
const sizeMul = look?.scaleMul != null && look.scaleMul > 0 ? look.scaleMul : 1;
monsterComp.sizeMul = sizeMul;
const scale = this._resolveMonsterDisplayScale(monsterData, sizeMul);
if (jumpToPath) {
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 jumpStart = v3();
const hasJumpStart = SummonJumpPending.takeJumpStart(entityId, jumpStart);
if (!hasJumpStart) {
console.log('[CocosBattleMonsterPresenter] 技能召唤跳无出生点', entityId);
}
this.playSpawnJump(
monsterNode,
tf.row,
tf.col,
scale,
entityId,
undefined,
hasJumpStart ? jumpStart : undefined,
look ? scale * look.scaleFrom : scale * SUMMON_JUMP_SCALE_FROM,
look?.holdSec ?? SUMMON_JUMP_HOLD_SEC,
look?.growSec ?? SUMMON_JUMP_GROW_SEC,
look?.scaleHold != null ? scale * look.scaleHold : undefined,
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 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, 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) {
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)
.delay(wait)
.call(() => {
posProxy.x = start.x;
posProxy.y = start.y;
this._runSpawnJumpArc(node, entityId, posProxy, start, peak, end, landRow, landCol, onLand, upSec, landSec);
})
.start();
} else {
this._runSpawnJumpArc(node, entityId, posProxy, start, peak, end, landRow, landCol, onLand, 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);
}
/**
* 弦上匀速 + 二次抬升,整段一条抛物线(顶点瞬时 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, 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;
}
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(() => {
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<ReturnType<typeof assetManager.getBundle>>) => {
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 recycleMonsterNode(monsterNode: Node): void {
if (!monsterNode?.isValid) return;
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。
* @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);
}