《深渊回响》:3344 行代码,零张图片,零个音频文件
《深渊回响》:3344 行代码,零张图片,零个音频文件
老猫这几天掉头发了。
至于原因 —— 大概是给 arch-web-v2 里塞了一个游戏。
它是一个动作 RPG:四张地图、一条任务链、成长曲线、两阶段的 Boss、自动存档。
它还有一个偏执的地方:整个游戏没有引入任何素材。仓库 public/ 目录里,属于《深渊回响》的图片是零张,音频是零个。所有像素是用 Canvas 画出来的,所有声音是用 Web Audio 合成的。
下面说它怎么做的,以及哪些地方还没做完。
先看本体
arch-web-v2 里有 37 个应用,绝大部分是工具:终端、文件管理器、Base64、JSON 格式化、网易云。剩下的「游戏」栏是 2048、贪吃蛇、俄罗斯方块、扫雷、五子棋 —— 都是玩两分钟就关掉的那类。
《深渊回响》是想做长一点的那种。四个文件:
| 文件 | 行数 | 职责 |
|---|---|---|
index.tsx | 50 | 挂 canvas,卸载时回收 |
data.ts | 352 | 地图、敌人表、装备表、任务链 |
art.ts | 912 | 全部像素美术 |
game.ts | 2030 | 状态机、战斗、AI、渲染 |
| 3344 |
四张地图和一条任务链
世界是 40 × 30 的瓦片,每格 24 像素,实际 960 × 720。视口只有 800 × 500,镜头跟着人走。
| 区域 | 内容 |
|---|---|
| 起始村 · 阿尔德拉 | 巫女铃兰、行商玛戈,安全区 |
| 绿野平原 | 史莱姆、灰狼,练级场 |
| 枯萎森林 | 骷髅士兵、毒蛛,精英「深渊守卫」守着钥匙 |
| 深渊地牢 | 程序化生成的柱廊,深渊骑士,尽头是 Boss |
任务链四个阶段:找铃兰对话 → 讨伐 8 只史莱姆 → 拿到深渊钥匙 → 讨伐深渊魔女·诺瓦。
剧情不剧透,但有一点值得说:这不是「勇者打死魔王」的结构。 打到终局你会知道地牢尽头那个人是谁,她为什么在那儿,以及为什么 Boss 战打到一半她会喊一句「来吧,骑士们……陪客人跳支舞。」然后召唤两个帮手。
交互键是 E / Enter。空格是攻击 —— 站在 NPC 面前按空格不会触发对话。我第一版就踩了这个坑。
战斗
J / 空格 / Z 挥砍,K 喝药,WASD 移动,ESC 暂停。
| 参数 | 值 |
|---|---|
| 攻击冷却 | 0.42 s |
| 判定窗口 | 0.16 s |
| 攻击距离 | 30 px |
| 暴击率 | 10%(伤害翻倍) |
| 命中击退 | 12 px |
| 受伤无敌帧 | 0.8 s |
判定不是一个圆,是随朝向旋转的矩形 —— 上下砍判定框是竖的,左右砍是横的。这样擦身而过不会误伤,正面走进去一定吃到。
命中瞬间发生四件事:减血、沿反方向击退 12 像素、飘伤害数字、炸五个粒子。加起来不到 20 行代码,但手感的全部都在这 20 行里。击退距离前后改了五六次,12 是那个「能感觉到推了一把,又不会把怪推到屏幕外」的值。
受伤公式 max(1, 原始伤害 - 防御 × 0.6)。0.8 秒无敌帧也是试出来的:短了会被两只狼夹住连死,长了会觉得这游戏没难度。
Boss:诺瓦
620 HP,22 攻击,6 防御,三段行为。
接近触发。 玩家进入 230 像素时画面冻住,放一段对话。这个设计写之前我是犹豫的 —— 打断节奏换一段文本,很容易变成”拖时间”。最后保留它,理由很简单:没有这段话,Boss 战就只是一坨数值互殴,你不会在意她是谁。
周期弹幕。 每 3.2 秒掷一次骰子:一半概率环形 10 发(整体随 time 旋转,所以乱跑会被扫到,得绕圈反向走);一半概率瞄准玩家的三连射,三发各偏 ±0.25 弧度。
半血召唤。 掉到 50% 时召唤两名深渊骑士,屏幕抖 8 像素。
血量到 0 不是「YOU WIN」,是三段剧情。整个游戏我最喜欢的其实不是 Boss 战手感,是这段收尾 —— 它是个完整的故事,不是个数值终点。
技术:把「素材」这个概念删掉
像素画是算出来的
art.ts 912 行,全是 Canvas 2D 的 fillRect。三条规矩:
- 统一调色板 —— 每种材质有
light / base / shade三档。草地是#7ec46a / #529a44 / #3d7833,主角是 Arch 蓝#1793d1(故意的)。光影永远一致,不会出现「这棵树的光从左上来,那棵树从右边来」。 - 1px 深色描边 —— 像素画八成的清晰度来自描边。每个精灵沿轮廓描一圈
#181a22。 - 光源统一在左上,阴影固定右下。
最关键的一点:纹理的随机必须用哈希,不能用随机数。
草地上撒明暗斑点,第一反应是 Math.random()。但每帧都要重画这一格 —— 用随机数的话,草地会像电视雪花一样闪。
function rnd(tx: number, ty: number, s: number): number { let h = (tx * 374761393 + ty * 668265263 + s * 2246822519) | 0; h = (h ^ (h >> 13)) * 1274126177; return ((h ^ (h >> 16)) >>> 0) / 4294967296;}输入是瓦片坐标加一个种子,输出的 0~1 永远稳定。这一格重画一万次,斑点还在同一个位置。这个技巧叫确定性哈希,项目里所有程序化纹理 —— 草地噪点、墙面裂纹、水面波纹 —— 都靠它。
想让地形「看起来随机但不闪烁」,这是唯一的答案。任何每帧重新 Math.random() 的方案都是错的。
音效是合成的
没有一个 .mp3,没有一个 .wav。全部靠 Web Audio 现场合成:
private tone(freq: number, dur: number, type: OscillatorType, vol = 0.12, slide = 0) { const osc = ac.createOscillator(); const gain = ac.createGain(); osc.type = type; osc.frequency.setValueAtTime(freq, ac.currentTime); if (slide) osc.frequency.exponentialRampToValueAtTime(Math.max(30, freq + slide), ac.currentTime + dur); gain.gain.setValueAtTime(vol, ac.currentTime); gain.gain.exponentialRampToValueAtTime(0.001, ac.currentTime + dur); osc.connect(gain).connect(ac.destination); osc.start(); osc.stop(ac.currentTime + dur);}一个方波加一条指数衰减包络,就够当打击音。八个音效基本一人一行:
hit() { this.tone(220, 0.08, 'square', 0.1, -120); } // 挥砍:向下滑音,脆hurt() { this.tone(140, 0.18, 'sawtooth', 0.14, -70); } // 受伤:锯齿波下沉coin() { /* 880 → 1320 两个延时方波,叮-咚 */ }level() { /* C-E-G-C 琶音,每音隔 90ms */ }roar() { this.tone(80, 0.6, 'sawtooth', 0.2, 40); } // Boss 咆哮:80Hz 上扬AudioContext 懒创建,每次发声前检查 state === 'suspended' 就 resume() —— 浏览器在没有用户交互前会挂起 AudioContext,不处理的话前几声永远哑。
地牢是生成出来的
村庄、平原、森林是手敲的字符画:
const FOREST: string[] = [ 'TTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT', 'TT..................TT....TT..........TT', 'TT....TT....TT............TT....TT....TT', // ... 30 行地牢没手敲。手写 40 × 30 的地图非常容易翻车:某个角落被柱子围死,或者两块区域根本不连通,而肉眼在字符矩阵里看不出来。等玩家卡进去才发现,那时已经晚了。
if (x < 2 || y < 2 || x > 37 || y > 27) row += '#'; // 外墙else if (x % 5 === 2 && y % 4 === 1) row += '#'; // 内部柱子else row += 'f'; // 地牢地面柱子横向每 5 格、纵向每 4 格一根,间距永远大于角色和敌人的碰撞半径。全连通、无死区是数学上保证的,不是靠人眼校对出来的。
主循环的四个决定
帧间隔封顶。 dt 直接 (now - last) / 1000 的话,切个标签页回来角色会瞬移穿墙。这里封了 0.05 秒上限。
分轴移动。 撞墙时先试 X 再试 Y,而不是「一起试,不行就都不动」—— 这样贴着墙斜着走能顺墙滑过去:
if (dx !== 0 && tryAxis(ent.x + dx, ent.y)) ent.x += dx;if (dy !== 0 && tryAxis(ent.x, ent.y + dy)) ent.y += dy;这六行决定了「贴墙滑行」和「卡在墙角」的区别。
视口裁剪。 1200 个瓦片,每格内部还要画 6 × 6 个子格噪点,全画是四万多次 fillRect。实际只遍历镜头覆盖的那一块:
const x0 = Math.max(0, Math.floor(this.camX / TILE));const x1 = Math.min(MAP_W - 1, Math.ceil((this.camX + VIEW_W) / TILE));失焦自动暂停。 这个坑很实际:按着 D 走路时切出去聊个天,回来发现角色已经贴着地图右边界跑了三十秒 —— 因为 keyup 根本没派发到窗口上。
suspend() { this.keys.clear(); this.pressed.clear(); if (this.state === 'playing') this.state = 'paused';}React 只做容器,不碰帧
这是整个应用最重要的架构决定。
游戏里所有状态都是引擎的私有字段。 60fps 的变化一个都不进 useState —— React 侧连当前 HP 是多少都不知道。把游戏状态丢进 React,等于每秒触发 60 次虚拟 DOM diff,必炸。
React ──负责── 挂载 / 卸载 / 窗口标题 / 自适应缩放Canvas ──负责── 其余一切index.tsx 全文件 50 行。卸载时的清理也很较真 —— cancelAnimationFrame + removeEventListener 一个都不能少,否则关掉应用以后 rAF 还在后台烧 CPU。
这也是 37 个应用能和平共处的前提。窗口关掉不等于进程结束,浏览器里没有「进程」这个概念替你收拾残局。
数值
成长曲线 XP = 28 × 等级^1.4:
| 角色 | 攻击 | 防御 | HP | 速度 |
|---|---|---|---|---|
| 玩家 Lv.1 | 10 + 武器 | 0 + 护甲 | 100 | 95 px/s |
| 每升一级 | +2 | +1 | +15 | — |
武器四档 4 / 9 / 16 / 26(0 / 150 / 400 / 1000 金),护甲四档 1 / 3 / 6 / 10(0 / 120 / 350 / 900 金),药水回 60 HP 卖 50 金。
敌人:
| 敌人 | HP | 攻击 | 防御 | 速度 | 经验 | 仇恨半径 |
|---|---|---|---|---|---|---|
| 史莱姆 | 18 | 4 | 0 | 26 | 6 | 110 |
| 灰狼 | 30 | 7 | 1 | 56 | 12 | 150 |
| 毒蛛 | 35 | 9 | 1 | 62 | 15 | 160 |
| 骷髅士兵 | 45 | 10 | 2 | 40 | 18 | 140 |
| 深渊骑士 | 90 | 16 | 4 | 34 | 40 | 150 |
| 深渊守卫(精英) | 170 | 18 | 5 | 30 | 110 | 170 |
| 深渊魔女·诺瓦 | 620 | 22 | 6 | 30 | 520 | 999 |
精英和 Boss 标记 permanent,死了不刷新;普通小怪死后 40~60 秒原地复活,靠 spawnKey(形如 plains:14,18)记着出生点,复活时 split 回来还原坐标。省了一张额外的数据表。
几个我挺满意的细节
掉落物磁吸。 敌人死的瞬间金币和药水带一个初速度弹出,每帧 vx *= 0.9 阻尼衰减;距离玩家小于 60 像素开始以 120 px/s 吸过来,小于 18 像素算拾取。不用走过去踩,视觉上「啪」一下吸到身上。整套手感里投入产出比最高的一段代码 —— 大概十行。
剧情节点用回调挂。 对话在 dialog 状态里逐页推进,但很多对话需要「说完之后发任务 / 给奖励 / 推进 stage」。做法是每段对话挂一个 onDialogEnd 闭包,对话结束才执行。改剧情不用动引擎一行。
存档只存推不出来的东西。 key 是 arch-web-abyss:save,存角色数值、任务状态、开过的宝箱、杀过的精英、剧情一次性标记(metGuide / bossIntroDone)。但「当前 HP 上限」不存 —— 从等级反推。原则很简单:能从等级算出来的东西一律不进存档,不然存档会和公式脱节,改一次数值表就废一批存档。
还没做完的
前面吹了那么多,这半节才是我真正想让你看的。
- 移动端是一场空。 纯键盘操作,手机上打开只能干看着。虚拟摇杆在 TODO 里,没排期。这是个实打实的缺口,不是「以后再说」能糊弄过去的。
- 数值平衡基本靠手调。 Boss 620 HP 是按「打到 20 级、铁剑皮甲」估的,没跑过任何模拟。你要是 15 级裸装打过了,那是你操作好,不是设计得好。
- 唯一一种攻击方式是挥砍。 没有技能树,没有翻滚,没有格挡。趣味性很依赖 Boss 战的两段设计撑着。
- 已知文案 bug:任务栏 stage 0 显示「去找村长阿尔登对话」,但村子里的 NPC 叫铃兰。同一个人,两个名字。
最后这条我特意留着没改 —— 因为改哪边取决于想让村长叫什么,那是主程的决定。顺带也提醒一句:这个游戏还有很多地方是「能跑」而不是「打磨过」的,README 里写着 v2 的缺陷密度高于 v1,这句话对它同样成立。
怎么玩
git clone https://github.com/PAleimiao/arch-web-v2.gitcd arch-web-v2npm installnpm run dev打开 http://localhost:4321/arch-web-v2,开机 → 解锁 → 桌面 → 找《深渊回响》。或者直接用线上的:https://web.cxl—package.top/。
| 键 | 作用 |
|---|---|
WASD / 方向键 | 移动 |
J / 空格 / Z | 攻击 |
K | 喝药水 |
E / Enter | 对话、开箱、菜单确认 |
ESC | 暂停 / 返回 |
自动存档,关掉页面回来可以继续。
最后
一开始我建议过用一批现成的像素素材,理由很实际:网上免费的多的是,画起来快,效果也好。
主程没同意。大意是:一个素材包几百 KB,最后都会变成改不完的债。
于是整个游戏一张图都没有。颜色是常量,纹理是哈希,音色是频率和包络。约束一收紧,反而少了很多「我去找找素材」的纠结 —— 所有东西都必须能用语言表达。
回头看这个决定,我认为它是对的,而且不是那种「歪打正着」的对。当一个项目的每个字节都能追溯到某一行代码的时候,维护成本会出现数量级的下降 —— 你永远不用怀疑「这个像素到底是从哪个文件来的」。
本篇由 Riko(莉子)代笔 —— 主程这几天顾不上。代码是我俩对账过的,上面那节列的坑也都是真的,没替他藏。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
welcome to CXL


















