From d186a96f0d2a389a5b251addbf1cac9b2328c12a Mon Sep 17 00:00:00 2001 From: Lang <7system7@gmail.com> Date: Fri, 10 Apr 2026 19:09:05 +0200 Subject: [PATCH] chg: dev: more, massive refactor for front-end #4 --- assets/js/mine-seeker/MineSeeker.jsx | 2 +- .../js/mine-seeker/components/GameBoard.jsx | 11 +- .../components/grid/GridControl.jsx | 15 +- .../mine-seeker/components/grid/GridField.jsx | 11 +- .../js/mine-seeker/components/user/User.jsx | 28 ++- .../components/user/UserControl.jsx | 4 +- .../js/mine-seeker/contexts/GameContext.jsx | 230 +---------------- .../js/mine-seeker/contexts/GameProvider.jsx | 234 ++++++++++++++++++ assets/js/mine-seeker/hooks/useGameRefs.jsx | 26 ++ assets/js/mine-seeker/hooks/useGameState.jsx | 36 +++ ...rverComm.js => useServerCommunication.jsx} | 65 +++-- .../{constants.js => utils/constants.jsx} | 17 +- eslint.config.mjs | 19 +- 13 files changed, 402 insertions(+), 296 deletions(-) create mode 100644 assets/js/mine-seeker/contexts/GameProvider.jsx create mode 100644 assets/js/mine-seeker/hooks/useGameRefs.jsx create mode 100644 assets/js/mine-seeker/hooks/useGameState.jsx rename assets/js/mine-seeker/hooks/{useServerComm.js => useServerCommunication.jsx} (77%) rename assets/js/mine-seeker/{constants.js => utils/constants.jsx} (74%) diff --git a/assets/js/mine-seeker/MineSeeker.jsx b/assets/js/mine-seeker/MineSeeker.jsx index 13be55f..efeef6f 100644 --- a/assets/js/mine-seeker/MineSeeker.jsx +++ b/assets/js/mine-seeker/MineSeeker.jsx @@ -1,6 +1,6 @@ import React, { useRef } from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { GameProvider } from './contexts/GameContext'; +import { GameProvider } from './contexts/GameProvider'; import { GameBoard } from './components/GameBoard'; const queryClient = new QueryClient(); diff --git a/assets/js/mine-seeker/components/GameBoard.jsx b/assets/js/mine-seeker/components/GameBoard.jsx index 5b060f6..4afc5d3 100644 --- a/assets/js/mine-seeker/components/GameBoard.jsx +++ b/assets/js/mine-seeker/components/GameBoard.jsx @@ -9,12 +9,12 @@ import React from 'react'; import { useGame } from '../contexts/GameContext'; -import useServerComm from '../hooks/useServerComm'; +import useServerCommunication from '../hooks/useServerCommunication'; import GridControl from './grid/GridControl'; export const GameBoard = ({ gameAssoc, gameInherited, isEnvDev }) => { const { gridReady } = useGame(); - const { onClick, resign } = useServerComm(gameAssoc, gameInherited, isEnvDev); + const { onClick, resign } = useServerCommunication(gameAssoc, gameInherited, isEnvDev); if (!gridReady) { return ( @@ -24,5 +24,10 @@ export const GameBoard = ({ gameAssoc, gameInherited, isEnvDev }) => { ); } - return ; + return ( + + ); }; diff --git a/assets/js/mine-seeker/components/grid/GridControl.jsx b/assets/js/mine-seeker/components/grid/GridControl.jsx index 4f95d1e..6b1d7de 100644 --- a/assets/js/mine-seeker/components/grid/GridControl.jsx +++ b/assets/js/mine-seeker/components/grid/GridControl.jsx @@ -2,13 +2,13 @@ import React from 'react'; import { useGame } from '../../contexts/GameContext'; import GridField from './GridField'; import UserControl from '../user/UserControl'; -import { BOMB_SYMBOLS, bombRadius } from '../../constants'; +import { BOMB_SYMBOLS, bombRadius } from '../../utils/constants'; const GridControl = ({ onClick, resign }) => { const { overlay, overlayTitle, overlaySubTitle, - webPlayer, activePlayer, mines, foundMines, bombSelected, - red, blue, cells, setCells, onBombToggle, + webPlayer, activePlayer, bombSelected, + cells, setCells, } = useGame(); const handleHover = (row, col) => { @@ -39,13 +39,6 @@ const GridControl = ({ onClick, resign }) => {
@@ -58,7 +51,7 @@ const GridControl = ({ onClick, resign }) => { onClick={() => onClick([r, c])} onMouseEnter={() => handleHover(r, c)} /> - )) + )), )}
diff --git a/assets/js/mine-seeker/components/grid/GridField.jsx b/assets/js/mine-seeker/components/grid/GridField.jsx index a22896e..1986c5b 100644 --- a/assets/js/mine-seeker/components/grid/GridField.jsx +++ b/assets/js/mine-seeker/components/grid/GridField.jsx @@ -1,12 +1,12 @@ import React, { memo } from 'react'; -import { IMG } from '../../constants'; +import { IMAGES } from '../../utils/constants'; const bombSrc = area => { if (null === area) return null; const vert = ['left', 'center', 'right'][area[0]] ?? null; const hor = ['top', 'middle', 'bottom'][area[1]] ?? null; - if (null === vert || null === hor) return IMG + 'bg-bomb-empty-outbg.png'; - return `${IMG}bg-bomb-${hor}-${vert}-outbg.png`; + if (null === vert || null === hor) return IMAGES.bombEmpty; + return IMAGES.bombPos(hor, vert); }; const GridField = memo(function GridField({ cell, onClick, onMouseEnter }) { @@ -28,13 +28,12 @@ const GridField = memo(function GridField({ cell, onClick, onMouseEnter }) { const bSrc = bombSrc(bombTargetArea); const showLast = lastClickedRed || lastClickedBlue; const lastClass = 'field-' + (lastClickedRed ? 'red' : 'blue') + '-last last-clicked'; - const lastSrc = lastClickedRed ? IMG + 'bg-last-red-outbg.png' : IMG + 'bg-last-blue-outbg.png'; return (
- + {bSrc && } - {showLast && } + {showLast && }
{inner}
diff --git a/assets/js/mine-seeker/components/user/User.jsx b/assets/js/mine-seeker/components/user/User.jsx index eb55adc..5411933 100644 --- a/assets/js/mine-seeker/components/user/User.jsx +++ b/assets/js/mine-seeker/components/user/User.jsx @@ -1,34 +1,38 @@ import React, { memo } from 'react'; - -const SRC = '/images/'; +import { IMAGES } from '../../utils/constants'; const User = memo(function User({ - color, webPlayer, - name, desc, active, mines, haveBomb, enabledBomb, + color, + webPlayer, + name, + desc, + active, + mines, + haveBomb, + enabledBomb, onClickBombSelector, }) { const buzzClass = 'bomb-container' + (active && color === webPlayer && haveBomb && enabledBomb ? ' buzz' : ''); - const bombImg = haveBomb - ? SRC + (enabledBomb && active ? 'bg-bomb-outbg.png' : 'bg-bomb-disabled-outbg.png') - : SRC + 'bg-bomb-exploded-outbg.png'; - return (
{color}
- {active && } - + {active && } +
{name}
{desc}
- +
{mines}
-
+
+ {haveBomb && } + {!haveBomb && } +
diff --git a/assets/js/mine-seeker/components/user/UserControl.jsx b/assets/js/mine-seeker/components/user/UserControl.jsx index 3b591b6..4c3cbb8 100644 --- a/assets/js/mine-seeker/components/user/UserControl.jsx +++ b/assets/js/mine-seeker/components/user/UserControl.jsx @@ -1,7 +1,9 @@ import React from 'react'; +import { useGame } from '../../contexts/GameContext'; import User from './User'; -const UserControl = ({ webPlayer, activePlayer, mines, foundMines, red, blue, onBombToggle, resign }) => { +const UserControl = ({ resign }) => { + const { webPlayer, activePlayer, mines, foundMines, red, blue, onBombToggle } = useGame(); const activeColor = activePlayer ? 'blue' : 'red'; const resignClass = 'resign' + (activeColor !== webPlayer ? ' disabled' : ''); const minesClass = 'active-mines' + (foundMines ? ' found-mine' : ''); diff --git a/assets/js/mine-seeker/contexts/GameContext.jsx b/assets/js/mine-seeker/contexts/GameContext.jsx index 3c8f4a3..b8ac5a4 100644 --- a/assets/js/mine-seeker/contexts/GameContext.jsx +++ b/assets/js/mine-seeker/contexts/GameContext.jsx @@ -1,233 +1,7 @@ -import React, { createContext, useContext, useRef, useState } from 'react'; -import { Howl } from 'howler'; -import { IMG, PLAYER_DEF, DESC, patchCells, initCells } from '../constants'; - -// ── Context ────────────────────────────────────────────────────────────────── +import { createContext, useContext } from 'react'; const GameContext = createContext(null); export const useGame = () => useContext(GameContext); -// ── Provider ───────────────────────────────────────────────────────────────── - -export const GameProvider = ({ children }) => { - // Refs that are read inside async callbacks (stay in sync with state via sync* helpers) - const webPlayerRef = useRef(null); - const activePlayerRef = useRef(false); - const bombSelectedRef = useRef(false); - const connectionLostRef = useRef(false); - const redRef = useRef({ ...PLAYER_DEF }); - const blueRef = useRef({ ...PLAYER_DEF }); - const lastClickedRef = useRef({ red: null, blue: null }); - const endRef = useRef(false); - - const sounds = useRef({ - click: new Howl({ src: ['/sound/click.mp3'] }), - bomb: new Howl({ src: ['/sound/bomb.mp3'] }), - mine: new Howl({ src: ['/sound/mine.mp3'] }), - warning: new Howl({ src: ['/sound/warning.mp3'] }), - won: new Howl({ src: ['/sound/won.mp3'] }), - }); - - // Display state - const [webPlayer, setWebPlayer] = useState(null); - const [activePlayer, setActivePlayer] = useState(false); - const [overlay, setOverlay] = useState(true); - const [overlayTitle, setOverlayTitle] = useState(''); - const [overlaySubTitle, setOverlaySubTitle] = useState(''); - const [mines, setMines] = useState(51); - const [bombSelected, setBombSelected] = useState(false); - const [foundMines, setFoundMines] = useState(false); - const [red, setRed] = useState({ ...PLAYER_DEF }); - const [blue, setBlue] = useState({ ...PLAYER_DEF }); - const [cells, setCells] = useState(initCells); - const [gridReady, setGridReady] = useState(false); - const [connectionLost, setConnectionLost] = useState(false); - - // ── Sync helpers (keep ref + state in lockstep) ────────────────────────── - const syncWebPlayer = v => { - webPlayerRef.current = v; - setWebPlayer(v); - }; - const syncActivePlayer = v => { - activePlayerRef.current = v; - setActivePlayer(v); - }; - const syncBombSelected = v => { - bombSelectedRef.current = v; - setBombSelected(v); - }; - const syncConnLost = v => { - connectionLostRef.current = v; - setConnectionLost(v); - }; - const syncRed = fn => { - const n = fn(redRef.current); - redRef.current = n; - setRed(n); - }; - const syncBlue = fn => { - const n = fn(blueRef.current); - blueRef.current = n; - setBlue(n); - }; - - // ── Overlay ─────────────────────────────────────────────────────────────── - const showOverlay = (title, sub) => { - setOverlay(true); - setOverlayTitle(title); - setOverlaySubTitle(sub); - }; - - const hideOverlay = () => setOverlay(false); - - // ── Cell helpers ────────────────────────────────────────────────────────── - const applyRevealedCell = (cell, player, isMainCell = false) => { - const { row, col, value } = cell; - setCells(prev => { - if (prev[row][col].active) return prev; - const patch = 'm' === value - ? { currentImage: `${IMG}bg-flag-${player}-outbg.png`, currentObj: 'm', active: true } - : { currentImage: value, currentObj: value, active: true }; - if (isMainCell) { - patch.lastClickedRed = 'red' === player; - patch.lastClickedBlue = 'blue' === player; - } - return patchCells(prev, [{ row, col, ...patch }]); - }); - if (isMainCell) lastClickedRef.current = { ...lastClickedRef.current, [player]: [row, col] }; - }; - - const showLeftMines = (leftMines = []) => { - if (!leftMines.length) return; - setCells(prev => { - const patches = leftMines - .filter(({ row, col }) => !prev[row][col].active) - .map(({ row, col }) => ({ row, col, currentImage: IMG + 'bg-left-mine-outbg.png' })); - return patches.length ? patchCells(prev, patches) : prev; - }); - }; - - // ── Game logic ──────────────────────────────────────────────────────────── - const changePlayer = () => { - const wasColor = activePlayerRef.current ? 'blue' : 'red'; - const nextColor = activePlayerRef.current ? 'red' : 'blue'; - const nextVal = activePlayerRef.current ? 0 : 1; - const desc = wasColor === webPlayerRef.current ? DESC.buddy : DESC.you; - - syncActivePlayer(nextVal); - syncRed(p => ({ ...p, active: 'red' === nextColor, desc: 'red' === nextColor ? desc : '' })); - syncBlue(p => ({ ...p, active: 'blue' === nextColor, desc: 'blue' === nextColor ? desc : '' })); - }; - - const applyStep = stepData => { - const { player, bomb: isBomb, minesFound = 0, revealedCells = [], redPoints: rp, bluePoints: bp } = stepData; - - if (isBomb) { - sounds.current.bomb.play(); - } else if (0 < minesFound) { - const cur = ('red' === player ? redRef : blueRef).current.mines; - sounds.current[20 < cur + minesFound ? 'warning' : 'mine'].play(); - } else { - sounds.current.click.play(); - } - - const lc = lastClickedRef.current[player]; - setCells(prev => { - let next = prev; - if (lc) next = patchCells(next, [{ row: lc[0], col: lc[1], lastClickedRed: false, lastClickedBlue: false }]); - - revealedCells.forEach(({ row, col, value }, idx) => { - if (next[row][col].active) return; - const patch = 'm' === value - ? { currentImage: `${IMG}bg-flag-${player}-outbg.png`, currentObj: 'm', active: true } - : { currentImage: value, currentObj: value, active: true }; - if (0 === idx) { - patch.lastClickedRed = 'red' === player; - patch.lastClickedBlue = 'blue' === player; - } - next = patchCells(next, [{ row, col, ...patch }]); - }); - - if (isBomb) next = next.map(r => r.map(c => null !== c.bombTargetArea ? { ...c, bombTargetArea: null } : c)); - return next; - }); - - if (0 < revealedCells.length) { - lastClickedRef.current = { ...lastClickedRef.current, [player]: [revealedCells[0].row, revealedCells[0].col] }; - } - - if (0 < minesFound) { - setMines(51 - rp - bp); - setFoundMines(true); - setTimeout(() => setFoundMines(false), 500); - syncRed(p => ({ ...p, mines: 'red' === player ? rp : p.mines })); - syncBlue(p => ({ ...p, mines: 'blue' === player ? bp : p.mines })); - } - - syncRed(p => ({ ...p, enabledBomb: rp <= bp })); - syncBlue(p => ({ ...p, enabledBomb: bp <= rp })); - - if (isBomb || 0 === minesFound) changePlayer(); - - if (isBomb) { - syncBombSelected(false); - syncRed(p => 'red' === player ? { ...p, haveBomb: false } : p); - syncBlue(p => 'blue' === player ? { ...p, haveBomb: false } : p); - } - }; - - const makeGameEndIfItEnds = (bluePoints, redPoints, resign = false, leftMines = []) => { - const redWins = 25 < redPoints; - const blueWins = 25 < bluePoints; - - if (redWins || blueWins || resign) { - sounds.current.won.play(); - - if (!resign) showOverlay((redWins ? 'Red' : 'Blue') + ' wins the game!', 'Play again!'); - - showLeftMines(leftMines); - syncActivePlayer(false); - syncRed(p => ({ ...p, desc: '' })); - syncBlue(p => ({ ...p, desc: '' })); - } - }; - - const resignProcess = color => { - const wp = webPlayerRef.current; - showOverlay( - color === wp ? 'You have been give up' : 'Your opponent has been resigned', - color === wp ? 'You LOSE!' : 'You WIN!', - ); - endRef.current = true; - makeGameEndIfItEnds(0, 0, true); - }; - - const onBombToggle = () => { - const next = !bombSelectedRef.current; - syncBombSelected(next); - if (!next) { - setCells(prev => prev.map(r => r.map(c => null !== c.bombTargetArea ? { ...c, bombTargetArea: null } : c))); - } - }; - - // ── Context value ───────────────────────────────────────────────────────── - const value = { - // State (for rendering) - webPlayer, activePlayer, overlay, overlayTitle, overlaySubTitle, - mines, bombSelected, foundMines, red, blue, cells, gridReady, connectionLost, - // Refs (needed by useServerComm for async-safe reads) - webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, endRef, - // Setters needed by useServerComm - setCells, setGridReady, - syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue, - // Game logic called by useServerComm - showOverlay, hideOverlay, - applyRevealedCell, applyStep, - makeGameEndIfItEnds, resignProcess, - // UI action (bomb toggle is pure state, no server call) - onBombToggle, - }; - - return {children}; -}; +export default GameContext; diff --git a/assets/js/mine-seeker/contexts/GameProvider.jsx b/assets/js/mine-seeker/contexts/GameProvider.jsx new file mode 100644 index 0000000..d13bd1d --- /dev/null +++ b/assets/js/mine-seeker/contexts/GameProvider.jsx @@ -0,0 +1,234 @@ +import React, { useRef } from 'react'; +import { Howl } from 'howler'; +import GameContext from './GameContext'; +import useGameRefs from '../hooks/useGameRefs'; +import useGameState from '../hooks/useGameState'; +import { DESC, IMAGES, patchCells } from '../utils/constants'; + +export const GameProvider = ({ children }) => { + const { + webPlayerRef, + activePlayerRef, + bombSelectedRef, + connectionLostRef, + redRef, + blueRef, + lastClickedRef, + endRef, + } = useGameRefs(); + + const { + webPlayer, setWebPlayer, + activePlayer, setActivePlayer, + overlay, setOverlay, + overlayTitle, setOverlayTitle, + overlaySubTitle, setOverlaySubTitle, + mines, setMines, + bombSelected, setBombSelected, + foundMines, setFoundMines, + red, setRed, + blue, setBlue, + cells, setCells, + gridReady, setGridReady, + connectionLost, setConnectionLost, + } = useGameState(); + + const sounds = useRef({ + click: new Howl({ src: ['/sound/click.mp3'] }), + bomb: new Howl({ src: ['/sound/bomb.mp3'] }), + mine: new Howl({ src: ['/sound/mine.mp3'] }), + warning: new Howl({ src: ['/sound/warning.mp3'] }), + won: new Howl({ src: ['/sound/won.mp3'] }), + }); + + // ── Sync helpers (keep ref + state in lockstep) ────────────────────────── + const syncWebPlayer = v => { + webPlayerRef.current = v; + setWebPlayer(v); + }; + const syncActivePlayer = v => { + activePlayerRef.current = v; + setActivePlayer(v); + }; + const syncBombSelected = v => { + bombSelectedRef.current = v; + setBombSelected(v); + }; + const syncConnLost = v => { + connectionLostRef.current = v; + setConnectionLost(v); + }; + const syncRed = fn => { + const n = fn(redRef.current); + redRef.current = n; + setRed(n); + }; + const syncBlue = fn => { + const n = fn(blueRef.current); + blueRef.current = n; + setBlue(n); + }; + + // ── Overlay ─────────────────────────────────────────────────────────────── + const showOverlay = (title, sub) => { + setOverlay(true); + setOverlayTitle(title); + setOverlaySubTitle(sub); + }; + + const hideOverlay = () => setOverlay(false); + + // ── Cell helpers ────────────────────────────────────────────────────────── + const applyRevealedCell = (cell, player, isMainCell = false) => { + const { row, col, value } = cell; + setCells(prev => { + if (prev[row][col].active) return prev; + const patch = 'm' === value + ? { currentImage: IMAGES.flag(player), currentObj: 'm', active: true } + : { currentImage: value, currentObj: value, active: true }; + if (isMainCell) { + patch.lastClickedRed = 'red' === player; + patch.lastClickedBlue = 'blue' === player; + } + return patchCells(prev, [{ row, col, ...patch }]); + }); + if (isMainCell) lastClickedRef.current = { ...lastClickedRef.current, [player]: [row, col] }; + }; + + const showLeftMines = (leftMines = []) => { + if (!leftMines.length) return; + setCells(prev => { + const patches = leftMines + .filter(({ row, col }) => !prev[row][col].active) + .map(({ row, col }) => ({ row, col, currentImage: IMAGES.leftMine })); + return patches.length ? patchCells(prev, patches) : prev; + }); + }; + + // ── Game logic ──────────────────────────────────────────────────────────── + const changePlayer = () => { + const wasColor = activePlayerRef.current ? 'blue' : 'red'; + const nextColor = activePlayerRef.current ? 'red' : 'blue'; + const nextVal = activePlayerRef.current ? 0 : 1; + const desc = wasColor === webPlayerRef.current ? DESC.buddy : DESC.you; + + syncActivePlayer(nextVal); + syncRed(p => ({ ...p, active: 'red' === nextColor, desc: 'red' === nextColor ? desc : '' })); + syncBlue(p => ({ ...p, active: 'blue' === nextColor, desc: 'blue' === nextColor ? desc : '' })); + }; + + const applyStep = stepData => { + const { player, bomb: isBomb, minesFound = 0, revealedCells = [], redPoints: rp, bluePoints: bp } = stepData; + + if (isBomb) { + sounds.current.bomb.play(); + } else if (0 < minesFound) { + const cur = ('red' === player ? redRef : blueRef).current.mines; + sounds.current[20 < cur + minesFound ? 'warning' : 'mine'].play(); + } else { + sounds.current.click.play(); + } + + const lc = lastClickedRef.current[player]; + setCells(prev => { + let next = prev; + if (lc) next = patchCells(next, [{ row: lc[0], col: lc[1], lastClickedRed: false, lastClickedBlue: false }]); + + revealedCells.forEach(({ row, col, value }, idx) => { + if (next[row][col].active) return; + const patch = 'm' === value + ? { currentImage: IMAGES.flag(player), currentObj: 'm', active: true } + : { currentImage: value, currentObj: value, active: true }; + if (0 === idx) { + patch.lastClickedRed = 'red' === player; + patch.lastClickedBlue = 'blue' === player; + } + next = patchCells(next, [{ row, col, ...patch }]); + }); + + if (isBomb) next = next.map(r => r.map(c => null !== c.bombTargetArea ? { ...c, bombTargetArea: null } : c)); + return next; + }); + + if (0 < revealedCells.length) { + lastClickedRef.current = { ...lastClickedRef.current, [player]: [revealedCells[0].row, revealedCells[0].col] }; + } + + if (0 < minesFound) { + setMines(51 - rp - bp); + setFoundMines(true); + setTimeout(() => setFoundMines(false), 500); + syncRed(p => ({ ...p, mines: 'red' === player ? rp : p.mines })); + syncBlue(p => ({ ...p, mines: 'blue' === player ? bp : p.mines })); + } + + syncRed(p => ({ ...p, enabledBomb: rp <= bp })); + syncBlue(p => ({ ...p, enabledBomb: bp <= rp })); + + if (isBomb || 0 === minesFound) changePlayer(); + + if (isBomb) { + syncBombSelected(false); + syncRed(p => 'red' === player ? { ...p, haveBomb: false } : p); + syncBlue(p => 'blue' === player ? { ...p, haveBomb: false } : p); + } + }; + + const makeGameEndIfItEnds = (bluePoints, redPoints, resign = false, leftMines = []) => { + const redWins = 25 < redPoints; + const blueWins = 25 < bluePoints; + + if (redWins || blueWins || resign) { + sounds.current.won.play(); + + if (!resign) showOverlay((redWins ? 'Red' : 'Blue') + ' wins the game!', 'Play again!'); + + showLeftMines(leftMines); + syncActivePlayer(false); + syncRed(p => ({ ...p, desc: '' })); + syncBlue(p => ({ ...p, desc: '' })); + } + }; + + const resignProcess = color => { + const wp = webPlayerRef.current; + showOverlay( + color === wp ? 'You have been give up' : 'Your opponent has been resigned', + color === wp ? 'You LOSE!' : 'You WIN!', + ); + endRef.current = true; + makeGameEndIfItEnds(0, 0, true); + }; + + const onBombToggle = () => { + const next = !bombSelectedRef.current; + syncBombSelected(next); + if (!next) { + setCells(prev => prev.map(r => r.map(c => null !== c.bombTargetArea ? { ...c, bombTargetArea: null } : c))); + } + }; + + return ( + + {children} + + ); +}; diff --git a/assets/js/mine-seeker/hooks/useGameRefs.jsx b/assets/js/mine-seeker/hooks/useGameRefs.jsx new file mode 100644 index 0000000..6112f6f --- /dev/null +++ b/assets/js/mine-seeker/hooks/useGameRefs.jsx @@ -0,0 +1,26 @@ +import { useRef } from 'react'; +import { PLAYER_DEF } from '../utils/constants'; + +const useGameRefs = () => { + const webPlayerRef = useRef(null); + const activePlayerRef = useRef(false); + const bombSelectedRef = useRef(false); + const connectionLostRef = useRef(false); + const redRef = useRef({ ...PLAYER_DEF }); + const blueRef = useRef({ ...PLAYER_DEF }); + const lastClickedRef = useRef({ red: null, blue: null }); + const endRef = useRef(false); + + return { + webPlayerRef, + activePlayerRef, + bombSelectedRef, + connectionLostRef, + redRef, + blueRef, + lastClickedRef, + endRef, + }; +}; + +export default useGameRefs; diff --git a/assets/js/mine-seeker/hooks/useGameState.jsx b/assets/js/mine-seeker/hooks/useGameState.jsx new file mode 100644 index 0000000..74fc0f2 --- /dev/null +++ b/assets/js/mine-seeker/hooks/useGameState.jsx @@ -0,0 +1,36 @@ +import { useState } from 'react'; +import { initCells, PLAYER_DEF } from '../utils/constants'; + +const useGameState = () => { + const [webPlayer, setWebPlayer] = useState(null); + const [activePlayer, setActivePlayer] = useState(false); + const [overlay, setOverlay] = useState(true); + const [overlayTitle, setOverlayTitle] = useState(''); + const [overlaySubTitle, setOverlaySubTitle] = useState(''); + const [mines, setMines] = useState(51); + const [bombSelected, setBombSelected] = useState(false); + const [foundMines, setFoundMines] = useState(false); + const [red, setRed] = useState({ ...PLAYER_DEF }); + const [blue, setBlue] = useState({ ...PLAYER_DEF }); + const [cells, setCells] = useState(initCells); + const [gridReady, setGridReady] = useState(false); + const [connectionLost, setConnectionLost] = useState(false); + + return { + webPlayer, setWebPlayer, + activePlayer, setActivePlayer, + overlay, setOverlay, + overlayTitle, setOverlayTitle, + overlaySubTitle, setOverlaySubTitle, + mines, setMines, + bombSelected, setBombSelected, + foundMines, setFoundMines, + red, setRed, + blue, setBlue, + cells, setCells, + gridReady, setGridReady, + connectionLost, setConnectionLost, + }; +}; + +export default useGameState; diff --git a/assets/js/mine-seeker/hooks/useServerComm.js b/assets/js/mine-seeker/hooks/useServerCommunication.jsx similarity index 77% rename from assets/js/mine-seeker/hooks/useServerComm.js rename to assets/js/mine-seeker/hooks/useServerCommunication.jsx index 55731cc..1ead01c 100644 --- a/assets/js/mine-seeker/hooks/useServerComm.js +++ b/assets/js/mine-seeker/hooks/useServerCommunication.jsx @@ -1,26 +1,22 @@ import React, { useEffect, useRef } from 'react'; -import { useQuery, useMutation } from '@tanstack/react-query'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { useGame } from '../contexts/GameContext'; -import { DESC } from '../constants'; +import { DESC } from '../utils/constants'; -/** - * Handles all server communication: SSE (Mercure), REST calls, and the - * initialization lifecycle. Exposes only the UI-facing callbacks the - * component needs: onClick, resign. - */ -const useServerComm = (gameAssoc, gameInherited, isEnvDev) => { +/** Handles all server communication: SSE (Mercure), REST calls, and the initialization lifecycle. */ +const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => { const { - // Async-safe refs + /** Async-safe refs */ webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, endRef, - // State setters + /** State setters */ setGridReady, - // Sync helpers + /** Sync helpers */ syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue, - // Game logic + /** Game logic */ showOverlay, hideOverlay, applyRevealedCell, applyStep, makeGameEndIfItEnds, resignProcess, - // Current cells snapshot (for active-check in onClick) + /** Current cells snapshot (for active-check in onClick) */ cells, } = useGame(); @@ -28,7 +24,7 @@ const useServerComm = (gameAssoc, gameInherited, isEnvDev) => { const rpcUsersRef = useRef(null); const stepCacheRef = useRef([]); - // ── Helpers ─────────────────────────────────────────────────────────────── + /** HELPERS */ const correctGridSize = () => { let $f = $('#mine-wrapper .grid'); @@ -38,7 +34,7 @@ const useServerComm = (gameAssoc, gameInherited, isEnvDev) => { $('#mine-wrapper .grid .field-wrapper .field').height($f.width()).css('line-height', ($f.width() - 2) + 'px'); }; - // ── REST mutations / queries ────────────────────────────────────────────── + /** REST mutations / queries */ const connectQuery = useQuery({ queryKey: ['game-connect', gameAssoc], @@ -72,7 +68,7 @@ const useServerComm = (gameAssoc, gameInherited, isEnvDev) => { }).then(r => r.json()), }); - // ── Game-start helpers (triggered by server events) ─────────────────────── + /** Game-start helpers (triggered by server events) */ const wInit = (revealedCells = []) => { setGridReady(true); @@ -91,17 +87,20 @@ const useServerComm = (gameAssoc, gameInherited, isEnvDev) => { const makeGameStart = payload => { syncActivePlayer(1); - syncRed(p => ({ ...p, name: payload.users.red || payload.users.redAnon || p.name })); + syncRed(p => ({ + ...p, + name: payload.users.red || payload.users.redAnon || p.name, + })); syncBlue(p => ({ ...p, - name: payload.users.blue || payload.users.blueAnon || p.name, - desc: 'blue' === webPlayerRef.current ? DESC.you : DESC.buddy, + name: payload.users.blue || payload.users.blueAnon || p.name, + desc: 'blue' === webPlayerRef.current ? DESC.you : DESC.buddy, active: true, })); hideOverlay(); }; - // ── Mercure / SSE message handlers ─────────────────────────────────────── + /** Mercure / SSE message handlers */ const wSubscribe = (payload, rpcUsers = null) => { isEnvDev && console.info((payload.user ?? 'user') + ' subscribed'); @@ -174,17 +173,26 @@ const useServerComm = (gameAssoc, gameInherited, isEnvDev) => { es.onmessage = e => handleMercureMessage(JSON.parse(e.data)); es.onopen = () => { isEnvDev && console.info('SSE opened'); - if (connectionLostRef.current) { isEnvDev && console.info('SSE reconnected'); joinMutation.mutate(); } + if (connectionLostRef.current) { + isEnvDev && console.info('SSE reconnected'); + joinMutation.mutate(); + } + }; + es.onerror = () => { + isEnvDev && console.error('SSE error'); + syncConnLost(true); }; - es.onerror = () => { isEnvDev && console.error('SSE error'); syncConnLost(true); }; eventSourceRef.current = es; }; - // ── Initialization ──────────────────────────────────────────────────────── + /** Initialization */ useEffect(() => { (async () => { - if (connectionLostRef.current) { openEventSource(); return; } + if (connectionLostRef.current) { + openEventSource(); + return; + } try { if (gameInherited) { const serverData = await connectQuery.refetch().then(r => { @@ -219,7 +227,7 @@ const useServerComm = (gameAssoc, gameInherited, isEnvDev) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // ── UI-facing callbacks ─────────────────────────────────────────────────── + /** UI-facing callbacks */ const onClick = async coords => { const activeColor = activePlayerRef.current ? 'blue' : 'red'; @@ -230,7 +238,10 @@ const useServerComm = (gameAssoc, gameInherited, isEnvDev) => { const dataPack = { coords, player: activeColor, bomb: bombSelectedRef.current, resign: null }; - if (connectionLostRef.current) { stepCacheRef.current.push(dataPack); return; } + if (connectionLostRef.current) { + stepCacheRef.current.push(dataPack); + return; + } try { const result = await stepMutation.mutateAsync(dataPack); @@ -261,4 +272,4 @@ const useServerComm = (gameAssoc, gameInherited, isEnvDev) => { return { onClick, resign }; }; -export default useServerComm; +export default useServerCommunication; diff --git a/assets/js/mine-seeker/constants.js b/assets/js/mine-seeker/utils/constants.jsx similarity index 74% rename from assets/js/mine-seeker/constants.js rename to assets/js/mine-seeker/utils/constants.jsx index 368eccf..a7a3143 100644 --- a/assets/js/mine-seeker/constants.js +++ b/assets/js/mine-seeker/utils/constants.jsx @@ -10,6 +10,21 @@ export const WAVES = { 3: 'bg-wave-2-outbg.png', }; +export const IMAGES = { + target: `${IMG}bg-target-outbg.png`, + bomb: `${IMG}bg-bomb-outbg.png`, + bombDisabled: `${IMG}bg-bomb-disabled-outbg.png`, + bombExploded: `${IMG}bg-bomb-exploded-outbg.png`, + bombEmpty: `${IMG}bg-bomb-empty-outbg.png`, + leftMine: `${IMG}bg-left-mine-outbg.png`, + cursor: color => `${IMG}bg-cursor-${color}-outbg.png`, + figure: color => `${IMG}bg-figure-${color}-outbg.png`, + flag: player => `${IMG}bg-flag-${player}-outbg.png`, + last: color => `${IMG}bg-last-${color}-outbg.png`, + wave: n => `${IMG}${WAVES[n]}`, + bombPos: (hor, vert) => `${IMG}bg-bomb-${hor}-${vert}-outbg.png`, +}; + export const PLAYER_DEF = { name: '...', desc: '', active: false, mines: 0, haveBomb: true, enabledBomb: true, }; @@ -53,7 +68,7 @@ export const patchCells = (prev, patches) => { export const initCells = () => Array.from({ length: ROWS }, () => Array.from({ length: COLS }, () => ({ - currentImage: IMG + WAVES[Math.floor(Math.random() * 3) + 1], + currentImage: IMAGES.wave(Math.floor(Math.random() * 3) + 1), currentObj: 'w', active: false, lastClickedRed: false, diff --git a/eslint.config.mjs b/eslint.config.mjs index 17325ce..4cc297c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -7,12 +7,6 @@ export default [ { ignores: ['node_modules/**', 'vendor/**', 'var/**', 'public/build/**'], }, - { - files: ['**/*.jsx'], - rules: { - 'react/jsx-uses-vars': 'error', - }, - }, { files: ['**/*.{js,mjs,cjs,jsx,ts,tsx}'], plugins: { @@ -111,4 +105,17 @@ export default [ }], }, }, + { + files: ['**/*.jsx'], + rules: { + 'react/jsx-uses-vars': 'error', + 'no-unused-vars': ['error', { + varsIgnorePattern: '^React$', + args: 'after-used', + caughtErrors: 'none', + ignoreRestSiblings: false, + reportUsedIgnorePattern: false, + }], + }, + }, ];