Private
Public Access
1
0

chg: dev: massive refactor on front-end - and remove unnecessary deps #4

This commit is contained in:
2026-04-10 17:57:26 +02:00
parent 086d6c601e
commit b57442bec1
22 changed files with 2619 additions and 1425 deletions

12
assets/js/app.jsx Normal file
View File

@@ -0,0 +1,12 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import MineSeeker from './mine-seeker/MineSeeker';
const wrapper = document.getElementById('mine-wrapper');
createRoot(wrapper).render(
<MineSeeker
env={wrapper.dataset.env}
gameId={wrapper.dataset.gameId}
/>,
);

View File

@@ -1,11 +0,0 @@
import React from "react";
import ReactDOM from "react-dom";
import MineSeeker from "./mine-seeker/app";
ReactDOM.render(
<MineSeeker
env={document.getElementById("mine-wrapper").dataset.env}
gameId={document.getElementById("mine-wrapper").dataset.gameId}
/>,
document.getElementById("mine-wrapper"),
);

View File

@@ -0,0 +1,26 @@
import React, { useRef } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { GameProvider } from './contexts/GameContext';
import { GameBoard } from './components/GameBoard';
const queryClient = new QueryClient();
const MineSeeker = ({ env, gameId }) => {
const isEnvDev = 'dev' === env;
const gameAssoc = useRef('' !== gameId ? gameId : crypto.randomUUID()).current;
const gameInherited = '' !== gameId;
return (
<QueryClientProvider client={queryClient}>
<GameProvider>
<GameBoard
gameAssoc={gameAssoc}
gameInherited={gameInherited}
isEnvDev={isEnvDev}
/>
</GameProvider>
</QueryClientProvider>
);
};
export default MineSeeker;

View File

