
【OpenHarmony/HarmonyOS】ArkUI 游戏结算弹窗统计展示、结果分支与操作闭环结算弹窗不是在画面上放一句“胜利”就结束了。它位于游戏会话、统计快照、排行榜写入、资产刷新和下一步操作的交叉点显示早了数据还没同步显示晚了玩家以为游戏卡住按钮连续点击可能启动两局遮罩没有拦截触摸玩家还能在弹窗下开火。本文通过一个真实 ArkUIGameOverDialog拆解结算组件应该如何划分职责、传递数据并处理边界。一、结算流程先于结算 UI项目的完整结束链路由 GameEngine、Index 页面和弹窗组件共同完成sequenceDiagram participant EngineasGameEngine participant PageasIndex 页面 participant StoreasManager participant DialogasGameOverDialog Engine-Engine: 锁定一次性结算 Engine-Engine: 设置 game_over Engine-Page: onGameEnd(result) Page-Page: 复制统计快照 Page-Store: 保存排行榜/用户统计 Page-Page: isGameOvertruePage-Dialog: result stats callbacks Dialog--Page: onRestart 或 onExit层次应负责的内容不应负责的内容GameEngine判定胜负、停止世界规则、产出结果构建 ArkUI 弹窗Index 页面同步快照、保存统计、决定是否展示逐个绘制结算指标GameOverDialog展示结果、展示只读统计、发出用户意图直接重置引擎或写 Preferences当前实现基本遵守了这个边界弹窗没有导入 GameEngine 或 Manager只接收输入并调用回调因此可在不同模式中复用。二、组件 API结果、统计和两个命令核心接口非常小Componentexportstruct GameOverDialog { Prop result:win|lose|p2_winlose; Prop stats: GameStats newGameStats(); onRestart:()void(){}; onExit:()void(){}; }Prop表示数据由父组件传入弹窗只读取不拥有权威状态。默认值使组件即使在预览或调用遗漏时也能构建不会因undefined直接崩溃。两个函数属性把“用户点击了什么”传回父页面弹窗不需要知道重开的具体时序。当前result支持p2_win而 Index 页的gameResult类型只有win | loseGameEngine 回调也按本机玩家视角产出胜负。因此p2_win更像预留分支在当前主调用链中不可达。类型设计应以领域语义为准若多人模式需要展示具体获胜队伍可以使用winnerTeam: A | B | null而不是继续增加p2_win、p3_win。三、父页面如何提供一次统计快照引擎回调到达后页面新建GameStats并复制字段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; };新建对象而不是直接把引擎对象交给 UI有“冻结快照”的意图结算显示不再随着引擎内部变化。但手工逐字段复制容易漏项项目当前没有复制coinsCollected和部分其他字段。虽然当前弹窗网格只展示击毁数、分数和时间个人资料里的其他结算视图可能因此看到默认值。更稳定的做法是让引擎产出明确 DTOinterface GameResultSnapshot { result:win|lose; score: number; targetsDestroyed: number; survivalTimeSeconds: number; coinsCollected: number; mode: GameModeType; wave: number; }这是演进设计。DTO 一旦建立弹窗、排行榜和用户统计都读取同一份不可变结果避免三套字段复制规则。四、结果标题和颜色必须来自同一判断弹窗通过方法映射标题资源getResultTitle(): ResourceStr {if(this.result win) {return$r(app.string.result_victory); }if(this.result p2_win) {return$r(app.string.result_p2_win); }return$r(app.string.result_defeat); }标题颜色则把win和p2_win都视为绿色否则使用红色。当前两处判断语义一致但写了两遍。模式变多后可能出现标题写成胜利、颜色仍走失败分支。可以先计算视图模型interfaceResultPresentation{ title: ResourceStr; accentColor: string; glowColor: string; }privategetPresentation(): ResultPresentation {constpositive this.result win||this.result p2_win;return{ title:this.getResultTitle(), accentColor: positive ?#69F0AE:#FF5252, glowColor: positive ?rgba(105, 240, 174, 0.5):rgba(255, 82, 82, 0.5)}; }这段属于重构方向不是当前代码。集中映射能让视觉与语义共同变化。五、全屏遮罩的第一职责是阻断下层交互弹窗最外层使用全屏 Stack先绘制半透明矩形Stack() { Rect() .width(100%) .height(100%) .fill(rgba(0, 0, 0, 0.7)) .onClick(() {});Column() { // 标题、统计与按钮 } } .width(100%) .height(100%) .alignContent(Alignment.Center)空的onClick并不是多余代码它让遮罩成为命中目标防止触摸穿透到下方 Canvas、摇杆或开火按钮。页面还通过!isGameOver隐藏底部操作区形成第二层保护。遮罩是否允许点击空白关闭需要谨慎。普通确认框可以游戏结算不适合因为关闭后应该进入主页还是继续停在冻结世界并不明确。当前只允许“退出”和“再来一局”状态闭环更清楚。六、统计网格稳定尺寸比动态卡片更重要弹窗使用三列 Grid 展示击毁、总分和生存时间Grid(){GridItem(){ this.StatCell(labelKills,killsText)}GridItem(){ this.StatCell(labelScore,scoreText)}GridItem(){ this.StatCell(labelTime,timeText)} } .columnsTemplate(1fr 1fr 1fr).width(100%) .height(60)指标来源格式化击毁数stats.targetsDestroyed整数文本总分stats.score整数文本生存时间stats.survivalTimem:ss固定三列能让数字变化时布局不跳动。统计值较大时应考虑最大宽度、缩写或动态字体但不能让一个超长数字把按钮挤出弹窗。当前第一个标签使用的资源名是stat_score_title显示的值却是targetsDestroyed。资源内容需要结合 string.json 再确认命名本身已经不够清晰。推荐为每个指标建立明确资源键避免“标题资源复用后语义漂移”。七、时间格式化的边界组件内部格式化秒数privateformatTime(seconds:number):string{constminutes Math.floor(seconds /60);constremain seconds %60;return${minutes}:${remain.toString().padStart(2,0)}; }对正常非负整数这会得到0:05、12:34。但输入若为小数、负数或NaN输出会变得不可预测。引擎和页面都可能计算时间因此格式化边界最好统一functionformatDuration(rawSeconds:number):string{consttotal Number.isFinite(rawSeconds) ?Math.max(0,Math.floor(rawSeconds)) :0;constminutes Math.floor(total /60);constseconds total %60;return${minutes}:${seconds.toString().padStart(2,0)}; }统一工具还能避免 HUD 使用00:05、结算使用0:05的格式差异。八、按钮只发出意图不承担业务按钮代码保持简单Button($r(app.string.btn_exit)) .onClick(() { this.onExit(); });Button($r(app.string.btn_play_again)) .onClick(() { this.onRestart(); });父组件决定实际行为GameOverDialog({ result:this.gameResult, stats:this.gameStats, onRestart: () this.restartGame(), onExit: () this.stopGame() });这使弹窗可以被预览、测试也不依赖路由。点击再来一局由页面关闭弹窗、停止旧循环、重置初始化状态并启动新局退出则处理未结算资源、停止循环并回主页。当前按钮没有提交中状态。快速连续点击可能多次触发restartGame()排入多个定时器。可以由父页面在第一次点击后进入restarting或exiting状态并把disabled传给弹窗。九、显示弹窗前还发生了哪些副作用页面在onGameEnd中还保存排行榜与累计用户统计if(score 0) { ScoreManager.getInstance().saveScore({ date: Date.now(), score, mode:this.gameMode multiplayer?pvp:pve, playerName:this.userName, avatar:this.userAvatar }); } UserManager.getInstance().updateUserStats( result win?win:lose, score,this.gameStats.targetsDestroyed,this.gameStats.survivalTime );这些方法返回异步结果但回调没有等待弹窗立即出现。对于本地单机体验这能减少等待如果保存失败玩家却没有看到任何提示。可以把结束过程拆成两个阶段先冻结并展示结果后台保存按钮可立即操作但页面记录失败并稍后重试。若排行榜是云端权威则应显示独立的“成绩同步中/同步失败”状态不要阻塞整个结算弹窗。十、尺寸与响应式问题 内容容器当前固定宽度 360Column({ space:15}){// ...}.width(360).padding(24).backgroundColor(rgba(30, 30, 40, 0.6)).backdropBlur(40).borderRadius(24)横屏手机通常足够但在半折叠区域、分屏或高字体缩放下360vp 加左右安全区可能过宽。更稳妥的是width(90%)加constraintSize({ maxWidth: 360 })让窄屏收缩、宽屏不无限拉长。固定 60 高的统计 Grid 也要测试多语言。日文、韩文或系统大字体可能让标签换行与数值重叠。可以给标签两行空间、使用最小高度并允许内容增长。十一、可访问性与反馈胜负不能只靠绿/红颜色表达当前标题文本已经提供语义这是正确方向。还可以补充为结果标题设置适合辅助功能读取的描述弹窗出现后将焦点移到结果标题或主操作保证按钮触控区域不少于系统建议尺寸高对比度下仍能识别半透明边框“退出”和“再来一局”的焦点顺序符合视觉顺序减弱动画设置开启时不强制复杂缩放或闪烁。触感反馈应该由结束事件触发而不是弹窗每次重新构建都触发否则状态刷新会重复振动。当前音频与振动位于 GameEngine 结算链路方向正确。十二、错误与重复结算边界 ⚠️结算弹窗自身不负责防重复真正的闩锁在引擎isGameOverSequenceStarted。这是正确位置因为在 UI 出现之前就要阻止重复加币和重复回调。但仍要注意onGameEnd可能因旧会话延时回调在新局中到达页面离开后回调仍引用页面状态快速双击按钮会重复安排重开多人网络结果与本地死亡可能竞争手工复制统计可能形成不完整快照。可使用会话 ID 验证回调归属并让页面阶段从settling只能迁移一次到game_over。十三、建议测试清单 场景预期resultwin胜利资源标题与正向颜色resultlose失败标题与警示颜色resultp2_win当前预留分支能正确展示空 stats所有指标显示安全默认值生存 5 秒显示0:05生存时间异常钳制后不出现 NaN/负号点击遮罩不关闭不穿透到底层连续点击重开父状态只接收一次有效命令保存排行榜失败弹窗仍可操作失败可观测窄屏与大字体文本、网格和按钮不重叠中日韩资源标签不会溢出固定区域旧会话回调不能覆盖当前新局结果组件测试可以直接传入不同GameStats和回调计数器不需要启动完整游戏引擎端到端测试再覆盖引擎锁、数据保存和页面迁移。十四、总结 ✨一个好的结算弹窗应该“展示事实、发出意图”而不是成为第二个游戏控制器。当前GameOverDialog的优点是 API 小、输入单向、回调清楚、遮罩能阻断下层交互、统计布局稳定父页面负责快照、排行榜和重开退出职责边界基本合理。真实改进点同样明确结果类型存在不可达预留值统计快照手工复制会漏字段固定宽度需适配窄屏和大字体按钮需要防连点格式化函数需要处理异常输入异步保存应有可观测状态。把这些细节补齐结算弹窗才不只是“好看的浮层”而是一次游戏会话可靠结束和下一次行动自然开始的连接器。推荐标签OpenHarmonyHarmonyOSArkTSArkUI游戏开发组件设计状态管理响应式布局