diff --git a/.vscode/settings.json b/.vscode/settings.json index e747f02..183a1df 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -49,8 +49,10 @@ ] }, "cSpell.words": [ + "bufferutil", "dotp", "gamepadconnected", - "gamepaddisconnected" + "gamepaddisconnected", + "pino" ] } diff --git a/app/components/game.css b/app/components/game.css index 9a0dce1..f0eb1e9 100644 --- a/app/components/game.css +++ b/app/components/game.css @@ -57,7 +57,7 @@ text-align: center; box-sizing: border-box; min-width: 120px; - min-height: 44px; + min-height: 54px; } .gameWrapper .playerControls { @@ -80,7 +80,7 @@ border-radius: 5px; text-align: center; min-height: 120px; - min-width: 44px; + min-width: 54px; } .gameWrapper .playerControls .item .shortcut { @@ -107,7 +107,7 @@ text-align: center; box-sizing: border-box; min-width: 120px; - min-height: 44px; + min-height: 54px; } .gameWrapper .gameControls .item .shortcut { diff --git a/app/components/game.tsx b/app/components/game.tsx index abebde8..eadd5cd 100644 --- a/app/components/game.tsx +++ b/app/components/game.tsx @@ -8,14 +8,20 @@ import { Game } from '@/lib/game' import { MdArrowUpward, MdArrowDownward } from 'react-icons/md' import { useTheme } from 'next-themes' import { useStopwatch } from 'react-timer-hook' -// import toast from 'react-hot-toast' import '@/components/game.css' // Init vars const isGamepadSupported = typeof window !== 'undefined' && window.navigator.getGamepads !== undefined +// Props represents the component props. +type Props = { + wsAddress?: string +} + // GameComponent represents a game component. -export default function GameComponent() { +export default function GameComponent(props: Props) { + const { wsAddress } = props + const { setTheme } = useTheme() const canvasRef = useRef(null) const gameServiceRef = useRef() @@ -59,7 +65,7 @@ export default function GameComponent() { // onGameOver handles the game over event. const onGameOver = useCallback((game: Game) => { - console.debug('GameComponent.onTogglePause', game.getId(), game.getGameState(), game.getWinner()) + console.debug('GameComponent.onGameOver', game.getId(), game.getGameState(), game.getWinner()) if (game.getWinner() === 'dark') { setScore((prevScore) => ({ ...prevScore, dark: prevScore.dark + 1 })) @@ -76,7 +82,9 @@ export default function GameComponent() { if (gameServiceRef.current) { if (document.visibilityState !== 'visible') { stopwatchControlsRef.current.pause() - gameServiceRef.current.pause() + if (gameServiceRef.current.getGameState() === 'running') { + gameServiceRef.current.pause() + } } } }, []) @@ -92,14 +100,15 @@ export default function GameComponent() { gameServiceRef.current = new GameService({ canvasElement: canvasRef.current, gamepadEnabled: isGamepadSupported, + wsAddress: wsAddress, onNewGame: onNewGame, onTogglePause: onTogglePause, onGameOver: onGameOver, }) - gameServiceRef.current.handleMouseEvent('controlW', 'w', 'dark') - gameServiceRef.current.handleMouseEvent('controlS', 's', 'dark') - gameServiceRef.current.handleMouseEvent('controlO', 'o', 'light') - gameServiceRef.current.handleMouseEvent('controlL', 'l', 'light') + gameServiceRef.current.handleMouseEventByElementId('controlDarkUp', 'dark', 'up') + gameServiceRef.current.handleMouseEventByElementId('controlDarkDown', 'dark', 'down') + gameServiceRef.current.handleMouseEventByElementId('controlLightUp', 'light', 'up') + gameServiceRef.current.handleMouseEventByElementId('controlLightDown', 'light', 'down') // Splash screen if (showSplash) { @@ -114,7 +123,7 @@ export default function GameComponent() { gameServiceRef.current?.destroy() document.removeEventListener('visibilitychange', onVisibilityChange) } - }, [showSplash, onNewGame, onTogglePause, onGameOver, onVisibilityChange, setTheme]) + }, [wsAddress, showSplash, onNewGame, onTogglePause, onGameOver, onVisibilityChange, setTheme]) return (
@@ -126,8 +135,8 @@ export default function GameComponent() {
-
W
-
S
+
W
+
S
-
O
-
L
+
O
+
L
diff --git a/app/lib/ball.ts b/app/lib/ball.ts index 153cfe0..1041c51 100644 --- a/app/lib/ball.ts +++ b/app/lib/ball.ts @@ -1,11 +1,11 @@ // For the full copyright and license information, please view the LICENSE.txt file. -import { Game, PlayerSide } from '@/lib/game' +import { Game, Side, PlayerSide } from '@/lib/game' // BallOptions represents the options to create a new ball. export interface BallOptions { game: Game - side: PlayerSide + playerSide: PlayerSide x: number y: number speedX: number @@ -21,7 +21,8 @@ export class Ball { private options: BallOptions private game: Game - private side: PlayerSide + private side: Side + private playerSide: PlayerSide private x: number private y: number private speedX: number @@ -31,11 +32,15 @@ export class Ball { // constructor creates a new instance. constructor(options: BallOptions) { - const { game, side, x, y, speedX, speedY, radius, color } = options + const { game, playerSide, x, y, speedX, speedY, radius, color } = options this.options = options this.game = game - this.side = side + this.playerSide = playerSide + // Light ball collides with the light grid cells and dark ball collides with the dark grid cells. + // Hence we launch light ball at the dark side and dark ball at the light side, + // so that balls can collide with the opposite side grid cells. + this.side = playerSide === 'dark' ? 'light' : 'dark' this.x = x this.y = y this.speedX = speedX @@ -79,9 +84,14 @@ export class Ball { return this.speedY } + // getSide returns the side of the ball. + public getSide(): Side { + return this.side + } + // getPlayerSide returns the player side of the paddle. public getPlayerSide(): PlayerSide { - return this.side + return this.playerSide } // setX sets the x position of the ball. diff --git a/app/lib/collision.ts b/app/lib/collision.ts index ad06d69..0a9b523 100644 --- a/app/lib/collision.ts +++ b/app/lib/collision.ts @@ -1,18 +1,24 @@ // For the full copyright and license information, please view the LICENSE.txt file. -import { Game } from '@/lib/game' +import { Game, PlayerSide } from '@/lib/game' import { Ball } from '@/lib/ball' import { Paddle } from '@/lib/paddle' // CollisionManagerOptions represents the options to create a new collision manager. export interface CollisionManagerOptions { game: Game + onBallToBoundaryCollision?: (collision: BallToBoundaryCollision) => void + onBallToPaddleCollision?: (collision: BallToPaddleCollision) => void + onBallToGridCollision?: (collision: BallToGridCollision) => void } // CollisionManager manages collisions between game components. export class CollisionManager { private options: CollisionManagerOptions private game: Game + private onBallToBoundaryCollision?: (collision: BallToBoundaryCollision) => void + private onBallToPaddleCollision?: (collision: BallToPaddleCollision) => void + private onBallToGridCollision?: (collision: BallToGridCollision) => void // constructor creates a new instance. constructor(options: CollisionManagerOptions) { @@ -20,6 +26,9 @@ export class CollisionManager { this.options = options this.game = game + this.onBallToBoundaryCollision = options.onBallToBoundaryCollision + this.onBallToPaddleCollision = options.onBallToPaddleCollision + this.onBallToGridCollision = options.onBallToGridCollision } // reset resets the collision manager. @@ -38,11 +47,10 @@ export class CollisionManager { // Check for collision between the ball and boundaries const b2b = this.checkBallToBoundaryCollision(ball, ballFutureX, ballFutureY) if (b2b.collided) { + if (this.onBallToBoundaryCollision) this.onBallToBoundaryCollision(b2b) + if (b2b.oppositeSide) { - // Note that we launch light ball at the dark side and dark ball at the light side, - // so that balls can collide with the opposite side boundary. - // Hence we reverse the player side here. - this.game.gameOver(ball.getPlayerSide() === 'dark' ? 'light' : 'dark') + this.game.gameOver(ball.getPlayerSide()) return } if (b2b.speedX) ball.setSpeedX(b2b.speedX) @@ -55,6 +63,8 @@ export class CollisionManager { for (let j = 0, m = paddles.length; j < m; j++) { const b2p = this.checkBallToPaddleCollision(ball, paddles[j], ballFutureX, ballFutureY) if (b2p.collided) { + if (this.onBallToPaddleCollision) this.onBallToPaddleCollision(b2p) + if (b2p.speedX) ball.setSpeedX(b2p.speedX) if (b2p.speedY) ball.setSpeedY(b2p.speedY) if (b2p.futureX) ball.setX(b2p.futureX) @@ -80,6 +90,8 @@ export class CollisionManager { // Check for collisions between the balls and the grid const b2g = this.checkBallToGridCollision(ball, ballFutureX, ballFutureY) if (b2g.collided) { + if (this.onBallToGridCollision) this.onBallToGridCollision(b2g) + if (b2g.speedX) ball.setSpeedX(b2g.speedX) if (b2g.speedY) ball.setSpeedY(b2g.speedY) if (b2g.futureX) ball.setX(b2g.futureX) @@ -87,7 +99,7 @@ export class CollisionManager { if (b2g.cells) { for (let k = 0, n = b2g.cells.length; k < n; k++) { const cell = b2g.cells[k] - this.game.getGrid().setCell(cell[0], cell[1], ball.getPlayerSide() === 'dark' ? 'light' : 'dark') + this.game.getGrid().setCell(cell[0], cell[1], ball.getPlayerSide()) } } } @@ -109,18 +121,24 @@ export class CollisionManager { collision.speedX = -ball.getSpeedX() collision.futureX = radius // for avoiding sticking - // Check if the ball is on the opposite side of the boundary - if (ball.getPlayerSide() === 'dark') { + // ballX - radius <= 0 means the ball is on the left side of the boundary (dark side) + if (ball.getPlayerSide() === 'light') { collision.oppositeSide = true + } else { + collision.ownSide = true } } else if (ballX + radius >= canvasWidth) { collision.collided = true collision.speedX = -ball.getSpeedX() collision.futureX = canvasWidth - radius // for avoiding sticking - // Check if the ball is on the opposite side of the boundary - if (ball.getPlayerSide() === 'light') { + // ballX + radius >= canvasWidth means the ball is on the right side of the boundary (light side) + // If the ball side is light (own by the dark side) then + // it's on the opposite side of the boundary, since we launch light ball at the dark side. + if (ball.getPlayerSide() === 'dark') { collision.oppositeSide = true + } else { + collision.ownSide = true } } @@ -135,6 +153,10 @@ export class CollisionManager { collision.futureY = canvasHeight - radius // for avoiding sticking } + if (collision.collided) { + collision.playerSide = ball.getPlayerSide() + } + return collision } @@ -174,6 +196,10 @@ export class CollisionManager { return collision } + // Set the collision properties + collision.playerSide = ball.getPlayerSide() + collision.paddlePlayerSide = paddle.getPlayerSide() + // Calculate the angle of the collision let collidePoint = ball.getY() - (paddle.getY() + paddle.getHeight() / 2) collidePoint = collidePoint / (paddle.getHeight() / 2) @@ -235,11 +261,12 @@ export class CollisionManager { // Check if the ball is within the grid bounds if (gridY >= 0 && gridY < this.game.getGrid().getRowLength() && gridX >= 0 && gridX < this.game.getGrid().getColLength()) { - const cellPlayerSide = this.game.getGrid().getCell(gridX, gridY) + const cellSide = this.game.getGrid().getCell(gridX, gridY) - // Check for collision with a cell of the same player side - if (cellPlayerSide === ball.getPlayerSide()) { + // If the ball side is the same as the cell side then it's a collision + if (cellSide === ball.getSide()) { collision.collided = true + collision.playerSide = ball.getPlayerSide() collision.speedX = -ball.getSpeedX() collision.speedY = -ball.getSpeedY() collision.futureX = ballX - ball.getSpeedX() * this.game.getSinceLastFrame() @@ -255,22 +282,26 @@ export class CollisionManager { } // BallToBoundaryCollision represents a collision between a ball and a boundary. -type BallToBoundaryCollision = { +export type BallToBoundaryCollision = { collided: boolean speedX?: number speedY?: number futureX?: number futureY?: number + playerSide?: PlayerSide oppositeSide?: boolean + ownSide?: boolean } // BallToPaddleCollision represents a collision between a ball and a paddle. -type BallToPaddleCollision = { +export type BallToPaddleCollision = { collided: boolean speedX?: number speedY?: number futureX?: number futureY?: number + playerSide?: PlayerSide + paddlePlayerSide?: PlayerSide } // BallToBallCollision represents a collision between two balls. @@ -283,11 +314,12 @@ type BallToBallCollision = { } // BallToGridCollision represents a collision between a ball and a grid cell. -type BallToGridCollision = { +export type BallToGridCollision = { collided: boolean speedX?: number speedY?: number futureX?: number futureY?: number cells?: number[][] + playerSide?: PlayerSide } diff --git a/app/lib/controller.ts b/app/lib/controller.ts index d5c71a0..ebefa80 100644 --- a/app/lib/controller.ts +++ b/app/lib/controller.ts @@ -2,8 +2,7 @@ // TODO: Implement Controller interface for keyboard and mouse controllers. -import { Game, PlayerSide } from '@/lib/game' -import { Paddle } from '@/lib/paddle' +import { Game, PlayerSide, PlayerAction } from '@/lib/game' // ControllerManagerOptions represents the options for the controller manager. export type ControllerManagerOptions = { @@ -24,6 +23,7 @@ export class ControllerManager { private gamepadEnabled: boolean private controllers: Map = new Map() private controllersInterval: NodeJS.Timeout | undefined + private playerControls: PlayerControls private onNewGame?: (game: Game) => void private onTogglePause?: (game: Game) => void private onControllerConnected?: (game: Game, controller: Controller) => void @@ -36,6 +36,7 @@ export class ControllerManager { this.options = options this.game = game this.gamepadEnabled = gamepadEnabled || false + this.playerControls = { dark: { up: 'w', down: 's' }, light: { up: 'o', down: 'l' } } this.onNewGame = onNewGame this.onTogglePause = onTogglePause this.onControllerConnected = onControllerConnected @@ -95,7 +96,9 @@ export class ControllerManager { // newGame starts a new game. private newGame(): void { - this.game.stop() + if (this.game.getGameState() === 'running') { + this.game.stop() + } this.game.start() if (this.onNewGame) this.onNewGame(this.game) } @@ -111,29 +114,31 @@ export class ControllerManager { return this.controllers.get('g' + gamepad.index) } + // movePaddleByPlayerSide moves the paddle by player side. + public movePaddle(side: PlayerSide, up: boolean, down: boolean): void { + this.game.getPaddle(side).setMovement(up, down) + } + // setPaddleMovement sets the paddle movement based on the keys pressed. private setPaddleMovement() { // Update paddles - this.game.getPaddle('dark').setMovement(this.keysPressed.has('w'), this.keysPressed.has('s')) - this.game.getPaddle('light').setMovement(this.keysPressed.has('o'), this.keysPressed.has('l')) + const sides: PlayerSide[] = ['dark', 'light'] + sides.forEach((side: PlayerSide) => { + this.game.getPaddle(side).setMovement( + this.keysPressed.has(this.playerControls[side].up), + this.keysPressed.has(this.playerControls[side].down) + ) + }) } // startContinuousMovement starts continuous movement for a paddle. - private startContinuousMovement(key: string, paddle: Paddle): void { - if (paddle.getPlayerSide() === 'dark'){ - this.game.getPaddle('dark').setMovement(key === 'w', key === 's') - } else { - this.game.getPaddle('light').setMovement(key === 'o', key === 'l') - } + private startContinuousMovement(side: PlayerSide, action: PlayerAction): void { + this.game.getPaddle(side).setMovement(action === 'up', action === 'down') } // stopContinuousMovement stops continuous movement for a paddle. - private stopContinuousMovement(key: string, paddle: Paddle): void { - if (paddle.getPlayerSide() === 'dark'){ - this.game.getPaddle('dark').setMovement(false, false) - } else { - this.game.getPaddle('light').setMovement(false, false) - } + private stopContinuousMovement(side: PlayerSide): void { + this.game.getPaddle(side).setMovement(false, false) } // handleKeyDown handles key down events. @@ -163,24 +168,26 @@ export class ControllerManager { this.setPaddleMovement() } - // handleMouseEvent handles mouse events. - public handleMouseEvent(elementId: string, key: string, paddle: Paddle): void { + // handleMouseEventByElementId handles a mouse event by element id. + public handleMouseEventByElementId(elementId: string, playerSide: PlayerSide, action: PlayerAction): void { const element = document.getElementById(elementId) if (!element) return + const paddle = this.game?.getPaddle(playerSide) + if (!paddle) return // Initialize event listeners - const mouseDownListener = () => this.startContinuousMovement(key, paddle) - const mouseUpListener = () => this.stopContinuousMovement(key, paddle) - const mouseLeaveListener = () => this.stopContinuousMovement(key, paddle) + const mouseDownListener = () => this.startContinuousMovement(paddle.getPlayerSide(), action) + const mouseUpListener = () => this.stopContinuousMovement(paddle.getPlayerSide()) + const mouseLeaveListener = () => this.stopContinuousMovement(paddle.getPlayerSide()) const touchStartListener = (e: Event) => { if (e.cancelable) { // Prevent scrolling and ensure touch is used for control e.preventDefault() } - this.startContinuousMovement(key, paddle) + this.startContinuousMovement(paddle.getPlayerSide(), action) } const touchEndListener = () => { - this.stopContinuousMovement(key, paddle) + this.stopContinuousMovement(paddle.getPlayerSide()) } // Add event listeners @@ -294,6 +301,15 @@ export class ControllerManager { } } +// PlayerControls represents player controls. +type PlayerControls = { + // eslint-disable-next-line no-unused-vars + [key in PlayerSide]: { + up: string; + down: string; + }; +} + // ControllerType represents a controller type. type ControllerType = 'keyboard' | 'gamepad' diff --git a/app/lib/env.ts b/app/lib/env.ts index d803253..bef8122 100644 --- a/app/lib/env.ts +++ b/app/lib/env.ts @@ -3,8 +3,11 @@ import 'server-only' import fs from 'fs' import path from 'path' +import { Logger } from '@/lib/logger' +import { fileURLToPath } from 'url' // Init vars +const logger = Logger({ path: fileURLToPath(import.meta.url) }) const nodeEnvCache = new Map() const appEnvCache = new Map() @@ -78,7 +81,7 @@ function loadEnvFile(file?: string) { if (!file) return const filepath = path.resolve(process.cwd(), file) if (!fs.existsSync(filepath)) { - console.debug(`env file not found filepath=${filepath}`) + logger.error(`env file not found filepath=${filepath}`) return } @@ -91,5 +94,4 @@ function loadEnvFile(file?: string) { const [key, value] = line.split('=', 2) process.env[key] = value }) - console.debug(`env file loaded from filepath=${filepath}`) } diff --git a/app/lib/errors.ts b/app/lib/errors.ts new file mode 100644 index 0000000..f2bd192 --- /dev/null +++ b/app/lib/errors.ts @@ -0,0 +1,6 @@ +// For the full copyright and license information, please view the LICENSE.txt file. + +// tryError returns an error from a try catch block. +export function tryError(e: any): Error { + return (e instanceof Error) ? e : new Error(e.message || e) +} diff --git a/app/lib/game.ts b/app/lib/game.ts index 0ff6b0a..e65506a 100644 --- a/app/lib/game.ts +++ b/app/lib/game.ts @@ -1,16 +1,23 @@ // For the full copyright and license information, please view the LICENSE.txt file. -import { CollisionManager } from '@/lib/collision' +import { CollisionManager, BallToBoundaryCollision, BallToPaddleCollision, BallToGridCollision } from '@/lib/collision' import { ControllerManager } from '@/lib/controller' import { Grid } from '@/lib/grid' import { Ball } from '@/lib/ball' import { Paddle } from '@/lib/paddle' import { randomNumber } from '@/lib/numbers' +import { Msg } from '@/lib/wss' import { ulid } from 'ulidx' +// Side represents a side. +export type Side = 'dark' | 'light' + // PlayerSide represents a player side. export type PlayerSide = 'dark' | 'light' +// PlayerAction represents a player action. +export type PlayerAction = '' | 'up' | 'down' + // GameState represents the game state. export type GameState = 'initial' | 'running' | 'paused' | 'stopped' | 'over' @@ -18,6 +25,7 @@ export type GameState = 'initial' | 'running' | 'paused' | 'stopped' | 'over' export interface GameOptions { canvasElement: HTMLCanvasElement gamepadEnabled?: boolean + wsAddress?: string onNewGame?: (game: Game) => void onTogglePause?: (game: Game) => void onGameOver?: (game: Game) => void @@ -29,9 +37,11 @@ export class Game { static colorBlue = '#2E67F8' static colorGreen = '#22BA1A' static colorDark = '#202020' - static colorDarkPaddle = '#C8C8C8' + static colorDarkSidePaddle = '#C8C8C8' + static colorDarkSideBall = '#E0E0E0' static colorLight = '#E0E0E0' - static colorLightPaddle = '#3A3A3A' + static colorLightSidePaddle = '#3A3A3A' + static colorLightSideBall = '#202020' static ballSpeed = 300 static paddleSpeed = 500 @@ -41,6 +51,8 @@ export class Game { private canvas: HTMLCanvasElement private ctx: CanvasRenderingContext2D private gamepadEnabled: boolean + private wsAddress?: string + private ws?: WebSocket private onNewGame?: (game: Game) => void private onTogglePause?: (game: Game) => void private onGameOver?: (game: Game) => void @@ -59,7 +71,7 @@ export class Game { // constructor creates a new instance. constructor(options: GameOptions) { - const { canvasElement, gamepadEnabled, onNewGame, onTogglePause, onGameOver } = options + const { canvasElement, gamepadEnabled, wsAddress, onNewGame, onTogglePause, onGameOver } = options this.id = ulid() this.canvas = canvasElement @@ -69,67 +81,132 @@ export class Game { throw new Error(`could not get 2D context: ${e.message || e}`) } this.gamepadEnabled = gamepadEnabled || false + this.wsAddress = wsAddress if (onNewGame) this.onNewGame = onNewGame if (onTogglePause) this.onTogglePause = onTogglePause if (onGameOver) this.onGameOver = onGameOver // Initialize the main game components - this.collisionManager = new CollisionManager({ game: this }) + this.collisionManager = new CollisionManager({ + game: this, + onBallToBoundaryCollision: this.onBallToBoundaryCollision, + onBallToPaddleCollision: this.onBallToPaddleCollision, + onBallToGridCollision: this.onBallToGridCollision, + }) this.controllerManager = new ControllerManager({ game: this, gamepadEnabled: this.gamepadEnabled, onNewGame: this.onNewGame, onTogglePause: this.onTogglePause }) this.grid = new Grid({ game: this, width: this.canvas.width, height: this.canvas.height, cellSize: Game.cellSize }) // Initialize the light paddle on the dark side against the light side - // Light ball collides with the light grid cells, hence they are on the dark side. this.paddles.push(new Paddle({ game: this, - side: 'light', - x: this.canvas.width - Game.cellSize * 2, + playerSide: 'dark', + x: Game.cellSize, y: this.canvas.height / 2 - 50, - width: Game.cellSize, height: Game.cellSize * 5, speed: Game.paddleSpeed, - color: Game.colorLightPaddle, + color: Game.colorDarkSidePaddle, })) - // Initialize the light ball at the dark side and moving towards the light side + // Initialize the light ball at the dark player side and moving towards the light side this.balls.push(new Ball({ game: this, - side: 'light', + playerSide: 'dark', x: this.canvas.width / 4, y: randomNumber(Game.cellSize, this.canvas.height - Game.cellSize), speedX: Game.ballSpeed, speedY: Game.ballSpeed, radius: Game.cellSize / 2, - color: Game.colorLight, + color: Game.colorDarkSideBall, })) // Initialize the dark paddle on the light side against the dark side - // Dark ball collides with the dark grid cells, hence they are on the light side. this.paddles.push(new Paddle({ game: this, - side: 'dark', - x: Game.cellSize, + playerSide: 'light', + x: this.canvas.width - Game.cellSize * 2, y: this.canvas.height / 2 - 50, + width: Game.cellSize, height: Game.cellSize * 5, speed: Game.paddleSpeed, - color: Game.colorDarkPaddle, + color: Game.colorLightSidePaddle, })) - // Initialize the dark ball at the light side and moving towards the dark side + // Initialize the dark ball at the light player side and moving towards the dark side this.balls.push(new Ball({ game: this, - side: 'dark', + playerSide: 'light', x: this.canvas.width / 4 * 3, y: randomNumber(Game.cellSize, this.canvas.height - Game.cellSize), speedX: -Game.ballSpeed, speedY: -Game.ballSpeed, radius: Game.cellSize / 2, - color: Game.colorDark, + color: Game.colorLightSideBall, })) + + // Connect to the WebSocket server + if (this.wsAddress) { + try { + this.ws = new WebSocket(this.wsAddress) + this.ws.onopen = () => { + console.debug(`websocket connection opened: ${this.wsAddress}`) + } + this.ws.onerror = (event: Event) => { + console.error(`websocket error: ${event}`) + } + this.ws.onclose = (event: CloseEvent) => { + console.debug(`websocket connection closed: ${event.code} ${event.reason}`) + } + this.ws.onmessage = (event: MessageEvent) => { + try { + let debug = false + const msg: Msg = JSON.parse(event.data) + switch (msg?.command) { + case 'move': + if (!msg.move?.playerSide) { + break + } + this.controllerManager.movePaddle(msg.move.playerSide, msg.move?.up || false, msg.move?.down || false) + break + case 'start': + this.start() + debug = true + break + case 'stop': + this.stop() + debug = true + break + case 'pause': + this.pause() + debug = true + break + case 'resume': + this.resume() + debug = true + break + case 'restart': + this.stop() + this.start() + debug = true + break + } + if (debug) console.debug(`websocket message: ${JSON.stringify(msg)}`) + } catch (e: any) { + // For now ignore any unknown/invalid messages. + } + } + } catch (e: any) { + console.error(`could not connect to WebSocket server: ${e.message || e}`) + } + } } // destroy destroys the game. public destroy(): void { this.stop() this.controllerManager.destroy() + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.close() + } else if (this.ws?.readyState === WebSocket.CONNECTING) { + this.ws.onopen = () => this.ws?.close() + } } // getId returns the game id. @@ -218,6 +295,9 @@ export class Game { if (this.gameState === 'running') return this.reset() this.gameState = 'running' + + this.wsSend('{"event": "start"}', true) + requestAnimationFrame(this.gameLoop) } @@ -225,6 +305,9 @@ export class Game { public stop(): void { if (this.gameState === 'stopped') return this.gameState = 'stopped' + + this.wsSend('{"event": "stop"}', true) + cancelAnimationFrame(this.animationFrameId) } @@ -232,6 +315,9 @@ export class Game { public pause(): void { if (this.gameState === 'paused') return this.gameState = 'paused' + + this.wsSend('{"event": "pause"}', true) + cancelAnimationFrame(this.animationFrameId) } @@ -240,6 +326,9 @@ export class Game { if (this.gameState === 'running') return this.lastFrameTime = performance.now() // reset last frame time this.gameState = 'running' + + this.wsSend('{"event": "resume"}', true) + requestAnimationFrame(this.gameLoop) } @@ -259,6 +348,8 @@ export class Game { this.winner = playerSide this.stop() + this.wsSend(`{"event": "gameOver", "winner": "${playerSide}"}`, true) + if (this.onGameOver) this.onGameOver(this) } @@ -272,9 +363,36 @@ export class Game { this.update() // update the game state this.draw() // draw the next frame + + // Send the canvas pixels in base64 + this.wsSend(`{"event": "canvas", "data": "${this.canvas.toDataURL()}"}`, false) + this.animationFrameId = requestAnimationFrame(this.gameLoop) // request next frame } + // onBallToBoundaryCollision handles the ball to boundary collision. + private onBallToBoundaryCollision = (collision: BallToBoundaryCollision): void => { + this.wsSend(`{"event": "collision", "collision": {"kind": "ballToBoundary", "playerSide": "${collision.playerSide}", "oppositeSide": ${collision.oppositeSide || false}, "ownSide": ${collision.ownSide || false}}}`) + } + + // onBallToPaddleCollision handles the ball to paddle collision. + private onBallToPaddleCollision = (collision: BallToPaddleCollision): void => { + this.wsSend(`{"event": "collision", "collision": {"kind": "ballToPaddle", "playerSide": "${collision.playerSide}", "paddlePlayerSide": "${collision.paddlePlayerSide}"}}`) + } + + // onBallToGridCollision handles the ball to grid collision. + private onBallToGridCollision = (collision: BallToGridCollision): void => { + this.wsSend(`{"event": "collision", "collision": {"kind": "ballToGrid", "playerSide": "${collision.playerSide}"}}`) + } + + // wsSend sends a message to the WebSocket server. + public wsSend(msg: string, debug: boolean = false): void { + if (this.ws?.readyState === WebSocket.OPEN) { + if (debug) console.debug(`websocket send: ${msg}`) + this.ws.send(msg) + } + } + // update updates the game components. private update(): void { // Update controllers diff --git a/app/lib/logger.ts b/app/lib/logger.ts new file mode 100644 index 0000000..3a481b0 --- /dev/null +++ b/app/lib/logger.ts @@ -0,0 +1,49 @@ +// For the full copyright and license information, please view the LICENSE.txt file. + +import pino, { LoggerOptions, Logger as PinoLogger } from 'pino' + +// Init vars +const loggerTarget = process.env.LOGGER_TARGET +const loggerOpts: LoggerOptions = { + name: process.env.APP_NAME, + level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', + timestamp: pino.stdTimeFunctions.isoTime, + redact: { + paths: ['req.headers.authorization'], + }, +} +if (loggerTarget) { + loggerOpts.transport = { + target: loggerTarget + } +} +const baseLogger = pino(loggerOpts) +const logger: ExtendedLogger = { + ...baseLogger, + fatal: (msg, ...args) => { + baseLogger.fatal(msg, ...args) + process.exit(1) + } +} + +// ExtendedLogger extends PinoLogger. +interface ExtendedLogger extends PinoLogger { + fatal: (obj: any, ...args: any[]) => void +} + +// Options represents the options for the logger. +type Options = { + path?: string + sep?: string +} + +// Logger returns a new logger instance with the given options. +export function Logger({ path, sep }: Options): ExtendedLogger { + const bindings: any = {} + if (path) { + bindings.source = sep ? (path.includes(sep) ? path.split(sep)[1].substring(1) : path) : path + } + const childLogger = baseLogger.child(bindings) as ExtendedLogger + childLogger.fatal = logger.fatal.bind(childLogger) + return childLogger +} diff --git a/app/lib/paddle.ts b/app/lib/paddle.ts index d1b62d7..b55c524 100644 --- a/app/lib/paddle.ts +++ b/app/lib/paddle.ts @@ -5,7 +5,7 @@ import { Game, PlayerSide } from '@/lib/game' // PaddleOptions represents the options to create a new paddle. export interface PaddleOptions { game: Game - side: PlayerSide + playerSide: PlayerSide x: number y: number height?: number @@ -23,7 +23,7 @@ export class Paddle { private options: PaddleOptions private game: Game - private side: PlayerSide + private playerSide: PlayerSide private x: number private y: number private height: number = Paddle.height @@ -36,11 +36,11 @@ export class Paddle { // constructor creates a new instance. constructor(options: PaddleOptions) { - const { game, side, x, y, height, width, speed, color } = options + const { game, playerSide, x, y, height, width, speed, color } = options this.options = options this.game = game - this.side = side + this.playerSide = playerSide this.x = x this.y = y if (height !== undefined) this.height = height @@ -91,7 +91,7 @@ export class Paddle { // getPlayerSide returns the player side of the paddle. public getPlayerSide(): PlayerSide { - return this.side + return this.playerSide } // setSpeed sets the speed of the paddle. @@ -122,6 +122,11 @@ export class Paddle { if (this.moveUp) this.y -= this.speed * this.game.getSinceLastFrame() if (this.moveDown) this.y += this.speed * this.game.getSinceLastFrame() + this.checkBoundary() + } + + // checkBoundary checks if the paddle is at the boundary. + private checkBoundary(): void { // Prevent paddles from going off screen // this.y = Math.max(this.y, 0) // top // this.y = Math.min(this.y, this.ctx.canvas.height - this.height) // bottom diff --git a/app/lib/wss.ts b/app/lib/wss.ts new file mode 100644 index 0000000..2b71cb1 --- /dev/null +++ b/app/lib/wss.ts @@ -0,0 +1,76 @@ +// For the full copyright and license information, please view the LICENSE.txt file. + +import { Logger } from '@/lib/logger' +import { fileURLToPath } from 'url' +import { RawData, WebSocket, WebSocketServer } from 'ws' + +// Init vars +const logger = Logger({ path: fileURLToPath(import.meta.url), sep: 'dotp' }) + +// WSSOptions represents the options to create a new websocket server. +interface WSSOptions { + port: number +} + +// WSS represents a websocket server. +export class WSS { + private wss: WebSocketServer + private clients: Set = new Set() + + // constructor creates a new instance. + constructor(options: WSSOptions) { + const { port } = options + + this.wss = new WebSocketServer({ port }) + this.wss.on('connection', (ws: WebSocket) => { + // Add client to the list + this.clients.add(ws) + + // Remove client from the list + ws.on('close', () => { + this.clients.delete(ws) + }) + + ws.on('error', (e: Error) => { + logger.error(`websocket error: ${e.message || e}`) + }) + + ws.on('message', (data: RawData) => { + try { + const msg: Msg = JSON.parse(data.toString()) + // console.debug('websocket message', msg) // for debug + + // Forward the message to all clients. + this.clients.forEach((client) => { + if (client.readyState === client.OPEN) { + client.send(JSON.stringify(msg)) + } + }) + } catch (e: any) { + // For now ignore any unknown/invalid messages. + } + }) + }) + logger.info(`websocket server started on port ${port}`) + } +} + +// Msg represents a websocket message. +export type Msg = { + event?: MsgEvent + command?: MsgCommand + move?: MsgMove +} + +// MsgEvent represents an event field in a message. +type MsgEvent = 'start' | 'stop' | 'pause' | 'resume' | 'gameOver' | 'move' | 'collision' + +// MsgCommand represents a command field in a message. +type MsgCommand = 'start' | 'stop' | 'pause' | 'resume' | 'move' | 'restart' + +// MsgMove represents a move field in a message. +type MsgMove = { + playerSide: 'dark' | 'light' + up?: boolean + down?: boolean +} diff --git a/app/page.tsx b/app/page.tsx index 1b949a4..525bc7c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -3,11 +3,15 @@ import GameComponent from '@/app/components/game' import '@/app/page.css' +// Init vars +const wsEnabled = process.env.WS_ENABLED || false +const wsAddress = process.env.WS_ADDRESS || wsEnabled ? `ws://localhost:3001` : undefined + // Home returns the main component of the app. export default function Home() { return (
- +
) } diff --git a/app/services/game.ts b/app/services/game.ts index ad71fa6..579d312 100644 --- a/app/services/game.ts +++ b/app/services/game.ts @@ -1,11 +1,12 @@ // For the full copyright and license information, please view the LICENSE.txt file. -import { Game, PlayerSide, GameState } from '@/app/lib/game' +import { Game, GameState, PlayerAction, PlayerSide } from '@/app/lib/game' // GameServiceOptions represents game service options. export interface GameServiceOptions { canvasElement: HTMLCanvasElement gamepadEnabled?: boolean + wsAddress?: string onNewGame?: (game: Game) => void onTogglePause?: (game: Game) => void onGameOver?: (game: Game) => void @@ -16,16 +17,18 @@ export class GameService { private canvas?: HTMLCanvasElement private game?: Game private gamepadEnabled: boolean + private wsAddress?: string private onGameOver?: (game: Game) => void private onNewGame?: (game: Game) => void private onTogglePause?: (game: Game) => void // constructor creates a new instance. constructor(options: GameServiceOptions) { - const { canvasElement, gamepadEnabled, onNewGame, onTogglePause, onGameOver } = options + const { canvasElement, gamepadEnabled, wsAddress, onNewGame, onTogglePause, onGameOver } = options this.canvas = canvasElement this.gamepadEnabled = gamepadEnabled || false + this.wsAddress = wsAddress if (onNewGame) this.onNewGame = onNewGame if (onTogglePause) this.onTogglePause = onTogglePause if (onGameOver) this.onGameOver = onGameOver @@ -33,6 +36,7 @@ export class GameService { this.game = new Game({ canvasElement: this.canvas, gamepadEnabled: this.gamepadEnabled, + wsAddress: this.wsAddress, onNewGame: this.onNewGame, onTogglePause: this.onTogglePause, onGameOver: this.onGameOver, @@ -90,11 +94,8 @@ export class GameService { this.game.splash() } - // handleMouseEvent handles mouse events. - public handleMouseEvent(elementId: string, key: string, playerSide: PlayerSide): void { - if (!this.game) return - const paddle = this.game?.getPaddle(playerSide) - if (!paddle) return - this.game.getController().handleMouseEvent(elementId, key, paddle) + // handleMouseEventByElementId handles a mouse event by element id. + public handleMouseEventByElementId(elementId: string, playerSide: PlayerSide, action: PlayerAction): void { + this.game?.getController().handleMouseEventByElementId(elementId, playerSide, action) } } diff --git a/instrumentation.ts b/instrumentation.ts new file mode 100644 index 0000000..d540bb0 --- /dev/null +++ b/instrumentation.ts @@ -0,0 +1,18 @@ +// For the full copyright and license information, please view the LICENSE.txt file. + +// Ref: https://template.nextjs.guide/app/building-your-application/optimizing/instrumentation + +import { WSS } from '@/lib/wss' + +// Init vars +const wsEnabled = process.env.WS_ENABLED || false + +export async function register() { + if (process.env.NEXT_RUNTIME === 'nodejs') { + // Init webSocket server + if (wsEnabled) { + const wsPort = parseInt(process.env.WS_PORT || '3001') + new WSS({ port: wsPort }) + } + } +} diff --git a/next.config.mjs b/next.config.mjs index 94be31c..973f7bf 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,6 +1,12 @@ /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, + experimental: { + // Ref: https://github.com/vercel/next.js/issues/57078#issuecomment-1771887698 + serverComponentsExternalPackages: ['pino'], + // Ref: https://template.nextjs.guide/app/building-your-application/optimizing/instrumentation + instrumentationHook: true, + }, } export default nextConfig diff --git a/package-lock.json b/package-lock.json index 459ca28..2d1988d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,18 +14,23 @@ "framer-motion": "^11.0.3", "next": "14.1.0", "next-themes": "^0.2.1", + "pino": "^8.19.0", + "pino-http": "^9.0.0", + "pino-pretty": "^10.3.1", "react": "^18", "react-dom": "^18", "react-hot-toast": "^2.4.1", "react-icons": "^5.0.1", "react-timer-hook": "^3.0.7", "server-only": "^0.0.1", - "ulidx": "^2.3.0" + "ulidx": "^2.3.0", + "ws": "^8.16.0" }, "devDependencies": { "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "@types/ws": "^8.5.10", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "autoprefixer": "^10.0.1", @@ -34,6 +39,9 @@ "postcss": "^8", "tailwindcss": "^3.3.0", "typescript": "^5" + }, + "optionalDependencies": { + "bufferutil": "^4.0.8" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -2857,6 +2865,15 @@ "integrity": "sha512-/wdoPq1QqkSj9/QOeKkFquEuPzQbHTWAMPH/PaUMB+JuR31lXhlWXRZ52IpfDYVlDOUBvX09uBrPwxGT1hjNBg==", "dev": true }, + "node_modules/@types/ws": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", + "integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", @@ -3097,6 +3114,17 @@ } } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.11.3", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", @@ -3361,6 +3389,14 @@ "has-symbols": "^1.0.3" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/autoprefixer": { "version": "10.4.17", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.17.tgz", @@ -3433,6 +3469,25 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -3494,6 +3549,42 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/bufferutil": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.8.tgz", + "integrity": "sha512-4T53u4PdgsXqKaIctwF8ifXlRTTmEPJ8iEPWFdGZvcf7sbwYo6FKFEX9eNNAnzFZ7EzJAQ3CJeOtCRA4rDp7Pw==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -3664,6 +3755,11 @@ "resolved": "https://registry.npmjs.org/color2k/-/color2k-2.0.3.tgz", "integrity": "sha512-zW190nQTIoXcGCaU08DvVNFTmQhUpnJfVuAKfWqUQkflXKpaDdpaYoM0iluLS9lgJNHyBF58KKA2FBEwkD7wog==" }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==" + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -3718,6 +3814,14 @@ "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", "dev": true }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "engines": { + "node": "*" + } + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -3845,6 +3949,14 @@ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.15.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz", @@ -4419,6 +4531,27 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fast-copy": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.1.tgz", + "integrity": "sha512-Knr7NOtK3HWRYGtHoJrjkaWepqT8thIVGAwt0p0aUs1zqkAzXZV4vo9fFNwyb5fcqK1GKYFYxldQdIDVKhUAfA==" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4463,6 +4596,19 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fast-redact": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.3.0.tgz", + "integrity": "sha512-6T5V1QK1u4oF+ATxs1lWUmlEk6P2T9HqJG3e2DnHOdVgZy2rFJBoEnrIedcTXlkAHU/zKC+7KETJ+KGGKwxgMQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" + }, "node_modules/fastq": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", @@ -4652,6 +4798,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", @@ -4923,6 +5077,30 @@ "node": ">= 0.4" } }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/ignore": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", @@ -5391,6 +5569,14 @@ "jiti": "bin/jiti.js" } }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "engines": { + "node": ">=10" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5621,7 +5807,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -5755,6 +5940,17 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-gyp-build": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.0.tgz", + "integrity": "sha512-u6fs2AEUljNho3EYTJNBfImO5QTo/J/1Etd+NVdCj7qWKUSN/bSLkZwhDv7I+w/MSC6qJ4cknepkAYykDdK8og==", + "optional": true, + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-releases": { "version": "2.0.14", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", @@ -5904,11 +6100,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "dependencies": { "wrappy": "1" } @@ -6051,6 +6254,76 @@ "node": ">=0.10.0" } }, + "node_modules/pino": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-8.19.0.tgz", + "integrity": "sha512-oswmokxkav9bADfJ2ifrvfHUwad6MLp73Uat0IkQWY3iAw5xTRoznXbXksZs8oaOUMpmhVWD+PZogNzllWpJaA==", + "dependencies": { + "atomic-sleep": "^1.0.0", + "fast-redact": "^3.1.1", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "v1.1.0", + "pino-std-serializers": "^6.0.0", + "process-warning": "^3.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^3.7.0", + "thread-stream": "^2.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-1.1.0.tgz", + "integrity": "sha512-lsleG3/2a/JIWUtf9Q5gUNErBqwIu1tUKTT3dUzaf5DySw9ra1wcqKjJjLX1VTY64Wk1eEOYsVGSaGfCK85ekA==", + "dependencies": { + "readable-stream": "^4.0.0", + "split2": "^4.0.0" + } + }, + "node_modules/pino-http": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/pino-http/-/pino-http-9.0.0.tgz", + "integrity": "sha512-Q9QDNEz0vQmbJtMFjOVr2c9yL92vHudjmr3s3m6J1hbw3DBGFZJm3TIj9TWyynZ4GEsEA9SOtni4heRUr6lNOg==", + "dependencies": { + "get-caller-file": "^2.0.5", + "pino": "^8.17.1", + "pino-std-serializers": "^6.2.2", + "process-warning": "^3.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-10.3.1.tgz", + "integrity": "sha512-az8JbIYeN/1iLj2t0jR9DV48/LQ3RC6hZPpapKPkb84Q+yTidMCpgWxIT3N0flnBDilyBQ1luWNpOeJptjdp/g==", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^3.0.0", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^1.0.0", + "pump": "^3.0.0", + "readable-stream": "^4.0.0", + "secure-json-parse": "^2.4.0", + "sonic-boom": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-std-serializers": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-6.2.2.tgz", + "integrity": "sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA==" + }, "node_modules/pirates": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", @@ -6206,6 +6479,19 @@ "node": ">= 0.8.0" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-warning": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz", + "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -6217,6 +6503,15 @@ "react-is": "^16.13.1" } }, + "node_modules/pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -6245,6 +6540,11 @@ } ] }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==" + }, "node_modules/react": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", @@ -6396,6 +6696,21 @@ "pify": "^2.3.0" } }, + "node_modules/readable-stream": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", + "integrity": "sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -6407,6 +6722,14 @@ "node": ">=8.10.0" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.5.tgz", @@ -6568,6 +6891,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/safe-regex-test": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", @@ -6585,6 +6927,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-stable-stringify": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.4.3.tgz", + "integrity": "sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==", + "engines": { + "node": ">=10" + } + }, "node_modules/scheduler": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", @@ -6601,6 +6951,11 @@ "compute-scroll-into-view": "^3.0.2" } }, + "node_modules/secure-json-parse": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==" + }, "node_modules/semver": { "version": "7.6.0", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", @@ -6729,6 +7084,14 @@ "node": ">=8" } }, + "node_modules/sonic-boom": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-3.8.0.tgz", + "integrity": "sha512-ybz6OYOUjoQQCQ/i4LU8kaToD8ACtYP+Cj5qd2AO36bwbdewxWJ3ArmJ2cr6AvxlL2o0PqnCcPGUgkILbfkaCA==", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map-js": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", @@ -6737,6 +7100,14 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -6745,6 +7116,14 @@ "node": ">=10.0.0" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -6906,7 +7285,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, "engines": { "node": ">=8" }, @@ -7074,6 +7452,14 @@ "node": ">=0.8" } }, + "node_modules/thread-stream": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-2.4.1.tgz", + "integrity": "sha512-d/Ex2iWd1whipbT681JmTINKw0ZwOUBZm7+Gjs64DHuX34mmw8vJL2bFAaNacaW72zYiTJxSHi5abUuOi5nsfg==", + "dependencies": { + "real-require": "^0.2.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -7555,8 +7941,27 @@ "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "node_modules/ws": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", + "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } }, "node_modules/yallist": { "version": "4.0.0", diff --git a/package.json b/package.json index 7d75ab2..b058aa5 100644 --- a/package.json +++ b/package.json @@ -27,18 +27,23 @@ "framer-motion": "^11.0.3", "next": "14.1.0", "next-themes": "^0.2.1", + "pino": "^8.19.0", + "pino-http": "^9.0.0", + "pino-pretty": "^10.3.1", "react": "^18", "react-dom": "^18", "react-hot-toast": "^2.4.1", "react-icons": "^5.0.1", "react-timer-hook": "^3.0.7", "server-only": "^0.0.1", - "ulidx": "^2.3.0" + "ulidx": "^2.3.0", + "ws": "^8.16.0" }, "devDependencies": { "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "@types/ws": "^8.5.10", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "autoprefixer": "^10.0.1", @@ -47,5 +52,8 @@ "postcss": "^8", "tailwindcss": "^3.3.0", "typescript": "^5" + }, + "optionalDependencies": { + "bufferutil": "^4.0.8" } }