中秋猜灯谜:基于华为云码道零依赖纯 Canvas 手写一个灯会小游戏
项目地址:https://atomgit.com/sdf56g99988/mid-autumn-huadeng-riddle
运行方式:双击
index.html,离线即玩,无需安装、无需构建、无需联网。
中秋少不了猜灯谜。这篇文章记录一个「中秋灯会 · 猜灯谜」单页小游戏的完整实现------纯手写 HTML + CSS + JavaScript(Canvas 2D),零依赖、零构建、零后端、零外链 ,所有视觉(灯笼、夜空、明月、烟花)全部由代码绘制,不加载任何一张图片、一个字体文件、一个 CDN 资源。

先看成品的灯会现场:

夜空渐变底色、漫天闪烁繁星、右上角一轮带月晕的明月,下面是 24 盏红灯笼排成几排,在夜风里轻轻摆动、灯芯火光摇曳。每一盏灯笼背后都挂着一道灯谜。
一、玩法:一场能玩的灯会
整个游戏就是一个「逛灯会、猜灯谜」的闭环:
- 灯会场景:Canvas 绘制夜空 + 明月 + 繁星 + 24 盏悬挂灯笼,灯笼带摆动与火光摇曳动画。
- 点灯笼出题:点击任意一盏灯笼,对应的灯谜以「卷轴」形式从上方垂落展开(带展开动画)。
- 答题 :在卷轴输入框写下谜底,回车或点「提交」。
- 答对:该灯笼「点亮变金」+ 金光晕 + 烟花粒子特效,显示解析(谜底的扣合逻辑),计入得分。
- 答错:灯笼轻晃 + 提示「再想想」,不扣分,可重试。
- 提示:同一题连续答错 3 次后「提示」按钮解锁,每用一次消耗一次全局提示机会,按「由弱到强」渐进出示 1~3 条提示。
- 计分:每题满分 100,按用时阶梯给分(≤10s 满分 / ≤30s 八成 / ≤60s 六成 / >60s 四成),再乘以提示扣分(每用一次提示扣两成)。
- 通关:24 题全部答完弹出「灯会通关」结算面板------总分、用时、答对数、评级(灯谜状元 / 榜眼 / 探花 / 再接再厉)+ 星级。
点开灯笼,卷轴垂落展开的样子:

答对一题,灯笼点亮、烟花绽放、解析展示:

答对后回到灯会,那盏灯笼已经变成金色:

