316 lines
7.9 KiB
JavaScript
316 lines
7.9 KiB
JavaScript
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;
|
|
}
|
|
};
|
|
}
|