@@ -1,428 +0,0 @@
import React from 'react';
import GridControl from './grid/grid-control';
class MineSeeker extends React.Component {
constructor(props) {
super(props);
let gameAssoc = '' !== props.gameId ? props.gameId : crypto.randomUUID();
let channel = 'mineseeker/channel/' + gameAssoc;
this.state = {
env: props.env,
gameInherited: '' !== props.gameId,
gameAssoc: gameAssoc,
channel: channel,
stepCache: [],
connectionLost: false,
end: false,
};
/** SSE connection (not React state — no re-render needed) */
this.eventSource = null;
/**
* Users from GET /api/game/connect (inherited game).
* Passed to wSubscribe so it can determine the local player's colour.
*/
this.rpcUsers = null;
}
currectGridSize() {
let $field = $('#mine-wrapper .grid');
$field.height($field.width());
$field = $('#mine-wrapper .grid .field-wrapper');
$field.height($field.width());
$('#mine-wrapper .grid .field-wrapper .field')
.height($field.width())
.css('line-height', ($field.width() - 2) + 'px');
}
/**
* THE END
*/
makeGameEndIfItEnds(bluePoints, redPoints, resign = false, leftMines = []) {
let redWins = 25 < redPoints,
blueWins = 25 < bluePoints;
if (redWins || blueWins || resign) {
this.refs.gridControl.state.sound.won.play();
if (false === resign) {
this.refs.gridControl.setState({
overlay: true,
overlayTitle: (redWins ? 'Red' : 'Blue') + ' wins the game!',
overlaySubTitle: 'Play again!',
});
}
this.refs.gridControl.showLeftMines(leftMines);
this.refs.gridControl.refs.userControl.setState({ activePlayer: false });
this.refs.gridControl.refs.userControl.refs.red.setState({ desc: '' });
this.refs.gridControl.refs.userControl.refs.blue.setState({ desc: '' });
}
}
resignProcess(color) {
this.refs.gridControl.setState({
overlay: true,
overlayTitle: color === this.refs.gridControl.state.webPlayer
? 'You have been give up'
: 'Your opponent has been resigned',
overlaySubTitle: color === this.refs.gridControl.state.webPlayer
? 'You LOSE!'
: 'You WIN!',
});
this.setState({ end: true });
this.makeGameEndIfItEnds(0, 0, true);
}
clickResign() {
let resignColor = this.refs.gridControl.refs.userControl.state.activePlayer ? 'blue' : 'red';
this.publishStep({ resign: resignColor });
this.resignProcess(this.refs.gridControl.state.webPlayer);
}
clickResignCancel() {
this.refs.gridControl.setState({ overlay: false });
}
resign() {
let users = this.refs.gridControl.refs.userControl,
activePlayer = users.state.activePlayer ? 'blue' : 'red';
if (this.refs.gridControl.state.webPlayer === activePlayer) {
this.refs.gridControl.setState({
overlay: true,
overlayTitle: 'Are u sure u want to resign?!',
overlaySubTitle: (
<div className="resign">
<a onClick={this.clickResign.bind(this)}>Yes</a>
<a onClick={this.clickResignCancel.bind(this)}>No!</a>
</div>
),
});
}
}
// ------------------------------------------------------------------ //
// Mercure message handlers (same logic as former WAMP callbacks)
// ------------------------------------------------------------------ //
wSubscribe(payload, rpcUsers = null) {
'dev' === this.state.env && console.info(
('undefined' !== typeof payload.user ? payload.user : 'user') + ' has been subscribed to the channel!',
);
let firstUser = !rpcUsers;
null === this.refs.gridControl.state.webPlayer && this.refs.gridControl.setState({
webPlayer: payload.user === payload.users.blue
|| (
firstUser && '' !== payload.users.blueAnon
|| !firstUser && ('' === rpcUsers.blueAnon && '' === rpcUsers.blue)
)
? 'blue' : 'red',
});
(900 > $(document).width()) && this.currectGridSize();
if (
2 === payload.userCnt
&& (
!this.state.connectionLost
|| this.state.connectionLost && false === this.refs.gridControl.refs.userControl.state.activePlayer && !this.state.end
)
) {
this.makeGameStart(payload);
}
}
wUnsubscribe(payload) {
'dev' === this.state.env && console.info(payload.msg);
this.refs.gridControl.setState({
overlay: true,
overlayTitle: 'The connection has been lost w/ your friend...',
overlaySubTitle: 'Please, restart the game!',
});
}
/**
* Opponent's step arrived via Mercure — apply the server-resolved revealed cells.
*/
wTopic(payload) {
if (this.refs.gridControl.state.webPlayer !== payload.data.player) {
if (null === payload.data.resign) {
'dev' === this.state.env && console.warn(
payload.user + ' stepped to ' + payload.data.coords[0] + ',' + payload.data.coords[1],
);
this.refs.gridControl.refs.userControl.setState({ bombSelected: payload.data.bomb });
this.refs.gridControl.applyStep(payload.data);
this.makeGameEndIfItEnds(payload.data.bluePoints, payload.data.redPoints, false, payload.data.leftMines);
} else {
this.resignProcess(payload.data.resign);
}
}
}
// ------------------------------------------------------------------ //
// Mercure / SSE connection
// ------------------------------------------------------------------ //
handleMercureMessage(payload) {
let isTopicEvent = 'undefined' !== typeof payload.data;
let isNotUnsubscribe = 'undefined' === typeof payload.msg;
if (isTopicEvent) {
this.wTopic(payload);
} else if (isNotUnsubscribe) {
this.wSubscribe(payload, this.rpcUsers);
} else {
this.wUnsubscribe(payload);
}
/** Reconnection: replay cached steps once both players are back */
if (2 === payload.userCnt && this.state.connectionLost) {
'dev' === this.state.env && console.info('Reconnection process');
let cache = this.state.stepCache;
cache.forEach(item => this.publishStep(item));
this.setState({ connectionLost: false, stepCache: [] });
}
}
openEventSource() {
const wrapper = document.getElementById('mine-wrapper');
const hubUrl = wrapper.dataset.mercureHubUrl;
const subscriberJwt = wrapper.dataset.mercureSubscriberJwt;
const url = new URL(hubUrl, window.location.origin);
url.searchParams.append('topic', this.state.channel);
if (subscriberJwt) {
url.searchParams.append('authorization', subscriberJwt);
}
if (this.eventSource) {
this.eventSource.close();
}
this.eventSource = new EventSource(url.toString());
this.eventSource.onmessage = event => {
this.handleMercureMessage(JSON.parse(event.data));
};
this.eventSource.onopen = () => {
'dev' === this.state.env && console.info('SSE connection opened');
if (this.state.connectionLost) {
'dev' === this.state.env && console.info('SSE reconnected — rejoining channel');
this.joinGame();
}
};
this.eventSource.onerror = () => {
'dev' === this.state.env && console.error('SSE connection error / lost');
this.setState({ connectionLost: true });
};
}
/**
* Initialise the grid control with an empty 16×16 grid.
* For inherited games, previously revealed cells are applied after the grid renders.
*/
wInit(revealedCells = []) {
// 16×16 grid of null — cells are filled in lazily as they are revealed by the server
let emptyGrid = Array.from({ length: 16 }, () => Array(16).fill(null));
this.refs.gridControl.setState({
grid: emptyGrid,
channel: this.state.channel,
desc: {
buddy: (
<div>
Your buddy is <br />
making a <br />
move.
</div>
),
you: (
<div>
It is your turn! <br />
Make a move.
</div>
),
},
overlay: true,
overlayTitle: 'We are waiting for your opponent...',
overlaySubTitle: this.state.gameAssoc
? (
<div>
<h3>Share this unique link w/ your opponent</h3>
<div className="clippy">
<input
id="foo"
defaultValue={`${window.location.href}/${this.state.gameAssoc}`}
/>
</div>
<a href={`/play/${this.state.gameAssoc}`} target="_blank">Play w/ me!</a>
</div>
) : '',
renderGridFields: this.state.gameAssoc,
}, () => {
// After the grid fields are rendered, apply any historically revealed cells
revealedCells.forEach(cell => this.refs.gridControl.applyRevealedCell(cell, cell.player));
});
}
makeGameStart(payload) {
this.refs.gridControl.refs.userControl.setState({ activePlayer: 1 });
this.refs.gridControl.refs.userControl.refs.red.setState({
name: '' !== payload.users.red ? payload.users.red : payload.users.redAnon,
});
this.refs.gridControl.refs.userControl.refs.blue.setState({
name: '' !== payload.users.blue ? payload.users.blue : payload.users.blueAnon,
desc: 'blue' === this.refs.gridControl.state.webPlayer
? this.refs.gridControl.state.desc.you
: this.refs.gridControl.state.desc.buddy,
active: true,
});
this.refs.gridControl.setState({ overlay: false });
}
/** POST /api/game/join — register this player, broadcast subscription event via Mercure */
joinGame() {
return fetch('/api/game/join/' + this.state.gameAssoc, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}).catch(e => 'dev' === this.state.env && console.error('Join error', e));
}
/** POST /api/game/step — persist a move, fan it out via Mercure, and return revealed cells */
publishStep(dataPack) {
return fetch('/api/game/step/' + this.state.gameAssoc, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dataPack),
});
}
// ------------------------------------------------------------------ //
// Lifecycle
// ------------------------------------------------------------------ //
async componentDidMount() {
if (!this.state.connectionLost) {
try {
if (this.state.gameInherited) {
/** Fetch existing player info and previously revealed cells */
const resp = await fetch('/api/game/connect/' + this.state.gameAssoc);
const b64 = await resp.text();
const serverData = JSON.parse(window.atob(b64));
if ('undefined' === typeof serverData.users || null === serverData.users) {
this.refs.gridControl.setState({
overlay: true,
overlayTitle: 'This channel does not exists!',
overlaySubTitle: <a href="/play" target="_self">Restart game!</a>,
});
console.error('This channel does not exists!');
return;
}
this.rpcUsers = serverData.users;
this.openEventSource();
this.wInit(serverData.revealedCells || []);
} else {
/** Create the game record — the server generates the grid */
await fetch('/api/game/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gameAssoc: this.state.gameAssoc }),
});
this.openEventSource();
this.wInit();
}
'dev' === this.state.env && console.info('Connection initialised — joining channel');
await this.joinGame();
} catch (e) {
'dev' === this.state.env && console.error('Connection error', e);
setTimeout(() => this.componentDidMount(), 500);
}
} else {
/** Hard-reconnect path */
this.openEventSource();
}
/** Notify the server when the player closes / navigates away */
window.addEventListener('pagehide', () => {
navigator.sendBeacon('/api/game/leave/' + this.state.gameAssoc);
});
}
cachePublish(dataPack) {
let cache = this.state.stepCache;
cache.push(dataPack);
this.setState({ stepCache: cache });
}
async onClick(coords) {
let activePlayer = this.refs.gridControl.refs.userControl.state.activePlayer ? 'blue' : 'red';
if (!this.refs.gridControl.checkFieldHasBeenNeverClicked(coords[0], coords[1])) {
return;
}
if (activePlayer !== this.refs.gridControl.state.webPlayer) {
return;
}
let dataPack = {
coords: coords,
player: activePlayer,
bomb: this.refs.gridControl.refs.userControl.state.bombSelected,
resign: null,
};
if (this.state.connectionLost) {
this.cachePublish(dataPack);
return;
}
try {
const resp = await this.publishStep(dataPack);
const result = await resp.json();
this.refs.gridControl.applyStep(result);
this.makeGameEndIfItEnds(result.bluePoints, result.redPoints, false, result.leftMines);
} catch (e) {
'dev' === this.state.env && console.error('Step error', e);
}
}
render() {
return (
<GridControl
ref="gridControl"
env={'dev' === this.props.env}
resign={this.resign.bind(this)}
onClick={this.onClick.bind(this)}
/>
);
}
}
export default MineSeeker;

