在这里插入图片描述

今天我们用 React Native 实现一个井字棋游戏,支持双人对战、胜负判断、计分统计。

状态和类型定义

import React, { useState, useRef, useEffect } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Animated } from 'react-native';

type Player = 'X' | 'O' | null;

export const TicTacToe: React.FC = () => {
  const [board, setBoard] = useState<Player[]>(Array(9).fill(null));
  const [isXNext, setIsXNext] = useState(true);
  const [scores, setScores] = useState({ X: 0, O: 0, draw: 0 });
  const [winLine, setWinLine] = useState<number[] | null>(null);
  const cellAnims = useRef(Array(9).fill(0).map(() => new Animated.Value(0))).current;
  const winAnim = useRef(new Animated.Value(0)).current;
  const shakeAnim = useRef(new Animated.Value(0)).current;

状态设计包含棋盘、当前玩家、计分、获胜线。

玩家类型Player'X''O'nullnull 表示空格子。

棋盘状态board 是长度为 9 的数组,每个元素是一个格子的状态。索引 0-8 对应 3×3 棋盘的 9 个格子。

当前玩家isXNext 是布尔值,true 表示轮到 X,false 表示轮到 O。

计分scores 对象存储 X 的胜场、O 的胜场、平局次数。

获胜线winLine 是包含 3 个索引的数组,表示获胜的 3 个格子。比如 [0, 1, 2] 表示第一行获胜。

动画值

  • cellAnims:9 个格子的缩放动画,每个格子独立
  • winAnim:获胜格子的闪烁动画
  • shakeAnim:平局时棋盘的抖动动画

为什么用数组存储棋盘?因为数组是最简单的数据结构,索引对应格子位置。如果用二维数组 [[null, null, null], ...],访问格子需要两个索引 board[row][col],更复杂。一维数组只需要一个索引 board[index],更简洁。

获胜线定义和判断

  const lines = [[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6]];

  const checkWinner = (squares: Player[]): { winner: Player; line: number[] | null } => {
    for (const [a, b, c] of lines) {
      if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
        return { winner: squares[a], line: [a, b, c] };
      }
    }
    return { winner: null, line: null };
  };

  const { winner, line } = checkWinner(board);
  const isDraw = !winner && board.every(cell => cell !== null);

获胜线定义了 8 种获胜组合:3 行、3 列、2 条对角线。

获胜线数组

  • [0, 1, 2]:第一行
  • [3, 4, 5]:第二行
  • [6, 7, 8]:第三行
  • [0, 3, 6]:第一列
  • [1, 4, 7]:第二列
  • [2, 5, 8]:第三列
  • [0, 4, 8]:主对角线(左上到右下)
  • [2, 4, 6]:副对角线(右上到左下)

判断逻辑:遍历 8 种获胜线,检查每条线的 3 个格子是否相同且非空。

