| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596 |
- ---
- 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>
|