0.2.6-dev: merge claude/bw-l01 — host bandwidth layers 0 and 1 (no BGM download while the music is silent, WebSocket permessage-deflate, lossless WebP 玩法说明 pages, traffic counters, content-hashed asset URLs with immutable caching, brotli q5) on top of the 0.2.5 release; its note becomes DESIGN §31 in docs/history/0.2.6.md, CHANGELOG 0.2.6-dev, version 0.2.6-dev; golden: unchanged
This commit is contained in:
commit
3d5d6b380d
47 files changed
+2325
-136
No files matched your search
+27
-4
@@ -13,7 +13,9 @@
|
||||
// never block or warn; everything requested before that is remembered (BGM) or dropped (SFX).
|
||||
// - Channels: master → { bgm, sfx, voice } gains; volumes from settings (0..1) + mute. Tab hidden ⇒ suspend.
|
||||
// - BGM: `intro` then `loop` (1 s crossfade); switching tracks fades out/in (0.8 s). The same loop URL
|
||||
// keeps playing across phases (prep and combat share a track).
|
||||
// keeps playing across phases (prep and combat share a track). While the music is silent — muted, or the BGM volume
|
||||
// at 0 — no track is downloaded (≈ 14 MB a match): the wanted one is remembered, another one playing stops, and the
|
||||
// track the screen wants then starts once the music is audible again (`setVolumes`; host bandwidth, layer 0).
|
||||
// - 开战 BGM: `bgm.combatAlts` are the mode's own battle tracks (塞壬唱片 骑士之日 / 无畏者). The track is fixed per
|
||||
// round, not drawn: 无畏者 through rounds 1–7 and 骑士之日 from round 8 on (`combatTrackFor`, the official
|
||||
// schedule), so every client of a match hears the same one, a fight never switches track halfway through and the
|
||||
@@ -68,6 +70,7 @@
|
||||
import { sanitizeVoiceOverrides, voiceLangFor } from './voicePrefs.js';
|
||||
import { PHASE } from '../../shared/constants.js';
|
||||
import { mediaUrl } from './media.js';
|
||||
import { unhashedUrl } from '../../shared/assetPaths.js';
|
||||
|
||||
const MAX_VOICES = 8;
|
||||
const UNIT_COOLDOWN_MS = 160;
|
||||
@@ -191,11 +194,14 @@ export function unitSoundClass(info) {
|
||||
return 'char';
|
||||
}
|
||||
|
||||
/** URL of the generic token death sound (b_char_tokendead): sfx.battle.tokenDie, else next to charDie. */
|
||||
/**
|
||||
* URL of the generic token death sound (b_char_tokendead): sfx.battle.tokenDie, else next to charDie — without charDie's
|
||||
* content-hash segment, which names charDie's bytes, not this file's (the plain URL is cached for a day).
|
||||
*/
|
||||
function tokenDieUrl(manifest) {
|
||||
const b = manifest?.audio?.sfx?.battle;
|
||||
if (typeof b?.tokenDie === 'string') return b.tokenDie;
|
||||
return typeof b?.charDie === 'string' && /b_char_dead\.mp3$/.test(b.charDie) ? b.charDie.replace(/b_char_dead\.mp3$/, 'b_char_tokendead.mp3') : null;
|
||||
return typeof b?.charDie === 'string' && /b_char_dead\.mp3$/.test(b.charDie) ? unhashedUrl(b.charDie).replace(/b_char_dead\.mp3$/, 'b_char_tokendead.mp3') : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -662,6 +668,7 @@ export class AudioManager {
|
||||
*/
|
||||
setVolumes(v) {
|
||||
const n = (x, d) => (Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : d);
|
||||
const wasSilent = this.bgmSilent;
|
||||
this.volumes = {
|
||||
bgm: n(v?.bgm, this.volumes.bgm),
|
||||
sfx: n(v?.sfx, this.volumes.sfx),
|
||||
@@ -670,8 +677,13 @@ export class AudioManager {
|
||||
};
|
||||
this._applyVolumes();
|
||||
if (this.volumes.muted || this.volumes.sfx <= 0) this.stopBondCue();
|
||||
// the music became audible again: download and start the track the screen wants now (none was fetched meanwhile)
|
||||
if (wasSilent && !this.bgmSilent && this.ctx && this.wantBgm) { const k = this.wantBgm; this.wantBgm = null; this.playBgm(k); }
|
||||
}
|
||||
|
||||
/** Is the music silent — muted, or the BGM volume at 0? Then no BGM is downloaded (playBgm). */
|
||||
get bgmSilent() { return this.volumes.muted || this.volumes.bgm <= 0; }
|
||||
|
||||
/**
|
||||
* The voice dub (settings 语音语言): 'jp' plays `audio.voiceJp`, anything else `audio.voice` (中文, the default). The
|
||||
* line on air finishes in its own dub; the next one follows the setting.
|
||||
@@ -766,12 +778,23 @@ export class AudioManager {
|
||||
// ---- BGM ------------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Switch BGM (null stops). Same loop URL ⇒ no restart.
|
||||
* Switch BGM (null stops). Same loop URL ⇒ no restart. Before the first gesture, or while the music is silent
|
||||
* (`bgmSilent`), the key is only remembered (`wantBgm`) and nothing is downloaded; a different track that is still
|
||||
* playing stops. `_unlock` / `setVolumes` start the remembered track once it can be heard.
|
||||
* @param {string|null} key see bgmKeyFor
|
||||
*/
|
||||
playBgm(key) {
|
||||
try {
|
||||
if (!this.ctx) { this.wantBgm = key; return; }
|
||||
if (this.bgmSilent) {
|
||||
this.wantBgm = key;
|
||||
const silentTrack = resolveBgm(this.getManifest(), key);
|
||||
if (this.bgm && silentTrack && this.bgm.loopUrl === silentTrack.loop) { this.bgm.key = key; return; }
|
||||
++this.bgmToken; // a start still waiting for its download stays silent too
|
||||
this._fadeOutBgm();
|
||||
return;
|
||||
}
|
||||
this.wantBgm = null;
|
||||
const track = resolveBgm(this.getManifest(), key);
|
||||
if (this.bgm && track && this.bgm.loopUrl === track.loop) { this.bgm.key = key; return; }
|
||||
if (!track && !this.bgm) return;
|
||||
|
||||
+82
-4
@@ -5,19 +5,23 @@
|
||||
// the room code and the reconnect token replaced and the page's host cut from every URL. A secret is a whole token in
|
||||
// the prose and an exact string value in the attached battle JSON, so a name never rewrites a longer id. The battle
|
||||
// on screen can be attached as its BattleSpec (the b.start a browser receives: player ids, no names), which the dev
|
||||
// tools replay.
|
||||
// tools replay. The report's `traffic:` line sums what this page downloaded (Resource Timing `transferSize`: bytes
|
||||
// on the wire, 0 for a response from the browser cache) by the categories of shared/assetPaths.js trafficCategory,
|
||||
// other sites apart as `external` (host bandwidth, layer 0; the server's own counters: /healthz?traffic=1).
|
||||
//
|
||||
// recordError(source, err, detail?) source: 'error' (uncaught) | 'rejection' (unhandled promise) | 'sim' (a content
|
||||
// error the battle isolated) | 'runner' (the local battle runner) | 'request'
|
||||
// (a failed request or error push the player was shown)
|
||||
// errorLog() / errorCount() / clearErrorLog()
|
||||
// setBattleSource(fn) the battle runner registers how to read the battle on screen
|
||||
// installTrafficMeter() / recordResourceEntry(entry) / trafficSummary() / clearTraffic() the page's download totals
|
||||
// buildDiagnostics(input) the report text (pure: the state, the environment and the battle are passed in)
|
||||
// diagnosticsText({ state, settings, attachBattle }) the report of this page (this browser, the battle on screen)
|
||||
//
|
||||
// Report keys are English on purpose: the text is read by developers and searched in issues, whatever the UI language.
|
||||
|
||||
import { APP_VERSION } from '../../shared/constants.js';
|
||||
import { TRAFFIC_CATEGORIES, trafficCategory } from '../../shared/assetPaths.js';
|
||||
|
||||
export const LOG_MAX = 40;
|
||||
export const STACK_LINES = 6;
|
||||
@@ -87,6 +91,78 @@ export const errorLog = () => log.map((e) => ({ ...e }));
|
||||
export const errorCount = () => total;
|
||||
export function clearErrorLog() { log.length = 0; total = 0; dropped = 0; }
|
||||
|
||||
// ---- traffic: what this page downloaded ---------------------------------------------------------------------
|
||||
|
||||
/** @type {Map<string, { requests: number, bytes: number, cached: number }>} category → totals */
|
||||
const traffic = new Map();
|
||||
let trafficMeter = null;
|
||||
|
||||
/**
|
||||
* Add one Resource Timing entry (a PerformanceResourceTiming, or the page's own navigation entry) to the totals: bytes
|
||||
* on the wire (`transferSize`; headers included, 0 when the browser cache answered — counted as `cached`). A same-origin
|
||||
* URL counts under trafficCategory(path), another site's under `external`; data: / blob: URLs are not downloads.
|
||||
* @param {{ name?: string, transferSize?: number, decodedBodySize?: number }} entry
|
||||
* @param {string} [origin] the page's origin
|
||||
*/
|
||||
export function recordResourceEntry(entry, origin = globalThis.location?.origin) {
|
||||
if (!entry || typeof entry.name !== 'string') return;
|
||||
let u;
|
||||
try { u = new URL(entry.name, origin || 'http://localhost'); } catch { return; }
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') return;
|
||||
const cat = origin && u.origin !== origin ? 'external' : trafficCategory(u.pathname);
|
||||
let t = traffic.get(cat);
|
||||
if (!t) traffic.set(cat, (t = { requests: 0, bytes: 0, cached: 0 }));
|
||||
const bytes = Number(entry.transferSize) || 0;
|
||||
t.requests++;
|
||||
t.bytes += bytes;
|
||||
if (bytes === 0 && (Number(entry.decodedBodySize) || 0) > 0) t.cached++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start summing this page's downloads (main.js at boot): the entries already in the Resource Timing buffer, then
|
||||
* every new one (a PerformanceObserver also gets the entries a full buffer drops). False where unsupported.
|
||||
* @param {any} [win]
|
||||
*/
|
||||
export function installTrafficMeter(win = globalThis) {
|
||||
if (trafficMeter) return true;
|
||||
const PO = win?.PerformanceObserver;
|
||||
if (typeof PO !== 'function') return false;
|
||||
try {
|
||||
const origin = win.location?.origin;
|
||||
for (const e of win.performance?.getEntriesByType?.('navigation') || []) recordResourceEntry(e, origin);
|
||||
trafficMeter = new PO((list) => { for (const e of list.getEntries()) recordResourceEntry(e, origin); });
|
||||
trafficMeter.observe({ type: 'resource', buffered: true });
|
||||
return true;
|
||||
} catch {
|
||||
trafficMeter = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The totals so far: `{ [category]: { requests, bytes, cached } }` in report order, plus `total`. */
|
||||
export function trafficSummary() {
|
||||
const out = {};
|
||||
const total = { requests: 0, bytes: 0, cached: 0 };
|
||||
for (const cat of [...TRAFFIC_CATEGORIES, 'external']) {
|
||||
const t = traffic.get(cat);
|
||||
if (!t) continue;
|
||||
out[cat] = { ...t };
|
||||
if (cat !== 'external') { total.requests += t.requests; total.bytes += t.bytes; total.cached += t.cached; }
|
||||
}
|
||||
out.total = total;
|
||||
return out;
|
||||
}
|
||||
export function clearTraffic() { traffic.clear(); }
|
||||
|
||||
const sizeOf = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)} MB` : n >= 1e3 ? `${Math.round(n / 1e3)} KB` : `${n} B`);
|
||||
/** `assets 9.7 MB (254, 41 cached) | … | host total 21.4 MB (869)` — the report's traffic line; null when nothing was recorded. */
|
||||
export function trafficLine(summary) {
|
||||
if (!summary || !summary.total || !(summary.total.requests > 0)) return null;
|
||||
const parts = Object.entries(summary).filter(([k]) => k !== 'total')
|
||||
.map(([k, t]) => `${k} ${sizeOf(t.bytes)} (${t.requests}${t.cached ? `, ${t.cached} cached` : ''})`);
|
||||
return `${parts.join(' | ')} | host total ${sizeOf(summary.total.bytes)} (${summary.total.requests})`;
|
||||
}
|
||||
|
||||
let battleSource = null;
|
||||
/** @param {() => ({ battleId: string, fieldId?: string, kind?: string, spec: object, time?: number }|null)} fn */
|
||||
export function setBattleSource(fn) { battleSource = typeof fn === 'function' ? fn : null; }
|
||||
@@ -177,10 +253,10 @@ const join = (...parts) => parts.filter((p) => p != null && p !== '' && p !== fa
|
||||
* error log newest first) and, with `battle`, a ```json block of its spec. Secrets of `state` are whole tokens in the
|
||||
* prose and exact string values in the JSON.
|
||||
* @param {{ state: any, env?: any, settings?: any, battle?: any, entries?: any[], dropped?: number, total?: number,
|
||||
* now?: number, pageStart?: number, version?: string }} input
|
||||
* now?: number, pageStart?: number, version?: string, traffic?: ReturnType<typeof trafficSummary> | null }} input
|
||||
*/
|
||||
export function buildDiagnostics(input) {
|
||||
const { state = {}, env = {}, settings = null, battle = null } = input;
|
||||
const { state = {}, env = {}, settings = null, battle = null, traffic: trafficTotals = null } = input;
|
||||
const entries = input.entries ?? errorLog();
|
||||
const now = input.now ?? Date.now();
|
||||
const lines = [];
|
||||
@@ -208,6 +284,8 @@ export function buildDiagnostics(input) {
|
||||
if (settings) {
|
||||
lines.push(`settings: ${join(`quality ${settings.quality}`, settings.damageNumbers ? 'damage numbers' : null, settings.officialDamageNumbers ? 'official damage numbers' : null, settings.muted ? 'muted' : null)}`);
|
||||
}
|
||||
const tl = trafficLine(trafficTotals);
|
||||
if (tl) lines.push(`traffic: ${tl}`);
|
||||
const shown = entries.slice().reverse();
|
||||
const tot = input.total ?? total;
|
||||
const drop = input.dropped ?? dropped;
|
||||
@@ -265,5 +343,5 @@ export function browserEnv() {
|
||||
* @param {{ state: any, settings?: any, attachBattle?: boolean }} opts
|
||||
*/
|
||||
export function diagnosticsText({ state, settings = null, attachBattle = false }) {
|
||||
return buildDiagnostics({ state, settings, env: browserEnv(), battle: attachBattle ? currentBattle() : null });
|
||||
return buildDiagnostics({ state, settings, env: browserEnv(), battle: attachBattle ? currentBattle() : null, traffic: trafficSummary() });
|
||||
}
|
||||
+3
-1
@@ -55,7 +55,7 @@ import { installLoadoutSync, installOwnershipSync, installDiySync } from './ui/l
|
||||
import { startBuildGuard } from './ui/buildGuard.js';
|
||||
import { initLang, useLang, tickerText } from './ui/lang.js';
|
||||
import { t, N_, translateWire } from '../../shared/i18n.js';
|
||||
import { recordError } from './diag.js';
|
||||
import { recordError, installTrafficMeter } from './diag.js';
|
||||
|
||||
const RESTORE_GRACE_MS = 1500;
|
||||
const JOIN_DELAY_MS = 350;
|
||||
@@ -346,6 +346,8 @@ function installGlobalErrorHandlers() {
|
||||
|
||||
async function boot() {
|
||||
installGlobalErrorHandlers();
|
||||
// what this page downloads, by category, for the 问题反馈 report's traffic line (diag.js; nothing is sent anywhere)
|
||||
installTrafficMeter();
|
||||
// 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.
|
||||
|
||||
+7
-2
@@ -11,10 +11,14 @@
|
||||
//
|
||||
// `server/http/media.js` resolves /media/… back to the real file under public/assets/audio and still answers with
|
||||
// `Content-Type: audio/mpeg` + range support; Web Audio sniffs the container, so the URL is all the same to it.
|
||||
// A served manifest's content-hashed URL keeps its segment (shared/assetPaths.js; host bandwidth, layer 1):
|
||||
//
|
||||
// /assets/~0123456789abcdef/audio/bgm/act1.mp3 → /media/~0123456789abcdef/bgm/act1
|
||||
// A host that does not implement /media/ keeps working: `audio.js` falls back to the original URL whenever the
|
||||
// /media/ response is unusable (a 404, or a 200 that is not audio at all — some static hosts answer a missing
|
||||
// path with the SPA's index.html).
|
||||
import { MEDIA_PREFIX, AUDIO_EXTS } from '../../shared/media.js';
|
||||
import { splitHashedPath } from '../../shared/assetPaths.js';
|
||||
|
||||
const AUDIO_PATH = /^\/assets\/audio\/(.+)$/i;
|
||||
|
||||
@@ -36,7 +40,8 @@ export function mediaUrl(url, origin = globalThis.location?.origin) {
|
||||
// Cross-origin (or absolute while the page origin is unknown) stays untouched: that host serves its own media.
|
||||
const absolute = /^[a-z][a-z0-9+.-]*:/i.test(url) || url.startsWith('//');
|
||||
if (origin ? u.origin !== origin : absolute) return url;
|
||||
const m = AUDIO_PATH.exec(u.pathname);
|
||||
const { hash, path: plain } = splitHashedPath(u.pathname);
|
||||
const m = AUDIO_PATH.exec(plain);
|
||||
if (!m) return url;
|
||||
let rest = m[1];
|
||||
const ext = AUDIO_EXTS.find((e) => rest.toLowerCase().endsWith(e));
|
||||
@@ -44,5 +49,5 @@ export function mediaUrl(url, origin = globalThis.location?.origin) {
|
||||
rest = rest.slice(0, -ext.length);
|
||||
const segments = rest.split('/');
|
||||
if (!rest || segments.some((s) => !s || s === '.' || s === '..' || s.startsWith('.'))) return url;
|
||||
return `${MEDIA_PREFIX}${rest}${u.search}`;
|
||||
return `${MEDIA_PREFIX}${hash ? `~${hash}/` : ''}${rest}${u.search}`;
|
||||
}
|
||||
+16
-3
@@ -3,7 +3,8 @@
|
||||
// downloads from the public mirror (data/assets.json → ui['guide/<key>']; GitHub issue #42) — data.js artUrls; a page
|
||||
// whose picture fails to load tries its next URL. The pages are stored squashed to 1024² (both copies) and are
|
||||
// displayed stretched back to 16:9. Three chapters (基础规则 / 调度手册 / 进阶图鉴), ‹ › buttons, ←/→ (A/D) keys, page
|
||||
// dots, thumbnails; Esc or the backdrop closes. Adjacent pages are preloaded.
|
||||
// dots, thumbnails; Esc or the backdrop closes. Only the next page is preloaded (a page is 0.9–1.5 MB; the one before
|
||||
// has usually just been seen, so preloading it too doubled the download — host bandwidth, layer 0).
|
||||
// With no page listed the viewer shows the official loading-screen tips (config.tips) instead, and so does a page none
|
||||
// of whose copies loads (`guideStage`: data/assets.json lists the downloaded pages but the files are not on disk — a
|
||||
// `git pull` and restart without setup, an older asset folder, a failed download the shrink guard kept); the chapter
|
||||
@@ -66,6 +67,19 @@ export function guideStage(page, failed) {
|
||||
return src ? { kind: 'image', src } : { kind: 'tips' };
|
||||
}
|
||||
|
||||
/**
|
||||
* The one page the viewer preloads while page `i` is shown: the next one (wrapping around), its first URL that has not
|
||||
* failed — null without pages. The previous page is not preloaded (see the header).
|
||||
* @param {Array<{ urls?: string[] }>} pages guidePages()
|
||||
* @param {number} i
|
||||
* @param {Set<string>} [failed]
|
||||
*/
|
||||
export function guidePreloadUrl(pages, i, failed) {
|
||||
const n = Array.isArray(pages) ? pages.length : 0;
|
||||
if (!n || n < 2) return null;
|
||||
return nextArtUrl(pages[(((i + 1) % n) + n) % n]?.urls, failed);
|
||||
}
|
||||
|
||||
/** Open/closed + current page. */
|
||||
export const guideStore = createStore({ open: false, page: 0 });
|
||||
|
||||
@@ -136,8 +150,7 @@ export function GuideHost() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !n) return;
|
||||
preload(nextArtUrl(pages[(i + 1) % n]?.urls, failed));
|
||||
preload(nextArtUrl(pages[(i + n - 1) % n]?.urls, failed));
|
||||
preload(guidePreloadUrl(pages, i, failed));
|
||||
}, [open, i, n, failed]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
Reference in new issue
Block a user