反透視做完之後,挖礦變得不好玩了
上一篇講了 Kyokalith 的誘餌模型:原版生成的礦全部留著當誘餌,完全被實心方塊包住的那些,在第一次被挖開的瞬間才由一個確定性函數決算是不是真礦。透視看到的礦不保證是真的,而老實玩家從頭到尾不應該察覺有東西在動。
「不應該察覺」這句話是這篇的全部內容。上線之後回報進來:
- 地獄挖不到東西
- 挖到一顆露在洞穴壁上的鐵礦,挖開,後面什麼都沒有
- 一條礦脈挖著挖著突然變成石頭
- 拆掉鐵軌,旁邊本來看得到的礦不見了
反透視那部分是好的。壞掉的是挖礦。
一開始我修錯地方
第一個反應是密度不夠,調 config 就好。結果調了沒反應。翻進去看才發現 vein_size 根本是裝飾:它被轉成球半徑,整數除法一路 clamp,所有礦內建的 vein_size_max 7 到 10 全部映到同一顆約 33 格的球。config 那欄寫什麼數字都一樣。
那次 (v1.3.0) 我做的事是把半徑上限 2 提到 4,讓 1 到 7 至少能區分開。修完當下覺得有進展,現在看就是把儀表板的指針掰到正確位置,底下的東西沒動。
真正的問題是我在量錯的東西
一直以來我量密度的方式是:固定一層 Y,把每個座標丟進決算函數,數命中幾格,得到「hits / 10k」。這個數字很好看、很好測、每次都能重現,而且跟玩家的感受幾乎沒有關係。
換一個量法之後才看到真相。改成數「不同的 veinId 有幾顆、每顆多大、兩顆之間隔多遠」,基準版的樣本是:主世界整個取樣區只有 4 條礦脈,最大一條 123 格;地獄 15 條,最大 257 格;還可以穩定重現一條 613 格的石英連通鏈。
平均密度是對的。體感是:走很久什麼都沒有,然後突然踩進一個礦礦礦礦礦的房間,挖到手軟,再走很久什麼都沒有。
指標選錯的時候不會有人告訴你「測不出來」,你會拿到一個綠燈。然後那個綠燈會讓你一直往別的方向找原因。
把礦脈變回礦脈的形狀
v1.3.2 拆掉球。vein_size 現在就是精確的方塊數 (1–32),形狀用決定性 frontier growth 長出來:從 origin 開始,每次只從已選格的六面鄰居裡挑一個,挑誰由 splitmix64(seed, 局部座標) 排序決定。所以形狀一定六面連通、一定剛好 size 格、同輸入永遠同結果。
addFrontier(origin)
while (selected.size < size) {
val next = frontier.minWith(compareBy { mix(seed, packedLocal(it)) } ...)
selected += next
addFrontier(next)
}
仲裁也一起改。原本兩種礦重疊時是逐座標比 priority,贏的搶走那幾格——輸的那條脈被啃掉中間,剩下玩家挖起來只有一兩格的殘片。現在改成整顆原子淘汰:形狀一重疊,低 priority 的那顆整個不存在。存活下來的每一顆都保證是完整連通、格數等於設定值。相鄰 cell 的同礦種候選如果碰在一起,只留 veinId 較小的,不然它們會接成長帶,又回到 613 格石英那個世界。
順手把 Y 分布從單峰三角形換成 y_weight_points 分段線性,可以描雙峰跟平台。舊 config 沒寫的話仍然走三角 fallback——這裡踩過一次雷:Bukkit 的 copyDefaults 會把新的曲線 key 合併進舊設定,但保留舊的 y_min/y_max,湊出一組端點對不上的混合設定,插件開機直接 fail-fast。現在會偵測 config_schema_version,舊檔就把繼承來的曲線清空、警告、退回三角。
鐵軌那個順便修掉了。曝露判定原本只認 air / water / lava 是透明面,鐵軌旁邊的礦其實玩家早就看得見,但在程式眼裡它六面全包,拆鐵軌的瞬間被當成「首次曝露」,決算一跑沒命中,礦就沒了。改用 Bukkit 的 Material.isOccluding,而且在事件真的生效之前 snapshot 每個被移除方塊的遮蔽狀態,Paper 跟 Folia 兩條路徑讀的是同一份。
「表層一顆,後面沒了」是最難的那個
這是玩家講最多次的症狀,也是我花最久的。
情況是這樣:洞穴壁上有一顆世界生成就露出來的鐵礦,它是真的,Kyokalith 從來不碰它。它後面那幾格還埋著的原版鐵礦呢?對 v1.3.2 來說,那幾格是獨立的誘餌,各自決算。玩家挖掉表層那顆,後面的格子首次曝露,決算跑,沒命中,變石頭。
於是每一顆看得到的礦都變成孤兒。這在原版裡是不會發生的事,而且它剛好是玩家最容易注意到的那種不對勁。
修法有一條路我很快就放棄了:直接沿用原版那顆礦後面本來就有的形狀。它確實好看,問題是那個形狀由世界種子決定,種子地圖算得出來——把它保留下來,等於在反透視上開一個洞,而且是最好用的那種洞 (先看表層,再照著原版形狀往下挖)。
最後做成:世界生成就露出來的礦只是一個「已認證的入口」。玩家第一次挖它的時候,由 Kyokalith 的私有 salt 決定後面延續多大 (該礦種設定的 vein_size 區間) 跟往哪長,一樣是六面連通 frontier growth,限制在切比雪夫半徑 4 以內。整顆形狀在方塊消失之前一次寫進 materialized_positions。
還要有終點。延續形狀的外圈,如果直接貼著原版同礦種的礦,那些格子也一起鎖起來,鎖成「不是礦脈成員」——被鎖過的格子不能再當入口。少了這條,玩家會沿著延續一路挖,每挖到一顆原版礦就開一條新的延續,礦脈變無限長。上限是固定的 7 × size 筆 (最大 224),不是「掃到沒有為止」。
原子性這裡不能讓步。整批跨 chunk 共用一個 SQLite transaction,任何一筆失敗就整批 rollback、記 SEVERE、取消觸發的那次破壞事件——寧可玩家那一鎬沒挖動,也不能讓覆蓋方塊先消失、留下一顆沒被認證的誘餌暴露在外面。這條紅線後來套用到所有路徑:挖掘、爆炸、燃燒、實體改方塊、活塞。
合成礦脈那邊也一起收乾淨。原本是每次命中鎖定觸發點周圍 5×5×5 的同 veinId 格子,靠玩家繼續挖來接力推進;現在一次鎖完整顆形狀 (最多 32 格),移動視窗拿掉,免得接力接到隔壁去。DB 的 algorithm 版本推到 3,只失效 materialized_positions,salt、epoch、dirty、eligible、suspended 全部保留。
遭遇率
形狀對了之後還是稀。cell 從 16³ 縮成 8³ (8 整除 chunk 寬度,跨 chunk 跟負座標語意不變),候選中心密一點,再把 11 種礦的 vein_size 跟每 cell 機率整組重新校準——脈變小、顆數變多,總量不變。
量法也換掉:改成挖一條 2×1 的隧道走直線,數「平均幾格遇到一條新的脈」——因為玩家就是這樣挖的。現在常見礦大概 125 到 255 格會遇到一條,鑽石 434,綠寶石 1260,地獄石英 69。遠古遺骸在 Y9/Y15/Y60 分別是 1489、1057、1456,稀但不是抽不到。
校地獄那輪還挖出一件更蠢的事:nether_gold 的峰值 Y 我寫 60,原版真正的峰值在 15。這是我自己去翻原版生成資料才發現的,測試一次都沒抓到——它只會告訴我「y60 的密度符合期望值」,而期望值本身就是我抄錯的那個數字。
TNT 可以炸到挖不到的東西
最後一個洞是 1.3.4 補的,而且它是反透視的洞,不是體感的洞。
爆炸路徑原本只決算「坑洞新表面」——被炸出來、剛剛首次曝露的那圈方塊。但 TNT 直接摧毀的那一整塊體積呢?那些方塊在事件裡就沒了,沒有下一次首次曝露可以補救,掉落物直接生成。所以拿透視定位一顆埋著的誘餌,不要挖,炸它,就能拿到本來不該存在的礦。
修法是在 Bukkit 產生掉落物之前,先照固定的 world/x/y/z 順序鎖掉所有事件前就可見的天然礦入口,再把 blockList 裡每一格連同新斷面一起決算。埋著的誘餌沒命中就在被摧毀之前變回基底石,爆炸範圍內本來就該命中的則正常掉落。整個事件共用 4096 筆的硬上限,超過就取消整個事件;一般的 miss 仍然不寫資料庫 (熱路徑不碰 DB 是原本就有的紅線)。
順便,原本「爆炸方塊數超過 512 就把清單尾巴裁掉」也改成 O(1) 直接取消整個事件。裁尾巴的語意太難講清楚——哪些方塊留在世界變成隨機的。
一條不可能失敗的測試步驟
單元測試好寫,難的是真的炸下去會怎樣。驗法本身很直觀:埋一顆看不到的鑽石誘餌,炸它,看地上有沒有鑽石掉出來。
我的測試文件很早就有這條,場景寫在 Y=100,而它從寫下來那天起就沒有意義過。GriefPrevention 有一條 BlockSurfaceOtherExplosions 預設開著,會把海平面以上被 TNT 炸到的方塊整批從 blockList 移掉——那個場景在我這套插件組合底下,根本不會有任何方塊被摧毀。「沒掉出鑽石」當然成立,而且永遠都會成立。不可能失敗,也不可能通過。
搬到 Y=20 重跑——低於 GriefPrevention 的門檻,又高過鑽石設定的上限 16,埋著的鑽石誘餌一定得被決算掉。20 次爆破、180 顆誘餌,鑽石 0 顆,掉出來的是鵝卵石、鐵原礦、銅原礦、青金石。
現在
1.3.4 已經上線在跑了。
回頭看,反透視本身其實是這個插件裡最單純的部分——那個誘餌模型上線第一天就成立,到現在沒改過。後面四個版本全部在處理同一件事:讓一個正常玩家挖礦的時候,完全感覺不到底下有東西在替他決定什麼。
repo 在 github.com/TinyYana/Kyokalith,含繁中文件。