# HarmonyOS ArkTS 井字棋小游戏深度解析 —— 从零构建完整的三子棋对战应用

一、应用概述

井字棋(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 平台上的井字棋游戏应用。核心知识点包括:

  1. 8 线胜负判定算法 ------ 3 行 + 3 列 + 2 对角线的恒定时间复杂度检测
  2. 交替落子逻辑 ------ 状态转换、边界条件处理
  3. 声明式 UI 框架 ------ @State/@Prop 装饰器、Grid 容器、单向数据流
  4. 算法扩展 ------ Minimax AI、通用 N×N 棋盘检测

井字棋虽然规则简单,但涵盖了游戏开发中的许多基础概念:状态管理、事件处理、算法设计、UI 组织。这些知识可以平滑地迁移到更复杂的游戏和应用开发中。希望本文能为 HarmonyOS 开发者提供有价值的参考。

相关推荐
lmy_loveF2 小时前
HarmonyOS开发入门:DevEco Studio安装指南
华为·鸿蒙
FF2501_9402285814 小时前
HarmonyOS应用开发实战:猫猫大作战-在排行榜和历史记录查询中,排序是必不可少的操作【apple_product_name】
华为·harmonyos·鸿蒙
程序员黑豆18 小时前
鸿蒙应用开发:@Provider 与 @Consumer 跨组件双向同步详解
前端·harmonyos
程序员黑豆19 小时前
鸿蒙应用开发之跨组件传参:@Provide 与 @Consume 跨层级数据同步详解
前端·harmonyos
懿路向前19 小时前
【HarmonyOS学习笔记】2026-07-30 | 小艺开放平台智能体接入实战
笔记·学习·harmonyos
大锅盖119 小时前
HarmonyOS ArkTS 的新手练手样例:从 Text 和 Button 开始,做一个会变化的计数页面
华为·harmonyos
袁震21 小时前
小图传输,大图呈现——用 HarmonyOS 7 端侧 AI 实现 4 倍图像超分重建
人工智能·华为·harmonyos
结网的兔子1 天前
【前端开发】Web端迁移至 uni-app 及鸿蒙扩展方案对比
前端·uni-app·harmonyos
落叶飘飘s1 天前
餐饮服务与软件创新的融合:解析海底捞 APP 的 Flutter 鸿蒙开发之路
flutter·华为·harmonyos