二、灯谜引擎:把「判定」做成纯函数
这个小游戏最值得讲的不是画面,而是引擎与界面彻底解耦的设计。整个游戏拆成三层:
题库(riddles.js) → 只提供数据,零逻辑
引擎(engine.js) → 归一化 + 判定 + 状态演算 + 计分,纯函数,无 DOM
渲染(main.js) → Canvas 渲染与交互,只读状态
题库和引擎都用 UMD 包装(兼容 CommonJS / AMD / 全局挂载),因此同一份 engine.js 既能在浏览器里 <script> 直接引入跑游戏,也能在 Node 下 require 跑单元测试------这是它能做到「file:// 双击即玩」又能「npm test 全绿」的关键。
1. 答案归一化:让玩家怎么答都不算「冤」
玩家输入谜底的方式千奇百怪:可能带空格、用全角、写繁体、大小写混着来。如果直接 === 比对,「月餅」和「月饼」就会判错,体验很差。引擎的 normalizeAnswer 做四步归一:
js
function normalizeAnswer(input) {
if (input === null || input === undefined) return '';
var s = String(input);
s = toHalfWidth(s); // 全角 → 半角(含全角空格)
s = trimAll(s); // 去掉所有空白
s = t2s(s); // 常见繁体 → 简体(白名单)
s = s.toLowerCase(); // 英文 / 拼音大小写容错
return s;
}
其中「全角转半角」利用的是 Unicode 的编码规律:全角字符 !~~(U+FF01--U+FF5E)与对应半角字符的码点恰好相差 0xFEE0,所以一个 charCodeAt 偏移就能批量转;全角空格(U+3000)单独处理:
js
function toHalfWidth(s) {
return String(s)
.replace(/[\uFF01-\uFF5E]/g, function (ch) {
return String.fromCharCode(ch.charCodeAt(0) - 0xFEE0);
})
.replace(/\u3000/g, ' '); // 全角空格 → 半角空格
}
function trimAll(s) {
return String(s).replace(/\s+/g, '');
}
繁简转换不引入任何库,用一张「常见字白名单」映射表逐字替换------只收录谜底里真正会出现的那批字(餅→饼、燈→灯、籠→笼、煙→烟、圓→圆、團→团......),够用就好,避免为了一张全量对照表拖进几百 KB 数据:
js
var T2S = {
'餅': '饼', '燈': '灯', '籠': '笼', '煙': '烟', '吳': '吴', '剛': '刚',
'圓': '圆', '裡': '里', '個': '个', '們': '们', '這': '这', '對': '对',
'為': '为', '題': '题', '節': '节', '樂': '乐', '團': '团', '滿': '满',
// ...只收谜底相关字
};
function t2s(s) {
return String(s).split('').map(function (c) {
return T2S[c] || c;
}).join('');
}
判定时先与标准答案精确比对,再遍历每题可配置的 aliases 同义/俗称白名单。关键在于:判定结果带着原因 返回 { correct, reason },reason ∈ exact | alias | wrong | empty,UI 可以据此给出不同的反馈文案(比如 alias 命中时提示「也算对,标准写法是......」):
js
function checkAnswer(input, riddle) {
var norm = normalizeAnswer(input);
if (!norm) return { correct: false, reason: 'empty' };
if (norm === normalizeAnswer(riddle.answer)) {
return { correct: true, reason: 'exact' };
}
var aliases = riddle.aliases || [];
for (var i = 0; i < aliases.length; i++) {
if (norm === normalizeAnswer(aliases[i])) {
return { correct: true, reason: 'alias' };
}
}
return { correct: false, reason: 'wrong' };
}
注意 aliases 在比对前也要过一遍 normalizeAnswer------否则白名单里写「月饼」、玩家输入「月餅」时,归一化只对玩家输入生效,两边还是对不上。这是一个容易漏掉的细节。
2. 状态演算:不可变更新,渲染层只读
游戏状态(每盏灯笼是否已答对、尝试了几次、用了几条提示、总分、起止时间)全部放在一个纯数据对象里,所有变更都通过引擎的纯函数返回新状态 ,而不是原地修改。submitAnswer 是整个引擎的核心,看它一个函数就能看懂全部设计:
js
function submitAnswer(state, riddle, input, now) {
var next = cloneState(state); // ① 先克隆,不可变更新
var l = findLantern(next, riddle.id);
if (!l) return { state: next, result: { correct: false, reason: 'notfound', /*...*/ } };
if (l.solved) return { state: next, result: { correct: true, reason: 'solved', /*...*/ } };
var t = typeof now === 'number' ? now : Date.now(); // ② 时间可注入,便于单测
l.attempts += 1;
var check = checkAnswer(input, riddle);
if (check.correct) {
l.solved = true;
var timeMs = l.openTime != null ? Math.max(0, t - l.openTime) : 0;
l.score = scoreAnswer(timeMs, l.hintsUsed); // ③ 用时越短、提示越少,分越高
next.totalScore += l.score;
if (next.lanterns.every(function (x) { return x.solved; })) { // ④ 全部答完→通关
next.finished = true;
next.endTime = t;
next.currentId = null;
}
}
return { state: next, result: { correct: check.correct, reason: check.reason,
score: l.solved ? l.score : 0, attempts: l.attempts, finished: next.finished } };
}
几个设计点:
- 不可变更新 :先
cloneState再改副本,渲染层拿到的state永远是只读快照,可预测、可回放、可单测。 - 时间可注入 :
now允许从外部传入,单测里可以精确控制「用时」,验证计分阶梯,而不必真的去 sleep。 - 重复提交防护 :已答对的题再提交直接返回
reason: 'solved',不会重复加分。 - 通关检测内聚 :
every(solved)就地在引擎里判断,UI 只读finished标志。
createGameState / openRiddle / submitAnswer / requestHint / summarize 这一组函数覆盖了从开局到结算的全部状态流转。
3. 计分与评级
计分公式 score = round(100 × 时长系数 × 提示系数)------答得越快、用提示越少,分越高。时长系数按阶梯取,提示每用一次扣两成、扣到零为止:
js
function scoreAnswer(timeMs, hintsUsed) {
var base = 100;
var sec = (timeMs || 0) / 1000;
var timeFactor;
if (sec <= 10) timeFactor = 1.0;
else if (sec <= 30) timeFactor = 0.8;
else if (sec <= 60) timeFactor = 0.6;
else timeFactor = 0.4;
var hintFactor = Math.max(0, 1 - (hintsUsed || 0) * 0.2);
return Math.round(base * timeFactor * hintFactor);
}
评级看「答对比例」和「得分比例」两个维度:全对且得分 ≥90% 才够格「灯谜状元」(3 星),光蒙对但拖太久、提示用光也拿不到满分评级:
js
function gradeResult(totalScore, maxScore, correctCount, totalCount) {
var ratio = totalCount > 0 ? correctCount / totalCount : 0;
var scoreRatio = maxScore > 0 ? totalScore / maxScore : 0;
if (ratio >= 1 && scoreRatio >= 0.9) return { title: '灯谜状元', stars: 3 };
if (ratio >= 0.8) return { title: '灯谜榜眼', stars: 2 };
if (ratio >= 0.6) return { title: '灯谜探花', stars: 1 };
return { title: '再接再厉', stars: 0 };
}
4. 提示门槛:把「解锁条件」收进引擎
提示不是想用就用------要连续答错 3 次才解锁,且消耗全局提示机会。这个门槛逻辑也放在引擎里,用 canRequestHint 单独判断,UI 只负责拿结果去 disable 按钮:
js
function canRequestHint(state, riddle) {
var l = findLantern(state, riddle.id);
if (!l || l.solved) return false; // 已答对不需提示
if (l.attempts < 3) return false; // 连错 3 次才解锁
if (state.hintsRemaining <= 0) return false; // 全局机会耗尽
var hints = riddle.hints || [];
if (l.hintsUsed >= hints.length) return false; // 该题提示已用完
return true;
}
把「为什么不能用提示」拆成四个明确的分支,单测可以逐条断言;requestHint 里再返回 reason ∈ need-more-attempts | no-hint-left | no-more-hint | ok,UI 据此给出精准提示文案,而不是一句笼统的「暂不可用」。
5. 题库:数据驱动,扩充零成本
内置 24 道原创灯谜(字谜 9 / 成语谜 6 / 中秋意象谜 9),每题是一个结构化对象:
js
{
id: 'q01',
category: '字谜', // 谜面分类
answerType: '字', // 谜底类型:字 / 成语 / 事物
question: '一月一日非今天', // 谜面
answer: '明', // 谜底
aliases: [], // 同义/俗称白名单
analysis: '"日"与"月"相合为"明"......', // 解析(扣合逻辑)
hints: ['...', '...', '...'] // 1~3 条渐进提示,由弱到强
}
想加题?往 riddles 数组里追加一个同结构对象即可,引擎和 UI 一行都不用改。
三、Canvas 渲染:手写每一帧
画面没有任何素材,全是 Canvas 2D 一笔一画画出来的:
- 夜空:纵向渐变底色 + 随机分布、各自以不同频率闪烁的繁星。
- 明月:月晕(径向渐变光晕)+ 月体 + 暗斑。
- 灯笼:悬挂线、顶盖、椭圆主体、竖纹、灯芯火光(摇曳)、流苏、谜字标记;每盏灯有独立的摆动相位与速度,答对后叠加金光晕。
- 烟花:答对瞬间从灯笼处炸开 72 个粒子,带重力下落与淡出。
- 卷轴 :木质轴头 + 纸身,展开用
scale + translateY动画。 - 高 DPI 适配 :按
devicePixelRatio缩放画布,Retina 屏不模糊;布局随窗口响应式重排,也支持触摸点灯笼。
主循环是标准的 requestAnimationFrame 驱动,update(dt) 推进物理(摆动、火光、烟花粒子),render(t) 重绘每一帧。dt 做了封顶(Math.min(60, ...)),防止切后台再切回来时物理量暴跳:
js
function loop(t) {
if (!lastFrame) lastFrame = t;
var dt = Math.min(60, t - lastFrame); // 封顶,避免切后台后 dt 过大
lastFrame = t;
update(dt, t);
render(t);
requestAnimationFrame(loop);
}
1. 高 DPI 适配:一个 setTransform 解决模糊
Retina 屏上 Canvas 默认会糊。解法是把画布像素尺寸放大 devicePixelRatio 倍,再用 setTransform 把坐标系缩回去------之后所有绘制代码照常按 CSS 像素写,不用管物理像素:
js
function resize() {
dpr = window.devicePixelRatio || 1;
W = window.innerWidth;
H = window.innerHeight;
canvas.width = Math.floor(W * dpr); // 物理像素放大
canvas.height = Math.floor(H * dpr);
canvas.style.width = W + 'px'; // CSS 尺寸不变
canvas.style.height = H + 'px';
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 坐标系缩放,之后按 CSS 像素绘制
layoutStars();
layoutMoon();
if (state) layoutLanterns();
}
2. 画一盏灯笼:摆动 + 摇曳 + 点亮
一盏灯笼由悬挂线、顶盖、椭圆灯身、竖纹、灯芯火光、流苏、谜字拼成,全部即时绘制。核心是两个随时间变化的量------摆动 (正弦)和火光摇曳(正弦扰动半径)。答对后灯身从红色渐变切换为金色渐变:
js
function drawLantern(l, t) {
var ls = findLanternState(l.riddleId);
var solved = ls && ls.solved;
var swingX = Math.sin(t * 0.001 * l.swingSpeed + l.swing) * 5; // 夜风摆动
var shakeX = l.shake > 0 ? Math.sin(t * 0.05) * l.shake * 9 : 0; // 答错时的抖动
var x = l.x + swingX + shakeX, y = l.y;
var w = LANTERN_W, h = LANTERN_H;
// 灯身:横向线性渐变,答对切金色
var grad = ctx.createLinearGradient(x - w / 2, y, x + w / 2, y);
if (solved) {
grad.addColorStop(0, '#9c6f12');
grad.addColorStop(0.5, '#ffd76a');
grad.addColorStop(1, '#9c6f12');
} else {
grad.addColorStop(0, '#7a1f15');
grad.addColorStop(0.5, '#e74c3c');
grad.addColorStop(1, '#7a1f15');
}
ctx.fillStyle = grad;
ctx.beginPath();
ctx.ellipse(x, y, w / 2, h / 2, 0, 0, Math.PI * 2);
ctx.fill();
// 灯芯火光:半径随正弦摇曳,未答对才画
if (!solved) {
var flicker = 0.82 + 0.18 * Math.sin(t * 0.02 + l.swing);
var fireR = w * 0.20 * flicker;
var fg = ctx.createRadialGradient(x, y, 0, x, y, fireR * 2.1);
fg.addColorStop(0, 'rgba(255,242,190,0.9)');
fg.addColorStop(0.5, 'rgba(255,180,80,0.45)');
fg.addColorStop(1, 'rgba(255,120,40,0)');
ctx.fillStyle = fg;
ctx.beginPath();
ctx.arc(x, y, fireR * 2.1, 0, Math.PI * 2);
ctx.fill();
}
// ...顶盖 / 竖纹 / 流苏 / 谜字略
}
每盏灯用 Math.random() 生成独立的 swing(相位)与 swingSpeed(速度),所以 24 盏灯不会整齐划一地摆,而是错落有致------这是让画面「活」起来的关键。答错时的抖动复用了同一个 x 偏移:给 l.shake 置 1,再在 update 里随时间衰减回 0,就得到了一次「晃一下就停」的反馈。
3. 烟花:一个极简粒子系统
答对瞬间从灯笼处炸开 72 个粒子,每个粒子有初速度、重力加速度、空气阻尼和生命值。没有引入任何物理引擎,就是几条运动学公式:
js
function spawnFireworks(x, y) {
var colors = ['#FFD700', '#FF8C00', '#FF4500', '#FFB347', '#FFF8DC', '#FF6B6B', '#FFE066'];
for (var i = 0; i < 72; i++) {
var a = Math.random() * Math.PI * 2; // 随机方向
var sp = 1 + Math.random() * 4.6; // 随机速率
fireworks.push({
x: x, y: y,
vx: Math.cos(a) * sp,
vy: Math.sin(a) * sp - 1.6, // 整体向上偏一点
life: 1,
color: colors[Math.floor(Math.random() * colors.length)]
});
}
}
function updateFireworks() {
for (var i = fireworks.length - 1; i >= 0; i--) {
var p = fireworks[i];
p.x += p.vx;
p.y += p.vy;
p.vy += 0.08; // 重力
p.vx *= 0.99; // 阻尼
p.life -= 0.012; // 生命值衰减
if (p.life <= 0) fireworks.splice(i, 1); // 死了就移除
}
}
function drawFireworks() {
for (var i = 0; i < fireworks.length; i++) {
var p = fireworks[i];
ctx.globalAlpha = Math.max(0, p.life); // 用生命值做透明度 → 淡出
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, 2.2, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
vy 初始减 1.6 让烟花先向上蹿,再被重力 vy += 0.08 拉回来,形成抛物线;透明度直接绑在 life 上,粒子越接近消亡越淡。整套系统三个函数、不到 40 行,就是一朵像样的烟花。
四、工程化:5 个 commit,全程可验证
整个项目严格按语义化分 5 个 commit 推进,每个 commit 都能独立检出运行:
| # | Commit | 内容 | 验证 |
|---|---|---|---|
| 1 | chore |
仓库初始化(.gitignore / package.json / README / 目录骨架) | --- |
| 2 | feat(domain) |
题库 riddles.js + 判定引擎 engine.js(UMD 纯函数) |
Node 验证通过 |
| 3 | test |
引擎单测 test/engine.test.js(node:test + assert,零依赖) |
npm test 50/50 全绿 |
| 4 | feat(ui) |
Canvas 灯会三件套 index.html + style.css + main.js |
file:// 实测 8/8,控制台零报错 |
| 5 | docs |
完善 README(玩法 / 操作 / 题库 / 引擎 / 技术要点 / 运行 / 测试) | --- |
单测:50 个用例全绿
测试用 Node 内置的 node:test + node:assert,不引入任何测试框架,覆盖:题库完整性(题数 / 字段 / 类型 / id 唯一 / 分类齐全)、归一化(空白 / 全角 / 繁简 / 大小写 / 空值)、判定(精确 / 别名 / 繁体 / 答错 / 空答案)、计分阶梯、评级、渐进提示、洗牌可复现、状态演算(不可变 / 重复答 / 重复开题)、提示阶梯与机会耗尽、通关结算。
随机洗牌是单测的老大难------带 Math.random 的函数没法断言确定结果。引擎的解法是允许注入随机源 :shuffle(arr, random) 接受一个可选的 random 函数,游戏运行时默认用 Math.random,测试时传入一个固定序列,就能断言「同样的输入必然得到同样的打乱结果」:
js
function shuffle(arr, random) {
var rnd = typeof random === 'function' ? random : Math.random; // 可注入
var a = arr.slice(); // 不改原数组
for (var i = a.length - 1; i > 0; i--) {
var j = Math.floor(rnd() * (i + 1)); // Fisher-Yates
var tmp = a[i]; a[i] = a[j]; a[j] = tmp;
}
return a;
}
于是测试可以这样写:注入一个永远返回 0 的「随机」源,Fisher-Yates 的行为就完全确定,断言它既不打乱元素集合、又确实改变了顺序:
js
const test = require('node:test');
const assert = require('node:assert');
const E = require('../src/engine.js');
test('shuffle 可复现:注入固定随机源', () => {
const input = ['a', 'b', 'c', 'd', 'e'];
const out = E.shuffle(input, () => 0); // 注入确定性随机源
assert.deepStrictEqual([...out].sort(), [...input].sort()); // 元素不重不漏
assert.notDeepStrictEqual(out, input); // 顺序确实变了
assert.deepStrictEqual(input, ['a', 'b', 'c', 'd', 'e']); // 原数组未被修改
});
test('答对计分:越快越高,提示扣分', () => {
assert.strictEqual(E.scoreAnswer(5 * 1000, 0), 100); // 5 秒内、无提示 → 满分
assert.strictEqual(E.scoreAnswer(20 * 1000, 0), 80); // 30 秒内 → 八成
assert.strictEqual(E.scoreAnswer(5 * 1000, 2), 60); // 满分但用 2 次提示 → 六成
});
本地克隆下来跑一遍:
ℹ tests 50
ℹ pass 50
ℹ fail 0
ℹ duration_ms 401.31
file:// 实测
file:// 协议下没有模块系统,所以领域脚本全部用 UMD 挂到全局(window.MidAutumnRiddles / window.MidAutumnEngine),main.js 直接用全局引用。端到端实测 8/8 步骤全绿:渲染、点灯笼开题、答错反馈、请求提示、答对特效、重新开局,控制台零报错、零 pageerror。
五、过程剪影
这次开发由 AI 编程助手在云端沙箱里完成,从理解需求到推送上线一气呵成。几个关键节点:
任务下发后,助手先复述确认理解,并就「远端仓库推送方式」「仓库可见性」做了澄清:

设计题库时逐题校验扣合逻辑(比如「一月一日非今天 → 明」「半部春秋 → 秦」),确保每道题都经得起推敲:

五个 commit 全部完成、本地验证通过后,汇总了完整进度:

推送阶段还踩了两个有意思的坑:
坑一:lantern 是平台敏感词。 原计划仓库名叫 mid-autumn-lantern-riddle,创建远端时被拒("项目名称存在违规内容")。助手做了对照实验,确认 autumn、mid 都能正常创建、唯独 lantern 触发拦截,最后改用 huadeng(花灯)------语义上反而更贴切,中秋赏的本就是花灯。

坑二:云端沙箱的环境重置。 推送时发现沙箱在工具调用之间会重置 .git 的 refs,把 main 分支打回只有 1 个 commit。解法是先把 5 个 commit 的引用固定下来,再用一条原子命令 git reset --hard <完整commit> && git push origin main 在同一个 shell 调用内完成「恢复 + 推送」,避免中途再被重置:

最终 git push 输出 a16f4ed..5602b82 main -> main,远端 main 指向包含全部 5 个 commit 的最新节点,仓库公开可访问:
六、结语
这个项目没有用到任何框架和构建工具,却把「可玩性」和「工程质量」都做了出来:
- 对玩家,它是一场能点亮、会放烟花、有评级的中秋灯会;
- 对开发者,它是一次「纯函数引擎 + 数据驱动 + UMD 模块 + 零依赖单测」的练手------证明了不依赖任何第三方库,也能写出结构清晰、可测试、可扩展的前端代码。
中秋将至,祝你猜谜连中,个个状元。