用俄罗斯方块当沙漏来计时?我做了一个"堆沙漏"的番茄钟

用俄罗斯方块当沙漏来计时?我做了一个"堆沙漏"的番茄钟

根据文章内容,生成的摘要如下: TETRIS TIMER 是一款将沙漏灵感与俄罗斯方块结合的创意番茄钟工具。核心概念是用10×20的俄罗斯方块棋盘作为计时进度条:计时开始时空棋盘,随时间流逝,AI自动操控方块从底部填满棋盘,时间到则棋盘满,如同沙漏流尽。支持标准番茄钟三种模式(专注/短休息/长休息),并提供自定义时长。技术实现上,AI采用双策略:当棋盘高度落后于进度时使用“堆高策略”故意制造空洞;高度达标后切换为“正常策略”优先消行。AI通过状态机模拟真人按键节奏,并设有防卡死机制。项目使用Canvas和Web Audio API纯前端实现,像素风UI,可单文件运行。

 次点击
15 分钟阅读

如果时间是沙子,那方块就是它流逝的形状。

灵感来源:沙漏 × 俄罗斯方块

番茄钟这个东西,市面上多如牛毛。圆形进度条、线性进度条、数字倒计时……说实话,看久了都有点无聊。

有一天我盯着沙漏发呆,突然想到——沙漏的本质不就是"东西从上往下堆积,堆满了时间就到了"吗?这不就是俄罗斯方块吗?

于是 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 纯前端实现,无任何依赖,单文件即可运行。

这里放一个实例:

Tetris_timer

© 本文著作权归作者所有,未经许可不得转载使用。