神經網路與應用 · 期中專題

坦克競技場手冊

場地、坦克、觀測、動作與計分的完整數值。所有數字都和伺服器程式一致;你的配裝以繳交網站上顯示的為準。

60 s每局最長(1,200 步)
20每秒步數
5 / 10同場坦克(比賽 5 台,錄資料可選 10 台)
91觀測維度
5動作分類頭
800 × 800場地(像素)

01總覽

十字堡壘戰場:中央十字城牆、四個沙包掩體、左右兩座碉堡,五台不同機體的坦克分散在四周
十字堡壘的戰場示意(Blender 建模;為了看清楚,坦克放大約 1.8 倍)。觀戰網站的轉播用同一套模型,從正上方看。

每人各自寫一個規則策略(strategy.py)、錄下示範、訓練神經網路,一人繳交一份;比賽時上場的只有神經網路,規則策略不會執行。

第一步:申請金鑰、登入、選配裝。到繳交網站輸入學號、姓名、台科大信箱(…@ntust.edu.tw 或 …@mail.ntust.edu.tw)申請金鑰,金鑰寄到這個信箱(信裡有網站網址);用學號+金鑰登入(沒收到信先看垃圾郵件匣)。第一次登入成功後,學號就綁定這個信箱,之後重新申請只會寄到綁定的信箱。第一次登入先選配裝(選定後整學期不能改)、替坦克取名,之後才能上傳策略、錄資料。
  1. 寫規則:用 if-else 寫規則策略。規則策略看得到完整狀態(所有坦克、子彈的位置與速度)。函數庫見第 7 節,範例見第 8 節。
  2. 錄資料:在繳交網站的「策略與錄製」上傳 strategy.py,看試跑重播確認沒問題,再讓它打上千局,記下每一步「91 維觀測 → 策略的 5 個動作」(第 9 節)。
  3. 訓練與匯出:用分類模型學規則策略的決定,5 個動作就是 5 個分類頭;訓練完匯出成 model.pt(第 10 節)。
  4. 繳交、上場:上傳 model.pt 和期中報告(第 11 節)。天梯上只有模型在打,只拿得到 91 維觀測,沒有完整狀態。
規則內容
結束只剩 1 台,或滿 1,200 步(60 秒)
本場得分擊殺數+時間加成(有打中敵人才給)+生命加成(活到最後才有),詳見「計分方式」
傷害每發子彈 12 點(標準機體 100 血,要中 9 發才陣亡);子彈碰到牆、場邊或其他坦克就消失(打不到自己)
對手錄資料時是範例 bot(配裝可指定或隨機);天梯上是其他同學的模型,人數不足由範例 bot 補位
第一次做,先跑課堂實作 notebook:一開始有「離線小場地」練習(學生套件的 sandbox.py):自己擺一面牆和一台會定時開槍的假想坦克,不用連伺服器就能看策略每一步的動作與 20 秒的路線,從 Action 練到自己寫 Behavior。之後繳交網站「① 我的坦克」下載「課堂實作 notebook」,用 Colab(檔案 → 上傳筆記本)或 Antigravity/Jupyter 開啟,由上往下執行,就會把整個流程跑完一次:notebook 裡練習寫策略、訓練、匯出;上傳試跑、錄資料、繳交模型在繳交網站上做(notebook 不需要金鑰)。

手冊分三部分:規則(第 1~4 節)、坦克的輸入與輸出(第 5~6 節)、動手做(第 7~11 節,照做事的順序排)。

02計分方式

10競技分(10 分制,小數一位):天梯上「平均每場得分」換算,再加稱號加分
10創意與報告(10 分制):看報告的 4 個表格(我的策略、方法設計、訓練結果、心得),評分重點見第 11 節

兩部分最後都由老師依全班分佈換算成學期成績。

每場得分 = 擊殺數 + 時間加成 + 生命加成

部分怎麼算最多
擊殺每打掉一台 1 分(死在毒圈不算任何人的擊殺)4
時間加成0.3 × 存活時間比例:從開場活到陣亡的時間 ÷ 這場比賽的長度,活到最後就是 1。這場要打中過敵人才給0.3
生命加成0.8 × 剩餘血量比例,只有活到最後的才有0.8
一場的結果擊殺時間生命本場得分
打掉 2 台,活到最後剩 50% 血20.30.42.7
打掉 1 台,比賽進行到 2/3 時陣亡10.201.2
打中過敵人但沒擊殺,活到最後剩 50% 血00.30.40.7
一槍都沒打中,最後被打掉0000

競技分 = 10 × 你的平均每場得分 ÷ 2,平均每場 2 分以上就拿滿 10 分,取到小數一位。排行榜會把它拆成擊殺、時間、生命三部分,讓你看出分數從哪裡來。

  • 天梯每場 5 台,從所有同學的上場版本中隨機抽;人數不足時由範例 bot 補位,bot 不列入排名。每人至少打 200 場。
  • 打到同一台的最後一發才算擊殺。
  • 排行榜上的分數有 95% 區間,每人 200 場時大約 ±0.5 分:區間重疊的同學,差距可能只是運氣。
  • 對手是同學的模型,全班的程度會影響你能拿到幾個擊殺,所以競技分最後會由老師依全班分佈換算。
  • 三個部分的權重可能在公告後微調,排行榜下方永遠顯示目前用的算法。
  • 報告用 Word 範本的 4 個表格寫:靠什麼贏、為什麼這樣設計、訓練結果、心得,詳見第 11 節。天梯名次與分數由系統統整,報告不用寫。

稱號與加分

天梯依整體數據頒發稱號,每個稱號只給一人,要先達到門檻,每個稱號 +0.3 分、最多 +0.6 分。總分 = 競技分+稱號加分,最高 10。排行榜依總分排名。

稱號頒給門檻
搶人頭王者場均擊殺最多至少有擊殺
狙擊王命中率最高每場至少開 6 槍
閃避高手閃避率最高(敵方子彈飛到 40 像素內卻沒打中你的比例)每場至少被瞄準 4 次
生存大師活到最後的比例最高場均至少 0.3 殺
KD 之王擊殺 ÷ 陣亡最高至少有擊殺
火力全開場均傷害最高至少造成傷害

門檻是為了避免用極端打法刷稱號,例如只在貼臉時才開槍來衝命中率。專攻某個稱號是可以的策略,但要在報告裡說明你怎麼取捨。每場比賽結束時,觀戰頁也會頒發「本場稱號」,那些只是好玩、不加分。

為什麼時間加成要「打中過敵人」才給:我們用 600 場模擬測過,完全不動的坦克平均也能活到比賽的 80%,和最強的模型差不多,因為別人忙著互打、沒人理它。如果無條件給時間加成,躲著不打也能白拿分。加上這個條件後,「完全不動」只拿 0.3 分、「隨機亂按」0.9 分,訓練完整的模型 4~9 分(滿分 10),名次順序和只算擊殺時一樣。
待公告:決賽的截止時間與每人局數。

03場地與縮圈

場地 800 × 800 像素,原點在左上角、y 軸朝下。牆擋住坦克、子彈與視線;地形射線只量牆和場邊。每局地圖由伺服器從兩張地圖中隨機選一張,連線時也可以用 map 指定。

牆 5 台模式出生點 圈心可能出現的範圍 最小安全區(半徑 90)
項目數值說明
坦克半徑16坦克之間、坦克和牆都不能重疊;撞到就停在原地
5 台出生點半徑 350以場地中心為圓心,從正上方起每 72° 一個(上圖黑點),每局隨機分配
10 台出生點半徑 350、230兩圈上隨機挑 10 個不卡牆、彼此相距 ≥ 60 的點
開局朝向—車身與砲塔都朝向場地中心
視線340距離內、中間沒有牆的敵人才看得到(雷達 450)

縮圈

安全區圈心每局隨機,落在場地中心 ±80 的方形內(上圖虛線框)。第 10 秒開始縮,第 50 秒縮到半徑 90 後不再變小。

圈心常常在牆裡:兩張地圖的中央都是牆(十字城牆、大樓),大約八成的局圈心落在牆裡或貼著牆。直接開向 me.zone_center 會頂在牆邊、卡在十字的凹角。Behavior.go_to_safe_zone 已經會繞過牆、開到圈心附近放得下坦克的位置;me.outside_safe_zone() 的緩衝也會隨安全區縮小(最多是半徑的 30%)。伺服器的範例 bot 用的是同一套做法。
時間安全區半徑說明
0–10 秒(0–200 步)620開局時整個場地幾乎都在圈內
10–50 秒(200–1,000 步)620 → 90等速縮小,每秒約縮 13.3
50–60 秒90最小安全區

在圈外每秒扣 10 HP(每步 0.5);裝了抗毒塗層每秒 5 HP。死在圈外的不算任何人的擊殺。

04坦克與模組

第一次登入繳交網站時,每人自選一種機體與一種模組,選定後整學期不能更換;練習、錄資料、訓練、比賽都用同一台,所有人用同一套數值。速度單位是「每步移動的像素」,每秒 20 步。

五種機體並排:標準、重裝、輕裝(輪式)、速射(雙管)、長砲(長砲管)
標準standard重裝heavy輕裝scout速射gunner長砲lancer
五種機體的外觀(觀戰網站上用的是同一套模型,車身顏色換成每位同學的顏色)。外觀只是好認,碰撞半徑都是 16。
機體HP移速車身轉向裝填彈速射程取捨
標準 standard1003.080°/秒0.9 秒11770各項均衡
重裝 heavy1202.569°/秒0.9 秒11770耐打,但慢、轉向也慢
輕裝 scout803.886°/秒0.9 秒11770最快,但最脆
速射 gunner903.080°/秒0.6 秒11605射速最快,射程較短
長砲 lancer1003.080°/秒1.3 秒12.5875子彈快又遠,裝填慢
  • 射程=彈速 × 子彈存在的步數(速射 55 步,其餘 70 步)。
  • 砲塔轉向所有機體相同:每秒 126°(每步 0.11 弧度),和車身各自獨立,可以一邊後退一邊往後開火。
  • 後退速度是前進的 0.6 倍。開局有 1 秒射擊冷卻、3 秒技能冷卻。
  • 平衡測試:2,500 場隨機配裝,各機體、模組的平均名次都在 3.00 ±10% 內。

模組

主動模組要自己決定「什麼時候按」(動作第 5 個值設成 1),這也是神經網路要學的一個決定;被動模組一直有效,按了也會被忽略。

模組類型效果持續冷卻
隱身塗層 stealth主動不出現在其他坦克的觀測與視線中;開火立即解除(啟動那一步的開火會被忽略)。子彈仍然打得到你2.5 秒24 秒
能量護盾 shield主動吸收下一次最多 12 點傷害(剛好一發);時間到沒被打到就消失2 秒20 秒
推進衝刺 boost主動移速 ×2(前進、後退都算),期間不能開火1 秒6 秒
抗毒塗層 antizone被動圈外傷害減半(每秒 10 → 5 HP)——
雷達 radar被動視線 340 → 450、地形射線 320 → 430;代價:別人在 1.1 倍距離外就看得到你——

