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/combat/YingCampSummonHost.ts

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, '加速');
}
/**
* 己方玩家(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;
}