条件判断

  • squares[a]:第一个格子非空(不是 null
  • squares[a] === squares[b]:第一个和第二个格子相同
  • squares[a] === squares[c]:第一个和第三个格子相同

如果三个条件都满足,说明这条线获胜,返回获胜者和获胜线。

为什么先判断 squares[a]?因为如果第一个格子是 null,后面的比较没有意义。null === nulltrue,但 3 个空格子不算获胜。先判断非空,避免误判。

平局判断:没有获胜者,且所有格子都非空,就是平局。board.every(cell => cell !== null) 检查是否所有格子都有棋子。

获胜动画

  useEffect(() => {
    if (winner) {
      setWinLine(line);
      Animated.loop(
        Animated.sequence([
          Animated.timing(winAnim, { toValue: 1, duration: 500, useNativeDriver: true }),
          Animated.timing(winAnim, { toValue: 0, duration: 500, useNativeDriver: true }),
        ]),
        { iterations: 3 }
      ).start();
    }
  }, [winner]);

当有获胜者时,启动获胜格子的闪烁动画。

设置获胜线setWinLine(line) 保存获胜线的索引,用于高亮显示。

循环动画Animated.loop 让动画重复 3 次。内部是序列动画,从 0 到 1(500ms),再从 1 到 0(500ms),总共 1 秒一个周期。

为什么重复 3 次?因为 3 次刚好,既能让用户注意到获胜格子,又不会太多导致等待时间过长。3 秒后动画停止,用户可以开始新一局。

为什么用 useEffect?因为 winner 变化时才需要启动动画。如果在渲染函数中直接调用 Animated.loop,每次渲染都会启动新动画,导致动画重复。useEffect 确保动画只在 winner 变化时启动一次。

落子逻辑

  const handlePress = (index: number) => {
    if (board[index] || winner) return;
    
    // 动画
    Animated.spring(cellAnims[index], {
      toValue: 1,
      friction: 3,
      tension: 100,
      useNativeDriver: true,
    }).start();

    const newBoard = [...board];
    newBoard[index] = isXNext ? 'X' : 'O';
    setBoard(newBoard);
    setIsXNext(!isXNext);

落子函数处理格子点击,包含动画、状态更新、胜负判断。

防重复落子:如果格子已有棋子(board[index] 非空)或游戏已结束(winner 存在),直接返回,不处理点击。

落子动画:弹簧动画让格子从 0 缩放到 1,friction: 3 让弹簧有明显的回弹效果,tension: 100 控制弹簧的张力(弹性强度)。

更新棋盘

  • [...board]:复制棋盘数组,避免直接修改原数组
  • newBoard[index] = isXNext ? 'X' : 'O':在点击的格子放置当前玩家的棋子
  • setBoard(newBoard):更新棋盘状态

切换玩家setIsXNext(!isXNext) 切换到下一个玩家。

为什么要复制数组?因为 React 的状态更新需要新对象。如果直接修改 board[index],React 检测不到变化,不会重新渲染。复制数组后修改,React 能检测到新数组,触发重新渲染。

胜负判断和计分

    const result = checkWinner(newBoard);
    if (result.winner) {
      setScores({ ...scores, [result.winner]: scores[result.winner as 'X' | 'O'] + 1 });
    } else if (newBoard.every(cell => cell !== null)) {
      setScores({ ...scores, draw: scores.draw + 1 });
      // 平局抖动
      Animated.sequence([
        Animated.timing(shakeAnim, { toValue: 10, duration: 50, useNativeDriver: true }),
        Animated.timing(shakeAnim, { toValue: -10, duration: 50, useNativeDriver: true }),
        Animated.timing(shakeAnim, { toValue: 10, duration: 50, useNativeDriver: true }),
        Animated.timing(shakeAnim, { toValue: 0, duration: 50, useNativeDriver: true }),
      ]).start();
    }
  };

落子后检查胜负,更新计分。

检查获胜:用新棋盘调用 checkWinner,得到获胜者和获胜线。

更新胜场:如果有获胜者,对应玩家的胜场加 1。[result.winner] 是动态属性名,根据获胜者(‘X’ 或 ‘O’)更新对应的计分。

检查平局:如果没有获胜者,且所有格子都有棋子,就是平局。平局次数加 1。

平局动画:序列动画让棋盘左右抖动。从 0 到 10(向右)、到 -10(向左)、到 10(向右)、回到 0,总共 200ms。抖动 3 次,给用户"平局"的视觉反馈。

为什么平局要抖动?因为平局没有获胜格子可以高亮,需要其他方式提示用户。抖动是一种常见的"失败"或"无效"反馈,让用户知道游戏结束了。

重置函数

  const reset = () => {
    setBoard(Array(9).fill(null));
    setIsXNext(true);
    setWinLine(null);
    winAnim.setValue(0);
    cellAnims.forEach(anim => anim.setValue(0));
  };

  const resetAll = () => {
    reset();
    setScores({ X: 0, O: 0, draw: 0 });
  };

两个重置函数:reset 重置棋盘,resetAll 重置棋盘和计分。

重置棋盘

  • 棋盘清空,所有格子设为 null
  • 重置为 X 先手
  • 清除获胜线
  • 重置所有动画值为 0

重置计分:调用 reset 重置棋盘,再把计分清零。

为什么分两个函数?因为用户可能想开始新一局(保留计分),也可能想完全重新开始(清除计分)。两个函数提供不同的重置选项。

格子渲染

  const renderCell = (index: number) => {
    const cell = board[index];
    const isWinCell = winLine?.includes(index);
    const scale = cellAnims[index].interpolate({
      inputRange: [0, 1],
      outputRange: [0.3, 1],
    });
    const winScale = winAnim.interpolate({
      inputRange: [0, 1],
      outputRange: [1, 1.2],
    });

    return (
      <TouchableOpacity
        key={index}
        style={[styles.cell, isWinCell && styles.cellWin]}
        onPress={() => handlePress(index)}
        activeOpacity={0.7}
      >
        {cell && (
          <Animated.View
            style={[
              styles.cellContent,
              { transform: [{ scale }, ...(isWinCell ? [{ scale: winScale }] : [])] },
            ]}
          >
            <Text style={[styles.cellText, cell === 'X' ? styles.cellX : styles.cellO]}>
              {cell}
            </Text>
          </Animated.View>
        )}
        {!cell && (
          <View style={styles.cellHint}>
            <Text style={styles.cellHintText}>{isXNext ? 'X' : 'O'}</Text>
          </View>
        )}
      </TouchableOpacity>
    );
  };

格子渲染函数包含动画插值、条件样式、提示显示。

获取格子状态board[index] 得到格子的棋子(‘X’、‘O’ 或 null)。

判断获胜格子winLine?.includes(index) 检查当前格子是否在获胜线中。?. 是可选链操作符,如果 winLinenull,返回 undefined,不会报错。

缩放插值

  • scale:格子的缩放动画,从 0.3 到 1。棋子从小到大出现,有"弹出"效果
  • winScale:获胜格子的闪烁动画,从 1 到 1.2。获胜格子放大 20%,再缩回,循环 3 次

条件样式:如果是获胜格子,应用 cellWin 样式(绿色背景、青色边框、发光效果)。

棋子显示

  • 如果格子有棋子,显示棋子文字(‘X’ 或 ‘O’)
  • 应用两个缩放动画:落子动画和获胜动画(如果是获胜格子)
  • X 用红色,O 用青色

提示显示

  • 如果格子是空的,显示半透明的提示文字
  • 提示文字是当前玩家的符号(X 或 O),告诉用户"点击这里会放置什么棋子"

为什么提示文字透明度很低?因为提示文字只是辅助信息,不应该抢眼。透明度 0.1(10%)让提示文字若隐若现,用户能看到但不会干扰视觉。

界面渲染:计分板

  return (
    <View style={styles.container}>
      <View style={styles.scoreBoard}>
        <View style={[styles.scoreItem, !isXNext && !winner && styles.scoreItemActive]}>
          <Text style={[styles.scorePlayer, styles.playerX]}>X</Text>
          <Text style={styles.scoreValue}>{scores.X}</Text>
        </View>
        <View style={styles.scoreItem}>
          <Text style={styles.scorePlayer}>平</Text>
          <Text style={styles.scoreValue}>{scores.draw}</Text>
        </View>
        <View style={[styles.scoreItem, isXNext && !winner && styles.scoreItemActive]}>
          <Text style={[styles.scorePlayer, styles.playerO]}>O</Text>
          <Text style={styles.scoreValue}>{scores.O}</Text>
        </View>
      </View>

计分板显示 X 的胜场、平局次数、O 的胜场。

三个计分项

  • X 的胜场:左边,红色
  • 平局次数:中间,灰色
  • O 的胜场:右边,青色

激活状态

  • 如果轮到 O(!isXNext)且游戏未结束(!winner),X 的计分项高亮
  • 如果轮到 X(isXNext)且游戏未结束,O 的计分项高亮
  • 高亮样式是半透明白色背景

为什么轮到 O 时高亮 X?因为计分板显示的是"上一个玩家"的状态。轮到 O 说明 X 刚下完,所以高亮 X。这让用户知道"刚才是谁下的"。

状态显示和棋盘

      <View style={styles.statusContainer}>
        <Text style={styles.status}>
          {winner ? `🎉 ${winner} 获胜!` : isDraw ? '🤝 平局!' : `轮到 ${isXNext ? 'X' : 'O'}`}
        </Text>
      </View>

      <Animated.View style={[styles.board, { transform: [{ translateX: shakeAnim }] }]}>
        <View style={styles.boardInner}>
          {board.map((_, i) => renderCell(i))}
        </View>
        <View style={styles.boardGlow} />
      </Animated.View>

状态文字

  • 如果有获胜者:🎉 X 获胜! 或 🎉 O 获胜!
  • 如果平局:🤝 平局!
  • 否则:轮到 X 或 轮到 O

棋盘容器:应用抖动动画(translateX: shakeAnim),平局时左右抖动。

棋盘内容

  • boardInner:3×3 网格,包含 9 个格子
  • boardGlow:发光边框,绝对定位在棋盘外围,青色边框,半透明

为什么发光边框用绝对定位?因为发光边框不占据布局空间,不影响棋盘的大小和位置。绝对定位让边框"浮"在棋盘上方,营造发光效果。

按钮

      <View style={styles.btnRow}>
        <TouchableOpacity style={styles.btn} onPress={reset}>
          <Text style={styles.btnText}>🔄 新一局</Text>
        </TouchableOpacity>
        <TouchableOpacity style={[styles.btn, styles.btnSecondary]} onPress={resetAll}>
          <Text style={styles.btnText}>🗑️ 重置</Text>
        </TouchableOpacity>
      </View>
    </View>
  );
};

