心智模型
快照、命令、回執、事件、一拍、批次。理解這六個概念,就理解了 API 為什麼長這樣,以及怎麼寫才快。
快照:讀取,零等待
執行環境每 50 ms 在遊戲執行緒上把整個世界採集一遍,寫進共享記憶體。g.snapshot() 拿到的是一份完整、自洽的世界:
- 16 個玩家欄位:金、木、人口、人口上限、累計採集量、種族;
- 最多 1024 個單位:類型、擁有者、座標、生命 / 魔力(含上限)、目前訂單和訂單目標、實際在打誰(任務目標)、英雄等級 / 經驗 / 技能點、每個玩家對它的可見性;
- 最多 256 份單位細節:12 個技能(等級、剩餘冷卻)、8 個 buff、6 格物品欄;
- 地上物品、樹(每 2 秒更新)、生產表(訓練 / 研究 / 建造 / 升級的進度)、遊戲時鐘、遊戲內時間。
讀一份約 0.4 ms(Python 解析),不必等遊戲執行緒。所以:儘管讀。g.units()、g.my_army()、g.cooldown()、g.inventory() 這類 API 都是從同一份快照裡取值,一拍裡呼叫多少次都不貴。
發布週期可以調整:g.set_publish_period(ms),16 ~ 1000 毫秒。一次採集在遊戲執行緒上約 0.5 ~ 0.9 ms,設成 33 ms 也沒問題。整台機器共用一個值,以最後寫入的為準。
命令:寫入,約一幀
g.move / attack / gather / build / train / cast … 交給遊戲執行緒執行。執行環境在遊戲執行緒的事件分派裡批次執行用戶端提交的命令,所以一條命令大約等一幀(落在事件叢集裡時約 0.1 ms,否則等到下一次分派)。
- 命令可以傳入一個單位或一個清單,清單裡的單位在同一幀一起下令;
- 加上
queue='after'就是 Shift 排隊:做完手上這件再做; - 命令裡的單位直接用從快照拿到的物件即可,SDK 用控制代碼對核對身分(位址會被新單位重複使用,控制代碼不會)。
回執:每條命令都有
r = g.build(worker, "hbar", x, y)
if r: # 引擎接下了
...
else:
r.reason # 'rejected(金不够)'(= 金不夠)
r.verdict # 8
r.exec_us # 這一條在遊戲執行緒上執行了幾微秒
回執是在同一幀裡讀回來的:下令前後單位的訂單、引擎函式的回傳值、可行性檢查的原因碼。它回答「引擎有沒有接下這條命令、為什麼沒接下」,但不回答「最後做成了沒有」—— 做成了沒有要看快照和事件。
全部狀態碼和原因碼見 回執與原因碼。
事件:發生了什麼
on_event(g, ev) 會在每拍的 on_tick 之前,把上一拍以來的事件逐一交給你:
| 事件 | 含義 |
|---|---|
unit.appeared / unit.died / unit.removed | 單位出現、死亡、消失(進入金礦、被轉化、屍體腐爛也算消失,不等於死亡) |
unit.damaged / order.changed / owner.changed | 掉血、換訂單、換擁有者 |
hero.levelup | 英雄升級 |
item.appeared / item.removed | 地上的物品出現、被撿走或用掉 |
damage | 引擎層級:每一下傷害。來源單位、攻擊類型、傷害類型、實際扣血、護甲前傷害 |
killed | 引擎層級:這一下把它打死了,帶有兇手 |
production.done | 訓練 / 研究 / 建造 / 升級完成,帶有四字碼和花了多少遊戲秒。對手的也有 |
spell.cast | 單位放了技能:技能四字碼、等級、冷卻秒數、施法點 |
message | 螢幕訊息框裡出現一條:遊戲提示(「需要更多的農場」)、聊天(.chat 裡有發言者和內容)、系統訊息 |
selection.changed / player.left | 本機玩家的選取變了 / 有玩家離開或被判定落敗移除 |
game.started / game.ended | 新的一局開始 / 離開對局 |
畫板按鈕被點擊、熱鍵、點地面這些輸入事件,請見 介面與輸入。
事件流是全域的:對手的生產完成、野怪的死亡都在裡面。請用 ev.owner 或單位控制代碼過濾。
一拍:Bot 的節奏
on_tick 預設每秒呼叫 5 次(依實際時間)。一拍的耗時基本上就是你自己的運算:快照零等待,命令約一幀。一拍超過週期會自動順延,不會越積越多。
- 2 倍速下別依實際時間等待。 想等 3 遊戲秒,就看
g.clock()增加了 3,不要sleep(1.5)。 - 別在
on_tick裡sleep。 需要「過一會兒再做」,就記下目前的遊戲時間,下一拍再判斷。
批次:幾十條命令只等一次
一拍要下很多條命令時,包在 with g.batch(): 裡:
with g.batch():
g.attack(melee, target_a)
g.attack(ranged, target_b)
g.move(wounded, home.x, home.y)
g.cast(hero, "thunderclap")
# 區塊結束時整批提交:同一幀執行,只等一次遊戲執行緒
- 區塊裡的命令回傳
Pending,區塊結束後變成回執;區塊結束前讀取它會拋出錯誤; - 區塊裡拋出例外時,整批作廢(只送出一半的命令比不送更危險);
- 實測 8 條移動:逐條 68 ~ 99 ms,一批 6.5 ~ 10 ms。
同樣的思路也適用於查詢:g.can_do_many([(u, code), ...])、g.tech_many([...]) 一次查詢很多個。
讀取自己剛寫入的
同一拍裡,快照還看不到你剛下的命令(下一次發布才會跟上)。兩段邏輯可能會搶同一個工人:一段剛派它去蓋農場,另一段看快照以為它還閒著。
g.order_of(u) 解決這個問題:快照跟上之前,它以回執裡的新訂單為準。判斷「是否閒置」用 g.order_of(u),不要用 u.order。 g.idle_workers() 已經把「這一拍剛被派了工作的」排除掉了。
延遲檔位
| 檔位 | 通道 | 延遲 | 用於 |
|---|---|---|---|
| 0 | 推送快照 + 事件流 | 讀一份約 0.4 ms;資料每 50 ms 更新一份 | 所有「看」的 API |
| 1 | 快車道 | 約 1 幀;6 個行程並行時中位數 0.06 ms | 所有命令和查詢(SDK 預設) |
| 2 | 控制通道 | 20 ~ 40 ms | 備援、少數介面類操作(倍速、氣泡、訊息) |
| 3 | 閘道(WebSocket / JSON) | 檔位 1 + 約 1 ms | 任何語言、瀏覽器、LLM、另一台電腦上的程式 |
API 目錄 裡每個 API 都標明了它走哪一個檔位。