|
|
|
|
import type { LevelDef } from 'db://assets/mx/module/battle/config/BattleConfigTypes';
|
|
|
|
|
import { createMembershipSet, membershipAdd, membershipFromArray, membershipDedupeArray, membershipToArray, type MembershipSet, type ReadonlyMembershipSet } from '../../../mx/tools/MembershipSet';
|
|
|
|
|
import type { ProjectLevelMonsterConfig } from './LevelMonsterConfig';
|
|
|
|
|
import { BattleFlowMode } from 'db://assets/mx/module/battle/core/BattleFlowMode';
|
|
|
|
|
import { MonsterType } from '../../manager/MonsterDataManager';
|
|
|
|
|
import { TableNames } from '../../game/ConfigTableData';
|
|
|
|
|
import { BattleMapSpec, resolveMapSizeFallback } from '../map/BattleMapSpec';
|
|
|
|
|
import { probeMapGridSpecFromPrefab, type MapGridSpec } from '../map/MapGridSpecReader';
|
|
|
|
|
import { WeaponComposeConfigProvider } from '../bag/WeaponComposeConfigProvider';
|
|
|
|
|
import { LevelWaveParser } from './LevelWaveParser';
|
|
|
|
|
import { calcSpecialReplicaFinalHpScale, getWeaponParaFromMaxChapter, resolveHpMultiple } from './MonsterHpCalculator';
|
|
|
|
|
import { resetMonsterHpDebugLog } from '../debug/MonsterHpDebugLog';
|
|
|
|
|
|
|
|
|
|
/** atk_after 段 1 类 debuff 类型 → 状态特效预制体 */
|
|
|
|
|
const DEBUFF_TYPE_TO_STATUS_FX: Readonly<Record<number, string>> = {
|
|
|
|
|
1: '怪_火',
|
|
|
|
|
2: '怪_流血',
|
|
|
|
|
3: '怪_冰',
|
|
|
|
|
4: '怪_晕眩',
|
|
|
|
|
6: '怪_恶鬼',
|
|
|
|
|
7: '怪_电',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** 通用 debuff 特效(开战预载,避免局内首次 ApplyBuff 卡顿) */
|
|
|
|
|
const ALWAYS_BATTLE_DEBUFF_STATUS_FX = Object.values(DEBUFF_TYPE_TO_STATUS_FX);
|
|
|
|
|
|
|
|
|
|
/** Monster 表 icon/name → 预制体文件名(仅表名与 bundle 内文件名不一致时填写) */
|
|
|
|
|
const MONSTER_PREFAB_ALIASES: Readonly<Record<string, string>> = {
|
|
|
|
|
/** 旧名兼容;现资源与表均为 BOSS* */
|
|
|
|
|
Boss金: 'BOSS金',
|
|
|
|
|
Boss木: 'BOSS木',
|
|
|
|
|
Boss水: 'BOSS水',
|
|
|
|
|
Boss火: 'BOSS火',
|
|
|
|
|
Boss土: 'BOSS土',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export interface LevelDefBuildOptions {
|
|
|
|
|
/** 战场 Level 表 id */
|
|
|
|
|
levelId: number;
|
|
|
|
|
/** 段位难度加值(Rank.diffcult) */
|
|
|
|
|
rankDifficultyBonus?: number;
|
|
|
|
|
/** 己方段位 id(查 Level.weapon_para) */
|
|
|
|
|
playerRankId?: number;
|
|
|
|
|
wallHp?: number;
|
|
|
|
|
spawnInterval?: number;
|
|
|
|
|
/** @deprecated 仅覆盖后续波间隔;请用 firstWaveDelay / subsequentWaveInterval */
|
|
|
|
|
waveInterval?: number;
|
|
|
|
|
/** 首波开怪前延迟(秒),默认读 Const 波次间隔 para1 第 1 项 */
|
|
|
|
|
firstWaveDelay?: number;
|
|
|
|
|
/** 后续每波开怪前间隔(秒),默认读 Const para1 第 2 项 */
|
|
|
|
|
subsequentWaveInterval?: number;
|
|
|
|
|
flowMode?: BattleFlowMode;
|
|
|
|
|
/** 对局随机种子(Boss 概率链等) */
|
|
|
|
|
seed?: number;
|
|
|
|
|
/** 地图格子规格(默认从 mapLayer 预制体 MapManager 探测) */
|
|
|
|
|
mapGridSpec?: MapGridSpec;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Const 波次间隔:首波延迟 + 后续波间隔(秒) */
|
|
|
|
|
export interface WaveIntervalConfig {
|
|
|
|
|
/** 首波开怪前延迟(秒) */
|
|
|
|
|
firstWaveDelaySec: number;
|
|
|
|
|
/** 后续每波开怪前间隔(秒) */
|
|
|
|
|
subsequentIntervalSec: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 双方基地默认血量(后续养成覆盖) */
|
|
|
|
|
export const DEFAULT_BATTLE_WALL_HP = 3;
|
|
|
|
|
/** 单怪出怪间隔默认秒数(常量表无对应行) */
|
|
|
|
|
const DEFAULT_MONSTER_SPAWN_INTERVAL_SEC = 1;
|
|
|
|
|
/** 对齐 Const 表 id=8(ConstNumType.WaveInterval),避免从 ConfigProjectData 拉环 */
|
|
|
|
|
const CONST_WAVE_INTERVAL_ID = 8;
|
|
|
|
|
|
|
|
|
|
/** 从 Level 表构建 MX LevelDef */
|
|
|
|
|
export class ChapterLevelBuilder {
|
|
|
|
|
static build(options: LevelDefBuildOptions): LevelDef | null {
|
|
|
|
|
resetMonsterHpDebugLog();
|
|
|
|
|
const levelRow = gg.data.table.getTableData<ITableLevel>(TableNames.Level, options.levelId);
|
|
|
|
|
if (!levelRow) {
|
|
|
|
|
console.error('[ChapterLevelBuilder] level not found', options.levelId);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const difficultyRow = Math.max(1, options.rankDifficultyBonus ?? (Number(levelRow.diffcult) || 1));
|
|
|
|
|
const startGold = Math.floor(Number(levelRow.startnumber));
|
|
|
|
|
if (!(startGold >= 0) || !Number.isFinite(startGold)) {
|
|
|
|
|
console.error('[ChapterLevelBuilder] Level.startnumber 无效', options.levelId, levelRow.startnumber);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
const spawnInterval = this.resolveMonsterSpawnIntervalSec(options.spawnInterval);
|
|
|
|
|
const waveTiming = this.resolveWaveIntervalConfig();
|
|
|
|
|
const firstWaveDelay = options.firstWaveDelay ?? waveTiming.firstWaveDelaySec;
|
|
|
|
|
const subsequentInterval = options.subsequentWaveInterval
|
|
|
|
|
?? options.waveInterval
|
|
|
|
|
?? waveTiming.subsequentIntervalSec;
|
|
|
|
|
if (!(firstWaveDelay >= 0 && Number.isFinite(firstWaveDelay))) {
|
|
|
|
|
console.log('[ChapterLevelBuilder] invalid firstWaveDelay', firstWaveDelay);
|
|
|
|
|
}
|
|
|
|
|
if (!(subsequentInterval > 0 && Number.isFinite(subsequentInterval))) {
|
|
|
|
|
console.log('[ChapterLevelBuilder] invalid subsequentWaveInterval', subsequentInterval);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const monsterConfig: ProjectLevelMonsterConfig = {
|
|
|
|
|
levelId: options.levelId,
|
|
|
|
|
difficultyRow,
|
|
|
|
|
monsterGroup: difficultyRow,
|
|
|
|
|
hpMultiple: resolveHpMultiple(1),
|
|
|
|
|
specialReplicaHpScale: calcSpecialReplicaFinalHpScale(),
|
|
|
|
|
portHpMult: [1, 1, 1],
|
|
|
|
|
powerPara: 0,
|
|
|
|
|
powerPara2: 0,
|
|
|
|
|
weaponPara: getWeaponParaFromMaxChapter(options.playerRankId ?? 0),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const bossSeed = options.seed != null && Number.isFinite(Number(options.seed))
|
|
|
|
|
? (Math.floor(Number(options.seed)) >>> 0)
|
|
|
|
|
: ((Math.floor(Number(options.levelId) || 0) * 9973) >>> 0);
|
|
|
|
|
const waves = LevelWaveParser.toWaveDefs(
|
|
|
|
|
levelRow,
|
|
|
|
|
monsterConfig,
|
|
|
|
|
spawnInterval,
|
|
|
|
|
Math.max(0, firstWaveDelay),
|
|
|
|
|
Math.max(0.05, subsequentInterval),
|
|
|
|
|
bossSeed,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const flowMode = options.flowMode ?? this.resolveFlowMode(levelRow);
|
|
|
|
|
const mapPrefabName = this.resolveLevelMapPrefabName(options.levelId);
|
|
|
|
|
const mapId = this.getLevelMapId(options.levelId);
|
|
|
|
|
const mapSize = options.mapGridSpec
|
|
|
|
|
?? probeMapGridSpecFromPrefab(mapPrefabName)
|
|
|
|
|
?? (() => {
|
|
|
|
|
console.log('[ChapterLevelBuilder] 未能从预制体读取地图规格,使用兜底常量', mapPrefabName, mapId);
|
|
|
|
|
return resolveMapSizeFallback(mapId);
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: `level_${options.levelId}_d${difficultyRow}`,
|
|
|
|
|
flowMode,
|
|
|
|
|
mapRows: mapSize.rows,
|
|
|
|
|
mapCols: mapSize.cols,
|
|
|
|
|
cellSize: mapSize.cellSize,
|
|
|
|
|
wallHp: options.wallHp ?? DEFAULT_BATTLE_WALL_HP,
|
|
|
|
|
startGold,
|
|
|
|
|
extra: { monsterConfig },
|
|
|
|
|
waves,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 单怪出怪间隔兜底(秒):优先 Level.monster 第 4 列;缺省/无效时用 override 或局内默认。
|
|
|
|
|
*/
|
|
|
|
|
static resolveMonsterSpawnIntervalSec(override?: number): number {
|
|
|
|
|
if (typeof override === 'number' && override > 0 && Number.isFinite(override)) {
|
|
|
|
|
return override;
|
|
|
|
|
}
|
|
|
|
|
return DEFAULT_MONSTER_SPAWN_INTERVAL_SEC;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 从 Const 表 id=10 读取波次间隔:para1「首波延迟,后续间隔」 */
|
|
|
|
|
static resolveWaveIntervalConfig(): WaveIntervalConfig {
|
|
|
|
|
const parts = gg.data.table.getConstArray(CONST_WAVE_INTERVAL_ID);
|
|
|
|
|
const first = parts[0];
|
|
|
|
|
const subsequent = parts[1];
|
|
|
|
|
if (parts.length < 2) {
|
|
|
|
|
console.log(
|
|
|
|
|
'[ChapterLevelBuilder] Const 波次间隔 para1 须为「首波,后续」',
|
|
|
|
|
CONST_WAVE_INTERVAL_ID,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
const firstWaveDelaySec = (typeof first === 'number' && first >= 0 && Number.isFinite(first))
|
|
|
|
|
? first
|
|
|
|
|
: 10;
|
|
|
|
|
const subsequentIntervalSec = (typeof subsequent === 'number' && subsequent > 0 && Number.isFinite(subsequent))
|
|
|
|
|
? subsequent
|
|
|
|
|
: 5;
|
|
|
|
|
return { firstWaveDelaySec, subsequentIntervalSec };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 地图预制体名。读关卡表当前关卡 Level.map。
|
|
|
|
|
* @param levelId 当前关卡 id
|
|
|
|
|
*/
|
|
|
|
|
static resolveLevelMapPrefabName(levelId: number): string {
|
|
|
|
|
const level = gg.data.table.getTableData<ITableLevel>(TableNames.Level, levelId);
|
|
|
|
|
const raw = level?.map;
|
|
|
|
|
if (typeof raw === 'string') {
|
|
|
|
|
const name = raw.trim();
|
|
|
|
|
if (name) return name;
|
|
|
|
|
}
|
|
|
|
|
console.warn('[ChapterLevelBuilder] 关卡缺少 Level.map', levelId);
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 从 map 预制体名解析数字索引(mapLayer3→3);供 MAP_SIZE_BY_ID 等 legacy 逻辑 */
|
|
|
|
|
static getLevelMapId(levelId: number): number {
|
|
|
|
|
const name = ChapterLevelBuilder.resolveLevelMapPrefabName(levelId);
|
|
|
|
|
const matched = /^mapLayer(\d+)$/i.exec(name);
|
|
|
|
|
if (matched) {
|
|
|
|
|
const parsed = Number(matched[1]);
|
|
|
|
|
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
|
|
|
|
}
|
|
|
|
|
const numeric = Number(name);
|
|
|
|
|
if (Number.isFinite(numeric) && numeric > 0) return numeric;
|
|
|
|
|
return levelId > 0 ? levelId : 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @deprecated 使用 getLevelMapId */
|
|
|
|
|
static getChapterMapId(levelId: number): number {
|
|
|
|
|
return this.getLevelMapId(levelId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 战斗流程:Level.flow_mode;缺省经典备战。
|
|
|
|
|
* flow_mode: 0=经典备战,1=连续战斗
|
|
|
|
|
*/
|
|
|
|
|
static resolveFlowMode(chapter: ITableLevel): BattleFlowMode {
|
|
|
|
|
const rowMode = (chapter as ITableLevel & { flow_mode?: number }).flow_mode;
|
|
|
|
|
if (rowMode === 1) return BattleFlowMode.Continuous;
|
|
|
|
|
return BattleFlowMode.ClassicPrep;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** ???????????? id?????/???????? */
|
|
|
|
|
static collectLevelMonsterIds(levelId: number): number[] {
|
|
|
|
|
const level = gg.data.table.getTableData<ITableLevel>(TableNames.Level, levelId);
|
|
|
|
|
if (!level) return [];
|
|
|
|
|
return LevelWaveParser.collectMonsterIds(level);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @deprecated 使用 collectLevelMonsterIds */
|
|
|
|
|
static collectChapterMonsterIds(levelId: number): number[] {
|
|
|
|
|
return this.collectLevelMonsterIds(levelId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static collectLevelMonsterModels(levelId: number): string[] {
|
|
|
|
|
const models = createMembershipSet<string>();
|
|
|
|
|
for (const id of this.collectLevelMonsterIds(levelId)) {
|
|
|
|
|
const prefabName = this.resolveMonsterPrefabNameById(id);
|
|
|
|
|
if (prefabName) membershipAdd(models, prefabName);
|
|
|
|
|
}
|
|
|
|
|
return membershipToArray(models);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Monster 表 id → 预制体名(icon / model / name + 别名) */
|
|
|
|
|
static resolveMonsterPrefabNameById(monsterId: number): string | null {
|
|
|
|
|
const row = gg.data.table.getTableData<ITableMonster>(TableNames.Monster, monsterId)
|
|
|
|
|
?? gg.data.project.getMonsterDataByID(monsterId);
|
|
|
|
|
if (!row) return null;
|
|
|
|
|
return this.resolveMonsterPrefabFromRow(row);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Monster 行 → battleGameMonster 预制体文件名(icon → name) */
|
|
|
|
|
static resolveMonsterPrefabFromRow(row: ITableMonster): string | null {
|
|
|
|
|
const icon = typeof row.icon === 'string' ? row.icon.trim() : '';
|
|
|
|
|
const raw = icon || row.name;
|
|
|
|
|
return this.resolveMonsterPrefabName(raw);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 配表 model 或 name → battleGameMonster 预制体文件名 */
|
|
|
|
|
static resolveMonsterPrefabName(raw: unknown): string | null {
|
|
|
|
|
if (typeof raw !== 'string') return null;
|
|
|
|
|
const name = raw.trim();
|
|
|
|
|
if (!name) return null;
|
|
|
|
|
return MONSTER_PREFAB_ALIASES[name] ?? name;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static collectLevelBloodBarPrefabs(levelId: number): string[] {
|
|
|
|
|
const prefabs = createMembershipSet<string>();
|
|
|
|
|
for (const id of this.collectLevelMonsterIds(levelId)) {
|
|
|
|
|
const row = gg.data.table.getTableData<ITableMonster>(TableNames.Monster, id);
|
|
|
|
|
const type = row?.type ?? gg.data.project.getMonsterDataByID(id)?.type;
|
|
|
|
|
const prefab = this._bloodBarPrefabForMonsterType(type);
|
|
|
|
|
if (prefab) membershipAdd(prefabs, prefab);
|
|
|
|
|
}
|
|
|
|
|
return membershipToArray(prefabs);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 关卡开战预载的状态特效:
|
|
|
|
|
* - 通用正面 buff:怪_攻击buff
|
|
|
|
|
* - 上阵武器 skill / debuff 关联特效
|
|
|
|
|
*/
|
|
|
|
|
static collectLevelStatusFxPrefabs(levelId: number): string[] {
|
|
|
|
|
const prefabs = membershipFromArray<string>(['怪_攻击buff', '怪_加速', '怪物加血', '血量上限', '冰冻']);
|
|
|
|
|
for (const fx of ALWAYS_BATTLE_DEBUFF_STATUS_FX) {
|
|
|
|
|
membershipAdd(prefabs, fx);
|
|
|
|
|
}
|
|
|
|
|
for (const fx of this._collectLoadoutStatusFxPrefabs()) {
|
|
|
|
|
membershipAdd(prefabs, fx);
|
|
|
|
|
}
|
|
|
|
|
return membershipToArray(prefabs);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static _addMonsterModelName(models: MembershipSet<string>, model: unknown): void {
|
|
|
|
|
const prefabName = ChapterLevelBuilder.resolveMonsterPrefabName(model);
|
|
|
|
|
if (prefabName) membershipAdd(models, prefabName);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static _bloodBarPrefabForMonsterType(type: unknown): string | null {
|
|
|
|
|
const monsterType = Number(type);
|
|
|
|
|
if (!Number.isFinite(monsterType) || monsterType === MonsterType.TreasureBox) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
if (monsterType === MonsterType.Boss) return 'BOSS血条底框';
|
|
|
|
|
if (monsterType === MonsterType.SmallMonster || monsterType === MonsterType.SummonMonster) {
|
|
|
|
|
return '小怪血条底';
|
|
|
|
|
}
|
|
|
|
|
return 'MidBOSS血条框';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static _collectLoadoutStatusFxPrefabs(): string[] {
|
|
|
|
|
const fx = createMembershipSet<string>();
|
|
|
|
|
const baseAtkIds = createMembershipSet<number>();
|
|
|
|
|
const equipped = gg.data.project.getEquippedWeaponDataList();
|
|
|
|
|
const weapon1Ids = membershipFromArray(equipped.map((w) => Number(w.id)));
|
|
|
|
|
|
|
|
|
|
for (const w1 of equipped) {
|
|
|
|
|
for (const rawAtkId of w1.atk_id ?? []) {
|
|
|
|
|
const atkId = Number(rawAtkId);
|
|
|
|
|
if (Number.isFinite(atkId) && atkId > 0) membershipAdd(baseAtkIds, atkId);
|
|
|
|
|
}
|
|
|
|
|
const heroSkillId = Math.floor(Number(w1.skill_id) || 0);
|
|
|
|
|
if (heroSkillId > 0) membershipAdd(baseAtkIds, heroSkillId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const atkIds = membershipToArray(baseAtkIds);
|
|
|
|
|
for (let i = 0; i < atkIds.length; i++) {
|
|
|
|
|
const row = gg.data.table.getTableData<ITableSkill>(TableNames.Skill, atkIds[i]);
|
|
|
|
|
if (row) this._collectStatusFxFromSkillRow(row, fx);
|
|
|
|
|
}
|
|
|
|
|
return membershipToArray(fx);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static _collectStatusFxFromAtkHookRaw(raw: string, out: MembershipSet<string>): void {
|
|
|
|
|
if (!raw) return;
|
|
|
|
|
for (const seg of raw.split(';')) {
|
|
|
|
|
const part = seg.trim();
|
|
|
|
|
if (!part) continue;
|
|
|
|
|
const nums = part.split(',').map(Number);
|
|
|
|
|
if (!nums.length || !Number.isFinite(nums[0])) continue;
|
|
|
|
|
if (nums[0] !== 1 || nums.length < 3) continue;
|
|
|
|
|
const prefab = DEBUFF_TYPE_TO_STATUS_FX[nums[2]];
|
|
|
|
|
if (prefab) membershipAdd(out, prefab);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static _collectStatusFxFromSkillAtkBuff(raw: string, out: MembershipSet<string>): void {
|
|
|
|
|
const s = String(raw ?? '').trim();
|
|
|
|
|
if (!s) return;
|
|
|
|
|
const parts = s.split(',').map(Number);
|
|
|
|
|
const kind = parts[0] ?? 0;
|
|
|
|
|
if (kind === 1) membershipAdd(out, '怪_晕眩');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static _collectStatusFxFromSkillRow(row: ITableSkill, out: MembershipSet<string>): void {
|
|
|
|
|
this._collectStatusFxFromSkillAtkBuff(row.atk_buff1 ?? '', out);
|
|
|
|
|
this._collectStatusFxFromSkillAtkBuff(row.atk_buff2 ?? '', out);
|
|
|
|
|
const skill = row.skill?.trim();
|
|
|
|
|
if (!skill) return;
|
|
|
|
|
const parts = skill.split(',').map(Number);
|
|
|
|
|
if ((parts[0] ?? 0) !== 2) return;
|
|
|
|
|
const stunChance = parts[3] ?? 0;
|
|
|
|
|
const stunSec = parts[4] ?? 0;
|
|
|
|
|
if (stunChance > 0 && stunSec > 0) {
|
|
|
|
|
membershipAdd(out, '怪_晕眩');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|