Files
pixelheros/assets/script/game/map/RogueConfig.ts
walkpan e5e379aecc feat: 调整怪物属性与波次生成逻辑,新增测试脚本
1.  全怪物基础属性翻倍调整,同步更新英雄配置表
2.  修改模板M1的最低生效等级为1
3.  调整首波生成预算计算方式,修复低预算利用率检查逻辑
4.  新增spawn测试脚本,调整UI预制体布局参数
2026-05-15 20:42:24 +08:00

1088 lines
44 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 肉鸽刷怪系统 v2 —— 三层程序化生成架构
*
* 架构:蓝图模板(节奏) + 权重填充(内容) + 自适应微调(数值)
* 主线 15 波 / 5 阶梯 / 每档 3 波(恢复→攀升→高潮)
* 10 种怪物 + 8 种词缀 + 自适应难度 ±15%
* 通关后可选无限模式(分层推进)
*
* GDD: /gdd/rogue-spawning.md
*/
// ======================== 怪物类型枚举 ========================
/** 怪物类型10 种) */
export enum MonType {
Melee = 0,
Heavy = 1,
Long = 2,
Support = 3,
Bomber = 4,
Summoner = 5,
Assassin = 6,
Splitter = 7,
MeleeBoss = 8,
LongBoss = 9,
}
/**
* 怪物类型名称映射(调试/日志用)
* @example MonTypeName[MonType.Bomber] // => "自爆"
*/
export const MonTypeName: Record<number, string> = {
[MonType.Melee]: "近战",
[MonType.Heavy]: "重型",
[MonType.Long]: "远程",
[MonType.Support]: "辅助",
[MonType.Bomber]: "自爆",
[MonType.Summoner]: "召唤师",
[MonType.Assassin]: "刺客",
[MonType.Splitter]: "分裂",
[MonType.MeleeBoss]: "近战Boss",
[MonType.LongBoss]: "远程Boss",
}
// ======================== 词缀类型枚举 ========================
/** 怪物词缀8 种) */
export enum AffixType {
Elite = 0,
Berserk = 1,
Shield = 2,
Regen = 3,
Swift = 4,
Giant = 5,
Chain = 6,
SummonerA = 7,
CritRes = 8,
FreezeRes = 9,
KnockbackRes = 10,
}
/**
* 词缀配置接口
* @property name - 词缀中文名(显示/调试用)
* @property hpMultiplier - HP 倍率修饰,乘算到最终 HP 上1.0 = 无变化)
* @property apMultiplier - AP 倍率修饰,乘算到最终 AP 上1.0 = 无变化)
* @property cost - 该词缀占用的难度预算额外成本
* @property tierMin - 首次可出现的最低阶梯编号Tier低于此阶梯不会触发
* @property description - 词缀效果简述
*/
export interface AffixConfig {
name: string
hpMultiplier: number
apMultiplier: number
cost: number
tierMin: number
description: string
}
/**
* 词缀详细配置表
* key = AffixType 枚举值value = 对应词缀的完整配置
* @see AffixConfig 字段说明
*/
export const AffixConfigs: Record<AffixType, AffixConfig> = {
[AffixType.Elite]: {
name: "精英", hpMultiplier: 1.5, apMultiplier: 1.3,
cost: 20, tierMin: 3, description: "+50% HP, +30% AP",
},
[AffixType.Berserk]: {
name: "狂暴", hpMultiplier: 1.0, apMultiplier: 1.0,
cost: 15, tierMin: 3, description: "攻速 ×1.5 (行为层实现)",
},
[AffixType.Shield]: {
name: "护盾", hpMultiplier: 1.0, apMultiplier: 1.0,
cost: 25, tierMin: 3, description: "开局带 抵御2次 伤害吸收盾",
},
[AffixType.Regen]: {
name: "再生", hpMultiplier: 1.0, apMultiplier: 1.0,
cost: 20, tierMin: 4, description: "每秒回复 2% HP",
},
[AffixType.Swift]: {
name: "疾速", hpMultiplier: 1.0, apMultiplier: 1.0,
cost: 10, tierMin: 4, description: "移速 ×2",
},
[AffixType.Giant]: {
name: "巨型", hpMultiplier: 2.0, apMultiplier: 1.5,
cost: 30, tierMin: 4, description: "×2 体型, +100% HP, +50% AP",
},
[AffixType.Chain]: {
name: "连锁", hpMultiplier: 1.0, apMultiplier: 1.0,
cost: 20, tierMin: 5, description: "攻击附带 50% 溅射伤害",
},
[AffixType.SummonerA]: {
name: "召唤", hpMultiplier: 1.0, apMultiplier: 1.0,
cost: 25, tierMin: 5, description: "每 8 秒召唤 1 个小怪",
},
[AffixType.CritRes]: {
name: "坚韧", hpMultiplier: 1.0, apMultiplier: 1.0,
cost: 15, tierMin: 3, description: "+暴击抗性",
},
[AffixType.FreezeRes]: {
name: "防寒", hpMultiplier: 1.0, apMultiplier: 1.0,
cost: 15, tierMin: 3, description: "+冰冻抗性",
},
[AffixType.KnockbackRes]: {
name: "稳固", hpMultiplier: 1.0, apMultiplier: 1.0,
cost: 15, tierMin: 3, description: "+击退抗性",
},
}
/**
* 词缀互斥组:同组内的词缀最多只能出现一个
* 当随机到互斥冲突时,按 AffixPriority 保留优先级更高的词缀
* @example [AffixType.Giant, AffixType.Swift] — 巨型与疾速不能同时出现
*/
export const AffixMutualExclusion: AffixType[][] = [
[AffixType.Giant, AffixType.Swift],
[AffixType.Regen, AffixType.Shield],
]
/**
* 词缀优先级(从高到低)
* 互斥冲突时保留此数组中靠前的词缀,丢弃靠后的
*/
export const AffixPriority: AffixType[] = [
AffixType.Shield, AffixType.Regen, AffixType.Giant,
AffixType.Swift, AffixType.Elite, AffixType.Berserk,
AffixType.Chain, AffixType.SummonerA, AffixType.CritRes, AffixType.FreezeRes, AffixType.KnockbackRes,
]
// ======================== 蓝图模板类型 ========================
/** 模板类型 */
export enum TemplateType {
REST = 0,
NORMAL = 1,
MIXED = 2,
ELITE = 3,
BOSS = 4,
}
/**
* 模板类型修正系数
* 乘算到 base_budget 上,控制不同类型模板的预算总量
* - REST: 0.5(恢复波预算减半)
* - NORMAL: 1.0(标准预算)
* - MIXED: 1.2(混合波预算 +20%
* - ELITE: 0.8(精英波怪少但强,预算略低)
* - BOSS: 1.5Boss 波预算 +50%
*/
export const TemplateModifier: Record<TemplateType, number> = {
[TemplateType.REST]: 0.5,
[TemplateType.NORMAL]: 1.0,
[TemplateType.MIXED]: 1.2,
[TemplateType.ELITE]: 0.8,
[TemplateType.BOSS]: 1.5,
}
// ======================== 怪物 UUID 池 ========================
/**
* 各怪物类型对应的 UUID 列表
* 生成怪物时从对应数组中随机抽取一个 UUID用于匹配 heroSet.ts 中的怪物配置
* UUID 编号段6001-6006兽人| 6101-6105亡灵| 6201-6205新类型
*/
export const MonList: Record<number, number[]> = {
[MonType.Melee]: [6001, 6002],
[MonType.Heavy]: [6003, 6103],
[MonType.Long]: [6004, 6102],
[MonType.Support]: [6005],
[MonType.Bomber]: [6201, 6202],
[MonType.Summoner]: [6203],
[MonType.Assassin]: [6204],
[MonType.Splitter]: [6205],
[MonType.MeleeBoss]: [6006, 6105],
[MonType.LongBoss]: [6104],
}
// ======================== 怪物基础属性 & 成本 ========================
/**
* 怪物基础属性接口
* 定义怪物在 Tier 1无词缀、adaptive=1.0)时的原始属性
* @property hp - 基础生命值final_hp = hp × tier_multiplier × affix_hp_mul × adaptive_factor
* @property ap - 基础攻击力final_ap = ap × tier_multiplier × affix_ap_mul × adaptive_factor
* @property cost - 在难度预算中的成本,决定每波能出多少只
* @property isBoss - 是否为 Boss 类型(影响词缀叠加上限和 UI 显示)
*/
export interface MonsterBaseStats {
hp: number
ap: number
cost: number
isBoss: boolean
}
/**
* 怪物基础属性表
* key = MonType 枚举值value = 该类型在 Tier 1 时的基础属性
* @see MonsterBaseStats 字段说明
*/
export const MonsterStats: Record<MonType, MonsterBaseStats> = {
[MonType.Melee]: { hp: 360, ap: 12, cost: 30, isBoss: false },
[MonType.Heavy]: { hp: 1050, ap: 30, cost: 50, isBoss: false },
[MonType.Long]: { hp: 240, ap: 45, cost: 40, isBoss: false },
[MonType.Support]: { hp: 240, ap: 20, cost: 50, isBoss: false },
[MonType.Bomber]: { hp: 180, ap: 80, cost: 35, isBoss: false },
[MonType.Summoner]: { hp: 300, ap: 15, cost: 60, isBoss: false },
[MonType.Assassin]: { hp: 270, ap: 55, cost: 45, isBoss: false },
[MonType.Splitter]: { hp: 450, ap: 20, cost: 55, isBoss: false },
[MonType.MeleeBoss]: { hp: 4500, ap: 20, cost: 200, isBoss: true },
[MonType.LongBoss]: { hp: 1050, ap: 30, cost: 200, isBoss: true },
}
// ======================== 阶梯Tier配置 ========================
/**
* 阶梯Tier配置接口
* @property multiplier - 属性倍率,所有怪物的 HP/AP 乘以此值T1=1.0, T10=5.5
* @property budget - 该档基础难度预算,怪物成本总和的上限
* @property availableTypes - 该档允许生成的怪物类型列表(未列入的类型不会出现)
* @property isBossTier - 该档第 3 波是否为 Boss 波
*/
export interface TierConfig {
multiplier: number
budget: number
availableTypes: MonType[]
isBossTier: boolean
}
/** Boss 出现的 Tier 集合MiniBoss 或 MajorBoss */
const BOSS_TIERS = new Set([1, 2, 3, 4, 5])
/** MajorBoss高难度 Boss出现的 Tier 集合 */
const MAJOR_BOSS_TIERS = new Set([3, 5])
/**
* 5 阶梯配置表
* key = 阶梯编号 1-5value = 该阶梯的完整配置
* 主线 15 波映射wave 1-3 → T1, wave 4-6 → T2, ..., wave 13-15 → T5
*/
export const TierConfigs: Record<number, TierConfig> = {
1: { multiplier: 1.0, budget: 500, availableTypes: [MonType.Melee, MonType.Heavy, MonType.Long], isBossTier: false },
2: { multiplier: 1.6, budget: 1000, availableTypes: [MonType.Melee, MonType.Heavy, MonType.Long, MonType.Support], isBossTier: true },
3: { multiplier: 2.5, budget: 1800, availableTypes: [MonType.Melee, MonType.Heavy, MonType.Long, MonType.Support, MonType.Bomber, MonType.Assassin], isBossTier: true },
4: { multiplier: 3.8, budget: 3000, availableTypes: [MonType.Melee, MonType.Heavy, MonType.Long, MonType.Support, MonType.Bomber, MonType.Assassin, MonType.Summoner, MonType.Splitter], isBossTier: true },
5: { multiplier: 5.5, budget: 5000, availableTypes: [MonType.Melee, MonType.Heavy, MonType.Long, MonType.Support, MonType.Bomber, MonType.Assassin, MonType.Summoner, MonType.Splitter], isBossTier: true },
}
/**
* 获取指定阶梯的配置
* 支持 Tier 1-5查表和 Tier 6+无限模式递推计算T(n) = T(n-1) × 1.2
*
* @param tier - 阶梯编号1-5 主线6+ 无限模式)
* @returns TierConfig 该阶梯的完整配置
*/
export function getTierConfig(tier: number): TierConfig {
if (TierConfigs[tier]) return TierConfigs[tier]
// 无限模式T(n) = T(n-1) × 1.2
const prev = getTierConfig(tier - 1)
const growthRate = InfiniteModeConfig.tierGrowthRate
const allTypes: MonType[] = [
MonType.Melee, MonType.Heavy, MonType.Long, MonType.Support,
MonType.Bomber, MonType.Assassin, MonType.Summoner, MonType.Splitter,
]
return {
multiplier: Math.round(prev.multiplier * growthRate * 100) / 100,
budget: Math.round(prev.budget * growthRate),
availableTypes: allTypes,
isBossTier: true, // 无限模式每层都有 Boss
}
}
// ======================== 词缀概率配置 ========================
/**
* 每个 Tier 的基础词缀触发概率0.0-1.0
* T1-T2: 0%(前期无词缀)
* T3: 15%, T4: 30%, T5: 50%
* 最终概率 = baseAffixChance × roleMultiplier
*/
export const BaseAffixChance: Record<number, number> = {
1: 0, 2: 0, 3: 0.15, 4: 0.30, 5: 0.50,
}
/**
* 角色类型对词缀概率的倍率
* final_chance = baseAffixChance[tier] × roleMultiplier
* - normal: 1.0x — 普通怪
* - miniBoss: 2.0x — MiniBossT2/4/7/9 的 Boss
* - majorBoss: 3.0x — MajorBossT5/10 的 Boss
*/
export const AffixRoleMultiplier = {
normal: 1.0,
miniBoss: 2.0,
majorBoss: 3.0,
}
/**
* 词缀叠加上限(按角色类型)
* - normal: 1 — 普通怪最多 1 个词缀
* - miniBoss: 2 — MiniBoss 最多 2 个词缀
* - majorBoss: 3 — MajorBoss/FinalBoss 最多 3 个词缀
*/
export const AffixStackLimit = {
normal: 1,
miniBoss: 2,
majorBoss: 3,
}
// ======================== 蓝图模板池 ========================
/**
* 蓝图模板的怪物槽位定义
* @property typePool - 候选怪物类型数组,生成时从中随机选一个(等概率)
* @property countMin - 该槽位最少生成的怪物数量
* @property countMax - 该槽位最多生成的怪物数量(实际在 [min, max] 范围内随机)
* @property weight - 槽位权重(保留扩展用,当前各槽位按顺序填充)
* @property forceAffix - 若为 true该槽位中的怪物必定触发词缀判定至少 50% 概率)
*/
export interface BlueprintSlot {
typePool: MonType[]
countMin: number
countMax: number
weight: number
forceAffix?: boolean
}
/**
* 蓝图模板定义
* @property id - 模板唯一标识(如 "R1", "N2", "B3", "TUTORIAL"
* @property type - 模板类型REST/NORMAL/MIXED/ELITE/BOSS决定模板修正系数
* @property tierMin - 首次可出现的最低 Tier低于此 Tier 不会选到此模板)
* @property slots - 怪物槽位数组,按顺序依次填充
* @property allowAffix - 是否允许对槽位中的怪物进行词缀判定
*/
export interface BlueprintTemplate {
id: string
type: TemplateType
tierMin: number
slots: BlueprintSlot[]
allowAffix: boolean
}
/**
* 蓝图模板池
* 包含所有预定义模板,按类型分为 REST/NORMAL/MIXED/ELITE/BOSS 五类
* 生成引擎根据当前 Tier 和波内位置从中筛选并随机抽取
*/
export const BlueprintTemplates: BlueprintTemplate[] = [
// ---- REST 类 ----
{ id: "R1", type: TemplateType.REST, tierMin: 1, allowAffix: false,
slots: [{ typePool: [MonType.Melee], countMin: 5, countMax: 10, weight: 1.0 }] },
{ id: "R2", type: TemplateType.REST, tierMin: 1, allowAffix: false,
slots: [{ typePool: [MonType.Melee, MonType.Heavy], countMin: 5, countMax: 15, weight: 1.0 }] },
{ id: "R3", type: TemplateType.REST, tierMin: 3, allowAffix: false,
slots: [{ typePool: [MonType.Melee, MonType.Long], countMin: 10, countMax: 15, weight: 1.0 }] },
// ---- NORMAL 类 ----
{ id: "N1", type: TemplateType.NORMAL, tierMin: 1, allowAffix: false,
slots: [{ typePool: [MonType.Melee], countMin: 10, countMax: 20, weight: 1.0 }] },
{ id: "N2", type: TemplateType.NORMAL, tierMin: 1, allowAffix: false,
slots: [
{ typePool: [MonType.Melee], countMin: 5, countMax: 15, weight: 0.6 },
{ typePool: [MonType.Long, MonType.Heavy], countMin: 5, countMax: 10, weight: 0.4 },
] },
{ id: "N3", type: TemplateType.NORMAL, tierMin: 2, allowAffix: true,
slots: [
{ typePool: [MonType.Melee, MonType.Heavy], countMin: 10, countMax: 15, weight: 0.5 },
{ typePool: [MonType.Long], countMin: 5, countMax: 10, weight: 0.3 },
{ typePool: [MonType.Support], countMin: 5, countMax: 5, weight: 0.2 },
] },
{ id: "N4", type: TemplateType.NORMAL, tierMin: 3, allowAffix: true,
slots: [
{ typePool: [MonType.Melee, MonType.Heavy], countMin: 10, countMax: 20, weight: 0.4 },
{ typePool: [MonType.Long, MonType.Assassin], countMin: 5, countMax: 15, weight: 0.3 },
{ typePool: [MonType.Support, MonType.Bomber], countMin: 5, countMax: 10, weight: 0.3 },
] },
// ---- MIXED 类 ----
{ id: "M1", type: TemplateType.MIXED, tierMin: 1, allowAffix: true,
slots: [
{ typePool: [MonType.Melee, MonType.Heavy], countMin: 10, countMax: 15, weight: 0.4 },
{ typePool: [MonType.Long], countMin: 5, countMax: 10, weight: 0.3 },
{ typePool: [MonType.Support], countMin: 5, countMax: 5, weight: 0.3 },
] },
{ id: "M2", type: TemplateType.MIXED, tierMin: 3, allowAffix: true,
slots: [
{ typePool: [MonType.Melee, MonType.Heavy], countMin: 10, countMax: 20, weight: 0.3 },
{ typePool: [MonType.Long, MonType.Assassin], countMin: 10, countMax: 15, weight: 0.3 },
{ typePool: [MonType.Bomber], countMin: 5, countMax: 10, weight: 0.2 },
{ typePool: [MonType.Support], countMin: 5, countMax: 5, weight: 0.2 },
] },
{ id: "M3", type: TemplateType.MIXED, tierMin: 4, allowAffix: true,
slots: [
{ typePool: [MonType.Melee, MonType.Heavy], countMin: 15, countMax: 20, weight: 0.3 },
{ typePool: [MonType.Long, MonType.Assassin], countMin: 10, countMax: 15, weight: 0.25 },
{ typePool: [MonType.Summoner, MonType.Splitter], countMin: 5, countMax: 10, weight: 0.25 },
{ typePool: [MonType.Bomber, MonType.Support], countMin: 5, countMax: 10, weight: 0.2 },
] },
// ---- ELITE 类 ----
{ id: "E1", type: TemplateType.ELITE, tierMin: 3, allowAffix: true,
slots: [
{ typePool: [MonType.Melee, MonType.Heavy], countMin: 5, countMax: 10, weight: 0.5, forceAffix: true },
{ typePool: [MonType.Long, MonType.Assassin], countMin: 5, countMax: 10, weight: 0.5, forceAffix: true },
] },
{ id: "E2", type: TemplateType.ELITE, tierMin: 4, allowAffix: true,
slots: [
{ typePool: [MonType.Heavy], countMin: 5, countMax: 10, weight: 0.3, forceAffix: true },
{ typePool: [MonType.Assassin, MonType.Splitter], countMin: 5, countMax: 10, weight: 0.4, forceAffix: true },
{ typePool: [MonType.Bomber], countMin: 5, countMax: 10, weight: 0.3, forceAffix: true },
] },
// ---- BOSS 类 ----
{ id: "B1", type: TemplateType.BOSS, tierMin: 1, allowAffix: true,
slots: [
{ typePool: [MonType.MeleeBoss], countMin: 1, countMax: 1, weight: 1.0 },
{ typePool: [MonType.Melee], countMin: 10, countMax: 15, weight: 0.6 },
{ typePool: [MonType.Long], countMin: 0, countMax: 10, weight: 0.4 },
] },
{ id: "B2", type: TemplateType.BOSS, tierMin: 2, allowAffix: true,
slots: [
{ typePool: [MonType.MeleeBoss], countMin: 1, countMax: 1, weight: 1.0 },
{ typePool: [MonType.Melee, MonType.Heavy], countMin: 10, countMax: 15, weight: 0.5 },
{ typePool: [MonType.Long, MonType.Support], countMin: 5, countMax: 10, weight: 0.5 },
] },
{ id: "B3", type: TemplateType.BOSS, tierMin: 3, allowAffix: true,
slots: [
{ typePool: [MonType.MeleeBoss, MonType.LongBoss], countMin: 1, countMax: 1, weight: 1.0 },
{ typePool: [MonType.Melee, MonType.Heavy], countMin: 10, countMax: 20, weight: 0.4 },
{ typePool: [MonType.Long, MonType.Assassin], countMin: 5, countMax: 15, weight: 0.3 },
{ typePool: [MonType.Bomber, MonType.Support], countMin: 5, countMax: 10, weight: 0.3 },
] },
{ id: "B4", type: TemplateType.BOSS, tierMin: 4, allowAffix: true,
slots: [
{ typePool: [MonType.MeleeBoss, MonType.LongBoss], countMin: 1, countMax: 1, weight: 1.0 },
{ typePool: [MonType.Heavy], countMin: 10, countMax: 15, weight: 0.3 },
{ typePool: [MonType.Assassin, MonType.Splitter], countMin: 5, countMax: 10, weight: 0.3 },
{ typePool: [MonType.Summoner, MonType.Support], countMin: 5, countMax: 10, weight: 0.2 },
{ typePool: [MonType.Bomber], countMin: 5, countMax: 10, weight: 0.2 },
] },
// ---- 教程专用 ----
{ id: "TUTORIAL", type: TemplateType.NORMAL, tierMin: 1, allowAffix: false,
slots: [{ typePool: [MonType.Melee], countMin: 5, countMax: 5, weight: 1.0 }] },
]
// ======================== 自适应难度配置 ========================
/**
* 自适应难度配置
* @property factorMin - adaptive_factor 下限0.85,最多降 15%
* @property factorMax - adaptive_factor 上限1.15,最多加 15%
* @property deltaPerWave - 每波调整步长0.03
* @property targetClearTime - 目标通关时间(秒),低于此视为"玩家太强"
* @property strongThreshold - 英雄存活率高于此视为"太强"0.8 = 80% 存活)
* @property weakThreshold - 英雄存活率低于此视为"太弱"0.3 = 30% 存活)
* @property maxConsecutiveDirection - 允许连续同方向调整的最大波数
* @property antiDriftDelta - 反漂移时的反向微调量
*/
export const AdaptiveConfig = {
factorMin: 0.85,
factorMax: 1.15,
deltaPerWave: 0.03,
targetClearTime: 15.0,
strongThreshold: 0.8,
weakThreshold: 0.3,
maxConsecutiveDirection: 5,
antiDriftDelta: 0.01,
}
// ======================== 无限模式配置 ========================
/**
* 无限模式配置
* @property wavesPerLayer - 每层的波数4 波REST→NORMAL→MIXED→BOSS
* @property tierGrowthRate - 属性倍率递增率(每层 ×1.2
* @property bossAffixPerLayer - Boss 每层额外获得的词缀数
* @property megaBossInterval - 每 N 层出现一次 Mega Boss
*/
export const InfiniteModeConfig = {
wavesPerLayer: 4,
tierGrowthRate: 1.2,
bossAffixPerLayer: 1,
megaBossInterval: 3,
}
// ======================== 向后兼容接口 ========================
/**
* 向后兼容:波次占位配置
* @property type - 怪物类型编号(对应 MonType 枚举值)
* @property count - 该类型怪物的数量
* @property affixes - 可选,该组怪物的词缀列表(新系统新增字段,旧代码可忽略)
*/
export interface IWaveSlot {
type: number
count: number
affixes?: AffixType[]
}
/** 全局刷怪强度偏差系数(保留向后兼容,新系统中固定为 1.0 */
export const SpawnPowerBias = 1.0
// ======================== 生成结果接口 ========================
/**
* 最终生成的怪物实例 — 战斗系统的输入数据
*
* 战斗系统收到此数组后,按 spawnIndex 顺序依次在战场上放置怪物。
* 需要通过 uuid 去 heroSet.ts 中查找对应的完整怪物配置(路径、技能、速度等)。
* hp/ap 是已经过阶梯成长、词缀修饰、自适应微调后的最终值,直接使用即可。
*
* @property uuid - 怪物 UUID对应 heroSet.ts 中 HeroInfo 的 key如 6001, 6203
* @property type - 怪物类型MonType 枚举),用于判断近战/远程/Boss 等行为模式
* @property hp - 最终生命值(= base_hp × tier_multiplier × affix_hp_mul × adaptive_factor
* @property ap - 最终攻击力(= base_ap × tier_multiplier × affix_ap_mul × adaptive_factor
* @property affixes - 词缀数组(空数组表示无词缀)。战斗系统需实现各词缀的行为效果:
* - Elite/Berserk/Giant: 属性已在 hp/ap 中体现
* - Shield: 需在战斗中实现伤害吸收盾
* - Regen: 需在战斗中实现每秒回血
* - Swift: 需在战斗中实现移速翻倍
* - Chain: 需在战斗中实现溅射伤害
* - SummonerA: 需在战斗中实现定期召唤
* @property isBoss - 是否为 Boss影响 UI 显示Boss 血条独立显示在屏幕顶部)
* @property spawnIndex - 在该波次中的生成顺序0 起始),决定战场上的排列位置
* 排列规则spawnIndex=0 在最前X 最小),后续依次向后间隔排列
*/
export interface GeneratedMonster {
uuid: number
type: MonType
hp: number
ap: number
affixes: AffixType[]
isBoss: boolean
spawnIndex: number
}
// ======================== 生成引擎 ========================
/**
* 肉鸽刷怪引擎 — 核心生成器
*
* 职责:根据波次号(或无限模式层号),程序化生成该波的怪物列表。
* 每局游戏创建一个实例,通过 reset() 重置,跨波次保持自适应状态。
*
* 使用流程:
* 1. 游戏开始 → `const engine = new RogueSpawningEngine()` 或使用全局 `spawningEngine`
* 2. 每波开始 → `const monsters = engine.generateWave(waveNumber)`
* 3. 将 monsters 传给战斗系统生成怪物
* 4. 战斗结束 → `engine.updateAdaptive(heroesAliveRatio, clearTime)`
* 5. 下一波 → 回到步骤 2
* 6. 新一局 → `engine.reset()`
*
* 对接方:
* - 战斗系统:消费 `GeneratedMonster[]`
* - UI 系统:读取 `monsters[n].isBoss`、`monsters[n].affixes` 显示 Boss 血条和词缀标识
* - 分数系统:根据 `monsters[n].isBoss` 统计 Boss 击杀
*/
export class RogueSpawningEngine {
private adaptiveFactor = 1.0
private consecutiveDirection = 0
private lastDirection = 0 // -1 降难, 0 维持, 1 加难
private lastTemplateId = ""
private lastTemplateType: TemplateType | null = null
private consecutiveTemplateCount = 0
/**
* 生成指定波次的怪物列表(主线 1-15 波)
*
* 内部流程:
* 1. 根据 waveNumber 计算 tier 和 waveInTier
* 2. W1 特殊处理固定教程模板5 个 Melee
* 3. 根据 waveInTier 和 isBossTier 选择蓝图模板
* 4. 计算难度预算 = base_budget × template_modifier × adaptive_factor
* 5. 按模板槽位填充怪物、应用词缀、计算最终属性
*
* @param waveNumber - 波次编号1-15
* 1-3 → Tier 1, 4-6 → Tier 2, ..., 13-15 → Tier 5
* < 1 返回空数组
*
* @returns GeneratedMonster[] 该波次的怪物实例数组,按 spawnIndex 排列。
* 战斗系统按此顺序在战场上依次放置怪物。
* 每次调用结果不同(随机生成),如需固定结果请在同一次调用中缓存。
*
* @example
* // 生成第 1 波教程5 个 Melee
* const w1 = engine.generateWave(1)
* // w1 = [{ uuid: 6001, type: 0, hp: 120, ap: 12, affixes: [], isBoss: false, spawnIndex: 0 }, ...]
*
* // 生成第 6 波Tier 2 Boss 波)
* const w6 = engine.generateWave(6)
* // w6 包含 1 个 MeleeBoss + 若干小怪
*/
generateWave(waveNumber: number): GeneratedMonster[] {
if (waveNumber < 1) return []
const tier = Math.ceil(waveNumber / 3)
const waveInTier = ((waveNumber - 1) % 3) + 1 // 1, 2, or 3
// 特殊Tier 1 Wave 1 = 教程
if (waveNumber === 1) {
return this.spawnFromTemplate(
BlueprintTemplates.find(t => t.id === "TUTORIAL")!,
1, getTierConfig(1).budget * this.adaptiveFactor
)
}
const isBossWave = TierConfigs[tier]?.isBossTier && waveInTier === 3
const template = this.selectTemplate(tier, waveInTier, isBossWave)
const tierConfig = getTierConfig(tier)
const budget = Math.round(
tierConfig.budget * TemplateModifier[template.type] * this.adaptiveFactor
)
return this.spawnFromTemplate(template, tier, budget)
}
/**
* 生成无限模式指定层的指定波
*
* 无限模式结构:每层 4 波REST → NORMAL → MIXED → BOSSTier 从 6 开始递增。
* 属性倍率按 T(n) = T(n-1) × 1.2 递推,无上限。
*
* @param layer - 层编号(从 1 开始)
* Tier = 5 + layerlayer=1 → Tier 6, multiplier=6.6
* @param waveInLayer - 层内波次编号1-4
* 1: REST恢复波
* 2: NORMAL标准波
* 3: MIXED混合波
* 4: BOSSBoss 波Boss 每层额外获得词缀)
*
* @returns GeneratedMonster[] 该波的怪物实例数组
*
* @example
* // 无限模式第 1 层第 4 波Boss 波Tier 6
* const boss = engine.generateInfiniteWave(1, 4)
* // boss 包含 1 个 Boss + 若干小怪,属性倍率 = 6.6x
*/
generateInfiniteWave(layer: number, waveInLayer: number): GeneratedMonster[] {
const tier = 5 + layer
const tierConfig = getTierConfig(tier)
let templateType: TemplateType
// 无限模式每层 4 波REST → NORMAL → MIXED → BOSS
switch (waveInLayer) {
case 1: templateType = TemplateType.REST; break
case 2: templateType = TemplateType.NORMAL; break
case 3: templateType = TemplateType.MIXED; break
case 4: templateType = TemplateType.BOSS; break
default: templateType = TemplateType.NORMAL; break
}
const templates = BlueprintTemplates.filter(t =>
t.type === templateType && t.tierMin <= 5
)
const template = this.pickRandomTemplate(templates)
const budget = Math.round(
tierConfig.budget * TemplateModifier[template.type] * this.adaptiveFactor
)
return this.spawnFromTemplate(template, tier, budget)
}
/**
* 根据战斗结果更新自适应难度系数
*
* 每波战斗结束后调用一次。引擎内部维护 adaptiveFactor0.85-1.15
* 该系数会乘算到后续所有波的怪物 HP/AP 上。
*
* 调整规则:
* - 玩家太强(存活率 ≥ 80% 且 通关时间 < 15s→ adaptiveFactor += 0.03
* - 玩家太弱(存活率 ≤ 30% → adaptiveFactor -= 0.03
* - 其他情况 → 不调整
* - 连续 5 波同方向 → 强制反向微调 0.01(反漂移)
*
* @param heroesAliveRatio - 本波结束时英雄存活比例0.0-1.0
* = 存活英雄数 / 总英雄数
* 3 英雄中存活 2 个 → 0.67
* 3 英雄中存活 3 个 → 1.0
* @param clearTime - 本波通关时间(秒),从怪物全部生成到全部击败的时长
* < 15s 视为"秒杀",配合高存活率判定玩家过强
*
* @example
* // 3 英雄全部存活5 秒通关 → 判定为"太强"adaptiveFactor += 0.03
* engine.updateAdaptive(1.0, 5.0)
*
* // 仅 1 英雄存活 → 判定为"太弱"adaptiveFactor -= 0.03
* engine.updateAdaptive(0.33, 30.0)
*/
updateAdaptive(heroesAliveRatio: number, clearTime: number): void {
let direction = 0
let delta = 0
if (heroesAliveRatio >= AdaptiveConfig.strongThreshold &&
clearTime < AdaptiveConfig.targetClearTime) {
direction = 1
delta = AdaptiveConfig.deltaPerWave
} else if (heroesAliveRatio <= AdaptiveConfig.weakThreshold) {
direction = -1
delta = -AdaptiveConfig.deltaPerWave
}
// 反漂移:连续同方向超过阈值时强制反向
if (direction !== 0 && direction === this.lastDirection) {
this.consecutiveDirection++
if (this.consecutiveDirection >= AdaptiveConfig.maxConsecutiveDirection) {
delta = direction === 1
? -AdaptiveConfig.antiDriftDelta
: AdaptiveConfig.antiDriftDelta
this.consecutiveDirection = 0
}
} else {
this.consecutiveDirection = direction !== 0 ? 1 : 0
}
this.lastDirection = direction
this.adaptiveFactor = Math.max(
AdaptiveConfig.factorMin,
Math.min(AdaptiveConfig.factorMax, this.adaptiveFactor + delta)
)
}
/**
* 获取当前自适应难度系数
* @returns number 当前 adaptive_factor 值0.85-1.15
* 1.0 = 无调整,>1.0 = 更难,<1.0 = 更简单
*/
getAdaptiveFactor(): number { return this.adaptiveFactor }
/**
* 重置引擎状态(新一局游戏开始时调用)
* 将 adaptiveFactor 恢复为 1.0,清空模板连续计数和方向记录
*/
reset(): void {
this.adaptiveFactor = 1.0
this.consecutiveDirection = 0
this.lastDirection = 0
this.lastTemplateId = ""
this.lastTemplateType = null
this.consecutiveTemplateCount = 0
}
// ---- 内部方法 ----
private selectTemplate(
tier: number, waveInTier: number, isBossWave: boolean
): BlueprintTemplate {
let targetType: TemplateType
if (isBossWave) {
targetType = TemplateType.BOSS
} else if (waveInTier === 1) {
targetType = TemplateType.REST
} else if (waveInTier === 3) {
targetType = TemplateType.MIXED
} else {
// Wave 2: 从 NORMAL/MIXED/ELITE 中随机
const candidates = [TemplateType.NORMAL, TemplateType.MIXED, TemplateType.ELITE]
targetType = candidates[Math.floor(Math.random() * candidates.length)]
}
// 同一模板被连续抽取 3 次:强制下一次从不同类型模板池中抽取
if (this.consecutiveTemplateCount >= 3 && this.lastTemplateType !== null) {
if (targetType === this.lastTemplateType) {
const candidates = [TemplateType.REST, TemplateType.NORMAL, TemplateType.MIXED, TemplateType.ELITE, TemplateType.BOSS].filter(t => t !== this.lastTemplateType)
targetType = candidates[Math.floor(Math.random() * candidates.length)]
}
}
let templates = BlueprintTemplates.filter(t =>
t.type === targetType && t.tierMin <= tier && t.id !== "TUTORIAL"
)
if (templates.length === 0) {
templates = BlueprintTemplates.filter(t =>
t.type === TemplateType.NORMAL && t.tierMin <= tier && t.id !== "TUTORIAL"
)
}
return this.pickRandomTemplate(templates)
}
private pickRandomTemplate(templates: BlueprintTemplate[]): BlueprintTemplate {
const idx = Math.floor(Math.random() * templates.length)
const picked = templates[idx]
if (picked.id === this.lastTemplateId) {
this.consecutiveTemplateCount++
} else {
this.consecutiveTemplateCount = 1
this.lastTemplateId = picked.id
this.lastTemplateType = picked.type
}
return picked
}
private spawnFromTemplate(
template: BlueprintTemplate, tier: number, budget: number
): GeneratedMonster[] {
const monsters: GeneratedMonster[] = []
let remainingBudget = budget
let spawnIndex = 0
for (const slot of template.slots) {
const count = this.randomInt(slot.countMin, slot.countMax)
for (let i = 0; i < count; i++) {
// 从 typePool 中按权重随机选一个类型
const type = slot.typePool[Math.floor(Math.random() * slot.typePool.length)]
const stats = MonsterStats[type]
const tierConfig = getTierConfig(tier)
// 计算成本
let cost = stats.cost
let affixes: AffixType[] = []
// 生成词缀
if (template.allowAffix && tier >= 5) {
affixes = this.generateAffixes(type, tier, slot.forceAffix ?? false)
cost += affixes.reduce((sum, a) => sum + AffixConfigs[a].cost, 0)
}
// 检查预算
if (cost > remainingBudget && monsters.length > 0) continue
remainingBudget -= cost
// 计算最终属性
const affixHpMul = 1.0 + affixes.reduce(
(sum, a) => sum + (AffixConfigs[a].hpMultiplier - 1.0), 0
)
const affixApMul = 1.0 + affixes.reduce(
(sum, a) => sum + (AffixConfigs[a].apMultiplier - 1.0), 0
)
const finalHp = Math.max(1, Math.round(
stats.hp * tierConfig.multiplier * affixHpMul * this.adaptiveFactor
))
const finalAp = Math.max(1, Math.round(
stats.ap * tierConfig.multiplier * affixApMul * this.adaptiveFactor
))
// 从 UUID 池中随机选一个
const uuids = MonList[type] || MonList[MonType.Melee]
const uuid = uuids[Math.floor(Math.random() * uuids.length)]
monsters.push({
uuid, type, hp: finalHp, ap: finalAp,
affixes, isBoss: stats.isBoss, spawnIndex,
})
spawnIndex++
}
}
// 预算利用率检查 (目标 >= 70%)
let totalCost = budget - remainingBudget
if (budget > 0 && totalCost / budget < 0.7 && template.id !== "TUTORIAL") {
const tierConfig = getTierConfig(tier)
const type = MonType.Melee
const stats = MonsterStats[type]
while (remainingBudget >= stats.cost) {
const finalHp = Math.max(1, Math.round(stats.hp * tierConfig.multiplier * this.adaptiveFactor))
const finalAp = Math.max(1, Math.round(stats.ap * tierConfig.multiplier * this.adaptiveFactor))
const uuids = MonList[type]
const uuid = uuids[Math.floor(Math.random() * uuids.length)]
monsters.push({
uuid, type, hp: finalHp, ap: finalAp,
affixes: [], isBoss: false, spawnIndex
})
spawnIndex++
remainingBudget -= stats.cost
totalCost += stats.cost
}
}
// 保底:至少 1 个 Melee
if (monsters.length === 0) {
const tierConfig = getTierConfig(tier)
monsters.push({
uuid: 6001, type: MonType.Melee,
hp: Math.max(1, Math.round(120 * tierConfig.multiplier * this.adaptiveFactor)),
ap: Math.max(1, Math.round(12 * tierConfig.multiplier * this.adaptiveFactor)),
affixes: [], isBoss: false, spawnIndex: 0,
})
}
return monsters
}
private generateAffixes(
type: MonType, tier: number, forceAffix: boolean
): AffixType[] {
const stats = MonsterStats[type]
const maxAffixes = stats.isBoss
? (MAJOR_BOSS_TIERS.has(tier) ? AffixStackLimit.majorBoss : AffixStackLimit.miniBoss)
: AffixStackLimit.normal
// 计算触发概率
let chance = (BaseAffixChance[tier] ?? 0.5) *
(stats.isBoss ? (MAJOR_BOSS_TIERS.has(tier) ? AffixRoleMultiplier.majorBoss : AffixRoleMultiplier.miniBoss) : AffixRoleMultiplier.normal)
if (forceAffix) chance = Math.max(chance, 0.5)
chance = Math.min(chance, 1.0)
// 可用词缀列表(过滤 tierMin
const available = (Object.values(AffixType) as AffixType[]).filter(
a => typeof a === "number" && AffixConfigs[a].tierMin <= tier
)
if (available.length === 0) return []
const result: AffixType[] = []
for (let i = 0; i < maxAffixes; i++) {
if (Math.random() >= chance) continue
const candidates = available.filter(a => !result.includes(a))
if (candidates.length === 0) continue
result.push(candidates[Math.floor(Math.random() * candidates.length)])
}
// 互斥冲突时,按 AffixPriority 保留优先级更高的词缀
for (const group of AffixMutualExclusion) {
const present = result.filter(a => group.includes(a))
if (present.length > 1) {
present.sort((a, b) => AffixPriority.indexOf(a) - AffixPriority.indexOf(b))
const toRemove = present.slice(1)
for (const r of toRemove) {
const idx = result.indexOf(r)
if (idx !== -1) result.splice(idx, 1)
}
}
}
return result
}
private randomInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min
}
// ---- 向后兼容 ----
/**
* 向后兼容:获取指定波次的 IWaveSlot[](汇总后的类型+数量,不含最终属性)
*
* 将 generateWave() 的结果按怪物类型合并,返回简化的 [{type, count}] 格式。
* 注意:此方法不返回 HP/AP 值,调用方需自行处理属性计算或改用 generateWave()。
*
* @param waveNumber - 波次编号1-15
* @returns IWaveSlot[] 按怪物类型合并后的数组
* - type: MonType 枚举值
* - count: 该类型的怪物总数
* - affixes: 该组怪物的词缀列表(仅首个同类型怪物的词缀)
*
* @example
* engine.getWaveSlotConfig(6)
* // [{ type: 8, count: 1 }, { type: 0, count: 10 }, { type: 2, count: 5 }]
* // = 1 个 MeleeBoss + 10 个 Melee + 5 个 Long
*/
getWaveSlotConfig(waveNumber: number): IWaveSlot[] {
const generated = this.generateWave(waveNumber)
const slotMap = new Map<number, { count: number; affixes: AffixType[] }>()
for (const m of generated) {
const existing = slotMap.get(m.type)
if (existing) {
existing.count++
} else {
slotMap.set(m.type, { count: 1, affixes: m.affixes })
}
}
return Array.from(slotMap.entries()).map(([type, data]) => ({
type,
count: data.count,
...(data.affixes.length > 0 ? { affixes: data.affixes } : {}),
}))
}
}
// ======================== 全局单例 & 向后兼容导出 ========================
/**
* 全局刷怪引擎实例
* 推荐直接使用:`spawningEngine.generateWave(n)` / `spawningEngine.updateAdaptive(...)`
* 新一局游戏开始时调用 `spawningEngine.reset()`
*/
export const spawningEngine = new RogueSpawningEngine()
/**
* 向后兼容:动态生成波次配置
* 每次调用都会重新随机生成,如需固定结果请缓存返回值或使用 spawningEngine.generateWave()
*
* @param waveNumber - 波次编号1-30
* @returns IWaveSlot[] 按 MonType 合并后的怪物数量配置
*/
export function getWaveSlotConfig(waveNumber: number): IWaveSlot[] {
return spawningEngine.getWaveSlotConfig(waveNumber)
}
/**
* 向后兼容:默认占位配置(波次 > 15 或异常时的兜底配置)
* 20 个 Melee + 15 个 Long + 5 个 Support + 5 个 Bomber
*/
export const DefaultWaveSlot: IWaveSlot[] = [
{ type: MonType.Melee, count: 20 },
{ type: MonType.Long, count: 15 },
{ type: MonType.Support, count: 5 },
{ type: MonType.Bomber, count: 5 },
]
/**
* 向后兼容:波次配置映射
* 旧代码通过 `WaveSlotConfig[wave]` 访问时Proxy 会拦截并调用引擎动态生成。
* 注意:每次访问同一波次都会重新随机,结果可能不同。
* 如需固定结果,请改用 `spawningEngine.generateWave()` 并缓存。
*
* @example
* WaveSlotConfig[5] // 动态生成第 5 波的 IWaveSlot[]
* WaveSlotConfig[15] // 动态生成第 15 波的 IWaveSlot[]
*/
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 && wave <= 15) {
return spawningEngine.getWaveSlotConfig(wave)
}
if (prop === "toJSON") return () => ({})
return undefined
},
has(_target, prop: string) {
const wave = parseInt(prop, 10)
return !isNaN(wave) && wave >= 1 && wave <= 15
},
}
)
// ======================== 旧属性成长系统(向后兼容,新系统不再使用) ========================
/** @deprecated 新系统使用 TierConfigs 中的 tier_multiplier */
export enum UpType {
AP1_HP1 = 0,
HP2 = 1,
AP2 = 2,
}
/** @deprecated 新系统使用 RogueSpawningEngine */
export const StageGrow = {
[UpType.AP1_HP1]: [4, 10] as [number, number],
[UpType.HP2]: [2, 20] as [number, number],
[UpType.AP2]: [8, 0] as [number, number],
}
/** @deprecated 新系统使用 RogueSpawningEngine */
export const StageBossGrow = {
[UpType.AP1_HP1]: [3, 16] as [number, number],
[UpType.HP2]: [1, 24] as [number, number],
[UpType.AP2]: [10, 4] as [number, number],
}