feat: initial pong game with online lobby

This commit is contained in:
2026-02-23 10:42:49 +01:00
commit 7e659b86bf
18 changed files with 4587 additions and 0 deletions
+377
View File
@@ -0,0 +1,377 @@
import { useEffect, useRef, useState } from "react";
import { createLobbyClient, getDefaultLobbyState, getSavedLobbyName } from "./lobbyClient.js";
import { createPongGame, getInitialUiState } from "./pongGame.js";
export default function App() {
const canvasRef = useRef(null);
const gameRef = useRef(null);
const lobbyRef = useRef(null);
const [ui, setUi] = useState(getInitialUiState);
const [lobby, setLobby] = useState(getDefaultLobbyState);
const [lobbyName, setLobbyName] = useState(() => getSavedLobbyName());
const onlineGameSessionRef = useRef(null);
useEffect(() => {
if (!canvasRef.current) return undefined;
const game = createPongGame({
canvas: canvasRef.current,
onUiChange(nextUi) {
setUi((prev) => ({ ...prev, ...nextUi }));
}
});
gameRef.current = game;
return () => {
gameRef.current?.destroy();
gameRef.current = null;
};
}, []);
useEffect(() => {
const client = createLobbyClient({
onState(next) {
setLobby(next);
},
onMatchSignal(message) {
gameRef.current?.handleOnlineSignal(message.payload);
}
});
lobbyRef.current = client;
return () => {
client.disconnect();
lobbyRef.current = null;
};
}, []);
useEffect(() => {
const match = lobby.currentMatch;
const activeSession = onlineGameSessionRef.current;
if (!match || !match.started) {
if (activeSession) {
gameRef.current?.endOnlineMatch();
onlineGameSessionRef.current = null;
}
return;
}
if (activeSession?.matchId === match.matchId) return;
if (!gameRef.current || !lobbyRef.current) return;
gameRef.current.startOnlineMatch(match, (payload) => {
lobbyRef.current?.sendMatchSignal(payload);
});
onlineGameSessionRef.current = { matchId: match.matchId };
}, [lobby.currentMatch]);
const modeLabel =
ui.mode === "cpu" ? "1P vs CPU" : ui.mode === "2p" ? "2P lokal" : "Online 2P";
const soundLabel = ui.soundEnabled ? "På" : "Av";
const otherUsers = lobby.users.filter((user) => user.id !== lobby.selfId);
const availableUsers = otherUsers.filter((user) => user.status === "idle");
const onlineRole = lobby.currentMatch?.role || null;
const onlineOpponentName = lobby.currentMatch?.opponent?.name || "Motstander";
const leftPlayerLabel =
ui.mode === "online" ? (onlineRole === "guest" ? onlineOpponentName : "Deg") : "Deg";
const rightPlayerLabel =
ui.mode === "online" ? (onlineRole === "guest" ? "Deg" : onlineOpponentName) : "CPU";
return (
<main className="app">
<section className="panel">
<h1>Pong</h1>
<p className="subtitle">Spill mot CPU i nettleseren</p>
<div className="scoreboard" aria-label="Poengtavle">
<div>
<span className="label">{leftPlayerLabel}</span>
<strong>{ui.scores.player}</strong>
<span className="statline">
Treff: <span>{ui.hits.player}</span>
</span>
</div>
<div>
<span className="label">{rightPlayerLabel}</span>
<strong>{ui.scores.cpu}</strong>
<span className="statline">
Treff: <span>{ui.hits.cpu}</span>
</span>
</div>
</div>
<div className="meta">
<p>
<span className="label">Modus</span> <strong>{modeLabel}</strong>
</p>
<p>
<span className="label">Beste score</span> <strong>{ui.bestScore}</strong>
</p>
<p>
<span className="label">Lyd</span> <strong>{soundLabel}</strong>
</p>
<p className="volume-row">
<span className="label">Volum</span>
<input
type="range"
min="0"
max="100"
step="1"
value={ui.volumePercent}
onChange={(event) => gameRef.current?.setVolumePercent(event.currentTarget.value)}
/>
<strong>{ui.volumePercent}%</strong>
</p>
</div>
<div className="controls">
<p>
<kbd>W</kbd>/<kbd>S</kbd> eller <kbd></kbd>/<kbd></kbd>
</p>
<p>
<kbd>I</kbd>/<kbd>K</kbd> for spiller 2 (2P-modus)
</p>
<p>
<kbd>M</kbd> bytt modus, <kbd>O</kbd> lyd av/
</p>
<p>
<kbd>Space</kbd> start/pause, <kbd>R</kbd> restart
</p>
<p className="mobile-note">Mobil: dra fingeren opp/ned hver banehalvdel</p>
<div className="control-actions" aria-label="Spillkontroller">
<button
className="start-button"
type="button"
onClick={() => gameRef.current?.startOrPause()}
>
{ui.startButtonLabel}
</button>
<button
className="ghost-button"
type="button"
onClick={() => gameRef.current?.resetGame()}
>
Restart
</button>
<button
className="ghost-button"
type="button"
onClick={() => gameRef.current?.toggleMode()}
>
{ui.mode === "cpu" ? "Bytt til 2P" : "Bytt til 1P"}
</button>
<button
className="ghost-button"
type="button"
onClick={() => gameRef.current?.toggleSound()}
>
{ui.soundEnabled ? "Lyd av" : "Lyd på"}
</button>
</div>
</div>
<section className="lobby-panel" aria-label="Online 2P lobby">
<div className="lobby-head">
<h2>Online 2P (beta)</h2>
<span className={`status-pill ${lobby.connected ? "online" : "offline"}`}>
{lobby.connected
? "Tilkoblet"
: lobby.reconnecting
? "Rekobler..."
: lobby.connecting
? "Kobler til..."
: "Frakoblet"}
</span>
</div>
<p className="lobby-copy">
Lobby med navn, online-liste og invitasjoner er plass. Nettkamp-synk kommer neste steg.
</p>
<div className="lobby-row">
<label className="label" htmlFor="lobbyName">
Navn
</label>
<input
id="lobbyName"
className="text-input"
type="text"
maxLength={24}
value={lobbyName}
onChange={(event) => setLobbyName(event.currentTarget.value)}
placeholder="Ditt navn"
/>
</div>
<div className="lobby-actions">
<button
className="ghost-button"
type="button"
onClick={() => lobbyRef.current?.connect(lobbyName)}
disabled={lobby.connecting}
>
{lobby.connected
? "Koble til på nytt"
: lobby.reconnecting
? "Rekobler..."
: "Koble til lobby"}
</button>
<button
className="ghost-button"
type="button"
onClick={() => lobbyRef.current?.disconnect()}
disabled={!lobby.connected && !lobby.connecting}
>
Koble fra
</button>
</div>
{lobby.error ? <p className="lobby-error">{lobby.error}</p> : null}
{lobby.incomingInvite ? (
<div className="invite-box">
<p>
Invitasjon fra <strong>{lobby.incomingInvite.fromName}</strong>
</p>
<div className="lobby-actions">
<button
className="start-button"
type="button"
onClick={() =>
lobbyRef.current?.replyInvite(lobby.incomingInvite.inviteId, true)
}
>
Aksepter
</button>
<button
className="ghost-button"
type="button"
onClick={() =>
lobbyRef.current?.replyInvite(lobby.incomingInvite.inviteId, false)
}
>
Avslå
</button>
</div>
</div>
) : null}
{lobby.outgoingInvite ? (
<div className="invite-box">
<p>
Invitasjon sendt til <strong>{lobby.outgoingInvite.toName}</strong>
</p>
</div>
) : null}
{lobby.currentMatch ? (
<div className="invite-box">
<p>
Match mot <strong>{lobby.currentMatch.opponent.name}</strong> ({lobby.currentMatch.role})
</p>
{!lobby.currentMatch.started ? (
<>
<p className="mobile-note">
Trykk klar. Kampen starter når begge er klare.
</p>
<div className="ready-row">
<span className={`ready-badge ${lobby.currentMatch.ready?.self ? "yes" : "no"}`}>
Du: {lobby.currentMatch.ready?.self ? "Klar" : "Ikke klar"}
</span>
<span
className={`ready-badge ${lobby.currentMatch.ready?.opponent ? "yes" : "no"}`}
>
Motstander: {lobby.currentMatch.ready?.opponent ? "Klar" : "Ikke klar"}
</span>
</div>
</>
) : (
<p className="mobile-note">Nettkamp er aktiv. Host styrer spillets fysikk.</p>
)}
<div className="lobby-actions">
{!lobby.currentMatch.started ? (
<button
className="start-button"
type="button"
onClick={() => lobbyRef.current?.setReady(!lobby.currentMatch.ready?.self)}
>
{lobby.currentMatch.ready?.self ? "Ikke klar" : "Klar"}
</button>
) : null}
<button className="ghost-button" type="button" onClick={() => lobbyRef.current?.leaveMatch()}>
Forlat match
</button>
</div>
</div>
) : null}
<div className="lobby-list-wrap">
<div className="lobby-list-head">
<span>Spillere online</span>
<strong>{otherUsers.length}</strong>
</div>
<ul className="lobby-list">
{otherUsers.length === 0 ? (
<li className="lobby-empty">Ingen andre online akkurat </li>
) : (
otherUsers.map((user) => {
const canInvite =
lobby.connected &&
user.status === "idle" &&
!lobby.outgoingInvite &&
!lobby.incomingInvite &&
!lobby.currentMatch;
return (
<li key={user.id} className="lobby-user">
<div>
<strong>{user.name}</strong>
<span className={`user-status status-${user.status}`}>
{user.status === "idle"
? "Ledig"
: user.status === "in_match"
? "I kamp"
: user.status === "inviting"
? "Inviterer"
: "Har invitasjon"}
</span>
</div>
<button
className="ghost-button compact"
type="button"
disabled={!canInvite}
onClick={() => lobbyRef.current?.invite(user.id)}
>
Inviter
</button>
</li>
);
})
)}
</ul>
{availableUsers.length > 0 ? (
<p className="mobile-note">Ledige : {availableUsers.map((u) => u.name).join(", ")}</p>
) : null}
</div>
{lobby.notices.length > 0 ? (
<ul className="notice-list" aria-label="Lobby-hendelser">
{lobby.notices.map((notice) => (
<li key={notice.id} className={`notice-item ${notice.kind || "info"}`}>
{notice.text}
</li>
))}
</ul>
) : null}
</section>
</section>
<section className="game-shell" aria-label="Spillområde">
<canvas ref={canvasRef} id="gameCanvas" width="900" height="540"></canvas>
<div
className={`overlay${ui.overlayVisible ? "" : " hidden"}`}
role="status"
aria-live="polite"
dangerouslySetInnerHTML={{ __html: ui.overlayHtml }}
/>
</section>
</main>
);
}
+315
View File
@@ -0,0 +1,315 @@
const NAME_KEY = "spill.pong.lobbyName";
export function getSavedLobbyName() {
try {
return window.localStorage.getItem(NAME_KEY) || "";
} catch {
return "";
}
}
export function saveLobbyName(name) {
try {
window.localStorage.setItem(NAME_KEY, name);
} catch {
// Ignore storage failures.
}
}
export function getDefaultLobbyState() {
return {
connected: false,
connecting: false,
selfId: null,
selfName: "",
users: [],
error: "",
notices: [],
reconnecting: false,
incomingInvite: null,
outgoingInvite: null,
currentMatch: null
};
}
function wsUrlFromWindow() {
const { protocol, host } = window.location;
const wsProtocol = protocol === "https:" ? "wss:" : "ws:";
return `${wsProtocol}//${host}/ws`;
}
export function createLobbyClient({ onState, onNotice, onError, onMatchSignal } = {}) {
let ws = null;
let state = getDefaultLobbyState();
let reconnectTimer = null;
let manualDisconnect = false;
let suppressNextCloseReconnect = false;
let lastRequestedName = "";
let reconnectAttempt = 0;
let pingTimer = null;
function clearReconnectTimer() {
if (reconnectTimer) {
window.clearTimeout(reconnectTimer);
reconnectTimer = null;
}
}
function clearPingTimer() {
if (pingTimer) {
window.clearInterval(pingTimer);
pingTimer = null;
}
}
function emit(patch) {
state = { ...state, ...patch };
onState?.(state);
}
function addNotice(text, kind = "info") {
const item = { id: crypto.randomUUID(), text, kind, ts: Date.now() };
const notices = [item, ...state.notices].slice(0, 6);
emit({ notices });
onNotice?.(item);
}
function send(payload) {
if (!ws || ws.readyState !== WebSocket.OPEN) return false;
ws.send(JSON.stringify(payload));
return true;
}
function startPingLoop() {
clearPingTimer();
pingTimer = window.setInterval(() => {
send({ type: "ping" });
}, 15000);
}
function scheduleReconnect() {
if (manualDisconnect) return;
if (!lastRequestedName) return;
clearReconnectTimer();
reconnectAttempt += 1;
const delayMs = Math.min(5000, 600 * 2 ** Math.min(reconnectAttempt, 3));
emit({ reconnecting: true, connecting: true, connected: false });
addNotice(`Tilkobling mistet. Prøver igjen om ${Math.round(delayMs / 1000)}s`, "warn");
reconnectTimer = window.setTimeout(() => {
connect(lastRequestedName);
}, delayMs);
}
function handleMessage(message) {
switch (message.type) {
case "connected":
return;
case "hello_ok":
emit({
connected: true,
connecting: false,
reconnecting: false,
selfId: message.selfId,
selfName: message.name,
error: ""
});
reconnectAttempt = 0;
addNotice(`Pålogget som ${message.name}`);
return;
case "lobby_state":
emit({ users: Array.isArray(message.users) ? message.users : [] });
return;
case "invite_sent":
emit({
outgoingInvite: {
inviteId: message.inviteId,
toId: message.toId,
toName: message.toName
}
});
addNotice(`Invitasjon sendt til ${message.toName}`);
return;
case "invite_received":
emit({
incomingInvite: {
inviteId: message.inviteId,
fromId: message.fromId,
fromName: message.fromName
}
});
addNotice(`Invitasjon fra ${message.fromName}`);
return;
case "invite_replied":
emit({ incomingInvite: null });
return;
case "invite_declined":
emit({ outgoingInvite: null });
addNotice(`${message.byName || "Motstander"} avslo invitasjonen`, "warn");
return;
case "invite_canceled":
if (state.incomingInvite?.inviteId === message.inviteId) {
emit({ incomingInvite: null });
addNotice("Invitasjonen ble trukket tilbake", "warn");
}
if (state.outgoingInvite?.inviteId === message.inviteId) {
emit({ outgoingInvite: null });
}
return;
case "match_started":
case "match_created":
case "match_ready_state":
emit({
incomingInvite: null,
outgoingInvite: null,
currentMatch: {
matchId: message.matchId,
opponent: message.opponent,
role: message.role,
ready: message.ready || { self: false, opponent: false },
started: Boolean(message.started)
}
});
if (message.type === "match_created") {
addNotice(`Match opprettet mot ${message.opponent?.name || "motstander"}`);
}
if (message.type === "match_started") {
addNotice(`Kamp startet mot ${message.opponent?.name || "motstander"}`);
}
return;
case "match_ended":
emit({ currentMatch: null });
addNotice(
message.reason === "disconnect"
? "Motstander mistet forbindelsen"
: "Kamp avsluttet",
"warn"
);
return;
case "match_signal":
onMatchSignal?.(message);
return;
case "error":
emit({ error: message.message || "Feil" });
onError?.(message);
return;
default:
return;
}
}
function connect(name, url = wsUrlFromWindow()) {
const cleanName = String(name || "").trim();
if (!cleanName) {
emit({ error: "Skriv inn et navn først" });
return;
}
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
suppressNextCloseReconnect = true;
ws.close();
}
manualDisconnect = false;
lastRequestedName = cleanName;
clearReconnectTimer();
emit({
connecting: true,
reconnecting: reconnectAttempt > 0,
connected: false,
error: "",
selfName: cleanName
});
saveLobbyName(cleanName);
ws = new WebSocket(url);
ws.addEventListener("open", () => {
startPingLoop();
send({ type: "hello", name: cleanName });
});
ws.addEventListener("message", (event) => {
try {
handleMessage(JSON.parse(event.data));
} catch {
emit({ error: "Ugyldig svar fra server" });
}
});
ws.addEventListener("close", () => {
clearPingTimer();
const lostActiveMatch = Boolean(state.currentMatch);
emit({
connecting: false,
connected: false,
selfId: null,
users: [],
reconnecting: false,
incomingInvite: null,
outgoingInvite: null,
currentMatch: null
});
if (lostActiveMatch) {
addNotice("Match avsluttet fordi forbindelsen ble brutt", "warn");
}
if (suppressNextCloseReconnect) {
suppressNextCloseReconnect = false;
return;
}
scheduleReconnect();
});
ws.addEventListener("error", () => {
emit({ error: "Kunne ikke koble til lobby-server" });
});
}
function disconnect() {
manualDisconnect = true;
reconnectAttempt = 0;
suppressNextCloseReconnect = false;
clearReconnectTimer();
clearPingTimer();
ws?.close();
ws = null;
emit({ reconnecting: false });
}
function invite(toId) {
emit({ error: "" });
send({ type: "invite_send", toId });
}
function replyInvite(inviteId, accept) {
emit({ error: "" });
send({ type: "invite_reply", inviteId, accept });
if (!accept) emit({ incomingInvite: null });
}
function leaveMatch() {
send({ type: "leave_match" });
}
function setReady(ready) {
send({ type: "match_ready", ready: Boolean(ready) });
}
function sendMatchSignal(payload) {
return send({ type: "match_signal", payload });
}
return {
connect,
disconnect,
invite,
replyInvite,
leaveMatch,
setReady,
sendMatchSignal,
getState() {
return state;
}
};
}
+5
View File
@@ -0,0 +1,5 @@
import ReactDOM from "react-dom/client";
import App from "./App.jsx";
import "./style.css";
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
+881
View File
@@ -0,0 +1,881 @@
const BEST_SCORE_KEY = "spill.pong.bestScore";
const VOLUME_KEY = "spill.pong.volume";
export function getInitialUiState() {
return {
scores: { player: 0, cpu: 0 },
hits: { player: 0, cpu: 0 },
mode: "cpu",
bestScore: 0,
soundEnabled: true,
volumePercent: 60,
overlayVisible: true,
overlayHtml: "Trykk <kbd>Space</kbd> eller <strong>Start</strong> for å starte",
startButtonLabel: "Start"
};
}
export function createPongGame({ canvas, onUiChange }) {
if (!canvas) {
throw new Error("createPongGame requires a canvas element");
}
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("2D context is not available for the game canvas");
}
const audio = { ctx: null };
let rafId = null;
let destroyed = false;
const state = {
running: false,
paused: true,
winner: null,
keys: new Set(),
scores: { player: 0, cpu: 0 },
hits: { player: 0, cpu: 0 },
mode: "cpu",
bestScore: 0,
soundEnabled: true,
volume: 0.6,
overlay: {
visible: true,
html: ""
},
touches: {
leftId: null,
rightId: null
},
online: {
active: false,
role: null, // "host" | "guest"
matchId: null,
opponentName: "",
sendSignal: null,
remoteInput: {
up: false,
down: false,
pointerNormY: null
},
guestSentInput: {
up: false,
down: false
},
lastSnapshotSentAt: 0
},
paddle: {
width: 14,
height: 110,
speed: 430,
margin: 24,
playerY: canvas.height / 2 - 55,
cpuY: canvas.height / 2 - 55
},
ball: {
x: canvas.width / 2,
y: canvas.height / 2,
radius: 9,
speedX: 360,
speedY: 220,
maxSpeed: 780
},
winScore: 7,
lastTs: 0
};
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function lerp(a, b, t) {
return a + (b - a) * t;
}
function isOnlineHost() {
return state.online.active && state.online.role === "host";
}
function isOnlineGuest() {
return state.online.active && state.online.role === "guest";
}
function sendMatchSignal(payload) {
state.online.sendSignal?.(payload);
}
function getStartButtonLabel() {
if (state.winner) return "Ny runde";
if (!state.running) return "Start";
return state.paused ? "Fortsett" : "Pause";
}
function serializeSnapshot() {
return {
running: state.running,
paused: state.paused,
winner: state.winner,
mode: state.mode,
scores: { ...state.scores },
hits: { ...state.hits },
paddle: {
playerY: state.paddle.playerY,
cpuY: state.paddle.cpuY
},
ball: {
x: state.ball.x,
y: state.ball.y,
speedX: state.ball.speedX,
speedY: state.ball.speedY
},
overlay: {
visible: state.overlay.visible,
html: state.overlay.html
}
};
}
function applySnapshot(snapshot, options = {}) {
if (!snapshot || typeof snapshot !== "object") return;
const smoothPositions = Boolean(options.smoothPositions);
if (snapshot.scores) {
state.scores.player = Number(snapshot.scores.player || 0);
state.scores.cpu = Number(snapshot.scores.cpu || 0);
}
if (snapshot.hits) {
state.hits.player = Number(snapshot.hits.player || 0);
state.hits.cpu = Number(snapshot.hits.cpu || 0);
}
if (snapshot.paddle) {
const nextPlayerY = clamp(
Number(snapshot.paddle.playerY ?? state.paddle.playerY),
0,
canvas.height - state.paddle.height
);
const nextCpuY = clamp(
Number(snapshot.paddle.cpuY ?? state.paddle.cpuY),
0,
canvas.height - state.paddle.height
);
if (smoothPositions) {
state.paddle.playerY = lerp(state.paddle.playerY, nextPlayerY, 0.5);
state.paddle.cpuY = lerp(state.paddle.cpuY, nextCpuY, 0.5);
} else {
state.paddle.playerY = nextPlayerY;
state.paddle.cpuY = nextCpuY;
}
}
if (snapshot.ball) {
const nextBallX = Number(snapshot.ball.x ?? state.ball.x);
const nextBallY = Number(snapshot.ball.y ?? state.ball.y);
if (smoothPositions) {
state.ball.x = lerp(state.ball.x, nextBallX, 0.45);
state.ball.y = lerp(state.ball.y, nextBallY, 0.45);
} else {
state.ball.x = nextBallX;
state.ball.y = nextBallY;
}
state.ball.speedX = Number(snapshot.ball.speedX ?? state.ball.speedX);
state.ball.speedY = Number(snapshot.ball.speedY ?? state.ball.speedY);
}
state.running = Boolean(snapshot.running);
state.paused = Boolean(snapshot.paused);
state.winner = snapshot.winner ?? null;
if (snapshot.mode) state.mode = snapshot.mode;
if (snapshot.overlay) {
state.overlay.visible = Boolean(snapshot.overlay.visible);
state.overlay.html = String(snapshot.overlay.html || "");
}
emitUi();
}
function maybeBroadcastSnapshot(force = false) {
if (!isOnlineHost()) return;
const now = performance.now();
if (!force && now - state.online.lastSnapshotSentAt < 33) return;
state.online.lastSnapshotSentAt = now;
sendMatchSignal({ kind: "snapshot", snapshot: serializeSnapshot() });
}
function emitUi() {
if (destroyed) return;
onUiChange?.({
scores: { ...state.scores },
hits: { ...state.hits },
mode: state.mode,
bestScore: state.bestScore,
soundEnabled: state.soundEnabled,
volumePercent: Math.round(state.volume * 100),
overlayVisible: state.overlay.visible,
overlayHtml: state.overlay.html,
startButtonLabel: getStartButtonLabel()
});
}
function setOverlay(html) {
state.overlay.html = html;
state.overlay.visible = true;
emitUi();
}
function hideOverlay() {
state.overlay.visible = false;
emitUi();
}
function ensureAudioContext() {
const AudioCtx = window.AudioContext || window.webkitAudioContext;
if (!AudioCtx) return null;
if (!audio.ctx) audio.ctx = new AudioCtx();
if (audio.ctx.state === "suspended") {
audio.ctx.resume().catch(() => {});
}
return audio.ctx;
}
function playSound(kind) {
if (!state.soundEnabled) return;
const audioCtx = ensureAudioContext();
if (!audioCtx) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
const now = audioCtx.currentTime;
let freq = 440;
let endFreq = 440;
let duration = 0.05;
if (kind === "paddle") {
freq = 620;
endFreq = 760;
duration = 0.045;
} else if (kind === "wall") {
freq = 300;
endFreq = 250;
duration = 0.03;
} else if (kind === "score") {
freq = 220;
endFreq = 150;
duration = 0.13;
} else if (kind === "win") {
freq = 380;
endFreq = 840;
duration = 0.18;
}
osc.type = kind === "wall" ? "square" : "triangle";
osc.frequency.setValueAtTime(freq, now);
osc.frequency.exponentialRampToValueAtTime(Math.max(60, endFreq), now + duration);
gain.gain.setValueAtTime(0.0001, now);
gain.gain.exponentialRampToValueAtTime(Math.max(0.0001, 0.06 * state.volume), now + 0.01);
gain.gain.exponentialRampToValueAtTime(0.0001, now + duration);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start(now);
osc.stop(now + duration);
}
function loadBestScore() {
try {
const value = Number(window.localStorage.getItem(BEST_SCORE_KEY) || 0);
return Number.isFinite(value) ? value : 0;
} catch {
return 0;
}
}
function saveBestScore() {
try {
window.localStorage.setItem(BEST_SCORE_KEY, String(state.bestScore));
} catch {
// Ignore storage failures.
}
}
function loadVolume() {
try {
const value = Number(window.localStorage.getItem(VOLUME_KEY));
if (!Number.isFinite(value)) return 0.6;
return clamp(value, 0, 1);
} catch {
return 0.6;
}
}
function saveVolume() {
try {
window.localStorage.setItem(VOLUME_KEY, String(state.volume));
} catch {
// Ignore storage failures.
}
}
function resetBall(direction = Math.random() > 0.5 ? 1 : -1) {
state.ball.x = canvas.width / 2;
state.ball.y = canvas.height / 2;
const speed = 360 + Math.random() * 90;
const angle = (Math.random() * 0.8 - 0.4) * Math.PI;
state.ball.speedX = Math.cos(angle) * speed * direction;
state.ball.speedY = Math.sin(angle) * speed;
}
function syncBestScore() {
const candidate =
state.mode === "2p"
? Math.max(state.scores.player, state.scores.cpu)
: state.scores.player;
if (candidate > state.bestScore) {
state.bestScore = candidate;
saveBestScore();
emitUi();
}
}
function resetGame() {
state.scores.player = 0;
state.scores.cpu = 0;
state.hits.player = 0;
state.hits.cpu = 0;
state.paddle.playerY = canvas.height / 2 - state.paddle.height / 2;
state.paddle.cpuY = canvas.height / 2 - state.paddle.height / 2;
state.winner = null;
state.paused = true;
state.running = false;
state.lastTs = 0;
resetBall();
if (state.online.active) {
setOverlay(
`Online kamp mot <strong>${state.online.opponentName || "motstander"}</strong><br><small>Trykk <kbd>Space</kbd> eller <strong>Start</strong> for å starte</small>`
);
} else {
setOverlay(
`Trykk <kbd>Space</kbd> eller <strong>Start</strong> for å starte<br><small><kbd>M</kbd> bytter mellom 1P og 2P</small>`
);
}
emitUi();
maybeBroadcastSnapshot(true);
}
function togglePause() {
if (isOnlineGuest()) {
sendMatchSignal({ kind: "command", command: "toggle_pause" });
return;
}
if (state.winner) {
resetGame();
return;
}
if (!state.running) {
state.running = true;
state.paused = false;
hideOverlay();
return;
}
state.paused = !state.paused;
if (state.paused) {
setOverlay("Pause");
} else {
hideOverlay();
}
maybeBroadcastSnapshot(true);
}
function scorePoint(side) {
state.scores[side] += 1;
emitUi();
syncBestScore();
if (state.scores[side] >= state.winScore) {
state.winner = side;
state.paused = true;
state.running = false;
setOverlay(
`${side === "player" ? "Du vant!" : "CPU vant!"}<br>Trykk <kbd>R</kbd> eller <strong>Start</strong> for ny runde`
);
playSound("win");
maybeBroadcastSnapshot(true);
return;
}
state.paused = true;
setOverlay("Poeng! Trykk <kbd>Space</kbd> eller <strong>Start</strong> for serve");
resetBall(side === "player" ? -1 : 1);
playSound("score");
maybeBroadcastSnapshot(true);
}
function handleInput(dt) {
if (isOnlineGuest()) {
const guestUp = state.keys.has("ArrowUp") || state.keys.has("KeyI");
const guestDown = state.keys.has("ArrowDown") || state.keys.has("KeyK");
let guestDir = 0;
if (guestUp) guestDir -= 1;
if (guestDown) guestDir += 1;
state.paddle.cpuY = clamp(
state.paddle.cpuY + guestDir * state.paddle.speed * dt,
0,
canvas.height - state.paddle.height
);
if (
guestUp !== state.online.guestSentInput.up ||
guestDown !== state.online.guestSentInput.down
) {
state.online.guestSentInput.up = guestUp;
state.online.guestSentInput.down = guestDown;
sendMatchSignal({ kind: "guest_input", up: guestUp, down: guestDown });
}
return;
}
const moveUp = state.keys.has("ArrowUp") || state.keys.has("KeyW");
const moveDown = state.keys.has("ArrowDown") || state.keys.has("KeyS");
let dir = 0;
if (moveUp) dir -= 1;
if (moveDown) dir += 1;
state.paddle.playerY = clamp(
state.paddle.playerY + dir * state.paddle.speed * dt,
0,
canvas.height - state.paddle.height
);
if (isOnlineHost()) {
const remote = state.online.remoteInput;
if (remote.pointerNormY != null) {
const y = remote.pointerNormY * canvas.height - state.paddle.height / 2;
state.paddle.cpuY = clamp(y, 0, canvas.height - state.paddle.height);
} else {
let p2Dir = 0;
if (remote.up) p2Dir -= 1;
if (remote.down) p2Dir += 1;
state.paddle.cpuY = clamp(
state.paddle.cpuY + p2Dir * state.paddle.speed * dt,
0,
canvas.height - state.paddle.height
);
}
return;
}
if (state.mode === "2p") {
const p2Up = state.keys.has("KeyI");
const p2Down = state.keys.has("KeyK");
let p2Dir = 0;
if (p2Up) p2Dir -= 1;
if (p2Down) p2Dir += 1;
state.paddle.cpuY = clamp(
state.paddle.cpuY + p2Dir * state.paddle.speed * dt,
0,
canvas.height - state.paddle.height
);
}
}
function updateCpu(dt) {
if (state.online.active) return;
const center = state.paddle.cpuY + state.paddle.height / 2;
const target = state.ball.y + (state.ball.speedX > 0 ? state.ball.speedY * 0.08 : 0);
const diff = target - center;
const reaction = 0.9;
const speed = state.paddle.speed * reaction;
const step = clamp(diff, -speed * dt, speed * dt);
state.paddle.cpuY = clamp(state.paddle.cpuY + step, 0, canvas.height - state.paddle.height);
}
function collideWithPaddle(paddleX, paddleY, isPlayer) {
const { ball, paddle } = state;
const withinY =
ball.y + ball.radius >= paddleY && ball.y - ball.radius <= paddleY + paddle.height;
const withinX = isPlayer
? ball.x - ball.radius <= paddleX + paddle.width && ball.x > paddleX
: ball.x + ball.radius >= paddleX && ball.x < paddleX + paddle.width;
if (!withinX || !withinY) return false;
const hitPos = (ball.y - (paddleY + paddle.height / 2)) / (paddle.height / 2);
const speed = Math.min(Math.hypot(ball.speedX, ball.speedY) * 1.05, ball.maxSpeed);
const angle = hitPos * 1.05;
ball.speedX = (isPlayer ? 1 : -1) * Math.cos(angle) * speed;
ball.speedY = Math.sin(angle) * speed;
ball.x = isPlayer ? paddleX + paddle.width + ball.radius : paddleX - ball.radius;
state.hits[isPlayer ? "player" : "cpu"] += 1;
emitUi();
playSound("paddle");
return true;
}
function updateBall(dt) {
const { ball, paddle } = state;
ball.x += ball.speedX * dt;
ball.y += ball.speedY * dt;
if (ball.y - ball.radius <= 0) {
ball.y = ball.radius;
ball.speedY *= -1;
playSound("wall");
} else if (ball.y + ball.radius >= canvas.height) {
ball.y = canvas.height - ball.radius;
ball.speedY *= -1;
playSound("wall");
}
const playerX = paddle.margin;
const cpuX = canvas.width - paddle.margin - paddle.width;
collideWithPaddle(playerX, paddle.playerY, true);
collideWithPaddle(cpuX, paddle.cpuY, false);
if (ball.x + ball.radius < 0) scorePoint("cpu");
if (ball.x - ball.radius > canvas.width) scorePoint("player");
}
function drawBackground() {
const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height);
gradient.addColorStop(0, "#031d3a");
gradient.addColorStop(1, "#0b4b3a");
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = "rgba(255,255,255,0.22)";
ctx.lineWidth = 4;
ctx.setLineDash([14, 12]);
ctx.beginPath();
ctx.moveTo(canvas.width / 2, 0);
ctx.lineTo(canvas.width / 2, canvas.height);
ctx.stroke();
ctx.setLineDash([]);
}
function drawPaddles() {
const { paddle } = state;
ctx.fillStyle = "#f4f7ff";
ctx.fillRect(paddle.margin, paddle.playerY, paddle.width, paddle.height);
ctx.fillRect(
canvas.width - paddle.margin - paddle.width,
paddle.cpuY,
paddle.width,
paddle.height
);
}
function drawBall() {
const { ball } = state;
const glow = ctx.createRadialGradient(ball.x, ball.y, 1, ball.x, ball.y, 18);
glow.addColorStop(0, "#ffffff");
glow.addColorStop(1, "#7bd3ff");
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(ball.x, ball.y, ball.radius, 0, Math.PI * 2);
ctx.fill();
}
function render() {
drawBackground();
drawPaddles();
drawBall();
}
function pointerYToCanvas(clientY) {
const rect = canvas.getBoundingClientRect();
const normalized = (clientY - rect.top) / rect.height;
return clamp(normalized * canvas.height, 0, canvas.height);
}
function movePaddleToPointer(side, clientY) {
const y = pointerYToCanvas(clientY) - state.paddle.height / 2;
if (side === "left") {
state.paddle.playerY = clamp(y, 0, canvas.height - state.paddle.height);
} else {
state.paddle.cpuY = clamp(y, 0, canvas.height - state.paddle.height);
}
}
function frame(ts) {
if (destroyed) return;
if (!state.lastTs) state.lastTs = ts;
const dt = Math.min((ts - state.lastTs) / 1000, 0.033);
state.lastTs = ts;
handleInput(dt);
if (state.running && !state.paused && !state.winner) {
if (isOnlineHost()) {
updateBall(dt);
} else if (!isOnlineGuest()) {
if (state.mode === "cpu") updateCpu(dt);
updateBall(dt);
}
}
render();
maybeBroadcastSnapshot();
rafId = requestAnimationFrame(frame);
}
function preventGameKeys(event) {
if (
[
"ArrowUp",
"ArrowDown",
"Space",
"KeyW",
"KeyS",
"KeyR",
"KeyI",
"KeyK",
"KeyM",
"KeyO"
].includes(event.code)
) {
event.preventDefault();
}
}
function isEditableTarget(target) {
if (!(target instanceof Element)) return false;
if (target instanceof HTMLInputElement) return true;
if (target instanceof HTMLTextAreaElement) return true;
if (target instanceof HTMLSelectElement) return true;
if (target.isContentEditable) return true;
return Boolean(target.closest("input, textarea, select, [contenteditable='true']"));
}
function onKeyDown(event) {
if (isEditableTarget(event.target)) return;
preventGameKeys(event);
if (event.repeat) return;
ensureAudioContext();
if (event.code === "Space") {
togglePause();
return;
}
if (event.code === "KeyR") {
if (isOnlineGuest()) {
sendMatchSignal({ kind: "command", command: "reset" });
return;
}
resetGame();
return;
}
if (event.code === "KeyO") {
state.soundEnabled = !state.soundEnabled;
emitUi();
if (state.soundEnabled) playSound("paddle");
return;
}
if (event.code === "KeyM") {
if (state.online.active) return;
state.mode = state.mode === "cpu" ? "2p" : "cpu";
resetGame();
emitUi();
return;
}
state.keys.add(event.code);
}
function onKeyUp(event) {
if (isEditableTarget(event.target)) return;
state.keys.delete(event.code);
}
function onTouchStart(event) {
event.preventDefault();
ensureAudioContext();
for (const touch of event.changedTouches) {
const rect = canvas.getBoundingClientRect();
const x = touch.clientX - rect.left;
const side = x < rect.width / 2 ? "left" : "right";
if (isOnlineHost() && side === "right") continue;
if (isOnlineGuest() && side === "left") continue;
if (side === "left" && state.touches.leftId == null) {
state.touches.leftId = touch.identifier;
}
if (side === "right" && state.touches.rightId == null) {
state.touches.rightId = touch.identifier;
}
movePaddleToPointer(side, touch.clientY);
if (isOnlineGuest() && side === "right") {
const normalized = pointerYToCanvas(touch.clientY) / canvas.height;
state.online.remoteInput.pointerNormY = normalized;
sendMatchSignal({ kind: "guest_pointer", y: normalized });
}
}
}
function onTouchMove(event) {
event.preventDefault();
for (const touch of event.touches) {
if (touch.identifier === state.touches.leftId) {
movePaddleToPointer("left", touch.clientY);
}
if (touch.identifier === state.touches.rightId) {
movePaddleToPointer("right", touch.clientY);
if (isOnlineGuest()) {
const normalized = pointerYToCanvas(touch.clientY) / canvas.height;
state.online.remoteInput.pointerNormY = normalized;
sendMatchSignal({ kind: "guest_pointer", y: normalized });
}
}
}
}
function clearTouchIds(event) {
for (const touch of event.changedTouches) {
if (touch.identifier === state.touches.leftId) state.touches.leftId = null;
if (touch.identifier === state.touches.rightId) {
state.touches.rightId = null;
if (isOnlineGuest()) {
state.online.remoteInput.pointerNormY = null;
sendMatchSignal({ kind: "guest_pointer", y: null });
}
}
}
}
function setVolumePercent(value) {
state.volume = clamp(Number(value) / 100, 0, 1);
saveVolume();
emitUi();
if (state.soundEnabled) {
ensureAudioContext();
playSound("wall");
}
}
function toggleSound() {
state.soundEnabled = !state.soundEnabled;
emitUi();
if (state.soundEnabled) {
ensureAudioContext();
playSound("paddle");
}
}
function toggleMode() {
if (state.online.active) return;
state.mode = state.mode === "cpu" ? "2p" : "cpu";
resetGame();
}
function startOrPause() {
ensureAudioContext();
togglePause();
}
function applyOnlineDefaults(match) {
state.online.active = true;
state.online.role = match.role;
state.online.matchId = match.matchId;
state.online.opponentName = match.opponent?.name || "motstander";
state.mode = "online";
state.online.lastSnapshotSentAt = 0;
state.online.remoteInput = { up: false, down: false, pointerNormY: null };
state.online.guestSentInput = { up: false, down: false };
}
function startOnlineMatch(match, sendSignalFn) {
state.online.sendSignal = sendSignalFn;
applyOnlineDefaults(match);
resetGame();
if (isOnlineGuest()) {
setOverlay(
`Venter på host (<strong>${state.online.opponentName}</strong>) for kampdata...`
);
}
emitUi();
}
function endOnlineMatch() {
state.online.active = false;
state.online.role = null;
state.online.matchId = null;
state.online.opponentName = "";
state.online.sendSignal = null;
state.online.remoteInput = { up: false, down: false, pointerNormY: null };
state.mode = "cpu";
resetGame();
}
function handleOnlineSignal(payload) {
if (!payload || typeof payload !== "object") return;
if (isOnlineHost()) {
if (payload.kind === "guest_input") {
state.online.remoteInput.up = Boolean(payload.up);
state.online.remoteInput.down = Boolean(payload.down);
return;
}
if (payload.kind === "guest_pointer") {
state.online.remoteInput.pointerNormY =
payload.y == null ? null : clamp(Number(payload.y), 0, 1);
return;
}
if (payload.kind === "command") {
if (payload.command === "toggle_pause") {
togglePause();
} else if (payload.command === "reset") {
resetGame();
}
}
return;
}
if (isOnlineGuest() && payload.kind === "snapshot") {
const shouldSmooth =
Boolean(payload.snapshot?.running) &&
!Boolean(payload.snapshot?.paused) &&
!payload.snapshot?.winner;
applySnapshot(payload.snapshot, { smoothPositions: shouldSmooth });
}
}
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
canvas.addEventListener("touchstart", onTouchStart, { passive: false });
canvas.addEventListener("touchmove", onTouchMove, { passive: false });
canvas.addEventListener("touchend", clearTouchIds);
canvas.addEventListener("touchcancel", clearTouchIds);
state.bestScore = loadBestScore();
state.volume = loadVolume();
resetGame();
render();
rafId = requestAnimationFrame(frame);
return {
startOrPause,
resetGame,
toggleMode,
toggleSound,
setVolumePercent,
startOnlineMatch,
endOnlineMatch,
handleOnlineSignal,
destroy() {
destroyed = true;
if (rafId != null) cancelAnimationFrame(rafId);
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
canvas.removeEventListener("touchstart", onTouchStart);
canvas.removeEventListener("touchmove", onTouchMove);
canvas.removeEventListener("touchend", clearTouchIds);
canvas.removeEventListener("touchcancel", clearTouchIds);
state.keys.clear();
}
};
}
+460
View File
@@ -0,0 +1,460 @@
:root {
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #eef6ff;
background: #03111f;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at 15% 10%, rgba(92, 181, 255, 0.18), transparent 45%),
radial-gradient(circle at 90% 15%, rgba(0, 226, 170, 0.2), transparent 42%),
linear-gradient(180deg, #03111f 0%, #061a2b 100%);
}
.app {
min-height: 100vh;
display: grid;
grid-template-columns: minmax(260px, 320px) minmax(0, 900px);
gap: 1rem;
align-items: center;
justify-content: center;
padding: 1rem;
}
.panel {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 16px;
padding: 1rem;
backdrop-filter: blur(10px);
}
.panel h1 {
margin: 0;
font-size: 2rem;
}
.subtitle {
margin: 0.25rem 0 1rem;
color: #b9cbe0;
}
.scoreboard {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.scoreboard > div {
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 0.75rem;
display: grid;
gap: 0.15rem;
}
.label {
display: block;
color: #bfd0e5;
font-size: 0.9rem;
}
.scoreboard strong {
font-size: 2rem;
}
.statline {
color: #bfd0e5;
font-size: 0.9rem;
}
.meta {
display: grid;
gap: 0.25rem;
margin-top: 0.75rem;
padding: 0.7rem 0.75rem;
background: rgba(255, 255, 255, 0.04);
border-radius: 12px;
}
.meta p {
margin: 0;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
}
.volume-row {
align-items: center;
}
.volume-row input[type="range"] {
flex: 1;
accent-color: #7bd3ff;
}
.controls {
margin-top: 1rem;
color: #d1dff0;
line-height: 1.35;
}
.start-button {
margin-top: 0.65rem;
width: 100%;
border: 1px solid rgba(123, 211, 255, 0.45);
background: linear-gradient(180deg, rgba(123, 211, 255, 0.28), rgba(0, 226, 170, 0.2));
color: #eef6ff;
border-radius: 10px;
padding: 0.7rem 0.9rem;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.start-button:hover {
background: linear-gradient(180deg, rgba(123, 211, 255, 0.35), rgba(0, 226, 170, 0.28));
}
.start-button:focus-visible {
outline: 2px solid #7bd3ff;
outline-offset: 2px;
}
.control-actions {
display: grid;
gap: 0.55rem;
margin-top: 0.65rem;
}
.control-actions .start-button {
margin-top: 0;
}
.ghost-button {
width: 100%;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(255, 255, 255, 0.04);
color: #dcecff;
border-radius: 10px;
padding: 0.55rem 0.75rem;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.ghost-button:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.22);
}
.ghost-button:focus-visible {
outline: 2px solid #7bd3ff;
outline-offset: 2px;
}
kbd {
font: inherit;
font-weight: 700;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
padding: 0.05rem 0.4rem;
}
.game-shell {
position: relative;
width: min(100%, 900px);
}
canvas {
width: 100%;
height: auto;
display: block;
border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 22px 50px rgba(0, 0, 0, 0.35);
}
.overlay {
position: absolute;
inset: 0;
display: grid;
place-items: center;
text-align: center;
font-size: clamp(1rem, 2vw, 1.35rem);
background: rgba(3, 17, 31, 0.38);
color: white;
border-radius: 18px;
padding: 1rem;
}
.overlay small {
color: #d3e8ff;
}
.overlay.hidden {
display: none;
}
.mobile-note {
color: #b8d2ea;
margin-top: 0.35rem;
}
.lobby-panel {
margin-top: 1rem;
padding-top: 0.9rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
display: grid;
gap: 0.7rem;
}
.lobby-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.lobby-head h2 {
margin: 0;
font-size: 1rem;
}
.status-pill {
border-radius: 999px;
padding: 0.18rem 0.55rem;
font-size: 0.75rem;
font-weight: 700;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(255, 255, 255, 0.05);
color: #dcecff;
}
.status-pill.online {
border-color: rgba(0, 226, 170, 0.35);
background: rgba(0, 226, 170, 0.12);
color: #d7fff4;
}
.status-pill.offline {
border-color: rgba(255, 255, 255, 0.18);
}
.lobby-copy {
margin: 0;
color: #bfd0e5;
font-size: 0.9rem;
line-height: 1.35;
}
.lobby-row {
display: grid;
gap: 0.35rem;
}
.text-input {
width: 100%;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.04);
color: #eef6ff;
padding: 0.55rem 0.7rem;
font: inherit;
}
.text-input::placeholder {
color: #9fb4cb;
}
.text-input:focus-visible {
outline: 2px solid #7bd3ff;
outline-offset: 2px;
}
.lobby-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.ghost-button:disabled,
.start-button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.lobby-error {
margin: 0;
color: #ffc7c7;
background: rgba(255, 88, 88, 0.12);
border: 1px solid rgba(255, 88, 88, 0.22);
border-radius: 10px;
padding: 0.55rem 0.65rem;
font-size: 0.9rem;
}
.invite-box {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 0.65rem;
display: grid;
gap: 0.5rem;
}
.invite-box p {
margin: 0;
}
.ready-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.45rem;
}
.ready-badge {
border-radius: 999px;
padding: 0.28rem 0.55rem;
font-size: 0.78rem;
text-align: center;
border: 1px solid rgba(255, 255, 255, 0.14);
color: #d7e7f8;
background: rgba(255, 255, 255, 0.04);
}
.ready-badge.yes {
border-color: rgba(0, 226, 170, 0.3);
background: rgba(0, 226, 170, 0.1);
color: #d4fff2;
}
.ready-badge.no {
border-color: rgba(255, 255, 255, 0.12);
}
.lobby-list-wrap {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 0.65rem;
display: grid;
gap: 0.45rem;
}
.lobby-list-head {
display: flex;
justify-content: space-between;
align-items: center;
color: #d7e7f8;
font-size: 0.9rem;
}
.lobby-list {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 0.45rem;
}
.lobby-empty {
color: #b5c7da;
font-size: 0.9rem;
}
.lobby-user {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.5rem;
align-items: center;
background: rgba(255, 255, 255, 0.03);
border-radius: 10px;
padding: 0.45rem 0.5rem;
}
.lobby-user > div {
display: grid;
min-width: 0;
}
.lobby-user strong {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-status {
font-size: 0.8rem;
color: #bfd0e5;
}
.status-idle {
color: #aef5df;
}
.status-in_match {
color: #ffd8a8;
}
.status-inviting,
.status-invited {
color: #b7dfff;
}
.ghost-button.compact {
width: auto;
min-width: 78px;
padding: 0.38rem 0.6rem;
}
.notice-list {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 0.35rem;
}
.notice-item {
border-radius: 8px;
padding: 0.38rem 0.5rem;
font-size: 0.82rem;
color: #d8e9fb;
background: rgba(123, 211, 255, 0.08);
border: 1px solid rgba(123, 211, 255, 0.14);
}
.notice-item.warn {
background: rgba(255, 204, 102, 0.08);
border-color: rgba(255, 204, 102, 0.16);
color: #ffe8ba;
}
@media (max-width: 980px) {
.app {
grid-template-columns: 1fr;
grid-template-rows: auto auto;
}
.panel {
width: min(100%, 900px);
justify-self: center;
}
.lobby-actions {
grid-template-columns: 1fr;
}
.ready-row {
grid-template-columns: 1fr;
}
}