🎮 Doom 的故事
1993 年,id Software 发布了一款彻底改变游戏行业的作品 ——《Doom》。在那个 CPU 主频只有 66 MHz、没有 GPU 的年代,John Carmack 用纯软件渲染实现了流畅的"伪 3D"画面,让全世界的玩家第一次体验到了第一人称射击(FPS)的魅力。
Doom 并不是真正的 3D 游戏。它的世界本质上是一张 2D 网格地图,通过一种叫做 Raycasting(光线投射)的算法,把 2D 的地图信息"投影"成看起来像 3D 的画面。这种技术后来被称为 2.5D —— 介于 2D 和 3D 之间。
有一天我在博客里做完了贪吃蛇之后,突然想:能不能在浏览器里用 Canvas 2D 也复刻一个 Doom?于是就有了这个项目 —— 一个完全用 JavaScript + Canvas 2D 实现的 FPS 游戏,无需 WebGL,无需任何外部资源文件,所有的渲染、音效、物理全部程序化生成。
🔎 Raycasting:从 2D 地图到伪 3D 画面
Raycasting 的核心思想非常优雅:对屏幕上的每一列像素,从玩家位置发射一条射线,找到它碰到的第一面墙,根据距离来决定这面墙在屏幕上画多高。
想象你站在一个由方块组成的迷宫里。你的视野是 60 度,屏幕宽度是 960 像素。那么你就需要发射 960 条射线,均匀分布在这 60 度的扇形范围内:
// 对每一列像素发射射线
for (var col = 0; col < SCREEN_W; col++) {
// 射线角度 = 玩家朝向 - 半FOV + 当前列的偏移
var rayAngle = player.angle - HALF_FOV + (col / SCREEN_W) * FOV;
// ... 用DDA算法找到墙壁
}
DDA 算法
DDA(Digital Differential Analyzer) 是 Raycasting 的核心。它不是沿射线一小步一小步地前进(那样太慢),而是沿网格线跳跃 —— 每一步精确地跳到下一条网格线,检查那个格子是不是墙:
// DDA 核心:在X边界和Y边界之间交替前进
if (sideDistX < sideDistY) {
sideDistX += deltaDistX; // 跳到下一条竖直网格线
mapX += stepX;
side = 0; // 碰到的是东/西面的墙
} else {
sideDistY += deltaDistY; // 跳到下一条水平网格线
mapY += stepY;
side = 1; // 碰到的是南/北面的墙
}
这个算法的精妙之处在于:每次循环只需要一次比较和一次加法,不需要任何三角函数运算,所以即使在 1993 年的硬件上也能达到 30+ FPS。
鱼眼矫正
如果直接用射线到墙壁的欧几里得距离来计算墙高,你会看到一个诡异的"鱼眼"效果 —— 画面边缘的墙壁看起来向外膨胀弯曲。这是因为屏幕边缘的射线比中心的射线走了更远的路。
修复方法很简单 —— 使用垂直距离而不是实际距离:
// 鱼眼矫正:用垂直距离代替实际距离
// DDA 算法天然给出垂直距离:
var perpDist;
if (side === 0) perpDist = sideDistX - deltaDistX;
else perpDist = sideDistY - deltaDistY;
// 墙壁高度 = 屏幕高度 / 垂直距离
var wallH = Math.floor(SCREEN_H / perpDist);
距离着色与面着色
为了增强立体感,我们用了两个技巧:
- 距离衰减:墙壁颜色随距离变暗,
shade = max(0.15, 1 - dist / MAX_DEPTH)
- 面朝向差异:南北面(side=1)的亮度额外乘以 0.7,这样转角处就能看到明暗交替,增强空间感
👤 精灵系统:2D 角色在 3D 空间中
敌人和道具在 Raycasting 引擎中是 Billboard Sprites —— 它们是扁平的 2D 图像,但始终正对玩家(就像广告牌一样)。这样无论你从哪个角度看,它们都"看起来"是 3D 的。
精灵渲染的关键步骤:
- 世界坐标→屏幕坐标:计算精灵相对于玩家的角度偏差,映射到屏幕 X 位置
- 距离排序:先画远的,再画近的(画家算法)
- 深度缓冲裁剪:逐列比较精灵距离和该列墙壁距离,只画精灵比墙近的部分
// 精灵投影:世界坐标 → 屏幕位置
var spriteAngle = Math.atan2(dy, dx) - player.angle;
var screenX = SCREEN_W / 2 * (1 + spriteAngle / HALF_FOV);
var spriteH = SCREEN_H / dist; // 距离越近,画得越大
// 深度裁剪:逐列检查
for (var col = startX; col < endX; col++) {
if (dist < depthBuf[col]) {
// 这一列精灵比墙近,画出来
drawSpriteColumn(col, ...);
}
}
有趣的是,本项目的敌人不是用图片贴图,而是用 Canvas 程序化绘制 —— 头、身体、手臂、腿都是用 fillRect 和 arc 拼出来的。这意味着整个游戏没有任何外部资源文件。
🖱 鼠标旋转的坑
FPS 游戏最重要的交互就是鼠标瞄准。在浏览器中,我们使用 Pointer Lock API 来获取鼠标的相对移动量,然后转化为视角旋转:
canvas.requestPointerLock(); // 锁定鼠标
document.addEventListener('mousemove', function(e) {
if (pointerLocked) {
mouseMovX += e.movementX; // 累加水平移动量
}
});
// 每帧应用旋转
player.angle += mouseMovX * MOUSE_SENS;
mouseMovX = 0;
听起来很简单,但在实际开发中,鼠标视角会突然跳变 —— 转到某个方向时,画面猛地转了 90 度。
这个 bug 困扰了我很久,经历了 4 次修复迭代:
| 尝试 | 方案 | 结果 |
| 第 1 次 | 角度归一化到 [0, 2π] | ❌ 没用 |
| 第 2 次 | per-event clamp ±50px + 帧级 clamp ±150px | ❌ 依然跳变 |
| 第 3 次 | 指数移动平均 (EMA) 平滑 | ❌ 更糟了 —— 操作感变得迟钝粘滞 |
| 第 4 次 | 直接丢弃 |movementX| > 200 的异常事件 | ✅ 完美解决! |
最终的原因是 浏览器的 Pointer Lock 实现存在 bug:某些帧会报出离谱的 movementX 值(比如突然 +500),这并不是真实的鼠标移动。
// 最终方案:丢弃离群值
document.addEventListener('mousemove', function(e) {
if (pointerLocked) {
// 丢弃异常跳变(浏览器 Pointer Lock bug)
if (Math.abs(e.movementX) < 200) {
mouseMovX += e.movementX;
}
}
});
教训:有时候最简单的方案反而最有效。复杂的平滑算法不仅没解决问题,还引入了新的问题。直接找到异常数据并丢弃才是正解。
💣 手雷物理模拟
手雷的物理系统是整个项目最有趣的部分之一。它模拟了真实的抛物运动和弹跳效果,虽然只有几十行代码,但视觉效果非常满意。
三轴运动
虽然是 2.5D 的游戏,手雷的运动是在 3 个轴上进行的:x/y 是水平位置(地图上的坐标),z 是垂直高度。
// 抛出手雷
grenades.push({
x: player.x, y: player.y,
dx: cos * 6, dy: sin * 6, // 水平速度(朝向方向)
z: 0.5, // 初始高度(手持高度)
dz: 3.0, // 向上抛出的初速度
life: 3.0 // 3秒引信
});
// 每帧更新
g.dz -= 9.8 * dt; // 重力加速度
g.z += g.dz * dt; // 更新高度
地面弹跳
当手雷落到地面(z ≤ 0)时,垂直速度反转并乘以衰减系数,模拟非弹性碰撞:
if (g.z <= 0) {
g.z = 0;
if (Math.abs(g.dz) > 0.5) {
g.dz = -g.dz * 0.45; // 弹跳,保留45%能量
g.dx *= 0.7; // 水平速度也衰减
g.dy *= 0.7;
} else {
g.dz = 0; // 能量不够了,停止弹跳
g.dx *= 0.92; // 地面滚动摩擦
g.dy *= 0.92;
}
}
墙壁反弹
墙壁碰撞是 X 轴和 Y 轴独立检测 的。这意味着手雷打到墙角时,X 和 Y 方向都会反转,自然地弹回来:
// X方向碰墙?反转X速度
if (MAP[myO][mxN] !== 0) {
g.dx = -g.dx * 0.5; // 50%能量保留
nx = g.x; // 不穿墙
}
// Y方向碰墙?反转Y速度
if (MAP[myN][mxO] !== 0) {
g.dy = -g.dy * 0.5;
ny = g.y;
}
手雷在飞行时还会在地面投射一个椭圆形阴影,帮助玩家判断落点位置。
🤖 敌人 AI 状态机
每个敌人都运行着一个简单的状态机,有 4 个状态:
| 状态 | 行为 | 转换条件 |
| IDLE | 原地缓慢巡逻 | 听到枪声或看到玩家 → ALERT |
| ALERT | 朝声音方向转身 | 看到玩家 → CHASE |
| CHASE | 朝玩家移动 | 进入攻击范围 → ATTACK |
| ATTACK | 开火或近战 | 失去视线 → IDLE |
"看到玩家"的判定用的是一个简化的 Raycast:沿敌人到玩家的方向,每步 0.3 检查是否碰到墙壁。如果一路畅通,就说明视线没被遮挡。
敌人之间、敌人和玩家之间还有碰撞阻挡,防止它们重叠在一起。这个碰撞半径经历了多次调整 —— 太小会重叠,太大会卡在门口走不过去。
🎵 程序化音效
整个游戏的所有音效都是用 Web Audio API 实时生成的,没有一个外部音频文件。每种武器和音效都是由振荡器(Oscillator)、噪声缓冲(Noise Buffer)、滤波器(BiquadFilter)和增益节点(Gain)组合而成:
| 音效 | 实现方式 |
| 手枪 | 白噪声 + 低通滤波器 800Hz + 快速衰减 |
| 霰弹枪 | 白噪声 + 低通 600Hz + 更长的衰减 |
| 机枪 | 白噪声 + 带通滤波器 + 极短脉冲 |
| 狙击枪 | 锯齿波 150→30Hz + 低通 800Hz(深沉的开裂声) |
| 爆炸 | 长白噪声 + 低通 300Hz + 慢衰减(沉闷的轰鸣) |
| 匕首 | 白噪声 + 高通 3000Hz(尖锐的挥砍声) |
| 脚步 | 白噪声 + 低通 200Hz + 极短脉冲 |
// 以手枪为例:白噪声 + 低通滤波 + 快速衰减
case 'pistol':
bufferSize = audioCtx.sampleRate * 0.15;
buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
output = buffer.getChannelData(0);
for (var i = 0; i < bufferSize; i++)
output[i] = (Math.random() * 2 - 1) * Math.exp(-i / (bufferSize * 0.1));
// ... 连接低通滤波器和增益节点
🔒 反作弊设计
排行榜系统需要防止分数作弊。虽然客户端的任何方案都不可能完全防住,但合理的设计可以大幅提高作弊门槛:
- Token 验证:提交分数时需要附带
md5(score + '_' + duration + '_' + md5(serverSalt)),salt 每天更换
- 合理性检查:分数 0~99999,时长 0~36000 秒
- IP 限速:每个 IP 每 5 秒只能提交一次
- 数据清理:只保留前 20 名,超过 3 周的记录自动删除
一个有趣的小坑:最初 salt 是通过 WordPress 的 esc_js() 传给前端的,但这个函数会把 salt 中的特殊字符转义,导致前后端计算的 token 不一致。解决方案是在传递之前先对 salt 做一次 md5() —— 哈希值只包含 0-9a-f,不会被转义。
🚀 技术栈总结
| 模块 | 技术 |
| 渲染引擎 | Canvas 2D + Raycasting (DDA 算法) |
| 输入控制 | Pointer Lock API + 离群值过滤 |
| 音效系统 | Web Audio API 程序化生成 |
| 物理模拟 | 欧拉积分 + 弹性碰撞 |
| AI 系统 | 有限状态机 + 视线检测 |
| 后端 | WordPress AJAX + MySQL |
| 反作弊 | MD5 token + 服务端 salt |
| 外部依赖 | 无(纯原生 JS,无框架、无图片、无音频文件) |
整个游戏的代码量约 1800 行 JavaScript,加上 300 行 CSS 和 80 行 PHP,是一个完全自包含的小项目。它证明了即使在 2026 年,Raycasting 这个 1992 年的技术依然有它独特的魅力 —— 简单、高效、而且实现起来非常有趣。
如果你想挑战一下,试试通关两个关卡吧! 😊
🎮 The Story of Doom
In 1993, id Software released a game that changed the industry forever — Doom. In an era when CPUs ran at 66 MHz and GPUs didn't exist, John Carmack achieved smooth "pseudo-3D" rendering through pure software, giving the world its first taste of the first-person shooter (FPS) genre.
Doom isn't truly a 3D game. Its world is fundamentally a 2D grid map, projected into a 3D-looking image through an algorithm called Raycasting. This technique came to be known as 2.5D — somewhere between 2D and 3D.
One day, after building a Snake game for my blog, I thought: could I recreate Doom in the browser using Canvas 2D? And so this project was born — an FPS game built entirely with JavaScript + Canvas 2D. No WebGL, no external assets — all rendering, sound effects, and physics are procedurally generated.
🔎 Raycasting: From 2D Maps to Pseudo-3D
The core idea of Raycasting is elegant: for each column of pixels on screen, cast a ray from the player's position, find the first wall it hits, and draw that wall at a height determined by the distance.
Imagine standing in a maze made of blocks. Your field of view is 60 degrees, and the screen is 960 pixels wide. You cast 960 rays, evenly distributed across that 60-degree arc:
// Cast a ray for each screen column
for (var col = 0; col < SCREEN_W; col++) {
// Ray angle = player facing - half FOV + column offset
var rayAngle = player.angle - HALF_FOV + (col / SCREEN_W) * FOV;
// ... find wall using DDA algorithm
}
The DDA Algorithm
DDA (Digital Differential Analyzer) is the heart of Raycasting. Instead of stepping along the ray in tiny increments (too slow), it jumps along grid boundaries — each step lands exactly on the next grid line, checking if that cell is a wall:
// DDA core: alternate between X and Y grid boundaries
if (sideDistX < sideDistY) {
sideDistX += deltaDistX; // jump to next vertical grid line
mapX += stepX;
side = 0; // hit an East/West wall face
} else {
sideDistY += deltaDistY; // jump to next horizontal grid line
mapY += stepY;
side = 1; // hit a North/South wall face
}
The beauty of this algorithm: each iteration needs only one comparison and one addition — no trigonometry — so it could run at 30+ FPS even on 1993 hardware.
Fish-eye Correction
If you use the raw Euclidean distance from ray to wall, you get a bizarre "fish-eye" effect — walls at the screen edges appear to bulge outward. This happens because edge rays travel further than center rays.
The fix is simple — use perpendicular distance instead of actual distance:
// Fish-eye correction: use perpendicular distance
// DDA naturally provides this:
var perpDist;
if (side === 0) perpDist = sideDistX - deltaDistX;
else perpDist = sideDistY - deltaDistY;
// Wall height = screen height / perpendicular distance
var wallH = Math.floor(SCREEN_H / perpDist);
Distance & Face Shading
To enhance depth perception, two techniques are used:
- Distance attenuation: Wall colors darken with distance,
shade = max(0.15, 1 - dist / MAX_DEPTH)
- Face orientation: N/S faces (side=1) are dimmed by an extra 0.7x, creating visible light/dark alternation at corners
👤 Sprite System: 2D Characters in 3D Space
Enemies and items in a Raycasting engine are Billboard Sprites — flat 2D images that always face the player (like a billboard). This makes them "look" 3D from any angle.
Key steps in sprite rendering:
- World → Screen projection: Calculate the sprite's angular offset from the player, map to screen X position
- Distance sorting: Draw far sprites first, near ones last (painter's algorithm)
- Depth buffer clipping: Compare sprite distance against wall distance column-by-column, only draw where the sprite is closer
// Sprite projection: world coords → screen position
var spriteAngle = Math.atan2(dy, dx) - player.angle;
var screenX = SCREEN_W / 2 * (1 + spriteAngle / HALF_FOV);
var spriteH = SCREEN_H / dist; // closer = larger
// Depth clipping: per-column check
for (var col = startX; col < endX; col++) {
if (dist < depthBuf[col]) {
// This column is in front of the wall, draw it
drawSpriteColumn(col, ...);
}
}
Interestingly, enemies in this project aren't texture-mapped — they're drawn procedurally with Canvas. Heads, bodies, arms, and legs are assembled from fillRect and arc calls. This means the entire game has zero external asset files.
🖱 The Mouse Rotation Bug
Mouse aiming is the most critical interaction in an FPS. In the browser, we use the Pointer Lock API to capture relative mouse movement and convert it to view rotation:
canvas.requestPointerLock(); // lock the mouse
document.addEventListener('mousemove', function(e) {
if (pointerLocked) {
mouseMovX += e.movementX; // accumulate horizontal movement
}
});
// Apply rotation each frame
player.angle += mouseMovX * MOUSE_SENS;
mouseMovX = 0;
Sounds simple, but in practice, the view would suddenly snap — spinning 90 degrees in a single frame.
This bug tormented me through 4 fix iterations:
| Attempt | Approach | Result |
| #1 | Normalize angle to [0, 2π] | ❌ No effect |
| #2 | Per-event clamp ±50px + frame clamp ±150px | ❌ Still snapping |
| #3 | Exponential Moving Average (EMA) smoothing | ❌ Worse — controls felt sluggish and drifty |
| #4 | Discard events with |movementX| > 200 | ✅ Perfect fix! |
The root cause: a browser bug in the Pointer Lock implementation that occasionally reports wildly incorrect movementX values (e.g., a sudden +500), which don't represent real mouse movement.
// Final solution: discard outliers
document.addEventListener('mousemove', function(e) {
if (pointerLocked) {
// Discard anomalous jumps (browser Pointer Lock bug)
if (Math.abs(e.movementX) < 200) {
mouseMovX += e.movementX;
}
}
});
Lesson learned: Sometimes the simplest solution is the most effective. Complex smoothing algorithms didn't solve the problem — they made it worse. Identifying and discarding anomalous data was the real fix.
💣 Grenade Physics Simulation
The grenade physics system is one of the most satisfying parts of this project. It simulates realistic parabolic motion and bouncing with just a few dozen lines of code.
Three-Axis Motion
Despite being a 2.5D game, grenade motion uses 3 axes: x/y for horizontal position (map coordinates), and z for vertical height.
// Throw a grenade
grenades.push({
x: player.x, y: player.y,
dx: cos * 6, dy: sin * 6, // horizontal velocity (facing direction)
z: 0.5, // initial height (hand level)
dz: 3.0, // upward throw velocity
life: 3.0 // 3-second fuse
});
// Per-frame update
g.dz -= 9.8 * dt; // gravity
g.z += g.dz * dt; // update height
Ground Bounce
When the grenade hits the ground (z ≤ 0), vertical velocity is reversed and multiplied by a damping factor, simulating inelastic collision:
if (g.z <= 0) {
g.z = 0;
if (Math.abs(g.dz) > 0.5) {
g.dz = -g.dz * 0.45; // bounce, retain 45% energy
g.dx *= 0.7; // horizontal speed also decays
g.dy *= 0.7;
} else {
g.dz = 0; // not enough energy, stop bouncing
g.dx *= 0.92; // rolling friction
g.dy *= 0.92;
}
}
Wall Bounce
Wall collisions are detected independently on the X and Y axes. This means a grenade hitting a corner naturally reverses both directions:
// Hit wall on X axis? Reverse X velocity
if (MAP[myO][mxN] !== 0) {
g.dx = -g.dx * 0.5; // 50% energy retained
nx = g.x; // don't pass through
}
// Hit wall on Y axis? Reverse Y velocity
if (MAP[myN][mxO] !== 0) {
g.dy = -g.dy * 0.5;
ny = g.y;
}
During flight, the grenade also casts an elliptical shadow on the ground to help players judge the landing point.
🤖 Enemy AI State Machine
Each enemy runs a simple state machine with 4 states:
| State | Behavior | Transition |
| IDLE | Slow patrol in place | Hears gunfire or sees player → ALERT |
| ALERT | Turn toward sound | Sees player → CHASE |
| CHASE | Move toward player | In attack range → ATTACK |
| ATTACK | Fire or melee | Loses line of sight → IDLE |
"Seeing the player" uses a simplified raycast: step along the enemy-to-player direction in 0.3 increments, checking for wall hits. If the path is clear, line of sight is confirmed.
Enemies also have mutual collision blocking with each other and with the player, preventing overlap. The collision radius went through several adjustments — too small and they overlap, too large and they get stuck in doorways.
🎵 Procedural Sound Effects
Every sound in the game is generated in real-time using the Web Audio API — not a single audio file is loaded. Each weapon and effect is composed from Oscillators, Noise Buffers, BiquadFilters, and Gain nodes:
| Sound | Implementation |
| Pistol | White noise + lowpass 800Hz + fast decay |
| Shotgun | White noise + lowpass 600Hz + longer decay |
| Machine gun | White noise + bandpass filter + ultra-short pulse |
| Sniper | Sawtooth 150→30Hz + lowpass 800Hz (deep crack) |
| Explosion | Long white noise + lowpass 300Hz + slow decay (muffled boom) |
| Knife | White noise + highpass 3000Hz (sharp slash) |
| Footstep | White noise + lowpass 200Hz + ultra-short pulse |
// Example: Pistol - white noise + lowpass + fast decay
case 'pistol':
bufferSize = audioCtx.sampleRate * 0.15;
buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
output = buffer.getChannelData(0);
for (var i = 0; i < bufferSize; i++)
output[i] = (Math.random() * 2 - 1) * Math.exp(-i / (bufferSize * 0.1));
// ... connect lowpass filter and gain node
🔒 Anti-Cheat Design
The leaderboard needs protection against score manipulation. While no client-side solution is bulletproof, reasonable design can raise the barrier significantly:
- Token verification: Score submissions require
md5(score + '_' + duration + '_' + md5(serverSalt)), with the salt rotating daily
- Sanity checks: Score 0–99999, duration 0–36000 seconds
- IP rate limiting: One submission per IP every 5 seconds
- Data cleanup: Keep only top 20, auto-delete records older than 3 weeks
A fun gotcha: initially the salt was passed to the frontend via WordPress's esc_js(), which escapes special characters in the salt, causing token mismatches between client and server. The fix was to md5() the salt before passing it — hex digests contain only 0-9a-f and can't be corrupted by escaping.
🚀 Tech Stack Summary
| Module | Technology |
| Rendering | Canvas 2D + Raycasting (DDA algorithm) |
| Input | Pointer Lock API + outlier filtering |
| Audio | Web Audio API procedural generation |
| Physics | Euler integration + inelastic collision |
| AI | Finite state machine + line-of-sight detection |
| Backend | WordPress AJAX + MySQL |
| Anti-cheat | MD5 token + server-side salt |
| Dependencies | None (vanilla JS, no frameworks, no images, no audio files) |
The entire game is roughly 1,800 lines of JavaScript, plus 300 lines of CSS and 80 lines of PHP — a fully self-contained mini-project. It proves that even in 2026, Raycasting — a technique from 1992 — still has a unique charm: simple, efficient, and incredibly fun to implement.
If you're up for a challenge, try beating both levels! 😊