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 = {
- +
@@ -226,9 +226,7 @@ export const HTMLFragments = {
`, RESTART_GAME_BUTTON: - `
- -
`, + '', CREATE_GAME_DECK: `
@@ -240,6 +238,9 @@ export const HTMLFragments = {
`, + DECK_TEMPLATE: + `
+
`, CREATE_GAME_CUSTOM_ROLES: `
`, CREATE_GAME_DECK_STATUS: `
-
0 Players
+
+
0 Players
+ +
`, DECK_SELECT_ROLE: @@ -282,5 +286,6 @@ export const HTMLFragments = { `
remove one + info
` }; diff --git a/client/src/modules/RoleBox.js b/client/src/modules/RoleBox.js index 82eeee7..d5bbe5a 100644 --- a/client/src/modules/RoleBox.js +++ b/client/src/modules/RoleBox.js @@ -11,7 +11,6 @@ export class RoleBox { this.category = 'default'; this.deckManager = deckManager; this.defaultRoles = []; - console.log('hi'); this.customRoles = []; container.innerHTML += HTMLFragments.CREATE_GAME_CUSTOM_ROLES; this.defaultButton = document.getElementById('role-category-default'); @@ -36,7 +35,7 @@ export class RoleBox { loadDefaultRoles = () => { this.defaultRoles = defaultRoles.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); }).map((role) => { @@ -162,7 +161,7 @@ export class RoleBox { this.categoryTransition.play(); this.customRoles.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); }); diff --git a/client/src/scripts/game.js b/client/src/scripts/game.js index 22b7f7a..995b34c 100644 --- a/client/src/scripts/game.js +++ b/client/src/scripts/game.js @@ -383,7 +383,7 @@ function updateDOMWithNameChange (gameState, gameStateRenderer) { function activateRoleInfoButton (deck) { deck.sort((a, b) => { - return a.team === globals.ALIGNMENT.GOOD ? 1 : -1; + return a.team === globals.ALIGNMENT.GOOD ? -1 : 1; }); document.getElementById('role-info-button').addEventListener('click', (e) => { e.preventDefault(); diff --git a/client/src/styles/create.css b/client/src/styles/create.css index fa1a988..f55c90a 100644 --- a/client/src/styles/create.css +++ b/client/src/styles/create.css @@ -101,6 +101,43 @@ width: fit-content; } +.template-option { + color: #d7d7d7; + display: flex; + justify-content: space-between; + background-color: #0f0f10; + border: 2px solid #333243; + padding: 5px; + border-radius: 3px; + font-size: 16px; + flex-direction: column; + align-items: flex-start; + text-align: left; + height: 4em; + margin: 10px 0; +} + +#deck-template-container { + margin: 1em 0; + max-height: 64vh; + overflow-y: auto; + padding: 0 0.5em; +} + +.template-option-name { + font-size: 20px; +} + +.template-option:hover, .template-option:active { + border: 2px solid #e7e7e7; + background-color: #33343c; + cursor: pointer; +} + +#deck-template-modal h2 { + font-size: 20px; +} + #custom-roles-container { width: 95%; max-width: 25em; @@ -136,15 +173,20 @@ #deck-count { font-size: 30px; - position: sticky; - top: 0; - left: 5px; background-color: #333243; width: fit-content; padding: 0 5px; border-radius: 3px; } +#deck-status-header { + position: sticky; + z-index: 25; + top: 0; + display: flex; + justify-content: space-between; +} + #deck-list { margin-top: 0.5em; } @@ -486,6 +528,10 @@ input[type="number"] { margin-bottom: 4em; } +#step-2 .app-button { + padding: 5px; +} + #tracker-container { display: flex; align-items: center; diff --git a/client/src/styles/game.css b/client/src/styles/game.css index 8cb87c1..140f950 100644 --- a/client/src/styles/game.css +++ b/client/src/styles/game.css @@ -104,9 +104,17 @@ h1 { align-items: center; } +#end-of-game-header h2 { + border: 1px solid #333243; + border-radius: 5px; + background-color: #1a1726; + padding: 7px; + margin: 0.5em; +} + #end-of-game-header button { margin: 0.5em; - min-width: 10em; + min-width: 12em; } .potential-moderator { display: flex; @@ -497,6 +505,7 @@ label[for='moderator'] { .paused { animation: pulse 0.75s linear infinite alternate; + border: 1px solid #ffc83d !important; } .paused-low { @@ -507,11 +516,11 @@ label[for='moderator'] { display: flex; flex-wrap: wrap; flex-direction: column; - align-items: flex-start; + align-items: center; } #game-header button { - min-width: 10em; + min-width: 12em; } #timer-container-moderator { @@ -587,7 +596,7 @@ label[for='moderator'] { background-color: #586a6e; } -.reveal-role-button:hover, #mod-transfer-button:hover { +.reveal-role-button:hover { background-color: #4e5664; } @@ -619,15 +628,15 @@ label[for='moderator'] { align-items: center; } -.make-mod-button { - background-color: #3f5256; - font-size: 18px; - padding: 10px; - border: 2px transparent; - border-radius: 3px; - color: #d7d7d7; - font-family: signika-negative, sans-serif; -} +/*.make-mod-button {*/ +/* background-color: #3f5256;*/ +/* font-size: 18px;*/ +/* padding: 10px;*/ +/* border: 2px transparent;*/ +/* border-radius: 3px;*/ +/* color: #d7d7d7;*/ +/* font-family: signika-negative, sans-serif;*/ +/*}*/ .make-mod-button:hover { cursor: pointer; @@ -726,10 +735,10 @@ label[for='moderator'] { width: 45px; } - .make-mod-button { - font-size: 16px; - padding: 5px; - } + /*.make-mod-button {*/ + /* font-size: 16px;*/ + /* padding: 5px;*/ + /*}*/ .game-player-name { font-size: 16px; @@ -741,7 +750,7 @@ label[for='moderator'] { height: 30px; } - #role-info-button { + #role-info-button, #mod-transfer-button { padding: 7px; } diff --git a/client/src/styles/modal.css b/client/src/styles/modal.css index 06e1044..0a86111 100644 --- a/client/src/styles/modal.css +++ b/client/src/styles/modal.css @@ -11,7 +11,7 @@ align-items: center; justify-content: center; max-width: 25em; - font-family: sans-serif; + font-family: 'signika-negative', sans-serif; flex-direction: column; padding: 1em; display: none; diff --git a/client/src/view_templates/CreateTemplate.js b/client/src/view_templates/CreateTemplate.js index 3d43086..87a6bc2 100644 --- a/client/src/view_templates/CreateTemplate.js +++ b/client/src/view_templates/CreateTemplate.js @@ -41,6 +41,13 @@ const template = +

Create A Game

diff --git a/spec/e2e/create_spec.js b/spec/e2e/create_spec.js index bd7cc5b..0c04938 100644 --- a/spec/e2e/create_spec.js +++ b/spec/e2e/create_spec.js @@ -95,5 +95,29 @@ describe('Create page', function () { .querySelector('.role-name').innerText ).role).toEqual('Test name edited'); }); + + it('should load a deck template', () => { + document.getElementById('role-category-default').click(); + document.getElementById('deck-template-button').click(); + document.querySelectorAll('.template-option')[0].click(); + + expect(gameCreationStepManager.deckManager.deck.length).toEqual(5); + expect(document.querySelectorAll('.added-role').length).toEqual(5); + }); + + it('clear existing added cards and leave only what roles are part of the template', () => { + document.getElementById('role-category-default').click(); + const roles = document.querySelectorAll('.default-role'); + roles.forEach((el) => { + const plusElement = el.querySelector('.role-include'); + plusElement.click(); + }); + + document.getElementById('deck-template-button').click(); + document.querySelectorAll('.template-option')[0].click(); + + expect(gameCreationStepManager.deckManager.deck.length).toEqual(5); + expect(document.querySelectorAll('.added-role').length).toEqual(5); + }); }); });