cp2025 計算機程式

  • Home
    • SMap
    • reveal
    • blog
  • About
    • cs101
    • Computer
      • llama
      • nginx
    • AI
      • QandA
      • Teams
    • Homework
  • Topics
    • Git
      • Git_ex1
      • Git_ex2
    • Python
      • SE
      • CL
      • Loops
      • Recursion
      • SA
      • SST
      • Maze
      • Collect
      • GT
      • Python_ex1
    • Javascript
      • HTML and CSS
    • Project
      • Waitress
      • API
  • Brython
    • Brython_ex
    • Robot_ex
  • Ref
    • Reeborg
      • ex1
      • Otto_ninja
    • tkinter
    • Pyodide
    • Pyodide_ex
    • Pyodide2
      • robot.py
      • Example2
    • Pyodide3
      • png_files
      • Harvest
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

Copyright © All rights reserved | This template is made with by Colorlib