冷卻從按下的那一刻開始算。冷卻中或作用中按下都會被忽略,不扣分。觀測第 89 維是剩餘冷卻比例(0=就緒),第 90 維是否作用中。

05觀測:91 個數字

比賽時模型每一步只拿到這 91 個數字。方向都以自己的車頭為準:不論坦克在地圖哪裡,「右前方有敵人」看起來都一樣。數值大致落在 −1 到 1。

地形射線 ×32只量牆和場邊
敵人 4 台 × 6視線內最近 4 台
子彈 4 顆 × 5250 內最近 4 顆
自身 15自己、安全區、賽局、技能
索引內容正規化
0–31地形射線:0 號為車頭正前方,順時針每 11.25° 一條,到牆或場邊的距離÷ 320(雷達 ÷ 430),最大 1
32–55敵人 ×4,依距離由近到遠,每台 6 個:有無、距離、相對車頭角度的 sin、cos、在你車身座標下的 前進速度、側向速度距離 ÷ 340(雷達 ÷ 450,可能略大於 1);速度 ÷ 6
56–75子彈 ×4,250 範圍內最近的敵方子彈,每顆 5 個:有無、距離、相對車頭角度的 sin、cos、接近速度(正值=朝你飛來)距離 ÷ 250;接近速度 ÷ 14
76自身 HP÷ 最大 HP
77射擊冷卻,0 表示可以開火÷ 裝填時間
78–79自身速度:車身座標下的前進、側向÷(移速 × 2)
80–81砲塔相對車頭角度的 sin、cos—
82–83安全區圈心相對車頭角度的 sin、cos—
84到圈心的距離÷ 800
85到圈邊的有號距離,圈外為負÷ 620,限制在 −1 到 1
86縮圈進度:0 尚未縮,1 縮到最小—
87時間進度目前步數 ÷ 1,200
88剩餘敵人(包含看不到的)÷(同場台數 − 1):5 台 ÷ 4、10 台 ÷ 9
89技能剩餘冷卻比例(1=剛用完,0=就緒;被動模組恆為 0)—
90技能作用中(1 / 0)—
角度的方向:畫面 y 軸朝下,所以 sin > 0 表示目標在你的右側,cos > 0 表示在前方。
看不到的補 0:被牆擋住、隱身中或不足 4 台/4 顆時,那一組全部是 0;「有無」=0 告訴網路這組是空的。這就是記憶(疊加觀測、LSTM)派得上用場的地方。

06動作:5 個分類頭

每一步送出 5 個整數,就是 Class 2 的分類問題一次做 5 個。超出範圍的值會被當成「不動、不開火」。

action[0]移動
  1. 前進
  2. 後退
  3. 停止
action[1]車身
  1. 左轉
  2. 不動
  3. 右轉
action[2]砲塔
  1. 左轉
  2. 不動
  3. 右轉
action[3]開火
  1. 否
  2. 是
action[4]技能
  1. 不用
  2. 使用

例:[0, 2, 1, 1, 0]=前進、車身右轉、砲塔不動、開火、不用技能。冷卻中按開火無效,不扣分。

07寫規則策略:函數庫 strategy_lib

strategy.py 要定義 class Strategy,有 reset()(每局開始呼叫一次)和 act(state)(每一步呼叫,回傳 5 個整數)。可以 import strategy_lib、math、random、numpy、collections 等;不能讀寫檔案、連網路。state 是完整狀態,欄位見規格書第 3 節;一般用下面的 Me 整理過再用就好。

完整說明書(PDF)可以在繳交網站「① 我的坦克」下載,學生套件裡也有 client/docs/規則策略函數說明書.pdf。下面是同一份內容。

這份說明書教你用 strategy_lib 寫規則策略 strategy.py。第一次讀:先看 A(觀念)和 B(常見任務),照著改範例就能動;要查某個函數的參數,再翻 D(函數說明卡)。說明書裡的每個範例都實際跑過。

A 先懂的觀念

A1 座標與角度

場地 800×800 像素,原點在左上角,x 往右變大、y 往下變大。角度用弧度(π=180°):0 朝右、π/2 朝下、±π 朝左、−π/2 朝上;往右轉=角度變大。程式裡通常不用自己算角度,用 me.direction_to(目標) 就好。

(0, 0)x → 800 y ↓ 800 牆 {x, y, w, h} 我 (x, y) 敵人 0(右) π/2(下) ±π(左) −π/2(上) 往右轉=角度變大

A2 坦克每一步要給 5 個數字

每 1/20 秒呼叫一次 act(state),要回傳 5 個整數。通常不用自己寫數字,用 Action、Behavior 的函數加上 combine() 產生。

位置部位(dict 的鍵)012
第 1 個移動 move前進 FWD後退 BACK停止 STOP
第 2 個車身 body左轉 LEFT不轉 NONE右轉 RIGHT
第 3 個砲塔 turret左轉不轉右轉
第 4 個開火 fire不開火開火—
第 5 個技能 skill不按按技能—

例:[0, 2, 1, 1, 0]=前進、車身右轉、砲塔不動、開火、不按技能。車身和砲塔各自轉,所以可以一邊往前開、一邊往旁邊開火。每一步車身最多轉約 4°、砲塔約 6.3°;前進速度每步 2.5~3.8 像素(依機體),倒車是前進的 0.6 倍。

A3 combine:把幾張「小紙條」合成 5 個數字

每個 Action、Behavior 函數只負責其中幾格,回傳一張只寫了那幾格的小紙條(dict)。combine 從「什麼都不做」的預設值開始,照順序一張一張貼上去,最後變成 5 個數字:

move  = Behavior.keep_distance(me, e, 230)   # 例如 {"move": 0, "body": 2}   只管移動、車身
aim   = Action.aim_turret_at(me, e)          # 例如 {"turret": 0}            只管砲塔
fire  = Action.fire_when_aimed(me, e)        # 例如 {"fire": 1}              只管開火
skill = Action.use_skill_if(me, False)       # 例如 {"skill": 0}             只管技能
return combine(move, aim, fire, skill)
步驟移動車身砲塔開火技能
預設值(什麼都不做)2 停1 不轉1 不轉00
貼上 move0 前進2 右轉100
貼上 aim020 左轉00
貼上 fire0201 開火0
貼上 skill02010
結果[0, 2, 0, 1, 0]=前進、車身右轉、砲塔左轉、開火、不按技能

沒給的格子不是 0,而是「什麼都不做」的預設值(combine 一定回傳 5 個數字):

部位移動車身砲塔開火技能
沒給的時候補2(停;0 是前進)1(不轉)1(不轉)0(不開火)0(不按)

所以 combine() 什麼都不給=[2, 1, 1, 0, 0](原地不動);傳進去的是 None(例如自訂行為這一步沒事做)會直接跳過。

同一格貼兩次,後面的蓋掉前面的:combine(move, aim, fire, Action.hold_fire()) 裡,fire 先填「開火 1」,最後的 hold_fire 又填「開火 0」,所以一定不開火。想讓某個決定「最後說了算」,就放在最後面。

不用 combine 也可以:直接 return [0, 2, 0, 1, 0] 效果一樣。combine 只是讓你把「移動」交給 Behavior、「瞄準」交給 Action,各自決定好再合起來,不用記哪一格是第幾個。

A4 strategy.py 的骨架(每一行在做什麼)

from strategy_lib import Me, Memory, P, Action, Behavior, combine

# ① 常數與可調參數:寫在檔案最上面,名字用大寫,整場都不變
CHASSIS, MODULE = "standard", "boost"     # 你的配裝(網站下載的範本會自動填好)
KEEP_DIST = 230                           # 和敵人保持的距離(像素)
LOW_HP = 0.4                              # 血量低於多少就躲

class Strategy:                           # 類別名稱一定要叫 Strategy
    def reset(self):                      # ② 每局開始呼叫一次:在這裡建立「這局要記住」的變數
        self.memory = Memory()            #    Me 會用它記最後看到敵人的位置
        self.shots = 0                    #    自己的計數器:這局開了幾槍

    def act(self, state):                 # ③ 每一步(1/20 秒)呼叫一次,回傳 5 個整數
        me = Me(state, memory=self.memory)    # 把完整狀態整理成 me
        e = me.nearest_visible_enemy()        # 最近看得到的敵人,沒有就是 None

        # ④ 決定移動(這一步才用的變數,寫在 act() 裡就好)
        if me.outside_safe_zone(50):
            move = Behavior.go_to_safe_zone(me)
        elif e is None:
            move = Behavior.patrol(me)
        elif me.hp < LOW_HP:
            move = Behavior.hide_behind_wall(me, e)
        else:
            move = Behavior.keep_distance(me, e, KEEP_DIST)

        # ⑤ 瞄準、開火、技能
        aim = Action.aim_turret_at(me, e)
        fire = Action.fire_when_aimed(me, e)
        skill = Action.use_skill_if(me, me.bullet_coming())
        if fire["fire"]:
            self.shots += 1

        # ⑥ 合成 5 個整數回傳
        return combine(move, aim, fire, skill)

A5 變數要宣告在哪裡

要存什麼寫在哪裡例子
整場都不變的參數(距離、門檻)檔案最上面,名字大寫KEEP_DIST = 230
這局要一直記住的東西(下一局會清空)reset() 裡寫 self.名字 = 初始值,或放在 self.memory.名字self.shots = 0、self.memory.prev_hp = 1.0
只有這一步用的值act() 裡直接寫e = me.nearest_visible_enemy()

不要用檔案最上面的變數記「會變的東西」(例如上一步的血量):它不會在每局開始時清空。self.xxx 一定要先在 reset() 給初始值,否則第一次在 act() 讀它會出錯(AttributeError)。

A6 資料長什麼樣子

東西怎麼拿到欄位(單位)
我me = Me(state, memory=self.memory)見 D1 的屬性表,例如 me.x、me.hp、me.cooldown
敵人(dict)me.nearest_visible_enemy()、me.visible、me.enemies詳見 A8 敵人資訊卡:e["x"]、e["y"](像素)、e["vx"]、e["vy"](每步移動的像素)、e["hp_frac"](0~1)、e["heading"]、e["turret"](弧度)、e["stealthed"]、e["chassis"]、e["module"]
子彈(dict)me.bullets詳見 A9 子彈資訊卡:b["x"]、b["y"]、b["vx"]、b["vy"](子彈每步飛 11 像素,長砲 12.5)
牆(dict)me.walls詳見 A10 牆資訊卡:w["x"]、w["y"](左上角)、w["w"]、w["h"](寬、高)
位置P(x, y)p.x、p.y(注意:P 用 .x,dict 用 ["x"])

A7 清單怎麼用:visible、enemies、bullets、walls

