不用圖檔,我們怎麼管理像素素材
迷因大亂鬥和符文堡壘沒有任何圖檔,像素美術全寫在程式碼裡。這篇拆解精靈圖的格式、快取、描邊、換色與放大方式。
打開「迷因大亂鬥」和「符文堡壘」的資料夾,找不到任何一張 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 物件或一個迴圈。
缺點也很直接。在文字編輯器裡改字元圖看不到成品,只能一格一格對。一列少打一個字元不會報錯,只是那一列右邊少一格。兩種描邊則是記憶體和運算的交換:符文堡壘每格動畫多存一張白色剪影,迷因大亂鬥則每一格重新描邊。
新增一張精靈圖
符文堡壘
- 在
art.js用PAL裡的字母畫格子,怪物面向左。不用畫描邊,compile()會自己補上。 - 想要動畫就畫兩格;上半身一樣的話,用
concat接上不同的腳。 - 在
KINDS加一筆:rows、air、spd、fps,以及死亡粒子的顏色pc,需要時再加swap、alpha或scale。buildSprites()會自動編譯並產生白色閃光版本。 - 把名字加進
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()標出握把位置,圖要朝右。