发布于 2026-01-06 2 阅读
0

用 JavaScript 创建一个 13kb 的游戏

用 JavaScript 创建一个 13kb 的游戏

**本文由 ChatGPT 从葡萄牙语自动翻译,原文可在此处找到。

作为一名游戏爱好者和程序员,我时不时会学习游戏开发方面的知识。前段时间,我研究了如何仅使用 C 语言和 OpenGL 创建 2D 游戏场景,并由此完成了一个有趣的概念验证 (POC) 项目。

这个项目激发了我对二维图像处理的兴趣,也让我更好地理解了PNG等格式的工作原理。不久之后,我偶然看到一个名为js13kgames的JavaScript游戏开发大赛的公告。在这个比赛中,参赛者必须用13KB的源代码(包括脚本、库、声音和图像)开发出最好的JavaScript游戏!所有内容都必须打包到13KB以内。游戏开发必须在一个月内完成。8月份会公布游戏主题,9月份提交,10月份公布结果。

游戏可以用 ZIP 压缩,JavaScript 代码也可以最小化。由于我们有非常高效的 JavaScript 压缩工具,所以创建 13kb 以下的脚本并不是什么难题。MIDI 格式的声音文件和文本文件都不大。真正的挑战在于图形的制作。

受到挑战的鼓舞,我决定尝试一些新颖且技术上不寻常的方法。如果我使用PNG格式的精灵图来缩小尺寸,它的分辨率就必须非常低(就像许多参赛者那样)。创建矢量图形,例如SVG,是另一个显而易见的解决方案。然而,这样一来,画面几乎不可避免地会呈现出“Flash游戏”的风格,所有图像看起来都会像纸片剪贴而成(令人惊讶的是,尽管存在缺陷,但最终的获胜者还是采用了这种方法)。

几千字节的矩阵图像

我选择的方法是使用动画矩阵图像创建图形。这些图像将被插入到 JavaScript 数组中,而不是作为二进制资源。为了绘制这些图像,我使用了一个名为 Tiled 的开源地图创建工具。这张地图只有三种变体:透明、灰色和黑色,如下图所示:

平铺

Tiled 可以将上图导出为 JSON 格式。该 JSON 格式的属性之一是包含图像的数组,例如:

/* Anchor icon of the game title */
[1, 1, 1, 1, 1, 2, 3, 3, 3, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 3, 3, 3, 3, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 3, 3, 1, 1, 1, 2, 1, 1, 1, 1, 1, 2, 3, 1, 1, 1, 3, 3, 1, 1, 3, 3, 1, 1, 1, 1, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 1, 1, 1, 1, 1, 1, 1, 1, 2, 3, 1, 1, 1, 1, 3, 3, 1, 1, 1, 2, 3, 1, 1, 3, 3, 3, 3, 1, 1, 1, 3, 3, 1, 1, 3, 3, 3, 3, 2, 3, 3, 3, 3, 1, 1, 1, 3, 3, 1, 1, 3, 3, 3, 3, 2, 1, 2, 3, 1, 1, 1, 1, 3, 3, 1, 1, 1, 2, 3, 1, 1, 1, 2, 3, 1, 1, 1, 1, 3, 3, 1, 1, 1, 2, 3, 1, 1, 1, 2, 3, 3, 1, 1, 1, 3, 3, 1, 1, 1, 3, 3, 1, 1, 1, 1, 3, 3, 3, 2, 2, 3, 3, 2, 2, 3, 3, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 3, 3, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1, 1]
Enter fullscreen mode Exit fullscreen mode

这个数组代表一张图片。数字 1 代表透明像素,数字 2 代表灰色像素,数字 3 代表黑色像素。默认情况下,图片是黑白的。渲染时,你可以更改调色板,这样图片就可以显示为“深蓝色”和“浅蓝色”,而不是灰色和黑色。

上图是一张 16x16 分辨率的图像。在这种情况下,每 16 个像素,渲染函数就必须向数组中每 16 个位置移动一行。

单凭这一点就能大大提升 gzip 压缩效果,但还可以进一步缩小文件大小!因此,对于每张图片,我运行了一个脚本,将数组中的每个数字减 1,只保留 0、1 和 2。之后,我使用一个 8 位二进制数来表示该数组的 4 个位置。例如:

数组表示方法:

[2, 2, 1, 2]
Enter fullscreen mode Exit fullscreen mode

你可以直接使用数字116。

