feedback7 R22-VOICES: review fixes — a 选中 tap no longer restarts the battle-voice gap, and the second shop / reward card of one operator says its 选中 line too; the package switch's comment quotes the measured JP size; golden: unchanged

1. VoiceGate.start no longer moves lastAt for VOICE_TAP_SLOTS: a tap still skips the 1.2 s gap but starts none, so a
   部署 / 技能 / 接敌 the battle asks for once after a short 选中 line has ended plays where the last battle line's gap
   ends (a 部署 at 0, a tap at 500: a 作战中 at 1300 was dropped, now plays; inside the 部署 gap it still waits).
2. Shop / reward cards hand only the chess id to the detail, so two cards of one operator (the pool deals duplicates)
   had one 选中 key and the second tap said nothing nor replaced the first line: game.js numbers every card it opens
   (`tap`, cardTap), resolveDetail keeps it, and detailPanel's new selectVoiceKey() includes it (a re-render of the
   same tap keeps its key; pieces / units unchanged). Checked in the mock match: the second card of 歌蕾蒂娅 now calls
   voice() (it made no call on the parent).
3. tools/package.mjs FULL_ZIP_JP_VOICE comment: 89,388,041 bytes = 85.2 MiB on disk, about 76 MB zipped (as ASSETS /
   DEPLOY), not "~93 MB, barely compresses".
Tests: test/ui/audio.test.js (the tap-gap case; one earlier assertion that encoded the old gap after a tap replaced),
test/feedback7-voices.test.js (new: the card tap in resolveDetail / selectVoiceKey / game.js) — both fail on the parent.
§21.30's 0.2.2 note updated.
This commit is contained in:
sganggs committed 2026-10-08 20:28:38 +08:00
1 parent ddf8c39ed0
commit bcee737f40
7 files changed
+79 -11

No files matched your search

