516 lines
18 KiB
TypeScript
516 lines
18 KiB
TypeScript
/**
|
||
* @file RogueConfig.ts
|
||
* @description 关卡刷怪系统(章节-关-节三级推进)
|
||
*
|
||
* 游戏机制:
|
||
* - 玩家线性推进 1 章 10 关 × 每关 10 小节,进度精确到小节并持久化。
|
||
* - 普通关(1~4, 6~9):9 只小怪,前 3 只立即刷,后续每 2s 刷 1 只。
|
||
* - 精英关(5):3 只精英同时刷出。
|
||
* - Boss 关(10):2 精英先刷,Boss 延迟 1s 刷出。
|
||
* - 怪物属性 = 基础 × 0.8 × (1 + 0.05×(lv-1)) × 类型倍率(精英1.5 / Boss3.0)。
|
||
*
|
||
* 设计层次:
|
||
* 1. StageSet - 关卡配置(章节/关/节 + 数值公式)
|
||
* 2. MonSkillPool/BossSkillPool - 怪物技能池(atking / atked / dead 等全触发类型)
|
||
* 3. StageSpawningEngine - 生成引擎(按小节配置生成怪物列表)
|
||
*
|
||
* 核心公式:
|
||
* 最终属性 = HeroInfo 基础属性 × MON_START_COEF × (1 + MON_GROW_SLOPE×(lv-1)) × 类型倍率
|
||
* 1 级小怪 ≈ 英雄 × 0.8,10 级小怪 ≈ 英雄 × 1.16,保持平缓成长。
|
||
*/
|
||
|
||
import { HeroInfo, MonType, MonTypeName, TriggerGrouped } from "../common/config/heroSet";
|
||
import { SkillOverrides } from "../common/config/SkillSet";
|
||
import { FacSet } from "../common/config/GameSet";
|
||
import {
|
||
SectionType, SectionConfig, getSectionConfig, calcMonsterAttrs,
|
||
NORMAL_MON_COUNT, NORMAL_FIRST_SPAWN, ELITE_COUNT, BOSS_ELITE_COUNT, BOSS_DELAY,
|
||
MON_START_COEF, MON_GROW_SLOPE, ELITE_MUL, BOSS_MUL,
|
||
} from "../common/config/StageSet";
|
||
|
||
// ======================== 1. 战斗常量 ========================
|
||
|
||
/** 单场战斗时长基准(秒):供清屏速度评价(BattleBannerComp 清屏奖励)等使用 */
|
||
export const WAVE_DURATION = 30;
|
||
|
||
/** 战斗超时(秒):超过后强制结算整局(残留怪销毁并扣留存分) */
|
||
export const BATTLE_TIMEOUT = 90;
|
||
|
||
// ======================== 2. 怪物 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);
|
||
}
|
||
}
|
||
|
||
// ======================== 2.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));
|
||
}
|
||
|
||
// ======================== 3. 怪物技能池 ========================
|
||
|
||
/**
|
||
* 怪物技能套装(一套完整的触发技能配置)
|
||
* 覆盖触发时机:call / atking / atked / dead / fstart / 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;
|
||
/** 复活触发 */
|
||
revive?: TriggerGrouped;
|
||
/** 在技能池中的抽取权重 */
|
||
weight: number;
|
||
}
|
||
|
||
/**
|
||
* 怪物技能池(硬编码)
|
||
* 技能 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 专属技能池 */
|
||
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 } }],
|
||
},
|
||
},
|
||
};
|
||
|
||
// ======================== 4. 配置校验 ========================
|
||
|
||
/**
|
||
* 校验 MonSkillPool / BossSkillPool 引用一致性
|
||
* 建议在游戏启动时调用一次,便于发现配置错误
|
||
* @returns 错误信息数组,空数组表示校验通过
|
||
*/
|
||
export function validateRogueConfig(): string[] {
|
||
const errors: string[] = [];
|
||
|
||
// 校验 Boss 类型在 MonList 中有怪
|
||
if ((!MonList[MonType.MeleeBoss] || MonList[MonType.MeleeBoss].length === 0)
|
||
&& (!MonList[MonType.LongBoss] || MonList[MonType.LongBoss].length === 0)) {
|
||
errors.push("MonList 中无任何 Boss 类型(MeleeBoss/LongBoss)可用 uuid");
|
||
}
|
||
|
||
return errors;
|
||
}
|
||
|
||
// ======================== 5. 生成结果接口 ========================
|
||
|
||
/** 生成结果(传递给 MissionMonComp 用于实例化怪物) */
|
||
export interface GeneratedMonster {
|
||
uuid: number;
|
||
type: MonType;
|
||
hp: number;
|
||
ap: number;
|
||
isBoss: boolean;
|
||
/** 是否为精英(精英关/Boss 关的精英) */
|
||
isElite?: boolean;
|
||
/** 是否为 Boss 护卫队成员(供 UI/统计识别) */
|
||
isBossGuard?: boolean;
|
||
/** 怪物等级(驱动技能等级增量,Mon.load 第 6 参) */
|
||
lv: number;
|
||
spawnIndex: number;
|
||
/** 本怪所属批次(0=首批立即刷,1=后续定时刷) */
|
||
batch: number;
|
||
/** 延迟刷出时间(秒),0 表示立即 */
|
||
delay?: number;
|
||
|
||
/**
|
||
* 怪物技能套装(覆盖全部触发时机)
|
||
* 注入方式与 _testSkills 相同,在 Mon.load() 中写入 HeroAttrsComp
|
||
*/
|
||
skills?: {
|
||
skill?: { s_uuid: number; cd?: number; overrides?: SkillOverrides };
|
||
call?: TriggerGrouped;
|
||
atking?: TriggerGrouped;
|
||
atked?: TriggerGrouped;
|
||
dead?: TriggerGrouped;
|
||
fstart?: TriggerGrouped;
|
||
revive?: TriggerGrouped;
|
||
};
|
||
}
|
||
|
||
// ======================== 6. 测试模式配置 ========================
|
||
|
||
export const TestModeConfig = {
|
||
enable: false, // 默认关闭测试模式
|
||
baseHp: 150,
|
||
baseAp: 12,
|
||
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,
|
||
};
|
||
|
||
// ======================== 7. 生成引擎 ========================
|
||
|
||
/**
|
||
* 关卡刷怪生成引擎
|
||
* 按章节-关-节配置生成怪物列表,属性走 StageSet 数值公式
|
||
*/
|
||
export class StageSpawningEngine {
|
||
|
||
/**
|
||
* 生成指定小节的怪物列表
|
||
* @param chapter 章节号
|
||
* @param stage 关卡号
|
||
* @param section 小节号(1~10)
|
||
* @returns 怪物列表(按刷出顺序排列)
|
||
*/
|
||
generateSectionMonsters(chapter: number, stage: number, section: number): GeneratedMonster[] {
|
||
// 测试模式拦截:完全绕过生成引擎,直接返回硬编码测试怪
|
||
if (TestModeConfig.enable) {
|
||
return this.generateTestWave();
|
||
}
|
||
|
||
const cfg = getSectionConfig(chapter, stage, section);
|
||
if (!cfg) {
|
||
// 配置缺失兜底:生成 3 只 Lv1 近战小怪
|
||
return this.makeFallbackMonsters();
|
||
}
|
||
|
||
switch (cfg.type) {
|
||
case SectionType.Elite:
|
||
return this.generateEliteSection(cfg);
|
||
case SectionType.Boss:
|
||
return this.generateBossSection(cfg);
|
||
case SectionType.Normal:
|
||
default:
|
||
return this.generateNormalSection(cfg);
|
||
}
|
||
}
|
||
|
||
/** 重置引擎状态(每局开始时调用,预留) */
|
||
reset(): void {
|
||
}
|
||
|
||
/**
|
||
* 普通关:9 只小怪
|
||
* 前 3 只 batch=0(立即刷),后 6 只 batch=1(定时刷)
|
||
*/
|
||
private generateNormalSection(cfg: SectionConfig): GeneratedMonster[] {
|
||
const monsters: GeneratedMonster[] = [];
|
||
const types = cfg.monTypes ?? [MonType.Melee];
|
||
|
||
for (let i = 0; i < NORMAL_MON_COUNT; i++) {
|
||
const type = types[i % types.length];
|
||
const mon = this.makeMonster(type, cfg.monLv, i);
|
||
mon.batch = i < NORMAL_FIRST_SPAWN ? 0 : 1;
|
||
monsters.push(mon);
|
||
}
|
||
return monsters;
|
||
}
|
||
|
||
/**
|
||
* 精英关:3 只精英同时刷出
|
||
*/
|
||
private generateEliteSection(cfg: SectionConfig): GeneratedMonster[] {
|
||
const monsters: GeneratedMonster[] = [];
|
||
const count = cfg.eliteCount ?? ELITE_COUNT;
|
||
// 精英池:近战/重型/远程/刺客
|
||
const eliteTypes = [MonType.Melee, MonType.Heavy, MonType.Long, MonType.Assassin];
|
||
|
||
for (let i = 0; i < count; i++) {
|
||
const type = eliteTypes[i % eliteTypes.length];
|
||
const mon = this.makeMonster(type, cfg.monLv, i);
|
||
mon.isElite = true;
|
||
mon.batch = 0;
|
||
// 精英属性 = 普通 × ELITE_MUL
|
||
mon.hp = Math.max(1, Math.round(mon.hp * ELITE_MUL));
|
||
mon.ap = Math.max(1, Math.round(mon.ap * ELITE_MUL));
|
||
monsters.push(mon);
|
||
}
|
||
return monsters;
|
||
}
|
||
|
||
/**
|
||
* Boss 关:2 精英 + 1 Boss
|
||
* 精英先刷(batch=0),Boss 延迟刷出(batch=0, delay=BOSS_DELAY)
|
||
*/
|
||
private generateBossSection(cfg: SectionConfig): GeneratedMonster[] {
|
||
const monsters: GeneratedMonster[] = [];
|
||
const eliteCount = cfg.eliteCount ?? BOSS_ELITE_COUNT;
|
||
const eliteTypes = [MonType.Melee, MonType.Heavy];
|
||
|
||
// 先刷精英
|
||
for (let i = 0; i < eliteCount; i++) {
|
||
const type = eliteTypes[i % eliteTypes.length];
|
||
const mon = this.makeMonster(type, cfg.monLv, i);
|
||
mon.isElite = true;
|
||
mon.isBossGuard = true;
|
||
mon.batch = 0;
|
||
mon.hp = Math.max(1, Math.round(mon.hp * ELITE_MUL));
|
||
mon.ap = Math.max(1, Math.round(mon.ap * ELITE_MUL));
|
||
monsters.push(mon);
|
||
}
|
||
|
||
// Boss 延迟刷出
|
||
const boss = this.makeBoss(cfg.monLv, monsters.length);
|
||
boss.batch = 0;
|
||
boss.delay = BOSS_DELAY;
|
||
// Boss 属性 = 普通 × BOSS_MUL
|
||
boss.hp = Math.max(1, Math.round(boss.hp * BOSS_MUL));
|
||
boss.ap = Math.max(1, Math.round(boss.ap * BOSS_MUL));
|
||
monsters.push(boss);
|
||
|
||
return monsters;
|
||
}
|
||
|
||
/** 配置缺失兜底:3 只 Lv1 近战小怪 */
|
||
private makeFallbackMonsters(): GeneratedMonster[] {
|
||
const monsters: GeneratedMonster[] = [];
|
||
for (let i = 0; i < 3; i++) {
|
||
monsters.push({
|
||
uuid: 6001,
|
||
type: MonType.Melee,
|
||
hp: Math.round(560 * MON_START_COEF),
|
||
ap: Math.round(22 * MON_START_COEF),
|
||
isBoss: false,
|
||
lv: 1,
|
||
spawnIndex: i,
|
||
batch: 0,
|
||
});
|
||
}
|
||
return monsters;
|
||
}
|
||
|
||
/** 测试模式:完全绕过引擎逻辑 */
|
||
private generateTestWave(): GeneratedMonster[] {
|
||
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: TestModeConfig.baseHp,
|
||
ap: TestModeConfig.baseAp,
|
||
isBoss: false,
|
||
lv: 1,
|
||
spawnIndex: i,
|
||
batch: 0,
|
||
skills: {
|
||
skill: TestModeConfig.skill,
|
||
atking: TestModeConfig.atking,
|
||
atked: TestModeConfig.atked,
|
||
dead: TestModeConfig.dead,
|
||
fstart: TestModeConfig.fstart,
|
||
},
|
||
});
|
||
}
|
||
return monsters;
|
||
}
|
||
|
||
/** 生成 Boss(基础面板按 StageSet 公式计算) */
|
||
private makeBoss(monLv: number, spawnIndex: number): 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)];
|
||
|
||
// Boss 基础面板取 HeroInfo 基础值,属性由调用方乘 BOSS_MUL
|
||
const baseInfo = HeroInfo[uuid];
|
||
const baseHp = baseInfo ? baseInfo.hp : 600;
|
||
const baseAp = baseInfo ? baseInfo.ap : 30;
|
||
const attrs = calcMonsterAttrs(baseHp, baseAp, monLv, false, false);
|
||
|
||
return {
|
||
uuid,
|
||
type,
|
||
hp: attrs.hp,
|
||
ap: attrs.ap,
|
||
isBoss: true,
|
||
lv: monLv,
|
||
spawnIndex,
|
||
batch: 0,
|
||
};
|
||
}
|
||
|
||
/** 生成普通怪物(属性按 StageSet 公式计算) */
|
||
private makeMonster(type: MonType, monLv: 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;
|
||
const attrs = calcMonsterAttrs(baseHp, baseAp, monLv, false, false);
|
||
|
||
return {
|
||
uuid,
|
||
type,
|
||
hp: attrs.hp,
|
||
ap: attrs.ap,
|
||
isBoss: false,
|
||
lv: monLv,
|
||
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,
|
||
revive: picked.revive,
|
||
};
|
||
}
|
||
|
||
/** 通用加权随机抽取 */
|
||
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];
|
||
}
|
||
}
|
||
|
||
// ======================== 8. 全局单例 ========================
|
||
|
||
export const spawningEngine = new StageSpawningEngine();
|
||
|
||
// 保留旧接口兼容(逐步迁移期使用)
|
||
/** @deprecated 请使用 StageSpawningEngine.generateSectionMonsters */
|
||
export class RogueSpawningEngine extends StageSpawningEngine {
|
||
/** @deprecated 请使用 generateSectionMonsters */
|
||
generateBossCombo(): GeneratedMonster[] {
|
||
return this.generateSectionMonsters(1, 1, 1);
|
||
}
|
||
}
|
||
|
||
/** @deprecated 请使用 spawningEngine */
|
||
export const rogueSpawningEngine = new RogueSpawningEngine();
|