引言
无论是在中文论坛还是英语论坛, 都会看到一个说法
即如果第一个怪物是Cultist(邪教徒Kakka), 则有80%的概率, 你的下一个事件(问号)会变成战斗, 导致你无法使用捏奥的悲恸白嫖到精英.
这个让我很是奇怪, 在我之前的认知中, 杀戮尖塔遇到什么怪物, 问号碰到什么事件, 都是已经注定好的行为, 为什么会有Bug会让第一支怪物, 影响到后续的问号事件呢?
所幸杀戮尖塔是用Java写的, 所以我们可以很直接的反编译他的Jar文件, 看到源代码
源代码获取方式
打开杀戮尖塔文件夹, 里面会有一个名为desktop-1.0.jar
的文件, 使用任意反编译工具即可获取源代码.
这里我使用的是JD-GUI, 将其解析之后获得到源码
生成怪物和事件的关系
生成怪物
其中生成怪物的代码在desktop-1.0.jar.src\com\megacrit\cardcrawl\dungeons\Exordium.java
protected void generateMonsters() {
generateWeakEnemies(3);
generateStrongEnemies(12);
generateElites(10);
}
这里是Exordium
, 即第一章序幕也就是第一层, 会生成3个弱怪, 12个强怪, 10个精英.
这里可以证明, 3个弱怪之后就是强怪池.
其中生成弱怪的代码
protected void generateWeakEnemies(int count) {
ArrayList<MonsterInfo> monsters = new ArrayList<>();
monsters.add(new MonsterInfo("Cultist", 2.0F));
monsters.add(new MonsterInfo("Jaw Worm", 2.0F));
monsters.add(new MonsterInfo("2 Louse", 2.0F));
monsters.add(new MonsterInfo("Small Slimes", 2.0F));
MonsterInfo.normalizeWeights(monsters);
populateMonsterList(monsters, count, false);
}
其中MonsterInfo中, 第一个参数为怪物名称, 第二个是生成权重, 这里面四个怪物的权重是一样的, 所以理论上生成的概率是相同的.
其中MonsterInfo.normalizeWeights(monsters);
具体为
public static void normalizeWeights(ArrayList<MonsterInfo> list) {
Collections.sort(list);
float total = 0.0F;
for (MonsterInfo i : list){
total += i.weight;
}
for (MonsterInfo i : list) {
i.weight /= total;
if (Settings.isInfo){
logger.info(i.name + ": " + i.weight + "%");
}
}
}
可得, 每个弱怪池中的怪物权重均为$\frac{2}{(2+2+2+2)}=\frac{1}{4}$
public void populateMonsterList(ArrayList<MonsterInfo> monsters, int numMonsters, boolean elites) {
if (eliteMonsterList.isEmpty()) {
// 如果列表为空,直接添加
eliteMonsterList.add(MonsterInfo.roll(monsters, monsterRng.random()));
} else {
// 确保新生成的怪物与列表中最后一个不同
String toAdd = MonsterInfo.roll(monsters, monsterRng.random());
if (!toAdd.equals(eliteMonsterList.get(eliteMonsterList.size() - 1))) {
eliteMonsterList.add(toAdd);
} else {
i--; // 重复则重试
}
}
if (monsterList.isEmpty()) {
// 如果列表为空,直接添加
monsterList.add(MonsterInfo.roll(monsters, monsterRng.random()));
} else {
String toAdd = MonsterInfo.roll(monsters, monsterRng.random());
// 检查与最后一个怪物是否相同
if (!toAdd.equals(monsterList.get(monsterList.size() - 1))) {
// 如果列表长度>1,还要检查与倒数第二个怪物是否相同
if (monsterList.size() > 1 && toAdd.equals(monsterList.get(monsterList.size() - 2))) {
i--; // 与倒数第二个相同则重试
} else {
monsterList.add(toAdd);
}
} else {
i--; // 与最后一个相同则重试
}
}
可以看出,
MonsterInfo.roll(monsters, monsterRng.random());
决定了最终添加什么怪物, 也就是monsterRng.random()
的随机数决定了添加什么怪物, 其调用的random
函数为desktop-1.0.jar.src\com\megacrit\cardcrawl\random\Random.java
public float random() {
this.counter++;
return this.random.nextFloat();
}
上文提到, i.weight
在第一次刷新怪物的时候, 均为$\frac{1}{4}$, 同时MonsterInfo这个ArrayList的怪物顺序为
["Cultist","Jaw Worm","2 Louse","Small Slimes"]
如果第一只是Cultist, 则可知随机数的结果是0.00-0.25
随机数如何产生
种子
未指定种子的时候, 随机生成一个种子
private void setRandomSeed() {
// 使用系统时间作为源种子
long sourceTime = System.nanoTime();
Random rng = new Random(Long.valueOf(sourceTime));
Settings.seedSourceTimestamp = sourceTime;
// 生成一个"不冒犯"的种子(不含敏感词)
Settings.seed = Long.valueOf(SeedHelper.generateUnoffensiveSeed(rng));
Settings.seedSet = false;
}
public static long generateUnoffensiveSeed(Random rng) {
String safeString = "fuck";
while (BadWordChecker.containsBadWord(safeString) || TrialHelper.isTrialSeed(safeString)) { //生成的时候不但会考虑是否包含不文明的词汇, 还要考虑种子是否是特殊试炼的种子
long possible = rng.randomLong();
safeString = getString(possible);
}
return getLong(safeString);
}
或者是用户手动输入一个种子
public static void setSeed(String seedStr) {
if (seedStr.isEmpty()) {
Settings.seedSet = false;
Settings.seed = null;
Settings.specialSeed = null;
} else {
long seed = getLong(seedStr);
Settings.seedSet = true;
Settings.seed = Long.valueOf(seed);
Settings.specialSeed = null;
Settings.isDailyRun = false;
cachedSeed = null;
}
}
在我们选择角色的时候(desktop-1.0.jar.src\com\megacrit\cardcrawl\screens\charSelect\CharacterSelectScreen.java
)
AbstractDungeon.generateSeeds();
public static void generateSeeds() {
logger.info("Generating seeds: " + Settings.seed);
monsterRng = new Random(Settings.seed); //怪物
eventRng = new Random(Settings.seed); //事件
merchantRng = new Random(Settings.seed); //商店
cardRng = new Random(Settings.seed); //遇到的卡片
treasureRng = new Random(Settings.seed); //宝箱
relicRng = new Random(Settings.seed); //遗物
monsterHpRng = new Random(Settings.seed); //怪物血量
potionRng = new Random(Settings.seed); //药水
aiRng = new Random(Settings.seed); //怪物的策略ai
shuffleRng = new Random(Settings.seed); //排序/洗牌
cardRandomRng = new Random(Settings.seed); //卡牌的随即效果(比如跳瓶)
miscRng = new Random(Settings.seed); //杂项
}
我们会将种子作为基准种子传入很多随机数生成类, 这样保证了我们使用相同的种子, 开始的随机数总是相同的.
最后通过
public void setSeed(long seed) {
long seed0 = murmurHash3((seed == 0L) ? Long.MIN_VALUE : seed);
setState(seed0, murmurHash3(seed0));
}
private static final long murmurHash3(long x) {
x ^= x >>> 33L;
x *= -49064778989728563L;
x ^= x >>> 33L;
x *= -4265267296055464877L;
x ^= x >>> 33L;
return x;
}
murmurHash3算法生成一个seed0
作为实际计算随机数时使用的数字.
public void setSeed(long seed) {
long seed0 = murmurHash3((seed == 0L) ? Long.MIN_VALUE : seed);
setState(seed0, murmurHash3(seed0));
}
public void setState(long seed0, long seed1) {
this.seed0 = seed0;
this.seed1 = seed1;
}
seed1
是seed0
再经过一次murmurHash3得到的新数字, 由此, 我们获取了我们未来游戏中所有需要随机数的地方, 作为基准使用的两个变量seed0
和seed1
.
这里使用的是xoshiro128***算法, 其缺点就是
- 如果内部状态被泄露,未来的输出可被完全预测
- 对于某些变体,可以通过连续输出推断内部状态
也就是说, 这种算法的每一次随机, 其实不是通过真的随机生成, 而是通过最开始的基准数字, 在经过一系列的数学计算, 得到的随机数, 因此这一次获得的结果, 与上一次是深度耦合的.
因此, 之前出现了不少nosl预测下一个随机数, 以及尝试不同的出牌方式来寻找不同的世界线的sl玩法, 甚至有mod可以使得这个失效https://steamcommunity.com/sharedfiles/filedetails/?id=2181005326
其原理就是侵入式修改不同的事件的基底随机数, 使得不能被互相耦合对应
遭遇怪物
根据上面的结论, 如果我们第一个遇到的怪物是Cultist, 那么生成怪物时第一个随机数结果会是0.00-0.25之前的浮点数.
同时根据上面的代码可知, 每一个random.nextFloat();
第一个的结果都会是一样的
问号生成什么事件代码在desktop-1.0.jar.src\com\megacrit\cardcrawl\helpers\EventHelper.java
//初始化遇到各事件的概率
private static float ELITE_CHANCE = 0.1F;
private static float MONSTER_CHANCE = 0.1F;
private static float SHOP_CHANCE = 0.03F;
public static float TREASURE_CHANCE = 0.02F;
public enum RoomResult {
EVENT, ELITE, TREASURE, SHOP, MONSTER;
}
if (AbstractDungeon.floorNum < 6) //如果层数<6, 则不会在问号中遇到精英
eliteSize = 0;
int monsterSize = (int) (MONSTER_CHANCE * 100.0F); //10
int shopSize = (int) (SHOP_CHANCE * 100.0F); //3
int treasureSize = (int) (TREASURE_CHANCE * 100.0F); //2
if (AbstractDungeon.getCurrRoom() instanceof com.megacrit.cardcrawl.rooms.ShopRoom) //如果走过了一层商店, 那么将商店的概率清零
shopSize = 0;
int fillIndex = 0;
RoomResult[] possibleResults = new RoomResult[100];
Arrays.fill((Object[]) possibleResults, RoomResult.EVENT); //先将所有可能填充为"事件"
Arrays.fill((Object[]) possibleResults,Math.min(99, fillIndex),Math.min(100, fillIndex + monsterSize), RoomResult.MONSTER); //填充0->(0+10)为怪物事件
fillIndex += monsterSize; //fillIndex=0+10=10
Arrays.fill((Object[]) possibleResults, Math.min(99, fillIndex), Math.min(100, fillIndex + shopSize),RoomResult.SHOP); //填充10->(10+3)为商店事件
fillIndex += shopSize; //fillIndex=10+3=13
Arrays.fill((Object[]) possibleResults,Math.min(99, fillIndex),Math.min(100, fillIndex + treasureSize), RoomResult.TREASURE); //填充13->(13+2)为宝箱事件
RoomResult choice = possibleResults[(int) (roll * 100.0F)];
最后我们的RoomResult
应该为
0->10(不包含): Monster
10->13(不包含): Shop
13->15(不包含): Treasure
16->100(不包含): Event
即
[MONSTER, MONSTER, MONSTER, MONSTER, MONSTER, MONSTER, MONSTER, MONSTER, MONSTER, MONSTER,
SHOP, SHOP, SHOP,
TREASURE, TREASURE,
EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT]
结论
因为最开始的随机数都是按照种子来获得的, 则每一个随机数最开始的一个结果都是一致的.
那么当我们遇到Cultist时, roll为[0.00,0.25], 而根据RoomResult
中Monster的分布位置来看, 只有在roll为[0.00,0.10]时我们才会遇到怪物, 概率为
$$
\frac{0.1}{0.25}=0.4
$$
并不是网上传言的80%.
同时可以得出另一个结论, 当我们第一个遇到的怪物不是Cultist时, 我们的第一个事件(没有任何遗物影响的情况下), 必然不会是遭遇怪物.
当然这个结论是在不开启任何mod, 正常游戏而非无尽模式的情况下得到的, 如果有其他额外因素, 结论会发生变化.