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/runtime/BattleRuntime.ts

2019 lines
75 KiB

import {
BattleFactory,
BattlePhase,
BattleSession,
BattleEventType,
registerBuiltinBattleEffects,
UnitMetaComp,
TransformComp,
MovementComp,
TeamComp,
TagComp,
EntityTag,
BattleTeamId,
type BattleEvent,
type BattleEventSubscription,
} from 'db://assets/mx/module/battle';
import type { EntityId } from 'db://assets/mx/module/ecs';
import { createMembershipSet, membershipFromArray } from '../../../mx/tools/MembershipSet';
import { ClientErrorReporter } from '../../../mx/tools/ClientErrorReporter';
import { registerAtkHookEffects } from '../combat/AtkHookEffects';
import { registerSkillSummonEffects } from '../combat/SkillTableSummonExecutor';
import { registerSkillAtkBuffEffects } from '../combat/SkillAtkBuffExecutor';
import { getPassiveSkillHost, isPassiveResultDamage } from '../combat/PassiveSkillRuntime';
import { getTowerExpHost } from '../combat/TowerExpHost';
import { getBowAtkHost } from '../combat/BowAtkRuntime';
import { getYingCampSummonHost } from '../combat/YingCampSummonHost';
import { getBattleBuffMoveSpeedHost } from '../combat/BattleBuffMoveSpeedHost';
import { getBattleBuffUnitHpHost } from '../combat/BattleBuffUnitHpHost';
import { getBattleBuffUnitReviveHost } from '../combat/BattleBuffUnitReviveHost';
import { getBattleBuffUnitAttackSpeedHost } from '../combat/BattleBuffUnitAttackSpeedHost';
import { getBattleBuffEnemySlowHost } from '../combat/BattleBuffEnemySlowHost';
import { getBattleBuffBaseThunderHost } from '../combat/BattleBuffBaseThunderHost';
import { getBattleBuffBossInterruptHost } from '../combat/BattleBuffBossInterruptHost';
import { WeaponDropService } from '../combat/WeaponDropService';
import { BattleBuffCarryStore } from '../../manager/battleBuff/BattleBuffCarryStore';
import { resolveCarriedBaseHpBonus, rollCarriedKillPeachBonus } from '../../manager/battleBuff/BattleBuffPassiveSkillUtil';
import { getMonsterSkillHost } from '../combat/MonsterSkillHost';
import { getNezhaChargeHost } from '../presentation/NezhaChargePresenter';
import { getArrowRainHost } from '../presentation/ArrowRainPresenter';
import { getExtraRingHost } from '../presentation/RingProjectilesPresenter';
import { getExtraStrikeHost } from '../presentation/ExtraStrikePresenter';
import { getSpearTossHost } from '../presentation/SpearTossPresenter';
import { getPagodaSmashHost } from '../presentation/PagodaSmashPresenter';
import { getStaffSmashHost } from '../presentation/StaffSmashPresenter';
import { getTowerLevelDownHost } from '../combat/TowerLevelDownHost';
import { getTowerRuinHost } from '../combat/TowerRuinHost';
import { SkillAtkBuffBoardViewBind } from '../combat/SkillAtkBuffBoardBridge';
import { HeroActorHost } from '../heros/HeroActorHost';
import { getAllySkillExtraHost } from '../presentation/AllySkillExtraHost';
import { getFaXiangHost } from '../presentation/FaXiangHost';
import { getBattleGameGlobalAttackSpeedHost } from '../combat/BattleGameGlobalAttackSpeedHost';
import { getBattleGameRandomActiveSkillHost } from '../../ui/BattleGame/BattleGameRandomActiveSkillHost';
import { getBattleGameGlobalAttackRangeHost } from '../combat/BattleGameGlobalAttackRangeHost';
import { TableNames } from '../../game/ConfigTableData';
import { StatisticsType } from '../../game/GameDataEnums';
import { UIID } from '../../game/ConfigRes';
import { GEvent } from 'db://assets/mx/module/event/GEvent';
import type { LevelDef } from 'db://assets/mx/module/battle/config/BattleConfigTypes';
import { ChapterLevelBuilder, DEFAULT_BATTLE_WALL_HP } from '../config/ChapterLevelBuilder';
import { probeMapGridSpecFromPrefab } from '../map/MapGridSpecReader';
import {
applyGlobalMonsterSlowToEntity,
sumSelectedBaseAttrWan,
} from '../skill/GlobalSkillEffects';
import { sdkConfig } from '../../sdk/sdkConfig';
import type { MapManager } from '../map/legacy/MapManager';
import type { BattleBagSession } from '../bag/BattleBagSession';
import type { DeploySync } from './DeploySync';
import { BattleLogicThrottle } from './BattleLogicThrottle';
import { BattlePresentationBudget } from '../view/BattlePresentationBudget';
import { DeviceProfile } from '../view/DeviceProfile';
import { SPAWN_PRESENT_SEC, type CocosBattleMonsterPresenter } from '../view/CocosBattleMonsterPresenter';
import { BATTLE_MONSTER_CCCLASS } from '../view/IBattleMonsterView';
import { Node, Vec3 } from 'cc';
import type {
BattleDeployParams,
BattleStartParams,
IBattleRuntimeHandle,
ProjectBattleMode,
} from '../types/ProjectBattleTypes';
import { Player } from '../../game/Player';
import { setupTowerCombat } from '../combat/TowerCombatSetup';
import { getTowerLoadoutService } from '../combat/TowerLoadoutService';
import type { PlayerSlot } from 'db://assets/mx/module/battle';
import { BattleDpsMeter } from './BattleDpsMeter';
import { BattleMechanismHost } from '../mechanism/BattleMechanismHost';
import { CharmController } from '../mechanism/CharmController';
import { isMechanismKillExcluded, isMechanismWitchUnit } from '../mechanism/MechanismServices';
import {
parseChapterAdvertiseLimit,
getChapterFreeBox,
getChapterFreeTips,
getShovelAdMax,
isChapter1NormalOnly,
} from '../bag/BagPrepConfig';
import { parseLegacyMonsterId, parseMonsterUnitDefId } from '../config/MonsterHpCalculator';
import { MonsterDataManager, MonsterType } from '../../manager/MonsterDataManager';
import { BattleAudio } from '../audio/BattleAudio';
/** 复活赠送桃子数量(常量表无此行,局内固定) */
const REVIVE_PEACH_REWARD = 50;
/** 默认关;控制台 `setBattleRuntimeLogVerbose(true)` 开启 */
let _rtLogVerbose = false;
/**
* 开关 BattleRuntime 打印。
* @param enabled 是否开启
*/
export function setBattleRuntimeLogVerbose(enabled: boolean): void {
_rtLogVerbose = !!enabled;
}
/** 去掉对象参数,避免 DevTools 展开 / 小游戏序列化开销 */
function _withoutObjects(args: unknown[]): unknown[] {
const out: unknown[] = [];
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a !== null && typeof a === 'object') continue;
out.push(a);
}
return out;
}
/** BattleRuntime 调试 log */
function rtLog(...args: unknown[]): void {
if (!_rtLogVerbose) return;
console.log(..._withoutObjects(args));
}
/** BattleRuntime 调试 warn */
function rtWarn(...args: unknown[]): void {
if (!_rtLogVerbose) return;
console.log(..._withoutObjects(args));
}
try {
(globalThis as { setBattleRuntimeLogVerbose?: typeof setBattleRuntimeLogVerbose }).setBattleRuntimeLogVerbose =
setBattleRuntimeLogVerbose;
} catch {
/* ignore */
}
/** 怪物查询桥接:供 NewMechanism 查询场上怪物节点 */
class MonsterSpawnerBridge {
node: Node | null = null;
/**
* 仅把场上怪移回各自出生点(表现层)。
* 逻辑层 Transform/寻路由 BattleRuntime.resetAliveMonstersToBornPosAfterRevive 同步。
*/
/**
* 复活:怪物回出生点并待机,等 COMBAT 恢复后按出怪间隔逐个放行。
*/
holdAllMonstersAtBornPos(): void {
for (const node of this.getAllMonster()) {
const comp = node.getComponent(BATTLE_MONSTER_CCCLASS) as {
holdAtBornAfterRevive?: () => void;
} | null;
comp?.holdAtBornAfterRevive?.();
}
}
releaseReviveMonster(entityId: EntityId): void {
for (const node of this.getAllMonster()) {
const comp = node.getComponent(BATTLE_MONSTER_CCCLASS) as {
entityId?: EntityId | null;
releaseFromReviveQueue?: () => void;
} | null;
if (comp?.entityId === entityId) {
comp.releaseFromReviveQueue?.();
return;
}
}
}
/** @deprecated 请用 holdAllMonstersAtBornPos */
setAllMonsterToBornPos(): void {
this.holdAllMonstersAtBornPos();
}
/** WAIT_REVIVE:停掉场上怪的攻击表现 */
freezeMonstersForWaitRevive(): void {
for (const node of this.getAllMonster()) {
const comp = node.getComponent(BATTLE_MONSTER_CCCLASS) as {
onEnterWaitRevive?: () => void;
} | null;
comp?.onEnterWaitRevive?.();
}
}
getMonstersInArea(_pos: unknown, _radius: number): unknown[] {
return this.getAllMonster();
}
getAllMonster(): Node[] {
return this._presenter?.getAllMonsterNodes() ?? [];
}
private _presenter: CocosBattleMonsterPresenter | null = null;
bindPresenter(presenter: CocosBattleMonsterPresenter): void {
this._presenter = presenter;
this.node = presenter.monsterRoot;
}
}
/** 单次战斗运行时(含 UI / NewMechanism 所需的状态与桥接) */
export class BattleRuntime implements IBattleRuntimeHandle {
readonly session: BattleSession;
readonly levelConfigId: number;
/** Level.map 预制体名 */
readonly mapPrefabName: string;
/** @deprecated 从 mapPrefabName 解析的数字索引 */
readonly mapId: number;
readonly levelId: string;
readonly mode: ProjectBattleMode;
readonly playerRankId: number;
readonly opponentRankId: number;
readonly MonsterSpawner = new MonsterSpawnerBridge();
/** 本局理论秒伤(UI / 三选1 危险系数共用) */
readonly dpsMeter = new BattleDpsMeter();
/** 局内武器碎片掉落 */
readonly weaponDrop = new WeaponDropService();
/** 当前理论秒伤;由 UIBattleGame 刷新 */
currentDps = 0;
Map: MapManager | null = null;
bagSession: BattleBagSession | null = null;
/** 合成态 → ECS 塔对账(CocosBagBoardView.bind 时创建) */
deploySync: DeploySync | null = null;
/** 旧 Monster / MapAgent 仍读取 curMap */
get curMap(): MapManager | null {
return this.Map;
}
/** COMBAT 阶段怪物才可移动 */
get CanMove(): boolean {
return this.session.phase === BattlePhase.COMBAT;
}
/** MX 地图拓扑已导入(走逻辑层移动) */
get useMxMovement(): boolean {
const topo = this.session.topology;
if (topo.enablePatrolLoop) {
return topo.patrolLoop.length > 0;
}
return topo.attackTargets.length > 0;
}
/** 本局是否有基地/攻击站立目标(wallTarget) */
get hasTangSeng(): boolean {
return this.session.topology.attackTargets.length > 0;
}
get IsGamePause(): boolean {
const p = this.session.phase;
return p === BattlePhase.PAUSE || p === BattlePhase.WAIT_REVIVE;
}
get IsGameOver(): boolean {
return this.session.phase === BattlePhase.END;
}
get IsWaitingRevive(): boolean {
return this.session.phase === BattlePhase.WAIT_REVIVE;
}
LevelConfigId = 1;
MapId = 1;
PlayerRankId = 1;
OpponentRankId = 1;
BattleTime = 0;
TimeScale = 1;
/** 本局战斗倍速(1 或 2;UI 暂停时 TimeScale=0) */
battleSpeedScale = 1;
/** 本局已通过看视频解锁 2 倍速,可在 1x/2x 间自由切换 */
battleSpeed2xUnlockedThisMatch = false;
/** 本局已通过看视频解锁自动合成 */
autoMergeUnlockedThisMatch = false;
/** 自动合成是否处于激活状态 */
autoMergeActive = false;
static readonly BATTLE_SPEED_NORMAL = 1;
static readonly BATTLE_SPEED_FAST = 2;
/** 与 `_runTickStep` 序号对齐的异常上报点 */
private static readonly _tickCaptures = [
'BattleRuntime_baseThunder',
'BattleRuntime_session',
'BattleRuntime_forcedPath',
'BattleRuntime_battleTime',
'BattleRuntime_mechanism',
'BattleRuntime_charm',
'BattleRuntime_levelDown',
'BattleRuntime_ruin',
'BattleRuntime_passive',
'BattleRuntime_allySkill',
'BattleRuntime_faxiang',
'BattleRuntime_monsterSkill',
'BattleRuntime_bow',
'BattleRuntime_ying',
'BattleRuntime_nezha',
'BattleRuntime_arrowRain',
'BattleRuntime_spear',
'BattleRuntime_pagoda',
'BattleRuntime_staff',
'BattleRuntime_reviveRelease',
'BattleRuntime_bajiaoRelease',
'BattleRuntime_waveNum',
'BattleRuntime_extraRing',
];
CurentCoinNum = 0;
/** 最近一次复活成功发放的桃子数(供 UI 飞桃表现,读后清零) */
lastRevivePeachGrant = 0;
CurentWaveNum = 0;
TotalMonsterNum = 0;
/** 本局已使用广告回血次数 */
curAdAddHpLeftNum = 0;
IsWatchAd = false;
IsEdit = false;
/** 备战:本局剩余免费格子次数(Chapter.free_box) */
prepFreeGridRemaining = 0;
/** 备战:本局免费格子上限(Chapter.free_box,UI 展示用) */
prepFreeGridMax = 0;
/** 备战:本局免费铲尚未使用(每局 1 次;第一章普通难度无入口) */
prepFreeShovelAvailable = false;
/** 备战:本局已消耗格子广告次数 */
prepGridAdUsed = 0;
/** 备战:本局已消耗铲广告次数 */
prepShovelAdUsed = 0;
/** 备战:格子广告次数上限(advertise_limit[3]) */
prepGridAdMax = 0;
/** 备战:铲广告次数上限(advertise_limit[2]) */
prepShovelAdMax = 0;
/** 芭蕉扇:本局已消耗广告次数 */
bajiaoFanAdUsed = 0;
/** 芭蕉扇:广告次数上限(advertise_limit[6]) */
bajiaoFanAdMax = 0;
/** 门神盾牌:本局广告已展示(触发后待点击) */
menShenAdOfferVisible = false;
/** 门神盾牌:本局已观看广告并放置陷阱 */
menShenAdConsumed = false;
/** 备战:本局剩余免费摆放提示次数(Chapter.free_tips) */
prepFreeTipsRemaining = 0;
/** 备战:本局免费摆放提示上限(Chapter.free_tips,UI 展示用) */
prepFreeTipsMax = 0;
CurentConfigChapter: ITableLevel = {} as ITableLevel;
/** mapID 随机到的地图/出怪章 */
CurentConfigChapter2: ITableLevel | null = null;
private _destroyed = false;
/** 背包/地图拖动持有计数(仅画面弱化,不改 TimeScale) */
private _bagDragSlowHold = 0;
private _battleTimeAcc = 0;
private _curFailReviveLeftNum = 0;
private _monsterDeadNum = 0;
private _monsterPresenter: CocosBattleMonsterPresenter | null = null;
private readonly _mechanismHost = new BattleMechanismHost();
readonly charmController = new CharmController();
/**
* 魅惑或损毁:禁止该塔施法。Boss 出生跳出未完成时禁止技能。
* @param entityId 实体
*/
private readonly _blockCast = (entityId: EntityId): boolean => {
if (this._isBossSpawnJumping(entityId)) return true;
return this.charmController.isEntityCharmed(entityId) || getTowerRuinHost().isEntityRuined(entityId);
};
/**
* Boss 仍在出生跳出(presentLocked + spawnProtected)。
* 跳出落地会先解 presentLocked,此时开始技能计时。
* @param entityId 实体
*/
private _isBossSpawnJumping(entityId: EntityId): boolean {
const world = this.session.world;
const move = world.getComponent(entityId, MovementComp);
if (!move?.presentLocked || !move.spawnProtected) return false;
const meta = world.getComponent(entityId, UnitMetaComp);
if (!meta?.unitDefId) return false;
const monsterId = parseMonsterUnitDefId(meta.unitDefId)?.monsterId ?? parseLegacyMonsterId(meta.unitDefId);
if (!(monsterId > 0)) return false;
const row = MonsterDataManager.getMonsterDataById(monsterId);
return !!row && MonsterDataManager.isBossType(row.type);
}
/**
* Boss 跳出未完成:暂缓 OnTick 计时,避免出生即扣 CD / 立即放技能。
* @param entityId 实体
*/
private readonly _holdAbilityTick = (entityId: EntityId): boolean => {
return this._isBossSpawnJumping(entityId);
};
/** 复活后待按各出怪口间隔放行的敌人(保持原血量,不 despawn) */
private _reviveReleaseIds: EntityId[] = [];
private _reviveReleaseActive = false;
/** 复活:各出怪口独立待放队列与间隔计时(与芭蕉扇 / WaveSpawner 口内顺序一致) */
private readonly _revivePortRelease = new Map<number, { queue: EntityId[]; timer: number }>();
/** 芭蕉扇:各出怪口独立待放队列与间隔计时(与 WaveSpawner 口内顺序一致) */
private readonly _bajiaoPortRelease = new Map<number, { queue: EntityId[]; timer: number }>();
/** 芭蕉扇退回放行期间挂起波次出怪,避免与入队放行同帧叠出 */
private _bajiaoSpawnSuspended = false;
/** 复活回收怪按间隔放行期间挂起波次出怪(与芭蕉扇共用 suspendSpawn) */
private _reviveSpawnSuspended = false;
private _localPlayerId = 0;
private _players: Player[] = [];
/** 本局结算已落地(防止 WallDamaged / BattleEnd 重复弹窗) */
private _resultSettled = false;
/** 已上报 inLevel 打点的波次;-1 表示尚未上报(波形事件与变化检测共用,避免重复上报) */
private _reportedWaveNum = -1;
/** 本地玩家 MX 槽位(按 localPlayerId,非写死 players[0]) */
get localSlot(): PlayerSlot | undefined {
return this.session.players[this._localPlayerId];
}
get player(): Player | undefined {
return this.getPlayer(this._localPlayerId);
}
get players(): readonly Player[] {
return this._players;
}
/** 对手(PVP 中非 local 的玩家;solo 无对手) */
get remotePlayer(): Player | undefined {
return this._players.find(p => !p.isLocal);
}
getPlayer(playerId: number): Player | undefined {
return this._players.find(p => p.playerId === playerId);
}
/** 当前城墙血量(读写本地玩家槽) */
get CurentWallHp(): number {
return this.localSlot?.wallHp ?? 0;
}
set CurentWallHp(v: number) {
const slot = this.localSlot;
if (!slot) return;
slot.wallHp = Math.max(0, Math.min(slot.wallMaxHp, v));
}
/** 城墙最大血量 */
get WallMaxHp(): number {
return this.localSlot?.wallMaxHp ?? 100;
}
/** 剩余复活次数 */
get curFailReviveLeftNum(): number {
return this._curFailReviveLeftNum;
}
set curFailReviveLeftNum(v: number) {
this._curFailReviveLeftNum = Math.max(0, v);
}
/** 已击杀怪物数 */
get MonsterDeadNum(): number {
return this._monsterDeadNum;
}
set MonsterDeadNum(v: number) {
this._monsterDeadNum = Math.max(0, v);
}
constructor(
session: BattleSession,
params: BattleStartParams,
mapPrefabName: string,
mapId: number,
levelId: string,
mode: ProjectBattleMode,
) {
this.session = session;
this.levelConfigId = params.levelId;
this.mapPrefabName = mapPrefabName;
this.mapId = mapId;
this.levelId = levelId;
this.mode = mode;
this.playerRankId = params.playerRankId;
this.opponentRankId = params.opponentRankId;
this.LevelConfigId = params.levelId;
this.MapId = mapId;
this.PlayerRankId = params.playerRankId;
this.OpponentRankId = params.opponentRankId;
const levelRow = gg.data.table.getTableData<ITableLevel>(TableNames.Level, params.levelId);
if (levelRow) {
this.CurentConfigChapter = levelRow;
}
this.CurentConfigChapter2 = null;
this.CurentCoinNum = this.localGold;
this.syncWaveNum();
this.battleSpeedScale = BattleRuntime.BATTLE_SPEED_NORMAL;
this.battleSpeed2xUnlockedThisMatch = false;
this.autoMergeUnlockedThisMatch = false;
this.autoMergeActive = false;
this._applyCarriedBaseHpBonus(params.localPlayerId ?? 0);
this._initPlayers(params.localPlayerId ?? 0);
this.initBattleMeta(params.levelId);
this._bindEconomyEvents();
this._bindReviveEvents();
this._bindWallEvents();
this.charmController.bindRuntime(this);
this.session.castBlockPredicate = this._blockCast;
this.session.holdAbilityTickPredicate = this._holdAbilityTick;
this._bindSoldEvents();
this._bindPassiveSkillEvents();
this.session.events.on(BattleEventType.WaveStart, this._onWaveStart);
getTowerLevelDownHost().bindCombatRefresh(this._refreshLevelDownCombat);
SkillAtkBuffBoardViewBind.bindActors(HeroActorHost.instance);
}
/** 波次开始:上报 inLevel 波次打点 */
private readonly _onWaveStart = (e: BattleEvent): void => {
if (e.type !== BattleEventType.WaveStart) return;
this._reportWaveNum(e.waveIndex + 1);
};
/**
* 降级/恢复后按装载重绑战斗。
* @param session 战斗会话
* @param entity 塔实体
* @param level 新合成等级
* @param player 所属玩家
*/
private readonly _refreshLevelDownCombat = (session: BattleSession, entity: EntityId, level: number, player: Player): void => {
const loadout = getTowerLoadoutService().getLoadout(entity);
if (!loadout || !session.world.isAlive(entity)) return;
setupTowerCombat(session, entity, loadout.weapon1Id, level, player);
};
private _bindPassiveSkillEvents(): void {
const host = getPassiveSkillHost();
host.clear();
getTowerExpHost().clear();
this.session.events.on(BattleEventType.DamageDealt, this._onPassiveDamageDealt);
this.session.events.on(BattleEventType.EntityKilled, this._onPassiveEntityKilled);
this.session.events.on(BattleEventType.AbilityCast, this._onPassiveAbilityCast);
this.session.events.on(BattleEventType.UnitDespawned, this._onPassiveUnitDespawned);
}
/** DamageDealt → 塔经验归属 + condition 1/4(result 伤害不回触发) */
private readonly _onPassiveDamageDealt = (e: BattleEvent): void => {
if (e.type !== BattleEventType.DamageDealt) return;
if (e.caster > 0 && e.target > 0) {
getTowerExpHost().onDamage(e.caster, e.target, e.amount);
}
if (e.fromPassiveResult || isPassiveResultDamage(e.damageType)) return;
if (e.caster > 0 && e.target > 0) {
getPassiveSkillHost().onDealDamage(this.session, e.caster, e.target, e.amount);
}
};
/** EntityKilled → 击杀/助攻经验 + condition 5(result 击杀不回触发) */
private readonly _onPassiveEntityKilled = (e: BattleEvent): void => {
if (e.type !== BattleEventType.EntityKilled) return;
const host = getPassiveSkillHost();
if (e.victim > 0) {
getTowerExpHost().onKilled(this, e.killer, e.victim);
}
if (!e.fromPassiveResult && !isPassiveResultDamage(e.damageType) && e.killer > 0 && e.victim > 0) {
host.onKill(this.session, e.killer, e.victim);
}
if (e.victim > 0) host.forgetTarget(e.victim);
};
/** AbilityCast → condition 6 */
private readonly _onPassiveAbilityCast = (e: BattleEvent): void => {
if (e.type !== BattleEventType.AbilityCast) return;
const ctx = e.ctx;
if (!ctx) return;
const caster = ctx.caster;
if (!(caster > 0)) return;
const target = ctx.targets.length > 0 ? ctx.targets[0] : 0;
getPassiveSkillHost().onAbilityCast(this.session, caster, target);
};
/** 实体回收时清首次命中记录 */
private readonly _onPassiveUnitDespawned = (e: BattleEvent): void => {
if (e.type !== BattleEventType.UnitDespawned) return;
if (e.entity > 0) {
getTowerExpHost().forget(e.entity);
getPassiveSkillHost().forgetTarget(e.entity);
getBattleGameGlobalAttackSpeedHost().forgetEntity(e.entity);
getBattleGameGlobalAttackRangeHost().forgetEntity(e.entity);
}
};
/** 卖塔时清经验归属 */
private _bindSoldEvents(): void {
this.session.events.on(BattleEventType.UnitSold, (e) => {
if (e.type !== BattleEventType.UnitSold) return;
if (e.entity > 0) {
getTowerExpHost().forget(e.entity);
getTowerLoadoutService().removeEntity(e.entity);
getBowAtkHost().cancelCaster(e.entity);
getBattleGameGlobalAttackSpeedHost().forgetEntity(e.entity);
getBattleGameGlobalAttackRangeHost().forgetEntity(e.entity);
}
});
}
private _bindReviveEvents(): void {
this.session.events.on(BattleEventType.PhaseChanged, (e) => {
if (e.type !== BattleEventType.PhaseChanged) return;
this._mechanismHost.onPhaseChanged(e.phase);
if (e.phase === BattlePhase.COMBAT && e.prev === BattlePhase.WAIT_REVIVE) {
this._beginReviveMonsterStaggeredRelease();
this._tryWinAfterRevive();
}
});
}
/** 营地扣血胜负:己方归零失败/复活,敌方归零或清波胜利 */
private _bindWallEvents(): void {
this.session.events.on(BattleEventType.WallDamaged, this._onWallDamaged);
this.session.events.on(BattleEventType.BattleEnd, this._onBattleEnd);
}
/**
* 营地扣血:己方播长震动;本地归零则失败结算。
* @param e 城墙受伤事件
*/
private readonly _onWallDamaged = (e: BattleEvent): void => {
if (e.type !== BattleEventType.WallDamaged) return;
if (e.playerId === this._localPlayerId) {
BattleAudio.vibrateLong();
}
if (e.remain > 0) return;
if (this._resultSettled) return;
if (e.playerId === this._localPlayerId) {
this._enterDefeatFlow();
return;
}
this.gameOver(true);
};
/**
* 会话结束(清波胜利等);destroyed 不弹结算。
* @param e 战斗结束事件
*/
private readonly _onBattleEnd = (e: BattleEvent): void => {
if (e.type !== BattleEventType.BattleEnd) return;
if (e.reason === 'destroyed') return;
if (this.session.phase === BattlePhase.WAIT_REVIVE) return;
this.gameOver(e.win);
};
/** 最后一波漏怪归零并复活后:场上已无进攻本机的怪则胜利 */
private _tryWinAfterRevive(): void {
if (this._resultSettled) return;
const spawner = this.session.spawner;
if (spawner.hasMoreWaves() || spawner.isWaveCombatActive()) return;
if (spawner.hasAliveAttackers(this._localPlayerId)) return;
this.gameOver(true);
}
private _bindEconomyEvents(): void {
this.session.events.on(BattleEventType.WaveEnd, (e) => {
if (e.type !== BattleEventType.WaveEnd) return;
const wave = this.session.config.getWave(this.levelId, e.waveIndex);
const gold = wave?.rewardGold ?? 0;
if (gold > 0) this._addLocalGold(gold);
});
this.session.events.on(BattleEventType.EntityKilled, this._onKillPeachReward);
this.session.events.on(BattleEventType.EntityKilled, this._onKillWeaponDrop);
this.session.events.on(BattleEventType.UnitSpawned, (e) => {
if (e.type !== BattleEventType.UnitSpawned) return;
const player = this.player;
if (player) {
const slowWan = sumSelectedBaseAttrWan(player.selectedSkills, 8);
if (slowWan > 0) {
applyGlobalMonsterSlowToEntity(this.session, e.entity, slowWan);
}
}
});
}
/** EntityKilled → 武器碎片掉落掷骰 */
private readonly _onKillWeaponDrop = (e: BattleEvent): void => {
if (e.type !== BattleEventType.EntityKilled) return;
this.weaponDrop.tryOnKill(this, e.killer, e.victim);
};
/** EntityKilled → 按 Monster.peach 发桃子到对应 player 槽(destroy 前同步) */
private readonly _onKillPeachReward = (e: BattleEvent): void => {
if (e.type !== BattleEventType.EntityKilled) return;
const playerId = this.resolveKillPeachOwnerPlayerId(e.killer, e.victim);
const peach = this.resolveKillPeach(e.victim);
if (peach > 0) {
this._addPlayerGold(playerId, peach);
if (playerId !== this._localPlayerId) {
const gold = this.session.players[playerId]?.gold ?? -1;
rtLog(`[BattleRuntime] kill peach +${peach} -> player${playerId} gold=${gold}`);
}
}
// AI:武财神额外桃直接入账(本机走 KillPeachPresenter 飞币)
if (playerId === this._localPlayerId) return;
if (!this.isSmallMonsterVictim(e.victim)) return;
if (getBattleBuffUnitReviveHost().isPassiveRevivedEntity(e.victim)) return;
const bonus = rollCarriedKillPeachBonus(BattleBuffCarryStore.getPassives(playerId));
if (bonus > 0) {
this._addPlayerGold(playerId, bonus);
rtLog(`[BattleRuntime] kill peach bonus +${bonus} -> player${playerId}`);
}
};
/**
* 解析死亡实体 Monster 配表行。
* @param victim 死亡实体
*/
resolveMonsterRow(victim: EntityId): ITableMonster | null {
const meta = this.session.world.getComponent(victim, UnitMetaComp);
if (!meta?.unitDefId) return null;
const parsed = parseMonsterUnitDefId(meta.unitDefId);
const monsterId = parsed?.monsterId ?? parseLegacyMonsterId(meta.unitDefId);
if (!(monsterId > 0)) return null;
const row = gg.data.table.getTableData<ITableMonster>(TableNames.Monster, monsterId);
if (!row) {
rtWarn('[BattleRuntime] Monster 缺失', monsterId);
return null;
}
return row;
}
/** 是否小怪(Monster.type=1) */
isSmallMonsterVictim(victim: EntityId): boolean {
const row = this.resolveMonsterRow(victim);
return row != null && Math.floor(Number(row.type)) === MonsterType.SmallMonster;
}
/**
* 击杀桃子:Monster.peach。
* @param victim 死亡实体
*/
resolveKillPeach(victim: EntityId): number {
if (getBattleBuffUnitReviveHost().isPassiveRevivedEntity(victim)) return 0;
const row = this.resolveMonsterRow(victim);
if (!row) return 0;
const peach = Number(row.peach);
if (!Number.isFinite(peach) || peach < 0) {
rtWarn('[BattleRuntime] Monster.peach 非法', row.id, row.peach);
return 0;
}
return peach;
}
/**
* 击杀加桃归属:优先击杀者所属玩家(塔杀得桃),其次怪进攻目标玩家。
* @param killer 击杀者
* @param victim 死亡实体
*/
resolveKillPeachOwnerPlayerId(killer: EntityId, victim: EntityId): number {
const world = this.session.world;
const killerTeam = world.getComponent(killer, TeamComp);
if (killerTeam && !BattleTeamId.isEnemyTeam(killerTeam.team)) {
return Math.max(0, Math.floor(Number(killerTeam.ownerId) || 0));
}
const victimTeam = world.getComponent(victim, TeamComp);
if (victimTeam && BattleTeamId.isEnemyTeam(victimTeam.team)) {
return Math.max(0, Math.floor(Number(victimTeam.ownerId) || 0));
}
rtWarn(`[BattleRuntime] 击杀加桃无法归属 playerId killer=${killer} victim=${victim}`);
return this._localPlayerId;
}
/** 本机玩家 id */
get localPlayerId(): number {
return this._localPlayerId;
}
/**
* 向指定玩家槽加桃;本机槽同步 CurentCoinNum。
* @param playerId 槽位
* @param amount 数量
*/
addPlayerGold(playerId: number, amount: number): void {
this._addPlayerGold(playerId, amount);
}
/**
* 向指定玩家槽加桃;本机槽同步 CurentCoinNum。
* @param playerId 槽位
* @param amount 数量
*/
private _addPlayerGold(playerId: number, amount: number): void {
if (amount <= 0) return;
const id = Math.max(0, Math.floor(Number(playerId) || 0));
const slot = this.session.players[id];
if (!slot) {
rtWarn('[BattleRuntime] 加桃失败:player slot 缺失', id, amount);
return;
}
slot.addGold(amount);
if (id === this._localPlayerId) {
this.CurentCoinNum = slot.gold;
}
}
private _addLocalGold(amount: number): void {
this._addPlayerGold(this._localPlayerId, amount);
}
/** 被动 functiontype=6 镇宅牌:本机「我方基地」(己侧扣墙血区域)上限 +functionpar */
private _applyCarriedBaseHpBonus(localPlayerId: number): void {
const adjust = resolveCarriedBaseHpBonus();
if (!adjust.active || adjust.bonusHp <= 0) return;
const pid = Math.max(0, Math.floor(Number(localPlayerId) || 0));
const slot = this.session.players[pid];
if (!slot) {
console.log('[BattleRuntime] 基地加血 passive 应用失败:玩家槽缺失', pid);
return;
}
slot.wallMaxHp += adjust.bonusHp;
slot.wallHp += adjust.bonusHp;
}
private _initPlayers(localPlayerId: number): void {
this._localPlayerId = localPlayerId;
this._players = this.session.players.map(
slot => new Player(this, slot.id, slot.id === localPlayerId),
);
this.syncGmCampImmune();
}
/** 把 GM 营地免伤开关同步到各玩家槽(本次登录内存开关) */
syncGmCampImmune(): void {
const players = this.session.players;
for (let i = 0; i < players.length; i++) {
const slot = players[i];
slot.immuneWall = slot.id === this._localPlayerId
? gg.game.gmLocalCampImmune
: gg.game.gmEnemyCampImmune;
}
}
tick(dt: number): void {
if (this._destroyed) return;
this._tickImpl(dt);
}
/**
* 单步 tick。异常上报后继续后续步骤,避免一处读空把同帧出怪/放行一起停掉。
* @param step 步骤序号
* @param dt 真实帧间隔
* @param scaledDt 已乘倍速的逻辑步长
*/
private _guardTick(step: number, dt: number, scaledDt: number): void {
try {
this._runTickStep(step, dt, scaledDt);
} catch (e) {
ClientErrorReporter.report(BattleRuntime._tickCaptures[step] ?? 'BattleRuntime_tick', e);
}
}
/**
* 战斗帧步骤。序号与 `_tickCaptures` 对齐。
* @param step 步骤序号
* @param dt 真实帧间隔
* @param scaledDt 已乘倍速的逻辑步长
*/
private _runTickStep(step: number, dt: number, scaledDt: number): void {
switch (step) {
case 0:
getBattleBuffBaseThunderHost().tick(scaledDt);
return;
case 1:
this.session.tick(scaledDt);
return;
case 2:
this.session.tickForcedPathsWhilePaused(dt);
return;
case 3:
this.tickBattleTime(scaledDt);
return;
case 4:
this._mechanismHost.tick(dt);
return;
case 5:
this.charmController.tick(scaledDt);
return;
case 6:
getTowerLevelDownHost().tick(this, scaledDt);
return;
case 7:
getTowerRuinHost().tick(this);
return;
case 8:
getPassiveSkillHost().tick(scaledDt);
return;
case 9:
getAllySkillExtraHost().tick(scaledDt);
return;
case 10:
getFaXiangHost().tick(scaledDt);
return;
case 11:
getMonsterSkillHost().tick(scaledDt);
return;
case 12:
getBowAtkHost().bind(this.session);
getBowAtkHost().tick(scaledDt);
return;
case 13:
getYingCampSummonHost().tick(scaledDt);
return;
case 14:
getNezhaChargeHost().tick(scaledDt);
return;
case 15:
getArrowRainHost().tick(scaledDt);
return;
case 16:
getSpearTossHost().tick(scaledDt);
return;
case 17:
getPagodaSmashHost().tick(scaledDt);
return;
case 18:
getStaffSmashHost().tick(scaledDt);
return;
case 19:
this._tickReviveMonsterRelease(scaledDt);
return;
case 20:
this._tickBajiaoMonsterRelease(dt);
return;
case 21:
this.syncWaveNum();
return;
case 22:
getExtraRingHost().tick(scaledDt);
getExtraStrikeHost().tick(scaledDt);
return;
default:
return;
}
}
/** 战斗逻辑帧 */
private _tickImpl(dt: number): void {
// 结算后 session 已停表;host / 被动 / 怪物技仍跑会继续掉血、飞桃、还魂
if (this.IsGameOver || this.session.ended) {
this.session.tick(0);
return;
}
const scaledDt = dt * this.TimeScale;
// 须在 ReachGoal 漏怪 despawn 之前扫描,否则终点格一帧内到站会被直接清掉
this._guardTick(0, dt, scaledDt);
this._guardTick(1, dt, scaledDt);
if (this.IsGameOver || this.session.ended) return;
// UI 暂停时 TimeScale=0:仍推进芭蕉扇等强制路径,避免退回表现被冻住
if (this.IsGamePause) {
this._guardTick(2, dt, scaledDt);
}
this._guardTick(3, dt, scaledDt);
this._guardTick(4, dt, scaledDt);
this._guardTick(5, dt, scaledDt);
this._guardTick(6, dt, scaledDt);
this._guardTick(7, dt, scaledDt);
if (BattleLogicThrottle.shouldRunCosmeticHosts()) {
this._guardTick(8, dt, scaledDt);
this._guardTick(9, dt, scaledDt);
this._guardTick(10, dt, scaledDt);
this._guardTick(11, dt, scaledDt);
}
this._guardTick(12, dt, scaledDt);
this._guardTick(13, dt, scaledDt);
this._guardTick(14, dt, scaledDt);
this._guardTick(15, dt, scaledDt);
this._guardTick(16, dt, scaledDt);
this._guardTick(17, dt, scaledDt);
this._guardTick(18, dt, scaledDt);
this._guardTick(19, dt, scaledDt);
// 放行队列用真实 dt:UI 暂停时 TimeScale=0,不应冻住出怪口间隔计时
this._guardTick(20, dt, scaledDt);
this._guardTick(21, dt, scaledDt);
this._guardTick(22, dt, scaledDt);
}
/** 是否处于背包/地图拖动(画面弱化中;对局不暂停、不慢放) */
get isBagDragSlowActive(): boolean {
return this._bagDragSlowHold > 0;
}
/** 背包拖动开始:仅弱化战斗画面,TimeScale 保持当前倍速 */
beginBagDragSlowMo(): void {
const wasActive = this._bagDragSlowHold > 0;
this._bagDragSlowHold++;
if (!wasActive) {
GEvent.Ins.emit(GEvent.BagDragVisualChanged, true);
}
}
/** 背包拖动结束:关闭画面弱化(与 begin 成对) */
endBagDragSlowMo(): void {
if (this._bagDragSlowHold <= 0) return;
this._bagDragSlowHold--;
if (this._bagDragSlowHold === 0) {
GEvent.Ins.emit(GEvent.BagDragVisualChanged, false);
}
}
/**
* 任意打断/取消拖动须调用:无视 hold 计数,立即结束拖动画面弱化。
* 弹窗 pause、cancelActiveDrag、unbind 等路径统一走此出口。
*/
forceEndBagDragSlowMo(): void {
if (this._bagDragSlowHold <= 0) return;
this._bagDragSlowHold = 0;
GEvent.Ins.emit(GEvent.BagDragVisualChanged, false);
}
/** 上一次 deploy 成功部署的实体 ID(供 DeploySync 等需要回查的场景使用) */
lastDeployedEntity: import('db://assets/mx/module/battle').EntityId | null = null;
deploy(params: BattleDeployParams): boolean {
const playerId = params.playerId ?? 0;
const unitId = gg.battle.config.getUnitIdByWeapon1(params.weapon1Id);
if (!unitId) {
this.lastDeployedEntity = null;
return false;
}
const anchor = { row: params.row, col: params.col };
const check = this.session.deploy.canPlace(playerId, unitId, anchor);
if (!check.ok) {
this.lastDeployedEntity = null;
return false;
}
const entity = this.session.deploy.place(playerId, unitId, anchor);
if (entity == null) {
this.lastDeployedEntity = null;
return false;
}
const owner = this.getPlayer(playerId);
if (owner) {
setupTowerCombat(
this.session,
entity,
params.weapon1Id,
params.level ?? 1,
owner,
);
} else {
this.session.deploy.sell(playerId, entity);
this.lastDeployedEntity = null;
return false;
}
this.lastDeployedEntity = entity;
return true;
}
startPrep(): void {
this.session.startPrep();
}
startCombat(): void {
this.session.startCombat();
}
/** 快照恢复后:当前波次已开但出怪未跑时,重建本波出怪(不递增波次) */
resumeCurrentWaveCombat(): boolean {
return this.session.resumeCurrentWaveCombat();
}
/** 快照恢复后:按芭蕉扇/复活状态重同步出怪挂起(清掉快照里残留的唐僧逃跑挂起) */
resyncSpawnerSuspendFromRuntime(): void {
this._syncWaveSpawnSuspended();
}
pause(): void {
if (this.session.phase === BattlePhase.PAUSE) return;
if (this.session.ended || this.session.phase === BattlePhase.END) return;
// 弹窗遮罩常吞掉 DRAG_END,须先收尾背包拖动再停表
this.charmController.cancelBagDrag();
this.forceEndBagDragSlowMo();
this._setTimeScale(0);
this.session.pause();
}
resume(): void {
if (this.session.phase !== BattlePhase.PAUSE) return;
this.session.resume();
this.forceEndBagDragSlowMo();
this._setTimeScale(this.battleSpeedScale);
// cancelBagDrag 在 PAUSE 相位调过 _refreshDragEnabled(false),须恢复后再同步一次
this.charmController.refreshBagDragEnabled();
}
/** 设置本局战斗倍速(UI 暂停中不立刻改 TimeScale,恢复时再套用) */
setBattleSpeedScale(scale: number): void {
const next = scale >= BattleRuntime.BATTLE_SPEED_FAST
? BattleRuntime.BATTLE_SPEED_FAST
: BattleRuntime.BATTLE_SPEED_NORMAL;
if (this.battleSpeedScale === next) return;
this.battleSpeedScale = next;
if (!this.IsGamePause) {
this._setTimeScale(next);
}
GEvent.Ins.emit(GEvent.BattleSpeedChanged, next);
}
toggleBattleSpeedScale(): void {
this.setBattleSpeedScale(
this.battleSpeedScale >= BattleRuntime.BATTLE_SPEED_FAST
? BattleRuntime.BATTLE_SPEED_NORMAL
: BattleRuntime.BATTLE_SPEED_FAST,
);
}
get isBattleSpeed2x(): boolean {
return this.battleSpeedScale >= BattleRuntime.BATTLE_SPEED_FAST;
}
/** 看视频后解锁本局 2 倍速并立即启用 */
grantBattleSpeed2xForMatch(): void {
this.battleSpeed2xUnlockedThisMatch = true;
this.setBattleSpeedScale(BattleRuntime.BATTLE_SPEED_FAST);
}
/** 是否可直接切换倍速(永久解锁或本局已看视频) */
get canToggleBattleSpeedDirectly(): boolean {
return this.battleSpeed2xUnlockedThisMatch;
}
/** 看视频后解锁本局自动合成并立即激活 */
grantAutoMergeForMatch(): void {
this.autoMergeUnlockedThisMatch = true;
this.setAutoMergeActive(true);
}
/** 是否可直接使用自动合成(本局已看视频) */
get canUseAutoMergeDirectly(): boolean {
return this.autoMergeUnlockedThisMatch;
}
setAutoMergeActive(active: boolean): void {
if (this.autoMergeActive === active) return;
this.autoMergeActive = active;
GEvent.Ins.emit(GEvent.AutoMergeChanged, active);
}
on(eventType: BattleEventSubscription, listener: (e: BattleEvent) => void): void {
this.session.events.on(eventType, listener);
}
off(eventType: BattleEventSubscription, listener: (e: BattleEvent) => void): void {
this.session.events.off(eventType, listener);
}
destroy(): void {
if (this._destroyed) return;
this._destroyed = true;
this.forceEndBagDragSlowMo();
this._setTimeScale(1);
this.charmController.unbind();
getTowerLevelDownHost().clear();
getTowerLevelDownHost().bindCombatRefresh(null);
SkillAtkBuffBoardViewBind.bindActors(null);
getTowerRuinHost().clear();
this.session.castBlockPredicate = undefined;
this.session.holdAbilityTickPredicate = undefined;
this._mechanismHost.destroy();
getYingCampSummonHost().unbind();
getMonsterSkillHost().unbind();
getBattleBuffMoveSpeedHost().unbind();
getBattleBuffUnitHpHost().unbind();
getBattleBuffUnitReviveHost().unbind();
getBattleBuffUnitAttackSpeedHost().unbind();
getBattleBuffEnemySlowHost().unbind();
getBattleBuffBaseThunderHost().unbind();
getBattleBuffBossInterruptHost().unbind();
getNezhaChargeHost().abortAll();
getArrowRainHost().abortAll();
getExtraRingHost().abortAll();
getExtraStrikeHost().abortAll();
getSpearTossHost().abortAll();
getPagodaSmashHost().abortAll();
getStaffSmashHost().abortAll();
getAllySkillExtraHost().abortAll();
getFaXiangHost().abortAll();
getBattleGameGlobalAttackSpeedHost().reset();
getBattleGameGlobalAttackRangeHost().reset();
getBattleGameRandomActiveSkillHost().reset();
getPassiveSkillHost().clear();
getTowerExpHost().clear();
getBowAtkHost().clear();
getTowerLoadoutService().clear();
this.weaponDrop.clear();
this.session.endBattle(false, 'destroyed');
this.session.dispose();
}
private _setTimeScale(scale: number): void {
if (this.TimeScale === scale) return;
this.TimeScale = scale;
GEvent.Ins.emit(GEvent.BattleTimeScaleChanged, scale);
}
get phase(): BattlePhase {
return this.session.phase;
}
get localGold(): number {
return this.localSlot?.gold ?? 0;
}
/** 初始化城墙血量、复活次数、怪物总数 */
initBattleMeta(levelId: number): void {
const limits = parseChapterAdvertiseLimit(levelId);
// advertise_limit:铲,格子,广告刷新文字,复活,芭蕉扇(下标 2~6)
this.prepFreeGridMax = getChapterFreeBox(levelId);
this.prepFreeGridRemaining = this.prepFreeGridMax;
this.prepFreeShovelAvailable = false;
this.prepGridAdUsed = 0;
this.prepShovelAdUsed = 0;
this.prepGridAdMax = limits[3] ?? 0;
this.prepShovelAdMax = getShovelAdMax(levelId);
this.prepFreeTipsMax = getChapterFreeTips(levelId);
this.prepFreeTipsRemaining = this.prepFreeTipsMax;
this.bajiaoFanAdUsed = 0;
this.bajiaoFanAdMax = limits[6] ?? 0;
this.menShenAdOfferVisible = false;
this.menShenAdConsumed = false;
this.curFailReviveLeftNum = limits[5] ?? limits[3] ?? 1;
this.curAdAddHpLeftNum = 0;
this.MonsterDeadNum = 0;
this.TotalMonsterNum = this._countLevelMonsters(this.session.levelDef);
}
getPrepFreeGridRemaining(): number {
return Math.max(0, this.prepFreeGridRemaining);
}
getPrepFreeGridMax(): number {
return this.prepFreeGridMax;
}
hasPrepFreeGrid(): boolean {
return this.getPrepFreeGridRemaining() > 0;
}
consumePrepFreeGrid(): void {
if (this.prepFreeGridRemaining > 0) {
this.prepFreeGridRemaining--;
}
}
getPrepGridAdRemaining(): number {
return Math.max(0, this.prepGridAdMax - this.prepGridAdUsed);
}
consumePrepGridAd(): void {
this.prepGridAdUsed++;
}
canUsePrepGrid(): boolean {
return this.hasPrepFreeGrid() || this.getPrepGridAdRemaining() > 0;
}
hasPrepFreeShovel(): boolean {
return this.prepFreeShovelAvailable;
}
consumePrepFreeShovel(): void {
this.prepFreeShovelAvailable = false;
}
getPrepShovelAdRemaining(): number {
return Math.max(0, this.prepShovelAdMax - this.prepShovelAdUsed);
}
consumePrepShovelAd(): void {
this.prepShovelAdUsed++;
}
/** 第二章起展示铲;第一章仅普通难度隐藏,困难/地狱仍开放 */
canShowPrepShovel(): boolean {
if (!this._isShovelFeatureUnlocked()) return false;
return this.hasPrepFreeShovel() || this.getPrepShovelAdRemaining() > 0;
}
/** 铲功能已解锁:非「第一章普通」即与第二章起一致 */
private _isShovelFeatureUnlocked(): boolean {
return !isChapter1NormalOnly(this.LevelConfigId);
}
canUsePrepShovel(): boolean {
return this.canShowPrepShovel();
}
getBajiaoFanAdRemaining(): number {
return Math.max(0, this.bajiaoFanAdMax - this.bajiaoFanAdUsed);
}
consumeBajiaoFanAd(): void {
this.bajiaoFanAdUsed++;
}
canUseBajiaoFanAd(): boolean {
return this.getBajiaoFanAdRemaining() > 0;
}
/** 门神盾牌广告是否仍可展示(已触发且未观看) */
canShowMenShenAd(): boolean {
return this.menShenAdOfferVisible && !this.menShenAdConsumed;
}
/** 首次满足距离条件时触发门神广告(每局最多一次) */
triggerMenShenAdOffer(): void {
if (this.menShenAdConsumed || this.menShenAdOfferVisible) return;
this.menShenAdOfferVisible = true;
}
/** 观看门神广告成功后消耗 */
consumeMenShenAd(): void {
this.menShenAdConsumed = true;
this.menShenAdOfferVisible = false;
}
/** 芭蕉扇 / 复活:待按出怪口间隔放行的怪物尚未全部出场 */
hasBajiaoMonsterReleasePending(): boolean {
return this._bajiaoSpawnSuspended
|| this._hasBajiaoPendingRelease()
|| this._reviveSpawnSuspended
|| this._reviveReleaseActive
|| this._hasRevivePendingRelease();
}
getPrepFreeTipsRemaining(): number {
return Math.max(0, this.prepFreeTipsRemaining);
}
getPrepFreeTipsMax(): number {
return this.prepFreeTipsMax;
}
hasPrepFreeTips(): boolean {
return this.prepFreeTipsRemaining > 0;
}
consumePrepFreeTips(): void {
if (this.prepFreeTipsRemaining > 0) {
this.prepFreeTipsRemaining--;
}
}
/** 怪物死亡计数(复活/失败界面进度条) */
onMonsterKilled(): void {
this.MonsterDeadNum++;
this._tryDebugFastWin();
}
/** GM / sdkConfig:击杀 N 怪后直接胜利(isDebugFastWinMonsterNum > 0 时生效) */
private _tryDebugFastWin(): void {
const threshold = sdkConfig.isDebugFastWinMonsterNum;
if (threshold <= 0) return;
if (this.IsGameOver || this.session.ended) return;
if (this.session.phase !== BattlePhase.COMBAT) return;
if (this.MonsterDeadNum < threshold) return;
this.gameOver(true);
}
/**
* 扣城墙血。仅 COMBAT 可受伤;归零后进入失败结算。
*/
subWallHp(attack: number, hitTarget?: Node | null): void {
if (this.session.phase !== BattlePhase.COMBAT) return;
const slot = this.localSlot;
if (!slot) return;
slot.damageWall(attack, this.session.events);
GEvent.Ins.emit(GEvent.playerHit, hitTarget ?? null, attack);
}
/**
* 战败:直接结算失败并弹 BattleFail。
* 城墙归零、循环圈场内怪超限等路径共用。
*/
private _enterDefeatFlow(): void {
if (this.session.phase !== BattlePhase.COMBAT) return;
this.gameOver(false);
if (!gg.ui.hasPopPanelActiveOrQueued(UIID.BattleFail)) {
gg.ui.popPanel(UIID.BattleFail);
}
}
/**
* 复活成功:场上存活怪回出生点待机,保留血量;COMBAT 恢复后按出怪间隔逐个寻路。
*/
resetAliveMonstersToBornPosAfterRevive(): void {
this._clearBajiaoMonsterRelease();
this._reviveReleaseActive = false;
this._revivePortRelease.clear();
this._setReviveSpawnSuspended(false);
this._reviveReleaseIds = this._collectAliveEnemyEntitiesForRevive();
const presenter = this._monsterPresenter;
if (presenter) {
for (const entityId of this._reviveReleaseIds) {
this._holdMonsterMovementForRevive(entityId);
const bornPos = presenter.prepareReviveHold(entityId);
if (bornPos && this.useMxMovement && this.Map) {
this._holdMonsterAtBorn(entityId, bornPos);
}
}
return;
}
this.MonsterSpawner.holdAllMonstersAtBornPos();
if (!this.useMxMovement || !this.Map) {
for (const entityId of this._reviveReleaseIds) {
this._holdMonsterMovementForRevive(entityId);
}
return;
}
for (const node of this.MonsterSpawner.getAllMonster()) {
if (!node?.isValid) continue;
const comp = node.getComponent(BATTLE_MONSTER_CCCLASS) as {
entityId?: EntityId | null;
} | null;
const entityId = comp?.entityId;
if (entityId == null) continue;
this._holdMonsterMovementForRevive(entityId);
const port = this._entitySpawnPort(entityId);
const spawnNode = this.Map.spawnPointParent?.getChildByName(`spawnPoint_${port}`);
if (spawnNode) {
this._holdMonsterAtBorn(entityId, spawnNode.worldPosition);
}
}
}
/**
* 芭蕉扇:怪收回各自出生口隐藏,各出怪口独立排队、按口内间隔放行(保留血量)。
* 每次入队插到该口队首;已放行在外的同实体也会重新隐藏并插该口队首。
*/
holdMonstersAtBornForBajiaoFan(entityIds: readonly EntityId[]): void {
if (!entityIds.length) return;
const world = this.session.world;
const ordered = this._sortBajiaoHoldOrder(
entityIds.filter((id) => world.isAlive(id)),
);
if (!ordered.length) return;
this._setBajiaoSpawnSuspended(true);
const incoming = membershipFromArray(ordered);
for (const state of this._bajiaoPortRelease.values()) {
state.queue = state.queue.filter((id) => !incoming.has(id));
}
const byPort = new Map<number, EntityId[]>();
for (const id of ordered) {
const port = this._entitySpawnPort(id);
const list = byPort.get(port);
if (list) list.push(id);
else byPort.set(port, [id]);
}
for (const [port, ids] of byPort) {
const state = this._getOrCreateBajiaoPortState(port);
state.queue = [...ids, ...state.queue];
}
const presenter = this._monsterPresenter;
for (const entityId of ordered) {
const move = world.getComponent(entityId, MovementComp);
if (move) {
move.forcePathSpeed = 0;
move.forcePathHoldAtEnd = false;
move.path = [];
move.pathIndex = 0;
}
this._holdMonsterMovementForRevive(entityId);
if (presenter) {
const bornPos = presenter.prepareReviveHold(entityId);
if (bornPos && this.useMxMovement && this.Map) {
this._holdMonsterAtBorn(entityId, bornPos);
}
} else if (this.useMxMovement && this.Map) {
const port = this._entitySpawnPort(entityId);
const spawnNode = this.Map.spawnPointParent?.getChildByName(`spawnPoint_${port}`);
if (spawnNode) this._holdMonsterAtBorn(entityId, spawnNode.worldPosition);
}
}
this._tryKickBajiaoRelease();
}
private _entitySpawnPort(entityId: EntityId): number {
const meta = this.session.world.getComponent(entityId, UnitMetaComp);
return meta?.spawnPoint && meta.spawnPoint > 0 ? meta.spawnPoint : 1;
}
private _getOrCreateBajiaoPortState(port: number): { queue: EntityId[]; timer: number } {
let state = this._bajiaoPortRelease.get(port);
if (!state) {
state = { queue: [], timer: 0 };
this._bajiaoPortRelease.set(port, state);
}
return state;
}
private _hasBajiaoPendingRelease(): boolean {
for (const state of this._bajiaoPortRelease.values()) {
if (state.queue.length > 0) return true;
}
return false;
}
private _canResumeBajiaoWaveSpawn(): boolean {
for (const state of this._bajiaoPortRelease.values()) {
if (state.queue.length > 0 || state.timer > 0) return false;
}
return true;
}
/** 与 WaveSpawner 同口 segment.interval 对齐,且不得短于出怪口跳跃表现 */
private _resolveBajiaoSpawnInterval(entityId: EntityId): number {
const waveInterval = this.session.spawner.resolveSpawnIntervalForPort(this._entitySpawnPort(entityId));
return Math.max(waveInterval, SPAWN_PRESENT_SEC);
}
/** 队首优先:同出怪口按距出生口由远到近(先吹回的先出) */
private _sortBajiaoHoldOrder(ids: EntityId[]): EntityId[] {
const world = this.session.world;
return ids.slice().sort((a, b) => {
const ma = world.getComponent(a, UnitMetaComp);
const mb = world.getComponent(b, UnitMetaComp);
const pa = ma?.spawnPoint ?? 0;
const pb = mb?.spawnPoint ?? 0;
if (pa !== pb) return pa - pb;
return this._bajiaoDistFromHome(b) - this._bajiaoDistFromHome(a);
});
}
private _bajiaoDistFromHome(entityId: EntityId): number {
const world = this.session.world;
const tf = world.getComponent(entityId, TransformComp);
if (!tf) return 0;
const port = this._entitySpawnPort(entityId);
const home = this.session.topology.getSpawnCell(port);
if (!home) return 0;
const path = this.session.pathfinder.buildRetreatPath(
{ row: tf.row, col: tf.col },
Number.MAX_SAFE_INTEGER,
home,
port,
);
return path.length > 0 ? Math.max(0, path.length - 1) : 0;
}
/** 各口队非空且该口冷却结束:立刻放出该口队首 */
private _tryKickBajiaoRelease(): void {
if (this.session.phase !== BattlePhase.COMBAT) return;
for (const state of this._bajiaoPortRelease.values()) {
if (state.queue.length === 0 || state.timer > 0) continue;
this._releaseNextBajiaoMonsterForPort(state);
}
}
private _tickBajiaoMonsterRelease(dt: number): void {
if (this.session.phase !== BattlePhase.COMBAT) return;
for (const state of this._bajiaoPortRelease.values()) {
if (state.timer > 0) {
state.timer -= dt;
if (state.timer > 0) continue;
}
if (state.queue.length > 0) {
this._releaseNextBajiaoMonsterForPort(state);
}
}
if (this._canResumeBajiaoWaveSpawn()) {
this._bajiaoPortRelease.clear();
this._setBajiaoSpawnSuspended(false);
}
}
private _releaseNextBajiaoMonsterForPort(state: { queue: EntityId[]; timer: number }): void {
while (state.queue.length > 0) {
const entityId = state.queue.shift()!;
if (!this.session.world.isAlive(entityId)) continue;
this._releaseReviveMonster(entityId);
state.timer = this._resolveBajiaoSpawnInterval(entityId);
return;
}
}
private _clearBajiaoMonsterRelease(): void {
this._bajiaoPortRelease.clear();
this._setBajiaoSpawnSuspended(false);
}
private _setBajiaoSpawnSuspended(on: boolean): void {
if (this._bajiaoSpawnSuspended === on) return;
this._bajiaoSpawnSuspended = on;
this._syncWaveSpawnSuspended();
}
private _setReviveSpawnSuspended(on: boolean): void {
if (this._reviveSpawnSuspended === on) return;
this._reviveSpawnSuspended = on;
this._syncWaveSpawnSuspended();
}
/** 芭蕉扇 / 复活放行:任一挂起则暂停 WaveSpawner */
private _syncWaveSpawnSuspended(): void {
this.session.spawner.suspendSpawn(
this._bajiaoSpawnSuspended || this._reviveSpawnSuspended,
);
}
private _endReviveMonsterRelease(): void {
this._reviveReleaseActive = false;
this._reviveReleaseIds = [];
this._revivePortRelease.clear();
this._setReviveSpawnSuspended(false);
}
private _getOrCreateRevivePortState(port: number): { queue: EntityId[]; timer: number } {
let state = this._revivePortRelease.get(port);
if (!state) {
state = { queue: [], timer: 0 };
this._revivePortRelease.set(port, state);
}
return state;
}
private _hasRevivePendingRelease(): boolean {
for (const state of this._revivePortRelease.values()) {
if (state.queue.length > 0 || state.timer > 0) return true;
}
return false;
}
private _canResumeReviveWaveSpawn(): boolean {
for (const state of this._revivePortRelease.values()) {
if (state.queue.length > 0 || state.timer > 0) return false;
}
return true;
}
/** 与 WaveSpawner 同口 segment.interval 对齐,且不得短于出怪口跳跃表现 */
private _resolveReviveSpawnIntervalForEntity(entityId: EntityId): number {
const waveInterval = this.session.spawner.resolveSpawnIntervalForPort(this._entitySpawnPort(entityId));
return Math.max(waveInterval, SPAWN_PRESENT_SEC);
}
/** 各口队非空且该口冷却结束:立刻放出该口队首 */
private _tryKickReviveRelease(): void {
if (this.session.phase !== BattlePhase.COMBAT) return;
for (const state of this._revivePortRelease.values()) {
if (state.queue.length === 0 || state.timer > 0) continue;
this._releaseNextReviveMonsterForPort(state);
}
}
private _beginReviveMonsterStaggeredRelease(): void {
if (this._reviveReleaseIds.length === 0 || this._reviveReleaseActive) return;
this._reviveReleaseActive = true;
this._setReviveSpawnSuspended(true);
this._revivePortRelease.clear();
for (const entityId of this._reviveReleaseIds) {
const port = this._entitySpawnPort(entityId);
this._getOrCreateRevivePortState(port).queue.push(entityId);
}
this._reviveReleaseIds = [];
this._tryKickReviveRelease();
}
private _tickReviveMonsterRelease(dt: number): void {
if (!this._reviveReleaseActive || this.session.phase !== BattlePhase.COMBAT) return;
for (const state of this._revivePortRelease.values()) {
if (state.timer > 0) {
state.timer -= dt;
if (state.timer > 0) continue;
}
if (state.queue.length > 0) {
this._releaseNextReviveMonsterForPort(state);
}
}
if (this._canResumeReviveWaveSpawn()) {
this._endReviveMonsterRelease();
}
}
private _releaseNextReviveMonsterForPort(state: { queue: EntityId[]; timer: number }): void {
while (state.queue.length > 0) {
const entityId = state.queue.shift()!;
if (!this.session.world.isAlive(entityId)) continue;
this._releaseReviveMonster(entityId);
state.timer = this._resolveReviveSpawnIntervalForEntity(entityId);
return;
}
}
private _collectAliveEnemyEntitiesForRevive(): EntityId[] {
const world = this.session.world;
const ids: EntityId[] = [];
world.forEachAlive(id => {
const team = world.getComponent(id, TeamComp);
const tags = world.getComponent(id, TagComp);
const isEnemy = (team && BattleTeamId.isEnemyTeam(team.team))
|| !!tags?.tags.includes(EntityTag.Enemy);
if (!isEnemy) return;
if (!world.getComponent(id, MovementComp)) return;
ids.push(id);
});
ids.sort((a, b) => {
const ma = world.getComponent(a, UnitMetaComp);
const mb = world.getComponent(b, UnitMetaComp);
const pa = ma?.spawnPoint ?? 0;
const pb = mb?.spawnPoint ?? 0;
if (pa !== pb) return pa - pb;
return a - b;
});
return ids;
}
private _releaseReviveMonster(entityId: EntityId): void {
const world = this.session.world;
if (!world.isAlive(entityId)) return;
const mechanismUnit = isMechanismWitchUnit(this.session, entityId);
const onJumpLand = (): void => {
if (!world.isAlive(entityId)) return;
if (mechanismUnit) {
const move = world.getComponent(entityId, MovementComp);
if (move) {
move.spawnProtected = false;
move.presentLocked = true;
move.reachedGoal = true;
move.path = [];
}
return;
}
this.session.movement.assignPath(entityId);
};
if (this._monsterPresenter) {
this._monsterPresenter.playReviveRespawn(entityId, onJumpLand);
return;
}
const move = world.getComponent(entityId, MovementComp);
if (move) {
move.presentLocked = false;
move.reachedGoal = false;
move.spawnProtected = false;
}
onJumpLand();
this.MonsterSpawner.releaseReviveMonster(entityId);
}
private _holdMonsterAtBorn(entityId: EntityId, bornWorldPos: Vec3): void {
const map = this.Map;
if (!map) return;
const grid = map.getMapGridByWorldPos(bornWorldPos);
if (!grid) return;
const tf = this.session.world.getComponent(entityId, TransformComp);
const move = this.session.world.getComponent(entityId, MovementComp);
if (!tf || !move) return;
tf.row = grid.Row;
tf.col = grid.Col;
const w = this.session.grid.cellToWorld({ row: grid.Row, col: grid.Col });
move.worldX = w.x;
move.worldY = w.y;
move.path = [];
move.pathIndex = 0;
move.reachedGoal = false;
move.presentLocked = true;
move.spawnProtected = true;
move.forcePresentSync = true;
}
/** 复活回收:逻辑层清 reachedGoal / 锁表现,避免恢复 COMBAT 同帧继续攻墙 */
private _holdMonsterMovementForRevive(entityId: EntityId): void {
const world = this.session.world;
if (!world.isAlive(entityId)) return;
const move = world.getComponent(entityId, MovementComp);
if (!move) return;
move.reachedGoal = false;
move.presentLocked = true;
move.spawnProtected = true;
move.path = [];
move.pathIndex = 0;
move.forcePresentSync = true;
}
/** @deprecated 复活勿再调用:会重生满血;请用 resetAliveMonstersToBornPosAfterRevive */
requeueAliveMonstersAfterRevive(): void {
this.resetAliveMonstersToBornPosAfterRevive();
}
/** 设置当前城墙血量并同步逻辑层 */
setCurentWallHp(hp: number): void {
this.CurentWallHp = hp;
}
/** 战斗结束(胜/负计数与结算弹窗;会话已 END 时仍补 UI) */
gameOver(win: boolean): void {
if (this._resultSettled) return;
this._resultSettled = true;
if (win) {
gg.data.project.setFailCount(this.LevelConfigId, 0);
} else if (this.IsWatchAd && this.CurentWaveNum > 3) {
gg.data.project.addFailCount(this.LevelConfigId);
}
gg.data.addStatistics(StatisticsType.FirstLoginWeight, 1);
if (!this.session.ended) {
this.session.endBattle(win, win ? 'win' : 'defeat');
}
GEvent.Ins.emit(GEvent.GameOver, win);
if (win && !gg.ui.hasPopPanelActiveOrQueued(UIID.BattleSuccess)) {
gg.ui.popPanel(UIID.BattleSuccess);
}
}
private _countLevelMonsters(level: LevelDef): number {
let total = 0;
for (const wave of level.waves) {
if (wave.ports) {
for (const port of wave.ports) {
for (const seg of port.segments) {
total += seg.count ?? 0;
}
}
}
if (wave.spawns) {
for (const spawn of wave.spawns) {
total += spawn.count ?? 0;
}
}
}
return total;
}
/** 同步当前波次与金币;波次变化(含快照恢复等未发 WaveStart 的路径)时补上报 */
syncWaveNum(): void {
const waveNum = this.session.spawner.waveIndex + 1;
BattlePresentationBudget.setWave(waveNum);
if (waveNum !== this.CurentWaveNum) {
this.CurentWaveNum = waveNum;
this._reportWaveNum(waveNum);
}
this.session.tickSliceCount = DeviceProfile.battleTickSlice;
this.CurentCoinNum = this.localGold;
}
/** 波次打点:每个波次只上报一次,未开波(波次号 ≤ 0)不上报 */
private _reportWaveNum(waveNum: number): void {
if (waveNum <= 0 || waveNum === this._reportedWaveNum) return;
this._reportedWaveNum = waveNum;
gg.sdk.reportDY('inLevel', `${gg.game.getReportDYBattleCountLabel(waveNum)}`);
}
/**
* 战斗时间轴:仅 COMBAT 推进。
* 备战 PREP / 暂停 / 等复活不计入,否则机制 starttime/cd 会被备战耗时吃掉。
*/
tickBattleTime(dt: number): void {
if (this.session.phase !== BattlePhase.COMBAT || dt <= 0) return;
this._battleTimeAcc += dt;
this.BattleTime = this._battleTimeAcc;
}
canUpdateFrame(): boolean {
return this.phase !== BattlePhase.PAUSE
&& this.phase !== BattlePhase.END
&& this.phase !== BattlePhase.WAIT_REVIVE
&& !this.session.ended;
}
/**
* 抖音/30r 打点章节 id。
* 仅闯关主线章做困难 +1000 / 地狱 +2000;副本章用表 id(如 10003)。
*/
getReportDYLevelId(): number {
return this.LevelConfigId;
}
/** @deprecated 使用 getReportDYLevelId */
getReportDYChapterId(): number {
return this.getReportDYLevelId();
}
addCoinNum(n: number): void {
this._addLocalGold(n);
}
/** 复活成功:按局内固定数量发放桃子 */
grantRevivePeachReward(): number {
const amount = readRevivePeachRewardAmount();
if (amount <= 0) {
rtWarn('[BattleRuntime] revive peach amount invalid', amount);
this.lastRevivePeachGrant = 0;
return 0;
}
this.lastRevivePeachGrant = amount;
this._addLocalGold(amount);
return amount;
}
setMonsterPresenter(presenter: CocosBattleMonsterPresenter): void {
this._monsterPresenter = presenter;
this._mechanismHost.bind(this);
this._mechanismHost.tryStart();
getYingCampSummonHost().bind(this);
getMonsterSkillHost().bind(this);
getBattleBuffMoveSpeedHost().bind(this);
getBattleBuffUnitHpHost().bind(this);
getBattleBuffUnitAttackSpeedHost().bind(this);
getBattleBuffEnemySlowHost().bind(this);
}
getMonsterPresenter(): CocosBattleMonsterPresenter | null {
return this._monsterPresenter;
}
/** 白骨精 / 空投飞禽击杀不计入 MonsterDeadNum / 胜利进度 */
shouldCountMonsterKill(victim: EntityId): boolean {
return !isMechanismKillExcluded(this.session, victim);
}
attachMonsterRoot(root: Node): void {
this.MonsterSpawner.node = root;
if (this._monsterPresenter) {
this.MonsterSpawner.bindPresenter(this._monsterPresenter);
}
}
/** 还魂丹死亡特效父节点(map 层) */
setDeathFxParent(parent: Node | null): void {
getBattleBuffUnitReviveHost().bind(this, parent?.isValid ? parent : null);
}
}
/** 读取复活赠送桃子数量 */
export function readRevivePeachRewardAmount(): number {
return REVIVE_PEACH_REWARD > 0 ? REVIVE_PEACH_REWARD : 0;
}
/** 战斗运行时句柄(UI / 外围模块持有的类型) */
export type ProjectBattleHandle = BattleRuntime;
/** 创建战斗运行时 */
export function createBattleRuntime(params: BattleStartParams): ProjectBattleHandle | null {
registerBuiltinBattleEffects();
registerAtkHookEffects();
registerSkillSummonEffects();
registerSkillAtkBuffEffects();
const config = gg.battle.config;
config.refreshWeaponUnits();
config.refreshAbilities();
const wallHp = resolveInitialWallHp();
const rankRow = gg.data.table.getTableData<ITableRank>(TableNames.Rank, params.playerRankId);
if (!rankRow) {
console.log('[createBattleRuntime] Rank 不存在', params.playerRankId);
}
const rankBonus = rankRow?.diffcult ?? 0;
// 正整数种子:Date.now() 与 levelId 做无符号混入,避免 NaN/负数在位运算里塌成固定序列
const nowMix = (Date.now() % 100000) >>> 0;
const battleSeed = params.seed != null && Number.isFinite(Number(params.seed))
? (Math.floor(Number(params.seed)) >>> 0)
: (((Math.floor(Number(params.levelId) || 0) * 9973) >>> 0) + nowMix) >>> 0;
const mapPrefabName = ChapterLevelBuilder.resolveLevelMapPrefabName(params.levelId);
const mapId = ChapterLevelBuilder.getLevelMapId(params.levelId);
const mapGridSpec = probeMapGridSpecFromPrefab(mapPrefabName);
const level = ChapterLevelBuilder.build({
levelId: params.levelId,
playerRankId: params.playerRankId,
rankDifficultyBonus: rankBonus,
wallHp,
flowMode: params.flowMode,
seed: battleSeed,
mapGridSpec: mapGridSpec ?? undefined,
});
if (!level) return null;
config.registerLevel(level);
const flowMode = params.flowMode ?? level.flowMode;
const session = BattleFactory.create({
levelId: level.id,
config,
mode: params.mode ?? 'pvp',
seed: battleSeed,
flowMode,
waveClearRequiresKillAll: false,
});
return new BattleRuntime(
session,
params,
mapPrefabName,
mapId,
level.id,
params.mode ?? 'pvp',
);
}
/** 城墙最大血量:优先玩家养成,否则默认 3 */
function resolveInitialWallHp(): number {
const playerHp = (gg.game as { playerHp?: number }).playerHp ?? 0;
if (playerHp > 0) return playerHp;
return DEFAULT_BATTLE_WALL_HP;
}