+1 -1
View File
@@ -350,7 +350,7 @@ Two public GitHub issues after 0.1.0, both client-only. Normative lines rewritte
User: 「给每个卫戍干员都加语音」, then, after the first build, 「整备阶段不需要干员语音,战斗开始后才需要语音,也就是只需要作战中语音」. User: 「给每个卫戍干员都加语音」, then, after the first build, 「整备阶段不需要干员语音,战斗开始后才需要语音,也就是只需要作战中语音」.
**Changed in 0.2.2** (§27, the owner's request of 2026-10-08 「添加一下干员点击上去的语气一样的语音」): 选中干员 is no longer battle-only — the detail panel opening on an operator the player taps says it in every phase, the prep, the shop and the hand included (`DetailPanel voice` is true on the game screen), with the official FOCUS_CHAR timing: no cooldown and no global gap on an idle channel, a newer tap replaces the 选中 line on air, a higher-priority line is never interrupted (`VOICE_TAP_SLOTS`). Every other line stays battle-only. 0.2.2 also adds the Japanese dub (「全套的日配语音」) as a second tree beside the Chinese one, `audio.voiceJp` — the same slots and file names from the dump's `voice/` folder — which the client plays when the player picks 日本語 under 设置 → 语音语言 (default 中文, not tied to the interface language), falling back to the Chinese line it lacks. The paragraphs below describe 0.1.1. **Changed in 0.2.2** (§27, the owner's request of 2026-10-08 「添加一下干员点击上去的语气一样的语音」): 选中干员 is no longer battle-only — the detail panel opening on an operator the player taps says it in every phase, the prep, the shop and the hand included (`DetailPanel voice` is true on the game screen), with the official FOCUS_CHAR timing: no cooldown and no global gap on an idle channel, a newer tap replaces the 选中 line on air, a higher-priority line is never interrupted, and a tap starts no gap of its own for the battle lines (`VOICE_TAP_SLOTS`); every shop / reward card opened counts as a tap, so two cards of one operator each speak (`selectVoiceKey`). Every other line stays battle-only. 0.2.2 also adds the Japanese dub (「全套的日配语音」) as a second tree beside the Chinese one, `audio.voiceJp` — the same slots and file names from the dump's `voice/` folder — which the client plays when the player picks 日本語 under 设置 → 语音语言 (default 中文, not tied to the interface language), falling back to the Chinese line it lacks. The paragraphs below describe 0.1.1.
**Where the lines come from.** Every operator the mode can field already has official battle lines in the same voice dump the BGM and SFX come from — `voice_cn/{charId}/cn_nn.mp3` (JP/EN/KR: `voice/`, `voice_en/`, `voice_kr/`, the same file names, `tools/fetch-assets.mjs --voice-lang=jp|en|kr`). `charword_table.json`'s `placeType` says when each line is used, and `indexVoice()` turns it into the manifest's `audio.voice[charId]`: 行动出发 `start` (BATTLE_START), 行动开始 `faceEnemy` (BATTLE_FACE_ENEMY), 选中干员 `select`, 部署 `place`, 作战中1-4 `skill1..skill4` (BATTLE_SKILL_N), 编入队伍 `squad`, 任命队长 `squadFirst`, 结算 `resultFour/Three/Two/Lose` (完成高难行动 / 3星结束行动 / 非3星结束行动 / 行动失败) and 干员报到 `gacha`. A slot with several lines stays an array and the client draws one. Only the base word key is planned (`wordKey === charId`): the dump has no folder for a skin variant's word key (`char_x_ita`, `char_x_epoque#28`), so those lines 404. **Only the slots a battle can play are planned** (`audio.mjs VOICE_BATTLE_SLOTS`): 干员报到 / 编入队伍 / 任命队长 are lines of the official client's own 养成 / 编队 UI, nothing in this mode ever requests them (`test/docs-consistency.test.js` checks the client's `voice(...)` call sites), and downloading them cost 360 files (19.3 MB) on every run — `--voice-all` adds them back for the complete official set. Measured: 120 of the 121 in-mode charIds have a full set — the 17 预备干员 and the mode's own 盟约·辅助干员 have no battle voice at all, so they simply get no entry — 1680 lines of the 2040 upstream, in a manifest of 5694 files / 324.2 MB (files and `stats.voiceChars` in `data/assets.json`). **Where the lines come from.** Every operator the mode can field already has official battle lines in the same voice dump the BGM and SFX come from — `voice_cn/{charId}/cn_nn.mp3` (JP/EN/KR: `voice/`, `voice_en/`, `voice_kr/`, the same file names, `tools/fetch-assets.mjs --voice-lang=jp|en|kr`). `charword_table.json`'s `placeType` says when each line is used, and `indexVoice()` turns it into the manifest's `audio.voice[charId]`: 行动出发 `start` (BATTLE_START), 行动开始 `faceEnemy` (BATTLE_FACE_ENEMY), 选中干员 `select`, 部署 `place`, 作战中1-4 `skill1..skill4` (BATTLE_SKILL_N), 编入队伍 `squad`, 任命队长 `squadFirst`, 结算 `resultFour/Three/Two/Lose` (完成高难行动 / 3星结束行动 / 非3星结束行动 / 行动失败) and 干员报到 `gacha`. A slot with several lines stays an array and the client draws one. Only the base word key is planned (`wordKey === charId`): the dump has no folder for a skin variant's word key (`char_x_ita`, `char_x_epoque#28`), so those lines 404. **Only the slots a battle can play are planned** (`audio.mjs VOICE_BATTLE_SLOTS`): 干员报到 / 编入队伍 / 任命队长 are lines of the official client's own 养成 / 编队 UI, nothing in this mode ever requests them (`test/docs-consistency.test.js` checks the client's `voice(...)` call sites), and downloading them cost 360 files (19.3 MB) on every run — `--voice-all` adds them back for the complete official set. Measured: 120 of the 121 in-mode charIds have a full set — the 17 预备干员 and the mode's own 盟约·辅助干员 have no battle voice at all, so they simply get no entry — 1680 lines of the 2040 upstream, in a manifest of 5694 files / 324.2 MB (files and `stats.voiceChars` in `data/assets.json`).
+6 -3
View File
@@ -280,7 +280,9 @@ export const VOICE_COOLDOWN_MS = Object.freeze({
* The slot a player's tap asks for: 选中干员 (official FOCUS_CHAR — priority 10, cooldown 0, `overlapIfSamePriority: * The slot a player's tap asks for: 选中干员 (official FOCUS_CHAR — priority 10, cooldown 0, `overlapIfSamePriority:
* true`). The battle lines are timed by the gate's global gap; a tap is the player's own request and answers at once: * true`). The battle lines are timed by the gate's global gap; a tap is the player's own request and answers at once:
* on an idle channel it always plays (no gap, no cooldown), a newer tap replaces the 选中 line still on air (the same * on an idle channel it always plays (no gap, no cooldown), a newer tap replaces the 选中 line still on air (the same
* priority, overlapIfSamePriority), and it still never interrupts a higher-priority line (部署, 作战中, 开战 …). * priority, overlapIfSamePriority), and it still never interrupts a higher-priority line (部署, 作战中, 开战 …). Nor does
* it start a gap of its own: the battle lines keep the gap of the last battle line (a tap restarting it dropped a 部署 /
* 技能 / 接敌, which the battle asks for once, after a 选中 line shorter than the gap had already ended — many are).
*/ */
export const VOICE_TAP_SLOTS = Object.freeze(['select']); export const VOICE_TAP_SLOTS = Object.freeze(['select']);
@@ -411,7 +413,8 @@ export class SfxLimiter {
/** /**
* Voice gate: one line at a time, a global gap between two lines, a per-unit per-slot cooldown, and takeover by a * Voice gate: one line at a time, a global gap between two lines, a per-unit per-slot cooldown, and takeover by a
* clearly more important line (the caller fades the playing one out first). A tap's 选中干员 skips the gap and the * clearly more important line (the caller fades the playing one out first). A tap's 选中干员 skips the gap and the
* cooldown on an idle channel and replaces a 选中 line on air (VOICE_TAP_SLOTS). Pure — the clock is passed in. * cooldown on an idle channel, replaces a 选中 line on air and starts no gap itself (VOICE_TAP_SLOTS). Pure — the clock
* is passed in.
*/ */
export class VoiceGate { export class VoiceGate {
/** @param {{ gapMs?: number, preemptMargin?: number, maxUnits?: number }} [o] */ /** @param {{ gapMs?: number, preemptMargin?: number, maxUnits?: number }} [o] */
@@ -457,7 +460,7 @@ export class VoiceGate {
/** Record a line that started (call right after request() answered play / preempt). */ /** Record a line that started (call right after request() answered play / preempt). */
start(slot, unitKey, now) { start(slot, unitKey, now) {
this.playing = { slot, pri: VOICE_PRIORITY[slot] ?? 0 }; this.playing = { slot, pri: VOICE_PRIORITY[slot] ?? 0 };
this.lastAt = now; if (!VOICE_TAP_SLOTS.includes(slot)) this.lastAt = now; // the gap is between battle lines: a tap starts none
const cd = VOICE_COOLDOWN_MS[slot] ?? 0; const cd = VOICE_COOLDOWN_MS[slot] ?? 0;
if (unitKey != null && cd > 0) { if (unitKey != null && cd > 0) {
if (this.unitUntil.size > this.maxUnits) this.unitUntil.clear(); if (this.unitUntil.size > this.maxUnits) this.unitUntil.clear();
+4 -1
View File
@@ -169,6 +169,9 @@ function MatchScreen() {
const [bondOpen, setBondOpen] = useState(null); // { id, ownerId, from }: the bond popup and whose bond it shows const [bondOpen, setBondOpen] = useState(null); // { id, ownerId, from }: the bond popup and whose bond it shows
const [bondsCollapsed, setBondsCollapsed] = useState(false); const [bondsCollapsed, setBondsCollapsed] = useState(false);
const [detail, setDetail] = useState(null); // detail target const [detail, setDetail] = useState(null); // detail target
// every shop / reward card opened by a tap is a new tap — `tap` in the target: two cards of one operator (the pool
// deals duplicates) carry the same chess id and no piece, and each must say its 选中干员 (detailPanel selectVoiceKey)
const cardTap = useRef(0);
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [rewardMin, setRewardMin] = useState(false); const [rewardMin, setRewardMin] = useState(false);
const [emoteOpen, setEmoteOpen] = useState(false); const [emoteOpen, setEmoteOpen] = useState(false);
@@ -1355,7 +1358,7 @@ function MatchScreen() {
${showShop ? html`<${ShopBar} priv=${priv} editable=${editable} collapsed=${collapsed} onCollapse=${setCollapsed} ${showShop ? html`<${ShopBar} priv=${priv} editable=${editable} collapsed=${collapsed} onCollapse=${setCollapsed}
barRef=${barRef} offBonds=${offBonds} barRef=${barRef} offBonds=${offBonds}
onBuy=${buy} onLevel=${() => actions.levelUp()} onRefresh=${() => actions.refresh()} onFreeze=${() => actions.freeze()} onBuy=${buy} onLevel=${() => actions.levelUp()} onRefresh=${() => actions.refresh()} onFreeze=${() => actions.freeze()}
onDetail=${(id, kind, hint) => setDetail({ kind: kind === 'item' ? 'item' : 'chess', id, hint: hint || null })} onDetail=${(id, kind, hint) => setDetail({ kind: kind === 'item' ? 'item' : 'chess', id, hint: hint || null, tap: ++cardTap.current })}
onDetailClose=${() => setDetail((d) => (d?.kind === 'chess' || d?.kind === 'item' ? null : d))} onDetailClose=${() => setDetail((d) => (d?.kind === 'chess' || d?.kind === 'item' ? null : d))}
onRefuse=${(reason) => { toast(reason, 'warn'); audio.sfx('error', { volume: 0.5 }); }} onRefuse=${(reason) => { toast(reason, 'warn'); audio.sfx('error', { volume: 0.5 }); }}
reward=${phase === PHASE.PREP && !rewardMin ? priv?.shop?.rewardOffer || null : null} reward=${phase === PHASE.PREP && !rewardMin ? priv?.shop?.rewardOffer || null : null}
+18 -4
View File
@@ -755,9 +755,11 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get(
// (a teammate's 自选 row hands its unit's pick on, `target.diy`: their operator, not the empty 甄选干员 slot — 0.2.1) // (a teammate's 自选 row hands its unit's pick on, `target.diy`: their operator, not the empty 甄选干员 slot — 0.2.1)
const mate = foreign && c && target.diy && typeof target.diy === 'object' ? diyRecordFor(c, target.diy, dd) : null; const mate = foreign && c && target.diy && typeof target.diy === 'object' ? diyRecordFor(c, target.diy, dd) : null;
const d = mate ? { chess: mate, diy: target.diy } : foreign ? null : ownDiy(c); const d = mate ? { chess: mate, diy: target.diy } : foreign ? null : ownDiy(c);
if (d) return { type: 'chess', chess: d.chess, hint: target.hint || null, standIn: null, diy: d.diy, ...(items.length ? { unitItems: items } : {}) }; // `tap`: which card tap opened it (game.js numbers every shop / reward card it opens) — selectVoiceKey
const tap = target.tap != null ? { tap: target.tap } : null;
if (d) return { type: 'chess', chess: d.chess, hint: target.hint || null, standIn: null, diy: d.diy, ...(items.length ? { unitItems: items } : {}), ...tap };
const si = !c ? null : foreign ? (typeof target.standInFor === 'string' && target.standInFor ? standInOf(c, backups) : null) : ownSi(c); const si = !c ? null : foreign ? (typeof target.standInFor === 'string' && target.standInFor ? standInOf(c, backups) : null) : ownSi(c);
return c ? { type: 'chess', chess: c, hint: target.hint || null, standIn: si, ...(items.length ? { unitItems: items } : {}) } : null; return c ? { type: 'chess', chess: c, hint: target.hint || null, standIn: si, ...(items.length ? { unitItems: items } : {}), ...tap } : null;
} }
if (target.kind === 'item') { const it = data.lookup('items', target.id); return it ? { type: 'item', item: it } : null; } if (target.kind === 'item') { const it = data.lookup('items', target.id); return it ? { type: 'item', item: it } : null; }
if (target.kind === 'enemy') { const en = data.lookup('enemies', target.id); return en ? { type: 'enemy', enemy: en, count: target.count } : null; } if (target.kind === 'enemy') { const en = data.lookup('enemies', target.id); return en ? { type: 'enemy', enemy: en, count: target.count } : null; }
@@ -788,6 +790,18 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get(
return null; return null;
} }
/**
* The 选中干员 key of a resolved detail (null: nothing to say). The panel says the line once per opened operator and stays
* mounted while its target changes, so the key carries what identifies the opening: the chess record, the piece / battle
* unit, and the card tap (`tap`, game.js) — two shop / reward cards of one operator (the pool deals duplicates) carry the
* same chess id and no piece, so without it the second card's tap said nothing, nor replaced the first one's line.
* @param {any} detail resolveDetail's result
* @returns {string|null}
*/
export function selectVoiceKey(detail) {
return detail?.type === 'chess' ? `${detail.chess?.chessId || ''}:${detail.unitId ?? detail.piece?.uid ?? ''}:${detail.tap ?? ''}` : null;
}
/** /**
* The panel. * The panel.
* @param {{ detail:any, editable:boolean, snapHp?:{hp:number,max:number}|null, onClose:Function, onSell:(piece:any)=>void, onDestroy:(piece:any)=>void, * @param {{ detail:any, editable:boolean, snapHp?:{hp:number,max:number}|null, onClose:Function, onSell:(piece:any)=>void, onDestroy:(piece:any)=>void,
@@ -807,8 +821,8 @@ export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestr
const getter = typeof live === 'function' ? live : null; const getter = typeof live === 'function' ? live : null;
useTicker(detail && getter ? 250 : 0); useTicker(detail && getter ? 250 : 0);
// 选中干员 voice (audio.voice 'select'): once per opened operator — the panel stays mounted while the target changes, // 选中干员 voice (audio.voice 'select'): once per opened operator — the panel stays mounted while the target changes,
// so the key carries what identifies it (its chess record and its piece / battle unit id) // so the key carries what identifies it (its chess record, its piece / battle unit id, the card tap: selectVoiceKey)
const selectKey = voice && detail?.type === 'chess' ? `${detail.chess?.chessId || ''}:${detail.unitId ?? detail.piece?.uid ?? ''}` : null; const selectKey = voice && detail?.type === 'chess' ? selectVoiceKey(detail) : null;
// 0.2.0 补位: a chess fielded as its stand-in is spoken for by the stand-in (the operator on the field, whose model, name // 0.2.0 补位: a chess fielded as its stand-in is spoken for by the stand-in (the operator on the field, whose model, name
// and battle lines the card and audio.js show), never by the operator it replaces; a 自选 record is already the pick's // and battle lines the card and audio.js show), never by the operator it replaces; a 自选 record is already the pick's
const selectChar = voice && detail?.type === 'chess' ? detail.standIn?.charId || detail.chess?.charId || null : null; const selectChar = voice && detail?.type === 'chess' ? detail.standIn?.charId || detail.chess?.charId || null : null;
+37
View File
@@ -137,3 +137,40 @@ test('package: the full zip ships the JP dub by default; FULL_ZIP_JP_VOICE off h
const src = fs.readFileSync(path.join(ROOT, 'tools/package.mjs'), 'utf8'); const src = fs.readFileSync(path.join(ROOT, 'tools/package.mjs'), 'utf8');
assert.match(src, /removable: \(rel\) => !removalProblem\(rel\) && !heldBack\.has\(rel\)/); assert.match(src, /removable: \(rel\) => !removalProblem\(rel\) && !heldBack\.has\(rel\)/);
}); });
test('选中干员 on every card tap (review of fb7-voices): two shop / reward cards of one operator are two taps, a re-render is none', async () => {
// shop / reward cards hand only the chess id to the detail target (no piece, no unit): game.js numbers every card it opens
// (`tap`), resolveDetail keeps it, and the panel's 选中 key (selectVoiceKey) includes it — the second 斯卡蒂 card spoke
// nothing before, nor replaced the first card's line (the pool deals duplicates)
const origFetch = globalThis.fetch;
globalThis.fetch = async (url) => {
const name = String(url).split('/').pop();
let body;
try { body = readJson(`data/${name}`); } catch { return { ok: false, status: 404, json: async () => ({}) }; }
return { ok: true, status: 200, json: async () => body };
};
try {
const { resolveDetail, selectVoiceKey } = await import('../public/js/ui/detailPanel.js');
const { data } = await import('../public/js/data.js');
await data.loadAll('chess', 'backups');
const id = Object.values(readJson('data/chess.json')).find((c) => c.charId === 'char_263_skadi' && !c.isGolden).chessId;
const card = (tap) => resolveDetail({ kind: 'chess', id, hint: null, tap }, new Map(), { priv: null });
const a = card(1), b = card(2);
assert.equal(a.chess.charId, 'char_263_skadi');
assert.deepEqual([a.tap, b.tap], [1, 2], 'the tap rides along');
assert.notEqual(selectVoiceKey(a), selectVoiceKey(b), 'the second card of the same operator is a new opening');
assert.equal(selectVoiceKey(card(1)), selectVoiceKey(a), 'the same tap resolved again (a re-render) says nothing more');
assert.equal(resolveDetail({ kind: 'chess', id }, new Map(), { priv: null }).tap, undefined, 'no tap, no field');
// pieces and battle units keep their own identity; a non-operator card has no key
assert.equal(selectVoiceKey({ type: 'chess', chess: { chessId: 'x' }, piece: { uid: 4 } }), 'x:4:');
assert.equal(selectVoiceKey({ type: 'chess', chess: { chessId: 'x' }, unitId: 9 }), 'x:9:');
assert.equal(selectVoiceKey({ type: 'item' }), null);
assert.equal(selectVoiceKey(null), null);
// the panel keys its 选中 effect on it, and the game screen numbers every operator card it opens
assert.match(fs.readFileSync(path.join(ROOT, 'public/js/ui/detailPanel.js'), 'utf8'), /const selectKey = voice && detail\?\.type === 'chess' \? selectVoiceKey\(detail\) : null;/);
const game = fs.readFileSync(path.join(ROOT, 'public/js/screens/game.js'), 'utf8');
assert.match(game, /onDetail=\$\{\(id, kind, hint\) => setDetail\(\{ kind: kind === 'item' \? 'item' : 'chess', id, hint: hint \|\| null, tap: \+\+cardTap\.current \}\)\}/);
} finally {
globalThis.fetch = origFetch;
}
});
+12 -1
View File
@@ -264,7 +264,6 @@ describe('operator battle voice', () => {
assert.equal(g.request('select', null, 0), 'play'); assert.equal(g.request('select', null, 0), 'play');
g.start('select', null, 0); g.release(); // tap A, its line ended g.start('select', null, 0); g.release(); // tap A, its line ended
assert.equal(g.request('select', null, 300), 'play', 'an idle channel: the global gap never drops a tap'); assert.equal(g.request('select', null, 300), 'play', 'an idle channel: the global gap never drops a tap');
assert.equal(g.request('place', 'u1', 300), 'drop', 'the battle lines keep the gap');
g.start('select', 'u1', 300); g.release(); g.start('select', 'u1', 300); g.release();
assert.equal(g.request('select', 'u1', 400), 'play', 'no cooldown either, keyed or not'); assert.equal(g.request('select', 'u1', 400), 'play', 'no cooldown either, keyed or not');
g.start('select', null, 400); g.start('select', null, 400);
@@ -277,6 +276,18 @@ describe('operator battle voice', () => {
g.start('skill1', 'u3', 0); g.start('skill1', 'u3', 0);
assert.equal(g.request('select', null, 100), 'drop', '… nor a 作战中 line'); assert.equal(g.request('select', null, 100), 'drop', '… nor a 作战中 line');
g.reset(); g.reset();
// a tap starts no gap of its own (review of fb7-voices): a 部署 at 0 holds the battle lines until 1200; a tap at 500
// answers, its short line ends at 944, and a 作战中 the battle asks for once at 1300 still plays (it was dropped: the
// tap had moved the gap's start to 500)
g.start('place', 'u4', 0); g.release();
assert.equal(g.request('select', null, 500), 'play');
g.start('select', null, 500); g.release();
assert.equal(g.request('skill1', 'u5', 1100), 'drop', 'inside the 部署 gap a battle line still waits');
assert.equal(g.request('skill1', 'u5', 1300), 'play', 'the gap ends where the 部署 put it: the tap did not restart it');
g.reset();
g.start('select', null, 0); g.release();
assert.equal(g.request('place', 'u6', 100), 'play', 'a battle line right after a tap: no gap behind a 选中 line');
g.reset();
// the manager: two taps 0.3 s apart in the prep (no battle, the real gate) both speak, the second replacing the first // the manager: two taps 0.3 s apart in the prep (no battle, the real gate) both speak, the second replacing the first
const fw = fakeWindow(); const fw = fakeWindow();
const origFetch = globalThis.fetch; const origFetch = globalThis.fetch;
+1 -1
View File
@@ -96,7 +96,7 @@ export const PLAYER_NPM_SCRIPTS = ['start', 'setup', 'doctor', 'launch', 'postin
/** /**
* THE SWITCH for the Japanese voice dub in the full zip (`audio.voiceJp` of data/assets.json: 2674 files under * THE SWITCH for the Japanese voice dub in the full zip (`audio.voiceJp` of data/assets.json: 2674 files under
* public/assets/audio/voice/jp/, about 93 MB that zip compression barely shrinks). true (0.2.2): the full zip ships both * public/assets/audio/voice/jp/, 89,388,041 bytes = 85.2 MiB on disk, about 76 MB zipped). true (0.2.2): the full zip ships both
* dubs, so it still runs with nothing to download. false: the full zip (and an update zip built from it) leaves the JP * dubs, so it still runs with nothing to download. false: the full zip (and an update zip built from it) leaves the JP
* files out — setup downloads them on the first start like the lite zip's art, the 日本語 setting plays the Chinese line * files out — setup downloads them on the first start like the lite zip's art, the 日本語 setting plays the Chinese line
* until then (public/js/audio.js voiceLine), and an update never deletes a player's copy (`held`). The lite zip carries * until then (public/js/audio.js voiceLine), and an update never deletes a player's copy (`held`). The lite zip carries