鸿蒙原生开发——从零构建井字棋游
一、引言
井字棋(Tic-Tac-Toe)是世界上规则最简单的策略游戏之一——3×3 的九宫格,两人轮流画 ✕ 和 ◯,先连成一线者胜。它的规则用一句话就能说清楚,但背后的胜负判定、策略树和状态管理却是众多游戏算法的入门课题。
从技术角度看,井字棋是一个回合制状态机。每个棋盘格有三种状态(空/✕/◯),整个游戏在"玩家回合→落子→判定→AI回合→落子→判定"的循环中推进。与记忆翻牌(实时翻牌+延时)不同,井字棋的交互是异步回合式的——玩家点击后,AI 需要经过一段短暂的"思考延时"(约 400ms)再落子,模拟真实对局中的等待感。
本文用 ArkUI 从零构建一个井字棋游戏,包含双人对战和人机对战两种模式、五级优先级 AI 策略、胜负平判定、得分记录和胜利连线高亮。棋盘使用双层 ForEach 构建 3×3 网格,AI 延时使用 setInterval 实现。
阅读完本文,你将能够:
- 用双层
ForEach构建 3×3 网格棋盘 - 实现 8 条获胜线的胜负判定算法
- 构建五级优先级规则 AI(赢 > 堵 > 中 > 角 > 任意)
- 用
setInterval实现 AI 思考延时(400ms) - 管理回合制状态机(玩家回合 / AI回合 / 游戏结束)
二、游戏规则与设计
2.1 核心规则
井字棋的规则用三句话概括:
- 棋盘为 3×3 九宫格,玩家 1 使用 ✕,玩家 2(或 AI)使用 ◯
- 双方轮流在空格落子,先在横、竖、斜任一方向上连成三子者获胜
- 若棋盘填满而无人获胜,则为平局
8 条获胜线覆盖了所有可能的连线方式:
[0] [1] [2] 行:0-1-2, 3-4-5, 6-7-8
[3] [4] [5] 列:0-3-6, 1-4-7, 2-5-8
[6] [7] [8] 对角线:0-4-8, 2-4-6
棋盘用一维数组 number[9] 表示,索引 0-8 对应九宫格的九个位置。每个位置的值:0 = 空,1 = ✕,2 = ◯。一维数组而非二维数组的选择是为了简化遍历和获胜检测——8 条获胜线预先定义为索引三元组数组,检测时直接取值比较。
2.2 两种模式
游戏提供两种对战模式:
- 人机对战(默认):玩家执 ✕ 先手,AI 执 ◯ 后手。AI 在玩家落子后经过 400ms 延时自动落子。AI 使用五级优先级策略,不会犯低级错误,但也不是不可战胜——它没有使用 MinMax 全搜索,因此玩家有机会取胜。
- 双人对战:两人在同一设备上轮流落子,✕ 先手。模式切换按钮位于棋盘上方。
模式切换会立即重置当前棋局——包括棋盘、回合和连胜提示,但保留历史得分记录。
2.3 交互流程
一局典型的人机对局包含以下交互点:
- 选择模式(可选):点击"双人对战"或"人机对战"
- 玩家落子:点击空格 → ✕ 出现 → 判定 → 回合切换
- AI思考:400ms 延时 → AI 自动落子 → 判定 → 回合切回
- 游戏结束:有人获胜(高亮连线)或平局 → 得分更新
- 重新开始:点击按钮 → 棋盘清空,得分保留

