0.2.6-dev: the client fetches a served manifest's content-hashed audio as /media/~<hash>/… (download-manager route kept), and the token death sound derived beside charDie drops charDie's hash — host bandwidth layer 1.3; golden: unchanged
This commit is contained in:
1 parent
893d17e725
commit
35b09bb63b
7 files changed
+54
-5
No files matched your search
@@ -40,6 +40,8 @@ view.resize() ; view.destroy()
|
||||
|
||||
`createFieldView(host, { …, signal })` takes an `AbortSignal` (0.2.2, GitHub PR #302 by @siruimei07): an abort during the build — the Pixi load, the manifest, the fonts, the board art and the 3D board waits — destroys what the attempt made (the canvas, the ticker, the listeners, the observers) and rejects, a failure while it is built releases the same partial resources, and `view.destroy()` is idempotent. `ui/fieldHost.js mountFieldView(host, { signal })` aborts that signal when the engine fails, times out (12 s) or the owner unmounts, disposes a view a factory returns after that, and never clears the other children of `host` — the simplified view and a newer mount share it.
|
||||
|
||||
Asset URLs (0.2.6, host bandwidth layer 1): the client takes every art, model and audio URL from `data/assets.json` and `data/local-assets.json` and builds none itself, so the content-hash segment the server puts in front (`/assets/~<hash>/…`, docs/ASSETS.md "The committed manifest and the served manifests") needs nothing on this side: `validSpine` still sees `.skel`, pixi-spine finds the atlas and its pages under the same segment (a model shares one hash), audio is fetched as `/media/~<hash>/…`. The two URLs the client derives from another file's — the 3D board's `tiles.json` beside the atlas and the token death sound beside `charDie` — are served `no-cache` (the board) or drop the segment (`shared/assetPaths.js unhashedUrl`, the sound).
|
||||
|
||||
Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highland blocks with side faces, road/floor/forbidden styles per research 07 §7, pulsing red gates, blue objective, dashed hand pads. Units: Spine (Front/Back rule, feet anchored — flying units hover `FLY_HOVER` 1.3 tiles up, the client's 0.35 in character space, research 12, §25.13.5 — an enemy flyer from the road whatever tile it crosses (GitHub #277) —, shadow sprite on the ground), elite gold outline glow, tier chip, HP bar (green ally / red enemy), SP bar (skill ready glow; while an ammo skill runs it is the magazine instead — a yellow cell per round, emptying from the right, `b.snap ammo`, and the skill draws no standing aura, UnitInfo `ammoSkill`), 斩业星熊's 我执 pool as a red bar over her drained HP bar (`b.snap neg`), 伺夜's 狼群 count as a row of diamonds under the bars, the lit ones the 狼影 left (`b.snap wolves`; 0.2.2, GitHub PR #303 / #378 / #383), status icons, the element gauge row under the bars — icon + white bar of the remaining 元素值, element-coloured refill during a 爆发冷却 (§18.3, §20.8); enemies drawn × their official prefab factor (enemies.json `modelScale`, §20.4); an enemy's mode — the `form` of a sim fx (`shared/protocol.js fxForm`), or UnitInfo `form` for a view built later through `render/app/info.js renderInfo` — switches clip sets (`render/units.js FORMS`: 掠海漂移体 crawl, 暴鸰 bombed, 转译基底's forms, the 逐火 embers, the leaders' 重生, 守墓石像, the 孤岛风云 prisoners' warning and 解放; closing clips timed from the fx `dur`), a one-off cast (`PROJ[kind].once`) plays once, and a local-only model's web alias is tinted (`ALIAS_TINT`: 灼热 / 炽焰源石虫, §21.14); damage numbers (phys orange-white, arts purple, true white, heal green); projectiles drawn per kind, hit sparks and melee slashes, skill activation bursts and auras (§17.3); death fade for enemies, summons and devices — a knocked-out operator stays on its tile in its held Die pose under a redeploy ring (§18.3); leak flash at the objective. Fallback when Spine missing/failed: avatar in a rarity-coloured diamond with bob/lunge tweens — a failed or timed-out model is loaded again (bounded), and a view re-resolves its model and avatar when the asset manifest arrives late or the tab is shown again (§21.27). 60 fps target with ≤ 120 units (a crowd draws its Spine units through the cached atlas, `render/impostor.js`, each refreshed every k-th frame: the refresh turns follow this frame's update order — `ctx.impostorSlot()`, a counter reset at the start of every frame — so a frame refreshes ⌊n/k⌋ or ⌈n/k⌉ of n units instead of whatever random per-unit phases gave, and a unit not refreshed for 2k frames is refreshed regardless; GitHub PR #334 by @Cloudnyco, issue #327 step 1 — its step 2, a time budget by priority, is not done; a model whose pose cannot change — `SpineActor.poseHeld()`: a knocked-out operator in the held end of its Die clip, a frozen model — is neither re-posed nor redrawn, only its clock runs, until a new clip, a revive, a wind-up, a form's closing clip, a clipping switch or a zoom past the impostor's rescale threshold; the redeploy rings draw one shared rasterised label per number, `render/textures.js downLabel`, the same pixels as a Text of their own). Picking (which unit a press / hover / dropped item is on) and the drag target follow one rule, `render/pick.js` — the tile under the pointer (§18.1); the Spine store never hands out a skeleton whose unload is in flight (§17.1).
|
||||
|
||||
---
|
||||
|
||||
+6
-2
@@ -70,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;
|
||||
@@ -193,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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+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}`;
|
||||
}
|
||||
+3
-1
@@ -7,7 +7,9 @@
|
||||
// /assets/audio/bgm/act1.mp3 → /media/bgm/act1
|
||||
//
|
||||
// — and `server/http/media.js` resolves it back to the real file under public/assets/audio, still answering with
|
||||
// `Content-Type: audio/mpeg`, Range support and the ETag of the file it resolved.
|
||||
// `Content-Type: audio/mpeg`, Range support and the ETag of the file it resolved. The content-hash segment of a served
|
||||
// manifest's URL (shared/assetPaths.js) comes along: `/assets/~<hash>/audio/bgm/act1.mp3` → `/media/~<hash>/bgm/act1`,
|
||||
// cached by the browser for a year while the hash names the file.
|
||||
//
|
||||
// Both sides import this module: the browser to decide which URLs to rewrite (`public/js/media.js`), the server to
|
||||
// decide which extensions a `/media/…` request may resolve to. A second copy of the list on one side would let them
|
||||
|
||||
@@ -111,6 +111,11 @@ test('#4 audio classes (official ON_UNIT_DEAD/BORN defaults): only operators pla
|
||||
assert.equal(deathSfxUrl(manifest, bomb, { consumed: true }), null, 'used up ⇒ silent');
|
||||
assert.equal(deathSfxUrl(manifest, mapChar), SFX.battle.charDie, 'band map characters are operators');
|
||||
assert.equal(deathSfxUrl(manifest, crate), TOKEN_DEAD, 'act crate trap_1105 ⇒ b_char_tokendead');
|
||||
// a served manifest's charDie carries charDie's content hash (host bandwidth layer 1): the sound derived beside it
|
||||
// names another file, so it goes out without the segment (served plainly, cached a day) instead of a wrong hash
|
||||
const hashed = { ...manifest, audio: { ...manifest.audio, sfx: { ...SFX, battle: { ...SFX.battle, charDie: SFX.battle.charDie.replace(/^\/assets\//, '/assets/~0123456789abcdef/') } } } };
|
||||
assert.equal(deathSfxUrl(hashed, op), hashed.audio.sfx.battle.charDie);
|
||||
assert.equal(deathSfxUrl(hashed, tok), TOKEN_DEAD, 'b_char_tokendead without charDie\'s hash');
|
||||
assert.equal(deathSfxUrl(manifest, enemy), SFX.battle.enemyDie);
|
||||
assert.equal(deathSfxUrl(manifest, boss), SFX.battle.enemyDieHeavy);
|
||||
assert.equal(deathSfxUrl(manifest, op, { reason: 'retreat' }), null, 'a retreat is no knock-down');
|
||||
|
||||
@@ -23,6 +23,16 @@ describe('mediaUrl: 音频地址去掉扩展名(躲开下载器嗅探)', ()
|
||||
assert.equal(mediaUrl('/assets/audio/x/A.MP3', ORIGIN), '/media/x/A', '大小写不敏感');
|
||||
});
|
||||
|
||||
test('服务器下发的清单带内容哈希段(/assets/~<hash>/audio/…):改写后保留这一段', () => {
|
||||
const H = '0123456789abcdef';
|
||||
assert.equal(mediaUrl(`/assets/~${H}/audio/bgm/m_sys_act1autochess_loop.mp3`, ORIGIN), `/media/~${H}/bgm/m_sys_act1autochess_loop`);
|
||||
assert.equal(mediaUrl(`${ORIGIN}/assets/~${H}/audio/sfx/a.mp3`, ORIGIN), `/media/~${H}/sfx/a`);
|
||||
assert.doesNotMatch(mediaUrl(`/assets/~${H}/audio/bgm/a.mp3`, ORIGIN), /\.(mp3|m4a|aac|ogg|oga|opus|wav)(\?|$)/i);
|
||||
// 不是合法的哈希段(大写、长度不对)就不是我们的音频地址,原样返回
|
||||
assert.equal(mediaUrl(`/assets/~${H.toUpperCase()}/audio/a.mp3`, ORIGIN), `/assets/~${H.toUpperCase()}/audio/a.mp3`);
|
||||
assert.equal(mediaUrl(`/assets/~${H}/char/a.png`, ORIGIN), `/assets/~${H}/char/a.png`, '图片不改写');
|
||||
});
|
||||
|
||||
test('改写后的地址里不再有任何媒体扩展名(下载器就是按这个嗅探的)', () => {
|
||||
const out = mediaUrl('/assets/audio/bgm/m_bat_vtlionk_loop.mp3', ORIGIN);
|
||||
assert.equal(out.startsWith(MEDIA_PREFIX), true);
|
||||
|
||||
@@ -655,6 +655,27 @@ describe('AudioManager', () => {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
});
|
||||
test('a served manifest\'s content-hashed audio URL is fetched as /media/~<hash>/…, falling back to the hashed .mp3 URL', async () => {
|
||||
const H = '0123456789abcdef';
|
||||
const raw = manifest.audio.bgm.prep.loop.replace(/^\/assets\//, `/assets/~${H}/`);
|
||||
const served = { ...manifest, audio: { ...manifest.audio, bgm: { ...manifest.audio.bgm, prep: { loop: raw } } } };
|
||||
const fw = fakeWindow();
|
||||
const urls = [];
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (u) => { urls.push(u); return u.startsWith('/media/') ? { ok: false, status: 404 } : { ok: true, arrayBuffer: async () => new ArrayBuffer(8) }; };
|
||||
try {
|
||||
const a = new AudioManager({ win: fw.win, getManifest: () => served });
|
||||
a.install();
|
||||
fw.fire('pointerdown');
|
||||
a.playBgm('prep');
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
assert.deepEqual(urls, [raw.replace(/^\/assets\/(~[0-9a-f]{16})\/audio\/(.+)\.mp3$/, '/media/$1/$2'), raw]);
|
||||
assert.match(urls[0], new RegExp(`^/media/~${H}/bgm/`));
|
||||
assert.equal(a.bgm?.loopUrl, raw);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
test('音频先走无扩展名的 /media/ 路由;只有它 404 才回退到带扩展名的原地址', async () => {
|
||||
const raw = manifest.audio.bgm.prep.loop;
|
||||
const media = mediaUrl(raw);
|
||||
|
||||
Reference in new issue
Block a user