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
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
.DS_Store
.env
+47
View File
@@ -0,0 +1,47 @@
# Repository Guidelines
## Project Structure & Module Organization
This repository is currently an empty scaffold (`/home/steinhelge/Gitea/demo`) with no source, test, or asset directories yet. As the project grows, use a consistent layout:
- `src/` for application code
- `tests/` for automated tests
- `docs/` for design notes and contributor docs
- `assets/` for static files (images, sample data)
Keep modules focused and grouped by feature (for example, `src/auth/`, `src/api/`).
## Build, Test, and Development Commands
No build or test tooling is configured yet. When tooling is added, document the exact commands here and in the project README. Prefer simple, standard entry points such as:
- `make test` or `npm test` to run all tests
- `make lint` or `npm run lint` for static checks
- `make dev` or `npm run dev` for local development
If you introduce a new command, include a short description in the PR.
## Coding Style & Naming Conventions
Use consistent formatting and enforce it with tooling (for example, `prettier`, `eslint`, `black`, or `ruff`, depending on the language stack adopted). Until a language is chosen:
- Use 2 or 4 spaces consistently (no tabs)
- Prefer descriptive names (`user_service`, `fetchOrders`)
- Name files by language convention (`kebab-case`, `snake_case`, or `PascalCase` where appropriate)
Keep functions small and modules cohesive.
## Testing Guidelines
Add tests alongside new features. Place unit tests in `tests/` or next to code if the chosen framework prefers co-location. Use clear names that describe behavior, e.g., `test_login_rejects_invalid_password`.
Aim for meaningful coverage of core logic before merging.
## Commit & Pull Request Guidelines
Git history is not available in this directory, so no local commit convention can be inferred. Use clear, imperative commit messages (prefer Conventional Commits, e.g., `feat: add user validation`).
For pull requests:
- Describe the change and why it is needed
- Link related issues/tasks
- Include test evidence (command output or summary)
- Add screenshots for UI changes
## Agent-Specific Notes
When using coding agents, keep changes small, reviewable, and scoped to one concern per PR.
+14
View File
@@ -0,0 +1,14 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist/ /usr/share/nginx/html/pong/
COPY site/spill/ /usr/share/nginx/html/spill/
EXPOSE 80
+10
View File
@@ -0,0 +1,10 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install --omit=dev
COPY server ./server
EXPOSE 8787
CMD ["node", "server/lobby.js"]
+41
View File
@@ -0,0 +1,41 @@
services:
spill:
build:
context: .
dockerfile: Dockerfile
container_name: spill
restart: unless-stopped
networks:
- edge
labels:
- "traefik.enable=true"
- "traefik.docker.network=edge"
- "traefik.http.routers.spill.rule=Host(`spill.theriise.net`)"
- "traefik.http.routers.spill.entrypoints=websecure"
- "traefik.http.routers.spill.tls.certresolver=le"
- "traefik.http.routers.pong.rule=Host(`pong.theriise.net`)"
- "traefik.http.routers.pong.entrypoints=websecure"
- "traefik.http.routers.pong.tls.certresolver=le"
- "traefik.http.services.spill.loadbalancer.server.port=80"
- "com.centurylinklabs.watchtower.enable=false"
depends_on:
- lobby
lobby:
build:
context: .
dockerfile: Dockerfile.lobby
container_name: spill-lobby
restart: unless-stopped
networks:
- edge
environment:
- HOST=0.0.0.0
- PORT=8787
labels:
- "traefik.enable=false"
- "com.centurylinklabs.watchtower.enable=false"
networks:
edge:
external: true
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="no">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pong | pong.theriise.net</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+63
View File
@@ -0,0 +1,63 @@
server {
listen 80;
server_name spill.theriise.net;
root /usr/share/nginx/html/spill;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location = /healthz {
access_log off;
return 200 "ok\n";
add_header Content-Type text/plain;
}
}
server {
listen 80;
server_name pong.theriise.net;
root /usr/share/nginx/html/pong;
index index.html;
location /ws {
proxy_pass http://lobby:8787/ws;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
location = /healthz {
access_log off;
return 200 "ok\n";
add_header Content-Type text/plain;
}
}
server {
listen 80 default_server;
server_name _;
root /usr/share/nginx/html/spill;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location = /healthz {
access_log off;
return 200 "ok\n";
add_header Content-Type text/plain;
}
}
+1681
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "spill-pong",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 5173",
"lobby": "node server/lobby.js",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0 --port 4173",
"dev:all": "sh -c 'node server/lobby.js & vite --host 0.0.0.0 --port 5173'"
},
"devDependencies": {
"@vitejs/plugin-react": "^5.0.0",
"vite": "^5.4.14"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"ws": "^8.18.3"
}
}
+455
View File
@@ -0,0 +1,455 @@
import http from "node:http";
import { randomUUID } from "node:crypto";
import { WebSocketServer } from "ws";
const PORT = Number(process.env.PORT || 8787);
const HOST = process.env.HOST || "0.0.0.0";
const clients = new Map(); // clientId -> { ws, name, status, matchId }
const socketToClientId = new WeakMap();
const invites = new Map(); // inviteId -> { id, fromId, toId, status, createdAt }
const matches = new Map(); // matchId -> { id, aId, bId, createdAt, startedAt, ready: { [clientId]: boolean } }
function nowIso() {
return new Date().toISOString();
}
function send(ws, payload) {
if (ws.readyState !== 1) return;
ws.send(JSON.stringify(payload));
}
function getUserView(clientId) {
const client = clients.get(clientId);
if (!client) return null;
return {
id: clientId,
name: client.name,
status: client.status,
matchId: client.matchId || null
};
}
function broadcastLobbyState() {
const users = [...clients.keys()]
.map(getUserView)
.filter(Boolean)
.sort((a, b) => a.name.localeCompare(b.name, "no", { sensitivity: "base" }));
for (const { ws } of clients.values()) {
send(ws, { type: "lobby_state", users, ts: nowIso() });
}
}
function sanitizeName(input) {
const text = String(input || "").trim().replace(/\s+/g, " ");
return text.slice(0, 24);
}
function isNameTaken(name, exceptClientId = null) {
const lower = name.toLocaleLowerCase("no");
for (const [id, client] of clients) {
if (id === exceptClientId) continue;
if (client.name.toLocaleLowerCase("no") === lower) return true;
}
return false;
}
function resetBusyStatuses(clientIds) {
for (const clientId of clientIds) {
const client = clients.get(clientId);
if (!client) continue;
if (client.status !== "in_match") {
client.status = "idle";
client.matchId = null;
}
}
}
function cancelInvitesForClient(clientId) {
const affectedClientIds = new Set([clientId]);
for (const [inviteId, invite] of invites) {
if (invite.status !== "pending") continue;
if (invite.fromId !== clientId && invite.toId !== clientId) continue;
invite.status = "canceled";
const otherId = invite.fromId === clientId ? invite.toId : invite.fromId;
affectedClientIds.add(otherId);
const other = clients.get(otherId);
if (other) {
send(other.ws, {
type: "invite_canceled",
inviteId,
by: clientId,
ts: nowIso()
});
}
}
resetBusyStatuses(affectedClientIds);
}
function endMatch(matchId, reason = "left") {
const match = matches.get(matchId);
if (!match) return;
matches.delete(matchId);
for (const id of [match.aId, match.bId]) {
const client = clients.get(id);
if (!client) continue;
client.matchId = null;
client.status = "idle";
send(client.ws, { type: "match_ended", matchId, reason, ts: nowIso() });
}
}
function getMatchView(match, forClientId) {
const opponentId = match.aId === forClientId ? match.bId : match.aId;
const opponent = getUserView(opponentId);
return {
matchId: match.id,
opponent: opponent ? { id: opponent.id, name: opponent.name } : null,
role: match.aId === forClientId ? "host" : "guest",
ready: {
self: Boolean(match.ready?.[forClientId]),
opponent: Boolean(match.ready?.[opponentId])
},
started: Boolean(match.startedAt)
};
}
function cleanupClient(clientId) {
const client = clients.get(clientId);
if (!client) return;
cancelInvitesForClient(clientId);
if (client.matchId) endMatch(client.matchId, "disconnect");
clients.delete(clientId);
broadcastLobbyState();
}
function requireClient(ws) {
const clientId = socketToClientId.get(ws);
const client = clientId ? clients.get(clientId) : null;
return clientId && client ? { clientId, client } : null;
}
function onHello(ws, message) {
const name = sanitizeName(message.name);
if (!name) {
send(ws, { type: "error", code: "invalid_name", message: "Ugyldig navn" });
return;
}
const existing = requireClient(ws);
if (existing) {
if (isNameTaken(name, existing.clientId)) {
send(ws, { type: "error", code: "name_taken", message: "Navnet er opptatt" });
return;
}
existing.client.name = name;
send(ws, { type: "hello_ok", selfId: existing.clientId, name });
broadcastLobbyState();
return;
}
if (isNameTaken(name)) {
send(ws, { type: "error", code: "name_taken", message: "Navnet er opptatt" });
return;
}
const clientId = randomUUID();
clients.set(clientId, {
ws,
name,
status: "idle",
matchId: null
});
socketToClientId.set(ws, clientId);
send(ws, { type: "hello_ok", selfId: clientId, name });
broadcastLobbyState();
}
function onInviteSend(ws, message) {
const ctx = requireClient(ws);
if (!ctx) {
send(ws, { type: "error", code: "not_authenticated", message: "Send hello først" });
return;
}
const { clientId, client } = ctx;
const toId = String(message.toId || "");
if (!toId || toId === clientId) {
send(ws, { type: "error", code: "invalid_target", message: "Ugyldig motstander" });
return;
}
const target = clients.get(toId);
if (!target) {
send(ws, { type: "error", code: "target_offline", message: "Motstander er ikke pålogget" });
return;
}
if (client.status !== "idle" || target.status !== "idle") {
send(ws, { type: "error", code: "target_busy", message: "En av spillerne er opptatt" });
return;
}
const duplicatePending = [...invites.values()].find(
(invite) =>
invite.status === "pending" &&
((invite.fromId === clientId && invite.toId === toId) ||
(invite.fromId === toId && invite.toId === clientId))
);
if (duplicatePending) {
send(ws, { type: "error", code: "invite_exists", message: "Invitasjon finnes allerede" });
return;
}
const inviteId = randomUUID();
invites.set(inviteId, {
id: inviteId,
fromId: clientId,
toId,
status: "pending",
createdAt: nowIso()
});
client.status = "inviting";
target.status = "invited";
send(client.ws, { type: "invite_sent", inviteId, toId, toName: target.name, ts: nowIso() });
send(target.ws, {
type: "invite_received",
inviteId,
fromId: clientId,
fromName: client.name,
ts: nowIso()
});
broadcastLobbyState();
}
function onInviteReply(ws, message) {
const ctx = requireClient(ws);
if (!ctx) {
send(ws, { type: "error", code: "not_authenticated", message: "Send hello først" });
return;
}
const inviteId = String(message.inviteId || "");
const accept = Boolean(message.accept);
const invite = invites.get(inviteId);
if (!invite || invite.status !== "pending") {
send(ws, { type: "error", code: "invite_missing", message: "Invitasjonen finnes ikke" });
return;
}
if (invite.toId !== ctx.clientId) {
send(ws, { type: "error", code: "not_invited", message: "Du kan ikke svare på denne" });
return;
}
const fromClient = clients.get(invite.fromId);
const toClient = clients.get(invite.toId);
if (!fromClient || !toClient) {
invite.status = "canceled";
resetBusyStatuses([invite.fromId, invite.toId]);
broadcastLobbyState();
return;
}
if (!accept) {
invite.status = "declined";
fromClient.status = "idle";
toClient.status = "idle";
send(fromClient.ws, {
type: "invite_declined",
inviteId,
byId: invite.toId,
byName: toClient.name,
ts: nowIso()
});
send(toClient.ws, { type: "invite_replied", inviteId, accept: false, ts: nowIso() });
broadcastLobbyState();
return;
}
invite.status = "accepted";
const matchId = randomUUID();
matches.set(matchId, {
id: matchId,
aId: invite.fromId,
bId: invite.toId,
createdAt: nowIso(),
startedAt: null,
ready: {
[invite.fromId]: false,
[invite.toId]: false
}
});
fromClient.status = "in_match";
toClient.status = "in_match";
fromClient.matchId = matchId;
toClient.matchId = matchId;
// Cancel any other pending invites involving these two players.
for (const otherInvite of invites.values()) {
if (otherInvite.id === inviteId || otherInvite.status !== "pending") continue;
if (
[otherInvite.fromId, otherInvite.toId].includes(invite.fromId) ||
[otherInvite.fromId, otherInvite.toId].includes(invite.toId)
) {
otherInvite.status = "canceled";
}
}
const match = matches.get(matchId);
send(fromClient.ws, { type: "match_created", ...getMatchView(match, invite.fromId), ts: nowIso() });
send(toClient.ws, { type: "match_created", ...getMatchView(match, invite.toId), ts: nowIso() });
broadcastLobbyState();
}
function onLeaveMatch(ws) {
const ctx = requireClient(ws);
if (!ctx) return;
if (!ctx.client.matchId) return;
endMatch(ctx.client.matchId, "left");
broadcastLobbyState();
}
function onMatchReady(ws, message) {
const ctx = requireClient(ws);
if (!ctx) return;
if (!ctx.client.matchId) {
send(ws, { type: "error", code: "no_match", message: "Du er ikke i en match" });
return;
}
const match = matches.get(ctx.client.matchId);
if (!match) return;
match.ready[ctx.clientId] = Boolean(message.ready);
const a = clients.get(match.aId);
const b = clients.get(match.bId);
if (!a || !b) return;
send(a.ws, { type: "match_ready_state", ...getMatchView(match, match.aId), ts: nowIso() });
send(b.ws, { type: "match_ready_state", ...getMatchView(match, match.bId), ts: nowIso() });
if (!match.startedAt && match.ready[match.aId] && match.ready[match.bId]) {
match.startedAt = nowIso();
send(a.ws, { type: "match_started", ...getMatchView(match, match.aId), ts: nowIso() });
send(b.ws, { type: "match_started", ...getMatchView(match, match.bId), ts: nowIso() });
}
}
function onMatchSignal(ws, message) {
const ctx = requireClient(ws);
if (!ctx) return;
if (!ctx.client.matchId) return;
const match = matches.get(ctx.client.matchId);
if (!match || !match.startedAt) return;
const opponentId = match.aId === ctx.clientId ? match.bId : match.aId;
const opponent = clients.get(opponentId);
if (!opponent) return;
send(opponent.ws, {
type: "match_signal",
matchId: match.id,
fromId: ctx.clientId,
payload: message.payload ?? null,
ts: nowIso()
});
}
function onMessage(ws, raw) {
let message;
try {
message = JSON.parse(String(raw));
} catch {
send(ws, { type: "error", code: "bad_json", message: "Ugyldig JSON" });
return;
}
if (!message || typeof message !== "object") {
send(ws, { type: "error", code: "bad_message", message: "Ugyldig melding" });
return;
}
switch (message.type) {
case "hello":
onHello(ws, message);
break;
case "invite_send":
onInviteSend(ws, message);
break;
case "invite_reply":
onInviteReply(ws, message);
break;
case "leave_match":
onLeaveMatch(ws);
break;
case "match_ready":
onMatchReady(ws, message);
break;
case "match_signal":
onMatchSignal(ws, message);
break;
case "ping":
send(ws, { type: "pong", ts: nowIso() });
break;
default:
send(ws, { type: "error", code: "unknown_type", message: "Ukjent meldingstype" });
}
}
const server = http.createServer((req, res) => {
if (req.url === "/healthz") {
res.writeHead(200, { "content-type": "text/plain" });
res.end("ok\n");
return;
}
res.writeHead(404, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "not_found" }));
});
const wss = new WebSocketServer({ noServer: true });
server.on("upgrade", (req, socket, head) => {
if (!req.url || !req.url.startsWith("/ws")) {
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
});
wss.on("connection", (ws) => {
send(ws, { type: "connected", ts: nowIso() });
ws.on("message", (data) => onMessage(ws, data));
ws.on("close", () => {
const clientId = socketToClientId.get(ws);
if (clientId) cleanupClient(clientId);
});
ws.on("error", () => {
const clientId = socketToClientId.get(ws);
if (clientId) cleanupClient(clientId);
});
});
server.listen(PORT, HOST, () => {
console.log(`Lobby listening on http://${HOST}:${PORT}`);
});
+38
View File
@@ -0,0 +1,38 @@
<!doctype html>
<html lang="no">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spill | Theriise</title>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<main class="hub">
<section class="hero" aria-labelledby="hero-title">
<p class="eyebrow">theriise.net</p>
<h1 id="hero-title">Spill</h1>
<p class="lead">
En liten samling nettleserspill. Foreløpig er det bare Pong, men flere spill kan legges til her etter hvert.
</p>
</section>
<section class="games" aria-labelledby="games-title">
<div class="section-head">
<h2 id="games-title">Tilgjengelige spill</h2>
<span class="pill">1 spill</span>
</div>
<a class="game-card" href="https://pong.theriise.net" rel="noopener noreferrer">
<div>
<p class="game-tag">Arkade</p>
<h3>Pong</h3>
<p class="game-desc">
Klassisk Pong i nettleseren med CPU/2P, touch-styring og mobilvennlig startknapp.
</p>
</div>
<span class="cta">Åpne</span>
</a>
</section>
</main>
</body>
</html>
+152
View File
@@ -0,0 +1,152 @@
:root {
color-scheme: dark;
--bg: #061018;
--bg-2: #0a1d2a;
--panel: rgba(255, 255, 255, 0.06);
--panel-border: rgba(255, 255, 255, 0.12);
--text: #edf6ff;
--muted: #b5c9db;
--accent: #7bd3ff;
--accent-2: #00e2aa;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--text);
background:
radial-gradient(circle at 14% 12%, rgba(123, 211, 255, 0.2), transparent 46%),
radial-gradient(circle at 88% 18%, rgba(0, 226, 170, 0.16), transparent 42%),
linear-gradient(180deg, var(--bg) 0%, var(--bg-2) 100%);
}
.hub {
width: min(100%, 960px);
margin: 0 auto;
padding: 1.25rem;
display: grid;
gap: 1rem;
}
.hero,
.games {
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 18px;
backdrop-filter: blur(12px);
padding: 1rem;
}
.eyebrow {
margin: 0;
color: var(--muted);
font-size: 0.85rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
h1 {
margin: 0.25rem 0 0;
font-size: clamp(2rem, 5vw, 3rem);
line-height: 0.95;
}
.lead {
margin: 0.85rem 0 0;
color: var(--muted);
max-width: 58ch;
line-height: 1.45;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
h2 {
margin: 0;
font-size: 1.1rem;
}
.pill {
border: 1px solid rgba(123, 211, 255, 0.35);
background: rgba(123, 211, 255, 0.14);
color: #dff4ff;
border-radius: 999px;
padding: 0.2rem 0.55rem;
font-size: 0.8rem;
}
.game-card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.8rem;
align-items: center;
padding: 0.95rem;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02));
color: inherit;
text-decoration: none;
transition: transform 120ms ease, border-color 120ms ease, background 120ms ease;
}
.game-card:hover,
.game-card:focus-visible {
transform: translateY(-2px);
border-color: rgba(123, 211, 255, 0.35);
background: linear-gradient(180deg, rgba(123,211,255,0.08), rgba(0,226,170,0.06));
outline: none;
}
.game-tag {
margin: 0;
color: var(--accent);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
h3 {
margin: 0.2rem 0 0;
font-size: 1.35rem;
}
.game-desc {
margin: 0.35rem 0 0;
color: var(--muted);
line-height: 1.35;
}
.cta {
border-radius: 999px;
border: 1px solid rgba(0, 226, 170, 0.35);
background: rgba(0, 226, 170, 0.12);
color: #d7fff4;
padding: 0.38rem 0.75rem;
font-weight: 700;
white-space: nowrap;
}
@media (max-width: 640px) {
.hub {
padding: 0.8rem;
}
.game-card {
grid-template-columns: 1fr;
align-items: start;
}
.cta {
justify-self: start;
}
}
+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;
}
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
host: true,
port: 5173
}
});