返回小岛
技能市场/策划·运营/抽卡概率系统设计师

抽卡概率系统设计师

宇昂出品v1.0.1暂无评价8次安装

软+硬+大小保底 + 蒙特卡洛模拟 + 合规公示 + 免费资源节奏

策划运营

gacha-probability-designer — 抽卡/概率系统数值设计 Skill

触发条件

当用户提到"抽卡系统"、"概率设计"、"保底机制"、"Gacha"、"概率掉落"、"抽奖概率"、"软保底"、"硬保底"、"UP池"、"概率公示"、"抽卡经济"、"伪随机"、"PRD"、"蒙特卡洛模拟"时触发。 也适用于:用户说"帮我设计抽卡系统"、"这个保底怎么设计"、"抽卡概率怎么算"、"免费玩家多久出一个限定"、"概率合规要求"等场景。 当用户给出游戏类型并要求"设计抽卡/概率系统"时,也应触发。

技能描述

帮助数值策划快速完成抽卡/概率掉落系统的完整数值方案设计:输入游戏品类和商业化目标,输出概率模型选型、保底机制设计、期望花费计算、经济衔接方案、合规公示方案、蒙特卡洛验证代码。


一、抽卡系统类型分类与选型

选型决策流程

graph TD
    A[游戏品类?] --> B{核心变现模式}
    B -->|角色收集| C{角色池设计}
    C -->|单角色聚焦| D[单UP池]
    C -->|多角色并行| E[双UP池/多池]

    B -->|角色+装备| F{是否分离池子?}
    F -->|是| G[角色池 + 武器池 分离]
    F -->|否| H[混合池 — 不推荐]

    B -->|皮肤/外观| I[外观定向池/碎片池]

    B -->|装备驱动| J{装备获取方式}
    J -->|直接获取| K[装备池 — 需定向机制]
    J -->|碎片合成| L[碎片掉落池]

    D --> M{付费深度目标}
    E --> M
    G --> M
    M -->|轻度 月卡级| N[低保底 50-80抽]
    M -->|中度 小R| O[中保底 80-120抽]
    M -->|重度 大R向| P[高保底 160-200抽]

    style D fill:#58a6ff22,stroke:#58a6ff
    style E fill:#3fb95022,stroke:#3fb950
    style G fill:#bc8cff22,stroke:#bc8cff
    style N fill:#58a6ff22,stroke:#58a6ff
    style O fill:#d2992222,stroke:#d29922
    style P fill:#bc8cff22,stroke:#bc8cff

1.1 主流池子类型速查表

池子类型代表产品特点适用品类
单UP角色池原神、崩铁单个限定角色概率UP,保底明确角色收集RPG
双UP角色池FGO、明日方舟两个UP角色分流角色收集、策略
武器/装备池原神武器池、崩铁光锥池多目标分流,付费深度更大RPG、ARPG
定向选择池崩铁定轨、原神神铸定轨玩家可指定目标,降低随机性中重度RPG
常驻池/标准池各主流二游无UP、消化过剩资源通用
新手池多数手游折扣/必出高稀有度、限购次数通用
碎片/信物池部分卡牌游戏碎片积累兑换卡牌、SLG

1.2 池子设计核心决策清单

在设计任何抽卡池之前,需要明确以下决策项:

决策项选项影响
保底是否跨池继承?继承/不继承继承利好玩家,降低付费深度
大保底还是小保底?50/50+大保底 / 仅硬保底大保底显著降低最坏情况花费
十连是否有额外保底?有/无十连保底提升"十连"的感知价值
是否有软保底?有/无软保底平滑体验但增加设计复杂度
副产物设计?星辉/通用碎片/无副产物是保底外的兜底机制
天井(硬保底)上限?50~200抽直接决定付费深度和玩家心理预期

二、概率模型设计

2.1 固定概率模型

最基础的模型:每次抽取概率恒定。

数学描述

设单次获得目标的概率为 p
n 次内获得至少一个目标的概率:
P(X >= 1) = 1 - (1-p)^n

期望抽数:
E(X) = 1/p

到达期望抽数时仍未获得的概率:
P(X > 1/p) = (1-p)^(1/p) ≈ 1/e ≈ 36.8%
基础概率 p期望抽数50%概率所需抽数90%概率所需抽数99%概率所需抽数
0.6%167115383766
1.0%10069229458
1.6%6343143287
2.0%5034114228
3.0%332376152
5.0%20144590

问题:纯固定概率下,约36.8%的玩家在期望抽数内无法获得目标 — 这个比例在商业游戏中不可接受,因此需要保底机制。

2.2 软保底(递增概率)模型

核心思想:随着未出货次数增加,概率逐步提升。

数学描述(线性递增型)

设:
  p_base = 基础概率
  n_soft = 软保底起始抽数
  k = 每抽概率递增量
  n_hard = 硬保底抽数

第 i 抽的实际概率:
  p(i) = p_base,                            当 i <= n_soft
  p(i) = p_base + (i - n_soft) * k,         当 n_soft < i < n_hard
  p(i) = 1.0,                               当 i >= n_hard (硬保底触发)

约束条件:
  p_base + (n_hard - n_soft - 1) * k <= 1.0

经典参数设计(参考行业标杆)

参数角色池典型值武器池典型值
p_base0.6%0.7%
n_soft7362
k6.0%7.0%
n_hard9080
实际期望抽数~62~53

Python 实现 — 软保底概率计算

def soft_pity_prob(pull_number, p_base=0.006, n_soft=73, k=0.06, n_hard=90):
    """计算第 pull_number 抽的实际概率"""
    if pull_number >= n_hard:
        return 1.0
    elif pull_number > n_soft:
        return min(p_base + (pull_number - n_soft) * k, 1.0)
    else:
        return p_base

def expected_pulls_soft_pity(p_base=0.006, n_soft=73, k=0.06, n_hard=90):
    """计算含软保底的期望抽数"""
    # P(恰好第i抽出货) = p(i) * Π_{j=1}^{i-1}(1 - p(j))
    expected = 0.0
    survival = 1.0  # 到第i抽前仍未出货的概率
    for i in range(1, n_hard + 1):
        p_i = soft_pity_prob(i, p_base, n_soft, k, n_hard)
        expected += i * survival * p_i
        survival *= (1 - p_i)
    return expected

# 调用示例
E = expected_pulls_soft_pity()
print(f"含软保底的期望抽数: {E:.2f}")

2.3 伪随机分布(PRD)

PRD(Pseudo-Random Distribution)的核心机制是第 N 次尝试的概率 = C * N(封顶 1.0),通过递增概率消除极端连败/连胜。该机制常用于竞技类暴击/闪避,但同样适用于抽卡系统。

C 常数求解(本节自包含,无需外部 skill)

给定常数 C,可推出这套机制的「等效平均触发概率」p_eff(N_max = ⌈1/C⌉,到此必触发):