两个按钮:新一局(保留计分)、重置(清除计分)。

新一局按钮:蓝色背景,点击调用 reset,重置棋盘但保留计分。

重置按钮:灰色背景(btnSecondary),点击调用 resetAll,重置棋盘和计分。

为什么用不同颜色?因为颜色能传达按钮的重要性。蓝色是主要操作(新一局),灰色是次要操作(重置)。用户通常只需要"新一局","重置"是偶尔使用的功能。

鸿蒙 ArkTS 对比:游戏逻辑

@State board: (string | null)[] = Array(9).fill(null)
@State isXNext: boolean = true
@State scores: { X: number, O: number, draw: number } = { X: 0, O: 0, draw: 0 }
@State winLine: number[] | null = null

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]]

checkWinner(squares: (string | null)[]): { winner: string | null, line: number[] | null } {
  for (const [a, b, c] of this.lines) {
    if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
      return { winner: squares[a], line: [a, b, c] }
    }
  }
  return { winner: null, line: null }
}

handlePress(index: number) {
  if (this.board[index] || this.winner) return
  
  const newBoard = [...this.board]
  newBoard[index] = this.isXNext ? 'X' : 'O'
  this.board = newBoard
  this.isXNext = !this.isXNext
  
  const result = this.checkWinner(newBoard)
  if (result.winner) {
    this.scores[result.winner] += 1
    this.winLine = result.line
  } else if (newBoard.every(cell => cell !== null)) {
    this.scores.draw += 1
  }
}

