wololoEN

不用圖檔,我們怎麼管理像素素材

迷因大亂鬥和符文堡壘沒有任何圖檔,像素美術全寫在程式碼裡。這篇拆解精靈圖的格式、快取、描邊、換色與放大方式。

打開「迷因大亂鬥」和「符文堡壘」的資料夾,找不到任何一張 PNG。角色、怪物、武器、背景全都寫在 JavaScript 裡:精靈圖(sprite)是一串字串,背景是一段開機時跑一次的繪圖程式。這篇用兩款遊戲的實際程式碼,說明這些像素素材怎麼定義、快取、動起來、放大後還保持銳利,最後整理新增一張精靈圖的步驟。

一張精靈圖就是一個字串陣列

兩款遊戲用同一種格式:一個字串是一列像素,一個字元是一格顏色,調色盤裡查不到的字元(習慣上用 .)就是透明。下面是符文堡壘的史萊姆,14×9 格,以及怪物表裡的兩筆設定,都來自 draft/runekeep/js/art.js:

const SLIME_A = [
  '....gggggg....',
  '..ggGGgggggg..',
  '.gGGggggggggd.',
  '.gGgggggggggd.',
  'ggwxggggwxggdd',
  'ggxxggggxxggdd',
  'ggggggggggggdd',
  '.gggggggggggd.',
  '..dddddddddd..',
];
// ...
  slime: { rows: [SLIME_A, SLIME_B], air: false, spd: 0.9, fps: 3, pc: ['#6cc94f', '#b5ee7a', '#2f7a3a'] },
  slimeB: { rows: [SLIME_A, SLIME_B], swap: { g: '#5aa0f0', G: '#b4dcff', d: '#2c5aa8' }, air: false, spd: 0.95, fps: 3, pc: ['#5aa0f0', '#b4dcff', '#2c5aa8'] },

兩邊的差別在調色盤放哪裡。符文堡壘在 art.js 開頭定義一張全域的 PAL,28 個字母各對應一種顏色,所有精靈圖共用;同色系的深淺常用大小寫區分,例如 g 是草綠、G 是淺綠。迷因大亂鬥則是每張精靈圖自帶一個調色盤物件,同一個字母在不同圖裡可以是不同顏色:鴿子的 g 是灰色,大胖兔的 g 卻是淺棕色。前者讓整款遊戲的用色收在一張表裡;後者讓每顆迷因頭像自由挑色,代價是讀圖時得對照旁邊的調色盤。

迷因大亂鬥另有一個小工具 mir() 處理對稱的臉:只畫左半邊,每一列以最後一個字元為軸鏡射。Chad 和 Pop Cat 的頭在原始碼裡只有 8 個字元寬,編譯後是 15 格。

開機時編譯成離屏畫布

字串沒辦法直接畫到畫面上,得先轉成畫布。兩款遊戲都在載入時一次轉完,之後畫這些圖只要 drawImage。

迷因大亂鬥的 sprite(rows, pal) 在 core.js,它逐格 fillRect,畫進一張剛好大小的離屏畫布(offscreen canvas)。頭像 HEADS、武器 WSPR、道具 ISPR 都是 art.js 頂層的常數,腳本一執行就建好了。

符文堡壘的 compile(rows, swap) 多做兩件事:它直接寫入 ImageData,並在每個有顏色的像素四周補上一圈 1px 的墨色描邊,所以輸出的畫布比原圖寬高各多 2 格,註解說這是為了讓每個角色的線條一樣粗。game.js 一開頭就呼叫 buildSprites()(draft/runekeep/js/art.js):

function buildSprites() {
  const S = {};
  for (const [name, k] of Object.entries(KINDS)) {
    const f = k.rows.map((r) => compile(r, k.swap));
    S[name] = { f, w: f.map((c) => silhouette(c, '#ffffff')) };
  }
  S.wiz = compile(WIZARD);
  S.wizCast = compile(WIZARD, { c: '#e6fbff', w: '#ffffff' });
  S.heart = compile(HEART);
  S.heartEmpty = compile(HEART, { r: '#2e2850', w: '#2e2850' });
  return S;
}

