diff --git a/README.md b/README.md index b4153a4..66bf448 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,10 @@ smoothly when you don't have a deck, or when you and your friends are together v Ultimate Werewolf and by 2020's quarantine. The app is free to use and anonymous. +After a long hiatus from maintaining the application, I have come back and undertaken a large-scale redesign, rewriting +most of the code and producing a result that I believe is more stable and has much more sensible client-server interaction. +It's a shame that my first attempt is what ended up in Github's Artic Code Vault :) + ![player](./client/src/images/screenshots/player.PNG) ## Features @@ -23,10 +27,9 @@ The application prioritizes responsiveness. A key scenario would be when a group ## Tech Stack This is a Node.js application. It is written purely using JavaScript/HTML/CSS. The main dependencies are -Express.js and Socket.io. It is fully open source +Express.js and Socket.io. It is fully open-source and under the MIT license. This was (and still is) fundamentally a learning project, and thus I welcome collaboration -and feedback of any kind. After a long break of maintaining the application I am back to work on re-designing and -improving it. +and feedback of any kind. All pixel art is my own, for better or for worse. @@ -65,7 +68,7 @@ consulting these below. ### CLI Options These options will be at the end of your run command following two dashes e.g. `npm run start:dev -- [options]`. -Options are key-value pairs with the syntax `[key]=[value]` e.g. `port=4242`. Options include: +Options are whitespace-delimited key-value pairs with the syntax `[key]=[value]` e.g. `port=4242`. Options include: - `port`. Specify an integer port for the application. - `loglevel` the log level for the application. Can be `info`, `error`, `warn`, `debug`, or `trace`. diff --git a/client/src/config/globals.js b/client/src/config/globals.js index f4293f5..47b16e9 100644 --- a/client/src/config/globals.js +++ b/client/src/config/globals.js @@ -1,6 +1,8 @@ export const globals = { USER_SIGNATURE_LENGTH: 25, CLOCK_TICK_INTERVAL_MILLIS: 10, + MAX_CUSTOM_ROLE_NAME_LENGTH: 30, + MAX_CUSTOM_ROLE_DESCRIPTION_LENGTH: 500, TOAST_DURATION_DEFAULT: 6, ACCESS_CODE_LENGTH: 6, PLAYER_ID_COOKIE_KEY: 'play-werewolf-anon-id', diff --git a/client/src/images/add.svg b/client/src/images/add.svg new file mode 100644 index 0000000..b84df19 --- /dev/null +++ b/client/src/images/add.svg @@ -0,0 +1 @@ + diff --git a/client/src/images/delete.svg b/client/src/images/delete.svg new file mode 100644 index 0000000..9a8137f --- /dev/null +++ b/client/src/images/delete.svg @@ -0,0 +1,14 @@ + + + + background + + + + + + + Layer 1 + + + diff --git a/client/src/images/info.svg b/client/src/images/info.svg index c997041..a09de5e 100644 --- a/client/src/images/info.svg +++ b/client/src/images/info.svg @@ -1,14 +1 @@ - - - - background - - - - - - - Layer 1 - - - + diff --git a/client/src/images/pencil.svg b/client/src/images/pencil.svg new file mode 100644 index 0000000..355b4f4 --- /dev/null +++ b/client/src/images/pencil.svg @@ -0,0 +1,14 @@ + + + + background + + + + + + + Layer 1 + + + diff --git a/client/src/modules/DeckStateManager.js b/client/src/modules/DeckStateManager.js index cb6376a..b0bf0a9 100644 --- a/client/src/modules/DeckStateManager.js +++ b/client/src/modules/DeckStateManager.js @@ -1,3 +1,7 @@ +import { globals } from "../config/globals.js"; +import {toast} from "./Toast.js"; +import {ModalManager} from "./ModalManager"; + export class DeckStateManager { constructor() { this.deck = null; @@ -15,6 +19,16 @@ export class DeckStateManager { addToCustomRoleOptions(role) { this.customRoleOptions.push(role); + localStorage.setItem("play-werewolf-custom-roles", JSON.stringify(this.customRoleOptions.concat(this.deck.filter(card => card.custom === true)))); + } + + removeFromCustomRoleOptions(name) { + let option = this.customRoleOptions.find((option) => option.role === name); + if (option) { + this.customRoleOptions.splice(this.customRoleOptions.indexOf(option), 1); + localStorage.setItem("play-werewolf-custom-roles", JSON.stringify(this.customRoleOptions.concat(this.deck.filter(card => card.custom === true)))); + toast('"' + name + '" deleted.', 'error', true, true, 3); + } } addCopyOfCard(role) { @@ -54,4 +68,82 @@ export class DeckStateManager { } return total; } + + loadCustomRolesFromCookies() { + let customRoles = localStorage.getItem('play-werewolf-custom-roles'); + if (customRoles !== null && validateCustomRoleCookie(customRoles)) { + this.customRoleOptions = JSON.parse(customRoles); // we know it is valid JSON from the validate function + } + } + + loadCustomRolesFromFile(file, updateRoleListFunction, loadDefaultCardsFn, showIncludedCardsFn) { + let reader = new FileReader(); + reader.onerror = (e) => { + toast(reader.error.message, "error", true, true, 5); + } + reader.onload = (e) => { + let string; + if (typeof e.target.result !== "string") { + string = new TextDecoder("utf-8").decode(e.target.result); + } else { + string = e.target.result; + } + if (validateCustomRoleCookie(string)) { + this.customRoleOptions = JSON.parse(string); // we know it is valid JSON from the validate function + ModalManager.dispelModal("upload-custom-roles-modal", "modal-background"); + toast("Roles imported successfully", "success", true, true, 3); + localStorage.setItem("play-werewolf-custom-roles", JSON.stringify(this.customRoleOptions)); + updateRoleListFunction(this, document.getElementById("deck-select")); + // loadDefaultCardsFn(this); + // showIncludedCardsFn(this); + } else { + toast("Invalid formatting. Make sure you import the file as downloaded from this page.", "error", true, true, 5); + } + } + reader.readAsText(file); + } + + // via https://stackoverflow.com/a/18197341 + downloadCustomRoles(filename, text) { + let element = document.createElement('a'); + element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text)); + element.setAttribute('download', filename); + + element.style.display = 'none'; + document.body.appendChild(element); + + element.click(); + + document.body.removeChild(element); + } + +} + +// this is user-supplied, so we should validate it fully +function validateCustomRoleCookie(cookie) { + let valid = false; + if (typeof cookie === "string" && new Blob([cookie]).size <= 1000000) { + try { + let cookieJSON = JSON.parse(cookie); + if (Array.isArray(cookieJSON)) { + for (let entry of cookieJSON) { + if (typeof entry === "object") { + if (typeof entry.role !== "string" || entry.role.length > globals.MAX_CUSTOM_ROLE_NAME_LENGTH + || typeof entry.team !== "string" || (entry.team !== globals.ALIGNMENT.GOOD && entry.team !== globals.ALIGNMENT.EVIL) + || typeof entry.description !== "string" || entry.description.length > globals.MAX_CUSTOM_ROLE_DESCRIPTION_LENGTH + ) { + return false; + } + } else { + return false; + } + } + return true; + } + } catch(e) { + return false; + } + } + + return valid; } diff --git a/client/src/modules/GameCreationStepManager.js b/client/src/modules/GameCreationStepManager.js index 2ee447e..a67208b 100644 --- a/client/src/modules/GameCreationStepManager.js +++ b/client/src/modules/GameCreationStepManager.js @@ -5,9 +5,12 @@ import { ModalManager } from "./ModalManager.js"; import {XHRUtility} from "./XHRUtility.js"; import {globals} from "../config/globals.js"; import {templates} from "./Templates.js"; +import {defaultCards} from "../config/defaultCards"; export class GameCreationStepManager { constructor(deckManager) { + loadDefaultCards(deckManager); + deckManager.loadCustomRolesFromCookies(); this.step = 1; this.currentGame = new Game(null, null, null, null); this.deckManager = deckManager; @@ -197,6 +200,35 @@ function renderRoleSelectionStep(game, containerId, step, deckManager) { stepContainer.innerHTML += templates.CREATE_GAME_DECK; document.getElementById(containerId).appendChild(stepContainer); + document.querySelector('#custom-roles-export').addEventListener('click', (e) => { + e.preventDefault(); + deckManager.downloadCustomRoles('play-werewolf-custom-roles', JSON.stringify(deckManager.getCurrentCustomRoleOptions())); + }); + + document.querySelector('#custom-roles-import').addEventListener('click', (e) => { + e.preventDefault(); + ModalManager.displayModal("upload-custom-roles-modal", "modal-background", "close-upload-custom-roles-modal-button"); + }); + + document.getElementById("upload-custom-roles-form").onsubmit = (e) => { + e.preventDefault(); + let fileList = document.getElementById("upload-custom-roles").files; + if (fileList.length > 0) { + let file = fileList[0]; + if (file.size > 1000000) { + toast("Your file is too large (max 1MB)", "error", true, true, 5); + return; + } + if (file.type !== "text/plain") { + toast("Your file must be a text file", "error", true, true, 5); + return; + } + + deckManager.loadCustomRolesFromFile(file, updateCustomRoleOptionsList, loadDefaultCards, showIncludedCards); + } else { + toast("You must upload a text file", "error", true, true, 5); + } + } let clickHandler = () => { console.log("fired"); @@ -208,9 +240,9 @@ function renderRoleSelectionStep(game, containerId, step, deckManager) { } }; - //document.getElementById("custom-role-hamburger").addEventListener("click", clickHandler); + document.getElementById("custom-role-hamburger").addEventListener("click", clickHandler); - loadIncludedCards(deckManager); + showIncludedCards(deckManager); loadCustomRoles(deckManager); @@ -354,8 +386,9 @@ function showButtons(back, forward, forwardHandler, backHandler, builtGame=null) } // Display a widget for each default card that allows copies of it to be added/removed. Set initial deck state. -function loadIncludedCards(deckManager) { - for (let i = 0; i < deckManager.getCurrentDeck().length; i ++) { // each dropdown should include every +function showIncludedCards(deckManager) { + document.querySelectorAll('.compact-card').forEach((el) => { el.remove() }); + for (let i = 0; i < deckManager.getCurrentDeck().length; i ++) { let card = deckManager.getCurrentDeck()[i]; let cardEl = constructCompactDeckBuilderElement(card, deckManager); if (card.team === globals.ALIGNMENT.GOOD) { @@ -370,25 +403,23 @@ function loadIncludedCards(deckManager) { create a widget for it */ function loadCustomRoles(deckManager) { let select = document.getElementById("deck-select"); - addOptionsToList(deckManager.getCurrentCustomRoleOptions(), document.getElementById("deck-select")); - document.getElementById("include-role").addEventListener('click', (e) => { - e.preventDefault(); - if (select.value && select.value.length > 0) { - if (!deckManager.getCard(select.value)) { - deckManager.addToDeck(select.value); - let cardEl = constructCompactDeckBuilderElement(deckManager.getCard(select.value), deckManager); - toast('"' + select.value + '" included.', 'success', true, true, 3); - if (deckManager.getCard(select.value).team === globals.ALIGNMENT.GOOD) { - document.getElementById("deck-good").appendChild(cardEl); - } else { - document.getElementById("deck-evil").appendChild(cardEl); - } - updateCustomRoleOptionsList(deckManager, select); - } else { - toast('"' + select.value + '" already included.', 'error', true, true, 3); - } + addOptionsToList(deckManager, document.getElementById("deck-select")); +} + +function loadDefaultCards(deckManager) { + defaultCards.sort((a, b) => { + if (a.team !== b.team) { + return a.team === globals.ALIGNMENT.GOOD ? 1 : -1; } - }) + return a.role.localeCompare(b.role); + }); + let deck = []; + for (let i = 0; i < defaultCards.length; i ++) { + let card = defaultCards[i]; + card.quantity = 0; + deck.push(card); + } + deckManager.deck = deck; } function constructCompactDeckBuilderElement(card, deckManager) { @@ -453,9 +484,9 @@ function initializeRemainingEventListeners(deckManager) { toast('Your description is too long (max 500 characters).', "error", true); return; } - deckManager.addToCustomRoleOptions({role: name, description: description, team: team}); + deckManager.addToCustomRoleOptions({role: name, description: description, team: team, custom: true}); updateCustomRoleOptionsList(deckManager, document.getElementById("deck-select")) - ModalManager.dispelModal("add-role-modal", "add-role-modal-background"); + ModalManager.dispelModal("add-role-modal", "modal-background"); toast("Role Created", "success", true); } else { toast("There is already a role with this name", "error", true, true, 3); @@ -465,7 +496,7 @@ function initializeRemainingEventListeners(deckManager) { "click", () => { ModalManager.displayModal( "add-role-modal", - "add-role-modal-background", + "modal-background", "close-modal-button" ) } @@ -473,11 +504,12 @@ function initializeRemainingEventListeners(deckManager) { } function updateCustomRoleOptionsList(deckManager, selectEl) { - document.querySelectorAll('#deck-select option').forEach(e => e.remove()); - addOptionsToList(deckManager.customRoleOptions, selectEl); + document.querySelectorAll('#deck-select .deck-select-role').forEach(e => e.remove()); + addOptionsToList(deckManager, selectEl); } -function addOptionsToList(options, selectEl) { +function addOptionsToList(deckManager, selectEl) { + let options = deckManager.getCurrentCustomRoleOptions(); options.sort((a, b) => { if (a.team !== b.team) { return a.team === globals.ALIGNMENT.GOOD ? 1 : -1; @@ -485,29 +517,72 @@ function addOptionsToList(options, selectEl) { return a.role.localeCompare(b.role); }); for (let i = 0; i < options.length; i ++) { - let optionEl = document.createElement("option"); + let optionEl = document.createElement("div"); + optionEl.innerHTML = templates.DECK_SELECT_ROLE; + optionEl.classList.add('deck-select-role'); let alignmentClass = options[i].team === globals.ALIGNMENT.GOOD ? globals.ALIGNMENT.GOOD : globals.ALIGNMENT.EVIL optionEl.classList.add(alignmentClass); - optionEl.setAttribute("value", options[i].role); - optionEl.innerText = options[i].role; + optionEl.querySelector('.deck-select-role-name').innerText = options[i].role; selectEl.appendChild(optionEl); } + + addCustomRoleEventListeners(deckManager, selectEl); +} + +function addCustomRoleEventListeners(deckManager, select) { + document.querySelectorAll('.deck-select-role').forEach((role) => { + let name = role.querySelector('.deck-select-role-name').innerText; + role.querySelector('.deck-select-include').addEventListener('click', (e) => { + e.preventDefault(); + if (!deckManager.getCard(name)) { + deckManager.addToDeck(name); + let cardEl = constructCompactDeckBuilderElement(deckManager.getCard(name), deckManager); + toast('"' + name + '" included.', 'success', true, true, 3); + if (deckManager.getCard(name).team === globals.ALIGNMENT.GOOD) { + document.getElementById("deck-good").appendChild(cardEl); + } else { + document.getElementById("deck-evil").appendChild(cardEl); + } + updateCustomRoleOptionsList(deckManager, select); + } else { + toast('"' + select.value + '" already included.', 'error', true, true, 3); + } + }); + + role.querySelector('.deck-select-remove').addEventListener('click', (e) => { + if (confirm("Delete the role '" + name + "'?")) { + e.preventDefault(); + deckManager.removeFromCustomRoleOptions(name); + updateCustomRoleOptionsList(deckManager, select); + } + }) + }); } function updateDeckStatus(deckManager) { document.querySelectorAll('.deck-role').forEach((el) => el.remove()); document.getElementById("deck-count").innerText = deckManager.getDeckSize() + " Players"; - for (let card of deckManager.getCurrentDeck()) { - if (card.quantity > 0) { - let roleEl = document.createElement("div"); - roleEl.classList.add('deck-role'); - if (card.team === globals.ALIGNMENT.GOOD) { - roleEl.classList.add(globals.ALIGNMENT.GOOD); - } else { - roleEl.classList.add(globals.ALIGNMENT.EVIL); + if (deckManager.getDeckSize() === 0) { + let placeholder = document.createElement("div"); + placeholder.setAttribute("id", "deck-list-placeholder"); + placeholder.innerText = "Add a card from the included roles below."; + document.getElementById("deck-list").appendChild(placeholder); + } else { + if (document.getElementById("deck-list-placeholder")) { + document.getElementById("deck-list-placeholder").remove(); + } + for (let card of deckManager.getCurrentDeck()) { + if (card.quantity > 0) { + let roleEl = document.createElement("div"); + roleEl.classList.add('deck-role'); + if (card.team === globals.ALIGNMENT.GOOD) { + roleEl.classList.add(globals.ALIGNMENT.GOOD); + } else { + roleEl.classList.add(globals.ALIGNMENT.EVIL); + } + roleEl.innerText = card.quantity + 'x ' + card.role; + document.getElementById("deck-list").appendChild(roleEl); } - roleEl.innerText = card.quantity + 'x ' + card.role; - document.getElementById("deck-list").appendChild(roleEl); } } } diff --git a/client/src/modules/Templates.js b/client/src/modules/Templates.js index c3b69b3..06e231d 100644 --- a/client/src/modules/Templates.js +++ b/client/src/modules/Templates.js @@ -227,38 +227,40 @@ export const templates = { CREATE_GAME_DECK: "
" + "
" + - "" + + "" + "
" + "
" + "
" + - "" + + "" + "
" + "
" + "
", CREATE_GAME_CUSTOM_ROLES: '
' + - // '' + - // '' + + '' + + '' + '' + - '
' + - '' + - '' + - '
' + + '
' + '' + '
', CREATE_GAME_DECK_STATUS: '
' + '
0 Players
' + - '
' + - '
' + + '
' + + '
', + DECK_SELECT_ROLE: + '
' + + '
' + + 'include' + + 'info' + + 'edit' + + 'remove' + '
' - } diff --git a/client/src/scripts/create.js b/client/src/scripts/create.js index 7c7b458..5ddf3db 100644 --- a/client/src/scripts/create.js +++ b/client/src/scripts/create.js @@ -1,41 +1,14 @@ -import { defaultCards } from "../config/defaultCards.js"; -import { customCards } from "../config/customCards.js"; import { DeckStateManager } from "../modules/DeckStateManager.js"; import { GameCreationStepManager } from "../modules/GameCreationStepManager.js"; import { injectNavbar } from "../modules/Navbar.js"; -import {globals} from "../config/globals"; const create = () => { injectNavbar(); let deckManager = new DeckStateManager(); let gameCreationStepManager = new GameCreationStepManager(deckManager); - loadDefaultCards(deckManager); gameCreationStepManager.renderStep("creation-step-container", 1); } -function loadDefaultCards(deckManager) { - defaultCards.sort((a, b) => { - if (a.team !== b.team) { - return a.team === globals.ALIGNMENT.GOOD ? 1 : -1; - } - return a.role.localeCompare(b.role); - }); - let deck = []; - for (let i = 0; i < defaultCards.length; i ++) { - let card = defaultCards[i]; - card.quantity = 0; - deck.push(card); - } - deckManager.deck = deck; -} - -function loadCustomRoles(deckManager) { - customCards.sort((a, b) => { - return a.role.localeCompare(b.role); - }); - deckManager.customRoleOptions = customCards; -} - if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { module.exports = create; } else { diff --git a/client/src/styles/GLOBAL.css b/client/src/styles/GLOBAL.css index d8537b0..17957de 100644 --- a/client/src/styles/GLOBAL.css +++ b/client/src/styles/GLOBAL.css @@ -121,7 +121,7 @@ input, textarea { background-color: transparent; border: 1px solid white; border-radius: 3px; - color: #f7f7f7; + color: #d7d7d7; } a { diff --git a/client/src/styles/create.css b/client/src/styles/create.css index 67aa635..ff28b03 100644 --- a/client/src/styles/create.css +++ b/client/src/styles/create.css @@ -9,8 +9,6 @@ box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.4); border-radius: 3px; user-select: none; - max-width: 15em; - min-width: 130px; display: flex; height: 55px; } @@ -28,8 +26,6 @@ overflow: hidden; white-space: nowrap; text-overflow: ellipsis; - max-width: 9em; - font-size: 16px; } .selected-card { @@ -83,11 +79,19 @@ width: fit-content; } +#custom-roles-container { + width: 95%; + max-width: 25em; +} + .deck-role { border-radius: 3px; margin: 0.25em 0; padding: 0 5px; font-size: 18px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } #custom-roles-container, #deck-status-container { @@ -101,7 +105,8 @@ } #deck-status-container { - min-width: 15em; + width: 20em; + max-width: 95%; height: 13em; overflow-y: auto; position: relative; @@ -122,6 +127,17 @@ margin-top: 0.5em; } +#deck-list-placeholder { + margin: auto; + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + width: 290px; + height: 20px; +} + #custom-role-hamburger .hamburger-inner, #custom-role-hamburger .hamburger-inner::before, #custom-role-hamburger .hamburger-inner::after { background-color: whitesmoke; width: 28px; @@ -189,6 +205,8 @@ margin: 0 auto; justify-content: center; flex-direction: column; + width: 95%; + max-width: 38em; } #deck-container label { @@ -322,6 +340,53 @@ input[type="number"] { #deck-select { margin: 0.5em 1em 1.5em 0; + overflow-y: auto; + height: 15em; +} + +.deck-select-role { + display: flex; + justify-content: space-between; + background-color: black; + align-items: center; + padding: 5px; + margin: 0.25em 0; + border-radius: 3px; + border: 1px solid transparent; + font-size: 16px; +} + +.deck-select-role-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap +} + +.deck-select-role:hover { + border: 1px solid #d7d7d7; +} + +.deck-select-role-options { + display: flex; + align-items: center; + justify-content: center; +} + +#deck-select img { + height: 20px; + margin: 0 8px; + cursor: pointer; + padding: 5px; + border-radius: 3px; +} + +#deck-select img:nth-child(4) { + height: 18px; +} + +#deck-select img:hover { + filter: brightness(1.5); + background-color: #8080804d; } .dropdown { @@ -367,7 +432,7 @@ input[type="number"] { #game-creation-container { width: 95%; position: relative; - margin-bottom: 2em; + margin-bottom: 4em; } #tracker-container { @@ -378,6 +443,10 @@ input[type="number"] { width: 100%; } +#upload-custom-roles-modal input[type='file'] { + margin: 2em 0; +} + #creation-step-tracker { display: flex; justify-content: center; @@ -466,6 +535,20 @@ input[type="number"] { padding: 10px 15px; font-size: 16px; } + + .deck-select-role-name { + font-size: 13px; + font-weight: bold; + } + + .compact-card .card-role { + max-width: 9em; + font-size: 13px; + } + + .compact-card { + min-width: 130px; + } } @media(min-width: 551px) { @@ -476,4 +559,13 @@ input[type="number"] { #step-1 div { font-size: 25px; } + + .compact-card .card-role { + max-width: 10em; + font-size: 15px; + } + + .compact-card { + min-width: 155px; + } } diff --git a/client/src/views/create.html b/client/src/views/create.html index 290e4fd..db7afe6 100644 --- a/client/src/views/create.html +++ b/client/src/views/create.html @@ -22,7 +22,7 @@
- + - - - - - - - - - +

Create A Game