清單(list)就是「很多個 dict 排在一起」:用 for 一個一個取出來,用 [0] 取第一個,用 len() 數有幾個。清單可能是空的([]),用 [0] 之前先 if 清單: 檢查。 下面 A7~A11 的例子,都是從一場真的比賽(十字堡壘,第 28 步:看得到 2 台敵人、場上有 3 顆敵方子彈)抓下來的同一個瞬間,執行結果也是真的。

清單裡面是什麼由誰排序這個瞬間
me.visible敵人資訊卡(A8),只有看得到的由近到遠2 台
me.enemies敵人資訊卡(A8),所有活著的,包含看不到、隱身中的不排序4 台
me.bullets子彈資訊卡(A9),只有敵方的不排序3 顆
me.walls牆資訊卡(A10),整局都不變不排序8 面
print(me.view)                           # 我的視線距離(雷達 450、其他 340)
print(len(me.visible), "台看得到、", len(me.enemies), "台還活著")

for e in me.visible:                       # 一台一台看:看得到的敵人,由近到遠
    print("敵人", round(e["x"]), round(e["y"]), "距離", round(me.distance_to(e)),
          "血", e["hp_frac"], e["chassis"], e["module"])

hidden = [e for e in me.enemies if e not in me.visible]     # 還活著但看不到的(牆後、太遠、隱身)
print("看不到的", len(hidden), "台")

if me.bullets:                             # 子彈清單可能是空的,先檢查
    b = min(me.bullets, key=me.distance_to)                # 最近的一顆敵方子彈
    print("最近的子彈", round(me.distance_to(b)), "像素,速度", round(b["vx"], 1), round(b["vy"], 1))

w = me.walls[0]                            # 第一面牆:左上角 (x, y)、寬 w、高 h
print("牆", w["x"], w["y"], w["w"], w["h"], "共", len(me.walls), "面")

執行結果:

340
2 台看得到、 4 台還活著
敵人 421 130 距離 324 血 1 gunner boost
敵人 519 646 距離 340 血 1 scout stealth
看不到的 2 台
最近的子彈 401 像素,速度 -11.0 -0.7
牆 370 260 60 280 共 8 面

A8 敵人資訊卡(me.visible、me.enemies 裡的每一個)

每一台敵人是一個 dict,用 e["欄位"] 取值。me.nearest_visible_enemy() 回傳的也是同樣的資訊卡(或 None)。

欄位型別單位、範圍意思這個瞬間
e["id"]int1~4(10 台時 1~9)這台敵人的編號,整局不變;可以用來追蹤同一台1
e["x"]、e["y"]float像素,0~800坦克中心的位置(左上角是原點,y 往下變大)420.87、129.69
e["vx"]、e["vy"]float像素/步,約 −7.6~7.6上一步 x、y 各移動了多少(停著或撞牆=0)。速度大小=math.hypot(vx, vy);衝刺時是平常的 2 倍-0.62、2.93
e["heading"]float弧度,−π~π車身朝向(0 朝右、π/2 朝下),也是它前進的方向1.78
e["turret"]float弧度,−π~π砲口朝向,和車身各自轉;子彈會往這個方向射出2.23
e["hp_frac"]float0~1剩餘血量比例(1=滿血);每中一發扣 12 點,標準機體 100 血、重裝 120、輕裝 80、速射 90、長砲 1001
e["alive"]bool—還活著嗎;me.visible、me.enemies 只放活著的,所以一定是 TrueTrue
e["stealthed"]bool—正在隱身;隱身中的敵人不會出現在 me.visible(模型也看不到它)False
e["chassis"]strstandard、heavy、scout、gunner、lancer機體:標準、重裝、輕裝、速射、長砲gunner
e["module"]strstealth、shield、boost、antizone、radar模組:隱身、護盾、衝刺、抗毒、雷達boost
import math
from strategy_lib import wrap

e = me.visible[0]                                    # 最近的一台看得到的敵人
print("位置", round(e["x"]), round(e["y"]), "距離", round(me.distance_to(e)), "像素")
speed = math.hypot(e["vx"], e["vy"])                  # 上一步移動了幾像素(停著或被擋住=0)
print("速度", round(speed, 2), "像素/步")
side = wrap(me.direction_to(e) - me.heading)          # 相對我的車頭:正=在右邊、負=在左邊
print("在我的", "右邊" if side > 0 else "左邊", abs(round(math.degrees(side))), "度")
back = math.atan2(me.y - e["y"], me.x - e["x"])       # 從敵人看向我的方向
print("它的砲口離對準我還差", abs(round(math.degrees(wrap(back - e["turret"])))), "度")
print("10 步後大約在", round(e["x"] + e["vx"] * 10), round(e["y"] + e["vy"] * 10))
print("血", round(e["hp_frac"] * 100), "%|配裝", e["chassis"], e["module"], "|隱身中" if e["stealthed"] else "|沒有隱身")

執行結果:

位置 421 130 距離 324 像素
速度 3.0 像素/步
在我的 右邊 51 度
它的砲口離對準我還差 87 度
10 步後大約在 415 159
血 100 %|配裝 gunner boost |沒有隱身
注意

me.enemies 包含看不到的敵人,是規則策略的「特權」;神經網路比賽時只有觀測,看不到牆後的敵人。策略越依賴 me.enemies,模型越難學得像,一般建議用 me.visible 做決定。

A9 子彈資訊卡(me.bullets 裡的每一顆)

欄位型別單位、範圍意思這個瞬間
b["x"]、b["y"]float像素,0~800子彈現在的位置374.6、650.3
b["vx"]、b["vy"]float像素/步每一步 x、y 各前進多少;方向=math.atan2(vy, vx),速度大小固定 11(長砲射的 12.5)10.8、-1.9
b["own"]bool—是不是我自己射的;me.bullets 只放敵方的,所以一定是 FalseFalse

子彈的規則:從砲口前方 24 像素射出;每步飛 11 像素(長砲 12.5),最多飛 70 步(速射 55),所以射程約 770(速射 605、長砲 875);碰到牆或場邊就消失;離某台坦克中心不到 16 像素就算打中,扣 12 點(護盾作用中會先擋掉)。打不到自己。

import math

for b in me.bullets:                                  # 每一顆敵方子彈
    d = me.distance_to(b)
    speed = math.hypot(b["vx"], b["vy"])              # 11 像素/步(長砲 12.5)
    toward = (me.x - b["x"]) * b["vx"] + (me.y - b["y"]) * b["vy"] > 0    # 往我這邊飛嗎
    if toward:
        print("距離", round(d), "速度", round(speed, 1), "往我飛|約", round(d / speed), "步後飛到我附近")
    else:
        print("距離", round(d), "速度", round(speed, 1), "遠離我|不用理它")
print("會不會打到我(bullet_coming):", me.bullet_coming())

執行結果:

距離 426 速度 11.0 往我飛|約 39 步後飛到我附近
距離 401 速度 11.0 遠離我|不用理它
距離 459 速度 11.0 往我飛|約 42 步後飛到我附近
會不會打到我(bullet_coming): False

A10 牆資訊卡(me.walls 裡的每一面)

欄位型別單位意思這個瞬間(第一面)
w["x"]、w["y"]int像素牆的左上角370、260
w["w"]、w["h"]int像素寬(往右)、高(往下)。右下角=(x+w, y+h)60、280

牆是長方形,整局都不會變(十字堡壘 8 面、城區巷戰 9 面)。坦克開不過去、子彈打到就消失、視線會被擋住(me.can_see 就是在檢查有沒有牆擋)。

import math

w = me.walls[0]
print("左上角", (w["x"], w["y"]), "右下角", (w["x"] + w["w"], w["y"] + w["h"]), "中心", (w["x"] + w["w"] / 2, w["y"] + w["h"] / 2))

def inside(x, y, w):                                  # 點 (x, y) 在不在這面牆裡
    return w["x"] <= x <= w["x"] + w["w"] and w["y"] <= y <= w["y"] + w["h"]
print("點 (400, 400) 在第一面牆裡嗎:", inside(400, 400, w))

def gap(w):                                           # 我到這面牆最近的距離(0=貼著牆)
    dx = max(w["x"] - me.x, 0, me.x - (w["x"] + w["w"]))
    dy = max(w["y"] - me.y, 0, me.y - (w["y"] + w["h"]))
    return math.hypot(dx, dy)
near = min(me.walls, key=gap)
print("離我最近的牆", near, "距離", round(gap(near)), "像素")

執行結果:

左上角 (370, 260) 右下角 (430, 540) 中心 (400.0, 400.0)
點 (400, 400) 在第一面牆裡嗎: True
離我最近的牆 {'x': 670, 'y': 370, 'w': 40, 'h': 60} 距離 30 像素

A11 安全區(me.zone_center、me.zone_r)

屬性型別意思這個瞬間
me.zone_centerP安全區圓心,每局隨機,在地圖中心 (400, 400) 附近 ±80 像素內;整局不動P(420, 320)
me.zone_rfloat安全區半徑(像素)。第 0~200 步是 620,之後越縮越小,第 1,000 步(50 秒)縮到 90 就不再縮620

在圈外每秒扣 10 血(抗毒模組減半)。

c, r = me.zone_center, me.zone_r
edge = r - me.distance_to(c)                         # 正=在圈內,離邊界還有多遠;負=已經在圈外
print("圈心", round(c.x), round(c.y), "半徑", round(r), "|我離邊界", round(edge), "像素,", "在圈內" if edge > 0 else "在圈外,每秒扣 10 血")

執行結果:

圈心 420 320 半徑 620 |我離邊界 372 像素, 在圈內

B 常見任務怎麼寫

下面的片段放在 act() 裡,假設前面已經有 me、e = me.nearest_visible_enemy()、move、aim、fire(見 A4 的骨架)。

B1 砲口轉向敵人、對準了就開火

最基本的攻擊:轉砲口和開火是兩件事,兩個都要寫。

aim = Action.aim_turret_at(me, e)          # 砲口轉向敵人(打提前量)
fire = Action.fire_when_aimed(me, e)       # 對準、裝填好、沒有牆擋,才開火
return combine(move, aim, fire)

B2 開到地圖上某個位置

目的地用 P(x, y),x、y 是像素(0~800,左上角是原點)。

move = Action.drive_to(me, P(400, 400))              # 開到地圖中央
move = Action.drive_to(me, P(100, 700), arrive=30)    # 開到左下角附近,差 30 像素就算到

B3 往某個方向開(例如遠離敵人)

方向是角度(弧度)。敵人的方向加 π(180°)就是反方向。

import math
away = me.direction_to(e) + math.pi        # 敵人的反方向
move = Action.drive_in_direction(me, away)

B4 邊退邊打

倒車時車頭仍對著敵人,砲口另外瞄準。

move = Action.back_away_from(me, e)        # 車頭對著敵人往後退
return combine(move, Action.aim_turret_at(me, e), Action.fire_when_aimed(me, e))

B5 子彈飛來就開護盾(護盾模組)

護盾 2 秒內擋一發子彈,冷卻 20 秒,所以只在真的有子彈飛來時才開。

