From 20c93949800170cb95c7e5c3ae23a5cd945bd0bc Mon Sep 17 00:00:00 2001 From: Admin Date: Mon, 21 Sep 2026 05:26:30 +0000 Subject: [PATCH] Sync player settings and compact multiplayer status --- src/game-entry.js | 1 + src/game-v4.js | 6 +- src/multiplayer.js | 152 +++++++++++++---- src/player-settings.js | 372 +++++++++++++++++++++++++++++++++++++++++ tests/wiring.test.mjs | 26 +++ 5 files changed, 525 insertions(+), 32 deletions(-) create mode 100644 src/player-settings.js diff --git a/src/game-entry.js b/src/game-entry.js index 2b1ea33..922ec69 100644 --- a/src/game-entry.js +++ b/src/game-entry.js @@ -1,4 +1,5 @@ import './multiplayer.js'; +import './player-settings.js'; import './game-v4.js'; import './mobile-hud.js'; import './dock-ui.js'; diff --git a/src/game-v4.js b/src/game-v4.js index b067c6d..81f3816 100644 --- a/src/game-v4.js +++ b/src/game-v4.js @@ -1614,6 +1614,7 @@ class GameScene extends Phaser.Scene { remote.label.setText(remote.alive ? `${state.name || 'CAPTAIN'} ${Math.round(remote.hull)}HP K${state.kills || 0}/D${state.deaths || 0}` : `${state.name || 'CAPTAIN'} • DOWN`); + remote.label.setVisible(window.airshipSettings?.get?.().showPlayerLabels !== false); } 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.angle += signedAngleDelta(remote.targetAngle, sprite.angle) * 0.32; 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(); }); - 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() { diff --git a/src/multiplayer.js b/src/multiplayer.js index 14dd2a6..e885bd6 100644 --- a/src/multiplayer.js +++ b/src/multiplayer.js @@ -9,6 +9,7 @@ const KEYCLOAK_CONFIG = { }; 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); window.airshipLoading?.setNetwork?.('Checking captain credentials…'); @@ -18,12 +19,16 @@ let reconnectTimer = null; let authenticated = false; let playerCount = 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'); statusButton.id = 'multiplayer-status'; statusButton.type = 'button'; -statusButton.textContent = 'MULTIPLAYER'; -statusButton.setAttribute('aria-label', 'Multiplayer login'); +statusButton.textContent = '⚙ SOLO'; +statusButton.setAttribute('aria-label', 'Open player settings'); document.body.appendChild(statusButton); const style = document.createElement('style'); @@ -34,7 +39,7 @@ style.textContent = ` left: max(10px, env(safe-area-inset-left)); z-index: 410; min-height: 38px; - max-width: 190px; + max-width: 150px; padding: 7px 11px; border: 1px solid rgba(220, 168, 79, .72); border-radius: 999px; @@ -52,9 +57,17 @@ style.textContent = ` `; document.head.appendChild(style); -const setStatus = (state, label) => { +const setStatus = (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' ? `Shared skies online${playerCount > 1 ? ` • ${playerCount} captains` : ''}` @@ -62,15 +75,28 @@ const setStatus = (state, label) => { ? 'Joining shared skies…' : state === 'error' ? 'Multiplayer unavailable • solo ready' - : authenticated - ? 'Multiplayer ready' - : 'Solo flight ready'; + : state === 'disabled' + ? 'Shared skies disabled • solo ready' + : authenticated + ? 'Multiplayer ready' + : 'Solo flight ready'; window.airshipLoading?.setNetwork?.(loadingLabel); -}; +} + +const getState = () => ({ + authenticated, + connected: socket?.readyState === WebSocket.OPEN, + enabled: multiplayerEnabled, + playerCount, + selfId, + profile, + self: selfSnapshot, + worldSeed, +}); const emitConnection = (state, extra = {}) => { 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 response = await authFetch('/api/ws-ticket', { method: 'POST' }); if (!response.ok) throw new Error(`ticket ${response.status}`); @@ -93,7 +128,7 @@ const requestSocketTicket = async () => { }; const scheduleReconnect = () => { - if (!authenticated || reconnectTimer) return; + if (!authenticated || !multiplayerEnabled || reconnectTimer) return; reconnectTimer = window.setTimeout(() => { reconnectTimer = null; connect().catch(() => scheduleReconnect()); @@ -101,17 +136,17 @@ const scheduleReconnect = () => { }; const connect = async () => { - if (!authenticated) return; + if (!authenticated || !multiplayerEnabled) return; if (socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING) return; - setStatus('connecting', 'MULTIPLAYER • CONNECTING'); + setStatus('connecting'); const { ticket } = await requestSocketTicket(); const ws = new WebSocket(`${WS_BASE}/ws?ticket=${encodeURIComponent(ticket)}`); socket = ws; ws.addEventListener('open', () => { inputSequence = 0; - setStatus('online', 'MULTIPLAYER • ONLINE'); + setStatus('online'); emitConnection('online'); }); @@ -121,7 +156,8 @@ const connect = async () => { if (message.type === 'welcome') { selfId = message.playerId || selfId; - emitConnection('online', { worldSeed: message.worldSeed }); + worldSeed = message.worldSeed || worldSeed; + emitConnection('online'); return; } @@ -135,8 +171,8 @@ const connect = async () => { ? message.projectiles.filter((shot) => shot?.id) : []; playerCount = allPlayers.length; - const score = self ? ` • ${self.kills || 0}/${self.deaths || 0}` : ''; - setStatus('online', `MULTIPLAYER • ${playerCount}${score}`); + selfSnapshot = self; + setStatus('online'); window.dispatchEvent(new CustomEvent('airship:multiplayer-snapshot', { detail: { self, @@ -151,13 +187,19 @@ const connect = async () => { ws.addEventListener('close', () => { if (socket === ws) socket = null; playerCount = 0; - setStatus(authenticated ? 'connecting' : 'offline', authenticated ? 'MULTIPLAYER • RECONNECT' : 'MULTIPLAYER'); - emitConnection('offline'); - scheduleReconnect(); + selfSnapshot = null; + if (authenticated && multiplayerEnabled) { + setStatus('connecting'); + emitConnection('offline'); + scheduleReconnect(); + } else { + setStatus(authenticated ? 'disabled' : 'offline'); + emitConnection(authenticated ? 'disabled' : 'offline'); + } }); 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 logout = () => keycloak.logout({ redirectUri: `${window.location.origin}/` }); -statusButton.addEventListener('click', () => { - if (!authenticated) { - login(); +const setEnabled = (enabled) => { + multiplayerEnabled = Boolean(enabled); + 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; } - 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 = { @@ -199,6 +269,8 @@ window.airshipMultiplayer = { login, logout, connect, + setEnabled, + getState, getSelfId: () => selfId, isAuthenticated: () => authenticated, isConnected: () => socket?.readyState === WebSocket.OPEN, @@ -208,7 +280,7 @@ keycloak.onTokenExpired = () => { keycloak.updateToken(30).catch(() => { authenticated = false; if (socket) socket.close(); - setStatus('offline', 'MULTIPLAYER'); + setStatus('offline'); }); }; @@ -217,19 +289,37 @@ keycloak.init({ pkceMethod: 'S256', checkLoginIframe: false, silentCheckSsoRedirectUri: `${window.location.origin}/silent-check-sso.html`, -}).then((ok) => { +}).then(async (ok) => { authenticated = Boolean(ok); if (!authenticated) { - setStatus('offline', 'MULTIPLAYER'); + profile = null; + setStatus('offline'); emitConnection('signed-out'); 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(() => { - setStatus('error', 'MULTIPLAYER • RETRY'); + setStatus('error'); + emitConnection('error'); scheduleReconnect(); }); }).catch(() => { authenticated = false; - setStatus('error', 'MULTIPLAYER • LOGIN'); + profile = null; + setStatus('error'); + emitConnection('error'); }); diff --git a/src/player-settings.js b/src/player-settings.js new file mode 100644 index 0000000..6052bcc --- /dev/null +++ b/src/player-settings.js @@ -0,0 +1,372 @@ +const STORAGE_KEY = 'airship.player-settings.v1'; + +const DEFAULTS = { + showPlayerLabels: true, + screenShake: true, + reducedMotion: false, +}; + +const loadPreferences = () => { + try { + const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); + return { ...DEFAULTS, ...saved }; + } catch { + return { ...DEFAULTS }; + } +}; + +let preferences = loadPreferences(); +let multiplayerState = window.airshipMultiplayer?.getState?.() || {}; +let latestSelf = null; + +const overlay = document.createElement('div'); +overlay.id = 'player-settings-overlay'; +overlay.hidden = true; +overlay.innerHTML = ` +
+ +`; +document.body.appendChild(overlay); + +const style = document.createElement('style'); +style.textContent = ` + #player-settings-overlay { + position: fixed; + inset: 0; + z-index: 520; + color: #f0d9a0; + font-family: Georgia, 'Times New Roman', serif; + } + #player-settings-overlay[hidden] { display: none; } + .player-settings-backdrop { + position: absolute; + inset: 0; + background: rgba(2, 10, 15, .9); + backdrop-filter: blur(9px); + -webkit-backdrop-filter: blur(9px); + } + .player-settings-shell { + position: relative; + width: min(760px, calc(100vw - 18px)); + max-height: calc(100dvh - 18px); + margin: max(9px, env(safe-area-inset-top)) auto; + padding: 14px; + overflow: auto; + box-sizing: border-box; + border: 1px solid rgba(196, 140, 54, .64); + border-radius: 14px; + background: radial-gradient(circle at 50% 0%, rgba(20, 72, 84, .34), transparent 38%), #06161e; + box-shadow: 0 18px 60px rgba(0,0,0,.62); + overscroll-behavior: contain; + } + .player-settings-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 10px 14px; + border-bottom: 1px solid rgba(196, 140, 54, .34); + } + .player-settings-kicker, + .player-settings-label { + color: #adcfca; + font: 700 10px/1 system-ui, sans-serif; + letter-spacing: .18em; + } + .player-settings-header h1 { + margin: 4px 0 3px; + color: #fff0bd; + font-size: clamp(28px, 7vw, 46px); + line-height: .95; + } + .player-settings-header p { + margin: 0; + color: #9eb2b9; + font: 600 11px/1.3 system-ui, sans-serif; + } + .player-settings-close, + .player-settings-actions button { + min-height: 44px; + padding: 8px 14px; + border: 1px solid #9a6a2b; + border-radius: 9px; + background: linear-gradient(#174a50, #0a2930); + color: #ffe5a7; + font: 700 11px/1 Georgia, serif; + touch-action: manipulation; + } + .player-settings-content { + display: grid; + gap: 10px; + padding-top: 12px; + } + .player-settings-card { + padding: 14px; + border: 1px solid rgba(194, 142, 61, .46); + border-radius: 12px; + background: linear-gradient(180deg, rgba(10, 52, 61, .88), rgba(4, 24, 31, .96)); + } + .player-settings-profile { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 12px; + } + .player-avatar { + width: 58px; + height: 58px; + display: grid; + place-items: center; + border: 2px solid #a06b2d; + border-radius: 50%; + background: radial-gradient(circle, #1b6062, #082b34 68%); + box-shadow: inset 0 0 0 2px rgba(239, 197, 104, .2); + font-size: 28px; + } + .player-profile-copy { display: grid; gap: 3px; min-width: 0; } + .player-profile-copy strong { color: #fff0bd; font-size: 21px; overflow: hidden; text-overflow: ellipsis; } + .player-profile-copy > span:last-child { color: #9db4ba; font: 600 11px/1.3 system-ui,sans-serif; } + .player-stat-grid { + grid-column: 1 / -1; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 7px; + } + .player-stat-grid div { + display: grid; + gap: 3px; + padding: 9px; + border: 1px solid rgba(194, 142, 61, .3); + border-radius: 8px; + background: rgba(2, 18, 24, .72); + } + .player-stat-grid span { color: #8ca9ae; font:700 9px/1 system-ui,sans-serif; letter-spacing:.08em; } + .player-stat-grid strong { color:#f7dfa1; font:700 12px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace; } + .player-settings-section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding-bottom: 8px; + } + .player-settings-section-head > div { display:grid; gap:3px; } + .player-settings-section-head strong { color:#fff0bd; font-size:15px; } + .player-settings-section-head span { color:#9db4ba; font:600 10px/1.3 system-ui,sans-serif; } + .player-connection-chip { + padding: 6px 9px; + border: 1px solid rgba(194,142,61,.42); + border-radius: 999px; + color: #f7dfa1 !important; + white-space: nowrap; + } + .player-setting-row { + min-height: 58px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + border-top: 1px solid rgba(194,142,61,.18); + color: #f0d9a0; + } + .player-setting-row > span { display:grid; gap:3px; } + .player-setting-row strong { color:#f5dda0; font:700 13px/1.2 system-ui,sans-serif; } + .player-setting-row small { color:#91aab0; font:600 10px/1.3 system-ui,sans-serif; } + .player-setting-row input { + width: 46px; + height: 26px; + flex: 0 0 auto; + accent-color: #1b6b69; + } + .player-settings-account { + display:flex; + align-items:center; + justify-content:space-between; + gap:12px; + } + .player-settings-account > div:first-child { display:grid; gap:4px; } + .player-settings-account strong { color:#f5dda0; font:700 12px/1.25 system-ui,sans-serif; } + .player-settings-actions { display:flex; flex-wrap:wrap; justify-content:flex-end; gap:7px; } + body.player-settings-open #mobile-controls, + body.player-settings-open #multiplayer-status { visibility: hidden; } + html.airship-reduced-motion *, + html.airship-reduced-motion *::before, + html.airship-reduced-motion *::after { + scroll-behavior: auto !important; + animation-duration: .001ms !important; + animation-iteration-count: 1 !important; + transition-duration: .001ms !important; + } + @media (max-width: 560px) { + .player-settings-shell { min-height: calc(100dvh - 18px); } + .player-settings-account { align-items:flex-start; flex-direction:column; } + .player-settings-actions { width:100%; } + .player-settings-actions button { flex:1 1 140px; } + } +`; +document.head.appendChild(style); + +const fields = Object.fromEntries( + [...overlay.querySelectorAll('[data-player-settings]')].map((el) => [el.dataset.playerSettings, el]), +); +const settingInputs = Object.fromEntries( + [...overlay.querySelectorAll('[data-player-setting]')].map((el) => [el.dataset.playerSetting, el]), +); + +const persistPreferences = () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences)); + document.documentElement.classList.toggle('airship-reduced-motion', Boolean(preferences.reducedMotion)); + window.dispatchEvent(new CustomEvent('airship:settings-changed', { + detail: { ...preferences }, + })); +}; + +const render = () => { + multiplayerState = window.airshipMultiplayer?.getState?.() || multiplayerState || {}; + const profile = multiplayerState.profile || {}; + const connected = Boolean(multiplayerState.connected); + const authenticated = Boolean(multiplayerState.authenticated); + const enabled = multiplayerState.enabled !== false; + const self = multiplayerState.self || latestSelf || null; + + fields.name.textContent = profile.name || self?.name || (authenticated ? 'Captain' : 'Solo Captain'); + fields.session.textContent = authenticated + ? connected ? 'Signed in • shared skies connected' : enabled ? 'Signed in • reconnecting' : 'Signed in • multiplayer disabled' + : 'Not signed in • local flight'; + fields.world.textContent = multiplayerState.worldSeed || 'SKYWARD-001'; + fields.players.textContent = connected ? multiplayerState.playerCount || 1 : 1; + fields.kd.textContent = `${self?.kills || 0} / ${self?.deaths || 0}`; + fields.connection.textContent = connected ? `ONLINE ${multiplayerState.playerCount || 1}` : authenticated && enabled ? 'CONNECTING' : authenticated ? 'OFFLINE' : 'SOLO'; + fields['account-summary'].textContent = authenticated + ? `Arrowstack account • ${profile.name || self?.name || 'Captain'}` + : 'Play solo or sign in with Arrowstack.'; + + settingInputs.multiplayerEnabled.checked = enabled; + settingInputs.multiplayerEnabled.disabled = !authenticated; + settingInputs.showPlayerLabels.checked = Boolean(preferences.showPlayerLabels); + settingInputs.screenShake.checked = Boolean(preferences.screenShake); + settingInputs.reducedMotion.checked = Boolean(preferences.reducedMotion); + + const authButton = overlay.querySelector('[data-player-settings-action="auth"]'); + authButton.textContent = authenticated ? 'SIGN OUT' : 'SIGN IN'; + const accountButton = overlay.querySelector('[data-player-settings-action="account"]'); + accountButton.disabled = !authenticated; +}; + +const open = () => { + render(); + overlay.hidden = false; + document.body.classList.add('player-settings-open'); +}; +const close = () => { + overlay.hidden = true; + document.body.classList.remove('player-settings-open'); +}; + +for (const [key, input] of Object.entries(settingInputs)) { + input.addEventListener('change', () => { + if (key === 'multiplayerEnabled') { + window.airshipMultiplayer?.setEnabled?.(input.checked); + render(); + return; + } + preferences = { ...preferences, [key]: input.checked }; + persistPreferences(); + render(); + }); +} + +overlay.querySelector('.player-settings-close').addEventListener('click', close); +overlay.querySelector('.player-settings-backdrop').addEventListener('click', close); +overlay.querySelector('[data-player-settings-action="auth"]').addEventListener('click', () => { + if (window.airshipMultiplayer?.isAuthenticated?.()) window.airshipMultiplayer.logout(); + else window.airshipMultiplayer?.login?.(); +}); +overlay.querySelector('[data-player-settings-action="account"]').addEventListener('click', () => { + if (!window.airshipMultiplayer?.isAuthenticated?.()) return; + window.open('https://auth.arrowstack.dev/realms/dev/account/', '_blank', 'noopener,noreferrer'); +}); + +window.addEventListener('airship:settings-open', open); +window.addEventListener('airship:multiplayer-state', () => { + if (!overlay.hidden) render(); +}); +window.addEventListener('airship:multiplayer-snapshot', (event) => { + latestSelf = event.detail?.self || latestSelf; + if (!overlay.hidden) render(); +}); +window.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && !overlay.hidden) close(); +}); + +window.airshipSettings = { + get: () => ({ ...preferences }), + open, + close, +}; + +persistPreferences(); diff --git a/tests/wiring.test.mjs b/tests/wiring.test.mjs index b08aa76..346a19a 100644 --- a/tests/wiring.test.mjs +++ b/tests/wiring.test.mjs @@ -4,6 +4,32 @@ import { readFile } from 'node:fs/promises'; 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 () => { const [index, game, multiplayer] = await Promise.all([ read('index.html'),