01做个打地鼠:状态机、定时器和一点点手感
约 20 分钟
整个游戏是一个 HTML 文件:9 个洞、1 个计时器、不到 300 行代码。这一课按“把它做出来需要想清楚哪几件事”来讲。
一、游戏拆解
打地鼠看着热闹,逻辑其实只有四个问题:
- 什么时候出洞、从哪个洞出 —— 随机性
- 怎么判断打中了 —— 状态机
- 分数和命中率怎么算 —— 计数
- 什么时候结束 —— 倒计时
四件事想清楚了,游戏就写完了。
二、核心:每个洞是一个小状态机
这是整个游戏最重要的设计。每只鼹鼠只有三种状态:
idle(在地里) → up(冒头) → idle(缩回去)
↑
hit(被敲中) ← 从 up 可以走到这里
const holes = []; // 9 个洞,每个洞一个状态
holes.push({ el, mole, state: 'idle', timer: 0, type: 'mole' });
为什么需要状态机而不是“显示 / 隐藏”两个布尔值?因为只有 up 状态才能被打中。如果鼹鼠已经在下落的动画里还被算一击,玩家会觉得判定不对。状态机让每行逻辑都很直白:
function whack(h) {
if (!running || h.state !== 'up') return; // 只有冒头的才能打
h.state = 'hit';
// 计分、音效、飘字……
}
每次写游戏逻辑卡壳的时候,先问一句:是不是状态没收干净?
三、随机性和难度曲线
出洞是一个自我循环的定时器,不是 setInterval:
function spawn() {
if (!running) return;
// 从 idle 的洞里随机挑一个,让它冒头
const p = (DURATION - timeLeft) / DURATION; // 0 → 1,时间进度
spawnTimer = setTimeout(spawn, 900 - p * 500 + Math.random() * 250);
}
用 setTimeout 链式调用而不是 setInterval,好处是间隔可以每一轮都变:
- 开场 900ms 左右出一只,结尾接近 400ms 一只
- 鼹鼠停留时间也从 1400ms 缩到 700ms
这就是一条最简陋但有效的难度曲线:不是换关卡,是让同一个机制越跑越快。玩家感受到的“越来越紧张”,背后只是一个线性插值。
鼹鼠类型也是在这里决定的:10% 炸弹、8% 金鼠,其余普通。
四、敲击判定与飘字
鼹鼠本身设了 pointer-events: none,所以指针事件永远落在“洞”上,再由洞查自己状态:
board.addEventListener('pointerdown', (e) => {
const h = holes.find(x => x.el === e.target.closest('.hole'));
if (h.state === 'up') { whack(h); } // 命中
else if (running) { attempts++; } // 敲空,也算一次尝试
});
用 pointerdown 而不是 click,因为 click 要等抬起,手感慢半拍。一个小细节:敲空不算失败、只算进命中率,玩家不至于觉得惩罚太重,但想刷 100% 命中的人又有了追求。
分数飘字是一个临时 <div>,加进去 650ms 后删掉,不用管它的生命周期。
五、没素材怎么做出样子
- 鼹鼠、炸弹、锤子都是内联 SVG。金鼠不换图,直接
filter: sepia()调色 - 音效用 WebAudio 合成:
OscillatorNode发个 120ms 的方波就是敲击声,锯齿波低音就是炸弹声。零音频文件 - 鼹鼠冒头是
translateY过渡动画,缩回去就是取消类名。没用 requestAnimationFrame,CSS 就够
一个值得记住的判断:这类小游戏的“手感”八成来自动画时长和音效时机,而不是贴图质量。把 transition 从 0.12s 改成 0.4s,游戏立刻变得肉。
六、结课的坑
- 计时器嵌套了
setTimeout,结束时要把每只鼹鼠的h.timer都清掉,否则重开一局会有“鬼魂鼹鼠”冒出来 - 最高分用
localStorage存,读写都要容错(隐私模式可能抛异常) cursor: none+ 自定义锤子光标只在pointer: fine(有鼠标)时启用,手机上保留默认触控
练习
- 加一只“忍者鼹鼠”:停留时间只有普通鼹鼠的一半,+5 分
- 敲空三次扣 1 分,观察这对命中率指标的影响
- 把 9 个洞改成难度可选:3×3、4×4 两档