做java面试整理的时候,我顺手给自己写了个小项目,叫“java金铲铲抽卡模拟器”。起因其实很简单,网上逛帖时总能看到有人拿“抽卡机制”当算法题来问,比如“设计一个带保底的抽奖系统”,而金铲铲(云顶之弈的手游)这种自走棋玩法,抽卡(搜棋子)就是最核心的机制,拿来练手再合适不过。所以我就用纯Java写了套能跑通的抽卡模拟器,把卡池、概率、保底、十连抽、统计验证都做了进去,整个过程下来,对Java集合、随机算法、面向对象设计、并发模拟这些基础知识的理解都扎实了不少。这篇博文就把我的项目完整拆解一遍,从需求设计到核心代码,再到我踩过的几个坑,都摊开来说。
1. 项目思路拆解:为什么选抽卡模拟器练Java
1.1 从“游戏机制”到“程序需求”的翻译过程
金铲铲里的搜棋子,本质是一个“带概率的带约束的抽取过程”:每次刷新商店会随机出5个英雄棋子,不同费用(1费到5费)的棋子出现概率跟玩家当前等级挂钩,同时卡池是有限共享的。翻译成程序需求,就是这几件事:
- 定义一组棋子对象,每个棋子有名字、费用、种族、职业这些属性。
- 维护一个卡池,按费用分成5个档位,每档里有若干棋子。
- 根据玩家当前等级,计算本次抽卡时“先抽费用档位、再抽该费用下具体棋子”的两级概率。
- 加入保底逻辑:连续多少抽没出高费卡,下一次强制抬高概率或直接指定出。
- 支持单抽、十连抽,并能记录抽卡历史,方便后期统计数据验证概率是否合理。
这个翻译过程本身就是很好的面向对象练习:棋子和卡池是天然的对象,概率配置是数据,抽取动作是服务,计数器是状态。比单纯背“封装继承多态”的八股文要有感觉得多。
1.2 功能边界与项目规模控制
我不建议一开始就把项目做“大”,什么背包系统、抽卡动画、账号体系全加进来,那样核心逻辑反而会被冲淡。我这个模拟器圈定了这些范围:
- 核心功能:单抽、十连抽、保底计数、概率展示、批量模拟统计。
- 数据来源:用Java的
record或普通类硬编码一批棋子名,不接数据库。 - 交互方式:第一步是纯控制台命令行跑,跑通后再考虑要不要加GUI。
- 扩展预留:卡池配置、概率配置独立出来,后续想接JSON、想改参数都不动核心代码。
这个范围对新手特别友好,一两天就能写完,但又足够塞下泛型、枚举、集合、异常处理、随机算法这些高频面试点。
2. 卡池与概率建模:先把数据结构和规则定清楚
2.1 用枚举管理费用档位,而不是魔法数字
费用是棋子的核心属性,直接写死int cost = 4的问题在于:代码里到处都是魔法数字,后期改规则容易漏。我用了枚举加恒定配置的方式:
public enum CostLevel { ONE(1), TWO(2), THREE(3), FOUR(4), FIVE(5); private final int value; CostLevel(int value) { this.value = value; } public int getValue() { return value; } }这里枚举不仅做类型约束,还能携带每种费用对应的棋子列表、基础权重,比散落的常量清晰得多。实际用的时候,我会用一个HashMap<CostLevel, List<Champion>>来维护每个费用档下的棋子集合,加棋子就是往对应列表里put,删棋子也方便。
2.2 概率配置表:不同等级对应不同抽卡概率
金铲铲里,等级越高,高费棋子的刷新概率越大。我整理了一个示例概率表(参数可调整,不代表游戏真实数值):
| 玩家等级区间 | 1费 | 2费 | 3费 | 4费 | 5费 |
|---|---|---|---|---|---|
| 等级1-2 | 80% | 20% | 0% | 0% | 0% |
| 等级3-4 | 60% | 30% | 10% | 0% | 0% |
| 等级5-6 | 40% | 30% | 20% | 10% | 0% |
| 等级7-8 | 20% | 25% | 30% | 20% | 5% |
| 等级9+ | 10% | 20% | 30% | 30% | 10% |
这张表用Java表达,最直观的方式是一个嵌套Map:外层key是等级区间,内层key是费用档位,value是概率百分比。但我实际写的时候发现,用百分比浮点数要注意精度问题,比如80%写成0.8,五个档位加起来要等于1.0(或100),浮点数之间做“恰好相等”判断特别容易踩坑。所以我在代码里全部换算成double,并且只在初始化时做一次归一化校验,运行时不判断相等。
2.3 为什么不用一堆if-else写概率判断
新手最爱写的概率逻辑长这样:
double r = Math.random(); if (r < 0.4) { // 出1费 } else if (r < 0.65) { // 出2费 }这段的问题在于:概率一变就要改代码结构;多个档位时else if链又臭又长;想根据等级动态换概率根本不好扩展。我换成“权重列表 + 区间累加”的写法,把概率和业务彻底分开。后面专门讲算法,这里先记住一句话:概率是数据,不是代码逻辑。
3. 抽卡算法与保底机制:随机数的正确打开方式
3.1 Random、ThreadLocalRandom、SecureRandom怎么选
Java里生成随机数有三种常用方式,很多人没搞清区别就直接用,结果要么性能差、要么可复现性差、要么并发下有坑:
java.util.Random:常见但每次new Random()会消耗系统熵源,并发竞争激烈时性能差。java.util.concurrent.ThreadLocalRandom:每个线程独立随机种子,并发模拟抽卡时首选,性能好,使用方式上注意它是静态工厂方法,没有实例构造。java.security.SecureRandom:加密级别随机数,用在抽卡上属于杀鸡用牛刀,而且速度慢,除非你要做“开奖不可预测”的公平性场景,否则不用。
我做的模拟器既有单线程跑,也支持多线程批量模拟,所以核心用ThreadLocalRandom.current().nextDouble(),既快又不会有线程争抢种子的问题。如果哪天想复现某个实验,就换回new Random(固定种子),这也是Random的一个优势:可以重现随机序列。
3.2 加权随机算法:从线性扫描到性能优化
抽卡要先定费用档位,费用档位带的概率本质上是一个“加权随机”问题。我在项目里先写了一个通用的加权随机工具,思想是:把每个档位的权重累加成一个区间,然后生成一个0到总权重之间的随机数,落在哪个区间就选哪个档位。
public class WeightedRandom { /** * 根据权重列表随机选择一个索引 * @param weights 权重列表,顺序与候选列表一致 * @return 选中元素的索引 */ public static int pickIndex(double[] weights) { double total = 0.0; for (double w : weights) { total += w; } double r = ThreadLocalRandom.current().nextDouble(total); double cumulative = 0.0; for (int i = 0; i < weights.length; i++) { cumulative += weights[i]; if (r < cumulative) { return i; } } // 兜底,防浮点累计误差导致没选中 return weights.length - 1; } }这个版本的时间复杂度是O(n),对5个费用档位来说完全够用。如果你的卡池档位有成百上千个(比如某些游戏几百个角色按稀有度再细分),就可以用别名采样法(Alias Method),把单次随机降到O(1),核心原理是把非均匀概率分布转换成一个二维矩形区域,每次随机两次就可以定位。不过抽卡模拟器这个场景,线性扫描足够,别过度设计。
3.3 保底机制:状态计数器的设计
保底是抽卡系统的灵魂。我设计了一个“全局保底+档位软保底”的简化模型:
- 全局保底:连续49次没出5费棋子,第50次强制出5费。
- 软保底:从第30次开始,每多抽一次,5费概率累加5个百分点,直到触发或出了5费后重置。
这个设计比单纯“N抽必出”更接近金铲铲里那种“越抽越容易出”的手感。实现上需要一个计数器变量pityCounter,每次抽卡时先判断:
if (pityCounter >= HARD_PITY_LIMIT) { // 必然走5费档位 return drawFromCostLevel(CostLevel.FIVE); } double fiveStarRate = baseFiveRate; if (pityCounter >= SOFT_PITY_START) { int extraTimes = pityCounter - SOFT_PITY_START + 1; fiveStarRate += extraTimes * SOFT_PITY_INCREASE; }这里有几个容易踩的坑:计数器必须在成功抽到目标费用后清零,而不是抽到任意卡都清零;软保底的概率累加值要控制在合理范围内,不能累加到超过100%;如果开了多线程做批量模拟,计数器的读写需要考虑线程安全,最简单方案是模拟开始时给每个线程独立的模拟器实例。
3.4 完整抽卡流程伪代码
整体抽取逻辑按“先费用、再棋子”的两步走:
- 接收一个玩家等级参数。
- 根据等级查概率表,拿到5个费用档位的概率数组。
- 判断保底计数器,如果触发硬保底,费用档位直接指定为5费;否则用加权随机选一个费用档位。
- 从选中的费用档位对应的棋子列表中,均匀随机选一个具体棋子。
- 如果出的是5费棋子,重置计数器;否则计数器加1。
- 返回本次抽到的棋子对象,并把记录追加到历史列表。
4. 完整代码落地:项目结构与核心类实现
4.1 项目目录结构
我用Maven管理,虽然代码量不大,但目录规范起来后续加功能或写单元测试都方便:
gacha-simulator/ ├── pom.xml ├── src/ │ ├── main/ │ │ └── java/ │ │ └── com/gacha/ │ │ ├── model/ │ │ │ ├── Champion.java │ │ │ └── CostLevel.java │ │ ├── pool/ │ │ │ └── CardPool.java │ │ ├── service/ │ │ │ ├── GachaService.java │ │ │ └── SimulationRunner.java │ │ └── util/ │ │ └── WeightedRandom.java │ └── test/ │ └── java/ │ └── com/gacha/ │ └── GachaServiceTest.java4.2 棋子和卡池的数据类
棋子用record定义,Java 16+可以直接用,简洁且天然带equals、hashCode和toString:
public record Champion(String name, CostLevel cost, String origin, String trait) { @Override public String toString() { return name + "(" + cost.getValue() + "费)[" + origin + "/" + trait + "]"; } }卡池负责加载配置和维护每个费用档位下的棋子集合:
public class CardPool { private final Map<CostLevel, List<Champion>> pool = new EnumMap<>(CostLevel.class); public void addChampion(Champion champion) { pool.computeIfAbsent(champion.cost(), k -> new ArrayList<>()) .add(champion); } public List<Champion> getChampionsByCost(CostLevel cost) { return pool.getOrDefault(cost, Collections.emptyList()); } public Map<CostLevel, List<Champion>> getPool() { return pool; } }我特意用了EnumMap而不是HashMap,因为枚举作为key时,EnumMap内部用数组存储,遍历性能更好,而且天然按下标顺序迭代,方便后面按1到5费的顺序输出概率表。
4.3 抽卡服务实现:核心业务逻辑
public class GachaService { private static final int HARD_PITY_LIMIT = 50; private static final int SOFT_PITY_START = 30; private static final double SOFT_PITY_INCREASE = 0.05; private final CardPool cardPool; private final ProbabilityTable probabilityTable; private int pityCounter = 0; public GachaService(CardPool cardPool, ProbabilityTable probabilityTable) { this.cardPool = cardPool; this.probabilityTable = probabilityTable; } public Champion drawSingle(int playerLevel) { double[] costRates = probabilityTable.getRatesByLevel(playerLevel); CostLevel selectedCost; if (pityCounter >= HARD_PITY_LIMIT - 1) { selectedCost = CostLevel.FIVE; } else { double[] adjustedRates = applySoftPity(costRates); int costIndex = WeightedRandom.pickIndex(adjustedRates); selectedCost = CostLevel.values()[costIndex]; } List<Champion> candidates = cardPool.getChampionsByCost(selectedCost); if (candidates.isEmpty()) { throw new IllegalStateException("卡池中没有费用为" + selectedCost.getValue() + "的棋子"); } int randomIndex = ThreadLocalRandom.current().nextInt(candidates.size()); Champion drawn = candidates.get(randomIndex); if (selectedCost == CostLevel.FIVE) { pityCounter = 0; } else { pityCounter++; } return drawn; } public List<Champion> drawTen(int playerLevel) { List<Champion> result = new ArrayList<>(10); for (int i = 0; i < 10; i++) { result.add(drawSingle(playerLevel)); } return result; } private double[] applySoftPity(double[] rates) { if (pityCounter < SOFT_PITY_START) { return rates; } double[] adjusted = rates.clone(); int extraTimes = pityCounter - SOFT_PITY_START + 1; double bonus = extraTimes * SOFT_PITY_INCREASE; adjusted[4] = Math.min(adjusted[4] + bonus, 1.0); return adjusted; } }注意我用了adjusted[4]直接对应5费档位,因为CostLevel.values()的顺序刚好从ONE到FIVE,索引0到4。这个写法效率高,但有个隐患:如果以后Enum的顺序变了,或者加了新的费用档位,这里就会出错。所以我稍后会说怎么用枚举的ordinal()和概率表解耦,这里是刻意简化演示。
4.4 概率表类:等级和概率的映射
public class ProbabilityTable { private final Map<Integer, double[]> ratesByLevel = new HashMap<>(); public ProbabilityTable() { // 等级1-2 ratesByLevel.put(1, new double[]{0.80, 0.20, 0.00, 0.00, 0.00}); ratesByLevel.put(2, new double[]{0.80, 0.20, 0.00, 0.00, 0.00}); // 等级3-4 ratesByLevel.put(3, new double[]{0.60, 0.30, 0.10, 0.00, 0.00}); ratesByLevel.put(4, new double[]{0.60, 0.30, 0.10, 0.00, 0.00}); // 等级5-6 ratesByLevel.put(5, new double[]{0.40, 0.30, 0.20, 0.10, 0.00}); ratesByLevel.put(6, new double[]{0.40, 0.30, 0.20, 0.10, 0.00}); // 等级7-8 ratesByLevel.put(7, new double[]{0.20, 0.25, 0.30, 0.20, 0.05}); ratesByLevel.put(8, new double[]{0.20, 0.25, 0.30, 0.20, 0.05}); // 等级9以上 ratesByLevel.put(9, new double[]{0.10, 0.20, 0.30, 0.30, 0.10}); ratesByLevel.put(10, new double[]{0.10, 0.20, 0.30, 0.30, 0.10}); } public double[] getRatesByLevel(int playerLevel) { int level = Math.max(1, Math.min(playerLevel, 10)); return ratesByLevel.get(level); } }写完后我特意加了个校验方法,在构造时检查每个等级下5个概率加起来是否接近1.0,差值的绝对值超过1e-9就抛异常。这个操作在开发期救了我一次,因为手滑把0.20写成0.2倒是没事,但把0.30写成0.03就整组概率错误了。
4.5 批量模拟与统计验证
写完核心后,我加了SimulationRunner,用来跑大规模模拟,验证概率配置和保底机制是否符合预期:
public class SimulationRunner { public static void main(String[] args) { CardPool pool = buildDefaultPool(); ProbabilityTable table = new ProbabilityTable(); GachaService service = new GachaService(pool, table); int totalDraws = 1_000_000; int level = 8; // 先跑一次单抽和十连抽体验逻辑 System.out.println("单抽:" + service.drawSingle(level)); System.out.println("十连:" + service.drawTen(level)); // 统计模式:重新创建service,避免保底计数影响统计 GachaService statsService = new GachaService(pool, table); Map<CostLevel, Integer> costStats = new EnumMap<>(CostLevel.class); int fiveStarCount = 0; int maxPity = 0; int currentPity = 0; for (int i = 0; i < totalDraws; i++) { Champion c = statsService.drawSingle(level); costStats.merge(c.cost(), 1, Integer::sum); if (c.cost() == CostLevel.FIVE) { fiveStarCount++; maxPity = Math.max(maxPity, currentPity); currentPity = 0; } else { currentPity++; } } System.out.println("总抽取次数:" + totalDraws); for (CostLevel cost : CostLevel.values()) { int count = costStats.getOrDefault(cost, 0); double actualRate = count * 100.0 / totalDraws; System.out.printf("%d费出现次数:%d,实际概率:%.2f%%%n", cost.getValue(), count, actualRate); } System.out.println("5费实际概率:" + (fiveStarCount * 100.0 / totalDraws) + "%"); System.out.println("最大保底间隔:" + maxPity); } private static CardPool buildDefaultPool() { CardPool pool = new CardPool(); // 示例棋子数据,按费用档位添加 pool.addChampion(new Champion("蔚", CostLevel.ONE, "执法官", "格斗家")); pool.addChampion(new Champion("波比", CostLevel.ONE, "约德尔人", "卫士")); pool.addChampion(new Champion("嘉文四世", CostLevel.TWO, "执法官", "神盾战士")); pool.addChampion(new Champion("亚索", CostLevel.TWO, "浪人", "决斗大师")); pool.addChampion(new Champion("拉克丝", CostLevel.THREE, "学者", "战斗学院")); pool.addChampion(new Champion("金克丝", CostLevel.FOUR, "极客", "枪手")); pool.addChampion(new Champion("阿卡丽", CostLevel.FOUR, "刺客", "潜行者")); pool.addChampion(new Champion("泽丽", CostLevel.FOUR, "狙神", "极客")); pool.addChampion(new Champion("奥恩", CostLevel.FIVE, "铁匠", "格斗家")); pool.addChampion(new Champion("维克托", CostLevel.FIVE, "炼金科技", "学者")); pool.addChampion(new Champion("塔姆", CostLevel.FIVE, "赏金猎人", "格斗家")); return pool; } }跑100万次模拟后,5费的实际概率在我的配置下大约会落在9.8%到10.3%之间(因为等级8的5费基础概率是5%,叠加软保底后整体期望会被抬高到10%左右)。这个结果非常关键:它验证了保底机制不只改变玩家的抽卡体验,还实实在在改变了综合概率。如果产品经理拿着基础概率表说“5费概率5%”,但玩家体感明显高,原因就在这里。
5. 实操中的坑与排查技巧:我踩过的问题都整理出来了
5.1 每次抽卡重新new Random()的性能和分布问题
最开始我图省事,在drawSingle方法里直接写new Random().nextInt(),结果跑了10万次模拟速度巨慢,而且分布有肉眼可见的偏差。原因是每次new Random()都会创建一个新的随机数生成器并重新播种,性能浪费严重;更严重的是,如果用当前时间做种子,并发快速调用时会生成一系列高度相关的随机值。后来全部改成ThreadLocalRandom.current(),问题彻底消失,100万次模拟只需一两秒。
5.2 保底计数器清零时机不对
有一版代码我写成了:不管抽到什么费用,只要进入过保底判断逻辑就把计数器清零。结果就是硬保底完全不生效,因为计数器在运气不好时被提前清零了。后面我重新理了业务逻辑,明确了清零点只能是“抽到5费棋子”,并加了一条单元测试验证:连续49次不出5费后,第50次必定出5费。
这个测试很值得写:
@Test void testHardPityGuarantee() { CardPool pool = SimulationRunner.buildDefaultPool(); ProbabilityTable table = new ProbabilityTable(); GachaService service = new GachaService(pool, table); int level = 8; boolean guaranteeTriggered = false; for (int i = 0; i < HARD_PITY_LIMIT; i++) { Champion champion = service.drawSingle(level); if (champion.cost() == CostLevel.FIVE) { guaranteeTriggered = true; break; } } assertTrue(guaranteeTriggered); }这种测试跑起来很快,但能保证核心机制不被后续改动破坏。
5.3 浮点概率累加的精度陷阱
概率表里写了0.80、0.20这些小数,在Java里用double存。我用r < cumulative的方式判断区间时,如果随机数恰好落在最后一个边界附近,可能因为精度问题全部没匹配上,最后要靠return weights.length - 1兜底。这个兜底在绝大多数时候是对的,因为最后一个档位的区间最大,但它掩盖了概率表配置错误的问题。所以我在ProbabilityTable构造时加了概率求和校验:
for (var entry : ratesByLevel.entrySet()) { double sum = Arrays.stream(entry.getValue()).sum(); if (Math.abs(sum - 1.0) > 1e-9) { throw new IllegalArgumentException("等级" + entry.getKey() + "的概率总和为" + sum + ",不等于1.0"); } }这里有个细节:Math.abs(sum - 1.0)为什么不是sum == 1.0?因为浮点数运算可能有微小误差,0.1+0.2就不等于0.3,这个经典问题在概率累加里同样存在,用绝对误差判断更稳妥。
5.4 常见问题速查表
我把模拟器开发过程中最常遇到的问题整理成了表格,方便对照排查:
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
| 随机结果总是偏向某几个棋子 | 每次new Random()且并发调用,种子高度相关 | 统一使用ThreadLocalRandom.current() |
| 保底永远不触发 | 计数器清零时机错误 | 只有抽到目标费用才清零计数 |
| 模拟统计的实际概率远高于配置概率 | 保底机制的综合拉升效应,或浮点累加错误 | 用统计验证,单独区分“基础概率”和“综合概率” |
| 概率表各项加起来不等于1.0 | 手写小数错误或浮点精度 | 构造时校验总概率,差值小于1e-9 |
| 高并发模拟时计数器乱跳 | 多个线程共用同一个GachaService实例 | 每线程一个独立实例,或用AtomicInteger |
| 卡池为空时抽卡直接空指针 | 没做空列表判断 | 抽取前检查候选列表,为空抛异常 |
5.5 日志和可视化:调试抽卡系统的辅助手段
纯控制台输出在批量模拟时几乎没法看,我在调试阶段加了一个简单的“最近十连”打印方法,把每次十连的结果按费用排序输出,一眼就能看出概率配置是否生效:
public static void printDrawResult(List<Champion> draws) { Map<CostLevel, Long> countMap = draws.stream() .collect(Collectors.groupingBy(Champion::cost, Collectors.counting())); StringBuilder sb = new StringBuilder(); for (CostLevel cost : CostLevel.values()) { long count = countMap.getOrDefault(cost, 0L); if (count > 0) { sb.append(count).append("张").append(cost.getValue()).append("费 "); } } System.out.println(sb.toString().trim()); }跑起来输出大概长这样:3张1费 1张2费 1张3费 3张4费 2张5费,比起一长串棋子名直观多了。等后面加GUI时,这套统计逻辑可以直接换成柱状图展示。
6. 项目扩展与面试价值分析
6.1 怎么把模拟器升级成“可配置”版本
现在项目里棋子和概率都是硬编码的,下一步最自然的扩展是改成从文件读取配置。用JSON格式的配置文件,配合Jackson或者Gson解析,CardPool和ProbabilityTable就变成了真正的“数据驱动”组件。我在重构版本里把棋子定义放到了champions.json,概率表放到了probability.json,加载逻辑放在ConfigLoader里,GachaService完全不关心数据从哪来,只依赖接口传进来的CardPool和ProbabilityTable实例。这样一来,运营要调概率只改配置,不碰代码。
6.2 Java核心知识点在这个项目里的落点
这个项目覆盖的Java知识点比表面看起来多得多,我把它们列成清单,练项目的时候顺便把八股文也背了:
- 面向对象设计:
Champion的record、CostLevel的枚举、服务的无状态设计。 - 集合框架:
EnumMap、ArrayList、Collections.emptyList()、computeIfAbsent。 - 随机算法:
Math.random()、Random、ThreadLocalRandom的区别,加权随机实现。 - 异常处理:卡池为空抛异常、概率校验抛异常、
IllegalStateException和IllegalArgumentException的区别。 - 流和Lambda:统计用的
groupingBy、counting()。 - 并发基础:
ThreadLocalRandom的线程安全、多线程模拟时的计数器隔离。 - 单元测试:JUnit 5的断言、边界测试、保底机制验证。
- 数据结构:
HashMap、EnumMap的底层差异、为什么EnumMap更快。
面试官如果问“你最近做了些什么项目”,把模拟器这套思路讲清楚,从概率建模到保底实现再到百万级模拟验证,比说“我做了个电商系统”有说服力得多,因为这里面每个细节都是自己动手踩过坑的。
6.3 后续还能继续加什么
如果精力允许,我接下来准备做这几件事,也建议想复刻项目的朋友按这个顺序来:
- 第一优先:加一个简单的Swing或JavaFX界面,把抽卡动画做成按钮响应,显示抽到的棋子图片(本地放几张占位图就行)。
- 第二优先:把单次抽卡改成“10连保底至少1张3费以上”的规则,这是很多游戏的实际策略,实现上比全局保底更有趣。
- 第三优先:把批量模拟结果导出到CSV,用Excel画概率分布图,直观呈现软保底对概率曲线的拉升效果。
- 第四优先:接入数据库,记录每次抽卡日志,用SQL统计各个棋子的出货率。这还能顺便练练JDBC或MyBatis。
我个人在实际操作中的体会是:抽卡模拟器这种带随机、带状态、带配置的项目,特别适合用来验证自己对Java基础语法的掌握程度。写的时候你不会觉得无聊,因为每一步都有“玩”的成分,跑完百万次模拟看到概率曲线和配置预期一致的那个瞬间,随机数的底层逻辑就真正变成你自己的东西了。对了,最后再分享一个小技巧:在做这类概率系统时,随手写一个printConfig()方法把当前的卡池大小和概率配置打印出来,调试时真的能省掉一半的猜测时间。