Wire player settings into account and multiplayer
This commit is contained in:
parent
dee38cf602
commit
4c990597b7
@ -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';
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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'),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user