【OpenHarmony/HarmonyOS】从开始到结算:ArkUI 游戏页面的暂停、重开与状态机治理
声明式 UI 很擅长“状态决定界面”,但当页面同时拥有
currentPage、isGameRunning、isPaused、isGameOver、isLoading、isGameInitialized等多个变量时,状态组合会快速膨胀。某个按钮少改一个字段,就可能出现游戏循环在跑但暂停层仍显示、结算层和操作摇杆同时存在、重开后难度丢失等问题。本篇结合 ArkUI 游戏主页,分析现有布尔状态协作方式,并给出渐进演进为显式状态机的方案。🎮
一、页面同时管理了哪些状态
Index.ets不只是游戏画布,还承载主页、难度页、战斗、资料、HUD、暂停和结算。与游戏流程直接相关的字段包括:
@StatecurrentPage:'home'|'game'|'difficulty'='home';@StateisGameRunning: boolean = false;@StateisPaused: boolean = false;@StateisGameOver: boolean = false;@StateisLoading: boolean = false;@StateisGameInitialized: boolean = false;@StategameResult:'win'|'lose'='lose';@StategameMode: GameModeType ='pve';@StategameDifficulty:'easy'|'normal'|'nightmare'='normal';| 字段 | 表达的问题 |
|---|---|
currentPage | 当前显示主页、难度选择还是游戏 |
isGameRunning | 页面是否认为游戏会话正在进行 |
isPaused | 是否显示暂停层并停止循环 |
isGameOver | 是否显示结算层 |
isLoading | 是否显示进入游戏前的加载反馈 |
isGameInitialized | Canvas 尺寸是否已经交给引擎初始化 |
每个字段单看都合理,但 5 个布尔值理论上有 32 种组合,其中大多数没有业务意义。例如currentPage='home' && isPaused=true、isPaused=true && isGameOver=true都属于应该避免的组合。
二、当前页面其实已经有“隐式状态机”
虽然代码没有定义enum GamePhase,但方法之间已经形成状态迁移:
stateDiagram-v2 [*] --> Home Home-->Difficulty: 选择 PvE Home-->Loading: 选择其他模式 Difficulty-->Loading: 选择难度并支付 Loading-->Playing: Canvas 尺寸就绪并初始化 Playing-->Paused: 点击菜单 Paused-->Playing: 继续 Playing-->Settling: 引擎触发 onGameEnd Settling-->GameOver: 同步统计并显示弹窗 Paused-->Home: 退出 GameOver-->Loading: 再来一局 GameOver-->Home: 返回主页问题不在于“没有状态机就一定错”,而在于状态被分散写入多个方法和异步回调,迁移规则只能靠阅读者脑中拼接。
三、startGame:一次迁移包含同步和异步两部分
进入游戏时先打开 loading,再用 50ms 延迟让 ArkUI 有机会渲染,然后批量重置会话字段:
startGame( mode: GameModeType, difficulty:'easy'|'normal'|'nightmare'='normal', multiplayerConfig: string ='{}'): void {this.isLoading =true; setTimeout(() => {this.currentPage ='game';this.isGameRunning =true;this.isPaused =false;this.isGameOver =false;this.gameMode = mode;this.gameDifficulty = difficulty;this.pendingMultiplayerConfig = multiplayerConfig;this.isGameInitialized =false;this.currentWave =1;this.survivalTime =0;this.sessionCoins =0;// 根据 Canvas 尺寸决定立即初始化或等待 onAreaChange},50); }这段逻辑解决了真实的声明式 UI 时序问题:如果在主页树还没切换到 Canvas 时就执行重初始化,宽高可能为 0。先改变页面,再等待onAreaChange,能用真实布局尺寸初始化引擎。
但“固定等待 50ms”并不是布局完成的可靠契约。设备负载、动画和系统调度都可能变化。当前代码已经有Canvas.onReady()与onAreaChange(),更稳健的设计应以事件是否到达作为条件,而不是把毫秒数当成状态。
四、Canvas 初始化是另一个子状态
Canvas 就绪只设置上下文,不立即初始化:
Canvas(this.context) .onReady(() => {this.gameEngine?.setContext(this.context); }) .onAreaChange((_oldArea, newArea) => {constwidth = newArea.widthasnumber;constheight = newArea.heightasnumber;if(width <=0|| height <=0)return;this.screenWidth = width;this.screenHeight = height;if(this.isGameRunning &&this.gameEngine) {if(this.isGameInitialized) {this.gameEngine.updateScreenSize(width, height); }else{this.gameEngine.initGame( width, height,this.gameMode,this.gameDifficulty,this.pendingMultiplayerConfig );this.isGameInitialized =true; } } })isGameInitialized防止折叠屏变化或普通尺寸回调重复创建整局游戏。第一次走initGame(),后续只更新屏幕尺寸。
这个布尔值表达的其实是waiting_canvas与playing两个状态。如果初始化抛异常,当前代码仍可能在调用后把它设为true,页面认为成功但引擎未必可用。更好的接口是await engine.initGame()或返回显式结果,再进入 Playing。
五、暂停:UI 状态和引擎命令必须成对
暂停函数同时修改 ArkUI 状态和游戏循环:
togglePause(): void {this.isPaused = !this.isPaused;if(this.isPaused) {this.gameEngine?.stopGameLoop(); }else{this.gameEngine?.startGameLoop(); } }页面通过if (this.isPaused)显示遮罩,通过if (!this.isPaused && !this.isGameOver)隐藏摇杆与开火按钮。也就是说,一个状态同时控制视觉、输入和引擎时间,闭环较完整。
风险在于toggle依赖当前值。异步回调、快速连点或其他方法提前修改isPaused后,再调用 toggle 可能走向相反状态。命令式接口通常更安全:
pauseGame(): void {if(this.phase !=='playing')return;this.phase ='paused';this.gameEngine?.stopGameLoop(); } resumeGame(): void {if(this.phase !=='paused')return;this.phase ='playing';this.gameEngine?.startGameLoop(); }这是演进方案。明确的 pause/resume 具有幂等性,多次调用也不会反复翻转。
六、暂停菜单的重开时序值得警惕 ⚠️
暂停菜单当前重开动作是:
{ text:$r('app.string.btn_restart'), action:():void=>{ this.restartGame(); this.togglePause(); } }而restartGame()同步执行:
this.gameEngine?.stopGameLoop();this.isGameOver =false;this.isPaused =false;this.isGameInitialized =false; setTimeout(() => {this.startGame(this.gameMode); },10);restartGame()已经把isPaused设为 false,随后togglePause()又会把它改成 true 并执行 stop。10ms 后startGame()最终又会重设 false。多数情况下最后状态正确,但中间存在无意义翻转,逻辑依赖多个定时器先后到达。
结算弹窗的重开只调用restartGame(),暂停菜单却多调用一次 toggle,两个入口不一致。更清晰的做法是让restartGame()独自完成全部迁移,调用者不再补状态。
七、重开会丢失哪些会话参数
restartGame()最终调用:
this.startGame(this.gameMode);只传了模式,难度回退为默认normal,多人配置回退为'{}'。所以从 Nightmare 重开可能变成 Normal,从自定义多人局重开也可能丢失槽位配置。
页面明明已经保存gameDifficulty和pendingMultiplayerConfig,应该完整传递:
this.startGame(this.gameMode,this.gameDifficulty,this.pendingMultiplayerConfig );这段是直接可行的演进思路,但本文不修改业务源码。它说明状态机不只管理“处于哪一阶段”,还要携带该阶段的上下文。
八、结束回调:从引擎状态进入 UI 结算
GameEngine 通过onGameEnd将结果上抛,页面同步统计并切换 UI:
this.gameEngine.onGameEnd = (result) => {this.gameResult = result;this.gameStats = new GameStats();this.gameStats.targetsDestroyed =this.gameEngine!.gameStats.targetsDestroyed;this.gameStats.survivalTime =this.gameEngine!.gameStats.survivalTime;this.gameStats.score =this.gameEngine!.gameStats.score;this.isGameOver =true;this.isGameRunning =false;// 保存排行榜与用户统计,刷新资产和资料};isGameOver=true显示结算弹窗,isGameRunning=false停止页面的 HUD 轮询分支。引擎内部在调用前把gameState设为game_over,因此世界更新也会停止主要逻辑。
但页面没有在回调中显式stopGameLoop()。引擎循环可能仍执行帧回调,只是在 update 中提前返回,并继续 render。这样能保留最终画面,却会持续占用一定资源。产品可以选择“冻结并继续渲染”或“停止循环保留 Canvas 像素”,但应明确,而不是依赖早退的副作用。
九、结算层和暂停层为什么目前不会同时出现
绘制条件分别是:
if(this.isPaused) {this.OverlayMenu(...); }if(this.isGameOver) { GameOverDialog(...); }它们没有else互斥。如果状态同时为 true,两个全屏遮罩会叠加。当前正常流程中,结算发生前一般没有暂停;暂停后循环停止,也不会触发新结算,因此实际很少重叠。
但这只是时序上的“通常不会”,不是模型层保证。比如网络回调可能在暂停时通知比赛结束。显式状态枚举天然互斥,可以从根上避免两个主阶段同时成立。
十、退出游戏:页面、经济与循环一起收尾
stopGame()承担三类工作:保存中途退出晶石、恢复主页状态、停止游戏循环。
stopGame(): void {if(this.gameEngine && !this.isGameOver &&this.gameEngine.gameStats.coinsCollected >0) {// 根据模式结算中途收益}this.isGameRunning =false;this.isPaused =false;this.isGameOver =false;this.currentPage ='home';this.refreshCoins();this.gameEngine?.stopGameLoop(); }这个方法本质上是Playing/Paused/GameOver -> Home的统一出口。问题是结算副作用和 UI 迁移耦合:如果资产保存失败,页面仍立即回主页。长期来看可以先由 SessionController 完成endSession(reason),返回结算结果,再让页面导航。
十一、把状态收敛成“阶段 + 上下文”
无需一次性重构整个页面,可以先定义一个主阶段:
type AppPhase = 'home'|'difficulty'|'loading_game'|'playing'|'paused'|'settling'|'game_over'; interface GameSessionContext { mode: GameModeType; difficulty: 'easy'| 'normal' | 'nightmare';multiplayerConfig: string; initialized: boolean; }UI 条件变成:
if(this.phase ==='paused') {this.PauseOverlay(); }if(this.phase ==='game_over') { GameOverDialog({ result:this.gameResult, stats:this.gameStats }); }isGameRunning可以由phase推导,不再单独写入;isPaused、isGameOver同理。initialized仍可放在会话上下文,因为它是 Playing 内部与 Canvas 生命周期相关的子状态。
十二、迁移函数必须验证合法来源
状态枚举只是第一步,真正价值来自集中迁移:
privatetransitionTo(next: AppPhase):boolean{constallowed: Record<AppPhase, AppPhase[]> = { home: ['difficulty','loading_game'], difficulty: ['home','loading_game'], loading_game: ['playing','home'], playing: ['paused','settling','home'], paused: ['playing','loading_game','home','settling'], settling: ['game_over','home'], game_over: ['loading_game','home'] };if(!allowed[this.phase].includes(next))returnfalse;this.phase =next;returntrue; }这是示例方案。生产代码还要在迁移钩子中启动/停止循环、清理输入、取消定时器。关键是非法迁移能够被日志捕获,而不是悄悄形成矛盾布尔组合。
十三、异步操作需要“会话代号”
页面使用多个setTimeout:加载延迟、重开延迟、波次 Banner、引擎结算延迟。用户快速返回主页再开始新局时,旧回调可能晚到并修改新局状态。
可以为每次开局生成递增sessionId:
privatesessionVersion:number=0;startGame(...):void{constversion = ++this.sessionVersion;setTimeout(() =>{if(version !==this.sessionVersion)return;// 只允许当前会话继续初始化},50); }stopGame():void{this.sessionVersion++;// 旧异步回调随后会失效}这种“版本令牌”不能替代清除定时器,但能作为第二层防线,尤其适合无法取消的 Promise 回调。
十四、状态机测试矩阵 🧪
| 初始阶段 | 事件 | 期望阶段 | 额外断言 |
|---|---|---|---|
| Home | 选普通模式 | Loading | Canvas 未就绪前不初始化 |
| Loading | 尺寸有效 | Playing | 引擎只初始化一次 |
| Playing | 点击暂停 | Paused | 循环停止、输入隐藏 |
| Paused | 点击继续 | Playing | 循环只启动一次 |
| Paused | 重开 | Loading | 保留原难度与多人配置 |
| Playing | onGameEnd | GameOver | 结算只执行一次 |
| GameOver | 再来一局 | Loading | 旧弹窗先移除 |
| 任意游戏阶段 | 退出 | Home | 循环停止、旧回调失效 |
| Paused | 网络比赛结束 | GameOver | 不出现双遮罩 |
还应做不变量测试:phase !== 'playing'时不接受开火;只有 Playing/Paused/Settling/GameOver 持有有效会话;Home 不允许游戏循环运行。
十五、总结 ✨
当前页面已经通过startGame()、togglePause()、restartGame()、stopGame()和onGameEnd建立了可运行的隐式状态机,也正确考虑了 Canvas 尺寸就绪、声明式 UI 先渲染和循环暂停等实际问题。
它的主要风险来自状态分散:多个布尔值可以组成非法状态,暂停重开存在多余翻转,重开没有完整传递难度与多人配置,固定延时替代了事件契约,旧回调还可能越过会话边界。渐进治理的核心不是立刻重写 1900 行页面,而是先引入唯一主阶段、保留会话上下文、让迁移显式且幂等,再用会话版本和测试保护异步边界。这样 ArkUI 的“状态驱动界面”才能真正与游戏引擎的状态保持一致。🚀
推荐标签:OpenHarmonyHarmonyOSArkTSArkUI状态机Canvas游戏开发生命周期