Vibe Coding 第三弹:小游戏之俄罗斯方块

4 阅读13分钟

写在开始

Vive Coding 之旅继续,这一次使用AI编程开发一个浏览器小游戏:俄罗斯方块。这是一个经典的开发项目,在“古法编程”时代,是由基础到进阶的敲门砖,虽然游戏规则很简单,但是涉及到的细节还是不少的,比如旋转、下降,碰撞检测,性能开销等等。那么我们就一起看下这个“古法编程”时代经典的项目,在“AI编程”时代需要多久能完成,质量几何吧。

效果图

img_v3_0211q_0b65f90a-bf47-477f-97d6-5540054a9dhu.jpg

源码地址奉上 github.com/tianqinisna…

往期回顾

Vibe Coding 初体验:从对话模型到 RAG

Vibe Coding 第二弹:做一个 Canvas K线图

阶段 A:准备工作

技术方案

  • 构建与框架:Vite + React
  • 游戏渲染:Canvas(游戏画面在 Canvas 上绘制)

项目搭建

  • 形态:单页面应用(SPA)
  • 路由:使用 React Router;当前仅需配置根路径 /(后续路由可再扩展)

阶段 B:页面布局

页面分为 上下两层;上层为功能区(左:按钮,中:当前关卡,右:得分与掉落块);下层为 三栏「下一块」预览 Canvas、主游戏 Canvas、七种方块图例 Canvas。整体约束在一屏内展示,不出现整页纵向滚动。

一、上部分:功能区

  1. 按钮:开始、结束、暂停 / 继续游戏(不可用时置灰)
  2. 中间:显示当前 关卡(如「第 1 关」)
  3. 游戏信息
    • 得分:显示本局游戏的总得分
    • 掉落块:显示本局已锁定的方块总数

二、下层左侧:下一块预览

  • 独立 Canvas(逻辑尺寸 5×5 grid,与主棋盘共用 GRID),用于绘制 下一个 即将入场的方块(旋转 0、与生成朝向一致)。

三、下层中间:游戏区

  • 游戏主画面区域;挂载 一个 Canvas(逻辑分辨率示例 320×640),作为游戏逻辑与绘制的载体。
  • 在可用空间内按比例缩放,保持画布宽高比。

四、下层右侧:方块展示

  • 与主游戏区 并列,单独挂载 一个 Canvas,用于图例 / 预览。
  • 画布形态为 竖长、横窄;宽度为 5 个 grid,高度按竖向七种方块所需设定(实现中逻辑高度为 1120,对应 5×35 个 grid)。
  • 侧栏宽度随布局限制在合理区间(如 clamp),画布在栏内等比缩放。

阶段 C:基本功能实现(绘制与网格)

1. 格子(grid)与高清绘制

  • grid:游戏区与方块区的绘制均以 1 grid 为基本单位;两 Canvas 共用同一套像素步长常量(如 GRID = 32 逻辑像素,即一格在屏幕上的边长)。
  • 主游戏区:逻辑尺寸 10×20 grid320×640 像素。
  • 右侧展示区:逻辑尺寸 5×35 grid160×1120 像素(便于以「每槽 5 行 grid」竖向排布 7 种形状且网格线均为整格)。
  • 清晰度:先按不低于 2 倍 物理像素分配 backing store(canvas.width/height),再结合 devicePixelRatio 取更大者;显示尺寸仍为一倍逻辑大小(style 宽高 + ctx.setTransform),实现「高分缓冲、一倍呈现」,避免模糊。

2. 公共绘制方法(单位:1 grid)

  1. drawGrid:在给定 CanvasRenderingContext2D 上,按 1 grid 间距绘制横竖网格线(列数、行数、每格像素宽度可参数化,默认等于共享的 GRID)。
  2. 七种方块的绘制:形状与命名对照 俄罗斯方块-玩法说明.md 第 2 节;每种方块 单独一个绘制函数(如 drawTetrominoIdrawTetrominoL),并可在上层用 drawTetromino(kind, …) 统一分发。
    • 颜色约定(可与 Guideline 接近,便于辨认):
      • I#00ddee
      • O#f0c000
      • T#a020f0
      • S#00c853
      • Z#e53935
      • J#1e88e5
      • L#fb8c00

3. 首屏绘制内容

  • 两个 Canvas 均绘制背景色 + 完整网格
  • 右侧 Canvas:在竖向预留的槽位内,将 7 种形状各绘制一例,自上而下排列。

阶段 D:游戏设计

以下约定为可玩逻辑与交互的验收口径;其中 7-Bag、锁定、SRS 踢墙、碰撞判定、DAS/ARR 与输入缓冲等概念,均按常见 Guideline 系做法理解并写死到可实现的参数级。

