Files
ark-stronghold-protocol/public/js/ui/settings.js
T
sganggs c5e8d13636 feedback5: merge feedback4 (beb0296) — PR #73 (operator battle voices) ported to the split layout (the voice setting into ui/gameLogic/settings.js, the gameLogic.js shim unchanged; onResult and DetailPanel voice stay in screens/game.js, which needed its store import back; the sim's 'engage' in server/sim/ai.js performAttack, still the one battle/combat.js and devices.js call; plan.mjs / fetch-assets.mjs merged with --add-only, extraOperators and moduleTypes; the 干员语音 slider through t() with English); golden: roster / bonds / fields / standins / diy differ only in events.engage, matches unchanged
Ported by hand:
- public/js/ui/gameLogic.js is a re-export shim on feedback5: #73's DEFAULT_SETTINGS / sanitizeSettings `voice`
  hunk went into public/js/ui/gameLogic/settings.js.
- public/js/screens/game.js: MatchScreen still lives there, so #73's onResult (battleRunner 'result' → resultSpeaker /
  resultVoiceSlot) and `voice=${combat}` on the DetailPanel auto-merged in place. feedback5's game.js no longer
  imported `store` (the split moved its other users), so onResult's store.get() would have thrown into its
  try/catch and the 结算 line would never have played: `store` is imported again. `localAsset` stays dropped.
- public/js/ui/settings.js (i18n phase 1): the slider is `label=${t('干员语音')}`, public/i18n/en.json
  "干员语音": "Operator Voices" (next to Music / Sound Effects).
- tools/assets/plan.mjs: buildPlan takes #73's charword / voiceLang / voiceSlots and feedback5's extraOperators /
  moduleTypes; the template has `modules` and audio { bgm, bossBgm, voice, sfx }. `chars` now includes the 自选 picks,
  so the voice loop's comment says so (the next `npm run assets` plans their lines too).
- tools/fetch-assets.mjs: parseArgs keeps --add-only beside --voice-lang / --voice-all.
- data/assets.json: feedback5's manifest plus #73's audio.voice (120 operators, after bossBgm, the plan order);
  stats files +1680 and bytes +42 041 118 as measured on feedback4, voiceChars 120; the hash is recomputed with
  the generator's contentHash. No other entry changed.
- docs/ASSETS.md: the stats line lists modules and voiceChars. DESIGN §21.30 stays where feedback4 put it; SIM.md's
  'engage' line and docs-consistency's §21.30 test auto-merged (game.js / detailPanel.js still hold the pinned code).
- Auto-merged and checked: audio.js, battle/runner.js emit('result'), components.js mic icon, detailPanel.js voice
  prop, shared/protocol.js EV.ENGAGE, server/sim/ai.js, tools/assets/audio.mjs / cache.mjs, #73's tests.

