some timer logic

This commit is contained in:
Alec
2021-11-19 01:11:00 -05:00
parent eb4193fb1b
commit 0560dcffa9
24 changed files with 298 additions and 102 deletions

View File

@@ -1,5 +1,6 @@
export const globals = {
USER_SIGNATURE_LENGTH: 25,
CLOCK_TICK_INTERVAL_MILLIS: 100,
ACCESS_CODE_LENGTH: 6,
PLAYER_ID_COOKIE_KEY: 'play-werewolf-anon-id',
ACCESS_CODE_CHAR_POOL: 'abcdefghijklmnopqrstuvwxyz0123456789',
@@ -18,7 +19,8 @@ export const globals = {
},
EVENTS: {
PLAYER_JOINED: "playerJoined",
SYNC_GAME_STATE: "syncGameState"
SYNC_GAME_STATE: "syncGameState",
START_TIMER: "startTimer"
},
USER_TYPES: {
MODERATOR: "moderator",

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -485,6 +485,9 @@ function updateCustomRoleOptionsList(deckManager, selectEl) {
}
function addOptionsToList(options, selectEl) {
options.sort((a, b) => {
return a.role.localeCompare(b.role);
});
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

View File

@@ -4,6 +4,7 @@ import { toast } from "./Toast.js";
export class GameStateRenderer {
constructor(gameState) {
this.gameState = gameState;
this.cardFlipped = false;
}
renderLobbyPlayers() {
@@ -79,6 +80,20 @@ export class GameStateRenderer {
}
name.setAttribute("title", this.gameState.client.gameRole);
document.querySelector('#role-description').innerText = this.gameState.client.gameRoleDescription;
document.getElementById("role-image").setAttribute(
'src',
'../images/roles/' + this.gameState.client.gameRole.replaceAll(' ', '') + '.png'
);
document.getElementById("game-role-back").addEventListener('click', () => {
document.getElementById("game-role").style.display = 'flex';
document.getElementById("game-role-back").style.display = 'none';
});
document.getElementById("game-role").addEventListener('click', () => {
document.getElementById("game-role-back").style.display = 'flex';
document.getElementById("game-role").style.display = 'none';
});
}
}

View File

@@ -29,7 +29,7 @@ export const templates = {
"<div id='person-name'></div>" +
"<div id='game-header'>" +
"<div>" +
"<label for='game-timer'>Timer</label>" +
"<label for='game-timer'>Time Remaining</label>" +
"<div id='game-timer'></div>" +
"</div>" +
"<div>" +
@@ -37,9 +37,13 @@ export const templates = {
"<div id='alive-count'></div>" +
"</div>" +
"</div>" +
"<div id='game-role'>" +
"<div id='game-role' style='display:none'>" +
"<h4 id='role-name'></h4>" +
"<img alt='role' id='role-image'/>" +
"<p id='role-description'></p>" +
"</div>" +
"<div id='game-role-back'>" +
"<h4>Click to reveal your role</h4>" +
"<p>(click again to hide)</p>" +
"</div>"
}

View File

@@ -9,16 +9,18 @@ See: https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API
const messageParameters = {
STOP: 'stop',
TOTAL_TIME: 'totalTime',
TICK_INTERVAL: 'tickInterval'
TICK_INTERVAL: 'tickInterval',
HOURS: 'hours',
MINUTES: 'minutes'
};
onmessage = function (e) {
if (typeof e.data === 'object'
&& e.data.hasOwnProperty(messageParameters.TOTAL_TIME)
&& e.data.hasOwnProperty(messageParameters.HOURS)
&& e.data.hasOwnProperty(messageParameters.MINUTES)
&& e.data.hasOwnProperty(messageParameters.TICK_INTERVAL)
) {
const timer = new Singleton(e.data.totalTime, e.data.tickInterval);
const timer = new Singleton(e.data.hours, e.data.minutes, e.data.tickInterval);
timer.startTimer();
}
};
@@ -30,7 +32,10 @@ function stepFn (expected, interval, start, totalTime) {
}
const delta = now - expected;
expected += interval;
postMessage({ timeRemaining: (totalTime - (expected - start)) / 1000 });
postMessage({
timeRemainingInMilliseconds: totalTime - (expected - start),
displayTime: returnHumanReadableTime(totalTime - (expected - start))
});
Singleton.setNewTimeoutReference(setTimeout(() => {
stepFn(expected, interval, start, totalTime);
}, Math.max(0, interval - delta)
@@ -38,16 +43,17 @@ function stepFn (expected, interval, start, totalTime) {
}
class Timer {
constructor (totalTime, tickInterval) {
constructor (hours, minutes, tickInterval) {
this.timeoutId = undefined;
this.totalTime = totalTime;
this.hours = hours;
this.minutes = minutes;
this.tickInterval = tickInterval;
}
startTimer () {
if (!isNaN(this.totalTime) && !isNaN(this.tickInterval)) {
if (!isNaN(this.hours) && !isNaN(this.minutes) && !isNaN(this.tickInterval)) {
const interval = this.tickInterval;
const totalTime = this.totalTime;
const totalTime = convertFromHoursToMilliseconds(this.hours) + convertFromMinutesToMilliseconds(this.minutes);
const start = Date.now();
const expected = Date.now() + this.tickInterval;
if (this.timeoutId) {
@@ -61,19 +67,20 @@ class Timer {
}
class Singleton {
constructor (totalTime, tickInterval) {
constructor (hours, minutes, tickInterval) {
if (!Singleton.instance) {
Singleton.instance = new Timer(totalTime, tickInterval);
Singleton.instance = new Timer(hours, minutes, tickInterval);
} else {
// This allows the same timer to be configured to run for different intervals / at a different granularity.
Singleton.setNewTimerParameters(totalTime, tickInterval);
Singleton.setNewTimerParameters(hours, minutes, tickInterval);
}
return Singleton.instance;
}
static setNewTimerParameters (totalTime, tickInterval) {
static setNewTimerParameters (hours, minutes, tickInterval) {
if (Singleton.instance) {
Singleton.instance.totalTime = totalTime;
Singleton.instance.hours = hours;
Singleton.instance.minutes = minutes;
Singleton.instance.tickInterval = tickInterval;
}
}
@@ -84,3 +91,24 @@ class Singleton {
}
}
}
function convertFromMinutesToMilliseconds(minutes) {
return minutes * 60 * 1000;
}
function convertFromHoursToMilliseconds(hours) {
return hours * 60 * 60 * 1000;
}
function returnHumanReadableTime(milliseconds) {
let seconds = Math.floor((milliseconds / 1000) % 60);
let minutes = Math.floor((milliseconds / (1000 * 60)) % 60);
let hours = Math.floor((milliseconds / (1000 * 60 * 60)) % 24);
hours = hours < 10 ? "0" + hours : hours;
minutes = minutes < 10 ? "0" + minutes : minutes;
seconds = seconds < 10 ? "0" + seconds : seconds;
return hours + ":" + minutes + ":" + seconds;
}

View File

@@ -19,8 +19,9 @@ export const game = () => {
userId = gameState.client.id;
UserUtility.setAnonymousUserId(userId, environment);
let gameStateRenderer = new GameStateRenderer(gameState);
processGameState(gameState, userId, socket, gameStateRenderer); // this socket is initialized via a script tag in the game page HTML.
setClientSocketHandlers(gameStateRenderer, socket);
const timerWorker = new Worker('../modules/Timer.js');
processGameState(gameState, userId, socket, gameStateRenderer, timerWorker); // this socket is initialized via a script tag in the game page HTML.
setClientSocketHandlers(gameStateRenderer, socket, timerWorker);
}
});
} else {
@@ -29,7 +30,7 @@ export const game = () => {
});
};
function processGameState (gameState, userId, socket, gameStateRenderer) {
function processGameState (gameState, userId, socket, gameStateRenderer, timerWorker) {
cancelCurrentToast();
switch (gameState.status) {
case globals.STATUS.LOBBY:
@@ -58,7 +59,7 @@ function processGameState (gameState, userId, socket, gameStateRenderer) {
}
}
function setClientSocketHandlers(gameStateRenderer, socket) {
function setClientSocketHandlers(gameStateRenderer, socket, timerWorker) {
socket.on(globals.EVENTS.PLAYER_JOINED, (player, gameIsFull) => {
toast(player.name + " joined!", "success", false);
gameStateRenderer.gameState.people.push(player);
@@ -84,6 +85,16 @@ function setClientSocketHandlers(gameStateRenderer, socket) {
}
);
})
socket.on(globals.EVENTS.START_TIMER, () => {
runGameTimer(
gameStateRenderer.gameState.timerParams.hours,
gameStateRenderer.gameState.timerParams.minutes,
globals.CLOCK_TICK_INTERVAL_MILLIS,
null,
timerWorker
)
})
}
function displayStartGamePromptForModerators(gameStateRenderer) {
@@ -99,3 +110,14 @@ function displayStartGamePromptForModerators(gameStateRenderer) {
});
}
function runGameTimer (hours, minutes, tickRate, soundManager, timerWorker) {
if (window.Worker) {
timerWorker.onmessage = function (e) {
if (e.data.hasOwnProperty('timeRemainingInMilliseconds') && e.data.timeRemainingInMilliseconds > 0) {
document.getElementById('game-timer').innerText = e.data.displayTime;
}
};
timerWorker.postMessage({ hours: hours, minutes: minutes, tickInterval: tickRate });
}
}

View File

@@ -64,7 +64,7 @@ h1 {
#game-role {
position: relative;
border-bottom: 2px solid gray;
border: 5px solid transparent;
background-color: #e7e7e7;
display: flex;
flex-direction: column;
@@ -85,6 +85,56 @@ h1 {
/*transform-style: preserve-3d;*/
}
#game-role-back {
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
display: flex;
align-items: center;
justify-content: center;
background-color: #333243;
border: 5px solid #61606a;
position: relative;
flex-direction: column;
cursor: pointer;
max-width: 17em;
border-radius: 3px;
height: 23em;
margin: 0 auto 2em auto;
width: 100%;
box-shadow: 0 1px 1px rgba(0,0,0,0.11),
0 2px 2px rgba(0,0,0,0.11),
0 4px 4px rgba(0,0,0,0.11),
0 8px 8px rgba(0,0,0,0.11),
0 16px 16px rgba(0,0,0,0.11),
0 32px 32px rgba(0,0,0,0.11);
/*perspective: 1000px;*/
/*transform-style: preserve-3d;*/
}
#game-role-back h4 {
font-size: 24px;
padding: 0.5em;
text-align: center;
color: #e7e7e7;
}
#game-role-back p {
color: #c3c3c3;
font-size: 20px;
}
#game-timer {
padding: 1px;
background-color: #3c3c3c;
color: whitesmoke;
border-radius: 3px;
font-size: 35px;
text-shadow: 0 3px 4px rgb(0 0 0 / 85%);
border: 1px solid #747474;
}
#role-name {
position: absolute;
top: 6%;
@@ -100,10 +150,15 @@ h1 {
}
#role-image {
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
position: absolute;
top: 34%;
top: 37%;
left: 50%;
transform: translate(-50%, -50%);
width: 78%;
}
#role-description {
@@ -114,7 +169,7 @@ h1 {
transform: translate(-50%, 0);
font-size: 16px;
width: 78%;
max-height: 7em;
max-height: 6em;
}
#game-link img {

View File

@@ -1,6 +1,7 @@
const globals = {
ACCESS_CODE_CHAR_POOL: 'abcdefghijklmnopqrstuvwxyz0123456789',
ACCESS_CODE_LENGTH: 6,
CLOCK_TICK_INTERVAL_MILLIS: 100,
CLIENT_COMMANDS: {
FETCH_GAME_STATE: 'fetchGameState',
GET_ENVIRONMENT: 'getEnvironment',
@@ -8,7 +9,8 @@ const globals = {
},
STATUS: {
LOBBY: "lobby",
IN_PROGRESS: "in progress"
IN_PROGRESS: "in progress",
ENDED: "ended"
},
USER_SIGNATURE_LENGTH: 25,
USER_TYPES: {
@@ -33,6 +35,11 @@ const globals = {
ERROR: "error",
WARN: "warn",
TRACE: "trace"
},
GAME_PROCESS_COMMANDS: {
END_GAME: "endGame",
START_GAME: "startGame",
START_TIMER: "startTimer"
}
};

View File

@@ -1,5 +1,6 @@
class Game {
constructor(status, people, deck, hasTimer, moderator, timerParams=null) {
constructor(accessCode, status, people, deck, hasTimer, moderator, timerParams=null) {
this.accessCode = accessCode
this.status = status;
this.moderator = moderator;
this.people = people;

View File

@@ -1,90 +1,49 @@
const { fork } = require('child_process');
const path = require('path');
const logger = require('./logger')(false);
const globals = require('../config/globals');
class ActiveGameRunner {
constructor () {
constructor (logger) {
this.activeGames = {};
this.logger = logger;
}
// runGame = (game, namespace, gameStateFn) => {
// logger.debug('running game ' + game.accessCode);
// const gameProcess = fork(path.join(__dirname, '/GameProcess.js'));
// gameProcess.on('message', (msg) => {
// switch (msg.command) {
// case serverGlobals.COMMAND.END_COUNTDOWN:
// logger.debug('GAME PARENT PROCESS ' + game.accessCode + ': COMMAND: END COUNTDOWN');
// namespace.in(game.accessCode).emit(serverGlobals.COMMAND.END_COUNTDOWN);
// gameProcess.send({
// command: serverGlobals.COMMAND.START_GAME,
// cycleNumber: game.words.length - 1,
// cycleLength: game.timePerWord * 1000,
// accessCode: game.accessCode
// });
// break;
// case serverGlobals.COMMAND.START_GAME:
// game.status = serverGlobals.GAME_STATE.STARTED;
// game.lastCycleTime = new Date().toJSON();
// logger.debug('GAME PARENT PROCESS ' + game.accessCode + ': COMMAND: START GAME');
// namespace.in(game.accessCode).emit(serverGlobals.COMMAND.START_GAME, {
// firstWord: game.words[0].baseword,
// gameLength: game.words.length,
// timePerWord: game.timePerWord * 1000
// });
// break;
// case serverGlobals.COMMAND.CYCLE_WORD:
// game.currentWordIndex += 1;
// game.lastCycleTime = new Date().toJSON();
// logger.debug('GAME PARENT PROCESS ' + game.accessCode + ': COMMAND: CYCLE WORD');
// if (game.currentWordIndex < game.words.length) {
// namespace.in(game.accessCode).emit(serverGlobals.COMMAND.CYCLE_WORD, {
// word: game.words[game.currentWordIndex].baseword,
// index: game.currentWordIndex + 1,
// totalTime: game.timePerWord * 1000,
// gameLength: game.words.length
// });
// }
// gameProcess.send({
// command: serverGlobals.COMMAND.CYCLE_WORD,
// cycleIndex: game.currentWordIndex,
// cycleLength: game.timePerWord * 1000,
// accessCode: game.accessCode,
// gameLength: game.words.length
// });
// break;
// case serverGlobals.COMMAND.END_GAME:
// game.status = serverGlobals.GAME_STATE.ENDED;
// if (!game.posted) {
// logger.debug('GAME PARENT PROCESS: GAME ' + game.accessCode + ' HAS ENDED...BEGINNING POST TO DATABASE');
// this.postGameFn(game).then(() => {
// game.posted = true;
// logger.debug('GAME ' + game.accessCode + ' SUCCESSFULLY POSTED');
// namespace.in(game.accessCode).emit(serverGlobals.COMMAND.END_GAME, game.accessCode);
// });
// }
// break;
// }
// });
//
// gameProcess.on('exit', () => {
// if (this.activeGames[game.accessCode]) {
// delete this.activeGames[game.accessCode];
// logger.debug('GAME ' + game.accessCode + ' REMOVED FROM ACTIVE GAMES.');
// }
// });
// gameProcess.send({ command: serverGlobals.COMMAND.START_COUNTDOWN, accessCode: game.accessCode });
// game.status = serverGlobals.GAME_STATE.STARTING;
// game.startCountdownTime = new Date().toJSON();
// namespace.in(game.accessCode).emit(serverGlobals.COMMAND.START_COUNTDOWN);
// }
/* We're only going to fork a child process for games with a timer. They will report back to the parent process whenever
the timer is up.
*/
runGame = (game, namespace) => {
this.logger.debug('running game ' + game.accessCode);
const gameProcess = fork(path.join(__dirname, '/GameProcess.js'));
gameProcess.on('message', (msg) => {
switch (msg.command) {
case globals.GAME_PROCESS_COMMANDS.END_GAME:
game.status = globals.STATUS.ENDED;
this.logger.debug('PARENT: END GAME');
namespace.in(game.accessCode).emit(globals.GAME_PROCESS_COMMANDS.END_GAME, game.accessCode);
break;
}
});
gameProcess.on('exit', () => {
this.logger.debug('Game ' + game.accessCode + ' has ended. Elapsed: ' + (new Date() - game.startTime) + 'ms');
});
gameProcess.send({
command: globals.GAME_PROCESS_COMMANDS.START_TIMER,
accessCode: game.accessCode,
logLevel: this.logger.logLevel,
hours: game.timerParams.hours,
minutes: game.timerParams.minutes
});
game.startTime = new Date().toJSON();
namespace.in(game.accessCode).emit(globals.GAME_PROCESS_COMMANDS.START_TIMER);
}
}
class Singleton {
constructor () {
constructor (logger) {
if (!Singleton.instance) {
logger.log('CREATING SINGLETON ACTIVE GAME RUNNER');
Singleton.instance = new ActiveGameRunner();
Singleton.instance = new ActiveGameRunner(logger);
}
}

View File

@@ -9,7 +9,7 @@ class GameManager {
constructor (logger, environment) {
this.logger = logger;
this.environment = environment;
this.activeGameRunner = new ActiveGameRunner().getInstance();
this.activeGameRunner = new ActiveGameRunner(logger).getInstance();
this.namespace = null;
//this.gameSocketUtility = GameSocketUtility;
}
@@ -37,6 +37,9 @@ class GameManager {
if (game) {
game.status = globals.STATUS.IN_PROGRESS;
namespace.in(accessCode).emit(globals.EVENTS.SYNC_GAME_STATE);
if (game.hasTimer) {
this.activeGameRunner.runGame(game, namespace);
}
}
});
}
@@ -51,6 +54,7 @@ class GameManager {
const newAccessCode = this.generateAccessCode();
let moderator = initializeModerator(gameParams.moderatorName, gameParams.hasDedicatedModerator);
this.activeGameRunner.activeGames[newAccessCode] = new Game(
newAccessCode,
globals.STATUS.LOBBY,
initializePeopleForGame(gameParams.deck, moderator),
gameParams.deck,

View File

@@ -0,0 +1,26 @@
const globals = require('../config/globals.js');
const ServerTimer = require('./ServerTimer.js');
process.on('message', (msg) => {
const logger = require('./Logger')(msg.logLevel);
switch (msg.command) {
case globals.GAME_PROCESS_COMMANDS.START_TIMER:
logger.debug('CHILD PROCESS ' + msg.accessCode + ': START TIMER');
runGameTimer(msg.hours, msg.minutes, logger).then(() => {
logger.debug('Timer finished for ' + msg.accessCode);
process.send({ command: globals.GAME_PROCESS_COMMANDS.END_GAME });
process.exit(0);
});
break;
}
});
function runGameTimer (hours, minutes, logger) {
const cycleTimer = new ServerTimer(
hours,
minutes,
globals.CLOCK_TICK_INTERVAL_MILLIS,
logger
);
return cycleTimer.runTimer();
}

View File

@@ -2,6 +2,7 @@ const globals = require('../config/globals');
module.exports = function (logLevel = globals.LOG_LEVEL.INFO) {
return {
logLevel: logLevel,
log (message = '') {
const now = new Date();
console.log('LOG ', now.toGMTString(), ': ', message);

View File

@@ -0,0 +1,69 @@
/* ALL TIMES ARE IN MILLIS */
function stepFn (expected, interval, start, totalTime, ticking, timesUpResolver, logger) {
const now = Date.now();
if (now - start >= totalTime) {
clearTimeout(ticking);
logger.debug('ELAPSED: ' + (now - start) + 'ms (~' + (Math.abs(totalTime - (now - start)) / totalTime).toFixed(3) + '% error).');
timesUpResolver(); // this is a reference to the callback defined in the construction of the promise in runTimer()
return;
}
const delta = now - expected;
expected += interval;
ticking = setTimeout(function () {
stepFn(
expected,
interval,
start,
totalTime,
ticking,
timesUpResolver,
logger
);
}, Math.max(0, interval - delta)); // take into account drift
}
class ServerTimer {
constructor (hours, minutes, tickInterval, logger) {
this.hours = hours;
this.minutes = minutes;
this.tickInterval = tickInterval;
this.logger = logger;
}
runTimer () {
const interval = this.tickInterval;
const totalTime = convertFromHoursToMilliseconds(this.hours) + convertFromMinutesToMilliseconds(this.minutes);
const logger = this.logger;
logger.debug('STARTING TIMER FOR ' + totalTime + 'ms');
const start = Date.now();
const expected = Date.now() + this.tickInterval;
let timesUpResolver;
const timesUpPromise = new Promise((resolve) => {
timesUpResolver = resolve;
});
const ticking = setTimeout(function () {
stepFn(
expected,
interval,
start,
totalTime,
ticking,
timesUpResolver,
logger
);
}, this.tickInterval);
return timesUpPromise;
}
}
function convertFromMinutesToMilliseconds(minutes) {
return minutes * 60 * 1000;
}
function convertFromHoursToMilliseconds(hours) {
return hours * 60 * 60 * 1000;
}
module.exports = ServerTimer;