View File

@@ -0,0 +1,28 @@
/*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import React from 'react';
import { useGame } from '../contexts/GameContext';
import useServerComm from '../hooks/useServerComm';
import GridControl from './grid/GridControl';
export const GameBoard = ({ gameAssoc, gameInherited, isEnvDev }) => {
const { gridReady } = useGame();
const { onClick, resign } = useServerComm(gameAssoc, gameInherited, isEnvDev);
if (!gridReady) {
return (
<div className="game-overlay">
<div className="game-overlay-window"><h1>Loading</h1></div>
</div>
);
}
return <GridControl onClick={onClick} resign={resign} />;
};

View File

@@ -0,0 +1,69 @@
import React from 'react';
import { useGame } from '../../contexts/GameContext';
import GridField from './GridField';
import UserControl from '../user/UserControl';
import { BOMB_SYMBOLS, bombRadius } from '../../constants';
const GridControl = ({ onClick, resign }) => {
const {
overlay, overlayTitle, overlaySubTitle,
webPlayer, activePlayer, mines, foundMines, bombSelected,
red, blue, cells, setCells, onBombToggle,
} = useGame();
const handleHover = (row, col) => {
if (!bombSelected) return;
const activeColor = activePlayer ? 'blue' : 'red';
if (activeColor !== webPlayer) return;
setCells(prev => {
const next = prev.map(r => r.map(c =>
null !== c.bombTargetArea ? { ...c, bombTargetArea: null } : c,
));
bombRadius(row, col, prev.length, prev[0]?.length ?? 0).forEach(([r, c], i) => {
if (!next[r]?.[c]) return;
next[r] = [...next[r]];
next[r][c] = { ...next[r][c], bombTargetArea: BOMB_SYMBOLS[i] };
});
return next;
});
};
return (
<div className="game-wrapper">
<div className={`game-overlay${overlay ? '' : ' hide'}`}>
<div className="game-overlay-window">
<h1>{overlayTitle}</h1>
<h2>{overlaySubTitle}</h2>
</div>
</div>
<UserControl
webPlayer={webPlayer}
activePlayer={activePlayer}
mines={mines}
foundMines={foundMines}
red={red}
blue={blue}
onBombToggle={onBombToggle}
resign={resign}
/>
<div className="grid-container">
<div className="grid">
{cells.flatMap((row, r) =>
row.map((cell, c) => (
<GridField
key={`${r}_${c}`}
cell={cell}
onClick={() => onClick([r, c])}
onMouseEnter={() => handleHover(r, c)}
/>
))
)}
</div>
</div>
</div>
);
};
export default GridControl;

View File

@@ -0,0 +1,45 @@
import React, { memo } from 'react';
import { IMG } from '../../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`;
};
const GridField = memo(function GridField({ cell, onClick, onMouseEnter }) {
const { currentImage, currentObj, active, lastClickedRed, lastClickedBlue, bombTargetArea } = cell;
const fieldClass = 'field'
+ (active ? ' active' : '')
+ (active && 'm' === currentObj ? ' mine' : '')
+ ' color-' + currentObj;
const inner = isNaN(currentImage)
? (
<div className="flag-mine"><img src={currentImage} alt="" />
<div className="flag-mine-base" />
</div>
)
: currentImage ? <div className="flag-number">{currentImage}</div> : null;
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="" />
{bSrc && <img className="field-bomb-target" src={bSrc} alt="" />}
{showLast && <img className={lastClass} src={lastSrc} alt="" />}
<div className={fieldClass}>
<div className="field-corner">{inner}</div>
</div>
</div>
);
});
export default GridField;

View File

@@ -0,0 +1,39 @@
import React, { memo } from 'react';
const SRC = '/images/';
const User = memo(function User({
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="" />
</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="" />
<div className="user-control-mines">{mines}</div>
<div className={buzzClass} onClick={onClickBombSelector}>
<div className="bomb"><img src={bombImg} alt="" /></div>
</div>
<div className="clear" />
</div>
</div>
);
});
export default User;

View File

@@ -0,0 +1,43 @@
import React from 'react';
import User from './User';
const UserControl = ({ webPlayer, activePlayer, mines, foundMines, red, blue, onBombToggle, resign }) => {
const activeColor = activePlayer ? 'blue' : 'red';
const resignClass = 'resign' + (activeColor !== webPlayer ? ' disabled' : '');
const minesClass = 'active-mines' + (foundMines ? ' found-mine' : '');
const handleBombClick = (color, player) => {
const p = 'red' === color ? red : blue;
if (p.haveBomb && p.enabledBomb && activePlayer === player) {
onBombToggle();
}
};
return (
<div className="users">
<User
color="blue" webPlayer={webPlayer} {...blue}
onClickBombSelector={() => handleBombClick('blue', 1)}
/>
<div className="active-mines-container">
<i className="fa fa-star" />
<div className={minesClass}>
<div className="active-mines-nbr">{mines}</div>
<div className="active-mines-shine" />
</div>
<i className="fa fa-star" />
</div>
<div className="clear" />
<User
color="red" webPlayer={webPlayer} {...red}
onClickBombSelector={() => handleBombClick('red', 0)}
/>
<button className={resignClass} onClick={resign}>
<div className="resign-shine" />
Resign
</button>
</div>
);
}
export default UserControl;

View File

@@ -0,0 +1,63 @@
import React from 'react';
export const ROWS = 16;
export const COLS = 16;
export const IMG = '/images/';
export const WAVES = {
1: 'bg-wave-1-outbg.png',
2: 'bg-wave-1-outbg.png',
3: 'bg-wave-2-outbg.png',
};
export const PLAYER_DEF = {
name: '...', desc: '', active: false, mines: 0, haveBomb: true, enabledBomb: true,
};
export const DESC = {
buddy: <div>Your buddy is <br />making a <br />move.</div>,
you: <div>It is your turn! <br />Make a move.</div>,
};
export const BOMB_SYMBOLS = [
[null, null], [0, 0], [1, 0], [2, 0], [0, 1], [2, 1], [0, 2], [1, 2], [2, 2],
[null, null], [null, null], [null, null], [null, null], [null, null], [null, null], [null, null],
[null, null], [null, null], [null, null], [null, null], [null, null], [null, null], [null, null],
[null, null], [null, null],
];
export const bombRadius = (row, col, rows, cols) => {
const centre = 1 < row && row < rows - 2 && 1 < col && col < cols - 2;
if (!centre) {
col = Math.max(2, Math.min(col, cols - 3));
row = Math.max(2, Math.min(row, rows - 3));
}
return [
[row, col], [row - 2, col - 2], [row - 2, col], [row - 2, col + 2],
[row, col - 2], [row, col + 2], [row + 2, col - 2], [row + 2, col],
[row + 2, col + 2], [row - 2, col + 1], [row - 2, col - 1],
[row - 1, col - 2], [row - 1, col - 1], [row - 1, col], [row - 1, col + 1], [row - 1, col + 2],
[row, col - 1], [row, col + 1], [row + 1, col - 2], [row + 1, col - 1], [row + 1, col],
[row + 1, col + 1], [row + 1, col + 2], [row + 2, col - 1], [row + 2, col + 1],
];
};
export const patchCells = (prev, patches) => {
const next = prev.map(r => [...r]);
for (const { row, col, ...rest } of patches) {
next[row][col] = { ...next[row][col], ...rest };
}
return next;
};
export const initCells = () =>
Array.from({ length: ROWS }, () =>
Array.from({ length: COLS }, () => ({
currentImage: IMG + WAVES[Math.floor(Math.random() * 3) + 1],
currentObj: 'w',
active: false,
lastClickedRed: false,
lastClickedBlue: false,
bombTargetArea: null,
})),
);

View File

@@ -0,0 +1,233 @@
import React, { createContext, useContext, useRef, useState } from 'react';
import { Howl } from 'howler';
import { IMG, PLAYER_DEF, DESC, patchCells, initCells } from '../constants';
// ── Context ──────────────────────────────────────────────────────────────────
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>;
};

View File

@@ -1,301 +0,0 @@
import React from 'react';
import GridField from './grid-field';
import UserControl from '../user/user-control';
import { Howl } from 'howler';
class GridControl extends React.Component {
constructor(props) {
super(props);
let 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'] });
this.state = {
env: props.env,
webPlayer: null,
grid: null,
desc: null,
renderGridFields: false,
gridFields: [],
bombFieldCache: [],
overlay: false,
overlayTitle: '',
overlaySubTitle: '',
sound: {
click: click,
bomb: bomb,
mine: mine,
warning: warning,
won: won,
},
lastClicked: {
red: null,
blue: null,
},
};
}
refString(row, col) {
return 'gridField_' + row + '_' + col;
}
checkFieldHasBeenNeverClicked(row, col) {
return !this.refs[this.refString(row, col)].state.active;
}
getBombRadius(row, col) {
let isBombTargetCenter = 1 < row && row < this.state.grid.length - 2 && 1 < col && col < this.state.grid[row].length - 2;
if (!isBombTargetCenter) {
col = 2 > col ? 2 : col;
row = 2 > row ? 2 : row;
row = row > this.state.grid.length - 3 ? this.state.grid.length - 3 : row;
col = col > this.state.grid[0].length - 3 ? this.state.grid[0].length - 3 : col;
}
return [
[row, col], [row - 2, col - 2], [row - 2, col], [row - 2, col + 2], [row, col - 2], [row, col + 2],
[row + 2, col - 2], [row + 2, col], [row + 2, col + 2], [row - 2, col + 1], [row - 2, col - 1],
[row - 1, col - 2], [row - 1, col - 1], [row - 1, col], [row - 1, col + 1], [row - 1, col + 2],
[row, col - 1], [row, col + 1], [row + 1, col - 2], [row + 1, col - 1], [row + 1, col],
[row + 1, col + 1], [row + 1, col + 2], [row + 2, col - 1], [row + 2, col + 1],
];
}
getBombFieldRadius() {
return [
[null, null], [0, 0], [1, 0], [2, 0], [0, 1], [2, 1], [0, 2], [1, 2], [2, 2],
[null, null], [null, null], [null, null], [null, null], [null, null], [null, null], [null, null],
[null, null], [null, null], [null, null], [null, null], [null, null], [null, null], [null, null],
[null, null], [null, null],
];
}
bombClear() {
if (this.state.bombFieldCache.length) {
for (var i = 0, j = this.state.bombFieldCache.length; i < j; i++) {
var cacheItem = this.state.bombFieldCache[i];
this.refs[this.refString(cacheItem[0], cacheItem[1])]
.setState({ bombTargetArea: null });
}
this.state.bombFieldCache = [];
}
}
bombCreate(row, col) {
var bombFieldSymbols = this.getBombFieldRadius(),
bombFields = this.getBombRadius(row, col);
for (var i = 0, j = bombFields.length; i < j; i++) {
this.state.bombFieldCache.push(bombFields[i]);
this.refs[this.refString(bombFields[i][0], bombFields[i][1])]
.setState({ bombTargetArea: bombFieldSymbols[i] });
}
}
/**
* Show unrevealed mines at the end of the game.
* leftMines comes from the server and contains only cells not yet revealed.
*/
showLeftMines(leftMines = []) {
for (let mine of leftMines) {
let currentField = this.refs[this.refString(mine.row, mine.col)];
if (currentField && !currentField.state.active) {
currentField.setState({
currentImage: currentField.state.icons.root + currentField.state.icons.left,
});
}
}
}
/** set __ACTIVE__ player in the UserControl */
changePlayer() {
var userControl = this.refs.userControl,
activePlayer = userControl.state.activePlayer ? 'blue' : 'red',
inactivePlayer = userControl.state.activePlayer ? 'red' : 'blue';
userControl.setState({ activePlayer: userControl.state.activePlayer ? 0 : 1 });
userControl.refs[activePlayer].setState({ active: false, desc: '' });
userControl.refs[inactivePlayer].setState({
active: true,
desc: activePlayer === this.state.webPlayer
? this.state.desc.buddy
: this.state.desc.you,
});
}
/**
* Apply a single revealed cell to the grid UI.
* Called both for live steps and when reconstructing the board for a joining player.
*
* @param {object} cell - { row, col, value } value is 'm' | 0-8
* @param {string} player - 'red' | 'blue'
* @param {boolean} isMainCell - true for the primary clicked cell (sets last-clicked highlight)
*/
applyRevealedCell(cell, player, isMainCell = false) {
let ref = this.refs[this.refString(cell.row, cell.col)];
if (!ref || ref.state.active) {
return;
}
if ('m' === cell.value) {
ref.setState({
currentImage: ref.state.icons.root + ref.state.icons.flag[player],
currentObj: 'm',
active: true,
});
} else {
ref.setState({
currentImage: cell.value,
currentObj: cell.value,
active: true,
});
}
if (isMainCell) {
this.state.lastClicked[player] = [cell.row, cell.col];
ref.setState({
lastClickedRed: 'red' === player,
lastClickedBlue: 'blue' === player,
});
}
}
/**
* Apply a full step response from the server.
* stepData shape: { coords, player, bomb, revealedCells, minesFound, redPoints, bluePoints, resign, gameOver,
* leftMines }
*/
applyStep(stepData) {
let player = stepData.player;
let isBomb = stepData.bomb;
let minesFound = stepData.minesFound || 0;
let revealedCells = stepData.revealedCells || [];
// Reset previous last-clicked highlight for this player
let lastClicked = this.state.lastClicked[player];
if (lastClicked) {
let lastRef = this.refs[this.refString(lastClicked[0], lastClicked[1])];
if (lastRef) {
lastRef.setState({ lastClickedRed: false, lastClickedBlue: false });
}
}
// Sound
if (isBomb) {
this.state.sound.bomb.play();
} else if (0 < minesFound) {
let currentMines = this.refs.userControl.refs[player].state.mines;
this.state.sound[20 < (currentMines + minesFound) ? 'warning' : 'mine'].play();
} else {
this.state.sound.click.play();
}
// Apply each revealed cell; first cell gets the last-clicked highlight
revealedCells.forEach((cell, idx) => {
this.applyRevealedCell(cell, player, 0 === idx);
});
// Update scores
let userControl = this.refs.userControl;
let inactivePlayer = 'red' === player ? 'blue' : 'red';
if (0 < minesFound) {
userControl.setState({
mines: 51 - stepData.redPoints - stepData.bluePoints,
foundMines: true,
}, () => {
setTimeout(() => userControl.setState({ foundMines: false }), 500);
userControl.refs[player].setState({
mines: 'red' === player ? stepData.redPoints : stepData.bluePoints,
});
});
}
// Bomb-enabled status: a player may use their bomb when their score <= opponent's
userControl.refs.red.setState({ enabledBomb: stepData.redPoints <= stepData.bluePoints });
userControl.refs.blue.setState({ enabledBomb: stepData.bluePoints <= stepData.redPoints });
// Change active player: always after a bomb, or when no mine was found on a normal click
if (isBomb || 0 === minesFound) {
this.changePlayer();
}
// Clean up bomb state
if (isBomb) {
userControl.setState({ bombSelected: false });
userControl.refs[player].setState({ haveBomb: false });
this.bombClear();
}
}
/**
* On Hover when you want to drop BOMB — show target area overlay
*/
onHoverGridField(coords) {
if (this.refs.userControl.state.bombSelected) {
var activePlayer = this.refs.userControl.state.activePlayer ? 'blue' : 'red';
if (activePlayer === this.state.webPlayer) {
this.bombClear();
this.bombCreate(coords[0], coords[1]);
} else {
this.refs.userControl.setState({ bombSelected: false });
}
}
}
renderGridFields() {
for (let i = 0, j = this.state.grid.length; i < j; i++) {
for (let k = 0, l = this.state.grid[i].length; k < l; k++) {
this.state.gridFields.push(
<GridField
row={i}
col={k}
ref={this.refString(i, k)}
key={this.refString(i, k)}
handleHoverOn={this.onHoverGridField.bind(this, [i, k])}
onClick={this.props.onClick.bind(null, [i, k])}
/>,
);
}
}
}
render() {
/** Render the grid fields just one time in one party #12 */
this.state.renderGridFields && this.renderGridFields();
this.state.renderGridFields = false;
return (
<div className="game-wrapper">
<div className={`game-overlay ${this.state.overlay ? '' : ' hide'}`}>
<div className="game-overlay-window">
<h1>{this.state.overlayTitle}</h1>
<h2>{this.state.overlaySubTitle}</h2>
</div>
</div>
<UserControl
ref="userControl"
resign={this.props.resign}
webPlayer={this.state.webPlayer}
bombClear={this.bombClear.bind(this)}
/>
<div className="grid-container">
<div className="grid">
<>
{this.state.gridFields}
</>
</div>
</div>
</div>
);
}
}
export default GridControl;

