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; type Table = { id: number; sockets: Map; balls: Map; }; 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(); 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) { 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((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(); 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, 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(), balls: new Map(), } 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 };