import type { Socket } from "socket.io-client"; import { createPubSub } from "create-pubsub"; import { init, GameLoop, Vector, Text, Sprite, initPointer, onPointer, getPointer, degToRad, radToDeg, Pool, } from "kontra"; import { BallsPositions, ballsPositionsUpdatesPerSecond, Ball, squareCanvasSizeInPixels, ServerToClientEvents, ClientToServerEvents, ballRadius, ClientToServerEventName, ServerToClientEventName, Scoreboard, } from "./shared"; import { zzfx } from "zzfx"; const gameName = "YoYo Haku Pool"; const gameFramesPerSecond = 60; const gameStateUpdateFramesInterval = gameFramesPerSecond / ballsPositionsUpdatesPerSecond; const ballIdToBallSpriteMap = new Map(); const { canvas, context } = init(document.querySelector("#canvas") as HTMLCanvasElement); const scoreboardTextArea = document.querySelector("#b1 textarea") as HTMLTextAreaElement; const chatHistory = document.querySelector("#b2 textarea") as HTMLTextAreaElement; const chatInputField = document.querySelector("#b3 input") as HTMLInputElement; const chatButton = document.querySelector("#b4 button") as HTMLButtonElement; const tableImage = document.querySelector("img[src='table.webp']") as HTMLImageElement; const socket = io({ upgrade: false, transports: ["websocket"] }) as Socket; const [publishMainLoopUpdate, subscribeToMainLoopUpdate] = createPubSub(); const [publishMainLoopDraw, subscribeToMainLoopDraw] = createPubSub(); const [publishPageWithImagesLoaded, subscribeToPageWithImagesLoaded] = createPubSub(); const [publishGamePreparationComplete, subscribeToGamePreparationCompleted] = createPubSub(); const [publishPointerPressed, subscribeToPointerPressed, isPointerPressed] = createPubSub(false); const [setOwnSprite, , getOwnSprite] = createPubSub(null); const [setLastTimeEmittedPointerPressed, , getLastTimeEmittedPointerPressed] = createPubSub(Date.now()); const [publishSoundEnabled, , isSoundEnabled] = createPubSub(false); const messageReceivedSound = [2.01, , 773, 0.02, 0.01, 0.01, 1, 1.14, 44, -27, , , , , 0.9, , 0.18, 0.81, 0.01]; const scoreIncreasedSound = [ 1.35, , 151, 0.1, 0.17, 0.26, 1, 0.34, -4.1, -5, -225, 0.02, 0.14, 0.1, , 0.1, 0.13, 0.9, 0.22, 0.17, ]; const acceleratingSound = [, , 999, 0.2, 0.04, 0.15, 4, 2.66, -0.5, 22, , , , 0.1, , , , , 0.02]; const scoreDecreasedSound = [, , 727, 0.02, 0.03, 0, 3, 0.09, 4.4, -62, , , , , , , 0.19, 0.65, 0.2, 0.51]; const tableSprite = Sprite({ image: tableImage, }); const scoreTextPool = Pool({ create: Text as any, }); function setCanvasWidthAndHeight() { canvas.width = canvas.height = squareCanvasSizeInPixels; } function prepareGame() { updateDocumentTitleWithGameName(); printWelcomeMessage(); setCanvasWidthAndHeight(); handleWindowResized(); initPointer({ radius: 0 }); publishGamePreparationComplete(); } function emitPointerPressedIfNeeded() { if (!isPointerPressed() || Date.now() - getLastTimeEmittedPointerPressed() < 1000 / ballsPositionsUpdatesPerSecond) return; const { x, y } = getPointer(); socket.emit(ClientToServerEventName.Click, Math.trunc(x), Math.trunc(y)); setLastTimeEmittedPointerPressed(Date.now()); } function updateScene() { emitPointerPressedIfNeeded(); ballIdToBallSpriteMap.forEach((sprite) => { const newRotationDegree = radToDeg(sprite.rotation) + (Math.abs(sprite.dx) + Math.abs(sprite.dy)) * 7; sprite.rotation = degToRad(newRotationDegree > 360 ? newRotationDegree - 360 : newRotationDegree); sprite.update(); }); scoreTextPool.update(); } function drawLine(fromPoint: { x: number; y: number }, toPoint: { x: number; y: number }) { context.beginPath(); context.strokeStyle = "#fff"; context.moveTo(fromPoint.x, fromPoint.y); context.lineTo(toPoint.x, toPoint.y); context.stroke(); } function renderOtherSprites() { ballIdToBallSpriteMap.forEach((sprite) => { if (sprite !== getOwnSprite()) sprite.render(); }); } function renderOwnSpritePossiblyWithWire() { const ownSprite = getOwnSprite(); if (!ownSprite) return; if (isPointerPressed()) drawLine(ownSprite.position, getPointer()); ownSprite.render(); } function renderScene() { tableSprite.render(); renderOtherSprites(); renderOwnSpritePossiblyWithWire(); scoreTextPool.render(); } function startMainLoop() { return GameLoop({ update: publishMainLoopUpdate, render: publishMainLoopDraw }).start(); } function fitCanvasInsideItsParent(canvasElement: HTMLCanvasElement) { if (!canvasElement.parentElement) return; const { width, height, style, parentElement } = canvasElement; const { clientWidth, clientHeight } = parentElement; const widthScale = clientWidth / width; const heightScale = clientHeight / height; const scale = widthScale < heightScale ? widthScale : heightScale; style.marginTop = `${(clientHeight - height * scale) / 2}px`; style.marginLeft = `${(clientWidth - width * scale) / 2}px`; style.width = `${width * scale}px`; style.height = `${height * scale}px`; } function handleBallsPositionsReceived(balls: Ball[]) { ballIdToBallSpriteMap.clear(); balls.forEach((ball) => createSpriteForBall(ball)); } function createSpriteForBall(ball: Ball) { const sprite = Sprite({ x: squareCanvasSizeInPixels / 2, y: squareCanvasSizeInPixels / 2, anchor: { x: 0.5, y: 0.5 }, render: () => { sprite.context.fillStyle = ball.color; sprite.context.beginPath(); sprite.context.arc(0, 0, ballRadius, 0, 2 * Math.PI); sprite.context.fill(); }, }); const whiteCircle = Sprite({ anchor: { x: 0.5, y: 0.5 }, render: () => { sprite.context.fillStyle = "#fff"; sprite.context.beginPath(); sprite.context.arc(0, 0, ballRadius / 1.5, 0, 2 * Math.PI); sprite.context.fill(); }, }); sprite.addChild(whiteCircle); const ballLabel = Text({ text: ball.label, font: `${ballRadius}px monospace`, color: "black", anchor: { x: 0.5, y: 0.5 }, textAlign: "center", }); sprite.addChild(ballLabel); ballIdToBallSpriteMap.set(ball.id, sprite); if (ball.ownerSocketId === socket.id) setOwnSprite(sprite); return sprite; } function setSpriteVelocity(expectedPosition: Vector, sprite: Sprite) { const difference = expectedPosition.subtract(sprite.position); sprite.dx = difference.x / gameStateUpdateFramesInterval; sprite.dy = difference.y / gameStateUpdateFramesInterval; } function stopSprite(sprite: Sprite) { sprite.ddx = sprite.ddy = sprite.dx = sprite.dy = 0; } function handleChatMessageReceived(message: string) { playSound(messageReceivedSound); chatHistory.value += `${getHoursFromLocalTime()}:${getMinutesFromLocalTime()} ${message}\n`; if (chatHistory !== document.activeElement) chatHistory.scrollTop = chatHistory.scrollHeight; } function getMinutesFromLocalTime() { return new Date().getMinutes().toString().padStart(2, "0"); } function getHoursFromLocalTime() { return new Date().getHours().toString().padStart(2, "0"); } function sendChatMessage() { const messageToSend = chatInputField.value.trim(); chatInputField.value = ""; if (!messageToSend.length) { return; } else if (messageToSend.startsWith("/help")) { return printHelpText(); } else if (messageToSend.startsWith("/soundon")) { handleChatMessageReceived("šŸ“¢ Sounds enabled."); return publishSoundEnabled(true); } else if (messageToSend.startsWith("/soundoff")) { handleChatMessageReceived("šŸ“¢ Sounds disabled."); return publishSoundEnabled(false); } else { socket.emit(ClientToServerEventName.Message, messageToSend); } } function handleKeyPressedOnChatInputField(event: KeyboardEvent) { if (event.key === "Enter") sendChatMessage(); } function updateInnerHeightProperty() { document.documentElement.style.setProperty("--inner-height", `${window.innerHeight}px`); } function handleWindowResized() { updateInnerHeightProperty(); fitCanvasInsideItsParent(canvas); } function playSound(sound: (number | undefined)[]) { if (isSoundEnabled()) zzfx(...sound); } function enableSounds() { publishSoundEnabled(true); playSound(messageReceivedSound); } function handlePointerDown() { if (!getOwnSprite()) return; playSound(acceleratingSound); publishPointerPressed(true); } function handlePointerUp() { publishPointerPressed(false); } function handleObjectDeleted(id: number) { const spriteToDelete = ballIdToBallSpriteMap.get(id); if (!spriteToDelete) return; if (spriteToDelete === getOwnSprite()) setOwnSprite(null); ballIdToBallSpriteMap.delete(id); } function handlePositionsUpdated(positions: BallsPositions): void { positions.forEach(([objectId, x, y]) => { const sprite = ballIdToBallSpriteMap.get(objectId); if (sprite) { const expectedPosition = Vector(x, y); expectedPosition.distance(sprite.position) != 0 ? setSpriteVelocity(expectedPosition, sprite) : stopSprite(sprite); } }); } function handleScoreboardUpdated(overallScoreboard: Scoreboard, tableScoreboard: Scoreboard): void { let zeroPaddingLengthForScore = 0; if (overallScoreboard[0]) { const [, score] = overallScoreboard[0]; zeroPaddingLengthForScore = score.toString().length; } const maxNickLength = overallScoreboard.reduce((maxLength, [nick]) => Math.max(maxLength, nick.length), 0); scoreboardTextArea.value = "TABLE SCOREBOARD\n\n"; function writeScore([nick, score, tableId]: [nick: string, score: number, tableId: number]) { const formattedScore = String(score).padStart(zeroPaddingLengthForScore, "0"); const formattedNick = nick.padEnd(maxNickLength, " "); scoreboardTextArea.value += `${formattedScore} | ${formattedNick} | Table ${tableId}\n`; } tableScoreboard.forEach(writeScore); scoreboardTextArea.value += "\n\nOVERALL SCOREBOARD\n\n"; overallScoreboard.forEach(writeScore); } function handleScoredEvent(value: number, x: number, y: number) { playSound(value < 0 ? scoreDecreasedSound : scoreIncreasedSound); const scoreText = scoreTextPool.get({ text: `${value > 0 ? "+" : ""}${value}${value > 0 ? "āœØ" : "šŸ’€"}`, font: "36px monospace", color: value > 0 ? "#F9D82B" : "#3B3B3B", x, y, anchor: { x: 0.5, y: 0.5 }, textAlign: "center", dy: -1, dx: 1, update: () => { scoreText.advance(); scoreText.opacity -= 0.01; if (scoreText.opacity < 0) scoreText.ttl = 0; if (scoreText.x > x + 2 || scoreText.x < x - 2) scoreText.dx *= -1; }, }) as Text; } function handlePointerPressed(isPointerPressed: boolean) { canvas.style.cursor = isPointerPressed ? "grabbing" : "grab"; } function printWelcomeMessage() { return handleChatMessageReceived( `šŸ‘‹ Welcome to ${gameName}!\n\nā„¹ļø New to this game? Enter /help in the message field below to learn about it.\n` ); } function updateDocumentTitleWithGameName() { document.title = gameName; } function printHelpText() { handleChatMessageReceived( `ā„¹ļø ${gameName} puts you in control of a yoyo on a multiplayer pool table!\n\n` + `The goal is to keep the highest score as long as possible.\n\n` + `Click or touch the table to pull your yoyo.\n\n` + `Each ball has a value, and you should use yoyo maneuvers to bring them into the corner pockets.\n\n` + `If you push another yoyo into a corner pocket, you get part of their score, implying that you also lose part of your score if you end up in a corner pocket.\n\n` + `When the table is clean, balls are brought back to the table. Tip: Focus on pocketing the balls with high value first.\n\n` + `There are several tables in the room, and you can communicate with players from other tables through this chat.\n\n` + `You can also run the following commands here:\n\n` + `Command: /nick \n` + `Effect: Changes your nickname.\n\n` + `Command: /newtable\n` + `Effect: Starts a new game on an empty table.\n\n` + `Command: /jointable \n` + `Effect: Joins the game from a specific table.\n\n` + `Command: /soundon\n` + `Effect: Enables sounds.\n\n` + `Command: /soundoff\n` + `Effect: Disables sounds.\n` ); } subscribeToMainLoopUpdate(updateScene); subscribeToMainLoopDraw(renderScene); subscribeToGamePreparationCompleted(startMainLoop); subscribeToPageWithImagesLoaded(prepareGame); subscribeToPointerPressed(handlePointerPressed); onPointer("down", handlePointerDown); onPointer("up", handlePointerUp); window.addEventListener("load", publishPageWithImagesLoaded); window.addEventListener("resize", handleWindowResized); window.addEventListener("click", enableSounds, { once: true }); chatButton.addEventListener("click", sendChatMessage); chatInputField.addEventListener("keyup", handleKeyPressedOnChatInputField); socket.on(ServerToClientEventName.Message, handleChatMessageReceived); socket.on(ServerToClientEventName.Objects, handleBallsPositionsReceived); socket.on(ServerToClientEventName.Positions, handlePositionsUpdated); socket.on(ServerToClientEventName.Creation, createSpriteForBall); socket.on(ServerToClientEventName.Deletion, handleObjectDeleted); socket.on(ServerToClientEventName.Scored, handleScoredEvent); socket.on(ServerToClientEventName.Scoreboard, handleScoreboardUpdated);