diff --git a/client/src/config/globals.js b/client/src/config/globals.js index ebca36d..590c9a7 100644 --- a/client/src/config/globals.js +++ b/client/src/config/globals.js @@ -17,7 +17,8 @@ export const globals = { REVEAL_PLAYER: 'revealPlayer', TRANSFER_MODERATOR: 'transferModerator', CHANGE_NAME: 'changeName', - END_GAME: 'endGame' + END_GAME: 'endGame', + END_TIMER: 'endTimer' }, STATUS: { LOBBY: 'lobby', @@ -49,7 +50,8 @@ export const globals = { SYNC_GAME_STATE: 'syncGameState', START_TIMER: 'startTimer', PLAYER_LEFT: 'playerLeft', - NEW_SPECTATOR: 'newSpectator' + NEW_SPECTATOR: 'newSpectator', + RESTART_GAME: 'restartGame' }, USER_TYPES: { MODERATOR: 'moderator', diff --git a/client/src/images/play-pause-placeholder.svg b/client/src/images/play-pause-placeholder.svg new file mode 100644 index 0000000..8186e53 --- /dev/null +++ b/client/src/images/play-pause-placeholder.svg @@ -0,0 +1,7 @@ + + + Layer 1 + + + + diff --git a/client/src/images/shuffle.svg b/client/src/images/shuffle.svg new file mode 100644 index 0000000..54a4a2e --- /dev/null +++ b/client/src/images/shuffle.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/src/modules/front_end_components/Confirmation.js b/client/src/modules/front_end_components/Confirmation.js index 17cb9da..83e8785 100644 --- a/client/src/modules/front_end_components/Confirmation.js +++ b/client/src/modules/front_end_components/Confirmation.js @@ -1,16 +1,20 @@ import { toast } from './Toast.js'; -export const Confirmation = (message, onYes) => { +export const Confirmation = (message, onYes = null) => { document.querySelector('#confirmation')?.remove(); document.querySelector('#confirmation-background')?.remove(); let confirmation = document.createElement('div'); confirmation.setAttribute('id', 'confirmation'); - confirmation.innerHTML = - `
+ confirmation.innerHTML = onYes + ? `
+
` + : `
+
+
`; confirmation.querySelector('#confirmation-message').innerText = message; @@ -54,8 +58,13 @@ export const Confirmation = (message, onYes) => { }); }; - confirmation.querySelector('#confirmation-cancel-button').addEventListener('click', cancelHandler); - confirmation.querySelector('#confirmation-yes-button').addEventListener('click', confirmHandler); + if (onYes) { + confirmation.querySelector('#confirmation-cancel-button').addEventListener('click', cancelHandler); + confirmation.querySelector('#confirmation-yes-button').addEventListener('click', confirmHandler); + } else { // we are only displaying a message for them to acknowledge, so the yes button should dispel the confirmation + confirmation.querySelector('#confirmation-yes-button').addEventListener('click', cancelHandler); + } + background.addEventListener('click', cancelHandler); document.body.appendChild(background); diff --git a/client/src/modules/front_end_components/HTMLFragments.js b/client/src/modules/front_end_components/HTMLFragments.js index 6a918e9..6939d57 100644 --- a/client/src/modules/front_end_components/HTMLFragments.js +++ b/client/src/modules/front_end_components/HTMLFragments.js @@ -44,8 +44,8 @@ export const HTMLFragments = {

All players must join to start.

`, - END_GAME_PROMPT: - `
+ GAME_CONTROL_PROMPT: + `
`, PLAYER_GAME_VIEW: @@ -105,7 +105,7 @@ export const HTMLFragments = {
- +
@@ -216,22 +216,16 @@ export const HTMLFragments = { `

🏁 The moderator has ended the game. Roles are revealed.

-
- -
-
- - - -
+ + + +
`, - RESTART_GAME_BUTTON: - '', CREATE_GAME_DECK: `
diff --git a/client/src/modules/game_state/states/Ended.js b/client/src/modules/game_state/states/Ended.js index ea4e9fd..773c184 100644 --- a/client/src/modules/game_state/states/Ended.js +++ b/client/src/modules/game_state/states/Ended.js @@ -1,9 +1,6 @@ import { globals } from '../../../config/globals.js'; import { HTMLFragments } from '../../front_end_components/HTMLFragments.js'; -import { XHRUtility } from '../../utility/XHRUtility.js'; -import { UserUtility } from '../../utility/UserUtility.js'; -import { toast } from '../../front_end_components/Toast.js'; -import { Confirmation } from '../../front_end_components/Confirmation.js'; +import { SharedStateUtil } from './shared/SharedStateUtil.js'; export class Ended { constructor (containerId, stateBucket, socket) { @@ -11,25 +8,6 @@ export class Ended { this.socket = socket; this.container = document.getElementById(containerId); this.container.innerHTML = HTMLFragments.END_OF_GAME_VIEW; - this.restartGameHandler = () => { - XHRUtility.xhr( - '/api/games/' + this.stateBucket.currentGameState.accessCode + '/restart', - 'PATCH', - null, - JSON.stringify({ - playerName: this.stateBucket.currentGameState.client.name, - accessCode: this.stateBucket.currentGameState.accessCode, - sessionCookie: UserUtility.validateAnonUserSignature(globals.ENVIRONMENT.LOCAL), - localCookie: UserUtility.validateAnonUserSignature(globals.ENVIRONMENT.PRODUCTION) - }) - ) - .then((res) => { - toast('Game restarted!', 'success', true, true, 'medium'); - }) - .catch((res) => { - toast(res.content, 'error', true, true, 'medium'); - }); - }; } renderEndOfGame (gameState) { @@ -37,15 +15,7 @@ export class Ended { gameState.client.userType === globals.USER_TYPES.MODERATOR || gameState.client.userType === globals.USER_TYPES.TEMPORARY_MODERATOR ) { - const restartGameContainer = document.createElement('div'); - restartGameContainer.innerHTML = HTMLFragments.RESTART_GAME_BUTTON; - const button = restartGameContainer.querySelector('#restart-game'); - button.addEventListener('click', () => { - Confirmation('Restart the game, dealing everyone new roles?', () => { - this.restartGameHandler(); - }); - }); - document.getElementById('end-of-game-buttons').prepend(restartGameContainer); + document.getElementById('end-of-game-buttons').prepend(SharedStateUtil.createRestartButton(this.stateBucket)); } this.renderPlayersWithRoleInformation(); } diff --git a/client/src/modules/game_state/states/InProgress.js b/client/src/modules/game_state/states/InProgress.js index 1bd83d5..f690ca8 100644 --- a/client/src/modules/game_state/states/InProgress.js +++ b/client/src/modules/game_state/states/InProgress.js @@ -5,15 +5,13 @@ import { Confirmation } from '../../front_end_components/Confirmation.js'; import { ModalManager } from '../../front_end_components/ModalManager.js'; import { GameTimerManager } from '../../timer/GameTimerManager.js'; import { stateBucket } from '../StateBucket.js'; +import { SharedStateUtil } from './shared/SharedStateUtil.js'; export class InProgress { constructor (containerId, stateBucket, socket) { this.stateBucket = stateBucket; this.socket = socket; this.container = document.getElementById(containerId); - this.components = { - - }; this.killPlayerHandlers = {}; this.revealRoleHandlers = {}; this.transferModHandlers = {}; @@ -191,6 +189,15 @@ export class InProgress { } }); + if (this.socket.hasListeners(globals.EVENT_IDS.NEW_SPECTATOR)) { + this.socket.removeAllListeners(globals.EVENT_IDS.NEW_SPECTATOR); + } + + this.socket.on(globals.EVENT_IDS.NEW_SPECTATOR, (spectator) => { + stateBucket.currentGameState.spectators.push(spectator); + this.displayAvailableModerators(); + }); + if (this.stateBucket.currentGameState.timerParams) { const timerWorker = new Worker(new URL('../../timer/Timer.js', import.meta.url)); const gameTimerManager = new GameTimerManager(stateBucket, this.socket); @@ -407,9 +414,9 @@ function removeExistingPlayerElements (killPlayerHandlers, revealRoleHandlers) { } function createEndGamePromptComponent (socket, stateBucket) { - if (document.querySelector('#end-game-prompt') === null) { + if (document.querySelector('#game-control-prompt') === null) { const div = document.createElement('div'); - div.innerHTML = HTMLFragments.END_GAME_PROMPT; + div.innerHTML = HTMLFragments.GAME_CONTROL_PROMPT; div.querySelector('#end-game-button').addEventListener('click', (e) => { e.preventDefault(); Confirmation('End the game?', () => { @@ -419,11 +426,12 @@ function createEndGamePromptComponent (socket, stateBucket) { stateBucket.currentGameState.accessCode, null, () => { - document.querySelector('#end-game-prompt')?.remove(); + document.querySelector('#game-control-prompt')?.remove(); } ); }); }); + div.querySelector('#game-control-prompt').prepend(SharedStateUtil.createRestartButton(stateBucket)); document.getElementById('game-content').appendChild(div); } } diff --git a/client/src/modules/game_state/states/shared/SharedStateUtil.js b/client/src/modules/game_state/states/shared/SharedStateUtil.js new file mode 100644 index 0000000..10f32dd --- /dev/null +++ b/client/src/modules/game_state/states/shared/SharedStateUtil.js @@ -0,0 +1,240 @@ +import { XHRUtility } from '../../../utility/XHRUtility.js'; +import { UserUtility } from '../../../utility/UserUtility.js'; +import { globals } from '../../../../config/globals.js'; +import { toast } from '../../../front_end_components/Toast.js'; +import { Confirmation } from '../../../front_end_components/Confirmation.js'; +import { Lobby } from '../Lobby.js'; +import { stateBucket } from '../../StateBucket.js'; +import { InProgress } from '../InProgress.js'; +import { Ended } from '../Ended.js'; +import { HTMLFragments } from '../../../front_end_components/HTMLFragments.js'; +import { ModalManager } from '../../../front_end_components/ModalManager.js'; + +// This constant is meant to house logic that is utilized by more than one game state +export const SharedStateUtil = { + restartHandler: (stateBucket) => { + XHRUtility.xhr( + '/api/games/' + stateBucket.currentGameState.accessCode + '/restart', + 'PATCH', + null, + JSON.stringify({ + playerName: stateBucket.currentGameState.client.name, + accessCode: stateBucket.currentGameState.accessCode, + sessionCookie: UserUtility.validateAnonUserSignature(globals.ENVIRONMENT.LOCAL), + localCookie: UserUtility.validateAnonUserSignature(globals.ENVIRONMENT.PRODUCTION) + }) + ) + .then((res) => {}) + .catch((res) => { + toast(res.content, 'error', true, true, 'medium'); + }); + }, + + createRestartButton: (stateBucket) => { + const restartGameButton = document.createElement('button'); + restartGameButton.classList.add('app-button'); + restartGameButton.setAttribute('id', 'restart-game-button'); + restartGameButton.innerText = 'Restart'; + restartGameButton.addEventListener('click', () => { + Confirmation('Restart the game, dealing everyone new roles?', () => { + SharedStateUtil.restartHandler(stateBucket); + }); + }); + + return restartGameButton; + }, + + setClientSocketHandlers: (stateBucket, socket) => { + const commonAckLogic = (gameState) => { + stateBucket.currentGameState = gameState; + processGameState( + stateBucket.currentGameState, + gameState.client.cookie, + socket, + true, + true + ); + }; + const startGameStateAckFn = (gameState) => { + commonAckLogic(gameState); + toast('Game started!', 'success'); + }; + + const restartGameStateAckFn = (gameState) => { + commonAckLogic(gameState); + toast('Game restarted!', 'success'); + }; + + const fetchGameStateHandler = (ackFn) => { + socket.emit( + globals.SOCKET_EVENTS.IN_GAME_MESSAGE, + globals.EVENT_IDS.FETCH_GAME_STATE, + stateBucket.currentGameState.accessCode, + { personId: stateBucket.currentGameState.client.cookie }, + ackFn + ); + }; + + socket.on(globals.EVENT_IDS.START_GAME, () => { fetchGameStateHandler(startGameStateAckFn); }); + + socket.on(globals.EVENT_IDS.RESTART_GAME, () => { fetchGameStateHandler(restartGameStateAckFn); }); + + socket.on(globals.EVENT_IDS.SYNC_GAME_STATE, () => { + socket.emit( + globals.SOCKET_EVENTS.IN_GAME_MESSAGE, + globals.EVENT_IDS.FETCH_GAME_STATE, + stateBucket.currentGameState.accessCode, + { personId: stateBucket.currentGameState.client.cookie }, + function (gameState) { + stateBucket.currentGameState = gameState; + processGameState( + stateBucket.currentGameState, + gameState.client.cookie, + socket, + true, + true + ); + } + ); + }); + + socket.on(globals.COMMANDS.END_GAME, (people) => { + stateBucket.currentGameState.people = people; + stateBucket.currentGameState.status = globals.STATUS.ENDED; + processGameState( + stateBucket.currentGameState, + stateBucket.currentGameState.client.cookie, + socket, + true, + true + ); + }); + }, + + syncWithGame: (stateBucket, socket, cookie, window) => { + const splitUrl = window.location.href.split('/game/'); + const accessCode = splitUrl[1]; + if (/^[a-zA-Z0-9]+$/.test(accessCode) && accessCode.length === globals.ACCESS_CODE_LENGTH) { + socket.emit(globals.SOCKET_EVENTS.IN_GAME_MESSAGE, globals.EVENT_IDS.FETCH_GAME_STATE, accessCode, { personId: cookie }, function (gameState) { + if (gameState === null) { + window.location = '/not-found?reason=' + encodeURIComponent('game-not-found'); + } else { + stateBucket.currentGameState = gameState; + document.querySelector('.spinner-container')?.remove(); + document.querySelector('.spinner-background')?.remove(); + document.getElementById('game-content').innerHTML = HTMLFragments.INITIAL_GAME_DOM; + toast('You are connected.', 'success', true, true, 'short'); + processGameState(stateBucket.currentGameState, cookie, socket, true, true); + } + }); + } else { + window.location = '/not-found?reason=' + encodeURIComponent('invalid-access-code'); + } + } +}; + +function processGameState ( + currentGameState, + userId, + socket, + refreshPrompt = true, + animateContainer = false +) { + const containerAnimation = document.getElementById('game-state-container').animate( + [ + { opacity: '0', transform: 'translateY(10px)' }, + { opacity: '1', transform: 'translateY(0px)' } + ], { + duration: 500, + easing: 'ease-in-out', + fill: 'both' + }); + if (animateContainer) { + containerAnimation.play(); + } + + displayClientInfo(currentGameState.client.name, currentGameState.client.userType); + + switch (currentGameState.status) { + case globals.STATUS.LOBBY: + const lobby = new Lobby('game-state-container', stateBucket, socket); + if (refreshPrompt) { + lobby.removeStartGameFunctionalityIfPresent(); + } + lobby.populateHeader(); + lobby.populatePlayers(); + lobby.setSocketHandlers(); + if (( + currentGameState.client.userType === globals.USER_TYPES.MODERATOR + || currentGameState.client.userType === globals.USER_TYPES.TEMPORARY_MODERATOR + ) + && refreshPrompt + ) { + lobby.displayStartGamePromptForModerators(); + } + break; + case globals.STATUS.IN_PROGRESS: + if (refreshPrompt) { + document.querySelector('#game-control-prompt')?.remove(); + } + const inProgressGame = new InProgress('game-state-container', stateBucket, socket); + inProgressGame.setSocketHandlers(); + inProgressGame.setUserView(currentGameState.client.userType); + break; + case globals.STATUS.ENDED: { + const ended = new Ended('game-state-container', stateBucket, socket); + ended.renderEndOfGame(currentGameState); + break; + } + default: + break; + } + + activateRoleInfoButton(stateBucket.currentGameState.deck); +} + +function activateRoleInfoButton (deck) { + deck.sort((a, b) => { + return a.team === globals.ALIGNMENT.GOOD ? -1 : 1; + }); + document.getElementById('role-info-button').addEventListener('click', (e) => { + e.preventDefault(); + document.getElementById('role-info-prompt').innerHTML = HTMLFragments.ROLE_INFO_MODAL; + const modalContent = document.getElementById('game-role-info-container'); + for (const card of deck) { + const roleDiv = document.createElement('div'); + const roleNameDiv = document.createElement('div'); + + roleNameDiv.classList.add('role-info-name'); + + const roleName = document.createElement('h5'); + const roleQuantity = document.createElement('h5'); + const roleDescription = document.createElement('p'); + + roleDescription.innerText = card.description; + roleName.innerText = card.role; + roleQuantity.innerText = card.quantity + 'x'; + + if (card.team === globals.ALIGNMENT.GOOD) { + roleName.classList.add(globals.ALIGNMENT.GOOD); + } else { + roleName.classList.add(globals.ALIGNMENT.EVIL); + } + + roleNameDiv.appendChild(roleQuantity); + roleNameDiv.appendChild(roleName); + + roleDiv.appendChild(roleNameDiv); + roleDiv.appendChild(roleDescription); + + modalContent.appendChild(roleDiv); + } + ModalManager.displayModal('role-info-modal', 'role-info-modal-background', 'close-role-info-modal-button'); + }); +} + +function displayClientInfo (name, userType) { + document.getElementById('client-name').innerText = name; + document.getElementById('client-user-type').innerText = userType; + document.getElementById('client-user-type').innerText += globals.USER_TYPE_ICONS[userType]; +} diff --git a/client/src/modules/page_handlers/gameHandler.js b/client/src/modules/page_handlers/gameHandler.js index b2421ea..1dcf371 100644 --- a/client/src/modules/page_handlers/gameHandler.js +++ b/client/src/modules/page_handlers/gameHandler.js @@ -2,12 +2,7 @@ import { injectNavbar } from '../front_end_components/Navbar.js'; import { stateBucket } from '../game_state/StateBucket.js'; import { UserUtility } from '../utility/UserUtility.js'; import { toast } from '../front_end_components/Toast.js'; -import { globals } from '../../config/globals.js'; -import { HTMLFragments } from '../front_end_components/HTMLFragments.js'; -import { ModalManager } from '../front_end_components/ModalManager.js'; -import { Lobby } from '../game_state/states/Lobby.js'; -import { InProgress } from '../game_state/states/InProgress.js'; -import { Ended } from '../game_state/states/Ended.js'; +import { SharedStateUtil } from '../game_state/states/shared/SharedStateUtil.js'; export const gameHandler = async (socket, XHRUtility, window, gameDOM) => { document.body.innerHTML = gameDOM + document.body.innerHTML; @@ -25,7 +20,7 @@ export const gameHandler = async (socket, XHRUtility, window, gameDOM) => { stateBucket.environment = response.content; socket.on('connect', function () { - syncWithGame( + SharedStateUtil.syncWithGame( stateBucket, socket, UserUtility.validateAnonUserSignature(response.content), @@ -41,185 +36,5 @@ export const gameHandler = async (socket, XHRUtility, window, gameDOM) => { toast('Disconnected. Attempting reconnect...', 'error', true, false); }); - setClientSocketHandlers(stateBucket, socket); + SharedStateUtil.setClientSocketHandlers(stateBucket, socket); }; - -function syncWithGame (stateBucket, socket, cookie, window) { - const splitUrl = window.location.href.split('/game/'); - const accessCode = splitUrl[1]; - if (/^[a-zA-Z0-9]+$/.test(accessCode) && accessCode.length === globals.ACCESS_CODE_LENGTH) { - socket.emit(globals.SOCKET_EVENTS.IN_GAME_MESSAGE, globals.EVENT_IDS.FETCH_GAME_STATE, accessCode, { personId: cookie }, function (gameState) { - if (gameState === null) { - window.location = '/not-found?reason=' + encodeURIComponent('game-not-found'); - } else { - stateBucket.currentGameState = gameState; - document.querySelector('.spinner-container')?.remove(); - document.querySelector('.spinner-background')?.remove(); - document.getElementById('game-content').innerHTML = HTMLFragments.INITIAL_GAME_DOM; - toast('You are connected.', 'success', true, true, 'short'); - processGameState(stateBucket.currentGameState, cookie, socket, true, true); - } - }); - } else { - window.location = '/not-found?reason=' + encodeURIComponent('invalid-access-code'); - } -} - -function processGameState ( - currentGameState, - userId, - socket, - refreshPrompt = true, - animateContainer = false -) { - const containerAnimation = document.getElementById('game-state-container').animate( - [ - { opacity: '0', transform: 'translateY(10px)' }, - { opacity: '1', transform: 'translateY(0px)' } - ], { - duration: 500, - easing: 'ease-in-out', - fill: 'both' - }); - if (animateContainer) { - containerAnimation.play(); - } - - displayClientInfo(currentGameState.client.name, currentGameState.client.userType); - - switch (currentGameState.status) { - case globals.STATUS.LOBBY: - const lobby = new Lobby('game-state-container', stateBucket, socket); - if (refreshPrompt) { - lobby.removeStartGameFunctionalityIfPresent(); - } - lobby.populateHeader(); - lobby.populatePlayers(); - lobby.setSocketHandlers(); - if (( - currentGameState.client.userType === globals.USER_TYPES.MODERATOR - || currentGameState.client.userType === globals.USER_TYPES.TEMPORARY_MODERATOR - ) - && refreshPrompt - ) { - lobby.displayStartGamePromptForModerators(); - } - break; - case globals.STATUS.IN_PROGRESS: - if (refreshPrompt) { - document.querySelector('#end-game-prompt')?.remove(); - } - const inProgressGame = new InProgress('game-state-container', stateBucket, socket); - inProgressGame.setSocketHandlers(); - inProgressGame.setUserView(currentGameState.client.userType); - break; - case globals.STATUS.ENDED: { - const ended = new Ended('game-state-container', stateBucket, socket); - ended.renderEndOfGame(currentGameState); - break; - } - default: - break; - } - - activateRoleInfoButton(stateBucket.currentGameState.deck); -} - -function activateRoleInfoButton (deck) { - deck.sort((a, b) => { - return a.team === globals.ALIGNMENT.GOOD ? -1 : 1; - }); - document.getElementById('role-info-button').addEventListener('click', (e) => { - e.preventDefault(); - document.getElementById('role-info-prompt').innerHTML = HTMLFragments.ROLE_INFO_MODAL; - const modalContent = document.getElementById('game-role-info-container'); - for (const card of deck) { - const roleDiv = document.createElement('div'); - const roleNameDiv = document.createElement('div'); - - roleNameDiv.classList.add('role-info-name'); - - const roleName = document.createElement('h5'); - const roleQuantity = document.createElement('h5'); - const roleDescription = document.createElement('p'); - - roleDescription.innerText = card.description; - roleName.innerText = card.role; - roleQuantity.innerText = card.quantity + 'x'; - - if (card.team === globals.ALIGNMENT.GOOD) { - roleName.classList.add(globals.ALIGNMENT.GOOD); - } else { - roleName.classList.add(globals.ALIGNMENT.EVIL); - } - - roleNameDiv.appendChild(roleQuantity); - roleNameDiv.appendChild(roleName); - - roleDiv.appendChild(roleNameDiv); - roleDiv.appendChild(roleDescription); - - modalContent.appendChild(roleDiv); - } - ModalManager.displayModal('role-info-modal', 'role-info-modal-background', 'close-role-info-modal-button'); - }); -} - -function displayClientInfo (name, userType) { - document.getElementById('client-name').innerText = name; - document.getElementById('client-user-type').innerText = userType; - document.getElementById('client-user-type').innerText += globals.USER_TYPE_ICONS[userType]; -} - -// Should be reserved for socket events not specific to any one game state (Lobby, In Progress, etc.) -function setClientSocketHandlers (stateBucket, socket) { - socket.on(globals.EVENT_IDS.START_GAME, () => { - socket.emit( - globals.SOCKET_EVENTS.IN_GAME_MESSAGE, - globals.EVENT_IDS.FETCH_GAME_STATE, - stateBucket.currentGameState.accessCode, - { personId: stateBucket.currentGameState.client.cookie }, - function (gameState) { - stateBucket.currentGameState = gameState; - processGameState( - stateBucket.currentGameState, - gameState.client.cookie, - socket, - true, - true - ); - } - ); - }); - - socket.on(globals.EVENT_IDS.SYNC_GAME_STATE, () => { - socket.emit( - globals.SOCKET_EVENTS.IN_GAME_MESSAGE, - globals.EVENT_IDS.FETCH_GAME_STATE, - stateBucket.currentGameState.accessCode, - { personId: stateBucket.currentGameState.client.cookie }, - function (gameState) { - stateBucket.currentGameState = gameState; - processGameState( - stateBucket.currentGameState, - gameState.client.cookie, - socket, - true, - true - ); - } - ); - }); - - socket.on(globals.COMMANDS.END_GAME, (people) => { - stateBucket.currentGameState.people = people; - stateBucket.currentGameState.status = globals.STATUS.ENDED; - processGameState( - stateBucket.currentGameState, - stateBucket.currentGameState.client.cookie, - socket, - true, - true - ); - }); -} diff --git a/client/src/modules/timer/GameTimerManager.js b/client/src/modules/timer/GameTimerManager.js index ef43c5a..e44d8cd 100644 --- a/client/src/modules/timer/GameTimerManager.js +++ b/client/src/modules/timer/GameTimerManager.js @@ -1,4 +1,5 @@ import { globals } from '../../config/globals.js'; +import { Confirmation } from '../front_end_components/Confirmation.js'; export class GameTimerManager { constructor (stateBucket, socket) { @@ -88,11 +89,21 @@ export class GameTimerManager { } displayExpiredTime () { - const currentBtn = document.querySelector('#play-pause img'); - if (currentBtn) { - currentBtn.removeEventListener('click', this.pauseListener); - currentBtn.removeEventListener('click', this.playListener); - currentBtn.remove(); + if (this.stateBucket.currentGameState.client.userType === globals.USER_TYPES.TEMPORARY_MODERATOR + || this.stateBucket.currentGameState.client.userType === globals.USER_TYPES.MODERATOR) { + const currentBtn = document.querySelector('#timer-container-moderator #play-pause img'); + if (currentBtn) { + currentBtn.removeEventListener('click', this.pauseListener); + currentBtn.removeEventListener('click', this.playListener); + currentBtn.classList.add('disabled'); + currentBtn.setAttribute('src', '/images/play-pause-placeholder.svg'); + } else { + document.querySelector('#play-pause-placeholder')?.remove(); + const placeholderBtn = document.createElement('img'); + placeholderBtn.setAttribute('src', '../images/play-pause-placeholder.svg'); + placeholderBtn.classList.add('disabled'); + document.getElementById('play-pause').appendChild(placeholderBtn); + } } const timer = document.getElementById('game-timer'); @@ -123,6 +134,12 @@ export class GameTimerManager { } }); } + + if (!socket.hasListeners(globals.COMMANDS.END_TIMER)) { + socket.on(globals.COMMANDS.END_TIMER, () => { + Confirmation('The timer has expired!'); + }); + } } swapToPlayButton () { diff --git a/client/src/styles/confirmation.css b/client/src/styles/confirmation.css index 512d035..9bf69a2 100644 --- a/client/src/styles/confirmation.css +++ b/client/src/styles/confirmation.css @@ -34,7 +34,7 @@ margin: 1em 0 2em 0; } -.confirmation-buttons button { +.confirmation-buttons button, .confirmation-buttons-centered button { min-width: 5em; } @@ -43,6 +43,11 @@ justify-content: space-between; } +.confirmation-buttons-centered { + display: flex; + justify-content: center; +} + #confirmation-cancel-button { background-color: #762323 !important; } diff --git a/client/src/styles/game.css b/client/src/styles/game.css index 2bcaa95..d68457a 100644 --- a/client/src/styles/game.css +++ b/client/src/styles/game.css @@ -58,11 +58,13 @@ max-width: 17em; } -#restart-game { - background-color: #0078D7; - min-width: 10em; - margin-bottom: 1em !important; - animation: shadow-pulse 1.5s infinite ease-out; +#restart-game-button, #mod-transfer-button { + background-color: #045EA6; +} + +#restart-game-button:hover, #mod-transfer-button:hover { + background-color: #0078D773; + border: 2px solid #045EA6; } #play-pause-placeholder { @@ -130,7 +132,15 @@ h1 { #end-of-game-header button { margin: 0.5em; min-width: 12em; + font-size: 18px; } + +#end-of-game-header #restart-game-button { + margin-bottom: 1em !important; + animation: shadow-pulse 1.5s infinite ease-out; + padding: 10px; +} + .potential-moderator { display: flex; color: #d7d7d7; @@ -194,7 +204,7 @@ h1 { color: #21ba45; } -#role-info-button img { +#role-info-button img, #mod-transfer-button img { height: 25px; margin-left: 10px; } @@ -443,7 +453,7 @@ label[for='moderator'] { font-size: 30px; } -#start-game-prompt, #end-game-prompt { +#start-game-prompt, #game-control-prompt { padding: 0.5em 0; display: flex; flex-direction: column; @@ -466,6 +476,34 @@ label[for='moderator'] { background-color: #1b1a24; } +#game-control-prompt { + padding: 0.5em 0; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + position: fixed; + z-index: 3; + font-family: 'signika-negative', sans-serif; + font-weight: 100; + box-shadow: 0 -6px 15px rgba(0, 0, 0, 0.5); + left: 0; + right: 0; + bottom: 0; + border-radius: 3px; + /* width: fit-content; */ + font-size: 20px; + height: 100px; + margin: 0 auto; + max-width: 100%; + background-color: #1b1a24; +} + +#game-control-prompt button { + margin: 0 15px; + min-width: 5em; +} + #start-game-prompt p { color: whitesmoke; font-size: 15px; @@ -478,11 +516,11 @@ label[for='moderator'] { justify-content: space-evenly; } -#end-game-prompt { +#game-control-prompt { box-shadow: 0 -6px 40px black; } -#start-game-button, #end-game-button { +#start-game-button, #end-game-button, #restart-game-button { font-family: 'signika-negative', sans-serif !important; padding: 10px; border-radius: 3px; @@ -777,7 +815,7 @@ canvas { } @media(max-width: 800px) { - #start-game-prompt, #end-game-prompt { + #start-game-prompt, #game-control-prompt { border-radius: 0; width: 100%; bottom: 0; @@ -840,11 +878,11 @@ canvas { font-size: 20px; } - #start-game-prompt, #end-game-prompt { + #start-game-prompt, #game-control-prompt { height: 65px; } - #start-game-button, #end-game-button { + #start-game-button, #end-game-button, #restart-game-button { font-size: 20px; padding: 5px; } diff --git a/package.json b/package.json index 928130e..e8f6eab 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "start:dev": "NODE_ENV=development && webpack --config client/webpack/webpack-dev.config.js --mode=development && nodemon index.js", "start:dev:no-hot-reload": "NODE_ENV=development && webpack --config client/webpack/webpack-dev.config.js --mode=development && node index.js", "start:dev:windows": "SET NODE_ENV=development && webpack --config client/webpack/webpack-dev.config.js --mode=development && nodemon index.js", + "start:dev:windows:no-hot-reload:debug": "SET NODE_ENV=development && webpack --config client/webpack/webpack-dev.config.js --mode=development && node --inspect index.js", "start:dev:windows:no-hot-reload": "SET NODE_ENV=development && webpack --config client/webpack/webpack-dev.config.js --mode=development && node index.js", "start": "NODE_ENV=production node index.js -- loglevel=debug", "start:windows": "SET NODE_ENV=production && node index.js -- loglevel=debug port=8080", diff --git a/server/config/globals.js b/server/config/globals.js index 77d0dec..e8972f8 100644 --- a/server/config/globals.js +++ b/server/config/globals.js @@ -34,7 +34,8 @@ const globals = { REVEAL_PLAYER: 'revealPlayer', TRANSFER_MODERATOR: 'transferModerator', CHANGE_NAME: 'changeName', - END_GAME: 'endGame' + END_GAME: 'endGame', + RESTART_GAME: 'restartGame' }, MESSAGES: { ENTER_NAME: 'Client must enter name.' diff --git a/server/modules/ActiveGameRunner.js b/server/modules/ActiveGameRunner.js index 1749bee..fc7c2f7 100644 --- a/server/modules/ActiveGameRunner.js +++ b/server/modules/ActiveGameRunner.js @@ -21,11 +21,13 @@ class ActiveGameRunner { this.logger.debug('running game ' + game.accessCode); const gameProcess = fork(path.join(__dirname, '/GameProcess.js')); this.timerThreads[game.accessCode] = gameProcess; + this.logger.debug('game ' + game.accessCode + ' now associated with subProcess ' + gameProcess.pid); gameProcess.on('message', (msg) => { switch (msg.command) { case globals.GAME_PROCESS_COMMANDS.END_TIMER: game.timerParams.paused = false; game.timerParams.timeRemaining = 0; + namespace.in(game.accessCode).emit(globals.GAME_PROCESS_COMMANDS.END_TIMER); this.logger.trace('PARENT: END TIMER'); break; case globals.GAME_PROCESS_COMMANDS.PAUSE_TIMER: @@ -51,9 +53,8 @@ class ActiveGameRunner { } }); - gameProcess.on('exit', () => { - this.logger.debug('Game ' + game.accessCode + ' timer has expired.'); - delete this.timerThreads[game.accessCode]; + gameProcess.on('exit', (code, signal) => { + this.logger.debug('Game timer thread ' + gameProcess.pid + ' exiting with code ' + code + ' - game ' + game.accessCode); }); gameProcess.send({ command: globals.GAME_PROCESS_COMMANDS.START_TIMER, diff --git a/server/modules/GameManager.js b/server/modules/GameManager.js index c10834b..23b0e89 100644 --- a/server/modules/GameManager.js +++ b/server/modules/GameManager.js @@ -71,7 +71,7 @@ class GameManager { pauseTimer = (game, logger) => { const thread = this.activeGameRunner.timerThreads[game.accessCode]; - if (thread) { + if (thread && !thread.killed) { this.logger.debug('Timer thread found for game ' + game.accessCode); thread.send({ command: globals.GAME_PROCESS_COMMANDS.PAUSE_TIMER, @@ -83,7 +83,7 @@ class GameManager { resumeTimer = (game, logger) => { const thread = this.activeGameRunner.timerThreads[game.accessCode]; - if (thread) { + if (thread && !thread.killed) { this.logger.debug('Timer thread found for game ' + game.accessCode); thread.send({ command: globals.GAME_PROCESS_COMMANDS.RESUME_TIMER, @@ -95,14 +95,14 @@ class GameManager { getTimeRemaining = (game, socket) => { const thread = this.activeGameRunner.timerThreads[game.accessCode]; - if (thread) { + if (thread && (!thread.killed && thread.exitCode === null)) { thread.send({ command: globals.GAME_PROCESS_COMMANDS.GET_TIME_REMAINING, accessCode: game.accessCode, socketId: socket.id, logLevel: this.logger.logLevel }); - } else { + } else if (thread) { if (game.timerParams && game.timerParams.timeRemaining === 0) { this.namespace.to(socket.id).emit(globals.GAME_PROCESS_COMMANDS.GET_TIME_REMAINING, game.timerParams.timeRemaining, game.timerParams.paused); } @@ -143,7 +143,6 @@ class GameManager { if (this.activeGameRunner.timerThreads[game.accessCode]) { this.logger.trace('KILLING TIMER PROCESS FOR ENDED GAME ' + game.accessCode); this.activeGameRunner.timerThreads[game.accessCode].kill(); - delete this.activeGameRunner.timerThreads[game.accessCode]; } for (const person of game.people) { person.revealed = true; @@ -266,9 +265,13 @@ class GameManager { restartGame = async (game, namespace) => { // kill any outstanding timer threads - if (this.activeGameRunner.timerThreads[game.accessCode]) { - this.logger.info('KILLING STALE TIMER PROCESS FOR ' + game.accessCode); - this.activeGameRunner.timerThreads[game.accessCode].kill(); + const subProcess = this.activeGameRunner.timerThreads[game.accessCode]; + if (subProcess) { + if (!subProcess.killed) { + this.logger.info('Killing timer process ' + subProcess.pid + ' for: ' + game.accessCode); + this.activeGameRunner.timerThreads[game.accessCode].kill(); + } + this.logger.debug('Deleting reference to subprocess ' + subProcess.pid); delete this.activeGameRunner.timerThreads[game.accessCode]; } @@ -296,23 +299,11 @@ class GameManager { game.people[i].alignment = cards[i].team; } - /* If the game was originally set up with a TEMP mod and the game has gone far enough to establish - a DEDICATED mod, make the current mod a TEMP mod for the restart. */ - if (!game.hasDedicatedModerator && game.moderator.userType === globals.USER_TYPES.MODERATOR) { - game.moderator.userType = globals.USER_TYPES.TEMPORARY_MODERATOR; - } - - /* If the game was originally set up with a DEDICATED moderator and the current mod is DIFFERENT from that mod - (i.e. they transferred their powers at some point), check if the current mod was once a player (i.e. they have - a game role). If they were once a player, make them a temp mod for the restart. Otherwise, they were a - spectator, and we want to leave them as a dedicated moderator. + /* If there is currently a dedicated mod, and that person was once a player (i.e. they have a game role), make + them a temporary mod for the restarted game. */ - if (game.hasDedicatedModerator && game.moderator.id !== game.originalModeratorId) { - if (game.moderator.gameRole) { - game.moderator.userType = globals.USER_TYPES.TEMPORARY_MODERATOR; - } else { - game.moderator.userType = globals.USER_TYPES.MODERATOR; - } + if (game.moderator.gameRole && game.moderator.userType === globals.USER_TYPES.MODERATOR) { + game.moderator.userType = globals.USER_TYPES.TEMPORARY_MODERATOR; } // start the new game @@ -322,7 +313,7 @@ class GameManager { this.activeGameRunner.runGame(game, namespace); } - namespace.in(game.accessCode).emit(globals.EVENT_IDS.START_GAME); + namespace.in(game.accessCode).emit(globals.EVENT_IDS.RESTART_GAME); }; handleRequestForGameState = async (game, namespace, logger, gameRunner, accessCode, personCookie, ackFn, clientSocket) => { @@ -330,12 +321,12 @@ class GameManager { if (matchingPerson) { if (matchingPerson.socketId === clientSocket.id) { logger.trace('matching person found with an established connection to the room: ' + matchingPerson.name); - ackFn(GameStateCurator.getGameStateFromPerspectiveOfPerson(game, matchingPerson, gameRunner, clientSocket, logger)); + ackFn(GameStateCurator.getGameStateFromPerspectiveOfPerson(game, matchingPerson)); } else { logger.trace('matching person found with a new connection to the room: ' + matchingPerson.name); clientSocket.join(accessCode); matchingPerson.socketId = clientSocket.id; - ackFn(GameStateCurator.getGameStateFromPerspectiveOfPerson(game, matchingPerson, gameRunner, clientSocket, logger)); + ackFn(GameStateCurator.getGameStateFromPerspectiveOfPerson(game, matchingPerson)); } } else { rejectClientRequestForGameState(ackFn); diff --git a/server/modules/GameStateCurator.js b/server/modules/GameStateCurator.js index 2089af0..317bb5c 100644 --- a/server/modules/GameStateCurator.js +++ b/server/modules/GameStateCurator.js @@ -5,8 +5,8 @@ const globals = require('../config/globals'); information that they shouldn't. */ const GameStateCurator = { - getGameStateFromPerspectiveOfPerson: (game, person, gameRunner) => { - return getGameStateBasedOnPermissions(game, person, gameRunner); + getGameStateFromPerspectiveOfPerson: (game, person) => { + return getGameStateBasedOnPermissions(game, person); }, mapPeopleForModerator: (people) => { @@ -42,7 +42,7 @@ const GameStateCurator = { } }; -function getGameStateBasedOnPermissions (game, person, gameRunner) { +function getGameStateBasedOnPermissions (game, person) { const client = game.status === globals.STATUS.LOBBY // people won't be able to know their role until past the lobby stage. ? { name: person.name, hasEnteredName: person.hasEnteredName, id: person.id, cookie: person.cookie, userType: person.userType } : { diff --git a/spec/e2e/game_spec.js b/spec/e2e/game_spec.js index eed051d..012bd0f 100644 --- a/spec/e2e/game_spec.js +++ b/spec/e2e/game_spec.js @@ -290,7 +290,7 @@ describe('game page', () => { } ]); expect(document.getElementById('end-of-game-header')).not.toBeNull(); - expect(document.getElementById('restart-game')).not.toBeNull(); + expect(document.getElementById('restart-game-button')).not.toBeNull(); }); afterAll(() => { diff --git a/spec/unit/server/modules/GameManager_Spec.js b/spec/unit/server/modules/GameManager_Spec.js index b142c12..6fc94a5 100644 --- a/spec/unit/server/modules/GameManager_Spec.js +++ b/spec/unit/server/modules/GameManager_Spec.js @@ -222,7 +222,7 @@ describe('GameManager', () => { ); expect(GameStateCurator.getGameStateFromPerspectiveOfPerson) - .toHaveBeenCalledWith(gameRunner.activeGames.get('abc'), player, gameRunner, socket, logger); + .toHaveBeenCalledWith(gameRunner.activeGames.get('abc'), player); }); it('should send the game state to a matching person who reset their connection', () => { @@ -243,7 +243,7 @@ describe('GameManager', () => { ); expect(GameStateCurator.getGameStateFromPerspectiveOfPerson) - .toHaveBeenCalledWith(gameRunner.activeGames.get('abc'), player, gameRunner, socket, logger); + .toHaveBeenCalledWith(gameRunner.activeGames.get('abc'), player); expect(player.socketId).toEqual(socket.id); expect(socket.join).toHaveBeenCalled(); }); @@ -360,7 +360,7 @@ describe('GameManager', () => { expect(person.gameRole).toBeDefined(); } expect(shuffleSpy).toHaveBeenCalled(); - expect(emitSpy).toHaveBeenCalledWith(globals.EVENT_IDS.START_GAME); + expect(emitSpy).toHaveBeenCalledWith(globals.EVENT_IDS.RESTART_GAME); }); it('should reset all relevant game parameters, including when the game has a timer', async () => { @@ -387,7 +387,7 @@ describe('GameManager', () => { expect(runGameSpy).toHaveBeenCalled(); expect(Object.keys(gameManager.activeGameRunner.timerThreads).length).toEqual(0); expect(shuffleSpy).toHaveBeenCalled(); - expect(emitSpy).toHaveBeenCalledWith(globals.EVENT_IDS.START_GAME); + expect(emitSpy).toHaveBeenCalledWith(globals.EVENT_IDS.RESTART_GAME); }); it('should reset all relevant game parameters and preserve temporary moderator', async () => { @@ -408,7 +408,7 @@ describe('GameManager', () => { expect(person.gameRole).toBeDefined(); } expect(shuffleSpy).toHaveBeenCalled(); - expect(emitSpy).toHaveBeenCalledWith(globals.EVENT_IDS.START_GAME); + expect(emitSpy).toHaveBeenCalledWith(globals.EVENT_IDS.RESTART_GAME); }); it('should reset all relevant game parameters and restore a temporary moderator from a dedicated moderator', async () => { @@ -429,7 +429,7 @@ describe('GameManager', () => { expect(person.gameRole).toBeDefined(); } expect(shuffleSpy).toHaveBeenCalled(); - expect(emitSpy).toHaveBeenCalledWith(globals.EVENT_IDS.START_GAME); + expect(emitSpy).toHaveBeenCalledWith(globals.EVENT_IDS.RESTART_GAME); }); it('should reset all relevant game parameters and create a temporary mod if a dedicated mod transferred to a killed player', async () => { @@ -450,7 +450,7 @@ describe('GameManager', () => { expect(person.gameRole).toBeDefined(); } expect(shuffleSpy).toHaveBeenCalled(); - expect(emitSpy).toHaveBeenCalledWith(globals.EVENT_IDS.START_GAME); + expect(emitSpy).toHaveBeenCalledWith(globals.EVENT_IDS.RESTART_GAME); }); });