当前位置: 首页 > news >正文

羽球搭子 HarmonyOS 实战(16):实时计分页的状态机设计

一、计分页最怕状态由按钮文字暗示

实时计分看起来只是两个“+1”按钮,实际同时约束比赛是否进行、采用哪种赛制、比分能否修改、是否达到结束条件、是否已经落盘以及结束后是否允许重复提交。如果这些规则散落在多个点击回调中,21:20、30:29、抢 21 和普通 21 分制会出现互相矛盾的结果。

稳妥的实现是把比赛建模为显式状态机。playing状态接受加分、减分、直接录入和撤销;每次变化后统一执行赛制判定;命中结束条件后进入finished,冻结按钮并触发保存。ArkUI 页面只根据状态渲染,不自行推断比赛是否结束。HarmonyOS 状态管理的基础概念可参考ArkUI 状态管理概述。

二、领域状态要比 UI 状态更精确

计分卡片至少需要比赛标识、关联对局标识、计分制、双方名称与比分、比赛状态、胜方、开始时间和结束时间。弹窗是否展开、快捷比分面板是否显示属于 UI 状态,不应进入比赛实体。这样即使换成平板双栏或语音计分入口,领域模型仍保持一致。

字段约束参与的状态转换
status只能是 playing/finished决定所有计分按钮可用性
scoringSystem创建比赛后固定决定结束阈值与领先分
scoreA/scoreB非负整数每次变化后触发结束判定
winnerplaying 时为空finished 时必须是 A 或 B
endedAtplaying 时为 0finished 时写入时间戳
linkedSessionId可为空非空时结束后回写正式对局

状态字段不能互相打架。例如status='finished'winner='',统计页就无法判断胜方;playing却带有endedAt,恢复后可能被误当作已完成。创建副本时应一次写全相关字段,而不是分多次更新。

三、所有比分变化走同一个入口

按钮回调只传递队伍和变化量,统一入口负责查找比赛、检查状态、收敛非负数、记录可撤销动作、替换数组元素并触发结束判定。这样“加一分”“减一分”和未来的蓝牙遥控器都复用相同规则。

updateScore(matchId: string, team: 'A' | 'B', delta: number): void { const next = this.matches.slice() const index = next.findIndex((item: ScoreMatch) => item.id === matchId) if (index < 0 || next[index].status !== 'playing') return const current = next[index] const scoreA = team === 'A' ? Math.max(0, current.playerA.score + delta) : current.playerA.score const scoreB = team === 'B' ? Math.max(0, current.playerB.score + delta) : current.playerB.score this.recordHistory(matchId, team, delta, current, scoreA, scoreB) next[index] = this.copyWithScore(current, scoreA, scoreB) this.matches = next this.checkAutoFinish(matchId) }

这里先复制数组再替换元素,确保 ArkUI 能观察到引用变化。Math.max(0, ...)只是一层底线;更重要的是playing门禁,它阻止已结束比赛继续被减分“复活”。

四、赛制判定必须是纯函数

结束规则不应该直接操作页面。纯函数接收赛制和比分,返回胜方或空值,便于覆盖边界测试。普通 21 分制要求至少 21 分且领先 2 分;抢 21 到点且不平分即可结束;15 分制同理;一分到底等特殊玩法也可以作为独立策略扩展。

function resolveWinner(system: ScoringSystem, a: number, b: number): Team | '' { const lead = Math.abs(a - b) if (system === '21-point' && Math.max(a, b) >= 21 && lead >= 2) { return a > b ? 'A' : 'B' } if (system === '21-point-rush' && Math.max(a, b) >= 21 && a !== b) { return a > b ? 'A' : 'B' } if (system === '15-point' && Math.max(a, b) >= 15 && lead >= 2) { return a > b ? 'A' : 'B' } if (system === '100-point-rush' && Math.max(a, b) >= 100 && a !== b) { return a > b ? 'A' : 'B' } return '' }
场景普通 21 分制抢 21预期
20:20未结束未结束平分继续
21:20未结束A 胜普通制还差领先 2 分
22:20A 胜A 胜状态进入 finished
30:29未设置封顶时继续A 胜规则必须与产品定义一致
20:22B 胜B 胜胜方与结束时间同时写入

五、结束转换要保证只执行一次

快速双击可能让两次+1几乎同时进入回调。结束函数再次检查当前状态,只有仍为playing才创建结束副本。随后清空撤销栈、持久化比分,并根据是否关联正式对局决定同步路径。重复调用直接返回,避免一场比赛生成两条保存记录。

finishMatch(matchId: string, winner: Team): void { const next = this.matches.slice() const index = next.findIndex((item: ScoreMatch) => item.id === matchId) if (index < 0 || next[index].status === 'finished') return const current = next[index] next[index] = { ...current, status: 'finished', winner, endedAt: Date.now() } this.matches = next this.scoreHistory.delete(matchId) this.persistFinishedMatch(next[index]) }

保存失败也不应把比赛退回playing。本地结果先保存并给出明确提示,云端失败进入待同步状态;否则用户已经看到结束画面,却因为网络错误突然回到可计分状态,会造成更严重的数据不一致。

六、直接录入是一条新的比分基线

