import { type Node, Vec3 } from 'cc'; import { createMembershipSet, membershipAdd, membershipFromArray, membershipDedupeArray, membershipToArray, type MembershipSet, type ReadonlyMembershipSet } from '../../../mx/tools/MembershipSet'; import type { EntityId } from 'db://assets/mx/module/ecs'; import { AtkHookComp, MovementComp, TeamComp, TransformComp, UnitMetaComp } from 'db://assets/mx/module/battle'; import type { IBattleRuntimeHandle } from '../types/ProjectBattleTypes'; import type { Player } from '../../game/Player'; import type { MapManager } from '../map/legacy/MapManager'; import { MapGrid } from '../map/legacy/MapGrid'; /** 怪物表现(不 import Presenter 实现,避免 Env → 表现 → Runtime 环) */ interface IMonsterPresenterView { /** 占住死亡表现 */ holdVisual(entityId: EntityId): void; /** 释放死亡表现 */ releaseVisual(entityId: EntityId): void; /** 实体 → 节点 */ getEntityNodeMap(): { get(id: EntityId): Node | undefined } & Iterable<[EntityId, Node]>; /** 按 Y 排序 */ sortMonstersByY?(): void; } import { BATTLE_MONSTER_CCCLASS } from '../view/IBattleMonsterView'; import { DamageTextSpawner } from '../view/DamageTextSpawner'; import { buildDeployedTowerInfo, findCompositionForEntity, findGridIndexByEntity, type DeployedTowerInfo, } from '../runtime/DeployEntityQuery'; import { WeaponComposeConfigProvider } from '../bag/WeaponComposeConfigProvider'; import { heroUltSkillId, resolveTowerHeroId, textIconName } from '../config/BattleHeroTable'; import { TableNames } from '../../game/ConfigTableData'; import { parseWuSpinSkillParts, parseWheelSkillParts, } from './BaseAtkParamReader'; import { entityUltAbilityId } from '../config/weapon1/Weapon1BattleIds'; import { isPartnerWeaponActiveOnMap } from '../bond/BondEvaluator'; import { SkillEye } from './SkillEye'; import { getBattleTimeScale, schedulePresentation as schedulePresentationClock } from './BattlePresentationClock'; /** 地图字块(按 ccclass 取,避免 Env → TextGrid → Skill 环) */ interface IMapTextGrid { /** Text.id */ weapon2Id?: number; /** 皮肤根节点 */ getSkinNode(): Node | null; } /** base_atk id=511 skill 类型1(转刀)解析结果 */ export interface WuSpinSkillParams { secPerRev: number; stickCount: number; volumeCells: number; durationSec: number; damageScale: number; } /** base_atk skill 类型7(轮转圈):转速、数量、体积、半径、伤害倍数 */ export interface WheelSkillParams { secPerRev: number; count: number; volume: number; radiusPx: number; damageScale: number; } /** Skill 表现层共享上下文(ui_fly / 怪物坐标 / 部署格) */ export class SkillPresentationEnv { private static _flyLayer: Node | null = null; private static _scoreLayer: Node | null = null; private static _monsterPresenter: IMonsterPresenterView | null = null; private static _map: MapManager | null = null; private static _battle: IBattleRuntimeHandle | null = null; private static readonly _killFloaterSpawner = new DamageTextSpawner(); static bind(opts: { flyLayer: Node | null; scoreLayer?: Node | null; monsterPresenter: IMonsterPresenterView | null; map: MapManager | null; deploySync?: unknown; battle: IBattleRuntimeHandle | null; }): void { this._flyLayer = opts.flyLayer?.isValid ? opts.flyLayer : null; this._scoreLayer = opts.scoreLayer?.isValid ? opts.scoreLayer : null; this._monsterPresenter = opts.monsterPresenter; this._map = opts.map; this._battle = opts.battle; } /** * 按 ccclass 取字块,避免直接 import TextGrid。 * @param node 地图格节点 */ private static _readMapTextGrid(node: Node | null | undefined): IMapTextGrid | null { if (!node?.isValid) return null; return node.getComponentInChildren('TextGrid') as IMapTextGrid | null; } static unbind(): void { this._flyLayer = null; this._scoreLayer = null; this._monsterPresenter = null; this._map = null; this._battle = null; } static get flyLayer(): Node | null { return this._flyLayer?.isValid ? this._flyLayer : null; } /** 从 Merge.compositions 查询实体部署信息(现算,不读缓存;含 AI 玩家) */ static getDeployed(caster: EntityId): DeployedTowerInfo | null { const battle = this._battle; const map = this._map; const session = battle?.session; if (!battle || !map || !session) return null; const players = battle.players?.length ? battle.players : (battle.player ? [battle.player] : []); for (let i = 0; i < players.length; i++) { const player = players[i]; if (!player) continue; const comp = findCompositionForEntity(caster, player, session, map); if (comp) return buildDeployedTowerInfo(comp); } return null; } static getGridWorldCenter(gridIndex: number): Vec3 | null { const map = this._map; if (!map) return null; const mg = map.getMapGridByIndex(gridIndex); return mg?.node?.isValid ? mg.node.worldPosition.clone() : null; } static getGridWorldCenterByRowCol(row: number, col: number): Vec3 | null { const map = this._map; if (!map) return null; const mg = map.getMapGrid(row, col); return mg?.node?.isValid ? mg.node.worldPosition.clone() : null; } static parseGridKey(gridKey: string): { row: number; col: number } | null { if (gridKey.startsWith('e:')) return null; const parts = gridKey.split(','); if (parts.length !== 2) return null; const row = Number(parts[0]); const col = Number(parts[1]); if (!Number.isFinite(row) || !Number.isFinite(col)) return null; return { row, col }; } /** * 横向组合左起首字格 index(与 DeploySync 锚点一致:最小 gridIndex)。 * @param indices 占格 */ static leftmostGridIndex(indices: readonly number[]): number { let best = indices[0]; for (let i = 1; i < indices.length; i++) { if (indices[i] < best) best = indices[i]; } return best; } /** * 组合第一个字格世界坐标(Hero.text 左起首字;单格即该格)。 * 无 compositions 时回退 Transform 锚点(DeploySync 已落在最左格)。 * @param caster 施法实体 */ static getCompositionFirstGlyphWorld(caster: EntityId): Vec3 | null { const deployed = this.getDeployed(caster); if (deployed?.gridIndices.length) { return this.getGridWorldCenter(this.leftmostGridIndex(deployed.gridIndices)); } return this.getCasterGridCenter(caster); } /** * 施法者归属玩家(TeamComp.ownerId)。 * @param caster 施法实体 */ static getOwnerPlayer(caster: EntityId): Player | null { const session = this._battle?.session; if (!session?.world.isAlive(caster)) return null; const team = session.world.getComponent(caster, TeamComp); if (team == null) return null; return this._battle?.getPlayer?.(team.ownerId) ?? null; } /** 组合占格世界坐标包围盒中心(单格则返回该格中心;无部署时回退施法格) */ static getCompositionWorldCenter(caster: EntityId): Vec3 | null { const deployed = this.getDeployed(caster); if (!deployed?.gridIndices.length) return this.getCasterGridCenter(caster); const centers: Vec3[] = []; for (const idx of deployed.gridIndices) { const center = this.getGridWorldCenter(idx); if (center) centers.push(center); } if (centers.length === 0) return this.getCasterGridCenter(caster); let minX = Infinity; let maxX = -Infinity; let minY = Infinity; let maxY = -Infinity; for (const c of centers) { minX = Math.min(minX, c.x); maxX = Math.max(maxX, c.x); minY = Math.min(minY, c.y); maxY = Math.max(maxY, c.y); } return new Vec3( (minX + maxX) * 0.5, (minY + maxY) * 0.5, centers[0].z, ); } /** * 组合内按 Weapon2 name/resource 取皮肤根节点(如杨戬的「戬」)。 * 优先读 TextGrid.weapon2Id(与地图表现一致),mergeMap 仅作兜底。 */ static getCompositionSkinNodeByWeapon2Name( caster: EntityId, weapon2Name: string, ): Node | null { const deployed = this.getDeployed(caster); const map = this._map; if (!deployed || !map) return null; const provider = WeaponComposeConfigProvider.instance; const mergeMap = this._battle?.player?.bagSession?.merge?.map; for (const idx of deployed.gridIndices) { const mg = map.getMapGridByIndex(idx); if (!mg?.node?.isValid) continue; const tg = this._readMapTextGrid(mg.node); const w2Id = tg?.weapon2Id ?? mergeMap?.getTile(idx)?.weapon2Id; if (w2Id == null) continue; const cfg = provider.getWeapon2Config(w2Id); if (textIconName(cfg) !== weapon2Name && cfg?.group?.trim() !== weapon2Name) continue; const skin = tg?.getSkinNode(); if (skin?.isValid) return skin; } return null; } /** 组合内指定字块 anim/skillParent 下的 skillTamplate */ static getSkillTemplateOnComposition( caster: EntityId, weapon2Name: string, ): Node | null { const skin = this.getCompositionSkinNodeByWeapon2Name(caster, weapon2Name); if (!skin?.isValid) return null; return this._getSkillTemplateOnSkin(skin); } /** 组合内各字块 skillTamplate(杨戬杨/戬等,每字一块) */ static getAllSkillTemplatesOnComposition(caster: EntityId): Node[] { const deployed = this.getDeployed(caster); const map = this._map; if (!deployed || !map) return []; const templates: Node[] = []; const seen = createMembershipSet(); for (const idx of deployed.gridIndices) { const mg = map.getMapGridByIndex(idx); if (!mg?.node?.isValid) continue; const tg = this._readMapTextGrid(mg.node); const skin = tg?.getSkinNode(); if (!skin?.isValid) continue; const tmpl = this._getSkillTemplateOnSkin(skin); if (tmpl?.isValid && !seen.has(tmpl)) { membershipAdd(seen, tmpl); templates.push(tmpl); } } return templates; } static hideSkillTemplatesOnComposition(caster: EntityId): void { for (const tmpl of this.getAllSkillTemplatesOnComposition(caster)) { if (tmpl.isValid) tmpl.active = false; } } private static _getSkillTemplateOnSkin(skin: Node): Node | null { const skillParent = skin.find('anim/skillParent') ?? skin.find('skillParent'); if (!skillParent?.isValid) return null; const byName = skillParent.getChildByName('skillTamplate') ?? skillParent.getChildByName('skillTemplate'); if (byName?.isValid) return byName; // 兼容:表现层曾误改本体节点名时,仍能按 SkillEye 找回 // 勿命中 yang_laser_* 等运行时克隆,否则拆字重组合后会绑错模板 for (const child of skillParent.children) { if (!child?.isValid) continue; if (child.name.startsWith('yang_laser_')) continue; if (child.getComponent(SkillEye)) return child; } return null; } /** 组合内字块所在地图格 index */ static getCompositionGridIndexByWeapon2Name( caster: EntityId, weapon2Name: string, ): number | null { const deployed = this.getDeployed(caster); const map = this.getMap(); if (!deployed || !map) return null; const provider = WeaponComposeConfigProvider.instance; const mergeMap = this._battle?.player?.bagSession?.merge?.map; for (const idx of deployed.gridIndices) { const mg = map.getMapGridByIndex(idx); if (!mg?.node?.isValid) continue; const tg = this._readMapTextGrid(mg.node); const w2Id = tg?.weapon2Id ?? mergeMap?.getTile(idx)?.weapon2Id; if (w2Id == null) continue; const cfg = provider.getWeapon2Config(w2Id); if (textIconName(cfg) !== weapon2Name && cfg?.group?.trim() !== weapon2Name) continue; return idx; } return null; } static getMapGridRowCol(gridIndex: number): { row: number; col: number } | null { const map = this.getMap(); const mg = map?.getMapGridByIndex(gridIndex); if (mg) return { row: mg.Row, col: mg.Col }; return null; } /** 从字块皮肤节点向上查找 MapGrid,或按 gridIndex 回退 */ static resolveGridRowColForSkin(skin: Node, gridIndex: number | null): { row: number; col: number } | null { if (gridIndex != null) { const rc = this.getMapGridRowCol(gridIndex); if (rc) return rc; } let n: Node | null = skin; while (n) { const mg = n.getComponent(MapGrid); if (mg) return { row: mg.Row, col: mg.Col }; n = n.parent; } return null; } static getCompositionGridWorldByWeapon2Name( caster: EntityId, weapon2Name: string, ): Vec3 | null { const deployed = this.getDeployed(caster); const mergeMap = this._battle?.player?.bagSession?.merge?.map; if (!deployed || !mergeMap) return null; const provider = WeaponComposeConfigProvider.instance; for (const idx of deployed.gridIndices) { const tile = mergeMap.getTile(idx); if (!tile) continue; const cfg = provider.getWeapon2Config(tile.weapon2Id); if (textIconName(cfg) === weapon2Name || cfg?.group?.trim() === weapon2Name) { return this.getGridWorldCenter(idx); } } return null; } static getCasterAttr(caster: EntityId, attr: string): number { const session = this._battle?.session; if (!session) return 0; return session.attributes.getAttr(caster, attr); } /** 实体大招 PlayPresentation 上已叠词条后的 skillParts(如 `[1,0.77,3,1.95,5,1.5]`) */ static getWuSpinSkillParts(caster: EntityId): readonly number[] | null { const session = this._battle?.session; if (!session) return null; const heroId = resolveTowerHeroId(session.world, caster); if (!(heroId > 0)) return null; const hero = gg.data.table.getTableData(TableNames.Hero, heroId); const level = session.world.getComponent(caster, UnitMetaComp)?.level ?? 1; const ultSkillId = heroUltSkillId(hero, level); if (!(ultSkillId > 0)) return null; const ultId = entityUltAbilityId(ultSkillId, caster); const def = gg.battle.config.getAbility(ultId); if (!def) return null; for (const ev of def.timeline) { for (const fx of ev.effects) { if (fx.type === 'PlayPresentation' && Array.isArray(fx.params?.skillParts)) { return fx.params.skillParts as number[]; } } } return null; } /** 解析悟空大招转刀参数(含 502–506 / 553 等 skill_change 结果) */ static getWuSpinSkillParams(caster: EntityId): WuSpinSkillParams | null { const parts = this.getWuSpinSkillParts(caster); if (!parts) return null; return parseWuSpinSkillParts(parts); } /** 解析轮大招参数(含 skill_change 叠算结果) */ static getWheelSkillParams(caster: EntityId): WheelSkillParams | null { const parts = this.getWuSpinSkillParts(caster); if (!parts) return null; return parseWheelSkillParts(parts); } /** 施法者是否已选指定词条(按 tower weapon1 过滤) */ static casterHasSkillEntry(caster: EntityId, entryId: number): boolean { const session = this._battle?.session; if (!session?.world.isAlive(caster)) return false; const weapon1Id = resolveTowerHeroId(session.world, caster) || this.getDeployed(caster)?.weapon1Id; if (!weapon1Id) return false; for (const player of this._resolvePlayersForCaster(session, caster)) { for (const entry of player.selectedSkills) { if (Number(entry.weapon) !== weapon1Id) continue; if (Number(entry.id) === entryId) return true; } } return false; } /** 塔上已生效的 skill_special 原始串(如 `2,2`);与 Player.selectedSkills 合并 */ static getSkillSpecials(caster: EntityId): readonly string[] { return this._collectSkillSpecials(caster, null); } /** 大招词条 skill_special(up_type 2 / 4);如哪吒 1055 变大、1056 回旋 */ static getUltSkillSpecials(caster: EntityId): readonly string[] { return this._collectSkillSpecials( caster, (entry) => entry.up_type === 2 || entry.up_type === 4, ); } private static _collectSkillSpecials( caster: EntityId, filter: ((entry: ITableSkill) => boolean) | null, ): readonly string[] { const session = this._battle?.session; if (!session?.world.isAlive(caster)) return []; let weapon1Id = resolveTowerHeroId(session.world, caster); if (!weapon1Id) { weapon1Id = this.getDeployed(caster)?.weapon1Id; } const out = createMembershipSet(); for (const player of this._resolvePlayersForCaster(session, caster)) { for (const entry of player.selectedSkills) { if (weapon1Id && entry.weapon !== weapon1Id) continue; if (filter && !filter(entry)) continue; const raw = String(entry.skill_special ?? '').trim(); if (raw) membershipAdd(out, raw); } } const hook = session.world.getComponent(caster, AtkHookComp); if (hook?.specials?.length) { for (const raw of hook.specials) { const s = String(raw ?? '').trim(); if (!s) continue; if (filter) { const head = Number(s.split(',')[0]); // 大招 skill_special:3 散射 / 4 变大 / 5 回旋 / 8 轮半径脉冲(1 由 ultCanCrit 处理) if (head !== 3 && head !== 4 && head !== 5 && head !== 8) continue; } membershipAdd(out, s); } } return membershipToArray(out); } private static _resolvePlayersForCaster( session: NonNullable, caster: EntityId, ): Player[] { const team = session.world.getComponent(caster, TeamComp); const ownerPlayer = team != null ? this._battle?.getPlayer?.(team.ownerId) : undefined; const players: Player[] = []; const seen = createMembershipSet(); for (const p of [ownerPlayer, this._battle?.player]) { if (p && !seen.has(p)) { membershipAdd(seen, p); players.push(p); } } return players; } /** 大招 atk_after 是否含指定类型(如 6=伤害区域) */ static casterHasUltHookType(caster: EntityId, hookType: number): boolean { return this.parseCasterUltHookSegment(caster, hookType) != null; } /** 解析实体 ultAfter 中指定子类型的编码段(如 6=伤害区域) */ static parseCasterUltHookSegment(caster: EntityId, hookType: number): readonly number[] | null { const session = this._battle?.session; if (!session?.world.isAlive(caster)) return null; const hook = session.world.getComponent(caster, AtkHookComp); if (!hook?.ultAfter?.length) return null; for (const raw of hook.ultAfter) { const parts = String(raw).split(',').map(Number); if (parts[0] === hookType) return parts; } return null; } /** 756 等词条:ultAfter「6,半径,倍数,间隔,总时」 */ static parseUltDmgAreaParams(caster: EntityId): { radiusPx: number; scale: number; intervalSec: number; durationSec: number; } | null { const parts = this.parseCasterUltHookSegment(caster, 6); if (!parts || parts.length < 5) return null; const radiusPx = Math.max(1, parts[1] ?? 0); if (radiusPx <= 0) return null; return { radiusPx, scale: parts[2] ?? 1, intervalSec: Math.max(0.05, parts[3] ?? 0.5), durationSec: Math.max(0.1, parts[4] ?? 1), }; } static getFinalAttackSpeed(caster: EntityId): number { const session = this._battle?.session; if (!session) return 1; return session.attributes.getFinalAttackSpeed(caster); } static getCasterGridCenter(caster: EntityId): Vec3 | null { const session = this._battle?.session; const map = this._map; if (!session || !map) return null; const gridIndex = findGridIndexByEntity(caster, session, map); if (gridIndex == null) return null; return this.getGridWorldCenter(gridIndex); } /** 组合体各格世界中心(杨戬双格用任一格在射程内即可) */ static getCompositionCastOrigins(caster: EntityId): readonly Vec3[] { const deployed = this.getDeployed(caster); if (!deployed?.gridIndices.length) { const c = this.getCasterGridCenter(caster); return c ? [c] : []; } const out: Vec3[] = []; for (const idx of deployed.gridIndices) { const c = this.getGridWorldCenter(idx); if (c) out.push(c); } if (out.length > 0) return out; const fallback = this.getCasterGridCenter(caster); return fallback ? [fallback] : []; } static isEntityAlive(entityId: EntityId): boolean { const session = this._battle?.session; return session != null && session.world.isAlive(entityId); } /** * 表现层是否仍可对目标出手。 * 射程由 TargetResolver 在 cast 前判定;此处不再重复测距,避免逻辑格心与 Cocos 节点坐标不一致导致误拒。 */ static isPresentTargetValid(targetId: EntityId | undefined): boolean { if (targetId == null) return false; return this.isEntityAlive(targetId); } /** 出生跳出保护:不可被表现层索敌/点名 */ static isSpawnProtected(entityId: EntityId): boolean { const session = this._battle?.session; if (!session?.world.isAlive(entityId)) return false; return !!session.world.getComponent(entityId, MovementComp)?.spawnProtected; } static isImprisoned(entityId: EntityId): boolean { const session = this._battle?.session; if (!session?.world.isAlive(entityId)) return false; return !!session.world.getComponent(entityId, MovementComp)?.imprisoned; } /** 阎王伸爪拖入中或已囚禁 */ static isYanCaught(entityId: EntityId): boolean { const session = this._battle?.session; if (!session?.world.isAlive(entityId)) return false; return session.world.getComponent(entityId, MovementComp)?.imprisonBy != null; } /** 芭蕉扇吹退中:暂时不可被表现层索敌/点名 */ static isBajiaoBlowRetreating(entityId: EntityId): boolean { const session = this._battle?.session; if (!session?.world.isAlive(entityId)) return false; return !!session.world.getComponent(entityId, MovementComp)?.bajiaoBlowRetreating; } static getMap(): MapManager | null { if (this._map?.isValid) return this._map; const battleMap = this._battle?.Map; return battleMap?.isValid ? battleMap : null; } static getBattleSession() { return this._battle?.session ?? null; } /** 怪物表现层 */ static getMonsterPresenter(): IMonsterPresenterView | null { return this._monsterPresenter; } /** * 挑飞等:死亡也不立刻回收节点。 * @param entityId 怪物 */ static holdMonsterVisual(entityId: EntityId): void { this._monsterPresenter?.holdVisual(entityId); } /** * 结束表现占用:已死则回收。 * @param entityId 怪物 */ static releaseMonsterVisual(entityId: EntityId): void { this._monsterPresenter?.releaseVisual(entityId); } static getBattleRuntime(): IBattleRuntimeHandle | null { return this._battle; } /** 伙伴 Hero.id 在 map 攻击激活(group2 羁绊等) */ static isGroup2PartnerActive(caster: EntityId, partnerWeapon1Id: number): boolean { const runtime = this._battle; if (!runtime || !partnerWeapon1Id) return false; return isPartnerWeaponActiveOnMap(runtime, partnerWeapon1Id); } /** 与逻辑层 TargetResolver 同语义:组合圆心 + 射程内敌人,按距离升序 */ static listMonstersInPatrolRangeSorted(caster: EntityId, patrolRadiusPx: number): EntityId[] { const center = this.getCompositionWorldCenter(caster); if (!center) return []; const r2 = patrolRadiusPx * patrolRadiusPx; const items: Array<{ id: EntityId; d2: number }> = []; this.forEachMonsterNode((entityId) => { if (!this.isEntityAlive(entityId)) return; if (this.isSpawnProtected(entityId)) return; if (this.isYanCaught(entityId)) return; if (this.isBajiaoBlowRetreating(entityId)) return; const wp = this.getTargetWorldPos(entityId); if (!wp) return; const dx = wp.x - center.x; const dy = wp.y - center.y; const d2 = dx * dx + dy * dy; if (d2 > r2) return; items.push({ id: entityId, d2 }); }); items.sort((a, b) => a.d2 - b.d2); return items.map((x) => x.id); } /** 怪物所在地图格(row,col),用于莲花等同格去重 */ static getMonsterGridKey(entityId: EntityId): string | null { const session = this._battle?.session; if (!session?.world.isAlive(entityId)) return null; const tf = session.world.getComponent(entityId, TransformComp); if (!tf) return null; return `${tf.row},${tf.col}`; } /** 怪物所在格子中心(大招莲花落点) */ static getTargetGridWorldCenter(target: EntityId): Vec3 | null { const session = this._battle?.session; const map = this._map; if (!session || !map) return null; const tf = session.world.getComponent(target, TransformComp); if (!tf) return null; const mg = map.getMapGrid(tf.row, tf.col); return mg?.node?.isValid ? mg.node.worldPosition.clone() : null; } static getPlayersForCaster(caster: EntityId): Player[] { const session = this._battle?.session; if (!session) return []; return this._resolvePlayersForCaster(session, caster); } static getMonsterNode(entityId: EntityId): Node | null { const node = this._monsterPresenter?.getEntityNodeMap().get(entityId); return node?.isValid ? node : null; } /** 由地图皮肤节点反查战斗实体(组合字任一格均可) */ static findCasterBySkinNode(skin: Node): EntityId | null { const session = this._battle?.session; const player = this._battle?.player; const map = this._map; if (!session || !player || !map || !skin?.isValid) return null; for (const entityId of player.deployedUnits) { if (!session.world.isAlive(entityId)) continue; const comp = findCompositionForEntity(entityId, player, session, map); if (!comp) continue; for (const idx of comp.gridIndices) { const mg = map.getMapGridByIndex(idx); const tg = this._readMapTextGrid(mg?.node); const s = tg?.getSkinNode(); if (s?.isValid && s === skin) { return entityId; } } } return null; } static getTargetWorldPos(target: EntityId): Vec3 | null { return this.getTargetPosByAtkIndex(target, 2); } /** 秒杀/斩杀飘字锚点(须在扣血前取:EntityKilled 会立即回收怪物节点) */ static resolveKillFloaterWorldPos(target: EntityId): Vec3 | null { const node = this._monsterPresenter?.getEntityNodeMap().get(target); if (node?.isValid) { const monster = node.getComponent(BATTLE_MONSTER_CCCLASS) as { bloodPos?: Node | null } | null; const anchor = monster?.bloodPos?.isValid ? monster.bloodPos! : node; const pos = anchor.worldPosition.clone(); pos.y += 20; return pos; } const pos = this.getTargetWorldPos(target); if (!pos) return null; pos.y += 20; return pos; } /** 播放 killEff 预制体飘字(秒杀 / 斩杀) */ static playKillFloater(worldPos: Vec3, damageType: string): void { const kind = DamageTextSpawner.resolveKillFloaterKind(damageType); if (!kind) return; const layer = this._scoreLayer; if (!layer?.isValid) return; if (layer.parent?.isValid) { layer.setSiblingIndex(layer.parent.children.length - 1); } this._killFloaterSpawner.spawnKillEffect(worldPos, layer, kind); } /** 怪物头顶受击点(Weapon1.atkPos=1) */ static getTargetHeadWorldPos(target: EntityId): Vec3 | null { return this.getTargetPosByAtkIndex(target, 1); } static getTargetPosByAtkIndex(target: EntityId, atkPosIndex: number): Vec3 | null { const node = this._monsterPresenter?.getEntityNodeMap().get(target); if (!node?.isValid) { return this._getTargetFromTransform(target); } const monster = node.getComponent(BATTLE_MONSTER_CCCLASS) as { getPosByAtkPosIndex?: (idx: number) => Vec3; } | null; if (monster?.getPosByAtkPosIndex) { return monster.getPosByAtkPosIndex(atkPosIndex); } return node.worldPosition.clone(); } private static _getTargetFromTransform(target: EntityId): Vec3 | null { const session = this._battle?.session; if (!session) return null; const tf = session.world.getComponent(target, TransformComp); const map = this._map; if (!tf || !map) return null; const mg = map.getMapGrid(tf.row, tf.col); return mg?.node?.isValid ? mg.node.worldPosition.clone() : null; } static findAliveEnemyNearWorld(pos: Readonly, radiusPx: number): EntityId | null { const session = this._battle?.session; if (!session) return null; const radius2 = radiusPx * radiusPx; let best: { id: EntityId; d2: number } | null = null; this.forEachMonsterNode((entityId, node) => { if (!session.world.isAlive(entityId)) return; const wp = node.worldPosition; const dx = wp.x - pos.x; const dy = wp.y - pos.y; const d2 = dx * dx + dy * dy; if (d2 > radius2) return; if (!best || d2 < best.d2) best = { id: entityId, d2 }; }); return best?.id ?? null; } /** * Cocos 世界坐标 → 格子逻辑坐标(Y 向下)。 * `refEntity` 取其格心作参考原点,须与 `logicalWorldToCocos` 成对。 */ static toLogical(refEntity: EntityId, cocosX: number, cocosY: number): { x: number; y: number } | null { const session = this.getBattleSession(); const map = this.getMap(); const tf = session?.world.getComponent(refEntity, TransformComp); if (!session || !map || !tf) return null; const logicalCenter = session.grid.cellToWorld({ row: tf.row, col: tf.col }); const curGrid = map.getMapGrid(tf.row, tf.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 }; } static forEachMonsterNode(fn: (entityId: EntityId, node: Node) => void): void { const map = this._monsterPresenter?.getEntityNodeMap(); if (!map) return; for (const [entityId, node] of map) { if (node?.isValid) fn(entityId, node); } } /** 战斗逻辑时钟调度(与 Spine / 战斗倍速同步;勿用 scheduleOnce 墙钟) */ static schedulePresentation(delayLogicalSec: number, fn: () => void): void { schedulePresentationClock(delayLogicalSec, fn); } /** @deprecated 请用 schedulePresentation */ static scheduleBattle(delaySec: number, fn: () => void): void { schedulePresentationClock(delaySec, fn); } /** 局内战斗倍速(1 / 2;UI 暂停时 TimeScale=0) */ static getBattleTimeScale(): number { return getBattleTimeScale(); } /** 战斗 RNG(target_stage=3 等与逻辑层一致) */ static getBattleRng(): { next(): number } | null { return this._battle?.session?.rng ?? null; } }