diff --git a/client/config/globals.js b/client/config/globals.js index c832249..901ecf9 100644 --- a/client/config/globals.js +++ b/client/config/globals.js @@ -4,6 +4,26 @@ export const globals = { PLAYER_ID_COOKIE_KEY: 'play-werewolf-anon-id', ACCESS_CODE_CHAR_POOL: 'abcdefghijklmnopqrstuvwxyz0123456789', COMMANDS: { - FETCH_GAME_STATE: 'fetchGameState' + FETCH_GAME_STATE: 'fetchGameState', + GET_ENVIRONMENT: 'getEnvironment' + }, + GAME_STATE: { + LOBBY: 'lobby' + }, + ALIGNMENT: { + GOOD: "good", + EVIL: "evil" + }, + EVENTS: { + PLAYER_JOINED: "playerJoined" + }, + USER_TYPES: { + MODERATOR: "moderator", + PLAYER: "player", + TEMPORARY_MODERATOR: "temporary moderator" + }, + ENVIRONMENT: { + LOCAL: "local", + PRODUCTION: "production" } }; diff --git a/client/images/clock.svg b/client/images/clock.svg new file mode 100644 index 0000000..2aa2e4f --- /dev/null +++ b/client/images/clock.svg @@ -0,0 +1,14 @@ + + + + background + + + + + + + Layer 1 + + + diff --git a/client/images/copy.svg b/client/images/copy.svg new file mode 100644 index 0000000..dfb0268 --- /dev/null +++ b/client/images/copy.svg @@ -0,0 +1,210 @@ + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/client/modules/GameCreationStepManager.js b/client/modules/GameCreationStepManager.js index 6af6bb2..f10ada1 100644 --- a/client/modules/GameCreationStepManager.js +++ b/client/modules/GameCreationStepManager.js @@ -3,6 +3,7 @@ import { cancelCurrentToast, toast } from "./Toast.js"; import { customCards } from "../config/customCards.js"; import { ModalManager } from "./ModalManager.js"; import {XHRUtility} from "./XHRUtility.js"; +import {globals} from "../config/globals.js"; export class GameCreationStepManager { constructor(deckManager) { @@ -401,8 +402,9 @@ function loadCustomRoles(deckManager) { function constructCompactDeckBuilderElement(card, deckManager) { const cardContainer = document.createElement("div"); + let alignmentClass = card.team === globals.ALIGNMENT.GOOD ? globals.ALIGNMENT.GOOD : globals.ALIGNMENT.EVIL - cardContainer.setAttribute("class", "compact-card"); + cardContainer.setAttribute("class", "compact-card " + alignmentClass); cardContainer.setAttribute("id", "card-" + card.role.replaceAll(' ', '-')); @@ -419,6 +421,7 @@ function constructCompactDeckBuilderElement(card, deckManager) { ""; cardContainer.querySelector('.card-role').innerText = card.role; + cardContainer.title = card.role; cardContainer.querySelector('.card-quantity').innerText = card.quantity; if (card.quantity > 0) { @@ -477,6 +480,8 @@ function updateCustomRoleOptionsList(deckManager, selectEl) { function addOptionsToList(options, selectEl) { for (let i = 0; i < options.length; i ++) { let optionEl = document.createElement("option"); + let alignmentClass = customCards[i].team === globals.ALIGNMENT.GOOD ? globals.ALIGNMENT.GOOD : globals.ALIGNMENT.EVIL + optionEl.classList.add(alignmentClass); optionEl.setAttribute("value", customCards[i].role); optionEl.innerText = customCards[i].role; selectEl.appendChild(optionEl); diff --git a/client/modules/GameStateRenderer.js b/client/modules/GameStateRenderer.js new file mode 100644 index 0000000..fb2af5d --- /dev/null +++ b/client/modules/GameStateRenderer.js @@ -0,0 +1,75 @@ +import {globals} from "../config/globals.js"; + +export class GameStateRenderer { + constructor(gameState) { + this.gameState = gameState; + } + + renderLobbyPlayers() { + document.querySelectorAll('.lobby-player').forEach((el) => el.remove()) + let lobbyPlayersContainer = document.getElementById("lobby-players"); + if (this.gameState.userType !== globals.USER_TYPES.MODERATOR) { + renderClient(this.gameState.client, lobbyPlayersContainer); + } + for (let person of this.gameState.people) { + let personEl = document.createElement("div"); + personEl.innerText = person.name; + personEl.classList.add('lobby-player'); + lobbyPlayersContainer.appendChild(personEl); + } + let playerCount; + if (this.gameState.userType === globals.USER_TYPES.MODERATOR) { + playerCount = this.gameState.people.length; + } else { + playerCount = 1 + this.gameState.people.length; + } + document.querySelector("label[for='lobby-players']").innerText = + "Players ( " + playerCount + " / " + getGameSize(this.gameState.deck) + " )"; + } + + renderLobbyHeader() { + let title = document.createElement("h1"); + title.innerText = "Lobby"; + document.body.prepend(title); + let gameLinkContainer = document.getElementById("game-link"); + gameLinkContainer.innerText = window.location; + let copyImg = document.createElement("img"); + copyImg.setAttribute("src", "../images/copy.svg"); + gameLinkContainer.appendChild(copyImg); + + let moderatorContainer = document.getElementById("moderator"); + let text, modClass; + if (this.gameState.userType === globals.USER_TYPES.MODERATOR || this.gameState.userType === globals.USER_TYPES.TEMPORARY_MODERATOR) { + moderatorContainer.innerText = this.gameState.moderator.name + " (you)"; + moderatorContainer.classList.add('moderator-client'); + } else { + moderatorContainer.innerText = this.gameState.moderator.name; + } + } + + renderLobbyFooter() { + let gameDeckContainer = document.getElementById("game-deck"); + for (let card of this.gameState.deck) { + let cardEl = document.createElement("div"); + cardEl.innerText = card.quantity + 'x ' + card.role; + cardEl.classList.add('lobby-card') + } + } +} + +function renderClient(client, container) { + let clientEl = document.createElement("div"); + clientEl.innerText = client.name + ' (you)'; + clientEl.classList.add('lobby-player'); + clientEl.classList.add('lobby-player-client'); + container.prepend(clientEl); +} + +function getGameSize(cards) { + let quantity = 0; + for (let card of cards) { + quantity += card.quantity; + } + + return quantity; +} diff --git a/client/modules/Templates.js b/client/modules/Templates.js new file mode 100644 index 0000000..f7c2b0d --- /dev/null +++ b/client/modules/Templates.js @@ -0,0 +1,24 @@ +export const templates = { + LOBBY: + "
" + + "
" + + "" + + "" + + "
" + + "
" + + "
" + + "
" + + "" + + "
" + + "
" + + "
" + + "
" + + "
" + + "" + + "
" + + "
" + + "" + + "
" +} diff --git a/client/modules/Timer.js b/client/modules/Timer.js new file mode 100644 index 0000000..608873a --- /dev/null +++ b/client/modules/Timer.js @@ -0,0 +1,86 @@ +/* +A timer using setTimeout that compensates for drift. Drift can happen for several reasons: +https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setTimeout#reasons_for_delays + +This means the timer may very well be late in executing the next call (but never early). +This timer is accurate to within a few ms for any amount of time provided. It's meant to be utilized as a Web Worker. +See: https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API + */ + +const messageParameters = { + STOP: 'stop', + TOTAL_TIME: 'totalTime', + TICK_INTERVAL: 'tickInterval' +}; + +onmessage = function (e) { + if (typeof e.data === 'object' + && e.data.hasOwnProperty(messageParameters.TOTAL_TIME) + && e.data.hasOwnProperty(messageParameters.TICK_INTERVAL) + ) { + const timer = new Singleton(e.data.totalTime, e.data.tickInterval); + timer.startTimer(); + } +}; + +function stepFn (expected, interval, start, totalTime) { + const now = Date.now(); + if (now - start >= totalTime) { + return; + } + const delta = now - expected; + expected += interval; + postMessage({ timeRemaining: (totalTime - (expected - start)) / 1000 }); + Singleton.setNewTimeoutReference(setTimeout(() => { + stepFn(expected, interval, start, totalTime); + }, Math.max(0, interval - delta) + )); // take into account drift - also retain a reference to this clock tick so it can be cleared later +} + +class Timer { + constructor (totalTime, tickInterval) { + this.timeoutId = undefined; + this.totalTime = totalTime; + this.tickInterval = tickInterval; + } + + startTimer () { + if (!isNaN(this.totalTime) && !isNaN(this.tickInterval)) { + const interval = this.tickInterval; + const totalTime = this.totalTime; + const start = Date.now(); + const expected = Date.now() + this.tickInterval; + if (this.timeoutId) { + clearTimeout(this.timeoutId); + } + this.timeoutId = setTimeout(() => { + stepFn(expected, interval, start, totalTime); + }, this.tickInterval); + } + } +} + +class Singleton { + constructor (totalTime, tickInterval) { + if (!Singleton.instance) { + Singleton.instance = new Timer(totalTime, tickInterval); + } else { + // This allows the same timer to be configured to run for different intervals / at a different granularity. + Singleton.setNewTimerParameters(totalTime, tickInterval); + } + return Singleton.instance; + } + + static setNewTimerParameters (totalTime, tickInterval) { + if (Singleton.instance) { + Singleton.instance.totalTime = totalTime; + Singleton.instance.tickInterval = tickInterval; + } + } + + static setNewTimeoutReference (timeoutId) { + if (Singleton.instance) { + Singleton.instance.timeoutId = timeoutId; + } + } +} diff --git a/client/modules/Toast.js b/client/modules/Toast.js index 34585be..7b14cf5 100644 --- a/client/modules/Toast.js +++ b/client/modules/Toast.js @@ -7,7 +7,7 @@ export const toast = (message, type, positionAtTop = true) => { function buildAndInsertMessageElement (message, type, positionAtTop) { cancelCurrentToast(); let backgroundColor; - const position = positionAtTop ? 'top:4rem;' : 'bottom: 15px;'; + const position = positionAtTop ? 'top:4rem;' : 'bottom: 35px;'; switch (type) { case 'warning': backgroundColor = '#fff5b1'; diff --git a/client/modules/UserUtility.js b/client/modules/UserUtility.js index 15f754c..208f08a 100644 --- a/client/modules/UserUtility.js +++ b/client/modules/UserUtility.js @@ -2,24 +2,41 @@ import { globals } from '../config/globals.js'; export const UserUtility = { - createNewAnonymousUserId (force = true) { - let newId; - const currentId = sessionStorage.getItem(globals.PLAYER_ID_COOKIE_KEY); + createNewAnonymousUserId (force = true, environment) { + let newId, currentId; + if (environment === globals.ENVIRONMENT.LOCAL) { + currentId = sessionStorage.getItem(globals.PLAYER_ID_COOKIE_KEY); + } else { + currentId = localStorage.getItem(globals.PLAYER_ID_COOKIE_KEY); + } if (currentId !== null && !force) { newId = currentId; } else { newId = createRandomUserId(); - sessionStorage.setItem(globals.PLAYER_ID_COOKIE_KEY, newId); + if (environment === globals.ENVIRONMENT.LOCAL) { + sessionStorage.setItem(globals.PLAYER_ID_COOKIE_KEY, newId); + } else { + localStorage.setItem(globals.PLAYER_ID_COOKIE_KEY, newId); + } } return newId; }, - setAnonymousUserId (id) { - sessionStorage.setItem(globals.PLAYER_ID_COOKIE_KEY, id); + setAnonymousUserId (id, environment) { + if (environment === globals.ENVIRONMENT.LOCAL) { // use sessionStorage for cookie during local development to aid in testing. + sessionStorage.setItem(globals.PLAYER_ID_COOKIE_KEY, id); + } else { + localStorage.setItem(globals.PLAYER_ID_COOKIE_KEY, id); + } }, - validateAnonUserSignature () { - const userSig = sessionStorage.getItem(globals.PLAYER_ID_COOKIE_KEY); + validateAnonUserSignature (environment) { + let userSig; + if (environment === globals.ENVIRONMENT.LOCAL) { // use sessionStorage for cookie during local development to aid in testing. + userSig = sessionStorage.getItem(globals.PLAYER_ID_COOKIE_KEY); + } else { + userSig = localStorage.getItem(globals.PLAYER_ID_COOKIE_KEY); + } return ( userSig && typeof userSig === 'string' diff --git a/client/modules/third_party/semantic-ui/dropdown.min.js b/client/modules/third_party/semantic-ui/dropdown.min.js index 8ae4bbc..9aeba91 100644 --- a/client/modules/third_party/semantic-ui/dropdown.min.js +++ b/client/modules/third_party/semantic-ui/dropdown.min.js @@ -1 +1 @@ -!function(X,Y,G,J){"use strict";Y=void 0!==Y&&Y.Math==Math?Y:"undefined"!=typeof self&&self.Math==Math?self:Function("return this")(),X.fn.dropdown=function(z){var P,H=X(this),j=X(G),N=H.selector||"",U="ontouchstart"in G.documentElement,K=(new Date).getTime(),W=[],B=z,$="string"==typeof B,Q=[].slice.call(arguments,1);return H.each(function(n){var e,t,i,a,o,s,r,m,h=X.isPlainObject(z)?X.extend(!0,{},X.fn.dropdown.settings,z):X.extend({},X.fn.dropdown.settings),g=h.className,c=h.message,l=h.fields,p=h.keys,b=h.metadata,u=h.namespace,d=h.regExp,w=h.selector,v=h.error,f=h.templates,x="."+u,C="module-"+u,S=X(this),y=X(h.context),A=S.find(w.text),T=S.find(w.search),k=S.find(w.sizer),L=S.find(w.input),I=S.find(w.icon),D=0").html(a).attr("data-"+b.value,t).attr("data-"+b.text,t).addClass(g.addition).addClass(g.item),h.hideAdditions&&i.addClass(g.hidden),n=n===J?i:n.add(i),m.verbose("Creating user choices for value",t,i))}),n)},userLabels:function(e){var t=m.get.userValues();t&&(m.debug("Adding user labels",t),X.each(t,function(e,t){m.verbose("Adding custom user value"),m.add.label(t,t)}))},menu:function(){q=X("
").addClass(g.menu).appendTo(S)},sizer:function(){k=X("").addClass(g.sizer).insertAfter(T)}},search:function(e){e=e!==J?e:m.get.query(),m.verbose("Searching for query",e),m.has.minCharacters(e)?m.filter(e):m.hide()},select:{firstUnfiltered:function(){m.verbose("Selecting first non-filtered element"),m.remove.selectedItem(),R.not(w.unselectable).not(w.addition+w.hidden).eq(0).addClass(g.selected)},nextAvailable:function(e){var t=(e=e.eq(0)).nextAll(w.item).not(w.unselectable).eq(0),n=e.prevAll(w.item).not(w.unselectable).eq(0);0").addClass(g.search).prop("autocomplete","off").insertBefore(A)),m.is.multiple()&&m.is.searchSelection()&&!m.has.sizer()&&m.create.sizer(),h.allowTab&&m.set.tabbable()},select:function(){var e=m.get.selectValues();m.debug("Dropdown initialized on a select",e),S.is("select")&&(L=S),0").attr("class",L.attr("class")).addClass(g.selection).addClass(g.dropdown).html(f.dropdown(e)).insertBefore(L),L.hasClass(g.multiple)&&!1===L.prop("multiple")&&(m.error(v.missingMultiple),L.prop("multiple",!0)),L.is("[multiple]")&&m.set.multiple(),L.prop("disabled")&&(m.debug("Disabling dropdown"),S.addClass(g.disabled)),L.removeAttr("class").detach().prependTo(S)),m.refresh()},menu:function(e){q.html(f.menu(e,l)),R=q.find(w.item)},reference:function(){m.debug("Dropdown behavior was called on select, replacing with closest dropdown"),S=S.parent(w.dropdown),F=S.data(C),M=S.get(0),m.refresh(),m.setup.returnedObject()},returnedObject:function(){var e=H.slice(0,n),t=H.slice(n+1);H=e.add(S).add(t)}},refresh:function(){m.refreshSelectors(),m.refreshData()},refreshItems:function(){R=q.find(w.item)},refreshSelectors:function(){m.verbose("Refreshing selector cache"),A=S.find(w.text),T=S.find(w.search),L=S.find(w.input),I=S.find(w.icon),D=0 modified, recreating menu");var n=!1;X.each(e,function(e,t){if(X(t.target).is("select")||X(t.addedNodes).is("select"))return n=!0}),n&&(m.disconnect.selectObserver(),m.refresh(),m.setup.select(),m.set.selected(),m.observe.select())}},menu:{mutation:function(e){var t=e[0],n=t.addedNodes?X(t.addedNodes[0]):X(!1),i=t.removedNodes?X(t.removedNodes[0]):X(!1),a=n.add(i),o=a.is(w.addition)||0t.name?1:-1}),m.debug("Retrieved and sorted values from select",a)):m.debug("Retrieved values from select",a),a},activeItem:function(){return R.filter("."+g.active)},selectedItem:function(){var e=R.not(w.unselectable).filter("."+g.selected);return 0=h.maxSelections?(m.debug("Maximum selection count reached"),h.useLabels&&(R.addClass(g.filtered),m.add.message(c.maxSelections)),!0):(m.verbose("No longer at maximum selection count"),m.remove.message(),m.remove.filteredItem(),m.is.searchSelection()&&m.filterItems(),!1))}},restore:{defaults:function(){m.clear(),m.restore.defaultText(),m.restore.defaultValue()},defaultText:function(){var e=m.get.defaultText();e===m.get.placeholderText?(m.debug("Restoring default placeholder text",e),m.set.placeholderText(e)):(m.debug("Restoring default text",e),m.set.text(e))},placeholderText:function(){m.set.placeholderText()},defaultValue:function(){var e=m.get.defaultValue();e!==J&&(m.debug("Restoring default value",e),""!==e?(m.set.value(e),m.set.selected()):(m.remove.activeItem(),m.remove.selectedItem()))},labels:function(){h.allowAdditions&&(h.useLabels||(m.error(v.labels),h.useLabels=!0),m.debug("Restoring selected values"),m.create.userLabels()),m.check.maxSelections()},selected:function(){m.restore.values(),m.is.multiple()?(m.debug("Restoring previously selected values and labels"),m.restore.labels()):m.debug("Restoring previously selected values")},values:function(){m.set.initialLoad(),h.apiSettings&&h.saveRemoteData&&m.get.remoteValues()?m.restore.remoteValues():m.set.selected(),m.remove.initialLoad()},remoteValues:function(){var e=m.get.remoteValues();m.debug("Recreating selected from session data",e),e&&(m.is.single()?X.each(e,function(e,t){m.set.text(t)}):X.each(e,function(e,t){m.add.label(e,t)}))}},read:{remoteData:function(e){var t;if(Y.Storage!==J)return(t=sessionStorage.getItem(e))!==J&&t;m.error(v.noStorage)}},save:{defaults:function(){m.save.defaultText(),m.save.placeholderText(),m.save.defaultValue()},defaultValue:function(){var e=m.get.value();m.verbose("Saving default value as",e),S.data(b.defaultValue,e)},defaultText:function(){var e=m.get.text();m.verbose("Saving default text as",e),S.data(b.defaultText,e)},placeholderText:function(){var e;!1!==h.placeholder&&A.hasClass(g.placeholder)&&(e=m.get.text(),m.verbose("Saving placeholder text as",e),S.data(b.placeholderText,e))},remoteData:function(e,t){Y.Storage!==J?(m.verbose("Saving remote data to session storage",t,e),sessionStorage.setItem(t,e)):m.error(v.noStorage)}},clear:function(){m.is.multiple()&&h.useLabels?m.remove.labels():(m.remove.activeItem(),m.remove.selectedItem()),m.set.placeholderText(),m.clearValue()},clearValue:function(){m.set.value("")},scrollPage:function(e,t){var n,i,a=t||m.get.selectedItem(),o=a.closest(w.menu),s=o.outerHeight(),r=o.scrollTop(),l=R.eq(0).outerHeight(),c=Math.floor(s/l),u=(o.prop("scrollHeight"),"up"==e?r-l*c:r+l*c),d=R.not(w.unselectable);i="up"==e?d.index(a)-c:d.index(a)+c,0<(n=("up"==e?0<=i:i").addClass(g.label).attr("data-"+b.value,o).html(f.label(o,t)),i=h.onLabelCreate.call(i,o,t),m.has.label(e)?m.debug("User selection already exists, skipping",o):(h.label.variation&&i.addClass(h.label.variation),!0===n?(m.debug("Animating in label",i),i.addClass(g.hidden).insertBefore(a).transition(h.label.transition,h.label.duration)):(m.debug("Adding selection label",i),i.insertBefore(a)))},message:function(e){var t=q.children(w.message),n=h.templates.message(m.add.variables(e));0").html(n).addClass(g.message).appendTo(q)},optionValue:function(e){var t=m.escape.value(e);0").prop("value",t).addClass(g.addition).html(e).appendTo(L),m.verbose("Adding user addition as an