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(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(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, '加速'); } /** * 己方玩家(0) → port1 攻 AI(1);AI(1) → port2 攻本机(0)。 */ private _resolveCampRoute(ownerPlayerId: number): { spawnPort: number; targetPlayerId: number; enemyCamp: boolean; } { if (ownerPlayerId === 0) { return { spawnPort: 1, targetPlayerId: 1, enemyCamp: false }; } return { spawnPort: 2, 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 ? 1 : 2; 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; }