console
<div id="app"></div>
<script type="text/babel">
const { useState, useMemo } = React;
const PlayerEnum = {
'X': "Χ",
'O': "Ο",
}
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) {
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 {
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) => {
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>
:root{
--white: rgba(255, 255, 255, 1);
--dark: rgba(37, 37, 37, 1);
}
* {
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;
}
.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;
}