ArkTS 中的游戏逻辑完全一样,核心是数组操作和条件判断。获胜线定义、胜负判断、落子逻辑都是纯 JavaScript,跨平台通用。

样式定义:容器和计分板

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#0f0f23', padding: 20, alignItems: 'center' },
  scoreBoard: {
    flexDirection: 'row',
    marginBottom: 20,
    backgroundColor: '#1a1a3e',
    borderRadius: 20,
    padding: 10,
  },
  scoreItem: {
    alignItems: 'center',
    paddingHorizontal: 25,
    paddingVertical: 10,
    borderRadius: 15,
  },
  scoreItemActive: { backgroundColor: 'rgba(255,255,255,0.1)' },
  scorePlayer: { color: '#888', fontSize: 20, fontWeight: '600' },
  playerX: { color: '#ff6b6b' },
  playerO: { color: '#4ecdc4' },
  scoreValue: { color: '#fff', fontSize: 32, fontWeight: '700' },

容器用深蓝黑色背景(#0f0f23),营造深色主题。

计分板:深蓝色背景(#1a1a3e),圆角 20,水平排列。

计分项

  • 水平内边距 25,垂直内边距 10
  • 激活状态用半透明白色背景

玩家符号

  • 默认灰色(#888)
  • X 用红色(#ff6b6b)
  • O 用青色(#4ecdc4)

计分数字:白色,字号 32,粗体 700,醒目。

样式定义:棋盘和格子

  statusContainer: {
    backgroundColor: '#1a1a3e',
    paddingVertical: 12,
    paddingHorizontal: 30,
    borderRadius: 25,
    marginBottom: 30,
  },
  status: { color: '#fff', fontSize: 20, fontWeight: '600' },
  board: { marginBottom: 30 },
  boardInner: {
    width: 300,
    height: 300,
    flexDirection: 'row',
    flexWrap: 'wrap',
    backgroundColor: '#1a1a3e',
    borderRadius: 20,
    padding: 10,
    shadowColor: '#4A90D9',
    shadowOffset: { width: 0, height: 0 },
    shadowOpacity: 0.3,
    shadowRadius: 20,
    elevation: 10,
  },
  boardGlow: {
    position: 'absolute',
    top: -2,
    left: -2,
    right: -2,
    bottom: -2,
    borderRadius: 22,
    borderWidth: 2,
    borderColor: '#4A90D9',
    opacity: 0.5,
  },

状态容器用深蓝色背景,大圆角(25),像"胶囊"。

棋盘

  • 正方形(300×300)
  • 深蓝色背景,圆角 20
  • 蓝色阴影,向外扩散(shadowOffset: { width: 0, height: 0 }
  • flexWrap: 'wrap':格子自动换行,3 个一行

发光边框

  • 绝对定位,比棋盘大 4 像素(上下左右各 2 像素)
  • 青色边框,半透明(透明度 0.5)
  • 圆角 22,比棋盘的圆角 20 大 2,确保边框完全包裹棋盘

样式定义:格子和按钮

  cell: {
    width: 90,
    height: 90,
    margin: 3,
    backgroundColor: '#252550',
    borderRadius: 15,
    justifyContent: 'center',
    alignItems: 'center',
    borderWidth: 2,
    borderColor: '#3a3a6a',
  },
  cellWin: {
    backgroundColor: '#2a4a2a',
    borderColor: '#4ecdc4',
    shadowColor: '#4ecdc4',
    shadowOffset: { width: 0, height: 0 },
    shadowOpacity: 0.5,
    shadowRadius: 10,
  },
  cellContent: {},
  cellText: { fontSize: 48, fontWeight: '800' },
  cellX: { color: '#ff6b6b', textShadowColor: '#ff6b6b', textShadowOffset: { width: 0, height: 0 }, textShadowRadius: 10 },
  cellO: { color: '#4ecdc4', textShadowColor: '#4ecdc4', textShadowOffset: { width: 0, height: 0 }, textShadowRadius: 10 },
  cellHint: { opacity: 0.1 },
  cellHintText: { fontSize: 48, fontWeight: '800', color: '#fff' },
  btnRow: { flexDirection: 'row' },
  btn: {
    backgroundColor: '#4A90D9',
    paddingVertical: 14,
    paddingHorizontal: 24,
    borderRadius: 25,
    marginHorizontal: 8,
    shadowColor: '#4A90D9',
    shadowOffset: { width: 0, height: 4 },
    shadowOpacity: 0.4,
    shadowRadius: 10,
  },
  btnSecondary: { backgroundColor: '#666' },
  btnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
});

格子

  • 正方形(90×90),外边距 3
  • 深蓝色背景(#252550),圆角 15,边框

获胜格子

  • 深绿色背景(#2a4a2a)
  • 青色边框和阴影(#4ecdc4)
  • 阴影向外扩散,营造"发光"效果

棋子文字

  • 字号 48,粗体 800,醒目
  • X 用红色,O 用青色
  • 文字阴影和文字颜色一致,向外扩散,营造"发光"效果

提示文字:透明度 0.1,若隐若现。

按钮

  • 主按钮:蓝色背景,蓝色阴影
  • 次按钮:灰色背景(#666)
  • 圆角 25,像"胶囊"

小结

这个井字棋游戏展示了游戏逻辑和动画的结合。获胜线定义和判断是核心算法,落子动画、获胜闪烁、平局抖动营造丰富的交互反馈。计分统计让用户看到长期的对战结果。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