SOURCE

console 命令行工具 X clear

                    
>
console
<div id="app"></div>
<script type="text/babel">
const { useState, useMemo } = React;

// 定义常量
const PlayerEnum = {
  //'X': "X",
  //'O': "O",
  'X': "&#935;",
  'O': "&#927;",
}
const GameText = {
  'Playing': "玩家<b>%s%</b>落子",
  'Winning': "玩家<b>%s%</b>获胜!",
  'Drawgame': "平局!",
  'Replay': "重新开始",

  'GameHistoryLabel': "历史记录",
  'GameStartText': "游戏开始",
  'GameMoveText': "玩家<b>%s%</b>落子位置: <b>%s%</b>",
  'SortAsc': "升序",
  'SortDesc': "降序",
}


// 棋盘方格
const Square = ({ value, onSquareClick, className }) => {
    return (
    <div
        className={`square ${className}`}
        onClick={onSquareClick}
        dangerouslySetInnerHTML={{ __html: value }}
    ></div>
    );
};


// 棋盘
const Board = ({ xIsNext, squares, onPlay, onGameOver }) => {
    // 落子 - 棋盘点击事件
    const handleSquareClick = (i) => {
        // 裁判
        const { winner } = calckWinner(squares);
        if (winner) {
            // alert("游戏结束");
            return;
        }
        // 检查是否已落子
        if (squares[i] !== "") {
            alert("该位置已落子");
            return;
        }

        const nextSquares = squares.slice(); // 创建副本
        // 改变副本中对应索引的值
        nextSquares[i] = xIsNext ? PlayerEnum.X : PlayerEnum.O;
        //通知父组件重新渲染
        onPlay(nextSquares, i);
        // 判断游戏是否结束
        checkGameOver(nextSquares);
    };

    // 判断获胜者
    const calckWinner = (squares) => {
        const result = { winner: null, blocks: [] };
        // 所有获胜组合
        const combinations = [
            [0, 1, 2],
            [3, 4, 5],
            [6, 7, 8],
            [0, 3, 6],
            [1, 4, 7],
            [2, 5, 8],
            [0, 4, 8],
            [2, 4, 6],
        ];
        for (let i = 0; i < combinations.length; i++) {
            const [a, b, c] = combinations[i];
            if (
                squares[a] &&
                squares[a] === squares[b] &&
                squares[a] === squares[c]
            ) {
                // 设置胜方(获胜者,获胜组合),并返回
                result.winner = squares[a];
                result.blocks = combinations[i];
                return result;
            }
        }
        return result;
    };

    // 判断游戏是否结束 - 当棋盘格全部落满子,或者有玩家获胜时,游戏结束(上报父组件)
    const checkGameOver = (squares) => {
        const { winner } = calckWinner(squares);
        const ok = !!(winner || !squares.includes(""));
        if (onGameOver) {
            onGameOver(ok);
        }
    };

    // 计算当前游戏状态
    const { winner, blocks: winningBlocks } = calckWinner(squares);
    let statement = "";
    // 决出胜者
    if (winner) {
        statement = GameText.Winning.replace("%s%", winner);
    } else {
        // 未决出胜者, 判断平局 or 继续游戏
        statement = !squares.includes("") ? GameText.Drawgame : GameText.Playing.replace(
            "%s%",
            xIsNext ? PlayerEnum.X : PlayerEnum.O
        );
    }

    return (
    <div>
        <div
            className={`play-state ${
                winner
                    ? "result game-win"
                    : !squares.includes("")
                    ? "result game-draw"
                    : ""
            }`}
        >
            <span dangerouslySetInnerHTML={{ __html: statement }}></span>
        </div>
        <div className="board">
            {squares.map((square, i) => {
                return (
                    <Square
                        className={winningBlocks.includes(i) ? "winning-block" : ""}
                        value={square}
                        key={i}
                        onSquareClick={() => handleSquareClick(i)}
                    />
                );
            })}
        </div>
    </div>
    );
};