swap 是蓋在 PAL 上的小物件。藍色和紫色史萊姆、施法中的法師、空掉的愛心,都是同一張格子換幾個顏色。silhouette() 用 source-in 合成把整張圖塗成單色,每格動畫都預先備好一張白色版本,怪物被打中時直接換圖。塔被撞時閃的紅光也是同一招:TWR.red 就是塔身的紅色剪影。

迷因大亂鬥的換色寫成迴圈。SUS 外星人有 5 種太空衣顏色、2 格走路動畫,載入時就組出 10 張畫布(draft/meme-brawl/js/art.js):

const SUS_COLORS = {
  red: ['#d72c2c', '#9a1a24'],
  cyan: ['#3ad8e8', '#2290a8'],
  orange: ['#f0902a', '#b8601a'],
  purple: ['#8a4ad8', '#5a2a98'],
  black: ['#3a3a48', '#22222c'],
};
const SUS_SPR = {};
for (const [name, [r, R]] of Object.entries(SUS_COLORS)) {
  const pal = { r, R, v: '#93d5ef', V: '#5a96bd', w: '#ffffff', h: '#b4ec8c', k: '#14141a' };
  SUS_SPR[name] = SUS_LEGS.map((legs) => sprite([...SUS_BODY, ...legs], pal));
}

這段也看得出另一種重複利用:身體共用,只換腳。頭目「冒牌者」直接沿用這組圖,以 3 倍整數放大。符文堡壘的哥布林、骷髏和幽靈也是共用上半身,GOB_TOP.concat([...]) 接上兩組腳。

讓它動起來:換格,或現場組裝

符文堡壘的怪物動畫就是輪流換格。drawEnemy() 用 Math.floor((G.T + e.ph) * K.fps) % S.f.length 挑出要畫的那一格:G.T 是經過的秒數,e.ph 是每隻怪物的隨機相位,一群史萊姆才不會同步跳動。每種怪物的 fps 寫在 KINDS 裡,史萊姆是 3,蝙蝠是 8。巨龍的格子是 32×24,畫的時候放大 2 倍;幽靈另外加上半透明。怪物一律面向左,法師面向右,不需要翻轉。

迷因大亂鬥的小怪也有兩格動畫,像鴿子用 Math.floor(t / 5) & 1 在兩張圖之間切換。但主角和人形敵人不是一格一格畫出來的,而是「紙娃娃骨架」:頭是精靈圖,身體由 drawRig() 每一格用線段和矩形現場畫出。外型寫在 LOOKS(衣褲鞋的顏色、軀幹寬高、手腳長度、袖長和配件),動作寫在 entities.js 的 poseOf(),每個姿勢只是幾組關節角度。後方的手腳一律用 shade() 調暗成 0.74 倍,前後層次就出來了。13 張頭像搭配 14 套外型,就組出所有英雄、人形小怪和大部分頭目,不必替每個角色畫一整套動作。

描邊與閃白:先烤好,還是每格現做

符文堡壘的描邊在編譯時就烤進畫布,執行時不花力氣。迷因大亂鬥的角色每一格都是現組的,還會翻面、倒地旋轉,沒有現成的整張圖可以先描邊,所以 core.js 的 figEnd() 每一格都重做一次:

function figEnd(x, y, o = {}) {
  // ...
  tintX.globalCompositeOperation = 'source-in';
  tintX.fillStyle = o.outline || INK;
  tintX.fillRect(0, 0, s, s);
  // ...
  outX.drawImage(tintC, 0, 0, s, s, 0, 1, s, s);
  outX.drawImage(tintC, 0, 0, s, s, 2, 1, s, s);
  outX.drawImage(tintC, 0, 0, s, s, 1, 0, s, s);
  outX.drawImage(tintC, 0, 0, s, s, 1, 2, s, s);
  outX.drawImage(figC, 0, 0, s, s, 1, 1, s, s);
  // ...
  if (o.flip) d.scale(-1, 1);
  if (o.rot) d.rotate(o.rot);

角色先畫進最大 168×168 的暫存畫布,染成墨色的剪影往上下左右各偏 1 格蓋四次,再把原圖疊回中間,就是一圈描邊;之後才翻面、旋轉、蓋到場景上。描邊色可以換:精英敵人的描邊是 #7a3a00,上衣也換成金色。被打中的閃白則是用 source-atop 把整張塗白。

代價是每個角色每格多了好幾次 drawImage 和合成;好處是三張共用的暫存畫布就能應付所有角色。不會變形的道具和武器圖示則交給 inked(),描好邊的版本用 Map 快取起來。

放大不糊

兩款遊戲的世界都畫在一張 384×216 的緩衝畫布上,再整張放大到螢幕。銳利靠三道保險:ctx2d() 建立的 context 都關掉 imageSmoothingEnabled,最後放大那一步再關一次,<canvas> 的 CSS 也設了 image-rendering: pixelated。

符文堡壘的 fit() 多了一步:當一格像素至少對應 3 個裝置像素時,把倍率取整數,讓每顆像素一樣大。迷因大亂鬥的 fitView() 沒有這一步,倍率直接照視窗比例算。大胖兔蓄力時的壓扁效果,則是刻意把畫布拉成非整數倍。

大部分文字不走像素緩衝。迷因大亂鬥的中文和迷因字幕用 Anton、Comic Neue、Noto Sans TC,在畫面放大後才畫到全解析度畫布上;招牌、塗鴉、墓碑這類場景裡的字則用 core.js 那套 3×5 點陣字,每個字是 5 列、每列 3 個位元。符文堡壘怪物頭上的字母和單字標籤用 DotGothic16,同樣在放大後才畫,而且一次畫一個字母,因為 canvas 文字沒有關掉連字的開關;選單則是一般的 HTML。

背景也是程式畫的

背景是一段繪圖程式,配上固定種子的亂數 rng(seed),每次開遊戲畫出來都一樣。符文堡壘的 makeBackdrop() 開機時用 4×4 的 Bayer 矩陣做有序抖色(dithering),把天空漸層、月暈和草地拆成像素點,畫進一張 384×216 的畫布;星星閃爍、雲、火把和旗子才每格另外畫。塔拆成 back 和 front 兩張,讓法師站在牆和城垛之間。

迷因大亂鬥的 paintStage() 在建立關卡時依主題畫出近景,多數主題再加一層遠景,每個主題有自己的種子。stageBack() 用不同速度捲動兩層做出視差,霓虹招牌、股價跑馬燈這些會動的部分才每格重畫。

取捨

好處很直接。沒有圖片要載入,腳本跑完,精靈圖就在記憶體裡。改一顆像素就是改一個字元,git diff 看得一清二楚。換色只要一個 swap 物件或一個迴圈。

缺點也很直接。在文字編輯器裡改字元圖看不到成品,只能一格一格對。一列少打一個字元不會報錯,只是那一列右邊少一格。兩種描邊則是記憶體和運算的交換:符文堡壘每格動畫多存一張白色剪影,迷因大亂鬥則每一格重新描邊。

新增一張精靈圖

符文堡壘

  1. 在 art.js 用 PAL 裡的字母畫格子,怪物面向左。不用畫描邊,compile() 會自己補上。
  2. 想要動畫就畫兩格;上半身一樣的話,用 concat 接上不同的腳。
  3. 在 KINDS 加一筆:rows、air、spd、fps,以及死亡粒子的顏色 pc,需要時再加 swap、alpha 或 scale。buildSprites() 會自動編譯並產生白色閃光版本。
  4. 把名字加進 game.js 裡 spawn() 的空中或地面清單,牠才會出場。

只要換色版的話,跳過前兩步,在 KINDS 加一筆帶 swap 的設定即可。

迷因大亂鬥

  • 人形角色:在 HEADS 加一顆面向右的頭,用 mkHead(rows, pal, ax, ay, eye) 標出脖子的錨點和眼睛那一列(墨鏡和雷射眼會用到),對稱的臉交給 mir()。接著在 LOOKS 加一套外型,再到 data.js 的 ENEMIES 用 look 指過去,所有動作都由 poseOf() 提供。
  • 非人形敵人:在 art.js 用 sprite(rows, pal) 建圖,寫一個用 stamp() 畫的繪製函式,在 entities.js 的 Enemy.drawBody() 裡加一個 case,再到 ENEMIES 用 sprite 欄位指定。圖要畫得進 figSize 預設的 64×64 暫存區,描邊、閃白和翻面都由 figEnd() 處理。
  • 道具和武器:分別加進 ISPR 和 WSPR,鍵名要和 data.js 的 ITEMS、WEAPONS 一致。武器用 mkW() 標出握把位置,圖要朝右。

設計筆記