Private
Public Access
1
0
Files
MineSeeker/assets/js/mine-seeker/app.js

429 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;