Skip to content

Latest commit

 

History

History
283 lines (197 loc) · 8.53 KB

File metadata and controls

283 lines (197 loc) · 8.53 KB

开发日志 — Tetris App 首次构建全流程

记录时间:2026-02-22 开发环境:Windows 11,Node.js 24,Expo SDK 54,React Native 0.81.5 目标:从零构建一个可在 Android 真机安装的俄罗斯方块 App


一、需求梳理

开始之前明确了以下核心目标:

  1. 游戏本体:标准俄罗斯方块逻辑(10×20 棋盘、7 种方块、旋转、消行、计分)
  2. 视觉风格:暗黑系霓虹配色,科技感
  3. 触觉反馈:接入 expo-haptics,不同操作触发不同强度震动
  4. 操控方式:屏幕下半部分的大按钮,适合触屏
  5. 可安装:最终产物是可直接安装的 Android APK

二、脚手架与依赖安装

2.1 创建项目

npx create-expo-app@latest tetris --template blank

使用 blank 模板,得到最精简的 Expo 项目骨架,避免不必要的样板代码。

2.2 安装额外依赖

cd tetris
npx expo install expo-haptics
npx expo install react-native-safe-area-context

为什么用 npx expo install 而不是 npm install

expo install 会自动查询当前 Expo SDK 版本对应的兼容版本号,避免手动指定版本导致的原生模块不兼容问题。


三、架构设计

3.1 文件拆分原则

将代码拆成三层:

constants.js   ← 纯数据(不依赖任何框架)
gameLogic.js   ← 纯函数(不依赖 React)
App.js         ← React 组件(UI + 状态 + 副作用)

这样做的好处:

  • constants.jsgameLogic.js 可以独立测试
  • 逻辑与 UI 解耦,未来替换渲染层(如 Skia)不需要改逻辑

3.2 方块数据结构

每种方块预存 4 个旋转态的 4×4 矩阵,矩阵中的数字就是颜色索引:

// I 型方块,旋转态 0(横向)
[[0,0,0,0],
 [1,1,1,1],   // 1 = 青色
 [0,0,0,0],
 [0,0,0,0]]

预存所有旋转态(而不是运行时计算旋转矩阵)的好处是:简单、无 bug、性能好。

3.3 碰撞检测

function collides(board, piece, dx, dy, rotation) {
  // 遍历 4×4 矩阵中每个非零格
  // 计算其在棋盘上的实际坐标 (nx, ny)
  // 检查:越左墙、越右墙、越底板、压到已锁定格
}

dx / dy 参数允许"假设移动后检测",不需要真正移动方块就能知道是否合法。

3.4 墙踢(Wall Kick)

旋转时如果直接碰墙,依次尝试 x 偏移 [0, -1, +1, -2, +2],找到第一个不碰撞的位置就用它。这是简化版的 SRS(Super Rotation System)。


四、关键技术决策与踩坑

4.1 游戏循环的 Stale Closure 问题(重要)

第一版架构(有 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。

4.2 全面屏顶部遮挡问题

问题: 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} />,防止状态栏透明叠加在内容上方。

4.3 游戏循环速度随等级变化

每次等级变化都需要重建 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 的回调永远调用最新版本。


五、Haptics 触觉反馈设计

操作 API 感受
左移 / 右移 / 旋转 impactAsync(Light) 轻微一振,确认操作
方块触底固化 impactAsync(Medium) 中度一振,感受"落地"
硬降 impactAsync(Heavy) 强烈一振,感受冲击
消行成功 notificationAsync(Success) 成功节奏振动
游戏结束 notificationAsync(Error) 错误节奏振动

注意:Haptics 在 iOS 模拟器上无效,需真机测试。Android 模拟器部分支持。


六、EAS 打包配置

6.1 为什么用 EAS Build

EAS(Expo Application Services)在云端服务器上执行 Android/iOS 构建,本地无需安装 Android Studio、JDK、Gradle 等工具链,极大降低环境配置成本。

6.2 配置流程

# 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)

6.3 触发构建

npx eas-cli build --profile preview --platform android

构建日志可在 https://expo.dev/accounts/nightmoon/projects/tetris 实时查看。


七、遇到的其他问题

npx eas-cli build:configure 在非交互式终端失败

EAS CLI 的 build:configure 命令需要 TTY(交互式终端)来显示选择提示,在某些终端环境(如 IDE 内置终端、CI 环境)下会直接报错。

解决方案: 手动完成它做的两件事:

  1. npx eas-cli project:init --force 创建云端项目并写入 projectId
  2. 手动创建 eas.json

node -e "require('./App.js')" 报 JSX 语法错误

Node.js 原生不支持 JSX 语法,这是正常现象,不代表代码有问题。JSX 需要通过 Babel(Expo 内置)转译后才能运行。验证 JSX 文件语法应使用 npx expo start 或专门的 linter。


八、后续开发建议

  1. 添加音效:使用 expo-av,在消行、触底时播放音效
  2. 持久化最高分:使用 @react-native-async-storage/async-storage
  3. 手势控制:使用 react-native-gesture-handler 实现滑动操作
  4. 消行动画:使用 react-native-reanimated 做行消除的闪光动画
  5. 单元测试src/gameLogic.js 是纯函数,可以直接用 Jest 测试,无需 mock React

九、参考资料