打地鼠是"反应速度 + 定时器"最经典的组合:地鼠随机冒头,你必须在它缩回去之前点中它。这个项目没有复杂的坐标计算,重点全在定时器调度、随机策略和事件委托上,非常适合作为你的第五个 JavaScript 小游戏。
1. 游戏规则
3×3 共 9 个洞,地鼠会随机从某个洞里冒出来,停留约 0.8 秒后缩回。你需要在 30 秒内尽可能多地点击地鼠:每打中一只得 10 分,点击空洞不得分也不扣分。时间一到,游戏结束,展示总得分。
规则背后有三个要实现的机制:
- 随机冒头:定时生成"某洞冒出地鼠"的事件,且避免连续出现在同一个洞
- 点击判定:点中的必须是"正在冒头"的地鼠,点空洞无效
- 倒计时:一个独立的一秒定时器驱动,归零时结束所有活动
这三个机制分别对应两个定时器和一组事件,理清它们的关系,代码就不会乱。顺便说下节奏设计:每 0.9 秒冒一只、每只停留 0.8 秒,意味着大部分时间场上只有一只地鼠,节奏舒缓;想让场面更混乱,把出现间隔调到 0.5 秒并让两只同时存在即可——那只需要改一个数字,逻辑完全不用动。
2. 界面搭建
每个洞是一个 div.hole,里面放一只 div.mole。地鼠"冒头"的效果用 CSS 实现:洞容器 overflow: hidden,地鼠平时藏在底部(向下平移),加上 up 类后平移回原位——看起来就像从洞里钻出来。
<div class="game">
<h1>打地鼠</h1>
<p><span id="score">得分:0</span> | <span id="time">时间:30</span></p>
<div class="board">
<div class="hole"><div class="mole">🐹</div></div>
<div class="hole"><div class="mole">🐹</div></div>
<div class="hole"><div class="mole">🐹</div></div>
<div class="hole"><div class="mole">🐹</div></div>
<div class="hole"><div class="mole">🐹</div></div>
<div class="hole"><div class="mole">🐹</div></div>
<div class="hole"><div class="mole">🐹</div></div>
<div class="hole"><div class="mole">🐹</div></div>
<div class="hole"><div class="mole">🐹</div></div>
</div>
<button id="start">开始游戏</button>
</div>
9 个洞结构完全一致,手写虽然啰嗦,但胜在直观;想偷懒也可以在 JavaScript 里循环生成。#score 和 #time 是两块计分板,#start 负责开局。
3. 让地鼠"钻出来"
CSS 的关键是两层结构配合:洞负责"藏",地鼠负责"钻"。地鼠直接用 emoji 表示,零成本又可爱。
.board {
display: grid;
grid-template-columns: repeat(3, 120px);
gap: 12px;
}
.hole {
width: 120px;
height: 120px;
background: #6b4a2f;
border-radius: 50%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.mole {
font-size: 3rem;
transform: translateY(100%);
transition: transform 0.15s;
cursor: pointer;
}
.hole.up .mole {
transform: translateY(0);
}
translateY(100%) 把地鼠完全推到洞的底部,被 overflow: hidden 裁掉看不见;加上 up 类后平移归位,配合 0.15 秒的 transition,冒头和缩回都带一点弹性动画。cursor: pointer 让鼠标移上去变成小手,提示"这个可以点"。
4. 地鼠随机出现
用一个 moleTimer 定时器,每 0.9 秒随机挑一个洞,让它冒头 0.8 秒。为了避免"连续两次同一个洞"这种让人无语的情况,记录上一次的洞,随机到同一个就重新抽。
const holes = document.querySelectorAll('.hole');
const scoreEl = document.getElementById('score');
const timeEl = document.getElementById('time');
let lastHole = -1;
let moleTimer = null;
let gameTimer = null;
function randomHole() {
let idx = Math.floor(Math.random() * holes.length);
while (idx === lastHole) {
idx = Math.floor(Math.random() * holes.length);
}
lastHole = idx;
return holes[idx];
}
function showMole() {
const hole = randomHole();
hole.classList.add('up');
setTimeout(() => {
hole.classList.remove('up');
}, 800);
}
while 循环保证不连续重复——9 个洞里抽中同一个洞的概率是 1/9,重抽一次几乎必定不同。setTimeout 负责 0.8 秒后把地鼠按回去,它和 setInterval 的配合是这类"冒泡式"小游戏的标准写法。这里其实出现了两个时间尺度:冒头节奏(0.9 秒一次)和单只地鼠的存活时长(0.8 秒),它们独立运转、互不干扰,这也是为什么需要两个不同的定时器/延时器来管理。
5. 点击判定与计分
点击判定用事件委托:不给 9 只地鼠分别绑监听,只在棋盘上绑一个。点击时用 e.target.closest('.mole') 向上查找,看是否点到了地鼠;再检查它所在的洞有没有 up 类——没冒头的地鼠打了不算。
let score = 0;
let timeLeft = 30;
document.querySelector('.board').addEventListener('click', e => {
const mole = e.target.closest('.mole');
if (!mole) return;
const hole = mole.parentElement;
if (!hole.classList.contains('up')) return;
score += 10;
scoreEl.textContent = '得分:' + score;
hole.classList.remove('up');
});
事件委托的好处:即使以后用 JavaScript 动态增删地鼠,也不用重新绑监听。closest 会沿着 DOM 向上找最近的匹配元素,点击地鼠的"脸"还是"身子"都能命中。打中后立刻把洞的 up 移除,地鼠缩回去,防止同一只被连续点击刷分——这一行就是"防作弊"。
想加打击感,可以在打中时给地鼠瞬间加一个 hit 类,配合 CSS 动画做个"被打扁"的效果,再在 200 毫秒后移除。这个技巧和第 3 节的 transition 是同一套思路,留作练习。
6. 倒计时与开局
最后是倒计时:一个每秒走一次的 gameTimer,归零时清掉所有定时器并结算。开始按钮负责把一切状态复位,并启动两个定时器。
function start() {
score = 0;
timeLeft = 30;
scoreEl.textContent = '得分:0';
timeEl.textContent = '时间:30';
if (gameTimer) clearInterval(gameTimer);
if (moleTimer) clearInterval(moleTimer);
gameTimer = setInterval(() => {
timeLeft--;
timeEl.textContent = '时间:' + timeLeft;
if (timeLeft <= 0) {
clearInterval(gameTimer);
clearInterval(moleTimer);
alert('⏰ 时间到!最终得分:' + score);
}
}, 1000);
moleTimer = setInterval(showMole, 900);
}
document.getElementById('start').addEventListener('click', start);
开局先 clearInterval 清掉可能残留的旧定时器,避免"连点两次开始,倒计时速度翻倍"的经典 bug。倒计时归零时同时停掉两个定时器:地鼠不再冒头,时间不再走动——游戏从"进行中"干净地切换到"已结束"。用 alert 结算虽然朴素,但足够清晰,之后可以换成页面上更美观的弹层。
想让游戏越来越刺激?把"地鼠停留时间"抽成一个变量,再用一个定时器每隔 10 秒缩短它——这就是难度梯度的实现:
let showTime = 800;
let speedTimer = null;
function showMole() {
const hole = randomHole();
hole.classList.add('up');
setTimeout(() => {
hole.classList.remove('up');
}, showTime);
}
// 在 start() 里加上:
speedTimer = setInterval(() => {
showTime = Math.max(300, showTime - 100);
}, 10000);
// 时间归零时记得:clearInterval(speedTimer);
注意 showTime 是"读时生效"的:setTimeout 的回调在触发时才读取它的当前值,所以改掉变量后,下一只地鼠立刻用新时长,其他代码一个字都不用动。Math.max(300, ...) 给缩短设了下限,防止地鼠快到根本点不中——游戏要难,但不能难到没法玩。
7. 总结与练习
这篇你学会了三件套:setInterval 周期调度(冒头、倒计时)、随机策略(随机洞 + 防重复)、事件委托(一个监听管全盘)。打地鼠的代码量不大,但"多个定时器如何协同、如何安全停止"这个经验,在往后任何实时项目里都会反复用到。
练习一下:
- 增加难度梯度:每过 10 秒,地鼠停留时间从 0.8 秒缩短到 0.5 秒,考验极限手速。
- 加入"炸弹鼠":偶尔出现黑色地鼠,打中扣 20 分,增加策略性。
- 结算时把得分和
localStorage里的历史最高分比较,刷新纪录时高亮提示。
💡 调试定时器时,在 Console 里输入moleTimer可以看到定时器 ID;如果你发现地鼠越冒越快,先检查是不是start被多次调用、旧定时器没被清掉。