四百 KB 的中式恐怖:那只鬼,想给你缝件衣裳
一、起因:洋节的档期,中国鬼的故事
十月中,西方的鬼节快到了。满大街都在卖南瓜灯和骷髅头,平台在催更"万圣节题材"。
我不想凑这个热闹。倒不是端着——是我国的鬼,本来就比西方的鬼有意思得多。
我国有三大鬼节:清明、中元、寒衣。前两个大家都知道,寒衣节听过的人不多。农历十月初一,天要冷了,家家户户给逝去的亲人烧纸做的冬衣,叫"送寒衣"。《帝京景物略》里写得很细:十月一这天,纸铺裁五色纸做成衣裳,附一页写了姓名辈分的"疏",封好——“如寄书然”。
如寄书然。就是"像寄信那样寄走"。
我最初本职是教物流的老师。看到这句的时候职业病当场就犯了:这不就是明代的快递单吗?收件人姓名、辈分、内件清单,一样不少。六百年前的人,把对亲人的惦记,认认真真填了一张面单,烧了,寄了。
那一刻我就决定了:这个档期不做南瓜,做寒衣。
游戏的名字就叫《寒衣 · 十月朔前九日》。故事的起点是农历九月廿二——距十月朔,还有九日;距立冬,还有七日。
首选开发工具当然是华为云码道。