// 井字棋游戏
const Game = () => {
    const [history, setHistory] = useState([Array(9).fill("")]);
    const [curStep, setCurStep] = useState(0);
    const [isDesc, setIsDesc] = useState(false);

    // 记录当前落子位置
    const [posList, setPosList] = useState(Array(9).fill(-1));

    // 下棋方
    const xIsNext = curStep % 2 === 0;

    // 当前棋盘状态
    const curSquares = history[curStep];

    // 游戏逻辑
    const handlePlay = (nextSquares, index) => {
        // 记录历史
        const nextHistory = [...history.slice(0, curStep + 1), nextSquares];
        setHistory(nextHistory);

        // 更新当前步数 (指向历史记录最后一步)
        setCurStep(nextHistory.length - 1);

        // 记录当前落子位置
        const nextPosList = [...posList.slice(0, curStep + 1), index];
        setPosList(nextPosList);
    };

    // 回到某一步
    const handleGoTo = (step) => {
        // if (step === 0) return; //回退至游戏刚开始,无意义
        setCurStep(step);
    };

    // 游戏结束状态
    const [isGameOver, setIsGameOver] = useState(false);
    const handleGameOver = (state) => {
        setIsGameOver(state);
    };
    // 重新开始游戏
    const handleReplay = () => {
        setHistory([Array(9).fill("")]);
        setIsGameOver(false);
        setCurStep(0);
    };

    // 计算落子位置 - 通过棋盘一维数组索引值转化成二维数组坐标
    const calcPos = (pos, rowNum = 3, colNum = 3) => {
        return {
            x: Math.floor(pos / rowNum),
            y: pos % colNum,
        };
    };

    // 渲染历史记录
    const moves = useMemo(() => {
        return (isDesc ? history.slice().reverse() : history).map((_, i) => {
        let description = "";
        const step = isDesc ? history.length - i - 1 : i;
        const player = ((isDesc ? i : i + 1)) % 2 === 0 ? PlayerEnum.X : PlayerEnum.O;
        if (step === 0) {
            description = GameText.GameStartText;
        } else {
            const pos = calcPos(posList[step]);
            description = GameText.GameMoveText.replace("%s%", player).replace("%s%", `(${pos.x},${pos.y})`);
        }
        return (
            <li className={step === curStep ? "active" : ""} key={step}>
            <span
                className={`btn-step ${step === 0 && 'step-start'}`}
                dangerouslySetInnerHTML={{ __html: description }}
                onClick={() => handleGoTo(step)}
            />
            </li>
        );
        });
    }, [curStep, isDesc, history, posList]);

    return (
    <div className="game">
        <div className="container">
          <div className="warpper left">
            <Board
              xIsNext={xIsNext}
              squares={curSquares}
              onPlay={handlePlay}
              onGameOver={handleGameOver}
            />

            {isGameOver && (
              <div style={{ margin: "20px auto", textAlign: "center" }}>
                <button className="btn-replay" onClick={handleReplay}>
                  {GameText.Replay}
                </button>
              </div>
            )}
          </div>

          <div className="warpper right">
            <div className={`history ${isDesc && 'desc'}`}>
              <div className="label">
                {GameText.GameHistoryLabel}
                <button className="btn-sort" onClick={() => setIsDesc(!isDesc)}>{isDesc ? GameText.SortDesc : GameText.SortAsc }</button>
              </div>
              <div style={{paddingLeft: "20px"}}>
                <ol>{moves}</ol>
              </div>
            </div>
          </div>
        </div>
    </div>
    );
};

ReactDOM.createRoot(document.getElementById('app')).render(
    <Game />
);
</script>
/* define global theme */
:root{
  --white: rgba(255, 255, 255, 1);
  --dark: rgba(37, 37, 37, 1);
}
/* reset default stylesheet */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
  color: var(--dark);
  font-size: 20px;
  line-height: 24px;
  font-family: sans-serif;
}

.game {
  margin: 60px auto;
}

.game .container {
  display: flex;
  flex-direction: row;
  justify-content: center;
  align-items: flex-start;
}
.game .warpper {
  margin: 10px 20px;
}

.game .warpper.left {
  width: 220px;
}

.game .board {
  --game-bord-size: 200px;
  --border-line-size: 1px;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  grid-template-rows: repeat(3, 1fr);
  gap: 0px;
  width: var(--game-bord-size);
  height: var(--game-bord-size);
  border: var(--border-line-size) solid var(--dark);
}
.game .board .square {
  display: flex;
  justify-content: center;
  align-items: center;
  font-weight: 700;
  width: 100%;
  height: 100%;
  border: var(--border-line-size) solid var(--dark);
  background-color: var(--white);
  cursor: default;
}
.game .board .square.winning-block {
  background-color: yellow;
}

.game .play-state {
  position: relative;
  margin-bottom: 10px;
}
.game .play-state b {
  margin: 0 4px;
  font-weight: 700;
}

.game .btn-replay {
  padding: 5px 10px;
  background-color: var(--white);
  border: none;
  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25);
  border-radius: 5px;
  cursor: pointer;
}

.game .play-state.result {
  padding-left: 15px;
}
.game .play-state.result::before {
  content: "";
  position: absolute;
  top: 50%;
  left: 0;
  width: 10px;
  height: 10px;
  background-color: transparent;
  border-radius: 50%;
  transform: translate(0%, -50%);
}
.game .play-state.game-win::before {
  background-color: #52c41a;
}
.game .play-state.game-draw::before {
  background-color: #faad14;
}

.game .history .label {
  margin-bottom: 10px;
  font-size: 16px;
  font-weight: 700;
}
.game .history ol {
  list-style-position: outside;
  display: flex;
  flex-direction: column;
  justify-content: flex-start;
  gap: 15px;
  width: 400px;
  text-align: left;
}
.game .history ol > li {
  height: auto;
  /* padding: 0 10px; */
}
.game .history .btn-step {
  position: relative;
  padding: 4px 8px;
  border: none;
  border-radius: 5px;
  font-size: 14px;
  line-height: 16px;
  cursor: default;
}
.game .history .btn-step:hover {
  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1);
}
.game .history .btn-step.step-start {
    font-size: 16px;
    font-weight: 500;
}
.game .history .btn-step b {
  margin: 0 4px;
  font-size: 14px;
  line-height: 16px;
  font-weight: 700;
}
.game .history .active .btn-step::before {
  content: "";
  position: absolute;
  top: 50%;
  right: -9px;
  width: 7px;
  height: 7px;
  background-color: rgba(245, 106, 0, 1);
  border-radius: 1px;
  transform: translate(0%, -50%) rotate(135deg);
}
.game .history .active .btn-step::after {
  content: "";
  position: absolute;
  top: 50%;
  right: -20px;
  z-index: -1;
  width: 15px;
  height: 10px;
  background-color: rgba(245, 106, 0, 1);
  border-radius: 1px 2px 2px 1px;
  transform: translate(0%, -50%);
}

.game .history .btn-sort {
  margin: 0 10px;
  padding: 0 4px;
  font-size: 12px;
  line-height: 14px;
}

本项目引用的自定义外部资源