平台通知

社区技巧
开发技巧高级🎮 游戏编辑精选

基于玩家强度(PPI)与波次预算的肉鸽游戏动态数值平衡系统

通过“玩家强度指数(PPI)”与“有效血池(EPP)”实时反推敌人波次预算与 Boss 属性,结合基于 EMA 的橡皮筋动态难度(DDA)调控,实现不崩溃、免手动微调且高度符合预期通关时长的肉鸽数值平衡。

叁七 分享2026年8月6日

解决什么问题

  • 数值崩坏与后期战力膨胀:传统肉鸽游戏中,玩家构筑(Build)成型后输出容易指数级暴涨,固定数值的敌人迅速沦为“草芥”或直接成为“数值墙”。
  • 手动调参成本高昂且不精准:通过手动配置每一关敌人的血量、伤害与数量,极易因玩家不同的流派强度导致难度失控,缺乏统一的数学基准。
  • 敌人数量与清屏体验脱节:直接暴涨敌人血量会导致“打不动”,暴涨敌人数量又会导致性能卡顿或画面过乱。

适用条件

  • 引擎 / 框架:适用于所有支持自研或自定义数值框架的 2D/3D 游戏引擎(如 Unity、Unreal、Cocos Creator、Godot、Phaser/Canvas 等)。
  • AI 模型或 Agent:通用游戏设计与代码实现,无需特殊 AI Agent 或特定模型能力支持。
  • 已验证版本和日期:ES6 / TypeScript,已在 Web/Native 动作肉鸽(Rogue-lite/like)项目中验证,2026年8月。

使用方法

将数值体系解耦为三个核心概念并在主循环/关卡初始化时调用:

  1. 玩家强度指数(PPI, Player Power Index):综合主副武器 DPS、暴击/爆头期望、道具与副手伤害,量化玩家当前真正的输出上限。
  2. 有效血池(EPP, Effective Pool):综合基础生命、护甲值与回复资源,量化玩家当前的生存底线。
  3. 波次预算(Wave Budget):用 $\text{PPI} \times \text{目标清波时长} \times \text{实战效率}$ 动态计算当前波次的总血量预算,再通过预算反推敌人种类与数量。

核心代码实现

// ============================================================
// 数值平衡域:玩家强度指数 PPI / 有效血池 EPP / 波次预算 / Boss 派生 / 动态难度 DDA
// ============================================================
import { CFG, BOSS_TYPES } from './config.js';
import { player, rogue, settings, G } from './state.js';
import { clamp } from './utils.js';

export const BAL = {
  d: 1,             // 当前难度标量(仅默认设置下生效)
  enabled: true,
  perf: 1,          // 上一波表现分(>1 太难 / <1 太简单)
  muls: { hp: 1, acc: 1, freq: 1, count: 1 },
  waveStartT: 0,
  waveStartPool: 0,
};

// 难度相关设置键:任何一项偏离默认 → 玩家显式定制难度,DDA 自动旁路
const DEVIATION_KEYS = ['enemyHp', 'enemyDmg', 'enemyCount', 'enemyAcc', 'enemyFreq', 'enemySpeed',
  'playerDmg', 'playerHp', 'fireRate', 'reloadSpeed', 'headshotMul', 'nadeDmg', 'regenMul', 'speedMul', 'ammoMul'];

export function refreshDdaEnabled() {
  BAL.enabled = !DEVIATION_KEYS.some(k => Math.abs(settings[k] - (k === 'playerHp' ? 100 : 1)) > 0.001);
  return BAL.enabled;
}

export function resetBalance() {
  BAL.d = 1; BAL.perf = 1;
  BAL.waveStartT = 0; BAL.waveStartPool = 0;
  BAL.muls = { hp: 1, acc: 1, freq: 1, count: 1 };
  refreshDdaEnabled();
}

// ---- 1. 玩家强度指数 PPI:按武器实例实时结算(爆头/暴击期望 + 命中与实战损耗)----
export function computePPI() {
  let main = null, best = 0;
  const parts = [];
  for (const w of player.weapons) {
    if (!w || !w.stats) continue;
    const st = w.stats;
    const dbl = st.pellets === 1 ? (rogue.doubleShot || 0) : 0;   // 双连发:单弹丸武器 +1 弹丸
    let dps;
    if (st.projectile) dps = st.dmg * 2.4 * (st.rpm / 60) * 1.6 * (st.rangeMul || 1);   // 火箭:爆炸溅射折算(含范围成长)
    else if (st.energy) { const eff = (CFG.laserRegen * 0.4 * st.energyRegenMul) / st.energyCost; dps = st.dmg * (1 + 2.2 * 0.4) * eff * (1 + dbl); } // 能量武器:开火回能 ×0.4 + 平均 40% 蓄力
    else dps = st.dmg * (st.rpm / 60) * (st.pellets + dbl);
    parts.push({ w, dps });
    if (dps > best) { best = dps; main = w; }
  }
  if (!main) return 300;
  const st = main.stats;
  const side = parts.filter(p => p.w !== main).reduce((s, p) => s + p.dps * 0.25, 0);      // 副武器补充贡献
  const hsExpect = 1 + (st.hsMul - 1) * 0.25;                                              // 默认爆头率 25%
  const critRate = Math.min(1, (rogue.crit || 0) + (st.critp ? 0.25 : 0) + (st.alwaysCrit ? 1 : 0) + (st.hsChain ? (G.hsChainT > 0 ? 1 : 0) : 0));
  const nade = (CFG.nadeDmg * settings.nadeDmg) / CFG.nadeRegen * 0.5;
  const knife = 85 / 1.1 * 0.35 * (rogue.meleeMul || 1) * (1 + 0.35 * ((rogue.wspecs['knife'] || {}).knifeDmg || 0));
  return Math.max(220, best * hsExpect * (1 + critRate) * 0.75 * (1 + side / Math.max(1, best) * 0.5) + nade + knife);
}

