Add Keycloak multiplayer browser client
This commit is contained in:
parent
4bf7ff46a8
commit
908d636cc2
197
src/multiplayer.js
Normal file
197
src/multiplayer.js
Normal 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');
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user