Skip to main content

从弹幕射击到经典扫雷:纯 JS 游戏开发手记

Web games dev.webp
Published on
/
25 mins read

起因

距离高考还有不到一个月。

按理说这个时候应该全力冲刺,但说实话,连续刷了几个月的题,脑子有时候真的转不动了。晚自习回来,打开电脑,本来想看看数学错题,结果手不自觉地打开了 VS Code。这种感觉做过博客和 TimeMark 的人应该懂:一旦你习惯了创造东西,就很难停下来。

做游戏这个念头其实很早就有了。小时候在学校机房玩 4399 上的 Flash 小游戏,那时候就想:这些东西是怎么做出来的?后来学了 JavaScript,发现浏览器本身就是一个游戏引擎。Canvas 能画任何东西,requestAnimationFrame 能跑 60 帧。不需要 Unity,不需要 Godot,一个浏览器就够了。

还有一个原因。做完博客和 TimeMark 之后,我一直在用框架。Next.js、React、Hono……框架确实好用,但我想证明一件事:纯 Vanilla JavaScript,不依赖任何框架和库,也能做出复杂的东西。 博客是站在巨人肩膀上,TimeMark 是全栈但有框架兜底,这次我想从零开始,自己造所有轮子。

于是就有了这两个项目。一个大的,一个小的。一个花了两周,一个花了三天。

星域战机:一万一千行的弹幕射击

它是什么

星域战机是一个 HTML5 Canvas 弹幕射击游戏(STG)。无尽模式,越打越难,死了重来。

听起来简单对吧?但内容量比我预想的大得多。最终纯 JavaScript 部分大约一万一千多行,算上 HTML 和 CSS 接近两万行。

规模有多大?看数据:

  • 20 个流派(相当于角色 build)
  • 200 个技能(每次升级随机三选一)
  • 20 种武器
  • 40 个道具
  • 16+ 种敌人
  • 4 个 Boss

这些内容全部是数据驱动的,后面会详细说。

🎮 在线试玩:game1.the37777777.top/ulw

📦 源码:github.com/WXFffff666/stg-game

数据驱动架构:引擎和内容完全分离

这个项目最让我满意的设计决策是:引擎只负责执行逻辑,所有内容定义在配置文件里。

想加一个新流派?往 config.js 里加一条数据就行,引擎代码一行都不用改。来看看实际的流派配置长什么样:

javascript
FACTIONS: {
  attackSpeed: {
    id: 'attackSpeed', name: '⚡ 攻速流', color: '#ffdd00',
    description: '极致射速,弹幕如雨',
    baseStats: { attackSpeed: 0.4, attack: 0.85, hp: 100, speed: 300, critRate: 0.05, critMult: 1.5, bulletCount: 0 },
    icon: '⚡'
  },
  counter: {
    id: 'counter', name: '🛡️ 反伤流', color: '#ff6644',
    description: '挨打反弹,以守为攻',
    baseStats: { attackSpeed: 1.0, attack: 0.9, hp: 150, speed: 260, reflectDamage: 0.3, defense: 0.2, critRate: 0.05, critMult: 1.5 },
    icon: '🛡️'
  },
  crit: {
    id: 'crit', name: '💥 暴击流', color: '#ff0000',
    description: '一击必杀,刀刀暴击',
    baseStats: { attackSpeed: 1.0, attack: 1.2, hp: 90, speed: 290, critRate: 0.25, critMult: 3.0, critDamage: 0 },
    icon: '💥'
  },
  // ... 17 more factions
}

20 个流派,每个都是这样一条配置。攻速流射速快但攻击力低,反伤流血厚能反弹伤害,暴击流脆皮但爆发高。引擎不关心具体有多少流派,它只读取 baseStats 然后计算。

技能也是同样的思路。每个技能就是一个纯数据对象:

javascript
{ id: 'atk_up_1', name: '攻击提升 I', faction: 'any', type: 'passive', rarity: 'common',
  effects: [{ stat: 'attack', op: 'multiply', value: 0.15 }] },
{ id: 'bullet_plus_1', name: '弹道+1', faction: 'any', type: 'passive', rarity: 'uncommon',
  effects: [{ stat: 'bulletCount', op: 'add', value: 1 }] },
{ id: 'crit_up_2', name: '暴击率提升 II', faction: 'any', type: 'passive', rarity: 'uncommon',
  effects: [{ stat: 'critRate', op: 'add', value: 0.15 }] },

看到没?effects 数组里定义了这个技能修改哪个属性、用什么运算、改多少。引擎只需要遍历 effects,根据 op 字段做加法或乘法就行。200 个技能,引擎处理逻辑就那么几行,剩下的全是数据。

