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.
2355 lines
93 KiB
2355 lines
93 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.55,
|
|
小怪木: 0.55,
|
|
小怪水: 0.55,
|
|
小怪火: 0.55,
|
|
小怪土: 0.55,
|
|
Boss金: 1,
|
|
Boss木: 1,
|
|
Boss水: 1,
|
|
Boss火: 1,
|
|
Boss土: 1,
|
|
兵: 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?.playWalk?.();
|
|
}
|
|
|
|
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;
|
|
}
|
|
const node = this._entityNodes.get(entityId);
|
|
const view = node?.isValid
|
|
? (node.getComponent(BATTLE_MONSTER_CCCLASS) as IBattleMonsterView | null)
|
|
: null;
|
|
const data = view?.Data;
|
|
// 五行小怪:先播契约死亡轨再回收
|
|
if (node?.isValid && data && MonsterDataManager.isElementSmallMonster(data) && view?.playDeath) {
|
|
if (membershipHas(this._spawningEntities, entityId)) {
|
|
membershipDelete(this._spawningEntities, entityId);
|
|
}
|
|
this._unlockPresentMovement(entityId);
|
|
this._clearSpawnProtected(entityId);
|
|
this._clearSpawnInvulnTimer(entityId);
|
|
const played = view.playDeath(() => {
|
|
this._finishRemoveVisual(entityId, node);
|
|
});
|
|
if (played) return;
|
|
console.error('[CocosBattleMonsterPresenter] 五行小怪死亡轨未起播', data.id, data.icon);
|
|
}
|
|
this._finishRemoveVisual(entityId, node ?? null);
|
|
}
|
|
|
|
/**
|
|
* 回收怪物节点与映射。
|
|
* @param entityId 实体
|
|
* @param node 表现节点
|
|
*/
|
|
private _finishRemoveVisual(entityId: EntityId, node: Node | null): void {
|
|
membershipDelete(this._pendingRecycle, 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();
|
|
const cocos = logicalWorldToCocos(session, this._map, tf.row, tf.col, move.worldX, move.worldY);
|
|
if (!cocos) {
|
|
console.error('[CocosBattleMonsterPresenter] 机制停位坐标解析失败', entityId, tf.row, tf.col);
|
|
return node.worldPosition.clone();
|
|
}
|
|
return cocos;
|
|
}
|
|
|
|
/** 飞禽:芭蕉扇/复活收回落地格,而不是出怪口或当前格 */
|
|
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) {
|
|
console.error(
|
|
'[CocosBattleMonsterPresenter] 跳出无可用起落点',
|
|
entityId,
|
|
spawnRow,
|
|
spawnCol,
|
|
next,
|
|
);
|
|
this._recoverSpawnPortalDelayFailed(entityId, node);
|
|
onLand?.();
|
|
return;
|
|
}
|
|
if (!startWorld) applyMonsterFootOffset(spawnGrid.node.worldPosition, start, cellSize);
|
|
end.set(start);
|
|
}
|
|
|
|
if (!Number.isFinite(start.x) || !Number.isFinite(start.y) || !Number.isFinite(end.x) || !Number.isFinite(end.y)) {
|
|
console.error(
|
|
'[CocosBattleMonsterPresenter] 跳出坐标非法',
|
|
entityId,
|
|
start.x,
|
|
start.y,
|
|
end.x,
|
|
end.y,
|
|
spawnRow,
|
|
spawnCol,
|
|
);
|
|
this._recoverSpawnPortalDelayFailed(entityId, node);
|
|
onLand?.();
|
|
return;
|
|
}
|
|
// 禁止落到画布原点:格节点缺失时曾写出 (0,0),金币会从左下角飞出
|
|
if (Math.abs(start.x) < 1 && Math.abs(start.y) < 1) {
|
|
console.error(
|
|
'[CocosBattleMonsterPresenter] 跳出起点接近原点,中止',
|
|
entityId,
|
|
start.x,
|
|
start.y,
|
|
spawnRow,
|
|
spawnCol,
|
|
);
|
|
this._recoverSpawnPortalDelayFailed(entityId, node);
|
|
onLand?.();
|
|
return;
|
|
}
|
|
if (Math.abs(end.x) < 1 && Math.abs(end.y) < 1) {
|
|
console.error(
|
|
'[CocosBattleMonsterPresenter] 跳出落点接近原点,改为起点落地',
|
|
entityId,
|
|
end.x,
|
|
end.y,
|
|
spawnRow,
|
|
spawnCol,
|
|
landRow,
|
|
landCol,
|
|
);
|
|
end.set(start);
|
|
landRow = -1;
|
|
landCol = -1;
|
|
}
|
|
|
|
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 {
|
|
// 五行新怪 / 新 Boss:仅 default 皮,不 setSkin、不区分阵营
|
|
if (MonsterDataManager.skipsBattleSpineSkin(monsterData)) {
|
|
monster.applyMarchSpineCache?.();
|
|
return;
|
|
}
|
|
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.error('[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.error(
|
|
'[CocosBattleMonsterPresenter] 阵营皮肤全部失败',
|
|
preferred,
|
|
'id=',
|
|
monsterId,
|
|
'enemyCamp=',
|
|
enemyCamp,
|
|
'port=',
|
|
spawnPort,
|
|
);
|
|
}
|
|
|
|
/** @deprecated 保留给挂皮流程;正式套皮见 _applyCampSkinToMonster */
|
|
private _applyMonsterSpineSkin(
|
|
anim: sp.Skeleton,
|
|
monsterData: ITableMonster,
|
|
entityId?: EntityId,
|
|
spawnPort?: number,
|
|
): void {
|
|
if (MonsterDataManager.skipsBattleSpineSkin(monsterData)) return;
|
|
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) {
|
|
console.error('[CocosBattleMonsterPresenter] 阵营皮肤名为空', monsterData.id, 'port=', spawnPort);
|
|
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.error('[CocosBattleMonsterPresenter] alignMxEntity: 出怪口无拓扑格', spawnPort);
|
|
return;
|
|
}
|
|
const grid = map.getMapGrid(cell.row, cell.col);
|
|
if (!grid) {
|
|
console.error('[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);
|
|
const after = session.world.getComponent(entityId, MovementComp);
|
|
if (after && after.reachedGoal && after.hasAttackGoal && !(after.path?.length > 0)) {
|
|
console.error(
|
|
'[CocosBattleMonsterPresenter] 出怪后空路径且已到攻击点,将漏怪 despawn',
|
|
entityId,
|
|
spawnPort,
|
|
grid.Row,
|
|
grid.Col,
|
|
);
|
|
}
|
|
}
|
|
|