抽奖系统是游戏运营中常见的用户激励机制,其核心在于通过精心设计的概率分布与保底机制,在控制成本的同时维持玩家的参与热情。一个合理的抽奖系统需要平衡随机性与确定性:纯随机可能导致极端的”非洲玩家”体验,而过度保底又会削弱随机乐趣。本文将用Java实现一个支持多稀有度、概率权重与保底机制的抽奖模拟器,并分析其数学原理。
抽奖系统的核心设计要素
概率权重分配
现代抽奖系统通常采用权重分配模型。设总权重为W,各奖项权重为w_i,则第i项的中奖概率P_i = w_i / W。这种模型灵活支持任意概率配置,如SSR(1%)、SR(10%)、R(89%)的经典手游设定。
保底机制类型
- 软保底(Pity System):连续N次未获得高稀有度物品后,逐步提升其概率直至100%
- 硬保底(Hard Pity):累计抽取达到固定次数时强制获得高稀有度物品
- 水位继承:保底计数跨卡池继承,保护玩家历史投入
伪随机分布(PRD)
Dota 2率先采用的PRD机制用公式P(N) = C × N动态调整第N次尝试的概率,相比纯独立事件能更平滑地控制方差。常数C满足方程Σ(1-P(i))×P(i) = 目标概率,通常需要数值方法求解。
Java实现
import java.util.*;
/**
* 抽奖模拟器完整实现
* 支持权重概率、软保底、硬保底与PRD伪随机分布
*/
public class GachaSimulator {
/**
* 奖项定义
*/
static class Prize {
final String name;
final String rarity; // SSR, SR, R等
final int weight; // 基础权重
Prize(String name, String rarity, int weight) {
this.name = name;
this.rarity = rarity;
this.weight = weight;
}
@Override
public String toString() {
return String.format("%s[%s]", name, rarity);
}
}
/**
* 保底配置
*/
static class PityConfig {
final String targetRarity; // 目标稀有度
final int softPityStart; // 软保底起始次数
final double softPityBonus; // 每次额外增加的概率
final int hardPity; // 硬保底次数
final boolean resetOnHit; // 中奖后是否重置计数
PityConfig(String targetRarity, int softPityStart,
double softPityBonus, int hardPity, boolean resetOnHit) {
this.targetRarity = targetRarity;
this.softPityStart = softPityStart;
this.softPityBonus = softPityBonus;
this.hardPity = hardPity;
this.resetOnHit = resetOnHit;
}
}
private final List<Prize> prizes;
private final Map<String, PityConfig> pityConfigs;
private final Map<String, Integer> pityCounters; // 各稀有度保底计数
private final Random random;
// 统计信息
private final Map<String, Integer> drawStats;
private int totalDraws;
public GachaSimulator(List<Prize> prizes, List<PityConfig> configs) {
this.prizes = new ArrayList<>(prizes);
this.pityConfigs = new HashMap<>();
this.pityCounters = new HashMap<>();
this.random = new Random();
this.drawStats = new HashMap<>();
this.totalDraws = 0;
for (PityConfig config : configs) {
pityConfigs.put(config.targetRarity, config);
pityCounters.put(config.targetRarity, 0);
}
}
/**
* 计算当前有效权重(考虑保底加成)
*/
private Map<Prize, Double> calculateEffectiveWeights() {
Map<Prize, Double> weights = new HashMap<>();
double totalBaseWeight = prizes.stream().mapToInt(p -> p.weight).sum();
for (Prize prize : prizes) {
weights.put(prize, (double) prize.weight);
}
// 应用软保底加成
for (String rarity : pityConfigs.keySet()) {
PityConfig config = pityConfigs.get(rarity);
int counter = pityCounters.getOrDefault(rarity, 0);
if (counter >= config.softPityStart) {
double bonus = config.softPityBonus * (counter - config.softPityStart + 1);
// 将加成平均分配给目标稀有度的所有奖项
List<Prize> targetPrizes = new ArrayList<>();
for (Prize p : prizes) {
if (p.rarity.equals(rarity)) {
targetPrizes.add(p);
}
}
if (!targetPrizes.isEmpty()) {
double bonusPerPrize = bonus * totalBaseWeight / targetPrizes.size();
for (Prize p : targetPrizes) {
weights.put(p, weights.get(p) + bonusPerPrize);
}
}
}
}
return weights;
}
/**
* 执行单次抽取
* @return 抽中的奖项
*/
public Prize draw() {
totalDraws++;
// 检查硬保底
for (String rarity : pityConfigs.keySet()) {
PityConfig config = pityConfigs.get(rarity);
int counter = pityCounters.getOrDefault(rarity, 0);
if (counter + 1 >= config.hardPity) {
// 触发硬保底
List<Prize> targetPrizes = new ArrayList<>();
for (Prize p : prizes) {
if (p.rarity.equals(rarity)) {
targetPrizes.add(p);
}
}
if (!targetPrizes.isEmpty()) {
Prize prize = targetPrizes.get(random.nextInt(targetPrizes.size()));
updateCounters(prize);
recordStat(prize);
return prize;
}
}
}
// 权重抽奖
Map<Prize, Double> weights = calculateEffectiveWeights();
double totalWeight = weights.values().stream().mapToDouble(Double::doubleValue).sum();
double roll = random.nextDouble() * totalWeight;
double cumulative = 0;
Prize selected = prizes.get(0);
for (Prize prize : prizes) {
cumulative += weights.get(prize);
if (roll <= cumulative) {
selected = prize;
break;
}
}
updateCounters(selected);
recordStat(selected);
return selected;
}
/**
* 更新保底计数器
*/
private void updateCounters(Prize hitPrize) {
for (String rarity : pityConfigs.keySet()) {
PityConfig config = pityConfigs.get(rarity);
int current = pityCounters.getOrDefault(rarity, 0);
if (hitPrize.rarity.equals(rarity) && config.resetOnHit) {
pityCounters.put(rarity, 0);
} else {
pityCounters.put(rarity, current + 1);
}
}
}
private void recordStat(Prize prize) {
drawStats.put(prize.rarity, drawStats.getOrDefault(prize.rarity, 0) + 1);
}
/**
* 批量抽取
*/
public List<Prize> drawMulti(int count) {
List<Prize> results = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
results.add(draw());
}
return results;
}
/**
* 计算PRD常数C(数值方法)
* 满足:目标概率 = Σ(Π(1-C*j) * C*i) for i=1 to N
*/
public static double calculatePRDConstant(double targetProb, int maxAttempts) {
double low = 0, high = 1;
for (int iter = 0; iter < 100; iter++) {
double mid = (low + high) / 2;
double prob = 0;
double noHitProduct = 1;
for (int i = 1; i <= maxAttempts; i++) {
double pThis = Math.min(mid * i, 1.0);
prob += noHitProduct * pThis;
noHitProduct *= (1 - pThis);
}
if (prob < targetProb) {
low = mid;
} else {
high = mid;
}
}
return (low + high) / 2;
}
/**
* 使用PRD进行抽取
*/
public static boolean prdDraw(double targetProb, int maxAttempts, Random random) {
double c = calculatePRDConstant(targetProb, maxAttempts);
for (int i = 1; i <= maxAttempts; i++) {
double p = Math.min(c * i, 1.0);
if (random.nextDouble() < p) {
return true;
}
}
return false;
}
public Map<String, Integer> getStats() { return new HashMap<>(drawStats); }
public int getTotalDraws() { return totalDraws; }
public Map<String, Integer> getPityCounters() { return new HashMap<>(pityCounters); }
public static void main(String[] args) {
// 配置奖项池
List<Prize> prizes = Arrays.asList(
new Prize("传说之剑", "SSR", 10),
new Prize("史诗护甲", "SR", 100),
new Prize("精良戒指", "R", 500),
new Prize("普通药水", "N", 1390)
);
// 配置保底:SSR在80抽开始软保底,每抽+5%,90抽硬保底
List<PityConfig> configs = Arrays.asList(
new PityConfig("SSR", 80, 0.05, 90, true)
);
GachaSimulator simulator = new GachaSimulator(prizes, configs);
// 模拟10000次十连抽
int tenDrawCount = 10000;
Map<String, Long> rarityCount = new HashMap<>();
int ssrObtained = 0;
List<Integer> ssrIntervals = new ArrayList<>();
int lastSSRDraw = 0;
for (int i = 0; i < tenDrawCount * 10; i++) {
Prize p = simulator.draw();
rarityCount.merge(p.rarity, 1L, Long::sum);
if (p.rarity.equals("SSR")) {
ssrObtained++;
ssrIntervals.add(i - lastSSRDraw);
lastSSRDraw = i;
}
}
System.out.println("=== 抽奖模拟统计 ===");
System.out.println("总抽取次数: " + simulator.getTotalDraws());
System.out.println("\n稀有度分布:");
rarityCount.forEach((rarity, count) ->
System.out.printf(" %s: %,d (%.3f%%)%n", rarity, count, count * 100.0 / simulator.getTotalDraws()));
System.out.println("\nSSR获取统计:");
System.out.println(" 总获得数: " + ssrObtained);
if (!ssrIntervals.isEmpty()) {
double avgInterval = ssrIntervals.stream().mapToInt(Integer::intValue).average().orElse(0);
int maxInterval = ssrIntervals.stream().mapToInt(Integer::intValue).max().orElse(0);
System.out.printf(" 平均间隔: %.2f抽%n", avgInterval);
System.out.println(" 最大间隔: " + maxInterval + "抽");
}
// PRD演示
System.out.println("\n=== PRD伪随机演示 ===");
double target = 0.20; // 20%目标概率
double c = calculatePRDConstant(target, 10);
System.out.printf("目标概率 %.0f%% 的PRD常数C = %.6f%n", target * 100, c);
Random rand = new Random(42);
int prdSuccess = 0;
for (int i = 0; i < 100000; i++) {
if (prdDraw(target, 10, rand)) {
prdSuccess++;
}
}
System.out.printf("PRD 10万次模拟实际成功率: %.4f%n", prdSuccess / 100000.0);
}
}
复杂度分析
- 单次抽取:权重计算O(R)(R为奖项数量),抽奖选择O(R),总体O(R)
- 批量抽取N次:O(N × R)
- PRD常数计算:二分搜索100次 × maxAttempts,O(maxAttempts)
- 空间复杂度:O(R + C)(C为保底配置数量)
设计要点总结
抽奖系统的核心挑战在于方差控制。纯独立事件下,SSR的理论获取间隔为1/P_SSR抽,但标准差同样巨大。软保底通过动态概率有效压缩方差上界,硬保底则彻底消除极端情况。PRD机制以数学优雅的方式实现更平滑的概率曲线,是现代游戏设计的推荐方案。开发者应根据业务目标在”刺激感”与”公平感”之间寻找最佳平衡点。