summaryrefslogtreecommitdiff
path: root/content
diff options
context:
space:
mode:
authoreltrac <hi@eltr.ac>2026-07-25 19:02:40 +0800
committereltrac <hi@eltr.ac>2026-07-25 19:02:40 +0800
commit633fdd390a78399c00ec7c4becf170185e963a91 (patch)
treefb9a03e04603959e6b7a19512636f27a8654cd0e /content
parenta903cab029cb7d820776c314d53bfab2422040f9 (diff)
[publish] /article/rps-but-pokemon
Diffstat (limited to 'content')
-rw-r--r--content/article/RPS-but-pokemon.md383
1 files changed, 383 insertions, 0 deletions
diff --git a/content/article/RPS-but-pokemon.md b/content/article/RPS-but-pokemon.md
new file mode 100644
index 00000000..51495dfd
--- /dev/null
+++ b/content/article/RPS-but-pokemon.md
@@ -0,0 +1,383 @@
++++
+title = '如何用宝可梦属性玩剪刀石头布?'
+slug = 'rps-but-pokemon'
+tags = ["游戏","Lisp"]
+categories = '非无端联想'
+date = 2026-07-25 19:02:28
+draft = false
+toc = true
+endnotes = [
+ "本文中所有代码均有笔者本人手写,禁止用于训练 AI",
+ "如果你也想用文中的程序玩一玩的话,可以看[这里](https://src.eltr.ac/eltrac/pokemon-type-sims)"
+]
++++
+
+读者早上好,中午好,下午好,晚上好,本人上了五天班之后终于在周末开始发疯了(其实没那么难受,工作待遇挺好的,我只是想发疯),所以我们今天来玩石头剪刀布。<!--more-->
+
+因为我想看的《星期三》《人生切割术》和《同乐者》都还没出新剧集,而《瑞克与莫蒂》在周一才更新,所以我从头到尾刷了七遍《老友记》(当然是从我第一次看这部剧到现在的时间累计的),《生活大爆炸》次之,大概有三四遍吧。回顾已经看过的电视剧集会给人一种安全感,貌似有相关研究,但鉴于这篇文章我是来发疯的,所以就不扯心理学了,免得搬起石头砸自己的脚。
+
+在《生活大爆炸》里,Sheldon 说剪刀石头布不足以做出有意义的区分,而且在熟悉的人之间,人们更容易打成平手。所以,他提议用剪刀、石头、布、蜥蜴、史波克[^1]代替简单的石头剪刀布,规则是这样的:
+
+1. 剪刀剪断纸
+2. 纸包住石头
+3. 石头压死蜥蜴
+4. 蜥蜴毒死史波克
+5. 史波克砸烂剪刀
+6. 剪刀断首蜥蜴
+7. 蜥蜴吃掉纸
+8. 论文(paper)证明史波克不存在
+9. 史波克蒸发石头
+10. 石头压碎剪刀
+
+{{< right "——出自第二季第八集[片段](https://farside.link/invidious/watch?v=jnfz_9d9BUA)" >}}
+
+如果你没看懂这个规则,没关系,我也没看懂,下面是图示:
+
+![一个五芒星形状的图,用箭头表示上述的克制关系。](https://img.eltr.ac/geedeapro/article/rps-but-pokemon/rock-paper-scissors-lizard-spock.svg.webp "🖖 是史波克,🤏 是蜥蜴;图源:[Wikipedia](https://en.wikipedia.org/wiki/Rock_paper_scissors#/media/File:Pierre_ciseaux_feuille_l%C3%A9zard_spock_aligned.svg)")
+
+这个游戏也出现在《去月球》系列游戏里的某段对话里,具体出自哪部游戏,我已经忘记了。
+
+尽管是情景喜剧里的笑料,但这个改良版本的石头剪刀布,通过引入两个新的出招选择,巧妙地改变了石头剪刀布的环状结构。仔细观察,上图中每个出招都有两个弱点和两个克制对象。如果忽略上图的箭头,把这张图看成是五阶完全图,每个结点都有四的度;如果看成有向图,则每个结点有两个入度和两个出度,由于出度和入度相等,所以这是张五阶平衡有向图。
+
+你以为我要通过图论得出什么结论吗?我都说了我是来发疯的,~~我只是想炫耀一下我的图论知识~~。有一说一,石头、剪刀、布、蜥蜴、史波克还是太简单了,这个图里连自环都没有(意思是说一个结点有指向自身的一条边),两个节点之间最多只有一条边,也没有权重。想看看真正复杂的游戏吗?
+
+Behold. The Pokemon type chart! [^2]
+
+![宝可梦属性克制关系表,左侧是攻击方,上方是防御方,每个表项显示的是攻击方对防御方造成的伤害倍率](https://img.eltr.ac/geedeapro/article/rps-but-pokemon/pokemon-type-chart.png)
+
+表格的左侧代表进攻方,上方代表防御方(受击方),每个表项代表攻击倍率。可以先看第一行,一般系(NORMAL)作为攻击方时,对岩石系(ROCK)和钢系(STEEL)造成 ½ 倍率的伤害,对幽灵系(GHOST)造成 0 倍率的伤害。
+
+不难理解,宝可梦属性相克表实际上是个带权有向图的邻接矩阵,其中权重代表的是攻击方对防御方造成的伤害倍率,空白的格子其实应该是 `1`。我们可以把图画出来…… 噢等等,看样子还是算了吧。
+
+![Mermarid Live Editor 页面,试图画出所有的属性克制关系,但最终的效果是一团乱麻](https://img.eltr.ac/geedeapro/article/rps-but-pokemon/pokemon-type-graph.png)
+
+一般而言,攻击方对防御方造成 2 倍伤害被称作「克制」,比如火系克制草系;造成 0.5 倍伤害则被称作「抵抗」,比如水系抵抗自身;造成 0 倍伤害叫作「无效」,比如幽灵系对一般系无效。如果要用宝可梦属性玩剪刀石头布,就要考虑倍率问题。直觉上,由于火系克制草系,那么出「火」的玩家应该能立刻打败出「草」的玩家,那么「火」对「电」呢?倍率为 1 的情况下怎么判定?如果假设玩家有 2 的生命值,而两次出「火」就能打败两次都出「电」的玩家,那只要不是低概率出现的「无效」,所有玩家都能在两回合内战胜彼此,游戏就没有太多意义了。
+
+等等,好像也说不定?你等我写个程序模拟一下。
+
+先定义 `type-list`,用 Clojure 的关键词类型列出所有的属性。
+
+```clojure
+(def type-list [:NORMAL
+ :FIGHTING
+ :POISON
+ :FLYING
+ :GROUND
+ :ROCK
+ :BUG
+ :GHOST
+ :STEEL
+ :FIRE
+ :WATER
+ :GRASS
+ :ELECTRIC
+ :PSYCHIC
+ :ICE
+ :DRAGON
+ :DARK
+ :FAIRY])
+```
+
+然后写两个简单的小函数,方便获取属性在表中的索引,或者用索引获得属性名称,这就很像枚举类型了。
+
+```clojure
+(defn type-index [type-name]
+ (.indexOf type-list type-name))
+
+(type-index :BUG) ;; => 6
+
+(defn type-name [type-index]
+ (get type-list type-index))
+
+(type-name 2) ;; => POISON
+```
+
+接下来录入所有的属性相克关系,不必惊慌,可以直接复制属性相克表,粘贴到代码中变成一个二级数组,也就是矩阵的结构,然后运行 Vim 的 `:%s/.../...` 命令替换掉 `×` 之类的字符,就得到了一张干净的邻接矩阵。哦对了,Clojure 里 `1/2` 是不合法的,应该写成 `(/ 1 2)`,这里批量替换为 `0.5` 就好。
+
+```clojure
+(def type-effects [[1 1 1 1 1 0.5 1 0 0.5 1 1 1 1 1 1 1 1 1]
+ [2 1 0.5 0.5 1 2 0.5 0 2 1 1 1 1 0.5 2 1 2 0.5]
+ [1 2 1 1 1 0.5 2 1 0.5 1 1 2 0.5 1 1 1 1 1]
+ [1 1 1 0.5 0.5 0.5 1 0.5 0 1 1 2 1 1 1 1 1 2]
+ [1 1 0 2 1 2 0.5 1 2 2 1 0.5 2 1 1 1 1 1]
+ [1 0.5 2 1 0.5 1 2 1 0.5 2 1 1 1 1 2 1 1 1]
+ [1 0.5 0.5 0.5 1 1 1 0.5 0.5 0.5 1 2 1 2 1 1 2 0.5]
+ [0 1 1 1 1 1 1 2 1 1 1 1 1 2 1 1 0.5 1]
+ [1 1 1 1 1 2 1 1 0.5 0.5 0.5 1 0.5 1 2 1 1 2]
+ [1 1 1 1 1 0.5 2 1 2 0.5 0.5 2 1 1 2 0.5 1 1]
+ [1 1 1 1 2 2 1 1 1 2 0.5 0.5 1 1 1 0.5 1 1]
+ [1 1 0.5 0.5 2 2 0.5 1 0.5 0.5 2 0.5 1 1 1 0.5 1 1]
+ [1 1 2 1 0 1 1 1 1 1 2 0.5 0.5 1 1 0.5 1 1]
+ [1 2 1 2 1 1 1 1 0.5 1 1 1 1 0.5 1 1 0 1]
+ [1 1 2 1 2 1 1 1 0.5 0.5 0.5 2 1 1 0.5 2 1 1]
+ [1 1 1 1 1 1 1 1 0.5 1 1 1 1 1 1 2 1 0]
+ [1 0.5 1 1 1 1 1 2 1 1 1 1 1 2 1 1 0.5 0.5]
+ [1 2 1 0.5 1 1 1 1 0.5 0.5 1 1 1 1 1 2 2 1]])
+```
+
+数据结构清晰之后,写获取攻击效果的函数就很简单了。
+
+```clojure
+(defn get-attack-effect [attack defense]
+ (let [atk-index (type-index attack)
+ dfs-index (type-index defense)]
+ (-> type-effects (get atk-index) (get dfs-index))))
+
+(get-attack-effect :WATER :WATER)
+;; => 0.5
+(get-attack-effect :WATER :FIRE)
+;; => 2
+(get-attack-effect :DRAGON :FAIRY)
+;; => 0
+```
+
+一切就绪,就可以开始跑模拟了。先来模拟玩家出招,写一个 `play` 函数。
+
+```clojure
+(defn play []
+ (type-name (rand-int 18)))
+
+(play)
+;; => 随机输出一种属性名
+```
+
+写一个循环,模拟两人对战,有人生命值小于等于 0 时结束并返回结果。每位玩家的初始生命值为 2,每次出招玩家都会受到对方的攻击,攻击伤害等于 1 乘攻击倍率。结算时以生命值受到攻击伤害最小的玩家为赢家,比方说结束时 A 生命值为 0,而 B 生命值为 -0.5,那么 A 胜利。如果生命值相同,则打成平手。
+
+```clojure
+(defn play-til-death []
+ (loop [round 1
+ p1 2
+ p2 2]
+ (if (or (<= p1 0) (<= p2 0))
+ ;; one down, print result
+ (let [winner (if (= p1 p2) 0
+ (if (> p1 p2) 1 2))]
+ (if (= winner 0)
+ (println "It's a tie.")
+ (println (str "Player " winner " wins in " (- round 1) " rounds")))
+ {:round (- round 1) :winner winner})
+ ;; still going
+ (let [p1-move (play)
+ p2-move (play)
+ p1-to-p2 (get-attack-effect p1-move p2-move)
+ p2-to-p1 (get-attack-effect p2-move p1-move)]
+ (println round)
+ (println "Player 1 deals " p1-to-p2 " damage to player 2 by playing " p1-move)
+ (println "Player 2 deals " p2-to-p1 " damage to player 1 by playing " p2-move)
+ (println)
+ (recur (inc round)
+ (- p1 p2-to-p1)
+ (- p2 p1-to-p2))))))
+```
+
+要看看这个游戏是否可行,我们再写一个循环,进行 N 次游戏收集样本。
+
+```clojure
+(defn play-n-times [n]
+ (loop [counter 0
+ total-rounds 0
+ p1-wins 0
+ p2-wins 0
+ ties 0]
+ (if (>= counter n)
+ ;; print statistics
+ (do
+ (println "Counter: " counter)
+ (println "Average round: " (float (/ total-rounds counter)))
+ (println "Player 1 wins: " p1-wins (percentage p1-wins counter))
+ (println "Player 2 wins: " p2-wins (percentage p2-wins counter))
+ (println "Ties: " ties (percentage ties counter)))
+ ;; still going
+ (let [result (play-til-death)]
+ (recur (inc counter)
+ (+ total-rounds (:round result))
+ (inc-if (= (:winner result) 1) p1-wins)
+ (inc-if (= (:winner result) 2) p2-wins)
+ (inc-if (= (:winner result) 0) ties))))))
+```
+
+运行一万次试试。
+
+```clojure
+(play-n-times 10000)
+;; Counter: 10000
+;; Average round: 1.7555
+;; Player 1 wins: 3690 36.89999878406525%
+;; Player 2 wins: 3597 35.96999943256378%
+;; Ties: 2713 27.129998803138733%
+```
+
+结果是,这个游戏一般需要一到两轮结束(两轮的次数更多,查看记录也偶尔有三轮的情况),两名玩家赢的次数没有显著差异(合理,从算法来看,出招顺序并不影响游戏结果),整体而言,这个游戏有 27.13% 左右的概率打成平手。
+
+这是完全随机的情况,但毕竟玩家出手时有 18 种选择,即便彼此熟悉也很难摸清楚对方的出招规律(至少没有石头剪刀布那样简单)。看起来并不会出现我们一开始以为的游戏无意义的情况,至少在双人游戏中,每个人有大约 35% 的概率会赢,35% 的概率会输,接近 30% 的概率会打成平手,某种程度上还挺公平的。Voilà!现在你可以用宝可梦属性玩石头剪刀布了。
+
+---
+
+等等,宝可梦属性和剪刀石头布,以及剪刀、石头、布、蜥蜴、史波克的主要区别在于,属性相克关系带有权重,其次,每个属性在图中的出度和入度并不相等,也就是说宝可梦相克关系图并不是个平衡图。不平衡就导致了,必然有一些属性更容易获胜,另一些更容易失败。
+
+修改一下现有的代码,让 1 号玩家固定出招某个属性,让 2 号玩家保持随机,再运行几次测试,来看看哪个属性更容易胜利。顺便把所有打印语句都注释掉。
+
+```clojure {hl_lines=["2-3",16]}
+(defn play-til-death
+ ([] (play-til-death nil))
+ ([p1-fixed-move]
+ (loop [round 1
+ p1 2
+ p2 2]
+ (if (or (<= p1 0) (<= p2 0))
+ ;; one down, print result
+ (let [winner (if (= p1 p2) 0
+ (if (> p1 p2) 1 2))]
+ #_(if (= winner 0)
+ (println "It's a tie.")
+ (println (str "Player " winner " wins in " (- round 1) " rounds")))
+ {:round (- round 1) :winner winner})
+ ;; still going
+ (let [p1-move (or p1-fixed-move (play))
+ p2-move (play)
+ p1-to-p2 (get-attack-effect p1-move p2-move)
+ p2-to-p1 (get-attack-effect p2-move p1-move)]
+ #_(println round)
+ #_(println "Player 1 deals " p1-to-p2 " damage to player 2 by playing " p1-move)
+ #_(println "Player 2 deals " p2-to-p1 " damage to player 1 by playing " p2-move)
+ #_(println)
+ (recur (inc round)
+ (- p1 p2-to-p1)
+ (- p2 p1-to-p2)))))))
+```
+
+`play-n-times` 函数也做了类似的修改,此处略去。接下来我们还要写一个函数,遍历 `type-list`,用每种类型跑 10000 次游戏模拟看看情况。
+
+```clojure
+(defn play-all-types [each-times]
+ (map #(play-n-times each-times %) type-list))
+
+;; 由于直接在 REPL 里运行,所以 map 不需要包裹在 doall 里
+;; 严格来说这里应该返回惰性序列,但让我们忽略这一点
+(play-all-types 10000)
+```
+
+运行结果如下。
+
+```text
+[Type] :NORMAL
+Counter: 10000
+Average round: 2.0486
+Player 1 wins: 0 0.0%
+Player 2 wins: 3270 32.69999921321869%
+Ties: 6730 67.29999780654907%
+[Type] :FIGHTING
+Counter: 10000
+Average round: 1.6251
+Player 1 wins: 4326 43.25999915599823%
+Player 2 wins: 3846 38.46000134944916%
+Ties: 1828 18.279999494552612%
+[Type] :POISON
+Counter: 10000
+Average round: 1.6658
+Player 1 wins: 3507 35.06999909877777%
+Player 2 wins: 3387 33.8699996471405%
+Ties: 3106 31.060001254081726%
+[Type] :FLYING
+Counter: 10000
+Average round: 1.9019
+Player 1 wins: 3425 34.25000011920929%
+Player 2 wins: 4129 41.29000008106232%
+Ties: 2446 24.459999799728394%
+[Type] :GROUND
+Counter: 10000
+Average round: 1.5627
+Player 1 wins: 4301 43.00999939441681%
+Player 2 wins: 3671 36.71000003814697%
+Ties: 2028 20.28000056743622%
+[Type] :ROCK
+Counter: 10000
+Average round: 1.4984
+Player 1 wins: 4321 43.209999799728394%
+Player 2 wins: 4209 42.089998722076416%
+Ties: 1470 14.699999988079071%
+[Type] :BUG
+Counter: 10000
+Average round: 1.7554
+Player 1 wins: 3434 34.34000015258789%
+Player 2 wins: 5200 51.99999809265137%
+Ties: 1366 13.660000264644623%
+[Type] :GHOST
+Counter: 10000
+Average round: 1.925
+Player 1 wins: 3821 38.2099986076355%
+Player 2 wins: 1058 10.580000281333923%
+Ties: 5121 51.20999813079834%
+[Type] :STEEL
+Counter: 10000
+Average round: 1.8247
+Player 1 wins: 6063 60.62999963760376%
+Player 2 wins: 3512 35.120001435279846%
+Ties: 425 4.250000044703484%
+[Type] :FIRE
+Counter: 10000
+Average round: 1.6792
+Player 1 wins: 4340 43.39999854564667%
+Player 2 wins: 3451 34.50999855995178%
+Ties: 2209 22.08999991416931%
+[Type] :WATER
+Counter: 10000
+Average round: 1.8075
+Player 1 wins: 4428 44.279998540878296%
+Player 2 wins: 2704 27.039998769760132%
+Ties: 2868 28.679999709129333%
+[Type] :GRASS
+Counter: 10000
+Average round: 1.6325
+Player 1 wins: 3129 31.290000677108765%
+Player 2 wins: 5516 55.15999794006348%
+Ties: 1355 13.54999989271164%
+[Type] :ELECTRIC
+Counter: 10000
+Average round: 1.94
+Player 1 wins: 2933 29.330000281333923%
+Player 2 wins: 2765 27.649998664855957%
+Ties: 4302 43.02000105381012%
+[Type] :PSYCHIC
+Counter: 10000
+Average round: 1.8052
+Player 1 wins: 1953 19.529999792575836%
+Player 2 wins: 3796 37.95999884605408%
+Ties: 4251 42.50999987125397%
+[Type] :ICE
+Counter: 10000
+Average round: 1.5982
+Player 1 wins: 3594 35.94000041484833%
+Player 2 wins: 4214 42.14000105857849%
+Ties: 2192 21.9200000166893%
+[Type] :DRAGON
+Counter: 10000
+Average round: 1.8582
+Player 1 wins: 3171 31.709998846054077%
+Player 2 wins: 2732 27.320000529289246%
+Ties: 4097 40.97000062465668%
+[Type] :DARK
+Counter: 10000
+Average round: 1.8063
+Player 1 wins: 1976 19.760000705718994%
+Player 2 wins: 2933 29.330000281333923%
+Ties: 5091 50.91000199317932%
+[Type] :FAIRY
+Counter: 10000
+Average round: 1.7287
+Player 1 wins: 3559 35.589998960494995%
+Player 2 wins: 2667 26.669999957084656%
+Ties: 3774 37.74000108242035%
+```
+
+结果很有趣,宝可梦版本的石头剪刀布,每种出招的获胜概率都不同。非常特殊的是一般系,一直出一般系的话,在一万次的样本中,一次都没有获胜,但对手获胜的概率也被略微拉低了,增加了平手的概率。不难理解,在《宝可梦》游戏里,一般系就是非常抗打,除了格斗系没有别的弱点的属性,不过缺点是它的进攻能力很弱,不克制任何别的属性,甚至对幽灵系无效。反过来对一般系同样无效的幽灵系也非常有趣,只出幽灵系的话,平局的概率会被拉到一半,并且会让对手很难获胜(只有 10% 左右),自己则可以获得接近 40% 的胜利率。岩石系也很有趣,它提高了双方的获胜概率,并且大差不差,但是它让平局率降低到了 15% 以下。
+
+明显弱势,很容易输掉比赛的属性不少。虫属性会让对手在一半的情况下赢得比赛;飞行系会让对手在 41% 的情况下获胜;草属性让对手获胜的概率是最高的,超过了 55%;超能力系的获胜率不到 20%,恶属性也是。具有优势,更容易获胜的属性有水属性、火属性、钢属性、幽灵属性(不过幽灵属性很容易平局)、地面属性和格斗属性,其中钢属性是最容易获胜的,可能是因为它的进攻能力和抗性都不错。
+
+不过,《宝可梦》游戏中的对战和这个简单的石头剪刀布小游戏还有很大不同,就算只看属性克制方面,宝可梦自身的属性和攻击属性是分开的,而且自身属性还可能有双属性,一些属性抵抗还受到特性影响,比如具有干燥皮肤特性的宝可梦在受到水属性攻击时会回血,但是会变得弱火。抗性最好的宝可梦应该是麻麻鳗鱼王,它是纯电属性,只有地面系的弱点,但飘浮特性使得它可以无视地面系攻击,也就是没有弱点。
+
+如果要考虑这些的话,这个游戏就会变得太复杂,而且,我的发疯时间也到了,现在我要去做饭吃了。
+
+[^1]: 原文是 Spock,他是《星际迷航》里的角色。
+
+[^2]: ~~Holy shit. I'm a bigger nerd than Sheldon.~~