Spaces:
Sleeping
Sleeping
| 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 }; | |