P(n) = min(1, C·n)
期望触发间隔 E[N] = Σ_{n=1}^{N_max} n · P(n) · Π_{k=1}^{n-1}(1 - P(k))
等效概率 p_eff = 1 / E[N]

C 与 p_eff 无简单闭式解,用二分数值反解:给定目标概率 p,在 (0, p] 区间二分 C,使 p_eff(C) 收敛到 p。

def prd_eff_prob(C):
    """给定 PRD 常数 C,返回等效平均触发概率"""
    n_max = int(1.0 / C) + 1
    e_n, survival = 0.0, 1.0          # 期望触发间隔 / 到第 n 抽前仍未触发
    for n in range(1, n_max + 1):
        p_n = min(1.0, C * n)
        e_n += n * survival * p_n
        survival *= (1 - p_n)
    return 1.0 / e_n

def solve_prd_C(target_p, lo=1e-6, hi=1.0, iters=100):
    """二分求解使等效概率 = target_p 的 PRD 常数 C"""
    for _ in range(iters):
        mid = (lo + hi) / 2
        if prd_eff_prob(mid) < target_p:
            lo = mid       # C 越大触发越频繁,p_eff 单调递增
        else:
            hi = mid
    return (lo + hi) / 2

# 示例:竞技暴击 25% vs 抽卡 SSR 1.6%
for p in (0.25, 0.016):
    C = solve_prd_C(p)
    print(f"目标 {p:.3%} → C = {C:.6f},实际等效 {prd_eff_prob(C):.3%}")

PRD 应用于抽卡系统的关键差异

  • 概率区间不同:竞技场景常见 15%-30%(暴击率),而抽卡场景通常在 0.6%-3%(SSR 出率),需要针对极低概率区间重新计算 C 值
  • 触发后重置策略不同:竞技暴击触发后概率归零重新累积;抽卡系统可能叠加保底计数器,PRD 与硬保底/软保底形成组合机制
  • 玩家感知目标不同:竞技场景追求"公平感"(减少连续暴击),抽卡场景追求"希望感"(减少连续不出货)

2.4 各概率模型对比

graph LR
    A[概率模型选型] --> B{设计目标}
    B -->|简单直观| C[固定概率 + 硬保底]
    B -->|平滑体验| D[软保底递增概率]
    B -->|竞技公平| E[PRD伪随机]
    B -->|最大控制| F[软保底 + 大小保底组合]

    C --> G[适合: 休闲游戏/小程序]
    D --> H[适合: 中重度RPG/二游]
    E --> I[适合: MOBA/竞技概率事件]
    F --> J[适合: 头部二游/长线运营]

    style C fill:#3fb95022,stroke:#3fb950
    style D fill:#58a6ff22,stroke:#58a6ff
    style E fill:#d2992222,stroke:#d29922
    style F fill:#bc8cff22,stroke:#bc8cff
维度固定概率+硬保底软保底PRD软保底+大小保底
实现复杂度
玩家体验有截断感平滑平滑最优
付费可控性最高
数学可预测性
适用场景休闲/小程序中重度竞技头部二游

三、保底机制设计模式

3.1 硬保底(天井)

定义:达到指定抽数时,100%获得最高稀有度物品。

规则:
  if 累计未出货抽数 >= N_hard:
      本次必定产出最高稀有度
      重置计数器

3.2 小保底 + 大保底(50/50机制)

这是当前头部二游最主流的保底结构。

机制描述

第一次触发保底(小保底):
  - 50% 概率获得当期UP角色
  - 50% 概率获得常驻角色(歪了)

若小保底歪了,下一次触发保底(大保底):
  - 100% 获得当期UP角色

保底触发条件:
  - 软保底区间概率提升后自然触发
  - 或到达硬保底抽数强制触发

期望计算

设触发一次保底的期望抽数为 E_pity(含软保底约62抽)

获得UP角色的期望抽数:
  E_up = 0.5 * E_pity + 0.5 * 2 * E_pity
       = 0.5 * E_pity + 1.0 * E_pity
       = 1.5 * E_pity

若 E_pity ≈ 62:
  E_up ≈ 93 抽

最坏情况(大保底):
  W_up = 2 * N_hard = 2 * 90 = 180 抽

3.3 定轨/命定机制

用于武器池等多目标池,允许玩家指定想要的目标。

典型设计(三选一定轨)

武器池有2个UP武器: A和B
玩家可指定目标(如A)

规则:
  命定值初始 = 0
  每次出货非目标武器 → 命定值 +1
  命定值达到2 → 下次出货必定为目标武器

最坏情况 = 3次出货 = 3 * 单次保底抽数

3.4 水位/计数器继承

关键设计决策 — 保底计数是否跨池继承

继承方式优点缺点代表产品
完全继承玩家友好,不浪费投入降低新池付费动力原神、崩铁
同类型继承折中方案规则稍复杂部分二游
不继承每池独立付费玩家抵触情绪大早期手游

设计建议:当前市场环境下,完全继承已成为行业标准。不继承的设计在二游品类中已很难被玩家接受。

3.5 命座/精炼累积成本模型

角色收集型游戏中,同一角色重复获取可提升"命座"或"精炼"等级。

命座累积花费计算

设获得一个UP角色的期望抽数为 E_up
命座从0到满(通常6命)需要获得 7 次
  - 首次获得(0命): 1次
  - 1命~6命: 各1次,共6次

期望总抽数 = 7 * E_up
最坏总抽数 = 7 * W_up

以 E_up=93, W_up=180 为例:
  期望满命抽数 = 7 * 93 = 651 抽
  最坏满命抽数 = 7 * 180 = 1260 抽

各命座期望花费表(单抽=160抽卡货币)

命座等级需要获取次数期望累积抽数期望花费(货币)最坏累积抽数最坏花费(货币)
0命(首次)19314,88018028,800
1命218629,76036057,600
2命327944,64054086,400
3命437259,520720115,200
4命546574,400900144,000
5命655889,2801080172,800
6命(满命)7651104,1601260201,600

四、期望计算与付费深度分析

4.1 核心公式汇总

【基础期望】
E_pull = Σ_{i=1}^{N_hard} i * p(i) * Π_{j=1}^{i-1}(1-p(j))

【50/50机制下获得UP的期望】
E_up = E_pull * 1.5

【单抽成本(付费)】
Cost_per_pull = 充值包价格 / 包含抽数
  常见: 648元 = 8080货币 → 50.5抽 → 约12.83元/抽

【获得UP角色的期望花费】
Cost_up = E_up * Cost_per_pull

【月卡玩家资源估算】
Monthly_pulls_welkin = 月卡产出抽数 + 免费产出抽数
  典型值: 月卡约60-70抽/版本(6周)

【纯免费玩家资源估算】
Monthly_pulls_f2p = 免费产出抽数
  典型值: 约40-50抽/版本(6周)

4.2 不同玩家群体的抽卡节奏规划

