You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
433 lines
12 KiB
433 lines
12 KiB
const uuid = require('uuid');
|
|
const UrlParser = require('url');
|
|
const DEBUG = (process.env.NODE_ENV !== "production");
|
|
|
|
const STATE = {
|
|
COUNTDOWN: 'COUNTDOWN',
|
|
PLAY: 'PLAY',
|
|
WIN: 'WIN'
|
|
};
|
|
|
|
const COLORS = [
|
|
'#FF6200', // Orange
|
|
'#06E746', // Green
|
|
'#E370FF', // Purple
|
|
'#06D6C4', // Turq
|
|
'#ff217c' // Pink
|
|
];
|
|
|
|
const ICONS = [
|
|
'assets/comet.svg',
|
|
'assets/moon.svg',
|
|
'assets/planet.svg',
|
|
'assets/rocket.svg',
|
|
'assets/spacesuit.svg',
|
|
'assets/spider.svg',
|
|
'assets/ufo.svg'
|
|
];
|
|
|
|
const Ricochet = function({ messenger }) {
|
|
this.messenger = messenger;
|
|
this.squaresPerSide = 20;
|
|
|
|
// Reference lookups
|
|
this.robotIds = Array.from(Array(5).keys()).map(_ => uuid.v4());
|
|
|
|
const shuffledColors = this.shuffle(COLORS);
|
|
this.colors = this.robotIds.reduce((acc, id, i) => {
|
|
acc[id] = shuffledColors[i];
|
|
return acc;
|
|
}, {});
|
|
|
|
const shuffledIcons = this.shuffle(ICONS);
|
|
this.icons = this.robotIds.reduce((acc, id, i) => {
|
|
acc[id] = shuffledIcons[i];
|
|
return acc;
|
|
}, {});
|
|
|
|
// Properties that will be emitted
|
|
this.players = {};
|
|
this.robots = this.freshRobots();
|
|
this.state = STATE.PLAY;
|
|
this.walls = this.freshWalls();
|
|
this.objective = this.freshObjective();
|
|
|
|
this.countdownDuration = 60;
|
|
this.countdownTimer = null;
|
|
this.countdownTimestamp = null;
|
|
|
|
this.winningPlayerId = null;
|
|
this.winningStack = null;
|
|
};
|
|
|
|
//===== Connection management
|
|
|
|
Ricochet.prototype.onConnect = function(ws, req) {
|
|
if (Object.keys(this.players).length >= 10) {
|
|
this.messenger.messageOne(ws, { type: 'full' });
|
|
return;
|
|
}
|
|
|
|
const url = UrlParser.parse(req.url, true);
|
|
const query = url.query;
|
|
const santizedName = (query.name || 'Unknown').replace(/[^\w ]/g, '');
|
|
|
|
DEBUG && console.log(`Connected: ${santizedName} (${ws.id.substr(0, 8)}) via ${req.url}`);
|
|
|
|
this.addPlayer(ws.id, santizedName);
|
|
|
|
this.messenger.messageAll({ type: 'players', body: this.players });
|
|
|
|
this.messenger.messageOne(ws, { type: 'connected', body: { player_id: ws.id, player_name: santizedName } });
|
|
this.messenger.messageOne(ws, { type: 'robots', body: this.robots});
|
|
this.messenger.messageOne(ws, { type: 'objective', body: this.objective});
|
|
this.messenger.messageOne(ws, { type: 'state', body: this.state});
|
|
this.messenger.messageOne(ws, { type: 'walls', body: this.walls});
|
|
|
|
if (this.state === STATE.COUNTDOWN) {
|
|
this.messenger.messageOne(ws, { type: 'countdown', body: this.getCountdownStateBody() });
|
|
}
|
|
|
|
if (this.state === STATE.WIN) {
|
|
this.messenger.messageOne(ws, { type: 'win', body: this.getWinStateBody() });
|
|
}
|
|
};
|
|
|
|
Ricochet.prototype.onDisconnect = function(ws) {
|
|
DEBUG && console.log(`Disconnected: ${this.players[ws.id]} (${ws.id.substr(0, 8)})`);
|
|
|
|
this.removePlayer(ws.id);
|
|
|
|
this.messenger.messageAll({ type: 'players', body: this.players });
|
|
};
|
|
|
|
Ricochet.prototype.addPlayer = function(id, name) {
|
|
if (!this.players[id]) {
|
|
this.players[id] = name;
|
|
}
|
|
};
|
|
|
|
Ricochet.prototype.removePlayer = function(id) {
|
|
this.players[id] = undefined;
|
|
delete this.players[id];
|
|
};
|
|
|
|
|
|
//===== State generators
|
|
|
|
Ricochet.prototype.freshRobots = function() {
|
|
const result = [];
|
|
|
|
for (let k = 0; k < this.robotIds.length; k++) {
|
|
const id = this.robotIds[k];
|
|
const { i, j } = this.randomUnoccupiedSquare();
|
|
|
|
result.push({
|
|
i,
|
|
j,
|
|
color: this.colors[id],
|
|
id,
|
|
icon: this.icons[id]
|
|
});
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
Ricochet.prototype.freshWalls = function() {
|
|
// Corner count relates quadratically to squares per side.
|
|
const numberOfCorners = Math.ceil(Math.pow((this.squaresPerSide / 4), 2));
|
|
const corners = [];
|
|
let shortCircuitCounter = 0;
|
|
|
|
while (corners.length < numberOfCorners && shortCircuitCounter < numberOfCorners * 5) {
|
|
const { i, j } = this.randomSquare();
|
|
|
|
const isTooClose = corners.reduce((acc, v) => {
|
|
const delta = Math.abs(v.i - i) + Math.abs(v.j - j);
|
|
|
|
return acc || (delta < 3);
|
|
}, false);
|
|
|
|
if (isTooClose === false) {
|
|
corners.push({ i, j });
|
|
}
|
|
}
|
|
|
|
// Edge IDs are of the form [i1-j1-i2-j2]. Top left is 0, 0.
|
|
const horizontalEdges = corners.map(({ i, j }) => {
|
|
const isLeftward = i > 0 && i < this.squaresPerSide && (Math.random() < 0.5);
|
|
const i1 = isLeftward ? i - 1 : i;
|
|
const i2 = isLeftward ? i : i + 1;
|
|
|
|
return { i1, j1: j, i2, j2: j};
|
|
});
|
|
|
|
const verticalEdges = corners.map(({ i, j }) => {
|
|
const isUpward = j > 0 && j < this.squaresPerSide && (Math.random() < 0.5);
|
|
const j1 = isUpward ? j - 1 : j;
|
|
const j2 = isUpward ? j : j + 1;
|
|
|
|
return { i1: i, j1, i2: i, j2 };
|
|
});
|
|
|
|
const edges = horizontalEdges.concat(verticalEdges);
|
|
|
|
return edges.reduce((acc, { i1, j1, i2, j2 }) => {
|
|
// Remove walls along all edges
|
|
const onLeft = (i1 === 0 && i2 === 0);
|
|
const onRight = (i1 === this.squaresPerSide && i2 === this.squaresPerSide);
|
|
const onTop = (j1 === 0 && j2 === 0);
|
|
const onBottom = (j1 === this.squaresPerSide && j2 === this.squaresPerSide);
|
|
|
|
if (onLeft || onRight || onTop || onBottom) {
|
|
return acc;
|
|
}
|
|
|
|
// Remove some walls randomly
|
|
if (Math.random() < 0.3) {
|
|
return acc;
|
|
}
|
|
|
|
return acc.concat(`${i1}-${j1}-${i2}-${j2}`);
|
|
}, []);
|
|
};
|
|
|
|
Ricochet.prototype.freshObjective = function() {
|
|
const getRandomWall = () => {
|
|
if (!this.walls.length) {
|
|
this.randomUnoccupiedSquare();
|
|
}
|
|
|
|
const wall = this.walls[Math.floor((Math.random() * this.walls.length))];
|
|
|
|
const [i1, j1, i2, j2] = wall.split('-');
|
|
|
|
return (Math.random() < 0.5) ? { i: i1, j: j1 } : { i: i2, j: j2 };
|
|
};
|
|
|
|
const rand = Math.floor(Math.random() * this.robotIds.length);
|
|
const id = this.robotIds[rand];
|
|
|
|
const { i, j } = getRandomWall();
|
|
|
|
return {
|
|
i,
|
|
j,
|
|
id,
|
|
color: this.colors[id],
|
|
};
|
|
};
|
|
|
|
Ricochet.prototype.onMessage = function(ws, rawBody) {
|
|
try {
|
|
const message = JSON.parse(rawBody);
|
|
|
|
DEBUG && console.log('Received message: ');
|
|
DEBUG && console.log(message);
|
|
|
|
if (!message.type) {
|
|
DEBUG && console.warn("Unprocessable message: ")
|
|
DEBUG && console.warn(message);
|
|
return;
|
|
}
|
|
|
|
switch (message.type) {
|
|
case 'heartbeat': this.msgHeartbeat(ws); break;
|
|
case 'newround': this.msgNewRound(); break;
|
|
case 'objective': this.msgObjective(); break;
|
|
case 'robots': this.msgRobots(); break;
|
|
case 'skip': this.msgSkip(); break;
|
|
case 'solve': this.msgSolve(message.rawBody); break;
|
|
case 'walls': this.msgWalls(); break;
|
|
default: console.warn("Unknown message type: ", message.type)
|
|
}
|
|
} catch (error) {
|
|
console.error(error);
|
|
}
|
|
};
|
|
|
|
//===== Message handlers
|
|
Ricochet.prototype.msgHeartbeat = function(ws) {
|
|
this.messenger.messageOne(ws, { type: 'heartbeat' });
|
|
};
|
|
|
|
Ricochet.prototype.msgNewRound = function() {
|
|
this.objective = this.freshObjective();
|
|
this.state = STATE.PLAY;
|
|
|
|
const lastPositions = this.winningStack.reduce((acc, v) => {
|
|
acc[v.id] = v;
|
|
return acc;
|
|
}, {})
|
|
|
|
this.robots = Object.values(lastPositions).map(({ i, j, id }) => ({
|
|
i,
|
|
j,
|
|
id,
|
|
color: this.colors[id],
|
|
icon: this.icons[id]
|
|
}));
|
|
|
|
this.countdownTimer = null;
|
|
this.countdownTimestamp = null;
|
|
|
|
this.winningPlayerId = null;
|
|
this.winningStack = null;
|
|
|
|
this.messenger.messageAll({ type: 'newround' });
|
|
this.messenger.messageAll({ type: 'objective', body: this.objective});
|
|
this.messenger.messageAll({ type: 'state', body: this.state});
|
|
this.messenger.messageAll({ type: 'robots', body: this.robots});
|
|
};
|
|
|
|
Ricochet.prototype.msgObjective = function() {
|
|
this.objective = this.freshObjective();
|
|
this.messenger.messageAll({ type: 'objective', body: this.objective });
|
|
};
|
|
|
|
Ricochet.prototype.msgRobots = function() {
|
|
this.robots = this.freshRobots();
|
|
this.messenger.messageAll({ type: 'robots', body: this.robots});
|
|
};
|
|
|
|
Ricochet.prototype.msgWalls = function() {
|
|
this.walls = this.freshWalls();
|
|
this.messenger.messageAll({ type: 'walls', body: this.walls });
|
|
};
|
|
|
|
Ricochet.prototype.msgSkip = function() {
|
|
this.onCountdownComplete();
|
|
};
|
|
|
|
Ricochet.prototype.msgSolve = function(message) {
|
|
clearTimeout(this.countdownTimer);
|
|
|
|
this.countdownTimer = setTimeout(this.onCountdownComplete.bind(this), this.countdownDuration * 1000);
|
|
this.countdownTimestamp = Date.now();
|
|
|
|
this.state = STATE.COUNTDOWN;
|
|
|
|
this.winningStack = this.sanitizeStack(message.stack);
|
|
|
|
this.winningPlayerId = this.sanitizeId(message.id);
|
|
|
|
this.messenger.messageAll({ type: 'state', body: this.state });
|
|
|
|
this.messenger.messageAll({ type: 'countdown', body: this.getCountdownStateBody() });
|
|
};
|
|
|
|
//===== Helper functions
|
|
|
|
Ricochet.prototype.randomSquare = function() {
|
|
return {
|
|
i: Math.floor(Math.random() * this.squaresPerSide),
|
|
j: Math.floor(Math.random() * this.squaresPerSide)
|
|
};
|
|
};
|
|
|
|
Ricochet.prototype.randomUnoccupiedSquare = function() {
|
|
let result = this.randomSquare();
|
|
let CONTROL_COUNTER = 0;
|
|
|
|
while (this.isSquareOccupied(result) && CONTROL_COUNTER < 20) {
|
|
CONTROL_COUNTER++;
|
|
result = this.randomSquare();
|
|
}
|
|
|
|
if (CONTROL_COUNTER > 19) {
|
|
console.log(`==================\n\nCRITICAL ERROR!\n\nrandomUnoccupiedSquare() while() short-circuited!\n\n==================`);
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
Ricochet.prototype.isSquareOccupied = function({ i, j }) {
|
|
if (i < 0 || i > this.squaresPerSide) {
|
|
return true;
|
|
}
|
|
|
|
if (j < 0 || j > this.squaresPerSide) {
|
|
return true;
|
|
}
|
|
|
|
if (this.robots) {
|
|
for (let k = 0; k < this.robots.length; k++) {
|
|
if (this.robots[k].i === i && this.robots[k].j === j) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.objective && i === this.objective.i && j === this.objective.j) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
Ricochet.prototype.sanitizeStack = function(rawMoveStack) {
|
|
const sanitizedStack = rawMoveStack.map(move => {
|
|
const sanitizedRobotId = this.sanitizeId(move.id);
|
|
const sanitizedI = move.i * 1;
|
|
const sanitizedJ = move.j * 1;
|
|
|
|
return {
|
|
i: sanitizedI,
|
|
j: sanitizedJ,
|
|
id: sanitizedRobotId
|
|
};
|
|
});
|
|
|
|
return sanitizedStack;
|
|
};
|
|
|
|
Ricochet.prototype.sanitizeId = function(id) {
|
|
return id.replace(/[^0-9a-zA-Z\-]/g, '');
|
|
};
|
|
|
|
Ricochet.prototype.shuffle = function(arr) {
|
|
const result = [];
|
|
let CONTROL_COUNTER = 0;
|
|
|
|
while (arr.length && CONTROL_COUNTER < 50) {
|
|
const x = Math.floor(Math.random() * arr.length);
|
|
result.push(arr[x]);
|
|
arr.splice(x, 1);
|
|
}
|
|
|
|
if (CONTROL_COUNTER > 39) {
|
|
console.log(`==================\n\nCRITICAL ERROR!\n\shuffle()() while() short-circuited!\n\n==================`);
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
Ricochet.prototype.onCountdownComplete = function() {
|
|
clearTimeout(this.countdownTimer);
|
|
|
|
this.countdownTimestamp = null;
|
|
this.state = STATE.WIN;
|
|
|
|
this.messenger.messageAll({ type: 'state', body: this.state });
|
|
this.messenger.messageAll({ type: 'win', body: this.getWinStateBody() });
|
|
};
|
|
|
|
Ricochet.prototype.getCountdownStateBody = function() {
|
|
return {
|
|
duration: this.countdownDuration,
|
|
id: this.winningPlayerId,
|
|
moveCount: this.winningStack.length - this.robots.length,
|
|
timestamp: this.countdownTimestamp
|
|
};
|
|
};
|
|
|
|
Ricochet.prototype.getWinStateBody = function() {
|
|
return {
|
|
moveCount: this.winningStack.length - this.robots.length,
|
|
player_id: this.winningPlayerId,
|
|
stack: this.winningStack
|
|
};
|
|
};
|
|
|
|
module.exports = Ricochet;
|
|
|