// ---- 2. 有效血池 EPP:生命 + 护甲 + 治疗针折算 ----
export function computeEPP() {
  return player.hpMax + player.armorMax + Math.min(player.meds, player.medMax) * 40 * 0.4;
}

// ---- 3. 波次预算与生成 ----
export const T_W_BASE = 6;     // 基准清波射击时间(秒)
export const T_W_CAP = 14;     // 预算口径上限(清波不再随波次变慢,强度兑现为数量)
export const COMBAT_EFF = 0.42; // 实战输出效率(考虑移动 / 换弹 / 转火损耗)

export function planWave(n) {
  const cnt = settings.enemyCount;
  const ppi = computePPI();
  const tW = Math.min(T_W_BASE + n, T_W_CAP);
  const budget = ppi * tW * COMBAT_EFF; // 核心公式:预算 = PPI * 目标时长 * 实战效率
  
  const hpScaleTrash = 1 + CFG.waveHpGrowTrash * (n - 1);
  const hpScaleHeavy = 1 + CFG.waveHpGrow * (n - 1);
  const equivTotal = Math.max(6, budget / (80 * hpScaleTrash));

  // 兵种配额按预算按比例截断,防止特殊兵吃光预算
  const SPEC_BUDGET_MUL = 0.12;
  const SPECS = [
    ['shield', CFG.shieldRatio, CFG.shieldCap, CFG.shieldUnlock, 130],
    ['medic', CFG.medicRatio, CFG.medicCap, CFG.medicUnlock, 60],
    ['spit', CFG.spitRatio, CFG.spitCap, CFG.spitUnlock, 70],
    ['cloak', CFG.cloakRatio, CFG.cloakCap, CFG.cloakUnlock, 65],
    ['suicide', CFG.suicideRatio, CFG.suicideCap, CFG.suicideUnlock, 32],
  ];
  const specCounts = {};
  let specUsed = 0;
  for (const [key, ratio, cap, unlock, hp] of SPECS) {
    let c = 0;
    if (n >= unlock) {
      c = Math.min(Math.max(1, Math.round(equivTotal * ratio) * cnt), cap * cnt);
      const cost = c * hp * hpScaleTrash;
      const room = budget * SPEC_BUDGET_MUL - specUsed;
      if (cost > room) c = Math.max(0, Math.floor(room / (hp * hpScaleTrash)));
    }
    specCounts[key] = c;
    specUsed += c * hp * hpScaleTrash;
  }

  BAL.muls = {
    hp: Math.pow(BAL.d, CFG.ddaHpW),
    acc: Math.pow(BAL.d, CFG.ddaThreatW),
    freq: 1 / Math.pow(BAL.d, CFG.ddaThreatW),
    count: Math.pow(BAL.d, CFG.ddaCountW),
  };
  const dCount = BAL.enabled ? BAL.muls.count : 1;
  const comp = [];

  // 普通模式比例编制:预算增量优先转换为割草数量
  const runners = n >= 2 ? Math.round(Math.min(Math.max(2, equivTotal * CFG.runnerRatio), CFG.runnerCap) * cnt) : 0;
  const heavies = n >= 5 ? Math.round(Math.min(Math.max(1, equivTotal * CFG.heavyRatio), CFG.heavyCap) * cnt) : 0;
  let fixHP = heavies * 280 * hpScaleHeavy + runners * 50 * hpScaleTrash + specUsed;

  const lo = Math.max(0, 5 + n - runners - heavies - specCounts.shield - specCounts.medic - specCounts.spit - specCounts.cloak - specCounts.suicide);
  const hi = Math.max(0, CFG.waveCap - runners - heavies - specCounts.shield - specCounts.medic - specCounts.spit - specCounts.cloak - specCounts.suicide);
  const grunts = Math.min(
    Math.max(0, (4 + (n - 1) * 2) - runners - heavies - specCounts.shield - specCounts.medic - specCounts.spit - specCounts.cloak - specCounts.suicide),
    Math.max(0, Math.round(clamp((budget - fixHP) / (80 * hpScaleTrash) * cnt * dCount, Math.min(lo, hi), Math.max(lo, hi))))
  );

  for (let i = 0; i < grunts; i++) comp.push('grunt');
  for (let i = 0; i < runners; i++) comp.push('runner');
  for (let i = 0; i < heavies; i++) comp.push('heavy');
  for (const [type, c] of Object.entries(specCounts)) {
    for (let i = 0; i < c; i++) comp.push(type);
  }

  // 随机打乱刷怪列表
  for (let i = comp.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [comp[i], comp[j]] = [comp[j], comp[i]];
  }
  return comp;
}