这种架构的好处是:加内容的速度极快。 一旦引擎稳定了,后面就是纯粹的内容创作。我后期加技能的速度大概是一小时 5、6 个,因为不需要碰引擎代码。

每个流派还有自己的终极技能,定义在 skills.js 里:

javascript
var FACTION_SYSTEM = {
  attackSpeed: {
    corePassive: { effects: [{ stat: 'attackSpeed', op: 'multiply', value: -0.15 }, { stat: 'attack', op: 'multiply', value: 0.1 }] },
    exclusiveSkills: ['as_dual_wield', 'as_frenzy', 'as_machine_gun'],
    ultimate: { id: 'ut_attackSpeed', name: '⚡ 弹幕终结', faction: 'attackSpeed', type: 'passive', rarity: 'legendary', ultimate: true,
      description: '极致攻速的终极形态',
      effects: [{ stat: 'attackSpeed', op: 'multiply', value: -0.5 }, { stat: 'ricochet', op: 'add', value: 3 }],
      visualColor: '#ffdd00', visualType: 'lightning' }
  },
  // ...
};

攻速流的终极技能「弹幕终结」:攻速再乘以 -0.5(注意这里 attackSpeed 的值越小射速越快),子弹还能弹射 3 次。配合前面基础属性里 attackSpeed: 0.4 的底子,这个流派到后期就是满屏子弹乱飞。

对象池:零 GC 压力

弹幕射击游戏最大的性能瓶颈是什么?垃圾回收(GC)。

想象一下:屏幕上同时有几百颗子弹在飞,每颗子弹飞出屏幕就销毁,新子弹不断创建。如果每颗子弹都 new Bullet(),GC 会疯掉,游戏会卡顿。

我写了一个通用的对象池类来解决这个问题:

javascript
class ObjectPool {
  constructor(factory, initialSize) {
    this._items = [];
    this._factory = factory;
    for (var i = 0; i < (initialSize || 0); i++) {
      this._items.push(factory());
    }
  }
 
  get length() { return this._items.length; }
 
  pop() {
    if (this._items.length > 0) {
      return this._items.pop();
    }
    return this._factory();
  }
 
  push(obj) {
    this._items.push(obj);
  }
}

原理很简单:构造的时候传入一个工厂函数和初始大小,预先创建一批对象放进池子。需要新对象的时候调 pop(),池子里有就直接拿,没有才用工厂函数创建新的。对象「死亡」后调 push() 放回池子。整个游戏运行过程中,new 操作只在最开始发生几次,之后全部是复用。

子弹、粒子、敌人、伤害数字……所有频繁创建销毁的对象都用了对象池。实测下来,即使屏幕上有 300+ 子弹同时飞行,帧率也能稳定在 60fps。

踩坑:游戏循环与 Spiral of Death

游戏的心跳是 requestAnimationFrame + deltaTime。但这里有个坑,我一开始没注意到,后来被坑了一整个晚上。

先看最终的代码:

javascript
// Inside Game class _loop method:
const rawDt = timestamp - this.lastTime;
this.lastTime = timestamp;
 
// Cap delta time to prevent spiral of death
const dt = Math.min(rawDt, 50) * 0.001 * this.timeScale;
 
if (!this.isPaused) {
  this.gameTime += rawDt;
  this._update(dt);
}
 
this._draw();
this.rafId = requestAnimationFrame(this._loop);

看到那行 Math.min(rawDt, 50) 了吗?这是为了防止所谓的「spiral of death」(死亡螺旋)。

什么意思呢?假设某一帧因为 GC 或者其他原因卡了 500ms,如果不做限制,deltaTime 就是 0.5 秒。这一帧里所有物体都会移动半秒的距离,子弹可能直接穿过敌人,碰撞检测全部失效。更糟糕的是,这一帧要处理的计算量暴增,导致下一帧也卡,deltaTime 更大,计算量更多……恶性循环,游戏直接崩掉。

解决方案就是给 deltaTime 加个上限。超过 50ms(相当于 20fps 以下)的帧,一律按 50ms 算。游戏可能会短暂变慢,但不会崩。

这个 bug 的现象是:切到其他标签页再切回来,游戏里所有东西瞬间飞出屏幕。当时我完全懵了,以为是碰撞检测的问题,排查了半天。后来才想明白,浏览器在后台标签页会暂停 requestAnimationFrame,切回来的时候 deltaTime 直接飙到几秒。加了这个 cap 之后就好了。