skill = Action.use_skill_if(me, me.bullet_coming())
return combine(move, aim, fire, skill)

B6 血少時隱身,隱身中不開火(隱身模組)

隱身中一開火就解除,所以隱身時要用 hold_fire 蓋掉開火。

skill = Action.use_skill_if(me, me.hp < 0.4 and e is not None)
if me.skill_active:
    fire = Action.hold_fire()                  # 隱身中不開火
return combine(move, aim, fire, skill)

B7 在圈外時衝刺回安全區(衝刺模組)

衝刺 1 秒內速度 ×2,期間不能開火,冷卻只有 6 秒。

if me.outside_safe_zone(0):
    move = Behavior.go_to_safe_zone(me)
    skill = Action.use_skill_if(me, True)      # 就緒就按
else:
    skill = Action.use_skill_if(me, False)

B8 記住上一步的血量,判斷是不是剛被打

每一步都會重新建立 me,要跨步記住的東西放在 self.memory。先在 reset() 裡給初始值:self.memory.prev_hp = 1.0

hurt = me.hp < self.memory.prev_hp           # 血比上一步少 → 剛被打
self.memory.prev_hp = me.hp                  # 記下來給下一步用
if hurt and e is not None:
    move = Behavior.hide_behind_wall(me, e)

B9 敵人躲起來時去找他

最近看過敵人但現在看不到,就開到最後看到的位置。

if e is None and me.saw_enemy_recently(80):
    move = Behavior.go_to_last_seen(me)
elif e is None:
    move = Behavior.patrol(me)

B10 只打血少的敵人

me.visible 是所有看得到的敵人(由近到遠),可以自己挑目標。

weak = [x for x in me.visible if x["hp_frac"] < 0.5]
target = weak[0] if weak else e              # 有血少的就打它,否則打最近的
aim = Action.aim_turret_at(me, target)
fire = Action.fire_when_aimed(me, target)

C 技能

模組類型效果持續冷卻怎麼用
隱身塗層 stealth主動不出現在敵人的觀測裡;一開火就解除2.5 秒24 秒危險時按,作用中用 hold_fire()
能量護盾 shield主動擋下一發子彈(12 點)2 秒20 秒me.bullet_coming() 時按
推進衝刺 boost主動速度 ×2,期間不能開火1 秒6 秒逃跑、回圈、拉近距離時按
抗毒塗層 antizone被動圈外傷害減半一直有效不用按
雷達 radar被動視線 340 → 450(別人也更早看到你)一直有效不用按

主動技能用 Action.use_skill_if(me, 條件):條件成立而且 me.skill_ready 才會真的按。每局開始有 3 秒不能按。me.skill_active 表示技能正在作用中。範例見 B5~B7。

D 函數說明卡

D0 函數總覽(先看這張表找函數,再看下面的說明卡)

函數做什麼
Me 的方法(me.…)(內建 13 個)
me.distance_to(p)我到 p 的直線距離。
me.direction_to(p)從我指向 p 的方向。
me.can_see(a, b)a 和 b 之間的直線有沒有被牆擋住。
me.is_open(p, rad=18)p 這個位置能不能放下一台坦克(不撞牆、不出界)。
me.clearance(ang)從我的中心往 ang 方向,多遠會碰到牆或場邊(像素)。和模型觀測的地形射線是同一種量法。
me.wall_gap()我的車身邊緣離最近的牆或場邊還有多遠(像素);0=貼著牆。
me.zone_route()這一局回安全區的路線(第一次呼叫時計算,存在 memory 裡)。Behavior.go_to_safe_zone 會用它繞過牆。
me.nearest_visible_enemy()最近的一台看得到的敵人。
me.in_safe_zone(p, margin=0)p 在不在安全區裡,而且離邊界至少 margin 像素。
me.outside_safe_zone(margin=50)我是不是在安全區外,或離邊界不到 margin 像素(該往圈內走了)。
me.saw_enemy_recently(n=80)最近 n 步內有沒有看到過敵人。
me.bullet_coming(horizon=130, width=26)有沒有敵方子彈正朝我飛來。
me.cover_spot(enemy, margin=28)找一個牆後面、敵人看不到、又在安全區內的位置。
Action:基本動作(內建 9 個)
Action.turn_body_toward(me, ang, tol=0.04)把車身轉向某個角度(只轉,不前進)。
Action.drive_in_direction(me, ang)往某個方向開;前方被牆擋住會自動往兩側找出口。
Action.drive_to(me, p, arrive=12)開到某個位置,到了就停。會自動轉向、繞過前方的牆。
Action.back_away_from(me, p)車頭對著 p,往後倒車遠離它(倒車速度是前進的 0.6 倍)。適合邊退邊打。
Action.stop()停下、車身不轉。
Action.aim_turret_at(me, p, lead=True, tol=0.05)把砲口轉向目標。砲塔和車身分開轉,每一步最多轉約 6.3°。
Action.fire_when_aimed(me, e, tol=0.12, check_walls=True)瞄準了才開火:同時滿足下面三個條件才回傳開火。
Action.hold_fire()這一步不開火,例如隱身中(一開火隱身就解除)。放在 combine 的最後面就能蓋掉前面的開火。
Action.use_skill_if(me, want)條件成立而且技能就緒時按技能。
Behavior:行為(內建 8 個)
Behavior.go_to_safe_zone(me, arrive=12)往安全區中心開(在圈外會持續扣血,每秒 10)。會繞過牆:圈心在牆裡時,開到圈心附近放得下坦克的位置。
Behavior.keep_distance(me, e, d, near=40, far=60, approach=0.5)和敵人保持差不多 d 的距離,邊繞圈邊打。
Behavior.rush_at(me, e, stop_at=62)衝向敵人,靠近到 stop_at 像素就停。
Behavior.unstick(me, wait=1, back=16, gap=8, still=0.3, with_enemy=False, toward=None, pull=0.5, look=120, cooldown=0)脫困:貼著牆、幾乎沒在動時,往空曠的方向離開牆(牆在車頭就倒車,牆在車尾就前進)。
Behavior.hide_behind_wall(me, e, margin=28)躲到牆後敵人看不到的地方;附近找不到牆就撤回安全區。
Behavior.go_to_last_seen(me, arrive=12)開到最後一次看到敵人的位置(敵人躲起來時去找它)。
Behavior.patrol(me, radius=300, spread=0.6)沒看到敵人時在安全區裡隨機巡邏,到了就換下一個點。
Behavior.dodge(me)自己寫的第 2 層行為:找最近一顆朝自己飛來的子彈,往和子彈垂直的方向側移。
其他
combine(*parts)把各部位的決定合成坦克這一步的 5 個整數:[移動, 車身, 砲塔, 開火, 技能]。
Me(state, memory=None)「我」這一步看得到、用得到的資訊。每一步在 act() 的第一行建立:me = Me(state, memory=self.memory)
Memory()跨步保存的記憶。每一步都會重新建立 Me,只有存在 Memory(或 self)裡的東西會留到下一步。
P(x, y)平面上的一個點,用來表示「要去的地方」。
wrap(a)把角度換到 −π~π 之間(弧度)。
@Action.custom把自己寫的基本動作加進 Action,之後用 Action.函數名(me, …) 呼叫,和內建的一樣。
@Behavior.custom把自己寫的行為加進 Behavior,之後用 Behavior.函數名(me, …) 呼叫,和內建的一樣。
不是內建的:說明書和範例裡「自己寫」的函數(要先寫出來才能用)
Action.fire_if_close(me, e, max_dist=250)E 節的範例:用 @Action.custom 自己寫的,只在敵人夠近時開火
Behavior.careful_fight(me, e, d=230)E 節的範例:用 @Behavior.custom 自己寫的,血多保持距離、血少躲牆後
Behavior.dodge(me)範例 C 的 strategy.py 裡用 @Behavior.custom 自己寫的閃子彈
want_skill(me, e, hurt)範例 C 的 strategy.py 裡自己寫的一般函數:判斷各模組什麼時候按技能
D1 Me:我的資訊

me = Me(state, memory=self.memory)

「我」這一步看得到、用得到的資訊。每一步在 act() 的第一行建立:me = Me(state, memory=self.memory)

參數預設說明
state(必填)dict,act(state) 收到的完整狀態,原封不動傳進來
memoryNoneMemory,在 reset() 建立的那一個(不給的話每步都是新的,就記不住東西)
屬性說明
tint,第幾步(每秒 20 步,一局最多 1,200 步)
x、yfloat,我的位置(像素)
headingfloat,車身朝向(弧度)
vx、vyfloat,這一步實際移動了多少(像素);speed:float,移動的距離(撞牆停住時是 0)
turretfloat,砲口朝向(弧度,和車身各自轉)
hpfloat,剩餘血量比例 0~1(0.5=剩一半)
cooldownint,還要幾步才能再開火,0=可以開火
skill_readybool,技能可以按了(被動模組永遠 False)
skill_activebool,技能正在作用中
chassis、modulestr,我的配裝,例如 "heavy"、"shield"
viewint,我的視線距離(像素),雷達 450、其他 340
visiblelist,看得到的敵人,由近到遠(每個都是敵人 dict)
enemieslist,所有活著的敵人,包含看不到、隱身中的
bulletslist,場上的敵方子彈(dict:x、y、vx、vy)
wallslist,牆,每面是 dict:x、y(左上角)、w(寬)、h(高)
zone_centerP,安全區圓心;zone_r:float,安全區目前的半徑(像素)
memMemory,就是你傳進來的 memory

範例

me = Me(state, memory=self.memory)
if me.hp < 0.3 and me.skill_ready: ...

me.distance_to(p)

我到 p 的直線距離。

參數預設說明
p(必填)要量的目標,可以是敵人 dict、子彈 dict 或 P
回傳

float:距離(像素)

範例

if e is not None and me.distance_to(e) < 200: ...   # 敵人在 200 像素內

me.direction_to(p)

從我指向 p 的方向。

參數預設說明
p(必填)目標,可以是敵人 dict、子彈 dict 或 P
回傳

float:絕對角度(弧度,−π~π;0 朝右、π/2 朝下)

範例

ang = me.direction_to(e)
move = Action.drive_in_direction(me, ang + math.pi)   # 往敵人的反方向開

me.can_see(a, b)

a 和 b 之間的直線有沒有被牆擋住。

參數預設說明
a、b(必填)兩個點,可以是 me、敵人 dict、子彈 dict 或 P
回傳

bool:True=沒被擋住(看得到、子彈打得到)

範例

if me.can_see(me, e): ...                 # 我和敵人之間沒有牆

me.is_open(p, rad=18)

p 這個位置能不能放下一台坦克(不撞牆、不出界)。

參數預設說明
p(必填)P,要檢查的位置
rad18float,坦克半徑加一點餘裕(像素),預設 18
回傳

bool:True=放得下

me.clearance(ang)

從我的中心往 ang 方向,多遠會碰到牆或場邊(像素)。和模型觀測的地形射線是同一種量法。

