13. 抽奖与抽卡系统
2026/4/13大约 12 分钟
抽奖与抽卡系统
上一章讲了游戏运营的整体框架。本章深入讲解目前最流行的变现手段之一——抽奖与抽卡(Gacha)系统。
几乎每个成功的免费游戏都有抽卡系统:花虚拟货币(或真钱)"抽"随机物品,有概率获得稀有奖励。这个系统做好了能赚钱,做差了会毁口碑。
什么是抽卡系统
打个比方:你去买盲盒。每个盲盒 59 元,里面随机放一个手办,有普通款、隐藏款、限定款。你不知道自己会抽到哪个,但隐藏款的概率只有 1%,所以你会忍不住一直买,想抽到那个最稀有的。
抽卡系统就是游戏里的"盲盒":
| 现实盲盒 | 游戏抽卡 |
|---|---|
| 59 元买一个盲盒 | 花 150 钻石抽一次 |
| 有概率抽到隐藏款 | 有概率抽到 SSR 角色 |
| 隐藏款概率 1% | SSR 概率 1.5% |
| 买 10 个可能有 1 个隐藏款 | 10 连抽可能有 1 个 SSR |
抽卡系统的核心要素
1. 卡池设计
卡池就是"你可以抽到哪些东西"的集合:
| 卡池类型 | 说明 | 举例 |
|---|---|---|
| 常驻池 | 永久存在的卡池 | 基础角色池、武器池 |
| 限定池 | 限时开放的卡池 | "春节限定"角色,活动结束后下架 |
| 新手池 | 只对新玩家开放的优惠池 | 首次半价、必出 SR 以上 |
| 置换池 | 用特定道具兑换的池子 | 用"碎片"换指定角色 |
2. 稀有度分级
| 稀有度 | 常见名称 | 概率(典型值) | 视觉表现 |
|---|---|---|---|
| N | 普通 | 50%-60% | 灰色/白色边框 |
| R | 稀有 | 25%-35% | 蓝色边框 |
| SR | 超稀有 | 8%-12% | 紫色边框 |
| SSR | 极稀有 | 1%-3% | 金色边框 + 特效 |
3. 保底机制
保底是抽卡系统最重要的"安全阀"——保证玩家抽够一定次数后一定能拿到好东西。
| 保底类型 | 说明 | 举例 |
|---|---|---|
| 硬保底 | 达到次数必出最高稀有度 | 90 抽必出 SSR |
| 软保底 | 随着次数增加,概率逐渐提升 | 从第 70 抽开始 SSR 概率每抽 +2% |
| 大保底 | 如果没抽到当期限定,下次限定池必出 | 180 抽没出限定角色,下次限定池必出 |
| 兑换保底 | 抽到的重复物品可以兑换指定物品 | 200 个碎片换一个指定 SSR |
没有保底的抽卡系统是不道德的
想象一下:一个玩家花了 1000 元抽了 300 次都没出 SSR——这会让他非常愤怒,直接卸载游戏并给差评。保底机制保护玩家也保护你自己。
在设计抽卡系统时,保底是必须的,不是可选的。
抽卡概率的数据模型
基础概率设计
C
// 抽卡系统配置
using Godot;
using System.Collections.Generic;
public class GachaConfig
{
// 稀有度配置
public class RarityConfig
{
public string Rarity { get; set; } // N, R, SR, SSR
public float BaseRate { get; set; } // 基础概率(百分比)
public int SoftPityStart { get; set; } // 软保底开始次数(0=不启用)
public float PityBonus { get; set; } // 每次软保底增加的概率
}
// 卡池配置
public class PoolConfig
{
public string PoolId { get; set; }
public string PoolName { get; set; }
public int Cost { get; set; } // 单抽消耗
public int TenPullDiscount { get; set; } // 十连抽消耗(0=不优惠)
public int HardPity { get; set; } // 硬保底次数
public bool HasGuaranteedSsr { get; set; } // 是否有 SSR 保底
public List<RarityConfig> Rarities { get; set; }
}
// 示例:标准角色池配置
public static PoolConfig StandardPool => new PoolConfig
{
PoolId = "standard_character",
PoolName = "常驻角色祈愿",
Cost = 160,
TenPullDiscount = 1440, // 十连 9 折
HardPity = 90,
HasGuaranteedSsr = true,
Rarities = new List<RarityConfig>
{
new() { Rarity = "N", BaseRate = 55.0f, SoftPityStart = 0, PityBonus = 0 },
new() { Rarity = "R", BaseRate = 33.0f, SoftPityStart = 0, PityBonus = 0 },
new() { Rarity = "SR", BaseRate = 10.0f, SoftPityStart = 0, PityBonus = 0 },
new() { Rarity = "SSR", BaseRate = 1.5f, SoftPityStart = 73, PityBonus = 6.0f }
}
};
}GDScript
# 抽卡系统配置
class_name GachaConfig
## 稀有度配置
class RarityConfig:
var rarity: String # N, R, SR, SSR
var base_rate: float # 基础概率(百分比)
var soft_pity_start: int # 软保底开始次数(0=不启用)
var pity_bonus: float # 每次软保底增加的概率
## 卡池配置
class PoolConfig:
var pool_id: String
var pool_name: String
var cost: int # 单抽消耗
var ten_pull_discount: int # 十连抽消耗(0=不优惠)
var hard_pity: int # 硬保底次数
var has_guaranteed_ssr: bool # 是否有 SSR 保底
var rarities: Array[RarityConfig] = []
## 示例:标准角色池配置
static func standard_pool() -> PoolConfig:
var config = PoolConfig.new()
config.pool_id = "standard_character"
config.pool_name = "常驻角色祈愿"
config.cost = 160
config.ten_pull_discount = 1440 # 十连 9 折
config.hard_pity = 90
config.has_guaranteed_ssr = true
var n = RarityConfig.new()
n.rarity = "N"
n.base_rate = 55.0
n.soft_pity_start = 0
n.pity_bonus = 0
var r = RarityConfig.new()
r.rarity = "R"
r.base_rate = 33.0
r.soft_pity_start = 0
r.pity_bonus = 0
var sr = RarityConfig.new()
sr.rarity = "SR"
sr.base_rate = 10.0
sr.soft_pity_start = 0
sr.pity_bonus = 0
var ssr = RarityConfig.new()
ssr.rarity = "SSR"
ssr.base_rate = 1.5
ssr.soft_pity_start = 73
ssr.pity_bonus = 6.0
config.rarities = [n, r, sr, ssr]
return config抽卡逻辑实现
C
// 抽卡核心逻辑
using Godot;
using System.Collections.Generic;
using System.Linq;
public partial class GachaSystem : Node
{
// 玩家的抽卡记录(存储在服务器上)
private Dictionary<string, int> _pityCounters = new(); // 卡池ID → 已抽数
private Dictionary<string, bool> _guaranteedSsr = new(); // 卡池ID → 是否触发大保底
// 执行单次抽卡
public GachaResult Pull(GachaConfig.PoolConfig pool, string playerId)
{
// 1. 更新保底计数
if (!_pityCounters.ContainsKey(pool.PoolId))
_pityCounters[pool.PoolId] = 0;
_pityCounters[pool.PoolId]++;
int pityCount = _pityCounters[pool.PoolId];
// 2. 计算实际概率(考虑软保底)
var actualRates = new Dictionary<string, float>();
foreach (var rarity in pool.Rarities)
{
float rate = rarity.BaseRate;
// 软保底:达到起始次数后,每抽增加概率
if (rarity.SoftPityStart > 0 && pityCount >= rarity.SoftPityStart)
{
int extraPulls = pityCount - rarity.SoftPityStart;
rate += extraPulls * rarity.PityBonus;
}
actualRates[rarity.Rarity] = rate;
}
// 3. 硬保底检查
if (pool.HardPity > 0 && pityCount >= pool.HardPity)
{
// 硬保底:直接给最高稀有度
_pityCounters[pool.PoolId] = 0;
_guaranteedSsr[pool.PoolId] = false;
var result = new GachaResult
{
Rarity = "SSR",
IsPity = true,
PityCount = pityCount
};
result.Item = SelectRandomItem(pool, "SSR");
return result;
}
// 4. 概率归一化(软保底可能导致总概率超过 100%)
float totalRate = actualRates.Values.Sum();
var random = GD.Randf() * totalRate;
// 5. 按概率抽取稀有度
string selectedRarity = "N";
float cumulative = 0;
// 从高稀有度到低稀有度检查
foreach (var rarity in pool.Rarities.OrderByDescending(r => r.BaseRate))
{
cumulative += actualRates[rarity.Rarity];
if (random <= cumulative)
{
selectedRarity = rarity.Rarity;
break;
}
}
// 6. 抽到最高稀有度时,重置保底计数
bool isSsr = selectedRarity == "SSR";
if (isSsr)
{
_pityCounters[pool.PoolId] = 0;
// 限定池大保底逻辑:如果之前没出过限定,这次必须出限定
if (_guaranteedSsr.GetValueOrDefault(pool.PoolId, false))
{
// 标记为大保底出金
_guaranteedSsr[pool.PoolId] = false;
}
}
// 7. 从对应稀有度的物品中随机选一个
var result2 = new GachaResult
{
Rarity = selectedRarity,
IsPity = false,
PityCount = pityCount
};
result2.Item = SelectRandomItem(pool, selectedRarity);
return result2;
}
// 十连抽
public List<GachaResult> PullTen(GachaConfig.PoolConfig pool, string playerId)
{
var results = new List<GachaResult>();
for (int i = 0; i < 10; i++)
{
results.Add(Pull(pool, playerId));
}
return results;
}
// 从指定稀有度的物品池中随机选一个
private GachaItem SelectRandomItem(GachaConfig.PoolConfig pool, string rarity)
{
// 实际实现中从数据库/配置表中获取对应稀有度的物品列表
// 这里返回一个模拟物品
return new GachaItem
{
Id = $"{rarity}_{GD.Randi()}",
Name = $"模拟{rarity}物品",
Rarity = rarity
};
}
}
// 抽卡结果
public class GachaResult
{
public string Rarity { get; set; }
public GachaItem Item { get; set; }
public bool IsPity { get; set; } // 是否触发保底
public int PityCount { get; set; } // 当前已抽数
}
// 抽卡物品
public class GachaItem
{
public string Id { get; set; }
public string Name { get; set; }
public string Rarity { get; set; }
}GDScript
# 抽卡核心逻辑
extends Node
## 玩家的抽卡记录(存储在服务器上)
var _pity_counters: Dictionary = {} # 卡池ID → 已抽数
var _guaranteed_ssr: Dictionary = {} # 卡池ID → 是否触发大保底
## 执行单次抽卡
func pull(pool: GachaConfig.PoolConfig, player_id: String) -> Dictionary:
# 1. 更新保底计数
if not _pity_counters.has(pool.pool_id):
_pity_counters[pool.pool_id] = 0
_pity_counters[pool.pool_id] += 1
var pity_count = _pity_counters[pool.pool_id]
# 2. 计算实际概率(考虑软保底)
var actual_rates = {}
for rarity in pool.rarities:
var rate = rarity.base_rate
# 软保底:达到起始次数后,每抽增加概率
if rarity.soft_pity_start > 0 and pity_count >= rarity.soft_pity_start:
var extra_pulls = pity_count - rarity.soft_pity_start
rate += extra_pulls * rarity.pity_bonus
actual_rates[rarity.rarity] = rate
# 3. 硬保底检查
if pool.hard_pity > 0 and pity_count >= pool.hard_pity:
# 硬保底:直接给最高稀有度
_pity_counters[pool.pool_id] = 0
_guaranteed_ssr[pool.pool_id] = false
return {
"rarity": "SSR",
"item": _select_random_item(pool, "SSR"),
"is_pity": true,
"pity_count": pity_count
}
# 4. 概率归一化(软保底可能导致总概率超过 100%)
var total_rate = 0.0
for r in actual_rates.values():
total_rate += r
var random = randf() * total_rate
# 5. 按概率抽取稀有度(从高稀有度到低)
var sorted_rarities = pool.rarities.duplicate()
sorted_rarities.sort_custom(func(a, b): return a.base_rate > b.base_rate)
var selected_rarity = "N"
var cumulative = 0.0
for rarity in sorted_rarities:
cumulative += actual_rates[rarity.rarity]
if random <= cumulative:
selected_rarity = rarity.rarity
break
# 6. 抽到最高稀有度时,重置保底计数
var is_ssr = selected_rarity == "SSR"
if is_ssr:
_pity_counters[pool.pool_id] = 0
if _guaranteed_ssr.get(pool.pool_id, false):
_guaranteed_ssr[pool.pool_id] = false
# 7. 从对应稀有度的物品池中随机选一个
return {
"rarity": selected_rarity,
"item": _select_random_item(pool, selected_rarity),
"is_pity": false,
"pity_count": pity_count
}
## 十连抽
func pull_ten(pool: GachaConfig.PoolConfig, player_id: String) -> Array:
var results = []
for i in range(10):
results.append(pull(pool, player_id))
return results
## 从指定稀有度的物品池中随机选一个
func _select_random_item(pool: GachaConfig.PoolConfig, rarity: String) -> Dictionary:
# 实际实现中从数据库/配置表中获取对应稀有度的物品列表
return {
"id": "%s_%d" % [rarity, randi()],
"name": "模拟%s物品" % rarity,
"rarity": rarity
}客户端抽卡动画
抽卡系统不仅是概率计算,动画表现同样重要——一个好的抽卡动画能让玩家"爽感"加倍。
抽卡动画流程
点击"祈愿" → 扣除钻石 → 全屏动画开始 → 光效/粒子特效
→ 翻牌/开箱动画 → 显示结果(从低到高展示)
→ SSR 出现时:特殊音效 + 金色特效 + 震屏 + 角色登场
→ 10 连抽:逐个揭示,最后压轴展示最好的那个C
// 抽卡动画控制器
using Godot;
using System.Collections.Generic;
using System.Threading.Tasks;
public partial class GachaAnimation : Control
{
[Export] public AnimationPlayer Animator;
[Export] public Control ResultContainer;
[Export] public AudioStream SsrSoundEffect;
// 播放单抽动画
public async void PlaySingleAnimation(GachaResult result)
{
// 1. 播放开场动画
Animator.Play("pull_start");
await ToSignal(Animator, AnimationPlayer.SignalName.AnimationFinished);
// 2. 根据稀有度播放不同动画
string animName = result.Rarity switch
{
"SSR" => "reveal_ssr",
"SR" => "reveal_sr",
_ => "reveal_normal"
};
Animator.Play(animName);
// SSR 特殊效果
if (result.Rarity == "SSR")
{
PlaySsrEffects();
}
await ToSignal(Animator, AnimationPlayer.SignalName.AnimationFinished);
// 3. 显示结果
ShowResult(result);
}
// 播放十连抽动画(逐个揭示)
public async void PlayTenAnimation(List<GachaResult> results)
{
// 1. 开场动画
Animator.Play("pull_ten_start");
await ToSignal(Animator, AnimationPlayer.SignalName.AnimationFinished);
// 2. 按稀有度排序,最好的放最后
results.Sort((a, b) => GetRarityOrder(a.Rarity) - GetRarityOrder(b.Rarity));
// 3. 逐个揭示
for (int i = 0; i < results.Count; i++)
{
var card = CreateResultCard(results[i]);
ResultContainer.AddChild(card);
// 快速揭示普通卡,慢速揭示稀有卡
float delay = results[i].Rarity switch
{
"SSR" => 1.5f,
"SR" => 0.8f,
_ => 0.3f
};
if (results[i].Rarity == "SSR" && i == results.Count - 1)
{
// 最后一张是 SSR,播放压轴动画
PlaySsrEffects();
Animator.Play("reveal_ssr_finale");
}
await ToSignal(GetTree().CreateTimer(delay), SceneTreeTimer.SignalName.Timeout);
}
}
private void PlaySsrEffects()
{
// 播放 SSR 特效:音效 + 屏幕闪光 + 震动
var audioPlayer = new AudioStreamPlayer();
audioPlayer.Stream = SsrSoundEffect;
AddChild(audioPlayer);
audioPlayer.Play();
// 屏幕震动效果
var tween = CreateTween();
tween.SetLoops(5);
tween.TweenProperty(this, "position",
new Vector2((float)GD.RandRange(-3, 3), (float)GD.RandRange(-3, 3)), 0.05);
}
private int GetRarityOrder(string rarity) => rarity switch
{
"SSR" => 0,
"SR" => 1,
"R" => 2,
_ => 3
};
private void ShowResult(GachaResult result)
{
// 在 UI 上显示抽到的物品
var label = new Label();
label.Text = $"获得了 {result.Item.Name}({result.Rarity})";
if (result.Rarity == "SSR")
label.AddThemeColorOverride("font_color", new Color(1, 0.84f, 0));
ResultContainer.AddChild(label);
}
private Control CreateResultCard(GachaResult result)
{
var card = new PanelContainer();
var label = new Label();
label.Text = $"{result.Item.Name}\n{result.Rarity}";
card.AddChild(label);
// 根据稀有度设置边框颜色
var color = result.Rarity switch
{
"SSR" => new Color(1, 0.84f, 0), // 金色
"SR" => new Color(0.7f, 0.3f, 1), // 紫色
"R" => new Color(0.2f, 0.6f, 1), // 蓝色
_ => new Color(0.7f, 0.7f, 0.7f) // 灰色
};
card.AddThemeStyleboxOverride("panel",
CreateColoredStylebox(color));
return card;
}
private StyleBoxFlat CreateColoredStylebox(Color color)
{
var style = new StyleBoxFlat();
style.BorderColor = color;
style.SetBorderWidthAll(3);
style.SetContentMarginAll(10);
return style;
}
}GDScript
# 抽卡动画控制器
extends Control
@onready var animator: AnimationPlayer = $AnimationPlayer
@onready var result_container: Control = $ResultContainer
@export var ssr_sound_effect: AudioStream
## 播放单抽动画
func play_single_animation(result: Dictionary) -> void:
# 1. 播放开场动画
animator.play("pull_start")
await animator.animation_finished
# 2. 根据稀有度播放不同动画
var anim_name = match result.rarity:
"SSR": "reveal_ssr"
"SR": "reveal_sr"
_: "reveal_normal"
animator.play(anim_name)
# SSR 特殊效果
if result.rarity == "SSR":
_play_ssr_effects()
await animator.animation_finished
# 3. 显示结果
_show_result(result)
## 播放十连抽动画(逐个揭示)
func play_ten_animation(results: Array) -> void:
# 1. 开场动画
animator.play("pull_ten_start")
await animator.animation_finished
# 2. 按稀有度排序,最好的放最后
results.sort_custom(func(a, b):
return _get_rarity_order(a.rarity) < _get_rarity_order(b.rarity)
)
# 3. 逐个揭示
for i in range(results.size()):
var card = _create_result_card(results[i])
result_container.add_child(card)
# 快速揭示普通卡,慢速揭示稀有卡
var delay = match results[i].rarity:
"SSR": 1.5
"SR": 0.8
_: 0.3
if results[i].rarity == "SSR" and i == results.size() - 1:
# 最后一张是 SSR,播放压轴动画
_play_ssr_effects()
animator.play("reveal_ssr_finale")
await get_tree().create_timer(delay).timeout
func _play_ssr_effects():
# 播放 SSR 特效:音效 + 屏幕闪光 + 震动
var audio_player = AudioStreamPlayer.new()
audio_player.stream = ssr_sound_effect
add_child(audio_player)
audio_player.play()
# 屏幕震动效果
var tween = create_tween()
tween.set_loops(5)
tween.tween_property(self, "position",
Vector2(randf_range(-3, 3), randf_range(-3, 3)), 0.05)
func _get_rarity_order(rarity: String) -> int:
match rarity:
"SSR": return 0
"SR": return 1
"R": return 2
_: return 3
func _show_result(result: Dictionary):
var label = Label.new()
label.text = "获得了 %s(%s)" % [result.item.name, result.rarity]
if result.rarity == "SSR":
label.add_theme_color_override("font_color", Color(1, 0.84, 0))
result_container.add_child(label)
func _create_result_card(result: Dictionary) -> Control:
var card = PanelContainer.new()
var label = Label.new()
label.text = "%s\n%s" % [result.item.name, result.item.rarity]
card.add_child(label)
# 根据稀有度设置边框颜色
var color = match result.rarity:
"SSR": Color(1, 0.84, 0) # 金色
"SR": Color(0.7, 0.3, 1) # 紫色
"R": Color(0.2, 0.6, 1) # 蓝色
_: Color(0.7, 0.7, 0.7) # 灰色
var style = StyleBoxFlat.new()
style.border_color = color
style.set_border_width_all(3)
style.set_content_margin_all(10)
card.add_theme_stylebox_override("panel", style)
return card法规与道德
中国法规要求
如果你在中国大陆运营游戏,抽卡系统必须遵守以下法规:
| 法规 | 要求 |
|---|---|
| 公示概率 | 必须在游戏内公示每个稀有度的抽取概率 |
| 公示物品池 | 必须公示每个稀有度下有哪些物品及其概率 |
| 保底机制 | 必须有保底机制(不能无限抽都不出) |
| 未成年人限制 | 未成年人每日充值限额、每月充值限额 |
| 实名认证 | 所有玩家必须实名认证 |
道德设计原则
| 原则 | 说明 |
|---|---|
| 不诱导未成年人消费 | 未成年人消费限额、二次确认 |
| 概率真实透明 | 公示的概率和实际概率一致 |
| 不利用心理弱点 | 不设计"差一点就出"的假动画 |
| 提供退款渠道 | 误操作充值可以申请退款 |
切勿造假概率
有些不良游戏会在 UI 上显示一个概率,但实际代码里用的是另一个概率。这不仅是道德问题,更是违法行为。在中国,被发现公示概率与实际不符,会被罚款甚至下架。
抽卡系统检查清单
下一章
抽卡系统设计好了。接下来进入技术运维层面——怎么安全地把新版本推给所有玩家。