注释里写的 "prevent spiral of death" 就是那天晚上加的,算是给自己留个纪念。

程序化音效:零音频文件

整个游戏没有一个音频文件。所有音效都是用 Web Audio API 实时合成的:

javascript
// Shoot sound: short square wave decay
playShoot() {
  this._playTone('square', 800, 400, 0.05, 0.08);
}
 
// Explosion: noise burst with lowpass filter
playExplosion() {
  this._playNoise(0.2, 0.12, 1000);
}
 
// Core tone generator
_playTone(type, startFreq, endFreq, duration, vol) {
  this._ensureContext();
  if (this._muted) return;
  const ctx = this._ctx;
  const now = ctx.currentTime;
 
  const osc = ctx.createOscillator();
  const gain = ctx.createGain();
  osc.type = type;
  osc.frequency.setValueAtTime(Math.max(startFreq, 0.01), now);
  osc.frequency.exponentialRampToValueAtTime(Math.max(endFreq, 0.01), now + duration);
  gain.gain.setValueAtTime(vol, now);
  gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
  osc.connect(gain);
  gain.connect(this._masterGain);
  osc.start(now);
  osc.stop(now + duration + 0.01);
}

射击声是一段 800Hz 到 400Hz 的方波快速衰减,持续 0.05 秒。爆炸声是白噪声加 1000Hz 低通滤波器。升级音效是两个正弦波的快速上行。听起来很 retro,但和像素风的画面很搭。

好处是显而易见的:零网络请求,零加载时间,包体积极小。坏处是调音效全靠耳朵,没有可视化工具,改一个参数就要重新听一遍。playShoot 那个 0.05 秒的持续时间我调了十几次才觉得「对味」。0.03 太短听不清,0.08 又太拖沓,最后定在 0.05。

还有个坑:exponentialRampToValueAtTime 不能 ramp 到 0,只能 ramp 到一个接近 0 的值(我用的 0.001)。一开始写的是 ramp 到 0,浏览器直接报错,查了半天 MDN 才发现这个限制。指数衰减嘛,数学上不可能到 0,想想也合理。

最大的挑战:200 个技能的平衡性

200 个技能,怎么保证不会出现某个组合过于逆天?

说实话,保证不了。😅

我的做法是分层:技能分 common、uncommon、rare、legendary 四个稀有度,高稀有度出现概率低。同一流派的技能之间有协同效果,但不同流派混搭也不会太弱。然后就是大量的自己试玩,发现太强的就削一刀,太弱的就加强。

比如攻速流一开始太强了。基础 attackSpeed: 0.4 加上终极技能再乘 -0.5,配合「弹道+1」技能多发子弹,屏幕上全是子弹,Boss 秒融。后来我把终极技能的弹射次数从 5 降到 3,才算平衡了一点。

这个过程其实挺像做数学题的:调参数、验证、再调。只不过验证的方式是打一局游戏而不是算一道题。

经典扫雷:三天的像素级还原

它是什么

做完星域战机之后,我想做个小的项目换换脑子。扫雷是我小时候在 Windows XP 上玩得最多的游戏之一,规则简单,但实现起来有不少细节。

目标很明确:像素级还原 Windows XP/7 的扫雷体验。 不是「类似扫雷」,是打开之后让人觉得「这就是那个扫雷」。

最终成品大约 2800 行代码,支持 PWA 离线游玩。

🎮 在线试玩:game2.the37777777.top

📦 源码:github.com/WXFffff666/minesweeper

功能清单

别看扫雷「简单」,要做到完整还是有不少东西的:

  • 3D 凸起/凹陷边框(经典 Windows 风格)
  • LED 数字计数器(剩余雷数 + 计时器)
  • 左右键同时按的 chord click(双击翻开周围)
  • 第一次点击保证安全(不会踩雷)
  • BFS 洪水填充(点到空白区域自动展开)
  • 无尽模式(通关后自动开下一局)
  • 12 个成就
  • 3 套主题(经典、暗色、现代)
  • 统计数据和排行榜
  • PWA 离线支持

模块模式架构

扫雷的代码结构比星域战机清晰得多(毕竟规模小很多)。我用了 IIFE 模块模式,把逻辑拆成独立模块。来看看渲染模块的实际代码:

javascript
MS.Renderer = (function() {
    'use strict';
 
    var boardEl = document.querySelector('.board');
    var mineCounterEl = document.querySelector('.mine-counter');
    var timerEl = document.querySelector('.timer');
    var smileyBtn = document.querySelector('.smiley-btn');
    var currentCols = 0;
 
    function createBoard(rows, cols) {
        currentCols = cols;
        boardEl.innerHTML = '';
        boardEl.style.setProperty('--cols', cols);
 
        var fragment = document.createDocumentFragment();
        for (var r = 0; r < rows; r++) {
            for (var c = 0; c < cols; c++) {
                var cell = document.createElement('div');
                cell.className = 'cell';
                cell.dataset.row = r;
                cell.dataset.col = c;
                fragment.appendChild(cell);
            }
        }
        boardEl.appendChild(fragment);
    }
 
    // ... more methods ...
 
    return { createBoard: createBoard, updateCell: updateCell, ... };
})();

每个模块是一个 IIFE,内部变量完全私有,只通过 return 暴露公共接口。MS.Engine 负责游戏逻辑,MS.Renderer 负责渲染,MS.Input 负责输入处理,MS.Audio 负责音效,MS.Stats 负责统计和成就。

纯逻辑和渲染完全分离。MS.Engine 不知道自己被画在哪里,MS.Renderer 不关心游戏规则。如果以后想换一套 UI(比如从 DOM 换成 Canvas),只需要重写 MS.Renderer,其他模块一行不动。

注意 createBoard 里用了 document.createDocumentFragment()。如果直接在循环里 appendChild 到 DOM,每次都会触发重排。用 fragment 先在内存里拼好,最后一次性插入,性能差距在大棋盘(30x16 = 480 个格子)上很明显。

BFS 洪水填充

扫雷里点到一个周围没有雷的空白格子,会自动展开一大片区域。这个功能的实现是经典的 BFS(广度优先搜索)。来看实际代码:

javascript
function floodFill(startRow, startCol) {
    var queue = [];
    var revealed = [];
 
    queue.push({ row: startRow, col: startCol });
 
    while (queue.length > 0) {
        var cell = queue.shift();
        var r = cell.row;
        var c = cell.col;
        var boardCell = board[r][c];
 
        if (boardCell.revealed || boardCell.flagged) continue;
 
        boardCell.revealed = true;
        revealedCount++;
        revealed.push({ row: r, col: c });
 
        if (boardCell.adjacentMines === 0) {
            var neighbors = getNeighbors(r, c);
            for (var i = 0; i < neighbors.length; i++) {
                var nr = neighbors[i].row;
                var nc = neighbors[i].col;
                if (!board[nr][nc].revealed && !board[nr][nc].flagged) {
                    queue.push({ row: nr, col: nc });
                }
            }
        }
    }
 
    return revealed;
}

为什么用迭代 BFS 而不是递归 DFS?因为大棋盘(比如 30x16)上,递归深度可能超过浏览器的调用栈限制,直接 stack overflow。迭代版本用一个队列,不管棋盘多大都不会爆栈。

注意这里的逻辑:只有 adjacentMines === 0 的格子才会继续扩展邻居。如果一个格子周围有雷(数字不为 0),它会被翻开但不会继续往外扩。这就是为什么 BFS 展开后边缘总是一圈数字。

函数返回所有被翻开的格子坐标,渲染模块拿到这个数组后批量更新 DOM。逻辑和渲染分离的好处在这里就体现出来了。

这也是算法课上学的东西第一次在实际项目里派上用场。真的挺有成就感的。

踩坑:第一次点击保证安全

扫雷有个规则很多人不知道:第一次点击永远不会踩雷。Windows 原版就是这样的。

实现方式是:雷不是游戏开始时就放好的,而是第一次点击之后才放。 放雷的时候排除掉点击位置周围 3x3 的区域:

javascript
function placeMines(safeRow, safeCol) {
    var positions = [];
    for (var r = 0; r < rows; r++) {
        for (var c = 0; c < cols; c++) {
            // Exclude 3x3 area around safe cell
            if (Math.abs(r - safeRow) <= 1 && Math.abs(c - safeCol) <= 1) {
                continue;
            }
            positions.push({ row: r, col: c });
        }
    }
 
    shuffle(positions);
 
    var minesToPlace = Math.min(totalMines, positions.length);
    for (var i = 0; i < minesToPlace; i++) {
        board[positions[i].row][positions[i].col].mine = true;
    }
}

先收集所有「可以放雷」的位置(排除安全区),然后 shuffle 打乱,取前 N 个放雷。

这里有个边界情况我一开始没想到:Math.min(totalMines, positions.length) 这行。如果棋盘太小、雷太多,排除了 3x3 安全区之后可放的位置不够怎么办?比如 5x5 的棋盘放 20 颗雷,排除 9 个安全格后只剩 16 个位置。没有这个 Math.min 的话,数组越界直接报错。虽然正常游戏不太会出现这种情况,但自定义难度的时候用户什么参数都可能填。