graph TD
    A[玩家付费层级] --> B[纯免费 F2P]
    A --> C[月卡党 小月卡]
    A --> D[小R 月卡+偶尔大月卡]
    A --> E[中R 每期UP必抽]
    A --> F[大R 满命满精]

    B --> B1[约40-50抽/版本<br/>每2-3个版本抽一个限定<br/>需精准规划]
    C --> C1[约60-70抽/版本<br/>几乎每版本可保底一个<br/>偶尔需跨版本攒]
    D --> D1[约80-100抽/版本<br/>每版本稳定一个UP<br/>偶尔可冲命座]
    E --> E1[充值补齐缺口<br/>每期限定必拿<br/>偶尔追低命座]
    F --> F1[每期满命满精<br/>版本花费约3000-8000元]

    style B1 fill:#3fb95022,stroke:#3fb950
    style C1 fill:#58a6ff22,stroke:#58a6ff
    style D1 fill:#d2992222,stroke:#d29922
    style E1 fill:#bc8cff22,stroke:#bc8cff
    style F1 fill:#f8514922,stroke:#f85149

4.3 付费深度锚点设计

设计抽卡系统时,需要为不同付费层级设定明确的锚点:

锚点定义建议区间(元)设计意义
首次付费新手首充获得感6~30破冰,建立付费习惯
月卡线月卡玩家月均花费30~68大盘基础收入
单角色获取获得一个UP的期望花费600~1200中R核心花费点
单角色满命满命一个角色4000~8000大R核心花费点
版本全收集版本内全部限定2000~5000重度收集向花费

五、UP机制与概率分流设计

5.1 概率分流层级

抽卡系统的概率通常分为多层:

第一层:稀有度判定
  ├─ SSR(最高稀有度): 1.6%
  ├─ SR(次高稀有度): 13.0%
  └─ R(普通稀有度): 85.4%

第二层:UP判定(仅SSR触发时)
  ├─ UP角色: 50%(小保底时)或 100%(大保底时)
  └─ 常驻角色: 50%(小保底时)

第三层:具体角色选择
  └─ 常驻角色池中等概率随机

5.2 多UP角色分流

当一个池子包含多个UP角色时:

双UP池(如FGO):
  SSR出货时:
    UP角色A: 概率 = SSR率 * UP占比 * 1/UP角色数
    UP角色B: 概率 = SSR率 * UP占比 * 1/UP角色数

示例:SSR率1.0%, UP占比70%, 2个UP角色
    单个UP角色概率 = 1.0% * 70% * 50% = 0.35%
    期望获得指定UP = 1/0.0035 ≈ 286抽(无保底)

设计要点:多UP分流会显著提高指定角色的获取成本,需要配合更强的保底机制或定向选择系统。

5.3 UP机制参数设计表

设计参数保守方案标准方案激进方案
SSR基础概率0.6%1.0%-1.6%2.0%-3.0%
UP占比(小保底)50%50%-75%75%-100%
硬保底抽数80-9060-8040-60
大保底存在?可选
十连保底SR
期望单UP花费

六、概率公示与合规要求

6.1 中国游戏合规要求

根据国家新闻出版署相关规定和行业实践:

必须公示的信息

  1. 所有抽取物品的名称和类别
  2. 每个物品或类别的获得概率
  3. 抽取次数或金额与概率的关系(如保底规则)
  4. 必须在游戏内显著位置公示

公示格式建议

【角色祈愿】概率公示
━━━━━━━━━━━━━━━━━━━
5星角色综合概率: 1.600%
  ├ 当期UP角色[XXX]: 0.800%(含保底概率加权)
  └ 常驻5星角色: 0.800%(含保底概率加权)
4星物品综合概率: 13.000%
  ├ 当期UP4星角色: 合计 6.500%
  └ 其他4星角色/武器: 6.500%
3星武器概率: 85.400%

【保底规则】
- 每90抽内必出至少1个5星物品
- 5星物品中,当期UP角色概率为50%
- 若本次5星未获得UP角色,下次5星必定为UP角色
- 每10抽内必出至少1个4星及以上物品

注:以上概率为综合概率(含保底机制加权后的等效概率),
实际每次抽取的基础概率与保底递增规则详见下方。
━━━━━━━━━━━━━━━━━━━

6.2 综合概率 vs 基础概率

公示时需要区分两种概率:

基础概率: 无任何保底加成时的单次概率
  例: 5星基础概率 = 0.6%

综合概率: 将保底机制的效果折算后的等效概率
  综合概率 = 1 / 期望抽数

  例: 5星期望抽数 ≈ 62.5 抽
  综合概率 = 1/62.5 = 1.6%

注意:综合概率 远高于 基础概率,这是正常的

6.3 公示注意事项

  1. 概率精度:至少保留小数点后两位(百分比形式),如1.60%
  2. 保底规则明文化:不能用模糊语言,需写明具体规则
  3. 概率变更公告:如调整概率需要提前公告
  4. 抽取记录可查:玩家应能查看自己的历史抽取记录
  5. 外显与内部一致:公示概率必须与实际实现完全一致

七、抽卡经济与游戏经济系统的衔接

7.1 抽卡货币产出控制

graph TD
    A[抽卡货币来源] --> B[付费购买]
    A --> C[免费获取]

    B --> B1[直充<br/>最高性价比: 首充]
    B --> B2[月卡<br/>稳定日产出]
    B --> B3[大月卡/通行证<br/>版本绑定]

    C --> C1[每日任务<br/>每日固定产出]
    C --> C2[活动奖励<br/>版本活动产出]
    C --> C3[深渊/挑战<br/>周期性奖励]
    C --> C4[成就/探索<br/>一次性奖励]
    C --> C5[补偿/维护奖励<br/>不定期]

    style B fill:#f8514922,stroke:#f85149
    style C fill:#3fb95022,stroke:#3fb950

7.2 免费资源节奏设计

设计核心原则:免费资源的节奏决定了免费玩家和低付费玩家的体验底线。

版本资源产出表(以6周版本周期为例)

来源每日/每周/每版本抽卡货币量折算抽数
每日任务每日6060 * 42 = 2520~15.8
深渊/挑战每两周600600 * 3 = 1800~11.3
版本活动版本合计~2500~15.6
探索/宝箱版本合计~1500~9.4
角色试用版本合计~400~2.5
成就/其他版本合计~500~3.1
免费合计~9220~57.6
月卡额外每日9090 * 42 = 3780~23.6
月卡合计~12,700~79.4

7.3 资源控制黄金比例

目标:免费玩家每2个版本(约3个月)可保底1个UP角色
  需要: ~93抽 * 160 = 14,880 货币
  2版本免费产出: ~18,440 货币
  冗余率: 18,440 / 14,880 ≈ 1.24 → 24%冗余,合理

目标:月卡玩家每个版本可保底1个UP角色
  需要: ~93抽 * 160 = 14,880 货币
  1版本月卡产出: ~12,700 货币
  覆盖率: 12,700 / 14,880 ≈ 85%
  → 月卡玩家大概率可保底,偶尔需跨版本积累或小额充值

关键比例

指标建议范围含义
免费覆盖率55%-70%免费资源/单保底花费(每版本)
月卡覆盖率80%-95%月卡资源/单保底花费(每版本)
付费渗透目标月卡转化率5%-15%免费→月卡的转化
资源冗余率15%-30%让玩家能积攒余量,有规划感

八、数值验证方法

8.1 蒙特卡洛模拟 — 完整代码

以下代码可模拟任意抽卡系统的概率分布、期望、方差等关键指标。

import random
import statistics
from collections import Counter

class GachaSimulator:
    """通用抽卡系统蒙特卡洛模拟器"""

    def __init__(self, p_base=0.006, n_soft=73, k=0.06, n_hard=90,
                 up_rate=0.5, has_big_pity=True):
        """
        参数:
            p_base: SSR基础概率
            n_soft: 软保底起始抽数
            k: 每抽概率递增量
            n_hard: 硬保底抽数
            up_rate: UP角色占SSR的比例(小保底)
            has_big_pity: 是否有大保底机制
        """
        self.p_base = p_base
        self.n_soft = n_soft
        self.k = k
        self.n_hard = n_hard
        self.up_rate = up_rate
        self.has_big_pity = has_big_pity

    def _get_prob(self, pity_count):
        """获取当前抽的SSR概率"""
        if pity_count >= self.n_hard:
            return 1.0
        elif pity_count > self.n_soft:
            return min(self.p_base + (pity_count - self.n_soft) * self.k, 1.0)
        return self.p_base

    def simulate_until_up(self):
        """模拟直到获得UP角色,返回总抽数"""
        total_pulls = 0
        pity_count = 0
        guaranteed_up = False  # 大保底标记

        while True:
            total_pulls += 1
            pity_count += 1
            prob = self._get_prob(pity_count)

            if random.random() < prob:
                # 出SSR了
                pity_count = 0
                if guaranteed_up:
                    # 大保底,必定UP
                    return total_pulls
                if random.random() < self.up_rate:
                    # 小保底,命中UP
                    return total_pulls
                else:
                    # 歪了
                    if self.has_big_pity:
                        guaranteed_up = True
                    # 不有大保底就继续抽

    def run_simulation(self, n_trials=100000):
        """运行大量模拟并统计"""
        results = [self.simulate_until_up() for _ in range(n_trials)]

        report = {
            "样本数": n_trials,
            "期望抽数": statistics.mean(results),
            "中位数": statistics.median(results),
            "标准差": statistics.stdev(results),
            "最小值": min(results),
            "最大值": max(results),
            "25分位": sorted(results)[n_trials // 4],
            "75分位": sorted(results)[3 * n_trials // 4],
            "90分位": sorted(results)[int(n_trials * 0.9)],
            "99分位": sorted(results)[int(n_trials * 0.99)],
        }

        # 分布统计
        counter = Counter()
        for r in results:
            bucket = ((r - 1) // 10) * 10 + 1
            counter[f"{bucket}-{bucket+9}"] += 1

        report["分布"] = dict(sorted(counter.items(),
                              key=lambda x: int(x[0].split('-')[0])))
        return report

    def print_report(self, report):
        """打印模拟报告"""
        print("=" * 50)
        print("抽卡系统蒙特卡洛模拟报告")
        print("=" * 50)
        print(f"模拟次数: {report['样本数']:,}")
        print(f"期望抽数: {report['期望抽数']:.2f}")
        print(f"中位数:   {report['中位数']:.1f}")
        print(f"标准差:   {report['标准差']:.2f}")
        print(f"最小值:   {report['最小值']}")
        print(f"最大值:   {report['最大值']}")
        print(f"25分位:   {report['25分位']}")
        print(f"75分位:   {report['75分位']}")
        print(f"90分位:   {report['90分位']}")
        print(f"99分位:   {report['99分位']}")
        print("-" * 50)
        print("抽数分布:")
        for bucket, count in report["分布"].items():
            pct = count / report["样本数"] * 100
            bar = "█" * int(pct)
            print(f"  {bucket:>8}: {count:>6} ({pct:5.1f}%) {bar}")


# 使用示例
if __name__ == "__main__":
    # 标准角色池参数
    sim = GachaSimulator(
        p_base=0.006,
        n_soft=73,
        k=0.06,
        n_hard=90,
        up_rate=0.5,
        has_big_pity=True
    )
    report = sim.run_simulation(n_trials=100000)
    sim.print_report(report)

8.2 期望计算精确公式

对于含软保底的系统,期望抽数的精确计算:

def exact_expected_pulls(p_base, n_soft, k, n_hard, up_rate=0.5, has_big_pity=True):
    """精确计算含软保底+大小保底的期望抽数"""

    # 第一步:计算触发一次SSR的期望抽数
    E_ssr = 0.0
    survival = 1.0
    for i in range(1, n_hard + 1):
        if i >= n_hard:
            p_i = 1.0
        elif i > n_soft:
            p_i = min(p_base + (i - n_soft) * k, 1.0)
        else:
            p_i = p_base
        E_ssr += i * survival * p_i
        survival *= (1 - p_i)

    # 第二步:计算获得UP角色的期望
    if has_big_pity:
        # 50%直接命中, 50%歪了后下一个必定命中
        E_up = up_rate * E_ssr + (1 - up_rate) * 2 * E_ssr
        # 即 E_up = (2 - up_rate) * E_ssr
    else:
        # 每次独立 50/50
        E_up = E_ssr / up_rate

    return {
        "单次SSR期望": E_ssr,
        "获得UP期望": E_up,
        "最坏情况(大保底)": 2 * n_hard if has_big_pity else None,
    }

# 计算示例
result = exact_expected_pulls(0.006, 73, 0.06, 90)
print(f"单次SSR期望: {result['单次SSR期望']:.2f} 抽")
print(f"获得UP期望: {result['获得UP期望']:.2f} 抽")
print(f"最坏情况: {result['最坏情况(大保底)']} 抽")

8.3 方差与风险分析

方差衡量玩家体验的一致性:
  方差大 → 有人1抽出货,有人180保底,体验差距极大
  方差小 → 多数玩家花费接近,体验一致

衡量指标:
  变异系数 CV = 标准差 / 期望
  CV < 0.3: 体验一致(重保底系统)
  CV 0.3~0.6: 中等波动(软保底系统)
  CV > 0.6: 体验差异大(纯随机)

设计目标:对于核心付费点(获得UP角色),CV 应控制在 0.3~0.5

九、常见翻车案例与避坑指南

9.1 经典翻车模式

翻车类型描述后果避免方法
概率不公示或虚标实际概率与公示不符法律风险、舆论危机代码实现与公示严格对齐,自动化测试
保底抽数过高天井设在200+抽玩家流失、差评参考行业标准,控制在90抽以内
无大保底连续歪N次极端案例引爆舆论必须有大保底机制
武器池分流过多3个以上武器分流获取指定武器代价过高提供定轨/命定机制
水位不继承换池清零玩家感觉被"偷"了保底计数跨池继承
隐藏概率层表面公示简单但有隐藏分流信任危机完整公示所有分流层级
十连无保底SR十连和单抽无区别十连失去吸引力十连必出至少1个SR
免费资源断崖前期大量后期断供老玩家流失维持稳定的长期资源产出

9.2 设计自检清单

在设计完成后,逐项检查:

□ 概率公示完整且与实现一致?
□ 硬保底抽数是否合理(建议 ≤ 90)?
□ 是否有大保底机制?
□ 保底计数是否跨池继承?
□ 软保底曲线是否平滑?
□ 综合概率是否正确计算并公示?
□ 十连是否有SR保底?
□ 免费玩家能否在合理周期内获得核心角色?
□ 月卡玩家体验是否显著优于免费?
□ 命座/精炼系统的付费深度是否可接受?
□ 武器池是否有定向/命定机制?
□ 蒙特卡洛模拟结果是否符合预期?
□ 方差/变异系数是否在合理范围?
□ 是否考虑了副产物(星辉/碎片)的兜底效果?
□ 新手池是否有足够的获得感?

9.3 保底抽数与玩家满意度关系

根据行业经验,保底抽数与玩家满意度的经验关系:

硬保底 ≤ 50 抽: 非常慷慨,适合轻度/休闲游戏
硬保底 60-80 抽: 较为慷慨,适合想快速铺量的新游
硬保底 80-90 抽: 行业标准,头部二游常见
硬保底 100-120 抽: 偏高,需要很强的角色吸引力支撑
硬保底 > 120 抽: 玩家接受度低,容易引发负面舆论

大保底 = 2 * 硬保底 是通用做法
  - 90硬保底 → 180大保底: 行业标准
  - 80硬保底 → 160大保底: 较为友好

十、设计流程与决策树

10.1 抽卡系统设计完整流程

graph TD
    S[开始设计抽卡系统] --> A[明确游戏品类与变现目标]
    A --> B[选择池子类型结构]
    B --> C[设计概率模型]
    C --> D[设定保底机制]
    D --> E[设计UP机制与分流]
    E --> F[计算期望与付费深度]
    F --> G{付费深度合理?}
    G -->|否| C
    G -->|是| H[设计免费资源节奏]
    H --> I[编写概率公示文案]
    I --> J[蒙特卡洛模拟验证]
    J --> K{模拟结果达标?}
    K -->|否| C
    K -->|是| L[编写自检清单]
    L --> M[输出完整数值方案]

    style S fill:#58a6ff22,stroke:#58a6ff
    style M fill:#3fb95022,stroke:#3fb950
    style G fill:#d2992222,stroke:#d29922
    style K fill:#d2992222,stroke:#d29922

10.2 概率参数快速选型表

根据游戏品类快速选择参数起点:

品类基础SSR率硬保底软保底起始递增率UP比例大保底
二次元RPG0.6%90736%50%
卡牌策略1.0%-2.0%60-8050-655%-8%50%-70%
MMORPG0.5%-1.0%80-10065-804%-6%50%建议有
休闲/小程序2.0%-5.0%30-5025-408%-15%70%-100%可选
体育/竞速1.0%-3.0%50-8040-655%-10%50%-75%
SLG0.5%-1.5%80-12060-903%-6%50%建议有

10.3 输出物清单

完成抽卡数值设计后,应输出以下文档:

1. 概率参数总表(.xlsx)
   - 各池子概率参数
   - 软保底曲线数据
   - 分流概率明细

2. 期望计算表(.xlsx)
   - 各场景期望抽数
   - 付费深度对照
   - 各玩家层级花费估算

3. 概率公示文案(文本)
   - 符合合规要求的完整公示

4. 蒙特卡洛模拟报告(.xlsx + 图表),使用附录B的ExcelJS代码生成
   - Sheet1 概率曲线:单抽概率柱状图 + 累积概率折线图
   - Sheet2 出货分布:模拟出货抽数分布柱状图
   - Sheet3 统计汇总:参数配置、期望/分位数、花费估算

5. 免费资源节奏表(.xlsx)
   - 各来源产出明细
   - 各付费层级覆盖率

6. 设计自检表(文本)
   - 逐项检查结果

附录 A:软保底概率曲线可视化代码

import matplotlib.pyplot as plt
import numpy as np

def plot_soft_pity_curve(p_base=0.006, n_soft=73, k=0.06, n_hard=90):
    """绘制软保底概率曲线"""
    pulls = list(range(1, n_hard + 1))
    probs = []
    cumulative = []
    survival = 1.0

    for i in pulls:
        if i >= n_hard:
            p = 1.0
        elif i > n_soft:
            p = min(p_base + (i - n_soft) * k, 1.0)
        else:
            p = p_base
        probs.append(p * 100)
        cum = 1 - survival * (1 - p)
        survival *= (1 - p)
        cumulative.append((1 - survival) * 100)

    fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 8))

    # 单抽概率
    ax1.bar(pulls, probs, color='#58a6ff', alpha=0.7, width=1.0)
    ax1.set_xlabel('Pulls')
    ax1.set_ylabel('Pull Probability (%)')
    ax1.set_title('Soft Pity - Per Pull Probability')
    ax1.axvline(x=n_soft, color='orange', linestyle='--', label=f'Soft pity start ({n_soft})')
    ax1.axvline(x=n_hard, color='red', linestyle='--', label=f'Hard pity ({n_hard})')
    ax1.legend()

    # 累积概率
    ax2.plot(pulls, cumulative, color='#3fb950', linewidth=2)
    ax2.fill_between(pulls, cumulative, alpha=0.2, color='#3fb950')
    ax2.set_xlabel('Pulls')
    ax2.set_ylabel('Cumulative Probability (%)')
    ax2.set_title('Soft Pity - Cumulative Probability')
    ax2.axhline(y=50, color='gray', linestyle=':', alpha=0.5, label='50%')
    ax2.axhline(y=90, color='gray', linestyle=':', alpha=0.5, label='90%')
    ax2.axvline(x=n_soft, color='orange', linestyle='--')
    ax2.axvline(x=n_hard, color='red', linestyle='--')
    ax2.legend()

    plt.tight_layout()
    plt.savefig('soft_pity_curve.png', dpi=150)
    plt.show()

plot_soft_pity_curve()

附录 B:Excel 出货分布曲线图表生成

当需要将抽卡出货分布以 Excel 图表形式交付(方便非技术人员查看、调参),使用以下 Node.js 代码生成带图表的 .xlsx 文件。

依赖安装npm install exceljs

const ExcelJS = require('exceljs');

async function generateGachaDistributionXlsx(outputPath, config = {}) {
  const {
    pBase = 0.006,    // SSR基础概率
    nSoft = 73,       // 软保底起始
    k = 0.06,         // 每抽递增量
    nHard = 90,       // 硬保底
    upRate = 0.5,     // UP占比
    hasBigPity = true,// 大保底
    nTrials = 50000,  // 模拟次数
    title = '抽卡出货分布分析'
  } = config;

  // ── 概率计算函数 ──
  function getProb(pull) {
    if (pull >= nHard) return 1.0;
    if (pull > nSoft) return Math.min(pBase + (pull - nSoft) * k, 1.0);
    return pBase;
  }

  // ── Sheet1: 单抽概率 & 累积概率曲线数据 ──
  const curveData = [];
  let survival = 1.0;
  for (let i = 1; i <= nHard; i++) {
    const p = getProb(i);
    survival *= (1 - p);
    curveData.push({
      pull: i,
      prob: p,
      probPct: +(p * 100).toFixed(4),
      cumPct: +((1 - survival) * 100).toFixed(4)
    });
  }

  // ── 蒙特卡洛模拟 ──
  function simulateOnce() {
    let total = 0, pity = 0, guaranteed = false;
    while (true) {
      total++; pity++;
      if (Math.random() < getProb(pity)) {
        pity = 0;
        if (guaranteed) return total;
        if (Math.random() < upRate) return total;
        if (hasBigPity) guaranteed = true;
      }
    }
  }

  const results = [];
  for (let t = 0; t < nTrials; t++) results.push(simulateOnce());
  results.sort((a, b) => a - b);

  // ── 分桶统计(每10抽一组)──
  const maxPull = results[results.length - 1];
  const bucketSize = 10;
  const buckets = [];
  for (let start = 1; start <= maxPull; start += bucketSize) {
    const end = start + bucketSize - 1;
    const count = results.filter(r => r >= start && r <= end).length;
    buckets.push({ range: `${start}-${end}`, start, count, pct: +(count / nTrials * 100).toFixed(2) });
  }

  // ── 统计指标 ──
  const mean = results.reduce((s, v) => s + v, 0) / nTrials;
  const median = results[Math.floor(nTrials / 2)];
  const p25 = results[Math.floor(nTrials * 0.25)];
  const p75 = results[Math.floor(nTrials * 0.75)];
  const p90 = results[Math.floor(nTrials * 0.90)];
  const p99 = results[Math.floor(nTrials * 0.99)];

  // ── 创建 Excel ──
  const wb = new ExcelJS.Workbook();
  wb.creator = 'gacha-probability-designer';

  // ===== Sheet1: 概率曲线 =====
  const ws1 = wb.addWorksheet('概率曲线');
  ws1.columns = [
    { header: '抽数', key: 'pull', width: 8 },
    { header: '单抽概率(%)', key: 'probPct', width: 14 },
    { header: '累积概率(%)', key: 'cumPct', width: 14 },
    { header: '单抽概率(原值)', key: 'prob', width: 16 },
  ];
  // 表头样式
  ws1.getRow(1).eachCell(cell => {
    cell.font = { bold: true, color: { argb: 'FFFFFFFF' } };
    cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF2E75B6' } };
    cell.alignment = { horizontal: 'center' };
  });
  curveData.forEach(d => ws1.addRow(d));

  // 图表1: 单抽概率柱状图
  ws1.addChart({
    type: 'bar',      // ExcelJS: 'bar' = column chart
    subtype: 'clustered',
    title: { text: '单抽概率分布 (%)' },
    axes: [
      { title: { text: '抽数' }, position: 'bottom' },
      { title: { text: '概率 (%)' }, position: 'left' }
    ],
    series: [{
      categories: { sheet: '概率曲线', ref: `A2:A${nHard + 1}` },
      values: { sheet: '概率曲线', ref: `B2:B${nHard + 1}` },
      name: '单抽概率(%)',
      color: { argb: 'FF58A6FF' },
    }],
    legend: { position: 'bottom' },
  }, {
    tl: { col: 5, row: 1 },
    br: { col: 14, row: 18 },
  });

  // 图表2: 累积概率曲线
  ws1.addChart({
    type: 'line',
    title: { text: '累积出货概率 (%)' },
    axes: [
      { title: { text: '抽数' }, position: 'bottom' },
      { title: { text: '累积概率 (%)' }, position: 'left' }
    ],
    series: [{
      categories: { sheet: '概率曲线', ref: `A2:A${nHard + 1}` },
      values: { sheet: '概率曲线', ref: `C2:C${nHard + 1}` },
      name: '累积概率(%)',
      color: { argb: 'FF3FB950' },
      smooth: true,
    }],
    legend: { position: 'bottom' },
  }, {
    tl: { col: 5, row: 19 },
    br: { col: 14, row: 36 },
  });

  // ===== Sheet2: 模拟出货分布 =====
  const ws2 = wb.addWorksheet('出货分布');
  ws2.columns = [
    { header: '抽数区间', key: 'range', width: 14 },
    { header: '出货人次', key: 'count', width: 12 },
    { header: '占比(%)', key: 'pct', width: 10 },
  ];
  ws2.getRow(1).eachCell(cell => {
    cell.font = { bold: true, color: { argb: 'FFFFFFFF' } };
    cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF2E75B6' } };
    cell.alignment = { horizontal: 'center' };
  });
  buckets.forEach(b => ws2.addRow(b));

  // 图表3: 出货分布柱状图
  const bLen = buckets.length;
  ws2.addChart({
    type: 'bar',
    subtype: 'clustered',
    title: { text: `UP角色出货分布 (${nTrials.toLocaleString()}次模拟)` },
    axes: [
      { title: { text: '抽数区间' }, position: 'bottom' },
      { title: { text: '占比 (%)' }, position: 'left' }
    ],
    series: [{
      categories: { sheet: '出货分布', ref: `A2:A${bLen + 1}` },
      values: { sheet: '出货分布', ref: `C2:C${bLen + 1}` },
      name: '出货占比(%)',
      color: { argb: 'FFBC8CFF' },
    }],
    legend: { position: 'bottom' },
  }, {
    tl: { col: 4, row: 1 },
    br: { col: 13, row: 18 },
  });

  // ===== Sheet3: 统计汇总 =====
  const ws3 = wb.addWorksheet('统计汇总');
  ws3.columns = [
    { header: '指标', key: 'metric', width: 20 },
    { header: '数值', key: 'value', width: 15 },
  ];
  ws3.getRow(1).eachCell(cell => {
    cell.font = { bold: true, color: { argb: 'FFFFFFFF' } };
    cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF2E75B6' } };
  });
  const stats = [
    ['模拟次数', nTrials],
    ['基础概率', pBase],
    ['软保底起始', nSoft],
    ['递增步长', k],
    ['硬保底', nHard],
    ['UP占比', upRate],
    ['大保底', hasBigPity ? '是' : '否'],
    ['', ''],
    ['期望抽数', +mean.toFixed(2)],
    ['中位数', median],
    ['25分位', p25],
    ['75分位', p75],
    ['90分位', p90],
    ['99分位', p99],
    ['最小值', results[0]],
    ['最大值', results[results.length - 1]],
    ['', ''],
    ['期望花费(单抽6元)', `¥${(mean * 6).toFixed(0)}`],
    ['保底花费(单抽6元)', `¥${nHard * 6}`],
    ['大保底花费(单抽6元)', `¥${nHard * 2 * 6}`],
  ];
  stats.forEach(([m, v]) => ws3.addRow({ metric: m, value: v }));

  // 参数区高亮
  for (let r = 2; r <= 8; r++) {
    ws3.getRow(r).getCell(1).fill = {
      type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFEAF4FC' }
    };
  }

  await wb.xlsx.writeFile(outputPath);
  console.log(`Excel 出货分布报表已生成: ${outputPath}`);
  console.log(`  期望: ${mean.toFixed(1)} 抽 | 中位数: ${median} | 90分位: ${p90} | 99分位: ${p99}`);
}

// ── 使用示例 ──
generateGachaDistributionXlsx('抽卡出货分布.xlsx', {
  pBase: 0.006,     // 0.6% 基础
  nSoft: 73,        // 73抽开始软保底
  k: 0.06,          // 每抽+6%
  nHard: 90,        // 90抽硬保底
  upRate: 0.5,      // 50/50
  hasBigPity: true,  // 有大保底
  nTrials: 50000,
  title: '标准角色池出货分布'
});

生成的 Excel 包含

Sheet内容图表
概率曲线每抽概率 + 累积概率数据表柱状图(单抽概率)+ 折线图(累积概率)
出货分布蒙特卡洛模拟分桶统计柱状图(出货抽数分布)
统计汇总参数配置 + 期望/分位数/花费

多方案对比:修改 config 参数后多次调用,可生成不同方案的 xlsx 文件用于横向比较。也可扩展为在同一 xlsx 内创建多个 sheet 对比不同池子设计。

注意:附录 B 中的 ws.addChart() 方法是 ExcelJS 的实验性 API,在部分版本中不受支持。如需 确保图表一定能在 Excel 中显示,请使用下方附录 B-2 的 Canvas 图片嵌入方案。


Excel 出货分布曲线生成

方案说明:ExcelJS 本身不原生支持创建 Excel 图表(chart 对象)。本方案采用 node-canvas 绘制图表 → 导出 PNG → ExcelJS addImage 嵌入 的路线,确保生成的 .xlsx 文件在任何 Excel 版本中都能正常显示图表。

依赖安装

npm install exceljs canvas
  • exceljs: Excel 文件读写,支持样式、图片、冻结窗格等
  • canvas (node-canvas): 服务端 Canvas 2D 绘图,生成 PNG 图片

设计思路

┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│  概率计算引擎  │────▶│ node-canvas  │────▶│   ExcelJS    │
│ 三种保底模型  │     │ 绘制3张图表   │     │ 嵌入图片+数据 │
│ 蒙特卡洛模拟  │     │ 导出PNG Buffer│     │ 输出.xlsx    │
└──────────────┘     └──────────────┘     └──────────────┘

三种保底模型对比

模型代表产品基础概率软保底硬保底UP占比大保底
原神模型原神/崩铁0.6%73抽起+6%/抽90抽50%
FGO模型FGO1.0%330抽80%
方舟模型明日方舟2.0%50抽起+2%/抽99抽50%

生成的 Excel 结构

Sheet内容嵌入图表
概率分布数据三模型逐抽概率表(抽数1~N,单次概率、累计概率、期望出货占比)单抽概率曲线PNG + 累计概率曲线PNG
软保底递增曲线原神1-73抽0.6%→74-90抽+6%递增详细数据,FGO/方舟对比
蒙特卡洛模拟汇总10000次模拟统计指标 + 分桶直方图数据出货分布直方图PNG

完整代码

以下是核心代码片段,把下面 4 段按顺序拼成一个独立的 .js 脚本自行保存即可运行(不依赖随 skill 附带的文件)。

1. 模型定义与概率计算

// 三种保底模型参数定义
const MODELS = {
  genshin: {
    name: '原神',
    color: '#58a6ff',
    pBase: 0.006,    // 基础SSR概率 0.6%
    nSoft: 73,       // 软保底起始抽数
    k: 0.06,         // 每抽概率递增 6%
    nHard: 90,       // 硬保底抽数
    upRate: 0.5,     // UP占比 50%(50/50机制)
    hasBigPity: true, // 有大保底
  },
  fgo: {
    name: 'FGO',
    color: '#f5a623',
    pBase: 0.01,     // 基础SSR概率 1%
    nSoft: 999,      // FGO无软保底
    k: 0,
    nHard: 330,      // 330抽天井
    upRate: 0.8,     // UP占SSR 80%
    hasBigPity: false,
  },
  arknights: {
    name: '明日方舟',
    color: '#3fb950',
    pBase: 0.02,     // 基础6星概率 2%
    nSoft: 50,       // 50抽开始软保底
    k: 0.02,         // 每抽概率递增 2%
    nHard: 99,       // 99抽硬保底
    upRate: 0.5,
    hasBigPity: false,
  }
};

// 获取指定抽数的单抽概率
function getProb(pull, model) {
  if (pull >= model.nHard) return 1.0;
  if (pull > model.nSoft) return Math.min(model.pBase + (pull - model.nSoft) * model.k, 1.0);
  return model.pBase;
}

// 计算概率分布数据(单抽概率、累计概率、期望出货占比)
function calcProbDistribution(model, maxPull) {
  const data = [];
  let survival = 1.0;
  for (let i = 1; i <= maxPull; i++) {
    const p = getProb(i, model);
    const hitProb = survival * p;   // 恰好在第i抽出货的概率
    survival *= (1 - p);
    data.push({
      pull: i,
      singleProbPct: +(p * 100).toFixed(4),
      cumProbPct: +((1 - survival) * 100).toFixed(4),
      hitProbPct: +(hitProb * 100).toFixed(4),
    });
  }
  return data;
}

2. 蒙特卡洛模拟

// 模拟获取UP角色所需抽数
function monteCarloSimulate(model, nTrials) {
  const results = [];
  for (let t = 0; t < nTrials; t++) {
    let total = 0, pity = 0, guaranteed = false;
    while (true) {
      total++; pity++;
      if (Math.random() < getProb(pity, model)) {
        pity = 0;
        if (guaranteed) { results.push(total); break; }
        if (Math.random() < model.upRate) { results.push(total); break; }
        if (model.hasBigPity) guaranteed = true;
      }
    }
  }
  return results.sort((a, b) => a - b);
}

3. Canvas 绘制图表(以单抽概率曲线为例)

const { createCanvas } = require('canvas');

function drawSingleProbChart(allProbData) {
  const width = 1000, height = 500;
  const canvas = createCanvas(width, height);
  const ctx = canvas.getContext('2d');

  // 白色背景
  ctx.fillStyle = '#ffffff';
  ctx.fillRect(0, 0, width, height);

  const margin = { top: 50, right: 160, bottom: 60, left: 80 };
  const plotW = width - margin.left - margin.right;
  const plotH = height - margin.top - margin.bottom;

  // 计算坐标范围
  let maxPull = 0, maxProb = 0;
  for (const key in allProbData) {
    const data = allProbData[key];
    if (data.length > maxPull) maxPull = data.length;
    for (const d of data) {
      if (d.singleProbPct > maxProb) maxProb = d.singleProbPct;
    }
  }
  const yMax = Math.ceil(maxProb / 10) * 10 || 10;

  // 绘制各模型曲线
  for (const key of Object.keys(allProbData)) {
    const model = MODELS[key];
    const data = allProbData[key];
    ctx.strokeStyle = model.color;
    ctx.lineWidth = 2.5;
    ctx.beginPath();
    for (let i = 0; i < data.length; i++) {
      const x = margin.left + (data[i].pull / maxPull) * plotW;
      const y = margin.top + plotH - (data[i].singleProbPct / yMax) * plotH;
      if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
    }
    ctx.stroke();
  }

  // ... 坐标轴、标题、图例绘制 ...

  return canvas.toBuffer('image/png'); // 返回PNG Buffer
}

4. ExcelJS 嵌入图片生成 xlsx

const ExcelJS = require('exceljs');

async function generateExcel() {
  const wb = new ExcelJS.Workbook();

  // ===== Sheet 1: 概率分布数据表 =====
  const ws1 = wb.addWorksheet('概率分布数据', {
    views: [{ state: 'frozen', xSplit: 1, ySplit: 1 }]  // 冻结首行首列
  });

  // 定义列(抽数 + 每个模型3列)
  ws1.columns = [
    { header: '抽数', key: 'pull', width: 8 },
    { header: '原神-单次概率(%)', key: 'genshin_single', width: 18 },
    { header: '原神-累计概率(%)', key: 'genshin_cum', width: 18 },
    { header: '原神-出货占比(%)', key: 'genshin_hit', width: 18 },
    // ... FGO、方舟同理
  ];

  // 表头样式:蓝色底白色字
  ws1.getRow(1).eachCell(cell => {
    cell.font = { bold: true, color: { argb: 'FFFFFFFF' } };
    cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF2E75B6' } };
    cell.alignment = { horizontal: 'center', vertical: 'middle' };
  });

  // 填充数据行...

  // 关键:将 Canvas 生成的 PNG 嵌入到 Excel
  const chartBuffer = drawSingleProbChart(allProbData);
  const imageId = wb.addImage({ buffer: chartBuffer, extension: 'png' });
  ws1.addImage(imageId, {
    tl: { col: 10.5, row: 1 },     // 左上角位置(第11列第2行附近)
    ext: { width: 800, height: 400 } // 图片尺寸(像素)
  });

  // ===== Sheet 2, 3 同理 =====

  await wb.xlsx.writeFile('gacha_distribution_curves.xlsx');
}

关键技术要点

  1. 为什么不直接用 ExcelJS 的 addChart? ExcelJS 的 addChart API 是社区贡献的实验性功能,在许多版本中未正式支持或行为不稳定。使用 addImage 嵌入 Canvas 绘制的 PNG 图片是更可靠的方案。

  2. Canvas 绘图要点

    • 使用 createCanvas(width, height) 创建画布
    • 手动绘制坐标轴、网格线、曲线、图例
    • canvas.toBuffer('image/png') 导出 PNG Buffer,无需写入临时文件
  3. Excel 专业样式

    • views: [{ state: 'frozen', xSplit: 1, ySplit: 1 }] 冻结窗格
    • 各模型使用不同的表头颜色区分
    • 隔行着色提高可读性
    • 列宽根据内容适配
  4. 三模型对比价值

    • 原神模型:软保底使出货集中在73-90抽区间,体验可控
    • FGO模型:纯固定概率无递增,方差极大,330天井过高
    • 方舟模型:2%基础率+50抽软保底,期望抽数较低但无大保底

运行方式

# 在你保存上面脚本的目录下执行
npm install exceljs canvas
node generate_gacha_curves.js
# 输出:gacha_distribution_curves.xlsx

生成的 xlsx 文件包含 3 个 Sheet 和 3 张嵌入图表,可直接用 Excel/WPS 打开查看。


附录 C:多池系统收益对比模拟(Python)

def compare_pool_designs():
    """对比不同池子设计的玩家花费分布"""
    configs = {
        "慷慨型 (硬保底60)": {
            "p_base": 0.016, "n_soft": 50, "k": 0.08,
            "n_hard": 60, "up_rate": 0.75, "has_big_pity": True
        },
        "标准型 (硬保底90)": {
            "p_base": 0.006, "n_soft": 73, "k": 0.06,
            "n_hard": 90, "up_rate": 0.5, "has_big_pity": True
        },
        "严格型 (硬保底120)": {
            "p_base": 0.005, "n_soft": 90, "k": 0.04,
            "n_hard": 120, "up_rate": 0.5, "has_big_pity": True
        },
    }

    for name, cfg in configs.items():
        sim = GachaSimulator(**cfg)
        report = sim.run_simulation(n_trials=50000)
        print(f"\n{'='*40}")
        print(f"方案: {name}")
        print(f"期望: {report['期望抽数']:.1f} 抽")
        print(f"中位数: {report['中位数']:.0f} 抽")
        print(f"90分位: {report['90分位']} 抽")
        print(f"99分位: {report['99分位']} 抽")
        print(f"标准差: {report['标准差']:.1f}")
        cv = report['标准差'] / report['期望抽数']
        print(f"变异系数: {cv:.3f}")

compare_pool_designs()

附录 D:抽卡系统关键术语表

术语英文定义
保底Pity达到一定抽数后保证获得高稀有度物品
硬保底/天井Hard Pity到达指定抽数时100%触发
软保底Soft Pity概率逐步递增的区间
大保底Guaranteed上次歪了后下次必定命中UP
小保底50/50首次出货时的50/50判定
歪了Lost 50/50小保底未命中UP角色
水位Pity Counter当前距离上次出货的累计抽数
UPRate Up概率提升的特定角色/物品
定轨/命定Epitomized Path指定目标的保底路径
常驻Standard/Permanent始终可获得的角色/物品
限定Limited仅在特定时间可获得
命座Constellation同一角色重复获取的强化等级
精炼Refinement同一武器重复获取的强化等级
十连10-Pull一次抽取10次
单抽Single Pull一次抽取1次
井/天井Ceiling保底的最大抽数
PRDPseudo-Random Distribution伪随机分布
星辉/通用碎片Starglitter/Dust抽卡副产物代币
综合概率Consolidated Rate含保底加权的等效概率