Files
pixelheros/assets/script/game/map/RogueConfig.ts
pan 409113e269 feat: 完成肉鸽回合制游戏核心玩法迭代
新增连杀系统、金币飞行特效、波次HUD、屏幕震动等表现功能,重构怪物刷出逻辑与难度动态调节,调整回合回血比例,优化游戏节奏与体验。

主要变更:
1.  调整回合回血比例从0.5到0.4,优化前期节奏
2.  新增连杀计数与奖励系统,支持5/10/20连杀触发对应表现
3.  实现怪物死亡掉落金币的抛物线飞行特效
4.  增加波次进度HUD,显示剩余怪物与全局回合进度
5.  新增屏幕震动工具与战斗横幅统一展示系统
6.  重构怪物刷出逻辑,支持按回合类型调整刷怪间隔,加入清场加速机制
7.  优化动态难度调节算法,增加滞回与指数平滑,避免难度突变
8.  新增Boss预警、登场事件与回合清屏事件,完善事件总线
9.  调整怪物数量阈值与回合倒计时档位,适配新的节奏设计
2026-08-11 19:00:23 +08:00

953 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file RogueConfig.ts
* @description 肉鸽刷怪系统(基于英雄强度的动态难度 + 心流循环)
*
* 设计层次:
* 1. WaveType - 回合类型(普通 / 压力 / 放松5 回合一个心流循环
* 2. WaveConfig - 每回合硬编码(数量 + 小队池 + HP/AP 倍率 + 强度微调)
* 3. DynamicTuner - 动态难度调节器(系统根据战况放水 / 加压)
* 4. MonSkillSet - 怪物技能池atking / atked / dead 等全触发类型)
* 5. RogueSpawningEngine - 生成引擎(按英雄强度反推怪物强度)
*
* 核心公式(最终强度 = heroPower × typeRatio × power_adjust × DDA × hp/ap_mul
* heroPower = Σ calcHeroPower(HeroInfo[uuid], lv) (场上存活英雄)
* targetPower = heroPower × 回合类型系数 × wave.power_adjust × DynamicTuner.factor
* hpScale = targetPower × wave.hp_mul ÷ Σ 怪物基础强度
* apScale = targetPower × wave.ap_mul ÷ Σ 怪物基础强度
* 每只怪: hp ×= hpScale, ap ×= apScale
*
* 回合节奏:
* - 最大 20 回合,第 20 回合通关
* - 每回合 30 秒,固定分 3 批,每 10 秒释放一批
* - 普通回合 18~36 只,放松回合 × 1.5 = 27~54 只
* - wave % 5 === 0 → 压力回合(必带 Boss强度高、数量少
* - wave % 5 === 4 → 放松回合(数量 × 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 === 4量大强度低大战前夜的收割补给
}
/** 回合类型名称 */
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;
/** Boss 护卫队数量(与 Boss 同批压轴进场,占用回合总名额) */
export const BOSS_GUARD_COUNT = 3;
/** 批次怪物数量占比(铺垫 → 加压 → 高潮,第三批另有收尾小队加压) */
export const BATCH_RATIO: number[] = [0.25, 0.35, 0.40];
/** 收尾高潮批额外补入的最强小队数量(非放松回合生效) */
export const FINALE_SQUAD_COUNT = 2;
/** 按回合类型的逐个刷怪间隔(秒):放松回合快速倾泻造潮水感,压力回合稍慢便于聚焦 */
export const SPAWN_INTERVAL_BY_TYPE: Record<WaveType, number> = {
[WaveType.Normal]: 0.18,
[WaveType.Pressure]: 0.25,
[WaveType.Relax]: 0.12,
};
/** 回合战斗超时超过后强制结束回合残留怪销毁并扣留存分DynamicTuner 因 clearTime 过大自动放水) */
export const WAVE_TIMEOUT = 75;
/** Boss 回合超时Boss 压轴进场(第 20 秒),击杀耗时更长,放宽兜底阈值 */
export const WAVE_TIMEOUT_BOSS = 90;
/**
* 获取指定回合的回合类型
* @param wave 回合1 起)
* @returns WaveType
*/
export function getWaveType(wave: number): WaveType {
if (wave % 5 === 0) return WaveType.Pressure;
if (wave % 5 === 4) return WaveType.Relax; // 大战前夜的收割补给:清杂攒金币备战 Boss
return WaveType.Normal;
}
// ======================== 2. 动态难度调节器 ========================
/**
* 动态难度调节器(系统控制放水 / 加压)
* 与硬编码系数并存,用于根据战况实时微调难度。
*
* 用法示例MissionComp 每回合结束时调用):
* DynamicTuner.adjust(effectiveClearTime, heroDeathCount);
*
* 设计原则(真隐形 DDA
* - 连续映射desired = 1 + (0.8 - clearTime/WAVE_DURATION) × K清场越快要价越高非离散跳变
* - 滞回:连续同方向判定满 HYSTERESIS 回合才生效,偶发超神/崩盘不立即拉阀门
* - 指数靠拢:每回合向 desired 移动 50%,避免突变被玩家察觉
* - 总幅度钳制 [0.7, 1.3]±30%),防止橡皮筋效应
* - 英雄死亡直接锚定 desired=0.8(温和放水),不与慢清场放水叠加
*/
export const DynamicTuner = {
/** 当前难度系数(默认 1.0>1 加压,<1 放水) */
factor: 1.0,
/** 系数下限(最多放水到 70% */
MIN_FACTOR: 0.7,
/** 系数上限(最多加压到 130% */
MAX_FACTOR: 1.3,
/** 连续映射增益clearTime 每偏离基准 100% 时长factor 偏移 K */
K: 0.5,
/** 滞回:连续同方向判定满 N 回合才生效 */
HYSTERESIS: 2,
/** 连续方向计数(>0 加压倾向,<0 放水倾向) */
streak: 0,
/**
* 根据上一回合战况自动调节难度
* @param clearTime 清场耗时(秒,已含清场加速提前量的还原口径)
* @param heroDeathCount 英雄死亡数
* @returns 本回合是否实际调整了 factor
*/
adjust(clearTime: number, heroDeathCount: number): boolean {
// 1) 连续映射期望系数:基准 0.8×时长不动,更快加压、更慢放水;死亡锚定 0.8
let desired: number;
if (heroDeathCount > 0) {
desired = 1 + (0.8 - 1.2) * this.K; // = 0.8
} else {
desired = 1 + (0.8 - clearTime / WAVE_DURATION) * this.K;
}
desired = Math.min(this.MAX_FACTOR, Math.max(this.MIN_FACTOR, desired));
// 2) 滞回:连续同方向满 HYSTERESIS 回合才向 desired 靠拢
const dir = Math.sign(desired - 1);
if (dir === 0) {
this.streak = 0;
return false;
}
this.streak = (Math.sign(this.streak) === dir) ? this.streak + dir : dir;
if (Math.abs(this.streak) < this.HYSTERESIS) return false;
// 3) 指数靠拢:每回合向 desired 移动 50%,避免跳变
const old = this.factor;
this.factor = old + (desired - old) * 0.5;
this.factor = Math.min(this.MAX_FACTOR, Math.max(this.MIN_FACTOR, this.factor));
return this.factor !== old;
},
/** 重置调节器(每局开始时调用) */
reset(): void {
this.factor = 1.0;
this.streak = 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 }] },
/** Boss 护卫队weight=0 不进随机池,仅供引擎在 Boss 回合直接引用,与 Boss 同批压轴进场) */
boss_guard: { id: "boss_guard", name: "Boss 护卫队", weight: 0, slots: [{ type: MonType.Heavy, count: 1 }, { type: MonType.Melee, 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 成长乘区(并入强度缩放分子,终值 = heroPower × 系数 × hp_mul ÷ Σ基础强度) */
hp_mul: number;
/** AP 成长乘区(同 hp_mul独立控制怪物肉度与输出的成长比例 */
ap_mul: number;
/** 强度微调(放水 / 加压,默认 1.0 */
power_adjust?: number;
/** 是否 Boss 回合(压力回合必为 true */
boss_wave?: boolean;
/** 普通怪技能池 id可选随机挂载 */
skill_pool?: string[];
/** Boss 技能池 idBoss 回合专用,随机挂载) */
boss_skill_pool?: string[];
}
/**
* 回合配置表(硬编码 wave 1~20
*
* 心流循环5 回合一循环):
* wave % 5 === 0 → 压力回合(必带 Boss强度高、数量少
* wave % 5 === 4 → 放松回合(数量 × 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 },
// 放松回合量大好清Boss 前收割补给
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"] },
// 放松回合量大好清Boss 前收割补给
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"] },
// 放松回合量大好清Boss 前收割补给
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"] },
// ===== 第四循环终极阶段17~19 power_adjust 逐步爬坡,为最终 Boss 蓄势) =====
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"], power_adjust: 1.05 },
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"], power_adjust: 1.10 },
// 放松回合:量大好清,最终 Boss 前收割补给
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"], power_adjust: 1.20 },
// 压力回合:最终 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})`);
}
if (cfg.power_adjust !== undefined && (cfg.power_adjust < 0.8 || cfg.power_adjust > 1.3)) {
errors.push(`Wave ${wave} power_adjust=${cfg.power_adjust} 越界 (0.8~1.3)`);
}
}
// 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;
/** 是否为 Boss 护卫队成员(与 Boss 同批压轴进场,供 UI/统计识别) */
isBossGuard?: 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;普通/压力回合预留收尾高潮批名额,防 slice 截掉)
let totalCount = cfg.base_count;
if (waveType === WaveType.Relax) {
totalCount = Math.round(totalCount * RELAX_COUNT_MUL);
} else {
totalCount += this.estimateFinaleCount(cfg.squad_pool);
}
totalCount = Math.min(totalCount, MAX_MONSTERS);
// 3. Boss 位(压力回合必带 Boss先记录延后挂载使其压轴进场而非第 0 秒开场
let boss: GeneratedMonster | null = null;
let remaining = totalCount;
if (cfg.boss_wave) {
boss = this.makeBoss(wave, cfg);
remaining -= 1 + BOSS_GUARD_COUNT; // Boss 1 只 + 护卫队名额
}
// 4. 小队拼装:按权重从 squad_pool 抽小队,累计到 remaining
const monsters: GeneratedMonster[] = this.assembleSquads(cfg.squad_pool, remaining, wave);
// 4.5 收尾高潮批:非放松回合额外补入最强小队,与 Boss 一样压轴(先补入再统一缩放,保证强度自洽)
const finaleSquad = waveType !== WaveType.Relax ? this.pickStrongestSquad(cfg.squad_pool) : null;
if (finaleSquad) {
for (let s = 0; s < FINALE_SQUAD_COUNT; s++) {
for (const slot of finaleSquad.slots) {
for (let c = 0; c < slot.count; c++) {
const m = this.makeMonster(slot.type, wave, 0);
m.batch = BATCH_COUNT - 1; // 标记收尾批,第 8 步不再覆盖
monsters.push(m);
}
}
}
}
// 4.6 Boss 与护卫队压队尾,使其在批次分配后落入最后一批(回合内高潮点)
if (boss) {
monsters.push(...this.makeBossGuards(wave));
monsters.push(boss);
}
// 5. 按英雄强度反推缩放系数hp_mul/ap_mul 并入目标强度乘区,而非预先乘到怪物上,
// 保证配置表语义单一:最终强度 = heroPower × typeRatio × power_adjust × DDA × hp/ap_mul
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 hpScale = (targetPower * cfg.hp_mul) / totalBasePower;
const apScale = (targetPower * cfg.ap_mul) / totalBasePower;
for (const m of monsters) {
m.hp = Math.max(1, Math.round(m.hp * hpScale));
m.ap = Math.max(1, Math.round(m.ap * apScale));
}
}
// 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. 分配批次:按 BATCH_RATIO 递增加权(铺垫 → 加压 → 高潮),收尾小队/Boss/护卫保持最后一批
this.assignBatches(monsters);
if (boss) {
const lastBatch = BATCH_COUNT - 1;
for (const m of monsters) {
if (m.isBoss || m.isBossGuard) m.batch = lastBatch;
}
}
for (let i = 0; i < monsters.length; i++) {
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压轴位batch/spawnIndex 为占位值,由 generateWave 统一分配并强制最后一批) */
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, // 占位值,由 generateWave 第 8 步统一分配
batch: 0, // 占位值generateWave 会强制 Boss 进入最后一批
};
}
/** 生成 Boss 护卫队(复用 boss_guard 小队模板),与 Boss 同批压轴进场 */
private makeBossGuards(wave: number): GeneratedMonster[] {
const squad = SquadLibrary["boss_guard"];
const guards: GeneratedMonster[] = [];
for (const slot of squad.slots) {
for (let i = 0; i < slot.count; i++) {
const g = this.makeMonster(slot.type, wave, 0);
g.isBossGuard = true;
guards.push(g);
}
}
return guards;
}
/**
* 批次分配:按 BATCH_RATIO 递增加权切分(铺垫 → 加压 → 高潮)。
* 已预标记 batch 的怪(收尾小队 / Boss / 护卫)不参与切分,保持最后一批。
*/
private assignBatches(monsters: GeneratedMonster[]): void {
const normal = monsters.filter(m => m.batch !== BATCH_COUNT - 1 && !m.isBoss && !m.isBossGuard);
const n = normal.length;
if (n === 0) return;
let cursor = 0;
for (let b = 0; b < BATCH_COUNT - 1; b++) {
const quota = Math.round(n * BATCH_RATIO[b]);
for (let k = 0; k < quota && cursor < n; k++, cursor++) {
normal[cursor].batch = b;
}
}
// 剩余全部进入高潮批
for (; cursor < n; cursor++) {
normal[cursor].batch = BATCH_COUNT - 1;
}
}
/** 预估收尾高潮批额外补入的怪物数量(用于 totalCount 预留名额) */
private estimateFinaleCount(pool: string[]): number {
const squad = this.pickStrongestSquad(pool);
if (!squad) return 0;
let per = 0;
for (const slot of squad.slots) per += slot.count;
return per * FINALE_SQUAD_COUNT;
}
/**
* 识别小队池中最强小队:按槽位 MonType 基础强度calcHeroPower 1 级样本)× 数量加权求和。
* 注意不能用 squad.weight——它是"出现频率"语义而非强度。
*/
private pickStrongestSquad(pool: string[]): SquadConfig | null {
let best: SquadConfig | null = null;
let bestScore = -1;
for (const id of pool) {
const sq = SquadLibrary[id];
if (!sq) continue;
let score = 0;
for (const slot of sq.slots) {
const uuids = MonList[slot.type];
const sample = uuids && uuids.length ? HeroInfo[uuids[0]] : null;
score += (sample ? calcHeroPower(sample, 1) : 100) * slot.count;
}
if (score > bestScore) {
bestScore = score;
best = sq;
}
}
return best;
}
/** 生成普通怪物wave 保留参数位,供后续按回合差异化基础属性扩展) */
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;
},
}
);