|
|
@@ -0,0 +1,596 @@
|
|
|
+---
|
|
|
+interface Props {
|
|
|
+ gameId: string;
|
|
|
+ isHost: boolean;
|
|
|
+ player1: string;
|
|
|
+ player2: string;
|
|
|
+ currentUser: string;
|
|
|
+}
|
|
|
+const { gameId, isHost, player1, player2, currentUser } = Astro.props;
|
|
|
+const svgId = `vg-${Math.random().toString(36).slice(2, 8)}`;
|
|
|
+---
|
|
|
+
|
|
|
+<div class="virtual-game" id={`${svgId}-wrap`}>
|
|
|
+ <!-- HUD -->
|
|
|
+ <div class="vg-hud">
|
|
|
+ <div class="vg-player vg-p1">
|
|
|
+ <span class="vg-pname">{player1}</span>
|
|
|
+ <span class="vg-pscore" id={`${svgId}-s1`}>0</span>
|
|
|
+ </div>
|
|
|
+ <div class="vg-center-info">
|
|
|
+ <span class="vg-status" id={`${svgId}-status`}>Connecting...</span>
|
|
|
+ <span class="vg-ft6">First to 6</span>
|
|
|
+ </div>
|
|
|
+ <div class="vg-player vg-p2">
|
|
|
+ <span class="vg-pscore" id={`${svgId}-s2`}>0</span>
|
|
|
+ <span class="vg-pname">{player2}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Flash overlay for goals -->
|
|
|
+ <div class="vg-flash" id={`${svgId}-flash`}></div>
|
|
|
+
|
|
|
+ <!-- Board SVG -->
|
|
|
+ <svg
|
|
|
+ id={svgId}
|
|
|
+ class="vg-board"
|
|
|
+ viewBox="0 0 400 260"
|
|
|
+ xmlns="http://www.w3.org/2000/svg"
|
|
|
+ >
|
|
|
+ <defs>
|
|
|
+ <linearGradient id={`vgsheen-${svgId}`} x1="0" y1="0" x2="0" y2="1">
|
|
|
+ <stop offset="0%" stop-color="#fff"/>
|
|
|
+ <stop offset="100%" stop-color="#fff" stop-opacity="0"/>
|
|
|
+ </linearGradient>
|
|
|
+ <radialGradient id={`vgball-${svgId}`} cx="35%" cy="30%" r="65%">
|
|
|
+ <stop offset="0%" stop-color="#fff" stop-opacity="0.9"/>
|
|
|
+ <stop offset="40%" stop-color="#ffee55"/>
|
|
|
+ <stop offset="100%" stop-color="#e6b800"/>
|
|
|
+ </radialGradient>
|
|
|
+ </defs>
|
|
|
+
|
|
|
+ <rect width="400" height="260" rx="12" fill="#1a1a2e"/>
|
|
|
+ <rect x="1" y="1" width="398" height="258" rx="12" fill="none" stroke="#2a2a4a" stroke-width="1.5"/>
|
|
|
+ <rect x="12" y="12" width="376" height="236" rx="8" fill="#1565c0"/>
|
|
|
+ <rect x="12" y="12" width="376" height="80" rx="8" fill={`url(#vgsheen-${svgId})`} opacity="0.12"/>
|
|
|
+ <line x1="200" y1="12" x2="200" y2="248" stroke="#fff" stroke-width="1.5" opacity="0.25"/>
|
|
|
+ <path d="M12,12 A28,28 0 0,1 40,12 A28,28 0 0,1 12,40 Z" fill="#fff" opacity="0.85"/>
|
|
|
+ <path d="M388,12 A28,28 0 0,0 360,12 A28,28 0 0,0 388,40 Z" fill="#fff" opacity="0.85"/>
|
|
|
+ <path d="M12,248 A28,28 0 0,0 40,248 A28,28 0 0,0 12,220 Z" fill="#fff" opacity="0.85"/>
|
|
|
+ <path d="M388,248 A28,28 0 0,1 360,248 A28,28 0 0,1 388,220 Z" fill="#fff" opacity="0.85"/>
|
|
|
+
|
|
|
+ <!-- Goals -->
|
|
|
+ <circle cx="36" cy="130" r="22" fill="#0d1b3e"/>
|
|
|
+ <circle cx="36" cy="130" r="18" fill="#0a1530"/>
|
|
|
+ <circle cx="36" cy="130" r="14" fill="#060d1e"/>
|
|
|
+ <circle cx="36" cy="130" r="22" fill="none" stroke="#4a6fa5" stroke-width="2"/>
|
|
|
+ <circle cx="364" cy="130" r="22" fill="#0d1b3e"/>
|
|
|
+ <circle cx="364" cy="130" r="18" fill="#0a1530"/>
|
|
|
+ <circle cx="364" cy="130" r="14" fill="#060d1e"/>
|
|
|
+ <circle cx="364" cy="130" r="22" fill="none" stroke="#4a6fa5" stroke-width="2"/>
|
|
|
+
|
|
|
+ <!-- Biscuits -->
|
|
|
+ <g id={`${svgId}-b0`}><circle r="6" fill="#e8e6e0"/><circle r="4.5" fill="#d0cdc6"/><circle r="2.5" fill="#b8b5ae"/></g>
|
|
|
+ <g id={`${svgId}-b1`}><circle r="6" fill="#e8e6e0"/><circle r="4.5" fill="#d0cdc6"/><circle r="2.5" fill="#b8b5ae"/></g>
|
|
|
+ <g id={`${svgId}-b2`}><circle r="6" fill="#e8e6e0"/><circle r="4.5" fill="#d0cdc6"/><circle r="2.5" fill="#b8b5ae"/></g>
|
|
|
+
|
|
|
+ <!-- Ball -->
|
|
|
+ <g id={`${svgId}-ball`}><circle r="8" fill={`url(#vgball-${svgId})`}/><circle r="8" fill="none" stroke="#c8960a" stroke-width="0.8" opacity="0.6"/></g>
|
|
|
+
|
|
|
+ <!-- Pucks -->
|
|
|
+ <g id={`${svgId}-p1`}><circle r="11" fill="#111"/><circle r="9" fill="#1c1c1c"/><circle r="6" fill="#262626"/><circle r="11" fill="none" stroke="#383838" stroke-width="1.2"/></g>
|
|
|
+ <g id={`${svgId}-p2`}><circle r="11" fill="#111"/><circle r="9" fill="#1c1c1c"/><circle r="6" fill="#262626"/><circle r="11" fill="none" stroke="#383838" stroke-width="1.2"/></g>
|
|
|
+
|
|
|
+ <rect x="12" y="12" width="376" height="236" rx="8" fill="none" stroke="#1e88e5" stroke-width="1.5" opacity="0.6"/>
|
|
|
+
|
|
|
+ <!-- Touch area (invisible, captures touch/mouse) -->
|
|
|
+ <rect x="12" y="12" width="376" height="236" rx="8" fill="transparent" id={`${svgId}-touch`} style="cursor:crosshair"/>
|
|
|
+ </svg>
|
|
|
+
|
|
|
+ <p class="vg-controls-hint" id={`${svgId}-hint`}>Arrow keys or drag to move your puck</p>
|
|
|
+</div>
|
|
|
+
|
|
|
+<script src="https://unpkg.com/peerjs@1.5.4/dist/peerjs.min.js" is:inline></script>
|
|
|
+
|
|
|
+<script define:vars={{ svgId, gameId, isHost, player1, player2, currentUser }}>
|
|
|
+(function() {
|
|
|
+ const statusEl = document.getElementById(`${svgId}-status`);
|
|
|
+ const s1El = document.getElementById(`${svgId}-s1`);
|
|
|
+ const s2El = document.getElementById(`${svgId}-s2`);
|
|
|
+ const flashEl = document.getElementById(`${svgId}-flash`);
|
|
|
+ const ballEl = document.getElementById(`${svgId}-ball`);
|
|
|
+ const p1El = document.getElementById(`${svgId}-p1`);
|
|
|
+ const p2El = document.getElementById(`${svgId}-p2`);
|
|
|
+ const bEls = [0,1,2].map(i => document.getElementById(`${svgId}-b${i}`));
|
|
|
+ const touchArea = document.getElementById(`${svgId}-touch`);
|
|
|
+ const svg = document.getElementById(svgId);
|
|
|
+
|
|
|
+ // Physics constants
|
|
|
+ const FX1 = 24, FX2 = 376, FY1 = 24, FY2 = 236, CX = 200;
|
|
|
+ const GOAL_L = { x: 36, y: 130, r: 22 };
|
|
|
+ const GOAL_R = { x: 364, y: 130, r: 22 };
|
|
|
+ const BALL_R = 8, BISC_R = 6, PUCK_R = 11;
|
|
|
+ const ATTACH_DIST = PUCK_R + BISC_R + 2;
|
|
|
+ const STUCK_ORBIT = PUCK_R + BISC_R - 2;
|
|
|
+ const WIN_SCORE = 6;
|
|
|
+
|
|
|
+ // Serve positions (loser's corners)
|
|
|
+ const SERVE_P1 = [{ x: 55, y: 45 }, { x: 55, y: 215 }]; // P1's corners
|
|
|
+ const SERVE_P2 = [{ x: 345, y: 45 }, { x: 345, y: 215 }]; // P2's corners
|
|
|
+
|
|
|
+ // Game state (host is authoritative)
|
|
|
+ let state = {
|
|
|
+ ball: { x: 200, y: 130, vx: 0, vy: 0 },
|
|
|
+ biscs: [
|
|
|
+ { x: 200, y: 88, vx: 0, vy: 0, stuck: null, angle: 0 },
|
|
|
+ { x: 200, y: 130, vx: 0, vy: 0, stuck: null, angle: 0 },
|
|
|
+ { x: 200, y: 172, vx: 0, vy: 0, stuck: null, angle: 0 },
|
|
|
+ ],
|
|
|
+ p1: { x: 95, y: 130, vx: 0, vy: 0, attached: 0 },
|
|
|
+ p2: { x: 305, y: 130, vx: 0, vy: 0, attached: 0 },
|
|
|
+ score1: 0,
|
|
|
+ score2: 0,
|
|
|
+ paused: true,
|
|
|
+ serving: false,
|
|
|
+ gameOver: false,
|
|
|
+ };
|
|
|
+
|
|
|
+ let myInput = { tx: isHost ? 95 : 305, ty: 130 }; // target position from input
|
|
|
+ let remoteInput = { tx: isHost ? 305 : 95, ty: 130 };
|
|
|
+ let conn = null;
|
|
|
+ let peer = null;
|
|
|
+ let connected = false;
|
|
|
+
|
|
|
+ // --- Physics helpers ---
|
|
|
+ function dist(ax, ay, bx, by) { const dx = ax - bx, dy = ay - by; return Math.sqrt(dx*dx + dy*dy); }
|
|
|
+ function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
|
|
|
+ function spd(o) { return Math.sqrt(o.vx*o.vx + o.vy*o.vy); }
|
|
|
+ function cap(o, m) { const s = spd(o); if (s > m) { o.vx = (o.vx/s)*m; o.vy = (o.vy/s)*m; } }
|
|
|
+
|
|
|
+ function collide(a, ar, b, br, bMass) {
|
|
|
+ const d = dist(a.x, a.y, b.x, b.y);
|
|
|
+ const minD = ar + br;
|
|
|
+ if (d >= minD || d < 0.01) return;
|
|
|
+ const nx = (a.x - b.x) / d, ny = (a.y - b.y) / d;
|
|
|
+ const dvx = a.vx - b.vx, dvy = a.vy - b.vy;
|
|
|
+ const dot = dvx * nx + dvy * ny;
|
|
|
+ if (dot > 0) return;
|
|
|
+ const tm = 1 + bMass;
|
|
|
+ a.vx -= (2 * bMass / tm) * dot * nx; a.vy -= (2 * bMass / tm) * dot * ny;
|
|
|
+ b.vx += (2 / tm) * dot * nx; b.vy += (2 / tm) * dot * ny;
|
|
|
+ const overlap = minD - d + 0.5;
|
|
|
+ a.x += nx * overlap * (bMass/tm); a.y += ny * overlap * (bMass/tm);
|
|
|
+ b.x -= nx * overlap * (1/tm); b.y -= ny * overlap * (1/tm);
|
|
|
+ }
|
|
|
+
|
|
|
+ function wallBounce(o, r) {
|
|
|
+ if (o.x - r < FX1) { o.x = FX1 + r; o.vx = Math.abs(o.vx) * 0.85; }
|
|
|
+ if (o.x + r > FX2) { o.x = FX2 - r; o.vx = -Math.abs(o.vx) * 0.85; }
|
|
|
+ if (o.y - r < FY1) { o.y = FY1 + r; o.vy = Math.abs(o.vy) * 0.85; }
|
|
|
+ if (o.y + r > FY2) { o.y = FY2 - r; o.vy = -Math.abs(o.vy) * 0.85; }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Drive a puck toward a target
|
|
|
+ function drivePuck(p, tx, ty, side) {
|
|
|
+ const margin = PUCK_R + 4;
|
|
|
+ // Clamp target to half
|
|
|
+ if (side === 1) tx = clamp(tx, FX1 + margin, CX - margin);
|
|
|
+ else tx = clamp(tx, CX + margin, FX2 - margin);
|
|
|
+ ty = clamp(ty, FY1 + margin, FY2 - margin);
|
|
|
+ const dx = tx - p.x, dy = ty - p.y;
|
|
|
+ // Stiff spring: move most of the way to target each frame, tiny residual slide
|
|
|
+ p.vx = dx * 0.35;
|
|
|
+ p.vy = dy * 0.35;
|
|
|
+ cap(p, 6);
|
|
|
+ p.x += p.vx; p.y += p.vy;
|
|
|
+ if (side === 1) p.x = clamp(p.x, FX1 + margin, CX - margin);
|
|
|
+ else p.x = clamp(p.x, CX + margin, FX2 - margin);
|
|
|
+ p.y = clamp(p.y, FY1 + margin, FY2 - margin);
|
|
|
+ }
|
|
|
+
|
|
|
+ function serve(loserSide) {
|
|
|
+ // Ball starts at a random corner on the loser's side, stationary
|
|
|
+ const corners = loserSide === 1 ? SERVE_P1 : SERVE_P2;
|
|
|
+ const c = corners[Math.floor(Math.random() * 2)];
|
|
|
+ state.ball.x = c.x; state.ball.y = c.y;
|
|
|
+ state.ball.vx = 0; state.ball.vy = 0;
|
|
|
+ // Reset biscuits
|
|
|
+ state.biscs[0] = { x: 200, y: 88, vx: 0, vy: 0, stuck: null, angle: 0 };
|
|
|
+ state.biscs[1] = { x: 200, y: 130, vx: 0, vy: 0, stuck: null, angle: 0 };
|
|
|
+ state.biscs[2] = { x: 200, y: 172, vx: 0, vy: 0, stuck: null, angle: 0 };
|
|
|
+ state.p1.attached = 0; state.p2.attached = 0;
|
|
|
+ // Reset puck positions
|
|
|
+ state.p1.x = 95; state.p1.y = 130; state.p1.vx = 0; state.p1.vy = 0;
|
|
|
+ state.p2.x = 305; state.p2.y = 130; state.p2.vx = 0; state.p2.vy = 0;
|
|
|
+ // Pause briefly then launch ball
|
|
|
+ state.serving = true;
|
|
|
+ setTimeout(() => {
|
|
|
+ // Launch ball toward center with slight randomness
|
|
|
+ const angle = (loserSide === 1 ? 0 : Math.PI) + (Math.random() - 0.5) * 0.8;
|
|
|
+ state.ball.vx = Math.cos(angle) * 3;
|
|
|
+ state.ball.vy = Math.sin(angle) * 3;
|
|
|
+ state.serving = false;
|
|
|
+ }, 1000);
|
|
|
+ }
|
|
|
+
|
|
|
+ function scoreGoal(scorerSide) {
|
|
|
+ if (state.gameOver) return;
|
|
|
+ if (scorerSide === 1) state.score1++; else state.score2++;
|
|
|
+ // Flash
|
|
|
+ flashEl.style.opacity = "1";
|
|
|
+ setTimeout(() => { flashEl.style.opacity = "0"; }, 300);
|
|
|
+ // Check win
|
|
|
+ if (state.score1 >= WIN_SCORE || state.score2 >= WIN_SCORE) {
|
|
|
+ state.gameOver = true;
|
|
|
+ state.paused = true;
|
|
|
+ const winner = state.score1 >= WIN_SCORE ? player1 : player2;
|
|
|
+ statusEl.textContent = `${winner} wins!`;
|
|
|
+ // Submit result to server
|
|
|
+ fetch(`/api/game/${gameId}/complete`, {
|
|
|
+ method: "POST",
|
|
|
+ headers: { "Content-Type": "application/json" },
|
|
|
+ body: JSON.stringify({ winner, score1: state.score1, score2: state.score2 }),
|
|
|
+ }).then(() => {
|
|
|
+ setTimeout(() => { location.reload(); }, 2000);
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ // Loser serves from their corner
|
|
|
+ const loserSide = scorerSide === 1 ? 2 : 1; // scorer is NOT the loser
|
|
|
+ serve(loserSide);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Stuck detection
|
|
|
+ let stuckX = 200, stuckY = 130, stuckTimer = 0;
|
|
|
+
|
|
|
+ // --- Host physics tick ---
|
|
|
+ function physicsTick(dt) {
|
|
|
+ if (state.paused || state.serving || state.gameOver) return;
|
|
|
+
|
|
|
+ const { ball, biscs, p1, p2 } = state;
|
|
|
+
|
|
|
+ // Drive pucks from inputs
|
|
|
+ const hostInput = isHost ? myInput : remoteInput;
|
|
|
+ const guestInput = isHost ? remoteInput : myInput;
|
|
|
+ drivePuck(p1, hostInput.tx, hostInput.ty, 1);
|
|
|
+ drivePuck(p2, guestInput.tx, guestInput.ty, 2);
|
|
|
+
|
|
|
+ // Ball movement
|
|
|
+ ball.x += ball.vx; ball.y += ball.vy;
|
|
|
+ ball.vx *= 0.999; ball.vy *= 0.999;
|
|
|
+
|
|
|
+ // Stuck detection
|
|
|
+ if (dist(ball.x, ball.y, stuckX, stuckY) < 15) {
|
|
|
+ stuckTimer += dt;
|
|
|
+ if (stuckTimer > 3000) {
|
|
|
+ // Nudge ball randomly instead of full reset
|
|
|
+ ball.vx += (Math.random() - 0.5) * 4;
|
|
|
+ ball.vy += (Math.random() - 0.5) * 4;
|
|
|
+ stuckTimer = 0;
|
|
|
+ }
|
|
|
+ } else { stuckX = ball.x; stuckY = ball.y; stuckTimer = 0; }
|
|
|
+
|
|
|
+ // Min speed
|
|
|
+ if (spd(ball) < 1.2) { const s = spd(ball)||1; ball.vx=(ball.vx/s)*1.2; ball.vy=(ball.vy/s)*1.2; }
|
|
|
+
|
|
|
+ // Biscuit logic
|
|
|
+ for (const b of biscs) {
|
|
|
+ if (b.stuck !== null) {
|
|
|
+ const puck = b.stuck === 1 ? p1 : p2;
|
|
|
+ b.angle += 0.02;
|
|
|
+ b.x = puck.x + Math.cos(b.angle) * STUCK_ORBIT;
|
|
|
+ b.y = puck.y + Math.sin(b.angle) * STUCK_ORBIT;
|
|
|
+ b.vx = 0; b.vy = 0;
|
|
|
+ } else {
|
|
|
+ b.x += b.vx; b.y += b.vy;
|
|
|
+ b.vx *= 0.97; b.vy *= 0.97;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Collisions
|
|
|
+ wallBounce(ball, BALL_R);
|
|
|
+ for (const b of biscs) { if (b.stuck === null) wallBounce(b, BISC_R); }
|
|
|
+ collide(ball, BALL_R, p1, PUCK_R, 5);
|
|
|
+ collide(ball, BALL_R, p2, PUCK_R, 5);
|
|
|
+ for (const b of biscs) { if (b.stuck === null) collide(ball, BALL_R, b, BISC_R, 1); }
|
|
|
+ for (let i = 0; i < biscs.length; i++) {
|
|
|
+ for (let j = i+1; j < biscs.length; j++) {
|
|
|
+ if (biscs[i].stuck === null && biscs[j].stuck === null) collide(biscs[i], BISC_R, biscs[j], BISC_R, 1);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ for (const b of biscs) {
|
|
|
+ if (b.stuck !== null) collide(ball, BALL_R, b, BISC_R, 5);
|
|
|
+ else { collide(b, BISC_R, p1, PUCK_R, 5); collide(b, BISC_R, p2, PUCK_R, 5); }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Attachment
|
|
|
+ for (const b of biscs) {
|
|
|
+ if (b.stuck !== null) continue;
|
|
|
+ if (dist(b.x, b.y, p1.x, p1.y) < ATTACH_DIST) { b.stuck = 1; b.angle = Math.atan2(b.y-p1.y, b.x-p1.x); p1.attached++; }
|
|
|
+ else if (dist(b.x, b.y, p2.x, p2.y) < ATTACH_DIST) { b.stuck = 2; b.angle = Math.atan2(b.y-p2.y, b.x-p2.x); p2.attached++; }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2 biscuits = point for opponent
|
|
|
+ if (p1.attached >= 2) { scoreGoal(2); } // P2 scores
|
|
|
+ else if (p2.attached >= 2) { scoreGoal(1); } // P1 scores
|
|
|
+
|
|
|
+ // Goal detection
|
|
|
+ if (dist(ball.x, ball.y, GOAL_L.x, GOAL_L.y) < GOAL_L.r - 4) { scoreGoal(2); } // P2 scores (ball in P1's goal)
|
|
|
+ else if (dist(ball.x, ball.y, GOAL_R.x, GOAL_R.y) < GOAL_R.r - 4) { scoreGoal(1); } // P1 scores (ball in P2's goal)
|
|
|
+
|
|
|
+ cap(ball, 7);
|
|
|
+ for (const b of biscs) { if (b.stuck === null) cap(b, 5); }
|
|
|
+ }
|
|
|
+
|
|
|
+ // --- Render ---
|
|
|
+ function render() {
|
|
|
+ const { ball, biscs, p1, p2 } = state;
|
|
|
+ ballEl.setAttribute("transform", `translate(${ball.x.toFixed(1)},${ball.y.toFixed(1)})`);
|
|
|
+ p1El.setAttribute("transform", `translate(${p1.x.toFixed(1)},${p1.y.toFixed(1)})`);
|
|
|
+ p2El.setAttribute("transform", `translate(${p2.x.toFixed(1)},${p2.y.toFixed(1)})`);
|
|
|
+ bEls.forEach((el, i) => el.setAttribute("transform", `translate(${biscs[i].x.toFixed(1)},${biscs[i].y.toFixed(1)})`));
|
|
|
+ s1El.textContent = state.score1;
|
|
|
+ s2El.textContent = state.score2;
|
|
|
+ }
|
|
|
+
|
|
|
+ // --- Input handling ---
|
|
|
+ const keys = {};
|
|
|
+ const PUCK_SPEED = 4;
|
|
|
+
|
|
|
+ document.addEventListener("keydown", (e) => {
|
|
|
+ if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight"].includes(e.key)) {
|
|
|
+ e.preventDefault();
|
|
|
+ keys[e.key] = true;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ document.addEventListener("keyup", (e) => { keys[e.key] = false; });
|
|
|
+
|
|
|
+ function updateInputFromKeys() {
|
|
|
+ let dx = 0, dy = 0;
|
|
|
+ if (keys["ArrowLeft"]) dx -= PUCK_SPEED;
|
|
|
+ if (keys["ArrowRight"]) dx += PUCK_SPEED;
|
|
|
+ if (keys["ArrowUp"]) dy -= PUCK_SPEED;
|
|
|
+ if (keys["ArrowDown"]) dy += PUCK_SPEED;
|
|
|
+ myInput.tx += dx;
|
|
|
+ myInput.ty += dy;
|
|
|
+ // Clamp to own half
|
|
|
+ const margin = PUCK_R + 4;
|
|
|
+ if (isHost) {
|
|
|
+ myInput.tx = clamp(myInput.tx, FX1 + margin, CX - margin);
|
|
|
+ } else {
|
|
|
+ myInput.tx = clamp(myInput.tx, CX + margin, FX2 - margin);
|
|
|
+ }
|
|
|
+ myInput.ty = clamp(myInput.ty, FY1 + margin, FY2 - margin);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Touch / mouse drag
|
|
|
+ let dragging = false;
|
|
|
+ function svgCoords(e) {
|
|
|
+ const rect = svg.getBoundingClientRect();
|
|
|
+ const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
|
|
+ const clientY = e.touches ? e.touches[0].clientY : e.clientY;
|
|
|
+ const scaleX = 400 / rect.width;
|
|
|
+ const scaleY = 260 / rect.height;
|
|
|
+ return { x: (clientX - rect.left) * scaleX, y: (clientY - rect.top) * scaleY };
|
|
|
+ }
|
|
|
+
|
|
|
+ touchArea.addEventListener("mousedown", (e) => { dragging = true; const c = svgCoords(e); myInput.tx = c.x; myInput.ty = c.y; });
|
|
|
+ touchArea.addEventListener("mousemove", (e) => { if (dragging) { const c = svgCoords(e); myInput.tx = c.x; myInput.ty = c.y; } });
|
|
|
+ document.addEventListener("mouseup", () => { dragging = false; });
|
|
|
+ touchArea.addEventListener("touchstart", (e) => { e.preventDefault(); dragging = true; const c = svgCoords(e); myInput.tx = c.x; myInput.ty = c.y; }, { passive: false });
|
|
|
+ touchArea.addEventListener("touchmove", (e) => { e.preventDefault(); if (dragging) { const c = svgCoords(e); myInput.tx = c.x; myInput.ty = c.y; } }, { passive: false });
|
|
|
+ touchArea.addEventListener("touchend", () => { dragging = false; });
|
|
|
+
|
|
|
+ // --- PeerJS networking ---
|
|
|
+ const peerId = `klask-${gameId}`;
|
|
|
+
|
|
|
+ function setupPeer() {
|
|
|
+ statusEl.textContent = "Connecting...";
|
|
|
+ peer = new Peer(isHost ? peerId : undefined);
|
|
|
+
|
|
|
+ peer.on("open", (id) => {
|
|
|
+ if (isHost) {
|
|
|
+ statusEl.textContent = "Waiting for opponent...";
|
|
|
+ peer.on("connection", (c) => {
|
|
|
+ conn = c;
|
|
|
+ setupConnection();
|
|
|
+ });
|
|
|
+ } else {
|
|
|
+ conn = peer.connect(peerId, { reliable: true });
|
|
|
+ setupConnection();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ peer.on("error", (err) => {
|
|
|
+ console.error("PeerJS error:", err);
|
|
|
+ if (err.type === "peer-unavailable") {
|
|
|
+ statusEl.textContent = "Host not found. Retrying...";
|
|
|
+ setTimeout(setupPeer, 2000);
|
|
|
+ } else {
|
|
|
+ statusEl.textContent = "Connection error";
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function setupConnection() {
|
|
|
+ conn.on("open", () => {
|
|
|
+ connected = true;
|
|
|
+ state.paused = false;
|
|
|
+ statusEl.textContent = "Live";
|
|
|
+ // Start with serve from P1's corner
|
|
|
+ if (isHost) serve(1);
|
|
|
+ });
|
|
|
+
|
|
|
+ conn.on("data", (data) => {
|
|
|
+ if (isHost) {
|
|
|
+ // Guest sends input
|
|
|
+ if (data.type === "input") {
|
|
|
+ remoteInput.tx = data.tx;
|
|
|
+ remoteInput.ty = data.ty;
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // Host sends full state
|
|
|
+ if (data.type === "state") {
|
|
|
+ state.ball = data.ball;
|
|
|
+ state.biscs = data.biscs;
|
|
|
+ state.p1 = data.p1;
|
|
|
+ state.p2 = data.p2;
|
|
|
+ state.score1 = data.score1;
|
|
|
+ state.score2 = data.score2;
|
|
|
+ state.gameOver = data.gameOver;
|
|
|
+ state.paused = data.paused;
|
|
|
+ if (data.gameOver && data.winner) {
|
|
|
+ statusEl.textContent = `${data.winner} wins!`;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ conn.on("close", () => {
|
|
|
+ connected = false;
|
|
|
+ state.paused = true;
|
|
|
+ statusEl.textContent = "Disconnected";
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // --- Main loop ---
|
|
|
+ let last = performance.now();
|
|
|
+ let sendTimer = 0;
|
|
|
+
|
|
|
+ function tick(now) {
|
|
|
+ const dt = Math.min(now - last, 32);
|
|
|
+ last = now;
|
|
|
+
|
|
|
+ updateInputFromKeys();
|
|
|
+
|
|
|
+ if (isHost && connected) {
|
|
|
+ physicsTick(dt);
|
|
|
+
|
|
|
+ // Send state to guest at ~30fps
|
|
|
+ sendTimer += dt;
|
|
|
+ if (sendTimer > 33 && conn && conn.open) {
|
|
|
+ sendTimer = 0;
|
|
|
+ try {
|
|
|
+ conn.send({
|
|
|
+ type: "state",
|
|
|
+ ball: state.ball,
|
|
|
+ biscs: state.biscs,
|
|
|
+ p1: state.p1,
|
|
|
+ p2: state.p2,
|
|
|
+ score1: state.score1,
|
|
|
+ score2: state.score2,
|
|
|
+ gameOver: state.gameOver,
|
|
|
+ paused: state.paused,
|
|
|
+ winner: state.gameOver ? (state.score1 >= WIN_SCORE ? player1 : player2) : null,
|
|
|
+ });
|
|
|
+ } catch {}
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!isHost && connected && conn && conn.open) {
|
|
|
+ sendTimer += dt;
|
|
|
+ if (sendTimer > 33) {
|
|
|
+ sendTimer = 0;
|
|
|
+ try { conn.send({ type: "input", tx: myInput.tx, ty: myInput.ty }); } catch {}
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ render();
|
|
|
+ requestAnimationFrame(tick);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Init
|
|
|
+ render();
|
|
|
+ setupPeer();
|
|
|
+ requestAnimationFrame(tick);
|
|
|
+})();
|
|
|
+</script>
|
|
|
+
|
|
|
+<style>
|
|
|
+ .virtual-game {
|
|
|
+ position: relative;
|
|
|
+ width: 100%;
|
|
|
+ max-width: 640px;
|
|
|
+ margin: 0 auto;
|
|
|
+ }
|
|
|
+
|
|
|
+ .vg-hud {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ gap: 12px;
|
|
|
+ margin-bottom: 12px;
|
|
|
+ padding: 8px 12px;
|
|
|
+ background: var(--bg-darker);
|
|
|
+ border: 1px solid var(--border);
|
|
|
+ border-radius: 4px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .vg-player {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .vg-pname {
|
|
|
+ font-size: 13px;
|
|
|
+ color: var(--text);
|
|
|
+ }
|
|
|
+
|
|
|
+ .vg-pscore {
|
|
|
+ font-family: 'Bebas Neue', sans-serif;
|
|
|
+ font-size: 2.2rem;
|
|
|
+ color: var(--text);
|
|
|
+ line-height: 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ .vg-center-info {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ gap: 2px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .vg-status {
|
|
|
+ font-size: 11px;
|
|
|
+ color: var(--green);
|
|
|
+ letter-spacing: 0.05em;
|
|
|
+ text-transform: uppercase;
|
|
|
+ }
|
|
|
+
|
|
|
+ .vg-ft6 {
|
|
|
+ font-size: 9px;
|
|
|
+ color: var(--text-dim);
|
|
|
+ letter-spacing: 0.08em;
|
|
|
+ text-transform: uppercase;
|
|
|
+ }
|
|
|
+
|
|
|
+ .vg-board {
|
|
|
+ width: 100%;
|
|
|
+ height: auto;
|
|
|
+ display: block;
|
|
|
+ border-radius: 12px;
|
|
|
+ box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
|
|
+ }
|
|
|
+
|
|
|
+ .vg-flash {
|
|
|
+ position: absolute;
|
|
|
+ top: 44px;
|
|
|
+ left: 0;
|
|
|
+ right: 0;
|
|
|
+ bottom: 28px;
|
|
|
+ border-radius: 12px;
|
|
|
+ background: rgba(255, 255, 255, 0.3);
|
|
|
+ pointer-events: none;
|
|
|
+ opacity: 0;
|
|
|
+ transition: opacity 0.3s ease;
|
|
|
+ }
|
|
|
+
|
|
|
+ .vg-controls-hint {
|
|
|
+ text-align: center;
|
|
|
+ font-size: 11px;
|
|
|
+ color: var(--text-dim);
|
|
|
+ margin-top: 8px;
|
|
|
+ letter-spacing: 0.03em;
|
|
|
+ }
|
|
|
+</style>
|