Files
ark-stronghold-protocol/public/js/main.js
T
sganggs 93afe2ccf2 feedback1c WJ: ticker queue helpers; a newer leader-damage line replaces the older one
Review follow-up for the Hidden Core broadcast fix. The strip's queue rules are
now pure, exported helpers (tickerSupersedes, enqueueTickerLines,
pruneTickerLines, nextTickerLine) with unit tests across a round 14 -> 15
change, instead of source regex checks only.

A player's newer BOSS_HIT line of the same round replaces their older one,
queued or on screen (it names a higher threshold), so a burst finish shows its
highest milestone before the round ends and the last round's lines are dropped.
Lines now carry playerId from main.js. The solo match test is labelled 绝境
(HARD), the difficulty it runs.
2026-10-03 03:16:44 +08:00

359 lines
16 KiB
JavaScript

// Client entry: boot (fonts, identity, socket), net → store wiring, router, global overlays.
//
// Router (derived from the store, no URL routes):
// not entered → Title
// m.public.phase ≠ LOBBY → Game (screens/game.js: briefing / band draft / match / result by phase)
// room.inMatch → Game (match starting, m.public on its way)
// in a room → Room
// otherwise → Lobby
// Deep link `?room=CODE`: remembered at boot, auto-joined once the player has entered and the
// session is online (after a short grace period in case the server restores a room on resume).
// Reloading a tab that already passed the title re-enters automatically (sessionStorage flag) and
// resumes the server session with the saved token; stale room/match state is dropped if the
// server does not re-push it within RESTORE_GRACE_MS after `welcome`. Boot waits for
// `identity.init()` (which token this tab may use without stealing another live tab's session)
// before the first connect; a page restored from the back/forward cache reloads.
// A `welcome` with a NEW playerId (the server restarted / the session expired) while a room or match was on screen:
// toast 「服务器会话已重置,上一局模拟已结束」 (store.js sessionResetNotice) and return to the lobby with nothing stale.
//
// Shared modules are imported relatively ('../../shared/…' resolves to /shared/… in the browser).
// Multi-device support (ui/device.js + css/devices.css): feature classes on <html>, no page zoom, safe areas, rotation
// re-layout; ui/compat.js polyfills are imported before anything else.
// 干员调配 (DESIGN §16): an overlay over any route (<LoadoutHost/>, opened from lobby / room / briefing); its loadout is
// kept in sync with the server by installLoadoutSync (room.loadout after every welcome and edit).
// Game data: every text of the game is static data (/data/*.json) downloaded once per page; the in-match files are
// warmed in the background as soon as the player is in a room (warmGameData), before the match needs them.
// Polyfills first (older Safari / Firefox ESR): every module evaluated after this one sees them.
import './ui/compat.js';
import { render } from '../vendor/preact.module.js';
import { useErrorBoundary } from '../vendor/hooks.module.js';
import { html, UiHosts, Button, MicroLabel, closeAllDialogs } from './ui/components.js';
import { ConnectionBanner } from './ui/connBanner.js';
import { ToastHost, toast, toastError, describeError } from './ui/toasts.js';
import { net, identity, NetError } from './net.js';
import { store, useStore, emptyMatch, selectRoute, sessionResetNotice } from './store.js';
import { data } from './data.js';
import { GAME_FILES } from './ui/gameComponents.js';
import { TitleScreen, sanitizeName } from './screens/title.js';
import { LobbyScreen, rememberRoom, parseRoomParam } from './screens/lobby.js';
import { RoomScreen } from './screens/room.js';
import { GameScreen } from './screens/game.js';
import { installAudio } from './audio.js';
import { settingsStore } from './ui/settings.js';
import { GuideHost } from './ui/guide.js';
import { installDeviceSupport } from './ui/device.js';
import { LoadoutHost } from './screens/loadout.js';
import { installLoadoutSync } from './ui/loadoutSync.js';
const RESTORE_GRACE_MS = 1500;
const JOIN_DELAY_MS = 350;
const TICKER_KEEP = 20;
const EMOTE_KEEP = 20;
const SCREENS = { title: TitleScreen, lobby: LobbyScreen, room: RoomScreen, game: GameScreen };
/** Copy of a server message without transport fields. */
function payload(msg) {
const { t, rid, ...rest } = msg; // eslint-disable-line no-unused-vars
return rest;
}
function clearRoomParam() {
try {
const url = new URL(location.href);
if (!url.searchParams.has('room')) return;
url.searchParams.delete('room');
history.replaceState(history.state, '', url.pathname + (url.search || '') + url.hash);
} catch { /* ignore */ }
}
// ---- net → store wiring ---------------------------------------------------------------------------
let seq = 0;
let welcomeAt = 0;
let roomStateAt = 0;
let matchAt = 0;
let restoreTimer = null;
let joinTimer = null;
let joinInFlight = false;
function clearPendingJoin() {
store.patch('ui', { pendingJoin: null });
clearRoomParam();
}
/** Auto-join the deep-linked room once entered + online (idempotent). */
function schedulePendingJoin() {
clearTimeout(joinTimer);
joinTimer = setTimeout(async () => {
const s = store.get();
const code = s.ui.pendingJoin;
if (!code || joinInFlight || !s.session.entered || net.status !== 'online') return;
if (s.room) {
if (s.room.code !== code) toast('你已在其他同盟中,请先离开当前同盟', 'warn');
clearPendingJoin();
return;
}
joinInFlight = true;
try {
await net.request('room.join', { code });
} catch (err) {
toastError(err);
} finally {
joinInFlight = false;
clearPendingJoin();
}
}, JOIN_DELAY_MS);
}
/** End the post-resume "syncing" state early once the server re-pushed what we were showing. */
function maybeFinishRestore() {
const s = store.get();
if (!s.ui.restoring) return;
const roomOk = !s.room || roomStateAt >= welcomeAt;
const matchOk = !s.match.public || matchAt >= welcomeAt || !s.room?.inMatch;
if (roomOk && matchOk) {
clearTimeout(restoreTimer);
store.patch('ui', { restoring: false });
}
}
/**
* Leave whatever room / match was on screen for the lobby with nothing stale left behind: open imperative dialogs,
* the post-resume restore timer, ticker lines and emote bubbles of the old match (the battle runner and the 暂离 flag
* follow the store themselves).
*/
function backToLobby() {
clearTimeout(restoreTimer);
const s = store.get();
if (s.room || s.match.public) closeAllDialogs();
store.set({ room: null, match: emptyMatch(), ticker: [], emotes: [] });
store.patch('ui', { restoring: false });
}
function onWelcome(msg) {
identity.saveToken(msg.token);
const prev = store.get();
const prevId = prev.me.playerId;
const name = typeof msg.name === 'string' && msg.name ? msg.name : prev.me.name;
store.set({ me: { playerId: msg.playerId ?? null, name, token: typeof msg.token === 'string' ? msg.token : null } });
welcomeAt = Date.now();
if (prevId != null && prevId !== msg.playerId) {
// A brand-new server session (the server restarted — crashed / killed, so no room.closed arrived — or this session
// expired on it): whatever we showed before is gone — back to the lobby cleanly and say why.
const notice = sessionResetNotice(prev, msg.playerId);
backToLobby();
if (notice) toast(notice, 'warn', { ttl: 7000 });
} else if (prev.room || prev.match.public) {
// Resumed session: the server re-pushes room/match state; drop whatever it doesn't.
store.patch('ui', { restoring: true });
clearTimeout(restoreTimer);
restoreTimer = setTimeout(() => {
const s = store.get();
const patch = {};
if (s.room && roomStateAt < welcomeAt) patch.room = null;
if (s.match.public && matchAt < welcomeAt) patch.match = emptyMatch();
store.set(patch);
store.patch('ui', { restoring: false });
}, RESTORE_GRACE_MS);
}
schedulePendingJoin();
}
function onRoomState(msg) {
const room = payload(msg);
roomStateAt = Date.now();
const myId = store.get().me.playerId;
const seats = Array.isArray(room.seats) ? room.seats : [];
if (myId != null && seats.length && !seats.some((s) => s && s.playerId === myId)) {
// We are no longer seated (kicked / left elsewhere).
if (store.get().room) toast('你已不在该同盟中', 'warn');
store.set({ room: null, match: emptyMatch() });
return;
}
const prevRoom = store.get().room;
// A (new) match starts: forget the previous match's state so stale results never show.
if (room.inMatch && !(prevRoom && prevRoom.inMatch && prevRoom.code === room.code)) store.set({ match: emptyMatch() });
store.set({ room });
if (room.mode === 'coop' && typeof room.code === 'string') rememberRoom(room.code);
maybeFinishRestore();
}
const CLOSE_REASON = {
// 'timeout' = this player was removed after staying disconnected past the lobby grace (server/lobby.js)
host_left: '创建者已离开,同盟已解散', timeout: '由于长时间断开连接,你已离开同盟', empty: '同盟已解散',
kicked: '你已被移出同盟', ended: '模拟已结束', expired: '同盟已过期', shutdown: '服务器维护中,同盟已关闭',
};
function wireNet() {
net.on('status', (snap) => {
const cur = store.get().connection;
store.set({
connection: {
status: snap.status, ping: snap.ping, attempt: snap.attempt, retryAt: snap.retryAt,
lastError: snap.lastError, everOnline: cur.everOnline || snap.status === 'online',
},
});
});
net.on('clock', (c) => store.set({ clock: { offset: c.offset, rtt: c.rtt, synced: c.synced } }));
net.on('welcome', onWelcome);
net.on('helloError', (err) => toastError(err));
net.on('replaced', () => toast('该身份已在其他页面登录,本页已断开', 'warn', { ttl: 6000 }));
net.on('unhandledError', (err) => toastError(err));
net.on('room.state', onRoomState);
net.on('room.closed', (msg) => {
backToLobby();
toast(CLOSE_REASON[msg.reason] || (typeof msg.reason === 'string' && msg.reason.length < 60 ? `同盟已关闭:${msg.reason}` : '同盟已关闭'), 'warn');
});
net.on('m.public', (msg) => { matchAt = Date.now(); store.patch('match', { public: payload(msg) }); maybeFinishRestore(); });
net.on('m.private', (msg) => { matchAt = Date.now(); store.patch('match', { private: payload(msg) }); });
net.on('m.field', (msg) => store.patch('match', { field: payload(msg) }));
net.on('m.result', (msg) => store.patch('match', { result: payload(msg) }));
net.on('m.toast', (msg) => {
const kind = ['info', 'success', 'warn', 'error'].includes(msg.kind) ? msg.kind : 'info';
toast(msg.text, kind);
});
net.on('m.ticker', (msg) => {
if (typeof msg.text !== 'string') return;
// type, player + the round it came in: a BOSS_HIT line is dropped once its boss round is over and superseded by the
// same player's next one (ui/ticker.js tickerLineLive / tickerSupersedes)
const type = typeof msg.type === 'string' ? msg.type : null;
const playerId = typeof msg.playerId === 'string' ? msg.playerId : null;
store.set((s) => ({ ticker: [...s.ticker.slice(-(TICKER_KEEP - 1)), { id: ++seq, text: msg.text, at: Date.now(), type, playerId, round: s.match?.public?.round ?? null }] }));
});
net.on('m.emote', (msg) => {
store.set((s) => ({ emotes: [...s.emotes.slice(-(EMOTE_KEEP - 1)), { seq: ++seq, playerId: msg.playerId, id: msg.id, at: Date.now() }] }));
});
// Entering (title → lobby) while already online also needs the deep-link join.
store.subscribe((s, prev) => {
if (s.session.entered && !prev.session.entered) schedulePendingJoin();
// in a room (co-op or solo, also a resumed one) a match is near: its data starts downloading
if (s.room && !prev.room) warmGameData();
});
}
/**
* Download every data file of the match UI (gameComponents GAME_FILES: operators, skills, bonds, items, enemies, 特质 …)
* in the background once the player is in a room — a match is near (the lobby alone never downloads them). The game's
* texts are static data loaded once per page — never fetched during a match — and the match screen waits for these
* files, so with them warmed it opens at once and no text ever appears late (user playtest #3 item 9). Idempotent (the
* data store shares each file's promise).
*/
function warmGameData() {
const go = () => { data.loadAll(GAME_FILES).catch(() => {}); };
if (typeof globalThis.requestIdleCallback === 'function') globalThis.requestIdleCallback(go, { timeout: 2500 });
else setTimeout(go, 600);
}
// ---- UI chrome (the connection banner lives in ui/connBanner.js) -----------------------------------
function ScreenCrashed({ error, reset }) {
return html`<div class="screen crash">
<div class="crash__box brackets">
<${MicroLabel} tone="mint">SYSTEM FAULT<//>
<h2>界面发生错误</h2>
<p class="t-lo">${String(error?.message || error).slice(0, 200)}</p>
<${Button} variant="primary" icon="refresh" onClick=${reset}>重新加载界面<//>
</div>
</div>`;
}
function App() {
const route = useStore(selectRoute);
const [error, resetError] = useErrorBoundary((err) => console.error('[ui] screen crashed', err));
const Screen = SCREENS[route] || LobbyScreen;
return html`<div class="app-root">
<div class="app-bg" aria-hidden="true"></div>
${error ? html`<${ScreenCrashed} error=${error} reset=${resetError} />` : html`<${Screen} key=${route} />`}
<${ConnectionBanner} />
<${ToastHost} />
<${UiHosts} />
<${GuideHost} />
<${LoadoutHost} />
</div>`;
}
// ---- boot -----------------------------------------------------------------------------------------
async function waitForFonts(ms) {
const fonts = document.fonts;
if (!fonts || typeof fonts.load !== 'function') return;
const loads = [
fonts.load('900 1em "Noto Sans SC"', '卫戍协议盟约'),
fonts.load('700 1em "Noto Sans SC"', '开始'),
fonts.load('700 1em Bender', '0123456789'),
fonts.load('700 1em Rajdhani', '0123456789'),
].map((p) => p.catch(() => null));
await Promise.race([Promise.all(loads), new Promise((r) => setTimeout(r, ms))]);
}
function installGlobalErrorHandlers() {
window.addEventListener('unhandledrejection', (ev) => {
const err = ev.reason;
// Media autoplay/abort rejections (audio.play() before a user gesture, interrupted loads) are
// expected browser behaviour, not app errors: log quietly, never toast.
if (err && (err.name === 'NotAllowedError' || err.name === 'AbortError')) { console.warn('[app] ignored rejection', err.name); return; }
console.error('[app] unhandled rejection', err);
if (err instanceof NetError) toastError(err);
else toast(`发生意外错误:${describeError(err)}`.slice(0, 120), 'error');
});
window.addEventListener('error', (ev) => {
if (!(ev instanceof ErrorEvent)) return; // resource load errors are not script errors
console.error('[app] uncaught error', ev.error || ev.message);
});
}
async function boot() {
installGlobalErrorHandlers();
// touch / hover / fullscreen classes, zoom-gesture blocking, rotation re-layout (ui/device.js, css/devices.css)
installDeviceSupport();
// A page restored from the back/forward cache has a dead socket and a stale token choice: start over.
window.addEventListener('pageshow', (ev) => { if (ev.persisted) location.reload(); });
// Pick this tab's reconnect token (asks other live tabs; ≤150 ms) while fonts load.
const identityReady = identity.init();
const pendingJoin = parseRoomParam(location.search);
const savedName = sanitizeName(identity.loadName());
const entered = identity.wasEntered() && !!savedName;
store.set((s) => ({
me: { ...s.me, name: savedName },
session: { entered },
ui: { ...s.ui, pendingJoin },
}));
wireNet();
installLoadoutSync({ net });
net.attachBrowserHooks();
// Audio: unlock on first gesture, BGM follows the route / match phase (js/audio.js).
installAudio({ getManifest: () => data.get('assets'), subscribe: store.subscribe, getState: store.get, selectRoute, settings: settingsStore.get() });
data.load('assets').catch(() => {});
// Warm the data cache in the background (missing files are tolerated).
data.loadAll('config').catch(() => {});
// Optional local-client art manifest (emotes, tutorial pages, official UI sprites; DESIGN §13).
data.load('local').catch(() => {});
const connectWhenReady = identityReady.then(() => {
if (entered) net.setName(savedName);
else net.connect();
});
await Promise.all([waitForFonts(1200), connectWhenReady]);
const root = document.getElementById('app');
render(html`<${App} />`, root);
const splash = document.getElementById('boot');
if (splash) {
splash.classList.add('is-done');
setTimeout(() => splash.remove(), 300);
}
globalThis.__SP__ = { store, net, data, version: 1 };
}
boot().catch((err) => {
console.error('[app] boot failed', err);
const el = document.getElementById('boot-err');
if (el) el.textContent = '启动失败,请刷新页面重试';
});