Wire player settings into account and multiplayer

This commit is contained in:
Admin 2026-09-21 05:25:24 +00:00
parent dee38cf602
commit 4c990597b7
4 changed files with 153 additions and 32 deletions

View File

@ -1,4 +1,5 @@
import './multiplayer.js'; import './multiplayer.js';
import './player-settings.js';
import './game-v4.js'; import './game-v4.js';
import './mobile-hud.js'; import './mobile-hud.js';
import './dock-ui.js'; import './dock-ui.js';

View File

@ -1614,6 +1614,7 @@ class GameScene extends Phaser.Scene {
remote.label.setText(remote.alive remote.label.setText(remote.alive
? `${state.name || 'CAPTAIN'} ${Math.round(remote.hull)}HP K${state.kills || 0}/D${state.deaths || 0}` ? `${state.name || 'CAPTAIN'} ${Math.round(remote.hull)}HP K${state.kills || 0}/D${state.deaths || 0}`
: `${state.name || 'CAPTAIN'} • DOWN`); : `${state.name || 'CAPTAIN'} • DOWN`);
remote.label.setVisible(window.airshipSettings?.get?.().showPlayerLabels !== false);
} }
for (const [id, remote] of this.remotePlayers) { for (const [id, remote] of this.remotePlayers) {
@ -1716,6 +1717,7 @@ class GameScene extends Phaser.Scene {
sprite.y = Phaser.Math.Linear(sprite.y, remote.targetY, 0.32); sprite.y = Phaser.Math.Linear(sprite.y, remote.targetY, 0.32);
sprite.angle += signedAngleDelta(remote.targetAngle, sprite.angle) * 0.32; sprite.angle += signedAngleDelta(remote.targetAngle, sprite.angle) * 0.32;
remote.label.setPosition(sprite.x, sprite.y - 70); remote.label.setPosition(sprite.x, sprite.y - 70);
remote.label.setVisible(window.airshipSettings?.get?.().showPlayerLabels !== false);
} }
} }
@ -2160,7 +2162,9 @@ class GameScene extends Phaser.Scene {
if (this.player?.active && this.gameState === 'playing') this.player.clearTint(); if (this.player?.active && this.gameState === 'playing') this.player.clearTint();
}); });
this.cameras.main.shake(120, clamp(0.0035 + damage * 0.00055, 0.004, 0.012)); if (window.airshipSettings?.get?.().screenShake !== false) {
this.cameras.main.shake(120, clamp(0.0035 + damage * 0.00055, 0.004, 0.012));
}
} }
validateLivePlayerState() { validateLivePlayerState() {

View File

@ -9,6 +9,7 @@ const KEYCLOAK_CONFIG = {
}; };
const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
const MULTIPLAYER_ENABLED_KEY = 'airship.multiplayer.enabled';
const keycloak = new Keycloak(KEYCLOAK_CONFIG); const keycloak = new Keycloak(KEYCLOAK_CONFIG);
window.airshipLoading?.setNetwork?.('Checking captain credentials…'); window.airshipLoading?.setNetwork?.('Checking captain credentials…');
@ -18,12 +19,16 @@ let reconnectTimer = null;
let authenticated = false; let authenticated = false;
let playerCount = 0; let playerCount = 0;
let inputSequence = 0; let inputSequence = 0;
let profile = null;
let selfSnapshot = null;
let worldSeed = 'SKYWARD-001';
let multiplayerEnabled = localStorage.getItem(MULTIPLAYER_ENABLED_KEY) !== 'false';
const statusButton = document.createElement('button'); const statusButton = document.createElement('button');
statusButton.id = 'multiplayer-status'; statusButton.id = 'multiplayer-status';
statusButton.type = 'button'; statusButton.type = 'button';
statusButton.textContent = 'MULTIPLAYER'; statusButton.textContent = '⚙ SOLO';
statusButton.setAttribute('aria-label', 'Multiplayer login'); statusButton.setAttribute('aria-label', 'Open player settings');
document.body.appendChild(statusButton); document.body.appendChild(statusButton);
const style = document.createElement('style'); const style = document.createElement('style');
@ -34,7 +39,7 @@ style.textContent = `
left: max(10px, env(safe-area-inset-left)); left: max(10px, env(safe-area-inset-left));
z-index: 410; z-index: 410;
min-height: 38px; min-height: 38px;
max-width: 190px; max-width: 150px;
padding: 7px 11px; padding: 7px 11px;
border: 1px solid rgba(220, 168, 79, .72); border: 1px solid rgba(220, 168, 79, .72);
border-radius: 999px; border-radius: 999px;
@ -52,9 +57,17 @@ style.textContent = `
`; `;
document.head.appendChild(style); document.head.appendChild(style);
const setStatus = (state, label) => { const setStatus = (state) => {
statusButton.dataset.state = state; statusButton.dataset.state = state;
statusButton.textContent = label; statusButton.textContent = state === 'online'
? `⚙ ONLINE ${Math.max(1, playerCount)}`
: state === 'connecting'
? '⚙ CONNECTING'
: state === 'disabled'
? '⚙ OFFLINE'
: state === 'error'
? '⚙ SOLO'
: '⚙ SOLO';
const loadingLabel = state === 'online' const loadingLabel = state === 'online'
? `Shared skies online${playerCount > 1 ? ` • ${playerCount} captains` : ''}` ? `Shared skies online${playerCount > 1 ? ` • ${playerCount} captains` : ''}`
@ -62,15 +75,28 @@ const setStatus = (state, label) => {
? 'Joining shared skies…' ? 'Joining shared skies…'
: state === 'error' : state === 'error'
? 'Multiplayer unavailable • solo ready' ? 'Multiplayer unavailable • solo ready'
: authenticated : state === 'disabled'
? 'Multiplayer ready' ? 'Shared skies disabled • solo ready'
: 'Solo flight ready'; : authenticated
? 'Multiplayer ready'
: 'Solo flight ready';
window.airshipLoading?.setNetwork?.(loadingLabel); window.airshipLoading?.setNetwork?.(loadingLabel);
}; }
const getState = () => ({
authenticated,
connected: socket?.readyState === WebSocket.OPEN,
enabled: multiplayerEnabled,
playerCount,
selfId,
profile,
self: selfSnapshot,
worldSeed,
});
const emitConnection = (state, extra = {}) => { const emitConnection = (state, extra = {}) => {
window.dispatchEvent(new CustomEvent('airship:multiplayer-state', { window.dispatchEvent(new CustomEvent('airship:multiplayer-state', {
detail: { state, authenticated, playerCount, selfId, ...extra }, detail: { state, ...getState(), ...extra },
})); }));
}; };
@ -86,6 +112,15 @@ const authFetch = async (path, options = {}) => {
}); });
}; };
const loadProfile = async () => {
const response = await authFetch('/api/me');
if (!response.ok) throw new Error(`profile ${response.status}`);
profile = await response.json();
worldSeed = profile.worldSeed || worldSeed;
emitConnection(socket?.readyState === WebSocket.OPEN ? 'online' : 'authenticated');
return profile;
};
const requestSocketTicket = async () => { const requestSocketTicket = async () => {
const response = await authFetch('/api/ws-ticket', { method: 'POST' }); const response = await authFetch('/api/ws-ticket', { method: 'POST' });
if (!response.ok) throw new Error(`ticket ${response.status}`); if (!response.ok) throw new Error(`ticket ${response.status}`);
@ -93,7 +128,7 @@ const requestSocketTicket = async () => {
}; };
const scheduleReconnect = () => { const scheduleReconnect = () => {
if (!authenticated || reconnectTimer) return; if (!authenticated || !multiplayerEnabled || reconnectTimer) return;
reconnectTimer = window.setTimeout(() => { reconnectTimer = window.setTimeout(() => {
reconnectTimer = null; reconnectTimer = null;
connect().catch(() => scheduleReconnect()); connect().catch(() => scheduleReconnect());
@ -101,17 +136,17 @@ const scheduleReconnect = () => {
}; };
const connect = async () => { const connect = async () => {
if (!authenticated) return; if (!authenticated || !multiplayerEnabled) return;
if (socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING) return; if (socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING) return;
setStatus('connecting', 'MULTIPLAYER • CONNECTING'); setStatus('connecting');
const { ticket } = await requestSocketTicket(); const { ticket } = await requestSocketTicket();
const ws = new WebSocket(`${WS_BASE}/ws?ticket=${encodeURIComponent(ticket)}`); const ws = new WebSocket(`${WS_BASE}/ws?ticket=${encodeURIComponent(ticket)}`);
socket = ws; socket = ws;
ws.addEventListener('open', () => { ws.addEventListener('open', () => {
inputSequence = 0; inputSequence = 0;
setStatus('online', 'MULTIPLAYER • ONLINE'); setStatus('online');
emitConnection('online'); emitConnection('online');
}); });
@ -121,7 +156,8 @@ const connect = async () => {
if (message.type === 'welcome') { if (message.type === 'welcome') {
selfId = message.playerId || selfId; selfId = message.playerId || selfId;
emitConnection('online', { worldSeed: message.worldSeed }); worldSeed = message.worldSeed || worldSeed;
emitConnection('online');
return; return;
} }
@ -135,8 +171,8 @@ const connect = async () => {
? message.projectiles.filter((shot) => shot?.id) ? message.projectiles.filter((shot) => shot?.id)
: []; : [];
playerCount = allPlayers.length; playerCount = allPlayers.length;
const score = self ? ` • ${self.kills || 0}/${self.deaths || 0}` : ''; selfSnapshot = self;
setStatus('online', `MULTIPLAYER • ${playerCount}${score}`); setStatus('online');
window.dispatchEvent(new CustomEvent('airship:multiplayer-snapshot', { window.dispatchEvent(new CustomEvent('airship:multiplayer-snapshot', {
detail: { detail: {
self, self,
@ -151,13 +187,19 @@ const connect = async () => {
ws.addEventListener('close', () => { ws.addEventListener('close', () => {
if (socket === ws) socket = null; if (socket === ws) socket = null;
playerCount = 0; playerCount = 0;
setStatus(authenticated ? 'connecting' : 'offline', authenticated ? 'MULTIPLAYER • RECONNECT' : 'MULTIPLAYER'); selfSnapshot = null;
emitConnection('offline'); if (authenticated && multiplayerEnabled) {
scheduleReconnect(); setStatus('connecting');
emitConnection('offline');
scheduleReconnect();
} else {
setStatus(authenticated ? 'disabled' : 'offline');
emitConnection(authenticated ? 'disabled' : 'offline');
}
}); });
ws.addEventListener('error', () => { ws.addEventListener('error', () => {
setStatus('error', 'MULTIPLAYER • RETRY'); setStatus('error');
}); });
}; };
@ -185,12 +227,40 @@ const fire = (side) => {
const login = () => keycloak.login({ redirectUri: window.location.href }); const login = () => keycloak.login({ redirectUri: window.location.href });
const logout = () => keycloak.logout({ redirectUri: `${window.location.origin}/` }); const logout = () => keycloak.logout({ redirectUri: `${window.location.origin}/` });
statusButton.addEventListener('click', () => { const setEnabled = (enabled) => {
if (!authenticated) { multiplayerEnabled = Boolean(enabled);
login(); localStorage.setItem(MULTIPLAYER_ENABLED_KEY, String(multiplayerEnabled));
if (!multiplayerEnabled) {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (socket) {
const current = socket;
socket = null;
current.close();
}
playerCount = 0;
selfSnapshot = null;
setStatus(authenticated ? 'disabled' : 'offline');
emitConnection(authenticated ? 'disabled' : 'signed-out');
return; return;
} }
if (socket?.readyState !== WebSocket.OPEN) connect().catch(() => setStatus('error', 'MULTIPLAYER • RETRY'));
if (authenticated) {
setStatus('connecting');
emitConnection('connecting');
connect().catch(() => {
setStatus('error');
emitConnection('error');
scheduleReconnect();
});
}
};
statusButton.addEventListener('click', () => {
window.dispatchEvent(new CustomEvent('airship:settings-open'));
}); });
window.airshipMultiplayer = { window.airshipMultiplayer = {
@ -199,6 +269,8 @@ window.airshipMultiplayer = {
login, login,
logout, logout,
connect, connect,
setEnabled,
getState,
getSelfId: () => selfId, getSelfId: () => selfId,
isAuthenticated: () => authenticated, isAuthenticated: () => authenticated,
isConnected: () => socket?.readyState === WebSocket.OPEN, isConnected: () => socket?.readyState === WebSocket.OPEN,
@ -208,7 +280,7 @@ keycloak.onTokenExpired = () => {
keycloak.updateToken(30).catch(() => { keycloak.updateToken(30).catch(() => {
authenticated = false; authenticated = false;
if (socket) socket.close(); if (socket) socket.close();
setStatus('offline', 'MULTIPLAYER'); setStatus('offline');
}); });
}; };
@ -217,19 +289,37 @@ keycloak.init({
pkceMethod: 'S256', pkceMethod: 'S256',
checkLoginIframe: false, checkLoginIframe: false,
silentCheckSsoRedirectUri: `${window.location.origin}/silent-check-sso.html`, silentCheckSsoRedirectUri: `${window.location.origin}/silent-check-sso.html`,
}).then((ok) => { }).then(async (ok) => {
authenticated = Boolean(ok); authenticated = Boolean(ok);
if (!authenticated) { if (!authenticated) {
setStatus('offline', 'MULTIPLAYER'); profile = null;
setStatus('offline');
emitConnection('signed-out'); emitConnection('signed-out');
return; return;
} }
setStatus('connecting', 'MULTIPLAYER • CONNECTING');
try {
await loadProfile();
} catch {
profile = { id: selfId, name: keycloak.tokenParsed?.preferred_username || keycloak.tokenParsed?.name || 'Captain' };
}
if (!multiplayerEnabled) {
setStatus('disabled');
emitConnection('disabled');
return;
}
setStatus('connecting');
emitConnection('connecting');
connect().catch(() => { connect().catch(() => {
setStatus('error', 'MULTIPLAYER • RETRY'); setStatus('error');
emitConnection('error');
scheduleReconnect(); scheduleReconnect();
}); });
}).catch(() => { }).catch(() => {
authenticated = false; authenticated = false;
setStatus('error', 'MULTIPLAYER • LOGIN'); profile = null;
setStatus('error');
emitConnection('error');
}); });

View File

@ -4,6 +4,32 @@ import { readFile } from 'node:fs/promises';
const read = (path) => readFile(new URL(`../${path}`, import.meta.url), 'utf8'); const read = (path) => readFile(new URL(`../${path}`, import.meta.url), 'utf8');
test('player settings owns account, multiplayer and local flight preferences', async () => {
const [entry, settings, multiplayer, game] = await Promise.all([
read('src/game-entry.js'),
read('src/player-settings.js'),
read('src/multiplayer.js'),
read('src/game-v4.js'),
]);
assert.match(entry, /import '.\/player-settings\.js'/);
assert.match(settings, /Player Settings/);
assert.match(settings, /data-player-setting="multiplayerEnabled"/);
assert.match(settings, /data-player-setting="showPlayerLabels"/);
assert.match(settings, /data-player-setting="screenShake"/);
assert.match(settings, /data-player-setting="reducedMotion"/);
assert.match(settings, /window\.airshipMultiplayer\?\.setEnabled/);
assert.match(settings, /window\.airshipMultiplayer\.logout\(\)/);
assert.match(settings, /realms\/dev\/account/);
assert.match(multiplayer, /airship:settings-open/);
assert.match(multiplayer, /const setEnabled = \(enabled\) =>/);
assert.match(multiplayer, /getState/);
assert.match(multiplayer, /\/api\/me/);
assert.match(multiplayer, /⚙ ONLINE/);
assert.match(game, /window\.airshipSettings\?\.get\?\.\(\)\.screenShake/);
assert.match(game, /showPlayerLabels/);
});
test('startup shows a real progress loading screen until the Phaser scene is ready', async () => { test('startup shows a real progress loading screen until the Phaser scene is ready', async () => {
const [index, game, multiplayer] = await Promise.all([ const [index, game, multiplayer] = await Promise.all([
read('index.html'), read('index.html'),