|
|
|
|
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 { 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 { getThrownCircleHost } from '../presentation/ThrownCircleAtkPresenter';
|
|
|
|
|
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,
|
|
|
|
|
} 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;
|
|
|
|
|
/** 备战:本局剩余免费摆放提示次数(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:
|
|
|
|
|
getNezhaChargeHost().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);
|
|
|
|
|
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();
|
|
|
|
|
getBattleBuffUnitHpHost().unbind();
|
|
|
|
|
getBattleBuffUnitReviveHost().unbind();
|
|
|
|
|
getBattleBuffUnitAttackSpeedHost().unbind();
|
|
|
|
|
getBattleBuffEnemySlowHost().unbind();
|
|
|
|
|
getBattleBuffBaseThunderHost().unbind();
|
|
|
|
|
getBattleBuffBossInterruptHost().unbind();
|
|
|
|
|
getNezhaChargeHost().abortAll();
|
|
|
|
|
getArrowRainHost().abortAll();
|
|
|
|
|
getExtraRingHost().abortAll();
|
|
|
|
|
getExtraStrikeHost().abortAll();
|
|
|
|
|
getThrownCircleHost().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:格子,广告刷新文字,复活,芭蕉扇等(下标 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.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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
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;
|
|
|
|
|
}
|