还有个细节:排除的是 3x3 区域而不是单个格子。为什么?因为如果只排除点击的那一个格子,第一次点击虽然不会死,但可能翻开一个数字 8(周围全是雷),体验很差。排除 3x3 保证第一次点击至少能展开一小片区域,给玩家一个好的开局。

像素级还原的 CSS 挑战

Windows XP 扫雷那个经典的 3D 外观,全靠 CSS 实现:

css
.cell-raised {
  border-top: 2px solid #fff;
  border-left: 2px solid #fff;
  border-bottom: 2px solid #808080;
  border-right: 2px solid #808080;
}
 
.cell-sunken {
  border-top: 1px solid #808080;
  border-left: 1px solid #808080;
  border-bottom: 1px solid #fff;
  border-right: 1px solid #fff;
}

上边和左边用亮色,下边和右边用暗色,就能模拟出光照从左上方打过来的 3D 效果。翻开的格子反过来,就是凹陷的效果。注意凸起是 2px 边框,凹陷是 1px,这个细节也是对着原版截图一点点调出来的。

LED 数字显示器也是纯 CSS 画的,用七段数码管的方式拼出 0-9。计时器和雷数计数器都用这个组件。

说起来简单,但调到「看起来对」花了不少时间。我对着 Windows XP 虚拟机的截图一个像素一个像素地比对,调颜色、调边框宽度、调间距。强迫症发作的时候真的停不下来。

PWA 离线支持

扫雷这种游戏太适合做 PWA 了。断网环境下也能玩,安装到手机桌面之后和原生 App 体验一模一样。

Service Worker 缓存了所有静态资源,第一次加载之后就完全不需要网络了。加上整个游戏零外部依赖,离线体验和在线完全一致。

技术对比

两个项目放在一起看,差异还挺大的:

星域战机经典扫雷
代码量~20000 行~2800 行
渲染方式Canvas 2DDOM
架构数据驱动 + 对象池模块模式 + MVC
音效Web Audio 程序化Web Audio 程序化
离线Service WorkerPWA + Service Worker
开发周期~2 周~3 天
输入键盘 + 鼠标鼠标 + 触屏
状态管理全局 Game 对象模块内部状态

星域战机是「大而全」,什么都自己造轮子;扫雷是「小而精」,在有限的规模里把每个细节打磨到位。两种开发体验都很有意思。

共同的设计哲学

虽然两个项目规模差很多,但有几个原则是一致的:

零依赖。 没有 React,没有 Vue,没有 jQuery,没有任何 npm 包。所有代码都是从零写的。这不是为了装逼,是为了学习。用框架的时候,很多底层细节被封装掉了,你不知道 Canvas 怎么画一个圆,不知道事件委托怎么实现,不知道音频合成的原理。自己写一遍,这些东西就真的变成你的了。

数据驱动。 星域战机的技能配置是数据驱动,扫雷的主题和成就也是数据驱动。把「是什么」和「怎么做」分开,代码会清晰很多。加内容不需要改逻辑,改逻辑不会破坏内容。

程序化音效。 两个游戏都没有音频文件,全部用 Web Audio API 实时合成。省带宽,省加载时间,而且可以动态调整参数(比如根据连击数改变音调)。

渐进增强。 核心功能不依赖任何高级 API。Web Audio 不支持?静音也能玩。Service Worker 不支持?在线也能玩。触屏设备?也做了适配。不会因为某个 API 不可用就整个游戏崩掉。

写在最后

这两个游戏是在备考间隙做的。晚上回来,刷完当天的任务,如果还有精力,就写一会儿代码。有时候是半小时,有时候是两小时。星域战机断断续续写了两周,扫雷集中三天搞定。

对我来说,写代码是一种很好的减压方式。和刷题不同,写代码的时候你在创造东西,看着一个游戏从空白画布变成能玩的成品,那种成就感是做对一道数学题给不了的。

回头看这一年多的项目:博客让我入了前端的门,TimeMark 让我理解了全栈和 Docker,这两个游戏让我真正掌握了 Vanilla JS 的底层能力。每个项目都在前一个的基础上往前走了一步。

高考之后,我想试试用真正的游戏引擎做点东西。Godot 或者 Unity,做一个有完整剧情的小游戏。也可能会继续用 Web 技术,毕竟浏览器的能力还远没被我榨干。

不管怎样,这两个项目算是高中阶段的一个句号。

好了,该去背英语了。


试玩链接: