在網頁中打造微型世界:Playground 像素藝術與尋路實作

Posted by Wayne X.Y. on Thursday, February 26, 2026

在網頁中打造微型世界:Playground 像素藝術與尋路實作

Playground Overview

前陣子 coding 時看到 github 上面一個有趣的專案叫做 Pixel Agents,能夠把 AI coding 時,將 agent 模擬成像素風格的小人物,並把處理過程模擬成在辦公室工作的樣子,看起來很可愛😆,也因為我很喜歡像素風格,就想說能不能在自己的網站也呈現類似的感覺,於是就有了這個 Playground

這篇文章將會分享這個小天地背後的實作細節,包含如何用 HTML5 Canvas 渲染出銳利的像素畫、角色狀態的設計,以及他們是如何在房間內找到路徑(BFS 尋路演算法)的。

場景素材則是來自 Modern Interiors


🎨 核心架構設計:Canvas 與銳利的像素

整個 Playground 的基礎是建立在 HTML5 的 <canvas> 標籤上。為了忠實呈現早期遊戲機那種清晰的「顆粒感」,我們必須確保圖片放大後不會變得模糊。

在繪圖素材上,角色的單格尺寸其實非常小,僅有 16x32 像素(寬 16 像素,高 32 像素),而基礎的地磚區塊大小 (Tile Size) 則是 16x16 像素。

如果我們直接把畫布依原尺寸顯示在網頁上,由於現代螢幕解析度極高,使用者可能要拿放大鏡才看得到角色。因此,我們需要在 CSS 中把它放大:

#playgroundCanvas {
    width: 832px;     /* 原始寬度 416 放大了兩倍 */
    height: 480px;    /* 原始高度 240 放大了兩倍 */
    max-width: 100%;
    image-rendering: pixelated; /* 關鍵:保持像素銳利邊緣 */
    image-rendering: crisp-edges;
}

透過 image-rendering: pixelated;,瀏覽器在縮放 Canvas 時會使用最近鄰插值 (Nearest-neighbor interpolation),從而保留了我們想要的 8-bit 復古感。

而在 JavaScript 端,整個場景的推進依賴於一個標準的遊戲迴圈 (Game Loop)。我們利用 requestAnimationFrame 來驅動畫面的更新,並計算每一幀之間的時間差 (dt),確保角色的移動速度在不同更新率的螢幕上都能保持一致。

var lastTime = 0;

function loop(ts) {
    var dt = Math.min(ts - lastTime, 200); // 限制最大延遲,避免網頁休眠回來後角色暴衝
    if (lastTime === 0) dt = 16;
    lastTime = ts;

    drawFloors();
    drawFurniture();

    characters.forEach(function (c, idx) { updateCharacter(c, dt, idx); });

    // 依據角色的 Y 軸座標進行排序 (Z-index 排序),製造出前景遮擋後景的景深效果
    var sorted = characters.slice().sort(function (a, b) {
        // ... (計算真實 Y 座標排序)
    });
    sorted.forEach(drawCharacter);

    requestAnimationFrame(loop);
}

🗺️ 地圖與網格系統 (Grid System)

這個小天地是建立在一個二維陣列的網格系統上。將整個畫布切分為一格一格 16x16 的區塊(Tile)。

在程式碼中,地圖 (map) 的陣列元素定義了不同的地形屬性:

  • 0 (黑色/不可進入):代表牆壁或地圖外圍。
  • 1 (白色/可走動):代表可以自由行走的地板。
  • 2 (被阻擋的空間):當地板上擺放了「會阻擋通行」的家具時,這些 1 會轉變為 2

我們只需寫幾行迴圈設定牆壁,就能隔出客廳、臥室、浴室等不同空間:

function createHouseMap() {
    // ... (初始化預設全為 1)
    
    // 建立外牆
    for (x = 0; x < COLS; x++) { map[0][x] = 0; map[ROWS - 1][x] = 0; }
    for (y = 0; y < ROWS; y++) { map[y][0] = 0; map[y][COLS - 1] = 0; }

    // 用同樣的方式隔出浴室 (留一道門)
    for (y = 0; y <= 6; y++) map[y][6] = 0;
    for (x = 0; x <= 6; x++) map[6][x] = 0;
    map[4][6] = 1; // 給門留的開口
}

