1. 把推箱子题目拆开,先看清这到底是个什么需求
“React Native 鸿蒙跨平台实现推箱子游戏,完成玩家移动与箱子推动,当所有箱子都被推到目标位置时,玩家获胜。”这个标题乍看是一整句需求描述,拆开其实是一道特别经典的游戏编程题:在地图网格里控制一个角色移动,角色碰到箱子就把箱子往前推,箱子不能穿墙也不能同时推两个,最后判断每个目标点是否都被箱子占住。
核心点其实就三个:玩家移动、箱子推动、胜利判定。听起来简单,但真正落地时会牵扯到坐标系设计、碰撞检测、地图数据建模、状态同步、UI渲染、跨平台适配。尤其是当目标平台变成鸿蒙设备,又不希望只做一个单平台 Demo 时,React Native 就成了一个很实际的选项:游戏逻辑跑在 JavaScript/TypeScript 一层,UI 用 React 组件描述,底层鸿蒙原生控件负责渲染渲染,一套代码在多个平台复用。这不只是省工作量,更重要的是让“游戏规则”和“平台差异”彻底解耦,后续维护时不需要把一份逻辑复制多份。
这篇文章我会把这个推箱子项目从需求拆解到完整实现全部过一遍。整个项目适合有一定 React Native 基础、想试试游戏类应用的人,也适合刚接触鸿蒙跨平台开发的读者。你跟着把地图数据、移动判断、界面渲染、事件处理这几块搭起来,就能得到一个小而完整的可玩 GameDemo,再往里面加关卡、加撤销、加音效都是水到渠成的事。
2. 整体设计与地图数据建模
2.1 用二维数组表达地图,是推箱子最朴素的解法
推箱子的地图天然就是二维网格,所以用二维数组表达是最直观的。一个典型的关卡可以这样描述:每一格可能是墙、空地、箱子、玩家、目标点,而目标点可以被箱子覆盖,也可以被玩家站上去,这就需要一套数字编码来处理叠加状态。
我最终采用的是“静态底图 + 动态棋子”两个层次:
- 底图只存三种元素:墙、空地、目标点。
- 动态层存玩家位置和所有箱子位置。
export const TILE = { EMPTY: 0, WALL: 1, TARGET: 2, BOX: 3, PLAYER: 4, } as const; export interface Point { row: number; col: number; } export interface GameState { player: Point; boxes: Point[]; }这样做的最大好处是:渲染时可以通过“底图单元格 + 当前是否有箱子 + 当前是否有玩家”来推导出最终画面,而不需要维护一堆“箱子已经在目标上”“玩家已经在目标上”的混合状态。否则到后面判断胜利、重置关卡、做撤销时,状态组合会爆炸。
2.2 一个最小可玩的关卡长什么样
我准备了一个 5x5 的基础关卡,格子的具体含义如下:
export const LEVEL_1 = [ [1, 1, 1, 1, 1], [1, 0, 0, 0, 1], [1, 0, 4, 0, 1], [1, 0, 3, 2, 1], [1, 1, 1, 1, 1], ];数字对应关系:1 是墙,0 是空地,2 是目标点,4 是玩家,3 是箱子。这一关里玩家只需要向右推一步,就能把箱子推上右边的目标点。
实际项目中关卡数据不会直接拿来当运行状态,因为玩家和箱子是会移动的。所以我写了一个解析函数,把 LEVEL_1 这种带初始棋子的二维数组拆成“底图”和“初始状态”两份干净的数据:
export function parseLevel(source: number[][]) { const baseMap = source.map(row => row.map(cell => cell === TILE.WALL || cell === TILE.TARGET ? cell : TILE.EMPTY ) ); const boxes: Point[] = []; let player: Point | null = null; source.forEach((row, r) => { row.forEach((cell, c) => { if (cell === TILE.PLAYER) { player = { row: r, col: c }; } if (cell === TILE.BOX) { boxes.push({ row: r, col: c }); } }); }); if (!player) { throw new Error('地图里至少需要一个玩家'); } return { baseMap, initialState: { player, boxes }, }; }思路不复杂:底图里只保留墙和目标点,其余格子都转成空地;箱子坐标收集到数组,玩家坐标单独存。以后做多关卡时只需要继续往数组里塞数据,解析逻辑完全不用动。
2.3 把游戏引擎与 React 组件拆开,比想象中更重要
很多人写小游戏时会直接把地图 state 塞进 React 组件里,然后在按钮的 onPress 里直接改数组。这个做法在功能上跑得通,但很容易让代码越来越难维护。我的建议是:把“移动逻辑”写成纯函数,组件只负责调用它、渲染结果。
纯函数的意思是:给一个旧状态和移动方向,返回一个新状态,不直接修改旧状态里的数组。这样做有实打实的好处:
- 逻辑不依赖 React,不依赖鸿蒙或 iOS 的 API,可以随意做单元测试;
- 撤销、重做、自动求解这类功能都能建立在“状态快照”上;
- 以后如果要把游戏逻辑搬到别的界面框架里,可以直接复用。
React 组件里的状态就保持单一来源,统一用useState管理一个GameState对象。后面你会在移动函数里看到,每次操作后都返回一个新对象,React 才能正确触发重渲染。
3. 核心逻辑实现:移动、推箱、胜负判断
3.1 玩家移动与越界、撞墙判断
玩家移动是所有逻辑的第一步。这里我采用 row/col 作为坐标,row 表示第几行,col 表示第几列,和屏幕坐标的 x/y 并不一样。向上移动时 row 减 1,向下移动时 row 加 1;向左移动 col 减 1,向右移动 col 加 1。
export function moveState( state: GameState, baseMap: number[][], dx: number, dy: number ): GameState { const { player, boxes } = state; const nr = player.row + dy; const nc = player.col + dx; const rows = baseMap.length; const cols = baseMap[0].length; if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) { return state; } if (baseMap[nr][nc] === TILE.WALL) { return state; } const boxIndex = boxes.findIndex(b => b.row === nr && b.col === nc); if (boxIndex === -1) { return { ...state, player: { row: nr, col: nc }, }; } // 下面是推箱子的分支 }很多人会忽略边界判断,直接把baseMap[nr][nc]拿出来比较,结果玩家走到地图边缘时,nr或nc超出数组范围,轻则取到 undefined,重则直接报错。先判断行列坐标是否越界,再判断是墙还是空地,这是一个非常基础的习惯。
为什么碰到箱子时不直接让玩家穿过去?因为推箱子的规则就是:玩家面前如果有箱子,玩家不能占据箱子所在格,只能让箱子往更前方滚动一格。如果箱子前方也是墙或者被另一个箱子挡住,那这一步移动就不合法,玩家停在原地。
3.2 箱子的推动与连续推动细节
箱子推动是整个游戏里最容易写错的地方。箱子的新位置等于“玩家移动方向再往前一格”,所以要用nr + dy和nc + dx计算。此时要判断两个条件:箱子前方是不是墙,箱子前方有没有其他箱子。
const nbr = nr + dy; const nbc = nc + dx; const beyondIsWall = baseMap[nbr]?.[nbc] === TILE.WALL; const beyondHasBox = boxes.some(b => b.row === nbr && b.col === nbc); const beyondOutOfBounds = nbr < 0 || nbr >= rows || nbc < 0 || nbc >= cols; if (beyondOutOfBounds || beyondIsWall || beyondHasBox) { return state; } const nextBoxes = boxes.map((box, index) => index === boxIndex ? { row: nbr, col: nbc } : box ); return { player: { row: nr, col: nc }, boxes: nextBoxes, }; }这里有个特别容易忽略的点:箱子可能正站在目标点上。从视觉上看,你可能会用“箱子在目标点”和“箱子不在目标点”两种状态分别渲染,但在逻辑层并没必要区分。底图里的目标点一直都在,箱子数组里记录的是它的坐标。我只需要在渲染时判断“这个坐标在底图里是不是 TARGET”,是就画成“箱子+目标点叠加样式”,不是就画成普通箱子。
这种设计避免了推箱子时反复改写底图的状态,也避免了“箱子离开目标点后目标点消失”的经典 Bug。你只需要保证:箱子位置的底图信息永远不变,箱子位置本身是可变的。
如果箱子前方是目标点,我并不会把它特殊标记成一个“已到位箱子”,因为箱子数组里的坐标已经说明一切。胜利时只需要逐个比对箱子坐标对应的底图是不是 TARGET。
3.3 胜利判定:不是“没有箱子失败”,而是“每个箱子都在目标点上”
胜利条件听起来很直接:所有箱子都被推到目标位置。实现上写成:
export function checkWin(boxes: Point[], baseMap: number[][]): boolean { return boxes.every(box => { const cell = baseMap[box.row]?.[box.col]; return cell === TILE.TARGET; }); }这里我推荐用every,语义和需求完全一致:只要有一个箱子不在目标点,就返回 false。有些人会反过来写“不存在不在目标点上的箱子”,逻辑上等价,但可读性差很多。
需要特别提醒的是:初始地图里箱子的数量和目标点的数量必须相等。如果箱数少于目标数,就算所有箱子都放在目标点上,仍然有空目标点,游戏永远无法获胜;如果箱数多于目标数,就永远有多余箱子推不上目标。所以多关卡里最好在解析时加一个校验,比如统计TARGET和BOX的数量,不一致就直接报警,避免玩家卡死在不可能通关的关卡里。
3.4 React 状态更新方式:为什么一定要用函数式更新
React 的状态更新是异步的,而且同一时刻多次点击时,game这个闭包变量未必能拿到最新值。比如快速连按两次方向键,如果直接在 onPress 里写:
const next = moveState(game, baseMap, dx, dy); setGame(next);第二次点击时拿到的game可能还是旧状态,结果两次移动基于同一份旧数据计算,最终只生效一步,玩家会觉得按键“失灵”。
正确做法是用函数式更新,让 React 保证传入的一定是最新状态:
function move(dx: number, dy: number) { setGame(prev => moveState(prev, baseMap, dx, dy)); }这样无论点击多快,每次更新都建立在最新状态之上。步骤计数也可以通过比较函数返回值是否和旧状态是同一个引用来判断是否算作有效移动:
function move(dx: number, dy: number) { let moved = false; setGame(prev => { const next = moveState(prev, baseMap, dx, dy); if (next === prev) { return prev; } moved = true; return next; }); if (moved) { setSteps(prev => prev + 1); } }注意moveState在非法移动时直接返回原对象,所以判断next === prev就能区分有效移动和无效移动,不会给玩家的步数多加计数。
4. React Native 界面搭建:从地图渲染到操作交互
4.1 准备组件状态和初始化数据
游戏界面需要一个初始关卡,用useState存当前游戏状态。为了避免每次渲染都重新解析关卡,我习惯用useMemo缓存初始数据:
import React, { useEffect, useMemo, useState } from 'react'; import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; const levelData = useMemo(() => parseLevel(LEVEL_1), []); const [game, setGame] = useState(levelData.initialState); const [steps, setSteps] = useState(0); const [won, setWon] = useState(false); useEffect(() => { if (checkWin(game.boxes, levelData.baseMap)) { setWon(true); } }, [game.boxes]);won状态没有塞进game对象里,是因为它属于界面层的东西。核心纯函数只负责判断“这一堆箱子坐标是否全部命中目标点”,至于要不要弹窗、要不要放礼花,那是 UI 层需要考虑的。这样职责更清楚,也方便以后接语音提示、震动反馈等平台能力。
重置操作也很简单:重新生成一份初始状态,同时把步数和胜利状态清空。
function resetGame() { setGame({ player: { ...levelData.initialState.player }, boxes: levelData.initialState.boxes.map(box => ({ ...box })), }); setSteps(0); setWon(false); }这里为什么要复制一份对象?因为useState里的对象如果直接复用,重置后可能和初始状态对象共享引用,后续一旦误操作修改了某个字段,初始数据也被污染。浅拷贝一层虽小,却是避免诡异 Bug 的好习惯。
4.2 把二维数组渲染成游戏棋盘
棋盘渲染本质上就是两层循环。外层遍历行,内层遍历列,每格渲染一个<View>,再根据格子的类型决定背景样式。
function renderCell(row: number, col: number) { const hasBox = game.boxes.some(b => b.row === row && b.col === col); const isPlayer = game.player.row === row && game.player.col === col; const base = levelData.baseMap[row][col]; let cellStyle = styles.empty; if (base === TILE.WALL) { cellStyle = styles.wall; } else if (hasBox) { cellStyle = base === TILE.TARGET ? styles.boxOnTarget : styles.box; } else if (isPlayer) { cellStyle = base === TILE.TARGET ? styles.playerOnTarget : styles.player; } else if (base === TILE.TARGET) { cellStyle = styles.target; } return <View key={`${row}-${col}`} style={[styles.cell, cellStyle]} />; } <View style={styles.board}> {levelData.baseMap.map((rowData, row) => ( <View style={styles.row} key={row}> {rowData.map((_, col) => renderCell(row, col))} </View> ))} </View>样式上我习惯把格子做成固定边长,并让棋盘整体居中:
const styles = StyleSheet.create({ board: { alignItems: 'center', }, row: { flexDirection: 'row', }, cell: { width: 44, height: 44, }, empty: { backgroundColor: '#f0efe4', }, wall: { backgroundColor: '#555b66', }, target: { backgroundColor: '#faf3c0', borderWidth: 2, borderColor: '#d9b48f', }, box: { backgroundColor: '#c8935a', }, boxOnTarget: { backgroundColor: '#7fae5f', }, player: { backgroundColor: '#3f8fc9', }, playerOnTarget: { backgroundColor: '#2d6fa8', }, });用颜色区分不同元素只是最基础方案。想要更好看,也可以把每个格子替换成 Image 或 Icon,但核心渲染逻辑都是一样的:根据当前格子的“底图类型 + 动态层状态”选择资源。
4.3 方向按钮与手势滑动的实现
小游戏在手机上最常见的交互有两种:点按方向键、滑动屏幕。方向键最稳,实现也简单,四个按钮按行列方向触发移动:
<View style={styles.controls}> <View style={styles.controlRow}> <TouchableOpacity style={styles.button} onPress={() => move(0, -1)}> <Text style={styles.buttonText}>上</Text> </TouchableOpacity> </View> <View style={styles.controlRow}> <TouchableOpacity style={styles.button} onPress={() => move(-1, 0)}> <Text style={styles.buttonText}>左</Text> </TouchableOpacity> <TouchableOpacity style={styles.button} onPress={() => move(1, 0)}> <Text style={styles.buttonText}>右</Text> </TouchableOpacity> </View> <View style={styles.controlRow}> <TouchableOpacity style={styles.button} onPress={() => move(0, 1)}> <Text style={styles.buttonText}>下</Text> </TouchableOpacity> </View> </View>按钮点击区域尽量做大一些,比如 64x64 以上,减少误触。鸿蒙设备上有不少全面屏机型,屏幕边缘容易触发系统手势,所以底部控制区要留足安全距离。
如果还想支持滑动操作,React Native 的PanResponder是很直接的选择:
const panResponder = useRef( PanResponder.create({ onPanResponderRelease: (_, gestureState) => { const dx = gestureState.dx; const dy = gestureState.dy; if (Math.abs(dx) < 30 && Math.abs(dy) < 30) { return; } if (Math.abs(dx) > Math.abs(dy)) { move(dx > 0 ? 1 : -1, 0); } else { move(0, dy > 0 ? 1 : -1); } }, }) ).current;滑动判断的要点是:先设定一个阈值,避免普通点击被误判成滑动;再比较横向纵向位移的绝对值,取更明显的一个方向作为移动方向。这套逻辑本质上和推箱子无关,是一种通用的手势方向识别,以后在小游戏、轮播图、翻页组件里都能复用。
把panResponder.panHandlers展开到棋盘容器上,滑动和按钮就能同时生效。要注意的是容器层级,不要让 PanResponder 拦截掉子层按钮的点击,通常把panHandlers放在棋盘区域外层的 View 上更安全。
4.4 步数、胜利提示和重置按钮
游戏界面上还需要几个简单的状态元素。步数可以用一个文本显示,胜利提示则在won为 true 时显示。这个小东西虽然不起眼,但体验上的差别很大:玩家推箱子时不知道自己走了多少步,就会缺少目标和成就感。
<View style={styles.statusBar}> <Text style={styles.statusText}>步数:{steps}</Text> {won ? <Text style={styles.winText}>恭喜通关</Text> : null} <TouchableOpacity style={styles.resetButton} onPress={resetGame}> <Text style={styles.resetText}>重置</Text> </TouchableOpacity> </View>胜利弹窗、通关后自动进入下一关、播放音效,这些都可以基于won这个状态继续扩展。短期内不需要做得太复杂,先把核心玩法闭环跑通。
5. 鸿蒙跨平台适配与常见坑
5.1 跨平台运行时的界面差异处理
React Native 在鸿蒙生态里的核心优势是:JavaScript 逻辑和 React 组件描述能直接复用。比如推箱子里的数组遍历、React state、TouchableOpacity 交互,在 iOS、Android、鸿蒙设备上行为基本一致。真正有差异的是平台视觉特性。
鸿蒙设备目前有不少大屏、折叠屏机型,屏幕比例和 iOS/Android 主流机型并不完全一样。如果格子宽度写死 44,在大屏上会显得棋盘偏小;在小屏上又可能被刘海、挖孔等区域遮挡。所以我更推荐用动态尺寸:
import { useWindowDimensions } from 'react-native'; const screenWidth = useWindowDimensions().width; const boardSize = Math.min(screenWidth - 32, 360); const cellSize = boardSize / levelData.baseMap[0].length;用useWindowDimensions拿到实时窗口尺寸后,格子大小根据列数动态计算。这样横竖屏切换时,棋盘也能自动适配。计算时记住留出左右边距,别把棋盘顶到屏幕边缘,否则全面屏的圆角区域会挡住一部分格子。
安全区域也要注意。底部控制按钮如果和系统手势区域重叠,玩家操作时很容易触发返回手势。可以给整个页面套一个安全区容器,或者给底部控制区加动态 padding。具体写法取决于你在用的导航组件,但核心思路是一样的:让可交互元素避开系统手势热区。
5.2 触摸与按钮交互在鸿蒙设备上的注意点
鸿蒙设备上的触摸事件和 React Native 标准触摸体系整体兼容,但我在实际测试中遇到过几个值得注意的现象:
- 按钮点击区域太小会偶尔没反应。系统级触摸目标建议至少 44x44,实际项目里我一般做成 64x64 以上。
- 快速连续点按时,如果按钮没有做防抖,可能一次点按被识别成两次。这在推箱子里反而可以通过函数式更新规避,因为移动逻辑本身是幂等的。
- PanResponder 和 ScrollView 同时存在时会抢手势,所以游戏界面尽量不要包一层可滚动容器,除非你明确设置了手势优先级。
另外,在鸿蒙设备上如果按钮用了比较特殊的字体,个别汉字可能显示宽度不一致,导致按钮里的“上、下、左、右”居中不准确。稳妥的做法是给按钮文本固定宽度,或者把文字单独居中,而不是依赖按钮的 padding 自动撑开。
5.3 常见问题与排查速查表
我把实际开发里最常踩的坑整理成一个速查表,你可以直接对照检查:
| 现象 | 可能原因 | 处理方法 |
|---|---|---|
| 玩家移动到地图边缘后画面卡死或报错 | 没有做边界判断,访问了不存在的数组下标 | 移动前先检查 nr/nc 是否在合法范围 |
| 玩家能直接走到箱子上 | 没有检测目标格是否已被箱子占用 | 在普通移动分支前先查 boxes.findIndex |
| 箱子被推出地图边界 | 只检测箱子前方是墙,没检测越界 | 推箱分支里对箱子新坐标做同样的边界检查 |
| 两个箱子叠在一起 | 推箱时只检查了墙,没检查箱子的新坐标有没有被其他箱子占位 | 用 boxes.some 检查箱子新坐标 |
| 通关条件永远不满足 | 目标点数量和箱子数量不一致 | 解析关卡时单独统计 TARGET 和 BOX 数量 |
| 快速连按方向键只移动一次 | 使用了闭包中的旧 game 状态 | 改成 setGame(prev => moveState(prev, ...)) |
| 重置后箱子位置错乱 | 重置时直接复用了初始对象的引用 | 对 player 和每个 box 做浅拷贝 |
| 鸿蒙端棋盘被状态栏或挖孔遮挡 | 布局没有考虑安全区域 | 使用安全区组件或动态 padding |
| 滑动屏幕时方向判断不对 | dx/dy 的阈值或方向判断逻辑写反 | 确认 row 增加方向与 dy 正负的对应关系 |
还有一个很多人会忽视的细节:如果状态更新后需要立刻读取最新game值做一些操作,不要直接写在 setGame 后面,因为此时 state 还没更新完。正确的方式是放到useEffect里监听game变化,或者在函数式更新内部处理。这个坑在 React 里很经典,在 React Native 里也一样,无论目标平台是 iOS、Android 还是鸿蒙,都要遵循同一套状态更新规则。
5.4 关于鸿蒙适配的一点实际体会
这个项目选择 React Native 跨平台方案,不是因为鸿蒙原生开发不行,而是因为推箱子这种逻辑密度高、平台交互少的应用,用跨端方案能把开发效率拉满。纯游戏逻辑写一次,UI 组件复用,真正需要针对鸿蒙做调整的可能只是屏幕适配、触摸热区、安全边距这些外围细节。
如果你后续想把游戏做得更有“鸿蒙味”,可以考虑把界面中的按钮替换成系统组件风格,或者接入震动反馈、系统分享等能力。这些扩展通常不影响已有的移动和推箱核心逻辑。
我个人在实际操作中最有体会的一点是:永远不要把“判断箱子是否到位”写成“箱子坐标等于目标坐标数组里某一个”这样的强匹配。因为只要关卡地图稍大一点,这种写法会越改越乱。基于底图判断目标点的方式在任何复杂度下都稳定,这才是推箱子逻辑层的正确打开方式。
另外,项目里如果打算加多关卡,建议把关卡数据从代码里拆出去,单独放成一个 JSON 或 TS 常量文件,解析逻辑保持单一。后续加关卡、做编辑器、测试自动求解都会顺手很多。推箱子这种小游戏虽然体量不大,但把数据、逻辑、UI 三层边界划清楚之后,它应对后续扩展的能力会远超你的预期。