Spaces:
Sleeping
Sleeping
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<number, Sprite>(); | |
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<ServerToClientEvents, ClientToServerEvents>; | |
const [publishMainLoopUpdate, subscribeToMainLoopUpdate] = createPubSub<number>(); | |
const [publishMainLoopDraw, subscribeToMainLoopDraw] = createPubSub<number>(); | |
const [publishPageWithImagesLoaded, subscribeToPageWithImagesLoaded] = createPubSub<Event>(); | |
const [publishGamePreparationComplete, subscribeToGamePreparationCompleted] = createPubSub(); | |
const [publishPointerPressed, subscribeToPointerPressed, isPointerPressed] = createPubSub(false); | |
const [setOwnSprite, , getOwnSprite] = createPubSub<Sprite | null>(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 <nickname>\n` + | |
`Effect: Changes your nickname.\n\n` + | |
`Command: /newtable\n` + | |
`Effect: Starts a new game on an empty table.\n\n` + | |
`Command: /jointable <number>\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); | |