Flutter 实战:tic_tac_toe 井字棋的三阶棋盘、胜负扫描与鸿蒙适配解析
Flutter 实战:tic_tac_toe 井字棋的三阶棋盘、胜负扫描与鸿蒙适配解析
前言
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
tic_tac_toe 是一个用 Flutter 实现的双人井字棋小游戏。它使用 3x3 二维数组保存棋盘状态,通过当前玩家、赢家状态和游戏结束标记完成落子、换手、胜负检测、平局判定和新一局重置。
本文基于项目真实源码展开,重点分析 三阶棋盘状态建模、X/O 玩家换手、行列对角线胜负扫描、平局判定、棋盘 UI 渲染、New Game 重置和鸿蒙适配关注点。文章内容可直接发布到 CSDN,不包含面向作者的检查说明。
井字棋是学习 Flutter 小游戏的好例子:规则很小,但状态流非常完整。它能把“点击事件、状态更新、胜负判断、UI 反馈”串成一条清晰的工程链路。

图示说明:本文围绕 Flutter 井字棋的棋盘状态、点击落子、胜负扫描和跨端适配展开,适合用于鸿蒙、Android、iOS 等多端小游戏开发复盘。
一、项目定位与功能概览
1.1 应用主题
tic_tac_toe 的定位是一个 双人本地井字棋小游戏。玩家 X 先手,玩家 O 后手,双方轮流点击 3x3 棋盘格子。任一玩家在同一行、同一列或任一对角线连成三个相同标记,即判定获胜;棋盘填满且无人获胜,则判定平局。
1.2 核心功能
项目功能可以概括为:
| 功能 | 页面表现 | 源码实现 |
|---|---|---|
| 3x3 棋盘 | 九个可点击格子 | _board |
| 当前玩家 | X's Turn 或 O's Turn |
_currentPlayer |
| 落子 | 点击空格写入 X/O | _onCellTap() |
| 胜负检测 | 获胜后显示赢家 | _checkWinner() |
| 平局判定 | 显示 It's a Draw! |
_winner = 'Draw' |
| 新一局 | New Game 按钮 |
_resetGame() |
1.3 技术学习价值
这个项目适合学习以下 Flutter 实战能力:
- 如何用二维数组表达棋盘。
- 如何用
GestureDetector处理点击落子。 - 如何在落子后立即扫描胜负。
- 如何根据状态渲染不同颜色和文案。
- 如何面向鸿蒙检查触摸、布局和视觉反馈。
二、工程结构与运行方式
2.1 工程结构
项目保持标准 Flutter 工程结构,核心代码集中在 lib/main.dart:
| 文件或目录 | 作用 | 说明 |
|---|---|---|
lib/main.dart |
应用入口与页面实现 | 包含棋盘状态、胜负判断和 UI |
pubspec.yaml |
依赖声明 | 使用 Flutter SDK 与 Material 图标 |
test/widget_test.dart |
Widget 测试入口 | 可扩展为棋盘落子测试 |
ohos/ |
鸿蒙平台工程目录 | 用于跨端构建和适配 |
2.2 依赖声明
项目没有引入复杂第三方依赖:
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.8
这说明游戏逻辑、棋盘渲染和交互反馈都由 Flutter 与 Dart 完成,不依赖网络、数据库、物理引擎或平台通道。
2.3 常用命令
开发和验证时可以使用以下命令:
flutter pub get
flutter analyze
flutter test
flutter run
| 命令 | 作用 | 使用场景 |
|---|---|---|
flutter pub get |
获取依赖 | 首次运行或依赖变化 |
flutter analyze |
静态分析 | 检查语法和 lint |
flutter test |
执行测试 | 验证 Widget 行为 |
flutter run |
启动应用 | 本地调试界面 |
三、应用入口与主题配置
3.1 main 函数
Flutter 应用入口非常简洁:
void main() {
runApp(const MyApp());
}
井字棋不需要复杂初始化,入口只负责挂载根组件。
3.2 MyApp 根组件
根组件负责创建 MaterialApp:
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Tic Tac Toe',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
),
home: const MyHomePage(title: 'Tic Tac Toe'),
);
}
}
这段代码说明:
- 应用标题是
Tic Tac Toe。 - 主题种子色是
Colors.blue。 - 首页是
MyHomePage。
3.3 主题色与游戏反馈
源码中 X 使用蓝色,O 使用橙色,获胜状态使用绿色,平局状态使用灰色。这种颜色区分非常适合棋盘小游戏,能让玩家快速理解当前局势。
当前源码没有显式设置
useMaterial3: true,因此文章以真实代码为准。如果后续需要统一 Material 3 表现,可以在ThemeData中补充该配置。
四、棋盘状态建模
4.1 核心状态总览
页面核心状态定义在 _MyHomePageState 中:
List<List<String>> _board = List.generate(3, (_) => List.filled(3, ''));
String _currentPlayer = 'X';
String _winner = '';
bool _gameOver = false;
字段含义如下:
| 字段 | 类型 | 初始值 | 作用 |
|---|---|---|---|
_board |
List<List<String>> |
3x3 空字符串 | 保存棋盘 |
_currentPlayer |
String |
X |
当前轮到的玩家 |
_winner |
String |
空字符串 | 保存赢家或平局 |
_gameOver |
bool |
false |
标记游戏是否结束 |
4.2 为什么使用二维数组
井字棋天然是三行三列,二维数组能非常自然地表达棋盘:
[
['', '', ''],
['', '', ''],
['', '', ''],
]
每个格子只需要三种可能值:
| 值 | 含义 |
|---|---|
| 空字符串 | 未落子 |
X |
X 玩家落子 |
O |
O 玩家落子 |
4.3 状态分层
可以把状态分成三类:
| 状态类别 | 字段 | 说明 |
|---|---|---|
| 棋盘状态 | _board |
描述每个格子内容 |
| 回合状态 | _currentPlayer |
描述谁来落子 |
| 结果状态 | _winner、_gameOver |
描述是否结束和结果 |
4.4 状态驱动 UI
这个项目没有额外状态管理库,而是直接使用 StatefulWidget 和 setState()。对于九宫格小游戏,这种方式足够清楚,也便于读者理解。
五、重置新一局逻辑
5.1 resetGame 方法
New Game 按钮会触发 _resetGame():
void _resetGame() {
setState(() {
_board = List.generate(3, (_) => List.filled(3, ''));
_currentPlayer = 'X';
_winner = '';
_gameOver = false;
});
}
5.2 重置内容
重置时会恢复四类状态:
| 状态 | 重置后 |
|---|---|
| 棋盘 | 全部为空 |
| 当前玩家 | X |
| 赢家 | 空字符串 |
| 游戏结束标记 | false |
5.3 为什么 X 固定先手
源码中每局都从 X 开始。这符合常见井字棋规则,也能降低状态复杂度。如果想让先手轮换,可以额外保存一个局数或先手字段。
5.4 重置按钮位置
页面底部使用 ElevatedButton.icon 展示新一局按钮:
ElevatedButton.icon(
onPressed: _resetGame,
icon: const Icon(Icons.refresh),
label: const Text('New Game'),
)
图标和文字一起出现,能让用户明确这是重新开始,而不是撤销一步。
六、点击落子逻辑
6.1 onCellTap 方法
点击棋盘格子会调用 _onCellTap():
void _onCellTap(int row, int col) {
if (_gameOver || _board[row][col].isNotEmpty) return;
setState(() {
_board[row][col] = _currentPlayer;
_checkWinner(row, col);
if (!_gameOver) {
_currentPlayer = _currentPlayer == 'X' ? 'O' : 'X';
}
});
}
6.2 落子前校验
方法开头有两个保护条件:
if (_gameOver || _board[row][col].isNotEmpty) return;
这意味着:
| 条件 | 行为 |
|---|---|
| 游戏已结束 | 不允许继续落子 |
| 格子已有棋子 | 不允许覆盖 |
| 游戏未结束且格子为空 | 可以落子 |
6.3 落子后检查
落子后立即调用:
_checkWinner(row, col);
这里传入刚刚落子的行列位置,可以减少不必要的扫描范围。因为只有刚落子的那一行、那一列以及可能涉及的对角线会产生新的胜利。
6.4 玩家换手
如果游戏没有结束,则切换玩家:
_currentPlayer = _currentPlayer == 'X' ? 'O' : 'X';
这是一种非常简洁的双人轮换写法。
七、胜负检测总览
7.1 checkWinner 方法职责
_checkWinner() 是井字棋最核心的业务方法。它负责检查:
- 当前行是否三连。
- 当前列是否三连。
- 主对角线是否三连。
- 副对角线是否三连。
- 棋盘是否填满形成平局。
7.2 为什么只检查相关行列
一次落子只会影响:
- 当前行。
- 当前列。
- 如果落在对角线上,则影响对应对角线。
其他行列不可能因为这次落子突然获胜。因此源码只检查与当前落子相关的路径,是比较高效的做法。
7.3 胜利后的状态变化
当检测到胜利时,会设置:
_winner = _currentPlayer;
_gameOver = true;
7.4 平局后的状态变化
当棋盘填满且没有赢家时:
_gameOver = true;
_winner = 'Draw';
八、行与列扫描
8.1 行扫描
源码先检查当前行:
if (_board[row].every((cell) => cell == _currentPlayer)) {
_winner = _currentPlayer;
_gameOver = true;
return;
}
8.2 every 的作用
every() 会判断集合中每个元素是否都满足条件。这里表示当前行三个格子是否都等于当前玩家。
| 当前行 | 当前玩家 | 是否胜利 |
|---|---|---|
X X X |
X | 是 |
X O X |
X | 否 |
O O O |
O | 是 |
8.3 列扫描
列扫描使用:
if (_board.every((r) => r[col] == _currentPlayer)) {
_winner = _currentPlayer;
_gameOver = true;
return;
}
这里遍历每一行,取同一列的值判断是否都等于当前玩家。
8.4 行列扫描的特点
行列扫描只依赖当前落子的 row 和 col,不需要遍历所有胜利组合,代码既短又直观。
九、对角线扫描
9.1 主对角线
主对角线条件是 row == col:
if (row == col && [
_board[0][0],
_board[1][1],
_board[2][2],
].every((cell) => cell == _currentPlayer)) {
_winner = _currentPlayer;
_gameOver = true;
return;
}
9.2 副对角线
副对角线条件是 row + col == 2:
if (row + col == 2 && [
_board[0][2],
_board[1][1],
_board[2][0],
].every((cell) => cell == _currentPlayer)) {
_winner = _currentPlayer;
_gameOver = true;
return;
}
9.3 为什么需要先判断位置
并不是所有格子都在对角线上。比如 (0, 1) 不属于任何对角线,落在这个位置时就没必要检查对角线。
| 坐标 | 是否主对角线 | 是否副对角线 |
|---|---|---|
(0,0) |
是 | 否 |
(1,1) |
是 | 是 |
(2,2) |
是 | 否 |
(0,2) |
否 | 是 |
(2,0) |
否 | 是 |
9.4 对角线扫描的完整性
井字棋总共有 8 条获胜路径:
- 3 行。
- 3 列。
- 2 条对角线。
源码通过当前行、当前列和可能的对角线覆盖了所有可能胜利路径。
十、平局判定
10.1 棋盘填满检查
如果没有任何胜利路径,再检查平局:
if (_board.every((row) => row.every((cell) => cell.isNotEmpty))) {
_gameOver = true;
_winner = 'Draw';
}
10.2 双层 every
这里使用了双层 every():
| 层级 | 作用 |
|---|---|
| 外层 every | 检查每一行 |
| 内层 every | 检查每个格子非空 |
只有所有格子都非空,才说明棋盘已经填满。
10.3 为什么平局放在最后
平局必须在胜利检查之后判断。因为最后一步落子可能同时让棋盘填满并产生胜利,如果先判断棋盘填满,就可能误判为平局。
10.4 平局文案
平局时顶部状态栏显示:
It\'s a Draw!
这和获胜文案区分明显,用户能快速理解结果。
十一、顶部状态卡片
11.1 状态卡片颜色
页面顶部用 Card 显示当前状态:
Card(
color: _winner.isEmpty
? Colors.blue.shade50
: (_winner == 'Draw' ? Colors.grey.shade100 : Colors.green.shade50),
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(...),
),
)
11.2 状态文案
文案根据 _winner 判断:
_winner.isEmpty
? '$_currentPlayer's Turn'
: (_winner == 'Draw' ? 'It\'s a Draw!' : 'Player $_winner Wins!')
11.3 状态文案表
| 状态 | 文案 |
|---|---|
| 游戏进行中且 X 回合 | X's Turn |
| 游戏进行中且 O 回合 | O's Turn |
| X 获胜 | Player X Wins! |
| O 获胜 | Player O Wins! |
| 平局 | It's a Draw! |
11.4 颜色反馈
| 状态 | 背景色 | 文本色 |
|---|---|---|
| 进行中 | 蓝色浅色 | 蓝色 |
| 获胜 | 绿色浅色 | 绿色 |
| 平局 | 灰色浅色 | 灰色 |
十二、棋盘 UI 渲染
12.1 AspectRatio 保持正方形
棋盘使用 AspectRatio 保持 1:1:
AspectRatio(
aspectRatio: 1,
child: Container(
margin: const EdgeInsets.all(32),
child: Column(...),
),
)
井字棋棋盘必须是正方形,这个组件能保证不同屏幕上棋盘比例稳定。
12.2 使用 Column 和 Row 生成九宫格
棋盘通过嵌套生成:
Column(
children: List.generate(3, (row) {
return Expanded(
child: Row(
children: List.generate(3, (col) {
return Expanded(child: GestureDetector(...));
}),
),
);
}),
)
12.3 Expanded 的作用
每一行和每一列都用 Expanded,可以让 9 个格子平均分配棋盘空间。
12.4 格子点击区域
每个格子使用 GestureDetector:
GestureDetector(
onTap: () => _onCellTap(row, col),
child: Container(...),
)
这种写法简单直接,非常适合棋盘类小游戏。
十三、棋子视觉设计
13.1 空格子样式
空格子使用灰色背景和蓝色边框:
color: _board[row][col].isEmpty
? Colors.grey.shade200
: ...
边框逻辑如下:
border: Border.all(
color: _board[row][col].isEmpty
? Colors.blue.shade200
: Colors.transparent,
width: 2,
)
13.2 X 与 O 的颜色
已落子的格子根据内容变化:
_board[row][col] == 'X'
? Colors.blue.shade100
: Colors.orange.shade100
文字颜色也对应变化:
color: _board[row][col] == 'X'
? Colors.blue
: Colors.orange
13.3 棋子显示
格子中的文字使用大字号:
Text(
_board[row][col],
style: TextStyle(
fontSize: 48,
fontWeight: FontWeight.bold,
),
)
13.4 视觉映射表
| 格子内容 | 背景色 | 文字色 |
|---|---|---|
| 空 | 灰色浅色 | 无文字 |
| X | 蓝色浅色 | 蓝色 |
| O | 橙色浅色 | 橙色 |
十四、鸿蒙适配关注点
14.1 为什么适配风险较低
tic_tac_toe 不依赖网络、存储、定位、相机或系统输入法,主要由 Flutter 标准组件和 Dart 状态逻辑完成,因此基础适配风险较低。
| 模块 | 是否依赖平台能力 | 适配关注度 |
|---|---|---|
| 棋盘渲染 | 否 | 低 |
| 触摸落子 | Flutter 标准触摸 | 中 |
| 胜负判断 | Dart 逻辑 | 低 |
| 新一局重置 | Dart 状态 | 低 |
| 多端布局 | Flutter 布局 | 中 |
14.2 触摸体验
鸿蒙设备上需要重点验证:
- 点击格子是否灵敏。
- 已落子的格子是否不能被覆盖。
- 游戏结束后是否不能继续落子。
- New Game 按钮是否能完整重置状态。
14.3 棋盘尺寸适配
棋盘使用 AspectRatio(aspectRatio: 1),这对跨端很友好。仍然建议验证:
| 设备类型 | 验证点 |
|---|---|
| 手机竖屏 | 棋盘是否居中 |
| 手机横屏 | 顶部和底部是否拥挤 |
| 平板 | 棋盘是否过大 |
| 折叠屏 | 展开后比例是否稳定 |
14.4 游戏类应用的适配特点
小游戏不仅要跑起来,还要保证交互“跟手”。井字棋虽然没有动画,但触摸反馈、颜色变化、状态文案都要足够及时。
十五、测试设计与默认测试改造
15.1 当前测试入口
项目中的测试文件仍是默认计数器测试。对于井字棋,更有价值的是验证初始状态、落子、换手、胜利和平局。
15.2 初始页面测试
可以验证页面初始状态:
testWidgets('tic tac toe renders initial state', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
expect(find.text('Tic Tac Toe'), findsWidgets);
expect(find.text("X's Turn"), findsOneWidget);
expect(find.text('New Game'), findsOneWidget);
});
15.3 落子与换手测试
点击第一个空格后,应出现 X,并切换到 O:
testWidgets('can place X and switch to O', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
await tester.tap(find.byType(GestureDetector).first);
await tester.pump();
expect(find.text('X'), findsOneWidget);
expect(find.text("O's Turn"), findsOneWidget);
});
15.4 新一局测试
点击 New Game 后,应恢复 X 先手:
testWidgets('new game resets the board', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
await tester.tap(find.byType(GestureDetector).first);
await tester.pump();
await tester.tap(find.text('New Game'));
await tester.pump();
expect(find.text("X's Turn"), findsOneWidget);
});
15.5 胜利测试思路
可以模拟 X 在第一行三连:
testWidgets('player X can win a row', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
final cells = find.byType(GestureDetector);
await tester.tap(cells.at(0)); // X
await tester.pump();
await tester.tap(cells.at(3)); // O
await tester.pump();
await tester.tap(cells.at(1)); // X
await tester.pump();
await tester.tap(cells.at(4)); // O
await tester.pump();
await tester.tap(cells.at(2)); // X
await tester.pump();
expect(find.text('Player X Wins!'), findsOneWidget);
});
十六、可维护性优化方向
16.1 抽离棋盘模型
当前使用字符串二维数组,适合小项目。若后续扩展,可以定义棋子枚举:
enum PlayerMark {
empty,
x,
o,
}
枚举比字符串更安全,能减少拼写错误。
16.2 抽离胜利组合
也可以把 8 条胜利路径抽成常量:
const winningLines = [
[0, 1, 2],
[3, 4, 5],
[6, 7, 8],
[0, 3, 6],
[1, 4, 7],
[2, 5, 8],
[0, 4, 8],
[2, 4, 6],
];
这种写法更适合一维棋盘结构。
16.3 增加计分
当前项目只记录当前局结果。如果要增加计分,可以加入:
int _xScore = 0;
int _oScore = 0;
int _drawScore = 0;
16.4 增加 AI 对战
如果要做单人模式,可以增加简单 AI。井字棋最经典的 AI 方案是 minimax 算法,它可以做到不败。
十七、功能扩展方向
17.1 增加比分面板
可以在顶部或底部展示 X、O 和平局次数:
| 指标 | 说明 |
|---|---|
| X Wins | X 获胜次数 |
| O Wins | O 获胜次数 |
| Draws | 平局次数 |
17.2 增加获胜线高亮
当前获胜后只显示文案。后续可以记录获胜路径,然后把三格高亮。
17.3 增加动画
落子时可以加入缩放动画,获胜时可以加入闪烁或线条动画,让游戏反馈更强。
17.4 增加难度模式
如果加入 AI,可以设置:
- 简单模式:随机落子。
- 普通模式:优先防守。
- 困难模式:minimax。
十八、常见问题与优化建议
18.1 为什么不能覆盖已经落子的格子
源码在 _onCellTap() 中判断 _board[row][col].isNotEmpty,已有棋子时直接返回,避免破坏棋局。
18.2 为什么胜利后不能继续落子
胜利或平局后 _gameOver 会变成 true,点击格子时会被拦截。
18.3 为什么只检查刚落子的行列
一次落子只可能影响当前行、当前列和相关对角线,所以不需要扫描全棋盘所有组合。
18.4 为什么平局要最后判断
最后一步可能同时填满棋盘并产生胜利。如果先判平局,可能把胜利误判为平局。
18.5 鸿蒙适配最应该关注什么
重点关注触摸落子、棋盘比例、横竖屏布局、结果文案、New Game 重置和游戏结束后的状态锁定。当前项目没有复杂原生依赖,主要风险集中在 UI 和交互体验。
十九、完整流程复盘
19.1 页面启动流程
main()
-> runApp(MyApp)
-> MaterialApp
-> MyHomePage
-> 初始化 3x3 空棋盘
-> 当前玩家设为 X
-> build 渲染页面
19.2 落子流程
点击棋盘格子
-> _onCellTap(row, col)
-> 判断游戏是否结束
-> 判断格子是否为空
-> 写入当前玩家
-> 检查胜负
-> 未结束则切换玩家
19.3 胜利流程
玩家落子
-> 检查当前行
-> 检查当前列
-> 检查可能的对角线
-> 命中三连
-> 设置 winner
-> 设置 gameOver
-> 顶部文案显示获胜者
19.4 平局与重置流程
棋盘填满且无人胜利
-> winner = Draw
-> gameOver = true
-> 显示平局文案
点击 New Game
-> 清空棋盘
-> X 重新先手
-> 清空 winner
-> gameOver = false
二十、相关资源与继续学习
20.1 Flutter 学习资源
井字棋涉及布局、状态、点击事件和测试,可以结合以下资源学习:
| 资源 | 内容 |
|---|---|
| Flutter Docs | Flutter 官方开发文档 |
| Dart 官方文档 | Dart 语言与核心库 |
| Widget catalog | Flutter 常用组件 |
| Flutter testing | Widget 测试与交互模拟 |
20.2 小游戏扩展方向
后续可以继续增强:
- 比分统计。
- 获胜线高亮。
- 落子动画。
- AI 对战。
- 深色模式。
- 音效反馈。
- 多局历史记录。
20.3 跨端实践价值
tic_tac_toe 很适合作为 Flutter 适配鸿蒙的小型游戏样例。它依赖很轻,但包含棋盘状态、触摸输入、结果反馈和重置流程,能帮助开发者验证很多跨端交互细节。
总结
tic_tac_toe 用简洁的 Flutter 代码实现了一个完整井字棋小游戏。它通过 _board 保存三阶棋盘,通过 _currentPlayer 控制 X/O 换手,通过 _checkWinner() 完成行、列、对角线和平局扫描,通过 _gameOver 锁定游戏结束后的交互。
从工程角度看,这个项目最值得学习的是“棋盘状态 + 落子事件 + 胜负判定 + UI 反馈”的完整闭环。面向鸿蒙适配时,项目依赖较轻,主要需要验证触摸反馈、棋盘比例、状态文案和重置逻辑。对于想学习 Flutter 小游戏开发的开发者来说,它是一个清晰、实用且容易扩展的案例。
如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!
相关资源:
更多推荐

所有评论(0)