🏓《乒乓球电子裁判:基于 Flutter for OpenHarmony 的发球检测系统》
🌐加入社区
欢迎加入 开源鸿蒙跨平台开发者社区,获取最新资源与技术支持!
一、引言:为什么需要“电子发球裁判”?
在业余乒乓球比赛中,发球违例(如遮挡、抛球不足16cm、非垂直抛球)是最常见也最难判定的争议点。专业裁判需经过严格训练,而普通玩家往往只能“凭感觉”判罚,影响公平性。
借助Flutter for OpenHarmony,我们可以在平板或智慧屏设备上部署一个辅助裁判系统:通过手动标记发球动作,自动记录发球方、轮次、违例次数,并提示当前应由谁发球。
用户只需点击“A 发球 OK”、“B 发球违规”,系统即可自动推进回合、统计数据——界面只有文字和按钮,却能显著提升比赛规范性。
二、系统目标:轻量、离线、规则准确
本系统不依赖摄像头或 AI,而是基于ITTF(国际乒联)简化规则,实现以下功能:
- ✅ 支持双打/单打(默认单打)
- ✅ 每2分自动交换发球权
- ✅ 11分制,需领先2分获胜(最多打至15分)
- ✅ 手动标记“有效发球”或“发球违例”
- ✅ 实时显示当前发球方、比分、局数
💡设计理念:
不替代人眼,而是辅助记忆与规则执行。裁判/球员主动操作,系统负责计数与提醒。
三、核心状态设计
1. 数据结构
classMatchState{// 当前局int currentSet=1;// 比分 [A, B]List<int>scores=[0,0];// 每局历史 [[A1,B1], [A2,B2], ...]List<List<int>>setHistory=[];// 发球控制bool isTeamAServing=true;// true=A发球,false=B发球int serveCount=0;// 当前发球方已连续发球次数(0~1)// 违例统计int foulCountA=0;int foulCountB=0;}2. 发球轮换逻辑
根据 ITTF 规则:
- 每方连续发2球后交换发球权
- 局末平分(10:10)后,每1球交换发球
voidadvanceServe(){serveCount++;if((scores[0]<10||scores[1]<10)&&serveCount>=2){// 常规阶段:2球换发_switchServe();}elseif(scores[0]>=10&&scores[1]>=10){// deuce 阶段:1球换发_switchServe();}}void_switchServe(){isTeamAServing=!isTeamAServing;serveCount=0;}3. 得分处理
voidaddPoint(Stringteam){finalindex=team=='A'?0:1;scores[index]++;// 检查是否赢下当前局if(_checkSetWin()){setHistory.add([...scores]);if(setHistory.length<3){// 最多3局scores=[0,0];isTeamAServing=setHistory.length.isOdd;// 交替先发serveCount=0;}}else{advanceServe();// 正常得分后推进发球}}bool_checkSetWin(){finala=scores[0],b=scores[1];if(a>=11||b>=11){return(a-b).abs()>=2||a==15||b==15;}returnfalse;}四、交互设计:极简裁判面板
界面分为三部分:
- 当前局比分 + 发球提示
- 发球操作区(标记有效/违例)
- 历史局分 + 违例统计
所有操作均为手动点击,避免误触,确保裁判可控性。
五、完整可运行代码(lib/main.dart)
import'package:flutter/material.dart';voidmain()=>runApp(constMyApp());classMyAppextendsStatelessWidget{constMyApp({super.key});@overrideWidgetbuild(BuildContextcontext){returnMaterialApp(title:'PingPong Referee - OH',home:Scaffold(body:PingPongReferee()),);}}classPingPongRefereeextendsStatefulWidget{@overrideState<PingPongReferee>createState()=>_PingPongRefereeState();}class_PingPongRefereeStateextendsState<PingPongReferee>{int currentSet=1;List<int>scores=[0,0];// [A, B]List<List<int>>setHistory=[];// e.g., [[11,8], [9,11]]bool isTeamAServing=true;int serveCount=0;int foulCountA=0;int foulCountB=0;voidresetMatch(){setState((){currentSet=1;scores=[0,0];setHistory.clear();isTeamAServing=true;serveCount=0;foulCountA=0;foulCountB=0;});}voidrecordValidServe(Stringteam){// 有效发球 → 对方失分(即本方得分)addPoint(team);}voidrecordFoulServe(Stringteam){// 发球违例 → 对方直接得分finalopponent=team=='A'?'B':'A';addPoint(opponent);// 记录违例setState((){if(team=='A')foulCountA++;elsefoulCountB++;});}voidaddPoint(Stringteam){setState((){finalidx=team=='A'?0:1;scores[idx]++;// 检查是否赢下当前局if(_checkSetWin()){setHistory.add([scores[0],scores[1]]);if(setHistory.length<3){// 开始新局scores=[0,0];currentSet=setHistory.length+1;// 交替先发球权isTeamAServing=(setHistory.length%2==1);serveCount=0;}}else{_advanceServe();}});}bool_checkSetWin(){finala=scores[0],b=scores[1];if(a>=11||b>=11){return(a-b).abs()>=2||a==15||b==15;}returnfalse;}void_advanceServe(){serveCount++;if((scores[0]<10||scores[1]<10)&&serveCount>=2){_switchServe();}elseif(scores[0]>=10&&scores[1]>=10){_switchServe();}}void_switchServe(){isTeamAServing=!isTeamAServing;serveCount=0;}@overrideWidgetbuild(BuildContextcontext){finalservingTeam=isTeamAServing?'A':'B';finalnextServeInfo='当前发球: Team$servingTeam(第${serveCount+1}球)';returnPadding(padding:constEdgeInsets.all(16.0),child:Column(children:[// 标题constText('🏓 乒乓球电子裁判',style:TextStyle(fontSize:24,fontWeight:FontWeight.bold)),constSizedBox(height:10),// 当前局比分Text('第$currentSet局',style:constTextStyle(fontSize:20)),Row(mainAxisAlignment:MainAxisAlignment.spaceEvenly,children:[Text('A\n${scores[0]}',style:constTextStyle(fontSize:48,fontWeight:FontWeight.bold)),Text('B\n${scores[1]}',style:constTextStyle(fontSize:48,fontWeight:FontWeight.bold)),],),constSizedBox(height:10),Text(nextServeInfo,style:constTextStyle(fontSize:18,color:Colors.blue)),constSizedBox(height:30),// 发球操作区constText('发球判定',style:TextStyle(fontSize:20,fontWeight:FontWeight.bold)),constSizedBox(height:10),Row(mainAxisAlignment:MainAxisAlignment.spaceEvenly,children:[Column(children:[constText('Team A'),Row(children:[ElevatedButton(onPressed:()=>recordValidServe('A'),child:constText('有效'),style:ElevatedButton.styleFrom(backgroundColor:Colors.green),),constSizedBox(width:10),ElevatedButton(onPressed:()=>recordFoulServe('A'),child:constText('违例'),style:ElevatedButton.styleFrom(backgroundColor:Colors.red),),]),],),Column(children:[constText('Team B'),Row(children:[ElevatedButton(onPressed:()=>recordValidServe('B'),child:constText('有效'),style:ElevatedButton.styleFrom(backgroundColor:Colors.green),),constSizedBox(width:10),ElevatedButton(onPressed:()=>recordFoulServe('B'),child:constText('违例'),style:ElevatedButton.styleFrom(backgroundColor:Colors.red),),]),],),],),constSizedBox(height:20),// 历史与统计if(setHistory.isNotEmpty)...[constText('历史局分',style:TextStyle(fontSize:18,fontWeight:FontWeight.bold)),Wrap(spacing:10,children:setHistory.asMap().entries.map((e){finalset=e.key+1;finala=e.value[0];finalb=e.value[1];returnChip(label:Text('第$set局:$a-$b'));}).toList(),),],constSizedBox(height:10),Text('违例统计: A=$foulCountA, B=$foulCountB',style:constTextStyle(color:Colors.orange)),constSpacer(),// 重置按钮ElevatedButton.icon(onPressed:resetMatch,icon:constIcon(Icons.refresh),label:constText('重置比赛'),),],),);}}六、OpenHarmony 实测说明
- 创建项目:DevEco Studio → 新建Flutter for OpenHarmony项目
- 替换代码:将上述代码粘贴至
lib/main.dart - 依赖检查:确保
oh-package.json5包含:"dependencies": { "@ohos/flutter_ohos": "file:./har/flutter.har" } - 运行:执行
ohpm install后点击 ▶️
✅操作流程:
- 比赛开始 → 系统提示 “当前发球: Team A”
- A 发球成功 → 点击 “A 有效” → 若 B 未接回,则再点一次 “A 有效” 得分
- A 发球遮挡 → 点击 “A 违例” → B 直接得1分
- 每2分自动切换发球方,10:10后每1分切换
七、结语:用技术守护体育精神
本系统虽无 AI 视觉识别,但通过规则内嵌 + 人工确认的方式,在 OpenHarmony 设备上实现了可靠的发球辅助裁判功能。它证明了:即使是简单的状态机,也能在体育场景中创造真实价值。