《卷宗:纸新娘》—— 抖音小游戏开发全记录
中式民俗恐怖 × 警察刑侦点击解谜,全部画面 canvas 程序化绘制,无外部图片/音频依赖。从场景路由、存档系统到八卦锁谜题,记录一个纯客户端小游戏的设计与实现。
《卷宗:纸新娘》—— 抖音小游戏开发全记录
项目概览
《卷宗:纸新娘》是一个完全运行在客户端的抖音小游戏,类型是中式民俗恐怖 × 警察刑侦点击解谜。你扮演 1998 年兴安县公安局的刑警,奉命进入陈家村,调查「纸新娘娶亲」冥婚失踪案——阅读卷宗、走访场景、搜集证物、破解机关、当众指认真凶。
全部画面为 canvas 程序化绘制,外部图片仅用于人物立绘和场景背景(JPG/PNG),无任何音频依赖,可直接在抖音开发者工具中编译预览。
项目地址:F:\Douyin_workspace\DiSs\
工程结构
整个游戏没有使用任何第三方框架,纯原生 JavaScript 手写,基于抖音小游戏的 tt API 开发。
game.js 入口
game.json 竖屏配置
js/
env.js 画布 dpr 适配,设计坐标 375px 宽
runtime.js 场景路由单例
state.js 游戏状态 + tt.setStorageSync 存档
data.js 卷宗/证物/嫌疑人/谜题答案数据
images.js 图片资源异步加载 + 按需绘制
art.js 程序化美术库(灯笼/纸人/棺材/符纸/雾气)
ui.js HUD + 对话弹层 + 证物栏 + Toast
main.js 游戏循环 + 触摸分发 + 场景挂载
scenes/
title.js 标题画面
dossier.js 卷宗阅读(首读/回看两种模式)
explore.js 探索引擎(村口/灵堂/闺房/纸扎铺/义庄 五场景)
puzzles.js 灯笼谜题 / 数字锁 / 八卦锁
deduction.js 指认系统(凶手×关键证据 双选)
ending.js 结局四幕 + 重开
技术实现细节
1. 场景路由系统
核心是一个超轻量的路由单例 runtime.js,只有 11 行代码:
module.exports = {
go: function () {}, // (sceneName, params) — main.js 启动时注入
prev: '', // 上一个场景名
time: 0, // 全局时间(秒),main 每帧更新
vibrate: function () {
try { tt.vibrateShort(); } catch (e) {}
},
};
场景注册在 main.js 中完成:register(require('./scenes/title')),每个场景暴露 enter()、render()、update()、onTap()、hud 等生命周期方法。runtime.go() 切换场景时自动保存上一个场景名(供返回用),并调用新场景的 enter()。
游戏主循环 (loop) 每帧调用当前场景的 update → 清屏 → render → HUD → 弹层,使用 requestAnimationFrame 驱动,在不支持的模拟环境下降级为 setTimeout(fn, 16)。
2. Canvas 程序化美术
这是项目最独特的地方——大量美术元素直接写在 art.js 中,用 canvas API 绘制,无需外部素材:
- 雾气粒子系统:预生成一组烟雾粒子(坐标、半径、速度、透明度),每帧更新位置并循环滚动,实现晨雾弥漫的效果
- 暗角 (vignette):径向渐变从中心透明到边缘黑色,压暗四周,聚焦视线
- 小人绘制:通过路径组合(身体多边形+头圆形+头发弧形)绘制程序化 NPC,支持外套颜色、驼背、精灵图覆盖
- 灯笼绘制:椭圆灯笼身 + 金色骨架 + 底部流苏 + 发光辉光
- 纸人绘制:苍白面孔 + 圆形腮红 + 微笑嘴角 = 诡异效果
- 棺材/符纸/八卦符号:全部用 canvas 几何路径绘制
// 雾气粒子系统——预生成,运行时只更新位置
function makeFog(n, W, H) {
var arr = [];
for (var i = 0; i < n; i++) {
arr.push({
x: (i * 97) % W,
y: H * 0.3 + ((i * 61) % (H * 0.6)),
r: 40 + ((i * 37) % 60),
v: 4 + ((i * 13) % 10),
a: 0.04 + ((i * 7) % 10) * 0.006,
});
}
return arr;
}
这种做法的好处:包体极小、加载无等待、风格统一。代价是美术细节受限于代码——太复杂的油画质感画不出来,适合像素风或扁平中式美学。
3. 图像加载策略
虽然程序化美术够用,但标题背景、场景背景、人物立绘还是用了外部图片,提升沉浸感。
images.js 实现了标准的异步加载模式:
- 声明清单
MANIFEST,映射 key 到 assets 目录的图片路径 preloadAll()在游戏启动时被调用,遍历清单创建tt.createImage()加载drawCover()和drawContain()提供两种缩放策略——cover(填满裁剪)和 contain(完整显示),与 CSS 的object-fit概念一致- 加载完成前或失败时,fallback 到程序化绘制的抽象背景——保证游戏在任何状态下都能运行
每个场景在 render() 中先尝试 images.drawCover(),返回 false 时走程序化 fallback:
var hasBg = images.drawCover(ctx, 'bg_village', 0, 0, W, H);
if (!hasBg) {
// 程序化 fallback:深灰渐变背景
var g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, '#0b0806');
g.addColorStop(1, '#171009');
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
这对于在小游戏低端机上加载慢的场景非常重要——用户不会看到白屏。
4. 存档系统
state.js 使用 tt.setStorageSync / tt.getStorageSync 实现本地持久化:
var KEY = 'cis_paper_bride_v1';
var state = {
flags: {}, // 剧情触发标记(如 "看了日记"、"开了义庄后门")
evidence: [], // 已获证物 id 列表
started: false,
};
存档 key 加了版本号 v1,后续更新游戏版本时只需改 key 后缀即可重置存档,防止旧存档在新版本中损坏。这个设计细节看起来简单,但在实际迭代中极其重要——比很多团队直接用固定 key 存 JSON 要严谨。
5. 探索引擎
explore.js 是整个游戏体量最大的文件(637 行),采用数据驱动设计:
每个场景 = 一个配置对象:{ bg: 'bg_village', hotspots: [...], exits: [...], onEnter, onTap, render }
- 热点 (hotspots):场景中可交互的光点,带有脉冲动画(
0.6 + Math.sin(t * 3) * 0.2呼吸效果),点击触发剧情推进或获得证物 - 出口 (exits):带方向指示的圆形传送门,点击后
runtime.go('explore', { scene: 'next' })切换子场景 - 状态驱动:每个热点都有一个
requires或condition,只有满足特定 flag 后才出现或改变行为
五个可探索场景:村口 → 灵堂 → 闺房 → 纸扎铺 → 义庄。场景切换通过数据对象完成,所有场景共享同一个 explore 场景实例,只是切换内部状态——避免了多场景实例的内存浪费。
6. 三个谜题
谜题系统在 puzzles.js 中实现,三种完全不同风格的锁:
① 灯笼顺序谜题(闺房) 展示顺序打乱为 红·黄·白·青,正确点亮顺序是 白·青·红·黄。提示藏在灵堂香炉里的童谣纸条上:
「一盏白,二盏青,三盏红来照新人,最后一盏黄泉引。」 点击灯笼切换亮/灭状态,按顺序点亮四个才解锁。灯笼带有蜡烛火焰动画 + 抖动效果(
Math.sin(t * 60) * 4),点错时全灭并震动。解谜后会发现日记(陈万堂贪污修庙款的记录)+ 半张照片。
② 数字密码锁(纸扎铺) 3 位旋钮数字锁,答案 0814 —— 来自卷宗里沈小梅的生辰「八月十四」。提示文本没有显式写出数字,而是藏在卷宗第三页的「现场勘查备注」中:「义庄门前贴有冥婚帖……帖上生辰:丙辰年 八月十四 生人」。玩家必须仔细阅读卷宗才能联想到密码。这强迫玩家在场景间来回切换——卷宗系统不只是叙事背景,它兼作玩法提示的索引。
破解后获得香火账本——陈万堂贪污罪证之一。
③ 八卦铜盘锁(义庄主棺) 8 个可选八卦符号,选出 4 位正确顺序:坎·离·震·兑。提示在拼合照片背面。这个谜题最巧妙的地方在于:要拿到提示(合影照片),玩家必须先完成另外两条线索链——灵堂拿半张 + 闺房拿半张,两者缺一不可。
换句话说,八卦锁是关卡收敛点——玩家走完前面所有分支后才能解开,天然形成了一个「收集 → 合成 → 解锁」的进度闭环。
7. 指认与结局系统
集齐全部 6 件证物后,地窖门解锁,进入指认场景 (deduction.js)——玩家必须同时选定凶手和关键证据。
四个嫌疑人各有反驳台词:
- 吴老倌:「不识字、腿脚不便,抬不动活人」
- 周远:「案发当夜在县城监考,有考勤簿可证」
- 沈王氏:「进不了义庄、调不动祭祀,没这个能量」
- 陈万堂:唯一没有人替他说话的人——指向性已经很明显了
关键证据是香火账本 + 带血的符纸(二者的私章一致),缺一不可。选错证据或选错凶手,系统会给出推理不完整的提示(并 toast 震动),让玩家重选。
正确指认后进入结局四幕:
- 收网:陈万堂被按倒在供桌前
- 真相:1995 年阿秀撞破账目被灭口,此后纸新娘传说成了最好的掩护
- 黎明:沈小梅活着被救出地窖,阿秀和春枝的骸骨得以入土
- 结案:卷宗 [1998] 第 047 号,侦查终结——「世上本没有鬼。装神弄鬼的,从来都是人。」
结局页面的视觉从冷色渐变到暖色,象征黎明破晓,代码中用 blend() 函数在帧循环中插值颜色。
8. 冒烟测试
为了可以脱离抖音开发工具进行调试,项目在根目录下准备了 smoke_test.js:
// stub 抖音小游戏环境
global.tt = {
getSystemInfoSync: function () { return { windowWidth: 375, windowHeight: 667, pixelRatio: 1 }; },
createCanvas: function () { return { width: 0, height: 0, getContext: makeCtx }; },
createImage: function () { return {}; },
setStorageSync: function (k, v) { storage[k] = v; },
getStorageSync: function (k) { return storage[k]; },
// ... 其他 stub
};
这个 stub 模拟了抖音小游戏环境的核心 API,使得 node smoke_test.js 可以在 Node.js 中驱动完整的自动化通关流程——从点击标题到走完所有场景、解谜、指认、通关,每一步都有 assert 验证。这在开发调试中极大提高了迭代效率,不用每次改代码都要打开模拟器、手动点一遍游戏。
数据流与设计模式
全局状态机
整个游戏的状态流动:
用户触摸 → main.onTouchStart → 坐标转换 → ui.handleOverlayTap (弹层优先)
→ 弹层处理 → 更新 state/evidence/flags
→ 无弹层 → cur.onTap 场景自定义逻辑
→ 更新 state → state.save() 自动写 tt.setStorageSync
每帧循环 → cur.update(dt) → 清屏 → cur.render(ctx, t)
→ ui.renderHUD (右上角卷宗按钮 + 底部证物栏)
→ ui.renderOverlays (对话弹层/证物详情/Toast)
数据驱动 vs 硬编码
explore 场景是所有场景中泛化程度最高的:五个子场景通过同一套热点/出口数据结构渲染,每个子场景只是一个数据配置,不需要单独的场景文件。
相比之下,puzzles 是三个独立的小场景共享一个文件,每种谜题的渲染和交互逻辑完全不同——因为灯笼点击、数字旋钮、八卦选择这三种交互模式差异太大,泛化反而得不偿失。
这个取舍是正确的:对于交互方式相同的场景,用数据驱动减少重复代码;对于交互方式不同的场景,保持各自独立的实现。
项目反思
做得好的
- 纯 canvas 渲染:整个游戏没有 DOM 依赖,没有第三方框架,包体极小。所有视觉元素要么是程序化绘制(0 KB),要么是 JPG/PNG 背景素材
- 场景路由轻量化:11 行代码的 runtime 单例,配合 main.js 注册 + 注入模式,比用 pub/sub 事件总线更可控
- fallback 策略:图片加载失败时自动降级到程序化绘制,保证游戏在任何设备上都能跑
- 测试策略:通过 stub 抖音 API,在 Node 中运行冒烟测试,极大降低了调试成本
可以改进的
- 没有音频系统:恐怖游戏最核心的音效(脚步、开门、心跳、音效反馈)完全没有实现。后续可以加
tt.createInnerAudioContext() - 探索场景没有过场动画:场景切换是瞬间完成的,没有 fade in/out 或画面撕裂过渡,体验上有点生硬
- 接触判定区不够灵活:热点交互检测用的是简单的圆形距离判断(
dist < 30),如果后续场景变多、交互点密集,应该改用矩形区域或多边形碰撞检测 - 没有服务端:纯单机游戏,没有线上排行、成就系统或多端存档同步——但作为抖音小游戏的 MVP,这个范围是对的
总结
《卷宗:纸新娘》是一个小而完整的抖音小游戏项目,从技术角度看,它在极小的代码量内实现了场景路由、状态管理、程序化美术、谜题系统和剧情叙事。从设计角度看,三条线索链(灯笼→日记+照片、生辰→账本、照片背面八卦→血符铜盘)交织成网,在最后一幕——祠堂指认中收束,形成了完整的「收集→推理→指认」体验闭环。
项目的核心价值不在用了多牛的技术,而在于用最朴素的原生 JavaScript,在抖音小游戏的平台限制下,写出了结构清晰、可直接上线的完整游戏。