feat: initial pong game with online lobby
This commit is contained in:
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user