Where #73 met feedback5 work:
- 补位: a chess fielded as its stand-in shows the stand-in in the detail card, and audio.js keys battle lines by
  UnitInfo spine (the stand-in's model). The 选中干员 line now takes detail.standIn.charId first, so the stand-in
  speaks, not the operator it replaces (test/ui/standin-ui.test.js pins it). A 自选 record is already the pick's.
- The sim's 'engage': stand-in and 自选 units attack through the same performAttack, so it fires for them as well.

Golden regenerated: every moved scenario differs only in events.engage (the new event's count; no gameplay change),
proven by a field-path diff against feedback5:
- roster: all 49; bonds: all 46; fields: all 22; standins: all 10 (the stand-ins engage).
- diy: 101 of 109. Unmoved: diy-010/011/012/092/093 (闪灵, 夜莺, 凯尔希 squads: they only heal, and a heal never
  engages) and diy-077 (薇薇安娜), diy-080/081 (赫德雷), whose picks make no attack in those battles (already so on
  feedback5).
- matches: unchanged.
2026-10-06 04:31:38 +08:00

90 lines
5.0 KiB
JavaScript

// Player settings (BGM/SFX/voice volume, mute, damage numbers, render quality): 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`).
import { 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 } from './gameLogic.js';
import { audio } from '../audio.js';
import { openGuide } from './guide.js';
import { detectFeatures } from './device.js';
import { LangToggle } from './lang.js';
import { t, tc, N_ } from '../../../shared/i18n.js';
/** Settings store: { bgm, sfx, voice, muted, damageNumbers, quality }. */
export const settingsStore = createStore(sanitizeSettings(loadPref('settings', null)));
settingsStore.subscribe((s) => {
savePref('settings', sanitizeSettings(s));
audio.setVolumes(s);
});
audio.setVolumes(settingsStore.get());
/** @param {Partial<ReturnType<typeof sanitizeSettings>>} patch */
export function updateSettings(patch) {
settingsStore.set(sanitizeSettings({ ...settingsStore.get(), ...patch }));
}
/** Preact hook: current settings. */
export const useSettings = () => useStore((s) => s, Object.is, settingsStore);
function Slider({ label, micro, value, onInput, icon }) {
const pct = Math.round(value * 100);
return html`<label class="set-row">
<span class="set-row__label"><${Icon} name=${icon} />${label}<${MicroLabel}>${micro}<//></span>
<input class="set-range" type="range" min="0" max="100" step="5" value=${pct} style=${`--pct:${pct}%`}
onInput=${(e) => onInput(Number(e.currentTarget.value) / 100)} />
<span class="set-row__val num">${pct}</span>
</label>`;
}
function Toggle({ label, micro, value, onChange }) {
return html`<div class="set-row">
<span class="set-row__label">${label}<${MicroLabel}>${micro}<//></span>
<button type="button" class=${`set-toggle${value ? ' is-on' : ''}`} role="switch" aria-checked=${value ? 'true' : 'false'}
onClick=${() => onChange(!value)}><i></i><span>${value ? tc('toggle', '开启') : tc('toggle', '关闭')}</span></button>
</div>`;
}
const QUALITY = [['high', N_('高')], ['medium', N_('中')], ['low', N_('低')]];
/** Desktop shortcuts of the hint line: key → action (msgids). */
const SHORTCUTS = [['R', N_('刷新')], ['F', N_('冻结')], ['D', N_('升级')], ['Q', N_('撤退选中干员')], ['X', N_('出售选中干员')],
['Space', N_('准备就绪')], ['Esc', N_('关闭弹窗')]];
/**
* Settings modal.
* @param {{ open: boolean, onClose: Function }} props
*/
export function SettingsModal({ open, onClose }) {
const s = useSettings();
const [tested, setTested] = useState(false);
const [touchUi] = useState(() => detectFeatures().coarse && !detectFeatures().fine);
return html`<${Modal} open=${open} onClose=${onClose} title=${t('设置')} micro="SETTINGS" width="7.4rem"
actions=${html`<${Button} variant="secondary" icon="book" class="set-guide" onClick=${() => openGuide(0)}>${t('玩法说明')}<//>
<${Button} variant="primary" icon="check" onClick=${onClose}>${t('完成')}<//>`}>
<div class="set-list">
<div class="set-row">
<span class="set-row__label">${t('语言')}<${MicroLabel}>LANGUAGE<//></span>
<${LangToggle} class="set-lang" />
</div>
<${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 })} />
<${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 })} />
<${Toggle} label=${t('显示伤害数字')} micro="DAMAGE NUMBERS" value=${s.damageNumbers} onChange=${(v) => updateSettings({ damageNumbers: v })} />
<div class="set-row">
<span class="set-row__label">${t('画面质量')}<${MicroLabel}>QUALITY<//></span>
<div class="set-seg" role="radiogroup">
${QUALITY.map(([id, label]) => html`<button key=${id} type="button" role="radio" aria-checked=${s.quality === id ? 'true' : 'false'}
class=${s.quality === id ? 'is-on' : ''} onClick=${() => updateSettings({ quality: id })}>${t(label)}</button>`)}
</div>
</div>
${touchUi
? html`<p class="set-hint">${t('触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向')}</p>`
: html`<p class="set-hint">${t('快捷键:')}${SHORTCUTS.map(([key, label]) => html`<kbd>${key}</kbd> ${t(label)} · `)}${t('右键查看详情')}</p>`}
</div>
<//>`;
}