You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
AdLike2048/assets/script/battle/skill/SkillPresentationEnv.ts

823 lines
32 KiB

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 unknown 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<Node>();
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<ITableHero>(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);
}
/** 施法者已选技能是否含指定 id(旧词条 weapon 过滤已删) */
static casterHasSkillEntry(caster: EntityId, entryId: number): boolean {
const session = this._battle?.session;
if (!session?.world.isAlive(caster)) return false;
for (const player of this._resolvePlayersForCaster(session, caster)) {
for (const entry of player.selectedSkills) {
if (Number(entry.id) === entryId) return true;
}
}
return false;
}
/** 塔上 AtkHookComp.specials(旧 Skill.skill_special 词条已删) */
static getSkillSpecials(caster: EntityId): readonly string[] {
return this._collectSkillSpecials(caster, false);
}
/** 大招向 specials:head=3/4/5/8 */
static getUltSkillSpecials(caster: EntityId): readonly string[] {
return this._collectSkillSpecials(caster, true);
}
/**
* @param caster 施法者
* @param ultOnly 是否只收大招 head
*/
private static _collectSkillSpecials(caster: EntityId, ultOnly: boolean): readonly string[] {
const session = this._battle?.session;
if (!session?.world.isAlive(caster)) return [];
const out = createMembershipSet<string>();
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 (ultOnly) {
const head = Number(s.split(',')[0]);
if (head !== 3 && head !== 4 && head !== 5 && head !== 8) continue;
}
membershipAdd(out, s);
}
}
return membershipToArray(out);
}
private static _resolvePlayersForCaster(
session: NonNullable<IBattleRuntimeHandle['session']>,
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<Player>();
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<Vec3>, 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;
}
}