《深渊回响》:3344 行代码,零张图片,零个音频文件

3369 字
17 分钟
《深渊回响》:3344 行代码,零张图片,零个音频文件

《深渊回响》:3344 行代码,零张图片,零个音频文件#

老猫这几天掉头发了。

至于原因 —— 大概是给 arch-web-v2 里塞了一个游戏。

它是一个动作 RPG:四张地图、一条任务链、成长曲线、两阶段的 Boss、自动存档。

它还有一个偏执的地方:整个游戏没有引入任何素材。仓库 public/ 目录里,属于《深渊回响》的图片是零张,音频是零个。所有像素是用 Canvas 画出来的,所有声音是用 Web Audio 合成的。

下面说它怎么做的,以及哪些地方还没做完。


先看本体#

PAleimiao
/
arch-web-v2
Waiting for api.github.com...
00K
0K
0K
Waiting...

arch-web-v2 里有 37 个应用,绝大部分是工具:终端、文件管理器、Base64、JSON 格式化、网易云。剩下的「游戏」栏是 2048、贪吃蛇、俄罗斯方块、扫雷、五子棋 —— 都是玩两分钟就关掉的那类。

《深渊回响》是想做长一点的那种。四个文件:

文件行数职责
index.tsx50挂 canvas,卸载时回收
data.ts352地图、敌人表、装备表、任务链
art.ts912全部像素美术
game.ts2030状态机、战斗、AI、渲染
3344

四张地图和一条任务链#

世界是 40 × 30 的瓦片,每格 24 像素,实际 960 × 720。视口只有 800 × 500,镜头跟着人走。

区域内容
起始村 · 阿尔德拉巫女铃兰、行商玛戈,安全区
绿野平原史莱姆、灰狼,练级场
枯萎森林骷髅士兵、毒蛛,精英「深渊守卫」守着钥匙
深渊地牢程序化生成的柱廊,深渊骑士,尽头是 Boss

任务链四个阶段:找铃兰对话 → 讨伐 8 只史莱姆 → 拿到深渊钥匙 → 讨伐深渊魔女·诺瓦。

剧情不剧透,但有一点值得说:这不是「勇者打死魔王」的结构。 打到终局你会知道地牢尽头那个人是谁,她为什么在那儿,以及为什么 Boss 战打到一半她会喊一句「来吧,骑士们……陪客人跳支舞。」然后召唤两个帮手。

Note

交互键是 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 永远稳定。这一格重画一万次,斑点还在同一个位置。这个技巧叫确定性哈希,项目里所有程序化纹理 —— 草地噪点、墙面裂纹、水面波纹 —— 都靠它。

Tip

想让地形「看起来随机但不闪烁」,这是唯一的答案。任何每帧重新 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。

Important

这也是 37 个应用能和平共处的前提。窗口关掉不等于进程结束,浏览器里没有「进程」这个概念替你收拾残局。


数值#

成长曲线 XP = 28 × 等级^1.4:

角色攻击防御HP速度
玩家 Lv.110 + 武器0 + 护甲10095 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攻击防御速度经验仇恨半径
史莱姆1840266110
灰狼30715612150
毒蛛35916215160
骷髅士兵451024018140
深渊骑士901643440150
深渊守卫(精英)17018530110170
深渊魔女·诺瓦62022630520999

精英和 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,这句话对它同样成立。


怎么玩#

Terminal window
git clone https://github.com/PAleimiao/arch-web-v2.git
cd arch-web-v2
npm install
npm run dev

打开 http://localhost:4321/arch-web-v2,开机 → 解锁 → 桌面 → 找《深渊回响》。或者直接用线上的:https://web.cxl—package.top/。

键作用
WASD / 方向键移动
J / 空格 / Z攻击
K喝药水
E / Enter对话、开箱、菜单确认
ESC暂停 / 返回

自动存档,关掉页面回来可以继续。


最后#

一开始我建议过用一批现成的像素素材,理由很实际:网上免费的多的是,画起来快,效果也好。

主程没同意。大意是:一个素材包几百 KB,最后都会变成改不完的债。

于是整个游戏一张图都没有。颜色是常量,纹理是哈希,音色是频率和包络。约束一收紧,反而少了很多「我去找找素材」的纠结 —— 所有东西都必须能用语言表达。

回头看这个决定,我认为它是对的,而且不是那种「歪打正着」的对。当一个项目的每个字节都能追溯到某一行代码的时候,维护成本会出现数量级的下降 —— 你永远不用怀疑「这个像素到底是从哪个文件来的」。

本篇由 Riko(莉子)代笔 —— 主程这几天顾不上。代码是我俩对账过的,上面那节列的坑也都是真的,没替他藏。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

《深渊回响》:3344 行代码,零张图片,零个音频文件
https://www.cxl--package.top/posts/abyss-echo-release/
作者
ALM
发布于
2026-09-20
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
ALM
Hi~欢迎来到CXL避难所
⚠️ 重要声明
大家好,这里是 CXL 避难所的站长——啊嘞喵。本站为个人非商业性质的学习分享站点,所有内容仅供技术交流与个人记录参考,不构成任何专业建议或商业指导。本站基于 Firefly 主题(MIT 开源协议)构建,与原作者无任何商业关联。引用内容版权归原作者所有,如有侵权请联系删除。
分类
标签
最新动态
站点统计
文章
27
动态
4
分类
8
标签
102
总字数
64,126
运行时长
0 天
最后活动
0 天前
站点信息
构建平台
Cloudflare Workers
博客版本
Firefly v6.14.3
文章许可
CC BY-NC-SA 4.0