鸿蒙原生开发——从零构建猜词游戏
一、引言
猜词游戏(Hangman / Word Guess)是一款世界范围内广受欢迎的英文单词猜谜游戏——玩家通过逐个猜测字母来拼出隐藏的英文单词,每次猜错都会消耗一条命,在规定次数内猜出完整单词即为胜利。它的历史可以追溯到 19 世纪维多利亚时代的纸笔游戏,至今仍是语言学习和课堂互动中的经典工具。
从技术角度看,猜词游戏是一个集合匹配问题。与井字棋的"回合制落子"和华容道的"空间滑动"不同,猜词游戏的核心操作是字符匹配——每次猜测一个字母,在当前单词中查找。用户的思维过程是在 26 个字母的搜索空间中进行推理和排除,这个过程与打字式输入完全不同:每个字母只能猜一次,猜过的字母从"可用选项"变为"已排除项"。
本文用 ArkUI 从零构建一个英文猜词游戏,包含虚拟键盘输入、6 条命的生命值系统、中文提示和中文字库轮换机制。所有交互通过点击屏幕上的 26 个字母按钮完成——无需系统输入法,操作流畅一致。
阅读完本文,你将能够:
- 用虚拟键盘替代系统输入法处理单字母输入
- 实现"已猜字母"集合的追踪与排除
- 用词池洗牌机制避免题目重复
- 用三色反馈区分"未猜/猜对/猜错"三种字母状态
- 实现生命值递减与游戏结束的条件判断
二、游戏设计
2.1 规则与目标
猜词游戏的规则极为简洁:
- 系统从词库中随机选一个英文单词,展示中文提示
- 玩家每次点击虚拟键盘上的一个字母
- 如果该字母在单词中,所有出现位置都会被揭开
- 如果该字母不在单词中,损失一条命(共 6 条)
- 在 6 条命耗尽前揭开所有字母即胜利
- 每个字母只能猜一次,重复猜测无效
2.2 词库设计
词库由 15 个英文单词及其对应的中文提示组成:
interface WordEntry {
word: string;
hint: string;
}
const WORD_BANK: WordEntry[] = [
{ word: 'HARMONY', hint: '华为自研操作系统' },
{ word: 'JAVASCRIPT', hint: '网页交互的编程语言' },
{ word: 'ALGORITHM', hint: '解决问题的计算步骤' },
{ word: 'FUNCTION', hint: '可重复调用的代码单元' },
{ word: 'VARIABLE', hint: '存储和引用值的容器' },
{ word: 'INTERNET', hint: '连接全球的网络系统' },
{ word: 'MOUNTAIN', hint: '高耸入云的自然地貌' },
{ word: 'KEYBOARD', hint: '计算机的输入外设' },
{ word: 'DIAMOND', hint: '最坚硬的天然矿物' },
{ word: 'PYTHON', hint: '人工智能首选编程语言' },
{ word: 'PICTURE', hint: '静态的视觉记录' },
{ word: 'SCIENCE', hint: '系统化的知识体系' },
{ word: 'LIBRARY', hint: '存放书籍的建筑' },
{ word: 'WEATHER', hint: '每日的天气状况' },
{ word: 'CHICKEN', hint: '最常见的家禽' }
];
15 个单词涵盖了操作系统、编程语言、数据结构、自然科学、生活常识等多个领域。每个单词附带一个中文提示——提示的作用是缩小搜索空间:玩家看到"华为自研操作系统",就知道这个单词与"操作系统"相关,而非泛泛地从 26 个字母中随机尝试。提示使游戏从"盲目搜索"变为"线索推理"。
2.3 词池机制
与前面 Demo 中"每次随机选一个"的方式不同,猜词游戏使用了词池轮换机制:
private wordPool: WordEntry[] = [];
private poolIdx: number = 0;
shufflePool(): void {
this.wordPool = [];
for (let i = 0; i < WORD_BANK.length; i++) {
this.wordPool.push(WORD_BANK[i]);
}
// Fisher-Yates 洗牌
for (let i = this.wordPool.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const tmp = this.wordPool[i];
this.wordPool[i] = this.wordPool[j];
this.wordPool[j] = tmp;
}
this.poolIdx = 0;
}
newGame(): void {
if (this.poolIdx >= this.wordPool.length) {
this.shufflePool(); // 词池耗尽,重新洗牌
}
const entry = this.wordPool[this.poolIdx];
this.poolIdx++;
this.targetWord = entry.word;
this.hint = entry.hint;
// ...
}
这个机制的运作方式:
- 首次进入页面时,
wordPool为空,poolIdx为 0 newGame()检测到poolIdx >= 0(初始空数组长度为 0),触发shufflePool()shufflePool()将WORD_BANK的所有条目复制到wordPool,然后用 Fisher-Yates 洗牌打乱顺序,将poolIdx重置为 0- 此后每调用一次
newGame(),poolIdx递增 1,取出词池的下一个单词 - 当
poolIdx到达词池末尾时(第 16 次换题),shufflePool()重新洗牌,开始新一轮循环
这种机制保证了两点:
- 不重复:在词池的一轮循环中(15 个单词),同一个单词不会出现两次
- 随机顺序:每一轮的单词顺序都是重新随机排列的,玩家无法预测下一个单词是什么
15 个单词的池子对于猜词游戏来说大小适中——太小(如 5 个)会让玩家很快看到重复题目,太大(如 50 个)则初始加载无意义。15 个单词需要约 3-5 分钟完成一轮,是合理的一次游戏时长。
2.4 交互流程
一局游戏的交互流程:
- 开始:系统从词池中取出一个单词,展示中文提示和 26 个字母的虚拟键盘
- 猜测:玩家点击一个字母 → 判断是否在单词中 → 更新对应位置的显示
- 猜对:单词中该字母的每个出现位置都被揭开
- 猜错:生命值减 1,错误字母显示在"错误"列表中
- 胜利:所有字母被揭开 → 显示绿色通关横幅
- 失败:6 条命耗尽 → 显示红色失败横幅,揭晓答案
- 换题:点击"换一题"按钮,从词池中取出下一个单词

