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