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 TurnO's Turn _currentPlayer
落子 点击空格写入 X/O _onCellTap()
胜负检测 获胜后显示赢家 _checkWinner()
平局判定 显示 It's a Draw! _winner = 'Draw'
新一局 New Game 按钮 _resetGame()

1.3 技术学习价值

这个项目适合学习以下 Flutter 实战能力:

  1. 如何用二维数组表达棋盘。
  2. 如何用 GestureDetector 处理点击落子。
  3. 如何在落子后立即扫描胜负。
  4. 如何根据状态渲染不同颜色和文案。
  5. 如何面向鸿蒙检查触摸、布局和视觉反馈。

二、工程结构与运行方式

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

这个项目没有额外状态管理库,而是直接使用 StatefulWidgetsetState()。对于九宫格小游戏,这种方式足够清楚,也便于读者理解。

五、重置新一局逻辑

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() 是井字棋最核心的业务方法。它负责检查:

  1. 当前行是否三连。
  2. 当前列是否三连。
  3. 主对角线是否三连。
  4. 副对角线是否三连。
  5. 棋盘是否填满形成平局。

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 行列扫描的特点

行列扫描只依赖当前落子的 rowcol,不需要遍历所有胜利组合,代码既短又直观。

九、对角线扫描

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 小游戏开发的开发者来说,它是一个清晰、实用且容易扩展的案例。

如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!


相关资源:

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