VirtualGame.astro 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596
  1. ---
  2. interface Props {
  3. gameId: string;
  4. isHost: boolean;
  5. player1: string;
  6. player2: string;
  7. currentUser: string;
  8. }
  9. const { gameId, isHost, player1, player2, currentUser } = Astro.props;
  10. const svgId = `vg-${Math.random().toString(36).slice(2, 8)}`;
  11. ---
  12. <div class="virtual-game" id={`${svgId}-wrap`}>
  13. <!-- HUD -->
  14. <div class="vg-hud">
  15. <div class="vg-player vg-p1">
  16. <span class="vg-pname">{player1}</span>
  17. <span class="vg-pscore" id={`${svgId}-s1`}>0</span>
  18. </div>
  19. <div class="vg-center-info">
  20. <span class="vg-status" id={`${svgId}-status`}>Connecting...</span>
  21. <span class="vg-ft6">First to 6</span>
  22. </div>
  23. <div class="vg-player vg-p2">
  24. <span class="vg-pscore" id={`${svgId}-s2`}>0</span>
  25. <span class="vg-pname">{player2}</span>
  26. </div>
  27. </div>
  28. <!-- Flash overlay for goals -->
  29. <div class="vg-flash" id={`${svgId}-flash`}></div>
  30. <!-- Board SVG -->
  31. <svg
  32. id={svgId}
  33. class="vg-board"
  34. viewBox="0 0 400 260"
  35. xmlns="http://www.w3.org/2000/svg"
  36. >
  37. <defs>
  38. <linearGradient id={`vgsheen-${svgId}`} x1="0" y1="0" x2="0" y2="1">
  39. <stop offset="0%" stop-color="#fff"/>
  40. <stop offset="100%" stop-color="#fff" stop-opacity="0"/>
  41. </linearGradient>
  42. <radialGradient id={`vgball-${svgId}`} cx="35%" cy="30%" r="65%">
  43. <stop offset="0%" stop-color="#fff" stop-opacity="0.9"/>
  44. <stop offset="40%" stop-color="#ffee55"/>
  45. <stop offset="100%" stop-color="#e6b800"/>
  46. </radialGradient>
  47. </defs>
  48. <rect width="400" height="260" rx="12" fill="#1a1a2e"/>
  49. <rect x="1" y="1" width="398" height="258" rx="12" fill="none" stroke="#2a2a4a" stroke-width="1.5"/>
  50. <rect x="12" y="12" width="376" height="236" rx="8" fill="#1565c0"/>
  51. <rect x="12" y="12" width="376" height="80" rx="8" fill={`url(#vgsheen-${svgId})`} opacity="0.12"/>
  52. <line x1="200" y1="12" x2="200" y2="248" stroke="#fff" stroke-width="1.5" opacity="0.25"/>
  53. <path d="M12,12 A28,28 0 0,1 40,12 A28,28 0 0,1 12,40 Z" fill="#fff" opacity="0.85"/>
  54. <path d="M388,12 A28,28 0 0,0 360,12 A28,28 0 0,0 388,40 Z" fill="#fff" opacity="0.85"/>
  55. <path d="M12,248 A28,28 0 0,0 40,248 A28,28 0 0,0 12,220 Z" fill="#fff" opacity="0.85"/>
  56. <path d="M388,248 A28,28 0 0,1 360,248 A28,28 0 0,1 388,220 Z" fill="#fff" opacity="0.85"/>
  57. <!-- Goals -->
  58. <circle cx="36" cy="130" r="22" fill="#0d1b3e"/>
  59. <circle cx="36" cy="130" r="18" fill="#0a1530"/>
  60. <circle cx="36" cy="130" r="14" fill="#060d1e"/>
  61. <circle cx="36" cy="130" r="22" fill="none" stroke="#4a6fa5" stroke-width="2"/>
  62. <circle cx="364" cy="130" r="22" fill="#0d1b3e"/>
  63. <circle cx="364" cy="130" r="18" fill="#0a1530"/>
  64. <circle cx="364" cy="130" r="14" fill="#060d1e"/>
  65. <circle cx="364" cy="130" r="22" fill="none" stroke="#4a6fa5" stroke-width="2"/>
  66. <!-- Biscuits -->
  67. <g id={`${svgId}-b0`}><circle r="6" fill="#e8e6e0"/><circle r="4.5" fill="#d0cdc6"/><circle r="2.5" fill="#b8b5ae"/></g>
  68. <g id={`${svgId}-b1`}><circle r="6" fill="#e8e6e0"/><circle r="4.5" fill="#d0cdc6"/><circle r="2.5" fill="#b8b5ae"/></g>
  69. <g id={`${svgId}-b2`}><circle r="6" fill="#e8e6e0"/><circle r="4.5" fill="#d0cdc6"/><circle r="2.5" fill="#b8b5ae"/></g>
  70. <!-- Ball -->
  71. <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>
  72. <!-- Pucks -->
  73. <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>
  74. <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>
  75. <rect x="12" y="12" width="376" height="236" rx="8" fill="none" stroke="#1e88e5" stroke-width="1.5" opacity="0.6"/>
  76. <!-- Touch area (invisible, captures touch/mouse) -->
  77. <rect x="12" y="12" width="376" height="236" rx="8" fill="transparent" id={`${svgId}-touch`} style="cursor:crosshair"/>
  78. </svg>
  79. <p class="vg-controls-hint" id={`${svgId}-hint`}>Arrow keys or drag to move your puck</p>
  80. </div>
  81. <script src="https://unpkg.com/peerjs@1.5.4/dist/peerjs.min.js" is:inline></script>
  82. <script define:vars={{ svgId, gameId, isHost, player1, player2, currentUser }}>
  83. (function() {
  84. const statusEl = document.getElementById(`${svgId}-status`);
  85. const s1El = document.getElementById(`${svgId}-s1`);
  86. const s2El = document.getElementById(`${svgId}-s2`);
  87. const flashEl = document.getElementById(`${svgId}-flash`);
  88. const ballEl = document.getElementById(`${svgId}-ball`);
  89. const p1El = document.getElementById(`${svgId}-p1`);
  90. const p2El = document.getElementById(`${svgId}-p2`);
  91. const bEls = [0,1,2].map(i => document.getElementById(`${svgId}-b${i}`));
  92. const touchArea = document.getElementById(`${svgId}-touch`);
  93. const svg = document.getElementById(svgId);
  94. // Physics constants
  95. const FX1 = 24, FX2 = 376, FY1 = 24, FY2 = 236, CX = 200;
  96. const GOAL_L = { x: 36, y: 130, r: 22 };
  97. const GOAL_R = { x: 364, y: 130, r: 22 };
  98. const BALL_R = 8, BISC_R = 6, PUCK_R = 11;
  99. const ATTACH_DIST = PUCK_R + BISC_R + 2;
  100. const STUCK_ORBIT = PUCK_R + BISC_R - 2;
  101. const WIN_SCORE = 6;
  102. // Serve positions (loser's corners)
  103. const SERVE_P1 = [{ x: 55, y: 45 }, { x: 55, y: 215 }]; // P1's corners
  104. const SERVE_P2 = [{ x: 345, y: 45 }, { x: 345, y: 215 }]; // P2's corners
  105. // Game state (host is authoritative)
  106. let state = {
  107. ball: { x: 200, y: 130, vx: 0, vy: 0 },
  108. biscs: [
  109. { x: 200, y: 88, vx: 0, vy: 0, stuck: null, angle: 0 },
  110. { x: 200, y: 130, vx: 0, vy: 0, stuck: null, angle: 0 },
  111. { x: 200, y: 172, vx: 0, vy: 0, stuck: null, angle: 0 },
  112. ],
  113. p1: { x: 95, y: 130, vx: 0, vy: 0, attached: 0 },
  114. p2: { x: 305, y: 130, vx: 0, vy: 0, attached: 0 },
  115. score1: 0,
  116. score2: 0,
  117. paused: true,
  118. serving: false,
  119. gameOver: false,
  120. };
  121. let myInput = { tx: isHost ? 95 : 305, ty: 130 }; // target position from input
  122. let remoteInput = { tx: isHost ? 305 : 95, ty: 130 };
  123. let conn = null;
  124. let peer = null;
  125. let connected = false;
  126. // --- Physics helpers ---
  127. function dist(ax, ay, bx, by) { const dx = ax - bx, dy = ay - by; return Math.sqrt(dx*dx + dy*dy); }
  128. function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
  129. function spd(o) { return Math.sqrt(o.vx*o.vx + o.vy*o.vy); }
  130. function cap(o, m) { const s = spd(o); if (s > m) { o.vx = (o.vx/s)*m; o.vy = (o.vy/s)*m; } }
  131. function collide(a, ar, b, br, bMass) {
  132. const d = dist(a.x, a.y, b.x, b.y);
  133. const minD = ar + br;
  134. if (d >= minD || d < 0.01) return;
  135. const nx = (a.x - b.x) / d, ny = (a.y - b.y) / d;
  136. const dvx = a.vx - b.vx, dvy = a.vy - b.vy;
  137. const dot = dvx * nx + dvy * ny;
  138. if (dot > 0) return;
  139. const tm = 1 + bMass;
  140. a.vx -= (2 * bMass / tm) * dot * nx; a.vy -= (2 * bMass / tm) * dot * ny;
  141. b.vx += (2 / tm) * dot * nx; b.vy += (2 / tm) * dot * ny;
  142. const overlap = minD - d + 0.5;
  143. a.x += nx * overlap * (bMass/tm); a.y += ny * overlap * (bMass/tm);
  144. b.x -= nx * overlap * (1/tm); b.y -= ny * overlap * (1/tm);
  145. }
  146. function wallBounce(o, r) {
  147. if (o.x - r < FX1) { o.x = FX1 + r; o.vx = Math.abs(o.vx) * 0.85; }
  148. if (o.x + r > FX2) { o.x = FX2 - r; o.vx = -Math.abs(o.vx) * 0.85; }
  149. if (o.y - r < FY1) { o.y = FY1 + r; o.vy = Math.abs(o.vy) * 0.85; }
  150. if (o.y + r > FY2) { o.y = FY2 - r; o.vy = -Math.abs(o.vy) * 0.85; }
  151. }
  152. // Drive a puck toward a target
  153. function drivePuck(p, tx, ty, side) {
  154. const margin = PUCK_R + 4;
  155. // Clamp target to half
  156. if (side === 1) tx = clamp(tx, FX1 + margin, CX - margin);
  157. else tx = clamp(tx, CX + margin, FX2 - margin);
  158. ty = clamp(ty, FY1 + margin, FY2 - margin);
  159. const dx = tx - p.x, dy = ty - p.y;
  160. // Stiff spring: move most of the way to target each frame, tiny residual slide
  161. p.vx = dx * 0.35;
  162. p.vy = dy * 0.35;
  163. cap(p, 6);
  164. p.x += p.vx; p.y += p.vy;
  165. if (side === 1) p.x = clamp(p.x, FX1 + margin, CX - margin);
  166. else p.x = clamp(p.x, CX + margin, FX2 - margin);
  167. p.y = clamp(p.y, FY1 + margin, FY2 - margin);
  168. }
  169. function serve(loserSide) {
  170. // Ball starts at a random corner on the loser's side, stationary
  171. const corners = loserSide === 1 ? SERVE_P1 : SERVE_P2;
  172. const c = corners[Math.floor(Math.random() * 2)];
  173. state.ball.x = c.x; state.ball.y = c.y;
  174. state.ball.vx = 0; state.ball.vy = 0;
  175. // Reset biscuits
  176. state.biscs[0] = { x: 200, y: 88, vx: 0, vy: 0, stuck: null, angle: 0 };
  177. state.biscs[1] = { x: 200, y: 130, vx: 0, vy: 0, stuck: null, angle: 0 };
  178. state.biscs[2] = { x: 200, y: 172, vx: 0, vy: 0, stuck: null, angle: 0 };
  179. state.p1.attached = 0; state.p2.attached = 0;
  180. // Reset puck positions
  181. state.p1.x = 95; state.p1.y = 130; state.p1.vx = 0; state.p1.vy = 0;
  182. state.p2.x = 305; state.p2.y = 130; state.p2.vx = 0; state.p2.vy = 0;
  183. // Pause briefly then launch ball
  184. state.serving = true;
  185. setTimeout(() => {
  186. // Launch ball toward center with slight randomness
  187. const angle = (loserSide === 1 ? 0 : Math.PI) + (Math.random() - 0.5) * 0.8;
  188. state.ball.vx = Math.cos(angle) * 3;
  189. state.ball.vy = Math.sin(angle) * 3;
  190. state.serving = false;
  191. }, 1000);
  192. }
  193. function scoreGoal(scorerSide) {
  194. if (state.gameOver) return;
  195. if (scorerSide === 1) state.score1++; else state.score2++;
  196. // Flash
  197. flashEl.style.opacity = "1";
  198. setTimeout(() => { flashEl.style.opacity = "0"; }, 300);
  199. // Check win
  200. if (state.score1 >= WIN_SCORE || state.score2 >= WIN_SCORE) {
  201. state.gameOver = true;
  202. state.paused = true;
  203. const winner = state.score1 >= WIN_SCORE ? player1 : player2;
  204. statusEl.textContent = `${winner} wins!`;
  205. // Submit result to server
  206. fetch(`/api/game/${gameId}/complete`, {
  207. method: "POST",
  208. headers: { "Content-Type": "application/json" },
  209. body: JSON.stringify({ winner, score1: state.score1, score2: state.score2 }),
  210. }).then(() => {
  211. setTimeout(() => { location.reload(); }, 2000);
  212. });
  213. return;
  214. }
  215. // Loser serves from their corner
  216. const loserSide = scorerSide === 1 ? 2 : 1; // scorer is NOT the loser
  217. serve(loserSide);
  218. }
  219. // Stuck detection
  220. let stuckX = 200, stuckY = 130, stuckTimer = 0;
  221. // --- Host physics tick ---
  222. function physicsTick(dt) {
  223. if (state.paused || state.serving || state.gameOver) return;
  224. const { ball, biscs, p1, p2 } = state;
  225. // Drive pucks from inputs
  226. const hostInput = isHost ? myInput : remoteInput;
  227. const guestInput = isHost ? remoteInput : myInput;
  228. drivePuck(p1, hostInput.tx, hostInput.ty, 1);
  229. drivePuck(p2, guestInput.tx, guestInput.ty, 2);
  230. // Ball movement
  231. ball.x += ball.vx; ball.y += ball.vy;
  232. ball.vx *= 0.999; ball.vy *= 0.999;
  233. // Stuck detection
  234. if (dist(ball.x, ball.y, stuckX, stuckY) < 15) {
  235. stuckTimer += dt;
  236. if (stuckTimer > 3000) {
  237. // Nudge ball randomly instead of full reset
  238. ball.vx += (Math.random() - 0.5) * 4;
  239. ball.vy += (Math.random() - 0.5) * 4;
  240. stuckTimer = 0;
  241. }
  242. } else { stuckX = ball.x; stuckY = ball.y; stuckTimer = 0; }
  243. // Min speed
  244. if (spd(ball) < 1.2) { const s = spd(ball)||1; ball.vx=(ball.vx/s)*1.2; ball.vy=(ball.vy/s)*1.2; }
  245. // Biscuit logic
  246. for (const b of biscs) {
  247. if (b.stuck !== null) {
  248. const puck = b.stuck === 1 ? p1 : p2;
  249. b.angle += 0.02;
  250. b.x = puck.x + Math.cos(b.angle) * STUCK_ORBIT;
  251. b.y = puck.y + Math.sin(b.angle) * STUCK_ORBIT;
  252. b.vx = 0; b.vy = 0;
  253. } else {
  254. b.x += b.vx; b.y += b.vy;
  255. b.vx *= 0.97; b.vy *= 0.97;
  256. }
  257. }
  258. // Collisions
  259. wallBounce(ball, BALL_R);
  260. for (const b of biscs) { if (b.stuck === null) wallBounce(b, BISC_R); }
  261. collide(ball, BALL_R, p1, PUCK_R, 5);
  262. collide(ball, BALL_R, p2, PUCK_R, 5);
  263. for (const b of biscs) { if (b.stuck === null) collide(ball, BALL_R, b, BISC_R, 1); }
  264. for (let i = 0; i < biscs.length; i++) {
  265. for (let j = i+1; j < biscs.length; j++) {
  266. if (biscs[i].stuck === null && biscs[j].stuck === null) collide(biscs[i], BISC_R, biscs[j], BISC_R, 1);
  267. }
  268. }
  269. for (const b of biscs) {
  270. if (b.stuck !== null) collide(ball, BALL_R, b, BISC_R, 5);
  271. else { collide(b, BISC_R, p1, PUCK_R, 5); collide(b, BISC_R, p2, PUCK_R, 5); }
  272. }
  273. // Attachment
  274. for (const b of biscs) {
  275. if (b.stuck !== null) continue;
  276. 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++; }
  277. 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++; }
  278. }
  279. // 2 biscuits = point for opponent
  280. if (p1.attached >= 2) { scoreGoal(2); } // P2 scores
  281. else if (p2.attached >= 2) { scoreGoal(1); } // P1 scores
  282. // Goal detection
  283. 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)
  284. 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)
  285. cap(ball, 7);
  286. for (const b of biscs) { if (b.stuck === null) cap(b, 5); }
  287. }
  288. // --- Render ---
  289. function render() {
  290. const { ball, biscs, p1, p2 } = state;
  291. ballEl.setAttribute("transform", `translate(${ball.x.toFixed(1)},${ball.y.toFixed(1)})`);
  292. p1El.setAttribute("transform", `translate(${p1.x.toFixed(1)},${p1.y.toFixed(1)})`);
  293. p2El.setAttribute("transform", `translate(${p2.x.toFixed(1)},${p2.y.toFixed(1)})`);
  294. bEls.forEach((el, i) => el.setAttribute("transform", `translate(${biscs[i].x.toFixed(1)},${biscs[i].y.toFixed(1)})`));
  295. s1El.textContent = state.score1;
  296. s2El.textContent = state.score2;
  297. }
  298. // --- Input handling ---
  299. const keys = {};
  300. const PUCK_SPEED = 4;
  301. document.addEventListener("keydown", (e) => {
  302. if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight"].includes(e.key)) {
  303. e.preventDefault();
  304. keys[e.key] = true;
  305. }
  306. });
  307. document.addEventListener("keyup", (e) => { keys[e.key] = false; });
  308. function updateInputFromKeys() {
  309. let dx = 0, dy = 0;
  310. if (keys["ArrowLeft"]) dx -= PUCK_SPEED;
  311. if (keys["ArrowRight"]) dx += PUCK_SPEED;
  312. if (keys["ArrowUp"]) dy -= PUCK_SPEED;
  313. if (keys["ArrowDown"]) dy += PUCK_SPEED;
  314. myInput.tx += dx;
  315. myInput.ty += dy;
  316. // Clamp to own half
  317. const margin = PUCK_R + 4;
  318. if (isHost) {
  319. myInput.tx = clamp(myInput.tx, FX1 + margin, CX - margin);
  320. } else {
  321. myInput.tx = clamp(myInput.tx, CX + margin, FX2 - margin);
  322. }
  323. myInput.ty = clamp(myInput.ty, FY1 + margin, FY2 - margin);
  324. }
  325. // Touch / mouse drag
  326. let dragging = false;
  327. function svgCoords(e) {
  328. const rect = svg.getBoundingClientRect();
  329. const clientX = e.touches ? e.touches[0].clientX : e.clientX;
  330. const clientY = e.touches ? e.touches[0].clientY : e.clientY;
  331. const scaleX = 400 / rect.width;
  332. const scaleY = 260 / rect.height;
  333. return { x: (clientX - rect.left) * scaleX, y: (clientY - rect.top) * scaleY };
  334. }
  335. touchArea.addEventListener("mousedown", (e) => { dragging = true; const c = svgCoords(e); myInput.tx = c.x; myInput.ty = c.y; });
  336. touchArea.addEventListener("mousemove", (e) => { if (dragging) { const c = svgCoords(e); myInput.tx = c.x; myInput.ty = c.y; } });
  337. document.addEventListener("mouseup", () => { dragging = false; });
  338. touchArea.addEventListener("touchstart", (e) => { e.preventDefault(); dragging = true; const c = svgCoords(e); myInput.tx = c.x; myInput.ty = c.y; }, { passive: false });
  339. touchArea.addEventListener("touchmove", (e) => { e.preventDefault(); if (dragging) { const c = svgCoords(e); myInput.tx = c.x; myInput.ty = c.y; } }, { passive: false });
  340. touchArea.addEventListener("touchend", () => { dragging = false; });
  341. // --- PeerJS networking ---
  342. const peerId = `klask-${gameId}`;
  343. function setupPeer() {
  344. statusEl.textContent = "Connecting...";
  345. peer = new Peer(isHost ? peerId : undefined);
  346. peer.on("open", (id) => {
  347. if (isHost) {
  348. statusEl.textContent = "Waiting for opponent...";
  349. peer.on("connection", (c) => {
  350. conn = c;
  351. setupConnection();
  352. });
  353. } else {
  354. conn = peer.connect(peerId, { reliable: true });
  355. setupConnection();
  356. }
  357. });
  358. peer.on("error", (err) => {
  359. console.error("PeerJS error:", err);
  360. if (err.type === "peer-unavailable") {
  361. statusEl.textContent = "Host not found. Retrying...";
  362. setTimeout(setupPeer, 2000);
  363. } else {
  364. statusEl.textContent = "Connection error";
  365. }
  366. });
  367. }
  368. function setupConnection() {
  369. conn.on("open", () => {
  370. connected = true;
  371. state.paused = false;
  372. statusEl.textContent = "Live";
  373. // Start with serve from P1's corner
  374. if (isHost) serve(1);
  375. });
  376. conn.on("data", (data) => {
  377. if (isHost) {
  378. // Guest sends input
  379. if (data.type === "input") {
  380. remoteInput.tx = data.tx;
  381. remoteInput.ty = data.ty;
  382. }
  383. } else {
  384. // Host sends full state
  385. if (data.type === "state") {
  386. state.ball = data.ball;
  387. state.biscs = data.biscs;
  388. state.p1 = data.p1;
  389. state.p2 = data.p2;
  390. state.score1 = data.score1;
  391. state.score2 = data.score2;
  392. state.gameOver = data.gameOver;
  393. state.paused = data.paused;
  394. if (data.gameOver && data.winner) {
  395. statusEl.textContent = `${data.winner} wins!`;
  396. }
  397. }
  398. }
  399. });
  400. conn.on("close", () => {
  401. connected = false;
  402. state.paused = true;
  403. statusEl.textContent = "Disconnected";
  404. });
  405. }
  406. // --- Main loop ---
  407. let last = performance.now();
  408. let sendTimer = 0;
  409. function tick(now) {
  410. const dt = Math.min(now - last, 32);
  411. last = now;
  412. updateInputFromKeys();
  413. if (isHost && connected) {
  414. physicsTick(dt);
  415. // Send state to guest at ~30fps
  416. sendTimer += dt;
  417. if (sendTimer > 33 && conn && conn.open) {
  418. sendTimer = 0;
  419. try {
  420. conn.send({
  421. type: "state",
  422. ball: state.ball,
  423. biscs: state.biscs,
  424. p1: state.p1,
  425. p2: state.p2,
  426. score1: state.score1,
  427. score2: state.score2,
  428. gameOver: state.gameOver,
  429. paused: state.paused,
  430. winner: state.gameOver ? (state.score1 >= WIN_SCORE ? player1 : player2) : null,
  431. });
  432. } catch {}
  433. }
  434. }
  435. if (!isHost && connected && conn && conn.open) {
  436. sendTimer += dt;
  437. if (sendTimer > 33) {
  438. sendTimer = 0;
  439. try { conn.send({ type: "input", tx: myInput.tx, ty: myInput.ty }); } catch {}
  440. }
  441. }
  442. render();
  443. requestAnimationFrame(tick);
  444. }
  445. // Init
  446. render();
  447. setupPeer();
  448. requestAnimationFrame(tick);
  449. })();
  450. </script>
  451. <style>
  452. .virtual-game {
  453. position: relative;
  454. width: 100%;
  455. max-width: 640px;
  456. margin: 0 auto;
  457. }
  458. .vg-hud {
  459. display: flex;
  460. align-items: center;
  461. justify-content: space-between;
  462. gap: 12px;
  463. margin-bottom: 12px;
  464. padding: 8px 12px;
  465. background: var(--bg-darker);
  466. border: 1px solid var(--border);
  467. border-radius: 4px;
  468. }
  469. .vg-player {
  470. display: flex;
  471. align-items: center;
  472. gap: 10px;
  473. }
  474. .vg-pname {
  475. font-size: 13px;
  476. color: var(--text);
  477. }
  478. .vg-pscore {
  479. font-family: 'Bebas Neue', sans-serif;
  480. font-size: 2.2rem;
  481. color: var(--text);
  482. line-height: 1;
  483. }
  484. .vg-center-info {
  485. display: flex;
  486. flex-direction: column;
  487. align-items: center;
  488. gap: 2px;
  489. }
  490. .vg-status {
  491. font-size: 11px;
  492. color: var(--green);
  493. letter-spacing: 0.05em;
  494. text-transform: uppercase;
  495. }
  496. .vg-ft6 {
  497. font-size: 9px;
  498. color: var(--text-dim);
  499. letter-spacing: 0.08em;
  500. text-transform: uppercase;
  501. }
  502. .vg-board {
  503. width: 100%;
  504. height: auto;
  505. display: block;
  506. border-radius: 12px;
  507. box-shadow: 0 8px 32px rgba(0,0,0,0.5);
  508. }
  509. .vg-flash {
  510. position: absolute;
  511. top: 44px;
  512. left: 0;
  513. right: 0;
  514. bottom: 28px;
  515. border-radius: 12px;
  516. background: rgba(255, 255, 255, 0.3);
  517. pointer-events: none;
  518. opacity: 0;
  519. transition: opacity 0.3s ease;
  520. }
  521. .vg-controls-hint {
  522. text-align: center;
  523. font-size: 11px;
  524. color: var(--text-dim);
  525. margin-top: 8px;
  526. letter-spacing: 0.03em;
  527. }
  528. </style>