跳到正文
Joeplover
项目实战·2026-07-06·约 11 分钟阅读

《卷宗:纸新娘》—— 抖音小游戏开发全记录

中式民俗恐怖 × 警察刑侦点击解谜,全部画面 canvas 程序化绘制,无外部图片/音频依赖。从场景路由、存档系统到八卦锁谜题,记录一个纯客户端小游戏的设计与实现。

Misty Chinese village at dusk with red lanterns, folk horror atmosphere

《卷宗:纸新娘》—— 抖音小游戏开发全记录

项目概览

《卷宗:纸新娘》是一个完全运行在客户端的抖音小游戏,类型是中式民俗恐怖 × 警察刑侦点击解谜。你扮演 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 实现了标准的异步加载模式:

  1. 声明清单 MANIFEST,映射 key 到 assets 目录的图片路径
  2. preloadAll() 在游戏启动时被调用,遍历清单创建 tt.createImage() 加载
  3. drawCover() 和 drawContain() 提供两种缩放策略——cover(填满裁剪)和 contain(完整显示),与 CSS 的 object-fit 概念一致
  4. 加载完成前或失败时,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 震动),让玩家重选。

正确指认后进入结局四幕:

  1. 收网:陈万堂被按倒在供桌前
  2. 真相:1995 年阿秀撞破账目被灭口,此后纸新娘传说成了最好的掩护
  3. 黎明:沈小梅活着被救出地窖,阿秀和春枝的骸骨得以入土
  4. 结案:卷宗 [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 是三个独立的小场景共享一个文件,每种谜题的渲染和交互逻辑完全不同——因为灯笼点击、数字旋钮、八卦选择这三种交互模式差异太大,泛化反而得不偿失。

这个取舍是正确的:对于交互方式相同的场景,用数据驱动减少重复代码;对于交互方式不同的场景,保持各自独立的实现。

项目反思

做得好的

  1. 纯 canvas 渲染:整个游戏没有 DOM 依赖,没有第三方框架,包体极小。所有视觉元素要么是程序化绘制(0 KB),要么是 JPG/PNG 背景素材
  2. 场景路由轻量化:11 行代码的 runtime 单例,配合 main.js 注册 + 注入模式,比用 pub/sub 事件总线更可控
  3. fallback 策略:图片加载失败时自动降级到程序化绘制,保证游戏在任何设备上都能跑
  4. 测试策略:通过 stub 抖音 API,在 Node 中运行冒烟测试,极大降低了调试成本

可以改进的

  1. 没有音频系统:恐怖游戏最核心的音效(脚步、开门、心跳、音效反馈)完全没有实现。后续可以加 tt.createInnerAudioContext()
  2. 探索场景没有过场动画:场景切换是瞬间完成的,没有 fade in/out 或画面撕裂过渡,体验上有点生硬
  3. 接触判定区不够灵活:热点交互检测用的是简单的圆形距离判断(dist < 30),如果后续场景变多、交互点密集,应该改用矩形区域或多边形碰撞检测
  4. 没有服务端:纯单机游戏,没有线上排行、成就系统或多端存档同步——但作为抖音小游戏的 MVP,这个范围是对的

总结

《卷宗:纸新娘》是一个小而完整的抖音小游戏项目,从技术角度看,它在极小的代码量内实现了场景路由、状态管理、程序化美术、谜题系统和剧情叙事。从设计角度看,三条线索链(灯笼→日记+照片、生辰→账本、照片背面八卦→血符铜盘)交织成网,在最后一幕——祠堂指认中收束,形成了完整的「收集→推理→指认」体验闭环。

项目的核心价值不在用了多牛的技术,而在于用最朴素的原生 JavaScript,在抖音小游戏的平台限制下,写出了结构清晰、可直接上线的完整游戏。