四百 KB 的中式恐怖:那只鬼,想给你缝件衣裳

举报
爱学习的多宝 发表于 2026/10/31 11:52:42 2026/10/31
【摘要】 一、起因:洋节的档期,中国鬼的故事十月中,西方的鬼节快到了。满大街都在卖南瓜灯和骷髅头,平台在催更"万圣节题材"。我不想凑这个热闹。倒不是端着——是我国的鬼,本来就比西方的鬼有意思得多。我国有三大鬼节:清明、中元、寒衣。前两个大家都知道,寒衣节听过的人不多。农历十月初一,天要冷了,家家户户给逝去的亲人烧纸做的冬衣,叫"送寒衣"。《帝京景物略》里写得很细:十月一这天,纸铺裁五色纸做成衣裳,附...

一、起因:洋节的档期,中国鬼的故事

十月中,西方的鬼节快到了。满大街都在卖南瓜灯和骷髅头,平台在催更"万圣节题材"。

我不想凑这个热闹。倒不是端着——是我国的鬼,本来就比西方的鬼有意思得多。

我国有三大鬼节:清明、中元、寒衣。前两个大家都知道,寒衣节听过的人不多。农历十月初一,天要冷了,家家户户给逝去的亲人烧纸做的冬衣,叫"送寒衣"。《帝京景物略》里写得很细:十月一这天,纸铺裁五色纸做成衣裳,附一页写了姓名辈分的"疏",封好——“如寄书然”。

如寄书然。就是"像寄信那样寄走"。

我最初本职是教物流的老师。看到这句的时候职业病当场就犯了:这不就是明代的快递单吗?收件人姓名、辈分、内件清单,一样不少。六百年前的人,把对亲人的惦记,认认真真填了一张面单,烧了,寄了。

那一刻我就决定了:这个档期不做南瓜,做寒衣。

游戏的名字就叫《寒衣 · 十月朔前九日》。故事的起点是农历九月廿二——距十月朔,还有九日;距立冬,还有七日。

首选开发工具当然是华为云码道。

二、它是什么:一盏灯,一座空宅,四张图

玩法一句话:你提着一盏灯走进一座空了很久的宅子,里面有人在喊冷。你要替这户人家,把寒衣寄出去。

整个游戏只有 一个 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 件作品。按老规矩:不做输赢、不做排行榜、不打分——它只是一个可以玩的故事。

如果你点开它,大概十分钟就能走完。走到终章,火光转暖,纸衣化作光点升上去的时候,希望你也听到那十声很轻的哭。

冬天快到了。

天冷了,记得添衣。


【版权声明】本文为华为云社区用户原创内容,未经允许不得转载,如需转载请自行联系原作者进行授权。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。