YoYo-Haku-Pool / src /server.ts
github-actions[bot]
Sync to HuggingFace Spaces
a07d36d
import type { Socket } from "socket.io";
import type { DefaultEventsMap } from "socket.io/dist/typed-events";
import MainLoop from "mainloop.js";
import {
accelerate,
add,
collideCircleCircle,
collideCircleEdge,
inertia,
normalize,
overlapCircleCircle,
rewindToCollisionPoint,
sub,
v2,
Vector2,
distance,
scale,
} from "pocket-physics";
import {
Ball,
ballsPositionsUpdatesPerSecond,
squareCanvasSizeInPixels,
ballRadius,
ClientToServerEvents,
ServerToClientEvents,
ServerToClientEventName,
ClientToServerEventName,
BallsPositions,
Scoreboard,
} from "./shared";
type GameSocketData = {
ball: Ball;
nickname: string;
score: number;
table: Table;
};
type GameSocket = Socket<ClientToServerEvents, ServerToClientEvents, DefaultEventsMap, GameSocketData>;
type Table = {
id: number;
sockets: Map<string, GameSocket>;
balls: Map<number, Ball>;
};
let lastScoreboardEmitted = "";
let uniqueIdCounter = 1;
let timePassedSinceLastStateUpdateEmitted = 0;
let timePassedSinceLastScoreboardUpdate = 0;
const nonPlayableBallsValuesRange = [1, 8] as [min: number, max: number];
const maxSocketsPerTable = 4;
const scoreboardUpdateMillisecondsInterval = 1000;
const objectsPositionsUpdateMillisecondsInterval = 1000 / ballsPositionsUpdatesPerSecond;
const massOfImmovableObjects = -1;
const tables = new Map<number, Table>();
const ballColors = ["#fff", "#ffff00", "#0000ff", "#ff0000", "#aa00aa", "#ffaa00", "#1f952f", "#550000", "#1a191e"];
const collisionDamping = 0.9;
const cornerPocketSize = 100;
const tablePadding = 64;
const maximumNicknameLength = 21;
const tableLeftRailPoints = [
v2(tablePadding, cornerPocketSize),
v2(tablePadding, squareCanvasSizeInPixels - cornerPocketSize),
] as [Vector2, Vector2];
const tableRightRailPoints = [
v2(squareCanvasSizeInPixels - tablePadding, cornerPocketSize),
v2(squareCanvasSizeInPixels - tablePadding, squareCanvasSizeInPixels - cornerPocketSize),
] as [Vector2, Vector2];
const tableTopRailPoints = [
v2(cornerPocketSize, tablePadding),
v2(squareCanvasSizeInPixels - cornerPocketSize, tablePadding),
] as [Vector2, Vector2];
const tableBottomRailPoints = [
v2(cornerPocketSize, squareCanvasSizeInPixels - tablePadding),
v2(squareCanvasSizeInPixels - cornerPocketSize, squareCanvasSizeInPixels - tablePadding),
] as [Vector2, Vector2];
const tableRails = [tableLeftRailPoints, tableRightRailPoints, tableTopRailPoints, tableBottomRailPoints];
const scoreLineDistanceFromCorner = 140;
const scoreLines = [
[v2(0, scoreLineDistanceFromCorner), v2(scoreLineDistanceFromCorner, 0)],
[
v2(squareCanvasSizeInPixels - scoreLineDistanceFromCorner, 0),
v2(squareCanvasSizeInPixels, scoreLineDistanceFromCorner),
],
[
v2(0, squareCanvasSizeInPixels - scoreLineDistanceFromCorner),
v2(scoreLineDistanceFromCorner, squareCanvasSizeInPixels),
],
[
v2(squareCanvasSizeInPixels, squareCanvasSizeInPixels - scoreLineDistanceFromCorner),
v2(squareCanvasSizeInPixels - scoreLineDistanceFromCorner, squareCanvasSizeInPixels),
],
] as [Vector2, Vector2][];
function getUniqueId() {
const id = uniqueIdCounter;
id < Number.MAX_SAFE_INTEGER ? uniqueIdCounter++ : 1;
return id;
}
function getRandomElementFrom(object: any[] | string) {
return object[Math.floor(Math.random() * object.length)];
}
function getRandomTextualSmile() {
return `${getRandomElementFrom(":=")}${getRandomElementFrom("POD)]")}`;
}
function addBallToTable(table: Table, properties?: Partial<Ball>) {
const ball = {
id: getUniqueId(),
cpos: v2(),
ppos: v2(),
acel: v2(),
radius: 1,
mass: 1,
value: 0,
label: getRandomTextualSmile(),
lastTouchedTimestamp: Date.now(),
...properties,
} as Ball;
placeBallInRandomPosition(ball);
table.balls.set(ball.id, ball);
table.sockets.forEach((socket) => socket.emit(ServerToClientEventName.Creation, ball));
return ball;
}
function getRandomPositionForBallOnTable() {
return (
tablePadding + ballRadius + Math.floor(Math.random() * (squareCanvasSizeInPixels - (tablePadding + ballRadius) * 2))
);
}
function placeBallInRandomPosition(ball: Ball) {
const x = getRandomPositionForBallOnTable();
const y = getRandomPositionForBallOnTable();
ball.cpos = v2(x, y);
ball.ppos = v2(x, y);
}
function isColliding(firstObject: Ball, secondObject: Ball) {
return overlapCircleCircle(
firstObject.cpos.x,
firstObject.cpos.y,
firstObject.radius,
secondObject.cpos.x,
secondObject.cpos.y,
secondObject.radius
);
}
function handleCollision(firstObject: Ball, secondObject: Ball) {
if (firstObject.ownerSocketId || secondObject.ownerSocketId) {
if (firstObject.ownerSocketId) secondObject.lastTouchedBySocketId = firstObject.ownerSocketId;
if (secondObject.ownerSocketId) firstObject.lastTouchedBySocketId = secondObject.ownerSocketId;
} else {
if (firstObject.lastTouchedTimestamp > secondObject.lastTouchedTimestamp) {
secondObject.lastTouchedBySocketId = firstObject.lastTouchedBySocketId;
} else {
firstObject.lastTouchedBySocketId = secondObject.lastTouchedBySocketId;
}
}
firstObject.lastTouchedTimestamp = secondObject.lastTouchedTimestamp = Date.now();
collideCircleCircle(
firstObject,
firstObject.radius,
firstObject.mass,
secondObject,
secondObject.radius,
secondObject.mass,
true,
collisionDamping
);
}
function createBallForSocket(socket: GameSocket) {
if (!socket.data.table) return;
socket.data.ball = addBallToTable(socket.data.table, {
radius: ballRadius,
ownerSocketId: socket.id,
color: getRandomHexColor(),
value: 9,
});
}
function deleteBallFromSocket(socket: GameSocket) {
if (!socket.data.table || !socket.data.ball) return;
deleteBallFromTable(socket.data.ball, socket.data.table);
socket.data.ball = undefined;
}
function getNumberOfNonPlayableBallsOnTable(table: Table) {
return Array.from(table.balls.values()).filter((ball) => !ball.ownerSocketId).length;
}
function handleSocketConnected(socket: GameSocket) {
socket.data.nickname = `Player ${getUniqueId()}`;
socket.data.score = 0;
const table =
Array.from(tables.values()).find((currentTable) => currentTable.sockets.size < maxSocketsPerTable) ?? createTable();
addSocketToTable(socket, table);
setupSocketListeners(socket);
}
function handleSocketDisconnected(socket: GameSocket) {
if (!socket.data.table) return;
removeSocketFromTable(socket, socket.data.table);
}
function broadcastChatMessageToTable(message: string, table: Table) {
return table.sockets.forEach((socket) => socket.emit(ServerToClientEventName.Message, message));
}
function broadcastChatMessageToAllTables(message: string) {
return tables.forEach((table) => broadcastChatMessageToTable(message, table));
}
function accelerateBallFromSocket(x: number, y: number, socket: GameSocket) {
if (!socket.data.ball) return;
const accelerationVector = v2();
sub(accelerationVector, v2(x, y), socket.data.ball.cpos);
normalize(accelerationVector, accelerationVector);
const elasticityFactor = 20 * (distance(v2(x, y), socket.data.ball.cpos) / squareCanvasSizeInPixels);
scale(accelerationVector, accelerationVector, elasticityFactor);
add(socket.data.ball.acel, socket.data.ball.acel, accelerationVector);
}
function handleMessageReceivedFromSocket(message: string, socket: GameSocket) {
if (message.startsWith("/nick ")) {
const trimmedNickname = message.replace("/nick ", "").trim().substring(0, maximumNicknameLength);
if (trimmedNickname.length) {
broadcastChatMessageToAllTables(`📢 ${socket.data.nickname} is now known as ${trimmedNickname}!`);
socket.data.nickname = trimmedNickname;
}
} else if (message.startsWith("/newtable")) {
removeSocketFromTable(socket, socket.data.table);
addSocketToTable(socket, createTable());
} else if (message.startsWith("/jointable ")) {
const tableId = Number(message.replace("/jointable ", "").trim());
if (isNaN(tableId) || !tables.has(tableId)) {
socket.emit(ServerToClientEventName.Message, `📢 Table not found!`);
} else if (tables.get(tableId) === socket.data.table) {
socket.emit(ServerToClientEventName.Message, `📢 Already on table ${tableId}!`);
} else if ((tables.get(tableId) as Table).sockets.size >= maxSocketsPerTable) {
socket.emit(ServerToClientEventName.Message, `📢 Table is full!`);
} else {
removeSocketFromTable(socket, socket.data.table);
addSocketToTable(socket, tables.get(tableId) as Table);
}
} else {
broadcastChatMessageToAllTables(`💬 ${socket.data.nickname}: ${message}`);
}
}
function setupSocketListeners(socket: GameSocket) {
socket.on("disconnect", () => handleSocketDisconnected(socket));
socket.on(ClientToServerEventName.Message, (message) => handleMessageReceivedFromSocket(message, socket));
socket.on(ClientToServerEventName.Click, (x, y) => accelerateBallFromSocket(x, y, socket));
}
function checkCollisionWithTableRails(ball: Ball) {
tableRails.forEach(([pointA, pointB]) => {
if (rewindToCollisionPoint(ball, ball.radius, pointA, pointB))
collideCircleEdge(
ball,
ball.radius,
ball.mass,
{
cpos: pointA,
ppos: pointA,
},
massOfImmovableObjects,
{
cpos: pointB,
ppos: pointB,
},
massOfImmovableObjects,
true,
collisionDamping
);
});
}
function deleteBallFromTable(ball: Ball, table: Table) {
if (table.balls.has(ball.id)) {
table.balls.delete(ball.id);
table.sockets.forEach((targetSocket) => targetSocket.emit(ServerToClientEventName.Deletion, ball.id));
}
if (getNumberOfNonPlayableBallsOnTable(table) == 0) addNonPlayableBallsToTable(table);
}
function checkCollisionWithScoreLines(ball: Ball, table: Table) {
scoreLines.forEach(([pointA, pointB]) => {
if (rewindToCollisionPoint(ball, ball.radius, pointA, pointB)) {
deleteBallFromTable(ball, table);
if (ball.ownerSocketId) {
const socket = table.sockets.get(ball.ownerSocketId);
if (socket) {
const negativeScore = -ball.value;
socket.data.score = Math.max(0, (socket.data.score as number) + negativeScore);
socket.emit(ServerToClientEventName.Scored, negativeScore, ball.cpos.x, ball.cpos.y);
createBallForSocket(socket);
}
}
if (ball.lastTouchedBySocketId) {
const socket = table.sockets.get(ball.lastTouchedBySocketId);
if (socket) {
socket.data.score = (socket.data.score as number) + ball.value;
socket.emit(ServerToClientEventName.Scored, ball.value, ball.cpos.x, ball.cpos.y);
}
}
}
});
}
function emitObjectsPositionsToConnectedSockets() {
Array.from(tables.values())
.filter((table) => table.balls.size)
.forEach((table) => {
const positions = Array.from(table.balls.values()).reduce<BallsPositions>((resultArray, ball) => {
resultArray.push([ball.id, Math.trunc(ball.cpos.x), Math.trunc(ball.cpos.y)]);
return resultArray;
}, []);
table.sockets.forEach((socket) => {
socket.emit(ServerToClientEventName.Positions, positions);
});
});
}
function emitScoreboardToConnectedSockets() {
const tableIdPerScoreboardMap = new Map<number, Scoreboard>();
tables.forEach((table) => {
const tableScoreboard = Array.from(table.sockets.values())
.sort((a, b) => (b.data.score as number) - (a.data.score as number))
.reduce<Scoreboard>((scoreboard, socket) => {
scoreboard.push([socket.data.nickname as string, socket.data.score as number, table.id as number]);
return scoreboard;
}, []);
tableIdPerScoreboardMap.set(table.id, tableScoreboard);
});
const overallScoreboard = [] as Scoreboard;
tableIdPerScoreboardMap.forEach((tableScoreboard) => overallScoreboard.push(...tableScoreboard));
overallScoreboard.sort(([, scoreA], [, scoreB]) => scoreB - scoreA);
const scoreBoardToEmit = JSON.stringify(overallScoreboard);
if (lastScoreboardEmitted === scoreBoardToEmit) return;
lastScoreboardEmitted = scoreBoardToEmit;
tables.forEach((table) => {
table.sockets.forEach((socket) => {
let tableScoreboard = [] as Scoreboard;
if (socket.data.table && tableIdPerScoreboardMap.has(socket.data.table.id)) {
tableScoreboard = tableIdPerScoreboardMap.get(socket.data.table.id) as Scoreboard;
}
socket.emit(ServerToClientEventName.Scoreboard, overallScoreboard, tableScoreboard);
});
});
}
function repositionBallIfItIsOutOfTable(ball: Ball) {
if (
ball.cpos.x < 0 ||
ball.cpos.x > squareCanvasSizeInPixels ||
ball.cpos.y < 0 ||
ball.cpos.y > squareCanvasSizeInPixels
) {
placeBallInRandomPosition(ball);
}
}
function updatePhysics(deltaTime: number) {
tables.forEach((table) => {
Array.from(table.balls.values()).forEach((ball, _, balls) => {
repositionBallIfItIsOutOfTable(ball);
accelerate(ball, deltaTime);
balls
.filter((otherBalls) => ball !== otherBalls && isColliding(ball, otherBalls))
.forEach((otherBall) => handleCollision(ball, otherBall));
checkCollisionWithTableRails(ball);
checkCollisionWithScoreLines(ball, table);
inertia(ball);
});
});
}
function getRandomHexColor() {
const randomInteger = (max: number) => Math.floor(Math.random() * (max + 1));
const randomRgbColor = () => [randomInteger(255), randomInteger(255), randomInteger(255)];
const [r, g, b] = randomRgbColor();
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
}
function handleMainLoopUpdate(deltaTime: number) {
updatePhysics(deltaTime);
timePassedSinceLastStateUpdateEmitted += deltaTime;
if (timePassedSinceLastStateUpdateEmitted > objectsPositionsUpdateMillisecondsInterval) {
timePassedSinceLastStateUpdateEmitted -= objectsPositionsUpdateMillisecondsInterval;
emitObjectsPositionsToConnectedSockets();
}
timePassedSinceLastScoreboardUpdate += deltaTime;
if (timePassedSinceLastScoreboardUpdate > scoreboardUpdateMillisecondsInterval) {
timePassedSinceLastScoreboardUpdate -= scoreboardUpdateMillisecondsInterval;
emitScoreboardToConnectedSockets();
}
}
function addNonPlayableBallsToTable(table: Table) {
const [min, max] = nonPlayableBallsValuesRange;
for (let value = min; value <= max; value++) {
addBallToTable(table, {
radius: ballRadius,
value,
label: `${value}`,
color: ballColors[value],
});
}
}
function addSocketToTable(socket: GameSocket, table: Table) {
table.sockets.set(socket.id, socket);
socket.data.table = table;
createBallForSocket(socket);
socket.emit(ServerToClientEventName.Objects, Array.from(table.balls.values()));
broadcastChatMessageToAllTables(`📢 ${socket.data.nickname} joined Table ${table.id}!`);
}
function removeSocketFromTable(socket: GameSocket, table?: Table) {
if (!table) return;
deleteBallFromSocket(socket);
table.sockets.delete(socket.id);
socket.data.table = undefined;
if (!table.sockets.size) deleteTable(table);
}
function createTable() {
const table = {
id: getUniqueId(),
sockets: new Map<string, GameSocket>(),
balls: new Map<number, Ball>(),
} as Table;
tables.set(table.id, table);
addNonPlayableBallsToTable(table);
return table;
}
function deleteTable(table: Table) {
Array.from(table.balls.values()).forEach((ball) => deleteBallFromTable(ball, table));
tables.delete(table.id);
}
MainLoop.setUpdate(handleMainLoopUpdate).start();
export default { io: handleSocketConnected };