本次提交将全项目中所有与「波次」相关的术语、注释、配置描述、UI文本全部替换为「回合」,包括: 1. 技能卡牌、怪物刷新、经济系统的相关注释与变量名 2. 配置表中的波次相关常量与描述文本 3. UI预制体中的波次显示文本 4. 所有相关的文档注释与接口说明 统一术语后,游戏的关卡流程描述将更符合常规肉鸽游戏的表达习惯,避免与「波次」字面含义混淆,提升代码可读性与团队协作一致性。
806 lines
31 KiB
TypeScript
806 lines
31 KiB
TypeScript
/**
|
||
* @file RogueConfig.ts
|
||
* @description 肉鸽刷怪系统(基于英雄强度的动态难度 + 心流循环)
|
||
*
|
||
* 设计层次:
|
||
* 1. WaveType - 回合类型(普通 / 压力 / 放松),5 回合一个心流循环
|
||
* 2. WaveConfig - 每回合硬编码(数量 + 小队池 + HP/AP 倍率 + 强度微调)
|
||
* 3. DynamicTuner - 动态难度调节器(系统根据战况放水 / 加压)
|
||
* 4. MonSkillSet - 怪物技能池(atking / atked / dead 等全触发类型)
|
||
* 5. RogueSpawningEngine - 生成引擎(按英雄强度反推怪物强度)
|
||
*
|
||
* 核心公式:
|
||
* heroPower = Σ calcHeroPower(HeroInfo[uuid], lv) (场上存活英雄)
|
||
* targetPower = heroPower × 回合类型系数 × wave.power_adjust × DynamicTuner.factor
|
||
* scale = targetPower ÷ Σ 怪物基础强度
|
||
* 每只怪: hp ×= scale, ap ×= scale
|
||
*
|
||
* 回合节奏:
|
||
* - 最大 20 回合,第 20 回合通关
|
||
* - 每回合 30 秒,固定分 3 批,每 10 秒释放一批
|
||
* - 普通回合 18~36 只,放松回合 × 1.5 = 27~54 只
|
||
* - wave % 5 === 0 → 压力回合(必带 Boss,强度高、数量少)
|
||
* - wave % 5 === 1 → 放松回合(数量 × 1.5,强度低,爽快清屏)
|
||
*/
|
||
|
||
import { HeroInfo, MonType, MonTypeName, calcHeroPower, TriggerGrouped, LvReviveEntry, heroInfo } from "../common/config/heroSet";
|
||
import { SkillOverrides } from "../common/config/SkillSet";
|
||
import { FacSet } from "../common/config/GameSet";
|
||
import { ecs } from "../../../../extensions/oops-plugin-framework/assets/libs/ecs/ECS";
|
||
import { HeroAttrsComp } from "../hero/HeroAttrsComp";
|
||
|
||
// ======================== 1. 回合类型与节奏常量 ========================
|
||
|
||
/** 回合类型枚举 */
|
||
export enum WaveType {
|
||
Normal = 0, // 普通回合
|
||
Pressure = 1, // 压力回合(wave % 5 === 0,必带 Boss)
|
||
Relax = 2, // 放松回合(wave % 5 === 1,量大强度低)
|
||
}
|
||
|
||
/** 回合类型名称 */
|
||
export const WaveTypeName: Record<WaveType, string> = {
|
||
[WaveType.Normal]: "普通",
|
||
[WaveType.Pressure]: "压力",
|
||
[WaveType.Relax]: "放松",
|
||
};
|
||
|
||
/** 回合类型强度系数(硬编码) */
|
||
export const WAVE_TYPE_POWER_RATIO: Record<WaveType, number> = {
|
||
[WaveType.Normal]: 0.9, // 普通回合:标准强度
|
||
[WaveType.Pressure]: 1.2, // 压力回合:强度高、数量少
|
||
[WaveType.Relax]: 0.6, // 放松回合:量大、强度低
|
||
};
|
||
|
||
/** 放松回合数量倍率(相对普通回合) */
|
||
export const RELAX_COUNT_MUL = 1.5;
|
||
|
||
/** 最大回合数(第 20 回合通关) */
|
||
export const MAX_WAVE = 20;
|
||
|
||
/** 每回合时长(秒) */
|
||
export const WAVE_DURATION = 30;
|
||
|
||
/** 每回合分批次数 */
|
||
export const BATCH_COUNT = 3;
|
||
|
||
/** 每批间隔(秒):30 秒 / 3 批 = 10 秒 */
|
||
export const BATCH_INTERVAL = WAVE_DURATION / BATCH_COUNT;
|
||
|
||
/** 每回合怪物硬上限(放松回合 36 × 1.5 = 54) */
|
||
export const MAX_MONSTERS = 54;
|
||
|
||
/**
|
||
* 获取指定回合的回合类型
|
||
* @param wave 回合(1 起)
|
||
* @returns WaveType
|
||
*/
|
||
export function getWaveType(wave: number): WaveType {
|
||
if (wave % 5 === 0) return WaveType.Pressure;
|
||
if (wave % 5 === 1) return WaveType.Relax;
|
||
return WaveType.Normal;
|
||
}
|
||
|
||
// ======================== 2. 动态难度调节器 ========================
|
||
|
||
/**
|
||
* 动态难度调节器(系统控制放水 / 加压)
|
||
* 与硬编码系数并存,用于根据战况实时微调难度。
|
||
*
|
||
* 用法示例(MissionComp 每回合结束时调用):
|
||
* DynamicTuner.adjust(clearTime, heroDeathCount);
|
||
*
|
||
* 调节规则(内部硬编码):
|
||
* - 清场时间 < 20s 且无英雄死亡 → factor += 0.05(加压)
|
||
* - 清场时间 > 28s 或有英雄死亡 → factor -= 0.05(放水)
|
||
* - factor 范围钳制 [0.5, 2.0]
|
||
*/
|
||
export const DynamicTuner = {
|
||
/** 当前难度系数(默认 1.0,>1 加压,<1 放水) */
|
||
factor: 1.0,
|
||
|
||
/** 系数下限(最多放水到 50%) */
|
||
MIN_FACTOR: 0.5,
|
||
/** 系数上限(最多加压到 200%) */
|
||
MAX_FACTOR: 2.0,
|
||
/** 单次调节步长 */
|
||
STEP: 0.05,
|
||
|
||
/**
|
||
* 根据上一回合战况自动调节难度
|
||
* @param clearTime 清场耗时(秒)
|
||
* @param heroDeathCount 英雄死亡数
|
||
*/
|
||
adjust(clearTime: number, heroDeathCount: number): void {
|
||
if (heroDeathCount > 0 || clearTime > WAVE_DURATION * 0.95) {
|
||
// 有英雄死亡或清场过慢 → 放水
|
||
this.factor = Math.max(this.MIN_FACTOR, this.factor - this.STEP);
|
||
} else if (clearTime < WAVE_DURATION * 0.65 && heroDeathCount === 0) {
|
||
// 清场过快且无死亡 → 加压
|
||
this.factor = Math.min(this.MAX_FACTOR, this.factor + this.STEP);
|
||
}
|
||
},
|
||
|
||
/** 重置调节器(每局开始时调用) */
|
||
reset(): void {
|
||
this.factor = 1.0;
|
||
},
|
||
};
|
||
|
||
// ======================== 3. 怪物 UUID 池 ========================
|
||
|
||
/** 按 MonType 分组的怪物 uuid 池,动态从 HeroInfo 提取,避免硬编码 */
|
||
export const MonList: Record<number, number[]> = {};
|
||
for (const key in HeroInfo) {
|
||
const info = HeroInfo[key];
|
||
if (info.fac === FacSet.MON && info.monType !== undefined) {
|
||
if (!MonList[info.monType]) {
|
||
MonList[info.monType] = [];
|
||
}
|
||
MonList[info.monType].push(info.uuid);
|
||
}
|
||
}
|
||
|
||
// ======================== 3.5 怪物金币掉落配置 ========================
|
||
|
||
/**
|
||
* 怪物金币掉落配置(按 MonType 分类)
|
||
* - base: 普通怪物死亡时掉落的固定金币数
|
||
* - boss: Boss 怪物死亡时掉落的固定金币数(仅对 Boss 类型生效)
|
||
*/
|
||
export const MonsterGoldSet: Record<number, { base: number; boss: number }> = {
|
||
[MonType.Melee]: { base: 1, boss: 0 },
|
||
[MonType.Heavy]: { base: 2, boss: 0 },
|
||
[MonType.Long]: { base: 2, boss: 0 },
|
||
[MonType.Support]: { base: 3, boss: 0 },
|
||
[MonType.Summoner]: { base: 3, boss: 0 },
|
||
[MonType.Assassin]: { base: 3, boss: 0 },
|
||
[MonType.MeleeBoss]: { base: 0, boss: 15 },
|
||
[MonType.LongBoss]: { base: 0, boss: 15 },
|
||
};
|
||
|
||
/**
|
||
* 获取指定怪物类型的金币掉落数量
|
||
* @param monType 怪物类型(MonType)
|
||
* @param isBoss 是否为 Boss
|
||
* @returns 掉落的金币数量(≥0)
|
||
*/
|
||
export function getMonsterGoldDrop(monType: number, isBoss: boolean): number {
|
||
const cfg = MonsterGoldSet[monType];
|
||
if (!cfg) return 0;
|
||
return Math.max(0, Math.floor(isBoss ? cfg.boss : cfg.base));
|
||
}
|
||
|
||
// ======================== 4. 小队模板库 ========================
|
||
|
||
/** 小队内单种怪物的槽位定义 */
|
||
export interface SquadSlot {
|
||
type: MonType;
|
||
count: number;
|
||
}
|
||
|
||
/** 小队模板(3-4 只怪的组合单元) */
|
||
export interface SquadConfig {
|
||
id: string;
|
||
name: string;
|
||
slots: SquadSlot[];
|
||
weight: number; // 在小队池中的抽取权重
|
||
}
|
||
|
||
/** 小队模板库(硬编码) */
|
||
export const SquadLibrary: Record<string, SquadConfig> = {
|
||
melee_grunt: { id: "melee_grunt", name: "近战步兵组", weight: 10, slots: [{ type: MonType.Melee, count: 3 }] },
|
||
assassin_squad: { id: "assassin_squad", name: "刺客突袭组", weight: 6, slots: [{ type: MonType.Assassin, count: 2 }, { type: MonType.Support, count: 1 }] },
|
||
mixed_balanced: { id: "mixed_balanced", name: "平衡混合组", weight: 8, slots: [{ type: MonType.Melee, count: 1 }, { type: MonType.Long, count: 1 }, { type: MonType.Heavy, count: 1 }] },
|
||
long_line: { id: "long_line", name: "远程线列组", weight: 7, slots: [{ type: MonType.Long, count: 2 }, { type: MonType.Support, count: 1 }] },
|
||
heavy_shield: { id: "heavy_shield", name: "重盾堡垒组", weight: 5, slots: [{ type: MonType.Heavy, count: 1 }, { type: MonType.Melee, count: 2 }] },
|
||
summoner_cult: { id: "summoner_cult", name: "召唤教派组", weight: 4, slots: [{ type: MonType.Summoner, count: 1 }, { type: MonType.Long, count: 2 }] },
|
||
};
|
||
|
||
// ======================== 5. 怪物技能池 ========================
|
||
|
||
/**
|
||
* 怪物技能套装(一套完整的触发技能配置)
|
||
* 覆盖触发时机:call / atking / atked / dead / fstart / fend / revive
|
||
*/
|
||
export interface MonSkillSet {
|
||
id: string;
|
||
name: string;
|
||
/** 普攻技能覆盖(可选,不填则使用怪物默认普攻) */
|
||
skill?: { s_uuid: number; cd?: number; overrides?: SkillOverrides };
|
||
/** 召唤触发 */
|
||
call?: TriggerGrouped;
|
||
/** 攻击触发 */
|
||
atking?: TriggerGrouped;
|
||
/** 受击触发 */
|
||
atked?: TriggerGrouped;
|
||
/** 死亡触发 */
|
||
dead?: TriggerGrouped;
|
||
/** 战斗开始触发 */
|
||
fstart?: TriggerGrouped;
|
||
/** 战斗结束触发 */
|
||
fend?: TriggerGrouped;
|
||
/** 复活 */
|
||
revive?: LvReviveEntry[];
|
||
/** 在技能池中的抽取权重 */
|
||
weight: number;
|
||
}
|
||
|
||
/**
|
||
* 怪物技能池(硬编码)
|
||
* 压力回合 / Boss 可配置专属技能,普通回合随机挂载增加变数。
|
||
*
|
||
* 技能 uuid 引用 SkillSet 中的 6000~6500 段触发技能。
|
||
*/
|
||
export const MonSkillPool: Record<string, MonSkillSet> = {
|
||
/** 狂暴:攻击触发自身攻击提升 */
|
||
berserk: {
|
||
id: "berserk", name: "狂暴", weight: 10,
|
||
atking: {
|
||
6401: [{ lv: 1, t_num: 5, overrides: { ap: 1 } }],
|
||
},
|
||
},
|
||
/** 坚韧:受击获得护盾 */
|
||
tough: {
|
||
id: "tough", name: "坚韧", weight: 8,
|
||
atked: {
|
||
6301: [{ lv: 1, t_num: 3, overrides: { ap: 2 } }],
|
||
},
|
||
},
|
||
/** 遗志:死亡时全队攻击提升 */
|
||
legacy: {
|
||
id: "legacy", name: "遗志", weight: 6,
|
||
dead: {
|
||
6401: [{ lv: 1, t_num: 1, overrides: { ap: 3 } }],
|
||
},
|
||
},
|
||
/** 战吼:战斗开始时全队攻击提升 */
|
||
warcry: {
|
||
id: "warcry", name: "战吼", weight: 5,
|
||
fstart: {
|
||
6401: [{ lv: 1, t_num: 1, overrides: { ap: 2 } }],
|
||
},
|
||
},
|
||
/** 吸血:攻击恢复生命 */
|
||
leech: {
|
||
id: "leech", name: "吸血", weight: 7,
|
||
atking: {
|
||
6302: [{ lv: 1, t_num: 4, overrides: { ap: 150 } }],
|
||
},
|
||
},
|
||
};
|
||
|
||
/** Boss 专属技能池(压力回合 Boss 随机挂载) */
|
||
export const BossSkillPool: Record<string, MonSkillSet> = {
|
||
/** 狂暴领主:攻击触发全队攻击提升 */
|
||
boss_rage: {
|
||
id: "boss_rage", name: "狂暴领主", weight: 10,
|
||
atking: {
|
||
6401: [{ lv: 1, t_num: 3, overrides: { ap: 3 } }],
|
||
},
|
||
dead: {
|
||
6401: [{ lv: 1, t_num: 1, overrides: { ap: 5 } }],
|
||
},
|
||
},
|
||
/** 铁壁领主:受击获得高额护盾 */
|
||
boss_iron: {
|
||
id: "boss_iron", name: "铁壁领主", weight: 8,
|
||
atked: {
|
||
6301: [{ lv: 1, t_num: 2, overrides: { ap: 5 } }],
|
||
},
|
||
},
|
||
/** 毁灭领主:战斗开始时全队攻击大幅提升 */
|
||
boss_doom: {
|
||
id: "boss_doom", name: "毁灭领主", weight: 6,
|
||
fstart: {
|
||
6401: [{ lv: 1, t_num: 1, overrides: { ap: 8 } }],
|
||
},
|
||
atking: {
|
||
6401: [{ lv: 1, t_num: 5, overrides: { ap: 2 } }],
|
||
},
|
||
},
|
||
};
|
||
|
||
// ======================== 6. 回合配置表 ========================
|
||
|
||
/** 单回合完整配置 */
|
||
export interface WaveConfig {
|
||
/** 基础怪物总数(普通回合 36 为上限,放松回合自动 × 1.5 = 54) */
|
||
base_count: number;
|
||
/** 可选小队 id 池,引擎按权重抽取拼装到 base_count */
|
||
squad_pool: string[];
|
||
/** HP 强化倍率(硬编码,逐回合递进) */
|
||
hp_mul: number;
|
||
/** AP 强化倍率(硬编码,逐回合递进) */
|
||
ap_mul: number;
|
||
/** 强度微调(放水 / 加压,默认 1.0) */
|
||
power_adjust?: number;
|
||
/** 是否 Boss 回合(压力回合必为 true) */
|
||
boss_wave?: boolean;
|
||
/** 普通怪技能池 id(可选,随机挂载) */
|
||
skill_pool?: string[];
|
||
/** Boss 技能池 id(Boss 回合专用,随机挂载) */
|
||
boss_skill_pool?: string[];
|
||
}
|
||
|
||
/**
|
||
* 回合配置表(硬编码 wave 1~20)
|
||
*
|
||
* 心流循环(5 回合一循环):
|
||
* wave % 5 === 0 → 压力回合(必带 Boss,强度高、数量少)
|
||
* wave % 5 === 1 → 放松回合(数量 × 1.5,强度低)
|
||
* 其余 → 普通回合(标准强度)
|
||
*
|
||
* 强度递进:hp_mul / ap_mul 每 5 回合一档,压力回合额外提升。
|
||
*/
|
||
export const WaveConfigs: Record<number, WaveConfig> = {
|
||
// ===== 第一循环:教学期 =====
|
||
1: { base_count: 18, squad_pool: ["melee_grunt"], hp_mul: 1.00, ap_mul: 1.00 },
|
||
2: { base_count: 21, squad_pool: ["melee_grunt", "mixed_balanced"], hp_mul: 1.00, ap_mul: 1.00 },
|
||
3: { base_count: 24, squad_pool: ["melee_grunt", "mixed_balanced", "long_line"], hp_mul: 1.05, ap_mul: 1.00 },
|
||
4: { base_count: 27, squad_pool: ["mixed_balanced", "long_line", "heavy_shield"], hp_mul: 1.10, ap_mul: 1.05 },
|
||
// 压力回合:第一 Boss
|
||
5: { base_count: 21, squad_pool: ["melee_grunt", "mixed_balanced", "heavy_shield"], hp_mul: 1.15, ap_mul: 1.10, boss_wave: true, boss_skill_pool: ["boss_rage"] },
|
||
|
||
// ===== 第二循环:引入技能怪 =====
|
||
// 放松回合:量大好清
|
||
6: { base_count: 30, squad_pool: ["melee_grunt", "mixed_balanced", "long_line"], hp_mul: 1.10, ap_mul: 1.05 },
|
||
7: { base_count: 30, squad_pool: ["melee_grunt", "heavy_shield", "long_line"], hp_mul: 1.15, ap_mul: 1.10, skill_pool: ["tough"] },
|
||
8: { base_count: 33, squad_pool: ["assassin_squad", "mixed_balanced", "summoner_cult"], hp_mul: 1.20, ap_mul: 1.15, skill_pool: ["berserk"] },
|
||
9: { base_count: 33, squad_pool: ["heavy_shield", "long_line", "mixed_balanced"], hp_mul: 1.25, ap_mul: 1.20, skill_pool: ["berserk", "tough"] },
|
||
// 压力回合:第二 Boss
|
||
10: { base_count: 24, squad_pool: ["melee_grunt", "assassin_squad", "mixed_balanced"], hp_mul: 1.30, ap_mul: 1.25, boss_wave: true, boss_skill_pool: ["boss_rage", "boss_iron"] },
|
||
|
||
// ===== 第三循环:组合多样化 =====
|
||
// 放松回合
|
||
11: { base_count: 33, squad_pool: ["assassin_squad", "long_line", "summoner_cult", "mixed_balanced"], hp_mul: 1.25, ap_mul: 1.20, skill_pool: ["leech"] },
|
||
12: { base_count: 33, squad_pool: ["heavy_shield", "melee_grunt", "mixed_balanced", "long_line"], hp_mul: 1.30, ap_mul: 1.25, skill_pool: ["tough", "warcry"] },
|
||
13: { base_count: 36, squad_pool: ["assassin_squad", "summoner_cult", "mixed_balanced"], hp_mul: 1.35, ap_mul: 1.30, skill_pool: ["berserk", "leech"] },
|
||
14: { base_count: 36, squad_pool: ["heavy_shield", "long_line", "melee_grunt"], hp_mul: 1.40, ap_mul: 1.35, skill_pool: ["berserk", "tough", "legacy"] },
|
||
// 压力回合:第三 Boss(中期高潮)
|
||
15: { base_count: 27, squad_pool: ["assassin_squad", "mixed_balanced", "summoner_cult"], hp_mul: 1.50, ap_mul: 1.40, boss_wave: true, boss_skill_pool: ["boss_rage", "boss_iron", "boss_doom"] },
|
||
|
||
// ===== 第四循环:终极阶段 =====
|
||
// 放松回合
|
||
16: { base_count: 36, squad_pool: ["assassin_squad", "heavy_shield", "long_line"], hp_mul: 1.45, ap_mul: 1.40, skill_pool: ["leech", "warcry"] },
|
||
17: { base_count: 36, squad_pool: ["melee_grunt", "summoner_cult", "mixed_balanced"], hp_mul: 1.50, ap_mul: 1.45, skill_pool: ["berserk", "legacy"] },
|
||
18: { base_count: 36, squad_pool: ["assassin_squad", "long_line", "heavy_shield"], hp_mul: 1.55, ap_mul: 1.50, skill_pool: ["berserk", "tough", "leech"] },
|
||
19: { base_count: 36, squad_pool: ["mixed_balanced", "summoner_cult", "melee_grunt"], hp_mul: 1.60, ap_mul: 1.55, skill_pool: ["berserk", "tough", "legacy", "warcry"] },
|
||
// 压力回合:最终 Boss
|
||
20: { base_count: 30, squad_pool: ["assassin_squad", "heavy_shield", "summoner_cult", "mixed_balanced"], hp_mul: 1.80, ap_mul: 1.70, boss_wave: true, boss_skill_pool: ["boss_doom", "boss_rage"] },
|
||
};
|
||
|
||
// ======================== 7. 配置校验 ========================
|
||
|
||
/**
|
||
* 校验 WaveConfigs / SquadLibrary / MonSkillPool 引用一致性
|
||
* 建议在游戏启动时调用一次,便于发现配置错误
|
||
* @returns 错误信息数组,空数组表示校验通过
|
||
*/
|
||
export function validateRogueConfig(): string[] {
|
||
const errors: string[] = [];
|
||
|
||
// 1. 校验 WaveConfigs 引用与范围
|
||
for (const wave in WaveConfigs) {
|
||
const cfg = WaveConfigs[wave];
|
||
for (const squadId of cfg.squad_pool) {
|
||
if (!SquadLibrary[squadId]) {
|
||
errors.push(`Wave ${wave} 引用了不存在的小队: ${squadId}`);
|
||
}
|
||
}
|
||
if (cfg.skill_pool) {
|
||
for (const skillId of cfg.skill_pool) {
|
||
if (!MonSkillPool[skillId]) {
|
||
errors.push(`Wave ${wave} 引用了不存在的技能: ${skillId}`);
|
||
}
|
||
}
|
||
}
|
||
if (cfg.boss_skill_pool) {
|
||
for (const skillId of cfg.boss_skill_pool) {
|
||
if (!BossSkillPool[skillId]) {
|
||
errors.push(`Wave ${wave} 引用了不存在的 Boss 技能: ${skillId}`);
|
||
}
|
||
}
|
||
}
|
||
if (cfg.base_count < 1 || cfg.base_count > MAX_MONSTERS) {
|
||
errors.push(`Wave ${wave} base_count=${cfg.base_count} 越界 (1~${MAX_MONSTERS})`);
|
||
}
|
||
}
|
||
|
||
// 2. 校验 SquadLibrary 中所有 type 在 MonList 中有怪
|
||
for (const id in SquadLibrary) {
|
||
const squad = SquadLibrary[id];
|
||
for (const slot of squad.slots) {
|
||
if (!MonList[slot.type] || MonList[slot.type].length === 0) {
|
||
errors.push(`小队 ${id} 引用的怪物类型 ${slot.type}(${MonTypeName[slot.type] || "?"}) 在 MonList 中无可用 uuid`);
|
||
}
|
||
}
|
||
}
|
||
|
||
return errors;
|
||
}
|
||
|
||
// ======================== 8. 生成结果接口 ========================
|
||
|
||
/** 生成结果(传递给 MissionMonComp 用于实例化怪物) */
|
||
export interface GeneratedMonster {
|
||
uuid: number;
|
||
type: MonType;
|
||
hp: number;
|
||
ap: number;
|
||
isBoss: boolean;
|
||
spawnIndex: number;
|
||
/** 本怪所属批次(0~2,由 MissionMonComp 按 BATCH_INTERVAL 释放) */
|
||
batch: number;
|
||
|
||
/**
|
||
* 怪物技能套装(覆盖全部触发时机)
|
||
* 注入方式与 _testSkills 相同,在 Mon.load() 中写入 HeroAttrsComp
|
||
*/
|
||
skills?: {
|
||
skill?: { s_uuid: number; cd?: number; overrides?: SkillOverrides };
|
||
call?: TriggerGrouped;
|
||
atking?: TriggerGrouped;
|
||
atked?: TriggerGrouped;
|
||
dead?: TriggerGrouped;
|
||
fstart?: TriggerGrouped;
|
||
fend?: TriggerGrouped;
|
||
revive?: LvReviveEntry[];
|
||
};
|
||
}
|
||
|
||
// ======================== 9. 测试模式配置 ========================
|
||
|
||
export const TestModeConfig = {
|
||
enable: false, // 默认关闭测试模式
|
||
baseHp: 150,
|
||
baseAp: 12,
|
||
growthRatePerWave: 0.2,
|
||
monType: MonType.Melee,
|
||
monUuid: 6001,
|
||
spawnCount: 1,
|
||
|
||
skill: undefined as { s_uuid: number; cd?: number; overrides?: SkillOverrides } | undefined,
|
||
atking: undefined as TriggerGrouped | undefined,
|
||
atked: undefined as TriggerGrouped | undefined,
|
||
dead: undefined as TriggerGrouped | undefined,
|
||
fstart: undefined as TriggerGrouped | undefined,
|
||
fend: undefined as TriggerGrouped | undefined,
|
||
};
|
||
|
||
// ======================== 10. 生成引擎 ========================
|
||
|
||
/**
|
||
* 肉鸽刷怪生成引擎
|
||
* 按英雄强度反推怪物强度,结合回合类型系数与动态调节器生成怪物列表
|
||
*/
|
||
export class RogueSpawningEngine {
|
||
|
||
/**
|
||
* 生成指定回合的怪物列表
|
||
* @param waveNumber 回合(1 起,>MAX_WAVE 时复用 wave MAX_WAVE 配置)
|
||
* @returns 怪物列表,长度 ≤ MAX_MONSTERS
|
||
*/
|
||
generateWave(waveNumber: number): GeneratedMonster[] {
|
||
if (waveNumber < 1) return [];
|
||
|
||
// 测试模式拦截:完全绕过生成引擎,直接返回硬编码测试怪
|
||
if (TestModeConfig.enable) {
|
||
return this.generateTestWave(waveNumber);
|
||
}
|
||
|
||
const wave = Math.min(waveNumber, MAX_WAVE);
|
||
const cfg = WaveConfigs[wave];
|
||
const waveType = getWaveType(wave);
|
||
const typeRatio = WAVE_TYPE_POWER_RATIO[waveType];
|
||
|
||
// 1. 计算目标强度
|
||
const heroPower = this.getCurrentHeroPower();
|
||
const powerAdjust = cfg.power_adjust ?? 1.0;
|
||
const targetPower = heroPower * typeRatio * powerAdjust * DynamicTuner.factor;
|
||
|
||
// 2. 确定怪物总数(放松回合 × 1.5)
|
||
let totalCount = cfg.base_count;
|
||
if (waveType === WaveType.Relax) {
|
||
totalCount = Math.round(totalCount * RELAX_COUNT_MUL);
|
||
}
|
||
totalCount = Math.min(totalCount, MAX_MONSTERS);
|
||
|
||
// 3. Boss 位(压力回合必带 Boss,占 1 个名额)
|
||
const monsters: GeneratedMonster[] = [];
|
||
let remaining = totalCount;
|
||
if (cfg.boss_wave) {
|
||
monsters.push(this.makeBoss(wave, cfg));
|
||
remaining -= 1;
|
||
}
|
||
|
||
// 4. 小队拼装:按权重从 squad_pool 抽小队,累计到 remaining
|
||
const squadMonsters = this.assembleSquads(cfg.squad_pool, remaining, wave);
|
||
monsters.push(...squadMonsters);
|
||
|
||
// 5. 应用硬编码 HP/AP 倍率
|
||
for (const m of monsters) {
|
||
m.hp = Math.max(1, Math.round(m.hp * cfg.hp_mul));
|
||
m.ap = Math.max(1, Math.round(m.ap * cfg.ap_mul));
|
||
}
|
||
|
||
// 6. 按英雄强度反推缩放系数
|
||
const totalBasePower = monsters.reduce((sum, m) => {
|
||
const info = HeroInfo[m.uuid];
|
||
return sum + (info ? calcHeroPower(info, 1) : m.hp + m.ap);
|
||
}, 0);
|
||
|
||
if (totalBasePower > 0 && targetPower > 0) {
|
||
const scale = targetPower / totalBasePower;
|
||
for (const m of monsters) {
|
||
m.hp = Math.max(1, Math.round(m.hp * scale));
|
||
m.ap = Math.max(1, Math.round(m.ap * scale));
|
||
}
|
||
}
|
||
|
||
// 7. 挂载技能(普通怪随机技能池,Boss 专属技能池)
|
||
for (const m of monsters) {
|
||
if (m.isBoss && cfg.boss_skill_pool) {
|
||
m.skills = this.pickSkillSet(cfg.boss_skill_pool, BossSkillPool);
|
||
} else if (!m.isBoss && cfg.skill_pool) {
|
||
m.skills = this.pickSkillSet(cfg.skill_pool, MonSkillPool);
|
||
}
|
||
}
|
||
|
||
// 8. 分配批次(0~2,均匀分布)
|
||
for (let i = 0; i < monsters.length; i++) {
|
||
monsters[i].batch = i % BATCH_COUNT;
|
||
monsters[i].spawnIndex = i;
|
||
}
|
||
|
||
// 9. 硬上限保护
|
||
return monsters.slice(0, MAX_MONSTERS);
|
||
}
|
||
|
||
/** 重置引擎状态(每局开始时调用) */
|
||
reset(): void {
|
||
DynamicTuner.reset();
|
||
}
|
||
|
||
/**
|
||
* 获取指定回合的怪物总数
|
||
* @param waveNumber 目标回合数
|
||
* @returns 预计生成的怪物总数
|
||
*/
|
||
getWaveMonsterCount(waveNumber: number): number {
|
||
if (waveNumber < 1) return 0;
|
||
if (TestModeConfig.enable) {
|
||
return Math.max(1, TestModeConfig.spawnCount || 1);
|
||
}
|
||
const wave = Math.min(waveNumber, MAX_WAVE);
|
||
const cfg = WaveConfigs[wave];
|
||
const waveType = getWaveType(wave);
|
||
let count = cfg.base_count;
|
||
if (waveType === WaveType.Relax) {
|
||
count = Math.round(count * RELAX_COUNT_MUL);
|
||
}
|
||
return Math.min(count, MAX_MONSTERS);
|
||
}
|
||
|
||
// ======================== 私有生成子算法 ========================
|
||
|
||
/**
|
||
* 计算场上存活英雄的总强度
|
||
* 通过 ECS 查询所有 HeroAttrsComp,累加 calcHeroPower
|
||
*/
|
||
private getCurrentHeroPower(): number {
|
||
let total = 0;
|
||
ecs.query(ecs.allOf(HeroAttrsComp)).forEach((entity: ecs.Entity) => {
|
||
const attrs = entity.get(HeroAttrsComp);
|
||
if (!attrs || attrs.is_dead || attrs.fac !== FacSet.HERO) return;
|
||
const info = HeroInfo[attrs.hero_uuid];
|
||
if (!info) return;
|
||
// 使用英雄当前等级(升级卡驱动),保证强度评估与实战一致
|
||
const lv = Math.max(1, attrs.lv || 1);
|
||
total += calcHeroPower(info, lv);
|
||
});
|
||
// 兜底:无英雄时返回基准强度,避免除零
|
||
return Math.max(total, 100);
|
||
}
|
||
|
||
/** 测试模式:完全绕过引擎逻辑 */
|
||
private generateTestWave(waveNumber: number): GeneratedMonster[] {
|
||
const growth = 1 + (waveNumber - 1) * TestModeConfig.growthRatePerWave;
|
||
const count = Math.max(1, TestModeConfig.spawnCount || 1);
|
||
const monsters: GeneratedMonster[] = [];
|
||
|
||
for (let i = 0; i < count; i++) {
|
||
monsters.push({
|
||
uuid: TestModeConfig.monUuid,
|
||
type: TestModeConfig.monType,
|
||
hp: Math.round(TestModeConfig.baseHp * growth),
|
||
ap: Math.round(TestModeConfig.baseAp * growth),
|
||
isBoss: false,
|
||
spawnIndex: i,
|
||
batch: i % BATCH_COUNT,
|
||
skills: {
|
||
skill: TestModeConfig.skill,
|
||
atking: TestModeConfig.atking,
|
||
atked: TestModeConfig.atked,
|
||
dead: TestModeConfig.dead,
|
||
fstart: TestModeConfig.fstart,
|
||
fend: TestModeConfig.fend,
|
||
},
|
||
});
|
||
}
|
||
return monsters;
|
||
}
|
||
|
||
/**
|
||
* 小队拼装:按权重从 squad_pool 抽小队,累计到 target 数量
|
||
* 超出部分按剩余位截断
|
||
*/
|
||
private assembleSquads(pool: string[], target: number, wave: number): GeneratedMonster[] {
|
||
const result: GeneratedMonster[] = [];
|
||
let filled = 0;
|
||
let safety = 20; // 防御性循环上限
|
||
|
||
while (filled < target && safety-- > 0) {
|
||
const squad = this.pickWeightedSquad(pool);
|
||
if (!squad) break;
|
||
|
||
const remaining = target - filled;
|
||
for (const slot of squad.slots) {
|
||
for (let i = 0; i < slot.count && filled < remaining; i++) {
|
||
result.push(this.makeMonster(slot.type, wave, filled));
|
||
filled++;
|
||
}
|
||
if (filled >= remaining) break;
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/** 生成 Boss(首位) */
|
||
private makeBoss(wave: number, cfg: WaveConfig): GeneratedMonster {
|
||
const isMeleeBoss = Math.random() < 0.5;
|
||
const type = isMeleeBoss ? MonType.MeleeBoss : MonType.LongBoss;
|
||
|
||
// Boss 类型兜底:若 MonList 缺失则退回 MeleeBoss
|
||
let uuids = MonList[type];
|
||
if (!uuids || uuids.length === 0) {
|
||
uuids = MonList[MonType.MeleeBoss] || MonList[MonType.Melee] || [6001];
|
||
}
|
||
const uuid = uuids[Math.floor(Math.random() * uuids.length)];
|
||
|
||
const baseInfo = HeroInfo[uuid];
|
||
const baseHp = baseInfo ? baseInfo.hp : 500;
|
||
const baseAp = baseInfo ? baseInfo.ap : 30;
|
||
|
||
// Boss 自带额外血量加成:每 5 回合递增 20%(wave 5=1.0, 10=1.2, 15=1.4...)
|
||
const bossBonusHpMul = 1 + 0.20 * Math.floor(Math.max(0, wave - 5) / 5);
|
||
|
||
return {
|
||
uuid,
|
||
type,
|
||
hp: Math.round(baseHp * bossBonusHpMul),
|
||
ap: baseAp,
|
||
isBoss: true,
|
||
spawnIndex: 0,
|
||
batch: 0, // Boss 固定第一批
|
||
};
|
||
}
|
||
|
||
/** 生成普通怪物 */
|
||
private makeMonster(type: MonType, wave: number, spawnIndex: number): GeneratedMonster {
|
||
let uuids = MonList[type];
|
||
if (!uuids || uuids.length === 0) {
|
||
// 兜底 Melee
|
||
uuids = MonList[MonType.Melee] || [6001];
|
||
}
|
||
const uuid = uuids[Math.floor(Math.random() * uuids.length)];
|
||
|
||
const baseInfo = HeroInfo[uuid];
|
||
const baseHp = baseInfo ? baseInfo.hp : 100;
|
||
const baseAp = baseInfo ? baseInfo.ap : 10;
|
||
|
||
return {
|
||
uuid,
|
||
type,
|
||
hp: baseHp,
|
||
ap: baseAp,
|
||
isBoss: false,
|
||
spawnIndex,
|
||
batch: 0, // 后续统一分配
|
||
};
|
||
}
|
||
|
||
/** 从技能池中按权重抽取一套技能 */
|
||
private pickSkillSet(pool: string[], library: Record<string, MonSkillSet>): GeneratedMonster["skills"] | undefined {
|
||
const valid = pool.map(id => library[id]).filter(s => s);
|
||
if (valid.length === 0) return undefined;
|
||
const picked = this.pickWeighted(valid);
|
||
if (!picked) return undefined;
|
||
return {
|
||
skill: picked.skill,
|
||
call: picked.call,
|
||
atking: picked.atking,
|
||
atked: picked.atked,
|
||
dead: picked.dead,
|
||
fstart: picked.fstart,
|
||
fend: picked.fend,
|
||
revive: picked.revive,
|
||
};
|
||
}
|
||
|
||
/** 按权重从小队 id 池抽 1 个小队 */
|
||
private pickWeightedSquad(pool: string[]): SquadConfig | null {
|
||
const valid = pool.map(id => SquadLibrary[id]).filter(s => s);
|
||
if (valid.length === 0) return null;
|
||
return this.pickWeighted(valid);
|
||
}
|
||
|
||
/** 通用加权随机抽取 */
|
||
private pickWeighted<T extends { weight: number }>(items: T[]): T | null {
|
||
if (items.length === 0) return null;
|
||
const total = items.reduce((s, it) => s + it.weight, 0);
|
||
if (total <= 0) return items[Math.floor(Math.random() * items.length)];
|
||
let r = Math.random() * total;
|
||
for (const it of items) {
|
||
r -= it.weight;
|
||
if (r <= 0) return it;
|
||
}
|
||
return items[items.length - 1];
|
||
}
|
||
}
|
||
|
||
// ======================== 11. 全局单例 & 向后兼容导出 ========================
|
||
|
||
export const spawningEngine = new RogueSpawningEngine();
|
||
|
||
/** 历史接口:回合槽位(保留兼容) */
|
||
export interface IWaveSlot {
|
||
type: number;
|
||
count: number;
|
||
}
|
||
|
||
/**
|
||
* 获取指定回合预计的怪物总数量
|
||
* @param waveNumber 目标回合数
|
||
*/
|
||
export function getWaveMonsterCount(waveNumber: number): number {
|
||
return spawningEngine.getWaveMonsterCount(waveNumber);
|
||
}
|
||
|
||
/** 获取回合槽位配置(向后兼容,从 generateWave 反推) */
|
||
export function getWaveSlotConfig(waveNumber: number): IWaveSlot[] {
|
||
const generated = spawningEngine.generateWave(waveNumber);
|
||
const slotMap = new Map<number, number>();
|
||
|
||
for (const m of generated) {
|
||
slotMap.set(m.type, (slotMap.get(m.type) || 0) + 1);
|
||
}
|
||
|
||
return Array.from(slotMap.entries()).map(([type, count]) => ({ type, count }));
|
||
}
|
||
|
||
export const DefaultWaveSlot: IWaveSlot[] = [
|
||
{ type: MonType.Melee, count: 20 },
|
||
{ type: MonType.Long, count: 15 },
|
||
{ type: MonType.Support, count: 5 },
|
||
];
|
||
|
||
/** 回合槽位配置代理(按需生成) */
|
||
export const WaveSlotConfig: { [wave: number]: IWaveSlot[] } = new Proxy(
|
||
{} as { [wave: number]: IWaveSlot[] },
|
||
{
|
||
get(_target, prop: string) {
|
||
const wave = parseInt(prop, 10);
|
||
if (!isNaN(wave) && wave >= 1) {
|
||
return getWaveSlotConfig(wave);
|
||
}
|
||
if (prop === "toJSON") return () => ({});
|
||
return undefined;
|
||
},
|
||
has(_target, prop: string) {
|
||
const wave = parseInt(prop, 10);
|
||
return !isNaN(wave) && wave >= 1;
|
||
},
|
||
}
|
||
);
|