又一場JS13k 遊戲大賽才剛落幕,我終於延續了八年來參加這項遊戲創作比賽的紀錄。在這篇文章中,我想分享一些我使用過的「低科技」——其中很多都是一些幫助我創作小遊戲的底層開發技巧。
- [Story Engine](#story-engine)
- [Sprites](#sprites)
- [Font](#font)
- [Emoji icons](#emoji-icons)
- [Sound effects](#sound-effects)
- [Music](#music)
- ["AI"](#ai)
- [The villager](#the-villager)
- [The spirit](#the-spirit)
- [Map generation](#map-generation)
今年的「黑貓」主題啟發了我,讓我創作了一款基於網格的冒險遊戲。這些年來,我探索了不同類型的遊戲,並在過程中學習了新的遊戲開發概念。
《喵喵山》講述了一隻神奇的貓咪守護著一座山的故事。這隻貓咪午睡太久,不小心讓守護這座山免受邪靈侵擾的魔法屏障崩塌。結果,山上的村民不再照顧散落在山上的貓祭壇。這些祭壇對貓咪的魔法能力至關重要,所以我們的主角必須修復所有祭壇,恢復屏障,讓山恢復和平。
這是我第一次開發網格類遊戲,也是我的第一款冒險遊戲。由於時間和規模的限制,我之前關於背景故事和遊戲機制的許多想法都被擱置了。
最初,這隻貓的設定是女巫,她可以變成一隻貓,悄悄地在山間潛行,不被村民發現。女巫會運用她的魔力幫助村民,提高收成,清理村莊之間通行的道路,或確保村民獲得食物和水。這些支線任務會激勵村民在貓咪祭壇前獻祭,進而為女巫提供魔力。現在回想起來,這個遊戲機制顯然太複雜,無法與其他機制同時實現。
我最初想探索的另一個想法是為村民創造一個AI。遊戲中的村民只是漫無目的地在村子裡閒晃(詳見下文)。我最初的目標是讓他們住在房子裡,有自己的日常生活和工作。他們每天離開家去工作,有些村民還可以在村莊之間穿梭。可惜的是,這些設定最終都沒能融入遊戲,所以他們的行動基本上是隨機的。
我還設想了一個更詳細的目標和成就係統,玩家可以參考它來了解下一步該做什麼。由於任務系統最終變得簡單得多(修復方尖碑和雕像),這不再是優先事項。我仍然會顯示一個“新目標”的彈出窗口,這個功能很受歡迎,但無法查看當前目標。
最後,我計劃讓玩家能夠使用魔法來完成除了修復魔法屏障之外的其他事情。我曾設想過召喚咒語和特殊攻擊,以輔助完成支線任務和對抗幽靈。同時,我希望不同的幽靈也擁有不同的力量和攻擊模式。
我確實希望找到動力和時間來繼續提高我的比賽水平,並可能重新審視這些想法。
我一直很想做一款薩爾達風格的RPG遊戲。今年我終於有信心可以做到了。遊戲的故事很簡單:主角陷入了長時間的沉睡,世界陷入了混亂。主線任務是找到魔法方尖碑來修復屏障。當玩家嘗試修復屏障時,他們發現自己的魔力耗盡了,所以貓祭壇肯定出了問題。修復貓祭壇後,玩家發現這不足以恢復所有魔力,他們必須找到所有祭壇。
我使用對話框來逐步引導玩家。
我建立了一個簡單的可復用的“故事引擎”,它使用一個故事情節資料結構,並追蹤對話和遊戲事件。它相當簡陋,因為這是我第一次實現這樣的功能。今年,我把建立清晰的故事情節和新手引導作為優先事項,因為 JS13k 中反覆出現的一個問題是,如果沒有冗長的手冊,遊戲很容易讓玩家感到困惑。但正如人們所說:
沒人有時間做這件事。
——柏拉圖或愛因斯坦或其他人。
歷史表明,人們不喜歡閱讀說明。所以遊戲一開始就以柔和的說明開始。玩家了解了精靈的存在,這意味著魔法屏障已經消失。
這也向玩家介紹了主角的戰鬥能力。嚴格來說,第一個幽靈(以及所有其他幽靈)不需要被殺死。逃跑是這款遊戲中可行的策略。
我盡可能地展現而非講述。主角發現自己被茂密的灌木叢包圍。理論上來說,玩家可以自由地穿過草地離開家鄉的草地,但一條巧妙的路徑試圖引導他們走向貓雕像。清理完第一個雕像後,主要操作應該顯而易見:刮擦、修復方尖碑,以及修復雕像。我留下了另外兩個未解釋的機制供玩家探索:從其他雕像傳送到家鄉的雕像,以及在家中「睡覺」以恢復生命值。
遊戲偶爾會顯示一些對話框來引導玩家,但遊戲進度主要由魔力槽來追蹤。魔力槽充滿後,意味著玩家的魔力已恢復,可以再次修復屏障。
多年來,我學會瞭如何實現各種遊戲功能。雖然現代遊戲引擎可以處理大多數與聲音、圖像、物理等相關的內容,但在 JS13k 中,你無法使用大型通用遊戲引擎。這意味著許多核心引擎功能必須經過精心優化。
以下是我多年來開發和改進的一些可重複使用的區塊。
我之前寫過如何在沒有任何圖像檔案的情況下製作遊戲(點擊此處了解更多)。實現這一點的方法有很多。影像可以透過程式產生(例如,《喵嗚山》中的小地圖)、使用畫布圖元繪製(例如, 《市場街大亨》中的所有背景影像以及《喵嗚山》中的 UI 元素),或作為原始碼的一部分進行編碼。
幾年前,我受到xem的啟發,開始簡化圖標的創作。在製作《市場街大亨》時,我建立了迷你像素畫編輯器 (Mini Pixelart Editor) 。這是一個線上像素畫編輯器,專注於使用有限的調色板創作像素畫,然後產生精靈圖的字串編碼版本以及解碼所需的 JavaScript。
近年來,這種方法對我幫助很大,因為我的遊戲從來都不太注重圖像。然而,《喵嗚山》裡有幾十個精靈圖,包括幾個動畫。我的精靈圖編輯器變得太笨重,難以使用,所以我又恢復了傳統的 PNG 圖片。在使用有限的調色板時,PNG 的壓縮效果很好,所以這種方法暫時有效。然而,我很快就覺得有必要從遊戲中釋放一些寶貴的位元組。
我編寫了一些腳本,幫助將 PNG 圖片編碼成更簡單的字串,類似於 Mini Image Editor 的做法。它的工作原理大致如下:
第一個腳本讀取精靈表並將其分割成單獨的圖像
第二個腳本將圖像轉換為值陣列和顏色清單:
顏色清單包含影像中存在的所有顏色
值陣列包含調色板中每種顏色的索引
[[1,0,0,0,0,0,0,0],['#000000']]
表示一個 3x3 透明影像,角落處有一個黑色像素
第三個腳本將此資訊編碼為字串:
由於影像只有 2 種顏色(黑色和透明),因此每個像素只需要 1 位元
我們的陣列變成二進位數字10000000
,以 32 為基數是40
這種方法的一個優點是顏色資訊現在與像素資訊分離,因此我可以在整個過程中重複使用相同的遊戲調色板。以不同的顏色重複使用相同的精靈也變得輕而易舉。對於我的 16x16 圖標,即使考慮到額外的解碼程式碼,精靈尺寸也減少了約 40%。
我希望進一步將精靈管理簡化為可重複使用的 npm 套件。
與精靈渲染類似,我一直使用像素字體,其中每個字元都是字串編碼的精靈。由於文字只有一種顏色,因此每個字母都可以看作 1 位精靈。在這個遊戲中,我使用了 5x5 像素字體。然而,與去年不同的是,我改進了渲染方式,使其能夠支援非方形字形。到目前為止,我的字體一直是等寬的。
為了讓字體編輯更簡單,我使用了我的迷你字體編輯器,它是迷你像素藝術編輯器的一個分支,適用於建立像素藝術字體。
編碼後的字體如下所示:
export const tinyFont = '6v7ic,6trd0,6to3o,6nvic,55eyo,2np50,2jcjo,3ugt8,34ao,7k,glc,1,opzc,3xdeu,3sapz,8rhfz,8ri26,1bzky,9j1ny,3ws2u,9dv9k,3xb1i,3xbmu,2t8g,2t8s,26ndv,ajmo,fl5ug,3x7nm,n75t,54br,59u0e,53if,rlev,4jrb,1yjk4,4eav,55q95,18zsz,mi3r,574tl,1aedd,ljn9,a1bd,4f1i,a1fs,549t,53ig,5832,1dwsh,6iw6,6ix0,cbsa,6gix,6fk4,aky7,7mbws,cvtyq,deehh,2sfi3'.split(',');
就像精靈一樣,每個像6v7ic
這樣的字串都是以 32 為基數的數字,當轉換為二進位時,它表示一個由黑色和透明像素組成的 1 位陣列。
渲染邏輯與精靈略有不同,但我希望將兩者整合到同一個可重複使用的 npm 套件中。
今年我還嘗試使用像素風格的 emoji 圖示來節省寶貴的位元組數。畢竟,一個 emoji 圖示佔用 2-3 個字節,而一個彩色圖示則佔用數十個位元組。
問題是,如果你直接在畫布上渲染 emoji,結果會是模糊的抗鋸齒效果。如果我們能以某種方式預處理 emoji,限制調色板,消除顏色和 alpha 抗鋸齒效果,會怎麼樣?你可以在 Code Pen 上找到我的實驗。
這種方法的缺點是設備和瀏覽器使用的表情符號字體不同。除非我們載入真正的表情符號字體,否則我們將無法控制遊戲部分的外觀。然而,我認為這違背了 JS13kGames 的精神(儘管其他人可能不同意)。這是我第一次嘗試這種方法,所以程式碼可能不是最有效率或最優雅的。
/**
* Quantizes rgba color values to 8bit.
*/
const quantizeToPalette = (r: number, g: number, b: number, a: number) => {
// 1-bit transparency
if (a < 128) {
return [0, 0, 0, 0]; // transparent
}
const qr = Math.round(r / 51) * 51;
const qg = Math.round(g / 51) * 51;
const qb = Math.round(b / 51) * 51;
return [qr, qg, qb, 255];
};
/**
* Converts an emoji to a pixelated image by quantizing the colors
* to 8 bit and the transparency to 1 bit.
*/
export const emojiToPixelArt = (
emoji: string,
fontSize = 10,
): HTMLImageElement => {
// Some emoji are a bit bigger than the font
const spriteScale = 0.25;
const spriteSize = Math.floor(fontSize * (1 + spriteScale));
const padding = Math.floor(fontSize * spriteScale / 2);
// Create temporary canvas
const [_, tmpCtx] = createCanvasWithCtx(spriteSize, spriteSize);
// Draw emoji in chosen font size
tmpCtx.font = `${fontSize}px sans-serif`;
tmpCtx.textBaseline = 'top';
tmpCtx.clearRect(0, 0, spriteSize, spriteSize);
tmpCtx.translate(-1, 0);
tmpCtx.fillText(emoji, padding, padding);
// Read pixels
const imgData = tmpCtx.getImageData(0, 0, spriteSize, spriteSize);
const data = imgData.data;
// Create new image data with quantized colors
const outImg = tmpCtx.createImageData(spriteSize, spriteSize);
const outData = outImg.data;
for (let i = 0; i < data.length; i += 4) {
const [r, g, b, a] = quantizeToPalette(
data[i], // red
data[i + 1], // green
data[i + 2], // blue
data[i + 3], // alpha
);
outData[i] = r;
outData[i + 1] = g;
outData[i + 2] = b;
outData[i + 3] = a;
}
// Create a new canvas to draw the quantized image
const [outCanvas, outCtx] = createCanvasWithCtx(spriteSize, spriteSize);
outCtx.putImageData(outImg, 0, 0);
// Create an image element from the canvas
const img = new Image();
img.src = outCanvastoDataURL();
return img;
};
音效是遊戲中使用的小音訊片段。喵山有各種音效,包括移動、攻擊、受傷等。由於聲音檔案通常很大,JS13K 遊戲開發者通常會透過渲染聲波並使用 Web Audio API 播放來製作聲音。我的「聲音播放器」如下圖所示:
export const playSound = (f: (i: number) => number) => {
// Create a new audio buffer.
// This buffer has 96000 samples (audio "pixels")
// and 48000 samples per second. More samples
// per second allows for higher sound quality.
const m = audioCtx.createBuffer(1,96e3,48e3);
// Create an audio buffer, that will contain
// the sound data.
// Access a single channel data for mono sound.
// For stereo, more channels can be used.
const b = m.getChannelData(0);
// This function expects an f() function,
// which generates a sound wave for each sample i
for(let i = 96e3; i--;) b[i] = f(i);
// The buffer source object controls the
// playback.
const s = audioCtx.createBufferSource();
// We connect the buffer to the source
// and connect source to the audio destination
// which by default is your device's speakers.
s.buffer = m;
s.connect(audioCtx.destination);
// Start the audio.
s.start();
};
f() 波函數可以是任何消耗i
並傳回數字的函數。
例如, f(i) => Math.sin(i)
傳回純正弦波。在喵嗚山中播放腳步聲的函數如下圖所示:
export const step = playSound((i: number) => {
const n = 2e3;
return i > n ? 0 : 0.15 * (Math.random() * 2 - 1) * Math.sin((Math.PI * i) / n);
});
Math.sin((Math.PI * i) / n)
為聲音提供一定的音調,而(Math.random() * 2 - 1)
提供隨機噪音。
噪音使波聽起來更“自然”,而不像純波。
在這個例子中, 0.15
用於將波的振幅(音量)降低到 15%。
為了播放音樂,我使用音訊工作單元。
Worklet 是在背景執行的小型瀏覽器工作程序,在單獨的執行緒上工作,防止主 JavaScript 執行緒被阻塞。
例如,要以 44000Hz 的取樣率產生連續的音樂,我們需要每秒中斷主執行緒 44000 次。相比之下,典型的遊戲週期更新頻率為 60Hz。將音樂處理移至後台工作執行緒可以為遊戲的其餘部分釋放大量資源。
Audio Worklet 是一種特殊的後台工作器,它負責音訊處理,並且可以存取常規 JavaScript 中不可用的特殊 Web Audio API,即AudioWorkletProcessor
類別。我正在撰寫一篇更長的文章,專門介紹如何使用 Web Audio 創作音樂,敬請期待。本質上,它的工作原理與我在音效部分描述的相同。波函數產生一系列值,這些值構成一個波。在本例中,我們不斷以不同的頻率和振幅產生連續的波,從而形成一段旋律。在 Meow Mountain 中,波函數如下所示:
const SAMPLE_RATE = 40000;
const NOTE_LENGTH = SAMPLE_RATE / 4;
const BaseSound = (
pitchOffset: number,
sustainTime: number,
volume: number,
s: (t:number, p:number) => number,
) => (value: PitchLength) => {
const [pitch, length] = value || [0, 1];
let t = 0;
const p = 2 ** ((pitch - pitchOffset * 12) / 12) * 1.24;
const decay = Math.pow(0.9999, 2 / (length));
return function render() {
if (pitch === 0) return 0;
++t;
if (t >= (length * NOTE_LENGTH)) {
return undefined;
};
const sustain = t <= length * NOTE_LENGTH * sustainTime ? 1 : Math.pow(decay, t - length * NOTE_LENGTH * sustainTime);
return s(t,p) * sustain * volume;
};
}
基本聲音功能為每個音符提供了一個包絡,並帶有持續和衰減時間。
但是這種聲音的音色是由s()
參數給出的,它可以是類似於(t,p) => Math.tan(Math.cbrt(Math.sin((t * p) / 30)))
。
解釋為什麼我在這個例子中使用正切、立方根或正弦超出了本文的範圍。
我鼓勵你嘗試波函數來聽聽它們的聲音。
現在我們已經探索了一些可以在其他遊戲中重複使用的通用技術概念,讓我們深入了解一些特定於遊戲的功能。
這是我第一次在遊戲中實現自主的NPC。演算法非常簡陋,效率可能也很低。
村民在村莊半徑範圍內的某個地方生成,然後遵循以下演算法:
尋找可以前往的空白方向
如果它有先前的方向,則有 80% 的機率再次朝該方向移動
否則選擇另一個可能的方向
迷信子程序:
- Look ahead 3 cells
- While player is in one of those cells, increase superstition
- Repeat until player is not present
- Otherwise, continue moving
正如我在上文「未完成的點子」中提到的,我想要一個複雜的村民,擁有合適的路徑、工作和住所。對於這個初始版本,我決定只讓村民隨機移動就足夠了。然而,這也意味著村民有時會在遠離家鄉的森林中迷路。在特別不幸的情況下,村民可能會卡在玩家的路徑上,導致遊戲無法完成。
這些幽靈會在它們出沒的貓咪祭壇附近某處出現。它們使用上面描述的像素表情符號來渲染。
9種靈種,實力不斷提升。
他們使用簡單的廣度優先演算法來尋找通往玩家的路徑。
在 10x10 的方格內尋找玩家
如果找到了玩家,則使用廣度優先搜尋找到一條從靈魂到玩家的空路徑,繞過障礙物
- Other spirits are not seen as obstacles by the algorithm, allowing spirits to "gang up" on the player
- Take one step towards the player
如果找不到玩家,則保持靜止
重複
再次強調,這可能不是最有效的。也就是說,BFS 演算法非常簡單,而且由於幽靈傾向於先上下移動,然後再左右移動,因此很容易躲避它們。改進的方法是先朝玩家的大致方向移動,或是讓它們沿著對角線移動。
我想建立一個基於網格的地圖,讓它感覺自然流暢,不單調。同時,我需要地圖產生具有確定性,以避免某些玩家發現自己身處不可能獲勝的地圖(儘管由於測試不充分,這種情況最終還是在最終版本中發生了)。為此,我使用了一個基於種子的確定性偽隨機數產生器。
為了以節省空間的方式實現這一點,我開發了一種地圖渲染演算法:
建立 160x160 網格
用樹木填滿整個網格
清除地圖中一系列路徑上的儲存格:
- A path is a series of coordinates and widths like `[x, y, w]`; for example `[[10, 10, 2], [10, 20, 2], [20, 20, 3]]` is a path with 3 vertices and 2 edges, where the first segment has a width of 2 and the second segment has a width of 3
- For each path, apply a level of random jittering to the edges, making them look more organic
- Each path can also contain bushes in random cells
- Similar to the paths algorithm, large circular clearings are cleared for some of the villages
- Randomly place a number of houses within the village radius
- Randomly place a number of villagers
- Randomly place bushes
- Clear a 3x3 area around the altar and fill it with bushes
- This prevents the altar from being surrounded by trees, ensuring the player has access to it
將方尖碑放置在地圖的中心
放置起始精神
放置貓
總的來說,這款遊戲並沒有什麼革命性的改變。我能夠在有限的大小內塞入相當多的內容。現在回想起來,如果我有更多時間,很多東西都可以優化,尤其是那些我在早期尚未確定所有遊戲機制時就實現的功能。地圖生成有時相當混亂,過於複雜。 NPC 本來可以更聰明一些。
如果你對我的程式碼有興趣,可以在我的 GitHub 上找到原始碼。 https ://github.com/lopis/meow-mountain 。如果你想體驗遊戲,可以在這裡玩。
原文出處:https://dev.to/lopis/meow-mountain-postmortem-of-a-13kb-game-5fb6