
一、应用概述
井字棋(Tic-Tac-Toe)是一款经典的两人轮流在 3×3 格子上标记 X 和 O 的智力游戏。本篇文章将带领读者在 HarmonyOS 平台上使用 ArkTS 语言和声明式 UI 框架,从零构建一个完整的井字棋对战应用。文章将深入分析游戏的核心算法------胜负判定(8 线检测)、交替落子逻辑、状态管理以及响应式的 UI 交互设计。
二、架构设计
2.1 整体模块划分
井字棋应用采用标准的单页面 + 组件化架构,主要划分为三个层次:
| 层级 | 职责 | 核心文件 |
|---|---|---|
| 数据层 | 游戏状态管理、棋盘数据、胜负判定逻辑 | GameModel.ets |
| 业务层 | 交替落子、重置、历史记录 | GameController.ets |
| 表现层 | 网格渲染、状态指示、交互反馈 | TicTacToeView.ets, GridCell.ets |
2.2 数据流向
用户点击 → GridCell.onClick()
→ GameController.handleMove(row, col)
→ GameModel.updateBoard(row, col, player)
→ GameModel.checkWinner()
→ 状态更新触发 UI 重渲染
ArkTS 的 @State 和 @Prop 装饰器驱动了从数据到视图的单向数据流,确保任何状态的变更都能实时反映到界面。
三、核心技术实现
3.1 游戏状态与数据结构
arkts
// GameModel.ets
enum Player { None = 0, X = 1, O = 2 }
class GameState {
board: Player[][] = [
[Player.None, Player.None, Player.None],
[Player.None, Player.None, Player.None],
[Player.None, Player.None, Player.None]
];
currentPlayer: Player = Player.X;
winner: Player = Player.None;
moveCount: number = 0;
isDraw: boolean = false;
}
board使用二维数组存储 3×3 棋盘状态,Player.None表示空格currentPlayer记录当前轮到谁(X 先手)winner存储获胜方,isDraw标记平局
3.2 胜负判定算法 ------ 8 线检查
这是整个游戏最核心的算法模块。在 3×3 棋盘中,可能的胜利组合有 8 条线:3 行 + 3 列 + 2 条对角线。算法需要在每次落子后快速扫描这 8 条线,判断是否有玩家完成了连线。
arkts
// GameController.ets
function checkWinner(board: Player[][]): Player {
// 1. 检查所有行
for (let i = 0; i < 3; i++) {
if (board[i][0] !== Player.None &&
board[i][0] === board[i][1] &&
board[i][1] === board[i][2]) {
return board[i][0];
}
}
// 2. 检查所有列
for (let j = 0; j < 3; j++) {
if (board[0][j] !== Player.None &&
board[0][j] === board[1][j] &&
board[1][j] === board[2][j]) {
return board[0][j];
}
}
// 3. 检查主对角线(左上到右下)
if (board[0][0] !== Player.None &&
board[0][0] === board[1][1] &&
board[1][1] === board[2][2]) {
return board[0][0];
}
// 4. 检查副对角线(右上到左下)
if (board[0][2] !== Player.None &&
board[0][2] === board[1][1] &&
board[1][1] === board[2][0]) {
return board[0][2];
}
return Player.None; // 暂无胜者
}
算法复杂度分析:
- 时间复杂度:O(1) ------ 固定检查 8 条线,每条线 3 个格子,总操作次数恒定
- 空间复杂度:O(1) ------ 仅使用常数额外变量
优化思考:在真实对战中,我们可以在每次落子后只检查与当前落子位置相关的行、列和对角线,而非每次都检查全部 8 条线。例如若在 (1,1) 中心落子,只需检查第 1 行、第 1 列、主对角线和副对角线(最多 4 条)。但对于 3×3 的规模,全量检查已经足够高效。
3.3 交替落子逻辑
arkts
function handleMove(row: number, col: number): boolean {
// 1. 合法性校验:位置已被占用或游戏已结束
if (gameState.board[row][col] !== Player.None) {
return false; // 无效操作
}
if (gameState.winner !== Player.None || gameState.isDraw) {
return false;
}
// 2. 落子
gameState.board[row][col] = gameState.currentPlayer;
gameState.moveCount++;
// 3. 检查胜负
const result = checkWinner(gameState.board);
if (result !== Player.None) {
gameState.winner = result;
return true;
}
// 4. 检查平局(所有格子已满且无胜者)
if (gameState.moveCount === 9) {
gameState.isDraw = true;
return true;
}
// 5. 切换玩家
gameState.currentPlayer =
(gameState.currentPlayer === Player.X) ? Player.O : Player.X;
return true;
}
关键边界条件:
- 格子已被占用时拒绝落子
- 游戏已分出胜负或平局后拒绝继续操作
- 第 9 步落子后若仍未分出胜负则自动判定平局
- 玩家切换使用三元运算符保证简洁性
3.4 声明式 UI 构建
arkts
// TicTacToeView.ets
@Component
struct TicTacToeView {
@State board: Player[][] = [...];
@State currentPlayer: Player = Player.X;
@State winner: Player = Player.None;
@State isDraw: boolean = false;
build() {
Column() {
// 标题与状态栏
Text(this.getStatusText())
.fontSize(24)
.fontWeight(FontWeight.Bold)
.margin({ bottom: 20 })
// 3x3 棋盘网格
Grid() {
ForEach(this.board, (row: Player[], rowIndex: number) => {
ForEach(row, (cell: Player, colIndex: number) => {
GridCell({
value: cell,
onClick: () => this.handleCellClick(rowIndex, colIndex)
})
}, (colIndex: number) => colIndex.toString())
}, (rowIndex: number) => rowIndex.toString())
}
.columnsTemplate('1fr 1fr 1fr')
.rowsTemplate('1fr 1fr 1fr')
.width(300)
.height(300)
.padding(4)
// 重置按钮
Button('重新开始')
.onClick(() => this.resetGame())
.margin({ top: 20 })
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
getStatusText(): string {
if (this.winner !== Player.None) {
return `玩家 ${this.winner === Player.X ? 'X' : 'O'} 获胜!🎉`;
}
if (this.isDraw) {
return '平局!🤝';
}
return `轮到 ${this.currentPlayer === Player.X ? 'X' : 'O'} 落子`;
}
}
3.5 网格单元格组件
arkts
@Component
struct GridCell {
@Prop value: Player;
onClick?: () => void;
build() {
Button() {
if (this.value === Player.X) {
Text('X')
.fontSize(40)
.fontWeight(FontWeight.Bold)
.fontColor(Color.Red)
} else if (this.value === Player.O) {
Text('O')
.fontSize(40)
.fontWeight(FontWeight.Bold)
.fontColor(Color.Blue)
}
}
.width('100%')
.height('100%')
.backgroundColor(this.value === Player.None ?
Color.White : Color.LightGray)
.border({ width: 2, color: Color.Gray })
.onClick(() => {
if (this.onClick) this.onClick();
})
}
}
四、HarmonyOS 特性分析
4.1 @State 与 @Prop 装饰器
- @State:标记组件内部状态,任何改变会自动触发该组件及其子组件的重新渲染
- @Prop:从父组件接收单向数据,子组件内修改不会影响父组件
在井字棋应用中,根视图持有完整的游戏状态(board、currentPlayer、winner),通过 @State 实现响应式。子组件 GridCell 使用 @Prop 接收单个格子的值,实现高效的局部更新。
4.2 Grid 容器组件
HarmonyOS 的 Grid 组件提供了强大的网格布局能力:
arkts
Grid() {
// ...子元素
}
.columnsTemplate('1fr 1fr 1fr') // 三列等宽
.rowsTemplate('1fr 1fr 1fr') // 三行等高
.width(300)
.height(300)
1fr 表示按比例分配剩余空间,三列各占 1/3,自动适配不同屏幕尺寸。相比于使用 Row/Column 嵌套,Grid 在性能上更优,且代码更简洁。
4.3 声明式 UI 的优势
- 可预测性 :UI 是状态的函数 ------
UI = f(state) - 易维护性:改状态即改界面,无需手动操作 DOM
- 性能优化:框架自动计算最小重绘范围
五、算法深入探讨
5.1 胜负判定的扩展思考
虽然 8 线检测在 3×3 棋盘上已经足够,但如果扩展到 N×N 棋盘(五子棋场景),就需要更通用的算法:
arkts
function checkWinnerGeneric(board: Player[][],
row: number,
col: number,
winLength: number): boolean {
const directions = [
[0, 1], // 水平
[1, 0], // 垂直
[1, 1], // 主对角线
[1, -1] // 副对角线
];
const player = board[row][col];
if (player === Player.None) return false;
for (const [dx, dy] of directions) {
let count = 1; // 当前棋子
// 正方向延伸
for (let step = 1; step < winLength; step++) {
const r = row + dx * step;
const c = col + dy * step;
if (r >= 0 && r < board.length &&
c >= 0 && c < board[0].length &&
board[r][c] === player) {
count++;
} else break;
}
// 反方向延伸
for (let step = 1; step < winLength; step++) {
const r = row - dx * step;
const c = col - dy * step;
if (r >= 0 && r < board.length &&
c >= 0 && c < board[0].length &&
board[r][c] === player) {
count++;
} else break;
}
if (count >= winLength) return true;
}
return false;
}
这种通用算法在 3×3 棋盘上同样可用,且代码的可扩展性更好。
5.2 极小极大算法(AI 对手)
如果希望加入单机 AI 模式,可以使用极小极大(Minimax)算法:
arkts
function minimax(board: Player[][],
depth: number,
isMaximizing: boolean): number {
const winner = checkWinner(board);
if (winner === Player.X) return -10 + depth; // AI 输
if (winner === Player.O) return 10 - depth; // AI 赢
if (isBoardFull(board)) return 0; // 平局
if (isMaximizing) {
let bestScore = -Infinity;
for (let i = 0; i < 3; i++) {
for (let j = 0; j < 3; j++) {
if (board[i][j] === Player.None) {
board[i][j] = Player.O;
const score = minimax(board, depth + 1, false);
board[i][j] = Player.None;
bestScore = Math.max(bestScore, score);
}
}
}
return bestScore;
} else {
let bestScore = Infinity;
for (let i = 0; i < 3; i++) {
for (let j = 0; j < 3; j++) {
if (board[i][j] === Player.None) {
board[i][j] = Player.X;
const score = minimax(board, depth + 1, true);
board[i][j] = Player.None;
bestScore = Math.min(bestScore, score);
}
}
}
return bestScore;
}
}
Minimax 算法的时间复杂度为 O(9!),在 3×3 棋盘上搜索空间为 362,880 种排列,完全可接受。
六、UI/UX 设计
6.1 交互反馈
- 点击反馈:格子被点击时有背景色变化和轻微的缩放动画
- 状态提示:顶部文字实时显示当前玩家或胜者信息
- 操作限制:无效点击(已占格子、游戏已结束)不产生反应
6.2 视觉设计
- 使用 Grid 容器保证网格严格对齐
- X 用红色、O 用蓝色,颜色对比鲜明
- 背景色区分已占与未占格子
- 按键大小自适应屏幕
6.3 动画增强
arkts
// 落子动画
Button() {
Text('X')
.scale({ x: this.animate ? 1 : 0, y: this.animate ? 1 : 0 })
.animation({
duration: 300,
curve: Curve.EaseOut
})
}
七、最佳实践总结
7.1 代码组织
- 将模型、控制器、视图分离,遵循单一职责原则
- 使用枚举类型而非魔法值表示玩家和状态
- 将重复逻辑抽象为工具函数
7.2 状态管理
- 避免在多个组件中维护冗余状态
- 使用单向数据流,状态提升到最近的公共父组件
- 合理使用 @State、@Prop、@Link 装饰器
7.3 性能优化
- 对于 3×3 的小规模棋盘,无需额外优化
- 如扩展到更大的棋盘,考虑使用 LazyForEach 按需渲染
7.4 可测试性
- 将纯逻辑(棋盘状态更新、胜负判定)与 UI 分离
- 胜负判定函数可独立于 UI 进行单元测试
八、完整代码清单
arkts
// GameModel.ets
export enum Player { None = 0, X = 1, O = 2 }
export class GameModel {
board: Player[][] = [
[Player.None, Player.None, Player.None],
[Player.None, Player.None, Player.None],
[Player.None, Player.None, Player.None]
];
currentPlayer: Player = Player.X;
winner: Player = Player.None;
moveCount: number = 0;
isDraw: boolean = false;
reset(): void {
this.board = [
[Player.None, Player.None, Player.None],
[Player.None, Player.None, Player.None],
[Player.None, Player.None, Player.None]
];
this.currentPlayer = Player.X;
this.winner = Player.None;
this.moveCount = 0;
this.isDraw = false;
}
}
arkts
// GameController.ets
import { GameModel, Player } from './GameModel';
export function checkWinner(board: Player[][]): Player {
for (let i = 0; i < 3; i++) {
if (board[i][0] !== Player.None &&
board[i][0] === board[i][1] &&
board[i][1] === board[i][2]) {
return board[i][0];
}
}
for (let j = 0; j < 3; j++) {
if (board[0][j] !== Player.None &&
board[0][j] === board[1][j] &&
board[1][j] === board[2][j]) {
return board[0][j];
}
}
if (board[0][0] !== Player.None &&
board[0][0] === board[1][1] &&
board[1][1] === board[2][2]) {
return board[0][0];
}
if (board[0][2] !== Player.None &&
board[0][2] === board[1][1] &&
board[1][1] === board[2][0]) {
return board[0][2];
}
return Player.None;
}
export function handleMove(model: GameModel, row: number, col: number): boolean {
if (model.board[row][col] !== Player.None) return false;
if (model.winner !== Player.None || model.isDraw) return false;
model.board[row][col] = model.currentPlayer;
model.moveCount++;
const result = checkWinner(model.board);
if (result !== Player.None) {
model.winner = result;
return true;
}
if (model.moveCount === 9) {
model.isDraw = true;
return true;
}
model.currentPlayer =
(model.currentPlayer === Player.X) ? Player.O : Player.X;
return true;
}
arkts
// Index.ets (主页面)
import { GameModel, Player } from './GameModel';
import { checkWinner, handleMove } from './GameController';
@Entry
@Component
struct Index {
@State model: GameModel = new GameModel();
build() {
Column() {
Text(this.getStatusText())
.fontSize(24)
.fontWeight(FontWeight.Bold)
.margin({ bottom: 20 })
Grid() {
ForEach(this.model.board, (row: Player[], rowIndex: number) => {
ForEach(row, (cell: Player, colIndex: number) => {
Button() {
if (cell === Player.X) {
Text('X').fontSize(40).fontWeight(FontWeight.Bold)
.fontColor(Color.Red)
} else if (cell === Player.O) {
Text('O').fontSize(40).fontWeight(FontWeight.Bold)
.fontColor(Color.Blue)
}
}
.width(100)
.height(100)
.backgroundColor(cell === Player.None ?
Color.White : Color.LightGray)
.border({ width: 2, color: Color.Gray })
.onClick(() => {
handleMove(this.model, rowIndex, colIndex);
})
}, (colIndex: number) => colIndex.toString())
}, (rowIndex: number) => rowIndex.toString())
}
.columnsTemplate('1fr 1fr 1fr')
.rowsTemplate('1fr 1fr 1fr')
.width(300)
.height(300)
.padding(4)
Button('重新开始')
.fontSize(18)
.margin({ top: 20 })
.onClick(() => {
this.model.reset();
})
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
getStatusText(): string {
if (this.model.winner !== Player.None) {
return `玩家 ${this.model.winner === Player.X ? 'X' : 'O'} 获胜!🎉`;
}
if (this.model.isDraw) {
return '平局!🤝';
}
return `轮到 ${this.model.currentPlayer === Player.X ? 'X' : 'O'} 落子`;
}
}
九、总结
通过本篇文章,我们完整地实现了 HarmonyOS 平台上的井字棋游戏应用。核心知识点包括:
- 8 线胜负判定算法 ------ 3 行 + 3 列 + 2 对角线的恒定时间复杂度检测
- 交替落子逻辑 ------ 状态转换、边界条件处理
- 声明式 UI 框架 ------ @State/@Prop 装饰器、Grid 容器、单向数据流
- 算法扩展 ------ Minimax AI、通用 N×N 棋盘检测
井字棋虽然规则简单,但涵盖了游戏开发中的许多基础概念:状态管理、事件处理、算法设计、UI 组织。这些知识可以平滑地迁移到更复杂的游戏和应用开发中。希望本文能为 HarmonyOS 开发者提供有价值的参考。