From 4d3786c49560fcb817365524744599612630e15f Mon Sep 17 00:00:00 2001 From: WanCW Date: Fri, 25 Apr 2025 19:52:34 +0800 Subject: [PATCH 1/2] feat: make naive proof-of-concept --- index.html | 2 +- src/App.css | 49 +++++++-------- src/App.tsx | 172 +++++++++++++++++++++++++++++++++++++++++++++------- 3 files changed, 172 insertions(+), 51 deletions(-) diff --git a/index.html b/index.html index 0c589ec..0068160 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - Vite + React + Tic Tac Toe Bolt
diff --git a/src/App.css b/src/App.css index b9d355d..b7b6a48 100644 --- a/src/App.css +++ b/src/App.css @@ -5,38 +5,33 @@ text-align: center; } -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); +.board { + display: flex; + flex-direction: column; + gap: 10px; } -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } +.board-row { + display: flex; + flex-direction: row; + gap: 10px; } -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } +.board-cell { + width: 100px; + height: 100px; + border: 1px solid black; + display: flex; + justify-content: center; + align-items: center; + font-size: 24px; + cursor: pointer; } -.card { - padding: 2em; +.board-cell:hover { + background-color: #f0f0f0; } -.read-the-docs { - color: #888; -} +.board-cell.fading { + color: red; +} \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index f67355a..838919a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,33 +1,159 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from '/vite.svg' +import { useEffect, useState } from 'react' import './App.css' +import React from 'react'; + +type Symbol = 'X' | 'O'; + +type CellState = { + symbol?: Symbol; + isFading?: boolean; +}; + +type RowState = CellState[]; +type BoardState = RowState[]; + +type CellClickHandler = (row: number, col: number) => boolean; +type CellProps = { + row: number; + col: number; + state: CellState; + onClick?: CellClickHandler; +} + + +function Cell({row, col, state: {symbol, isFading}, onClick}: CellProps) { + const onClick1 = (() => { + onClick && onClick(row, col) + }); + + return
{symbol}
; +} + +type RowProps = { + index: number, state: RowState + onCellClick: CellClickHandler; +}; + +function Row({state, index, onCellClick}: RowProps) { + return ( +
+ {state.map((cell: CellState, i) => )} +
+ ) +} + +type BoardProps = { + state: BoardState; + onCellClick: CellClickHandler; +} + +function Board({state, onCellClick}: BoardProps) { + return ( +
+ {state.map((row, i) => )} +
+ ) + +} + +function findWinner(state: BoardState): Symbol | undefined { + // check rows + if (state[0]?.[0]?.symbol === state[0]?.[1]?.symbol && state[0]?.[1]?.symbol === state[0]?.[2]?.symbol) + return state[0]?.[0]?.symbol; + + if (state[1]?.[0]?.symbol === state[1]?.[1]?.symbol && state[1]?.[1]?.symbol === state[1]?.[2]?.symbol) + return state[1]?.[0]?.symbol; + + if (state[2]?.[0]?.symbol === state[2]?.[1]?.symbol && state[2]?.[1]?.symbol === state[2]?.[2]?.symbol) + return state[2]?.[0]?.symbol; + + // check columns + if (state[0]?.[0]?.symbol === state[1]?.[0]?.symbol && state[1]?.[0]?.symbol === state[2]?.[0]?.symbol) + return state[0]?.[0]?.symbol; + + if (state[0]?.[1]?.symbol === state[1]?.[1]?.symbol && state[1]?.[1]?.symbol === state[2]?.[1]?.symbol) + return state[0]?.[1]?.symbol; + + if (state[0]?.[2]?.symbol === state[1]?.[2]?.symbol && state[1]?.[2]?.symbol === state[2]?.[2]?.symbol) + return state[0]?.[2]?.symbol; + + // check diagonals + if (state[0]?.[0]?.symbol === state[1]?.[1]?.symbol && state[1]?.[1]?.symbol === state[2]?.[2]?.symbol) + return state[0]?.[0]?.symbol; + + if (state[0]?.[2]?.symbol === state[1]?.[1]?.symbol && state[1]?.[1]?.symbol === state[2]?.[0]?.symbol) + return state[0]?.[2]?.symbol; + + return undefined; +} function App() { - const [count, setCount] = useState(0) + const [nextPlayer, setNextPlayer] = useState('O') + + const [state, updateState] = useState([ + [{}, {}, {}], + [{}, {}, {}], + [{}, {}, {}], + ]) + + const [latestCells, updateLatestCells] = useState<{ row: number, col: number }[]>([]); + const [winner, updateWinner] = useState(undefined); + + const onClick = (rowNumber: number, colNumber: number): boolean => { + const targetCell = state[rowNumber]?.[colNumber]; + if (winner || !targetCell || targetCell.symbol) { + return false; + } + + updateState((prevState) => prevState.map((row, r) => row.map((cell, c) => { + if (r === rowNumber && c === colNumber) { + return {...cell, symbol: nextPlayer}; + } + if (latestCells.length >= 5 && latestCells[0] && latestCells[0].row === r && latestCells[0].col === c) { + return {...cell, isFading: true}; + } + return cell.isFading ? {symbol: undefined, isFading: false} : cell; + }))); + + updateLatestCells((prevState) => { + console.info('updating latestCells', {rowNumber, colNumber}); + const appended = [...prevState, {row: rowNumber, col: colNumber}]; + if (appended.length >= 6) { + return appended.slice(1); + } + return appended; + }) + setNextPlayer(nextPlayer === 'X' ? 'O' : 'X'); + return false; + }; + + useEffect(() => { + const winner = findWinner(state); + if (winner) { + updateWinner(winner); + } + }, [state]); return ( <> -
- - Vite logo - - - React logo - + +
+ {winner ?

Winner is {winner}

:

Next player is {nextPlayer}

} + {latestCells.length === 0 && (

Click any cell to start

)} + {winner &&

{ + + updateState((prevState) => prevState.map((row, r) => row.map((cell, c) => { + return {}; + }))); + + updateLatestCells([]); + + setNextPlayer('O'); + updateWinner(undefined); + }}>Click to restart

}
-

Vite + React

-
- -

- Edit src/App.jsx and save to test HMR -

-
-

- Click on the Vite and React logos to learn more -

) } From ba260f2895d6a60194c8ede3dedf734153753f55 Mon Sep 17 00:00:00 2001 From: WanCW Date: Mon, 28 Apr 2025 16:50:49 +0800 Subject: [PATCH 2/2] refactor: merge all game state into 1 state variable --- src/App.css | 13 ++- src/App.tsx | 247 +++++++++++++++++++++++++++++----------------------- 2 files changed, 150 insertions(+), 110 deletions(-) diff --git a/src/App.css b/src/App.css index b7b6a48..3bfd7ec 100644 --- a/src/App.css +++ b/src/App.css @@ -9,8 +9,11 @@ display: flex; flex-direction: column; gap: 10px; + margin-top: 1em; +} +.messages { + line-height: 1em; } - .board-row { display: flex; flex-direction: row; @@ -28,6 +31,14 @@ cursor: pointer; } +.game-set .board-cell { + cursor: not-allowed; +} + +.game-set .board-cell:hover { + background-color: unset; +} + .board-cell:hover { background-color: #f0f0f0; } diff --git a/src/App.tsx b/src/App.tsx index 838919a..ff66e0b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,5 @@ -import { useEffect, useState } from 'react' +import React, { useEffect, useState } from 'react' import './App.css' -import React from 'react'; type Symbol = 'X' | 'O'; @@ -12,147 +11,177 @@ type CellState = { type RowState = CellState[]; type BoardState = RowState[]; -type CellClickHandler = (row: number, col: number) => boolean; -type CellProps = { - row: number; - col: number; - state: CellState; - onClick?: CellClickHandler; -} +type CellClickHandler = (row: number, col: number) => void; +type CellProps = CellState & { rowIndex: number, columnIndex: number, onClick?: CellClickHandler }; - -function Cell({row, col, state: {symbol, isFading}, onClick}: CellProps) { - const onClick1 = (() => { - onClick && onClick(row, col) - }); - - return
{symbol}
; +function Cell({ rowIndex, columnIndex, symbol, isFading, onClick }: CellProps) { + return
onClick && onClick(rowIndex, columnIndex)}>{symbol}
; } type RowProps = { - index: number, state: RowState + index: number, + state: RowState onCellClick: CellClickHandler; }; -function Row({state, index, onCellClick}: RowProps) { +function Row({ state, index, onCellClick }: RowProps) { return (
- {state.map((cell: CellState, i) => )} + {state.map((cellState: CellState, i) => )}
) } type BoardProps = { state: BoardState; + hasWinner?: boolean; onCellClick: CellClickHandler; } -function Board({state, onCellClick}: BoardProps) { +function Board({ state, hasWinner, onCellClick }: BoardProps) { return ( -
+
{state.map((row, i) => )}
) - } -function findWinner(state: BoardState): Symbol | undefined { - // check rows - if (state[0]?.[0]?.symbol === state[0]?.[1]?.symbol && state[0]?.[1]?.symbol === state[0]?.[2]?.symbol) - return state[0]?.[0]?.symbol; +function hasWin(board: BoardState, size: number, row: number, column: number): boolean { + const targetCell = board[row]?.[column]; + if (!targetCell) { return false; } - if (state[1]?.[0]?.symbol === state[1]?.[1]?.symbol && state[1]?.[1]?.symbol === state[1]?.[2]?.symbol) - return state[1]?.[0]?.symbol; - - if (state[2]?.[0]?.symbol === state[2]?.[1]?.symbol && state[2]?.[1]?.symbol === state[2]?.[2]?.symbol) - return state[2]?.[0]?.symbol; - - // check columns - if (state[0]?.[0]?.symbol === state[1]?.[0]?.symbol && state[1]?.[0]?.symbol === state[2]?.[0]?.symbol) - return state[0]?.[0]?.symbol; - - if (state[0]?.[1]?.symbol === state[1]?.[1]?.symbol && state[1]?.[1]?.symbol === state[2]?.[1]?.symbol) - return state[0]?.[1]?.symbol; - - if (state[0]?.[2]?.symbol === state[1]?.[2]?.symbol && state[1]?.[2]?.symbol === state[2]?.[2]?.symbol) - return state[0]?.[2]?.symbol; - - // check diagonals - if (state[0]?.[0]?.symbol === state[1]?.[1]?.symbol && state[1]?.[1]?.symbol === state[2]?.[2]?.symbol) - return state[0]?.[0]?.symbol; - - if (state[0]?.[2]?.symbol === state[1]?.[1]?.symbol && state[1]?.[1]?.symbol === state[2]?.[0]?.symbol) - return state[0]?.[2]?.symbol; - - return undefined; -} - -function App() { - const [nextPlayer, setNextPlayer] = useState('O') - - const [state, updateState] = useState([ - [{}, {}, {}], - [{}, {}, {}], - [{}, {}, {}], - ]) - - const [latestCells, updateLatestCells] = useState<{ row: number, col: number }[]>([]); - const [winner, updateWinner] = useState(undefined); - - const onClick = (rowNumber: number, colNumber: number): boolean => { - const targetCell = state[rowNumber]?.[colNumber]; - if (winner || !targetCell || targetCell.symbol) { - return false; + let win = true; + for (let r = 0; r < size; r++) { + if (board[r]?.[column]?.symbol !== targetCell.symbol) { + win = false; + break; } + } + if (win) { + return true; + } - updateState((prevState) => prevState.map((row, r) => row.map((cell, c) => { - if (r === rowNumber && c === colNumber) { - return {...cell, symbol: nextPlayer}; - } - if (latestCells.length >= 5 && latestCells[0] && latestCells[0].row === r && latestCells[0].col === c) { - return {...cell, isFading: true}; - } - return cell.isFading ? {symbol: undefined, isFading: false} : cell; - }))); - - updateLatestCells((prevState) => { - console.info('updating latestCells', {rowNumber, colNumber}); - const appended = [...prevState, {row: rowNumber, col: colNumber}]; - if (appended.length >= 6) { - return appended.slice(1); - } - return appended; - }) - setNextPlayer(nextPlayer === 'X' ? 'O' : 'X'); + win = true; + for (let c = 0; c < size; c++) { + if (board[row]?.[c]?.symbol !== targetCell.symbol) { + win = false; + break; + } + } + if (win) { + return true; + } + if (row !== column) { return false; - }; + } - useEffect(() => { - const winner = findWinner(state); - if (winner) { - updateWinner(winner); + + win = true; + for (let i = 0; i < size; i++) { + if (board[i]?.[i]?.symbol !== targetCell.symbol) { + win = false; + break; } - }, [state]); + } + + return win; +} + +type MoveRecord = { + row: number; + col: number; + player: Symbol; +}; + +type GameState = { + board: BoardState; + latestMoves:MoveRecord[]; + currentPlayer: Symbol; + winner?: Symbol; +} + +function useBoardState(size: number = 3): [GameState, { reset: ()=> void; playOn: (row: number, col: number) => boolean } ] { + const [state, updateState] = useState({ + board: Array(size).fill(Array(size).fill({})), + latestMoves: [], + currentPlayer: 'O', + }); + + return [ + state, + { + reset: () => updateState({ + board: Array(size).fill(Array(size).fill({})), + latestMoves: [], + currentPlayer: 'O', + }), + + playOn: (row: number, col: number) => { + if (state.winner) { + return false; + } + + const targetCell = state.board[row]?.[col]; + if (!targetCell || targetCell.symbol) { + return false; + } + + const cellToFading = state.latestMoves.length >= (2*size-1) ? state.latestMoves[0] : undefined; + const fadingRow = cellToFading?.row; + const fadingCol = cellToFading?.col; + + const nextState: GameState = { + board: state.board.map((oldRow, r) => oldRow.map((oldCell, c)=> { + return { + ...(oldCell.isFading ? {} : oldCell), + ...(r===row && c===col ? { symbol: state.currentPlayer } : undefined), + ...(r===fadingRow && c===fadingCol ? { isFading: true} : undefined), + }; + })), + + latestMoves: [ + ...state.latestMoves, + { + row, + col, + player: state.currentPlayer, + } + ].slice(-2*size+1), + + currentPlayer: state.currentPlayer === 'X' ? 'O' : 'X' as Symbol, + }; + + if (hasWin(nextState.board, size, row, col)) { + nextState.winner = state.currentPlayer; + } + updateState(nextState); + + return true; + }, + }, + ]; +} +function App() { + const [state, { playOn, reset: resetGame }] = useBoardState(5); + + const nextPlayer = state.currentPlayer; + const latestCells = state.latestMoves; + const winner = state.winner; return ( <> - +
- {winner ?

Winner is {winner}

:

Next player is {nextPlayer}

} - {latestCells.length === 0 && (

Click any cell to start

)} - {winner &&

{ - - updateState((prevState) => prevState.map((row, r) => row.map((cell, c) => { - return {}; - }))); - - updateLatestCells([]); - - setNextPlayer('O'); - updateWinner(undefined); - }}>Click to restart

} +

+ {winner ? `Winner is ${winner}` : (<>Next player is {nextPlayer})} + {latestCells.length === 0 && ("Click any cell to start")} + {winner && } +

)