feedback7 R22-VOICES: the Japanese voice dub as a second tree beside the Chinese one — audio.voiceJp (the same slots and file names from ArknightsAssets2 voice/: 2674 files, 89.4 MB, for the 191 voiced operators), 设置 → 语音语言 中文 / 日本語 (default 中文, not tied to the interface language), a JP line the manifest or the host lacks plays the Chinese one; the full zip ships both dubs (one switch, tools/package.mjs FULL_ZIP_JP_VOICE), the lite zip stays art-free (the owner's request of 2026-10-08 「全套的日配语音」); golden: unchanged
- tools/assets/plan.mjs: one voiceTree() per dub — audio.voice (--voice-lang, cn by default) and audio.voiceJp (VOICE_JP_LANG, always the JP dub, audio/voice/jp/<charId>/cn_nnn.mp3); fetch-assets plans and downloads both (stats.voiceJpChars), so setup (checkAssets walks every listed URL) fetches both too. - data/assets.json regenerated with the project's tool (node tools/fetch-assets.mjs --add-only, raw.githubusercontent.com, 107 s): the only change is audio.voiceJp + stats (+2674 files, +89,388,041 bytes, voiceJpChars 191) + hash — the rest is byte-identical; the JP tree is the Chinese one with the folder swapped (191 × 14 files, every file a valid MP3). The audio files are not committed (public/assets/ is git-ignored). - public/js/audio.js voiceLine(): 'jp' draws from voiceJp and falls back to the Chinese line per slot (a JP entry the manifest lacks) and per line at play time (the Chinese file of the same name when the host lacks the JP one — same token, same gate); operators without voice (stand-ins, 盟约·辅助干员, summons) stay silent. setVoiceLang(). - settings: voiceLang 'cn' | 'jp' (VOICE_LANGS, sanitizeSettings), the 语音语言 row under 干员语音 (each dub named in its own language, like the language switch), 语音语言 in the en / ja / ko / zh-TW packs. - tools/package.mjs FULL_ZIP_JP_VOICE (true): false holds the JP files out of the full zip (and its update zip) — not missing, not orphans, never in an update's `removed`; setup then downloads them. - Sizes measured: art download 548 MiB (was 463), full zip ≈ 505 MB (0.2.1: 428 MiB + 76 MiB of zipped JP voice), unzipped 711 MB (package --dry-run); lite zip unchanged. README, DEPLOY, ASSETS, setup's fallback size updated; PLAYING voice paragraph; §21.30 "Changed in 0.2.2" note. Tests: test/feedback7-voices.test.js (new: plan, manifest, settings, package switch), test/ui/audio.test.js (voiceLine, the manager's dub choice and fallback), test/ui/gameLogic.test.js, test/docs-consistency.test.js — all fail on the parent.
This commit is contained in:
1 parent
cb8865d9d8
commit
ddf8c39ed0
22 files changed
+414
-76
No files matched your search
@@ -148,6 +148,7 @@
|
||||
"完成": "Done",
|
||||
"背景音乐": "Music",
|
||||
"干员语音": "Operator Voices",
|
||||
"语音语言": "Voice Language",
|
||||
"音效": "Sound Effects",
|
||||
"静音": "Mute",
|
||||
"显示伤害数字": "Damage Numbers",
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
"完成": "完了",
|
||||
"背景音乐": "BGM",
|
||||
"干员语音": "オペレーターボイス",
|
||||
"语音语言": "ボイス言語",
|
||||
"音效": "効果音",
|
||||
"静音": "ミュート",
|
||||
"显示伤害数字": "ダメージ数値を表示",
|
||||
|
||||
@@ -150,6 +150,7 @@
|
||||
"完成": "완료",
|
||||
"背景音乐": "배경 음악",
|
||||
"干员语音": "오퍼레이터 음성",
|
||||
"语音语言": "음성 언어",
|
||||
"音效": "효과음",
|
||||
"静音": "음소거",
|
||||
"显示伤害数字": "대미지 수치 표시",
|
||||
|
||||
@@ -152,6 +152,7 @@
|
||||
"完成": "完成",
|
||||
"背景音乐": "背景音樂",
|
||||
"干员语音": "幹員語音",
|
||||
"语音语言": "語音語言",
|
||||
"音效": "音效",
|
||||
"静音": "靜音",
|
||||
"显示伤害数字": "顯示傷害數字",
|
||||
|
||||
+63
-12
@@ -4,6 +4,7 @@
|
||||
// bgm { lobby, prep, combat, combatAlts?: [ {intro?, loop}, … ], boss: { intro?, loop } },
|
||||
// bossBgm { [bossId]: { intro?, loop } },
|
||||
// voice { [charId]: { start, faceEnemy, select, place, skill1…skill4, squad, squadFirst, result*, gacha } },
|
||||
// voiceJp { [charId]: { …the same slots } } (the Japanese dub, settings 语音语言 日本語; see voiceLine),
|
||||
// sfx.ui { click, buy, sell, refresh, freeze, levelup, merge, equip, ready, timer, yourTurn, … },
|
||||
// sfx.battle { deploy, tokenDeploy, charDie, tokenDie?, enemyDie, enemyHit, heal, killCoin, … },
|
||||
// sfx.units { [charId|tokenId|enemyId]: { attack?, hit?, skill?, die?, born?, mix?: { [role]: { p?, vol? } } } }.
|
||||
@@ -25,7 +26,9 @@
|
||||
// operators, then fires dozens of skills, so they run on their own channel (own gain, settings 干员语音) through
|
||||
// VoiceGate: one line at a time, a global gap, a per-unit per-slot cooldown, and a higher-priority line taking the
|
||||
// channel over — the official scheduling of `audio_data.json battleVoice.voiceTypeOptions` (a tap's 选中 line skips
|
||||
// the gap: VOICE_TAP_SLOTS).
|
||||
// the gap: VOICE_TAP_SLOTS). Two dubs: 中文 `audio.voice` (default) and 日本語 `audio.voiceJp` (settings 语音语言, not
|
||||
// tied to the interface language; the owner's request of 2026-10-08 「全套的日配语音」) — a JP line the manifest or the
|
||||
// host lacks falls back to the Chinese one (voiceLine).
|
||||
// - Battle SFX from `b.ev` tuples (`handleBattleEvents`): at most MAX_VOICES concurrent unit sounds, at most
|
||||
// MAX_PER_URL overlapping copies of one sound (the official banks' maxSoundAllowed 2), a per-unit cooldown and a
|
||||
// per-URL minimum gap (SfxLimiter), so a 60-unit fight stays listenable.
|
||||
@@ -281,6 +284,34 @@ export const VOICE_COOLDOWN_MS = Object.freeze({
|
||||
*/
|
||||
export const VOICE_TAP_SLOTS = Object.freeze(['select']);
|
||||
|
||||
/**
|
||||
* The line an operator says for a slot, in the chosen dub (settings 语音语言, ui/gameLogic/settings.js VOICE_LANGS):
|
||||
* { url, fallback }, or null for an operator no dub voices (the 预备干员 / 原型干员 stand-ins, 盟约·辅助干员, summons)
|
||||
* or a slot it lacks. 'cn' draws from `audio.voice`; 'jp' from
|
||||
* `audio.voiceJp`, the same slots and file names in the Japanese dub, and falls back to the Chinese line twice over: per
|
||||
* slot when the JP tree lacks it (a file the fetch could not get is left out of the manifest), and per line at play time
|
||||
* — `fallback` is the Chinese file of the same name, played when the host does not have the JP one (a full zip built
|
||||
* without the JP dub, before setup downloaded it). A slot with several lines draws one (`random` ∈ [0, 1)).
|
||||
* @param {any} audio the manifest's `audio`
|
||||
* @param {string} charId
|
||||
* @param {string} slot
|
||||
* @param {'cn'|'jp'|string} [lang]
|
||||
* @param {() => number} [random]
|
||||
* @returns {{ url: string, fallback: string|null } | null}
|
||||
*/
|
||||
export function voiceLine(audio, charId, slot, lang = 'cn', random = Math.random) {
|
||||
const lines = (line) => (Array.isArray(line) ? line : [line]).filter((u) => typeof u === 'string' && u);
|
||||
const draw = (list) => (list.length ? list[Math.min(list.length - 1, Math.floor(random() * list.length))] : null);
|
||||
const cn = lines(audio?.voice?.[charId]?.[slot]);
|
||||
const jp = lang === 'jp' ? draw(lines(audio?.voiceJp?.[charId]?.[slot])) : null;
|
||||
if (jp) {
|
||||
const file = (u) => u.slice(u.lastIndexOf('/') + 1);
|
||||
return { url: jp, fallback: cn.find((u) => file(u) === file(jp)) ?? draw(cn) };
|
||||
}
|
||||
const url = draw(cn);
|
||||
return url ? { url, fallback: null } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The settlement slot of a finished 作战: 完美作战 ⇒ 3星结束行动 (绝境 / 终极 ⇒ 完成高难行动 instead), a leaked enemy
|
||||
* ⇒ 非3星结束行动, nothing killed at all ⇒ 行动失败.
|
||||
@@ -475,6 +506,7 @@ export class AudioManager {
|
||||
this.voiceGate = new VoiceGate();
|
||||
this.voiceNode = null; // { src, gain, url, token } of the line on air
|
||||
this.voiceToken = 0;
|
||||
this.voiceLang = 'cn'; // settings 语音语言: 'cn' (audio.voice) | 'jp' (audio.voiceJp, falling back to audio.voice)
|
||||
this.startVoiceDone = false; // 行动出发 of this field (the first operator deployed says it)
|
||||
this.uiVoices = 0;
|
||||
this.wantBgm = null; // desired key (kept while locked)
|
||||
@@ -614,6 +646,15 @@ export class AudioManager {
|
||||
this._applyVolumes();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param {string} lang
|
||||
*/
|
||||
setVoiceLang(lang) {
|
||||
this.voiceLang = lang === 'jp' ? 'jp' : 'cn';
|
||||
}
|
||||
|
||||
_applyVolumes() {
|
||||
if (!this.ctx) return;
|
||||
try {
|
||||
@@ -841,7 +882,8 @@ export class AudioManager {
|
||||
// ---- operator battle voice ----------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Play an operator's battle line (`audio.voice[charId][slot]`; a slot with several lines draws one at random).
|
||||
* Play an operator's battle line (`audio.voice[charId][slot]`, or `audio.voiceJp` when the 语音语言 setting is 日本語 —
|
||||
* voiceLine; a slot with several lines draws one at random).
|
||||
* Every caller is a running battle's own event stream or its settlement, except 选中干员: the detail panel opening on
|
||||
* an operator the player tapped, in every phase (ui/detailPanel.js `voice`; the owner's request of 2026-10-08). The
|
||||
* line must pass VoiceGate: one at a time, a global gap, a per-unit cooldown, higher priority wins.
|
||||
@@ -855,31 +897,40 @@ export class AudioManager {
|
||||
try {
|
||||
if (!this.ctx || !this.voiceGain || this.volumes.muted || this.volumes.voice <= 0) return false;
|
||||
if (typeof charId !== 'string' || typeof slot !== 'string') return false;
|
||||
const line = this.getManifest()?.audio?.voice?.[charId]?.[slot];
|
||||
const url = Array.isArray(line) ? line[Math.floor(Math.random() * line.length)] : line;
|
||||
if (typeof url !== 'string' || !url) return false;
|
||||
// the chosen dub's line (settings 语音语言), with the Chinese file of the same name as its fallback (voiceLine)
|
||||
const line = voiceLine(this.getManifest()?.audio, charId, slot, this.voiceLang);
|
||||
if (!line) return false;
|
||||
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
const verdict = this.voiceGate.request(slot, o.unitKey ?? null, now);
|
||||
if (verdict === 'drop') return false;
|
||||
if (verdict === 'preempt') this._stopVoice();
|
||||
this.voiceGate.start(slot, o.unitKey ?? null, now);
|
||||
const token = ++this.voiceToken;
|
||||
this._playVoice(url, token, o.volume);
|
||||
this._playVoice(line.url, token, o.volume, line.fallback);
|
||||
return true;
|
||||
} catch (err) { this._warn('voice', err); return false; }
|
||||
}
|
||||
|
||||
/** Fetch/decode and start one voice line through the voice channel. */
|
||||
_playVoice(url, token, volume) {
|
||||
/**
|
||||
* Fetch/decode and start one voice line through the voice channel. `fallback`: the line to play instead when this one
|
||||
* cannot be loaded (a JP file the host lacks → the Chinese file of the same name; voiceLine) — same token, same gate.
|
||||
*/
|
||||
_playVoice(url, token, volume, fallback = null) {
|
||||
// `token` is the line's own `voiceToken`. Every deferred step below — the decode, a failed fetch, `onended` and the
|
||||
// safety timer — can land AFTER this line was taken over or stopped: `voiceToken` has moved on and the channel then
|
||||
// belongs to the line that replaced it. So each step re-checks its token and, when it is stale, touches NOTHING:
|
||||
// `_stopVoice` (takeover / stop) and `setFieldUnits` released the gate themselves. An unconditional release here let
|
||||
// a stale callback free the channel the NEW line had just taken, and the next line walked in on top of it (review
|
||||
// on #73).
|
||||
const failed = () => {
|
||||
if (token !== this.voiceToken) return; // taken over / stopped meanwhile: not ours to release
|
||||
if (fallback && fallback !== url && this.ctx && this.voiceGain) this._playVoice(fallback, token, volume);
|
||||
else this.voiceGate.release();
|
||||
};
|
||||
this._buffer(url).then((buf) => {
|
||||
if (token !== this.voiceToken) return; // taken over / stopped while it decoded: not ours to release
|
||||
if (!buf || !this.ctx || !this.voiceGain) { this.voiceGate.release(); return; }
|
||||
if (!buf) { failed(); return; }
|
||||
if (!this.ctx || !this.voiceGain) { this.voiceGate.release(); return; }
|
||||
try {
|
||||
const src = this.ctx.createBufferSource();
|
||||
src.buffer = buf;
|
||||
@@ -907,7 +958,7 @@ export class AudioManager {
|
||||
this._warn('voice-play', err);
|
||||
if (token === this.voiceToken) this.voiceGate.release();
|
||||
}
|
||||
}, () => { if (token === this.voiceToken) this.voiceGate.release(); });
|
||||
}, failed);
|
||||
}
|
||||
|
||||
/** Fade the line on air out (a higher priority line is taking the channel over). */
|
||||
@@ -1058,13 +1109,13 @@ export const audio = new AudioManager({ getManifest: () => manifestGetter() });
|
||||
/**
|
||||
* Wire the singleton to the app (called once by main.js): manifest source, settings and store-driven BGM.
|
||||
* @param {{ getManifest: () => any, subscribe: (fn: (s:any, prev:any) => void) => () => void, getState: () => any,
|
||||
* selectRoute: (s:any) => string, settings?: { bgm:number, sfx:number, voice:number, muted:boolean } }} deps
|
||||
* selectRoute: (s:any) => string, settings?: { bgm:number, sfx:number, voice:number, muted:boolean, voiceLang?:string } }} deps
|
||||
*/
|
||||
export function installAudio(deps) {
|
||||
try {
|
||||
manifestGetter = typeof deps?.getManifest === 'function' ? deps.getManifest : manifestGetter;
|
||||
audio.install();
|
||||
if (deps?.settings) audio.setVolumes(deps.settings);
|
||||
if (deps?.settings) { audio.setVolumes(deps.settings); audio.setVoiceLang(deps.settings.voiceLang); }
|
||||
if (typeof deps?.subscribe === 'function' && typeof deps?.getState === 'function') {
|
||||
const sync = (s) => {
|
||||
try {
|
||||
|
||||
@@ -40,7 +40,7 @@ export { normalizeDraft, normalizeSp } from './gameLogic/draft.js';
|
||||
export { groupEnemies, PEN, penZoneTiles, penPlacement, previewEnemyKey, factionTypes } from './gameLogic/enemies.js';
|
||||
export { snapHud, bossFrac, bossPctText, hasFlag, UF, attackInterval, fmtNum, rangeGridBox } from './gameLogic/format.js';
|
||||
export { shortcutFor, closesOnFieldPress, shortcutBlocked, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, isBindableCode, hotkeyLabel, sanitizeHotkeys, rebindHotkey, isDefaultHotkeys, hotkeyOf, actionForKey, captureHotkey, facingSwallows } from './gameLogic/shortcuts.js';
|
||||
export { DEFAULT_SETTINGS, sanitizeSettings } from './gameLogic/settings.js';
|
||||
export { DEFAULT_SETTINGS, VOICE_LANGS, sanitizeSettings } from './gameLogic/settings.js';
|
||||
export { normalizeResult } from './gameLogic/result.js';
|
||||
export { chessLoadout, unitLoadout } from './gameLogic/loadout.js';
|
||||
export { standInIds, fieldsStandIn, standInOf, ownStandIn, cardStandIn, memberStandIn, standInGetter, standInLoadout, deployedRecord, deployedModuleId, standInLabel, standInForText, standInTip } from './gameLogic/standIn.js';
|
||||
|
||||
@@ -6,14 +6,20 @@ import { DEFAULT_HOTKEYS, sanitizeHotkeys } from './shortcuts.js';
|
||||
|
||||
// ---- settings ------------------------------------------------------------------------------------------------------
|
||||
|
||||
/** keys: the in-match shortcuts' key map (ui/gameLogic/shortcuts.js; settings → 快捷键). */
|
||||
export const DEFAULT_SETTINGS = Object.freeze({ bgm: 0.6, sfx: 0.8, voice: 0.8, muted: false, damageNumbers: true, quality: 'high', keys: DEFAULT_HOTKEYS });
|
||||
/**
|
||||
* The operator voice dubs (settings 语音语言): 'cn' 中文 (`audio.voice`, the default) and 'jp' 日本語 (`audio.voiceJp`,
|
||||
* falling back to the Chinese line it lacks — public/js/audio.js voiceLine). Not tied to the interface language.
|
||||
*/
|
||||
export const VOICE_LANGS = Object.freeze(['cn', 'jp']);
|
||||
|
||||
/** keys: the in-match shortcuts' key map (ui/gameLogic/shortcuts.js; settings → 快捷键). voiceLang: VOICE_LANGS. */
|
||||
export const DEFAULT_SETTINGS = Object.freeze({ bgm: 0.6, sfx: 0.8, voice: 0.8, voiceLang: 'cn', muted: false, damageNumbers: true, quality: 'high', keys: DEFAULT_HOTKEYS });
|
||||
const QUALITIES = ['high', 'medium', 'low'];
|
||||
|
||||
/**
|
||||
* Sanitize persisted settings.
|
||||
* @param {any} raw
|
||||
* @returns {{ bgm: number, sfx: number, voice: number, muted: boolean, damageNumbers: boolean, quality: 'high'|'medium'|'low',
|
||||
* @returns {{ bgm: number, sfx: number, voice: number, voiceLang: 'cn'|'jp', muted: boolean, damageNumbers: boolean, quality: 'high'|'medium'|'low',
|
||||
* keys: Record<'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready', string> }}
|
||||
*/
|
||||
export function sanitizeSettings(raw) {
|
||||
@@ -23,6 +29,7 @@ export function sanitizeSettings(raw) {
|
||||
bgm: vol(r.bgm, DEFAULT_SETTINGS.bgm),
|
||||
sfx: vol(r.sfx, DEFAULT_SETTINGS.sfx),
|
||||
voice: vol(r.voice, DEFAULT_SETTINGS.voice),
|
||||
voiceLang: VOICE_LANGS.includes(r.voiceLang) ? r.voiceLang : DEFAULT_SETTINGS.voiceLang,
|
||||
muted: typeof r.muted === 'boolean' ? r.muted : DEFAULT_SETTINGS.muted,
|
||||
damageNumbers: typeof r.damageNumbers === 'boolean' ? r.damageNumbers : DEFAULT_SETTINGS.damageNumbers,
|
||||
quality: QUALITIES.includes(r.quality) ? r.quality : DEFAULT_SETTINGS.quality,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Player settings (BGM/SFX/voice volume, mute, damage numbers, render quality, the shortcut keys): a tiny observable
|
||||
// store persisted in localStorage (`sp.pref.settings`), applied to the audio manager on every change, plus
|
||||
// Player settings (BGM/SFX/voice volume, the voice dub 语音语言, mute, damage numbers, render quality, the shortcut keys): a
|
||||
// tiny observable store persisted in localStorage (`sp.pref.settings`), applied to the audio manager on every change, plus
|
||||
// the settings modal — which also holds the language switch (ui/lang.js; kept apart in `sp.pref.lang`; under it a note
|
||||
// while the current language's pack is a machine translation, `_meta.machineTranslated`) and the 快捷键
|
||||
// section that rebinds the in-match shortcuts (the key map: ui/gameLogic/shortcuts.js; the community request
|
||||
@@ -8,7 +8,7 @@
|
||||
import { useLayoutEffect, useState } from '../../vendor/hooks.module.js';
|
||||
import { html, Modal, Button, Icon, MicroLabel } from './components.js';
|
||||
import { createStore, useStore, loadPref, savePref } from '../store.js';
|
||||
import { sanitizeSettings, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, hotkeyLabel, rebindHotkey, isDefaultHotkeys, captureHotkey } from './gameLogic.js';
|
||||
import { sanitizeSettings, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, hotkeyLabel, rebindHotkey, isDefaultHotkeys, captureHotkey, VOICE_LANGS } from './gameLogic.js';
|
||||
import { audio } from '../audio.js';
|
||||
import { openGuide } from './guide.js';
|
||||
import { detectFeatures } from './device.js';
|
||||
@@ -17,14 +17,16 @@ import { t, tc, N_ } from '../../../shared/i18n.js';
|
||||
|
||||
const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
||||
|
||||
/** Settings store: { bgm, sfx, voice, muted, damageNumbers, quality, keys }. */
|
||||
/** Settings store: { bgm, sfx, voice, voiceLang, muted, damageNumbers, quality, keys }. */
|
||||
export const settingsStore = createStore(sanitizeSettings(loadPref('settings', null)));
|
||||
|
||||
settingsStore.subscribe((s) => {
|
||||
savePref('settings', sanitizeSettings(s));
|
||||
audio.setVolumes(s);
|
||||
audio.setVoiceLang(s.voiceLang);
|
||||
});
|
||||
audio.setVolumes(settingsStore.get());
|
||||
audio.setVoiceLang(settingsStore.get().voiceLang);
|
||||
|
||||
/** @param {Partial<ReturnType<typeof sanitizeSettings>>} patch */
|
||||
export function updateSettings(patch) {
|
||||
@@ -60,6 +62,11 @@ function Toggle({ label, micro, value, onChange }) {
|
||||
}
|
||||
|
||||
const QUALITY = [['high', N_('高')], ['medium', N_('中')], ['low', N_('低')]];
|
||||
/**
|
||||
* 语音语言: each dub named in its own language, like the interface language switch (ui/lang.js) — the owner's
|
||||
* 「中文 / 日本語」 (2026-10-08); VOICE_LANGS order.
|
||||
*/
|
||||
const VOICE_LANG_NAMES = { cn: '中文', jp: '日本語' }; // i18n-ignore
|
||||
/** The rebindable shortcuts' names (msgids), by action. */
|
||||
const HOTKEY_NAMES = { refresh: N_('刷新商店'), freeze: N_('冻结 / 解冻商店'), levelUp: N_('升级调度中心'), retreat: N_('撤退选中干员'),
|
||||
sell: N_('出售选中干员'), ready: N_('准备就绪 / 暂停(独立模拟)') };
|
||||
@@ -164,6 +171,13 @@ export function SettingsModal({ open, onClose }) {
|
||||
${mtNote ? html`<p class="set-hint set-lang-note" data-testid="lang-mt-note">${mtNote}</p>` : null}
|
||||
<${Slider} label=${t('背景音乐')} micro="BGM" icon="play" value=${s.bgm} onInput=${(v) => updateSettings({ bgm: v })} />
|
||||
<${Slider} label=${t('干员语音')} micro="VOICE" icon="mic" value=${s.voice} onInput=${(v) => updateSettings({ voice: v })} />
|
||||
<div class="set-row">
|
||||
<span class="set-row__label">${t('语音语言')}<${MicroLabel}>VOICE LANGUAGE<//></span>
|
||||
<div class="set-seg" role="radiogroup" aria-label=${t('语音语言')} data-testid="voice-lang">
|
||||
${VOICE_LANGS.map((id) => html`<button key=${id} type="button" role="radio" aria-checked=${s.voiceLang === id ? 'true' : 'false'}
|
||||
lang=${id === 'jp' ? 'ja' : 'zh'} class=${s.voiceLang === id ? 'is-on' : ''} onClick=${() => updateSettings({ voiceLang: id })}>${VOICE_LANG_NAMES[id]}</button>`)}
|
||||
</div>
|
||||
</div>
|
||||
<${Slider} label=${t('音效')} micro="SFX" icon="signal" value=${s.sfx}
|
||||
onInput=${(v) => { updateSettings({ sfx: v }); if (!tested) { setTested(true); setTimeout(() => setTested(false), 400); audio.sfx('click'); } }} />
|
||||
<${Toggle} label=${t('静音')} micro="MUTE" value=${s.muted} onChange=${(v) => updateSettings({ muted: v })} />
|
||||
|
||||
Reference in new issue
Block a user