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
+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;
}
};
}