ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于ArkUI的俄罗斯方块与消消乐双游戏实现:Canvas渲染与状态管理实战

基于ArkUI的俄罗斯方块与消消乐双游戏实现:Canvas渲染与状态管理实战 简介面向鸿蒙应用开发学习者以及毕业设计、课程设计的学生这份基于ArkUI框架开发的俄罗斯方块与消消乐双游戏项目完整覆盖了鸿蒙系统接口的界面搭建、状态管理、交互事件处理与核心游戏逻辑实现既有常见桌面小游戏的趣味性又具备课设项目所需的完整度。压缩包内共一百一十二个文件以ets页面代码、JSON配置文件、GIF演示动画和JPG运行截图为主其中ets文件对应游戏场景与逻辑JSON负责工程参数图片素材辅助说明交互效果整体结构清晰代码注释详尽新手也能快速读懂各模块的职责与调用关系。整个资源压缩后约为十六点二七兆已有二百六十九人学习下载。该项目曾获得九十八分并受到导师高度认可包含完整的游戏循环、方块旋转与消行算法、消消乐消除判定、计分排行等模块简单部署后即可直接运行也适合作为课程设计答辩演示或二次开发的基础实用价值明显。1. 为什么这个组合能成为高分项目三年前照着教程敲下来的页面跳转Demo和今天开了预览器直接拖组件进页面的HarmonyOS 5.0工程中间隔的不是API版本号而是从ArkTS声明式写法到State驱动渲染的一套思维切换。俄罗斯方块和消消乐恰好是两类最典型的游戏范式前者拼的是碰撞检测与矩阵变换的精度后者拼的是连通区域标记与掉落模拟的稳定性。两者合在一个工程里恰好把ArkUI的自定义组件、Canvas绘制、事件分发和状态管理全走了一遍——这正是评分老师想看的东西。这个项目适合两类人一是正在准备HarmonyOS应用基础认证、需要一份拿得出手的完整代码的开发者二是已经写过几个普通页面应用、想验证自己对API和ArkUI掌握深度的进阶玩家。接下来这套方案不依赖任何第三方游戏引擎全部用系统API和ArkUI原生能力实现。2. ArkUI里的双游戏骨架缓存Canvas渲染方案与组件化状态拆分2.1 为什么不用Canvas组件直接绘制的默认模式ArkUI的Canvas组件走的是声明式渲染路径你往CanvasRenderingContext2D对象上调用fillRect、drawImage之后框架会在合适的时机把指令合成到图层上。问题在于游戏场景每一帧都在变化如果每帧都触发状态更新整个组件树都要做diff卡片游戏的掉帧就是这么来的。常见做法是让Canvas在初始化后把自己“托管”出来用离屏Canvas先把方块、数字、背景一次性画成位图主Canvas每帧只做drawImage贴图。这套思路在ArkUI里的实现路径是OffscreenCanvas它可以脱离UI线程做图形预绘制。// MainAbility.ets 里的全局画布管理器 export class CanvasManager { private static offscreen: OffscreenCanvas; private static offCtx: OffscreenCanvasRenderingContext2D; private static mainCtx: CanvasRenderingContext2D; static init(width: number, height: number, ctx: CanvasRenderingContext2D) { this.offscreen new OffscreenCanvas(width, height); this.offCtx this.offscreen.getContext(2d); this.mainCtx ctx; } static drawGame(drawFn: (ctx: OffscreenCanvasRenderingContext2D) void) { drawFn(this.offCtx); // 先画到离屏画布 this.mainCtx.drawImage(this.offscreen, 0, 0, this.offscreen.width, this.offscreen.height); } }这段代码的核心点是先把所有游戏元素画进离屏画布再一次性贴到主Canvas上。drawImage这个调用的开销远小于几十个fillRect逐帧重绘尤其当棋盘有20×10个格子时性能差距能到三倍以上。初始化时传的width和height建议与页面实际渲染尺寸做一次像素比缩放否则在部分平板上会显得发虚。2.2 用State拆出“游戏状态”和“渲染状态”两类数据ArkUI的状态管理有个容易踩的坑把棋盘数组直接声明成State每次落块都触发整体刷新。实际上需要拆成两个层级——顶层用State保存游戏状态得分、等级、当前方块类型、游戏是否暂停棋盘矩阵用Observed装饰器配合ObjectLink来做细粒度观察这样只有某一行变化时才通知Canvas重绘。Observed export class BoardModel { grid: number[][] Array.from({ length: 20 }, () Array(10).fill(0)); score: number 0; level: number 1; clearLines(): number { let linesCleared 0; for (let row this.grid.length - 1; row 0; row--) { if (this.grid[row].every(cell cell ! 0)) { this.grid.splice(row, 1); this.grid.unshift(Array(10).fill(0)); linesCleared; } } this.score [0, 100, 300, 500, 800][linesCleared] * this.level; return linesCleared; } }这里用splice加unshift代替原地修改数组目的是触发Observed的数组变化侦听。如果你直接写this.grid[row][col] value框架不会感知到嵌套维度的变化。实际开发里我还习惯在clearLines里顺便判断linesCleared是否大于0如果是就播放一次短促的提示音声音加载也用ArkUI的媒体API错误码返回801时优先检查资源路径有没有加rawfile前缀。2.3 游戏循环的两种驱动方式及选择标准俄罗斯方块需要定时下移消消乐需要等待用户交互两种循环不能共用一个定时器。俄罗斯方块用setInterval驱动每500毫秒触发一次下落这个间隔要跟着等级走——等级每升一级间隔乘以0.85最低不能低于80毫秒。消消乐则完全事件驱动只有用户点击和消除动画两个触发点。两者并存时务必用页面级生命周期管理定时器否则退出游戏页后定时器继续执行日志里刷出的“Cannot read property of undefined”多半就是这个原因。aboutToDisappear(): void { if (this.timerId) { clearInterval(this.timerId); this.timerId -1; } }3. 俄罗斯方块核心算法SRS旋转系统与碰撞检测的实现3.1 七种方块的矩阵存储与旋转规则俄罗斯方块有一个玩家们熟悉的SRSSuper Rotation System标准定义七种方块在4×4矩阵里的形态。我用的是四维数组存储法第一维表示方块类型第二维是旋转角度索引第三、四维是4×4矩阵。每个方块占用的实际格点数不超过4个剩下全是空白填充。const SHAPES: number[][][][] [ // I 方块 [ [[0,0,0,0],[1,1,1,1],[0,0,0,0],[0,0,0,0]], [[0,0,1,0],[0,0,1,0],[0,0,1,0],[0,0,1,0]] ], // T 方块 [ [[0,0,0,0],[0,1,0,0],[1,1,1,0],[0,0,0,0]], [[0,1,0,0],[0,1,1,0],[0,1,0,0],[0,0,0,0]] ] ]; function rotate(matrix: number[][], times: number): number[][] { let result matrix; for (let i 0; i times; i) { const rotated Array.from({ length: 4 }, () Array(4).fill(0)); for (let row 0; row 4; row) { for (let col 0; col 4; col) { rotated[col][3 - row] result[row][col]; } } result rotated; } return result; }旋转公式要记住一个关键点新坐标是(col, 3-row)不是(col, row)。同理镜像操作是(row, 3-col)。这个细节错了方块转着转着就漂移了调试时很难看出来。SRS系统里还有踢墙偏移Wall Kick数据用来处理方块在旋转时贴近边界的情况基础版本用最简单的偏移表即可尝试右移一格、左移一格、右移两格。3.2 碰撞检测的两次判定时机碰撞检测的经典实现是遍历当前方块占用的非零格子对每个格子做边界检查。这个逻辑要在两个时机调用移动时和落定前。移动时检测的是“目标位置是否合法”落定前检测的是“下方是否有障碍”。两处共用同一个合法性判断函数但参数不同。function isValidPosition( board: number[][], piece: { shape: number[][]; x: number; y: number } ): boolean { for (let row 0; row piece.shape.length; row) { for (let col 0; col piece.shape[row].length; col) { if (!piece.shape[row][col]) continue; const boardX piece.x col; const boardY piece.y row; if (boardX 0 || boardX 10 || boardY 20) return false; if (boardY 0 board[boardY][boardX] ! 0) return false; } } return true; }boardY大于等于0这个条件很重要因为方块从顶部出现时有一个短暂过程是落在可见区域之外的此时行坐标为负不做棋盘数组访问就能直接认为合法。很多初版实现会把负坐标当成越界导致方块生成瞬间就游戏结束这是最常见的误判来源。3.3 消行消除与连续加分权重消行判定简单难点在加分权重的设计。高分项目与普通项目的差异点不在“能消行”而在“鼓励什么行为”。这里我参考了经典NES版本和现代指南模式的混合规则单次消1行100分乘以当前等级2行3003行5004行800同时用combo计数器连续消行时在基础分上再多加50×combo。const bonusMap: number[] [0, 100, 300, 500, 800]; const comboBonus combo 0 ? combo * 50 : 0; const totalGain bonusMap[linesCleared] * this.level comboBonus;这里combo的含义是“连续下落都至少消了一行”如果某次下落没有消行则combo清零。这个设计会让玩家在硬降和精确落位之间做取舍也方便在UI上设计连击标签。硬降Hard Drop本身不额外加分但可以叠加一个2分的落地奖励——这个细节写进项目README里会让整体完整度高出一截。3.4 键盘与触摸的双输入映射HarmonyOS应用要同时适配手机和折叠屏输入方式必须双轨。手机用触摸滑动手势判断方向折叠屏用户大概率接键盘。ArkUI里监听键盘用onKeyEvent滑动用触摸事件的手势绑定两者都要映射到同一个调用函数。.onKeyEvent((event: KeyEvent) { if (event.type KeyType.Down) { switch (event.keyCode) { case 2013: // 左方向键 this.movePiece(-1); break; case 2014: // 右方向键 this.movePiece(1); break; case 2015: // 下方向键 this.softDrop(); break; case 2053: // 空格键 this.hardDrop(); break; } } })KeyEvent的keyCode枚举值在不同API版本上有过变更API 9到API 11之间做过一次统一调整。拿到设备上测试时先在弹窗里打印一个event.keyCode确认设备实际返回的数值再写进判空逻辑。触摸手势的滑动阈值建议设计成16vp大于这个值才触发移动小于则忽略防止误触导致方块乱偏。4. 消消乐匹配算法BFS连通域标记与重力掉落模拟4.1 网格模型与方块类型编码消消乐棋盘用8行8列共64个格子这个尺寸在手机竖屏时刚好填满上半屏。方块类型编码不用字符串直接用数字0到5代表六种颜色0还兼任“空格子”的角色。这样设计的好处是染色和匹配判定可以直接用位运算不必每次转字符串再比较。type CellState 0 | 1 | 2 | 3 | 4 | 5; interface BoardCell { type: CellState; row: number; col: number; isMatched: boolean; }isMatched字段是给动画准备的匹配结束后先标true播放一小段缩放消失动画动画结束后再执行掉落逻辑。如果直接删除格子立刻掉落整个交互手感会显得很“脆”。动画时长控制在220毫秒左右比较合适太长会让连续消除的节奏感变差。4.2 用BFS做三连匹配判定及死局检测消消乐的匹配本质是找到所有水平或垂直方向连续三个以上同色方块。这里遍历所有格子对每个未访问过的格子做一次BFS方向只走上下左右四连通。BFS过程中记录访问过的格子坐标集合大小大于等于3就标记整组为待消除。function findMatches(board: BoardCell[][]): BoardCell[][] { const rows board.length; const cols board[0].length; const visited: boolean[][] Array.from({ length: rows }, () Array(cols).fill(false)); const matchedGroups: BoardCell[][] []; for (let r 0; r rows; r) { for (let c 0; c cols; c) { if (visited[r][c] || board[r][c].type 0) continue; const queue: Array[number, number] [[r, c]]; const group: BoardCell[] []; const type board[r][c].type; visited[r][c] true; while (queue.length 0) { const [cr, cc] queue.shift()!; group.push(board[cr][cc]); const directions [[1,0],[-1,0],[0,1],[0,-1]]; for (const [dr, dc] of directions) { const nr cr dr; const nc cc dc; if (nr 0 nr rows nc 0 nc cols !visited[nr][nc] board[nr][nc].type type) { visited[nr][nc] true; queue.push([nr, nc]); } } } if (group.length 3) matchedGroups.push(group); } } return matchedGroups; }BFS的队列用数组模拟即可64个格子的规模完全不需要优化成链表。死局检测在用户界面里表现为“这步点了没反应”很多初学者会奇怪明明两个方块相邻为什么换不过去原因是交换后没有形成三连被回退了。function trySwap(r1: number, c1: number, r2: number, c2: number): boolean { const board this.board; [board[r1][c1], board[r2][c2]] [board[r2][c2], board[r1][c1]]; const matches findMatches(board); if (matches.length 0) { // 回退交换 [board[r1][c1], board[r2][c2]] [board[r2][c2], board[r1][c1]]; return false; } return true; }回退交换用了解构赋值的黑科技写法比临时变量三行交换更紧凑。提交上来的项目代码里我见过不少在交换前忘记做边界检查的比如点击到棋盘外的坐标数组越界直接崩溃。trySwap函数入口处要先判断两个坐标是否在同一行或同一列且距离为1否则直接返回false。4.3 掉落模拟与上方补块的两阶段刷新消消乐消除后棋盘会出现空洞常见做法是“整体下移顶部随机补块”。这里有一个容易做错的地方同一列里如果有多个空洞必须自下而上逐行处理否则会出现方块穿插的现象。function applyGravity(board: BoardCell[][]): void { const rows board.length; const cols board[0].length; for (let c 0; c cols; c) { let writeRow rows - 1; for (let r rows - 1; r 0; r--) { if (board[r][c].type ! 0) { if (writeRow ! r) { board[writeRow][c] board[r][c]; board[r][c] { type: 0, row: r, col: c, isMatched: false }; } writeRow--; } } for (let r writeRow; r 0; r--) { board[r][c] { type: Math.floor(Math.random() * 6) as CellState, row: r, col: c, isMatched: false }; } } }这里writeRow指针从最底行开始向上扫描遇到非空格子就把它搬到writeRow位置。补块的随机类型要排除当前列顶部刚出现就形成三连的特殊情况——如果补完块马上检测到新匹配要自动再消除。这个连锁反应在游戏里很讨喜玩家会觉得自己的操作触发了链式反应实际上只是随机数分布导致的。4.4 匹配、消除、掉落的串行动画编排三个动作不能同时在Canvas上一次画完要串行执行。我通常用状态机的思路IDLE、SWAPPING、MATCHED、FALLING、REFILLING五个状态。每个状态执行完自己的逻辑后通过回调进入下一个状态。状态机的实现放在ValueAnimator里逐帧驱动。enum GameState { IDLE, SWAPPING, MATCHED, FALLING, CHECKING } state: GameState GameState.IDLE; processNextRound(): void { if (this.state GameState.IDLE) return; if (this.state GameState.MATCHED) { // 触发掉落逻辑 applyGravity(this.board); this.state GameState.FALLING; } else if (this.state GameState.FALLING) { const matches findMatches(this.board); if (matches.length 0) { this.state GameState.MATCHED; } else { this.state GameState.IDLE; this.checkGameOver(); } } }注意状态从MATCHED到FALLING之间要留动画间隔不能ProcessNextRound里一帧全走完否则玩家根本看不到消除过程。动画间隔用ValueAnimator设置300毫秒即可期间如果有新输入要缓存到操作队列里防止状态机错乱。4.5 道具与连击评分对照高分项目的消消乐必然要加道具系统。横向消除道具消一整行、炸弹道具3×3范围消除、变色道具把选中的一组全变同色是三个最常见的设计。道具获取方式有两种一种是通过消除四连以上时获得概率分别为四连80%、五连100%另一种是看广告获得。在HarmonyOS工程里接广告SDK要先检查设备是否支持AppGallery Connect服务模拟器上经常提示没有安装HMS Core。消除模式基础分连击加成道具掉落率三连消除30每连击100四连消除80每连击2040%L/T形消除120每连击3050%五连或以上200每连击50100%评分规则完全可以按项目需求微调比如关卡设计里设定“本关内必须累计触发两次五连”这种目标玩家就需要特意去堆积方块而不是单纯消除。这个设计思路要从头写在后端逻辑里不能只在UI上画一个目标标签——否则就会出现“目标已达成但关卡不结束”的bug这种bug在评审时非常影响观感。5. 高分项目的性能与工程化优化技巧5.1 Canvas离屏缓存把静态元素从重绘中摘除游戏中的网格线、背景色块、得分面板边框这些元素从开局到结束完全不变。如果每帧都调fillRect画一遍会白白消耗GPU带宽。正确做法是启动时画一张静态背景缓存位图之后每帧只drawImage一次。function createStaticBackground(ctx: CanvasRenderingContext2D): void { // 绘制网格线 ctx.strokeStyle #3A3F44; ctx.lineWidth 1; for (let i 0; i 10; i) { ctx.beginPath(); ctx.moveTo(i * cellWidth, 0); ctx.lineTo(i * cellWidth, boardHeight); ctx.stroke(); } // 绘制得分面板底板 ctx.fillStyle #282C34; ctx.fillRect(0, boardHeight, screenWidth, topPanelHeight); }只画一次的背景和每帧重绘的背景在Android或iOS里区别不大但在HarmonyOS的ArkUI渲染管道里差异明显因为ArkUI的Canvas走的是自研渲染引擎对离屏缓存的支持做了专门优化。实际测试里同一台设备开启缓存后帧率能提升15帧到20帧。5.2 状态管理与对象复用避免创建临时数组游戏循环里频繁创建临时对象会造成垃圾回收压力这一点在ArkTS里比JavaScript更明显——ArkTS对对象字面量的内存分配有额外的检查开销。消消乐网格和俄罗斯方块棋盘都建议维护在类内部作为成员变量不要每帧创建新的二维数组。// 不推荐每帧创建新数组 function getBoardCopy(): number[][] { return this.board.map(row [...row]); } // 推荐复用固定大小的临时缓冲区 private tempBoard: number[][] Array.from({ length: 20 }, () Array(10).fill(0)); private copyToTemp(): void { for (let r 0; r 20; r) { for (let c 0; c 10; c) { this.tempBoard[r][c] this.board[r][c]; } } }固定缓冲区还有额外好处可以在单个循环里做碰撞检测和边界检查编译器有机会把循环向量化。写代码时有一个判断标准双循环里除了元素赋值之外不要出现任何方法调用或new操作这样性能最稳妥。5.3 音效与震动反馈的API集成游戏体验的完成度在线性震动马达的反馈上体现得最明显。消消乐消除时震一下俄罗斯方块消行时震一下硬降时震一下三种强度要区分。import { vibrator } from kit.SensorServiceKit; vibrator.vibrate({ type: time, duration: 30 }, { usage: alarm });震动的usage参数在不同设备上响应不一样建议在真机测试里体验一下alarm和notification的差异选一个当前设备手感更好的。API 12版本在模拟器上震动功能无效函数会返回错误码记得用try-catch包住不然启动时直接抛异常闪退。5.4 Dart层与ArkUI层的异常边界与日志策略最后一步是统一错误处理。项目里所有刷新逻辑都加一层try-catch捕获后调用console.error打印堆栈。用户操作触发的方法点击、滑动、键盘也要包异常防止单个输入事件把整个UI进程拖垮。真机调试时打开DevEco Studio的Profiler面板重点是看主线程帧率图和内存分配曲线优化目标设定为帧率波动不超过5帧、内存占用曲线没有持续上升的阶梯。沿着这个思路把两个游戏的代码组织成一个HarmonyOS工程里的两个独立模块共享一套Canvas渲染底层和音效管理工具类其余业务逻辑完全隔离。这种“底层复用、上层隔离”的结构也是通常意义上高分项目的架构加分点。继续实现的时候优先把消消乐的连锁算法递归调用边界测试写完再把俄罗斯方块的SRS踢墙数据补齐两步做完这个项目的完成度已经超越相当多提交上来的同题材作品。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进