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.
823 lines
32 KiB
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;
|
|
}
|
|
|
|
/**
|
|
* 组合第一个字格世界坐标(左起首格;单格即该格)。
|
|
* 无 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) 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) 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) {
|
|
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;
|
|
}
|
|
}
|
|
|