View File

@@ -1,156 +0,0 @@
import React from 'react';
class GridField extends React.Component {
constructor(props) {
super(props);
this.state = {
currentObj: 'w',
currentImage: null,
active: false,
lastClickedRed: false,
lastClickedBlue: false,
bombTargetArea: null,
icons: {
root: '/images/',
water: {
1: 'bg-wave-1-outbg.png',
2: 'bg-wave-1-outbg.png',
3: 'bg-wave-2-outbg.png',
},
flag: {
red: 'bg-flag-red-outbg.png',
blue: 'bg-flag-blue-outbg.png',
},
target: {
lastBlue: 'bg-last-blue-outbg.png',
lastRed: 'bg-last-red-outbg.png',
crosshair: 'bg-target-outbg.png',
crosshairBomb: 'bg-target-bomb-outbg.png',
},
left: 'bg-left-mine-outbg.png',
},
};
}
componentWillMount() {
var wave = Math.floor(Math.random() * 3) + 1;
this.setState({
currentImage: this.state.icons.root + this.state.icons.water[wave],
});
}
classNameWhenActive() {
return 'field'
+ (true === this.state.active ? ' active' : '')
+ (true === this.state.active && 'm' === this.state.currentObj ? ' mine' : '')
+ ' color-' + this.state.currentObj;
}
currentImage() {
return isNaN(this.state.currentImage)
? (
<div className="flag-mine">
<img src={this.state.currentImage} alt="current image" />
<div className="flag-mine-base" />
</div>
) : this.state.currentImage ? <div className="flag-number">{this.state.currentImage}</div> : '';
}
lastClickedClass() {
return 'field-'
+ (this.state.lastClickedRed ? 'red' : '')
+ (this.state.lastClickedBlue ? 'blue' : '') + '-last last-clicked';
}
lastClickedSrc() {
return this.state.lastClickedRed
? '/images/bg-last-red-outbg.png'
: '/images/bg-last-blue-outbg.png';
}
currentLastClicked() {
return this.state.lastClickedRed || this.state.lastClickedBlue
? (
<img
className={this.lastClickedClass()}
src={this.lastClickedSrc()}
alt="blue last"
/>
) : '';
}
createBombTarget() {
if (null !== this.state.bombTargetArea) {
let vert, hor = '';
switch (this.state.bombTargetArea[0]) {
case 0:
vert = 'left';
break;
case 1:
vert = 'center';
break;
case 2:
vert = 'right';
break;
default:
vert = null;
break;
}
switch (this.state.bombTargetArea[1]) {
case 0:
hor = 'top';
break;
case 1:
hor = 'middle';
break;
case 2:
hor = 'bottom';
break;
default:
vert = null;
break;
}
var src = null === vert
? '/images/bg-bomb-empty-outbg.png'
: '/images/bg-bomb-' + hor + '-' + vert + '-outbg.png';
return (
<img
className="field-bomb-target"
src={src}
alt="bomb target"
/>
);
}
}
render() {
return (
<div
className="field-wrapper"
onClick={this.props.onClick}
onMouseEnter={this.props.handleHoverOn}
>
<img
className="field-target"
src="/images/bg-target-outbg.png"
alt="target"
/>
{this.createBombTarget()}
{this.currentLastClicked()}
<div className={this.classNameWhenActive()}>
<div className="field-corner">
{this.currentImage()}
</div>
</div>
</div>
);
}
}
export default GridField;

