Pyodide2 <<
Previous Next >> Example2
robot.py
robot.py
第一段:模組匯入與常數定義
js 是 Brython 提供的 JS-Python 橋接模組,可直接操作 JavaScript DOM。
asyncio 是 Python 的非同步處理模組,用來讓動畫非同步進行。
CELL_SIZE 設定地圖中每個格子的大小(40px × 40px)。
WALL_THICKNESS 是用於繪製牆壁的線條厚度。
IMG_PATH 是儲存所有圖片的伺服器路徑(用於載入牆壁與機器人圖)。
import js, asyncio
# 每個格子的像素寬度
CELL_SIZE = 40
# 牆壁的厚度(像素)
WALL_THICKNESS = 6
# 圖片資源的網址前綴
IMG_PATH = "https://mde.tw/cp2025/reeborg/src/images/"
第二段:World 類別 – 建立世界與地圖圖層
World 類負責建立整個地圖畫面,並初始化所需的畫布圖層。
_image_cache 是類別層級變數,用來快取圖片物件。
class World:
_image_cache = {} # 用來暫存載入過的圖片,避免重複下載
def __init__(self, width, height):
self.width = width # 地圖寬(幾格)
self.height = height # 地圖高(幾格)
self.layers = self._create_layers() # 建立四個 canvas 圖層
self._init_html() # 將圖層與控制按鈕加到 HTML 畫面
第三段:建立圖層
這些圖層都是 canvas 元素,彼此重疊在一起,依序繪製地圖。
def _create_layers(self):
return {
"grid": js.document.createElement("canvas"), # 網格底圖
"walls": js.document.createElement("canvas"), # 牆壁
"objects": js.document.createElement("canvas"), # 痕跡/物件
"robots": js.document.createElement("canvas"), # 機器人
}
第四段:初始化 HTML 結構
建立一個 container 容器,設定為相對定位。
將四層 canvas 疊放進容器中,每層用不同的 zIndex 疊層排序。
def _init_html(self):
container = js.document.createElement("div")
container.style.position = "relative"
container.style.width = f"{self.width * CELL_SIZE}px"
container.style.height = f"{self.height * CELL_SIZE}px"
for z, canvas in enumerate(self.layers.values()):
canvas.width = self.width * CELL_SIZE
canvas.height = self.height * CELL_SIZE
canvas.style.position = "absolute"
canvas.style.top = "0px"
canvas.style.left = "0px"
canvas.style.zIndex = str(z)
container.appendChild(canvas)
第五段:建立控制按鈕並加入畫面
建立兩個按鈕:「前進」與「左轉」。
設定美觀的樣式(內邊距、字體大小等)。
button_container = js.document.createElement("div")
button_container.style.marginTop = "10px"
button_container.style.textAlign = "center"
move_button = js.document.createElement("button")
move_button.innerHTML = "Move Forward"
move_button.style.margin = "5px"
move_button.style.padding = "10px 20px"
move_button.style.fontSize = "16px"
button_container.appendChild(move_button)
turn_button = js.document.createElement("button")
turn_button.innerHTML = "Turn Left"
turn_button.style.margin = "5px"
turn_button.style.padding = "10px 20px"
turn_button.style.fontSize = "16px"
button_container.appendChild(turn_button)
第六段:掛載進 HTML 與按鈕綁定
將畫面掛載到 HTML 中的 brython_div1 元素內。
同時記錄兩個按鈕到 self.move_button、self.turn_button 屬性,以便之後綁定事件
brython_div = js.document.getElementById("brython_div1")
if not brython_div:
raise RuntimeError("🚨 'brython_div1' element not found in HTML!")
brython_div.innerHTML = ""
brython_div.appendChild(container)
brython_div.appendChild(button_container)
self.move_button = move_button
self.turn_button = turn_button
第七段:繪製地圖網格線(grid)
這段程式會畫出地圖的格線,形成棋盤狀的網格。
每格大小為 CELL_SIZE = 40 像素。
格線只是視覺輔助,沒有碰撞作用。
def _draw_grid(self):
ctx = self.layers["grid"].getContext("2d") # 取得網格圖層的繪圖上下文
ctx.strokeStyle = "#cccccc" # 設定線條顏色為淡灰色
# 垂直線(每列格線)
for i in range(self.width + 1):
ctx.beginPath()
ctx.moveTo(i * CELL_SIZE, 0)
ctx.lineTo(i * CELL_SIZE, self.height * CELL_SIZE)
ctx.stroke()
# 水平線(每欄格線)
for j in range(self.height + 1):
ctx.beginPath()
ctx.moveTo(0, j * CELL_SIZE)
ctx.lineTo(self.width * CELL_SIZE, j * CELL_SIZE)
ctx.stroke()
第八段:通用繪圖函式 _draw_image()
此函式負責畫圖片在指定格子位置。
需要傳入:
ctx: 要繪製的畫布上下文。
img_key: 要畫的圖片鍵(例如 "blue_robot_e")。
(x, y): 要畫在哪個格子(以地圖邏輯座標為主)。
(w, h): 圖片的寬與高。
會自動調整畫面位置,將 y 軸上下反轉,使原點在左下角。
def _draw_image(self, ctx, img_key, x, y, w, h, offset_x=0, offset_y=0):
img = World._image_cache.get(img_key)
if img and img.complete and img.naturalWidth > 0:
px = x * CELL_SIZE + offset_x
py = (self.height - 1 - y) * CELL_SIZE + offset_y
ctx.drawImage(img, px, py, w, h)
return True
else:
print(f"⚠️ Image '{img_key}' not ready for drawing.")
return False
第九段:繪製牆壁 _draw_walls()
這段會將四周的「邊界牆」畫出來。
利用 _draw_image 畫出 north.png 與 east.png。
offset_x / offset_y 用來對齊圖片位置(不會蓋到格子內)。
async def _draw_walls(self):
ctx = self.layers["walls"].getContext("2d")
ctx.clearRect(0, 0, self.width * CELL_SIZE, self.height * CELL_SIZE)
success = True
# 繪製上下兩排的北牆(頂部與底部)
for x in range(self.width):
success &= self._draw_image(ctx, "north", x, self.height - 1, CELL_SIZE, WALL_THICKNESS, offset_y=0)
success &= self._draw_image(ctx, "north", x, 0, CELL_SIZE, WALL_THICKNESS, offset_y=CELL_SIZE - WALL_THICKNESS)
# 繪製左右兩側的東牆(左邊與右邊)
for y in range(self.height):
success &= self._draw_image(ctx, "east", 0, y, WALL_THICKNESS, CELL_SIZE, offset_x=0)
success &= self._draw_image(ctx, "east", self.width - 1, y, WALL_THICKNESS, CELL_SIZE, offset_x=CELL_SIZE - WALL_THICKNESS)
return success
第十段:預先載入圖片 _preload_images()
此函式會載入所有需要用到的圖片(牆壁與機器人朝向圖)。
利用 Promise 建立圖片載入完成的非同步事件,確保載入成功。
透過 await js.await_promise(js.Promise.all(...)) 等待所有圖片載入完畢。
async def _preload_images(self):
image_files = {
"blue_robot_e": "blue_robot_e.png",
"blue_robot_n": "blue_robot_n.png",
"blue_robot_w": "blue_robot_w.png",
"blue_robot_s": "blue_robot_s.png",
"north": "north.png",
"east": "east.png",
}
promises = []
for key, filename in image_files.items():
if key in World._image_cache and World._image_cache[key].complete:
continue
img = js.document.createElement("img")
img.crossOrigin = "Anonymous"
img.src = IMG_PATH + filename
World._image_cache[key] = img
def make_promise(img_element):
def executor(resolve, reject):
def on_load(event):
img_element.removeEventListener("load", on_load)
img_element.removeEventListener("error", on_error)
resolve(img_element)
def on_error(event):
img_element.removeEventListener("load", on_load)
img_element.removeEventListener("error", on_error)
reject(f"Failed to load image: {img_element.src}")
img_element.addEventListener("load", on_load)
img_element.addEventListener("error", on_error)
if img_element.complete and img_element.naturalWidth > 0:
resolve(img_element)
return js.Promise.new(executor)
promises.append(make_promise(img))
if not promises:
return True
try:
await js.await_promise(js.Promise.all(promises))
return True
except Exception as e:
print(f"🚨 Error during image preloading: {str(e)}")
return False
第十一段:初始化地圖與資源 setup()
setup() 是建構完世界後必須呼叫的初始化函式。
包含資源載入、地圖格線與牆壁的繪製。
使用 asyncio.sleep(0) 是一種「讓出主控權給瀏覽器」的技巧,避免卡住畫面。
async def setup(self):
# 嘗試三次載入圖片資源,若載入成功則跳出迴圈
for _ in range(3):
if await self._preload_images():
break
await asyncio.sleep(0.5) # 等待 0.5 秒後再試
else:
print("🚨 Failed to preload images after retries.")
return False
await asyncio.sleep(0) # 放棄當前事件迴圈執行權,確保 UI 有機會更新
self._draw_grid() # 繪製底層的網格
# 嘗試三次繪製牆壁,等待圖片載入完成
for _ in range(3):
if await self._draw_walls():
break
await asyncio.sleep(0.5)
else:
print("🚨 Failed to draw walls after retries.")
return False
# 最後確認機器人朝向東的圖片是否可用
robot_img_key = "blue_robot_e"
if not (World._image_cache.get(robot_img_key) and World._image_cache[robot_img_key].complete):
print(f"🚨 Robot image '{robot_img_key}' still not ready after setup!")
return False
return True # 所有步驟成功後回傳 True
第十二段:機器人類別 Robot
每個 Robot 物件都有座標與面向,並能畫出自己與移動的軌跡。
傳入 world 是為了能取得地圖的畫布資訊。
class Robot:
def __init__(self, world, x, y):
self.world = world
self.x = x - 1 # 將人類習慣的 1-index 轉成 0-index
self.y = y - 1
self.facing = "E" # 預設朝東(右邊)
self._facing_order = ["E", "N", "W", "S"] # 左轉時的順序
self.robot_ctx = world.layers["robots"].getContext("2d") # 機器人圖層
self.trace_ctx = world.layers["objects"].getContext("2d") # 移動軌跡圖層
self._draw_robot() # 初始畫上機器人
_robot_image_key():依面向回傳圖片鍵
根據面向回傳對應的圖片鍵,例如 E
會回傳 "blue_robot_e"
。
def _robot_image_key(self):
return f"blue_robot_{self.facing.lower()}"
_draw_robot():畫出機器人圖像
先清除原圖,避免留下殘影,再畫上新的機器人圖像。
def _draw_robot(self):
self.robot_ctx.clearRect(0, 0, self.world.width * CELL_SIZE, self.world.height * CELL_SIZE)
self.world._draw_image(self.robot_ctx, self._robot_image_key(), self.x, self.y, CELL_SIZE, CELL_SIZE)
_draw_trace():畫出移動路徑
此方法畫出機器人從起點到終點的直線軌跡。
def _draw_trace(self, from_x, from_y, to_x, to_y):
ctx = self.trace_ctx
ctx.strokeStyle = "#d33" # 紅色線條
ctx.lineWidth = 2
ctx.beginPath()
fx = from_x * CELL_SIZE + CELL_SIZE / 2
fy = (self.world.height - 1 - from_y) * CELL_SIZE + CELL_SIZE / 2
tx = to_x * CELL_SIZE + CELL_SIZE / 2
ty = (self.world.height - 1 - to_y) * CELL_SIZE + CELL_SIZE / 2
ctx.moveTo(fx, fy)
ctx.lineTo(tx, ty)
ctx.stroke()
第十三段:機器人行走與轉彎
walk(steps):前進
根據面向方向更新位置,並畫出移動。
超出地圖邊界時停止。
async def walk(self, steps=1):
for _ in range(steps):
from_x, from_y = self.x, self.y
dx, dy = 0, 0
if self.facing == "E": dx = 1
elif self.facing == "W": dx = -1
elif self.facing == "N": dy = 1
elif self.facing == "S": dy = -1
next_x = self.x + dx
next_y = self.y + dy
if 0 <= next_x < self.world.width and 0 <= next_y < self.world.height:
self.x, self.y = next_x, next_y
self._draw_trace(from_x, from_y, self.x, self.y)
self._draw_robot()
await asyncio.sleep(0.2)
else:
print("🚨 Hit a wall, stop moving!")
break
turn_left():左轉
從目前面向向左轉一格,更新圖片。
async def turn_left(self):
idx = self._facing_order.index(self.facing)
self.facing = self._facing_order[(idx + 1) % 4] # 循環轉向
self._draw_robot()
await asyncio.sleep(0.3)
第十四段:綁定控制 _bind_controls(robot)
定義一個私有方法,將控制行為綁定給特定 robot 實例。
def _bind_controls(robot: Robot):
鍵盤控制
設定 j → 前進,i → 左轉。
使用 asyncio.create_task() 以非同步方式執行,避免卡住主執行緒。
def handle_key(event):
try:
if event.key == 'j':
asyncio.create_task(robot.walk(1)) # 按下 j 移動一步
elif event.key == 'i':
asyncio.create_task(robot.turn_left()) # 按下 i 左轉
except Exception as e:
print(f"🚨 Error in key handler: {e}")
按鈕點擊控制
這兩個函式綁定到 UI 裡的按鈕(前面 _init_html() 中定義的)。
功能與鍵盤控制一樣,只是透過滑鼠點擊。
def handle_move_button(event):
try:
asyncio.create_task(robot.walk(1))
except Exception as e:
print(f"🚨 Error in move button handler: {e}")
def handle_turn_button(event):
try:
asyncio.create_task(robot.turn_left())
except Exception as e:
print(f"🚨 Error in turn button handler: {e}")
註冊事件到 JavaScript
將 handle_key() 註冊為全域 py_handle_key,讓 JavaScript 層級可以呼叫 Python。
將按鈕的點擊事件對應到 Python 定義的控制函式。
js.window.py_handle_key = handle_key
js.document.addEventListener('keydown', js.Function("event", "py_handle_key(event);"))
js.window.py_handle_move_button = handle_move_button
js.window.py_handle_turn_button = handle_turn_button
robot.world.move_button.addEventListener('click', js.Function("event", "py_handle_move_button(event);"))
robot.world.turn_button.addEventListener('click', js.Function("event", "py_handle_turn_button(event);"))
第十五段:初始化並啟動 init()
提供一個使用者簡單快速初始化整個世界與機器人的介面。
def init(world_width=10, world_height=10, robot_x=1, robot_y=1):
包裝為非同步任務
這個包裝函式 async def _inner() 是用來實作內部非同步邏輯。
async def _inner():
world = World(world_width, world_height) # 建立世界
if not await world.setup(): # 等待世界初始化完成
raise RuntimeError("World setup failed!") # 若失敗則丟出錯誤
robot = Robot(world, robot_x, robot_y) # 建立機器人
_bind_controls(robot) # 綁定控制事件
return world, robot # 傳回 world 和 robot 物件
建立並啟動非同步任務
return asyncio.create_task(_inner())
回傳一個非同步任務(asyncio.Task),讓使用者可以這樣呼叫:
world, robot = await init(10, 10, 1, 1)
等待 init() 的結果後,就可以直接控制 robot.walk() 或 robot.turn_left()。
Pyodide2 <<
Previous Next >> Example2