:從零構(gòu)建回合制對戰(zhàn)模擬器與勝率平衡分析)
同樣一套屬性數(shù)據(jù)擺在面前不同的人能得出完全相反的結(jié)論有人說高攻高血碾壓一切有人說高速高暴擊才是版本答案還有人只看技能倍率就下判斷。這類“論戰(zhàn)”之所以永遠吵不出結(jié)果根本原因不是誰不懂數(shù)值而是缺少一個可復現(xiàn)、可量化、可批量驗證的判定標準——每次對局都靠腦補隨機因素全被人為忽略。本文換一個思路不再用嘴論證而是用 Python 從零寫一個回合制對戰(zhàn)模擬器把“馬鯨笙 VS 茱莉亞 VS 一個黑帶 VS 一個船廠打手”這場混戰(zhàn)變成可以跑十萬次、每次都輸出勝率的程序。文中會完整給出角色配置、傷害公式推導、戰(zhàn)斗引擎、命令行入口和參數(shù)敏感度掃描腳本代碼全部可復制運行。無論你是剛學完 Python 基礎想找個綜合練手項目還是已經(jīng)在做游戲數(shù)值、需要一套可復用的對局模擬框架都可以直接拿走改。1. 從口頭論戰(zhàn)到可計算模型1.1 論戰(zhàn)吵不出結(jié)果的技術原因任何一場“誰更強”的討論本質(zhì)上都在做一件沒有顯式寫出來的事用大腦模擬戰(zhàn)斗過程。而人腦模擬有幾個硬傷。第一是隨機性被抹平暴擊、閃避、傷害浮動這些概率事件在口頭推演里通常直接被忽略于是“打得過”變成了“理論上打得過”。第二是回合數(shù)被壓縮真實對局可能打十幾回合人腦往往只推演前三回合就下結(jié)論而很多對局的勝負恰恰在第七、第八回合才翻轉(zhuǎn)。第三是樣本量為零一次推演就是一個樣本而戰(zhàn)斗結(jié)果本身是概率分布用單個樣本代表分布誤差大到?jīng)]有參考價值。把這件事交還給程序問題就變得很清晰定義好角色屬性、傷害公式、出手順序和技能規(guī)則讓程序把對局跑上一萬次統(tǒng)計每個角色的勝場。勝率 51% 和勝率 78% 是兩個完全不同的結(jié)論而它們都可以被復現(xiàn)、被審計、被修改參數(shù)后重新驗證。這才是“論戰(zhàn)”該有的形態(tài)。1.2 戰(zhàn)斗模擬器的三個組成要素一個最小可用的戰(zhàn)斗模擬器只需要三塊東西缺一不可。第一塊是靜態(tài)數(shù)據(jù)也就是角色的血量、攻擊、防御、速度、暴擊率、閃避率、技能配置這部分應該放在配置文件里和代碼解耦改數(shù)值不需要動邏輯。第二塊是戰(zhàn)斗規(guī)則包括傷害怎么算、誰先出手、技能什么時候放、打完一個目標之后打誰這部分是引擎的核心也是所有爭議的焦點。第三塊是統(tǒng)計層負責把單場對局重復跑很多次匯總成勝率、平均回合數(shù)、平均剩余血量這類指標。這三塊拆開之后整個項目就變成了“數(shù)據(jù) 規(guī)則 統(tǒng)計”的經(jīng)典結(jié)構(gòu)。后續(xù)你想加新角色只改 JSON想調(diào)平衡性只改常量想換統(tǒng)計維度只改統(tǒng)計層。這種分層不是為了好看而是因為數(shù)值調(diào)整是高頻操作如果數(shù)值和邏輯攪在一起改一個攻擊力就得重新讀一遍戰(zhàn)斗循環(huán)效率極低且容易改錯。1.3 明確模型邊界它算的不是“真實戰(zhàn)力”這里必須提前說清楚一件事避免讀者產(chǎn)生誤解。本文實現(xiàn)的模型是簡化模型它只保留了對勝負影響最大的幾個變量忽略了站位、裝備、地形、連招、控制鏈、治療、增益減益等一大堆真實戰(zhàn)斗系統(tǒng)里存在的東西。所以模擬結(jié)果的意義不是“證明某個角色客觀上更強”而是“在這套規(guī)則和這組數(shù)值下誰的期望勝率更高”。這個區(qū)別非常重要。如果你把規(guī)則改了比如把閃避改成減傷或者把先手判定從速度改成隨機勝率排名可能完全翻盤。所以正確的用法是把模擬器當成一個假設檢驗工具。你提出一個規(guī)則假設模擬器告訴你在這個假設下的結(jié)果換一個假設再跑一次。反復幾次你就能知道哪些結(jié)論是穩(wěn)健的換個規(guī)則依然成立哪些結(jié)論是脆弱的規(guī)則稍變就反轉(zhuǎn)。這比單純爭一個“誰更強”有價值得多。2. 環(huán)境準備與項目結(jié)構(gòu)2.1 運行環(huán)境與版本說明本文的示例代碼只依賴 Python 標準庫不需要安裝任何第三方包這是刻意設計的。原因有兩個一是降低復現(xiàn)門檻讀者 clone 下來就能跑不會被 pip 源、虛擬環(huán)境、版本沖突卡住二是模擬器的性能瓶頸在純 Python 循環(huán)上引入 numpy 之類的庫能提速但會讓代碼可讀性下降對教學不友好。具體的環(huán)境要求如下項目要求說明操作系統(tǒng)Windows / macOS / Linux 均可代碼里沒有平臺相關調(diào)用Python3.8 及以上用到dataclasses.replace和from __future__ import annotations第三方依賴無全部使用標準庫運行方式命令行python main.py編輯器任意VS Code / PyCharm / Vim 都能跑如果你本地是 Python 3.7dataclasses雖然存在但部分行為有差異建議直接升到 3.8 以上。版本不確定的話先執(zhí)行python --version確認一下。2.2 目錄結(jié)構(gòu)項目最終的結(jié)構(gòu)如下一共五個 Python 文件和一份 JSON 配置規(guī)模很小但分層清晰battle-sim/ ├── battle/ │ ├── __init__.py # 空文件標記為包 │ ├── model.py # 角色模型配置類 運行時狀態(tài) │ └── engine.py # 戰(zhàn)斗引擎1v1 對決 多人混戰(zhàn) ├── data/ │ └── characters.json # 四名角色的數(shù)值配置 ├── main.py # 命令行入口演示 / 勝率矩陣 / 混戰(zhàn)統(tǒng)計 └── balance.py # 參數(shù)敏感度掃描腳本battle/__init__.py必須存在否則from battle.engine import ...會報ModuleNotFoundError。這個文件可以是空的它的唯一作用就是告訴 Python“這個目錄是一個包”。2.3 為什么把角色數(shù)據(jù)和邏輯分開很多人寫這類小項目習慣把角色屬性直接寫成 Python 字典塞進代碼里。一開始很爽改兩次就痛苦了數(shù)值散落在十幾個地方想對比兩套配置還得來回注釋。把配置抽到 JSON 之后你可以同時維護多個配置文件用--config參數(shù)切換跑出來的結(jié)果直接可比。更重要的是JSON 是可被外部程序讀寫的。如果你后面想接一個簡單的網(wǎng)頁界面或者用 Excel 批量生成配置JSON 都是最省事的中間格式。這個決策在項目只有幾百行的時候看不出價值但只要你打算長期改數(shù)值它省下的時間會非??捎^。3. 數(shù)值模型拆解3.1 角色屬性字段設計先確定每個角色需要哪些字段。字段不是越多越好每多一個字段就多一層調(diào)試成本而且字段之間容易產(chǎn)生隱性耦合。本文只保留七個核心字段覆蓋了絕大多數(shù)回合制戰(zhàn)斗的主要變量字段含義作用hp最大生命值決定能扛多少刀atk攻擊力傷害的基礎來源defense防御力通過公式削減傷害spd速度決定每回合的出手順序crit暴擊率觸發(fā)時傷害乘以暴擊倍率dodge閃避率觸發(fā)時本次攻擊完全無效skill技能配置威力倍率、段數(shù)、冷卻這四個角色的定位差異全部通過這七個字段的組合體現(xiàn)出來馬鯨笙是均衡型各項都不突出但沒有短板茱莉亞是敏捷刺客速度、暴擊、閃避都很高血量和防御偏低黑帶是重甲型防御最高、血量厚但速度慢、爆發(fā)低船廠打手是高血高攻低速的蠻力型靠血量和攻擊硬頂。3.2 傷害公式的推導傷害公式是整個模型的心臟也是最容易寫錯的地方。常見的錯誤寫法是傷害 atk - defense這個公式在某一段數(shù)值區(qū)間內(nèi)看著正常但只要防御接近攻擊傷害就會掉到 0 甚至負數(shù)導致對局永遠打不完。另一種常見寫法是傷害 atk * (1 - defense / 100)當防御超過 100 時會出現(xiàn)負傷害同樣有硬傷。本文采用百分比減傷公式最終傷害 攻擊力 × 技能倍率 × 隨機浮動 × 暴擊倍率 × (100 / (100 防御力))這個公式的好處是防御力永遠無法把傷害削到 0只會逐漸逼近 0是一條平滑的衰減曲線。防御 0 時減傷系數(shù)是 1.0防御 40 時是 0.714防御 62 時是 0.617防御到 200 時是 0.333。也就是說防御收益是遞減的堆到后面性價比越來越低這符合大多數(shù)游戲的數(shù)值直覺也避免了“堆防御無敵”的退化策略。隨機浮動取0.92到1.08之間的均勻分布作用是打破完全確定的對局。如果沒有浮動相同配置的兩場對局會一模一樣樣本之間沒有獨立性跑一萬次等于跑一次。暴擊倍率固定為1.8這個值讓暴擊成為有意義的變量但不至于一擊定勝負。3.3 結(jié)算順序閃避、暴擊、減傷的先后結(jié)算順序看起來是細節(jié)實際上會顯著影響結(jié)果必須固定下來。本文的順序是先判閃避 → 再算基礎傷害與浮動 → 再判暴擊 → 最后過減傷公式。先判閃避的理由很直接閃避意味這次攻擊完全沒打中那么后面的暴擊判定、減傷計算全都不該發(fā)生。如果先算傷害再判閃避雖然結(jié)果數(shù)值一樣但會白白消耗隨機數(shù)讓不同配置下的隨機序列發(fā)生偏移可復現(xiàn)性變差。暴擊放在減傷之前是因為暴擊是“攻擊方的加成”減傷是“防御方的削減”邏輯上先加成后削減更符合直覺數(shù)值上也不會出現(xiàn)“暴擊后被減傷削成普通傷害”的尷尬情況。這里還有一個小細節(jié)max(1, dmg)。無論減傷算出多小的值最終傷害至少為 1。這是為了防止極端配置下出現(xiàn) 0 傷害導致對局無法結(jié)束屬于工程上的兜底不是數(shù)值設計。3.4 技能與冷卻機制技能用三個參數(shù)描述威力倍率mult、段數(shù)hits、冷卻回合cooldown。釋放時機采用最簡單的策略——冷卻好了就放。這個策略沒有 AI 決策看似簡陋但它保證了模擬的確定性也避免了引入“技能釋放策略”這個額外變量干擾數(shù)值對比。冷卻的遞減邏輯放在每個角色自己的回合開始時而不是每回合結(jié)束時統(tǒng)一遞減。這兩種寫法在 1v1 里結(jié)果一樣但在多人混戰(zhàn)里不同如果統(tǒng)一在回合結(jié)束遞減速度快的角色和速度慢的角色冷卻節(jié)奏一致如果在自己回合開始遞減實際節(jié)奏會更貼近“行動次數(shù)”而不是“回合數(shù)”。本文選擇后者因為它更符合“技能是消耗行動機會”的直覺。多段攻擊的意義在于提升暴擊期望。以茱莉亞的疾影連擊為例單段威力 0.75、共 2 段總倍率是 1.5。雖然總倍率不如船廠打手的 1.7但兩次獨立的暴擊判定讓她的暴擊期望更高配合 28% 的暴擊率實際輸出相當可觀。這個差異只有通過模擬才能量化靠腦子算是算不出來的。3.5 回合調(diào)度與目標選擇回合調(diào)度采用速度降序同速時用隨機數(shù)打破平局。注意平局判定必須在排序的 key 函數(shù)里完成保證每次排序只消耗一次隨機數(shù)避免排序算法內(nèi)部比較次數(shù)不同導致隨機序列不一致。目標選擇在 1v1 里沒有意義但在四人混戰(zhàn)里是關鍵規(guī)則。本文采用“優(yōu)先攻擊血量比例最低的對手”先算出所有存活對手的當前血量 / 最大血量取最小值如果有多個并列就在并列者中隨機挑一個。這條規(guī)則模擬的是“集火殘血”的直覺會讓混戰(zhàn)更快收斂也避免了隨機選目標導致對局拖到回合上限。這里刻意沒有加“復仇機制”或者“威脅評估”因為那會讓規(guī)則復雜度上升一個量級而收益有限。規(guī)則越簡單結(jié)果越容易被解釋這對一個用于討論的工具來說比“更真實”重要得多。4. 完整實戰(zhàn)四人對戰(zhàn)模擬器4.1 定義角色配置文件先寫配置。文件名data/characters.json四個角色的數(shù)值按前文的定位分配{ characters: [ { name: 馬鯨笙, hp: 1100, atk: 150, defense: 40, spd: 88, crit: 0.15, dodge: 0.08, skill: { name: 穩(wěn)扎穩(wěn)打, mult: 1.35, hits: 1, cooldown: 3 } }, { name: 茱莉亞, hp: 850, atk: 175, defense: 25, spd: 115, crit: 0.28, dodge: 0.18, skill: { name: 疾影連擊, mult: 0.75, hits: 2, cooldown: 3 } }, { name: 黑帶, hp: 1250, atk: 130, defense: 62, spd: 70, crit: 0.10, dodge: 0.05, skill: { name: 過肩摔, mult: 1.5, hits: 1, cooldown: 4 } }, { name: 船廠打手, hp: 1350, atk: 165, defense: 32, spd: 58, crit: 0.08,