三、数据模型与状态管理
3.1 棋盘数组
棋盘用一个 9 元素的一维数组表示:
@State board: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
索引与九宫格位置的映射关系:
0 │ 1 │ 2
──┼───┼──
3 │ 4 │ 5
──┼───┼──
6 │ 7 │ 8
一维数组的好处是获胜线可以用索引三元组简洁表达:
const WIN_LINES: number[][] = [
[0, 1, 2], [3, 4, 5], [6, 7, 8], // 行
[0, 3, 6], [1, 4, 7], [2, 5, 8], // 列
[0, 4, 8], [2, 4, 6] // 对角线
];
3.2 状态变量
页面的 @State 变量分为棋盘状态、游戏状态和得分三类:
@State board: number[] = [...]; // 棋盘(核心数据,9个0/1/2)
@State currentPlayer: number = 1; // 当前回合(1=✕, 2=◯)
@State gameOver: boolean = false; // 游戏是否结束
@State winner: number = 0; // 胜者(0=无, 1=✕, 2=◯, 3=平局)
@State winLine: number[] = []; // 获胜线的三个索引(用于高亮)
@State scoreX: number = 0; // ✕ 累计得分
@State scoreO: number = 0; // ◯ 累计得分
@State draws: number = 0; // 平局次数
@State vsAI: boolean = true; // 当前模式
winLine 是一个关键的辅助变量——它不直接控制游戏逻辑,但决定了 UI 中哪些格子需要高亮。当 checkWin() 检测到获胜时,winLine 被设置为获胜线的三个索引,isWinCell() 方法据此判断每个格子是否需要橙色边框。
winner 的值有四种含义:0 = 游戏未结束,1 = ✕ 胜,2 = ◯ 胜,3 = 平局。平局用 3 而非 -1,是为了在条件判断中与 0(无胜者)明确区分——if (this.winner > 0) 可以覆盖三种结束状态。
3.3 不可变棋盘更新
与记忆翻牌中的 updateCard() 模式一致,井字棋使用 makeMove() 辅助方法创建新棋盘数组:
makeMove(b: number[], idx: number, player: number): number[] {
const nb: number[] = [];
for (let i = 0; i < b.length; i++) {
nb.push(i === idx ? player : b[i]);
}
return nb;
}
每次落子都创建一个全新的 9 元素数组。这个方法被玩家落子、AI 试走(检测能否赢/堵)等多个场景复用,避免了重复的数组拷贝代码。
四、胜负判定引擎
4.1 checkWin 方法
胜负检测的核心是一个遍历 8 条获胜线的方法:
checkWin(b: number[]): number {
for (let i = 0; i < WIN_LINES.length; i++) {
const a = WIN_LINES[i][0];
const b1 = WIN_LINES[i][1];
const c = WIN_LINES[i][2];
if (b[a] !== 0 && b[a] === b[b1] && b[b1] === b[c]) {
this.winLine = [a, b1, c];
return b[a];
}
}
return 0;
}
对于每条获胜线 [a, b1, c],检测三个条件:
b[a] !== 0:第一个格子不为空b[a] === b[b1]:第一个和第二个相同b[b1] === b[c]:第二个和第三个相同
三个条件同时满足 → 返回该格子中的玩家值(1 或 2),同时设置 winLine 用于 UI 高亮。返回 0 表示当前棋盘上无人获胜。
4.2 isBoardFull 方法
平局检测更为简单——遍历 9 个格子,只要有一个空格就未满:
isBoardFull(b: number[]): boolean {
for (let i = 0; i < b.length; i++) {
if (b[i] === 0) return false;
}
return true;
}
isBoardFull 在 checkWin 返回 0 之后才被调用,确保"先检测胜负,再检测平局"的顺序——因为存在"最后一手同时满足连胜和填满棋盘"的情况,此时应判胜而非平。
五、AI 策略设计
5.1 五级优先级
AI 不搜索整棵博弈树,而是使用基于规则的即时决策。五条规则按优先级从高到低排列:
aiMove(): void {
const b = this.board;
// 1. 赢:如果有一格能让 AI 连成三子,立刻落子
for (let i = 0; i < 9; i++) {
if (b[i] === 0) {
const test = this.makeMove(b, i, 2);
if (this.checkWin(test) === 2) {
this.applyAIMove(test);
return;
}
}
}
// 2. 堵:如果有一格能让玩家连成三子,堵住它
for (let i = 0; i < 9; i++) {
if (b[i] === 0) {
const test = this.makeMove(b, i, 1);
if (this.checkWin(test) === 1) {
this.applyAIMove(this.makeMove(b, i, 2));
return;
}
}
}
// 3. 中:占据中心位置(索引 4)
if (b[4] === 0) {
this.applyAIMove(this.makeMove(b, 4, 2));
return;
}
// 4. 角:占据角位(索引 0, 2, 6, 8)
const corners = [0, 2, 6, 8];
for (let ci = 0; ci < corners.length; ci++) {
if (b[corners[ci]] === 0) {
this.applyAIMove(this.makeMove(b, corners[ci], 2));
return;
}
}
// 5. 任意:选择第一个空格
for (let i = 0; i < 9; i++) {
if (b[i] === 0) {
this.applyAIMove(this.makeMove(b, i, 2));
return;
}
}
}
为什么不用 MinMax?
MinMax 算法对井字棋的搜索空间仅为 9! = 362880 个节点,完全可以暴力搜索。但本文的目标是展示规则 AI 的设计模式——通过优先级分层,用 5 个独立的规则块替代递归搜索树,代码结构清晰、没有递归深度问题,且每一步的决策都是 O(n) 时间复杂度。这个 AI 的"水平"大约相当于熟悉规则的初学者——它会赢、会堵、会占好位置,但不会主动构造双重威胁。
5.2 AI 思考延时
AI 落子不是瞬时的,而是通过 setInterval 实现 400ms 延时:
// 在 placePiece 中,玩家落子后:
if (this.vsAI) {
this.aiTimerId = setInterval(() => {
clearInterval(this.aiTimerId);
this.aiTimerId = -1;
this.aiMove();
}, 400);
}
400ms 的选择是一个微妙的交互设计决策。如果小于 200ms,玩家感受不到 AI 在"思考";如果大于 600ms,游戏节奏过慢。400ms 恰好是"一个短暂的停顿"——足够让玩家意识到回合已经切换,又不会让等待变得不耐烦。
延时期间,棋盘处于"锁定"状态——placePiece 方法检查 this.aiTimerId !== -1 并拒绝任何点击。这防止了玩家在 AI 思考期间连续落子。
六、回合制交互逻辑
6.1 placePiece 方法
玩家落子的完整流程包含五层守卫条件:
placePiece(idx: number): void {
if (this.aiTimerId !== -1) return; // AI 思考中,棋盘锁定
if (this.board[idx] !== 0) return; // 已有棋子
if (this.gameOver) return; // 游戏已结束
if (this.vsAI && this.currentPlayer === 2) return; // AI 的回合
// 落子(不可变更新)
this.board = this.makeMove(this.board, idx, 1);
// 胜负检测
const w = this.checkWin(this.board);
if (w > 0) {
this.gameOver = true;
this.winner = w;
if (w === 1) this.scoreX++;
return;
}
// 平局检测
if (this.isBoardFull(this.board)) {
this.gameOver = true;
this.winner = 3;
this.draws++;
return;
}
// 回合切换
this.currentPlayer = 2;
// AI 回合(人机模式)
if (this.vsAI) {
this.aiTimerId = setInterval(() => {
clearInterval(this.aiTimerId);
this.aiTimerId = -1;
this.aiMove();
}, 400);
}
}
五层守卫条件反映了回合制状态机的复杂性——在任何时间点,棋盘可能处于"等待玩家落子"、“等待 AI 落子”、"游戏已结束"三种宏观状态之一,每个状态对点击事件的响应截然不同。
6.2 applyAIMove 方法
AI 落子后的处理与玩家落子对称,但更新的是 ◯ 的得分:
applyAIMove(newBoard: number[]): void {
this.board = newBoard;
const w = this.checkWin(newBoard);
if (w > 0) {
this.gameOver = true;
this.winner = w;
if (w === 2) this.scoreO++;
return;
}
if (this.isBoardFull(newBoard)) {
this.gameOver = true;
this.winner = 3;
this.draws++;
return;
}
this.currentPlayer = 1; // 回合返还玩家
}
applyAIMove 被设计为接收一个已构造好的新棋盘(而非内部构造),因为它被 AI 的五个策略分支调用——每个分支已经构造了候选棋盘,直接传入避免了重复构造。
七、UI 设计
7.1 信息架构
页面从上到下分为五个区域:
┌────────────────────────────┐
│ 🎮 井字棋(深色标题栏) │
├────────────────────────────┤
│ [双人对战] [人机对战] │ ← 模式切换
├────────────────────────────┤
│ 你 (✕) 3 平局 1 AI (◯) 2 │ ← 得分栏
├────────────────────────────┤
│ 你的回合 (✕) │ ← 状态提示
├────────────────────────────┤
│ ┌────┬────┬────┐ │
│ │ │ ✕ │ │ │ ← 3×3 棋盘
│ ├────┼────┼────┤ │
│ │ │ ◯ │ │ │
│ ├────┼────┼────┤ │
│ │ ✕ │ │ ◯ │ │
│ └────┴────┴────┘ │
├────────────────────────────┤
│ 🔄 重新开始 │
└────────────────────────────┘
7.2 棋盘构建
棋盘使用双层 ForEach 构建 3×3 网格:
Column() {
ForEach(this.rowsArr, (row: number, ri: number) => {
Row() {
ForEach(this.rowsArr, (col: number, ci: number) => {
Column() {
Text(this.cellSymbol(this.board[ri * 3 + ci]))
.fontSize(40)
.fontColor(this.cellColor(this.board[ri * 3 + ci]))
.fontWeight(FontWeight.Bold)
}
.width(88)
.height(88)
.backgroundColor(
this.isWinCell(ri * 3 + ci) ? '#FFF3E0' :
(this.board[ri * 3 + ci] === 0 ? '#F5F5FA' : '#FFFFFF'))
.borderRadius(BorderRadius.MD)
.border({
width: this.isWinCell(ri * 3 + ci) ? 2 : 0,
color: '#FF9800'
})
.onClick(() => { this.placePiece(ri * 3 + ci); })
}, (col: number, ci: number) => `${ci}`)
}
}, (row: number, ri: number) => `${ri}`)
}
扁平索引的计算:ri * 3 + ci——行索引乘以 3 加上列索引,将二维坐标映射为一维数组索引。例如第 2 行第 1 列(索引 1, 0)= 1*3+0 = 3。
每个格子的视觉效果分为四种状态:
| 格子状态 | 内容 | 文字颜色 | 背景色 |
|---|---|---|---|
| 空 | 无 | — | #F5F5FA(浅灰) |
| ✕ 占据 | ✕ | #FF4D4F(红) |
#FFFFFF(白) |
| ◯ 占据 | ◯ | #1677FF(蓝) |
#FFFFFF(白) |
| 获胜线 | ✕/◯ | 同上 | #FFF3E0(浅橙)+ 2px #FF9800 边框 |
获胜线通过 isWinCell() 判断当前格子是否在 winLine 数组中。获胜时三个格子同时获得橙色边框 + 浅橙背景,形成醒目的"连线"效果。
7.3 双色符号系统
✕ 和 ◯ 使用两种高对比度颜色:
- ✕(
#FF4D4F红色):玩家的颜色,热情、主动、有攻击性。红色在所有文化中都意味着"行动",符合玩家先手落子的主动性。 - ◯(
#1677FF蓝色):AI 的颜色,冷静、计算、被动。蓝色暗示理性和算法,与 AI 的机器属性一致。
在双人对战模式下,✕ 和 ◯ 分别代表两位玩家,红色和蓝色提供了清晰的视觉区分——玩家不会混淆"刚才是我下的还是对方下的"。
八、完整代码结构
TicTacToePage (~260 行)
├── 常量定义
│ └── WIN_LINES[] — 8 条获胜线(行/列/对角线)
├── 状态变量
│ ├── @State board[9] — 棋盘(0=空, 1=✕, 2=◯)
│ ├── @State currentPlayer / gameOver / winner / winLine — 游戏状态
│ ├── @State scoreX / scoreO / draws — 得分记录
│ └── @State vsAI — 双人/人机模式
├── 棋盘操作
│ ├── makeMove() — 不可变落子(返回新数组)
│ ├── checkWin() — 遍历 8 条线检测胜负(设置 winLine)
│ └── isBoardFull() — 平局检测
├── 玩家交互
│ ├── placePiece() — 玩家落子(五层守卫 + 胜负判定 + AI触发)
│ └── cellSymbol() / cellColor() — 格子显示
├── AI 引擎
│ ├── aiMove() — 五级优先级决策(赢>堵>中>角>任意)
│ └── applyAIMove() — AI落子后的状态更新
├── 游戏控制
│ ├── newGame() — 清空棋盘保留得分
│ └── toggleMode() — 切换双人/人机模式
├── UI 视图
│ ├── 标题栏 — 🎮 井字棋
│ ├── 模式选择 — 双人对战 / 人机对战
│ ├── 得分栏 — X得分 / 平局 / O得分
│ ├── 状态提示 — 回合/胜负提示
│ ├── 3×3棋盘 — 双层ForEach网格
│ └── 重新开始按钮
└── 生命周期
└── aboutToDisappear() — 清理AI定时器
九、总结
本文从零构建了一个井字棋游戏。与记忆翻牌的记忆力挑战不同,井字棋是一场策略的较量——它的核心不是记住位置,而是在有限的棋盘上预测对手的下一步。从技术角度看,井字棋也是回合制状态机的典型实现——玩家回合、AI 回合、游戏结束三种宏观状态通过守卫条件严格隔离,AI 延时实现了回合之间的自然过渡。
核心要点回顾:
-
一维棋盘数组:
number[9]表示 3×3 九宫格,每个格子的值(0/1/2)编码了空/✕/◯ 三种状态。一维数组简化了索引计算和获胜检测——每个获胜线只需 3 个整数即可表达。 -
8 条获胜线:3 行 + 3 列 + 2 对角线 = 8 种获胜方式。
checkWin()遍历这 8 条线,检测"三个格子非空且相等"。平局检测在胜负检测之后,确保"最后一手同时连线+填满"判胜不平。 -
五级优先级 AI:赢 > 堵 > 中 > 角 > 任意。每条规则独立地扫描棋盘并返回首个满足条件的落子。时间复杂度 O(n),不需要递归或搜索树。这个 AI 不会犯"漏堵"的低级错误,但也不会主动创建双重威胁。
-
AI 思考延时:
setInterval(fn, 400)+ 即时clearInterval实现 400ms 的"思考停顿"。延时期间aiTimerId充当棋盘锁,防止玩家在 AI 回合落子。这个模式与记忆翻牌的 800ms 翻转延时使用相同的setInterval技术。 -
不可变棋盘更新:
makeMove()每次返回全新的 9 元素数组,不修改原数组。同一个方法被玩家落子、AI 试走、AI 实际落子三个场景复用——共用的代码减少了状态不一致的风险。 -
双色符号系统:✕ 红色(
#FF4D4F)、◯ 蓝色(#1677FF)——两种颜色在白色棋盘上形成高对比度区分。获胜线使用橙色(#FF9800)边框 + 浅橙(#FFF3E0)背景高亮,橙色作为红蓝之间的"中性胜利色",不偏向任何一方。 -
得分跨局保留:
scoreX、scoreO、draws三个变量在多次游戏中持续累积。newGame()清空棋盘和胜负状态,但保留得分——这是一个有意为之的设计选择,鼓励玩家多玩几局。
井字棋的魅力在于它的简单——规则三秒学会,策略五分钟入门。但这个 260 行的 ArkUI 实现展示了一个完整的回合制游戏所需的全部要素:状态机、胜负判定、AI 决策、延时交互和得分系统。它是游戏开发的最小完备示例,也是状态管理模式的一次集中实践。
更多推荐




所有评论(0)