From 908d636cc2536f2744e6e16a60758ef80897b6d6 Mon Sep 17 00:00:00 2001 From: Admin Date: Mon, 21 Sep 2026 01:35:57 +0000 Subject: [PATCH] Add Keycloak multiplayer browser client --- src/multiplayer.js | 197 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 src/multiplayer.js diff --git a/src/multiplayer.js b/src/multiplayer.js new file mode 100644 index 0000000..ba1b3c3 --- /dev/null +++ b/src/multiplayer.js @@ -0,0 +1,197 @@ +import Keycloak from 'keycloak-js'; + +const API_BASE = 'https://game.arrowstack.dev'; +const WS_BASE = 'wss://game.arrowstack.dev'; +const KEYCLOAK_CONFIG = { + url: 'https://auth.arrowstack.dev', + realm: 'dev', + clientId: 'airship-web', +}; + +const keycloak = new Keycloak(KEYCLOAK_CONFIG); +let socket = null; +let selfId = null; +let reconnectTimer = null; +let authenticated = false; +let playerCount = 0; + +const statusButton = document.createElement('button'); +statusButton.id = 'multiplayer-status'; +statusButton.type = 'button'; +statusButton.textContent = 'MULTIPLAYER'; +statusButton.setAttribute('aria-label', 'Multiplayer login'); +document.body.appendChild(statusButton); + +const style = document.createElement('style'); +style.textContent = ` + #multiplayer-status { + position: fixed; + top: max(10px, env(safe-area-inset-top)); + left: max(10px, env(safe-area-inset-left)); + z-index: 410; + min-height: 38px; + max-width: 180px; + padding: 7px 11px; + border: 1px solid rgba(220, 168, 79, .72); + border-radius: 999px; + background: rgba(5, 25, 31, .86); + color: #f5dda0; + font: 700 10px/1.1 Georgia, 'Times New Roman', serif; + letter-spacing: .05em; + box-shadow: 0 3px 12px rgba(0,0,0,.35); + touch-action: manipulation; + } + #multiplayer-status[data-state="online"] { border-color: rgba(115, 207, 145, .82); color: #c8f0cf; } + #multiplayer-status[data-state="error"] { border-color: rgba(215, 92, 76, .85); color: #ffd0c7; } + body.dock-open #multiplayer-status, + body.shipyard-open #multiplayer-status { visibility: hidden; } +`; +document.head.appendChild(style); + +const setStatus = (state, label) => { + statusButton.dataset.state = state; + statusButton.textContent = label; +}; + +const emitConnection = (state, extra = {}) => { + window.dispatchEvent(new CustomEvent('airship:multiplayer-state', { + detail: { state, authenticated, playerCount, selfId, ...extra }, + })); +}; + +const authFetch = async (path, options = {}) => { + await keycloak.updateToken(30); + return fetch(`${API_BASE}${path}`, { + ...options, + headers: { + ...(options.headers || {}), + Authorization: `Bearer ${keycloak.token}`, + 'Content-Type': 'application/json', + }, + }); +}; + +const requestSocketTicket = async () => { + const response = await authFetch('/api/ws-ticket', { method: 'POST' }); + if (!response.ok) throw new Error(`ticket ${response.status}`); + return response.json(); +}; + +const scheduleReconnect = () => { + if (!authenticated || reconnectTimer) return; + reconnectTimer = window.setTimeout(() => { + reconnectTimer = null; + connect().catch(() => scheduleReconnect()); + }, 1800); +}; + +const connect = async () => { + if (!authenticated) return; + if (socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING) return; + + setStatus('connecting', 'MULTIPLAYER • CONNECTING'); + const { ticket } = await requestSocketTicket(); + const ws = new WebSocket(`${WS_BASE}/ws?ticket=${encodeURIComponent(ticket)}`); + socket = ws; + + ws.addEventListener('open', () => { + setStatus('online', 'MULTIPLAYER • ONLINE'); + emitConnection('online'); + }); + + ws.addEventListener('message', (event) => { + let message; + try { message = JSON.parse(event.data); } catch { return; } + + if (message.type === 'welcome') { + selfId = message.playerId || selfId; + emitConnection('online', { worldSeed: message.worldSeed }); + return; + } + + if (message.type === 'snapshot') { + const players = Array.isArray(message.players) + ? message.players.filter((player) => player?.id && player.id !== selfId) + : []; + playerCount = players.length + 1; + setStatus('online', `MULTIPLAYER • ${playerCount}`); + window.dispatchEvent(new CustomEvent('airship:multiplayer-snapshot', { + detail: { players, serverTime: message.serverTime || 0 }, + })); + } + }); + + ws.addEventListener('close', () => { + if (socket === ws) socket = null; + playerCount = 0; + setStatus(authenticated ? 'connecting' : 'offline', authenticated ? 'MULTIPLAYER • RECONNECT' : 'MULTIPLAYER'); + emitConnection('offline'); + scheduleReconnect(); + }); + + ws.addEventListener('error', () => { + setStatus('error', 'MULTIPLAYER • RETRY'); + }); +}; + +const sendState = (state) => { + if (socket?.readyState !== WebSocket.OPEN || !state) return false; + socket.send(JSON.stringify({ + type: 'state', + x: Number(state.x) || 0, + y: Number(state.y) || 0, + angle: Number(state.angle) || 0, + hull: Math.max(0, Math.round(Number(state.hull) || 0)), + })); + return true; +}; + +const login = () => keycloak.login({ redirectUri: window.location.href }); +const logout = () => keycloak.logout({ redirectUri: `${window.location.origin}/` }); + +statusButton.addEventListener('click', () => { + if (!authenticated) { + login(); + return; + } + if (socket?.readyState !== WebSocket.OPEN) connect().catch(() => setStatus('error', 'MULTIPLAYER • RETRY')); +}); + +window.airshipMultiplayer = { + sendState, + login, + logout, + connect, + isAuthenticated: () => authenticated, + isConnected: () => socket?.readyState === WebSocket.OPEN, +}; + +keycloak.onTokenExpired = () => { + keycloak.updateToken(30).catch(() => { + authenticated = false; + if (socket) socket.close(); + setStatus('offline', 'MULTIPLAYER'); + }); +}; + +keycloak.init({ + onLoad: 'check-sso', + pkceMethod: 'S256', + checkLoginIframe: false, + silentCheckSsoRedirectUri: `${window.location.origin}/silent-check-sso.html`, +}).then((ok) => { + authenticated = Boolean(ok); + if (!authenticated) { + setStatus('offline', 'MULTIPLAYER'); + emitConnection('signed-out'); + return; + } + setStatus('connecting', 'MULTIPLAYER • CONNECTING'); + connect().catch(() => { + setStatus('error', 'MULTIPLAYER • RETRY'); + scheduleReconnect(); + }); +}).catch(() => { + authenticated = false; + setStatus('error', 'MULTIPLAYER • LOGIN'); +});