mirror of
https://github.com/AlecM33/Werewolf.git
synced 2025-12-26 15:57:50 +01:00
10
README.md
10
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 <a href="https://en.wikipedia.org/wiki/Mafia_(party_game)">Werewolf (Mafia)</a>
|
||||
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
|
||||
<a href="https://expressjs.com/">Express.js</a> and <a href="https://socket.io/">Socket.io</a>. It runs as a containerized application
|
||||
via <a href='https://cloud.google.com/run'>Google Cloud Run</a>.
|
||||
via <a href='https://cloud.google.com/run'>Google Cloud Run</a>. 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 <a href='https://redis.io/'>Redis</a>.
|
||||
## Contributing and Developers' Guide
|
||||
|
||||
### Running Locally
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -100,7 +100,7 @@ export const HTMLFragments = {
|
||||
<div id="play-pause-placeholder"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button id='mod-transfer-button' class='moderator-player-button make-mod-button'>Transfer Mod Powers \uD83D\uDD00</button>
|
||||
<button id='mod-transfer-button' class='moderator-player-button make-mod-button app-button'>Transfer Mod Powers \uD83D\uDD00</button>
|
||||
<div>
|
||||
<button id='role-info-button' class='app-button'>Roles in This Game <img src='/images/info.svg'/></button>
|
||||
</div>
|
||||
@@ -226,9 +226,7 @@ export const HTMLFragments = {
|
||||
<div id='game-player-list'></div>
|
||||
</div>`,
|
||||
RESTART_GAME_BUTTON:
|
||||
`<div>
|
||||
<button id='restart-game' class='app-button'>Run it back 🔄</button>
|
||||
</div>`,
|
||||
'<button id=\'restart-game\' class=\'app-button\'>Run it back 🔄</button>',
|
||||
CREATE_GAME_DECK:
|
||||
`<div id='deck-container'>
|
||||
<div>
|
||||
@@ -240,6 +238,9 @@ export const HTMLFragments = {
|
||||
<div id='deck-evil'></div>
|
||||
</div>
|
||||
</div>`,
|
||||
DECK_TEMPLATE:
|
||||
`<div class='template-option-name'></div>
|
||||
<div class='template-option-roles'></div>`,
|
||||
CREATE_GAME_CUSTOM_ROLES:
|
||||
`<div id="custom-roles-container">
|
||||
<button id="custom-role-hamburger" class="hamburger hamburger--collapse" type="button">
|
||||
@@ -261,7 +262,10 @@ export const HTMLFragments = {
|
||||
</div>`,
|
||||
CREATE_GAME_DECK_STATUS:
|
||||
`<div id="deck-status-container">
|
||||
<div id="deck-count">0 Players</div>
|
||||
<div id="deck-status-header">
|
||||
<div id="deck-count">0 Players</div>
|
||||
<button id="deck-template-button" class="app-button">Use Template</button>
|
||||
</div>
|
||||
<div id="deck-list"></div>
|
||||
</div>`,
|
||||
DECK_SELECT_ROLE:
|
||||
@@ -282,5 +286,6 @@ export const HTMLFragments = {
|
||||
`<div class="role-name"></div>
|
||||
<div class="role-options">
|
||||
<img tabindex="0" class="role-remove" src="images/remove.svg" title="remove one" alt="remove one"/>
|
||||
<img tabindex="0" class="role-info" src="images/info.svg" title="info" alt="info"/>
|
||||
</div>`
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -41,6 +41,13 @@ const template =
|
||||
<button id="close-custom-role-info-modal-button" class="cancel app-button">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div tabindex='-1' id='deck-template-modal' class='modal'>
|
||||
<h2>Choose a pre-built game:</h2>
|
||||
<div id='deck-template-container'></div>
|
||||
<div class='modal-button-container'>
|
||||
<button id='close-deck-template-modal-button' class='cancel app-button'>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<h1>Create A Game</h1>
|
||||
<div id="tracker-container">
|
||||
<div id="creation-step-tracker">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user