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. 用下划线初始化显示文本。
  3. 玩家点击字母。
  4. 应用判断字母是否存在于目标词。
  5. 猜中则更新显示文本。
  6. 猜错则增加错误次数。
  7. 根据显示文本和错误次数判断胜负。

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;

这行代码处理了两个边界:

  1. 游戏结束后不再接受字母输入。
  2. 已经猜过的字母不会再次处理。

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 单词卡片

目标词展示区域使用 CardWrap

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 适配

WrapRow 更适合单词显示,因为较长单词在窄屏设备上可以自然换行,减少溢出风险。

十二、字母键盘设计

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 的作用

源码没有使用 ElevatedButtonTextButton,而是用 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 绘制结果:

  1. 基础支架是否显示完整。
  2. 错误次数增加后绘制是否更新。
  3. 线条颜色和粗细是否正常。
  4. 绘制区域是否居中。
  5. 重新开局后图形是否清空。

14.3 点击区域验证

字母键尺寸为 32x40:

width: 32,
height: 40,

在移动端上这个点击区域偏小但仍可用。若面向真实产品,可以适当增大字母键或根据屏幕宽度自适应。

14.4 颜色反馈验证

适配时应确认已猜字母颜色是否清晰:

状态 颜色验证
猜中 绿色背景与绿色文字
猜错 红色背景与红色文字
未猜 灰色背景与深灰文字
胜利 绿色提示卡片
失败 红色提示卡片

14.5 布局验证

需要在不同屏幕上验证:

  • 单词长度较长时字符格子是否换行。
  • A-Z 键盘是否完整显示。
  • Play Again 按钮是否居中。
  • AppBar 刷新按钮是否可点击。

十五、测试设计与现有测试问题

15.1 当前测试状态

test/widget_test.dart 当前仍是 Flutter 默认计数器测试,会查找 01 和加号按钮。但实际页面是猜词游戏,没有计数器文本,也没有加号自增逻辑。

因此,测试文件需要根据真实页面改造。

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 绘制练习和鸿蒙端交互验证。需要注意的是,当前源码是本地单机演示,不包含联网词库、存档、排行榜或多难度系统。

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


相关资源:

Logo

一站式 AI 云服务平台

更多推荐