Private
Public Access
1
0

chg: dev: more, massive refactor for front-end #4

This commit is contained in:
2026-04-10 19:09:05 +02:00
parent b57442bec1
commit d186a96f0d
13 changed files with 402 additions and 296 deletions

View File

@@ -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();

View File

@@ -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 <GridControl onClick={onClick} resign={resign} />;
return (
<GridControl
onClick={onClick}
resign={resign}
/>
);
};

View File

@@ -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 }) => {
</div>
</div>
<UserControl
webPlayer={webPlayer}
activePlayer={activePlayer}
mines={mines}
foundMines={foundMines}
red={red}
blue={blue}
onBombToggle={onBombToggle}
resign={resign}
/>
<div className="grid-container">
@@ -58,7 +51,7 @@ const GridControl = ({ onClick, resign }) => {
onClick={() => onClick([r, c])}
onMouseEnter={() => handleHover(r, c)}
/>
))
)),
)}
</div>
</div>

View File

@@ -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 (
<div className="field-wrapper" onClick={onClick} onMouseEnter={onMouseEnter}>
<img className="field-target" src={IMG + 'bg-target-outbg.png'} alt="" />
<img className="field-target" src={IMAGES.target} alt="" />
{bSrc && <img className="field-bomb-target" src={bSrc} alt="" />}
{showLast && <img className={lastClass} src={lastSrc} alt="" />}
{showLast && <img className={lastClass} src={IMAGES.last(lastClickedRed ? 'red' : 'blue')} alt="" />}
<div className={fieldClass}>
<div className="field-corner">{inner}</div>
</div>

View File

@@ -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 (
<div className={`user-container user-${color}`}>
<div className="user-header">
<div className="user-color">{color}</div>
{active && <img src={`${SRC}bg-cursor-${color}-outbg.png`} alt="" className="user-cursor" />}
<img src={`${SRC}bg-figure-${color}-outbg.png`} alt="" />
{active && <img src={IMAGES.cursor(color)} alt="" className="user-cursor" />}
<img src={IMAGES.figure(color)} alt="" />
</div>
<div className="user-name"> {name} </div>
<div className="user-caret"><i className="fa fa-caret-down" /></div>
<div className="user-desc"> {desc} </div>
<div className="user-control">
<img src={`${SRC}bg-flag-${color}-outbg.png`} alt="" />
<img src={IMAGES.flag(color)} alt="" />
<div className="user-control-mines">{mines}</div>
<div className={buzzClass} onClick={onClickBombSelector}>
<div className="bomb"><img src={bombImg} alt="" /></div>
<div className="bomb">
{haveBomb && <img src={enabledBomb && active ? IMAGES.bomb : IMAGES.bombDisabled} alt="" />}
{!haveBomb && <img src={IMAGES.bombExploded} alt="" />}
</div>
</div>
<div className="clear" />
</div>

View File

@@ -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' : '');

View File

@@ -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 <GameContext.Provider value={value}>{children}</GameContext.Provider>;
};
export default GameContext;

View File

@@ -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 (
<GameContext.Provider
value={{
// State (for rendering)
webPlayer, activePlayer, overlay, overlayTitle, overlaySubTitle,
mines, bombSelected, foundMines, red, blue, cells, gridReady, connectionLost,
// Setters needed by useServerComm
setCells, setGridReady,
// Refs (needed by useServerComm for async-safe reads)
webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, endRef,
// Sync helpers
syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue,
// Game logic called by useServerComm
showOverlay, hideOverlay,
applyRevealedCell, applyStep,
makeGameEndIfItEnds, resignProcess,
// UI action
onBombToggle,
}}
>
{children}
</GameContext.Provider>
);
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,

View File

@@ -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,
}],
},
},
];