{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "9402ffb5",
   "metadata": {},
   "source": [
    "# 坦克競技場：脫困練習（卡在牆邊怎麼辦）\n",
    "\n",
    "這本是《課堂實作》notebook 的延伸，全部在**離線小場地**做，不需要金鑰、不用連網站。建議先做完《課堂實作》第 1 節 ①～③。\n",
    "\n",
    "為什麼要學脫困：規則策略在牆邊停下來時，知道自己為什麼停（在等敵人、要去的地方在牆後）；訓練出來的**模型只看得到 91 個觀測**，常常只學到「牆很近 → 停」，比賽時就在牆邊停住、原地打轉，整局不動。\n",
    "\n",
    "| 部分 | 內容 |\n",
    "|---|---|\n",
    "| **A　內建的脫困** | 用函數庫的 `Behavior.unstick`，比較 6 種參數 |\n",
    "| **B　自己寫脫困規則** | 分 5 格寫出自己的 `my_unstick`（答案都填好了），組合後比較、看動畫，再自己改 |\n",
    "\n",
    "每一格由上往下按 ▶ 執行（Colab：Shift+Enter）。"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "29ec2e80",
   "metadata": {},
   "source": [
    "## 0　設定\n",
    "下載學生套件、準備離線小場地（和《課堂實作》第 0、1 節相同）。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c5f99440",
   "metadata": {},
   "outputs": [],
   "source": [
    "import os\n",
    "PORTAL_URL = os.environ.get(\"PORTAL_URL\") or \"https://arena.physictim.org\"   # 老師另外公布網址時改這裡\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "74e646ab",
   "metadata": {},
   "outputs": [],
   "source": [
    "# 下載學生套件（strategy_lib、train.py、export_model.py、範例、說明書），只要做一次\n",
    "import io, sys, zipfile, importlib, subprocess, requests\n",
    "if not os.path.exists(\"client/strategy_lib.py\"):\n",
    "    r = requests.get(PORTAL_URL + \"/kit.zip\", timeout=60); r.raise_for_status()\n",
    "    zipfile.ZipFile(io.BytesIO(r.content)).extractall(\".\")\n",
    "sys.path.insert(0, os.path.abspath(\"client\"))\n",
    "os.makedirs(\"my_tank\", exist_ok=True); os.makedirs(\"unstick_parts\", exist_ok=True)\n",
    "for pkg in (\"numpy\", \"torch\", \"matplotlib\"):\n",
    "    if importlib.util.find_spec(pkg) is None:\n",
    "        subprocess.run([sys.executable, \"-m\", \"pip\", \"install\", \"-q\", pkg], check=True)\n",
    "import torch\n",
    "print(\"套件就緒｜torch\", torch.__version__, \"｜GPU\" if torch.cuda.is_available() else \"｜CPU（夠用）\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "752ed6a4",
   "metadata": {},
   "outputs": [],
   "source": [
    "import importlib.util, random\n",
    "from sandbox import Scene\n",
    "\n",
    "def scene(hp=1.0, x=290, y=500, heading=-1.57, zone_x=400, zone_y=400, zone_r=500):\n",
    "    s = Scene()                                # 空地圖\n",
    "    s.add_wall(330, 280, 40, 140)              # 一面牆：左上角 (330, 280)、寬 40、高 140\n",
    "    s.set_me(x, y, heading=heading, hp=hp)     # 我：預設在牆的左下方、車頭朝上；hp＝剩多少血（1＝滿血）\n",
    "                                               # 換起點：scene(x=100, y=100, heading=0.785)；heading 是弧度，0 朝右、1.57 朝下\n",
    "    s.add_enemy(520, 380, fire_every=30)       # 假想坦克：牆的右下方，不會動，每 30 步（1.5 秒）朝我射一發\n",
    "    s.set_zone(zone_x, zone_y, zone_r)         # 安全區：圓心與半徑（圈外每秒扣 10 HP；不會縮）\n",
    "    return s\n",
    "\n",
    "def load_strategy(path):                                 # 讀一個 strategy 檔，回傳它的 Strategy()\n",
    "    spec = importlib.util.spec_from_file_location(\"s_\" + os.path.basename(path)[:-3], path)\n",
    "    m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)\n",
    "    return m.Strategy()\n",
    "\n",
    "scene().draw()                                   # 藍＝我（粗線車頭、細線砲口）、紅＝假想坦克、灰＝牆、虛線＝安全區"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "57b7ef4a",
   "metadata": {},
   "outputs": [],
   "source": [
    "try:\n",
    "    from google.colab import files as colab_files       # 只有 Colab 有\n",
    "except ImportError:\n",
    "    colab_files = None\n",
    "\n",
    "def download_file(path):\n",
    "    \"\"\"把 notebook 裡的檔案拿回電腦，準備上傳到網站\"\"\"\n",
    "    if not os.path.exists(path):\n",
    "        raise FileNotFoundError(f\"找不到 {path}：前面那一格可能沒成功，請先重新執行前一格\")\n",
    "    if colab_files:\n",
    "        try:\n",
    "            colab_files.download(path)\n",
    "            print(f\"已送出下載：{os.path.basename(path)}（看瀏覽器的下載清單）\")\n",
    "        except Exception as e:\n",
    "            print(f\"下載沒有成功（{type(e).__name__}）\")\n",
    "        print(\"沒看到檔案？① 網址列右邊若顯示「已封鎖下載」，按允許後再執行這一格 ② Safari 常常不能下載，請改用 Chrome／Edge\"\n",
    "              f\" ③ 點左邊的資料夾圖示，找到 {path}，按右鍵選「下載」\")\n",
    "    else:\n",
    "        print(\"檔案在：\", os.path.abspath(path))\n",
    "print(\"下載工具就緒｜\", \"Colab\" if colab_files else \"在自己的電腦上（Jupyter／Antigravity）\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "ee5f9fb2",
   "metadata": {},
   "source": [
    "## A　內建的脫困 `Behavior.unstick`\n",
    "課堂實作 ③ 的 `hide_and_stay` 躲到牆後就**停著不動**。規則策略知道自己為什麼停（記得敵人在哪、在等血量），但訓練出來的**模型只看得到 91 個觀測**，常常只學到「牆很近 → 停」，結果比賽時在牆邊停住、原地打轉，整局不動。\n",
    "\n",
    "`Behavior.unstick(me, …)` 是函數庫內建的「脫困」：**貼著牆、幾乎沒在動**時，往空曠的方向離開（牆在車頭就倒車、在車尾就前進）。觸發條件（牆多近、速度多慢、看不看得到敵人）都在觀測裡看得到，錄進資料後模型學得到「卡在牆邊 → 離開」。學生不用自己寫，**在戰術最前面呼叫一行**就好，參數可以調：\n",
    "\n",
    "| 參數 | 預設 | 意思 |\n",
    "|---|---|---|\n",
    "| `gap` | 8 | 車身離牆多近算「貼著牆」（像素） |\n",
    "| `wait` | 1 | 貼牆又沒動幾步才算卡住 |\n",
    "| `back` | 16 | 離開時開幾步 |\n",
    "| `cooldown` | 0 | 脫困完幾步內不再觸發 |\n",
    "| `still` | 0.3 | 一步移動不到多少像素算「沒在動」 |\n",
    "| `with_enemy` | False | 看得到敵人時也脫困 |\n",
    "| `toward`、`pull` | None、0.5 | 離開時偏向哪裡（例如 `me.zone_center` 圈內、`me.mem.last_seen` 最後看到敵人的地方）、偏多少 |\n",
    "| `look` | 120 | 量「哪邊空曠」時看多遠 |\n",
    "\n",
    "下面的策略帶著課堂實作 ③ 的 `hide_and_stay`（多了 `margin` 參數），傳 `margin=20` **貼著牆躲**，並在戰術最前面加了內建的脫困。這個檔案就叫 **`my_tank/strategy.py`**：改完參數、比較完，可以直接下載、上傳網站試跑和錄資料（A 的最後一格）。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c2982096",
   "metadata": {},
   "outputs": [],
   "source": [
    "import os, shutil\n",
    "if os.path.exists(\"my_tank/strategy.py\") and \"脫困練習 A\" not in open(\"my_tank/strategy.py\", encoding=\"utf-8\").read():\n",
    "    shutil.copy(\"my_tank/strategy.py\", \"my_tank/strategy_backup.py\")\n",
    "    print(\"原本的 my_tank/strategy.py 已備份成 my_tank/strategy_backup.py（下一格會寫入脫困練習 A 的策略）\")\n",
    "else:\n",
    "    print(\"沒有需要備份的 strategy.py\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "a1addf4c",
   "metadata": {},
   "outputs": [],
   "source": [
    "%%writefile my_tank/strategy.py\n",
    "# 脫困練習 A：躲牆＋內建脫困（可以直接上傳網站錄製；配裝以網站選的為準）\n",
    "from strategy_lib import Me, Memory, Action, Behavior, combine\n",
    "\n",
    "UNSTICK = dict(gap=20)          # 內建脫困的參數（下一格會換成不同設定比較）；None＝不脫困\n",
    "\n",
    "@Behavior.custom                                       # 和課堂實作 ③ 同一個行為（每個策略檔要自己帶著）\n",
    "def hide_and_stay(me, e, margin=40):\n",
    "    \"\"\"躲到牆後並待著：看得到敵人時找掩體位置記下來；看不到了也繼續開到記下的位置\"\"\"\n",
    "    if e is not None:\n",
    "        spot = me.cover_spot(e, margin=margin)\n",
    "        if spot is not None:\n",
    "            me.mem.cover = spot\n",
    "    if me.mem.cover is None:\n",
    "        return Behavior.go_to_safe_zone(me)\n",
    "    return Action.drive_to(me, me.mem.cover)\n",
    "\n",
    "class Strategy:\n",
    "    def reset(self):\n",
    "        self.memory = Memory()\n",
    "        self.memory.cover = None\n",
    "\n",
    "    def act(self, state):\n",
    "        me = Me(state, memory=self.memory)\n",
    "        e = me.nearest_visible_enemy()\n",
    "        unstick = Behavior.unstick(me, **UNSTICK) if UNSTICK is not None else None   # 內建脫困：每一步都要呼叫\n",
    "        if unstick:\n",
    "            move = unstick\n",
    "        elif me.hp < 0.5:\n",
    "            move = Behavior.hide_and_stay(me, e, margin=20)   # 和課堂實作 ③ 一樣躲牆，但貼著牆躲（離牆 20）\n",
    "        elif e is None:\n",
    "            move = Behavior.patrol(me)\n",
    "        else:\n",
    "            move = Behavior.keep_distance(me, e, 230)\n",
    "        return combine(move, Action.aim_turret_at(me, e), Action.fire_when_aimed(me, e))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "8474c8f8",
   "metadata": {},
   "outputs": [],
   "source": [
    "def with_unstick(u):                                     # 載入 strategy.py，並把裡面的 UNSTICK 換成 u\n",
    "    st = load_strategy(\"my_tank/strategy.py\")\n",
    "    type(st).act.__globals__[\"UNSTICK\"] = u\n",
    "    return st\n",
    "\n",
    "for u in [None, dict(gap=8), dict(gap=20), dict(gap=20, wait=40), dict(gap=20, cooldown=100), dict(gap=20, back=40)]:\n",
    "    print(\"\\nUNSTICK =\", u)\n",
    "    scene(hp=0.45).run(with_unstick(u), 400, animate=False)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4dc8f0c6",
   "metadata": {},
   "source": [
    "比一比（從 45% 血開始、走 20 秒）：\n",
    "- **不脫困**：躲到牆邊就一直停著（被打中 0 發）。規則策略這樣很好，但這正是模型最學不會「什麼時候該離開」的情況。\n",
    "- **`gap=8`（預設）**：停下的位置離牆還有十幾像素，不算「貼著牆」，所以不會觸發。\n",
    "- **`gap=20`**：會觸發，但一直在牆邊來回，停不下來。\n",
    "- **`wait=40`、`cooldown=100`**：停一陣子才離開、離開後一陣子不再觸發，來回少很多。\n",
    "- **`back=40`**：退太遠，離開掩體，被打中了。\n",
    "\n",
    "脫困**不是越多越好**：它讓坦克不會呆站在牆邊，也讓坦克沒辦法好好躲著。老師實測，用範例 C 開脫困的資料訓練，模型對 bot 的名次比較好，但牆邊停住的時間反而比較多。要不要開、參數怎麼設，請改 `strategy.py` 開頭的 `UNSTICK` 參數試試看，錄資料、訓練後比較，寫進報告。"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b08a77b6",
   "metadata": {},
   "source": [
    "**A 完成：拿去網站錄製**\n",
    "1. 想用哪一組脫困參數，就改上面 `my_tank/strategy.py` 那一格開頭的 `UNSTICK = ...`（例如 `dict(gap=20, wait=40)`；`None`＝不脫困），重新執行那一格。\n",
    "2. 執行下面這格，把 `strategy.py` 下載回電腦。\n",
    "3. 到繳交網站「② 策略與錄製」上傳、試跑看重播，再錄資料、訓練（流程和《課堂實作》第 4～8 節相同）。\n",
    "\n",
    "配裝以網站上選的為準；這個策略和你的個人範本（範例 C）是不同的戰術，兩個都可以錄，比較看看哪個訓練出來的模型比較好。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "85433ab4",
   "metadata": {},
   "outputs": [],
   "source": [
    "download_file(\"my_tank/strategy.py\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "27ccbd17",
   "metadata": {},
   "source": [
    "## B　自己寫脫困規則\n",
    "不想用內建的 `unstick`，也可以照自己的想法寫一個（名字要不一樣，例如 `my_unstick`）。下面分 5 格寫（答案都填好了），每一格都說明它在做什麼。每一格存成 `unstick_parts/` 裡的一個小檔案，B-6 再把它們組成 `my_tank/unstick_b.py`。寫法分四步：\n",
    "\n",
    "1. **判斷「卡住」只用模型也看得到的線索**（B-2）\n",
    "2. **連續卡幾步才算**（B-4）\n",
    "3. **脫困要一次做完**：倒車並轉向比較空的一側，再往那個方向開一段（B-3、B-4）\n",
    "4. **放在戰術最前面、每一步都呼叫**（B-5）\n",
    "\n",
    "場景：一面牆擋在我和圈心中間。移動故意寫成「直接朝圈心開、不避牆」，所以一定會頂到牆。函數庫的 `drive_to` 本來就會避牆；自己寫的移動、或故意貼牆等待的戰術，才最需要脫困。\n",
    "\n",
    "**B-1　檔頭和參數**：每一格開頭的 `%%writefile unstick_parts/….py` 會把這一格存成一個小檔案（不會執行它）。B-6 再把 5 個小檔案依序接起來。改了哪一格，就重新執行**那一格和 B-6**，不用從頭來。\n",
    "- `MY_UNSTICK`：脫困的 4 個參數放在最上面，方便實驗。`wait`＝連續卡幾步才算、`back`＝倒車幾步、`go`＝再往前開幾步、`near`＝車頭前面多近有牆算擋住（像素）。改成 `None` 就是不脫困。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "f2a05005",
   "metadata": {},
   "outputs": [],
   "source": [
    "%%writefile unstick_parts/1_header.py\n",
    "import math\n",
    "from strategy_lib import Me, Memory, Action, Behavior, combine\n",
    "\n",
    "MY_UNSTICK = dict(wait=6, back=15, go=40, near=25)   # 脫困的參數；改成 None＝不脫困"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "77b76d00",
   "metadata": {},
   "source": [
    "**B-2　① 判斷「卡住」**：`is_stuck()` 回傳 True／False，三個條件都成立才算卡住。\n",
    "- `me.clearance(me.heading)`：從車子中心往車頭方向，多遠會碰到牆或場邊。模型的觀測裡有 32 條地形射線，第 1 條就是車頭正前方，所以模型也看得到。\n",
    "- `me.speed`：這一步實際移動了幾像素。全速是 2.5～3.8，撞牆停住時是 0。觀測裡有自身速度。\n",
    "- `e is None`：看不到敵人。看得到敵人時多半是在交戰或躲牆，不該打斷。\n",
    "- 不要用模型看不到的東西當條件（例如記憶裡的目標點、牆後敵人的位置），否則模型學不到「什麼時候該脫困」。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "21f57fea",
   "metadata": {},
   "outputs": [],
   "source": [
    "%%writefile unstick_parts/2_is_stuck.py\n",
    "\n",
    "def is_stuck(me, e, near):\n",
    "    \"\"\"① 判斷這一步是不是「卡住」：只用模型也看得到的線索\"\"\"\n",
    "    wall_ahead = me.clearance(me.heading) < near     # 車頭前面 near 像素內有牆（觀測：地形射線）\n",
    "    not_moving = me.speed < 0.3                      # 這一步幾乎沒動（觀測：自身速度）\n",
    "    no_enemy = e is None                             # 看不到敵人（觀測：敵人欄位）\n",
    "    return wall_ahead and not_moving and no_enemy"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "0a2d67c6",
   "metadata": {},
   "source": [
    "**B-3　往哪一側繞**：`pick_side()` 回傳車身要怎麼轉（動作的第 2 個值：0＝左轉、1＝不轉、2＝右轉）。\n",
    "- `me.heading - math.pi / 2`、`me.heading + math.pi / 2`：車頭往左、往右各轉 90° 的方向。畫面的 y 軸朝下，所以角度變大是往右轉。\n",
    "- 比較左右兩邊多遠有牆，往比較空的那邊轉。\n",
    "- 結果存在 `me.mem.esc_body`，**之後都用同一側**：每次重新挑的話，可能這次往左、下次往右，在牆前來回打轉。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "b4c5e77b",
   "metadata": {},
   "outputs": [],
   "source": [
    "%%writefile unstick_parts/3_pick_side.py\n",
    "\n",
    "def pick_side(me):\n",
    "    \"\"\"往哪一側繞：0＝左轉、2＝右轉。第一次卡住時挑比較空的一側，之後都用同一側（不會左右來回）\"\"\"\n",
    "    if me.mem.esc_body is None:                      # 還沒決定過\n",
    "        left = me.clearance(me.heading - math.pi / 2)    # 左手邊多遠有牆\n",
    "        right = me.clearance(me.heading + math.pi / 2)   # 右手邊多遠有牆\n",
    "        me.mem.esc_body = 0 if left > right else 2\n",
    "    return me.mem.esc_body"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "414dbb3a",
   "metadata": {},
   "source": [
    "**B-4　② 數步數、③ 訂計畫並做完**：`my_unstick()` 是脫困的本體，每一步都會被呼叫，依序檢查三件事：\n",
    "- **正在脫困嗎？**（`esc_left > 0`）：照計畫做完。計畫分兩段，剩下的步數 ≥ `go` 時是第一段（倒車＋轉向），之後是第二段（往前直開）。回傳的 dict 只寫移動和車身：`{\"move\": 1, \"body\": …}`＝倒車＋轉向、`{\"move\": 0, \"body\": 1}`＝前進、車身不轉。\n",
    "- **卡了幾步？**：`stuck_steps` 是「連續」卡住的步數，有一步沒卡就歸零。還不到 `wait` 步就回傳 `None`，交給後面的戰術決定。\n",
    "- **卡住了**：先 `pick_side()` 決定方向，把 `esc_left` 設成 `back + go`（計畫總共幾步），這一步就開始倒車。\n",
    "- 為什麼要第二段？只倒車的話，退開以後原本的移動又直直開回同一面牆（下面 B-7 會看到）。\n",
    "- `@Behavior.custom`：把這個函數加進 `Behavior`，之後就能像內建行為一樣用 `Behavior.my_unstick(me, e, …)` 呼叫。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "26a583cc",
   "metadata": {},
   "outputs": [],
   "source": [
    "%%writefile unstick_parts/4_my_unstick.py\n",
    "\n",
    "@Behavior.custom                                     # 加進 Behavior，之後用 Behavior.my_unstick(...) 呼叫\n",
    "def my_unstick(me, e, wait=6, back=15, go=40, near=25):\n",
    "    \"\"\"自己寫的脫困：連續卡住 wait 步 → 倒車 back 步（同時轉向）→ 直直往前開 go 步\"\"\"\n",
    "    m = me.mem\n",
    "    # ③ 正在脫困：照計畫做完（esc_left＝計畫還剩幾步）\n",
    "    if m.esc_left > 0:\n",
    "        m.esc_left -= 1\n",
    "        if m.esc_left >= go:\n",
    "            return {\"move\": 1, \"body\": m.esc_body}   # 第一段：倒車（move 1）＋轉向\n",
    "        return {\"move\": 0, \"body\": 1}                # 第二段：往前開（move 0）、車身不轉（body 1）\n",
    "    # ② 數「連續」卡了幾步：有一步沒卡就歸零\n",
    "    if is_stuck(me, e, near):\n",
    "        m.stuck_steps += 1\n",
    "    else:\n",
    "        m.stuck_steps = 0\n",
    "    if m.stuck_steps < wait:\n",
    "        return None                                  # 還不算卡住：回傳 None，交給後面的戰術\n",
    "    # 卡住了：訂計畫，這一步就開始倒車\n",
    "    pick_side(me)\n",
    "    m.esc_left = back + go\n",
    "    m.stuck_steps = 0\n",
    "    return {\"move\": 1, \"body\": m.esc_body}"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "78d96098",
   "metadata": {},
   "source": [
    "**B-5　④ 放進戰術**：和課堂實作 ①～③ 一樣的 `class Strategy`。\n",
    "- `reset()`：每局開始呼叫一次。`my_unstick` 用到的 3 個記憶（`stuck_steps`、`esc_left`、`esc_body`）都要在這裡給初始值，否則第一步就會出現 AttributeError。\n",
    "- `act()` 的第一件事就是呼叫 `my_unstick`：它要**每一步**都被呼叫，才數得到連續卡了幾步。回傳 `None` 表示沒卡住，交給後面的 if-elif。\n",
    "- `**MY_UNSTICK`：把 B-1 的參數 dict 拆開傳進去，等於 `my_unstick(me, e, wait=6, back=15, go=40, near=25)`。\n",
    "- 後面的移動故意不避牆：`turn_body_toward` 只轉車身，再把 `move` 設成 0（前進）。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c59fe9e3",
   "metadata": {},
   "outputs": [],
   "source": [
    "%%writefile unstick_parts/5_strategy.py\n",
    "\n",
    "class Strategy:\n",
    "    def reset(self):                                 # 每局開始：自己加的記憶都要給初始值\n",
    "        self.memory = Memory()\n",
    "        self.memory.stuck_steps = 0                  # 連續卡了幾步\n",
    "        self.memory.esc_left = 0                     # 脫困計畫還剩幾步（0＝沒在脫困）\n",
    "        self.memory.esc_body = None                  # 往哪一側繞（第一次卡住時決定）\n",
    "\n",
    "    def act(self, state):\n",
    "        me = Me(state, memory=self.memory)\n",
    "        e = me.nearest_visible_enemy()\n",
    "        # ④ 脫困放在戰術最前面，每一步都呼叫（它要數步數）\n",
    "        unstick = Behavior.my_unstick(me, e, **MY_UNSTICK) if MY_UNSTICK is not None else None\n",
    "        if unstick:\n",
    "            move = unstick\n",
    "        elif e is None:\n",
    "            # 自己寫的移動：直接朝圈心開、不避牆（圈心在牆後就會頂住）\n",
    "            move = Action.turn_body_toward(me, me.direction_to(me.zone_center))\n",
    "            move[\"move\"] = 0\n",
    "        else:\n",
    "            move = Behavior.keep_distance(me, e, 230)\n",
    "        return combine(move, Action.aim_turret_at(me, e), Action.fire_when_aimed(me, e))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "36030072",
   "metadata": {},
   "source": [
    "**B-6　組合**：把 `unstick_parts/` 裡的 5 個小檔案依序接成 `my_tank/unstick_b.py`，並檢查有沒有語法錯誤。\n",
    "- 少了哪一格（還沒執行），這裡會直接告訴你是哪一格。\n",
    "- 改了 B-1～B-5 任何一格之後，都要**再執行一次這一格**，`my_tank/unstick_b.py` 才會更新。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "74f4e9c1",
   "metadata": {},
   "outputs": [],
   "source": [
    "import pathlib\n",
    "PARTS = [(\"B-1\", \"1_header\"), (\"B-2\", \"2_is_stuck\"), (\"B-3\", \"3_pick_side\"), (\"B-4\", \"4_my_unstick\"), (\"B-5\", \"5_strategy\")]\n",
    "missing = [cell for cell, name in PARTS if not pathlib.Path(f\"unstick_parts/{name}.py\").exists()]\n",
    "if missing:\n",
    "    raise RuntimeError(\"還沒執行：\" + \"、\".join(missing) + \"。請先執行這幾格，再執行這一格。\")\n",
    "src = \"\\n\".join(pathlib.Path(f\"unstick_parts/{name}.py\").read_text(encoding=\"utf-8\").rstrip() + \"\\n\" for _, name in PARTS)\n",
    "compile(src, \"unstick_b.py\", \"exec\")                 # 有語法錯誤會在這裡告訴你是第幾行\n",
    "pathlib.Path(\"my_tank/unstick_b.py\").write_text(src, encoding=\"utf-8\")\n",
    "print(f\"✓ 組好 my_tank/unstick_b.py（{len(src.splitlines())} 行）\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "db7c3f56",
   "metadata": {},
   "source": [
    "**B-7　比一比**：同一個檔案，換三種 `MY_UNSTICK` 各走 15 秒（300 步）。`wall_scene()` 擺一面牆擋在我（左邊、車頭朝右）和圈心 (450, 350) 中間，沒有假想坦克。\n",
    "- `type(st).act.__globals__[\"MY_UNSTICK\"] = u`：不改檔案，直接換掉剛載入的策略裡的 `MY_UNSTICK`，方便一次比較好幾種設定。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "3e154865",
   "metadata": {},
   "outputs": [],
   "source": [
    "def wall_scene():                                        # 一面牆擋在我和圈心中間，沒有假想坦克\n",
    "    s = Scene()\n",
    "    s.add_wall(330, 280, 40, 140)\n",
    "    s.set_zone(450, 350, 300)                            # 圈心 (450, 350) 在牆的另一邊\n",
    "    s.set_me(290, 350, heading=0.0)                      # 我在牆的左邊、車頭朝右\n",
    "    return s\n",
    "\n",
    "BASE = dict(wait=6, back=15, go=40, near=25)\n",
    "for u in [None, dict(BASE, go=0), BASE]:                 # 不脫困、只倒車、倒車＋往旁邊開\n",
    "    st = load_strategy(\"my_tank/unstick_b.py\")\n",
    "    type(st).act.__globals__[\"MY_UNSTICK\"] = u           # 換掉 unstick_b.py 裡的 MY_UNSTICK\n",
    "    print(\"\\nMY_UNSTICK =\", u)\n",
    "    wall_scene().run(st, 300, animate=False)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "bb7f1199",
   "metadata": {},
   "source": [
    "結果：\n",
    "- **不脫困**（`None`）：頂在牆上，只移動 24 像素。\n",
    "- **只倒車**（`go=0`）：退開以後又直直開回同一面牆，一直重複，最後還在牆的這一邊。\n",
    "- **倒車＋往旁邊開**（`go=40`）：繞過牆，最後停在圈心附近 (426, 371)。\n",
    "\n",
    "**B-8　看動畫**：播放 `unstick_b.py` 原本的設定（B-1 的 `MY_UNSTICK`）。\n",
    "\n",
    "**動手改**（改完重新執行那一格和 B-6（組合），再看 B-7、B-8）：\n",
    "- B-1：`go` 改 20、`near` 改 15、`wait` 改 40，什麼時候繞不過去？\n",
    "- B-3：把 `pick_side` 改成每次都重新挑（拿掉 `if me.mem.esc_body is None:` 這行、下面三行往左縮），會不會在牆前來回？\n",
    "- B-2：把 `no_enemy` 拿掉，看得到敵人時也脫困，好不好？\n",
    "- 想放進自己的策略：把 B-1～B-4 的函數和 `MY_UNSTICK` 貼到 `strategy.py` 上面，`reset()` 加 3 個記憶，`act()` 最前面加呼叫。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "ee1a6090",
   "metadata": {},
   "outputs": [],
   "source": [
    "wall_scene().run(load_strategy(\"my_tank/unstick_b.py\"), 300)      # 播放動畫"
   ]
  }
 ],
 "metadata": {
  "colab": {
   "provenance": []
  },
  "kernelspec": {
   "display_name": "Python 3",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
