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.
643 lines
25 KiB
643 lines
25 KiB
import { Vec3, sp } from 'cc';
|
|
import type { EntityId } from 'db://assets/mx/module/ecs';
|
|
import { BattlePhase } from 'db://assets/mx/module/battle';
|
|
import type { Cell } from 'db://assets/mx/module/battle/battlefield/Cell';
|
|
import type { GridMap } from 'db://assets/mx/module/battle/battlefield/GridMap';
|
|
import type { MapTopology } from 'db://assets/mx/module/battle/battlefield/MapTopology';
|
|
import type { Pathfinder } from 'db://assets/mx/module/battle/battlefield/Pathfinder';
|
|
import { AttributeComp, MovementComp, TransformComp } from 'db://assets/mx/module/battle/ecs/Components';
|
|
import { parseMonsterUnitDefId } from '../config/MonsterHpCalculator';
|
|
import type { TableBattleConfigProvider } from '../config/TableBattleConfigProvider';
|
|
import type { ProjectBattleHandle } from '../runtime/BattleRuntime';
|
|
import { findGridIndexForEntity } from '../runtime/DeployEntityQuery';
|
|
import { getTowerLoadoutService } from './TowerLoadoutService';
|
|
import { SkillAtkBuffExecutor } from './SkillAtkBuffExecutor';
|
|
import { MonsterDataManager, MonsterType } from '../../manager/MonsterDataManager';
|
|
import { YING_WEAPON1_ID } from '../heros/HeroActorRes';
|
|
import { TableNames } from '../../game/ConfigTableData';
|
|
import { getMonsterStatusText } from '../view/MonsterStatusText';
|
|
import { TextGrid } from '../view/TextGrid';
|
|
import {
|
|
YingCampAnim,
|
|
YING_ATTACK_OPEN_SEC,
|
|
YING_HOLD_BEFORE_JUMP_SEC,
|
|
YING_JUMP_SPEED,
|
|
YING_SPAWN_GROW_SEC,
|
|
YING_SPAWN_SCALE,
|
|
YING_SUMMON_SIZE_MUL,
|
|
} from '../view/YingCampAnim';
|
|
|
|
/** 营召唤移速 modifier 分组 */
|
|
const YING_SPEED_MOD_GROUP = 'ying_summon_speed';
|
|
|
|
/** 营字换位进度暂存有效期(毫秒):地图↔背包拖拽换位在数秒内完成,超时视为新营立即出怪 */
|
|
const YING_ELAPSED_STASH_TTL_MS = 10_000;
|
|
|
|
/** 单个营塔的独立出怪计时状态 */
|
|
interface YingSummonInstance {
|
|
/** 营塔实体 */
|
|
entityId: EntityId;
|
|
/** 部署玩家 id */
|
|
ownerPlayerId: number;
|
|
/** 营等级 */
|
|
level: number;
|
|
/** 出怪间隔(秒) */
|
|
intervalSec: number;
|
|
/** 每次触发出怪数量 */
|
|
count: number;
|
|
/** 配表怪物 id;-1 表示用当前波小怪 */
|
|
monsterId: number;
|
|
/** Skill.atk_buff1 原文 */
|
|
buff1: string;
|
|
/** Skill.atk_buff2 原文 */
|
|
buff2: string;
|
|
/** 距上次出怪累计时间 */
|
|
elapsed: number;
|
|
}
|
|
|
|
/** 草字头打开后再出怪 */
|
|
interface YingOpenPend {
|
|
/** 距打开剩余秒 */
|
|
remain: number;
|
|
/** 营塔 */
|
|
campEntity: EntityId;
|
|
/** 部署玩家 */
|
|
ownerPlayerId: number;
|
|
/** 出生格 */
|
|
campCell: Cell;
|
|
/** 跳出落点 */
|
|
join: Cell;
|
|
/** 单位定义 */
|
|
unitDefId: string;
|
|
/** 逻辑口 */
|
|
spawnPort: number;
|
|
/** 进攻目标玩家 */
|
|
targetPlayerId: number;
|
|
/** Spine 皮 */
|
|
skin: string;
|
|
/** atk_buff1 */
|
|
buff1: string;
|
|
/** atk_buff2 */
|
|
buff2: string;
|
|
/** 数量 */
|
|
count: number;
|
|
}
|
|
|
|
/** atk_para(怪 id、数量)+ Skill.cd(间隔)+ 同级技能 buff */
|
|
interface YingSkillConfig {
|
|
monsterId: number;
|
|
count: number;
|
|
intervalSec: number;
|
|
buff1: string;
|
|
buff2: string;
|
|
}
|
|
|
|
/** 营字换位进度暂存(实体被撤时保存,重新上场继承,避免换位丢 CD / 立即出怪) */
|
|
interface YingElapsedStash {
|
|
/** 部署玩家 id */
|
|
pid: number;
|
|
/** 被撤时的出怪进度(秒) */
|
|
elapsed: number;
|
|
/** 暂存时刻(毫秒) */
|
|
atMs: number;
|
|
}
|
|
|
|
/**
|
|
* 营字强化出怪:每个激活的营独立按 Skill.cd 间隔额外召唤当前波小怪。
|
|
* 攻击动画打开草字头后出生,再跳出进路;不走普攻 Ability。
|
|
* 损毁/魅惑禁攻时暂停出怪,解除后从原间隔进度恢复。
|
|
*/
|
|
export class YingCampSummonHost {
|
|
private _battle: ProjectBattleHandle | null = null;
|
|
private readonly _instances: YingSummonInstance[] = [];
|
|
private readonly _openPends: YingOpenPend[] = [];
|
|
private readonly _elapsedStash: YingElapsedStash[] = [];
|
|
private readonly _grassWorld = new Vec3();
|
|
|
|
/** 绑定战斗句柄 */
|
|
bind(battle: ProjectBattleHandle): void {
|
|
this._battle = battle;
|
|
this._instances.length = 0;
|
|
this._openPends.length = 0;
|
|
this._elapsedStash.length = 0;
|
|
}
|
|
|
|
/** 解除绑定 */
|
|
unbind(): void {
|
|
this._battle = null;
|
|
this._instances.length = 0;
|
|
this._openPends.length = 0;
|
|
this._elapsedStash.length = 0;
|
|
YingCampAnim.clear();
|
|
}
|
|
|
|
/**
|
|
* 推进计时并尝试出怪。
|
|
* @param dt 战斗缩放时间
|
|
*/
|
|
tick(dt: number): void {
|
|
const battle = this._battle;
|
|
if (!battle || battle.session.phase !== BattlePhase.COMBAT) return;
|
|
if (!(dt > 0)) return;
|
|
|
|
this._syncFromBattle(battle);
|
|
this._tickOpenPends(battle, dt);
|
|
|
|
for (let i = 0; i < this._instances.length; i++) {
|
|
const inst = this._instances[i];
|
|
if (!(inst.intervalSec > 0) || inst.count <= 0) continue;
|
|
// 损毁/魅惑禁攻:暂停计时,解除后从原进度恢复
|
|
if (this._isCampInactive(battle, inst.entityId)) continue;
|
|
inst.elapsed += dt;
|
|
while (inst.elapsed >= inst.intervalSec) {
|
|
inst.elapsed -= inst.intervalSec;
|
|
this._spawnBatch(battle, inst);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 扫描双方部署的营塔,登记/更新/移除 */
|
|
private _syncFromBattle(battle: ProjectBattleHandle): void {
|
|
const loadoutSvc = getTowerLoadoutService();
|
|
const seen: EntityId[] = [];
|
|
|
|
for (const player of battle.players) {
|
|
for (const entityId of player.deployedUnits) {
|
|
const loadout = loadoutSvc.getLoadout(entityId);
|
|
if (!loadout || loadout.weapon1Id !== YING_WEAPON1_ID) continue;
|
|
seen.push(entityId);
|
|
const cfg = this._resolveSkillConfig(loadout.level);
|
|
if (!cfg) continue;
|
|
const existing = this._findInstance(entityId);
|
|
if (existing) {
|
|
existing.ownerPlayerId = player.playerId;
|
|
existing.level = loadout.level;
|
|
existing.intervalSec = cfg.intervalSec;
|
|
existing.count = cfg.count;
|
|
existing.monsterId = cfg.monsterId;
|
|
existing.buff1 = cfg.buff1;
|
|
existing.buff2 = cfg.buff2;
|
|
} else {
|
|
// 换位(地图↔背包)重部署:继承被撤实体残留的出怪进度,不立即出怪;无暂存(新合成/开局)才立刻出一波
|
|
this._instances.push({
|
|
entityId,
|
|
ownerPlayerId: player.playerId,
|
|
level: loadout.level,
|
|
intervalSec: cfg.intervalSec,
|
|
count: cfg.count,
|
|
monsterId: cfg.monsterId,
|
|
buff1: cfg.buff1,
|
|
buff2: cfg.buff2,
|
|
elapsed: this._takeStashedElapsed(player.playerId) ?? cfg.intervalSec,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// 地图有营合字但 ECS 未部署时暴露问题
|
|
let yingCompose = 0;
|
|
for (const player of battle.players) {
|
|
const comps = player.compositions;
|
|
for (let i = 0; i < comps.length; i++) {
|
|
if (comps[i].weapon1Id === YING_WEAPON1_ID) yingCompose++;
|
|
}
|
|
}
|
|
if (yingCompose > 0 && seen.length === 0) {
|
|
console.log(
|
|
'[YingCampSummonHost] 地图有营但无 ECS 实体/loadout,强化出怪未启动',
|
|
'compose=',
|
|
yingCompose,
|
|
);
|
|
}
|
|
|
|
for (let i = this._instances.length - 1; i >= 0; i--) {
|
|
if (seen.indexOf(this._instances[i].entityId) < 0) {
|
|
const gone = this._instances[i];
|
|
// 营被撤下(拖回背包/卖出):暂存出怪进度,供重新上场继承
|
|
if (gone.intervalSec > 0) {
|
|
this._elapsedStash.push({
|
|
pid: gone.ownerPlayerId,
|
|
elapsed: Math.max(0, Math.min(gone.elapsed, gone.intervalSec)),
|
|
atMs: Date.now(),
|
|
});
|
|
}
|
|
this._instances.splice(i, 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 取出同玩家未过期的换位进度暂存(FIFO,过期条目顺带清理)。
|
|
* @param pid 部署玩家 id
|
|
*/
|
|
private _takeStashedElapsed(pid: number): number | null {
|
|
const now = Date.now();
|
|
for (let i = 0; i < this._elapsedStash.length; i++) {
|
|
const s = this._elapsedStash[i];
|
|
if (now - s.atMs > YING_ELAPSED_STASH_TTL_MS) {
|
|
this._elapsedStash.splice(i, 1);
|
|
i--;
|
|
continue;
|
|
}
|
|
if (s.pid !== pid) continue;
|
|
this._elapsedStash.splice(i, 1);
|
|
return s.elapsed;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private _findInstance(entityId: EntityId): YingSummonInstance | null {
|
|
for (let i = 0; i < this._instances.length; i++) {
|
|
if (this._instances[i].entityId === entityId) return this._instances[i];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Hero.atk_id[level-1] → Skill.atk_para(怪 id、数量)+ Skill.cd(间隔秒) */
|
|
private _resolveSkillConfig(level: number): YingSkillConfig | null {
|
|
const hero = gg.data.table.getTableData<ITableHero>(TableNames.Hero, YING_WEAPON1_ID);
|
|
if (!hero?.atk_id?.length) {
|
|
console.log('[YingCampSummonHost] 营缺少 atk_id');
|
|
return null;
|
|
}
|
|
const idx = Math.max(0, Math.min(hero.atk_id.length - 1, Math.floor(level) - 1));
|
|
const skillId = hero.atk_id[idx];
|
|
const skill = gg.data.table.getTableData<ITableSkill>(TableNames.Skill, skillId);
|
|
if (!skill) {
|
|
console.log('[YingCampSummonHost] 营技能缺失', skillId);
|
|
return null;
|
|
}
|
|
const parts = String(skill.atk_para ?? '').split(',').map((s) => Number(String(s).trim()));
|
|
if (parts.length < 2) {
|
|
console.log('[YingCampSummonHost] atk_para 非法', skill.atk_para);
|
|
return null;
|
|
}
|
|
const monsterId = parts[0];
|
|
const count = Math.floor(parts[1]);
|
|
const intervalSec = Number(skill.cd);
|
|
if (!(count > 0) || !(intervalSec > 0)) {
|
|
console.log('[YingCampSummonHost] 数量/CD 无效', skillId, skill.atk_para, skill.cd);
|
|
return null;
|
|
}
|
|
return {
|
|
monsterId,
|
|
count,
|
|
intervalSec,
|
|
buff1: String(skill.atk_buff1 ?? '').trim(),
|
|
buff2: String(skill.atk_buff2 ?? '').trim(),
|
|
};
|
|
}
|
|
|
|
private _spawnBatch(battle: ProjectBattleHandle, inst: YingSummonInstance): void {
|
|
const waveInfo = this._resolveWaveSmallMonster(battle, inst.ownerPlayerId);
|
|
if (!waveInfo) return;
|
|
|
|
let monsterId = inst.monsterId;
|
|
if (monsterId === -1) {
|
|
monsterId = waveInfo.monsterId;
|
|
}
|
|
const row = MonsterDataManager.getMonsterDataById(monsterId);
|
|
if (!row) {
|
|
console.log('[YingCampSummonHost] 怪物表缺失', monsterId);
|
|
return;
|
|
}
|
|
if (row.type !== MonsterType.SmallMonster) {
|
|
console.log('[YingCampSummonHost] 营只出小怪,跳过', monsterId, row.type);
|
|
return;
|
|
}
|
|
|
|
const { spawnPort, targetPlayerId, enemyCamp } = this._resolveCampRoute(inst.ownerPlayerId);
|
|
const campCell = this._resolveCampCell(battle, inst.entityId);
|
|
if (!campCell) {
|
|
console.log('[YingCampSummonHost] 营塔无格坐标', inst.entityId);
|
|
return;
|
|
}
|
|
const campAreaId = battle.session.topology.getSpawnPortCampArea(spawnPort);
|
|
if (campAreaId === undefined) {
|
|
console.log('[YingCampSummonHost] 出怪口无阵营区域', spawnPort);
|
|
return;
|
|
}
|
|
const join = resolveSummonJumpJoin(battle.session.grid, battle.session.pathfinder, battle.session.topology, campCell, spawnPort);
|
|
if (!join) {
|
|
console.log('[YingCampSummonHost] 无跳出落点', campCell, spawnPort);
|
|
return;
|
|
}
|
|
|
|
const config = gg.battle.config as TableBattleConfigProvider;
|
|
const unitDefId = config.ensureSpawnMonsterDef(monsterId, spawnPort, waveInfo.difficultyRow);
|
|
const skin = MonsterDataManager.resolveYingSummonSpineSkin(row, inst.level, enemyCamp);
|
|
if (!skin) {
|
|
console.log('[YingCampSummonHost] 营召唤皮肤为空', monsterId, inst.level);
|
|
}
|
|
|
|
this._playCampAttack(battle, inst.entityId, inst.ownerPlayerId);
|
|
this._openPends.push({
|
|
remain: YING_ATTACK_OPEN_SEC,
|
|
campEntity: inst.entityId,
|
|
ownerPlayerId: inst.ownerPlayerId,
|
|
campCell,
|
|
join,
|
|
unitDefId,
|
|
spawnPort,
|
|
targetPlayerId,
|
|
skin: skin || '',
|
|
buff1: inst.buff1,
|
|
buff2: inst.buff2,
|
|
count: inst.count,
|
|
});
|
|
}
|
|
|
|
/** 推进草字头打开等待 */
|
|
private _tickOpenPends(battle: ProjectBattleHandle, dt: number): void {
|
|
for (let i = this._openPends.length - 1; i >= 0; i--) {
|
|
const pend = this._openPends[i];
|
|
if (this._isCampInactive(battle, pend.campEntity)) {
|
|
this._openPends.splice(i, 1);
|
|
continue;
|
|
}
|
|
pend.remain -= dt;
|
|
if (pend.remain > 0) continue;
|
|
this._openPends.splice(i, 1);
|
|
this._flushOpenPend(battle, pend);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 营是否处于禁攻(损毁 / 魅惑)。解除后出怪恢复。
|
|
* @param battle 战斗句柄
|
|
* @param campEntity 营塔
|
|
*/
|
|
private _isCampInactive(battle: ProjectBattleHandle, campEntity: EntityId): boolean {
|
|
return !!battle.session.castBlockPredicate?.(campEntity);
|
|
}
|
|
|
|
/** 草字头打开:先挂起点再出怪(unitSpawned 同步读 pending) */
|
|
private _flushOpenPend(battle: ProjectBattleHandle, pend: YingOpenPend): void {
|
|
const cellNode = battle.Map?.getMapGrid(pend.campCell.row, pend.campCell.col)?.node ?? null;
|
|
const hasStart = YingCampAnim.readSpawnWorld(cellNode, this._grassWorld);
|
|
if (hasStart) {
|
|
YingCampAnim.setPendingStart(this._grassWorld, {
|
|
scaleFrom: YING_SPAWN_SCALE,
|
|
scaleMul: YING_SUMMON_SIZE_MUL,
|
|
growSec: YING_SPAWN_GROW_SEC,
|
|
holdSec: YING_HOLD_BEFORE_JUMP_SEC,
|
|
jumpTrail: true,
|
|
jumpSpeed: YING_JUMP_SPEED,
|
|
});
|
|
}
|
|
else console.log('[YingCampSummonHost] 营格节点缺失,怪会从营格心起跳', pend.campEntity);
|
|
for (let n = 0; n < pend.count; n++) {
|
|
const entity = battle.session.spawner.spawnExtraEnemyAtCell(
|
|
pend.unitDefId,
|
|
pend.spawnPort,
|
|
pend.targetPlayerId,
|
|
pend.campCell,
|
|
pend.skin || undefined,
|
|
pend.join,
|
|
);
|
|
if (entity == null) break;
|
|
this._applySpawnBuffs(battle, entity, pend.buff1);
|
|
this._applySpawnBuffs(battle, entity, pend.buff2);
|
|
}
|
|
YingCampAnim.clearPendingStart();
|
|
}
|
|
|
|
/** 播营「攻击」打开草字头 */
|
|
private _playCampAttack(battle: ProjectBattleHandle, campEntity: EntityId, ownerPlayerId: number): void {
|
|
const sk = this._resolveCampSkeleton(battle, campEntity, ownerPlayerId);
|
|
if (!sk) {
|
|
console.log('[YingCampSummonHost] 营展示 Spine 缺失', campEntity);
|
|
return;
|
|
}
|
|
YingCampAnim.playAttack(sk);
|
|
}
|
|
|
|
/** 营塔格子上的展示 Spine */
|
|
private _resolveCampSkeleton(battle: ProjectBattleHandle, campEntity: EntityId, ownerPlayerId: number): sp.Skeleton | null {
|
|
const tf = battle.session.world.getComponent(campEntity, TransformComp);
|
|
const cell = tf ? { row: tf.row, col: tf.col } : null;
|
|
return this._resolveCampTextGrid(battle, campEntity, ownerPlayerId, cell)?.getYingCampSkeleton() ?? null;
|
|
}
|
|
|
|
/** 优先 Transform 格上的 TextGrid,再按实体回查 */
|
|
private _resolveCampTextGrid(battle: ProjectBattleHandle, campEntity: EntityId, ownerPlayerId: number, campCell: { row: number; col: number } | null): TextGrid | null {
|
|
const map = battle.Map;
|
|
if (!map) return null;
|
|
if (campCell) {
|
|
const mg = map.getMapGrid(campCell.row, campCell.col);
|
|
const atCell = mg?.node?.getComponentInChildren(TextGrid) ?? null;
|
|
if (atCell) return atCell;
|
|
}
|
|
const player = battle.getPlayer(ownerPlayerId);
|
|
if (!player) return null;
|
|
const gridIndex = findGridIndexForEntity(campEntity, player, battle.session, map);
|
|
if (gridIndex == null) return null;
|
|
const mg = map.getMapGridByIndex(gridIndex);
|
|
return mg?.node?.getComponentInChildren(TextGrid) ?? null;
|
|
}
|
|
|
|
/** 营塔 Transform 格 */
|
|
private _resolveCampCell(battle: ProjectBattleHandle, entityId: EntityId): Cell | null {
|
|
const tf = battle.session.world.getComponent(entityId, TransformComp);
|
|
if (!tf) return null;
|
|
return { row: tf.row, col: tf.col };
|
|
}
|
|
|
|
/**
|
|
* 出生时应用 atk_buff:3=移速加算、4=血量上限(保持原血量比;出生不加血特效)。
|
|
* @param battle 战斗
|
|
* @param entity 刚召唤的怪
|
|
* @param raw atk_buff 原文
|
|
*/
|
|
private _applySpawnBuffs(battle: ProjectBattleHandle, entity: EntityId, raw: string): void {
|
|
const s = String(raw ?? '').trim();
|
|
if (!s) return;
|
|
const parts = s.split(',').map((x) => Number(String(x).trim()));
|
|
const kind = parts[0] || 0;
|
|
const chanceWan = parts[1] ?? 10000;
|
|
if (chanceWan < 10000 && Math.random() * 10000 >= chanceWan) return;
|
|
|
|
if (kind === 3) {
|
|
this._applySpeedBuff(battle, entity, parts[2] ?? 0, parts[3] ?? 0);
|
|
return;
|
|
}
|
|
if (kind === 4) {
|
|
SkillAtkBuffExecutor.applyMaxHpCoeff(battle.session.world, entity, parts[2] ?? 0, battle, false);
|
|
return;
|
|
}
|
|
console.log('[YingCampSummonHost] 未支持的 atk_buff 类型', kind);
|
|
}
|
|
|
|
/** 移速:最终移速 × (1 + 加成万分比/10000) */
|
|
private _applySpeedBuff(battle: ProjectBattleHandle, entity: EntityId, bonusWan: number, durationSec: number): void {
|
|
if (!(bonusWan > 0)) return;
|
|
const mul = 1 + bonusWan / 10000;
|
|
const world = battle.session.world;
|
|
if (!world.getComponent(entity, AttributeComp)) {
|
|
world.addComponent(entity, new AttributeComp());
|
|
}
|
|
const move = world.getComponent(entity, MovementComp);
|
|
battle.session.attributes.addModifier(entity, {
|
|
attr: 'move_speed_mul',
|
|
op: 'Mul',
|
|
value: mul,
|
|
source: YING_SPEED_MOD_GROUP,
|
|
group: YING_SPEED_MOD_GROUP,
|
|
remain: durationSec > 0 ? durationSec : undefined,
|
|
});
|
|
if (move) {
|
|
move.speed = Math.max(0, (move.baseSpeed || move.speed) * mul);
|
|
}
|
|
getMonsterStatusText().play(entity, '加速');
|
|
}
|
|
|
|
/**
|
|
* 本机从 spawnPoint_2 出我方怪攻 AI;AI 从 spawnPoint_1 出敌方怪攻本机。
|
|
*/
|
|
private _resolveCampRoute(ownerPlayerId: number): {
|
|
spawnPort: number;
|
|
targetPlayerId: number;
|
|
enemyCamp: boolean;
|
|
} {
|
|
if (ownerPlayerId === 0) {
|
|
return { spawnPort: 2, targetPlayerId: 1, enemyCamp: false };
|
|
}
|
|
return { spawnPort: 1, targetPlayerId: 0, enemyCamp: true };
|
|
}
|
|
|
|
/** 当前/最近一波小怪段 id 与难度行 */
|
|
private _resolveWaveSmallMonster(
|
|
battle: ProjectBattleHandle,
|
|
ownerPlayerId: number,
|
|
): { monsterId: number; difficultyRow: number } | null {
|
|
const waves = battle.session.levelDef?.waves;
|
|
if (!waves?.length) {
|
|
console.log('[YingCampSummonHost] 无波次配置');
|
|
return null;
|
|
}
|
|
let wi = battle.session.spawner.waveIndex;
|
|
if (wi < 0) wi = 0;
|
|
if (wi >= waves.length) wi = waves.length - 1;
|
|
const wave = waves[wi];
|
|
const spawnPort = ownerPlayerId === 0 ? 2 : 1;
|
|
let portPlan = null as (typeof wave.ports)[number] | null;
|
|
for (let i = 0; i < (wave.ports?.length ?? 0); i++) {
|
|
if (wave.ports[i].port === spawnPort) {
|
|
portPlan = wave.ports[i];
|
|
break;
|
|
}
|
|
}
|
|
if (!portPlan) portPlan = wave.ports?.[0] ?? null;
|
|
if (!portPlan?.segments?.length) {
|
|
console.log('[YingCampSummonHost] 波次无出怪段', wi, spawnPort);
|
|
return null;
|
|
}
|
|
|
|
for (let s = 0; s < portPlan.segments.length; s++) {
|
|
const seg = portPlan.segments[s];
|
|
const parsed = parseMonsterUnitDefId(seg.unitDefId);
|
|
if (!parsed) continue;
|
|
const row = MonsterDataManager.getMonsterDataById(parsed.monsterId);
|
|
if (row?.type === MonsterType.SmallMonster) {
|
|
return {
|
|
monsterId: parsed.monsterId,
|
|
difficultyRow: Math.max(1, Number(seg.difficultyRow) || parsed.difficultyRow || 1),
|
|
};
|
|
}
|
|
}
|
|
console.log('[YingCampSummonHost] 本波无小怪段', wi);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** 横向最近进路格(不含出怪口);没有或与口同行则用普通怪跳跃落点。 */
|
|
export function resolveSummonJumpJoin(grid: GridMap, pathfinder: Pathfinder, topology: MapTopology, from: Cell, spawnPort: number): Cell | null {
|
|
const spawn = topology.getSpawnCell(spawnPort);
|
|
const sameRowAsPortal = !!spawn && from.row === spawn.row;
|
|
const campAreaId = topology.getSpawnPortCampArea(spawnPort);
|
|
if (!sameRowAsPortal && campAreaId !== undefined) {
|
|
const hz = findHorizontalSummonJoin(grid, pathfinder, from, campAreaId, spawn);
|
|
if (hz && !isSpawnPortalCell(hz, spawn)) return hz;
|
|
}
|
|
const land = resolvePortalJumpLand(pathfinder, topology, spawnPort);
|
|
if (!land || isSpawnPortalCell(land, spawn)) {
|
|
console.log('[YingCampSummonHost] 出怪口不可作跳跃落点', spawnPort, from);
|
|
return null;
|
|
}
|
|
return land;
|
|
}
|
|
|
|
/**
|
|
* 同行最近可达进路格;起点本身可走且可达则直接用。
|
|
* 左右等距时取路径代价更低者。出怪口格不参选。
|
|
* @param grid 地图
|
|
* @param pathfinder 寻路
|
|
* @param from 起点
|
|
* @param campAreaId 阵营区域
|
|
* @param spawn 出怪口格(排除)
|
|
*/
|
|
export function findHorizontalSummonJoin(grid: GridMap, pathfinder: Pathfinder, from: Cell, campAreaId: number, spawn?: Cell | null): Cell | null {
|
|
if (grid.isWalkable(from) && !isSpawnPortalCell(from, spawn)) {
|
|
const selfCost = pathfinder.getPathCost(from, campAreaId);
|
|
if (Number.isFinite(selfCost)) return { row: from.row, col: from.col };
|
|
}
|
|
|
|
let best: Cell | null = null;
|
|
let bestDist = Number.POSITIVE_INFINITY;
|
|
let bestCost = Number.POSITIVE_INFINITY;
|
|
for (let c = 0; c < grid.cols; c++) {
|
|
if (c === from.col) continue;
|
|
const cell = { row: from.row, col: c };
|
|
if (isSpawnPortalCell(cell, spawn)) continue;
|
|
if (!grid.isWalkable(cell)) continue;
|
|
const cost = pathfinder.getPathCost(cell, campAreaId);
|
|
if (!Number.isFinite(cost)) continue;
|
|
const dist = Math.abs(c - from.col);
|
|
if (dist < bestDist || (dist === bestDist && cost < bestCost)) {
|
|
best = cell;
|
|
bestDist = dist;
|
|
bestCost = cost;
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
/** 普通怪出怪口跳跃落点(进路第一格,不含出怪口本身)。 */
|
|
export function resolvePortalJumpLand(pathfinder: Pathfinder, topology: MapTopology, spawnPort: number): Cell | null {
|
|
const spawn = topology.getSpawnCell(spawnPort);
|
|
if (!spawn) return null;
|
|
const campAreaId = topology.getSpawnPortCampArea(spawnPort);
|
|
if (campAreaId === undefined) {
|
|
console.log('[YingCampSummonHost] 出怪口无阵营区域,无法取跳跃落点', spawnPort);
|
|
return null;
|
|
}
|
|
const target = pathfinder.shortestTargetCell(spawn, campAreaId);
|
|
if (!target) {
|
|
console.log('[YingCampSummonHost] 出怪口无攻击目标,无法取跳跃落点', spawnPort);
|
|
return null;
|
|
}
|
|
const path = pathfinder.buildPathToTarget(spawn, target, campAreaId);
|
|
if (path.length <= 1) {
|
|
console.log('[YingCampSummonHost] 出怪口进路不足一格', spawnPort);
|
|
return null;
|
|
}
|
|
const land = { row: path[1].row, col: path[1].col };
|
|
if (isSpawnPortalCell(land, spawn)) {
|
|
console.log('[YingCampSummonHost] 进路第一格仍是出怪口', spawnPort);
|
|
return null;
|
|
}
|
|
return land;
|
|
}
|
|
|
|
/** 是否出怪口格 */
|
|
function isSpawnPortalCell(cell: Cell, spawn: Cell | null | undefined): boolean {
|
|
return !!spawn && cell.row === spawn.row && cell.col === spawn.col;
|
|
}
|
|
|
|
let _yingHost: YingCampSummonHost | null = null;
|
|
|
|
/** 全局营召唤 Host(单局复用) */
|
|
export function getYingCampSummonHost(): YingCampSummonHost {
|
|
if (!_yingHost) _yingHost = new YingCampSummonHost();
|
|
return _yingHost;
|
|
}
|
|
|