一、积分规则

  1. 落块计分:每 成功锁定 一块(新块已生成且上一块已固定到场地)计 +1 分,并与界面「掉落块」计数同步;不计 仅生成尚未锁定的块。
  2. 消行计分:单次同时消除 n 行(n ≥ 1)时,得分 10 * n + n * (n - 1)(例:n=1→10,n=2→22,n=3→36,n=4→52)。若同一次锁定后既消行又满足上一条,两笔分数都加(不做「消行时不再给落块 +1」的互斥)。
  3. 加速下落加分玩家按住下键软降每格 +1 分阶段 E 已取消软降加分;自然下落仍不计分)。

二、游戏规则

  1. 随机序列(7-Bag):全局维护一只「袋子」,内装 I、O、T、S、Z、J、L 各一枚;开局与每取空一袋时,将七枚 洗牌 后依次作为出块顺序;取完再装一袋重洗。这样任意时刻不会出现长期缺某一形状,也不会无限连出同一种。新局开始时重新装袋。

  2. 场地与可见区:棋盘 10 列可见 20 行用于渲染与判定玩家视野。为减少 SRS 在顶部旋转顶死、以及生成位过浅的问题,逻辑上在可见区上方再保留 4 行缓冲带(即整块逻辑棋盘为 10 列 × 24 行,最底下 20 行与当前画布对应;缓冲行不画进 UI 亦可,但碰撞与锁定必须在完整逻辑矩阵上计算)。若实现选择「无缓冲」则须自行承担顶行旋转失败率升高。

  3. 重力(基础值,关卡修正见阶段 E):自然下落基准为 每 1000ms 下落 1 grid(第 1 关);暂停 时重力计时器冻结。

  4. 生成高度:新块生成时竖直位置取 可见区顶行(逻辑行 BUFFER_ROWS)对齐:使旋转 0 下整块的最小行坐标落在可见顶行,不再长时间停留在顶部缓冲带内,进入画面即可见。

  5. 左右移动:左 / 右方向键单次平移 1 grid。长按使用 DAS / ARR:按住满 170ms 后进入连发,之后每隔 50ms 再步进 1 grid(左右共用同一组参数)。

  6. 软降(下键):下键每次令活动块 主动下落 1 grid不计分);长按连发与左右一致:首段间隔 170ms,之后每 50ms 一步(与左右共用同一套 DAS/ARR 状态机)。

  7. 旋转:上键为 顺时针旋转(本阶段仅这一种旋转输入)。每次旋转先在当前格坐标下生成旋转后的占用格,若与墙或已锁定格 碰撞,则按 SRS 顺序尝试有限次 踢墙偏移(含 (0,0));全部失败则本次旋转作废,保持旋转前姿态。实现可先做「完整 SRS 表」或先做「简化踢墙(如仅左右各移一格)」再迭代,但对外行为须符合「能踢则踢、不能则放弃」。O 块旋转不改变形状,可不调用踢墙表。

  8. 碰撞检测:任意时刻对活动块所占的每一格判断:是否越过 左右下边界(上边界由逻辑高度与缓冲带约束)、是否与 已锁定 的格子重叠。移动、下落、旋转及每一次踢墙尝试都须通过同一套判定。

  9. 锁定延迟与 move reset:当块因重力或操作 触底无法再因重力下移 时,进入锁定流程:在 500ms 的锁定窗口内,玩家仍可左右移动、旋转或软降;每次 成功的移动或旋转 可将该窗口 重新计时(move reset),但单块累计 最多重置 15 次,超过后不再重置,窗口耗尽即 锁定。若在窗口内块再次变为可因重力下落(如移出承托),则退出锁定态按正常重力处理。

  10. 消行与重力:任意一行 10 格均被占满 则该行消除;多行可同时消除。消除后 上方所有行整体下移填补空洞,再结算消行得分。

  11. 游戏结束(Dead):满足其一即本局结束:① 下一块在 生成位 已无法合法摆放(与已锁定格重叠或越界);或② 锁定完成后,已有方块占据 可见区上沿以上(即溢出到玩家视野顶线之上,具体判定与缓冲行坐标一致)。玩家在 游戏中 / 暂停 下点击 结束,视为立即触发 Dead,与上两条等价。

  12. 状态机:全局处于且仅处于:初始游戏中暂停游戏结束

    • 暂停:不推进重力、不消耗锁定计时、不响应游戏操作键(或等价冻结游戏时钟);继续 后从中断点恢复。
    • 开始:仅在 初始游戏结束 可点;清空场地、分数、袋、当前/下一块等,进入新一局 游戏中
    • 结束:在 游戏中暂停 可点;立即转 游戏结束
    • 暂停 / 继续:仅在 游戏中暂停 之间切换,互斥;初始 / 游戏结束 下该按钮置灰或无效。
  13. 输入缓冲:对 左、右、下、上(旋转) 各维护短窗:在 120ms 内若按下时尚不可执行,则 最多缓存 1 次 该操作;一旦下一帧变为可执行,立即消耗这 1 次(避免「刚好差一帧按不到」的挫败感)。

  14. 本阶段明确不做(留待扩展 / 部分由阶段 E 承接)硬降HoldT-Spin / Combo 等;关卡与动态重力由 阶段 E 描述。硬降与 Hold 仍可不暴露键位。进一步扩展路线见 阶段 F

