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 { getBowAtkHost } from '../combat/BowAtkRuntime'; import { getBattleBuffMoveSpeedHost } from '../combat/BattleBuffMoveSpeedHost'; import { getBattleBuffUnitAttackSpeedHost } from '../combat/BattleBuffUnitAttackSpeedHost'; import { getBattleBuffBaseThunderHost } from '../combat/BattleBuffBaseThunderHost'; import { getBattleBuffDigitStatHost } from '../combat/BattleBuffDigitStatHost'; import { BattleBuffDamageHook } from '../combat/BattleBuffDamageHook'; import { WeaponDropService } from '../combat/WeaponDropService'; import { BattleBuffCarryStore } from '../../manager/battleBuff/BattleBuffCarryStore'; import { resolveCarriedBaseHpBonus, resolveCarriedBothBaseHp, rollCarriedKillCoinBonus, } from '../../manager/battleBuff/BattleBuffPassiveSkillUtil'; import { getMonsterSkillHost } from '../combat/MonsterSkillHost'; import { getNezhaChargeHost } from '../presentation/NezhaChargePresenter'; import { getEarthChargeHost } from '../presentation/EarthChargePresenter'; import { getArrowRainHost } from '../presentation/ArrowRainPresenter'; import { getExtraRingHost } from '../presentation/RingProjectilesPresenter'; import { getExtraStrikeHost } from '../presentation/ExtraStrikePresenter'; import { getGroundCircleHost, getThrownCircleHost } from '../presentation/ThrownCircleAtkPresenter'; import { getIceBallHost } from '../presentation/IceBallAtkPresenter'; import { getFlySwordHost } from '../presentation/FlySwordAtkPresenter'; 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, getBattleCoinAdRewards, } 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(); /** 局内宝石掉落(选品 Level.expdrop) */ 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_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; /** 备战:本局已消耗格子广告次数 */ prepGridAdUsed = 0; /** 备战:格子广告次数上限(advertise_limit[3]) */ prepGridAdMax = 0; /** 芭蕉扇:本局已消耗广告次数 */ bajiaoFanAdUsed = 0; /** 芭蕉扇:广告次数上限(advertise_limit[6]) */ bajiaoFanAdMax = 0; /** 门神盾牌:本局广告已展示(触发后待点击) */ menShenAdOfferVisible = false; /** 门神盾牌:本局已观看广告并放置陷阱 */ menShenAdConsumed = false; /** 局内看视频加金币:已用次数 */ coinAdUsed = 0; /** 局内看视频加金币:每次奖励数量(Const38 para1) */ coinAdRewards: number[] = []; /** 备战:本局剩余免费摆放提示次数(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(); /** 芭蕉扇:各出怪口独立待放队列与间隔计时(与 WaveSpawner 口内顺序一致) */ private readonly _bajiaoPortRelease = new Map(); /** 芭蕉扇退回放行期间挂起波次出怪,避免与入队放行同帧叠出 */ private _bajiaoSpawnSuspended = false; /** 复活回收怪按间隔放行期间挂起波次出怪(与芭蕉扇共用 suspendSpawn) */ private _reviveSpawnSuspended = false; private _localPlayerId = 0; private _players: Player[] = []; /** * 已应用到基地的气血丸/双生盾累计(按携带方),局内三选一用增量避免重复加血。 */ private readonly _appliedBaseHp: { playerId: number; baseHp: number; bothSelf: number; bothEnemy: number }[] = []; /** 本局结算已落地(防止 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(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(); BattleBuffDamageHook.install(); 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(); 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.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.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) { 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) { getTowerLoadoutService().removeEntity(e.entity); getBowAtkHost().cancelCaster(e.entity); getFlySwordHost().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._onKillCoinReward); 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 _onKillCoinReward = (e: BattleEvent): void => { if (e.type !== BattleEventType.EntityKilled) return; const playerId = this.resolveKillPeachOwnerPlayerId(e.killer, e.victim); const coin = this.resolveKillPeach(e.victim); if (coin > 0) { this._addPlayerGold(playerId, coin); if (playerId !== this._localPlayerId) { const gold = this.session.players[playerId]?.gold ?? -1; rtLog(`[BattleRuntime] kill coin +${coin} -> player${playerId} gold=${gold}`); } } // 本机赏金令走 KillPeachPresenter 飞币;AI 直接入账 if (playerId === this._localPlayerId) return; if (!this.isSmallMonsterVictim(e.victim)) return; const bonus = rollCarriedKillCoinBonus(BattleBuffCarryStore.getPassives(playerId)); if (bonus > 0) { this._addPlayerGold(playerId, bonus); rtLog(`[BattleRuntime] kill coin 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(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 { 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); } /** 开战:按 CarryStore 为各方套用气血丸/双生盾 */ private _applyCarriedBaseHpBonus(_localPlayerId: number): void { this._appliedBaseHp.length = 0; for (let i = 0; i < this.session.players.length; i++) { this.applyInGameBaseHpCarry(this.session.players[i].id); } } /** * 局内三选一获得气血丸/双生盾后:按 CarryStore 增量加基地血。 * @param carrierPlayerId 携带方 playerId * @returns 是否有血量变化 */ applyInGameBaseHpCarry(carrierPlayerId: number): boolean { const pid = Math.max(0, Math.floor(Number(carrierPlayerId) || 0)); const selfSlot = this.session.players[pid]; if (!selfSlot) { console.warn('[BattleRuntime] 基地加血失败:玩家槽缺失', pid); return false; } const passives = BattleBuffCarryStore.getPassives(pid); const base = resolveCarriedBaseHpBonus(passives); const both = resolveCarriedBothBaseHp(passives); let prev = this._findAppliedBaseHp(pid); if (!prev) { prev = { playerId: pid, baseHp: 0, bothSelf: 0, bothEnemy: 0 }; this._appliedBaseHp.push(prev); } const dBase = Math.max(0, base.bonusHp - prev.baseHp); const dSelf = Math.max(0, both.selfHp - prev.bothSelf); const dEnemy = Math.max(0, both.enemyHp - prev.bothEnemy); let changed = false; const selfAdd = dBase + dSelf; if (selfAdd > 0) { selfSlot.wallMaxHp += selfAdd; selfSlot.wallHp += selfAdd; changed = true; } if (dEnemy > 0) { const enemyPid = this._opponentSlotId(pid); const enemySlot = enemyPid >= 0 ? this.session.players[enemyPid] : null; if (!enemySlot) { console.warn('[BattleRuntime] 双生盾对方槽缺失', pid); } else { enemySlot.wallMaxHp += dEnemy; enemySlot.wallHp += dEnemy; changed = true; } } prev.baseHp = Math.max(prev.baseHp, base.bonusHp); prev.bothSelf = Math.max(prev.bothSelf, both.selfHp); prev.bothEnemy = Math.max(prev.bothEnemy, both.enemyHp); return changed; } /** * 双人对战对手槽 id。 * @param playerId 当前玩家 */ private _opponentSlotId(playerId: number): number { for (let i = 0; i < this.session.players.length; i++) { if (this.session.players[i].id !== playerId) return this.session.players[i].id; } return -1; } /** * @param playerId 携带方 */ private _findAppliedBaseHp(playerId: number): { playerId: number; baseHp: number; bothSelf: number; bothEnemy: number } | null { for (let i = 0; i < this._appliedBaseHp.length; i++) { if (this._appliedBaseHp[i].playerId === playerId) return this._appliedBaseHp[i]; } return null; } 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: getNezhaChargeHost().tick(scaledDt); getEarthChargeHost().tick(scaledDt); return; case 14: getArrowRainHost().tick(scaledDt); return; case 15: getSpearTossHost().tick(scaledDt); return; case 16: getPagodaSmashHost().tick(scaledDt); return; case 17: getStaffSmashHost().tick(scaledDt); return; case 18: this._tickReviveMonsterRelease(scaledDt); return; case 19: this._tickBajiaoMonsterRelease(dt); return; case 20: this.syncWaveNum(); return; case 21: getExtraRingHost().tick(scaledDt); getExtraStrikeHost().tick(scaledDt); getThrownCircleHost().tick(scaledDt); getGroundCircleHost().tick(scaledDt); getFlySwordHost().tick(scaledDt); getIceBallHost().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); } /** 是否处于背包/地图拖动(画面弱化中;对局不暂停、不慢放) */ 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(); getMonsterSkillHost().unbind(); getBattleBuffMoveSpeedHost().unbind(); getBattleBuffUnitAttackSpeedHost().unbind(); getBattleBuffBaseThunderHost().unbind(); getBattleBuffDigitStatHost().unbind(); BattleBuffDamageHook.uninstall(); this._appliedBaseHp.length = 0; getNezhaChargeHost().abortAll(); getEarthChargeHost().abortAll(); getArrowRainHost().abortAll(); getExtraRingHost().abortAll(); getExtraStrikeHost().abortAll(); getThrownCircleHost().abortAll(); getGroundCircleHost().abortAll(); getFlySwordHost().abortAll(); getIceBallHost().abortAll(); getSpearTossHost().abortAll(); getPagodaSmashHost().abortAll(); getStaffSmashHost().abortAll(); getAllySkillExtraHost().abortAll(); getFaXiangHost().abortAll(); getBattleGameGlobalAttackSpeedHost().reset(); getBattleGameGlobalAttackRangeHost().reset(); getBattleGameRandomActiveSkillHost().reset(); getPassiveSkillHost().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:格子,广告刷新文字,复活,芭蕉扇等(下标 3~6) this.prepFreeGridMax = getChapterFreeBox(levelId); this.prepFreeGridRemaining = this.prepFreeGridMax; this.prepGridAdUsed = 0; this.prepGridAdMax = limits[3] ?? 0; this.prepFreeTipsMax = getChapterFreeTips(levelId); this.prepFreeTipsRemaining = this.prepFreeTipsMax; this.bajiaoFanAdUsed = 0; this.bajiaoFanAdMax = limits[6] ?? 0; this.menShenAdOfferVisible = false; this.menShenAdConsumed = false; this.coinAdRewards = getBattleCoinAdRewards(); this.coinAdUsed = 0; this.curFailReviveLeftNum = limits[5] ?? limits[3] ?? 1; this.curAdAddHpLeftNum = 0; this.MonsterDeadNum = 0; this.TotalMonsterNum = this._countLevelMonsters(this.session.levelDef); } /** 局内看视频加金币是否仍可观看 */ canUseCoinAd(): boolean { return this.coinAdUsed < this.coinAdRewards.length; } /** 下一次看视频加金币数量;不可用时返回 0 */ nextCoinAdAmount(): number { if (!this.canUseCoinAd()) return 0; return this.coinAdRewards[this.coinAdUsed] ?? 0; } /** * 观看金币广告成功:入账并消耗一次。 * @returns 实际入账数量;不可用时 0 */ consumeCoinAd(): number { const amount = this.nextCoinAdAmount(); if (!(amount > 0)) { console.log('[BattleRuntime] 金币广告不可用', this.coinAdUsed, this.coinAdRewards.length); return 0; } this._addLocalGold(amount); this.coinAdUsed++; return amount; } 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; } 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(); 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(); getMonsterSkillHost().bind(this); getBattleBuffMoveSpeedHost().bind(this); getBattleBuffUnitAttackSpeedHost().bind(this); getBattleBuffDigitStatHost().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 { } } /** 读取复活赠送桃子数量 */ 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(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; }