🏓《乒乓球电子裁判:基于 Flutter for OpenHarmony 的发球检测系统》

🌐 加入社区
欢迎加入 开源鸿蒙跨平台开发者社区,获取最新资源与技术支持!

一、引言:为什么需要"电子发球裁判"?
在业余乒乓球比赛中,发球违例(如遮挡、抛球不足16cm、非垂直抛球)是最常见也最难判定的争议点。专业裁判需经过严格训练,而普通玩家往往只能"凭感觉"判罚,影响公平性。
借助 Flutter for OpenHarmony ,我们可以在平板或智慧屏设备上部署一个辅助裁判系统 :通过手动标记发球动作,自动记录发球方、轮次、违例次数,并提示当前应由谁发球。
用户只需点击"A 发球 OK"、"B 发球违规",系统即可自动推进回合、统计数据------界面只有文字和按钮,却能显著提升比赛规范性。
二、系统目标:轻量、离线、规则准确
本系统不依赖摄像头或 AI,而是基于 ITTF(国际乒联)简化规则,实现以下功能:
- ✅ 支持双打/单打(默认单打)
- ✅ 每2分自动交换发球权
- ✅ 11分制,需领先2分获胜(最多打至15分)
- ✅ 手动标记"有效发球"或"发球违例"
- ✅ 实时显示当前发球方、比分、局数
💡 设计理念 :
不替代人眼,而是辅助记忆与规则执行。裁判/球员主动操作,系统负责计数与提醒。
三、核心状态设计
1. 数据结构
dart
class MatchState {
// 当前局
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球交换发球
dart
void advanceServe() {
serveCount++;
if ((scores[0] < 10 || scores[1] < 10) && serveCount >= 2) {
// 常规阶段:2球换发
_switchServe();
} else if (scores[0] >= 10 && scores[1] >= 10) {
// deuce 阶段:1球换发
_switchServe();
}
}
void _switchServe() {
isTeamAServing = !isTeamAServing;
serveCount = 0;
}
3. 得分处理
dart
void addPoint(String team) {
final index = 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() {
final a = scores[0], b = scores[1];
if (a >= 11 || b >= 11) {
return (a - b).abs() >= 2 || a == 15 || b == 15;
}
return false;
}
四、交互设计:极简裁判面板
界面分为三部分:
- 当前局比分 + 发球提示
- 发球操作区(标记有效/违例)
- 历史局分 + 违例统计
所有操作均为手动点击 ,避免误触,确保裁判可控性。
五、完整可运行代码(lib/main.dart)
dart
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'PingPong Referee - OH',
home: Scaffold(body: PingPongReferee()),
);
}
}
class PingPongReferee extends StatefulWidget {
@override
State<PingPongReferee> createState() => _PingPongRefereeState();
}
class _PingPongRefereeState extends State<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;
void resetMatch() {
setState(() {
currentSet = 1;
scores = [0, 0];
setHistory.clear();
isTeamAServing = true;
serveCount = 0;
foulCountA = 0;
foulCountB = 0;
});
}
void recordValidServe(String team) {
// 有效发球 → 对方失分(即本方得分)
addPoint(team);
}
void recordFoulServe(String team) {
// 发球违例 → 对方直接得分
final opponent = team == 'A' ? 'B' : 'A';
addPoint(opponent);
// 记录违例
setState(() {
if (team == 'A') foulCountA++;
else foulCountB++;
});
}
void addPoint(String team) {
setState(() {
final idx = 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() {
final a = scores[0], b = scores[1];
if (a >= 11 || b >= 11) {
return (a - b).abs() >= 2 || a == 15 || b == 15;
}
return false;
}
void _advanceServe() {
serveCount++;
if ((scores[0] < 10 || scores[1] < 10) && serveCount >= 2) {
_switchServe();
} else if (scores[0] >= 10 && scores[1] >= 10) {
_switchServe();
}
}
void _switchServe() {
isTeamAServing = !isTeamAServing;
serveCount = 0;
}
@override
Widget build(BuildContext context) {
final servingTeam = isTeamAServing ? 'A' : 'B';
final nextServeInfo = '当前发球: Team $servingTeam (第 ${serveCount + 1} 球)';
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
// 标题
const Text('🏓 乒乓球电子裁判', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
const SizedBox(height: 10),
// 当前局比分
Text('第 $currentSet 局', style: const TextStyle(fontSize: 20)),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Text('A\n${scores[0]}', style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold)),
Text('B\n${scores[1]}', style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold)),
],
),
const SizedBox(height: 10),
Text(nextServeInfo, style: const TextStyle(fontSize: 18, color: Colors.blue)),
const SizedBox(height: 30),
// 发球操作区
const Text('发球判定', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Column(
children: [
const Text('Team A'),
Row(children: [
ElevatedButton(
onPressed: () => recordValidServe('A'),
child: const Text('有效'),
style: ElevatedButton.styleFrom(backgroundColor: Colors.green),
),
const SizedBox(width: 10),
ElevatedButton(
onPressed: () => recordFoulServe('A'),
child: const Text('违例'),
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
),
]),
],
),
Column(
children: [
const Text('Team B'),
Row(children: [
ElevatedButton(
onPressed: () => recordValidServe('B'),
child: const Text('有效'),
style: ElevatedButton.styleFrom(backgroundColor: Colors.green),
),
const SizedBox(width: 10),
ElevatedButton(
onPressed: () => recordFoulServe('B'),
child: const Text('违例'),
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
),
]),
],
),
],
),
const SizedBox(height: 20),
// 历史与统计
if (setHistory.isNotEmpty) ...[
const Text('历史局分', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
Wrap(
spacing: 10,
children: setHistory.asMap().entries.map((e) {
final set = e.key + 1;
final a = e.value[0];
final b = e.value[1];
return Chip(label: Text('第$set局: $a-$b'));
}).toList(),
),
],
const SizedBox(height: 10),
Text('违例统计: A=$foulCountA, B=$foulCountB', style: const TextStyle(color: Colors.orange)),
const Spacer(),
// 重置按钮
ElevatedButton.icon(
onPressed: resetMatch,
icon: const Icon(Icons.refresh),
label: const Text('重置比赛'),
),
],
),
);
}
}
六、OpenHarmony 实测说明
-
创建项目 :DevEco Studio → 新建 Flutter for OpenHarmony 项目
-
替换代码 :将上述代码粘贴至
lib/main.dart -
依赖检查 :确保
oh-package.json5包含: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 设备上实现了可靠的发球辅助裁判功能。它证明了:即使是简单的状态机,也能在体育场景中创造真实价值。