參數預設說明
ang(必填)float,方向(弧度,絕對角度);車頭正前方是 me.heading
回傳

float:距離(像素)

範例

if me.clearance(me.heading) < 30:      # 車頭前面 30 像素內就是牆
    ...

me.wall_gap()

我的車身邊緣離最近的牆或場邊還有多遠(像素);0=貼著牆。

回傳

float

範例

if me.wall_gap() < 6 and me.speed < 0.3: ...   # 貼著牆、幾乎沒在動

me.zone_route()

這一局回安全區的路線(第一次呼叫時計算,存在 memory 裡)。Behavior.go_to_safe_zone 會用它繞過牆。

me.nearest_visible_enemy()

最近的一台看得到的敵人。

「看得到」=在我的視線距離內(340,雷達 450)、沒被牆擋、沒有隱身,和模型比賽時的觀測一致。

回傳

敵人 dict 或 None(沒有看到任何敵人)
敵人 dict 的欄位:x、y(位置)、vx、vy(每步移動的像素)、heading、turret(弧度)、
hp_frac(剩餘血量比例)、alive、stealthed、chassis、module

範例

e = me.nearest_visible_enemy()
if e is None:
    move = Behavior.patrol(me)
注意

回傳可能是 None,用 e["x"] 之前要先檢查。Action 的函數收到 None 會自動「不轉、不開火」。

me.in_safe_zone(p, margin=0)

p 在不在安全區裡,而且離邊界至少 margin 像素。

參數預設說明
p(必填)P 或敵人 dict
margin0float,要離邊界多遠才算安全(像素),預設 0
回傳

bool

me.outside_safe_zone(margin=50)

我是不是在安全區外,或離邊界不到 margin 像素(該往圈內走了)。

參數預設說明
margin50float,離邊界多近就算危險(像素),預設 50;最多是安全區半徑的 30%(最小圈 90 時是 27)
回傳

bool:開局前 150 步(7.5 秒)一律回傳 False;圈心在牆裡、而我已經在圈心附近的空位時也回傳 False

範例

if me.outside_safe_zone(60):
    move = Behavior.go_to_safe_zone(me)

me.saw_enemy_recently(n=80)

最近 n 步內有沒有看到過敵人。

參數預設說明
n80int,往回算幾步,預設 80(4 秒)
回傳

bool:True 時 me.mem.last_seen 是最後看到的位置

範例

if e is None and me.saw_enemy_recently(60):
    move = Behavior.go_to_last_seen(me)

me.bullet_coming(horizon=130, width=26)

有沒有敵方子彈正朝我飛來。

參數預設說明
horizon130float,子彈再飛多遠以內才算(像素),預設 130(子彈每步飛 11,約 12 步內會到)
width26float,會從我旁邊多近擦過才算(像素),預設 26(坦克半徑 16)
回傳

bool

範例

skill = Action.use_skill_if(me, me.bullet_coming())   # 護盾:子彈飛來才開

me.cover_spot(enemy, margin=28)

找一個牆後面、敵人看不到、又在安全區內的位置。

參數預設說明
enemy(必填)敵人 dict,要躲開誰
margin28float,離牆多遠(像素),預設 28
回傳

P 或 None(附近沒有適合的牆)

D2 Action:基本動作(第 1 層)

第 1 層:基本動作,每個函數只控制坦克的一兩個部位。

回傳的都是 dict,只寫要控制的部位:
move:0 前進、1 後退、2 停止
body:0 車身左轉、1 不轉、2 右轉
turret:0 砲塔左轉、1 不轉、2 右轉
fire:0 不開火、1 開火
skill:0 不按、1 按技能
最後用 combine() 把幾個 dict 合成 5 個整數。

Action.turn_body_toward(me, ang, tol=0.04)

把車身轉向某個角度(只轉,不前進)。

每一步車身最多轉約 4°(重裝 3.4°、輕裝 4.3°),所以轉半圈要 40 多步。

參數預設說明
me(必填)Me
ang(必填)float,目標角度(弧度,絕對角度,通常用 me.direction_to(某點) 算)
tol0.04float,差多少以內就不轉(弧度),預設 0.04(約 2°)
回傳

dict:{"body": 0 左轉/1 不轉/2 右轉}

Action.drive_in_direction(me, ang)

往某個方向開;前方被牆擋住會自動往兩側找出口。

方向和車頭差很多時,邊開邊轉(轉彎半徑約 43 像素);車頭前面 45 像素內就是牆時,改成倒車轉向。
不會停下來原地轉:原地轉向時要往哪邊轉,取決於模型看不到的目的地,模型學了容易在原地打轉。

參數預設說明
me(必填)Me
ang(必填)float,要去的方向(弧度,絕對角度)
回傳

dict:{"move": 0 前進或 1 倒車, "body": 0/1/2}

範例

ang = me.direction_to(e) + math.pi          # 敵人的反方向
move = Action.drive_in_direction(me, ang)

Action.drive_to(me, p, arrive=12)

開到某個位置,到了就停。會自動轉向、繞過前方的牆。

目的地在 90 像素內又不在正前方時:在後方就倒車過去,否則停下來轉向(避免邊開邊轉繞過頭)。

參數預設說明
me(必填)Me
p(必填)P 或敵人 dict,目的地,例如 P(400, 400)
arrive12float,距離小於多少就算到了、停下(像素),預設 12
回傳

dict:{"move", "body"}

範例

move = Action.drive_to(me, P(400, 400))     # 開到地圖中央
注意

只管移動和車身,砲塔要另外用 aim_turret_at 控制。

Action.back_away_from(me, p)

車頭對著 p,往後倒車遠離它(倒車速度是前進的 0.6 倍)。適合邊退邊打。

參數預設說明
me(必填)Me
p(必填)要遠離的目標(敵人 dict 或 P)
回傳

dict:{"move": 1, "body": 0/1/2}

Action.stop()

停下、車身不轉。

回傳

dict:{"move": 2, "body": 1}

Action.aim_turret_at(me, p, lead=True, tol=0.05)

把砲口轉向目標。砲塔和車身分開轉,每一步最多轉約 6.3°。

參數預設說明
me(必填)Me
p(必填)要瞄準的目標:敵人 dict、P,或 None(None 時砲塔不動)
leadTruebool,預設 True。是否打提前量:依子彈飛到目標的時間,瞄準敵人「待會會在」的位置; p 是 P(固定點)時沒有作用
tol0.05float,差多少以內就不轉(弧度),預設 0.05(約 3°)
回傳

dict:{"turret": 0 左轉/1 不轉/2 右轉}

範例

aim = Action.aim_turret_at(me, e)               # 瞄準最近的敵人
aim = Action.aim_turret_at(me, e, lead=False)   # 不打提前量
注意

只負責「轉」,開火要另外用 fire_when_aimed。

Action.fire_when_aimed(me, e, tol=0.12, check_walls=True)

瞄準了才開火:同時滿足下面三個條件才回傳開火。

1. 裝填完成(me.cooldown == 0;裝填 0.9 秒,速射 0.6、長砲 1.3)
2. 砲口和敵人(含提前量)的角度誤差小於 tol
3. 中間沒有牆擋住(check_walls=True 時)
參數預設說明
me(必填)Me
e(必填)敵人 dict 或 None(None 時不開火)
tol0.12float,角度誤差多少以內就開火(弧度),預設 0.12(約 7°)。越小越準、但開火越少
check_wallsTruebool,預設 True。False 時不管牆,對準就開火
回傳

dict:{"fire": 0 或 1}

範例

fire = Action.fire_when_aimed(me, e, tol=0.08)
注意

要先用 aim_turret_at 把砲口轉過去,否則一直對不準、不會開火。

Action.hold_fire()

這一步不開火,例如隱身中(一開火隱身就解除)。放在 combine 的最後面就能蓋掉前面的開火。

回傳

dict:{"fire": 0}

Action.use_skill_if(me, want)

條件成立而且技能就緒時按技能。

模組:隱身、護盾、衝刺是主動技能,要按;抗毒、雷達是被動能力,一直有效,按了也沒效果。
每局開始有 3 秒不能按;按下後要等冷卻(隱身 24 秒、護盾 20 秒、衝刺 6 秒)。

參數預設說明
me(必填)Me
want(必填)bool,你想不想用技能(寫成一個條件)
回傳

dict:{"skill": 0 或 1}(技能沒就緒時一定是 0,按了也是浪費)

範例

Action.use_skill_if(me, me.bullet_coming())                  # 護盾:子彈飛來
Action.use_skill_if(me, me.hp < 0.4 and e is not None)       # 隱身:快死時躲
Action.use_skill_if(me, me.outside_safe_zone(0))             # 衝刺:在圈外時衝回去

combine(parts)

把各部位的決定合成坦克這一步的 5 個整數:[移動, 車身, 砲塔, 開火, 技能]。

依序合併,後面的會覆蓋前面同一個部位;沒有任何人指定的部位用預設值:停止、不轉、不轉、不開火、不按技能。

參數預設說明
*parts(必填)任意個 dict(或 None,會跳過)
回傳

list:5 個整數,act() 直接 return 它

範例

return combine(move, Action.aim_turret_at(me, e), Action.fire_when_aimed(me, e))
return combine(move, aim, fire, Action.hold_fire())   # 最後一個蓋掉開火 → 一定不開火
D3 Behavior:行為(第 2 層)

第 2 層:行為,每個函數完成一件事(例如保持距離、躲牆後),會自己決定移動和車身。

回傳 dict(move、body),再和瞄準、開火、技能一起用 combine() 合起來。
參數都有預設值,不寫就用預設;想調整就用「參數名=值」,例如 Behavior.patrol(me, radius=200)。

Behavior.go_to_safe_zone(me, arrive=12)

往安全區中心開(在圈外會持續扣血,每秒 10)。會繞過牆:圈心在牆裡時,開到圈心附近放得下坦克的位置。

兩張地圖中央都是牆,約八成的局圈心在牆裡;直接 drive_to(me.zone_center) 會頂在牆邊、卡在十字的凹角。

參數預設說明
me(必填)Me
arrive12float,離目的地多近就停(像素),預設 12
回傳

dict:{"move", "body"}

Behavior.keep_distance(me, e, d, near=40, far=60, approach=0.5)

和敵人保持差不多 d 的距離,邊繞圈邊打。

距離 < d − near:倒車(車頭仍對著敵人)
距離 > d + far :斜斜地靠近
中間:側移繞圈(不容易被打中)
參數預設說明
me(必填)Me
e(必填)敵人 dict(不能是 None)
d(必填)float,想保持的距離(像素),例如 230
near40float,預設 40
far60float,預設 60
approach0.5float,靠近時偏離正前方多少(弧度),預設 0.5(約 29°)
回傳

dict:{"move", "body"}

範例

move = Behavior.keep_distance(me, e, 230)
move = Behavior.keep_distance(me, e, 300, near=20, far=100)   # 長砲:拉遠一點