116 in binary = 10100110
Enter fullscreen mode Exit fullscreen mode

10 的二进制表示为 2
10 的二进制表示为 2
01 的二进制表示为 1
10 的二进制表示为 2

如果我们使用的是底层语言,这就没有意义了。但由于 JavaScript 文件中的所有内容都是字符串,因此文本“116”比文本“[2,2,1,2]”占用更少的磁盘空间。

此外,执行此压缩的脚本还会处理连续的零序列。由于图像的所有透明部分均为“0”,因此会将多个零序列添加到数组中。这可以用一个表示连续零序列数量的负数来代替。例如:

[1,0,0,0,0,0,0,0,0,0,0,0,0,2]
Enter fullscreen mode Exit fullscreen mode

替换为:

[1,-12,2]
Enter fullscreen mode Exit fullscreen mode

这样,我们就得到了一个短得多的字符串。当图像的“解包”函数遇到负数时,它只需在数组中添加“x*-1”个零即可。

执行此压缩的脚本如下:

(它既可以在浏览器控制台中使用,也可以在 NodeJS 或 BUN 等运行时环境中使用)

const image = [1, 1, 3, ....];

const leftPad = (str, length) => {
    while (str.length < length) {
        str = '0' + str;
    }
    return str;
}

const IMAGE_ARRAY_NUMBER_LENGTH = 8;


function compressImage(image) {
    let byteBuffer = '';
    return image.reduce((acc, pixel) => {
        let pixelVal = pixel - 1;
        pixelVal = pixelVal > 2 ? 0 : pixelVal; /* Sometimes tiled exports wrong map tiles */
        pixelVal = pixelVal < 0 ? 1 : pixelVal; /* Sometimes tiled exports wrong map tiles */
        byteBuffer += leftPad(pixelVal.toString(2)+'', 2);
        if (byteBuffer.length === IMAGE_ARRAY_NUMBER_LENGTH) {
            const val = parseInt(byteBuffer, 2);
            acc.push(val);
            byteBuffer = '';
        }
        return acc;
    }, []);
}

function uncompressImage(compressed) {
    return compressed.reduce((acc, byte) => {
        let binaryNumber = leftPad((+byte).toString(2), IMAGE_ARRAY_NUMBER_LENGTH);
        while (binaryNumber.length) {
            const twoBits = binaryNumber.substring(0, 2);
            const twoBitsInInt = parseInt(twoBits, 2);
            acc.push(twoBitsInInt);
            binaryNumber = binaryNumber.substring(2, binaryNumber.length);
        }
        return acc;
    }, []);
}

function compressMore(compressed) {
    let buffer = 0;
    const compressedMore = compressed.reduce((acc, current) => {
        if (current === 0) {
            buffer += 1;
            return acc;
        }
        if (buffer) {
            acc.push(buffer * -1);
            buffer = 0;
        }
        acc.push(current);
        return acc;
    }, []);
    if (buffer) {
        compressedMore.push(buffer * -1);
    }
    return compressedMore;
}


const compressed = compressImage(image);
console.log(JSON.stringify(compressMore(compressed)));
const uncompressed = uncompressImage(compressed);
Enter fullscreen mode Exit fullscreen mode

结果

死亡之海 XIII

游戏《死亡之海 XIII》就是采用上述方法制作的。要玩这款游戏,只需点击以下链接:

点击这里播放!
死亡之海 XIII
游戏玩法

我选择制作一款2D射击游戏,因为它的编程速度很快,无论是机制方面,还是难度和游戏性的平衡方面。“死亡之海”这个名字的由来显而易见,“13”(罗马数字XIII)指的是游戏故事发生的世纪,也暗指比赛。


js13kgames竞赛每年举办一次,分为多个类别。本项目参加的是桌面游戏类别。在开发死亡之海》期间,我关注了竞赛 Slack 频道上的其他项目。其中许多项目都非常出色。我建议对此感兴趣的读者去看看这些项目:

我建议大家看看这份榜单,而不仅仅是关注获奖名单。可惜的是,这些游戏都没能进入桌面游戏类别的前十名。获奖游戏都是拥有最佳用户界面的。这更像是一场网页设计比赛,而不是游戏比赛,这多少有点令人失望。我的游戏居然奇迹般地挤进了前100名(在146个游戏中排名第90),因为它的图形界面存在诸多缺陷,其中之一就是不够美观。

文章来源:https://dev.to/justaguyfrombr/creating-a-13kb-game-in-javascript-42g5