diff --git a/README.md b/README.md index 3029d22..bb80aef 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,12 @@ Find the latest production deployment at: https://play-werewolf.app/ +- [Features](#features) +- [Tech Stack](#tech-stack) +- [Contributing and Developers' Guide](#contributing-and-developers-guide) +- [Testing](#testing) +- [Code Formatting](#code-formatting) + An application to run games of Werewolf (Mafia) smoothly when you don't have a deck, or when you and your friends are together virtually. Basically, a host builds a game and deals a role to everyone's device, and then the app keeps track of the game state (timer, player statuses, etc). @@ -41,8 +47,10 @@ The application prioritizes responsiveness. A key scenario would be when a group This is a Node.js application. It is written purely using JavaScript/HTML/CSS. The main dependencies are Express.js and Socket.io. It runs as a containerized application -via Google Cloud Run. +via Google Cloud Run. There is no data persisted in any database. +Currently there is one container instance, which is sufficient scaling at this time. In the event I need to scale to multiple containers, I will likely +integrate with a message queue like Redis. ## Contributing and Developers' Guide ### Running Locally diff --git a/client/src/modules/DeckStateManager.js b/client/src/modules/DeckStateManager.js index 23a79bf..5eed73b 100644 --- a/client/src/modules/DeckStateManager.js +++ b/client/src/modules/DeckStateManager.js @@ -1,10 +1,43 @@ import { globals } from '../config/globals.js'; import { HTMLFragments } from './HTMLFragments.js'; import { toast } from './Toast.js'; +import { ModalManager } from './ModalManager.js'; export class DeckStateManager { constructor () { this.deck = []; + this.templates = { + '5 Players': { + Villager: 1, + Werewolf: 1, + Sorceress: 1, + 'Parity Hunter': 1, + Seer: 1 + }, + '7 Players': { + Villager: 6, + Werewolf: 1 + }, + '9 Players': { + Villager: 7, + Werewolf: 2 + }, + '11 Players': { + Villager: 8, + Werewolf: 2, + Seer: 1 + }, + '13 Players': { + Villager: 10, + Werewolf: 2, + Seer: 1 + }, + '15 Players': { + Villager: 12, + Werewolf: 2, + Seer: 1 + } + }; } addToDeck (role) { @@ -54,6 +87,46 @@ export class DeckStateManager { return total; } + loadDeckTemplates = (roleBox) => { + if (document.querySelectorAll('.template-option').length === 0) { + for (const templateName of Object.keys(this.templates)) { + const templateOption = document.createElement('div'); + templateOption.classList.add('template-option'); + templateOption.innerHTML = HTMLFragments.DECK_TEMPLATE; + templateOption.querySelector('.template-option-name').innerText = templateName; + for (let i = 0; i < Object.keys(this.templates[templateName]).length; i ++) { + const role = Object.keys(this.templates[templateName])[i]; + const roleEl = document.createElement('span'); + roleEl.innerText = this.templates[templateName][role] + ' ' + role; + if (i < Object.keys(this.templates[templateName]).length - 1) { // construct comma-delimited list + roleEl.innerText += ', '; + } + roleEl.classList.add(roleBox.defaultRoles.find((entry) => entry.role === role).team); + templateOption.querySelector('.template-option-roles').appendChild(roleEl); + } + templateOption.addEventListener('click', (e) => { + e.preventDefault(); + for (const card of this.deck) { + card.quantity = 0; + } + for (const role of Object.keys(this.templates[templateName])) { + const roleObj = roleBox.getDefaultRole(role); + if (!this.hasRole(roleObj.role)) { + this.addToDeck(roleObj); + } + for (let i = roleObj.quantity; i < this.templates[templateName][role]; i ++) { + this.addCopyOfCard(roleObj.role); + } + } + this.updateDeckStatus(); + ModalManager.dispelModal('deck-template-modal', 'modal-background'); + toast('Template loaded', 'success', true, true, 'short'); + }); + document.getElementById('deck-template-container').appendChild(templateOption); + } + } + }; + displayDeckPlaceHolder = () => { const placeholder = document.createElement('div'); placeholder.setAttribute('id', 'deck-list-placeholder'); @@ -69,7 +142,7 @@ export class DeckStateManager { } const sortedDeck = this.deck.sort((a, b) => { if (a.team !== b.team) { - return a.team === globals.ALIGNMENT.GOOD ? 1 : -1; + return a.team === globals.ALIGNMENT.GOOD ? -1 : 1; } return a.role.localeCompare(b.role); }); @@ -109,6 +182,22 @@ export class DeckStateManager { }; roleEl.querySelector('.role-remove').addEventListener('click', minusOneHandler); roleEl.querySelector('.role-remove').addEventListener('keyup', minusOneHandler); + + const infoHandler = (e) => { + if (e.type === 'click' || e.code === 'Enter') { + const alignmentEl = document.getElementById('custom-role-info-modal-alignment'); + alignmentEl.classList.remove(globals.ALIGNMENT.GOOD); + alignmentEl.classList.remove(globals.ALIGNMENT.EVIL); + e.preventDefault(); + document.getElementById('custom-role-info-modal-name').innerText = sortedDeck[i].role; + alignmentEl.classList.add(sortedDeck[i].team); + document.getElementById('custom-role-info-modal-description').innerText = sortedDeck[i].description; + alignmentEl.innerText = sortedDeck[i].team; + ModalManager.displayModal('custom-role-info-modal', 'modal-background', 'close-custom-role-info-modal-button'); + } + }; + roleEl.querySelector('.role-info').addEventListener('click', infoHandler); + roleEl.querySelector('.role-info').addEventListener('keyup', infoHandler); } } else { sortedDeck[i].markedForRemoval = true; diff --git a/client/src/modules/GameCreationStepManager.js b/client/src/modules/GameCreationStepManager.js index bc26b5b..b6fdfa9 100644 --- a/client/src/modules/GameCreationStepManager.js +++ b/client/src/modules/GameCreationStepManager.js @@ -205,11 +205,15 @@ export class GameCreationStepManager { stepContainer.innerHTML += HTMLFragments.CREATE_GAME_DECK_STATUS; document.getElementById(containerId).appendChild(stepContainer); + this.roleBox = new RoleBox(stepContainer, deckManager); + deckManager.roleBox = this.roleBox; this.roleBox.loadDefaultRoles(); this.roleBox.loadCustomRolesFromCookies(); this.roleBox.displayDefaultRoles(document.getElementById('role-select')); + deckManager.loadDeckTemplates(this.roleBox); + const exportHandler = (e) => { if (e.type === 'click' || e.code === 'Enter') { e.preventDefault(); @@ -502,6 +506,13 @@ function initializeRemainingEventListeners (deckManager, roleBox) { } } }; + document.getElementById('deck-template-button').addEventListener('click', () => { + ModalManager.displayModal( + 'deck-template-modal', + 'modal-background', + 'close-deck-template-modal-button' + ); + }); document.getElementById('custom-role-btn').addEventListener( 'click', () => { const createBtn = document.getElementById('create-role-button'); diff --git a/client/src/modules/GameStateRenderer.js b/client/src/modules/GameStateRenderer.js index 1ae1bdd..97b560d 100644 --- a/client/src/modules/GameStateRenderer.js +++ b/client/src/modules/GameStateRenderer.js @@ -234,7 +234,9 @@ export class GameStateRenderer { }); } document.querySelectorAll('.game-player').forEach((el) => el.remove()); - sortPeopleByStatus(this.stateBucket.currentGameState.people); + /* TODO: UX issue - it's easier to parse visually when players are sorted this way, + but shifting players around when they are killed or revealed is bad UX for the moderator. */ + // sortPeopleByStatus(this.stateBucket.currentGameState.people); const modType = tempMod ? this.stateBucket.currentGameState.moderator.userType : null; renderGroupOfPlayers( this.stateBucket.currentGameState.people, @@ -343,19 +345,6 @@ function renderLobbyPerson (name, userType) { return el; } -function sortPeopleByStatus (people) { - people.sort((a, b) => { - if (a.out !== b.out) { - return a.out ? 1 : -1; - } else { - if (a.revealed !== b.revealed) { - return a.revealed ? -1 : 1; - } - return a.name >= b.name ? 1 : -1; - } - }); -} - function getGameSize (cards) { let quantity = 0; for (const card of cards) { diff --git a/client/src/modules/HTMLFragments.js b/client/src/modules/HTMLFragments.js index e91a2d5..d2517be 100644 --- a/client/src/modules/HTMLFragments.js +++ b/client/src/modules/HTMLFragments.js @@ -100,7 +100,7 @@ export const HTMLFragments = {
- +