Behavior.rush_at(me, e, stop_at=62)

衝向敵人,靠近到 stop_at 像素就停。

參數預設說明
me(必填)Me
e(必填)敵人 dict
stop_at62float,停下的距離(像素),預設 62
回傳

dict:{"move", "body"}

Behavior.unstick(me, wait=1, back=16, gap=8, still=0.3, with_enemy=False, toward=None, pull=0.5, look=120, cooldown=0)

脫困:貼著牆、幾乎沒在動時,往空曠的方向離開牆(牆在車頭就倒車,牆在車尾就前進)。

為什麼需要:坦克停在牆邊的原因(等敵人、目的地在牆後、被擋住)常常是模型看不到的,
模型只學到「牆邊 → 停」,就會在牆邊左右轉個不停、整局不動。
觸發條件(牆很近、速度接近 0、看不看得到敵人)在 91 維觀測裡都看得到
(地形射線、自身速度、敵人欄位),錄進資料後模型學得到「卡在牆邊 → 離開」。

每一步都要呼叫(它要數「已經停了幾步」);沒卡住時回傳 None,照原本的戰術走。
參數(都可以調,拿來做實驗):
me:Me(要有 memory,才記得住停了幾步)
wait:int,貼牆又沒動幾步就算卡住,預設 1。等越久,資料裡「貼牆不動」標成「停」的越多,模型越學不會離開
back:int,離開時開幾步,預設 16(0.8 秒)。太短:剛離開又貼回去;太長:浪費時間
gap:float,車身離牆多近算「貼著牆」(像素),預設 8
still:float,這一步移動不到多少像素算「沒在動」,預設 0.3(全速約 2.5~3.8)
with_enemy:bool,看得到敵人時也觸發,預設 False(躲在牆後等敵人時不會被打斷)
toward:P、敵人 dict 或 None,離開時偏向哪裡,例如 me.zone_center(往圈內)、me.mem.last_seen(往最後看到敵人的地方)
pull:float,偏向 toward 的強度,0=只看哪裡空曠,1=很在意方向,預設 0.5
look:float,量「哪個方向空曠」時最多看多遠(像素),預設 120
cooldown:int,脫困結束後幾步內不再觸發,預設 0(避免在同一個角落反覆脫困可以設 20~40)

回傳

dict:{"move": 0 前進或 1 倒車, "body"};沒卡住時 None

範例

unstick = Behavior.unstick(me)                                   # 放在戰術的最前面,每一步都呼叫
unstick = Behavior.unstick(me, toward=me.zone_center, pull=0.8)  # 脫困時盡量往圈內
if unstick:
    move = unstick
elif ...:

Behavior.hide_behind_wall(me, e, margin=28)

躲到牆後敵人看不到的地方;附近找不到牆就撤回安全區。

參數預設說明
me(必填)Me
e(必填)敵人 dict,要躲誰
margin28float,離牆多遠(像素),預設 28
回傳

dict:{"move", "body"}

範例

if me.hp < 0.4:
    move = Behavior.hide_behind_wall(me, e)

Behavior.go_to_last_seen(me, arrive=12)

開到最後一次看到敵人的位置(敵人躲起來時去找它)。

參數預設說明
me(必填)Me
arrive12float,預設 12
回傳

dict:{"move", "body"}

注意

先確認 me.saw_enemy_recently() 為 True,否則位置可能是 None。

Behavior.patrol(me, radius=300, spread=0.6)

沒看到敵人時在安全區裡隨機巡邏,到了就換下一個點。

參數預設說明
me(必填)Me
radius300float,巡邏點離安全區中心最遠多少(像素),預設 300
spread0.6float,最遠不超過安全區半徑的幾倍,預設 0.6
回傳

dict:{"move", "body"}

Behavior.dodge(me)

自己寫的第 2 層行為:找最近一顆朝自己飛來的子彈,往和子彈垂直的方向側移。

D4 Memory、P、wrap

self.memory = Memory()

跨步保存的記憶。每一步都會重新建立 Me,只有存在 Memory(或 self)裡的東西會留到下一步。

在 reset() 裡建立新的,每局開始時就會清空:self.memory = Memory()

屬性說明
last_seenP 或 None,最後一次看到敵人的位置
last_seen_tint,那是第幾步
waypointP 或 None,巡邏的目標點(Behavior.patrol 用)
side+1 或 −1,這局偏好往右或往左繞(每局隨機) 也可以自己加屬性記東西(不用事先宣告): self.memory.prev_hp = me.hp

範例

def reset(self):
    self.memory = Memory()
    self.memory.prev_hp = 1.0             # 自己加的:上一步的血量

P(x, y)

平面上的一個點,用來表示「要去的地方」。

參數預設說明
x(必填)float,像素,0(左)~800(右)
y(必填)float,像素,0(上)~800(下)

範例

center = P(400, 400)                      # 地圖正中央
move = Action.drive_to(me, center)

wrap(a)

把角度換到 −π~π 之間(弧度)。

兩個角度相減後常常超出範圍(例如 3.0 − (−3.0) = 6.0),用 wrap 換回來,
正值就是「往右轉比較近」,負值就是「往左轉比較近」,絕對值是差幾度(弧度)。

參數預設說明
a(必填)float,任意角度(弧度)
回傳

float:−π~π 之間的同一個角度

範例

diff = wrap(me.direction_to(e) - me.turret)   # 砲口還差多少才對準敵人
if diff > 0: print("要往右轉")

E 自己寫基本動作與行為

注意

下面範例裡的 fire_if_close、careful_fight 不在函數庫裡,是這段程式自己寫的。要先寫出 @Behavior.custom 和 def careful_fight(...) 那幾行,才能用 Behavior.careful_fight(...);只抄呼叫那一行會出現 AttributeError。範例 C 的 dodge() 也是這樣寫出來的。

在自己寫的函數上一行加 @Action.custom 或 @Behavior.custom,之後就能和內建函數一樣用 Action.名字(me, …)、Behavior.名字(me, …) 呼叫。基本動作只控制想控制的部位;行為通常決定移動與車身,這一步沒事做就回傳 None。名稱不能和內建的重複。

from strategy_lib import Me, Memory, Action, Behavior, combine

@Action.custom                                   # 自己寫的基本動作:加進 Action
def fire_if_close(me, e, max_dist=250):
    """只在敵人夠近、對準了才開火(遠距離子彈容易被閃掉)"""
    if e is None or me.distance_to(e) > max_dist:
        return {"fire": 0}
    return Action.fire_when_aimed(me, e)

@Behavior.custom                                 # 自己寫的行為:加進 Behavior
def careful_fight(me, e, d=230):
    """血多就保持距離繞圈,血少就躲到牆後"""
    if me.hp < 0.4:
        return Behavior.hide_behind_wall(me, e)
    return Behavior.keep_distance(me, e, d, near=20, far=100)

class Strategy:
    def reset(self):
        self.memory = Memory()

    def act(self, state):
        me = Me(state, memory=self.memory)
        e = me.nearest_visible_enemy()
        if e is None:
            move = Behavior.patrol(me, radius=200)      # 內建行為也可以調參數
        else:
            move = Behavior.careful_fight(me, e, 250)   # 和內建行為一樣呼叫
        return combine(move, Action.aim_turret_at(me, e), Action.fire_if_close(me, e, 300))

E2 自己寫脫困規則(卡在牆邊時怎麼辦)

函數庫有內建的 Behavior.unstick(me, …)(見 D3),參數可以調。想用自己的想法,可以自己寫一個(名字要不一樣,例如 my_unstick)。寫法分四步:

  1. 判斷「卡住」只用模型也看得到的線索(第 2 段)
  2. 連續卡幾步才算(第 4 段)
  3. 脫困要一次做完:倒車並轉向比較空的一側,再往那個方向開一段(第 3、4 段)
  4. 放在戰術最前面、每一步都呼叫(第 5 段)

下面分 5 段寫出一個完整的 strategy.py,由上往下接起來就是整個檔案(《脫困練習》notebook 的 B 部分是同一份程式,可以直接執行)。例子故意用「直接朝圈心開、不避牆」的移動,圈心在牆後就會頂住。

第 1 段 檔頭和參數

MY_UNSTICK:脫困的 4 個參數放在最上面,方便實驗。wait=連續卡幾步才算、back=倒車幾步、go=再往前開幾步、near=車頭前面多近有牆算擋住(像素)。改成 None 就是不脫困。

import math
from strategy_lib import Me, Memory, Action, Behavior, combine

MY_UNSTICK = dict(wait=6, back=15, go=40, near=25)   # 脫困的參數;改成 None=不脫困
第 2 段 ① 判斷「卡住」

is_stuck() 回傳 True/False,三個條件都成立才算卡住。

  • me.clearance(me.heading):從車子中心往車頭方向,多遠會碰到牆或場邊。模型的觀測裡有 32 條地形射線,第 1 條就是車頭正前方,所以模型也看得到。
  • me.speed:這一步實際移動了幾像素。全速是 2.5~3.8,撞牆停住時是 0。觀測裡有自身速度。
  • e is None:看不到敵人。看得到敵人時多半是在交戰或躲牆,不該打斷。
  • 不要用模型看不到的東西當條件(例如記憶裡的目標點、牆後敵人的位置),否則模型學不到「什麼時候該脫困」。
def is_stuck(me, e, near):
    """① 判斷這一步是不是「卡住」:只用模型也看得到的線索"""
    wall_ahead = me.clearance(me.heading) < near     # 車頭前面 near 像素內有牆(觀測:地形射線)
    not_moving = me.speed < 0.3                      # 這一步幾乎沒動(觀測:自身速度)
    no_enemy = e is None                             # 看不到敵人(觀測:敵人欄位)
    return wall_ahead and not_moving and no_enemy
第 3 段 往哪一側繞

pick_side() 回傳車身要怎麼轉(動作的第 2 個值:0=左轉、1=不轉、2=右轉)。

  • me.heading - math.pi / 2、me.heading + math.pi / 2:車頭往左、往右各轉 90° 的方向。畫面的 y 軸朝下,所以角度變大是往右轉。
  • 比較左右兩邊多遠有牆,往比較空的那邊轉。
  • 結果存在 me.mem.esc_body,之後都用同一側:每次重新挑的話,可能這次往左、下次往右,在牆前來回打轉。
def pick_side(me):
    """往哪一側繞:0=左轉、2=右轉。第一次卡住時挑比較空的一側,之後都用同一側(不會左右來回)"""
    if me.mem.esc_body is None:                      # 還沒決定過
        left = me.clearance(me.heading - math.pi / 2)    # 左手邊多遠有牆
        right = me.clearance(me.heading + math.pi / 2)   # 右手邊多遠有牆
        me.mem.esc_body = 0 if left > right else 2
    return me.mem.esc_body
第 4 段 ② 數步數、③ 訂計畫並做完

