Add Keycloak multiplayer browser client

This commit is contained in:
Admin 2026-09-21 01:35:57 +00:00
parent 4bf7ff46a8
commit 908d636cc2

197
src/multiplayer.js Normal file
View File

@ -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');
});