|
|
|
|
import { Vec3, v3 } from 'cc';
|
|
|
|
|
import type { IBattleBridge, BattleSession, EntityId } from 'db://assets/mx/module/battle';
|
|
|
|
|
import { isBajiaoBlowRetreating, MovementComp, TagComp, TransformComp } from 'db://assets/mx/module/battle';
|
|
|
|
|
import type { CocosBattleMonsterPresenter } from '../view/CocosBattleMonsterPresenter';
|
|
|
|
|
import { BATTLE_MONSTER_CCCLASS, type IBattleMonsterView } from '../view/IBattleMonsterView';
|
|
|
|
|
import { MonsterState } from '../../ui/BattleGame/MonsterState';
|
|
|
|
|
import type { MapManager } from '../map/legacy/MapManager';
|
|
|
|
|
import { applyMonsterFootOffset } from '../map/BattleMapSpec';
|
|
|
|
|
import { MECHANISM_UNIT_TAG } from '../mechanism/MechanismConstants';
|
|
|
|
|
|
|
|
|
|
export { applyMonsterFootOffset, MONSTER_VISUAL_OFFSET_Y_CELLS } from '../map/BattleMapSpec';
|
|
|
|
|
|
|
|
|
|
const _syncWorldPos = new Vec3();
|
|
|
|
|
const _syncFootPos = new Vec3();
|
|
|
|
|
|
|
|
|
|
/** 怪物腰点世界坐标(与 Monster.getPosByAtkPosIndex(2) 一致) */
|
|
|
|
|
export function getMonsterWaistWorldPos(node: import('cc').Node): Vec3 {
|
|
|
|
|
const comp = node.getComponent(BATTLE_MONSTER_CCCLASS) as {
|
|
|
|
|
getPosByAtkPosIndex?: (idx: number) => Vec3;
|
|
|
|
|
} | null;
|
|
|
|
|
if (comp?.getPosByAtkPosIndex) {
|
|
|
|
|
return comp.getPosByAtkPosIndex(2);
|
|
|
|
|
}
|
|
|
|
|
return node.worldPosition.clone();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 牢笼/爪子锚点世界坐标 → 怪物根节点 worldPosition。
|
|
|
|
|
* 地图上怪物节点在脚底偏移后落位;牢笼锚点应对齐腰点而非脚底。
|
|
|
|
|
*/
|
|
|
|
|
export function monsterNodePosForCageAnchor(
|
|
|
|
|
cageAnchorWorld: Readonly<Vec3>,
|
|
|
|
|
monsterNode: import('cc').Node,
|
|
|
|
|
): Vec3 {
|
|
|
|
|
const waist = getMonsterWaistWorldPos(monsterNode);
|
|
|
|
|
const p = monsterNode.worldPosition;
|
|
|
|
|
return new Vec3(
|
|
|
|
|
cageAnchorWorld.x - (waist.x - p.x),
|
|
|
|
|
cageAnchorWorld.y - (waist.y - p.y),
|
|
|
|
|
cageAnchorWorld.z - (waist.z - p.z),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 战斗表现桥接:MX 逻辑坐标 → Cocos 怪物节点
|
|
|
|
|
*/
|
|
|
|
|
export class CocosBattleMovementBridge implements IBattleBridge {
|
|
|
|
|
constructor(private readonly _presenter: CocosBattleMonsterPresenter) {}
|
|
|
|
|
|
|
|
|
|
onCombatTick(session: BattleSession, _dt: number): void {
|
|
|
|
|
const map = this._presenter.battle?.Map;
|
|
|
|
|
if (!map?.gridsInitialized) return;
|
|
|
|
|
|
|
|
|
|
for (const [entityId, node] of this._presenter.getEntityNodeMap()) {
|
|
|
|
|
if (!node?.isValid) continue;
|
|
|
|
|
// 占用表现已死:等 releaseVisual 回收,勿再同步/强行显形
|
|
|
|
|
if (this._presenter.isPendingRecycle(entityId)) continue;
|
|
|
|
|
// 出怪跳跃中由位移动画接管坐标,只同步战斗状态
|
|
|
|
|
if (this._presenter.isSpawning(entityId)) {
|
|
|
|
|
syncMonsterCombatState(session, entityId, node);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
this._presenter.ensureMarchVisible(entityId, node);
|
|
|
|
|
syncMonsterNode(session, map, entityId, node);
|
|
|
|
|
}
|
|
|
|
|
this._presenter.tickMonsterYSort();
|
|
|
|
|
this._presenter.tickSpineLod();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 将 MX MovementComp 同步到 Cocos 节点(怪物、技能子弹等通用)
|
|
|
|
|
*/
|
|
|
|
|
export function syncMovementNode(
|
|
|
|
|
session: BattleSession,
|
|
|
|
|
map: MapManager,
|
|
|
|
|
entityId: EntityId,
|
|
|
|
|
node: import('cc').Node,
|
|
|
|
|
): void {
|
|
|
|
|
const move = session.world.getComponent(entityId, MovementComp);
|
|
|
|
|
const tf = session.world.getComponent(entityId, TransformComp);
|
|
|
|
|
if (!move || !tf) return;
|
|
|
|
|
|
|
|
|
|
if (!resolveEntityWorldPosInto(session, map, tf.row, tf.col, move, _syncWorldPos)) {
|
|
|
|
|
console.error('[CocosBattleMovementBridge] syncMovementNode 坐标解析失败,保持原位', entityId, tf.row, tf.col);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
node.setWorldPosition(_syncWorldPos);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 将 MX 实体移动状态同步到怪物节点(含攻击状态)
|
|
|
|
|
*/
|
|
|
|
|
export function syncMonsterNode(
|
|
|
|
|
session: BattleSession,
|
|
|
|
|
map: MapManager,
|
|
|
|
|
entityId: EntityId,
|
|
|
|
|
node: import('cc').Node,
|
|
|
|
|
): void {
|
|
|
|
|
const move = session.world.getComponent(entityId, MovementComp);
|
|
|
|
|
const tf = session.world.getComponent(entityId, TransformComp);
|
|
|
|
|
// 阎王拘魂:牢笼内直接落怪物节点锚点(已按腰点对齐牢笼),不做格心脚底偏移
|
|
|
|
|
if (move?.imprisoned && tf) {
|
|
|
|
|
node.setWorldPosition(move.imprisonCocosAnchorX, move.imprisonCocosAnchorY, 0);
|
|
|
|
|
syncMonsterCombatState(session, entityId, node);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// 阎王拖入:表现层 tween 驱动坐标,逻辑层已 presentLocked
|
|
|
|
|
if (move?.imprisonBy != null && !move.imprisoned) {
|
|
|
|
|
syncMonsterCombatState(session, entityId, node);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// 机制降落 / 出怪前:逻辑坐标已落格,表现由 tween 接管。
|
|
|
|
|
// 门神盾牌顶推:施法锁期间仍须跟逻辑坐标走。
|
|
|
|
|
const shieldPush = isBajiaoBlowRetreating(move) || !!move?.forcePresentSync;
|
|
|
|
|
if (move?.presentLocked && !shieldPush) {
|
|
|
|
|
syncMonsterCombatState(session, entityId, node);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// 已停步开战:默认保持表现坐标原地攻击(避免与格间 lerp 不一致导致后退);
|
|
|
|
|
// 芭蕉扇吹退 / 击退等权威改写逻辑坐标时须同步表现。
|
|
|
|
|
const bajiaoRetreat = (move?.forcePathSpeed ?? 0) > 0 || move?.bajiaoBlowRetreating;
|
|
|
|
|
if (move?.reachedGoal && !move.forcePresentSync && !bajiaoRetreat) {
|
|
|
|
|
syncMonsterCombatState(session, entityId, node);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (move) move.forcePresentSync = false;
|
|
|
|
|
if (!move || !tf) {
|
|
|
|
|
syncMonsterCombatState(session, entityId, node);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!resolveEntityWorldPosInto(session, map, tf.row, tf.col, move, _syncWorldPos)) {
|
|
|
|
|
console.error(
|
|
|
|
|
'[CocosBattleMovementBridge] syncMonsterNode 坐标解析失败,保持原位',
|
|
|
|
|
entityId,
|
|
|
|
|
tf.row,
|
|
|
|
|
tf.col,
|
|
|
|
|
move.worldX,
|
|
|
|
|
move.worldY,
|
|
|
|
|
);
|
|
|
|
|
syncMonsterCombatState(session, entityId, node);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
applyMonsterFootOffset(_syncWorldPos, _syncFootPos, map.mapGridSize);
|
|
|
|
|
if (!setMonsterNodeWorldPos(node, _syncFootPos)) {
|
|
|
|
|
syncMonsterCombatState(session, entityId, node);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
syncMonsterCombatState(session, entityId, node);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setMonsterNodeWorldPos(node: import('cc').Node, pos: Readonly<Vec3>): boolean {
|
|
|
|
|
if (!node?.isValid) return false;
|
|
|
|
|
if (!Number.isFinite(pos.x) || !Number.isFinite(pos.y)) {
|
|
|
|
|
console.log('[CocosBattleMovementBridge] skip invalid monster world pos', pos.x, pos.y);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
node.setWorldPosition(pos);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 白骨精等 mechanism_unit:不可攻唐僧;空投飞禽无此标签,可攻 */
|
|
|
|
|
function isWallAttackForbidden(
|
|
|
|
|
session: BattleSession,
|
|
|
|
|
entityId: EntityId,
|
|
|
|
|
comp: { mechanismImmobile?: boolean } | null,
|
|
|
|
|
): boolean {
|
|
|
|
|
if (comp?.mechanismImmobile) return true;
|
|
|
|
|
const tags = session.world.getComponent(entityId, TagComp);
|
|
|
|
|
return tags?.tags.includes(MECHANISM_UNIT_TAG) ?? false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 将 MX 移动/攻击状态同步到怪物 Cocos 视图 */
|
|
|
|
|
export function syncMonsterCombatState(
|
|
|
|
|
session: BattleSession,
|
|
|
|
|
entityId: EntityId,
|
|
|
|
|
node: import('cc').Node,
|
|
|
|
|
): void {
|
|
|
|
|
const move = session.world.getComponent(entityId, MovementComp);
|
|
|
|
|
if (!move) return;
|
|
|
|
|
|
|
|
|
|
const comp = node.getComponent(BATTLE_MONSTER_CCCLASS) as IBattleMonsterView & {
|
|
|
|
|
useMxMovement?: boolean;
|
|
|
|
|
state?: MonsterState;
|
|
|
|
|
resolveAttackTarget?: () => void;
|
|
|
|
|
mechanismImmobile?: boolean;
|
|
|
|
|
} | null;
|
|
|
|
|
if (!comp) return;
|
|
|
|
|
|
|
|
|
|
comp.useMxMovement = true;
|
|
|
|
|
if (move.presentLocked || move.spawnProtected) {
|
|
|
|
|
if (comp.state !== MonsterState.STOP) {
|
|
|
|
|
comp.state = MonsterState.STOP;
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// 策划:除空投飞禽外,机制怪不可进入攻墙态(白骨精 reachedGoal=true 易被误判开战)
|
|
|
|
|
if (isWallAttackForbidden(session, entityId, comp)) {
|
|
|
|
|
if (comp.state === MonsterState.ATTACKING) {
|
|
|
|
|
comp.state = MonsterState.STOP;
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (move.reachedGoal) {
|
|
|
|
|
if (comp.state !== MonsterState.ATTACKING) {
|
|
|
|
|
comp.state = MonsterState.ATTACKING;
|
|
|
|
|
comp.resolveAttackTarget?.();
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
comp.state = MonsterState.MOVING_FORWARD;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 出怪跳出起/落点:沿出怪格→下一寻路格 Cocos 格心 lerp,再套脚底 Y 偏移(与 syncMonsterNode 一致)。
|
|
|
|
|
* @param t 0=出怪格,1=下一寻路格
|
|
|
|
|
*/
|
|
|
|
|
export function resolveSpawnJumpVisualPosInto(
|
|
|
|
|
map: MapManager,
|
|
|
|
|
spawnRow: number,
|
|
|
|
|
spawnCol: number,
|
|
|
|
|
nextRow: number,
|
|
|
|
|
nextCol: number,
|
|
|
|
|
t: number,
|
|
|
|
|
out: Vec3,
|
|
|
|
|
): boolean {
|
|
|
|
|
const g0 = map.getMapGrid(spawnRow, spawnCol);
|
|
|
|
|
const g1 = map.getMapGrid(nextRow, nextCol);
|
|
|
|
|
if (!g0?.node?.isValid || !g1?.node?.isValid) {
|
|
|
|
|
const fallback = g0?.node?.isValid ? g0.node.worldPosition : g1?.node?.worldPosition;
|
|
|
|
|
if (!fallback) return false;
|
|
|
|
|
applyMonsterFootOffset(fallback, out, map.mapGridSize);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
Vec3.lerp(out, g0.node.worldPosition, g1.node.worldPosition, t);
|
|
|
|
|
applyMonsterFootOffset(out, out, map.mapGridSize);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 解析实体表现世界坐标。
|
|
|
|
|
* @returns 成功写入 out 时为 true;格节点缺失时为 false(不得写 0,0)
|
|
|
|
|
*/
|
|
|
|
|
function resolveEntityWorldPosInto(
|
|
|
|
|
session: BattleSession,
|
|
|
|
|
map: MapManager,
|
|
|
|
|
row: number,
|
|
|
|
|
col: number,
|
|
|
|
|
movement: MovementComp,
|
|
|
|
|
out: Vec3,
|
|
|
|
|
): boolean {
|
|
|
|
|
// 已停步开战(path 已清空)或走完路径:用逻辑 world 映射到 Cocos,禁止 (x,-y) 或强行吸回格心
|
|
|
|
|
if (movement.reachedGoal || movement.pathIndex >= movement.path.length) {
|
|
|
|
|
return logicalWorldToCocosInto(session, map, row, col, movement.worldX, movement.worldY, out);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const curGrid = map.getMapGrid(row, col);
|
|
|
|
|
if (!curGrid?.node?.isValid) {
|
|
|
|
|
return logicalWorldToCocosInto(session, map, row, col, movement.worldX, movement.worldY, out);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const curCenter = session.grid.cellToWorld({ row, col });
|
|
|
|
|
const next = movement.path[movement.pathIndex];
|
|
|
|
|
const nextGrid = map.getMapGrid(next.row, next.col);
|
|
|
|
|
if (!nextGrid?.node?.isValid) {
|
|
|
|
|
return logicalWorldToCocosInto(session, map, row, col, movement.worldX, movement.worldY, out);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const nextCenter = session.grid.cellToWorld(next);
|
|
|
|
|
const dx = nextCenter.x - curCenter.x;
|
|
|
|
|
const dy = nextCenter.y - curCenter.y;
|
|
|
|
|
const total = Math.sqrt(dx * dx + dy * dy);
|
|
|
|
|
const px = movement.worldX - curCenter.x;
|
|
|
|
|
const py = movement.worldY - curCenter.y;
|
|
|
|
|
const along = total > 1e-4 ? (px * dx + py * dy) / total : 0;
|
|
|
|
|
const perp = total > 1e-4
|
|
|
|
|
? Math.abs(px * dy - py * dx) / total
|
|
|
|
|
: Math.sqrt(px * px + py * py);
|
|
|
|
|
// 击退等会把逻辑坐标打出路径线段;再按路程投影会把后退/侧移折成前进
|
|
|
|
|
if (along < -1 || along > total + 1 || perp > 4) {
|
|
|
|
|
return logicalWorldToCocosInto(session, map, row, col, movement.worldX, movement.worldY, out);
|
|
|
|
|
}
|
|
|
|
|
const t = total > 1e-4 ? Math.min(1, Math.max(0, along / total)) : 1;
|
|
|
|
|
|
|
|
|
|
Vec3.lerp(out, curGrid.node.worldPosition, nextGrid.node.worldPosition, t);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 逻辑世界坐标 → Cocos 世界坐标(写入 out)。
|
|
|
|
|
* @returns 地图格可用时 true;缺失时 false 且不改 out(禁止写成屏幕原点)
|
|
|
|
|
*/
|
|
|
|
|
function logicalWorldToCocosInto(
|
|
|
|
|
session: BattleSession,
|
|
|
|
|
map: MapManager,
|
|
|
|
|
row: number,
|
|
|
|
|
col: number,
|
|
|
|
|
logicalX: number,
|
|
|
|
|
logicalY: number,
|
|
|
|
|
out: Vec3,
|
|
|
|
|
): boolean {
|
|
|
|
|
const logicalCenter = session.grid.cellToWorld({ row, col });
|
|
|
|
|
const ox = logicalX - logicalCenter.x;
|
|
|
|
|
const oy = logicalY - logicalCenter.y;
|
|
|
|
|
|
|
|
|
|
const curGrid = map.getMapGrid(row, col);
|
|
|
|
|
if (curGrid?.node?.isValid) {
|
|
|
|
|
const cw = curGrid.node.worldPosition;
|
|
|
|
|
out.set(cw.x + ox, cw.y - oy, 0);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.error(
|
|
|
|
|
'[CocosBattleMovementBridge] map grid missing for visual sync',
|
|
|
|
|
row,
|
|
|
|
|
col,
|
|
|
|
|
'map=',
|
|
|
|
|
map.mapRows,
|
|
|
|
|
'x',
|
|
|
|
|
map.mapCols,
|
|
|
|
|
'logic=',
|
|
|
|
|
logicalX,
|
|
|
|
|
logicalY,
|
|
|
|
|
);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 逻辑世界坐标(GridMap,Y 向下)→ Cocos 世界坐标(Y 向上)。
|
|
|
|
|
* 以当前格心为锚做局部偏移;格节点缺失返回 null(禁止落到屏幕原点)。
|
|
|
|
|
*/
|
|
|
|
|
export function logicalWorldToCocos(
|
|
|
|
|
session: BattleSession,
|
|
|
|
|
map: MapManager,
|
|
|
|
|
row: number,
|
|
|
|
|
col: number,
|
|
|
|
|
logicalX: number,
|
|
|
|
|
logicalY: number,
|
|
|
|
|
): Vec3 | null {
|
|
|
|
|
const out = v3();
|
|
|
|
|
if (!logicalWorldToCocosInto(session, map, row, col, logicalX, logicalY, out)) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Cocos 世界坐标 → 逻辑世界坐标(与 logicalWorldToCocos 互逆) */
|
|
|
|
|
export function cocosWorldToLogical(
|
|
|
|
|
session: BattleSession,
|
|
|
|
|
map: MapManager,
|
|
|
|
|
row: number,
|
|
|
|
|
col: number,
|
|
|
|
|
cocosX: number,
|
|
|
|
|
cocosY: number,
|
|
|
|
|
): { x: number; y: number } {
|
|
|
|
|
const logicalCenter = session.grid.cellToWorld({ row, col });
|
|
|
|
|
const curGrid = map.getMapGrid(row, col);
|
|
|
|
|
if (curGrid?.node?.isValid) {
|
|
|
|
|
const cw = curGrid.node.worldPosition;
|
|
|
|
|
return {
|
|
|
|
|
x: logicalCenter.x + (cocosX - cw.x),
|
|
|
|
|
y: logicalCenter.y + (cw.y - cocosY),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
return { x: cocosX, y: -cocosY };
|
|
|
|
|
}
|