0.2.3: prepare follow-up fixes and client features (#407, #408, #410, #413, #431, #435, #436, #440, #444)

Add special-target activation without bypassing attack cadence, cooperative
card preferences and emotes, skill sound banks, per-operator voice choices,
text sizing, install prompts, closed-tab recovery and unanimous setup rerolls.
Retain late deployment elapsed time and resolve echo/counter pulses without
recursive hook overflow. Independent final review remains pending.

Golden: roster-016, roster-035, roster-036, roster-045;
boss-boss_1-pair, boss-boss_3-pair, boss-boss_5-pair, boss-boss_7-pair,
hidden-boss_9-pair; solo-HARD-9-boosted: Lemuen attack cadence.
diy-132: Necras extra activation targets.
coop2-NORMAL-14-standins: cooperative purchase preference and simulation fixes.
hidden-boss_10-solo: echo pulse FIFO resolution.
See DESIGN 28.13-28.24 for assumptions, sources and contributor credit.
This commit is contained in:
sganggs committed 2026-10-09 22:55:24 +08:00
1 parent 314564c139
commit f3658a0dd4
104 files changed
+3674 -741

No files matched your search

+10 -1
View File
@@ -226,7 +226,11 @@ export function bgmEntry(m, kind) {
/** SFX URL: group 'ui' | 'battle', key e.g. 'buy'. */
export const sfxUrl = (m, group, key) => str(get(get(get(get(m, 'audio'), 'sfx'), str(group) || ''), str(key) || ''));
/** Per-unit SFX: kind 'attack' | 'hit' | 'skill' | 'die' | 'born'; `skillIndex` picks `skills[i]` when present. */
/**
* Per-unit SFX: kind 'attack' | 'hit' | 'skill' | 'die' | 'born'; `skillIndex` picks `skills[i]` and — for an attack or
* an impact — `attacks[i]` / `hits[i]` (the running skill's own files, tools/assets/audio.mjs pickModeAttacks /
* pickModeHits) when the manifest has them.
*/
export function unitSfxUrl(m, id, kind, skillIndex) {
const u = get(get(get(get(m, 'audio'), 'sfx'), 'units'), str(id) || '') || get(get(get(get(m, 'audio'), 'sfx'), 'units'), baseCharId(id) || '');
if (!isObj(u)) return null;
@@ -234,6 +238,11 @@ export function unitSfxUrl(m, id, kind, skillIndex) {
const s = str(u.skills[skillIndex]);
if (s) return s;
}
const per = kind === 'attack' ? u.attacks : kind === 'hit' ? u.hits : null;
if (Number.isInteger(skillIndex) && isObj(per)) {
const a = str(per[skillIndex]);
if (a) return a;
}
return str(u[kind]);
}
+44 -13
View File
@@ -43,6 +43,16 @@
// sound of that target. An operator's attack / hit sound that is a skill-mode file of its own (official names end in
// `_n` for the normal attack, `_d` / `_h` / `_s` for its skill modes — the manifest picked 纯烬艾雅法拉's S3 impact
// p_imp_gtshpbrnch_s as her `hit`) never plays for a normal attack (normalAttackSfx).
// - The attack sound of the RUNNING skill (`attacks`, 技能1 / 2 / 3 = `_d` / `_h` / `_s`): an operator whose every attack
// belongs to a skill mode has no normal attack bank at all — 司霆惊蛰 (解放者: she only attacks while a skill runs) was
// completely silent when she attacked (report 「三技能攻击没有音效」). The manifest then carries that mode's own file
// per skill index (`attacks`, tools/assets/audio.mjs pickModeAttacks) and it plays while the skill is active, from the
// `['skill', id, 1]` / `0` events; a unit with a normal bank too (能天使 S2 过载模式, 史尔特尔 S2) keeps that bank
// outside the skill. Being the official file of that very skill, it is not the normalAttackSfx case above.
// - …and its impact (`hits`): a mode's swing is not always the whole difference — 赤刃明霄陈 S3's slashes swing
// p_atk_hljdswd_s AND land p_imp_hljdswd_s, while her normal attack uses p_atk_hljdswd_n / p_imp_hljdswd_n
// (report 「开启三技能后斩击音效应该和普通攻击不一样」). Same mechanism and the same window: the `hit` of an attack
// aimed while the skill ran — the attacker's state travels with `lastAttacker`.
// - The official bank mix of a unit's own attack / hit / die / born sound (`mix`, tools/assets/audio.mjs bankMix; community
// report #30): it plays with chance `p` — 猎狗pro / 深池侦察犬's attack bank is 80 % silence, so they bark on about one
// attack in five (never replaced by the generic enemy sound) — at its base gain × `vol`, capped at 1: an official volume
@@ -55,6 +65,7 @@
// `bgmKeyFor(route, pub)` picks the track for the current screen/phase (main.js calls `audio.install()`,
// which follows the store).
import { sanitizeVoiceOverrides, voiceLangFor } from './voicePrefs.js';
import { PHASE } from '../../shared/constants.js';
import { mediaUrl } from './media.js';
@@ -514,6 +525,7 @@ export class AudioManager {
this.voiceGate = new VoiceGate();
this.voiceNode = null; // { src, gain, url, token } of the line on air
this.voiceToken = 0;
this.voiceOverrides = {};
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;
@@ -522,7 +534,8 @@ export class AudioManager {
this.bgmToken = 0;
this.units = new Map(); // battle unit id → defId
this.pendingSkill = new Map(); // unit id → the 'skill' tuple that arrived before the unit was known (see _track)
this.lastAttacker = new Map(); // target id → { def, at } of the hostile attack last aimed at it (its impact sound)
this.lastAttacker = new Map(); // target id → { def, at, skillIndex, skillActive } of the hostile attack aimed at it (its impact sound,
// including the running skill's own `hits` — 赤刃明霄陈 S3's slash)
this.consumed = new Set(); // summons used up by their own effect (香槟炸弹 exploded): no death sound
this.installed = false;
this._unlock = this._unlock.bind(this);
@@ -660,8 +673,9 @@ export class AudioManager {
* line on air finishes in its own dub; the next one follows the setting.
* @param {string} lang
*/
setVoiceLang(lang) {
setVoiceLang(lang, overrides = this.voiceOverrides) {
this.voiceLang = lang === 'jp' ? 'jp' : 'cn';
this.voiceOverrides = sanitizeVoiceOverrides(overrides);
}
_applyVolumes() {
@@ -870,18 +884,27 @@ export class AudioManager {
* @param {string} defId charId/tokenId/enemyId (or chess id — mapped via its spine/char id by the caller)
* @param {'attack'|'hit'|'skill'|'die'|'born'} kind
* @param {number|string} unitId battle unit id (cooldown key)
* @param {number} [skillIndex] the unit's equipped skill slot (0-based)
* @param {boolean} [skillActive] whether that skill runs right now (`attacks` / `hits` need it, see header)
* @returns {boolean} whether a unit-specific sound exists
*/
unit(defId, kind, unitId, skillIndex) {
unit(defId, kind, unitId, skillIndex, skillActive = false) {
try {
const u = this.getManifest()?.audio?.sfx?.units?.[defId];
// DESIGN §16: the equipped skill's own ON_SKILL_START sound (`skills[index]`) when the manifest has it
const own = kind === 'skill' && Number.isInteger(skillIndex) && u?.skills ? u.skills[skillIndex] : null;
const url = typeof own === 'string' ? own : u?.[kind];
// the running skill's own swing / impact (header); only while it runs — otherwise 能天使 S2's file would ring on
// every normal attack of hers (exactly what normalAttackSfx keeps out). 赤刃明霄陈 S3: `attacks[2]` = the S3 swing,
// `hits[2]` = the S3 slash's impact.
const table = kind === 'attack' ? u?.attacks : kind === 'hit' ? u?.hits : null;
const mode = skillActive && Number.isInteger(skillIndex) && table ? table[skillIndex] : null;
const url = typeof own === 'string' ? own : typeof mode === 'string' ? mode : u?.[kind];
if (typeof url !== 'string') return false;
if ((kind === 'attack' || kind === 'hit') && !normalAttackSfx(defId, url)) return false;
// a skill-mode file is fine here when it IS the running skill's (mode); the gate is for normal attacks
if ((kind === 'attack' || kind === 'hit') && typeof mode !== 'string' && !normalAttackSfx(defId, url)) return false;
// the official bank's mix (header): a silent roll still counts as the unit's own sound (no generic fallback)
const mix = kind === 'skill' ? null : u?.mix?.[kind];
const mix = kind === 'skill' ? null : typeof mode === 'string'
? (kind === 'attack' ? u?.attackMix : u?.hitMix)?.[skillIndex] : u?.mix?.[kind];
if (!unitSoundPlays(mix, this.random())) return true;
this._play(url, { volume: unitGain(kind === 'attack' || kind === 'hit' ? 0.55 : 0.8, mix), limited: true, unitKey: `${unitId}:${kind}` });
return true;
@@ -907,7 +930,7 @@ export class AudioManager {
if (!this.ctx || !this.voiceGain || this.volumes.muted || this.volumes.voice <= 0) return false;
if (typeof charId !== 'string' || typeof slot !== 'string') 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);
const line = voiceLine(this.getManifest()?.audio, charId, slot, voiceLangFor(charId, this.voiceLang, this.voiceOverrides));
if (!line) return false;
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
const verdict = this.voiceGate.request(slot, o.unitKey ?? null, now);
@@ -1005,9 +1028,9 @@ export class AudioManager {
_track(u) {
if (!u || typeof u !== 'object' || u.id == null) return;
// UnitInfo.spine is the model id (charId / tokenId / enemyId) — the key of sfx.units; kind/defId pick the
// official class sounds (operator vs summon vs device)
// official class sounds (operator vs summon vs device). `skillActive` follows the ['skill', id, 1] / 0 events.
this.units.set(u.id, { def: u.spine || u.defId, defId: u.defId ?? null, kind: u.kind ?? null, side: u.side, boss: !!u.boss,
skillIndex: Number.isInteger(u.skillIndex) ? u.skillIndex : null });
skillIndex: Number.isInteger(u.skillIndex) ? u.skillIndex : null, skillActive: !!u.skillActive });
// a cast that arrived before this unit was known (handleBattleEvents): it sounds now, once
const held = this.pendingSkill.get(u.id);
if (held) {
@@ -1040,18 +1063,20 @@ export class AudioManager {
if (!src) continue;
// only a hostile attack authors the target's next impact (a heal — an ally aiming at an ally — never does)
const tgt = this.units.get(e[2]);
if (tgt && tgt.side !== src.side) this.lastAttacker.set(e[2], { def: src.def, at: now });
if (!this.unit(src.def, 'attack', e[1]) && src.side === 'enemy') this.battle('enemyHit', { unitKey: `${e[1]}:atk`, volume: 0.35 });
if (tgt && tgt.side !== src.side) this.lastAttacker.set(e[2], { def: src.def, at: now, skillIndex: src.skillIndex ?? null, skillActive: !!src.skillActive });
if (!this.unit(src.def, 'attack', e[1], src.skillIndex ?? undefined, src.skillActive) && src.side === 'enemy') this.battle('enemyHit', { unitKey: `${e[1]}:atk`, volume: 0.35 });
} else if (kind === 'dmg') {
const by = this.lastAttacker.get(e[1]);
if (!by || !IMPACT_TYPES.has(e[3])) continue;
this.lastAttacker.delete(e[1]); // one impact per attack
if (now - by.at <= IMPACT_WINDOW_MS) this.unit(by.def, 'hit', `h${e[1]}`);
if (now - by.at <= IMPACT_WINDOW_MS) this.unit(by.def, 'hit', `h${e[1]}`, by.skillIndex ?? undefined, by.skillActive);
} else if (kind === 'heal') {
this.battle('heal', { unitKey: `heal:${e[1]}`, volume: 0.35 });
} else if (kind === 'skill' && e[2]) {
const u = this.units.get(e[1]);
if (u) {
// the running skill's own swing / impact (`attacks` / `hits`, header) follow this flag
u.skillActive = true;
this.unit(u.def, 'skill', e[1], u.skillIndex ?? undefined);
// 作战中N: the equipped skill's own slot (0-based; 作战中4 is the fallback of a 4th slot)
if (unitSoundClass(u) === 'char') {
@@ -1066,6 +1091,12 @@ export class AudioManager {
if (this.pendingSkill.size >= PENDING_SKILL_MAX) this.pendingSkill.delete(this.pendingSkill.keys().next().value);
this.pendingSkill.set(e[1], e);
}
} else if (kind === 'skill') {
// ['skill', id, 0] = the skill ENDED (the sim's skills.js: `b._ev(['skill', u.id, 0])`): back to the unit's own
// attack / impact. The end carries no sound of its own (the activation sound belongs to the start).
const u = this.units.get(e[1]);
if (u) u.skillActive = false;
this.pendingSkill.delete(e[1]);
} else if (kind === 'engage') {
// 行动开始: the first attack a unit makes on an enemy (the sim's ENGAGE, official ENCOUNTER_ENEMY, 3 s apart)
const u = this.units.get(e[1]);
@@ -1140,7 +1171,7 @@ export function installAudio(deps) {
try {
manifestGetter = typeof deps?.getManifest === 'function' ? deps.getManifest : manifestGetter;
audio.install();
if (deps?.settings) { audio.setVolumes(deps.settings); audio.setVoiceLang(deps.settings.voiceLang); }
if (deps?.settings) { audio.setVolumes(deps.settings); audio.setVoiceLang(deps.settings.voiceLang, deps.settings.voiceOverrides); }
if (typeof deps?.subscribe === 'function' && typeof deps?.getState === 'function') {
const sync = (s) => {
try {
+4
View File
@@ -136,6 +136,7 @@ function maybeFinishRestore() {
* follow the store themselves).
*/
function backToLobby() {
identity.rememberMatch(null);
clearTimeout(restoreTimer);
const s = store.get();
if (s.room || s.match.public) closeAllDialogs();
@@ -145,6 +146,7 @@ function backToLobby() {
function onWelcome(msg) {
identity.saveToken(msg.token);
identity.rememberMatch(null);
const prev = store.get();
const prevId = prev.me.playerId;
const name = typeof msg.name === 'string' && msg.name ? msg.name : prev.me.name;
@@ -188,6 +190,8 @@ function onRoomState(msg) {
// A (new) match starts: forget the previous match's state so stale results never show.
if (room.inMatch && !(prevRoom && prevRoom.inMatch && prevRoom.code === room.code)) store.set({ match: emptyMatch() });
store.set({ room });
identity.rememberMatch(room.inMatch && seats.some((seat) => seat?.playerId === myId)
? { name: store.get().me.name, code: room.code || '' } : null);
if (room.mode === 'coop' && typeof room.code === 'string') rememberRoom(room.code);
maybeFinishRestore();
}
+53
View File
@@ -660,6 +660,7 @@ export class Net {
const K_NAME = 'sp.name';
const K_TOKEN = 'sp.token'; // sessionStorage: this tab's token
const K_RECENT = 'sp.tokens'; // localStorage: this browser's recent tokens, most recent first
const K_MATCHES = 'sp.matches'; // local metadata for explicit recovery of another closed tab's seat
const K_ENTERED = 'sp.entered'; // sessionStorage: this tab passed the title screen
const RECENT_MAX = 4;
const TOKEN_MAX_LEN = 64; // protocol limit for hello.token
@@ -725,6 +726,7 @@ export function createIdentity(deps = {}) {
let channel = null;
let initPromise = null;
let initialized = false;
let resumeBusy = false;
let current = null; // token this tab uses (null = let the server create a session)
let resolving = null; // { hashes: Set<hash>, taken: Set<hash> } while init() waits for claims
@@ -738,6 +740,25 @@ export function createIdentity(deps = {}) {
};
const writeRecent = (list) => sset(local, K_RECENT, JSON.stringify([...new Set(list)].slice(0, RECENT_MAX)));
const readMatches = () => {
try {
const v = JSON.parse(sget(local, K_MATCHES) || '{}');
return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
} catch { return {}; }
};
const rememberMatch = (info) => {
if (!current) return;
const old = readMatches(), next = {};
for (const token of readRecent()) {
const hash = tokenHash(token);
const v = token === current ? info : old[hash];
if (v && typeof v.name === 'string' && typeof v.code === 'string') {
next[hash] = { name: v.name.slice(0, 64), code: v.code.slice(0, 32) };
}
}
sset(local, K_MATCHES, JSON.stringify(next));
};
const post = (msg) => {
try { channel?.postMessage({ ...msg, from: tabId }); } catch { /* channel closed */ }
};
@@ -773,6 +794,10 @@ export function createIdentity(deps = {}) {
// Without a channel we cannot tell whether a shared token is in use: never adopt one.
const candidates = [...new Set([ownOk, ...(channel ? readRecent() : [])].filter(isToken))];
if (!channel || candidates.length === 0) return ownOk;
return queryCandidates(candidates);
}
async function queryCandidates(candidates) {
resolving = { hashes: new Set(candidates.map(tokenHash)), taken: new Set() };
post({ type: 'who', hashes: [...resolving.hashes] });
await new Promise((r) => setTimer(r, queryMs));
@@ -802,6 +827,32 @@ export function createIdentity(deps = {}) {
}
return initPromise;
},
/** Record only display metadata; the existing recent-token ring remains the authority. */
rememberMatch,
recoverable() {
const matches = readMatches();
return readRecent().filter((token) => token !== current).flatMap((token) => {
const hash = tokenHash(token), v = matches[hash];
return v && typeof v.name === 'string' && typeof v.code === 'string'
? [{ id: hash, name: v.name.slice(0, 64), code: v.code.slice(0, 32) }] : [];
});
},
/** Explicit recovery only. Recheck live claims immediately before selecting a saved seat. */
async resume(id) {
await this.init();
if (!channel || resumeBusy) return false;
const token = readRecent().find((t) => t !== current && tokenHash(t) === id);
if (!token || !this.recoverable().some((m) => m.id === id)) return false;
resumeBusy = true;
try {
const pick = await queryCandidates([token]);
if (!pick) return false;
current = pick;
sset(session, K_TOKEN, pick);
return true;
} catch { return false; }
finally { resumeBusy = false; }
},
/** @returns {string} remembered player name ('' if none) */
loadName: () => (sget(local, K_NAME) || '').slice(0, 64),
/** @param {string} name */
@@ -816,12 +867,14 @@ export function createIdentity(deps = {}) {
/** @param {string} token from `welcome` */
saveToken(token) {
if (!isToken(token)) return;
if (current !== token) rememberMatch(null);
current = token;
sset(session, K_TOKEN, token);
writeRecent([token, ...readRecent().filter((t) => t !== token)]);
},
/** Forget this tab's token (e.g. the server said the session is invalid). */
clearToken() {
rememberMatch(null);
const t = current || sget(session, K_TOKEN);
current = null;
sdel(session, K_TOKEN);
+40
View File
@@ -0,0 +1,40 @@
// Install prompts are browser-issued, one-use capabilities. No service worker or application cache is installed.
export function createPwaInstall(win) {
let prompt = null, installed = false;
const listeners = new Set();
const display = win?.matchMedia?.('(display-mode: standalone), (display-mode: fullscreen)');
const available = () => !!prompt && !installed && !display?.matches && !win?.navigator?.standalone;
const emit = () => { for (const fn of listeners) fn(available()); };
const offer = (event) => {
if (installed || display?.matches || win?.navigator?.standalone || typeof event.prompt !== 'function') return;
event.preventDefault();
prompt = event;
emit();
};
const done = () => { installed = true; prompt = null; emit(); };
win?.addEventListener?.('beforeinstallprompt', offer);
win?.addEventListener?.('appinstalled', done);
display?.addEventListener?.('change', emit);
return {
available,
subscribe(fn) { listeners.add(fn); fn(available()); return () => listeners.delete(fn); },
async request() {
if (!available()) return false;
const event = prompt;
prompt = null; // Consume before awaiting: dismissal, rejection and repeated clicks cannot reuse this event.
emit();
try {
const result = await event.prompt();
const choice = event.userChoice ? await event.userChoice : result;
return choice?.outcome === 'accepted';
} catch { return false; }
},
dispose() {
win?.removeEventListener?.('beforeinstallprompt', offer);
win?.removeEventListener?.('appinstalled', done);
display?.removeEventListener?.('change', emit);
prompt = null; listeners.clear();
},
};
}
export const pwaInstall = createPwaInstall(globalThis.window);
+7 -1
View File
@@ -530,6 +530,7 @@ export class UnitView {
this.actor = null;
this.spineReady = false;
this._modelDirty = false; // died / stood up since the last frame: update() checks Front ⇄ Back (_syncModel)
this._pendingDeployElapsed = null; // elapsed game seconds until a late model can continue Start
this._spineBusy = false; // a Spine load of this view is in flight
this._spineTries = 0; // failed loads since the last model (SPINE_RETRY_MS)
this._retryAt = 0; // when the next retry is due (ms, performance clock; 0 = none)
@@ -650,7 +651,8 @@ export class UnitView {
// no fallback diamond in between; a deploy clip it was playing goes on on the new model (an operator facing UP
// redeployed: its Back model takes over from the Front model it lay down with)
const swap = !!this.actor;
const deployed = swap && this.alive ? this.actor.deployElapsed() : null;
const deployed = swap && this.alive ? this.actor.deployElapsed() : this._pendingDeployElapsed;
this._pendingDeployElapsed = null;
if (swap) this._dropActor();
this.actor = actor;
this._actorEntry = entry;
@@ -961,6 +963,7 @@ export class UnitView {
/** An attack was made (b.ev 'atk'). `target` = view or null. */
onAttack(target, now, kind) {
if (!this.alive) return;
this._pendingDeployElapsed = null;
// a one-off cast (PROJ[kind].once: 暴鸰's bomb drop) is no attack rhythm: its clip plays once at its own speed
const once = !!PROJ[kind]?.once;
if (!once) {
@@ -1018,6 +1021,7 @@ export class UnitView {
onDeploy() {
this.fadeIn = 0;
if (!this.alive) this.revive();
this._pendingDeployElapsed = this.actor ? null : 0;
if (this.actor) this.actor.deploy();
}
@@ -1038,6 +1042,7 @@ export class UnitView {
*/
die(instant = false) {
if (!this.alive) return;
this._pendingDeployElapsed = null;
this.alive = false;
this._modelDirty = true;
this._dieForm = this._formSpec();
@@ -1105,6 +1110,7 @@ export class UnitView {
/** @param {number} dt real seconds @param {any} cam camera @param {number} t real clock */
update(dt, cam, t) {
if (this.destroyed) return;
if (this._pendingDeployElapsed != null) this._pendingDeployElapsed += dt * (this.ctx.animRate?.() || 1);
// a displacement's slide (slideTo): held where it stood until the snapshot with the destination is shown, then
// eased into the sampled position (sync keeps `lx, ly` current) under a constant deceleration
if (this.slide) {
+4 -2
View File
@@ -11,6 +11,7 @@ import { useGameData, Img, RichText } from '../ui/gameComponents.js';
import { StepHeader, ExitModal } from '../ui/matchChrome.js';
import { MatchInfo, matchInfoModel } from '../ui/matchInfo.js';
import { LoadoutButton } from './loadout.js';
import { SetupReroll } from '../ui/setupReroll.js';
import { actions } from '../ui/gameActions.js';
import { factionTypes, sortedPlayers, phaseTotalSeconds } from '../ui/gameLogic.js';
import { enemyIconUrl, factionIconUrl } from '../ui/assetUrls.js';
@@ -50,7 +51,7 @@ export function BriefingScreen() {
const ready = async () => {
if (busy || me?.ready) return;
setBusy(true);
await actions.infoReady();
await actions.infoReady(pub.setupRevision ?? 0);
setBusy(false);
};
@@ -91,6 +92,7 @@ export function BriefingScreen() {
<${MatchInfo} model=${info} />
</section>
</main>
<${SetupReroll} pub=${pub} />
<footer class="brief__foot">
<${LoadoutButton} from="briefing" size="lg" class="brief-loadout" />
<div class="brief-ready">
@@ -98,7 +100,7 @@ export function BriefingScreen() {
<span class="brief-ready__pips">${players.map((p) => html`<i key=${p.playerId} class=${cx(p.ready && 'on', p.playerId === myId && 'me')} title=${p.name}><${Icon} name="user" /></i>`)}</span>
</div>
<${Button} variant="primary" size="xl" icon=${me?.ready ? 'check' : me ? 'play' : 'eye'} active=${!!me?.ready} loading=${busy}
disabled=${!!me?.ready || !me} onClick=${ready}>${me?.ready ? t('已就绪') : me ? t('准备就绪') : t('观战中')}<//>
disabled=${!!me?.ready || !me || !!pub.rerollVote} onClick=${ready}>${me?.ready ? t('已就绪') : me ? t('准备就绪') : t('观战中')}<//>
</footer>
<${ExitModal} open=${exit} onClose=${() => setExit(false)} solo=${solo} />
</div>`;
+2
View File
@@ -16,6 +16,7 @@ import { chessAvatarUrl, chessPortraitUrl, profIconUrl, skillRecordIconUrl, modu
import { data } from '../data.js';
import { PROF_NAME, skillLabel, moduleBadge, fullTraitText } from '../ui/loadoutModel.js';
import { diySlotList, pickChoices, pickOptions, slotRecord, defaultPick } from '../ui/diyModel.js';
import { OperatorVoice } from '../ui/operatorVoice.js';
import { CultivationSelects } from './cultivation.js';
import { t } from '../../../shared/i18n.js';
@@ -77,6 +78,7 @@ function SlotCard({ m, slot, pick, illegal, onOpen, onClear, ops = {}, onOps = n
</span>
${proto ? html`<small class="diy-slot__cult t-dim">${t('原型干员没有潜能与练度')}</small>`
: onOps ? html`<span class="diy-slot__cult"><${CultivationSelects} charId=${rec.charId} ops=${ops} onSet=${onOps} /></span>` : null}
<${OperatorVoice} charId=${rec.charId} />
${illegal ? html`<p class="diy-slot__bad"><${Icon} name="warn" />${t('这项自选在当前版本不可用,开局时会被移除')}</p>` : null}
</div>
<div class="diy-slot__acts">
+2
View File
@@ -37,6 +37,7 @@ import {
opsOf, setOps, resetOps, moduleRecord,
} from '../ui/loadoutModel.js';
import { loadoutStore, openLoadout, closeLoadout, setEntries, setOpsMap, applyLoadoutEntries, setNotOwned, applyOwnershipImport, setDiyPicks, applyDiyImport } from '../ui/loadoutSync.js';
import { OperatorVoice } from '../ui/operatorVoice.js';
import { CultivationSelects, CultivationSection } from './cultivation.js';
import { cultivationCharIds } from '../../../shared/protocol.js';
import { atPotential } from '../../../shared/potential.js';
@@ -387,6 +388,7 @@ function Detail({ m, chess, golden, entries, ops = {}, onChange, onOps, onReset,
<${Button} variant="ghost" size="sm" icon="refresh" class="lo-dhead__reset" disabled=${!changed} onClick=${onReset}>${t('恢复默认')}<//>
</div>
<div class="lo-detail__body" ref=${bodyRef}>
<${OperatorVoice} charId=${chess.charId} />
<${CultivationSection} charId=${chess.charId} ops=${ops} onSet=${onOps} standIn=${notOwned} />
<${LoadoutGarrisons} chess=${level === 'elite' && golden ? golden : chess} m=${m} />
<section class="lo-sec">
+4
View File
@@ -11,6 +11,7 @@
// Texts go through t() (docs/I18N.md); the module-level tables hold msgids (N_) translated where they are shown, the
// config.json mode texts come localized from data.js.
import { ResumeMatchButton } from '../ui/resumeMatch.js';
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS, ROOM_CODE_LEN, MAX_SEATS, MAX_SPECTATORS, modeIdFor, ERR } from '../../../shared/constants.js';
import { html, Button, Icon, MicroLabel, Panel, TextField, PingPill, AvatarFrame, Tooltip, Spinner, DifficultyIcon, doctorNo } from '../ui/components.js';
@@ -18,6 +19,7 @@ import { toast, toastError } from '../ui/toasts.js';
import { GuideButton } from '../ui/guide.js';
import { openStats } from './stats.js';
import { SettingsButton } from '../ui/settings.js';
import { PwaInstallButton } from '../ui/device.js';
import { LoadoutButton } from './loadout.js';
import { net, identity } from '../net.js';
import { store, useStore, shallowEqual, loadPref, savePref } from '../store.js';
@@ -315,6 +317,8 @@ export function LobbyScreen() {
<div class="topbar__right">
<${Button} variant="secondary" size="sm" icon="chart" class="stats-entry" onClick=${openStats} title=${t('统计数据')} aria-label=${t('统计数据')}>${t('统计')}<//>
<${SettingsButton} class="lobby-settings" variant="secondary" label=${t('设置')} />
<${ResumeMatchButton} />
<${PwaInstallButton} class="lobby-pwa" />
<${GuideButton} class="lobby-guide" variant="secondary" label=${t('玩法说明')} />
<${LoadoutButton} from="lobby" size="sm" class="lobby-loadout" label=${t('干员调配')} />
<div class="me-chip">
+4 -1
View File
@@ -10,6 +10,7 @@
// entry/loading illustration names) it is layered under the CSS art; otherwise the screen is
// pure CSS/SVG (radar, ridgelines, glow), so it never issues a request that can 404.
import { ResumeMatchButton } from '../ui/resumeMatch.js';
import { useMemo, useState } from '../../vendor/hooks.module.js';
import { NAME_MAX_LEN, APP_VERSION, DEV_BUILD } from '../../../shared/constants.js';
import { html, Button, Icon, MicroLabel, TextField, PingPill } from '../ui/components.js';
@@ -19,7 +20,7 @@ import { toast } from '../ui/toasts.js';
import { net, identity } from '../net.js';
import { store, useStore, shallowEqual } from '../store.js';
import { data, useData } from '../data.js';
import { FullscreenButton, detectFeatures } from '../ui/device.js';
import { FullscreenButton, PwaInstallButton, detectFeatures } from '../ui/device.js';
import { LangToggle, useLang } from '../ui/lang.js';
import { t, N_ } from '../../../shared/i18n.js';
import { scriptOf } from '../../../shared/i18nPacks.js';
@@ -287,6 +288,8 @@ export function TitleScreen() {
<${SettingsModal} open=${settingsOpen} onClose=${() => setSettingsOpen(false)} />
<footer class="title-foot">
<${ResumeMatchButton} />
<${PwaInstallButton} />
<span>${t('非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有')}</span>
<${MicroLabel}>v${APP_VERSION} · WEB SIMULATION<//>
${DEV_BUILD ? html`<span class="title-dev" role="note">${t('开发版 · 不稳定,请勿用于公开服务器')}</span>` : null}
+23 -1
View File
@@ -20,7 +20,8 @@
// CSS counterpart: public/css/devices.css (safe-area insets, touch-action, overscroll, tap-target expansion).
import { useEffect, useState } from '../../vendor/hooks.module.js';
import { html, Icon } from './components.js';
import { pwaInstall } from '../pwa.js';
import { html, Icon, Button } from './components.js';
import { t } from '../../../shared/i18n.js';
/** A touch held this long without moving opens the detail (contextmenu) on DOM controls. */
@@ -162,6 +163,20 @@ export function FullscreenButton({ class: cls = '' }) {
</button>`;
}
/** Only a browser-issued install offer shows a button; installed and unsupported environments stay quiet. */
export function PwaInstallButton({ class: cls = '', size = 'sm' }) {
const [available, setAvailable] = useState(pwaInstall.available);
useEffect(() => pwaInstall.subscribe(setAvailable), []);
if (!available) return null;
return html`<${Button} variant="secondary" size=${size} icon="download" class=${cls}
data-testid="pwa-install" onClick=${() => pwaInstall.request()}>${t('添加到桌面')}<//>`;
}
export function updateRotateHintPwaI18n(doc = globalThis.document) {
const btn = doc?.getElementById?.('rotate-hint-pwa');
if (btn) btn.textContent = t('添加到桌面');
}
// ---- boot-time installation ----------------------------------------------------------------------------------------
let installed = null;
@@ -253,6 +268,13 @@ export function installDeviceSupport(win = globalThis) {
if (lp.swallowUntil && Date.now() < lp.swallowUntil) { lp.swallowUntil = 0; e.preventDefault(); e.stopPropagation(); }
}, { capture: true });
const pwaBtn = doc?.getElementById?.('rotate-hint-pwa');
if (pwaBtn) {
updateRotateHintPwaI18n(doc);
offs.push(pwaInstall.subscribe((available) => { pwaBtn.hidden = !available; }));
on(pwaBtn, 'click', () => { pwaInstall.request(); });
}
installed = () => { for (const off of offs.splice(0)) { try { off(); } catch { /* ignore */ } } clearTimeout(rotTimer); installed = null; };
return installed;
}
+4 -1
View File
@@ -48,7 +48,10 @@ export async function act(t, fields = {}, opts = {}) {
}
export const actions = {
infoReady: () => act('g.infoReady'),
infoReady: (setupRevision = 0) => act('g.infoReady', { setupRevision }),
rerollSetup: (setupRevision) => act('room.rerollSetup', { setupRevision }, { sfx: 'confirm' }),
rerollVote: (voteId, agree) => act('g.rerollVote', { voteId, agree }, { sfx: agree ? 'confirm' : 'back' }),
cancelReroll: (voteId) => act('room.cancelReroll', { voteId }, { sfx: 'back' }),
band: (bandId) => act('g.band', { bandId }),
bandSkip: () => act('g.bandSkip'),
buy: (slot) => act('g.buy', { slot }),
+1 -1
View File
@@ -40,7 +40,7 @@ export { normalizeDraft, normalizeSp, normalizePersonalChoice } from './gameLogi
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, facingEnter } from './gameLogic/shortcuts.js';
export { DEFAULT_SETTINGS, VOICE_LANGS, sanitizeSettings } from './gameLogic/settings.js';
export { DEFAULT_SETTINGS, VOICE_LANGS, TEXT_SIZES, sanitizeSettings } from './gameLogic/settings.js';
export { normalizeResult } from './gameLogic/result.js';
export { chessLoadout, unitLoadout, unitCultivation } from './gameLogic/loadout.js';
export { standInIds, fieldsStandIn, standInOf, ownStandIn, cardStandIn, memberStandIn, standInGetter, standInLoadout, deployedRecord, deployedModuleId, standInLabel, standInForText, standInTip } from './gameLogic/standIn.js';
+18 -5
View File
@@ -1,5 +1,8 @@
// ui/gameLogic/settings.js — settings defaults and sanitising. Re-exported from ../gameLogic.js.
import { VOICE_LANGS, sanitizeVoiceOverrides } from '../../voicePrefs.js';
export { VOICE_LANGS } from '../../voicePrefs.js';
import { clamp, isObj } from './shared.js';
import { DEFAULT_HOTKEYS, sanitizeHotkeys } from './shortcuts.js';
@@ -10,17 +13,25 @@ import { DEFAULT_HOTKEYS, sanitizeHotkeys } from './shortcuts.js';
* 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 });
/**
* The steps of 设置 →「文字大小」 (textSize): the interface text root `--t` of css/theme.css — 'sm' is the design's own
* sizes (`--t: 1rem`), the others lift the phone's 40 px root by a floor and grow the desktop gently. Text only: the
* layout root `1rem` (and with it the field camera, the detail card's side and the DOM fallback board) never moves.
*/
export const TEXT_SIZES = Object.freeze(['sm', 'md', 'lg', 'xl']);
/** keys: the in-match shortcuts' key map (ui/gameLogic/shortcuts.js; settings → 快捷键). voiceLang: VOICE_LANGS.
* textSize: TEXT_SIZES (css/theme.css `--t`, applied by ui/settings.js applyTextSize). */
export const DEFAULT_SETTINGS = Object.freeze({ bgm: 0.6, sfx: 0.8, voice: 0.8, voiceLang: 'cn', voiceOverrides: Object.freeze({}), muted: false, damageNumbers: true, quality: 'high', textSize: 'sm', keys: DEFAULT_HOTKEYS });
const QUALITIES = ['high', 'medium', 'low'];
/**
* Sanitize persisted settings.
* @param {any} raw
* @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> }}
* @returns {{ bgm: number, sfx: number, voice: number, voiceLang: 'cn'|'jp', voiceOverrides: Record<string, string>, muted: boolean, damageNumbers: boolean, quality: 'high'|'medium'|'low',
* textSize: 'sm'|'md'|'lg'|'xl', keys: Record<'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready', string> }}
*/
export function sanitizeSettings(raw) {
const r = isObj(raw) ? raw : {};
@@ -30,9 +41,11 @@ export function sanitizeSettings(raw) {
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,
voiceOverrides: sanitizeVoiceOverrides(r.voiceOverrides),
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,
textSize: TEXT_SIZES.includes(r.textSize) ? r.textSize : DEFAULT_SETTINGS.textSize,
keys: sanitizeHotkeys(r.keys),
};
}
+2
View File
@@ -23,6 +23,7 @@ import { PACKS_URL, PACK_INDEX_FILE, readPackIndex, langMetaOf } from '../../../
import { DEV_BUILD } from '../../../shared/constants.js';
import { loadPref, savePref } from '../store.js';
import { data } from '../data.js';
import { updateRotateHintPwaI18n } from './device.js';
import { html } from './components.js';
/** The switch's own label, in both languages (whoever opens it may not read the current one). */
@@ -160,6 +161,7 @@ function applyDocument(lang) {
doc.documentElement.dataset.lang = lang;
doc.documentElement.dataset.script = scriptOf(t('卫戍协议'));
doc.title = t('卫戍协议:盟约 · STRONGHOLD PROTOCOL');
updateRotateHintPwaI18n(doc);
}
// `{ dn }` params and tName(): Chinese game-data names → the current language (data/i18n/<lang>.json names)
+25
View File
@@ -0,0 +1,25 @@
// Local listening preference, shared by roster and DIY details; deliberately absent from room.loadout.
import { html } from './components.js';
import { useSettings, updateSettings } from './settings.js';
import { t } from '../../../shared/i18n.js';
const VOICE_LANG_NAMES = { cn: '中文', jp: '日本語' }; // i18n-ignore
export function OperatorVoice({ charId }) {
const settings = useSettings();
if (!charId) return null;
const value = settings.voiceOverrides[charId] || '';
const change = (lang) => {
const voiceOverrides = { ...settings.voiceOverrides };
if (lang) voiceOverrides[charId] = lang;
else delete voiceOverrides[charId];
updateSettings({ voiceOverrides });
};
return html`<label class="lo-voice" data-voice-char=${charId}>
<span>${t('此干员语音')}</span>
<span class="lo-select"><select aria-label=${t('此干员语音')} value=${value} onChange=${(e) => change(e.currentTarget.value)}>
<option value="">${t('跟随全局')}</option><option value="cn">${VOICE_LANG_NAMES.cn}</option><option value="jp">${VOICE_LANG_NAMES.jp}</option>
</select></span>
<small>${t('仅保存在此浏览器,缺失的日语语音会回退到中文。')}</small>
</label>`;
}
+38
View File
@@ -0,0 +1,38 @@
// Recover an explicitly selected local seat; nickname and IP never identify a player.
import { useEffect, useState } from '../../vendor/hooks.module.js';
import { html, Button, Modal } from './components.js';
import { identity } from '../net.js';
import { t } from '../../../shared/i18n.js';
import { toast } from './toasts.js';
export function ResumeMatchButton() {
const [items, setItems] = useState(() => identity.recoverable());
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
useEffect(() => {
const refresh = () => setItems(identity.recoverable());
window.addEventListener('storage', refresh);
window.addEventListener('focus', refresh);
refresh();
return () => { window.removeEventListener('storage', refresh); window.removeEventListener('focus', refresh); };
}, []);
if (!items.length) return null;
const resume = async (item) => {
if (busy) return;
setBusy(true);
if (await identity.resume(item.id)) {
identity.saveName(item.name);
identity.setEntered(true);
location.reload();
} else {
setBusy(false);
toast(t('此对局仍在其他窗口中,或当前浏览器无法安全恢复。请关闭原窗口后重试。'), 'warn');
}
};
return html`<${Button} variant="secondary" size="sm" data-testid="resume-local-match" onClick=${() => setOpen(true)}>${t('恢复本机对局')}<//>
<${Modal} open=${open} onClose=${() => !busy && setOpen(false)} title=${t('恢复本机对局')}>
<p>${t('选择已关闭窗口的对局。服务器重启或对局过期后无法恢复。')}</p>
${items.map((item) => html`<${Button} key=${item.id} variant="secondary" block=${true} disabled=${busy} onClick=${() => resume(item)}>
${t('{name} · 同盟 {code}', { name: item.name || t('博士'), code: item.code || '—' })}<//>`)}
<//>`;
}
+50 -16
View File
@@ -1,18 +1,24 @@
// 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
// 「快捷键可不可以自己设置」, the owner's decision of 2026-10-07) and 问题反馈, which copies the diagnostics of this page
// for a bug report (diag.js: the error log, this browser, optionally the battle on screen; nothing is uploaded). The
// lobby and the room open it from a 设置 button next to 玩法说明 (SettingsButton, GitHub #238); the title screen and the
// match have their own gear.
// 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
// and to the document 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 「快捷键可不可以自己设置」, the owner's decision of 2026-10-07) and
// 问题反馈, which copies the diagnostics of this page for a bug report (diag.js: the error log, this browser, optionally
// the battle on screen; nothing is uploaded). The lobby and the room open it from a 设置 button next to 玩法说明
// (SettingsButton, GitHub #238); the title screen and the match have their own gear.
//
// 文字大小 (textSize, applied by applyTextSize): the interface text root `--t` of css/theme.css — a phone clamps the
// layout root `1rem` at 40 px (theme.css), which left the .18rem body text at 7.2 CSS px while the browser's own font
// settings only inflate the glyphs inside fixed boxes (and page zoom is off: index.html's viewport, ui/device.js).
// Only font-size declarations read `--t`, so the board, the HUD bands the prep camera keeps clear and the detail
// card's side do not move — the field is sized from the host element's clientWidth (render/app.js).
import { useLayoutEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { html, Modal, Button, Icon, MicroLabel } from './components.js';
import { GIcon } from './gameComponents.js';
import { createStore, useStore, loadPref, savePref, store } from '../store.js';
import { sanitizeSettings, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, hotkeyLabel, rebindHotkey, isDefaultHotkeys, captureHotkey, VOICE_LANGS } from './gameLogic.js';
import { sanitizeSettings, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, hotkeyLabel, rebindHotkey, isDefaultHotkeys, captureHotkey, VOICE_LANGS, TEXT_SIZES } from './gameLogic.js';
import { audio } from '../audio.js';
import { openGuide } from './guide.js';
import { detectFeatures } from './device.js';
@@ -23,16 +29,31 @@ import { copyText } from './clipboard.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/** Settings store: { bgm, sfx, voice, voiceLang, muted, damageNumbers, quality, keys }. */
/** Settings store: { bgm, sfx, voice, voiceLang, voiceOverrides, muted, damageNumbers, quality, textSize, keys }. */
export const settingsStore = createStore(sanitizeSettings(loadPref('settings', null)));
/**
* 设置 →「文字大小」: put the step on <html data-text> — css/theme.css turns it into the text root `--t`
* (`:root[data-text="md"] { --t: … }`), which every readable font-size reads; no layout value does. The attribute
* (not an inline style) keeps the default in the stylesheet: without it — no JavaScript, an old saved profile, a
* value a future version dropped — the page is the design's own sizes. Values outside TEXT_SIZES fall back to 小.
* @param {'sm'|'md'|'lg'|'xl'} v
*/
export function applyTextSize(v) {
const el = globalThis.document?.documentElement;
if (el) el.dataset.text = TEXT_SIZES.includes(v) ? v : 'sm';
}
settingsStore.subscribe((s) => {
savePref('settings', sanitizeSettings(s));
audio.setVolumes(s);
audio.setVoiceLang(s.voiceLang);
audio.setVoiceLang(s.voiceLang, s.voiceOverrides);
applyTextSize(s.textSize);
});
audio.setVolumes(settingsStore.get());
audio.setVoiceLang(settingsStore.get().voiceLang);
audio.setVoiceLang(settingsStore.get().voiceLang, settingsStore.get().voiceOverrides);
// before the first render (main.js boot renders after its imports ran): the stored step is on screen without a flash
applyTextSize(settingsStore.get().textSize);
/** @param {Partial<ReturnType<typeof sanitizeSettings>>} patch */
export function updateSettings(patch) {
@@ -68,6 +89,11 @@ function Toggle({ label, micro, value, onChange }) {
}
const QUALITY = [['high', N_('高')], ['medium', N_('中')], ['low', N_('低')]];
/**
* 文字大小: one step of TEXT_SIZES (ui/gameLogic/settings.js) with its label — 小 is the design's own sizes, the others
* raise the text root `--t` (css/theme.css) and with it every readable font-size; the board and the HUD's boxes stay.
*/
const TEXT_SCALES = [['sm', N_('小')], ['md', N_('中')], ['lg', N_('大')], ['xl', 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.
@@ -216,8 +242,8 @@ export function SettingsModal({ open, onClose }) {
const [touchUi] = useState(() => detectFeatures().coarse && !detectFeatures().fine);
const mtNote = machineTranslationNote(); // a pack marked as machine translation says so under the switch
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('完成')}<//>`}>
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>
@@ -234,7 +260,7 @@ export function SettingsModal({ open, onClose }) {
</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'); } }} />
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">
@@ -244,6 +270,14 @@ export function SettingsModal({ open, onClose }) {
class=${s.quality === id ? 'is-on' : ''} onClick=${() => updateSettings({ quality: id })}>${t(label)}</button>`)}
</div>
</div>
<div class="set-row">
<span class="set-row__label">${t('文字大小')}<${MicroLabel}>TEXT SIZE<//></span>
<div class="set-seg set-textsize" role="radiogroup" aria-label=${t('文字大小')} data-testid="text-size">
${TEXT_SCALES.map(([id, label]) => html`<button key=${id} type="button" role="radio" aria-checked=${s.textSize === id ? 'true' : 'false'}
class=${s.textSize === id ? 'is-on' : ''} onClick=${() => updateSettings({ textSize: id })}>${t(label)}</button>`)}
</div>
</div>
<p class="set-hint set-textsize-note">${t('调整界面文字大小,棋盘保持原比例。')}</p>
<${HotkeySection} keys=${s.keys} touchUi=${touchUi} />
<${DiagSection} />
<p class="set-hint">${touchUi ? t('触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向') : t('右键查看详情')}</p>
+49
View File
@@ -0,0 +1,49 @@
// A server-owned unanimous vote. Reconnects render the current vote from m.public.
import { useRef, useState } from '../../vendor/hooks.module.js';
import { html, Button } from './components.js';
import { actions } from './gameActions.js';
import { useStore } from '../store.js';
import { t } from '../../../shared/i18n.js';
export function SetupReroll({ pub }) {
const myId = useStore((s) => s.me.playerId);
const hostId = useStore((s) => s.room?.hostId);
const online = useStore((s) => s.connection.status === 'online');
const [busy, setBusy] = useState(false);
const pending = useRef(false);
const vote = pub.rerollVote;
const host = myId === hostId;
const players = pub.players || [];
const me = players.find((p) => p.playerId === myId && p.status !== 'left');
const allConnected = players.every((p) => p.isBot || p.status === 'left' || p.connected);
const voter = !!vote?.voters.includes(myId);
const agreed = !!vote?.agreed.includes(myId);
const send = async (fn) => {
if (pending.current) return;
pending.current = true;
setBusy(true);
try { await fn(); } finally { pending.current = false; setBusy(false); }
};
if (!vote && (!host || !me)) return null;
return html`<section class="brief-reroll" aria-label=${t('重刷本局')} aria-live="polite">
<div class="brief-reroll__text">
<strong>${vote ? t('重刷本局投票:{agreed}/{total}', { agreed: vote.agreed.length, total: vote.voters.length }) : t('重刷本局')}</strong>
<span>${vote ? t('全员同意后重新随机战场、领袖、特训敌人和禁用;确认倒计时已暂停。') : t('保留房间和个人配置,重新随机全部开局信息。')}</span>
${vote ? html`<div class="brief-reroll__players">${vote.voters.map((id) => {
const p = players.find((p) => p.playerId === id);
return html`<span key=${id} class=${vote.agreed.includes(id) ? 'is-agreed' : ''}>${p?.name || id} · ${vote.agreed.includes(id) ? t('已同意') : t('待同意')}</span>`;
})}</div>` : null}
</div>
<div class="brief-reroll__actions">
${!vote ? html`<${Button} icon="refresh" size="lg" loading=${busy} disabled=${!online || !allConnected}
title=${allConnected ? t('全体真人玩家一致同意后刷新,随机结果可能重复。') : t('请等待所有玩家连接后再发起投票')}
onClick=${() => send(() => actions.rerollSetup(pub.setupRevision ?? 0))}>${t('重刷本局')}<//>` : null}
${vote && voter ? html`<${Button} variant="primary" loading=${busy} disabled=${!online || agreed}
onClick=${() => send(() => actions.rerollVote(vote.id, true))}>${agreed ? t('已同意') : t('同意重刷')}<//>
<${Button} variant="danger" disabled=${busy || !online}
onClick=${() => send(() => actions.rerollVote(vote.id, false))}>${t('拒绝重刷')}<//>` : null}
${vote && host && me ? html`<${Button} disabled=${busy || !online}
onClick=${() => send(() => actions.cancelReroll(vote.id))}>${t('取消投票')}<//>` : null}
</div>
</section>`;
}
+14
View File
@@ -0,0 +1,14 @@
// Browser-local dub preferences. Every variant of an operator shares its charId; absent entries follow the global dub.
export const VOICE_LANGS = Object.freeze(['cn', 'jp']);
export function sanitizeVoiceOverrides(raw) {
const out = {};
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
for (const [id, lang] of Object.entries(raw).slice(0, 512)) {
if (/^char_[0-9]+_[a-z0-9]+$/.test(id) && id.length <= 80 && VOICE_LANGS.includes(lang)) out[id] = lang;
}
return out;
}
export function voiceLangFor(charId, globalLang, overrides) {
const own = Object.hasOwn(overrides || {}, charId) ? overrides[charId] : null;
return VOICE_LANGS.includes(own) ? own : globalLang === 'jp' ? 'jp' : 'cn';
}