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: (
Yes No!
), }); } } // ------------------------------------------------------------------ // // 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: (
Your buddy is
making a
move.
), you: (
It is your turn!
Make a move.
), }, overlay: true, overlayTitle: 'We are waiting for your opponent...', overlaySubTitle: this.state.gameAssoc ? (

Share this unique link w/ your opponent

Play w/ me!
) : '', 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: Restart game!, }); 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 ( ); } } export default MineSeeker;