From fe2de91e91d338ebfee63375c61707ba6502d3c6 Mon Sep 17 00:00:00 2001 From: Lang <7system7@gmail.com> Date: Fri, 10 Apr 2026 12:23:21 +0200 Subject: [PATCH] chg: dev: outsource the Grid generation and interactions to the backend #4 --- assets/js/mine-seeker/app.js | 180 +++++----- assets/js/mine-seeker/grid/grid-control.js | 373 +++++++-------------- assets/js/mine-seeker/grid/grid.js | 90 ----- src/Controller/MercureController.php | 12 +- src/Entity/Step.php | 26 ++ src/Interfaces/RpcManagerInterface.php | 2 +- src/Interfaces/TopicManagerInterface.php | 2 +- src/Util/RpcManager.php | 161 ++++----- src/Util/TopicManager.php | 294 ++++++++++++++-- 9 files changed, 586 insertions(+), 554 deletions(-) delete mode 100644 assets/js/mine-seeker/grid/grid.js diff --git a/assets/js/mine-seeker/app.js b/assets/js/mine-seeker/app.js index c142249..c124aa7 100644 --- a/assets/js/mine-seeker/app.js +++ b/assets/js/mine-seeker/app.js @@ -1,5 +1,4 @@ import React from 'react'; -import Grid from './grid/grid'; import GridControl from './grid/grid-control'; class MineSeeker extends React.Component { @@ -41,54 +40,10 @@ class MineSeeker extends React.Component { .css('line-height', ($field.width() - 2) + 'px'); } - /** - * STEP - */ - makePointsCalcAndStep(coords) { - let users = this.refs.gridControl.refs.userControl, - activePlayer = users.state.activePlayer ? 'blue' : 'red', - inactivePlayer = users.state.activePlayer ? 'red' : 'blue', - redPoints = 'red' === activePlayer - ? users.refs[activePlayer].state.mines - : users.refs[inactivePlayer].state.mines, - bluePoints = 'blue' === activePlayer - ? users.refs[activePlayer].state.mines - : users.refs[inactivePlayer].state.mines; - - this.refs.gridControl.stepEvent(coords); - - let mineCache = this.refs.gridControl.state.foundUserMineCache; - redPoints += 'red' === activePlayer ? mineCache : 0; - bluePoints += 'blue' === activePlayer ? mineCache : 0; - - return { red: redPoints, blue: bluePoints }; - } - - /** - * START - */ - 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 }); - } - /** * THE END */ - makeGameEndIfItEnds(bluePoints, redPoints, resign = false) { + makeGameEndIfItEnds(bluePoints, redPoints, resign = false, leftMines = []) { let redWins = 25 < redPoints, blueWins = 25 < bluePoints; @@ -103,7 +58,7 @@ class MineSeeker extends React.Component { }); } - this.refs.gridControl.showLeftMines(); + 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: '' }); @@ -196,16 +151,19 @@ class MineSeeker extends React.Component { }); } + /** + * 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 + ' has been stepped to coords: ' + payload.data.coords[0] + ', ' + payload.data.coords[1]); - 'dev' === this.state.env && console.warn('Opponent stepped: Auto-Step process'); + '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 }); - - let points = this.makePointsCalcAndStep(payload.data.coords); - this.makeGameEndIfItEnds(points.blue, points.red); + this.refs.gridControl.applyStep(payload.data); + this.makeGameEndIfItEnds(payload.data.bluePoints, payload.data.redPoints, false, payload.data.leftMines); } else { this.resignProcess(payload.data.resign); } @@ -216,13 +174,8 @@ class MineSeeker extends React.Component { // Mercure / SSE connection // ------------------------------------------------------------------ // - /** - * Dispatches every incoming SSE message. - * Distinguishes subscription events, game-step events, and disconnect events - * using the same payload shape as the former WAMP broadcast. - */ - handleMercureMessage(payload) { - let isTopicEvent = 'undefined' !== typeof payload.data; + handleMercureMessage(payload) { + let isTopicEvent = 'undefined' !== typeof payload.data; let isNotUnsubscribe = 'undefined' === typeof payload.msg; if (isTopicEvent) { @@ -244,9 +197,9 @@ class MineSeeker extends React.Component { } openEventSource() { - const wrapper = document.getElementById('mine-wrapper'); - const hubUrl = wrapper.dataset.mercureHubUrl; - const subscriberJwt = wrapper.dataset.mercureSubscriberJwt; + 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); @@ -279,9 +232,16 @@ class MineSeeker extends React.Component { }; } - wInit(gridServer, gridClient) { + /** + * 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: this.state.gameInherited ? gridServer : gridClient, + grid: emptyGrid, channel: this.state.channel, desc: { buddy: ( @@ -314,9 +274,30 @@ class MineSeeker extends React.Component { ) : '', 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, { @@ -325,13 +306,13 @@ class MineSeeker extends React.Component { }).catch(e => 'dev' === this.state.env && console.error('Join error', e)); } - /** POST /api/game/step — persist a move and fan it out via Mercure */ + /** 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), - }).catch(e => 'dev' === this.state.env && console.error('Step error', e)); + }); } // ------------------------------------------------------------------ // @@ -340,16 +321,14 @@ class MineSeeker extends React.Component { async componentDidMount() { if (!this.state.connectionLost) { - let gridClient = this.state.gameInherited ? null : new Grid().state.grid; - try { if (this.state.gameInherited) { - /** Fetch existing grid and player info */ - const resp = await fetch('/api/game/connect/' + this.state.gameAssoc); - const b64 = await resp.text(); + /** 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.grid || null === serverData.grid) { + if ('undefined' === typeof serverData.users || null === serverData.users) { this.refs.gridControl.setState({ overlay: true, overlayTitle: 'This channel does not exists!', @@ -361,21 +340,18 @@ class MineSeeker extends React.Component { this.rpcUsers = serverData.users; this.openEventSource(); - this.wInit(serverData.grid, null); + this.wInit(serverData.revealedCells || []); } else { - /** Create the game record with this client's grid */ + /** Create the game record — the server generates the grid */ await fetch('/api/game/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - grid: window.btoa(JSON.stringify(gridClient)), - gameAssoc: this.state.gameAssoc, - }), + body: JSON.stringify({ gameAssoc: this.state.gameAssoc }), }); this.openEventSource(); - this.wInit(null, gridClient); + this.wInit(); } 'dev' === this.state.env && console.info('Connection initialised — joining channel'); @@ -403,29 +379,37 @@ class MineSeeker extends React.Component { this.setState({ stepCache: cache }); } - onClick(coords) { + async onClick(coords) { let activePlayer = this.refs.gridControl.refs.userControl.state.activePlayer ? 'blue' : 'red'; - if (this.refs.gridControl.checkFieldHasBeenNeverClicked(coords[0], coords[1])) { - if (activePlayer === this.refs.gridControl.state.webPlayer) { - let points = this.makePointsCalcAndStep(coords); - this.makeGameEndIfItEnds(points.blue, points.red); + if (!this.refs.gridControl.checkFieldHasBeenNeverClicked(coords[0], coords[1])) { + return; + } - let dataPack = { - coords: coords, - player: activePlayer, - bomb: this.refs.gridControl.refs.userControl.state.bombSelected, - redPoints: points.red, - bluePoints: points.blue, - resign: null, - redExplodedBomb: 'red' === activePlayer && this.refs.gridControl.refs.userControl.state.bombSelected, - blueExplodedBomb: 'blue' === activePlayer && this.refs.gridControl.refs.userControl.state.bombSelected, - }; + if (activePlayer !== this.refs.gridControl.state.webPlayer) { + return; + } - !this.state.connectionLost - ? this.publishStep(dataPack) - : this.cachePublish(dataPack); - } + 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); } } diff --git a/assets/js/mine-seeker/grid/grid-control.js b/assets/js/mine-seeker/grid/grid-control.js index 7a7de85..449c497 100644 --- a/assets/js/mine-seeker/grid/grid-control.js +++ b/assets/js/mine-seeker/grid/grid-control.js @@ -20,10 +20,7 @@ class GridControl extends React.Component { desc: null, renderGridFields: false, gridFields: [], - updatedFieldCache: [], bombFieldCache: [], - foundUserMineCache: 0, - playBomb: false, overlay: false, overlayTitle: '', overlaySubTitle: '', @@ -45,18 +42,13 @@ class GridControl extends React.Component { return 'gridField_' + row + '_' + col; } - checkMine(row, col) { - return 'undefined' !== typeof this.state.grid[row] && 'undefined' !== typeof this.state.grid[row][col] && 'm' !== this.state.grid[row][col]; - } - checkFieldHasBeenNeverClicked(row, col) { - return 0 > this.state.updatedFieldCache.indexOf(this.refString(row, col)) && !this.refs[this.refString(row, col)].state.active; + 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 the (5x5) target not fits the grid */ if (!isBombTargetCenter) { col = 2 > col ? 2 : col; row = 2 > row ? 2 : row; @@ -82,53 +74,6 @@ class GridControl extends React.Component { ]; } - getNeighbourRadius(row, col) { - return [ - [row - 1, col], [row - 1, col - 1], [row - 1, col + 1], [row, col - 1], [row, col + 1], [row + 1, col], - [row + 1, col + 1], [row + 1, col - 1], - ]; - } - - checkNeighbourItem(row, col) { - if (this.checkMine(row, col)) { - var currentField = this.refs[this.refString(row, col)]; - - /** - * It must be cached because the GridField.state not updated until - * all showAppropriateFields() method runned out!! - */ - if (this.checkFieldHasBeenNeverClicked(row, col)) { - this.state.updatedFieldCache.push(this.refString(row, col)); - - currentField.setState({ - currentImage: this.state.grid[row][col], - currentObj: this.state.grid[row][col], - active: true, - }); - - if (0 === this.state.grid[row][col]) { - return { - row: row, - col: col, - }; - } - } - } - - return false; - } - - checkNeighbours(row, col) { - let anotherFields = [], - neighbours = this.getNeighbourRadius(row, col); - - for (let i = 0, j = neighbours.length; i < j; i++) { - anotherFields.push(this.checkNeighbourItem(neighbours[i][0], neighbours[i][1])); - } - - return anotherFields; - } - bombClear() { if (this.state.bombFieldCache.length) { for (var i = 0, j = this.state.bombFieldCache.length; i < j; i++) { @@ -152,246 +97,152 @@ class GridControl extends React.Component { } } - showLeftMines() { - 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++) { - let currentField = this.refs[this.refString(i, k)]; - - if ('m' === this.state.grid[i][k] && this.checkFieldHasBeenNeverClicked(i, k)) { - currentField.setState({ - currentImage: currentField.state.icons.root + currentField.state.icons.left, - }); - } + /** + * 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(idx, max, currentObject) { + /** set __ACTIVE__ player in the UserControl */ + changePlayer() { var userControl = this.refs.userControl, activePlayer = userControl.state.activePlayer ? 'blue' : 'red', inactivePlayer = userControl.state.activePlayer ? 'red' : 'blue'; - if ( - userControl.state.bombSelected - && idx === (max - 1) - || !idx - && !userControl.state.bombSelected - && 'm' !== currentObject - ) { - userControl.setState({ - activePlayer: userControl.state.activePlayer ? 0 : 1, - }); + userControl.setState({ activePlayer: userControl.state.activePlayer ? 0 : 1 }); - /** the desc is inversely because the user.active is not changed yet !!! */ - userControl.refs[activePlayer].setState({ - active: false, - desc: '', - }); + 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, + }); + } - userControl.refs[inactivePlayer].setState({ + /** + * 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, - desc: activePlayer === this.state.webPlayer - ? this.state.desc.buddy - : this.state.desc.you, + }); + } 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, }); } } /** - * Show all fields that needed after click - * - * @param currentField - * @param row - * @param col + * Apply a full step response from the server. + * stepData shape: { coords, player, bomb, revealedCells, minesFound, redPoints, bluePoints, resign, gameOver, + * leftMines } */ - showAppropriateFields(currentField, row, col) { - currentField.setState({ - currentObj: this.state.grid[row][col], - active: true, + 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); }); - if (this.checkFieldHasBeenNeverClicked(row, col)) { - this.state.updatedFieldCache.push(this.refString(row, col)); - } + // Update scores + let userControl = this.refs.userControl; + let inactivePlayer = 'red' === player ? 'blue' : 'red'; - if (0 === this.state.grid[row][col]) { - let neighbours = this.checkNeighbours(row, col); - - neighbours - .filter(i => false !== i) - .forEach(element => { - let currentField = this.refs[this.refString(element.row, element.col)]; - this.showAppropriateFields(currentField, element.row, element.col); + 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, }); - } - } - - /** - * Player control method - * - * @param currentObject {int|string} Current object from Grid class - * @param row {int} - * @param col {int} - * @param justOnFirstIteration {int} When bomb is being used check the whole explosion area - */ - handleGridField(currentObject, row, col, justOnFirstIteration = 0) { - var userControl = this.refs.userControl, - gridFieldControl = this.refs[this.refString(row, col)], - activePlayer = userControl.state.activePlayer ? 'blue' : 'red', - inactivePlayer = userControl.state.activePlayer ? 'red' : 'blue'; - - /** if the clicked field is NEVER CLICKED */ - if (this.checkFieldHasBeenNeverClicked(row, col)) { - /** update LAST CLICKED grid field */ - if (!justOnFirstIteration) { - if (null !== this.state.lastClicked[activePlayer]) { - this.refs[this.refString(this.state.lastClicked[activePlayer][0], this.state.lastClicked[activePlayer][1])].setState({ - lastClickedRed: false, - lastClickedBlue: false, - }); - } - } - - this.state.lastClicked[activePlayer] = [row, col]; - - /** if you found mine */ - if ('m' === currentObject) { - this.state.foundUserMineCache++; - - if (!justOnFirstIteration) { - /** set last clicked field w/ color */ - this.state.lastClicked[activePlayer] = [row, col]; - - this.state.sound[ - 20 < (userControl.refs[activePlayer].state.mines + this.state.foundUserMineCache) - ? 'warning' - : 'mine' - ].play(); - } - - /** set current image in field */ - gridFieldControl.setState({ - currentImage: gridFieldControl.state.icons.root + gridFieldControl.state.icons.flag[activePlayer], - }); - } else { - this.state.sound.click.play(); - - /** set current image in field - WHEN it is a number */ - if (!isNaN(currentObject)) { - gridFieldControl.setState({ - currentImage: currentObject, - }); - } - } - - /** - * set bombs status - we must add one mine (currentObject === 'm' ? 1 : 0) to current mine - * when it found NOW because the status is not refreshed unless the handleGridField() ends - */ - userControl.refs[activePlayer].setState({ - enabledBomb: userControl.refs[activePlayer].state.mines + ('m' === currentObject ? 1 : 0) <= userControl.refs[inactivePlayer].state.mines, }); + } - userControl.refs[inactivePlayer].setState({ - enabledBomb: userControl.refs[activePlayer].state.mines + ('m' === currentObject ? 1 : 0) >= userControl.refs[inactivePlayer].state.mines, - }); + // 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 }); - /** set-up last clicked */ - if (!justOnFirstIteration) { - gridFieldControl.setState({ - lastClickedRed: 'red' === activePlayer, - lastClickedBlue: 'blue' === activePlayer, - }); - } + // 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(); } } /** - * Show elems w/ conditions - * - * @param row - * @param col - * @param idx - * @param max - */ - show(row, col, idx = 0, max = 0) { - this.handleGridField(this.state.grid[row][col], row, col, idx); - this.showAppropriateFields(this.refs[this.refString(row, col)], row, col); - this.changePlayer(idx, max, this.state.grid[row][col]); - } - - /** - * STEP one - * - * @param coords - */ - stepEvent(coords) { - /** if the clicked field is NEVER CLICKED */ - if (this.checkFieldHasBeenNeverClicked(coords[0], coords[1])) { - var activePlayer = this.refs.userControl.state.activePlayer ? 'blue' : 'red'; - - this.state.foundUserMineCache = 0; - this.state.playBomb = true; - - /** Show elements */ - if (this.refs.userControl.state.bombSelected) { - this.state.sound.bomb.play(); - - var bombRadius = this.getBombRadius(coords[0], coords[1]); - for (var i = 0, j = bombRadius.length; i < j; i++) { - this.show(bombRadius[i][0], bombRadius[i][1], i, j); - } - - /** remove BOMB from activePlayer */ - this.refs.userControl.refs[activePlayer].setState({ - haveBomb: false, - }); - } else { - this.show(coords[0], coords[1]); - } - - /** Mine score handling */ - if (this.state.foundUserMineCache) { - this.refs.userControl.setState({ - mines: this.refs.userControl.state.mines - this.state.foundUserMineCache, - foundMines: true, - }, () => { - /** because of CSS animation in .found-mine */ - setTimeout(() => this.refs.userControl.setState({ foundMines: false }), 500); - - /** add the found mines to the active Player */ - this.refs.userControl.refs[activePlayer].setState({ - mines: this.refs.userControl.refs[activePlayer].state.mines + this.state.foundUserMineCache, - }); - }); - } - - /** Reset BOMB status */ - if (this.refs.userControl.state.bombSelected) { - /** reset bomb selected status */ - this.refs.userControl.setState({ bombSelected: false }); - - /** clear cache, reset symbols */ - this.bombClear(); - } - } - } - - /** - * On Hover when you want to drop BOMB - * Target grid field - * @param coords + * 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) { - /** clear cache, reset symbols */ this.bombClear(); - - /** new cache && field activate */ this.bombCreate(coords[0], coords[1]); } else { this.refs.userControl.setState({ bombSelected: false }); diff --git a/assets/js/mine-seeker/grid/grid.js b/assets/js/mine-seeker/grid/grid.js deleted file mode 100644 index 7ca5c7b..0000000 --- a/assets/js/mine-seeker/grid/grid.js +++ /dev/null @@ -1,90 +0,0 @@ -import React from 'react'; - -class Grid extends React.Component { - constructor() { - super(); - - this.state = { - row: 16, - col: 16, - mines: 51, - set: [], - }; - - this.state.grid = this.numberingGrid( - this.createGrid( - this.shuffleSet( - this.createSet( - this.state.set, - ), - ), - ), - ); - } - - createSet(obj) { - for (let i = 0, j = this.state.row * this.state.col; i < j; i++) { - obj.push(0 < this.state.mines ? 'm' : 'w'); - this.state.mines--; - } - - return obj; - } - - shuffleSet(obj) { - return obj.sort(function() { - return Math.round(Math.random()) - .5; - }); - } - - createGrid(obj) { - let grid = [[]], - row = 0, - col = 0; - - for (let i = 0, j = obj.length; i < j; i++) { - grid[row][col] = obj[i]; - - if (15 === col && 15 !== row) { - row++; - col = 0; - grid.push([]); - } else { - col++; - } - } - - return grid; - } - - isThereMine(obj, row, col) { - return 'm' === obj?.[row]?.[col] ? 1 : 0; - } - - numberingGrid(obj) { - let nbr = 0; - - for (let i = 0; i < this.state.row; i++) { - for (let j = 0; j < this.state.col; j++) { - if ('w' === obj[i][j]) { - nbr = 0; - - nbr += this.isThereMine(obj, i - 1, j); - nbr += this.isThereMine(obj, i - 1, j - 1); - nbr += this.isThereMine(obj, i - 1, j + 1); - nbr += this.isThereMine(obj, i, j - 1); - nbr += this.isThereMine(obj, i, j + 1); - nbr += this.isThereMine(obj, i + 1, j); - nbr += this.isThereMine(obj, i + 1, j + 1); - nbr += this.isThereMine(obj, i + 1, j - 1); - - obj[i][j] = nbr; - } - } - } - - return obj; - } -} - -export default Grid; diff --git a/src/Controller/MercureController.php b/src/Controller/MercureController.php index af1f6c0..77b2c03 100644 --- a/src/Controller/MercureController.php +++ b/src/Controller/MercureController.php @@ -39,11 +39,11 @@ class MercureController extends AbstractController ) { } - /** POST /api/game/start — save the grid and create the PlayedGame record */ + /** POST /api/game/start — generate the grid on the server and create the PlayedGame record */ public function start(Request $request): JsonResponse { - $data = $request->toArray(); - $result = $this->rpcManager->saveGrid([$data['grid'], $data['gameAssoc']]); + $data = $request->toArray(); + $result = $this->rpcManager->saveGrid($data['gameAssoc']); return $this->json(['success' => $result]); } @@ -64,12 +64,12 @@ class MercureController extends AbstractController return $this->json(['success' => true]); } - /** POST /api/game/step/{gameAssoc} — persist the step and broadcast game event via Mercure */ + /** POST /api/game/step/{gameAssoc} — persist the step, broadcast via Mercure, and return revealed cells */ public function step(string $gameAssoc, Request $request): JsonResponse { - $this->topicManager->publish($gameAssoc, $this->resolveUserName($request), $request->toArray()); + $result = $this->topicManager->publish($gameAssoc, $this->resolveUserName($request), $request->toArray()); - return $this->json(['success' => true]); + return $this->json($result); } /** POST /api/game/leave/{gameAssoc} — broadcast disconnect event via Mercure */ diff --git a/src/Entity/Step.php b/src/Entity/Step.php index 77430f0..209c206 100644 --- a/src/Entity/Step.php +++ b/src/Entity/Step.php @@ -44,6 +44,12 @@ class Step #[Column(nullable: true)] private ?bool $wBomb = null; + #[Column(length: 10, nullable: true)] + private ?string $player = null; + + #[Column(type: Types::JSON, nullable: true)] + private ?array $revealedCells = null; + #[ManyToOne(inversedBy: 'steps')] private ?PlayedGame $playedGame = null; @@ -86,6 +92,26 @@ class Step $this->wBomb = $wBomb; } + public function getPlayer(): ?string + { + return $this->player; + } + + public function setPlayer(?string $player): void + { + $this->player = $player; + } + + public function getRevealedCells(): ?array + { + return $this->revealedCells; + } + + public function setRevealedCells(?array $revealedCells): void + { + $this->revealedCells = $revealedCells; + } + public function getPlayedGame(): ?PlayedGame { return $this->playedGame; diff --git a/src/Interfaces/RpcManagerInterface.php b/src/Interfaces/RpcManagerInterface.php index 1a47b17..fc07209 100644 --- a/src/Interfaces/RpcManagerInterface.php +++ b/src/Interfaces/RpcManagerInterface.php @@ -24,5 +24,5 @@ interface RpcManagerInterface { public function getConnectInformation($params): string; - public function saveGrid($data): bool; + public function saveGrid(string $gameAssoc): bool; } diff --git a/src/Interfaces/TopicManagerInterface.php b/src/Interfaces/TopicManagerInterface.php index da8378e..76e1c98 100644 --- a/src/Interfaces/TopicManagerInterface.php +++ b/src/Interfaces/TopicManagerInterface.php @@ -28,5 +28,5 @@ interface TopicManagerInterface public function unSubscribe(string $gameAssoc, string $userName): void; - public function publish(string $gameAssoc, string $userName, array $event): void; + public function publish(string $gameAssoc, string $userName, array $event): array; } diff --git a/src/Util/RpcManager.php b/src/Util/RpcManager.php index 49faf15..1284d3a 100644 --- a/src/Util/RpcManager.php +++ b/src/Util/RpcManager.php @@ -33,6 +33,10 @@ use RuntimeException; */ class RpcManager implements RpcManagerInterface { + private const ROWS = 16; + private const COLS = 16; + private const MINES = 51; + public function __construct( private readonly EntityManagerInterface $entityManager, private readonly LoggerInterface $logger, @@ -42,54 +46,61 @@ class RpcManager implements RpcManagerInterface public function getConnectInformation($params): string { $gameAssoc = is_array($params) ? $params[0] : $params; - $grid = $this->getGrid($gameAssoc); - $users = null !== $grid ? $this->getUsers($gameAssoc) : null; + + $playedGame = $this->entityManager + ->getRepository(PlayedGame::class) + ->findOneByGameAssoc($gameAssoc); + + if (null === $playedGame) { + try { + return base64_encode(json_encode([ + 'users' => null, + 'revealedCells' => null, + ], JSON_THROW_ON_ERROR)); + } catch (JsonException $e) { + throw new RuntimeException($e->getMessage()); + } + } + + $users = $this->getUserCollection($playedGame); + $revealedCells = $this->aggregateRevealedCells($playedGame); try { return base64_encode(json_encode([ - 'grid' => $grid, - 'users' => $users, - ], JSON_THROW_ON_ERROR, 512)); + 'users' => $users, + 'revealedCells' => $revealedCells, + ], JSON_THROW_ON_ERROR)); } catch (JsonException $e) { throw new RuntimeException($e->getMessage()); } } - public function saveGrid($data): bool + public function saveGrid(string $gameAssoc): bool { $existingGame = $this->entityManager ->getRepository(PlayedGame::class) - ->findOneByGameAssoc($data[1]); + ->findOneByGameAssoc($gameAssoc); if (null !== $existingGame) { return true; } + $grid2d = $this->generateGrid(); $playedGame = new PlayedGame(); - $grid = new Grid(); - try { - $rows = json_decode(base64_decode($data[0]), true, 512, JSON_THROW_ON_ERROR); - } catch (JsonException $e) { - throw new RuntimeException($e->getMessage()); - } + $grid = new Grid(); try { - foreach ($rows as $row) { + foreach ($grid2d as $row) { $gridRow = new GridRow(); - $gridRow->setGridCol($row); - - /** Save Row */ $gridRow->setGrid($grid); $this->entityManager->persist($gridRow); } - /** Save Grid */ $grid->setPlayedGame($playedGame); $this->entityManager->persist($grid); - /** Save PlayedGame */ - $playedGame->setGameAssoc($data[1]); + $playedGame->setGameAssoc($gameAssoc); $playedGame->setGrid($grid); $playedGame->setCreated(new DateTime()); $playedGame->setUpdated(new DateTime()); @@ -104,77 +115,75 @@ class RpcManager implements RpcManagerInterface } /** - * It gets the current Grid by PlayedGame/gameAssoc - * - * @param $gameAssoc - * - * @return array + * Generate a random 16×16 grid with 51 mines and adjacent-mine numbers. */ - private function getGrid($gameAssoc): ?array + private function generateGrid(): array { - $gridCols = array(); + // Build flat set: 51 mines ('m') + remaining water ('w') + $set = array_merge( + array_fill(0, self::MINES, 'm'), + array_fill(0, self::ROWS * self::COLS - self::MINES, 'w'), + ); - try { - $this->entityManager->clear(); - - /** @var PlayedGame $playedGame */ - $playedGame = $this->entityManager - ->getRepository(PlayedGame::class) - ->findOneByGameAssoc($gameAssoc); - - if (null === $playedGame) { - return null; - } - - if (null === $rows = $playedGame->getGrid()) { - return null; - } - - $rows = $rows->getGridRow(); - - /** @var GridRow $row */ - foreach ($rows as $row) { - $gridCols[] = $row->getGridCol(); - } - - return $gridCols; - } catch (Exception $e) { - $this->logger->error($e->getMessage()); + // Fisher-Yates shuffle + for ($i = count($set) - 1; $i > 0; $i--) { + $j = random_int(0, $i); + [$set[$i], $set[$j]] = [$set[$j], $set[$i]]; } - return null; + // Reshape to 2-D + $grid = []; + for ($r = 0; $r < self::ROWS; $r++) { + $grid[$r] = array_slice($set, $r * self::COLS, self::COLS); + } + + // Replace 'w' with adjacent-mine count + $dirs = [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1]]; + for ($r = 0; $r < self::ROWS; $r++) { + for ($c = 0; $c < self::COLS; $c++) { + if ('w' !== $grid[$r][$c]) { + continue; + } + $count = 0; + foreach ($dirs as [$dr, $dc]) { + if (isset($grid[$r + $dr][$c + $dc]) && 'm' === $grid[$r + $dr][$c + $dc]) { + $count++; + } + } + $grid[$r][$c] = $count; + } + } + + return $grid; } /** - * Get the Users by PlayedGame - * - * @param $gameAssoc - * - * @return array + * Collect all cells revealed so far, enriched with the player colour from each Step. */ - private function getUsers($gameAssoc): array + private function aggregateRevealedCells(PlayedGame $playedGame): array { - return $this->getUserCollection( - $this->entityManager - ->getRepository(PlayedGame::class) - ->findOneByGameAssoc($gameAssoc) - ); + $all = []; + + foreach ($playedGame->getSteps() as $step) { + if (null === $step->getRevealedCells()) { + continue; + } + $player = $step->getPlayer(); + foreach ($step->getRevealedCells() as $cell) { + $all[] = array_merge($cell, ['player' => $player]); + } + } + + return $all; } - /** - * Get user collection from PlayedGame entity - * - * @param PlayedGame $playedGame - * - * @return array - */ private function getUserCollection(PlayedGame $playedGame): array { return [ - 'red' => null !== $playedGame->getRed() ? $playedGame->getRed()->getUsername() : '', - 'blue' => null !== $playedGame->getBlue() ? $playedGame->getBlue()->getUsername() : '', + 'red' => null !== $playedGame->getRed() ? $playedGame->getRed()->getUsername() : '', + 'blue' => null !== $playedGame->getBlue() ? $playedGame->getBlue()->getUsername() : '', 'redAnon' => null !== $playedGame->getRedAnon() ? $playedGame->getRedAnon()->getUserName() : '', - 'blueAnon' => null !== $playedGame->getBlueAnon() ? $playedGame->getBlueAnon()->getUserName() : '', + 'blueAnon' => null !== $playedGame->getBlueAnon()? $playedGame->getBlueAnon()->getUserName(): '', ]; } -} +} \ No newline at end of file diff --git a/src/Util/TopicManager.php b/src/Util/TopicManager.php index 07665cb..4d515e0 100644 --- a/src/Util/TopicManager.php +++ b/src/Util/TopicManager.php @@ -11,6 +11,7 @@ namespace App\Util; use App\Entity\Gamer; +use App\Entity\GridRow; use App\Entity\PlayedGame; use App\Entity\Step; use App\Entity\User; @@ -51,8 +52,8 @@ class TopicManager implements TopicManagerInterface return; } - $users = $this->getUserCollection($playedGame); - $count = $this->getPlayerCount($users); + $users = $this->getUserCollection($playedGame); + $count = $this->getPlayerCount($users); $isKnown = in_array($userName, array_filter(array_values($users)), true); /** Reject a third player who is not a reconnecting player */ @@ -94,17 +95,97 @@ class TopicManager implements TopicManagerInterface )); } - public function publish(string $gameAssoc, string $userName, array $event): void + /** + * Resolve the revealed cells for a step, persist it, and broadcast via Mercure. + * Returns the step result data (same shape as what is broadcast in `data`). + */ + public function publish(string $gameAssoc, string $userName, array $event): array { - null === $event['resign'] - ? $this->saveStepToDb($gameAssoc, $event) - : $this->saveResignToDb($gameAssoc, $event['resign']); + if (null !== $event['resign']) { + $this->saveResignToDb($gameAssoc, $event['resign']); + + $playedGame = $this->getPlayedGame($gameAssoc); + $users = $this->getUserCollection($playedGame); + $count = $this->getPlayerCount($users); + $topic = 'mineseeker/channel/' . $gameAssoc; + + $data = ['resign' => $event['resign'], 'coords' => null]; + + try { + $this->hub->publish(new Update( + $topic, + json_encode([ + 'userTopicId' => $userName, + 'channel' => $topic, + 'user' => $userName, + 'userCnt' => $count, + 'data' => $data, + ], JSON_THROW_ON_ERROR) + )); + } catch (JsonException $e) { + throw new RuntimeException($e->getMessage()); + } + + return $data; + } + + // ------------------------------------------------------------------ // + // Normal move + // ------------------------------------------------------------------ // + $coords = $event['coords']; + $player = $event['player']; // 'red' | 'blue' + $isBomb = (bool) $event['bomb']; $playedGame = $this->getPlayedGame($gameAssoc); + $grid = $this->loadGrid($gameAssoc); + + // Cells already revealed by previous steps (as "row,col" => true map) + $alreadyRevealed = $this->buildRevealedMap($playedGame); + + // Determine which cells to reveal for this step + if ($isBomb) { + $revealedCells = $this->getBombRevealedCells($grid, $coords[0], $coords[1], $alreadyRevealed); + } elseif ('m' === ($grid[$coords[0]][$coords[1]] ?? null)) { + // Direct click on a mine — reveal it immediately (flood-fill skips mines) + $revealedCells = [['row' => $coords[0], 'col' => $coords[1], 'value' => 'm']]; + } else { + $revealedCells = $this->floodFill($grid, $coords[0], $coords[1], $alreadyRevealed); + } + + $minesFound = count(array_filter($revealedCells, static fn($c) => 'm' === $c['value'])); + $redPoints = ($playedGame->getRedPoints() ?? 0) + ('red' === $player ? $minesFound : 0); + $bluePoints = ($playedGame->getBluePoints() ?? 0) + ('blue' === $player ? $minesFound : 0); + $gameOver = $redPoints > 25 || $bluePoints > 25; + + // Reveal remaining mines when the game ends + $leftMines = []; + if ($gameOver) { + $finalRevealed = $alreadyRevealed; + foreach ($revealedCells as $c) { + $finalRevealed[$c['row'] . ',' . $c['col']] = true; + } + $leftMines = $this->getLeftMines($grid, $finalRevealed); + } + + $this->saveStepToDb($gameAssoc, $event, $player, $revealedCells, $redPoints, $bluePoints); + $users = $this->getUserCollection($playedGame); $count = $this->getPlayerCount($users); $topic = 'mineseeker/channel/' . $gameAssoc; + $data = [ + 'coords' => $coords, + 'player' => $player, + 'bomb' => $isBomb, + 'revealedCells' => $revealedCells, + 'minesFound' => $minesFound, + 'redPoints' => $redPoints, + 'bluePoints' => $bluePoints, + 'resign' => null, + 'gameOver' => $gameOver, + 'leftMines' => $leftMines, + ]; + try { $this->hub->publish(new Update( $topic, @@ -113,14 +194,179 @@ class TopicManager implements TopicManagerInterface 'channel' => $topic, 'user' => $userName, 'userCnt' => $count, - 'data' => $event, + 'data' => $data, ], JSON_THROW_ON_ERROR) )); } catch (JsonException $e) { throw new RuntimeException($e->getMessage()); } + + return $data; } + // ------------------------------------------------------------------ // + // Grid helpers + // ------------------------------------------------------------------ // + + /** Load the grid rows from the database as a 2-D array. */ + private function loadGrid(string $gameAssoc): array + { + $playedGame = $this->getPlayedGame($gameAssoc); + $gridEntity = $playedGame?->getGrid(); + + if (null === $gridEntity) { + return []; + } + + $grid = []; + /** @var GridRow $row */ + foreach ($gridEntity->getGridRow() as $row) { + $grid[] = $row->getGridCol(); + } + + return $grid; + } + + /** + * BFS flood-fill starting at (row, col). + * Reveals the clicked cell plus all connected zero-value cells and their non-mine borders. + * Mines are never added to the result. + * + * @param array $visited Map of "row,col" already revealed; updated in-place. + */ + private function floodFill(array $grid, int $row, int $col, array &$visited): array + { + $cells = []; + $queue = [[$row, $col]]; + $dirs = [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1]]; + + while (!empty($queue)) { + [$r, $c] = array_shift($queue); + $key = $r . ',' . $c; + + if (isset($visited[$key])) { + continue; + } + $visited[$key] = true; + + if (!isset($grid[$r][$c])) { + continue; + } + + $value = $grid[$r][$c]; + + // Mines are never cascade-revealed + if ('m' === $value) { + continue; + } + + $cells[] = ['row' => $r, 'col' => $c, 'value' => $value]; + + // Only expand neighbours for zero-cells + if (0 === $value) { + foreach ($dirs as [$dr, $dc]) { + $nr = $r + $dr; + $nc = $c + $dc; + $nKey = $nr . ',' . $nc; + if (!isset($visited[$nKey]) && isset($grid[$nr][$nc])) { + $queue[] = [$nr, $nc]; + } + } + } + } + + return $cells; + } + + /** + * Compute the 25-cell bomb radius (mirrors the JS getBombRadius logic). + */ + private function getBombRadius(int $row, int $col): array + { + $max = 13; // grid is 16×16 (0–15); clamped centre must be in 2–13 + if (!($row > 1 && $row < 14 && $col > 1 && $col < 14)) { + $row = max(2, min($row, $max)); + $col = max(2, min($col, $max)); + } + + 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], + ]; + } + + /** + * Reveal cells hit by a bomb. Direct mine hits are revealed (flagged); + * non-mine cells trigger a normal flood-fill (so zero-cells still cascade). + */ + private function getBombRevealedCells(array $grid, int $row, int $col, array $alreadyRevealed): array + { + $bombCells = $this->getBombRadius($row, $col); + $visited = $alreadyRevealed; + $cells = []; + + foreach ($bombCells as [$r, $c]) { + $key = $r . ',' . $c; + if (isset($visited[$key]) || !isset($grid[$r][$c])) { + continue; + } + + if ('m' === $grid[$r][$c]) { + $visited[$key] = true; + $cells[] = ['row' => $r, 'col' => $c, 'value' => 'm']; + } else { + // flood-fill handles the zero-cascade and deduplication via $visited + $newCells = $this->floodFill($grid, $r, $c, $visited); + $cells = array_merge($cells, $newCells); + } + } + + return $cells; + } + + /** + * Build a "row,col" => true map from every previously saved step. + */ + private function buildRevealedMap(PlayedGame $playedGame): array + { + $map = []; + foreach ($playedGame->getSteps() as $step) { + foreach ($step->getRevealedCells() ?? [] as $cell) { + $map[$cell['row'] . ',' . $cell['col']] = true; + } + } + + return $map; + } + + /** + * Return coordinates of mines that have NOT yet been revealed. + * + * @param array $alreadyRevealed + */ + private function getLeftMines(array $grid, array $alreadyRevealed): array + { + $mines = []; + foreach ($grid as $r => $row) { + foreach ($row as $c => $value) { + if ('m' === $value && !isset($alreadyRevealed[$r . ',' . $c])) { + $mines[] = ['row' => $r, 'col' => $c]; + } + } + } + + return $mines; + } + + // ------------------------------------------------------------------ // + // Database helpers + // ------------------------------------------------------------------ // + private function getPlayedGame(string $gameAssoc): ?PlayedGame { return $this->entityManager @@ -130,7 +376,7 @@ class TopicManager implements TopicManagerInterface private function getPlayerCount(array $users): int { - $red = '' !== $users['red'] || '' !== $users['redAnon'] ? 1 : 0; + $red = '' !== $users['red'] || '' !== $users['redAnon'] ? 1 : 0; $blue = '' !== $users['blue'] || '' !== $users['blueAnon'] ? 1 : 0; return $red + $blue; @@ -139,30 +385,36 @@ class TopicManager implements TopicManagerInterface private function saveResignToDb(string $gameAssoc, string $color): void { $playedGame = $this->getPlayedGame($gameAssoc); - $playedGame->setResign($color); - $this->entityManager->persist($playedGame); $this->entityManager->flush(); } - private function saveStepToDb(string $gameAssoc, array $event): void - { + private function saveStepToDb( + string $gameAssoc, + array $event, + string $player, + array $revealedCells, + int $redPoints, + int $bluePoints, + ): void { try { $playedGame = $this->getPlayedGame($gameAssoc); $step = new Step(); $step->setRow($event['coords'][0]); $step->setCol($event['coords'][1]); - $step->setWBomb($event['bomb']); + $step->setWBomb((bool) $event['bomb']); + $step->setPlayer($player); + $step->setRevealedCells($revealedCells); $step->setPlayedGame($playedGame); $step->setCreated(new DateTime()); $this->entityManager->persist($step); - $playedGame->setBluePoints($event['bluePoints']); - $playedGame->setRedPoints($event['redPoints']); - $playedGame->setBlueExplodedBomb($event['blueExplodedBomb'] ? true : null); - $playedGame->setRedExplodedBomb($event['redExplodedBomb'] ? true : null); + $playedGame->setRedPoints($redPoints); + $playedGame->setBluePoints($bluePoints); + $playedGame->setRedExplodedBomb((bool) $event['bomb'] && 'red' === $player ? true : null); + $playedGame->setBlueExplodedBomb((bool) $event['bomb'] && 'blue' === $player ? true : null); $playedGame->setUpdated(new DateTime()); $this->entityManager->persist($playedGame); @@ -231,10 +483,10 @@ class TopicManager implements TopicManagerInterface private function getUserCollection(PlayedGame $playedGame): array { return [ - 'red' => null !== $playedGame->getRed() ? $playedGame->getRed()->getUsername() : '', - 'blue' => null !== $playedGame->getBlue() ? $playedGame->getBlue()->getUsername() : '', - 'redAnon' => null !== $playedGame->getRedAnon() ? $playedGame->getRedAnon()->getUserName() : '', + 'red' => null !== $playedGame->getRed() ? $playedGame->getRed()->getUsername() : '', + 'blue' => null !== $playedGame->getBlue() ? $playedGame->getBlue()->getUsername() : '', + 'redAnon' => null !== $playedGame->getRedAnon() ? $playedGame->getRedAnon()->getUserName() : '', 'blueAnon' => null !== $playedGame->getBlueAnon() ? $playedGame->getBlueAnon()->getUserName() : '', ]; } -} +} \ No newline at end of file