资讯动态

React Native 鸿蒙跨平台:从零实现一个推箱子小游戏的完整指南

发布时间:2026/10/18 17:46:58 来源:尧图企业网站定制
1. 立项逻辑为什么一个小游戏也要走 React Native 鸿蒙跨平台先说这个项目的来由。当时手头接到一个需求做一个跑在鸿蒙设备上的推箱子小游戏但同一份玩法逻辑后面还得复用到安卓和 iOS。很多人会觉得推箱子就是个几百行逻辑的网格小游戏直接在每个平台各写一遍不就行了问题是三套代码意味着三份地图编辑器、三份移动判定、三份关卡数据后续加一个功能就要改三个地方。与其这样不如从一开始就选一条能覆盖全端的路线用 React Native 和鸿蒙的跨平台能力把“玩法的壳”和“系统的壳”分开处理。这个项目的目标很简单玩家在地图上上下左右移动遇到箱子就推着走箱子不能穿过墙也不能连续推两个箱子当所有箱子都被推到目标点上时判定获胜。玩法本身不复杂但“跨平台 鸿蒙真机可跑”这两个约束让整个实现过程比单纯写一个网页版推箱子要有意思得多。这篇文章就把我实际搭建这套东西的思路、核心逻辑、以及踩过的坑完整拆一遍适合正在做 RN 跨平台小游戏、或者想了解鸿蒙侧接入方式的人参考。1.1 三种实现路径的对比原生三套、游戏引擎、RN动手之前我先列了一下可选方案避免做到一半才发现选错了技术栈。方案鸿蒙适配成本开发效率适合的游戏类型我的判断安卓/iOS/鸿蒙各写一套原生低原生支持最好低三套代码维护成本高对平台能力依赖极深的重度游戏排量小游戏不值得Cocos/Unity 等游戏引擎中等需要导出到目标平台较高但引擎体量大、学习曲线陡动效多、场景复杂的游戏推箱子的画面复杂度用不上React Native 鸿蒙适配层中低核心组件基本可用高玩法逻辑用一份 TS 就能通吃逻辑型、网格型、休闲型游戏最匹配这个项目我最终选了 React Native并不是因为它完美而是因为它在这里“够用且划算”。推箱子没有物理引擎需求、没有复杂粒子特效、没有高帧率渲染压力它的核心是状态机是“地图数据 玩家坐标 箱子坐标”在每次按键后的确定性变化。这类游戏最怕的不是性能而是逻辑分散在各端导致行为不一致RN 天然把逻辑收敛到了一处。当然游戏引擎也不是不能做Unity 导出到鸿蒙生态也有成熟的方案但为了一个 8×8 的网格小游戏引入整套引擎构建产物会膨胀不少真机上冷启动也明显更慢。RN 方案还有一个隐藏好处游戏核心逻辑可以写成纯函数完全脱离 UI 层后续如果要接一个无障碍版本、自动化测试甚至 Web 端核心代码几乎不用动。1.2 鸿蒙跨平台的适配现状与项目边界聊到鸿蒙得先把预期管理做好。React Native 官方主链路支持的是安卓和 iOS鸿蒙侧目前是通过社区维护的适配层来运行 JS 代码和渲染原生组件的。我实际体验下来因为这个推箱子项目只用到了View、Text、Pressable、PanResponder这些基础能力没有碰摄像头、定位、蓝牙这类原生模块所以适配层的覆盖范围完全够用。这也是我刻意控制的“项目边界”凡是能不用原生模块的一律不用。地图渲染用纯 View 和颜色不用本地图片资源弹窗用普通 View 做绝对定位不依赖系统 Modal输入用 RN 自带手势系统不接原生键盘。这样做的原因很简单任何一个原生模块在鸿蒙适配层上都有可能存在行为差异能绕开就绕开跨平台的项目最怕的就是“在某个平台上功能没对齐”。在开始写代码前我先给整个项目定了一个结构最底层是一套与平台无关的游戏逻辑纯函数中间是 React 的状态管理和 UI 组件最外层才是平台相关的入口和构建配置。后面所有步骤都是围绕这个分层展开的这一层设计直接决定了后面会不会重新返工。2. 地图建模与棋盘渲染先把“房间”搭出来推箱子的第一件事不是写移动逻辑而是把关卡地图变成一个程序能理解的数据结构。我见过不少初学者一上来就用对象数组存每个格子的完整信息字段又多又乱后面改起来非常痛苦。这里我推荐用“静态层 动态层”分离的方式这也是大多数正经推箱子实现采用的做法。2.1 用字符关卡特格式化出静态地图与动态实体我的关卡源数据是字符串数组每一个字符代表关卡特中的一个元素#表示墙.表示目标点$表示箱子表示玩家空格表示空地这种格式的好处是肉眼直接能看出关卡长什么样设计新关卡时在文本文件里画格子就行不用去数二维数组下标。export type Position { x: number; y: number }; // 静态地图0 空地1 墙2 目标点 export type StaticMap number[][]; export interface GameState { staticMap: StaticMap; player: Position; boxes: Position[]; steps: number; history: GameState[]; } function createLevel(rows: string[]) { const staticMap: StaticMap []; const boxes: Position[] []; let player: Position { x: 0, y: 0 }; rows.forEach((line, y) { const row: number[] []; [...line].forEach((ch, x) { switch (ch) { case #: row.push(1); break; case .: row.push(2); break; case $: row.push(0); boxes.push({ x, y }); break; case : row.push(0); player { x, y }; break; default: row.push(0); } }); staticMap.push(row); }); return { staticMap, player, boxes }; } const LEVEL_1 createLevel([ #####, # #, # $.#, # #, #####, ]);这里有个细节值得注意$和在静态地图里都记为 0也就是空地。箱子、玩家这类会变化的东西全部放进动态数据结构里而墙、目标点这类不变的东西才属于静态地图。这个分离不是洁癖后面移动判定、胜利检测、重开一局都要依赖这种清晰分层。2.2 动态层与静态层分离的原因为什么不能直接把箱子也写进静态地图里因为箱子的位置是变化的。如果混在一张图里每一次移动都要复制整张地图再修改两个格子判断“这个位置是目标点”还得额外记一套坐标集合代码会越写越绕。拆开之后渲染逻辑就变得很干净先画静态地图地板、墙、目标点再叠加动态内容箱子、玩家。判断“箱子是否在目标点上”只需要把箱子坐标集合和目标点坐标集合做一次交集判断。判断“这次移动是否合法”也只需要查静态地图的墙和动态箱子列表各司其职。还有一个实际好处是撤销功能。推箱子玩着玩着容易走进死胡同我做了“撤销一步”按钮。如果把箱子和地图混在一起撤销时要恢复的旧状态非常复杂分离之后撤销只需要把player和boxes恢复成上一步的快照就行静态地图从头到尾都不用动。2.3 棋盘渲染与图元绘制方案渲染我用的是绝对定位。棋盘容器根据屏幕宽度算出一个总尺寸再除以行数和列数的最大值得到每个格子的边长然后每个格子用left x * tileSize、top y * tileSize的方式铺开。绝对定位在网格类游戏里比 Flex 布局好用因为它天然就是一个坐标系格子之间不会因为内容大小不同而互相挤压。import React from react; import { View, StyleSheet, Dimensions } from react-native; const COLORS { floor: #f5f0e8, wall: #4a4a52, goal: #f6c344, box: #b5732f, boxOnGoal: #3f9e4e, player: #2f6fd6, }; const screenWidth Dimensions.get(window).width; function getGoalPositions(staticMap: StaticMap): Setstring { const goals new Setstring(); staticMap.forEach((row, y) { row.forEach((tile, x) { if (tile 2) goals.add(${x},${y}); }); }); return goals; } function Board({ staticMap, player, boxes }: { staticMap: StaticMap; player: Position; boxes: Position[]; }) { const rows staticMap.length; const cols staticMap[0].length; const boardSize Math.min(screenWidth - 24, 360); const tileSize boardSize / Math.max(rows, cols); const goalSet getGoalPositions(staticMap); const boxSet new Set(boxes.map((b) ${b.x},${b.y})); return ( View style{{ width: cols * tileSize, height: rows * tileSize }} {staticMap.map((row, y) row.map((tile, x) { const isGoal tile 2; const key ${x},${y}; const hasBox boxSet.has(key); const boxOnGoal hasBox goalSet.has(key); const hasPlayer player.x x player.y y; let bg COLORS.floor; if (tile 1) bg COLORS.wall; else if (isGoal) bg COLORS.goal; if (hasBox) bg boxOnGoal ? COLORS.boxOnGoal : COLORS.box; return ( View key{key} style{{ position: absolute, left: x * tileSize, top: y * tileSize, width: tileSize, height: tileSize, backgroundColor: bg, borderWidth: StyleSheet.hairlineWidth, borderColor: rgba(0,0,0,0.12), }} {hasPlayer View style{styles.player} /} /View ); }) )} /View ); } const styles StyleSheet.create({ player: { position: absolute, left: 22%, top: 22%, width: 56%, height: 56%, borderRadius: 999, backgroundColor: COLORS.player, }, });这里我特意没有用图片资源而是纯 View backgroundColor。原因有两个一是跨平台项目里图片路径在鸿蒙侧的解析规则和安卓不一样用图片就要维护两套资源映射二是推箱子这种方块感很强的游戏纯色块反而更有辨识度目标点用黄色底、箱子用棕色、箱子到位用绿色一眼就能看懂当前状态。玩家用圆形内嵌在格子里和箱子的方块形成视觉差异操作时不会看混。提示格子尺寸不要直接用boardSize / cols然后让行数多时溢出。用Math.max(rows, cols)做分母可以保证地图无论偏横还是偏竖都能完整落在棋盘区域内。3. 玩家移动与箱子推动判定逻辑的核心是“只改动态层”移动逻辑是整个推箱子的灵魂。它不复杂但非常容易在边界条件上写漏。我把它拆成三个层次来想玩家要去的格子是什么如果是空地或目标点直接走如果是墙原地不动如果是箱子还要看箱子前方的格子是什么。每一步只回答这三个问题逻辑就不会乱。3.1 三种可移动结果的判定顺序方向向量我做了统一映射玩家坐标加上方向增量得到目标格。移动时严格按下述顺序判定玩家目标格是墙或越界 → 这次移动无效返回原状态。玩家目标格不是箱子 → 空地或目标点把玩家坐标更新过去。玩家目标格是箱子 → 看箱子再往前一格箱子目标格如果是墙或越界 → 箱子推不动移动无效如果已经有一个箱子 → 不能叠箱子移动无效否则 → 同时更新玩家坐标和对应箱子的坐标。export type Direction up | down | left | right; const DIR_DELTA: RecordDirection, Position { up: { x: 0, y: -1 }, down: { x: 0, y: 1 }, left: { x: -1, y: 0 }, right: { x: 1, y: 0 }, }; function isWall(map: StaticMap, x: number, y: number): boolean { if (y 0 || y map.length || x 0 || x map[0].length) return true; return map[y][x] 1; } function isBoxAt(boxes: Position[], x: number, y: number): boolean { return boxes.some((b) b.x x b.y y); } function tryMove(state: GameState, dir: Direction): GameState | null { const { x: dx, y: dy } DIR_DELTA[dir]; const nx state.player.x dx; const ny state.player.y dy; if (isWall(state.staticMap, nx, ny)) { return null; } if (isBoxAt(state.boxes, nx, ny)) { const bx nx dx; const by ny dy; if (isWall(state.staticMap, bx, by) || isBoxAt(state.boxes, bx, by)) { return null; } const nextBoxes state.boxes.map((b) b.x nx b.y ny ? { x: bx, y: by } : b ); return { ...state, player: { x: nx, y: ny }, boxes: nextBoxes, steps: state.steps 1, history: [...state.history, snapshot(state)], }; } return { ...state, player: { x: nx, y: ny }, steps: state.steps 1, history: [...state.history, snapshot(state)], }; } function snapshot(state: GameState): GameState { return { staticMap: state.staticMap, player: { ...state.player }, boxes: state.boxes.map((b) ({ ...b })), steps: state.steps, history: [], }; }这段代码里最重要的一点是移动不直接修改原状态而是返回一个新状态。这既是为了让 React 能正确触发重新渲染也是撤销功能的基础。history里存的是每次移动前的一张快照快照里的history置空了避免层层嵌套导致内存膨胀。3.2 边界条件越界、撞墙和“推不动”我在写初版时犯过一个典型的错只判断了玩家撞墙忘了判断箱子推出地图边界。比如箱子在最后一行玩家在它下面往上推这时候箱子的目标坐标已经跑到地图外面去了数组下标直接访问会得到 undefined再往下处理就是一堆诡异行为。所以边界检查必须同时覆盖玩家和箱子两侧最好的办法是把“越界”统一当作“墙”处理也就是isWall里最前面的范围判断。这样语义上也能讲通地图边界是一堵看不见的墙谁都不能穿越。还有一个容易忽略的情况是“玩家推不动箱子但原地可以不推”。有些实现会在每个方向按键时强制更新玩家位置导致玩家站在箱子旁边按一下方向键步数也加了。我这里的做法是移动无效时tryMove返回 null上层 reducer 里保持原状态步数不增加。这看起来是个小细节但对最终玩法体验影响很大——步数统计要能反映真实操作量否则玩家对着墙连按十下方向键步数涨了十几步游戏就变得很荒谬。3.3 用 Reducer 收敛状态流转状态管理我选用了useReducer没有引入额外的状态库。推箱子的状态变化非常规律本质上就是一个有限状态机的迁移reducer 天然适合这种场景。动作只有几类移动、撤销、重开、下一关。type Action | { type: move; dir: Direction } | { type: undo } | { type: reset } | { type: nextLevel }; function gameReducer(state: GameState, action: Action): GameState { switch (action.type) { case move: { const next tryMove(state, action.dir); return next ?? state; } case undo: { const prev state.history[state.history.length - 1]; if (!prev) return state; return { ...prev, history: state.history.slice(0, -1) }; } case reset: return createInitialState(currentLevelIndex); case nextLevel: return createInitialState(currentLevelIndex 1); default: return state; } }用 reducer 还有一个好处游戏逻辑可以被单独抽出去做单元测试。把tryMove、gameReducer变成纯函数之后不需要启动任何模拟器直接在你的本地跑一段脚本输入一个初始状态和一连串按键就能验证最终箱子位置是否符合预期。我后面对这一层写了非常多的测试用例尤其是“箱子贴墙推不动”“两个箱子相邻不能推”“玩家站到目标点不会报错”这些边界场景全部靠单测兜底。4. 胜利条件、死锁识别与关卡流转移动逻辑跑通之后游戏已经“能玩”了但离一个完整产品还差三件事怎么判定赢了、怎么处理玩家把自己推进死胡同、怎么在多个关卡之间切换。这一节单独拿出来讲是因为这三个问题看着简单实际都有不少容易被带偏的细节。4.1 胜利检查的正确时机与判定方式胜利条件一句话所有箱子都位于目标点上。实现上就是判断箱子坐标集合是否是目标坐标集合的子集或者反过来看箱子列表里有没有一个不在目标集合里。function hasWon(state: GameState): boolean { const goalSet getGoalPositions(state.staticMap); return state.boxes.every((b) goalSet.has(${b.x},${b.y})); }关键不是怎么写这个判断而是什么时候触发判断。我的做法是在 reducer 外通过useEffect监听state.boxes的变化一旦hasWon返回 true 并且当前还没弹出胜利弹窗就弹出结算层。这里要注意一个边界每次移动成功都会改变boxes引用但“推箱子把箱子从目标点推出去”也会改变boxes这时候不能弹窗。所以弹窗条件必须由“状态是否满足胜利”来实时计算而不是在状态里存一个“是否赢了”的布尔值否则撤销一步之后还要手动回滚这个布尔值非常容易出错。4.2 最小可用的死锁预判推箱子玩到中后期玩家最大的挫败感不是走不动而是走着走着发现某个箱子被卡在墙角整个局面已经无解只能撤销或重开。专业推箱子游戏通常会做死锁检测在玩家做出一个不可逆的错误移动前提前提示。死锁的完整检测算法非常复杂需要处理凹槽、长条形推挤、多箱互锁等几十种局面。但对一个休闲项目来说做一版“角落死锁”预判就够了它覆盖了大多数入门级关卡里最常见的无解情况一个箱子被推进了墙角且那个角不是目标点。function isCornerDeadlock( staticMap: StaticMap, box: Position, goals: Setstring ): boolean { const key ${box.x},${box.y}; if (goals.has(key)) return false; const blockedUp isWall(staticMap, box.x, box.y - 1); const blockedDown isWall(staticMap, box.x, box.y 1); const blockedLeft isWall(staticMap, box.x - 1, box.y); const blockedRight isWall(staticMap, box.x 1, box.y); return ( (blockedUp blockedLeft) || (blockedUp blockedRight) || (blockedDown blockedLeft) || (blockedDown blockedRight) ); }这个函数的逻辑是箱子的两个相邻方向都被墙挡住那它就被封死在角落里了。放在关卡设计里我建议把它作为“提示”而不是“硬失败”。因为推箱子允许玩家故意走出这种局面然后又用其他箱子把它救出来完整的死锁判定涉及更复杂的推回路径简单判断误报率不低。我在项目里做的是在玩家每走一步后扫描所有箱子如果出现角落死锁用一个淡红色的外边框提示“这步之后这个箱子可能无法归位”让玩家自己决定是继续还是撤销。这个小提示极大降低了新手卡关的焦虑感。4.3 关卡数据与通关进度的组织关卡我维护了一个数组每关用createLevel生成初始状态。切换关卡时不能直接用之前 reducer 里的createInitialState因为当前关卡索引在 reducer 外维护两者必须保持同步。这里我给一个简洁的做法把所有关卡数据集中到一个文件里用一个levelIndex状态保存当前关卡切换时先深拷贝该关的初始数据再派发一个 reset 动作。下一关按钮只在胜利弹窗里出现弹窗上显示当前步数和“最佳步数”。最佳步数用AsyncStorage或类似本地存储方案保存都可以为了跨平台简单也可以先用内存里模块级变量存同一个会话内有效。真要在鸿蒙端长期保存本地存储模块的适配情况需要单独确认我在项目里为了控制范围只做了内存级的当前进度保存。5. 操作手感虚拟方向键与手势滑动玩法逻辑再正确按键手感不好游戏也立不起来。这一节聊输入层的两个核心问题手势判定怎么做才能不误触虚拟方向键怎么按才跟手。5.1 PanResponder 手势判定的阈值设计滑动操作的思路是玩家在棋盘上任意位置快速滑动系统根据滑动方向触发一次移动。RN 里用PanResponder监听关键是区分“滑动”和“轻微触摸抖动”否则很容易出现只想看一下地图结果角色动了一下的情况。const panResponder useRef( PanResponder.create({ onStartShouldSetPanResponder: () true, onMoveShouldSetPanResponder: (_, g) Math.abs(g.dx) 12 || Math.abs(g.dy) 12, onPanResponderRelease: (_, g) { const { dx, dy } g; if (Math.abs(dx) 12 Math.abs(dy) 12) return; if (Math.abs(dx) Math.abs(dy)) { dispatch({ type: move, dir: dx 0 ? right : left }); } else { dispatch({ type: move, dir: dy 0 ? down : up }); } }, }) ).current;这里有两个阈值比较关键一个是onMoveShouldSetPanResponder里的 12 像素低于这个值不会触发移动防止误触另一个是方向判定时Math.abs(dx) Math.abs(dy)用绝对值比较而不是直接用 dx 正负可以避免斜向滑动时系统按先触发的那根轴走实际体验会更符合直觉。真正用下来竖滑时 dy 明显大于 dx横滑时反过来这种方法比“角度判断”更简单也更稳。5.2 虚拟方向键布局与长按连发纯手势操作有个问题精推箱子时玩家需要小幅微调每次滑动都走一大段距离不合适。所以我加了底部虚拟方向键方向键本身不占棋盘空间用Pressable实现每个键在按下时调用一次移动。进一步做了长按连发按住方向键不放会以 220ms 的间隔持续移动。这个间隔我用useRef保存 setTimeout / setInterval 的句柄在触摸结束或组件卸载时清理避免重复触发。const timerRef useRefReturnTypetypeof setInterval | null(null); const startRepeat (dir: Direction) { dispatch({ type: move, dir }); if (timerRef.current) clearInterval(timerRef.current); timerRef.current setInterval(() { dispatch({ type: move, dir }); }, 220); }; const stopRepeat () { if (timerRef.current) { clearInterval(timerRef.current); timerRef.current null; } };Pressable上还要注意处理onPressOut手指从按键上滑走也要停止连发否则会出现“手指已经离开但箱子还在继续走”的幽灵操作。另外虚拟方向键按下的瞬间要给出视觉反馈我用的是透明度变化按下去按键变浅松手恢复玩家能清楚知道当前输入有没有被系统接住。5.3 键盘映射与调试辅助开发阶段在模拟器里跑的时候用鼠标点虚拟方向键非常累我加了一组键盘映射方向键直接移动z撤销r重开n下一关。React Native 里通过监听 keydown 事件就能实现。useEffect(() { const onKeyDown (e: KeyboardEvent) { switch (e.key) { case ArrowUp: dispatch({ type: move, dir: up }); break; case ArrowDown: dispatch({ type: move, dir: down }); break; case ArrowLeft: dispatch({ type: move, dir: left }); break; case ArrowRight: dispatch({ type: move, dir: right }); break; case z: dispatch({ type: undo }); break; case r: dispatch({ type: reset }); break; } }; window.addEventListener(keydown, onKeyDown); return () window.removeEventListener(keydown, onKeyDown); }, []);这段代码只影响开发调试进正式包前可以按平台条件去掉。但开发效率提升非常大不用一直点虚拟按键调试移动逻辑时手速能跟上脑子。6. 鸿蒙端联调实测与常见兼容坑逻辑写完之后真正花时间的是把同一套代码跑到鸿蒙真机上。这一节全是实操经验如果你只是在安卓上跑通了就以为万事大吉大概率会在鸿蒙设备上翻车。6.1 从 Metro 到鸿蒙设备的构建链路先说清楚鸿蒙端跑 React Native 的大致链路不同版本细节有差异但思路一致。RN 工程本身是标准的初始化好之后鸿蒙侧的壳工程需要引入社区维护的 RN 适配层然后在壳工程的入口配置里注册 RN 页面的模块名和加载方式。开发调试时先用 Metro 把 bundle 服务跑起来鸿蒙应用在启动 RN 页面时从 Metro 拉取 bundle这样改完 TS 代码保存就能在真机上刷新不用反复打安装包。走 release 时用适配层提供的打包命令把 bundle 编译成静态文件放进鸿蒙应用里离线时也能跑。实际测试里有一个值得注意的现象鸿蒙真机的调试日志主要输出到 Metro 终端而不是系统日志。所以如果页面白屏了第一件事不是打开系统日志而是回头看 Metro 终端有没有报错很多 JS 层异常都会在那里体现省掉大量排查时间。6.2 真机验证里最容易翻车的地方我在多台鸿蒙设备上跑这个游戏遇到过几个典型兼容问题挑几个印象最深的单独说。第一个是字体内边距。RN 在安卓上Text默认会有一点垂直方向的内间距在鸿蒙端这个表现可能不一样。推箱子界面上的文字不多主要就是步数显示和弹窗内容但按钮高度是按安卓的视觉习惯调的到了鸿蒙设备上文字被裁掉了一截。后面我把按钮高度改成不依赖文字默认行高而是固定高度加lineHeight居中这个问题就消除了。第二个是弹窗问题。标准做法是使用 RN 的Modal组件做胜利界面但适配层对Modal的支持不一定完整在某些鸿蒙版本上会出现弹窗背景没有遮罩、点击穿透到底层棋盘的问题。我直接抛弃了Modal改成在页面根视图里用一个position: absolute铺满全屏的遮罩层内部放结算面板。遮罩层在最外层和棋盘在一个视图树里层级关系完全可控两端的表现完全一致。第三个是安全区域。鸿蒙设备有没有打孔屏、底部有没有手势条都会影响棋盘和方向键的位置。我用SafeAreaView包住整个页面再把棋盘放到中间区域。这里需要提醒一句如果项目用了自定义状态栏样式先确认适配层的状态栏透明度属性是否生效否则棋盘顶部会顶进状态栏里。第四个是触摸事件冲突。棋盘上的PanResponder和底部方向键的Pressable共享同一个手势系统如果在方向键上滑动手指手势有可能被棋盘识别成滑动操作。我给方向键区域加了一层pointerEvents控制让按键区域优先响应点击棋盘的手势从这里不触发。6.3 一份可复制的跨端验收清单项目临近交付时我列了一张验收表在安卓、iOS、鸿蒙各跑了一遍这里直接放出来你可以照着抄。检查项安卓iOS鸿蒙地图图元颜色、格子边界显示通过通过通过玩家移动、箱子推动、撞墙判定通过通过通过箱子推到目标点后的颜色变化通过通过通过胜利弹窗、下一关、重开、撤销通过通过通过手势滑动和虚拟方向键连发通过通过通过步数统计不在地图刷新时重计通过通过通过横竖屏切换后棋盘不溢出通过通过通过需重绘适配横竖屏那一项安卓和 iOS 靠 flex 布局基本能自适应鸿蒙端因为部分版本旋转事件处理有差异我最后的处理是锁定竖屏从根源上避免旋转带来的布局问题。休闲小游戏锁竖屏是很常见的选择不丢体验反而省掉大量适配工作。在鸿蒙端还有一个很容易被忽略的细节是构建产物大小控制。RN 应用的基础库不小如果再把不必要的原生模块打进去安装包会膨胀明显。我在 release 配置里关闭了不必要的包依赖只保留渲染核心相关模块最终包体比默认配置小了不少真机冷启动也更快。这一步建议在项目一开始就规划最后再来拔依赖会很痛苦。这轮做完之后我最大的体会是像推箱子这样玩法固定的老游戏代码层面的技术难点反而不是那些花哨的效果而是把“地图建模、移动判定、胜利检测、输入手感”这四个基础模块做到干净、可测试、可跨端。我特别建议你也把移动和判定逻辑拆成纯函数在项目初期就搭一个最简单的本地测试脚本每次改动后跑一遍所有边界用例。我后来加撤销功能、加死锁提示时全靠这套测试兜底才没有把已经稳定的移动逻辑改坏。同样的思路往后再做华容道、数独这类网格游戏这套核心可以直接迁移跨平台红利能复用不止一次。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