Flutter 实战:hangman_game 猜词游戏的本地词库、字母键盘与鸿蒙适配解析
Flutter 实战:hangman_game 猜词游戏的本地词库、字母键盘与鸿蒙适配解析
前言
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
hangman_game 是一个基于 Flutter 编写的本地猜词小游戏。应用从内置英文词库中随机选择目标词,玩家点击 A-Z 字母键盘进行猜测;猜中字母时显示对应位置,猜错时增加错误次数,并由 CustomPainter 根据错误次数逐步绘制游戏图形。玩家在 6 次错误之前猜完整个单词则胜利,否则游戏结束并展示目标词。
这个项目没有联网词库、没有用户账号、没有关卡存档,也没有复杂游戏引擎。它的价值集中在 随机选词、状态驱动 UI、字母去重、胜负判断、自定义绘制和跨端渲染验证 上。对 Flutter 入门者和鸿蒙适配实践者来说,它是一个非常适合拆解的游戏类小案例。
小游戏代码看起来轻,但它往往能把状态流转、输入反馈、绘制逻辑和边界处理暴露得很彻底。
hangman_game正好是这样一个结构清楚、易于扩展的样例。

图示说明:本文围绕 Flutter 本地猜词游戏展开,重点分析随机词库、字母键盘、胜负判断、CustomPainter 绘制和鸿蒙端适配要点。
一、项目定位与源码概览
1.1 应用目标
hangman_game 的目标是实现一个可交互的英文猜词游戏。玩家面对由下划线组成的隐藏单词,通过点击字母逐步猜出目标词。每次错误猜测都会推进绘制进度,直到达到最大错误次数或玩家猜出完整单词。
核心流程可以概括为:
- 启动应用并随机选择目标词。
- 用下划线初始化显示文本。
- 玩家点击字母。
- 应用判断字母是否存在于目标词。
- 猜中则更新显示文本。
- 猜错则增加错误次数。
- 根据显示文本和错误次数判断胜负。
1.2 功能边界
当前源码实现的是 本地单机小游戏,不包含以下能力:
- 不从网络加载词库。
- 不保存游戏进度。
- 不记录玩家得分排行。
- 不支持难度选择。
- 不支持多语言词库。
- 不接入音效或动画资源。
因此,文章分析应围绕源码已有能力展开,不把它描述成完整游戏产品。
1.3 核心文件
| 文件 | 作用 | 说明 |
|---|---|---|
pubspec.yaml |
项目依赖声明 | 使用 Flutter SDK 和基础图标依赖 |
lib/main.dart |
游戏核心代码 | 包含入口、词库、状态、交互、绘制和页面 |
test/widget_test.dart |
Widget 测试入口 | 当前仍是默认计数器测试,需要按实际游戏改造 |
ohos |
鸿蒙工程目录 | 用于跨端构建与平台适配 |
1.4 技术关键词
| 技术点 | 项目体现 | 学习价值 |
|---|---|---|
StatefulWidget |
保存目标词、已猜字母和胜负状态 | 理解游戏状态 |
Random |
从本地词库随机取词 | 掌握基础随机逻辑 |
StringBuffer |
重新拼接显示单词 | 高效构造字符串 |
Wrap |
展示单词字符和字母键盘 | 适配不同屏幕宽度 |
GestureDetector |
处理字母点击 | 构建自定义按钮交互 |
CustomPainter |
绘制游戏图形 | 掌握 Canvas 绘制 |
二、运行环境与依赖结构
2.1 SDK 声明
项目在 pubspec.yaml 中声明 Dart SDK:
environment:
sdk: ^3.9.2
这说明项目运行在较新的 Dart 环境中,可以使用空安全、集合字面量、const 构造、级联操作和现代 Flutter API。
2.2 项目依赖
依赖保持轻量:
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.8
| 依赖 | 用途 | 说明 |
|---|---|---|
flutter |
UI、渲染、手势、绘制 | 核心能力来源 |
cupertino_icons |
图标资源 | 当前主页面主要使用 Material 图标 |
flutter_test |
Widget 测试 | 可验证页面状态和交互 |
flutter_lints |
静态规则 | 约束代码风格 |
2.3 常用命令
开发与验证常用命令如下:
flutter pub get
flutter analyze
flutter test
flutter run
这些命令分别用于依赖获取、静态检查、测试执行和本地运行。对于鸿蒙适配,建议先确保 Flutter 层可以稳定运行,再进入鸿蒙工程构建流程。
2.4 适配复杂度
hangman_game 不依赖平台插件,核心逻辑是 Dart 与 Flutter UI,因此适配重点主要集中在:
- Canvas 绘制是否正常。
- 字母键盘点击是否稳定。
Wrap在不同屏幕宽度下是否换行自然。- AppBar 刷新按钮是否能重开游戏。
- 游戏结束后按钮是否显示正确。
三、应用入口与主题配置
3.1 main 函数
应用入口如下:
void main() {
runApp(const MyApp());
}
main() 只负责启动根组件,具体页面和游戏逻辑都在后续 Widget 中完成。
3.2 MyApp 根组件
根组件负责配置应用标题、主题和首页:
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Hangman',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.purple),
),
home: const MyHomePage(title: 'Hangman'),
);
}
}
这里使用紫色作为主题种子色,页面提示卡片、按钮和游戏状态也围绕紫色、绿色、红色形成反馈体系。
3.3 首页组件
MyHomePage 是一个 StatefulWidget:
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
State<MyHomePage> createState() => _MyHomePageState();
}
猜词游戏需要记录多个可变状态,因此使用 StatefulWidget 是合理选择。
3.4 入口关系表
| 层级 | 类或函数 | 职责 |
|---|---|---|
| 启动层 | main() |
启动 Flutter 应用 |
| 应用层 | MyApp |
配置标题、主题和首页 |
| 页面层 | MyHomePage |
接收标题并创建状态 |
| 状态层 | _MyHomePageState |
管理词库、猜测、胜负和绘制触发 |
| 绘制层 | HangmanPainter |
根据错误次数绘制图形 |
四、游戏状态设计
4.1 状态字段总览
源码中的状态字段如下:
final List<String> _words = ['FLUTTER', 'HARMONY', 'MOBILE', 'DEVELOPER', 'ANDROID', 'WIDGET', 'STATELESS'];
late String _targetWord;
String _displayWord = '';
String _guessedLetters = '';
int _wrongGuesses = 0;
int _maxWrong = 6;
bool _gameOver = false;
bool _won = false;
4.2 字段职责
| 字段 | 类型 | 作用 |
|---|---|---|
_words |
List<String> |
本地目标词库 |
_targetWord |
String |
当前这一局的答案 |
_displayWord |
String |
下划线和已猜中字母组成的展示文本 |
_guessedLetters |
String |
已经点击过的字母 |
_wrongGuesses |
int |
当前错误次数 |
_maxWrong |
int |
最大允许错误次数,当前为 6 |
_gameOver |
bool |
游戏是否结束 |
_won |
bool |
玩家是否获胜 |
4.3 状态组合关系
这些字段并不是孤立存在的。它们之间有明确关系:
_targetWord决定答案。_guessedLetters决定哪些字母已经被尝试。_displayWord根据_targetWord和_guessedLetters生成。_wrongGuesses驱动绘制进度。_gameOver控制键盘和重玩按钮显示。_won决定结束提示的颜色和文案。
4.4 late 字段的使用
late String _targetWord;
_targetWord 使用 late,表示它会在对象创建后、真正使用前被初始化。源码在 initState() 中调用 _initGame(),确保目标词在页面渲染前完成赋值。
五、初始化与重新开局
5.1 initState 生命周期
void initState() {
super.initState();
_initGame();
}
initState() 是 StatefulWidget 状态对象创建后的初始化入口。这里调用 _initGame(),让应用启动时自动创建第一局游戏。
5.2 初始化函数
void _initGame() {
setState(() {
_targetWord = _words[Random().nextInt(_words.length)];
_displayWord = '_' * _targetWord.length;
_guessedLetters = '';
_wrongGuesses = 0;
_gameOver = false;
_won = false;
});
}
这段代码完成随机选词和状态重置。
5.3 随机选词
_targetWord = _words[Random().nextInt(_words.length)];
Random().nextInt(_words.length) 会生成一个从 0 到词库长度减 1 的下标,然后从 _words 中取出对应单词。
| 词库下标 | 单词 |
|---|---|
| 0 | FLUTTER |
| 1 | HARMONY |
| 2 | MOBILE |
| 3 | DEVELOPER |
| 4 | ANDROID |
| 5 | WIDGET |
| 6 | STATELESS |
5.4 初始化显示文本
_displayWord = '_' * _targetWord.length;
Dart 中字符串可以通过乘法生成重复内容。目标词有几个字母,展示文本就有几个下划线。
5.5 重开游戏入口
页面提供两个重开入口:
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _initGame,
)
游戏结束后还会显示 Play Again 按钮,它同样调用 _initGame()。
六、猜字母核心逻辑
6.1 方法入口
玩家点击字母时会调用 _guessLetter():
void _guessLetter(String letter) {
if (_gameOver || _guessedLetters.contains(letter)) return;
setState(() {
_guessedLetters += letter;
if (_targetWord.contains(letter)) {
final newDisplay = StringBuffer();
for (int i = 0; i < _targetWord.length; i++) {
if (_guessedLetters.contains(_targetWord[i])) {
newDisplay.write(_targetWord[i]);
} else {
newDisplay.write('_');
}
}
_displayWord = newDisplay.toString();
if (!_displayWord.contains('_')) {
_gameOver = true;
_won = true;
}
} else {
_wrongGuesses++;
if (_wrongGuesses >= _maxWrong) {
_gameOver = true;
}
}
});
}
这是项目最关键的业务逻辑。
6.2 防重复处理
if (_gameOver || _guessedLetters.contains(letter)) return;
这行代码处理了两个边界:
- 游戏结束后不再接受字母输入。
- 已经猜过的字母不会再次处理。
6.3 记录已猜字母
_guessedLetters += letter;
源码用字符串保存已猜字母。由于键盘只包含 A-Z,并且重复点击会被拦截,所以这种实现简单有效。
6.4 猜中处理
if (_targetWord.contains(letter)) {
final newDisplay = StringBuffer();
for (int i = 0; i < _targetWord.length; i++) {
if (_guessedLetters.contains(_targetWord[i])) {
newDisplay.write(_targetWord[i]);
} else {
newDisplay.write('_');
}
}
_displayWord = newDisplay.toString();
}
猜中字母后,程序会遍历目标词的每个字符。如果该字符已经存在于 _guessedLetters 中,就显示真实字母,否则继续显示下划线。
6.5 猜错处理
_wrongGuesses++;
if (_wrongGuesses >= _maxWrong) {
_gameOver = true;
}
猜错时只增加错误次数。当错误次数达到 6,游戏结束。
七、胜负判断
7.1 胜利条件
if (!_displayWord.contains('_')) {
_gameOver = true;
_won = true;
}
当展示文本不再包含下划线,说明目标词所有字母都被猜出,玩家获胜。
7.2 失败条件
if (_wrongGuesses >= _maxWrong) {
_gameOver = true;
}
当错误次数达到 _maxWrong,游戏结束。此时 _won 仍为 false,页面会展示失败文案和答案。
7.3 状态组合表
_gameOver |
_won |
页面状态 |
|---|---|---|
false |
false |
游戏进行中 |
true |
true |
玩家获胜 |
true |
false |
游戏失败 |
7.4 结束后键盘隐藏
源码在构建操作区时判断 _gameOver:
if (_gameOver)
ElevatedButton.icon(
onPressed: _initGame,
icon: const Icon(Icons.refresh),
label: const Text('Play Again'),
)
else
Wrap(
children: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').map((letter) {
// 字母键
}).toList(),
)
游戏结束后不再显示字母键盘,而是显示重玩按钮。
八、状态提示卡片
8.1 Card 颜色
顶部提示卡片根据游戏状态改变背景色:
Card(
color: _gameOver
? (_won ? Colors.green.shade50 : Colors.red.shade50)
: Colors.purple.shade50,
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
_gameOver
? (_won ? 'Congratulations! You won!' : 'Game Over! Word: $_targetWord')
: 'Wrong guesses: $_wrongGuesses / $_maxWrong',
),
),
)
8.2 文案状态
| 状态 | 文案 |
|---|---|
| 游戏中 | Wrong guesses: 当前错误 / 最大错误 |
| 胜利 | Congratulations! You won! |
| 失败 | Game Over! Word: 目标词 |
8.3 文字颜色
color: _gameOver ? (_won ? Colors.green : Colors.red) : Colors.purple
颜色和文案保持一致:进行中使用紫色,胜利用绿色,失败用红色。
8.4 反馈层次
这张卡片承担了游戏状态反馈职责。玩家不需要理解内部变量,只看卡片就能知道当前处于进行中、胜利还是失败。
九、自定义绘制结构
9.1 绘制入口
页面通过 _buildHangman() 创建绘制区域:
Widget _buildHangman() {
return Container(
height: 200,
padding: const EdgeInsets.all(16),
child: CustomPaint(
size: const Size(150, 180),
painter: HangmanPainter(_wrongGuesses),
),
);
}
CustomPaint 把 _wrongGuesses 传给 HangmanPainter,让绘制内容跟随错误次数变化。
9.2 Painter 类
class HangmanPainter extends CustomPainter {
final int wrongGuesses;
HangmanPainter(this.wrongGuesses);
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.grey.shade700
..strokeWidth = 3
..style = PaintingStyle.stroke;
}
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
CustomPainter 的核心是 paint() 方法。Flutter 会把 Canvas 和当前尺寸传进来,开发者用绘图 API 完成图形绘制。
9.3 画笔配置
final paint = Paint()
..color = Colors.grey.shade700
..strokeWidth = 3
..style = PaintingStyle.stroke;
这里使用级联操作配置画笔颜色、线宽和描边样式。
9.4 重绘策略
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
源码始终返回 true,表示状态变化时允许重新绘制。当前绘制复杂度很低,这样写简单直观。如果绘制内容更复杂,可以比较前后 wrongGuesses 再决定是否重绘。
十、Canvas 绘制步骤
10.1 固定结构绘制
游戏图形的基础支架始终绘制:
canvas.drawLine(Offset(10, size.height - 10), Offset(size.width / 2, size.height - 10), paint);
canvas.drawLine(Offset(size.width / 2, size.height - 10), Offset(size.width / 2, 10), paint);
canvas.drawLine(Offset(size.width / 2, 10), Offset(size.width / 2 + 40, 10), paint);
canvas.drawLine(Offset(size.width / 2 + 40, 10), Offset(size.width / 2 + 40, 30), paint);
这几条线构成基础支架。
10.2 错误次数与绘制部位
| 错误次数 | 绘制内容 | API |
|---|---|---|
| 1 | 头部 | drawCircle |
| 2 | 身体 | drawLine |
| 3 | 左臂 | drawLine |
| 4 | 右臂 | drawLine |
| 5 | 左腿 | drawLine |
| 6 | 右腿 | drawLine |
10.3 头部绘制
if (wrongGuesses >= 1) {
canvas.drawCircle(Offset(size.width / 2 + 40, 45), 15, paint);
}
当错误次数至少为 1,绘制圆形头部。
10.4 身体与四肢绘制
if (wrongGuesses >= 2) {
canvas.drawLine(Offset(size.width / 2 + 40, 60), Offset(size.width / 2 + 40, 100), paint);
}
if (wrongGuesses >= 3) {
canvas.drawLine(Offset(size.width / 2 + 40, 70), Offset(size.width / 2 + 20, 90), paint);
}
if (wrongGuesses >= 4) {
canvas.drawLine(Offset(size.width / 2 + 40, 70), Offset(size.width / 2 + 60, 90), paint);
}
if (wrongGuesses >= 5) {
canvas.drawLine(Offset(size.width / 2 + 40, 100), Offset(size.width / 2 + 20, 130), paint);
}
if (wrongGuesses >= 6) {
canvas.drawLine(Offset(size.width / 2 + 40, 100), Offset(size.width / 2 + 60, 130), paint);
}
随着错误次数增加,绘制内容逐步增多。这种设计让游戏反馈非常直观。
十一、目标词显示区域
11.1 单词卡片
目标词展示区域使用 Card 和 Wrap:
Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Wrap(
alignment: WrapAlignment.center,
spacing: 8,
children: List.generate(_targetWord.length, (index) {
final letter = _displayWord[index];
return Container(
width: 36,
height: 48,
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: Colors.grey.shade400, width: 3)),
),
child: Center(
child: Text(
letter,
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
),
),
);
}),
),
),
)
11.2 List.generate 的作用
List.generate(_targetWord.length, ...) 会根据目标词长度生成字符格子。目标词越长,格子越多。
11.3 下划线显示
每个字符格子的内容来自 _displayWord[index]。未猜中的位置显示 _,已猜中的位置显示真实字母。
11.4 Wrap 适配
Wrap 比 Row 更适合单词显示,因为较长单词在窄屏设备上可以自然换行,减少溢出风险。
十二、字母键盘设计
12.1 字母来源
源码直接从字符串生成键盘:
'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').map((letter) {
final isGuessed = _guessedLetters.contains(letter);
final isInWord = _targetWord.contains(letter);
return GestureDetector(
onTap: () => _guessLetter(letter),
child: Container(
width: 32,
height: 40,
decoration: BoxDecoration(
color: isGuessed
? (isInWord ? Colors.green.shade100 : Colors.red.shade100)
: Colors.grey.shade200,
borderRadius: BorderRadius.circular(4),
),
child: Center(
child: Text(letter),
),
),
);
}).toList()
12.2 键盘布局
字母键盘同样使用 Wrap:
Wrap(
spacing: 4,
runSpacing: 4,
alignment: WrapAlignment.center,
children: ...
)
这样 A-Z 的 26 个字母可以根据屏幕宽度自动换行。
12.3 字母状态颜色
| 字母状态 | 背景色 | 文字色 |
|---|---|---|
| 未猜过 | 灰色 | 深灰 |
| 已猜且存在于目标词 | 浅绿 | 绿色 |
| 已猜但不存在于目标词 | 浅红 | 红色 |
12.4 GestureDetector 的作用
源码没有使用 ElevatedButton 或 TextButton,而是用 GestureDetector 包裹自定义容器。这样可以更自由地控制字母键尺寸、颜色和圆角。
十三、整体页面布局
13.1 Scaffold 骨架
页面结构如下:
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _initGame,
),
],
),
body: Column(
children: [
// 状态卡片
// 绘制区域
// 单词卡片
// 操作区域
],
),
);
13.2 四个主要区域
| 区域 | 作用 |
|---|---|
| 状态卡片 | 展示错误次数、胜利或失败 |
| 绘制区域 | 根据错误次数绘制图形 |
| 单词卡片 | 展示下划线和已猜中字母 |
| 操作区域 | 游戏中显示键盘,结束后显示重玩按钮 |
13.3 Expanded 操作区
Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Play Again 或字母键盘
],
),
),
)
Expanded 让操作区域占据剩余空间,并通过居中布局让键盘或按钮保持在视觉中心。
13.4 小屏风险
当前 body 使用 Column,没有外层滚动容器。如果屏幕高度较小,可能出现垂直空间紧张。鸿蒙端适配时要重点观察不同设备尺寸下是否出现布局溢出。
十四、鸿蒙适配关注点
14.1 适配优势
hangman_game 的跨端适配难度较低,主要因为:
- 词库是本地常量。
- 游戏状态是纯 Dart 数据。
- 没有平台插件。
- 没有网络请求。
- 绘制使用 Flutter Canvas。
14.2 Canvas 绘制验证
鸿蒙端需要确认 CustomPaint 绘制结果:
- 基础支架是否显示完整。
- 错误次数增加后绘制是否更新。
- 线条颜色和粗细是否正常。
- 绘制区域是否居中。
- 重新开局后图形是否清空。
14.3 点击区域验证
字母键尺寸为 32x40:
width: 32,
height: 40,
在移动端上这个点击区域偏小但仍可用。若面向真实产品,可以适当增大字母键或根据屏幕宽度自适应。
14.4 颜色反馈验证
适配时应确认已猜字母颜色是否清晰:
| 状态 | 颜色验证 |
|---|---|
| 猜中 | 绿色背景与绿色文字 |
| 猜错 | 红色背景与红色文字 |
| 未猜 | 灰色背景与深灰文字 |
| 胜利 | 绿色提示卡片 |
| 失败 | 红色提示卡片 |
14.5 布局验证
需要在不同屏幕上验证:
- 单词长度较长时字符格子是否换行。
- A-Z 键盘是否完整显示。
Play Again按钮是否居中。- AppBar 刷新按钮是否可点击。
十五、测试设计与现有测试问题
15.1 当前测试状态
test/widget_test.dart 当前仍是 Flutter 默认计数器测试,会查找 0、1 和加号按钮。但实际页面是猜词游戏,没有计数器文本,也没有加号自增逻辑。
因此,测试文件需要根据真实页面改造。
15.2 初始页面测试
可以先验证初始页面是否包含标题和错误次数:
testWidgets('shows hangman game initially', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
expect(find.text('Hangman'), findsOneWidget);
expect(find.textContaining('Wrong guesses: 0 / 6'), findsOneWidget);
});
15.3 字母键盘测试
testWidgets('shows alphabet keyboard before game over', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
expect(find.text('A'), findsOneWidget);
expect(find.text('Z'), findsOneWidget);
});
这个测试可以确认键盘区域已经渲染。
15.4 刷新按钮测试
testWidgets('refresh button starts a new game', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
await tester.tap(find.byIcon(Icons.refresh));
await tester.pump();
expect(find.textContaining('Wrong guesses:'), findsOneWidget);
});
由于目标词随机,测试不应依赖具体答案,而应验证稳定可见的状态文本。
15.5 绘制测试思路
CustomPainter 的视觉内容不适合只用文本查找验证。可以从两条线入手:
- Widget 测试验证
CustomPaint存在。 - 单元测试抽离状态逻辑后验证错误次数变化。
十六、代码质量与可维护性
16.1 当前实现优点
hangman_game 的源码有几个明显优点:
- 游戏逻辑集中,阅读路径清楚。
- 本地词库简单直观。
- 胜负判断条件明确。
- 字母键盘通过数据生成,避免手写 26 个组件。
- 绘制逻辑独立在
HangmanPainter中。 - 重开游戏复用
_initGame()。
16.2 可以抽离的游戏状态
如果继续扩展,可以把状态抽成模型:
class HangmanGameState {
const HangmanGameState({
required this.targetWord,
required this.displayWord,
required this.guessedLetters,
required this.wrongGuesses,
required this.gameOver,
required this.won,
});
final String targetWord;
final String displayWord;
final String guessedLetters;
final int wrongGuesses;
final bool gameOver;
final bool won;
}
这样可以让 UI 层更专注展示,游戏规则更容易测试。
16.3 可以抽离的猜测函数
猜字母逻辑也可以变成纯函数:
String buildDisplayWord({
required String targetWord,
required String guessedLetters,
}) {
final buffer = StringBuffer();
for (var i = 0; i < targetWord.length; i++) {
final letter = targetWord[i];
buffer.write(guessedLetters.contains(letter) ? letter : '_');
}
return buffer.toString();
}
纯函数更容易做单元测试,也能减少 Widget 测试的复杂度。
16.4 词库扩展方式
当前词库写在源码中:
final List<String> _words = ['FLUTTER', 'HARMONY', 'MOBILE', 'DEVELOPER', 'ANDROID', 'WIDGET', 'STATELESS'];
如果词库变大,可以考虑放到本地 JSON 资源中,再通过 Flutter assets 加载。
十七、性能与体验优化
17.1 算法复杂度
每次猜字母时,主要操作是遍历目标词:
| 操作 | 复杂度 | 说明 |
|---|---|---|
| 判断是否重复猜测 | O(n) | 在已猜字符串中查找 |
| 判断目标词是否包含字母 | O(m) | 在目标词中查找 |
| 重建显示文本 | O(m) | 遍历目标词 |
其中 n 是已猜字母数量,m 是目标词长度。当前词库单词较短,性能完全足够。
17.2 已猜字母结构
源码用 String 保存已猜字母。对于 26 个英文字母来说简单可靠。如果要支持更大字符集,可以改用 Set<String>:
final guessedLetters = <String>{};
Set 在判断是否已猜时更符合语义。
17.3 绘制重绘优化
shouldRepaint 当前始终返回 true。可以进一步写成:
bool shouldRepaint(covariant HangmanPainter oldDelegate) {
return oldDelegate.wrongGuesses != wrongGuesses;
}
这样只有错误次数变化时才需要重绘。
17.4 键盘体验优化
字母键可以继续增强:
- 增大触控面积。
- 增加轻微动画。
- 禁用已猜字母点击效果。
- 增加无障碍语义。
- 支持实体键盘输入。
十八、常见问题与优化建议
18.1 为什么游戏启动就有目标词
因为 initState() 中调用了 _initGame(),而 _initGame() 会从 _words 中随机选择一个目标词。
18.2 为什么点击重复字母没有变化
_guessLetter() 开头会判断 _guessedLetters.contains(letter)。如果字母已经猜过,函数直接返回。
18.3 为什么失败后会显示答案
失败时提示文本使用:
'Game Over! Word: $_targetWord'
所以游戏结束且未获胜时,页面会展示目标词。
18.4 为什么错误次数最多是 6
源码中 _maxWrong = 6,并且 HangmanPainter 刚好按 6 次错误绘制 6 个阶段。
18.5 为什么刷新按钮和 Play Again 都能重开
它们都调用 _initGame()。这让顶部刷新和结束后的重玩按钮复用同一套初始化逻辑。
18.6 能不能接入更大的词库
可以。当前词库是本地列表,后续可以改为本地资源文件、内置分类词库或远程接口,但当前源码尚未实现这些能力。
十九、核心知识点速查
19.1 Widget 速查
| Widget | 使用位置 | 作用 |
|---|---|---|
MaterialApp |
根组件 | 应用配置 |
Scaffold |
页面骨架 | AppBar 与 Body |
Card |
状态和单词区域 | 信息分区 |
CustomPaint |
绘制区域 | 承载自定义绘制 |
Wrap |
单词和键盘 | 自动换行布局 |
GestureDetector |
字母键 | 捕获点击 |
ElevatedButton |
重玩按钮 | 重新开局 |
IconButton |
AppBar | 刷新游戏 |
19.2 方法速查
| 方法 | 作用 |
|---|---|
_initGame() |
随机选词并重置游戏状态 |
_guessLetter() |
处理玩家猜字母 |
_buildHangman() |
创建绘制区域 |
HangmanPainter.paint() |
根据错误次数绘制图形 |
shouldRepaint() |
控制绘制对象是否重绘 |
19.3 状态速查
| 状态 | 变化来源 | 影响 UI |
|---|---|---|
_targetWord |
开局随机选择 | 决定答案和字符格数量 |
_displayWord |
猜中字母后重建 | 决定显示字母或下划线 |
_guessedLetters |
点击字母后追加 | 决定键盘颜色和重复拦截 |
_wrongGuesses |
猜错后增加 | 决定绘制阶段 |
_gameOver |
胜利或失败时变为 true | 决定键盘或重玩按钮 |
_won |
猜出完整单词时变为 true | 决定胜利提示 |
二十、扩展方向
20.1 功能扩展
可以基于当前项目继续增加:
- 难度选择。
- 分类词库。
- 分数统计。
- 连胜记录。
- 游戏计时。
- 提示字母功能。
20.2 UI 扩展
界面层可以继续优化:
- 增加开始页。
- 增加胜利和失败动画。
- 增加深色模式。
- 增大字母键尺寸。
- 增加横屏布局。
20.3 工程扩展
工程层可以进一步演进:
- 抽离游戏状态模型。
- 抽离词库管理。
- 增加单元测试。
- 增加 Widget 测试。
- 优化
shouldRepaint。
20.4 跨端实践价值
hangman_game 适合作为 Flutter 适配鸿蒙的小游戏样例。它覆盖了 CustomPainter、Canvas 线条绘制、点击手势、状态卡片、动态键盘和胜负切换,能帮助开发者验证 Flutter 游戏类基础交互在鸿蒙端的表现。
总结
hangman_game 用一份简洁的 Flutter 源码实现了本地猜词游戏。它通过 _words 提供目标词库,通过 _targetWord 保存当前答案,通过 _displayWord 展示已猜进度,通过 _guessedLetters 防止重复点击,通过 _wrongGuesses 驱动 HangmanPainter 绘制,并通过 _gameOver 与 _won 控制胜负状态。
从工程角度看,这个项目最值得学习的是 状态驱动游戏逻辑 和 CustomPainter 自定义绘制。它没有复杂依赖,适合用于 Flutter 小游戏入门、Canvas 绘制练习和鸿蒙端交互验证。需要注意的是,当前源码是本地单机演示,不包含联网词库、存档、排行榜或多难度系统。
如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!
相关资源:
更多推荐




所有评论(0)