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:
1 parent
314564c139
commit
f3658a0dd4
104 files changed
+3674
-741
No files matched your search
+10
-1
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
Reference in new issue
Block a user