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/**'],
|
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: {
|
||||||
|
|||||||
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,
|
"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",
|
||||||
|
|||||||
@@ -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];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -4,59 +4,59 @@ var webpack = require('webpack');
|
|||||||
// Manually configure the runtime environment if not already configured yet by the "encore" command.
|
// 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.
|
// It's useful when you use tools that rely on webpack.config.js file.
|
||||||
if (!Encore.isRuntimeEnvironmentConfigured()) {
|
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
|
// 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'] };
|
||||||
})
|
})
|
||||||
.configureCssLoader(options => {
|
.configureCssLoader(options => {
|
||||||
// don't process absolute URLs (e.g. /images/...) — served by the web server
|
// don't process absolute URLs (e.g. /images/...) — served by the web server
|
||||||
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) {
|
.enableReactPreset()
|
||||||
// babelConfig.presets.push('env');
|
.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();
|
module.exports = Encore.getWebpackConfig();
|
||||||
|
|||||||
Reference in New Issue
Block a user