裁判可能从纸面记录补录 18:16,或者修正误操作后的整段比分。直接录入与连续点击不同:它不知道每一分由哪一队获得,因此不能保留旧撤销栈。输入需要校验为有限、非负整数,并按赛制设置合理上限;写入后清空历史,再执行统一结束判定。

applyDirectScore(matchId: string, rawA: string, rawB: string): boolean { const a = Number(rawA) const b = Number(rawB) if (!Number.isFinite(a) || !Number.isFinite(b) || a < 0 || b < 0) { return false } const max = this.scoringSystemOf(matchId) === '100-point-rush' ? 200 : 50 const scoreA = Math.min(max, Math.floor(a)) const scoreB = Math.min(max, Math.floor(b)) this.replaceScore(matchId, scoreA, scoreB) this.scoreHistory.delete(matchId) this.checkAutoFinish(matchId) return true }

直接录入达到结束条件时同样进入finished,不能绕过保存链路。若比分超过产品上限,应在输入框旁提示收敛结果,避免用户以为录入的 999 分被完整保存。

七、语音和动画是状态的副作用

比分播报、局点动画和振动反馈都应该在状态更新成功之后触发。播报函数需要知道旧比分和新比分,才能避免减分或无效点击重复朗读;自动结束发生后,则优先播报比赛结果,不再播报普通比分。副作用失败不能影响比分本身。

private afterScoreChanged(matchId: string, before: Score, after: Score): void { const current = this.findMatch(matchId) if (current === undefined) return if (current.status === 'finished') { this.speakResult(current) return } if (before.a !== after.a || before.b !== after.b) { this.speakScore(after) } }

把副作用和状态转换分开后,即使设备没有可用语音引擎,计分仍然可靠;平板布局只需要订阅相同比赛模型,不需要复制一套结束判断。

八、围绕边界比分做验收

计分状态机的测试重点不是 0:0 到 1:0,而是临界状态和重复操作。每一种赛制至少验证一组未结束、刚好结束和结束后继续点击的场景。

1. 普通 21 分制输入 20:20,双方各加 1 分,确认 21:21 仍在进行。 2. 再连续形成 23:21,确认只结束一次,胜方为 A,按钮全部禁用。 3. 抢 21 输入 21:20,确认立即结束;普通制下同比分不得结束。 4. 在 0:0 连续减分,确认比分不低于 0,也不产生撤销记录。 5. 对已结束比赛再次点击加分、重置和直接录入,确认状态不变化。 6. 结束时关闭网络,确认本地结果保留并出现可理解的同步提示。

九、总结

实时计分页的核心是一组受控状态转换,而不是一排按钮。领域模型明确playingfinished,比分变化统一进入一个函数,赛制判定保持纯净,结束转换具备幂等保护,直接录入重建撤销基线,语音等副作用不干扰主状态。这样才能让手机、平板、语音和云同步共用一套比赛规则。

http://www.jsqmd.com/news/1244219/

相关文章:

  • Windows系统文件dwmscene.dll丢失找不到问题解决
  • 分布式 ID
  • 2026年工商业储能系统推荐:系统效率、循环寿命与安全认证全解析 - 科技焦点
  • AI写作金句植入的5个致命误区:92%的创作者正在 silently 毁掉爆款基因
  • 市场知名的测功机工厂
  • gh_mirrors/log/logback授权控制详解:基于角色与权限的访问管理
  • C语言控制语句-循环
  • 如何用Niva快速构建第一个跨平台桌面应用?5分钟上手教程
  • 2026年7月博世壁挂炉售后服务电话24小时全新专属热线升级公示最新公告 - 家电技术百科
  • Linux下MyIpAdd库的使用
  • 羽球搭子 HarmonyOS 实战(17):比分撤销与边界校验
  • 终极解密利器AES-Killer:网络安全测试中的AES加密流量实时解密指南
  • 【超详细】二分查找(折半查找)核心知识点全解析(含多场景代码实现)
  • 2026年7月最新萧邦大连来福士维修保养服务电话 - 萧邦中国官方服务中心
  • 5个关键问题:GoFrame如何帮助企业级应用提升3倍开发效率?
  • DPPO环境扩展指南:如何将新机器人仿真环境接入训练框架
  • 2026年工商业储能系统选型指南:六款主流一体机深度对比 - 科技焦点
  • 树莓派部署BirdNET-Go常见问题解答:从硬件要求到性能优化
  • [JCMSuite] JCMSuite应用:等离子波导
  • json-swift高级技巧:函数式API带你玩转JSON数据处理
  • 2026记者采访整理录音,软件测评哪个好更适合新人使用
  • IT66318:HDMI 2.0 Retimer
  • 第四篇:《函数、指针与结构体:组织代码的核心工具》
  • Nativ:100%开源,让你在Mac本地运行AI模型,无需账户订阅!
  • 做公司PPT最烦的不是写内容,是套模板
  • B3843 [GESP202306 三级] 密码合规 题解
  • LLM输出稳定性差3倍?——从token级置信度、逻辑连贯性到领域适配度的8项硬指标深度拆解
  • 2026年经期记录小程序哪个好用?日常记录与周期预测实操经验 - 软件测评小帮手
  • 大数据hadoop的高校照明智慧监测预警系统
  • 2026 餐饮收银系统横向科普:二维火与客如云适配场景、能力差异解析