关注【索引目录】服务号,更多精彩内容等你来探索!
又一场JS13k 游戏大赛刚刚落下帷幕,我终于延续了八年来参加这项游戏创作大赛的记录。在这篇文章中,我想分享一些我用过的“低科技”——其中很多都是一些帮助我创作小游戏的底层开发技巧。
内容
- 关于游戏
- 游戏概念
- 未能实现的想法
- 主要概念
- 故事引擎
- 可重用组件
- 精灵
- 字体
- 表情符号图标
- 音效
- 音乐
- 其他游戏功能
- “人工智能”
- 村民
- 精神
- 地图生成
- 最后说明
关于游戏
今年的“黑猫”主题启发了我,让我创作了一款基于网格的冒险游戏。这些年来,我探索了不同类型的游戏,并在过程中学习了新的游戏开发理念。
《喵山》讲述了一只神奇的猫咪守护着一座山的故事。这只猫咪午睡太久,不小心让守护这座山免受邪灵侵扰的魔法屏障崩塌。结果,山上的村民们不再照看散落在山上的猫祭坛。这些祭坛对猫咪的魔法能力至关重要,所以我们的主角必须修复所有祭坛,重建屏障,让这座山恢复和平。
游戏概念
未能实现的想法
这是我第一次开发网格类游戏,也是我的第一款冒险游戏。由于时间和规模的限制,我之前关于背景故事和游戏机制的很多想法都被搁置了。
最初,这只猫的设定是女巫,她可以变成一只猫,悄悄地在山间潜行,不被村民发现。女巫会运用她的魔力帮助村民,提高收成,清理村庄之间通行的道路,或确保他们获得食物和水。这些支线任务会激励村民在猫祭坛前献祭,从而为女巫提供魔力。现在回想起来,这个游戏机制显然过于复杂,无法与其他机制同时实现。
我最初想探索的另一个想法是为村民创造一个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。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% 的概率再次朝该方向移动 -
否则选择另一个可能的方向 -
迷信子程序:
-
向前看 3 个单元格 -
当玩家位于其中一个牢房时,增加迷信 -
重复,直到玩家不再出现 -
否则,继续移动
-
继续移动整个单元格,然后转到步骤 1
正如我在上文“未完成的想法”中提到的,我想要一个功能复杂的村民,拥有合适的路径、工作和住所。对于这个初始版本,我决定只需随机移动就足够了。然而,这也意味着村民有时会在远离家乡的森林里迷路。在特别不幸的情况下,村民可能会卡在玩家的路径上,导致游戏无法完成。
精神
幽灵会在它们出没的猫祭坛附近某处刷新。它们使用上面描述的像素表情符号渲染。共有
9 种幽灵,强度依次递增。
他们使用简单的广度优先算法来寻找通往玩家的路径。
-
在 10x10 的方格内寻找玩家 -
如果找到了玩家,则使用广度优先搜索找到一条从精灵到玩家的空路径,绕过障碍物
-
其他幽灵不会被算法视为障碍,从而允许幽灵“联合起来”攻击玩家 -
向玩家迈一步
-
如果找不到玩家,则保持静止 -
重复
再次强调,这可能不是最高效的。也就是说,BFS 算法非常简单,而且由于幽灵倾向于先上下移动,然后再左右移动,因此很容易躲避它们。改进的方法是先朝玩家的大致方向移动,或者允许它们沿对角线移动。
地图生成
我想创建一个基于网格的地图,让它感觉自然流畅,不单调。同时,我需要地图生成具有确定性,以避免某些玩家发现自己身处不可能获胜的地图(尽管由于测试不充分,这种情况最终还是在最终版本中发生了)。为此,我使用了一个基于种子的确定性伪随机数生成器。
为了以节省空间的方式实现这一点,我开发了一种地图渲染算法:
-
创建 160x160 网格 -
用树木填满整个网格 -
清除地图中一系列路径上的单元格:
-
路径是一系列坐标和宽度,例如 [x, y, w];例如,[[10, 10, 2], [10, 20, 2], [20, 20, 3]]一条具有 3 个顶点和 2 条边的路径,其中第一段的宽度为 2,第二段的宽度为 3 -
对于每条路径,对边缘应用一定程度的随机抖动,使它们看起来更有机 -
每条路径还可以包含随机单元格中的灌木丛
-
清除网格中的圆圈列表:
-
与路径算法类似,一些村庄的大型圆形空地被清理出来
-
根据村民名单将村庄放置在地图上:
-
在村庄半径范围内随机放置一定数量的房屋 -
随机放置一定数量的村民 -
随机放置灌木丛
-
将猫祭坛从列表中放置在地图上:
-
清理祭坛周围 3x3 的区域并用灌木丛填满它 -
这样可以防止祭坛被树木包围,确保玩家可以进入祭坛
-
将方尖碑放置在地图的中心 -
放置起始精神 -
放置猫
最后说明
总的来说,这款游戏没有什么革命性的变化。我能够在有限的大小限制内塞入相当多的内容。现在回想起来,如果我有更多时间,很多东西都可以优化,尤其是那些我在早期尚未确定所有游戏机制时就实现的东西。地图生成有时相当混乱,过于复杂。NPC 本来可以更智能一些。
关注【索引目录】服务号,更多精彩内容等你来探索!

