diff --git a/client/src/config/defaultCards.js b/client/src/config/defaultRoles.js similarity index 95% rename from client/src/config/defaultCards.js rename to client/src/config/defaultRoles.js index b22c004..c589fc6 100644 --- a/client/src/config/defaultCards.js +++ b/client/src/config/defaultRoles.js @@ -1,4 +1,4 @@ -export const defaultCards = [ +export const defaultRoles = [ { role: 'Villager', team: 'good', @@ -20,7 +20,7 @@ export const defaultCards = [ description: 'Each night, learn if a chosen person is the Seer.' }, { - role: 'Minion', + role: 'Knowing Minion', team: 'evil', description: 'You are an evil Villager, and you know who the Werewolves are.' }, diff --git a/client/src/config/globals.js b/client/src/config/globals.js index 72d5bb6..72f5e8b 100644 --- a/client/src/config/globals.js +++ b/client/src/config/globals.js @@ -1,4 +1,5 @@ export const globals = { + CHAR_POOL: 'abcdefghijklmnopqrstuvwxyz0123456789', USER_SIGNATURE_LENGTH: 25, CLOCK_TICK_INTERVAL_MILLIS: 100, MAX_CUSTOM_ROLE_NAME_LENGTH: 30, diff --git a/client/src/images/framed-phone-screenshot-2.png b/client/src/images/framed-phone-screenshot-2.png new file mode 100644 index 0000000..4ad6746 Binary files /dev/null and b/client/src/images/framed-phone-screenshot-2.png differ diff --git a/client/src/images/remove.svg b/client/src/images/remove.svg new file mode 100644 index 0000000..11ba3cf --- /dev/null +++ b/client/src/images/remove.svg @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/client/src/images/roles/BrutalHunter.png b/client/src/images/roles/BrutalHunter.png new file mode 100644 index 0000000..c4251e3 Binary files /dev/null and b/client/src/images/roles/BrutalHunter.png differ diff --git a/client/src/images/roles/Minion.png b/client/src/images/roles/KnowingMinion.png similarity index 100% rename from client/src/images/roles/Minion.png rename to client/src/images/roles/KnowingMinion.png diff --git a/client/src/images/tutorial/add-custom-role.gif b/client/src/images/tutorial/add-custom-role.gif deleted file mode 100644 index ad9b7b2..0000000 Binary files a/client/src/images/tutorial/add-custom-role.gif and /dev/null differ diff --git a/client/src/images/tutorial/add-role-to-deck.gif b/client/src/images/tutorial/add-role-to-deck.gif new file mode 100644 index 0000000..884a7f3 Binary files /dev/null and b/client/src/images/tutorial/add-role-to-deck.gif differ diff --git a/client/src/images/tutorial/create-custom-role.gif b/client/src/images/tutorial/create-custom-role.gif new file mode 100644 index 0000000..7043beb Binary files /dev/null and b/client/src/images/tutorial/create-custom-role.gif differ diff --git a/client/src/images/tutorial/custom-roles.PNG b/client/src/images/tutorial/custom-roles.PNG deleted file mode 100644 index e5413d5..0000000 Binary files a/client/src/images/tutorial/custom-roles.PNG and /dev/null differ diff --git a/client/src/images/tutorial/default-roles.PNG b/client/src/images/tutorial/default-roles.PNG deleted file mode 100644 index 57f9c7c..0000000 Binary files a/client/src/images/tutorial/default-roles.PNG and /dev/null differ diff --git a/client/src/images/tutorial/moderation-option.png b/client/src/images/tutorial/moderation-option.png index b82fa70..0bf06d0 100644 Binary files a/client/src/images/tutorial/moderation-option.png and b/client/src/images/tutorial/moderation-option.png differ diff --git a/client/src/images/tutorial/transfer-mod.gif b/client/src/images/tutorial/transfer-mod.gif index 5470e96..d6c4609 100644 Binary files a/client/src/images/tutorial/transfer-mod.gif and b/client/src/images/tutorial/transfer-mod.gif differ diff --git a/client/src/modules/DeckStateManager.js b/client/src/modules/DeckStateManager.js index 1299271..fb31516 100644 --- a/client/src/modules/DeckStateManager.js +++ b/client/src/modules/DeckStateManager.js @@ -1,43 +1,14 @@ import { globals } from '../config/globals.js'; -import { toast } from './Toast.js'; -import { ModalManager } from './ModalManager.js'; +import { HTMLFragments } from './HTMLFragments.js'; export class DeckStateManager { constructor () { - this.deck = null; - this.customRoleOptions = []; - this.createMode = false; - this.currentlyEditingRoleName = null; + this.deck = []; } addToDeck (role) { - const option = this.customRoleOptions.find((option) => option.role === role); - if (option) { - option.quantity = 0; - this.deck.push(option); - this.customRoleOptions.splice(this.customRoleOptions.indexOf(option), 1); - } - } - - addToCustomRoleOptions (role) { - this.customRoleOptions.push(role); - localStorage.setItem('play-werewolf-custom-roles', JSON.stringify(this.customRoleOptions.concat(this.deck.filter(card => card.custom === true)))); - } - - updateCustomRoleOption (option, name, description, team) { - option.role = name; - option.description = description; - option.team = team; - localStorage.setItem('play-werewolf-custom-roles', JSON.stringify(this.customRoleOptions.concat(this.deck.filter(card => card.custom === true)))); - } - - removeFromCustomRoleOptions (name) { - const 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, 'short'); - } + role.quantity = 1; + this.deck.push(role); } addCopyOfCard (role) { @@ -49,27 +20,25 @@ export class DeckStateManager { removeCopyOfCard (role) { const existingCard = this.deck.find((card) => card.role === role); - if (existingCard && existingCard.quantity > 0) { + if (existingCard.quantity > 0) { existingCard.quantity -= 1; } } - getCurrentDeck () { return this.deck; } + removeRoleEntirelyFromDeck (entry) { + const existingCard = this.deck.find((card) => card.role === entry.role); + if (existingCard) { + existingCard.quantity = 0; + this.updateDeckStatus(); + } + } - getCard (role) { + hasRole (role) { return this.deck.find( (card) => card.role.toLowerCase().trim() === role.toLowerCase().trim() ); } - getCurrentCustomRoleOptions () { return this.customRoleOptions; } - - getCustomRoleOption (role) { - return this.customRoleOptions.find( - (option) => option.role.toLowerCase().trim() === role.toLowerCase().trim() - ); - }; - getDeckSize () { let total = 0; for (const role of this.deck) { @@ -78,86 +47,67 @@ export class DeckStateManager { return total; } - loadCustomRolesFromCookies () { - const 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) { - const reader = new FileReader(); - reader.onerror = (e) => { - toast(reader.error.message, 'error', true, true, 'medium'); - }; - 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; + updateDeckStatus = () => { + document.getElementById('deck-count').innerText = this.getDeckSize() + ' Players'; + if (this.deck.length > 0) { + if (document.getElementById('deck-list-placeholder')) { + document.getElementById('deck-list-placeholder').remove(); } - 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, 'short'); - 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, - 'medium' - ); - } - }; - reader.readAsText(file); - } - - // via https://stackoverflow.com/a/18197341 - downloadCustomRoles (filename, text) { - const 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) { - const valid = false; - if (typeof cookie === 'string' && new Blob([cookie]).size <= 1000000) { - try { - const cookieJSON = JSON.parse(cookie); - if (Array.isArray(cookieJSON)) { - for (const 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; - } + const sortedDeck = this.deck.sort((a, b) => { + if (a.team !== b.team) { + return a.team === globals.ALIGNMENT.GOOD ? 1 : -1; + } + return a.role.localeCompare(b.role); + }); + for (let i = 0; i < sortedDeck.length; i ++) { + const existingCardEl = document.querySelector('#deck-list [data-role-id="' + sortedDeck[i].id + '"]'); + if (sortedDeck[i].quantity > 0) { + if (existingCardEl) { + existingCardEl.querySelector('.role-name').innerText = sortedDeck[i].quantity + 'x ' + sortedDeck[i].role; } else { - return false; + const roleEl = document.createElement('div'); + roleEl.dataset.roleId = sortedDeck[i].id; + roleEl.classList.add('added-role'); + roleEl.innerHTML = HTMLFragments.DECK_SELECT_ROLE_ADDED_TO_DECK; + // roleEl.classList.add('deck-role'); + if (sortedDeck[i].team === globals.ALIGNMENT.GOOD) { + roleEl.classList.add(globals.ALIGNMENT.GOOD); + } else { + roleEl.classList.add(globals.ALIGNMENT.EVIL); + } + roleEl.querySelector('.role-name').innerText = sortedDeck[i].quantity + 'x ' + sortedDeck[i].role; + document.getElementById('deck-list').appendChild(roleEl); + const minusOneHandler = (e) => { + if (e.type === 'click' || e.code === 'Enter') { + e.preventDefault(); + this.removeCopyOfCard(sortedDeck[i].role); + this.updateDeckStatus(); + } + }; + roleEl.querySelector('.role-remove').addEventListener('click', minusOneHandler); + roleEl.querySelector('.role-remove').addEventListener('keyup', minusOneHandler); + } + } else { + sortedDeck[i].markedForRemoval = true; + if (existingCardEl) { + existingCardEl.remove(); } } - return true; + this.deck = this.deck.filter((card) => { + if (card.markedForRemoval) { + card.markedForRemoval = false; + return false; + } else { + return true; + } + }); + } + if (this.deck.length === 0) { + const placeholder = document.createElement('div'); + placeholder.setAttribute('id', 'deck-list-placeholder'); + placeholder.innerText = 'Add a card from the role box.'; + document.getElementById('deck-list').appendChild(placeholder); } - } catch (e) { - return false; } - } - - return valid; + }; } diff --git a/client/src/modules/GameCreationStepManager.js b/client/src/modules/GameCreationStepManager.js index 1d79166..169d35b 100644 --- a/client/src/modules/GameCreationStepManager.js +++ b/client/src/modules/GameCreationStepManager.js @@ -4,16 +4,15 @@ import { ModalManager } from './ModalManager.js'; import { XHRUtility } from './XHRUtility.js'; import { globals } from '../config/globals.js'; import { HTMLFragments } from './HTMLFragments.js'; -import { defaultCards } from '../config/defaultCards.js'; import { UserUtility } from './UserUtility.js'; +import { RoleBox } from './RoleBox.js'; export class GameCreationStepManager { constructor (deckManager) { - loadDefaultCards(deckManager); - deckManager.loadCustomRolesFromCookies(); this.step = 1; this.currentGame = new Game(null, null, null, null); this.deckManager = deckManager; + this.roleBox = null; this.defaultBackHandler = () => { cancelCurrentToast(); this.removeStepElementsFromDOM(this.step); @@ -38,7 +37,7 @@ export class GameCreationStepManager { title: 'Create your deck of cards:', forwardHandler: () => { if (this.deckManager.getDeckSize() >= 3 && this.deckManager.getDeckSize() <= 50) { - this.currentGame.deck = deckManager.getCurrentDeck().filter((card) => card.quantity > 0); + this.currentGame.deck = this.deckManager.deck.filter((card) => card.quantity > 0); cancelCurrentToast(); this.removeStepElementsFromDOM(this.step); this.incrementStep(); @@ -173,7 +172,7 @@ export class GameCreationStepManager { showButtons(false, true, this.steps[step].forwardHandler, null); break; case 2: - renderRoleSelectionStep(this.currentGame, containerId, step, this.deckManager); + this.renderRoleSelectionStep(this.currentGame, containerId, step, this.deckManager); showButtons(true, true, this.steps[step].forwardHandler, this.steps[step].backHandler); break; case 3: @@ -197,6 +196,81 @@ export class GameCreationStepManager { removeStepElementsFromDOM (stepNumber) { document.getElementById('step-' + stepNumber)?.remove(); } + + renderRoleSelectionStep = (game, containerId, step, deckManager) => { + const stepContainer = document.createElement('div'); + + setAttributes(stepContainer, { id: 'step-' + step, class: 'flex-row-container-left-align step' }); + + stepContainer.innerHTML += HTMLFragments.CREATE_GAME_DECK_STATUS; + + document.getElementById(containerId).appendChild(stepContainer); + this.roleBox = new RoleBox(stepContainer, deckManager); + this.roleBox.loadDefaultRoles(); + this.roleBox.loadCustomRolesFromCookies(); + this.roleBox.displayDefaultRoles(document.getElementById('role-select')); + + const exportHandler = (e) => { + if (e.type === 'click' || e.code === 'Enter') { + e.preventDefault(); + this.roleBox.downloadCustomRoles('play-werewolf-custom-roles', JSON.stringify( + this.roleBox.customRoles + .map((option) => ( + { role: option.role, team: option.team, description: option.description, custom: option.custom } + )) + )); + toast('Custom roles downloading', 'success', true, true); + document.getElementById('custom-role-actions').style.display = 'none'; + } + }; + + document.querySelector('#custom-roles-export').addEventListener('click', exportHandler); + document.querySelector('#custom-roles-export').addEventListener('keyup', exportHandler); + + const importHandler = (e) => { + if (e.type === 'click' || e.code === 'Enter') { + e.preventDefault(); + ModalManager.displayModal('upload-custom-roles-modal', 'modal-background', 'close-upload-custom-roles-modal-button'); + } + }; + document.querySelector('#custom-roles-import').addEventListener('click', importHandler); + document.querySelector('#custom-roles-import').addEventListener('keyup', importHandler); + + document.getElementById('upload-custom-roles-form').onsubmit = (e) => { + e.preventDefault(); + const fileList = document.getElementById('upload-custom-roles').files; + if (fileList.length > 0) { + const file = fileList[0]; + if (file.size > 1000000) { + toast('Your file is too large (max 1MB)', 'error', true, true, 'medium'); + return; + } + if (file.type !== 'text/plain') { + toast('Your file must be a text file', 'error', true, true, 'medium'); + return; + } + + this.roleBox.loadCustomRolesFromFile(file); + } else { + toast('You must upload a text file', 'error', true, true, 'medium'); + } + }; + + const clickHandler = () => { + const actions = document.getElementById('custom-role-actions'); + if (window.getComputedStyle(actions, null).display !== 'none') { + actions.style.display = 'none'; + } else { + actions.style.display = 'block'; + } + }; + + document.getElementById('custom-role-hamburger').addEventListener('click', clickHandler); + + deckManager.updateDeckStatus(); + + initializeRemainingEventListeners(deckManager, this.roleBox); + }; } function renderNameStep (containerId, step, game, steps) { @@ -208,7 +282,6 @@ function renderNameStep (containerId, step, game, steps) { const nameInput = document.querySelector('#moderator-name'); nameInput.value = game.moderatorName; nameInput.addEventListener('keyup', steps['4'].forwardHandler); - nameInput.focus(); } function renderModerationTypeStep (game, containerId, stepNumber) { @@ -253,78 +326,6 @@ function renderModerationTypeStep (game, containerId, stepNumber) { document.getElementById(containerId).appendChild(stepContainer); } -function renderRoleSelectionStep (game, containerId, step, deckManager) { - const stepContainer = document.createElement('div'); - setAttributes(stepContainer, { id: 'step-' + step, class: 'flex-row-container-left-align step' }); - - stepContainer.innerHTML = HTMLFragments.CREATE_GAME_CUSTOM_ROLES; - stepContainer.innerHTML += HTMLFragments.CREATE_GAME_DECK_STATUS; - stepContainer.innerHTML += HTMLFragments.CREATE_GAME_DECK; - - const exportHandler = (e) => { - if (e.type === 'click' || e.code === 'Enter') { - e.preventDefault(); - deckManager.downloadCustomRoles('play-werewolf-custom-roles', JSON.stringify( - deckManager.getCurrentCustomRoleOptions() - .map((option) => ( - { role: option.role, team: option.team, description: option.description, custom: option.custom } - )) - )); - } - }; - document.getElementById(containerId).appendChild(stepContainer); - document.querySelector('#custom-roles-export').addEventListener('click', exportHandler); - document.querySelector('#custom-roles-export').addEventListener('keyup', exportHandler); - - const importHandler = (e) => { - if (e.type === 'click' || e.code === 'Enter') { - e.preventDefault(); - ModalManager.displayModal('upload-custom-roles-modal', 'modal-background', 'close-upload-custom-roles-modal-button'); - } - }; - document.querySelector('#custom-roles-import').addEventListener('click', importHandler); - document.querySelector('#custom-roles-import').addEventListener('keyup', importHandler); - - document.getElementById('upload-custom-roles-form').onsubmit = (e) => { - e.preventDefault(); - const fileList = document.getElementById('upload-custom-roles').files; - if (fileList.length > 0) { - const file = fileList[0]; - if (file.size > 1000000) { - toast('Your file is too large (max 1MB)', 'error', true, true, 'medium'); - return; - } - if (file.type !== 'text/plain') { - toast('Your file must be a text file', 'error', true, true, 'medium'); - return; - } - - deckManager.loadCustomRolesFromFile(file, updateCustomRoleOptionsList, loadDefaultCards, showIncludedCards); - } else { - toast('You must upload a text file', 'error', true, true, 'medium'); - } - }; - - const clickHandler = () => { - const actions = document.getElementById('custom-role-actions'); - if (window.getComputedStyle(actions, null).display !== 'none') { - actions.style.display = 'none'; - } else { - actions.style.display = 'block'; - } - }; - - document.getElementById('custom-role-hamburger').addEventListener('click', clickHandler); - - showIncludedCards(deckManager); - - loadCustomRoles(deckManager); - - updateDeckStatus(deckManager); - - initializeRemainingEventListeners(deckManager); -} - function renderTimerStep (containerId, stepNumber, game, steps) { const div = document.createElement('div'); div.setAttribute('id', 'step-' + stepNumber); @@ -465,119 +466,25 @@ function showButtons (back, forward, forwardHandler, backHandler, builtGame = nu } } -// Display a widget for each default card that allows copies of it to be added/removed. Set initial deck state. -function showIncludedCards (deckManager) { - document.querySelectorAll('.compact-card').forEach((el) => { el.remove(); }); - for (let i = 0; i < deckManager.getCurrentDeck().length; i ++) { - const card = deckManager.getCurrentDeck()[i]; - const cardEl = constructCompactDeckBuilderElement(card, deckManager); - if (card.team === globals.ALIGNMENT.GOOD) { - document.getElementById('deck-good').appendChild(cardEl); - } else { - document.getElementById('deck-evil').appendChild(cardEl); - } - } -} - -/* Display a dropdown containing all the custom roles. Adding one will add it to the game deck and -create a widget for it */ -function loadCustomRoles (deckManager) { - 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); - }); - const deck = []; - for (let i = 0; i < defaultCards.length; i ++) { - const card = defaultCards[i]; - card.quantity = 0; - deck.push(card); - } - deckManager.deck = deck; -} - -function constructCompactDeckBuilderElement (card, deckManager) { - const cardContainer = document.createElement('div'); - const alignmentClass = card.team === globals.ALIGNMENT.GOOD ? globals.ALIGNMENT.GOOD : globals.ALIGNMENT.EVIL; - - cardContainer.setAttribute('class', 'compact-card ' + alignmentClass); - - cardContainer.setAttribute('id', 'card-' + card.role.replaceAll(' ', '-')); - - cardContainer.innerHTML = - "
" + - '

-

' + - '
' + - "
" + - "

" + - "
" + - '
' + - "
" + - '

+

' + - '
'; - - cardContainer.querySelector('.card-role').innerText = card.role; - cardContainer.title = card.role; - cardContainer.querySelector('.card-quantity').innerText = card.quantity; - - if (card.quantity > 0) { - cardContainer.classList.add('selected-card'); - } - - const addHandler = (e) => { - if (e.type === 'click' || e.code === 'Enter') { - deckManager.addCopyOfCard(card.role); - updateDeckStatus(deckManager); - cardContainer.querySelector('.card-quantity').innerText = deckManager.getCard(card.role).quantity; - if (deckManager.getCard(card.role).quantity > 0) { - document.getElementById('card-' + card.role.replaceAll(' ', '-')).classList.add('selected-card'); - } - } - }; - - const removeHandler = (e) => { - if (e.type === 'click' || e.code === 'Enter') { - deckManager.removeCopyOfCard(card.role); - updateDeckStatus(deckManager); - cardContainer.querySelector('.card-quantity').innerText = deckManager.getCard(card.role).quantity; - if (deckManager.getCard(card.role).quantity === 0) { - document.getElementById('card-' + card.role.replaceAll(' ', '-')).classList.remove('selected-card'); - } - } - }; - - cardContainer.querySelector('.compact-card-right').addEventListener('click', addHandler); - cardContainer.querySelector('.compact-card-right').addEventListener('keyup', addHandler); - cardContainer.querySelector('.compact-card-left').addEventListener('click', removeHandler); - cardContainer.querySelector('.compact-card-left').addEventListener('keyup', removeHandler); - - return cardContainer; -} - -function initializeRemainingEventListeners (deckManager) { +function initializeRemainingEventListeners (deckManager, roleBox) { document.getElementById('role-form').onsubmit = (e) => { e.preventDefault(); const name = document.getElementById('role-name').value.trim(); const description = document.getElementById('role-description').value.trim(); const team = document.getElementById('role-alignment').value.toLowerCase().trim(); - if (deckManager.createMode) { - if (!deckManager.getCustomRoleOption(name) && !deckManager.getCard(name)) { // confirm there is no existing custom role with the same name - processNewCustomRoleSubmission(name, description, team, deckManager, false); + if (roleBox.createMode) { + if (!roleBox.getCustomRole(name) && !roleBox.getDefaultRole(name)) { // confirm there is no existing custom role with the same name + processNewCustomRoleSubmission(name, description, team, deckManager, false, roleBox); } else { - toast('There is already a role with this name', 'error', true, true, 'short'); + toast('There is already a default or custom role with this name', 'error', true, true, 'short'); } } else { - const option = deckManager.getCustomRoleOption(deckManager.currentlyEditingRoleName); - if (name === option.role) { // did they edit the name? - processNewCustomRoleSubmission(name, description, team, deckManager, true, option); + const entry = roleBox.getCustomRole(roleBox.currentlyEditingRoleName); + if (name === entry.role) { // did they edit the name? + processNewCustomRoleSubmission(name, description, team, deckManager, true, roleBox, entry); } else { - if (!deckManager.getCustomRoleOption(name) && !deckManager.getCard(name)) { - processNewCustomRoleSubmission(name, description, team, deckManager, true, option); + if (!roleBox.getCustomRole(name) && !roleBox.getDefaultRole(name)) { + processNewCustomRoleSubmission(name, description, team, deckManager, true, roleBox, entry); } else { toast('There is already a role with this name', 'error', true, true, 'short'); } @@ -588,8 +495,8 @@ function initializeRemainingEventListeners (deckManager) { 'click', () => { const createBtn = document.getElementById('create-role-button'); createBtn.setAttribute('value', 'Create'); - deckManager.createMode = true; - deckManager.currentlyEditingRoleName = null; + roleBox.createMode = true; + roleBox.currentlyEditingRoleName = null; document.getElementById('role-name').value = ''; document.getElementById('role-alignment').value = globals.ALIGNMENT.GOOD; document.getElementById('role-description').value = ''; @@ -602,7 +509,7 @@ function initializeRemainingEventListeners (deckManager) { ); } -function processNewCustomRoleSubmission (name, description, team, deckManager, isUpdate, option = null) { +function processNewCustomRoleSubmission (name, description, team, deckManager, isUpdate, roleBox, option = null) { if (name.length > 40) { toast('Your name is too long (max 40 characters).', 'error', true); return; @@ -612,147 +519,16 @@ function processNewCustomRoleSubmission (name, description, team, deckManager, i return; } if (isUpdate) { - deckManager.updateCustomRoleOption(option, name, description, team); + roleBox.updateCustomRole(option, name, description, team); ModalManager.dispelModal('role-modal', 'modal-background'); toast('Role Updated', 'success', true); } else { - deckManager.addToCustomRoleOptions({ role: name, description: description, team: team, custom: true }); + roleBox.addCustomRole({ role: name, description: description, team: team, custom: true }); ModalManager.dispelModal('role-modal', 'modal-background'); toast('Role Created', 'success', true); } - - updateCustomRoleOptionsList(deckManager, document.getElementById('deck-select')); -} - -function updateCustomRoleOptionsList (deckManager, selectEl) { - document.querySelectorAll('#deck-select .deck-select-role').forEach(e => e.remove()); - addOptionsToList(deckManager, selectEl); -} - -function addOptionsToList (deckManager, selectEl) { - const options = deckManager.getCurrentCustomRoleOptions(); - options.sort((a, b) => { - if (a.team !== b.team) { - return a.team === globals.ALIGNMENT.GOOD ? 1 : -1; - } - return a.role.localeCompare(b.role); - }); - for (let i = 0; i < options.length; i ++) { - const optionEl = document.createElement('div'); - optionEl.innerHTML = HTMLFragments.DECK_SELECT_ROLE; - optionEl.classList.add('deck-select-role'); - const alignmentClass = options[i].team === globals.ALIGNMENT.GOOD ? globals.ALIGNMENT.GOOD : globals.ALIGNMENT.EVIL; - optionEl.classList.add(alignmentClass); - 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) => { - const name = role.querySelector('.deck-select-role-name').innerText; - const includeHandler = (e) => { - if (e.type === 'click' || e.code === 'Enter') { - e.preventDefault(); - if (!deckManager.getCard(name)) { - deckManager.addToDeck(name); - const cardEl = constructCompactDeckBuilderElement(deckManager.getCard(name), deckManager); - toast('"' + name + '" made available below.', 'success', true, true, 'medium'); - 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, 'short'); - } - } - }; - role.querySelector('.deck-select-include').addEventListener('click', includeHandler); - role.querySelector('.deck-select-include').addEventListener('keyup', includeHandler); - - const removeHandler = (e) => { - if (e.type === 'click' || e.code === 'Enter') { - if (confirm("Delete the role '" + name + "'?")) { - e.preventDefault(); - deckManager.removeFromCustomRoleOptions(name); - updateCustomRoleOptionsList(deckManager, select); - } - } - }; - role.querySelector('.deck-select-remove').addEventListener('click', removeHandler); - role.querySelector('.deck-select-remove').addEventListener('keyup', removeHandler); - - 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(); - const option = deckManager.getCustomRoleOption(name); - document.getElementById('custom-role-info-modal-name').innerText = name; - alignmentEl.classList.add(option.team); - document.getElementById('custom-role-info-modal-description').innerText = option.description; - alignmentEl.innerText = option.team; - ModalManager.displayModal('custom-role-info-modal', 'modal-background', 'close-custom-role-info-modal-button'); - } - }; - role.querySelector('.deck-select-info').addEventListener('click', infoHandler); - role.querySelector('.deck-select-info').addEventListener('keyup', infoHandler); - - const editHandler = (e) => { - if (e.type === 'click' || e.code === 'Enter') { - e.preventDefault(); - const option = deckManager.getCustomRoleOption(name); - document.getElementById('role-name').value = option.role; - document.getElementById('role-alignment').value = option.team; - document.getElementById('role-description').value = option.description; - deckManager.createMode = false; - deckManager.currentlyEditingRoleName = option.role; - const createBtn = document.getElementById('create-role-button'); - createBtn.setAttribute('value', 'Update'); - ModalManager.displayModal('role-modal', 'modal-background', 'close-modal-button'); - } - }; - role.querySelector('.deck-select-edit').addEventListener('click', editHandler); - role.querySelector('.deck-select-edit').addEventListener('keyup', editHandler); - }); -} - -function updateDeckStatus (deckManager) { - document.querySelectorAll('.deck-role').forEach((el) => el.remove()); - document.getElementById('deck-count').innerText = deckManager.getDeckSize() + ' Players'; - if (deckManager.getDeckSize() === 0) { - const placeholder = document.createElement('div'); - placeholder.setAttribute('id', 'deck-list-placeholder'); - placeholder.innerText = 'Add a card from the available roles below.'; - document.getElementById('deck-list').appendChild(placeholder); - } else { - if (document.getElementById('deck-list-placeholder')) { - document.getElementById('deck-list-placeholder').remove(); - } - const sortedDeck = deckManager.getCurrentDeck().sort((a, b) => { - if (a.team !== b.team) { - return a.team === globals.ALIGNMENT.GOOD ? 1 : -1; - } - return a.role.localeCompare(b.role); - }); - for (const card of sortedDeck) { - if (card.quantity > 0) { - const 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); - } - } + if (roleBox.category === 'custom') { + roleBox.displayCustomRoles(document.getElementById('role-select')); } } diff --git a/client/src/modules/HTMLFragments.js b/client/src/modules/HTMLFragments.js index db78500..e91a2d5 100644 --- a/client/src/modules/HTMLFragments.js +++ b/client/src/modules/HTMLFragments.js @@ -248,11 +248,15 @@ export const HTMLFragments = {
-
Export
-
Import
+
Export Roles
+
Import Roles
- -
+ +
+ + +
+
`, CREATE_GAME_DECK_STATUS: @@ -261,11 +265,22 @@ export const HTMLFragments = {
`, DECK_SELECT_ROLE: - `
-
- include - info - edit - remove + `
+
+ add one + info + edit + remove +
`, + DECK_SELECT_ROLE_DEFAULT: + `
+
+ add one + info +
`, + DECK_SELECT_ROLE_ADDED_TO_DECK: + `
+
+ remove one
` }; diff --git a/client/src/modules/RoleBox.js b/client/src/modules/RoleBox.js new file mode 100644 index 0000000..8656572 --- /dev/null +++ b/client/src/modules/RoleBox.js @@ -0,0 +1,342 @@ +import { HTMLFragments } from './HTMLFragments.js'; +import { globals } from '../config/globals.js'; +import { defaultRoles } from '../config/defaultRoles.js'; +import { toast } from './Toast.js'; +import { ModalManager } from './ModalManager.js'; + +export class RoleBox { + constructor (container, deckManager) { + this.createMode = false; + this.currentlyEditingRoleName = null; + 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'); + this.customButton = document.getElementById('role-category-custom'); + this.defaultButton.addEventListener('click', () => { this.changeRoleCategory('default'); }); + this.customButton.addEventListener('click', () => { this.changeRoleCategory('custom'); }); + this.categoryTransition = document.getElementById('role-select').animate( + [ + { opacity: 0 }, + { opacity: 1 } + ], { + fill: 'forwards', + easing: 'linear', + duration: 500 + }); + } + + render = () => { + + }; + + loadDefaultRoles = () => { + this.defaultRoles = defaultRoles.sort((a, b) => { + if (a.team !== b.team) { + return a.team === globals.ALIGNMENT.GOOD ? 1 : -1; + } + return a.role.localeCompare(b.role); + }).map((role) => { + role.id = createRandomId(); + return role; + }); + }; + + loadCustomRolesFromCookies () { + const customRoles = localStorage.getItem('play-werewolf-custom-roles'); + if (customRoles !== null && validateCustomRoleCookie(customRoles)) { + this.customRoles = JSON.parse(customRoles).map((role) => { + role.id = createRandomId(); + return role; + }); // we know it is valid JSON from the validate function + } + } + + loadCustomRolesFromFile (file) { + const reader = new FileReader(); + reader.onerror = (e) => { + toast(reader.error.message, 'error', true, true, 'medium'); + }; + 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.customRoles = JSON.parse(string).map((role) => { + role.id = createRandomId(); + return role; + }); // we know it is valid JSON from the validate function + const initialLength = this.customRoles.length; + // If any imported roles match a default role, exclude them. + this.customRoles = this.customRoles.filter((entry) => !this.defaultRoles + .find((defaultEntry) => defaultEntry.role.toLowerCase().trim() === entry.role.toLowerCase().trim())); + let message = this.customRoles.length === initialLength + ? 'All roles imported successfully!' + : 'Success, but one or more roles were excluded because their names match default roles.' + let messageType = this.customRoles.length === initialLength ? 'success' : 'warning' + ModalManager.dispelModal('upload-custom-roles-modal', 'modal-background'); + toast(message, messageType, true, true, 'medium'); + document.getElementById('custom-role-actions').style.display = 'none'; + localStorage.setItem('play-werewolf-custom-roles', JSON.stringify(this.customRoles)); + this.changeRoleCategory('custom'); + this.displayCustomRoles(document.getElementById('role-select')); + for (const card of this.deckManager.deck) { + card.quantity = 0; + } + this.deckManager.updateDeckStatus(); + } else { + toast( + 'Invalid formatting. Make sure you import the file as downloaded from this page.', + 'error', + true, + true, + 'medium' + ); + } + }; + reader.readAsText(file); + } + + // via https://stackoverflow.com/a/18197341 + downloadCustomRoles = (filename, text) => { + const 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); + }; + + changeRoleCategory = (category) => { + this.category = category; + if (category === 'default') { + this.displayDefaultRoles(document.getElementById('role-select')); + if (this.defaultButton) { + this.defaultButton.classList.add('role-category-button-selected'); + } + if (this.customButton) { + this.customButton.classList.remove('role-category-button-selected'); + } + } else if (category === 'custom') { + this.displayCustomRoles(document.getElementById('role-select')); + if (this.customButton) { + this.customButton.classList.add('role-category-button-selected'); + } + if (this.defaultButton) { + this.defaultButton.classList.remove('role-category-button-selected'); + } + } + }; + + displayDefaultRoles = (selectEl) => { + document.querySelectorAll('#role-select .default-role, #role-select .custom-role').forEach(e => e.remove()); + this.categoryTransition.play(); + for (let i = 0; i < this.defaultRoles.length; i ++) { + const defaultRole = document.createElement('div'); + defaultRole.innerHTML = HTMLFragments.DECK_SELECT_ROLE_DEFAULT; + defaultRole.classList.add('default-role'); + defaultRole.dataset.roleId = this.defaultRoles[i].id; + const alignmentClass = this.defaultRoles[i].team === globals.ALIGNMENT.GOOD + ? globals.ALIGNMENT.GOOD + : globals.ALIGNMENT.EVIL; + defaultRole.classList.add(alignmentClass); + defaultRole.querySelector('.role-name').innerText = this.defaultRoles[i].role; + selectEl.appendChild(defaultRole); + } + + this.addRoleEventListeners(selectEl, true, true, false, false, false); + }; + + displayCustomRoles = (selectEl) => { + document.querySelectorAll('#role-select .default-role, #role-select .custom-role').forEach(e => e.remove()); + this.categoryTransition.play(); + this.customRoles.sort((a, b) => { + if (a.team !== b.team) { + return a.team === globals.ALIGNMENT.GOOD ? 1 : -1; + } + return a.role.localeCompare(b.role); + }); + + for (let i = 0; i < this.customRoles.length; i ++) { + const customRole = document.createElement('div'); + customRole.innerHTML = HTMLFragments.DECK_SELECT_ROLE; + customRole.classList.add('custom-role'); + customRole.dataset.roleId = this.customRoles[i].id; + const alignmentClass = this.customRoles[i].team === globals.ALIGNMENT.GOOD ? globals.ALIGNMENT.GOOD : globals.ALIGNMENT.EVIL; + customRole.classList.add(alignmentClass); + customRole.querySelector('.role-name').innerText = this.customRoles[i].role; + selectEl.appendChild(customRole); + } + + this.addRoleEventListeners(selectEl, true, true, true, true, true); + }; + + addRoleEventListeners = (select, addOne, info, edit, remove, isCustom) => { + const elements = isCustom + ? document.querySelectorAll('#role-select .custom-role') + : document.querySelectorAll('#role-select .default-role'); + elements.forEach((role) => { + if (addOne) { + const plusOneHandler = (e) => { + if (e.type === 'click' || e.code === 'Enter') { + e.preventDefault(); + if (!this.deckManager.hasRole(name)) { + if (isCustom) { + this.deckManager.addToDeck(this.getCustomRole(name)); + } else { + this.deckManager.addToDeck(this.getDefaultRole(name)); + } + } else { + this.deckManager.addCopyOfCard(name); + } + this.deckManager.updateDeckStatus(); + } + }; + role.querySelector('.role-include').addEventListener('click', plusOneHandler); + role.querySelector('.role-include').addEventListener('keyup', plusOneHandler); + } + const name = role.querySelector('.role-name').innerText; + + if (remove) { + const removeHandler = (e) => { + if (e.type === 'click' || e.code === 'Enter') { + if (confirm("Delete the role '" + name + "'?")) { + e.preventDefault(); + this.removeFromCustomRoles(name); + if (this.category === 'custom') { + this.displayCustomRoles(document.getElementById('role-select')); + } + } + } + }; + role.querySelector('.role-remove').addEventListener('click', removeHandler); + role.querySelector('.role-remove').addEventListener('keyup', removeHandler); + } + if (info) { + 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(); + let role; + if (isCustom) { + role = this.getCustomRole(name); + } else { + role = this.getDefaultRole(name); + } + document.getElementById('custom-role-info-modal-name').innerText = name; + alignmentEl.classList.add(role.team); + document.getElementById('custom-role-info-modal-description').innerText = role.description; + alignmentEl.innerText = role.team; + ModalManager.displayModal('custom-role-info-modal', 'modal-background', 'close-custom-role-info-modal-button'); + } + }; + role.querySelector('.role-info').addEventListener('click', infoHandler); + role.querySelector('.role-info').addEventListener('keyup', infoHandler); + } + + if (edit) { + const editHandler = (e) => { + if (e.type === 'click' || e.code === 'Enter') { + e.preventDefault(); + const entry = this.getCustomRole(name); + document.getElementById('role-name').value = entry.role; + document.getElementById('role-alignment').value = entry.team; + document.getElementById('role-description').value = entry.description; + this.createMode = false; + this.currentlyEditingRoleName = entry.role; + const createBtn = document.getElementById('create-role-button'); + createBtn.setAttribute('value', 'Update'); + ModalManager.displayModal('role-modal', 'modal-background', 'close-modal-button'); + } + }; + role.querySelector('.role-edit').addEventListener('click', editHandler); + role.querySelector('.role-edit').addEventListener('keyup', editHandler); + } + }); + }; + + removeFromCustomRoles = (name) => { + const role = this.customRoles.find((entry) => entry.role === name); + if (role) { + this.customRoles.splice(this.customRoles.indexOf(role), 1); + this.deckManager.removeRoleEntirelyFromDeck(role); + localStorage.setItem('play-werewolf-custom-roles', JSON.stringify(this.customRoles)); + toast('"' + name + '" deleted.', 'error', true, true, 'short'); + } + }; + + getCustomRole (name) { + return this.customRoles.find( + (entry) => entry.role.toLowerCase().trim() === name.toLowerCase().trim() + ); + }; + + addCustomRole (role) { + role.id = createRandomId(); + this.customRoles.push(role); + localStorage.setItem('play-werewolf-custom-roles', JSON.stringify(this.customRoles)); + } + + updateCustomRole (entry, name, description, team) { + entry.role = name; + entry.description = description; + entry.team = team; + this.deckManager.updateDeckStatus(); + localStorage.setItem('play-werewolf-custom-roles', JSON.stringify(this.customRoles)); + } + + getDefaultRole (name) { + return this.defaultRoles.find( + (entry) => entry.role.toLowerCase().trim() === name.toLowerCase().trim() + ); + }; +} + +function createRandomId () { + let id = ''; + for (let i = 0; i < 25; i ++) { + id += globals.CHAR_POOL[Math.floor(Math.random() * globals.CHAR_POOL.length)]; + } + return id; +} + +// this is user-supplied, so we should validate it fully +function validateCustomRoleCookie (cookie) { + const valid = false; + if (typeof cookie === 'string' && new Blob([cookie]).size <= 1000000) { + try { + const cookieJSON = JSON.parse(cookie); + if (Array.isArray(cookieJSON)) { + for (const 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/styles/GLOBAL.css b/client/src/styles/GLOBAL.css index 57b8903..3e93edb 100644 --- a/client/src/styles/GLOBAL.css +++ b/client/src/styles/GLOBAL.css @@ -455,7 +455,6 @@ input { display: flex; flex-direction: row; flex-wrap: wrap; - align-items: center; } .animated-placeholder { diff --git a/client/src/styles/create.css b/client/src/styles/create.css index fa383be..0771085 100644 --- a/client/src/styles/create.css +++ b/client/src/styles/create.css @@ -13,6 +13,28 @@ height: 55px; } +.role-category-button { + background-color: transparent; + color: #b1afcd; + border: 1px solid #b1afcd; + border-radius: 25px; + font-size: 16px; + padding: 5px 10px; + margin: 5px; + font-family: 'signika-negative', sans-serif !important; + cursor: pointer; +} + +.role-category-button-selected { + color: black; + background-color: #b1afcd; +} + +#role-category-buttons { + margin-top: 10px; + display: flex; +} + .compact-card h1 { display: flex; align-items: center; @@ -107,7 +129,7 @@ #deck-status-container { width: 20em; max-width: 95%; - height: 10em; + height: 20em; overflow-y: auto; position: relative; } @@ -169,6 +191,7 @@ display: none; color: #e7e7e7; position: absolute; + z-index: 25; top: 38px; right: 29px; background-color: #333243; @@ -353,13 +376,13 @@ input[type="number"] { border: 2px solid #1c8a36; } -#deck-select { +#role-select { margin: 0.5em 1em 1.5em 0; overflow-y: auto; - height: 12em; + height: 16em; } -.deck-select-role { +.default-role, .custom-role, .added-role { display: flex; justify-content: space-between; background-color: black; @@ -371,23 +394,23 @@ input[type="number"] { font-size: 16px; } -.deck-select-role-name { +.role-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap } -.deck-select-role:hover { +.default-role:hover, .custom-role:hover, .added-role:hover { border: 1px solid #d7d7d7; } -.deck-select-role-options { +.role-options { display: flex; align-items: center; justify-content: center; } -#deck-select img { +#role-select img, #deck-status-container img { height: 20px; margin: 0 8px; cursor: pointer; @@ -395,11 +418,11 @@ input[type="number"] { border-radius: 3px; } -#deck-select img:nth-child(4) { +#role-select img:nth-child(4) { height: 18px; } -#deck-select img:hover { +#role-select img:hover, #deck-status-container img:hover { filter: brightness(1.5); background-color: #8080804d; } @@ -540,6 +563,9 @@ input[type="number"] { } @media(max-width: 550px) { + #custom-roles-container, #deck-status-container { + min-width: 90%; + } h1 { font-size: 35px; } @@ -558,7 +584,7 @@ input[type="number"] { font-size: 16px; } - .deck-select-role-name { + .role-name { font-size: 13px; font-weight: bold; } diff --git a/client/src/styles/home.css b/client/src/styles/home.css index e33b47f..179b7c0 100644 --- a/client/src/styles/home.css +++ b/client/src/styles/home.css @@ -11,7 +11,7 @@ button#home-create-button { padding: 20px; } -#framed-phone-screenshot { +#framed-phone-screenshot, #framed-phone-screenshot-2 { max-width: 250px; width: 40vw; min-width: 175px; @@ -23,8 +23,16 @@ button#home-create-button { align-items: center; flex-wrap: wrap; justify-content: center; - padding: 0 1em; - margin-top: 25px; + width: 100%; +} + +#about-container > div { + display: flex; + align-items: center; + justify-content: center; + width: fit-content; + flex-wrap: wrap; + padding: 25px 0; } #join-container form { @@ -34,6 +42,8 @@ button#home-create-button { #about-container h2 { max-width: 17em; font-size: 22px; + border-left: 1px solid #bababa; + padding: 15px; } #about-container img { @@ -52,14 +62,12 @@ button#home-create-button { align-items: center; width: 100%; background-color: #1e1b26; - margin-bottom: 15px; } form { display: flex; flex-wrap: wrap; margin: 10px 0; - padding: 10px; border-radius: 3px; justify-content: center; align-items: center; diff --git a/client/src/views/home.html b/client/src/views/home.html index 22e9760..d52d038 100644 --- a/client/src/views/home.html +++ b/client/src/views/home.html @@ -43,8 +43,14 @@
- framed phone screenshot -

Create your game, have everyone join, and deal a role to their device.

+
+ framed phone screenshot +

Join a game and have a role dealt to your device.

+
+
+ framed phone screenshot +

Create your own game with default or custom roles.

+