View File

@@ -0,0 +1,264 @@
import React, { useEffect, useRef } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useGame } from '../contexts/GameContext';
import { DESC } from '../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) => {
const {
// Async-safe refs
webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, endRef,
// State setters
setGridReady,
// Sync helpers
syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue,
// Game logic
showOverlay, hideOverlay,
applyRevealedCell, applyStep,
makeGameEndIfItEnds, resignProcess,
// Current cells snapshot (for active-check in onClick)
cells,
} = useGame();
const eventSourceRef = useRef(null);
const rpcUsersRef = useRef(null);
const stepCacheRef = useRef([]);
// ── Helpers ───────────────────────────────────────────────────────────────
const correctGridSize = () => {
let $f = $('#mine-wrapper .grid');
$f.height($f.width());
$f = $('#mine-wrapper .grid .field-wrapper');
$f.height($f.width());
$('#mine-wrapper .grid .field-wrapper .field').height($f.width()).css('line-height', ($f.width() - 2) + 'px');
};
// ── REST mutations / queries ──────────────────────────────────────────────
const connectQuery = useQuery({
queryKey: ['game-connect', gameAssoc],
queryFn: () => fetch('/api/game/connect/' + gameAssoc)
.then(r => r.text())
.then(b64 => JSON.parse(window.atob(b64))),
enabled: false,
retry: false,
});
const startMutation = useMutation({
mutationFn: () => fetch('/api/game/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gameAssoc }),
}),
});
const joinMutation = useMutation({
mutationFn: () => fetch('/api/game/join/' + gameAssoc, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}).catch(e => isEnvDev && console.error('Join error', e)),
});
const stepMutation = useMutation({
mutationFn: dataPack => fetch('/api/game/step/' + gameAssoc, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dataPack),
}).then(r => r.json()),
});
// ── Game-start helpers (triggered by server events) ───────────────────────
const wInit = (revealedCells = []) => {
setGridReady(true);
showOverlay('We are waiting for your opponent...', gameAssoc ? (
<div>
<h3>Share this unique link w/ your opponent</h3>
<div className="clippy">
<input id="foo" defaultValue={`${window.location.href}/${gameAssoc}`} />
</div>
<a href={`/play/${gameAssoc}`} target="_blank">Play w/ me!</a>
</div>
) : '');
setTimeout(() => revealedCells.forEach(cell => applyRevealedCell(cell, cell.player)), 0);
};
const makeGameStart = payload => {
syncActivePlayer(1);
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,
active: true,
}));
hideOverlay();
};
// ── Mercure / SSE message handlers ───────────────────────────────────────
const wSubscribe = (payload, rpcUsers = null) => {
isEnvDev && console.info((payload.user ?? 'user') + ' subscribed');
const firstUser = !rpcUsers;
if (null === webPlayerRef.current) {
const isBlue = payload.user === payload.users.blue
|| (firstUser ? '' !== payload.users.blueAnon : '' === rpcUsers.blueAnon && '' === rpcUsers.blue);
syncWebPlayer(isBlue ? 'blue' : 'red');
}
900 > $(document).width() && correctGridSize();
if (
2 === payload.userCnt
&& (!connectionLostRef.current
|| (connectionLostRef.current && false === activePlayerRef.current && !endRef.current))
) {
makeGameStart(payload);
}
};
const wUnsubscribe = payload => {
isEnvDev && console.info(payload.msg);
showOverlay('The connection has been lost w/ your friend...', 'Please, restart the game!');
};
const wTopic = payload => {
if (webPlayerRef.current !== payload.data.player) {
if (null === payload.data.resign) {
isEnvDev && console.warn(payload.user + ' stepped to ' + payload.data.coords.join(','));
syncBombSelected(payload.data.bomb);
applyStep(payload.data);
makeGameEndIfItEnds(payload.data.bluePoints, payload.data.redPoints, false, payload.data.leftMines);
} else {
resignProcess(payload.data.resign);
}
}
};
const handleMercureMessage = payload => {
if (undefined !== payload.data) {
wTopic(payload);
} else if (undefined === payload.msg) {
wSubscribe(payload, rpcUsersRef.current);
} else {
wUnsubscribe(payload);
}
if (2 === payload.userCnt && connectionLostRef.current) {
isEnvDev && console.info('Reconnection');
stepCacheRef.current.forEach(item => stepMutation.mutate(item));
stepCacheRef.current = [];
syncConnLost(false);
}
};
const openEventSource = () => {
const wrapper = document.getElementById('mine-wrapper');
const hubUrl = wrapper.dataset.mercureHubUrl;
const subscriberJwt = wrapper.dataset.mercureSubscriberJwt;
const url = new URL(hubUrl, window.location.origin);
url.searchParams.append('topic', 'mineseeker/channel/' + gameAssoc);
if (subscriberJwt) url.searchParams.append('authorization', subscriberJwt);
if (eventSourceRef.current) eventSourceRef.current.close();
const es = new EventSource(url.toString());
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(); }
};
es.onerror = () => { isEnvDev && console.error('SSE error'); syncConnLost(true); };
eventSourceRef.current = es;
};
// ── Initialization ────────────────────────────────────────────────────────
useEffect(() => {
(async () => {
if (connectionLostRef.current) { openEventSource(); return; }
try {
if (gameInherited) {
const serverData = await connectQuery.refetch().then(r => {
if (r.error) throw r.error;
return r.data;
});
if ('undefined' === typeof serverData.users || null === serverData.users) {
showOverlay('This channel does not exists!', <a href="/play" target="_self">Restart game!</a>);
console.error('This channel does not exists!');
return;
}
rpcUsersRef.current = serverData.users;
openEventSource();
wInit(serverData.revealedCells || []);
} else {
await startMutation.mutateAsync();
openEventSource();
wInit();
}
isEnvDev && console.info('Connection initialised — joining channel');
await joinMutation.mutateAsync();
} catch (e) {
isEnvDev && console.error('Connection error', e);
setTimeout(() => window.location.reload(), 500);
}
})();
window.addEventListener('pagehide', () => navigator.sendBeacon('/api/game/leave/' + gameAssoc));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ── UI-facing callbacks ───────────────────────────────────────────────────
const onClick = async coords => {
const activeColor = activePlayerRef.current ? 'blue' : 'red';
if (activeColor !== webPlayerRef.current) return;
const [r, c] = coords;
if (cells[r]?.[c]?.active) return;
const dataPack = { coords, player: activeColor, bomb: bombSelectedRef.current, resign: null };
if (connectionLostRef.current) { stepCacheRef.current.push(dataPack); return; }
try {
const result = await stepMutation.mutateAsync(dataPack);
applyStep(result);
makeGameEndIfItEnds(result.bluePoints, result.redPoints, false, result.leftMines);
} catch (e) {
isEnvDev && console.error('Step error', e);
}
};
const clickResign = () => {
const color = activePlayerRef.current ? 'blue' : 'red';
stepMutation.mutate({ resign: color });
resignProcess(webPlayerRef.current);
};
const resign = () => {
const activeColor = activePlayerRef.current ? 'blue' : 'red';
if (webPlayerRef.current !== activeColor) return;
showOverlay('Are u sure u want to resign?!', (
<div className="resign">
<a onClick={clickResign}>Yes</a>
<a onClick={hideOverlay}>No!</a>
</div>
));
};
return { onClick, resign };
};
export default useServerComm;

View File

@@ -1,82 +0,0 @@
import React from 'react';
import User from './user';
class UserControl extends React.Component {
constructor() {
super();
/**
* activePlayer - red: 0, blue: 1
* @type {{activePlayer: boolean, mines: number, bombSelected: boolean, foundMines: boolean}}
*/
this.state = {
activePlayer: false,
mines: 51,
bombSelected: false,
foundMines: false,
};
}
youCanSelectBomb(activePlayer, clickedPlayer) {
return this.refs[activePlayer].state.haveBomb
&& this.refs[activePlayer].state.enabledBomb
&& this.state.activePlayer === clickedPlayer;
}
onClickBombSelector(clickedPlayer) {
let activePlayer = this.state.activePlayer ? 'blue' : 'red';
if (this.youCanSelectBomb(activePlayer, clickedPlayer)) {
this.state.bombSelected = !this.state.bombSelected;
if (!this.state.bombSelected) {
this.props.bombClear();
}
}
}
getResignClass(webPlayer) {
let activePlayer = 1 === this.state.activePlayer ? 'blue' : 'red';
return 'resign' + (webPlayer !== activePlayer ? ' disabled' : '');
}
activeMines() {
return 'active-mines' + (this.state.foundMines ? ' found-mine' : '');
}
render() {
return (
<div className="users">
<User
ref="blue"
color="blue"
webPlayer={this.props.webPlayer}
active={1 === this.state.activePlayer}
onClickBombSelector={this.onClickBombSelector.bind(this, 1)}
/>
<div className="active-mines-container">
<i className="fa fa-star" />
<div className={this.activeMines()}>
<div className="active-mines-nbr">{this.state.mines}</div>
<div className="active-mines-shine" />
</div>
<i className="fa fa-star" />
</div>
<div className="clear" />
<User
ref="red"
color="red"
webPlayer={this.props.webPlayer}
active={0 === this.state.activePlayer}
onClickBombSelector={this.onClickBombSelector.bind(this, 0)}
/>
<button className={this.getResignClass(this.props.webPlayer)} onClick={this.props.resign}>
<div className="resign-shine" />
Resign
</button>
</div>
);
}
}
export default UserControl;

View File

@@ -1,90 +0,0 @@
import React from 'react';
class User extends React.Component {
constructor(props) {
super(props);
this.state = {
name: '...',
desc: '',
active: props.active,
color: 'blue' === props.color ? 1 : 0,
mines: 0,
srcRoot: '/images/',
haveBomb: true,
enabledBomb: true,
};
}
setColor(color) {
return 'user-container user-' + color;
}
getSrc(color) {
return this.state.srcRoot + 'bg-flag-' + color + '-outbg.png';
}
getBombBuzzClass(webPlayer) {
let activePlayer = 1 === this.state.color ? 'blue' : 'red';
return 'bomb-container'
+ (
this.state.active && (activePlayer === webPlayer) && this.state.haveBomb && this.state.enabledBomb
? ' buzz'
: ''
);
}
getBomb() {
let src = this.state.srcRoot;
if (this.state.haveBomb) {
src += this.state.enabledBomb && this.state.active
? 'bg-bomb-outbg.png'
: 'bg-bomb-disabled-outbg.png';
} else {
src += 'bg-bomb-exploded-outbg.png';
}
return src;
}
getFigure(color) {
return this.state.srcRoot + 'bg-figure-' + color + '-outbg.png';
}
getCursor(state, color) {
return state
? <img src={this.state.srcRoot + 'bg-cursor-' + color + '-outbg.png'} alt="cursor" className="user-cursor" />
: '';
}
render() {
return (
<div className={this.setColor(this.props.color)}>
<div className="user-header">
<div className="user-color">{this.props.color}</div>
{this.getCursor(this.props.active, this.props.color)}
<img src={this.getFigure(this.props.color)} alt="figure" />
</div>
<div className="user-name"> {this.state.name} </div>
<div className="user-caret"><i className="fa fa-caret-down" /></div>
<div className="user-desc"> {this.state.desc} </div>
<div className="user-control">
<img src={this.getSrc(this.props.color)} alt="flag" />
<div className="user-control-mines">
{this.state.mines}
</div>
<div className={this.getBombBuzzClass(this.props.webPlayer)} onClick={this.props.onClickBombSelector}>
<div className="bomb">
<img src={this.getBomb()} alt="bomb" />
</div>
</div>
<div className="clear" />
</div>
</div>
);
}
}
export default User;

View File

@@ -7,6 +7,12 @@ export default [
{ {
ignores: ['node_modules/**', 'vendor/**', 'var/**', 'public/build/**'], ignores: ['node_modules/**', 'vendor/**', 'var/**', 'public/build/**'],
}, },
{
files: ['**/*.jsx'],
rules: {
'react/jsx-uses-vars': 'error',
},
},
{ {
files: ['**/*.{js,mjs,cjs,jsx,ts,tsx}'], files: ['**/*.{js,mjs,cjs,jsx,ts,tsx}'],
plugins: { plugins: {

2048
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,17 +14,14 @@
"private": true, "private": true,
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^5.2.0", "@fortawesome/fontawesome-free": "^5.2.0",
"@tanstack/react-query": "^5.97.0",
"bootstrap": "3", "bootstrap": "3",
"buffer": "^5.4.3",
"howler": "^2.1.2", "howler": "^2.1.2",
"jquery": "^3.4.1", "jquery": "^3.4.1",
"js-base64": "^2.1.9",
"lodash": "^4.18.1", "lodash": "^4.18.1",
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
"react": "^16.11.0", "react": "^19.2.5",
"react-dom": "^16.11.0", "react-dom": "^19.2.5"
"uglify-js": "^2.7.4",
"uglifycss": "0.0.25"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.29.0", "@babel/core": "^7.29.0",
@@ -38,7 +35,7 @@
"babel-loader": "^9.0", "babel-loader": "^9.0",
"core-js": "^3.0.0", "core-js": "^3.0.0",
"eslint": "^9.0.0", "eslint": "^9.0.0",
"eslint-plugin-react": "^4.0.0", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-hooks": "^5.0.0",
"globals": "^15.0.0", "globals": "^15.0.0",
"sass": "^1.77.0", "sass": "^1.77.0",

View File

@@ -352,9 +352,10 @@ class TopicManager implements TopicManagerInterface
private function getLeftMines(array $grid, array $alreadyRevealed): array private function getLeftMines(array $grid, array $alreadyRevealed): array
{ {
$mines = []; $mines = [];
foreach ($grid as $r => $row) { foreach ($grid as $r => $row) {
foreach ($row as $c => $value) { foreach ($row as $c => $value) {
if ('m' === $value && !isset($alreadyRevealed[$r . ',' . $c])) { if ('m' === $value && !isset($alreadyRevealed["$r,$c"])) {
$mines[] = ['row' => $r, 'col' => $c]; $mines[] = ['row' => $r, 'col' => $c];
} }
} }

View File

@@ -373,7 +373,7 @@
}, },
"files": [ "files": [
"assets/css/app.css", "assets/css/app.css",
"assets/js/app.js", "assets/js/MineSeeker.js",
"config/packages/assets.yaml", "config/packages/assets.yaml",
"config/packages/prod/webpack_encore.yaml", "config/packages/prod/webpack_encore.yaml",
"config/packages/webpack_encore.yaml", "config/packages/webpack_encore.yaml",

View File

@@ -10,29 +10,29 @@ if (!Encore.isRuntimeEnvironmentConfigured()) {
// the project directory where compiled assets will be stored // the project directory where compiled assets will be stored
Encore Encore
.setOutputPath('public/build/') .setOutputPath('public/build/')
// the public path used by the web server to access the previous directory // the public path used by the web server to access the previous directory
.setPublicPath('/build') .setPublicPath('/build')
// .cleanupOutputBeforeBuild() // .cleanupOutputBeforeBuild()
.enableBuildNotifications() .enableBuildNotifications()
.enableSourceMaps(!Encore.isProduction()) .enableSourceMaps(!Encore.isProduction())
// uncomment to create hashed filenames (e.g. app.abc123.css) // uncomment to create hashed filenames (e.g. app.abc123.css)
.enableVersioning(Encore.isProduction()) .enableVersioning(Encore.isProduction())
// .copyFiles([ // .copyFiles([
// {from: './public/bundles/goswebsocket/', to: '/[path][name].[ext]', pattern: /\.(js|css|map)$/, includeSubdirectories: false}, // {from: './public/bundles/goswebsocket/', to: '/[path][name].[ext]', pattern: /\.(js|css|map)$/, includeSubdirectories: false},
// ]) // ])
// uncomment to define the assets of the project // uncomment to define the assets of the project
// .addEntry('js/app', './assets/js/app.js') // .addEntry('js/app', './assets/js/MineSeeker.js')
// .addStyleEntry('css/app', './assets/css/app.scss') // .addStyleEntry('css/app', './assets/css/app.scss')
// .addEntry('mineseeker', ['babel-polyfill', './assets/js/mine-seeker.js']) // .addEntry('mineseeker', ['babel-polyfill', './assets/js/MineSeeker.js'])
.addEntry('mineseeker', './assets/js/mine-seeker.js') .addEntry('mineseeker', './assets/js/app.jsx')
.addEntry('mineseekerStyle', './assets/css/style.mineseeker.scss') .addEntry('mineseekerStyle', './assets/css/style.mineseeker.scss')
.addEntry('homeStyle', './assets/css/style.layout.scss') .addEntry('homeStyle', './assets/css/style.layout.scss')
// uncomment if you use Sass/SCSS files // uncomment if you use Sass/SCSS files
.enableSassLoader(options => { .enableSassLoader(options => {
options.api = 'modern'; options.api = 'modern';
options.sassOptions = { silenceDeprecations: ['import'] }; options.sassOptions = { silenceDeprecations: ['import'] };
@@ -42,20 +42,20 @@ Encore
options.url = { filter: url => !url.startsWith('/') }; options.url = { filter: url => !url.startsWith('/') };
}) })
// provide $/jQuery as global variables for Bootstrap 3 and legacy code // provide $/jQuery as global variables for Bootstrap 3 and legacy code
.addPlugin(new webpack.ProvidePlugin({ .addPlugin(new webpack.ProvidePlugin({
$: 'jquery', $: 'jquery',
jQuery: 'jquery', jQuery: 'jquery',
'window.jQuery': 'jquery', 'window.jQuery': 'jquery',
})) }))
// .configureBabel(function (babelConfig) {
// babelConfig.presets.push('env');
// })
.enableReactPreset() .enableReactPreset()
.configureBabel(babelConfig => {
const idx = babelConfig.presets.findIndex(p => (Array.isArray(p) ? p[0] : p).includes('preset-react'));
if (-1 < idx) babelConfig.presets[idx] = [require.resolve('@babel/preset-react'), { runtime: 'automatic' }];
})
// .enableSingleRuntimeChunk() // .enableSingleRuntimeChunk()
.disableSingleRuntimeChunk() .disableSingleRuntimeChunk()
; ;