// ---- 4. Boss 派生:反推击杀时间与有效伤害 ----
export function deriveBossStats(bossType) {
  const b = BOSS_TYPES[bossType];
  const raw = computePPI();
  // PPI 边际递减:玩家越强 Boss 血量增加越缓和
  const ppi = Math.min(raw, CFG.bossPpiCap) + Math.max(0, raw - CFG.bossPpiCap) * CFG.bossPpiOverflow;
  const epp = computeEPP();
  const ttk = G.stage <= 3 ? CFG.bossTtkFirst : CFG.bossTtkLater; // 期望击杀时间
  const floor = CFG.bossHpBase * (1 + CFG.bossHpGrow * (G.stage - 1));
  
  // Boss 血量 = 动态 PPI * 目标时长 / 防御率
  const hp = Math.round(Math.max(floor, ppi * ttk * COMBAT_EFF / (1 - b.defense)));
  
  // 伤害按玩家有效血池 EPP 低比例封顶,保证容错率
  const stGrow = 1 + CFG.waveDmgGrow * (G.stage - 1);
  const slam = clamp(0.15 * epp * stGrow, 18, 68 * stGrow);
  const poolDps = clamp(0.05 * epp * stGrow, 8, 22 * stGrow);

  return { hp, slam, poolDps };
}

// ---- 5. DDA 橡皮筋机制:根据清波耗时与血耗结算 ----
export function perfWaveEnd() {
  if (!BAL.enabled) return;
  const dur = Math.max(0.5, G.now - BAL.waveStartT);
  const loss = clamp((BAL.waveStartPool - computeEPP()) / Math.max(1, BAL.waveStartPool), 0, 1);
  const tW = Math.min(8 + G.waveNum, 16);
  
  // 结合清波时长与战损的表现分
  const perf = clamp(dur / tW, 0.35, 2.5) * (1 + 2 * loss);
  BAL.perf = perf;
  
  // EMA 软调整难度标量
  const target = BAL.d * Math.pow(2, -(perf - 1) * 0.35);
  const step = CFG.ddaStep;
  BAL.d = clamp(target, Math.max(CFG.ddaMin, BAL.d * (1 - step)), Math.min(CFG.ddaMax, BAL.d * (1 + step)));
}


如何验证结果

  1. 极端 Build 压力测试
  • 在控制台将玩家伤害修改为原来的 $10\times$(如极其强力的成型流派),观察生成波次。
  • 预期:敌人数量或高阶兵种比例迅速增长,总清波时间稳定保持在预设的 T_W_CAP(如 10~14 秒)附近,而非瞬间割草通关。
  1. Boss 战时长收敛验证
  • 使用低配与高配两种 Build 进入 Boss 房,记录从 Boss 刷出到死亡的时长。
  • 预期:由于 Boss 血量公式为 $\text{HP} = \frac{\text{PPI} \times \text{TTK} \times \text{COMBAT_EFF}}{1 - \text{defense}}$,实测击杀时长将精准收敛在配置的 bossTtk(如 45 秒)左右。
  1. 玩家体验橡皮筋检验
  • 故意在清波过程中频繁扣血、延长清波时间。
  • 预期:下一波的 DDA 难度系数 $BAL.d$ 会平滑下降,敌人的命中率与密度适度降低,防猝死体验明显。

已知限制与风险

  • 机制型/非数值型 Build 估算偏差:若玩家的强大来自于机制(如控场、无敌帧、全屏冰冻),computePPI() 难以精确估算这部分隐性收益,可能导致公式算的 PPI 偏低。需要额外为特殊的机制型神贴/技能赋予固定 PPI 加成权重。
  • 特定高伤害 Boss 一击必杀风险:Boss 伤害基于 EPP 比例做 clamp 限制,但如果玩家处于极低血量状态(如“玻璃大炮”Build),需额外设置绝对伤害下限或防秒杀机制(One-hit Protection)。
  • 玩家显式调节冲突:代码中已处理 DEVIATION_KEYS;当玩家在自定义/难度设置面板改动了敌我数值参数时,DDA 动态调节必须主动旁路(Bypass),避免与玩家意图冲突。

来源与致谢

根据实际肉鸽项目代码逻辑与设计实践整理总结。采用“PPI 预算驱动 + 动态 DDA”的双核数值平衡方案,欢迎在开源和商业项目中参考使用。

关联作品

元素突围

看完技巧后,可以直接体验作者用它做出的作品。