my_unstick() 是脫困的本體,每一步都會被呼叫,依序檢查三件事:

  • 正在脫困嗎?(esc_left > 0):照計畫做完。計畫分兩段,剩下的步數 ≥ go 時是第一段(倒車+轉向),之後是第二段(往前直開)。回傳的 dict 只寫移動和車身:{"move": 1, "body": …}=倒車+轉向、{"move": 0, "body": 1}=前進、車身不轉。
  • 卡了幾步?:stuck_steps 是「連續」卡住的步數,有一步沒卡就歸零。還不到 wait 步就回傳 None,交給後面的戰術決定。
  • 卡住了:先 pick_side() 決定方向,把 esc_left 設成 back + go(計畫總共幾步),這一步就開始倒車。
  • 為什麼要第二段?只倒車的話,退開以後原本的移動又直直開回同一面牆(見下面的實測)。
  • @Behavior.custom:把這個函數加進 Behavior,之後就能像內建行為一樣用 Behavior.my_unstick(me, e, …) 呼叫。
@Behavior.custom                                     # 加進 Behavior,之後用 Behavior.my_unstick(...) 呼叫
def my_unstick(me, e, wait=6, back=15, go=40, near=25):
    """自己寫的脫困:連續卡住 wait 步 → 倒車 back 步(同時轉向)→ 直直往前開 go 步"""
    m = me.mem
    # ③ 正在脫困:照計畫做完(esc_left=計畫還剩幾步)
    if m.esc_left > 0:
        m.esc_left -= 1
        if m.esc_left >= go:
            return {"move": 1, "body": m.esc_body}   # 第一段:倒車(move 1)+轉向
        return {"move": 0, "body": 1}                # 第二段:往前開(move 0)、車身不轉(body 1)
    # ② 數「連續」卡了幾步:有一步沒卡就歸零
    if is_stuck(me, e, near):
        m.stuck_steps += 1
    else:
        m.stuck_steps = 0
    if m.stuck_steps < wait:
        return None                                  # 還不算卡住:回傳 None,交給後面的戰術
    # 卡住了:訂計畫,這一步就開始倒車
    pick_side(me)
    m.esc_left = back + go
    m.stuck_steps = 0
    return {"move": 1, "body": m.esc_body}
第 5 段 ④ 放進戰術
  • reset():每局開始呼叫一次。my_unstick 用到的 3 個記憶(stuck_steps、esc_left、esc_body)都要在這裡給初始值,否則第一步就會出現 AttributeError。
  • act() 的第一件事就是呼叫 my_unstick:它要每一步都被呼叫,才數得到連續卡了幾步。回傳 None 表示沒卡住,交給後面的 if-elif。
  • **MY_UNSTICK:把第 1 段的參數 dict 拆開傳進去,等於 my_unstick(me, e, wait=6, back=15, go=40, near=25)。
  • 後面的移動故意不避牆:turn_body_toward 只轉車身,再把 move 設成 0(前進)。
class Strategy:
    def reset(self):                                 # 每局開始:自己加的記憶都要給初始值
        self.memory = Memory()
        self.memory.stuck_steps = 0                  # 連續卡了幾步
        self.memory.esc_left = 0                     # 脫困計畫還剩幾步(0=沒在脫困)
        self.memory.esc_body = None                  # 往哪一側繞(第一次卡住時決定)

    def act(self, state):
        me = Me(state, memory=self.memory)
        e = me.nearest_visible_enemy()
        # ④ 脫困放在戰術最前面,每一步都呼叫(它要數步數)
        unstick = Behavior.my_unstick(me, e, **MY_UNSTICK) if MY_UNSTICK is not None else None
        if unstick:
            move = unstick
        elif e is None:
            # 自己寫的移動:直接朝圈心開、不避牆(圈心在牆後就會頂住)
            move = Action.turn_body_toward(me, me.direction_to(me.zone_center))
            move["move"] = 0
        else:
            move = Behavior.keep_distance(me, e, 230)
        return combine(move, Action.aim_turret_at(me, e), Action.fire_when_aimed(me, e))
實測

用離線小場地測(一面牆擋在我和圈心中間、沒有假想坦克,走 15 秒):

MY_UNSTICK移動距離最後離圈心看到什麼
None(不脫困)24136頂在牆上不動
go=0(只倒車)446158退開又開回同一面牆,一直重複
預設(go=40)85332繞過牆,到達圈心附近
go=20400150繞得不夠遠,又撞回同一面牆
wait=4075173等比較久才脫困,比較慢到

動手改:go 改 20、near 改 15、wait 改 40,什麼時候繞不過去?把 pick_side 改成每次重新挑,會不會在牆前來回?拿掉 no_enemy,看得到敵人時也脫困好不好?想放進自己的策略:把第 1~4 段的函數和 MY_UNSTICK 貼到 strategy.py 上面,reset() 加 3 個記憶,act() 最前面加呼叫。

注意:函數庫的 drive_to、drive_in_direction、go_to_safe_zone 本來就會避牆、繞牆,用它們移動時很少卡住;自己寫的脫困最常派上用場的是自己寫的移動,或是故意貼著牆等待的戰術。規則策略不卡住,不代表模型不卡住:開與不開各錄一次、各訓練一次,看天梯轉播比較。

F 常見錯誤

現象原因與改法
TypeError: 'NoneType' object is not subscriptable沒看到敵人時 e 是 None,卻用了 e["x"]。先寫 if e is not None:。
AttributeError: 'Strategy' object has no attribute …self.xxx 沒在 reset() 給初始值。
坦克一直原地打轉目的地在牆裡或圈外到不了,或每一步給的方向一直變。先用 me.is_open(P(…)) 檢查目的地。
規則策略正常,訓練出來的模型卻常在牆邊停住、原地打轉模型只看得到 91 維觀測。規則策略在牆邊停下的原因(等敵人、目的地在牆後)模型看不到,它只學到「牆邊 → 停」。改法:少用 Action.stop(),移動交給 drive_to、drive_in_direction(邊開邊轉,不會原地停下來轉);也可以在戰術最前面加 Behavior.unstick(me)(貼牆不動就馬上離開牆),再比較開與不開訓練出來的模型。
從來不開火只寫了 fire_when_aimed、沒寫 aim_turret_at(砲口沒轉過去),或 tol 設太小。
開火打在牆上用了 check_walls=False;預設會檢查牆。
技能從來沒用被動模組(抗毒、雷達)本來就不用按;主動模組檢查條件是否太嚴,開局 3 秒內也按不出來。
combine 後動作不是我要的後面的會覆蓋前面同一個部位,把「最後要算數」的放在最後。

08範例 C「教學版」

範例 A、B 是可以參考的成績;範例 C 是讓你知道可以怎麼寫。它把每一種寫法都放進來,但數值刻意調得不好,分數偏低,請從這裡開始改。位置:client/examples/C_tutorial/(標準+推進衝刺)。

檔案示範的功能
strategy.pyAction、Behavior 的每個函數、用 @Behavior.custom 自己寫新行為(閃子彈 dodge())、跨步記憶(上一步血量)、五種模組的技能時機、集中在檔案開頭的可調參數
agent.py一個檔案切換 MLP/疊加觀測(STACK)/LSTM(MODEL)、抽樣或取最大(SAMPLE)、正確的正規化與 reset()
model.pt只用 400 局、訓練 6 輪、只看當下、HIDDEN=64 的小模型

實測(平均名次,越小越好)

5 台10 台
完全不動3.628.38
範例 C 策略(原樣)2.605.03
範例 C 策略:只改 DODGE、LEAD、FIRE_TOL = 0.102.594.60
範例 C 模型(附的 model.pt)3.887.13

2026-10-25 用現行規則(子彈傷害 12)、現行函數庫與現行範例 bot 實測,配裝標準+推進衝刺。規則策略各 600 局、模型各 400 局(附的 model.pt 是用舊版函數庫的資料訓練的小模型,比完全不動還差,正好拿來比較)。只改三項在 5 台幾乎沒差、10 台才看得出進步:同一個改動在不同模式效果不同,兩種都要評估。300 局的平均名次大約有 ±0.08 的誤差:差 0.1 以內的改動要打更多局才分得出來,報告裡請寫局數。

在自己電腦上先試:離線小場地

上傳之前,可以先在自己電腦上看策略怎麼動、怎麼打(不用連網站):把 strategy.py,和學生套件的 strategy_lib.py、sandbox.py、try_sandbox.py 放在同一個資料夾,執行:

python try_sandbox.py          # Windows 可能要打 py try_sandbox.py

它會擺一面牆和一台每 1.5 秒朝你開一槍的假想坦克,讓你的策略走 20 秒,印出開火、打中、被打中、護盾擋下幾發,並把動畫存成網頁檔(用瀏覽器打開)。配裝直接用 strategy.py 開頭的 CHASSIS、MODULE,所以那兩行要和你在網站選的一樣(網站下載的範本已經填好)。想改場地,打開 try_sandbox.py 改牆和假想坦克的位置;更多玩法見課堂 notebook 第 1 節。

建議的練習順序

  1. 原樣評估規則策略與模型,記下名次。
  2. 一次改一個策略參數,每次至少 300 局,記錄前後差異(錄製工作台每批都有平均名次,可以直接比較)。
  3. 策略變強後重新錄資料(至少 1,500 局),再訓練。
  4. 改 agent.py:STACK = 4、HIDDEN = 256,或 MODEL = "lstm",比較驗證指標和上場名次。
  5. 5 台、10 台兩種模式都評估。

09錄資料:策略與錄製工作台

錄資料在繳交網站的「② 策略與錄製」分頁完成,不用自己架伺服器。還沒有自己的策略,可以先在「① 我的坦克」下載 strategy.py 範本(範例 C,已填好你的配裝)、函數說明書和你的個人規格書。

  1. 上傳 strategy.py:網站先檢查程式(只能 import strategy_lib、math、random、numpy、collections 等;不能讀寫檔案、連網路),再用你的配裝試跑一局,自動播放重播。錯誤會指出是 strategy.py 第幾行。
  2. 看重播、檢視樣本:確認坦克真的照你的邏輯在動。「檢視樣本」會列出動作比例(開火、技能佔幾成),並隨機抽 12 步畫出「模型會看到的觀測 → 策略做了什麼」,這就是神經網路要學的輸入與答案。
  3. 選情境錄製:地圖(隨機、十字堡壘、城區巷戰)、台數(5 或 10)、對手配裝(隨機或全部指定一種)、局數(50、200、500 或 1,000)。可以換不同情境多錄幾批,每批都存在暫存資料集。
  4. 整理資料集:每批顯示平均名次與筆數,不要的批次可以刪掉。每人資料總量上限 2,000 局。
  5. 下載:勾選要的批次,下載成一個 zip,直接拿去訓練(第 10 節)。

試跑、錄製都在隔離的伺服器執行。改了 strategy.py 就重新上傳一次;舊版本與它錄的資料都會保留,報告可以比較不同版本。

5 台與 10 台

