如果时间是沙子,那方块就是它流逝的形状。
灵感来源:沙漏 × 俄罗斯方块
番茄钟这个东西,市面上多如牛毛。圆形进度条、线性进度条、数字倒计时……说实话,看久了都有点无聊。
有一天我盯着沙漏发呆,突然想到——沙漏的本质不就是"东西从上往下堆积,堆满了时间就到了"吗?这不就是俄罗斯方块吗?
于是 TETRIS TIMER 就这样诞生了。
核心概念很简单:用一块 10×20 的俄罗斯方块棋盘作为计时进度条。计时开始时棋盘是空的,随着时间流逝,AI 会自动操控方块,让棋盘从底部逐渐填满。时间到了,棋盘也满了——就像沙漏里的沙子流尽。
功能一览
- 三种模式:专注 25 分钟 / 短休息 5 分钟 / 长休息 15 分钟,标准番茄钟节奏
- AI 自动玩俄罗斯方块:不需要你操作,AI 会模拟真人按键,旋转、平移、落下
- 进度可视化:棋盘填充高度 = 时间消耗进度,一眼就能感知剩余时间
- 番茄循环追踪:底部四个小方块点记录当前轮次,每完成 4 个番茄升一级
- 自定义时长:设置面板支持修改三种模式的时长,数据持久化到
localStorage - 像素风 UI:全程 Press Start 2P 字体,背景有随机飘落的半透明方块装饰
技术实现:让 AI 模拟沙漏
这是整个项目最有意思的部分。
进度与高度的映射
棋盘有 20 行,计时器有一个总时长。每一帧,我都会计算当前时间消耗的进度,然后换算成"目标高度":
let progress = 1 - (timeLeft / totalTime);
let targetHeight = Math.ceil(progress * ROWS); // ROWS = 20
let currentHeight = getBoardHeight();
getBoardHeight() 从上往下扫描棋盘,找到第一个非空格子,返回当前堆积高度。
有了目标高度和当前高度的差值,AI 就知道该"堆高"还是"消行"了。
双策略 AI
这是整个项目的核心设计。AI 有两种策略,根据当前状态动态切换:
let strategy = (currentHeight < targetHeight) ? 'BUILD_UP' : 'NORMAL';
BUILD_UP(堆高策略):当棋盘高度落后于时间进度时,AI 会故意制造空洞、不消行,让方块堆得又高又乱:
if (strategy === 'BUILD_UP') {
// 极度厌恶消除行,允许不平整,自然留出深井
return (lines * -10000) - (holes * 1000) - (bumpiness * 180) - (aggregateHeight * 10);
}
NORMAL(正常策略):当高度已经够了,AI 切换成标准俄罗斯方块 AI,开始消行、保持平整、压低高度:
else {
// 倾向消行、厌恶空洞、保持低高度
return (lines * 760) - (holes * 350) - (bumpiness * 180) - (aggregateHeight * 510);
}
这个评分函数是经典的俄罗斯方块 AI 启发式算法,通过穷举所有旋转角度和落点位置,选出得分最高的方案。
模拟真人操作
AI 不会瞬间把方块传送到目标位置,而是用一个状态机模拟真人按键的节奏:
THINKING → ROTATING → MOVING → DROPPING
每 250ms 执行一次操作(旋转一次或平移一格),看起来就像真的有人在玩:
if (aiState === 'ROTATING' && targetRot > 0) {
activePiece.shape = rotateMatrix(activePiece.shape);
targetRot--;
} else if (aiState === 'MOVING') {
if (activePiece.x < targetX) activePiece.x++;
else if (activePiece.x > targetX) activePiece.x--;
else aiState = 'DROPPING';
}
进入 DROPPING 状态后,下落间隔从 400ms 压缩到 100ms,方块快速落地,节奏感很强。
防卡死机制
有一个边界情况需要处理:如果 BUILD_UP 策略堆得太猛,新方块生成时直接碰撞(棋盘已满到顶部),游戏就卡死了。
解决方案是强制清底:
if (collide(board, activePiece)) {
board.splice(ROWS - 4, 4); // 删除底部 4 行
for (let i = 0; i < 4; i++) board.unshift(Array(COLS).fill(0)); // 顶部补空行
activePiece = null;
return;
}
删底部、补顶部,棋盘整体下移,给新方块腾出空间,同时视觉上不会太突兀。
细节打磨
方块渲染:每个方块都有简单的 3D 高光阴影,顶部和左侧加白色高光,底部和右侧加黑色阴影,像素感十足:
function drawBlock(x, y, color) {
tCtx.fillStyle = color;
tCtx.fillRect(x, y, BLOCK_SIZE, BLOCK_SIZE);
tCtx.fillStyle = 'rgba(255,255,255,0.4)';
tCtx.fillRect(x, y, BLOCK_SIZE, 2); // 顶部高光
tCtx.fillRect(x, y, 2, BLOCK_SIZE); // 左侧高光
tCtx.fillStyle = 'rgba(0,0,0,0.4)';
tCtx.fillRect(x, y + BLOCK_SIZE - 2, BLOCK_SIZE, 2); // 底部阴影
tCtx.fillRect(x + BLOCK_SIZE - 2, y, 2, BLOCK_SIZE); // 右侧阴影
}
完成音效:用 Web Audio API 合成了一段上升的方波音,不依赖任何音频文件:
osc.type = 'square';
osc.frequency.setValueAtTime(440, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(1760, ctx.currentTime + 0.2);
440Hz → 1760Hz,三步跳升,有点 8-bit 游戏通关的感觉。
背景动画:25 个随机方块在背景 Canvas 上缓慢飘落,透明度压到 0.05~0.35,不抢主体视觉焦点,但让整个页面活了起来。
一点感想
做这个项目最大的收获,是意识到"进度可视化"可以有很多种玩法。圆形进度条是进度,线段是进度,一块慢慢被填满的俄罗斯方块棋盘也是进度——只要映射关系清晰,什么形式都可以成为时间的载体。
沙漏用沙子计时,我们用方块计时。本质上没什么不同,只是多了一点点游戏的乐趣。
如果你也在找一个能让你"看着时间流逝"的番茄钟,不妨试试这个。
仅用 Canvas + Web Audio API 纯前端实现,无任何依赖,单文件即可运行。
这里放一个实例: