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.
This commit is contained in:
sganggs committed 2026-10-06 04:31:38 +08:00
commit c5e8d13636
29 files changed
+1059 -278

No files matched your search

+1
View File
@@ -67,6 +67,7 @@ export const ICONS = {
info: { d: 'M12 2a10 10 0 1 1 0 20 10 10 0 0 1 0-20zm-1 8v7h2v-7zm0-3v2h2V7z', eo: true },
warn: { d: 'M12 2 1 21h22zm-1 7v6h2V9zm0 7.5v2h2v-2z', eo: true },
play: { d: 'M7 4v16l13-8z' },
mic: { d: 'M12 14a3 3 0 0 0 3-3V5a3 3 0 0 0-6 0v6a3 3 0 0 0 3 3zm5-3a5 5 0 0 1-10 0H5a7 7 0 0 0 6 6.9V21H8v2h8v-2h-3v-3.1A7 7 0 0 0 19 11z' },
edit: { d: 'M3 17.2V21h3.8l11-11-3.8-3.8zM20.7 7.1a1 1 0 0 0 0-1.4l-2.4-2.4a1 1 0 0 0-1.4 0l-1.8 1.8 3.8 3.8z' },
sword: { d: 'M20 3h1v4L10.4 17.6l1.4 1.4-1.4 1.4-2.1-2.1-3.5 3.5-1.4-1.4 3.5-3.5L4.8 14.8l1.4-1.4 1.4 1.4L18 4z' },
shield: { d: 'M12 2 4 5v6c0 5 3.4 9.3 8 11 4.6-1.7 8-6 8-11V5z' },
+14 -1
View File
@@ -31,6 +31,7 @@
// The stats block (chessStatsBlock), the 特性 text (traitText) and the talent list (chessTalents) are exported: the 干员调配
// screen's 局内数值 section draws the same ones for the chosen skill / module, without a live entry (GitHub issue #64).
import { useEffect } from '../../vendor/hooks.module.js';
import { html, Icon, TierChip, MicroLabel, Button, confirmDialog, useTicker } from './components.js';
import { Img, RichText, UnitThumb, BondGlyph, GIcon, diyToken } from './gameComponents.js';
import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, briefingBondTip, pieceBondIds, grantedBonds, morphPairings, ownStandIn, standInOf, standInLoadout, standInLabel, standInTip, standInForText, ownDiyRecord, ownDiyPick, diyRecordFor, pickGetter } from './gameLogic.js';
@@ -41,6 +42,7 @@ import { attackRangeGrid } from '../../../shared/loadoutRecord.js';
import { SKILL_SUMMON_START_DEPLOY } from '../../../shared/constants.js';
import { moduleBadge } from './loadoutModel.js';
import { t } from '../../../shared/i18n.js';
import { audio } from '../audio.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
@@ -695,10 +697,21 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get(
* = the defaults
* live: the unit's live stats (unitStatsEntry + src 'battle' | 'prep') — an object, or a getter the panel re-reads 4×
* a second (the battle's own sim, battle/runner.js unitStats); null ⇒ the record's numbers
* voice: whether the panel may speak — 选中干员 (audio.voice 'select') plays only while a battle runs (user request:
* 整备期不播干员语音), so the game screen passes its combat flag
*/
export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestroy, bonds = [], offBonds = null, loadout = null, onBond = null, side = 'left', shopOpen = false, live = null }) {
export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestroy, bonds = [], offBonds = null, loadout = null, onBond = null, side = 'left', shopOpen = false, live = null, voice = false }) {
const getter = typeof live === 'function' ? live : null;
useTicker(detail && getter ? 250 : 0);
// 选中干员 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)
const selectKey = voice && detail?.type === 'chess' ? `${detail.chess?.chessId || ''}:${detail.unitId ?? detail.piece?.uid ?? ''}` : 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
// 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;
useEffect(() => {
if (selectKey && selectChar) audio.voice(selectChar, 'select');
}, [selectKey, selectChar]);
if (!detail) return null;
let liveNow = null;
try { liveNow = getter ? getter() : live && typeof live === 'object' ? live : null; } catch { liveNow = null; }
+3 -2
View File
@@ -5,13 +5,13 @@ import { clamp, isObj } from './shared.js';
// ---- settings ------------------------------------------------------------------------------------------------------
export const DEFAULT_SETTINGS = Object.freeze({ bgm: 0.6, sfx: 0.8, muted: false, damageNumbers: true, quality: 'high' });
export const DEFAULT_SETTINGS = Object.freeze({ bgm: 0.6, sfx: 0.8, voice: 0.8, muted: false, damageNumbers: true, quality: 'high' });
const QUALITIES = ['high', 'medium', 'low'];
/**
* Sanitize persisted settings.
* @param {any} raw
* @returns {{ bgm: number, sfx: number, muted: boolean, damageNumbers: boolean, quality: 'high'|'medium'|'low' }}
* @returns {{ bgm: number, sfx: number, voice: number, muted: boolean, damageNumbers: boolean, quality: 'high'|'medium'|'low' }}
*/
export function sanitizeSettings(raw) {
const r = isObj(raw) ? raw : {};
@@ -19,6 +19,7 @@ export function sanitizeSettings(raw) {
return {
bgm: vol(r.bgm, DEFAULT_SETTINGS.bgm),
sfx: vol(r.sfx, DEFAULT_SETTINGS.sfx),
voice: vol(r.voice, DEFAULT_SETTINGS.voice),
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,
+3 -2
View File
@@ -1,4 +1,4 @@
// Player settings (BGM/SFX volume, mute, damage numbers, render quality): a tiny observable store
// 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`).
@@ -12,7 +12,7 @@ import { detectFeatures } from './device.js';
import { LangToggle } from './lang.js';
import { t, tc, N_ } from '../../../shared/i18n.js';
/** Settings store: { bgm, sfx, muted, damageNumbers, quality }. */
/** Settings store: { bgm, sfx, voice, muted, damageNumbers, quality }. */
export const settingsStore = createStore(sanitizeSettings(loadPref('settings', null)));
settingsStore.subscribe((s) => {
@@ -69,6 +69,7 @@ export function SettingsModal({ open, onClose }) {
<${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 })} />