阶段 E:细节优化(消行动画、软降计分、下一块预览、关卡)

一、消行动画

  • 锁定后出现满行时,先不立即压缩矩阵:在约 320ms 内播放动画——满行格块 淡出,其上方未消行格块按 ease-out 曲线 竖直下移至落位后的逻辑行(视觉上为「整段下移」)。
  • 动画结束后再执行矩阵压缩并生成下一块;暂停 时消行动画的累计时间不增加(进度冻结),恢复后继续;活动块重力与输入仍按阶段 D 在暂停下不推进。

二、软降计分

  • 取消下键软降每下移 1 grid 的加分;落块 +1、消行公式仍按阶段 D。

三、下一块预览

  • 主棋盘 左侧 增加独立 Canvas5×5 grid),绘制 下一个 即将入场的方块(与 7-Bag 的 peek 一致,旋转状态 0)。

四、关卡与重力

  1. 升级依据:以 本关内已锁定块数 累计(与界面「掉落块」总数同步增长);达到本关阈值后 关卡 +1,本关计数清零。
  2. 阈值递增:第 1→2 关需 10 块;之后每升一级所需块数 +3(10 → 13 → 16 → 19…,实现公式:7 + 当前关卡 × 3)。越往后升级 需要的掉落块越多
  3. 重力:第 11000ms / 1 grid;每升一级间隔乘以 0.92,并设 下限 180ms/格(速度上限,再快不再缩短)。
  4. 界面:顶部栏 正中 显示「第 n 关」。

五、其它

  • 顶部按钮在不可用状态下 置灰(降低对比、cursor: not-allowed)。

阶段 F:建议路线(玩法扩展、体验、无障碍与工程)

以下基于当前代码与文档整理,不要求一次做完;可按优先级拆成多个小迭代。

一、玩法与规则(承接阶段 D 第 14 条)

  1. 硬降(Hard Drop):单次瞬间落底并立即锁定;常见计分可单独约定(如每格 +2 或仅加速感不计分,需与阶段 D 文档对齐)。
  2. Hold:单槽暂存当前块,与下一块交换;每落一块仅允许 使用一次 Hold(经典 Guideline 限制)。
  3. 幽灵块(Ghost):在主棋盘用半透明轮廓显示落底位置,降低误操作。
  4. 进阶计分(可选):Back-to-Back、Combo、T-Spin 检测与加分;需与现有「消行公式 + 落块 +1」是否叠加写清楚。

二、视听与反馈

  1. 音效:锁定、单消~四消、游戏结束、升级等分层音效;提供 静音 开关并持久化到 localStorage
  2. 轻量画面反馈:多行消除时短暂 屏震 或边框高亮(注意与现有消行动画时长协调)。
  3. 升级提示:关卡变化时顶部或 Toast 简短提示(可与音效同步)。

三、无障碍与输入

  1. 键盘keydown 对方向键 preventDefault,避免焦点在页内时 滚动页面;游戏区容器 tabIndex={0},开局或点击后 聚焦,保证键盘始终生效。
  2. 说明文案:功能区或页脚增加「操作说明」折叠区(方向键、暂停等),便于新玩家与验收。
  3. 可选键位:左右手友好的备用键(如 A/DW 旋转)可作为后续配置项。

四、界面信息(在不大改布局前提下)

  1. 关卡进度:例如「本关已锁定 a / 还需 b 块升级」或进度条,与 dropsNeededToAdvance 一致。
  2. 当前重力:显示「约 n ms / 格」或与阶段 E 公式对应的档位说明,减少「突然变快」的困惑。
  3. 高分榜:本机 localStorage 存 Top N(姓名可省略,仅存分数 + 日期)。

五、生命周期与多环境

  1. 切后台:监听 document.visibilityState隐藏标签页时自动暂停,避免后台仍消耗计时与误触。
  2. 移动端(若需要):触控 虚拟方向键 或滑动手势;需单独调 DAS/ARR 手感与误触。

六、工程与质量

  1. 单元测试:对 无 UI 模块优先写测例——boardUtils(消行、溢出判定)、bag7(洗牌与取块顺序)、srs(若干已知踢墙用例)、levelSystem(阈值与重力公式)。可选用 Vitest
  2. CI:提交/PR 时跑 npm run build + 测试脚本,保证重构不破坏规则。
  3. 性能:主循环已用 rAF;若将来 UI 状态增多,继续保持「高频数据不进 React state、仅 Canvas 重绘」的策略。

七、可选长期项

  • 本地存档 / 继续游戏(单局中途退出恢复)。
  • 主题:高对比 / 色弱友好调色板。
  • 联网排行(需后端,超出当前 SPA 范围)。

写在最后

以上阶段A - 阶段F,就是通过和AI对话,生成的开发文档,然后按照生成好的文档,按步骤进行代码实现,总计耗时2 - 3小时。那么问题来了,如果不使用AI,纯手写实现,你需要多久呢?

好了,那这次的Vibe Coding就是这样了,下次见。