1981 年,Bill Gates 与 Neil Konzen 为 IBM PC 编写了 DONKEY.BAS。游戏规则简单到几乎没有规则:玩家切换车道,躲开道路上的驴;撞上之后,屏幕显示 BOOM。
45 年后,这个作品被移植到了浏览器。它值得被重新实现,并不是因为它拥有现代游戏的复杂玩法,而是因为它像一块仍然能通电的计算机历史切片:从有限的字符图形、键盘输入和主循环里,可以看到早期个人计算机软件是怎样工作的。
规则越简单,代码越容易被看见
现代游戏通常把输入、动画、碰撞检测、资源加载和渲染拆进多个系统。DONKEY.BAS 的核心则可以压缩成几步:
- 读取玩家要去的车道。
- 让道路和驴向玩家移动。
- 判断车辆与驴是否重叠。
- 撞上时结束游戏,否则继续下一帧。
这个结构并不落后,反而非常适合教学。只要把程序循环画出来,就能理解几乎所有实时交互程序的共同骨架:读取状态,更新状态,渲染状态,然后重复。
所谓“复刻”也不必等同于逐字翻译 BASIC。浏览器没有 IBM PC 的文本模式环境,但有 canvas、键盘事件和 requestAnimationFrame。移植真正要保留的是规则和反馈,而不是原始平台的每个实现细节。
一个可以直接运行的浏览器版本
下面是一个单文件示例。把它保存为 donkey.html,直接用浏览器打开即可。使用左右方向键切换车道;车辆与驴所在车道重合时,游戏结束。示例中的图形使用文字绘制,便于阅读和改造,不依赖外部图片或库。
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>DONKEY.BAS Browser Edition</title>
<style>
body { margin: 0; background: #182027; color: #f3f4ed; font: 16px system-ui, sans-serif; text-align: center; }
canvas { display: block; margin: 24px auto 12px; max-width: calc(100vw - 24px); background: #d7d2bd; border: 4px solid #f3f4ed; }
p { margin: 8px; }
</style>
</head>
<body>
<canvas id="game" width="480" height="640"></canvas>
<p>左右方向键切换车道,按 R 重新开始</p>
<script>
const canvas = document.querySelector('#game');
const ctx = canvas.getContext('2d');
const lanes = [120, 240, 360];
let carLane, donkeyLane, donkeyY, speed, score, gameOver, lastTime;
function reset() {
carLane = 1;
donkeyLane = Math.floor(Math.random() * lanes.length);
donkeyY = -60;
speed = 180;
score = 0;
gameOver = false;
lastTime = performance.now();
requestAnimationFrame(loop);
}
addEventListener('keydown', event => {
if (event.key === 'ArrowLeft') carLane = Math.max(0, carLane - 1);
if (event.key === 'ArrowRight') carLane = Math.min(lanes.length - 1, carLane + 1);
if (event.key.toLowerCase() === 'r' && gameOver) reset();
});
function drawText(text, x, y, size = 28) {
ctx.font = `${size}px monospace`;
ctx.textAlign = 'center';
ctx.fillStyle = '#182027';
ctx.fillText(text, x, y);
}
function draw() {
ctx.fillStyle = '#d7d2bd';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = '#777466';
ctx.lineWidth = 3;
for (let x = 180; x <= 300; x += 120) {
ctx.setLineDash([22, 18]);
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, canvas.height);
ctx.stroke();
}
ctx.setLineDash([]);
drawText(`SCORE ${score}`, canvas.width / 2, 42, 22);
drawText('DONKEY', lanes[donkeyLane], donkeyY, 26);
drawText('[CAR]', lanes[carLane], 560, 24);
if (gameOver) {
ctx.fillStyle = '#b63b2f';
ctx.fillRect(55, 260, 370, 120);
ctx.fillStyle = '#fff';
ctx.font = 'bold 42px monospace';
ctx.fillText('BOOM', 240, 315);
ctx.font = '18px monospace';
ctx.fillText('按 R 重新开始', 240, 350);
}
}
function loop(now) {
const dt = Math.min((now - lastTime) / 1000, 0.05);
lastTime = now;
if (!gameOver) {
donkeyY += speed * dt;
if (donkeyY > canvas.height + 60) {
donkeyY = -60;
donkeyLane = Math.floor(Math.random() * lanes.length);
score++;
speed += 8;
}
const sameLane = donkeyLane === carLane;
const hitsCar = donkeyY > 505 && donkeyY < 590;
if (sameLane && hitsCar) gameOver = true;
}
draw();
if (!gameOver) requestAnimationFrame(loop);
}
reset();
</script>
</body>
</html>
运行方式:
open donkey.html # macOS
xdg-open donkey.html # Linux
# Windows 可直接双击文件,或在浏览器中打开
上面的实现不是原版源码,也不声称还原了原版的全部细节。它只是一个可运行的现代浏览器解释:把车道切换、驴的移动、碰撞和 BOOM 反馈缩小成一个容易检查的程序。
复刻旧软件时,先保留什么
1. 保留可观察规则
玩家不需要知道原程序内部变量如何命名,但会注意到三个事实:左右键改变位置,驴不断接近,碰撞会结束游戏。迁移时应优先固定这些可观察行为。否则即使画面更漂亮,也只是另一个游戏。
2. 让时间成为明确的输入
示例使用 requestAnimationFrame 提供动画节奏,并通过 dt 计算位移。这样速度表示为“每秒移动多少像素”,不会完全依赖某台机器的刷新率。对于这样的小程序,代码仍然很短,但运行行为更稳定。
3. 把历史限制变成设计约束
字符画、三条车道和单一碰撞条件看起来简陋,却让程序的因果关系非常清楚。复刻这类作品时,加入粒子、音效、菜单和复杂地图之前,最好先完成一个规则闭环。可运行的最小版本比一堆尚未接通的功能更接近原作的精神。
它为什么仍然值得看
DONKEY.BAS 的价值不在于证明早期游戏比现代游戏更好玩。恰恰相反,它的玩法很有限。它有价值,是因为它把一个完整的交互系统压缩到了普通个人计算机能够承受的尺度:输入、状态、输出和失败反馈都没有被框架藏起来。
对今天的开发者来说,这也是一个实用提醒:技术栈可以变,程序的基本循环没有消失。理解一个小到能在单个 HTML 文件中运行的游戏,往往比阅读一个庞大项目的抽象层更容易看清实时软件的本质。
如果要继续扩展这个复刻,可以依次加入声音、触屏按钮、最高分持久化和更接近原版的字符画。但每增加一个功能,都应该回答一个问题:它是在还原作品的历史特征,还是仅仅在堆叠现代效果?这条边界决定了最终得到的是一份复刻,还是一个借用了旧名字的新项目。