在網頁中打造微型世界:Playground 像素藝術與尋路實作
前陣子 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) 與尋路演算法。
狀態機設計
人物隨時都處於以下四種狀態之一:
IDLE(發呆):停在原地,偶爾會隨機轉頭看看四周。WALKING(走路):朝著隨機選定的一個目標地磚移動。SITTING(坐著):當走到沙發、椅子旁邊時,有機率觸發坐下休息。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 的第一版,未來或許可以加入更多好玩的功能:
- 滑鼠互動:點擊畫布某處,讓角色專程走過去。
- 動態光影:配合網站本身的 Dark/Light 主題切換,在夜晚時將房間變暗,只保留檯燈的柔和黃光。
- 更多彩蛋:藏一些點擊特定的家具會觸發的特效。