一、引言

井字棋(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 交互流程

一局典型的人机对局包含以下交互点:

  1. 选择模式(可选):点击"双人对战"或"人机对战"
  2. 玩家落子:点击空格 → ✕ 出现 → 判定 → 回合切换
  3. AI思考:400ms 延时 → AI 自动落子 → 判定 → 回合切回
  4. 游戏结束:有人获胜(高亮连线)或平局 → 得分更新
  5. 重新开始:点击按钮 → 棋盘清空,得分保留
    在这里插入图片描述

三、数据模型与状态管理

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],检测三个条件:

  1. b[a] !== 0:第一个格子不为空
  2. b[a] === b[b1]:第一个和第二个相同
  3. 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;
}

isBoardFullcheckWin 返回 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 延时实现了回合之间的自然过渡。

核心要点回顾:

  1. 一维棋盘数组number[9] 表示 3×3 九宫格,每个格子的值(0/1/2)编码了空/✕/◯ 三种状态。一维数组简化了索引计算和获胜检测——每个获胜线只需 3 个整数即可表达。

  2. 8 条获胜线:3 行 + 3 列 + 2 对角线 = 8 种获胜方式。checkWin() 遍历这 8 条线,检测"三个格子非空且相等"。平局检测在胜负检测之后,确保"最后一手同时连线+填满"判胜不平。

  3. 五级优先级 AI:赢 > 堵 > 中 > 角 > 任意。每条规则独立地扫描棋盘并返回首个满足条件的落子。时间复杂度 O(n),不需要递归或搜索树。这个 AI 不会犯"漏堵"的低级错误,但也不会主动创建双重威胁。

  4. AI 思考延时setInterval(fn, 400) + 即时 clearInterval 实现 400ms 的"思考停顿"。延时期间 aiTimerId 充当棋盘锁,防止玩家在 AI 回合落子。这个模式与记忆翻牌的 800ms 翻转延时使用相同的 setInterval 技术。

  5. 不可变棋盘更新makeMove() 每次返回全新的 9 元素数组,不修改原数组。同一个方法被玩家落子、AI 试走、AI 实际落子三个场景复用——共用的代码减少了状态不一致的风险。

  6. 双色符号系统:✕ 红色(#FF4D4F)、◯ 蓝色(#1677FF)——两种颜色在白色棋盘上形成高对比度区分。获胜线使用橙色(#FF9800)边框 + 浅橙(#FFF3E0)背景高亮,橙色作为红蓝之间的"中性胜利色",不偏向任何一方。

  7. 得分跨局保留scoreXscoreOdraws 三个变量在多次游戏中持续累积。newGame() 清空棋盘和胜负状态,但保留得分——这是一个有意为之的设计选择,鼓励玩家多玩几局。

井字棋的魅力在于它的简单——规则三秒学会,策略五分钟入门。但这个 260 行的 ArkUI 实现展示了一个完整的回合制游戏所需的全部要素:状态机、胜负判定、AI 决策、延时交互和得分系统。它是游戏开发的最小完备示例,也是状态管理模式的一次集中实践。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