錄製時可以選 5 台或 10 台:比賽是 5 台;10 台的場面更亂,可以錄到更多種狀況。你的坦克一台,其餘是範例 bot。觀測、動作完全相同,同一個模型兩種模式都能上場。

5 台(預設)10 台
名次範圍1–51–10
出生點一圈 5 點兩圈隨機 10 點
觀測第 88 維剩餘敵人 ÷ 4剩餘敵人 ÷ 9

10訓練與匯出

詳細內容看《神經網路訓練手冊》(繳交網站「① 我的坦克」下載,學生套件 client/docs/ 也有):資料格式、網路設定、看懂訓練結果、怎麼做實驗,附實際訓練、上場打出來的對照數字。

在自己的電腦(Python 3.10 以上,裝好 numpy、torch)訓練,CPU 就夠了。網路的結構和訓練設定都寫在 agent.py:從繳交網站下載「agent.py 範本」開始改(和課堂 notebook 6-3 同一份:MLP、疊 4 步、HIDDEN=128、EPOCHS=8)。學生套件裡範例 C 的 agent.py 刻意設得比較小(只看當下、HIDDEN=64),是讓你比較用的。

檔案放法:把 strategy.py、agent.py、train.py,以及「匯出工具」zip 裡的 export_model.py、scripted_agent.py,和下載的資料集 zip 放在同一個資料夾,在那個資料夾執行下面的指令。Windows 如果打 python 沒反應,改打 py。重複下載時瀏覽器會把檔名改成 strategy (1).py 這類名字,記得改回原名再放進資料夾。

1 訓練:train.py

# 在 client/ 底下;資料可以是網站下載的 zip,或用逗號接多個檔案
python train.py dataset_B11201001_1020-1430.zip agent.py
python train.py 第一批.zip,第二批.zip agent.py
python train.py dataset.zip agent.py --epochs 3      # 臨時覆蓋 agent.py 的設定(快速試跑)
  • 訓練完在 agent.py 同一個資料夾產生 model.pt(權重+正規化參數)和 metrics.json(各分類頭的準確率等驗證指標)。
  • 驗證集自動按局切(同一局不會同時出現在訓練和驗證)。開火、技能這兩個分類頭的正例很少,已經用類別權重補償。
  • 網路設定和訓練設定都寫在 agent.py 開頭(見下表),train.py 不用改。改了任何設定,都要重新訓練、重新匯出。
  • 每一輪會印出 loss,以及驗證集的移動準確率、開火召回率;驗證準確率不升反降,就是過擬合的訊號。
  • 每次的亂數種子會印出來,想重現同一次結果就把 SEED 填成那個數字。資料來源(哪幾批資料、種子、訓練設定)會記錄在模型裡。
  • LSTM(MODEL = "lstm")會把每局切成序列訓練,可以用 --seq-len、--burn 調整。
agent.py 的設定意思
MODEL"mlp" 或 "lstm"(有記憶,敵人暫時消失時還記得)
STACKMLP 才有用:疊加最近幾步觀測(1=只看當下;4=看得出移動方向)
HIDDEN隱藏層大小;越大越能學,但要注意推論時間(每步 10 ms 內)
SAMPLETrue=依機率抽樣動作,False=取機率最高的
EPOCHS訓練設定:訓練幾輪(範本設 8;agent.py 沒寫時,train.py 用 15)
LR訓練設定:學習率(預設 0.001),太大會不穩、太小學很慢
BATCH訓練設定:MLP 每次拿幾筆更新一次權重(預設 1024)
SEED訓練設定:亂數種子,None=每次隨機(建議;全班用同一個數字,初始權重會一樣,抄襲比對可能誤判)
FIRE_WEIGHT訓練設定:開火類別加重幾倍,None=依資料比例自動決定

沒寫的訓練設定用預設值;執行時加 --epochs、--lr、--batch、--seed、--fire-weight 可以臨時覆蓋。

2 匯出:export_model.py

python export_model.py agent.py          # → 同一個資料夾裡的 submit/model.pt(要上傳的就是這個)
  • 匯出後會自動比對:同樣的觀測,匯出前後選的動作必須完全相同。
  • 你的 strategy.py 和 agent.py 會一起打包進 model.pt,老師會看它評「方法設計」(比賽時不會執行)。規則策略不在同一個資料夾時,加 --strategy 路徑。
  • 比賽時由老師的程式統一處理正規化、疊加觀測、LSTM 記憶與抽樣(設定存在 model.pt 裡),你在 agent.py 裡自己寫的 act() 不會被執行。
最常見的錯誤:上傳了 train.py 存的 model.pt(權重檔)。要上傳的是 export_model.py 產生的 submit/model.pt,傳錯網站會擋下並說明原因。

3 模型在牆邊停住、原地打轉

常見情況:規則策略打得好好的,訓練出來的模型卻常在牆邊停住,車身左右轉個不停,甚至整局不動。原因是模型只看得到 91 維觀測:規則策略在牆邊停下來的理由(在等敵人、要去的地方在牆後、記憶裡的目標點)模型看不到,它只學到「牆很近、沒看到敵人 → 停下來轉」,卻不知道該往哪邊轉、什麼時候走。

  • 少用 Action.stop(),移動交給 drive_to、drive_in_direction。它們會邊開邊轉,車頭前面是牆時改成倒車轉向,不會停下來原地轉。
  • 脫困 Behavior.unstick(me, …):貼著牆、幾乎沒在動時,往空曠的方向離開。觸發條件在觀測裡都看得到(地形射線、自身速度、敵人欄位),模型學得到。可以調的地方: wait(貼牆不動幾步才算卡住)、back(離開時開幾步)、gap(多近算貼牆)、still(多慢算沒在動)、with_enemy(看得到敵人時要不要也脫困)、toward+pull(脫困時偏向圈內或最後看到敵人的地方)、look(量空曠時看多遠)、cooldown(脫困完幾步內不再觸發)。範例 C 開頭有 UNSTICK、UNSTICK_WAIT、UNSTICK_BACK、UNSTICK_TOWARD、UNSTICK_ENEMY 可以直接改。
  • 自己寫脫困規則:用 @Behavior.custom 寫一個新名字的行為(例如 my_unstick),四步:① 用模型看得到的線索判斷卡住(me.clearance(me.heading)、me.speed、看不看得到敵人)② 在 me.mem 數連續卡了幾步 ③ 脫困動作一次做完(倒車、轉向、再往旁邊開一段,只倒車會開回同一面牆)④ 放在戰術最前面、每一步呼叫。完整程式與實測見第 7 節的 E2 自己寫脫困規則(《規則策略函數說明書》E2)。
  • 脫困不是穩賺的開關:老師實測(範例 C 改三項、錄 3,000 局、HIDDEN 256)開脫困往圈內的模型,對 bot 平均名次 2.62–2.69(不開 2.87–3.04),但牆邊停住的時間反而比較多(3–5%,不開 1.5–1.9%)。開或不開、參數怎麼設,要自己錄、自己訓練、自己比較,這正是報告「方法設計」「訓練結果」的好題材。
  • 判斷方法:看天梯轉播或試跑重播,或到訓練資料裡找「牆很近、速度 0、沒看到敵人」的那些步,看規則策略在做什麼。如果多半是「停」,模型就會學成停。

實測(範例 C,600 局資料,4 個種子各打 100 場,2026-10-25):用舊版函數庫(停下來原地轉)的資料訓練,模型在牆邊停住的時間佔存活時間 16%、最長一次 48 秒,縮圈時只有 15–33% 的時間在移動,平均名次 3.37;用現行函數庫降到 1.6%、最長 8 秒,縮圈時 97% 以上的時間在移動,平均名次 3.05、擊殺 0.34 → 0.55。

11繳交與期中報告

每人繳交兩樣東西:上場的模型(競技分)和期中報告(創意與報告 10 分)。報告的重點不是「做了什麼」,而是為什麼這樣設計、怎麼證明有用。

繳交模型

  1. 照第 10 節訓練、匯出,得到 submit/model.pt。
  2. 登入繳交網站(申請金鑰:輸入學號、姓名、台科大信箱;第一次登入後學號綁定這個信箱)。第一次登入先選配裝(選定後不能改),也可以替坦克取名,會顯示在排行榜與轉播上。
  3. 在「③ 繳交模型與報告」上傳 submit/model.pt(不是 train.py 存的權重檔)。系統會檢查格式與推論速度。上傳紀錄會列出每一份的版本編號,頁首也會顯示目前的上場版本,報告封面要填。
  4. 第一個通過的版本自動成為上場版本;截止前可以換成其他通過的版本,截止後凍結。

報告格式

  • PDF,A4,內文 12 pt。不限頁數,4 個表格寫清楚即可。
  • 存成 PDF:Word 用「檔案 → 另存新檔 → PDF」;Mac 的 Pages 用「檔案 → 輸出為 → PDF」。
  • 報告範本:繳交網站「① 我的坦克」下載「期中報告範本(Word)」,封面已填好姓名、學號、配裝,章節和表格都排好了,照著填,寫完另存 PDF 上傳。
  • 每個成績數字都要寫局數與模式(5 台或 10 台),例如「平均名次 2.51(5 台,600 局)」。
  • 封面:姓名、學號、配裝、坦克名稱(範本已填好)、上場版本編號。天梯名次與分數由系統統整,報告不用寫。

報告就是 4 個表格,照表格裡的提示填寫:

表格要寫什麼
表 1 我的策略贏的策略(一段話說清楚靠什麼贏);配裝與理由(引用第 4 節的數值);戰術:什麼情況做什麼(if-else 摘要或流程圖);主要參數與理由
表 2 方法設計資料(批次、情境、局數、筆數、篩選);網路設定(MODEL、STACK、HIDDEN、SAMPLE);訓練設定(EPOCHS、LR、BATCH、FIRE_WEIGHT、SEED);設計理由與和範例的差異
表 3 訓練結果離線指標(train.py 印出的各動作準確率);結果解讀:哪些動作學得好、哪些學不好、你認為的原因
表 4 心得學到什麼;假如再給一個月會怎麼做(全新的策略、新的模型或訓練方法、新的想法);AI 使用聲明(必填):用了哪些工具、用在哪裡、怎麼驗證(規格書第 8 節)

評分重點

項目拿高分的樣子加分的地方
我的策略說得出靠什麼贏,配裝、戰術、參數彼此搭配用本手冊的數值或試跑數據支持你的選擇
方法設計每個設計都說得出「因為觀察到什麼,所以這樣改」和範例比較,清楚說明你改了什麼、為什麼
訓練結果離線指標寫清楚,說得出哪些動作學得好、哪些學不好說明你認為的原因,並提出具體的改進方法
心得說得出學到什麼,並提出假如再給一個月的具體做法全新的策略或模型方法,並說明打算怎麼驗證

報告繳交:登入繳交網站,在「③ 繳交模型與報告」頁的「期中報告」上傳 PDF(20 MB 內),截止前可以重新上傳,以最後一次為準。

待公告:報告與模型的截止時間。