"use strict"; const boardRatio = 1.5; const ballRadius = 0.01, padWidth = 0.01, padHeight = 0.15, padX = 0.01; const padBaseSpeed = 0.8; let gameId; let cvs, ctx, cvsW, cvsH, bdW, bdH, bdltX, bdltY; let socket; let playing = false, flip = false; let ballX, ballY, ballVX, ballVY; let padPos, padVel, pad2Pos, pad2Vel; function redraw() { ctx.fillStyle = "#000000"; ctx.fillRect(0, 0, cvsW, cvsH); redrawFast(); } function calcFlippedX(x) {return bdltX + bdW * (flip ? 1 - x : x);} function calcY(y) {return bdltY + bdH * y;} function calcLeftPadY() {return calcY((flip ? pad2Pos : padPos) - padHeight / 2);} function calcRightPadY() {return calcY((flip ? padPos : pad2Pos) - padHeight / 2);} function redrawFast() { ctx.strokeStyle = "#ffffff"; ctx.lineWidth = 2; ctx.strokeRect(bdltX - 1, bdltY - 1, bdW + 2, bdH + 2); ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(bdltX + bdW / 2 | 0, bdltY); ctx.lineTo(bdltX + bdW / 2 | 0, bdltY + bdH); ctx.stroke(); if (playing) { ctx.fillStyle = "#ffffff"; ctx.beginPath(); ctx.arc(calcFlippedX(ballX), calcY(ballY), ballRadius * bdW, 0, 2 * Math.PI); ctx.rect(bdltX + bdW * padX, calcLeftPadY(), bdW * padWidth, bdH * padHeight); ctx.rect(bdltX + bdW * (1 - padX - padWidth), calcRightPadY(), bdW * padWidth, bdH * padHeight); ctx.fill(); } } function undrawElements() { ctx.fillStyle = "#000000"; ctx.beginPath(); ctx.rect( calcFlippedX(ballX) - ballRadius * bdW - 2, calcY(ballY) - ballRadius * bdW - 2, 2 * ballRadius * bdW + 4, 2 * ballRadius * bdW + 4); ctx.rect( bdltX + bdW * padX - 2, calcLeftPadY() - 2, bdW * padWidth + 4, bdH * padHeight + 4); ctx.rect( bdltX + bdW * (1 - padX - padWidth) - 2, calcRightPadY() - 2, bdW * padWidth + 4, bdH * padHeight + 4); ctx.fill(); } function resizeHandler() { const rect = cvs.getBoundingClientRect(); cvsW = cvs.width = rect.width; cvsH = cvs.height = rect.height; // I believe this stuff is correct. if (cvsH * boardRatio > cvsW) { bdW = cvsW; bdH = bdW / boardRatio | 0; bdltX = 2; bdltY = (cvsH - bdH) / 2 + 2 | 0; bdW -= 4; bdH -= 4; } else { bdH = cvsH; bdW = boardRatio * bdH | 0; bdltX = (cvsW - bdW) / 2 + 2 | 0; bdltY = 2; bdW -= 4; bdH -= 4; } redraw(); } function displayStatus(msg) { document.getElementById("status").innerHTML = msg; } function displayScore(score) { document.getElementById("score").classList.remove("invisible"); document.getElementById("score-left").innerHTML = score[0]; document.getElementById("score-right").innerHTML = score[1]; setTimeout(function() { document.getElementById("score").classList.add("invisible"); }, 2000); } function openGame() { socket = io(); socket.on("redirect", function(url) { location.href = url; }); socket.on("status", function(msg) { displayStatus(msg); }); socket.on("score", function(score) { displayScore(score); }); socket.on("join", function() { playSound("join"); }); socket.on("leave", function() { playSound("leave"); }); socket.on("othermiss", function() { playSound("score"); }); socket.on("bounce", function() { playSound("bounce"); }); socket.on("serve", function(side, x, y, initServe) { playing = true; flip = side == "right"; ballX = x; ballY = y; padPos = pad2Pos = 0.5; redraw(); if (!initServe) playSound("ready"); }); socket.on("stop", function() { playing = false; stopPhysicsLoop(); stopGraphicsLoop(); redraw(); }); socket.on("startBall", function() { initPhysics(); ballVX = -0.5 / boardRatio; ballVY = -0.5; startPhysicsLoop(); startGraphicsLoop(); playSound("start"); }); socket.on("start", function() { initPhysics(); ballVX = 0.5 / boardRatio; ballVY = -0.5; startPhysicsLoop(); startGraphicsLoop(); playSound("start"); }); socket.on("padvec", function(pos, vel) { pad2Pos = pos; pad2Vel = vel; }); socket.on("ballvec", function(x, y, vx, vy) { ballX = x; ballY = y; ballVX = vx; ballVY = vy; }); socket.emit("open", gameId); } let graphicsLoopId = null, loopPhysics = false; function startGraphicsLoop() { // undrawElements(); redraw(); // TODO: redrawFast graphicsLoopId = requestAnimationFrame(startGraphicsLoop); } function stopGraphicsLoop() { if (graphicsLoopId == null) return; cancelAnimationFrame(graphicsLoopId); graphicsLoopId = null; } function startPhysicsLoop() { let prev = performance.now(); loopPhysics = true; function tick() { if (!loopPhysics) { return; } const now = performance.now(); const info = advancePhysics((now - prev) / 1e3); prev = now; if (info.bounce || info.otherBounce) { playSound("bounce"); } if (info.bounce || info.ourSide) { socket.emit("ballvec", ballX, ballY, ballVX, ballVY); } if (info.bounce) { socket.emit("bounce"); } if (ballX < -ballRadius) { socket.emit("ballout"); stopPhysicsLoop(); playSound("miss"); } requestAnimationFrame(tick); }; requestAnimationFrame(tick); } function stopPhysicsLoop() { loopPhysics = false; } function initPhysics() { ballX = ballY = 0.5; ballVX = ballVY = 0; padPos = pad2Pos = 0.5; padVel = pad2Vel = 0; } // -1 <= frac <= 1 function padHitAdjustCurve(frac) { // ✨ const a = 0.8, b = 0.12; const alpha = b / a**3; const beta = (1 - alpha) / (1 - a)**2; const x = Math.abs(frac); return Math.sign(frac) * (alpha * x**3 + (x > a) * beta * (x - a)**2); } // Returns {bounce, otherBounce, ourSide : Bool} function advancePhysics(deltaT) { const ret = {bounce: false, otherBounce: false, ourSide: false}; let newballX = ballX + ballVX * deltaT; let newballY = ballY + ballVY * deltaT; const newpadPos = padPos + padVel * deltaT; const newpad2Pos = pad2Pos + pad2Vel * deltaT; if (newballY <= ballRadius) { const t = (ballRadius - ballY) / ballVY; newballY = ballY + ballVY * t + (-ballVY) * (deltaT - t); ballVY = -ballVY; } else if (newballY >= 1 - ballRadius) { const t = (1 - ballRadius - ballY) / ballVY; newballY = ballY + ballVY * t + (-ballVY) * (deltaT - t); ballVY = -ballVY; } function bounceLeft(x, y, vx, vy, pady) { if (x + vx * deltaT > padX + padWidth + ballRadius) return null; const t = (padX + padWidth + ballRadius - x) / vx; if (Math.abs((y + t * vy) - pady) >= padHeight / 2 + ballRadius) return null; const frac = ((y + t * vy) - pady) / (padHeight / 2 + ballRadius); const angAdj = Math.PI / 4 * padHitAdjustCurve(frac); const curAng = Math.atan2(vy, -vx); // normal outgoing angle const newAng = Math.max(-0.45 * Math.PI, Math.min(0.45 * Math.PI, curAng + angAdj)); const speed = Math.hypot(vx, vy); const newvx = speed * Math.cos(newAng); const newvy = speed * Math.sin(newAng); const newx = x + vx * t + newvx * (deltaT - t); const newy = y + vy * t + newvy * (deltaT - t); return [newx, newy, newvx, newvy]; } let bounce = bounceLeft(ballX, ballY, ballVX, ballVY, padPos); if (bounce != null) { newballX = bounce[0]; newballY = bounce[1]; ballVX = bounce[2]; ballVY = bounce[3]; ret.bounce = true; } bounce = bounceLeft(1 - ballX, ballY, -ballVX, ballVY, pad2Pos); if (bounce != null) { newballX = 1 - bounce[0]; newballY = bounce[1]; ballVX = -bounce[2]; ballVY = bounce[3]; ret.otherBounce = true; } // If the ball has come to our side, send a ballvec if (ballX >= 0.5 && newballX < 0.5) { ret.ourSide = true; } ballX = newballX; ballY = newballY; padPos = newpadPos; pad2Pos = newpad2Pos; return ret; } const soundMap = {}; function preloadSounds() { const names = ["bounce", "leave", "ready", "start", "join", "miss", "score"]; const types = [["audio/ogg", "ogg"], ["audio/mpeg", "mp3"], ["audio/wav", "wav"]]; for (let i = 0; i < names.length; i++) { const audio = new Audio(); for (let j = 0; j < types.length; j++) { const source = document.createElement("source"); source.type = types[j][0]; source.src = "/snd/" + names[i] + "." + types[j][1]; audio.appendChild(source); } soundMap[names[i]] = audio; audio.preload = "auto"; } } const lastSoundStampMap = {}; function playSound(name) { const now = performance.now(); console.log("playSound(\"" + name + "\")"); if (lastSoundStampMap[name] == null || now - lastSoundStampMap[name] >= 500) { soundMap[name].play(); } lastSoundStampMap[name] = now; } function setupBindings() { let up = false, down = false; function padUpdate() { padVel = (down - up) * padBaseSpeed; socket.emit("padvec", padPos, padVel); } window.addEventListener("keydown", function(ev) { if (ev.key == "w" || ev.key == "ArrowUp") up = true; else if (ev.key == "s" || ev.key == "ArrowDown") down = true; padUpdate(); }); window.addEventListener("keyup", function(ev) { if (ev.key == "w" || ev.key == "ArrowUp") up = false; else if (ev.key == "s" || ev.key == "ArrowDown") down = false; padUpdate(); }); window.addEventListener("blur", function() { up = down = false; padUpdate(); }); } window.addEventListener("load", function() { gameId = document.location.href.replace(/.*\//, ""); cvs = document.getElementById("cvs"); ctx = cvs.getContext("2d"); resizeHandler(); setupBindings(); preloadSounds(); openGame(); }); let resizeDebounceTimeout = null; window.addEventListener("resize", function() { if (resizeDebounceTimeout != null) return; resizeDebounceTimeout = setTimeout(function() { resizeDebounceTimeout = null; resizeHandler(); }, 50); });