當家具圖片載入後,我們會讀取每個家具的宣告檔 (Furniture Definitions)。像是床 (bed) 佔了較大的面積,系統就會遍歷它所佔據的網格,將底下的 map 陣列值從 1 改為 2,如此一來,角色尋路時自然就會繞開床了。


🧠 角色與尋路演算法

如果場景裡的人物只會原地踏步就太可惜了。我希望他們有自己的「生活作息」。為此,我設計了一套簡單的狀態機 (State Machine) 與尋路演算法。

狀態機設計

人物隨時都處於以下四種狀態之一:

  1. IDLE (發呆):停在原地,偶爾會隨機轉頭看看四周。
  2. WALKING (走路):朝著隨機選定的一個目標地磚移動。
  3. SITTING (坐著):當走到沙發、椅子旁邊時,有機率觸發坐下休息。
  4. SLEEPING (睡覺):走到床邊時,可能會上床睡覺。

每個狀態都有自己的計時器 (stateTimer)。例如在發呆一陣子後 (updateIdle),計時器歸零,系統就會丟骰子決定他要去做什麼事——可能是繼續走到下一個隨機空地、也可能是去坐沙發。

BFS 尋路演算法 (Pathfinding)

當人物決定要去某個目標點 (targetX, targetY) 時,他要怎麼避開牆壁 (0) 和家具 (2) 走過去呢?

因為這是一個無權重的 2D 網格地圖,我選擇了經典的 廣度優先搜尋演算法 (Breadth-First Search, BFS)

每次當角色要踏出「下一步」時,系統就會從「目標點」往回推算出到達「當前角色位置」的最短路徑。我實作的 bfsNextStep 函式概念如下:

function bfsNextStep(sx, sy, tx, ty, excludeIdx) {
    // sx, sy: 角色當前座標
    // tx, ty: 目標座標
    
    // ... 中間是標準的 Queue 與 Visited 陣列實作 ...
    
    // 使用 BFS 從目標 (Target) 反向搜尋回起點 (Source)
    while (queue.length > 0) {
        var cur = queue.shift();
        for (var i = 0; i < DIRS.length; i++) { // 四個方向:上下左右
            var nx = cur.x + DIRS[i].x;
            var ny = cur.y + DIRS[i].y;
            
            // 檢查邊界與是否能走 (必須是 1 可走動區塊)
            if (map[ny][nx] !== 1) continue; 
            
            // 如果某條路徑正好踩回我們的起點 (sx, sy),
            // 那麼 cur (反向推回來的前一格) 就是我們要正向前進的「下一步」!
            if (nx === sx && ny === sy) {
                return { x: cur.x, y: cur.y };
            }
        }
    }
    return null; // 找不到路徑 (例如目標被完全包圍)
}

雖然每次走一步就重算整條短路徑看似耗效能,但在小型網格中效能其實非常充裕。

此外,為了避免兩個角色卡在同一格,在 tryStep 時會先呼叫 tileOccupied 檢查該地磚上是不是已經站著別人了。如果遇到阻擋,角色的「卡住計數器 (stuckCount)」會增加,如果卡住太多次,他就會聰明地放棄原定目標,改為進入 IDLE 狀態重新思考人生。


🏃 動畫與精靈圖 (Sprite Sheet) 拆解

Modern Interiors也有提供角色素材,並繪製包括站立、走路、坐下、睡覺等動作的每一幀畫面,只需將其連貫起來播放,角色就會動起來~

// sx, syHair, syBody 是根據角色現在正在走路還是罰站計算出來的切圖座標
ctx.drawImage(sheet, sx, syHair, CS, CS, dx, dy, CS, CS);
ctx.drawImage(sheet, sx, syBody, CS, CS, dx, dy + CS, CS, CS);

🔮 What’s Next?

完成這個小型的實作非常有成就感。看著小傢伙們在網頁一角走來走去,時不時停下腳步發呆,或是走到床邊睡著,網站好像也跟著活了起來。

這只是 Playground 的第一版,未來或許可以加入更多好玩的功能:

  1. 滑鼠互動:點擊畫布某處,讓角色專程走過去。
  2. 動態光影:配合網站本身的 Dark/Light 主題切換,在夜晚時將房間變暗,只保留檯燈的柔和黃光。
  3. 更多彩蛋:藏一些點擊特定的家具會觸發的特效。