三、核心数据结构
3.1 已猜字母的表示
猜词游戏的核心状态是"已经猜过哪些字母"。使用一个字符串 guessedLetters 来存储:
@State guessedLetters: string = '';
每猜一个字母,就将其追加到字符串末尾:
this.guessedLetters = this.guessedLetters + ch;
这种设计有三个好处:
- 去重检测简单:
this.guessedLetters.indexOf(ch) !== -1即可判断是否已猜过 - 遍历方便:猜测字母、错误字母都可以通过遍历字符串获得
- 序列化简单:字符串可以直接用于调试日志,不需要额外的格式化
为什么不使用数组 string[]?因为字符串的 indexOf 方法和 + 拼接操作在 ArkTS 中的行为更可预测,避免了数组不可变更新时可能遇到的引用问题。而且猜词游戏最多只猜 26 次(26 个字母),26 个字符的字符串拼接性能完全不是瓶颈。
3.2 单词显示
displayWord() 方法构建当前单词的显示字符串:
displayWord(): string {
let result = '';
for (let i = 0; i < this.targetWord.length; i++) {
const ch = this.targetWord[i];
if (this.guessedLetters.indexOf(ch) !== -1 || this.gameOver) {
result = result + ch + ' ';
} else {
result = result + '_ ';
}
}
return result;
}
两个分支:
- 已猜中或游戏结束:显示字母本身 + 空格(如
H A R M O N Y) - 未猜中:显示下划线 + 空格(如
_ _ _ _ _ _ _)
字母之间用空格分隔,一是在视觉上形成清晰的字母边界,二是等宽字体下每个字母/下划线占据相同宽度,形成整齐的网格感。
游戏结束时(this.gameOver)也显示完整单词——这样玩家在失败后可以立即看到正确答案,而不是只看到部分揭开的字母。
3.3 错误字母
wrongLetters() 方法从所有已猜字母中筛选出不在目标单词中的字母:
wrongLetters(): string {
let result = '';
for (let i = 0; i < this.guessedLetters.length; i++) {
const ch = this.guessedLetters[i];
if (this.targetWord.indexOf(ch) === -1) {
result = result + ch + ' ';
}
}
return result;
}
这个方法有两个作用:
- 视觉提示:让玩家快速看到自己已经猜过哪些无效字母,避免重复猜测
- 进度指示:错误字母列表的长度直观反映了剩余生命值的消耗情况
3.4 字母按钮的三色反馈
虚拟键盘上每个字母按钮的颜色由其状态决定:
letterBg(ch: string): string {
if (this.guessedLetters.indexOf(ch) === -1) return '#F5F5FA'; // 未猜过
if (this.targetWord.indexOf(ch) !== -1) return '#C8E6C9'; // 猜对
return '#E8E8EE'; // 猜错
}
letterColor(ch: string): string {
if (this.guessedLetters.indexOf(ch) === -1) return '#1a1a2e'; // 深色文字
if (this.targetWord.indexOf(ch) !== -1) return '#2E7D32'; // 绿色文字
return '#CCCCCC'; // 灰色文字
}
三色反馈形成了一个清晰的视觉状态机:
| 状态 | 背景色 | 文字色 | 语义 |
|---|---|---|---|
| 未猜过 | #F5F5FA 浅灰蓝 |
#1a1a2e 深色 |
可用,等待点击 |
| 猜对了 | #C8E6C9 浅绿 |
#2E7D32 深绿 |
正确,不可再点 |
| 猜错了 | #E8E8EE 浅灰 |
#CCCCCC 中灰 |
错误,不可再点 |
这种三色方案与大多数猜词游戏的视觉语言一致——绿色表示有效信息,灰色表示无效信息。已猜过的字母(无论对错)都会失去视觉"可点击感"——背景色变淡、文字色变浅——形成自然的"已使用"感知。
四、游戏逻辑
4.1 guessLetter 方法
每次点击字母按钮触发的核心逻辑:
guessLetter(ch: string): void {
// 守卫条件1:游戏已结束
if (this.gameOver || this.gameWon) return;
// 守卫条件2:已经猜过
if (this.guessedLetters.indexOf(ch) !== -1) return;
this.guessedLetters = this.guessedLetters + ch;
if (this.targetWord.indexOf(ch) === -1) {
// 不在单词中 → 生命-1
this.wrongCount++;
if (this.wrongCount >= MAX_LIVES) {
this.gameOver = true;
}
} else {
// 在单词中 → 检查是否全猜对
let allGuessed = true;
for (let i = 0; i < this.targetWord.length; i++) {
if (this.guessedLetters.indexOf(this.targetWord[i]) === -1) {
allGuessed = false;
break;
}
}
if (allGuessed) {
this.gameWon = true;
}
}
}
整个方法分为三个阶段:
阶段一:守卫条件(两道防线)
- 游戏已结束或已胜利 → 直接返回,不再处理任何输入
- 字母已经猜过 → 直接返回,防止重复扣命
这两个守卫条件共同构成了"一次性字母"规则——每个字母在整个游戏过程中只能被点击一次。这要求在 guessedLetters 中记录所有已猜字母(无论对错),而不仅仅记录猜对的字母。
阶段二:字母判断
this.targetWord.indexOf(ch) === -1:字母不在单词中 → 错误分支this.targetWord.indexOf(ch) !== -1:字母在单词中 → 正确分支
indexOf 的返回值:如果字母存在于单词中,返回首次出现的索引(0 到 word.length-1);如果不存在,返回 -1。这个 API 比 includes 更可靠——ArkTS 中 string.includes 在某些版本中可能不可用,而 indexOf 是 ECMAScript 早期标准,兼容性最好。
阶段三:通关检测
在正确分支中,需要检测是否所有字母都已被揭开。做法是遍历目标单词的每个字符,检查它是否在 guessedLetters 中。如果存在任何一个未被猜过的字母,allGuessed 保持 false,游戏继续;如果所有字母都在 guessedLetters 中(即 allGuessed 为 true),设置 this.gameWon = true。
这里有一个不明显的细节:通关检测只在猜对字母时执行。因为猜错字母不会揭开任何字母,所以"是否全猜对"的状态不会因为猜错而改变。只在猜对时检测避免了不必要的遍历。
4.2 生命值系统
const MAX_LIVES: number = 6;
livesDisplay(): string {
let result = '';
for (let i = 0; i < MAX_LIVES; i++) {
if (i < MAX_LIVES - this.wrongCount) {
result = result + '❤️';
} else {
result = result + '🖤';
}
}
return result;
}
6 条命对应 6 个爱心图标:
- 剩余生命:❤️(红色爱心)
- 已损失生命:🖤(黑色爱心)
为什么是 6 条命?这是一个约定俗成的数字——经典的 Hangman 游戏通常使用 6 次错误机会,对应 6 次猜错绘制出完整的"绞刑架"图案(头、身体、左手、右手、左腿、右腿)。本 Demo 虽然使用爱心而非绞刑架,但保留了 6 条命的设计——6 次错误在 26 个字母的搜索空间中给予了玩家足够的容错率,同时又不至于让游戏变得太简单。
4.3 游戏结果横幅
gameMessage(): string {
if (this.gameOver) return '💀 游戏结束';
if (this.gameWon) return '🎉 恭喜猜对!';
return '';
}
gameSubMessage(): string {
if (this.gameOver) return '答案是:' + this.targetWord;
if (this.gameWon) return '剩余 ' + (MAX_LIVES - this.wrongCount) + ' 条命';
return '';
}
gameBannerColor(): string {
if (this.gameOver) return '#FF4D4F';
return '#52C41A';
}
结果横幅使用条件渲染——只有 gameOver 或 gameWon 为 true 时才显示:
if (this.gameOver || this.gameWon) {
Column() {
Text(this.gameMessage())
Text(this.gameSubMessage())
}
.backgroundColor(this.gameBannerColor())
// ...
}
横幅的两个状态:
- 失败:红色背景(
#FF4D4F),显示"💀 游戏结束"和正确答案 - 胜利:绿色背景(
#52C41A),显示"🎉 恭喜猜对!"和剩余生命
gameBannerColor() 方法有一个有意思的逻辑简化:它只判断 gameOver 返回红色,其余情况返回绿色。因为在条件渲染的 if 已经保证了"要么 gameOver 要么 gameWon",所以不存在"两者都为 false 但仍需返回颜色"的情况。
五、UI 设计
5.1 页面结构
页面从上到下分为五个区域:
┌────────────────────────────────────┐
│ 🔤 猜词游戏(深色标题栏) │
├────────────────────────────────────┤
│ ❤️❤️❤️❤️❤️❤️(6条命) │ ← 生命值
│ 提示:华为自研操作系统 │ ← 中文提示
├────────────────────────────────────┤
│ [🎉 恭喜猜对! 剩余 4 条命](可选) │ ← 结果横幅
├────────────────────────────────────┤
│ H A R M O N Y │ ← 单词显示
│ 错误:B C D E F │ ← 错误字母
├────────────────────────────────────┤
│ A B C D E F G H I J K L M │ ← 虚拟键盘
│ N O P Q R S T U V W X Y Z │ (26个字母按钮)
├────────────────────────────────────┤
│ 🔄 换一题 │ ← 换题按钮
└────────────────────────────────────┘
5.2 虚拟键盘
26 个字母按钮使用 Flex({ wrap: FlexWrap.Wrap }) 布局:
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Center }) {
ForEach(ALPHABET, (ch: string) => {
Text(ch)
.fontSize(13)
.fontColor(this.letterColor(ch))
.fontWeight(FontWeight.Bold)
.width(34)
.height(34)
.backgroundColor(this.letterBg(ch))
.borderRadius(BorderRadius.SM)
.textAlign(TextAlign.Center)
.margin({ left: 1.5, right: 1.5, bottom: Spacing.SM })
.onClick(() => { this.guessLetter(ch); })
})
}
键盘布局的要点:
FlexWrap.Wrap:26 个按钮在水平方向排列不下时自动换行,适应不同屏幕宽度justifyContent: FlexAlign.Center:每一行的按钮居中排列- 每个按钮 34×34 vp:足够大的点击区域(手指轻松点击),同时 26 个按钮在 360vp 宽的屏幕上约 7-8 个一行,形成 4 行键盘
BorderRadius.SM(4vp 圆角):比默认的方形更友好,但又不像圆形按钮那样浪费空间margin: { left: 1.5, right: 1.5, bottom: Spacing.SM }:按钮间距极小,最大化利用宽度
与输入法键盘(QWERTY)不同,这里的 26 个字母按 A-Z 字母顺序排列。A-Z 顺序对于中文用户来说直观——用户不需要学习布局,"找字母"时按首字母自然排序比 QWERTY 更快。
5.3 换题按钮
Button('🔄 换一题')
.fontSize(FontSize.MEDIUM)
.fontColor('#FFFFFF')
.fontWeight(FontWeight.Bold)
.backgroundColor('#667eea')
.borderRadius(BorderRadius.FULL)
.padding({ left: 28, right: 28 })
.height(48)
.shadow({ radius: 8, color: '#667eea44' })
.onClick(() => { this.newGame(); })
换题按钮使用 BorderRadius.FULL(9999vp)实现胶囊形,搭配紫色渐变阴影(#667eea44,28% 透明度)形成微妙的悬浮感。点击后调用 newGame(),从词池中取出下一个单词,重置所有游戏状态。
六、完整代码结构
WordGuessPage (~260 行)
├── 数据定义
│ ├── WordEntry 接口 — 单词+提示
│ ├── WORD_BANK[15] — 词库
│ └── ALPHABET[26] — 26个字母
├── 状态变量
│ ├── @State targetWord — 当前单词
│ ├── @State hint — 中文提示
│ ├── @State guessedLetters — 已猜字母串
│ ├── @State wrongCount — 错误次数
│ └── @State gameOver / gameWon — 游戏阶段
├── 词池管理
│ ├── shufflePool() — Fisher-Yates洗牌
│ └── newGame() — 取词+初始化
├── 游戏逻辑
│ └── guessLetter() — 守卫→判断→检测
├── 视图辅助
│ ├── displayWord() — 单词/下划线显示
│ ├── wrongLetters() — 错误字母列表
│ ├── letterBg/letterColor() — 三色反馈
│ ├── livesDisplay() — 爱心生命值
│ └── gameMessage/subMessage/bannerColor()
├── 视图
│ ├── 标题栏 — 🔤 猜词游戏
│ ├── 生命值 — ❤️🖤 显示
│ ├── 中文提示
│ ├── 结果横幅(条件渲染)
│ ├── 单词显示区
│ ├── 错误字母区(条件渲染)
│ ├── 26键虚拟键盘 Flex+ForEach
│ └── 换题按钮
└── 生命周期
└── aboutToAppear() — 调用 newGame()
七、与前面 Demo 的对比
猜词游戏在技术层面上与本系列的前几个游戏 Demo 形成了清晰的对比:
7.1 输入方式
前面 Demo 的输入方式各不相同:
- 计算器/秒表:按钮操作确定性的计算/计时逻辑
- 待办清单/日记本:系统输入法文本输入
- 井字棋/华容道:棋盘格子点击(空间操作)
- 记忆翻牌:卡片翻转配对(记忆匹配)
猜词游戏的输入方式最独特:26 个字母按钮替代了系统输入法。这种设计的优势在于:
- 操作一致:所有交互都是"点击按钮",不需要切换输入法
- 视觉反馈:每个字母按钮的状态(未猜/已猜对/已猜错)直接可见
- 字母去重:已经猜过的字母视觉上不可再点(不需要额外检查)
7.2 状态表示
猜词游戏的状态表示比井字棋(9 格 3 状态)和华容道(16 格 16 值)更简单:
- primary 状态:
guessedLetters字符串(26 个字母的子集) - 派生状态:单词显示、错误列表、生命值、胜负判断——全部从
guessedLetters推导
这种"一个主状态 + 多个派生视图"的架构在 ArkUI 中非常自然——@State 变量变化自动触发所有依赖它的 Text、Column 重新渲染。不存在"同步多个状态"的复杂度。
7.3 难度调节
猜词游戏的难度由两个隐含因素决定:
- 单词长度:7-10 个字母的单词(HAMONY 7 个,JAVASCRIPT 10 个)是理想长度。太短(3-4 个字母)容易猜中,缺乏挑战;太长(12+ 个字母)需要猜的字母太多,容错率不足
- 提示精确度:"华为自研操作系统"缩小了搜索范围——知道这是操作系统名称,有助于排除大量无关单词
这种"提示引导 + 字母推理"的难度曲线比纯随机猜测要平滑——玩家在早期利用提示缩小范围,在后期利用已揭开的字母推断剩余字母,整个过程从"知识检索"过渡到"模式匹配"。
八、总结
本文从零构建了一个英文猜词游戏。与井字棋(策略博弈)、记忆翻牌(记忆力挑战)和华容道(空间推理)不同,猜词游戏的核心是字符串匹配与集合推理——每次猜测在 26 个字母的集合中进行排除,逐步缩小候选空间。从技术角度看,它也是虚拟键盘输入、三色状态反馈和词池轮换机制的完整示例。
核心要点回顾:
-
虚拟键盘替代系统输入法:26 个
Text按钮使用Flex({ wrap: FlexWrap.Wrap })布局,A-Z 字母顺序排列,每个按钮 34×34vp。所有交互通过点击完成,操作一致且视觉反馈直接。 -
字符串表示集合:
guessedLetters是一个字符串,存储所有已猜字母。indexOf()检测是否已猜过(去重)、是否在单词中(对错判断)。字符串的+拼接操作在 ArkTS 中行为稳定,26 个字符级的性能完全无问题。 -
三色反馈状态机:每个字母按钮有三种视觉状态——未猜(浅灰蓝底 + 深色字)、猜对(浅绿底 + 绿色字)、猜错(浅灰底 + 灰色字)。三种状态通过
letterBg()和letterColor()方法从guessedLetters和targetWord推导,没有额外的状态变量。 -
词池轮换避免重复:使用 Fisher-Yates 洗牌打乱 15 个单词的顺序,顺序取用。一轮 15 题中不会出现重复单词,词池耗尽后重新洗牌开始新一轮。这比"每次随机选"更能保证玩家体验的多样性。
-
通关条件判断:只在猜对字母时检测通关(遍历目标单词的每个字符,检查是否都在
guessedLetters中)。猜错字母不可能改变"是否全猜对"的状态,避免不必要的遍历。 -
派生视图模式:
displayWord()、wrongLetters()、livesDisplay()都是纯粹的派生方法——它们只读取@State变量,不修改任何状态。当guessedLetters变化时,所有使用它的视图自动重新渲染。这是 ArkUI 声明式 UI 的核心优势。
猜词游戏是一款"规则简单、策略丰富"的经典游戏。这个 260 行的 ArkUI 实现抓住了它的核心乐趣:虚拟键盘的触感反馈、猜对字母时的豁然开朗、以及最后几个字母揭晓前的紧张感。它是本系列第四篇游戏类文章,也是字符串集合操作和虚拟键盘交互的完整示例。
更多推荐




所有评论(0)