Files
ark-stronghold-protocol/public/js/main.js
T
sganggs 0a15c6e41d feedback1 integrate: ticker lines by broadcast priority; AI 托管 keeps a human's items out of temp
- the strip queues by the official broadcast priority (BOSS_HIT 30 > CHAR_DAMAGE 20 > SHOP_LEVEL 11 > ...),
  match-flow notices at 25, never cutting the line on screen short
- on a human seat under AI 托管 an item left in temp with a full hand gets a hand slot (a bench operator sold)
2026-10-03 07:57:28 +08:00

361 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;
// its broadcast priority: the strip plays the highest first (ui/ticker.js enqueueTickerLines)
const priority = Number.isFinite(msg.priority) ? msg.priority : 0;
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, priority }] }));
});
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 = '启动失败,请刷新页面重试';
});