记录时间:2026-02-22 开发环境:Windows 11,Node.js 24,Expo SDK 54,React Native 0.81.5 目标:从零构建一个可在 Android 真机安装的俄罗斯方块 App
开始之前明确了以下核心目标:
- 游戏本体:标准俄罗斯方块逻辑(10×20 棋盘、7 种方块、旋转、消行、计分)
- 视觉风格:暗黑系霓虹配色,科技感
- 触觉反馈:接入
expo-haptics,不同操作触发不同强度震动 - 操控方式:屏幕下半部分的大按钮,适合触屏
- 可安装:最终产物是可直接安装的 Android APK
npx create-expo-app@latest tetris --template blank使用 blank 模板,得到最精简的 Expo 项目骨架,避免不必要的样板代码。
cd tetris
npx expo install expo-haptics
npx expo install react-native-safe-area-context为什么用 npx expo install 而不是 npm install?
expo install 会自动查询当前 Expo SDK 版本对应的兼容版本号,避免手动指定版本导致的原生模块不兼容问题。
将代码拆成三层:
constants.js ← 纯数据(不依赖任何框架)
gameLogic.js ← 纯函数(不依赖 React)
App.js ← React 组件(UI + 状态 + 副作用)
这样做的好处:
constants.js和gameLogic.js可以独立测试- 逻辑与 UI 解耦,未来替换渲染层(如 Skia)不需要改逻辑
每种方块预存 4 个旋转态的 4×4 矩阵,矩阵中的数字就是颜色索引:
// I 型方块,旋转态 0(横向)
[[0,0,0,0],
[1,1,1,1], // 1 = 青色
[0,0,0,0],
[0,0,0,0]]预存所有旋转态(而不是运行时计算旋转矩阵)的好处是:简单、无 bug、性能好。
function collides(board, piece, dx, dy, rotation) {
// 遍历 4×4 矩阵中每个非零格
// 计算其在棋盘上的实际坐标 (nx, ny)
// 检查:越左墙、越右墙、越底板、压到已锁定格
}dx / dy 参数允许"假设移动后检测",不需要真正移动方块就能知道是否合法。
旋转时如果直接碰墙,依次尝试 x 偏移 [0, -1, +1, -2, +2],找到第一个不碰撞的位置就用它。这是简化版的 SRS(Super Rotation System)。
第一版架构(有 bug):
// 用多个 useState 管理状态
const [board, setBoard] = useState(createBoard());
const [piece, setPiece] = useState(null);
// 用 useRef 同步最新值
const boardRef = useRef(board);
useEffect(() => { boardRef.current = board; }, [board]);
// setInterval 里读 ref
const tick = useCallback(() => {
const p = pieceRef.current; // 读 ref
// ...
}, []);
// 游戏循环
useEffect(() => {
const id = setInterval(tick, speed);
return () => clearInterval(id);
}, [started, gameOver, paused, piece, level, tick]); // ← piece 在依赖数组里!Bug 原因:
依赖数组里有 piece,每次方块移动(setPiece)都会触发 effect 重新执行,重建 interval。但控制函数(如 moveLeft)调用 setPiece(...) 之后,pieceRef.current 不会同步更新——React 的 state 更新是异步的,下一次按键时 ref 里还是旧值,碰撞检测全部基于旧位置,导致按键完全失效。
第二版架构(修复):
// 所有可变状态放进单一 ref
const gs = useRef({
board: createBoard(),
piece: null,
score: 0,
// ...
});
// 只用一个 state 触发重渲染
const [, setTick] = useState(0);
const rerender = () => setTick(n => n + 1);
// 控制函数直接修改 ref,立即生效
const moveLeft = () => {
const g = gs.current;
if (!collides(g.board, g.piece, -1, 0)) {
g.piece = { ...g.piece, x: g.piece.x - 1 }; // 同步修改
rerender(); // 触发重渲染
}
};核心原则: setInterval 回调和控制函数都直接读写 gs.current,永远是最新值,彻底消除 stale closure。
问题: React Native 内置的 SafeAreaView 在 Android 全面屏设备上不可靠,内容会被系统状态栏遮挡。
修复: 换用 react-native-safe-area-context:
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
// 根组件用 Provider 包裹
<SafeAreaProvider>
<SafeAreaView edges={['top', 'bottom']}>
{/* 内容 */}
</SafeAreaView>
</SafeAreaProvider>edges 指定只处理上下边缘,左右不加 padding(游戏需要全宽)。
同时设置 <StatusBar translucent={false} />,防止状态栏透明叠加在内容上方。
每次等级变化都需要重建 interval(更新速度),但不能因为 level 在依赖数组里就频繁重建:
// 在 spawnNext 和 hardDrop 之后都调用 startLoopStable()
// startLoopStable 每次都 clearInterval 再重建,确保速度是最新的
const startLoopStable = () => {
stopLoop();
const speed = Math.max(80, TICK_MS - (gs.current.level - 1) * 50);
intervalRef.current = setInterval(() => tickRef.current?.(), speed);
};用 tickRef 存最新的 tick 函数引用,setInterval 的回调永远调用最新版本。
| 操作 | API | 感受 |
|---|---|---|
| 左移 / 右移 / 旋转 | impactAsync(Light) |
轻微一振,确认操作 |
| 方块触底固化 | impactAsync(Medium) |
中度一振,感受"落地" |
| 硬降 | impactAsync(Heavy) |
强烈一振,感受冲击 |
| 消行成功 | notificationAsync(Success) |
成功节奏振动 |
| 游戏结束 | notificationAsync(Error) |
错误节奏振动 |
注意:Haptics 在 iOS 模拟器上无效,需真机测试。Android 模拟器部分支持。
EAS(Expo Application Services)在云端服务器上执行 Android/iOS 构建,本地无需安装 Android Studio、JDK、Gradle 等工具链,极大降低环境配置成本。
# 1. 登录 Expo 账号
npx eas-cli login
# 2. 在云端创建项目(写入 projectId 到 app.json)
npx eas-cli project:init --force
# 3. 手动创建 eas.json(build:configure 需要交互式终端)eas.json 关键配置:
{
"build": {
"preview": {
"distribution": "internal",
"android": {
"buildType": "apk"
}
}
}
}distribution: "internal"— 内部分发,不走应用商店审核buildType: "apk"— 输出 APK 文件,可直接安装(默认是 AAB,只能上传 Play Store)
npx eas-cli build --profile preview --platform android构建日志可在 https://expo.dev/accounts/nightmoon/projects/tetris 实时查看。
EAS CLI 的 build:configure 命令需要 TTY(交互式终端)来显示选择提示,在某些终端环境(如 IDE 内置终端、CI 环境)下会直接报错。
解决方案: 手动完成它做的两件事:
npx eas-cli project:init --force创建云端项目并写入projectId- 手动创建
eas.json
Node.js 原生不支持 JSX 语法,这是正常现象,不代表代码有问题。JSX 需要通过 Babel(Expo 内置)转译后才能运行。验证 JSX 文件语法应使用 npx expo start 或专门的 linter。
- 添加音效:使用
expo-av,在消行、触底时播放音效 - 持久化最高分:使用
@react-native-async-storage/async-storage - 手势控制:使用
react-native-gesture-handler实现滑动操作 - 消行动画:使用
react-native-reanimated做行消除的闪光动画 - 单元测试:
src/gameLogic.js是纯函数,可以直接用 Jest 测试,无需 mock React