chg: dev: massive refactor on front-end - and remove unnecessary deps #4
This commit is contained in:
12
assets/js/app.jsx
Normal file
12
assets/js/app.jsx
Normal 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}
|
||||
/>,
|
||||
);
|
||||
@@ -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"),
|
||||
);
|
||||
26
assets/js/mine-seeker/MineSeeker.jsx
Normal file
26
assets/js/mine-seeker/MineSeeker.jsx
Normal 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;
|
||||
@@ -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;
|
||||
28
assets/js/mine-seeker/components/GameBoard.jsx
Normal file
28
assets/js/mine-seeker/components/GameBoard.jsx
Normal 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} />;
|
||||
};
|
||||
69
assets/js/mine-seeker/components/grid/GridControl.jsx
Normal file
69
assets/js/mine-seeker/components/grid/GridControl.jsx
Normal 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;
|
||||
45
assets/js/mine-seeker/components/grid/GridField.jsx
Normal file
45
assets/js/mine-seeker/components/grid/GridField.jsx
Normal 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;
|
||||
39
assets/js/mine-seeker/components/user/User.jsx
Normal file
39
assets/js/mine-seeker/components/user/User.jsx
Normal 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;
|
||||
43
assets/js/mine-seeker/components/user/UserControl.jsx
Normal file
43
assets/js/mine-seeker/components/user/UserControl.jsx
Normal 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;
|
||||
63
assets/js/mine-seeker/constants.js
Normal file
63
assets/js/mine-seeker/constants.js
Normal 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,
|
||||
})),
|
||||
);
|
||||
233
assets/js/mine-seeker/contexts/GameContext.jsx
Normal file
233
assets/js/mine-seeker/contexts/GameContext.jsx
Normal 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>;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
264
assets/js/mine-seeker/hooks/useServerComm.js
Normal file
264
assets/js/mine-seeker/hooks/useServerComm.js
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -7,6 +7,12 @@ 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: {
|
||||
|
||||
2050
package-lock.json
generated
2050
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -14,17 +14,14 @@
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^5.2.0",
|
||||
"@tanstack/react-query": "^5.97.0",
|
||||
"bootstrap": "3",
|
||||
"buffer": "^5.4.3",
|
||||
"howler": "^2.1.2",
|
||||
"jquery": "^3.4.1",
|
||||
"js-base64": "^2.1.9",
|
||||
"lodash": "^4.18.1",
|
||||
"prop-types": "^15.7.2",
|
||||
"react": "^16.11.0",
|
||||
"react-dom": "^16.11.0",
|
||||
"uglify-js": "^2.7.4",
|
||||
"uglifycss": "0.0.25"
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.0",
|
||||
@@ -38,7 +35,7 @@
|
||||
"babel-loader": "^9.0",
|
||||
"core-js": "^3.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",
|
||||
"globals": "^15.0.0",
|
||||
"sass": "^1.77.0",
|
||||
|
||||
@@ -352,9 +352,10 @@ class TopicManager implements TopicManagerInterface
|
||||
private function getLeftMines(array $grid, array $alreadyRevealed): array
|
||||
{
|
||||
$mines = [];
|
||||
|
||||
foreach ($grid as $r => $row) {
|
||||
foreach ($row as $c => $value) {
|
||||
if ('m' === $value && !isset($alreadyRevealed[$r . ',' . $c])) {
|
||||
if ('m' === $value && !isset($alreadyRevealed["$r,$c"])) {
|
||||
$mines[] = ['row' => $r, 'col' => $c];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,7 +373,7 @@
|
||||
},
|
||||
"files": [
|
||||
"assets/css/app.css",
|
||||
"assets/js/app.js",
|
||||
"assets/js/MineSeeker.js",
|
||||
"config/packages/assets.yaml",
|
||||
"config/packages/prod/webpack_encore.yaml",
|
||||
"config/packages/webpack_encore.yaml",
|
||||
|
||||
@@ -4,59 +4,59 @@ var webpack = require('webpack');
|
||||
// Manually configure the runtime environment if not already configured yet by the "encore" command.
|
||||
// It's useful when you use tools that rely on webpack.config.js file.
|
||||
if (!Encore.isRuntimeEnvironmentConfigured()) {
|
||||
Encore.configureRuntimeEnvironment(process.env.NODE_ENV || 'dev');
|
||||
Encore.configureRuntimeEnvironment(process.env.NODE_ENV || 'dev');
|
||||
}
|
||||
|
||||
// the project directory where compiled assets will be stored
|
||||
Encore
|
||||
.setOutputPath('public/build/')
|
||||
// the public path used by the web server to access the previous directory
|
||||
.setPublicPath('/build')
|
||||
.setOutputPath('public/build/')
|
||||
// the public path used by the web server to access the previous directory
|
||||
.setPublicPath('/build')
|
||||
|
||||
// .cleanupOutputBeforeBuild()
|
||||
.enableBuildNotifications()
|
||||
.enableSourceMaps(!Encore.isProduction())
|
||||
// uncomment to create hashed filenames (e.g. app.abc123.css)
|
||||
.enableVersioning(Encore.isProduction())
|
||||
// .cleanupOutputBeforeBuild()
|
||||
.enableBuildNotifications()
|
||||
.enableSourceMaps(!Encore.isProduction())
|
||||
// uncomment to create hashed filenames (e.g. app.abc123.css)
|
||||
.enableVersioning(Encore.isProduction())
|
||||
|
||||
// .copyFiles([
|
||||
// {from: './public/bundles/goswebsocket/', to: '/[path][name].[ext]', pattern: /\.(js|css|map)$/, includeSubdirectories: false},
|
||||
// ])
|
||||
// .copyFiles([
|
||||
// {from: './public/bundles/goswebsocket/', to: '/[path][name].[ext]', pattern: /\.(js|css|map)$/, includeSubdirectories: false},
|
||||
// ])
|
||||
|
||||
// uncomment to define the assets of the project
|
||||
// .addEntry('js/app', './assets/js/app.js')
|
||||
// .addStyleEntry('css/app', './assets/css/app.scss')
|
||||
// uncomment to define the assets of the project
|
||||
// .addEntry('js/app', './assets/js/MineSeeker.js')
|
||||
// .addStyleEntry('css/app', './assets/css/app.scss')
|
||||
|
||||
// .addEntry('mineseeker', ['babel-polyfill', './assets/js/mine-seeker.js'])
|
||||
.addEntry('mineseeker', './assets/js/mine-seeker.js')
|
||||
.addEntry('mineseekerStyle', './assets/css/style.mineseeker.scss')
|
||||
.addEntry('homeStyle', './assets/css/style.layout.scss')
|
||||
// .addEntry('mineseeker', ['babel-polyfill', './assets/js/MineSeeker.js'])
|
||||
.addEntry('mineseeker', './assets/js/app.jsx')
|
||||
.addEntry('mineseekerStyle', './assets/css/style.mineseeker.scss')
|
||||
.addEntry('homeStyle', './assets/css/style.layout.scss')
|
||||
|
||||
// uncomment if you use Sass/SCSS files
|
||||
.enableSassLoader(options => {
|
||||
options.api = 'modern';
|
||||
options.sassOptions = { silenceDeprecations: ['import'] };
|
||||
})
|
||||
.configureCssLoader(options => {
|
||||
// don't process absolute URLs (e.g. /images/...) — served by the web server
|
||||
options.url = { filter: url => !url.startsWith('/') };
|
||||
})
|
||||
// uncomment if you use Sass/SCSS files
|
||||
.enableSassLoader(options => {
|
||||
options.api = 'modern';
|
||||
options.sassOptions = { silenceDeprecations: ['import'] };
|
||||
})
|
||||
.configureCssLoader(options => {
|
||||
// don't process absolute URLs (e.g. /images/...) — served by the web server
|
||||
options.url = { filter: url => !url.startsWith('/') };
|
||||
})
|
||||
|
||||
// provide $/jQuery as global variables for Bootstrap 3 and legacy code
|
||||
.addPlugin(new webpack.ProvidePlugin({
|
||||
$: 'jquery',
|
||||
jQuery: 'jquery',
|
||||
'window.jQuery': 'jquery',
|
||||
}))
|
||||
// provide $/jQuery as global variables for Bootstrap 3 and legacy code
|
||||
.addPlugin(new webpack.ProvidePlugin({
|
||||
$: 'jquery',
|
||||
jQuery: 'jquery',
|
||||
'window.jQuery': 'jquery',
|
||||
}))
|
||||
|
||||
// .configureBabel(function (babelConfig) {
|
||||
// babelConfig.presets.push('env');
|
||||
// })
|
||||
.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' }];
|
||||
})
|
||||
|
||||
.enableReactPreset()
|
||||
|
||||
// .enableSingleRuntimeChunk()
|
||||
.disableSingleRuntimeChunk()
|
||||
// .enableSingleRuntimeChunk()
|
||||
.disableSingleRuntimeChunk()
|
||||
;
|
||||
|
||||
module.exports = Encore.getWebpackConfig();
|
||||
|
||||
Reference in New Issue
Block a user