feat: initial pong game with online lobby
This commit is contained in:
+377
@@ -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>
|
||||
<p>
|
||||
<kbd>Space</kbd> start/pause, <kbd>R</kbd> restart
|
||||
</p>
|
||||
<p className="mobile-note">Mobil: dra fingeren opp/ned på 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 på 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 nå</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 nå: {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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user