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 @@
+
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 @@
+
+
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