gamelobby
Version:
ES6 JS classes for Game Lobbies with friend lobbies, heartbeats and disconnect handlers
336 lines (285 loc) • 12.5 kB
JavaScript
"use strict";
const Player = require('./Player.js');
const objecthasherjs = require("objecthasherjs");
/**
* Lobby
*
* A lobby for a game, running or not
*/
class Lobby {
constructor (socketRoomName, lobbyManager, lobbyConfig={}) {
// Initialized as lobby
this.lobbyStatus = Lobby.WAITING; // Set initial status
this.playerList = [];
this.MINIMUM_PLAYER_COUNT_BEFORE_DC_GAMEOVER = lobbyConfig.MINIMUM_PLAYER_COUNT_BEFORE_DC_GAMEOVER || 2;
// Optional User defined game lobby config
this.lobbyConfig = lobbyConfig;
// this.lobbyConfig.playerCountForGame is ALWAYS SET before creating lobby to not mess with hashes
// Dynamic game config given by lobby manager
this.socketRoomName = socketRoomName;
this.lobbyManager = lobbyManager;
// For faster lobby indexing and search
if (this.lobbyManager.isExactConfigHashingEnabled()) {
this.lobbyConfig_hashed_id = objecthasherjs.calculateObjectHash(this.lobbyConfig);
}
this.scheduleDisconnectCheck();
}
getLobbyConfigHashIdentifier () {
return this.lobbyConfig_hashed_id;
}
updateStatus (newStatus) {
if (!(newStatus in this.constructor) || typeof(newStatus) != "string") {
console.log(`[!] WARNING! gamelobby.Lobby.updateStatus setting to unsupported status!`);
}
this.lobbyStatus = newStatus;
}
getStatus (newStatus) { return this.lobbyStatus; }
isWaitingInLobby () { return this.getStatus() == Lobby.WAITING; }
isGamePlaying () { return this.getStatus() == Lobby.PLAYING; }
getLobbyId () {
// Using the socketRoomName as lobby ID
return this.socketRoomName;
}
getConfigItem (itemName) {
return this.lobbyConfig[itemName];
}
scheduleDisconnectCheck () {
this.dcTimeoutInt = setTimeout(this.checkForDisconnects.bind(this), 2000);
}
checkForDisconnects () {
let now = new Date().getTime();
let timeoutThreshold = this.isWaitingInLobby() ? Lobby.LOBBY_TIMEOUT : Lobby.INGAME_TIMEOUT;
// Can occur in a lobby, but never in Lobby.PLAYING state... Shouldn't happen now
if (!this.playerList.length) {
return process.env.DEBUG == "gamelobby" && console.log("[D] gamelobby.Lobby.checkForDisconnects - NO PLAYERS");
}
let disconnectedList = this.playerList.filter((player, index) => {
return (now - player.lastHeartbeatTime) > timeoutThreshold &&
player.getConnectionStatus() != Player.DISCONNECTED;
// || !player.getSocket().isValidOrSomethingOrIsInRoom
});
// Don't want to call handleDisconnect outside of ingame/inlobby
if (disconnectedList.length && (this.isWaitingInLobby() || this.isGamePlaying())) {
// Handle disconnections. Game may end here
this.handleDisconnect(disconnectedList);
}
// Perform check to see if this game has taken WAY too long
if (this.isGamePlaying() &&
(now - this.startTime > Lobby.TIMEOUT_FOR_TOTAL_GAME_RUNTIME)) {
// Game has gone on way too long. Closing, killing.
return this.endGame(Lobby.TIMEOUT_FOR_TOTAL_GAME_RUNTIME);
}
// Schedule next check if the game/lobby is still open
if (this.isWaitingInLobby() || this.isGamePlaying()) {
this.scheduleDisconnectCheck();
}
}
destroyLobby () {
this.updateStatus(Lobby.DESTROYED);
clearTimeout(this.dcTimeoutInt); // don't check for DCs anymore
this.clearRoom();
this.lobbyManager.emit("LOBBY_DESTROYED", this);
}
jsonSerializePlayerArray (players) {
return players.map(p => p.toJsonObj());
}
getPlayersJSONSerialized () {
return this.jsonSerializePlayerArray(this.playerList);
}
// Called when someone closes the game OR a disconnect timeout occurs
handleDisconnect (disconnectedList) {
this.emitDisconnect(disconnectedList);
process.env.DEBUG == "gamelobby" && console.log("[D] gamelobby.Lobby.debug:",
disconnectedList, "lobbyStatus:", this.getStatus());
// Determine what to do based on lobbyStatus
if (this.isWaitingInLobby()) {
this.gameFilledTime = null; // game not eligible to start yet
// Remove player from lobby
disconnectedList.forEach(p => this.removePlayer(p));
// Delete lobby if no players and game not started
if (this.playerList.length == 0) {
this.destroyLobby();
}
} else if (this.isGamePlaying()) {
// Don't remove from game, just mark disconnected
disconnectedList.forEach(p => p.updateConnectionStatus(Player.DISCONNECTED));
let connectedPlayerCount = this.playerList.filter(p => p.isStillConnected()).length;
// Check if enough players to continue the game
if (connectedPlayerCount < this.MINIMUM_PLAYER_COUNT_BEFORE_DC_GAMEOVER) {
this.endGame(Lobby.DISCONNECTED);
}
// Else game continues
}
// Else Some other Lobby status... Do nothing. Shouldn't get here
}
broadcastNewEvent (originatingPlayer, eventName, jsonData, shouldExcludeOriginatingPlayer=false) {
if (shouldExcludeOriginatingPlayer) {
sails.sockets.broadcast(this.getSocketRoomName(), eventName, jsonData, originatingPlayer.getSocket());
} else {
sails.sockets.broadcast(this.getSocketRoomName(), eventName, jsonData);
}
}
emitEventToSinglePlayer (targetPlayer, eventName, jsonData) {
sails.sockets.broadcast(targetPlayer.getSocket().id, eventName, jsonData);
}
addToRoom (player) {
// add to socket room - can be overridden
sails.sockets.join(player.getSocket(), this.getSocketRoomName());
}
removeFromSocketRoom (player) {
sails.sockets.leave(player.getSocket(), this.getSocketRoomName());
}
clearRoom () {
// Remove sockets from room
this.playerList.forEach(p => this.removeFromSocketRoom(p));
}
emitPlayerAdded (player) {
this.lobbyManager.emit("PLAYER_ADDED", this, player);
// Emit new player name
this.broadcastNewEvent(null, 'PLAYER_ADDED', {
playerList: this.getPlayersJSONSerialized(),
lobbyConfig: this.lobbyConfig, // mostly to let new player know config
});
}
emitGameStart () {
this.startConfig = {}; // any listeners on GAME_START may modify startConfig to be sent
this.lobbyManager.emit("GAME_START", this);
// Emit start stuff
this.broadcastNewEvent(null, "GAME_START", {
playerList: this.getPlayersJSONSerialized(),
lobbyConfig: this.lobbyConfig, // let all players know config
startConfig: Object.assign(this.startConfig, { // additional start data
_randomSeed: Math.floor(Math.random() * 100000), // seed for synchronized randomness if desired
}),
});
}
emitGameOver () {
this.lobbyManager.emit("GAME_OVER", this);
// Send GAME_OVER to the players as a winner is decided
this.broadcastNewEvent(null, "GAME_OVER", {
updatedPlayerList: this.getPlayersJSONSerialized(),
});
}
emitDisconnect (disconnectedList) {
// Will the player already be gone from the socket room??
disconnectedList.forEach(p => this.removeFromSocketRoom(p));
this.lobbyManager.emit("USER_DISCONNECT", this, disconnectedList);
// Emit disconnect to still connected sockets
this.broadcastNewEvent(null, "USER_DISCONNECT", {
disconnectedList: this.jsonSerializePlayerArray(disconnectedList),
});
}
emitGameOverDueToDisconnects () {
this.lobbyManager.emit("GAME_OVER_DUE_TO_DISCONNECTS", this);
// Emit disconnect to still connected sockets
this.broadcastNewEvent(null, "GAME_OVER_DUE_TO_DISCONNECTS", {
updatedPlayerList: this.getPlayersJSONSerialized(),
});
}
getSocketRoomName () { return this.socketRoomName; }
getPlayerBySocket (socket) {
return this.playerList.filter(p => p.getSocket() == socket)[0]; // may be null
}
addPlayer (socket, playerConfig={}) {
let newPlayer = new Player(socket, playerConfig);
this.playerList.push(newPlayer);
this.emitPlayerAdded(newPlayer);
this.addToRoom(newPlayer);
// Game full, but NOT starting yet.
// Start in the heartbeat check when we KNOW for sure each person's
// latest heartbeat is AFTER right now
if (this.isFull()) {
this.gameFilledTime = new Date().getTime();
}
return true; // was added successfully
}
// Useful for checking uniqueness. If playerConfig has "username" for example,
// pass in "username", "bobby" to see if any user in the lobby has playerConfig.username == "bobby"
numPlayerConfigsInLobbyMatchAttribute (key, value) {
return this.playerList.filter(p => p.playerConfig[key] === value).length;
}
removePlayer (player) {
this.playerList.splice(this.playerList.indexOf(player), 1);
// would call this.removeFromSocketRoom(player); but should already be called on disconnection
}
// NOTE: This is a good function to override in a subclass
startGame () {
process.env.DEBUG == "gamelobby" && console.log("[D] gamelobby.Lobby.debug - Starting game!");
this.updateStatus(Lobby.PLAYING);
this.startTime = new Date().getTime();
// remove disconnected players (useful in the event same lobby is used to restart a game)
this.playerList.filter(p => p.getConnectionStatus() == Player.DISCONNECTED).forEach(p => this.removePlayer(p));
this.playerList.forEach(p => p.updateWinStatus(Player.UNDETERMINED)); // set initial win_status
this.emitGameStart();
}
// Perform endgame analysis stuff and store data
// Attach to the 'GAME_OVER' event to do things like update ranks
endGame (NEW_STATUS, shouldDestroyLobby=true) {
clearTimeout(this.dcTimeoutInt); // don't check for DCs anymore
this.updateStatus(NEW_STATUS || Lobby.GAME_OVER); // Update status (Lobby.GAME_OVER by default)
this.endTime = new Date().getTime();
// XXX Will need to add some checks here to look for disconnect status
// If this game was a timeout from going on too long
if (this.getStatus() == Lobby.TIMEOUT_FOR_TOTAL_GAME_RUNTIME) {
// Just pretend the game never even happened
this.lobbyManager.emit("TOOK_WAY_TOO_LONG", this);
this.broadcastNewEvent(null, 'TOOK_WAY_TOO_LONG', {});
return (shouldDestroyLobby) ? this.destroyLobby() : null;
}
// Update still connected people to losers
this.playerList.forEach((p) => {
if (p.isStillConnected() && p.getWinStatus() != Player.WIN) {
p.updateWinStatus(Player.LOST);
}
});
// Let everyone know game results
if (this.getStatus() == Lobby.GAME_OVER) {
this.emitGameOver();
} else if (this.getStatus() == Lobby.DISCONNECTED) {
this.emitGameOverDueToDisconnects();
}
return (shouldDestroyLobby) ? this.destroyLobby() : null;
}
areAllPlayerHeartbeatsAfterTime (time) {
return this.playerList.filter(p => p.lastHeartbeatTime < time).length == 0;
}
haveAllPlayersHeartbeatedSinceFilling () {
return this.gameFilledTime && this.areAllPlayerHeartbeatsAfterTime(this.gameFilledTime);
}
updateClientHeartbeat (socket, time=new Date().getTime()) {
// yes, additional hash table would be faster, but would use more memory
let player = this.getPlayerBySocket(socket);
if (!player) {
return process.env.DEBU == "gamelobby" && console.log("NO_PLAYER_FOUND_IN_UPDATEHEARTBEAT")
}
player.lastHeartbeatTime = time;
// If we have enough, and everyone's latest heartbeat is AFTER the fill time,
// start game!
if (this.isFull() && this.isWaitingInLobby() && this.haveAllPlayersHeartbeatedSinceFilling()) {
this.startGame();
}
}
getSocketCount () {
// XXX I'm not sure which one is more robust...
// it depends on whether closed sockets are automatically removed
// completely from all rooms on closing. Might find out later
return this.playerList.length;
//return sails.sockets.subscribers(this.getSocketRoomName()).length;
}
isFull () { return this.getSocketCount() == this.getConfigItem('playerCountForGame'); }
};
// Static Lobby class Statuses
// Assign each attribute directly onto Lobby class
Object.assign(Lobby, {
JUSTCREATED: "JUSTCREATED", // only applicable to client
WAITING: "WAITING",
PLAYING: "PLAYING",
GAME_OVER: "GAME_OVER",
DESTROYED: "DESTROYED",
DISCONNECTED: "DISCONNECTED", // Status for game end by disconnect
// Default values for timeout events, can set on Lobby to override
LOBBY_TIMEOUT: 8000, // specified in ms
INGAME_TIMEOUT: 30000, // specified in ms
TIMEOUT_FOR_TOTAL_GAME_RUNTIME: 2 * 60 * 60 * 1000, // specified in ms... 2 hours
});
module.exports = Lobby;