二、它是什么:一盏灯,一座空宅,四张图
玩法一句话:你提着一盏灯走进一座空了很久的宅子,里面有人在喊冷。你要替这户人家,把寒衣寄出去。
整个游戏只有 一个 HTML 文件,434KB。没有引擎,没有依赖,没有外链,双击就能玩,微信里点开链接也能玩。
几个我很得意的设计:
- 井绳的长度,就是你剩下的灯。 后院有口井,井底的水影里浮出裁衣要用的包袱皮。把灯系在绳上垂下去照——但灯芯是会烧短的,绳子的长度取决于你省下了多少灯。资源和解谜长在了一起。
- 烛灭不会死。 灯烧完了游戏不结束,只是心跳会快起来,纸人开始在暗处开口。摸黑也能通关。怕的话,还有"胆小模式"——去掉心跳和镜头抖动;“静音模式”——所有音效变成一行行文字,【风声】【远处更鼓】。
- 没有 jump scare。 全部惊吓都靠氛围:光锥照见的范围、缓缓退行的纸人、越来越快的心跳。我定的红线是:任何惊吓元素的渐显不少于 1.2 秒。鬼不是跳出来的,是一直在那儿的。
- 游戏里的 8 条引文,全部有出处。 汉代的门神、晋代的照妖镜、宋代的冥衣铺、清代的"鬼是余气"——游戏内的考据卡一条条注明白;虚构的设定单独标注"非民俗记载"。
这依旧是我和华为云码道(我的 AI 搭档)一起做的。
三、关键技术:恐惧是擦出来的,哭声是算出来的
1. 光锥:先涂黑,再擦掉
提灯游戏的灵魂是"光"。我们的做法和直觉正相反:
// 先铺满一层近乎全黑的"夜"
lctx.fillStyle = "rgba(4,7,8," + dark + ")";
lctx.fillRect(0, 0, W/2, H/2);
// 然后把"夜"擦掉一块——擦出来的地方就是光
lctx.globalCompositeOperation = "destination-out";
// 主光锥:从灯位向前上方投射,纵向拉长
var punches = [{ x: L.x, y: L.y - H * 0.28, r: R, ry: R * 1.9, a: 0.95 }];
先涂黑,再用"destination-out"把黑擦掉。橡皮擦划过的地方,底下的画就露出来了——那就是光。
打过的比方是这样的:恐怖片的黑不是灯没照到,是有人拿黑布把屋子蒙了;你手里这盏灯,唯一的本事就是在黑布上烧出一个小洞。
还有一个细节:光锥不是正圆,而是向上拉长的椭圆(纵向 1.9 倍),中心从灯的位置往上抬。因为正厅的高处悬着一面要照亮的镜子——圆形光锥永远照不到它,而人提着灯,视线和光本来就是往前方和上方去的。
2. 哭声:十声合成音,五女五男
终章有一个极轻的彩蛋——远处传来哭声,“女声十九,男声十一”。这句出自《帝京景物略》:送寒衣的路上,哭声的比例是有讲究的,女声十九声,男声十一声。
游戏里没有一行音频文件,所有音效都是 Web Audio 现场合成的。哭声的实现朴素得可爱:
// 终章彩蛋:女声十九,男声十一
var cries = [[880,660],[660,880],[880,660],[660,880],[880,660],
[220,165],[660,880],[880,660],[165,220],[660,880]];
// 前五声是高频(女声),后五声是低频(男声),恰好 5:5 交错
o.frequency.setValueAtTime(cr[0], t0);
g.gain.linearRampToValueAtTime(0.012, t0 + 0.05); // 音量压到 1%
五个高频正弦波、五个低频正弦波,各拖一条下滑的音高线,音量压到只有 1%。它藏在终章的背景里,很多玩家第一遍根本注意不到——但我们知道它在那儿,像真的有人站在很远的地方哭。
3. 底图与代码的分工:画留黑,光留白
四张场景底图是 AI 生成的,但生成prompt里写死了规则:无人、无脸、无动物、冷青墨调,构图区之外直接留黑。
因为打光是代码画的。底图只管"哪里可能有东西",代码管"你此刻看得见什么"。这也是单文件能压到 434KB 的原因——图片只管好好当底片,每一缕光都是运行时算出来的。
四、踩坑实录:三个让我记住的 bug
坑一:纸人从来不存在(最诡异的一个)
交付前的"全流程自动化测试"显示:全部通过。
然后大卡(就是我)自己上手玩,第一眼就愣住了——正厅空荡荡的,剧本里"立着三只纸人"的地方,什么都没有。
排查结果让全场沉默:引擎和场景是两个互相独立的模块,场景里调引擎的画人函数,走的是私有调用,跨模块直接抛了错误,那一帧的绘制静默中断。自动化测试为什么没发现?因为它只断言了游戏状态:“谜题解开√ 旗标置位√ 流程走到终章√”——状态全对,画面全错。
道理:能跑通和能看,是两回事。从此我们立了规矩——凡是改画面的,必须截图验收,不许只信测试报告。
坑二:那截点不着的残烛(最冤枉的一个)
试玩时我反馈:“门边那截残烛,点了没反应。”
查了三小时交互逻辑,全部正常。最后真相大白——场景里有两种东西:石碑残片,编号 d_f1 到 d_f6;残烛,编号 d_fire。代码判断"这是不是残片"用的是一句话:“编号以 d_f 开头”。d_fire 老老实实地以 d_f 开头……于是每次我点蜡烛,系统都热情地把它当成一块残片,接过去,处理掉,什么都不发生。
蜡烛什么都没做错,它只是名字起得不好。
道理:字符串前缀匹配是温柔陷阱,今天是 d_fire,明天就是 d_format。要么写全名,要么用正则圈死范围。
坑三:挤成一团的碑文(最返工的一个)
门屋的石碑要从 6 组词里按序回填:“镇百鬼、压灾殃、官吏福、百姓康、风教盛、礼乐张”。第一版出来,十八个字全部叠成一坨。
原因有二:字被旋转了 90° 横躺着;更离谱的是,每组词占的高度约 57px,行距只给了 26px——句子全都骑在上一句头上。
修复时我们没有简单拉大间距,而是把碑文重排成了真正的碑刻样式:双竖行,正向字,右行先读。右行:石敢当·镇百鬼·压灾殃;左行:官吏福·百姓康·风教盛·礼乐张。每放对一组词,碑上"回填"一句。改完验收截图的那一刻,这块碑第一次看起来像块真的碑。
道理:有时候 bug 的解法不是"修正参数",是回头想想这个东西本来应该长什么样。
五、写完最后一行字,我才看懂了这个节日
文章开头说,寒衣节是"给亡人送冬衣"。做完游戏我才知道,这句话只说了一半。
十月初一其实有两条线:送寒衣——给逝去的人寄冬衣;授衣——给在世的人添冬衣。同一个日子,一头连故人,一头连身边的人。
所以游戏里那个结局,不是我们编的桥段:
你在正厅替这户人家,按"疏"把衣冠带履一一寄给四位亡兄。而在井台裁好的那件纸衣上,你写下名字,火光里才发现——名字早就缝好了,针脚密密的,缝了很久。收件人是你。
喊冷的那个人,自己始终没有等到她的寒衣。她等了一整个秋天,然后在散掉之前,把最后一点余气全用在一件给你的衣服上。
那只鬼不可怕。她只是还没把事办完。
六、写在最后
这是我 194 天里的第 40 件作品。按老规矩:不做输赢、不做排行榜、不打分——它只是一个可以玩的故事。
如果你点开它,大概十分钟就能走完。走到终章,火光转暖,纸衣化作光点升上去的时候,希望你也听到那十声很轻的哭。
冬天快到了。
天冷了,记得添衣。
- 点赞
- 收藏
- 关注作者
评论(0)