Merge branch 'master' into fix/spine-start-anim

This commit is contained in:
Cromemadnd Lancity authored and GitHub committed 2026-10-11 20:27:12 +08:00
commit e2b0aab534
577 files changed
+43650 -10558

No files matched your search

+12 -2
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]);
}
@@ -831,10 +840,11 @@ export function createAssets(options) {
});
/** Image element (cached; failures resolve to null). */
function image(u) {
function image(u, { retry = false } = {}) {
const s = str(u);
if (!s) return Promise.resolve(null);
let e = images.get(s);
if (retry && e?.done && !e.value) { images.delete(s); e = null; }
if (!e) {
e = { value: null, done: false, promise: null };
e.promise = Promise.resolve().then(() => loadImage(s)).then(
+160 -34
View File
@@ -4,6 +4,7 @@
// bgm { lobby, prep, combat, combatAlts?: [ {intro?, loop}, … ], boss: { intro?, loop } },
// bossBgm { [bossId]: { intro?, loop } },
// voice { [charId]: { start, faceEnemy, select, place, skill1…skill4, squad, squadFirst, result*, gacha } },
// voiceJp { [charId]: { …the same slots } } (the Japanese dub, settings 语音语言 日本語; see voiceLine),
// sfx.ui { click, buy, sell, refresh, freeze, levelup, merge, equip, ready, timer, yourTurn, … },
// sfx.battle { deploy, tokenDeploy, charDie, tokenDie?, enemyDie, enemyHit, heal, killCoin, … },
// sfx.units { [charId|tokenId|enemyId]: { attack?, hit?, skill?, die?, born?, mix?: { [role]: { p?, vol? } } } }.
@@ -18,14 +19,22 @@
// schedule), so every client of a match hears the same one, a fight never switches track halfway through and the
// 联防 that follows a 作战 keeps its round's track.
// - 干员战斗语音 (`audio.voice`, user request): an operator says the official line of the moment it is in —
// 行动出发 start, 行动开始 faceEnemy, 选中干员 select, 部署 place, 作战中1-4 skillN and the settlement's
// 结算 result* (all battle-only; the 休整期 is silent). Voices are seconds long and a battle deploys eight
// 行动出发 start, 行动开始 faceEnemy, 部署 place, 作战中1-4 skillN and the settlement's 结算 result* (battle-only),
// and 选中干员 select whenever the player taps an operator — in the prep, the shop and the hand too (the owner's
// request of 2026-10-08 「添加一下干员点击上去的语气一样的语音」, which lifts 2026-10-03's 「整备阶段不需要干员语音」 for
// this line only). Voices are seconds long and a battle deploys eight
// operators, then fires dozens of skills, so they run on their own channel (own gain, settings 干员语音) through
// VoiceGate: one line at a time, a global gap, a per-unit per-slot cooldown, and a higher-priority line taking the
// channel over — the official scheduling of `audio_data.json battleVoice.voiceTypeOptions`.
// channel over — the official scheduling of `audio_data.json battleVoice.voiceTypeOptions` (a tap's 选中 line skips
// the gap: VOICE_TAP_SLOTS). Two dubs: 中文 `audio.voice` (default) and 日本語 `audio.voiceJp` (settings 语音语言, not
// tied to the interface language; the owner's request of 2026-10-08 「全套的日配语音」) — a JP line the manifest or the
// host lacks falls back to the Chinese one (voiceLine).
// - Battle SFX from `b.ev` tuples (`handleBattleEvents`): at most MAX_VOICES concurrent unit sounds, at most
// MAX_PER_URL overlapping copies of one sound (the official banks' maxSoundAllowed 2), a per-unit cooldown and a
// per-URL minimum gap (SfxLimiter), so a 60-unit fight stays listenable.
// per-URL minimum gap (SfxLimiter), so a 60-unit fight stays listenable. A `['skill', id, 1]` that arrives before its
// unit is known is held and played once the unit is tracked (`pendingSkill`): a unit that casts inside its own deploy
// tick emits that first cue before its `['spawn', unitInfo]`, and dropping it left the one cast silent while every
// later one played (GitHub PR #292 by @LimitlessHPPK).
// - Impact sounds (user playtest #4 item 6): a 'dmg' plays the `hit` sound of the unit whose hostile attack ('atk' on a
// unit of the other side) aimed at the target — once, within IMPACT_WINDOW_MS, and only for phys / arts / true damage.
// A heal "attack" ('atk' of a healer on an ally, chain heals) never makes the healer the author of the next damage
@@ -34,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
@@ -46,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';
@@ -76,6 +96,8 @@ const CHAIN_KINDS = new Set(['chain', 'chainHeal']);
const IMPACT_TYPES = new Set(['phys', 'arts', 'true']);
/** A 'dmg' later than this (real ms) after the attack aimed at the target is not that attack's impact. */
const IMPACT_WINDOW_MS = 2500;
/** Casts held for a unit that is not tracked yet (`pendingSkill`): a hold per unit of a field, never more than this. */
const PENDING_SKILL_MAX = 64;
/** Official operator sound files of a skill mode: `…_d` / `…_h` / `…_s` (+ digits) — the normal attack's end in `_n`. */
const SKILL_MODE_FILE = /_(d|h|s)\d*\.mp3$/i;
@@ -248,9 +270,9 @@ export function unitSoundPlays(mix, roll) {
/**
* Voice priorities — the official battle voice types (`audio_data.json battleVoice.voiceTypeOptions`) mapped onto the
* manifest's slots: BATTLE_START 100, BATTLE_FACE_ENEMY 90, SKILL_ACTIVE 70, PASSIVE_IMP 60, PASSIVE_NOR 50,
* PLACE_CHAR 20, FOCUS_CHAR 10. The settlement lines are no battle voice of the official scheduler: they sit at 85,
* above 作战中 (70) but below 接敌 (90), so a battle's last word is never cut off by an ordinary line. The four prep
* slots (部署 / 编入队伍 / 任命队长 / 干员报到) keep their levels although the 休整期 is silent (see the header).
* PLACE_CHAR 20, FOCUS_CHAR 10 (选中干员). The settlement lines are no battle voice of the official scheduler: they sit at
* 85, above 作战中 (70) but below 接敌 (90), so a battle's last word is never cut off by an ordinary line. The three
* prep-only slots (编入队伍 / 任命队长 / 干员报到) keep their levels although nothing plays them (see the header).
*/
export const VOICE_PRIORITY = Object.freeze({
start: 100, faceEnemy: 90,
@@ -259,14 +281,55 @@ export const VOICE_PRIORITY = Object.freeze({
gacha: 60, squadFirst: 45, squad: 30, place: 20, select: 10,
});
/** Per-unit per-slot cooldowns (ms): the official 10 s of the 作战中 (passive skill) lines, 3 s between 接敌 lines. */
/**
* Per-unit per-slot cooldowns (ms): the official 10 s of the 作战中 (passive skill) lines, 3 s between 接敌 lines. 选中干员
* has none — official FOCUS_CHAR `cooldown: 0` (it was 1.5 s until 0.2.2, when a tap in the prep made it audible).
*/
export const VOICE_COOLDOWN_MS = Object.freeze({
start: 0, faceEnemy: 3000,
skill1: 10000, skill2: 10000, skill3: 10000, skill4: 10000,
resultFour: 0, resultThree: 0, resultTwo: 0, resultLose: 0,
gacha: 0, squadFirst: 0, squad: 0, place: 0, select: 1500,
gacha: 0, squadFirst: 0, squad: 0, place: 0, select: 0,
});
/**
* The slot a player's tap asks for: 选中干员 (official FOCUS_CHAR — priority 10, cooldown 0, `overlapIfSamePriority:
* true`). The battle lines are timed by the gate's global gap; a tap is the player's own request and answers at once:
* on an idle channel it always plays (no gap, no cooldown), a newer tap replaces the 选中 line still on air (the same
* priority, overlapIfSamePriority), and it still never interrupts a higher-priority line (部署, 作战中, 开战 …). Nor does
* it start a gap of its own: the battle lines keep the gap of the last battle line (a tap restarting it dropped a 部署 /
* 技能 / 接敌, which the battle asks for once, after a 选中 line shorter than the gap had already ended — many are).
*/
export const VOICE_TAP_SLOTS = Object.freeze(['select']);
/**
* The line an operator says for a slot, in the chosen dub (settings 语音语言, ui/gameLogic/settings.js VOICE_LANGS):
* { url, fallback }, or null for an operator no dub voices (the 预备干员 / 原型干员 stand-ins, 盟约·辅助干员, summons)
* or a slot it lacks. 'cn' draws from `audio.voice`; 'jp' from
* `audio.voiceJp`, the same slots and file names in the Japanese dub, and falls back to the Chinese line twice over: per
* slot when the JP tree lacks it (a file the fetch could not get is left out of the manifest), and per line at play time
* — `fallback` is the Chinese file of the same name, played when the host does not have the JP one (a full zip built
* without the JP dub, before setup downloaded it). A slot with several lines draws one (`random` ∈ [0, 1)).
* @param {any} audio the manifest's `audio`
* @param {string} charId
* @param {string} slot
* @param {'cn'|'jp'|string} [lang]
* @param {() => number} [random]
* @returns {{ url: string, fallback: string|null } | null}
*/
export function voiceLine(audio, charId, slot, lang = 'cn', random = Math.random) {
const lines = (line) => (Array.isArray(line) ? line : [line]).filter((u) => typeof u === 'string' && u);
const draw = (list) => (list.length ? list[Math.min(list.length - 1, Math.floor(random() * list.length))] : null);
const cn = lines(audio?.voice?.[charId]?.[slot]);
const jp = lang === 'jp' ? draw(lines(audio?.voiceJp?.[charId]?.[slot])) : null;
if (jp) {
const file = (u) => u.slice(u.lastIndexOf('/') + 1);
return { url: jp, fallback: cn.find((u) => file(u) === file(jp)) ?? draw(cn) };
}
const url = draw(cn);
return url ? { url, fallback: null } : null;
}
/**
* The settlement slot of a finished 作战: 完美作战 ⇒ 3星结束行动 (绝境 / 终极 ⇒ 完成高难行动 instead), a leaked enemy
* ⇒ 非3星结束行动, nothing killed at all ⇒ 行动失败.
@@ -365,7 +428,9 @@ export class SfxLimiter {
/**
* Voice gate: one line at a time, a global gap between two lines, a per-unit per-slot cooldown, and takeover by a
* clearly more important line (the caller fades the playing one out first). Pure — the clock is passed in.
* clearly more important line (the caller fades the playing one out first). A tap's 选中干员 skips the gap and the
* cooldown on an idle channel, replaces a 选中 line on air and starts no gap itself (VOICE_TAP_SLOTS). Pure — the clock
* is passed in.
*/
export class VoiceGate {
/** @param {{ gapMs?: number, preemptMargin?: number, maxUnits?: number }} [o] */
@@ -396,10 +461,13 @@ export class VoiceGate {
*/
request(slot, unitKey, now) {
const pri = VOICE_PRIORITY[slot] ?? 0;
const tap = VOICE_TAP_SLOTS.includes(slot);
if (this.playing) {
if (tap && this.playing.slot === slot) return 'preempt'; // a newer tap replaces the 选中 line on air
if (pri < this.playing.pri + this.preemptMargin) return 'drop';
return 'preempt'; // a clearly more important line takes the channel
}
if (tap) return 'play'; // a tap on an idle channel always answers (FOCUS_CHAR cooldown 0, no gap)
if (now - this.lastAt < this.gapMs) return 'drop';
if (unitKey != null && now < (this.unitUntil.get(`${unitKey}:${slot}`) || 0)) return 'drop';
return 'play';
@@ -408,7 +476,7 @@ export class VoiceGate {
/** Record a line that started (call right after request() answered play / preempt). */
start(slot, unitKey, now) {
this.playing = { slot, pri: VOICE_PRIORITY[slot] ?? 0 };
this.lastAt = now;
if (!VOICE_TAP_SLOTS.includes(slot)) this.lastAt = now; // the gap is between battle lines: a tap starts none
const cd = VOICE_COOLDOWN_MS[slot] ?? 0;
if (unitKey != null && cd > 0) {
if (this.unitUntil.size > this.maxUnits) this.unitUntil.clear();
@@ -457,13 +525,17 @@ 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;
this.wantBgm = null; // desired key (kept while locked)
this.bgm = null; // { key, loopUrl, nodes: [{src, gain}], gain }
this.bgmToken = 0;
this.units = new Map(); // battle unit id → defId
this.lastAttacker = new Map(); // target id → { def, at } of the hostile attack last aimed at it (its impact sound)
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, 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);
@@ -596,6 +668,16 @@ export class AudioManager {
this._applyVolumes();
}
/**
* The voice dub (settings 语音语言): 'jp' plays `audio.voiceJp`, anything else `audio.voice` (中文, the default). The
* line on air finishes in its own dub; the next one follows the setting.
* @param {string} lang
*/
setVoiceLang(lang, overrides = this.voiceOverrides) {
this.voiceLang = lang === 'jp' ? 'jp' : 'cn';
this.voiceOverrides = sanitizeVoiceOverrides(overrides);
}
_applyVolumes() {
if (!this.ctx) return;
try {
@@ -802,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;
@@ -823,9 +914,11 @@ export class AudioManager {
// ---- operator battle voice ----------------------------------------------------------------------------------
/**
* Play an operator's battle line (`audio.voice[charId][slot]`; a slot with several lines draws one at random).
* Only in battle: every caller is a running battle's own event stream or its settlement (user request — the 休整期
* is silent). The line must pass VoiceGate: one at a time, a global gap, a per-unit cooldown, higher priority wins.
* Play an operator's battle line (`audio.voice[charId][slot]`, or `audio.voiceJp` when the 语音语言 setting is 日本語 —
* voiceLine; a slot with several lines draws one at random).
* Every caller is a running battle's own event stream or its settlement, except 选中干员: the detail panel opening on
* an operator the player tapped, in every phase (ui/detailPanel.js `voice`; the owner's request of 2026-10-08). The
* line must pass VoiceGate: one at a time, a global gap, a per-unit cooldown, higher priority wins.
* @param {string} charId e.g. 'char_263_skadi'
* @param {'start'|'faceEnemy'|'select'|'place'|'skill1'|'skill2'|'skill3'|'skill4'|'squad'|'squadFirst'
* |'resultFour'|'resultThree'|'resultTwo'|'resultLose'|'gacha'} slot
@@ -836,31 +929,40 @@ export class AudioManager {
try {
if (!this.ctx || !this.voiceGain || this.volumes.muted || this.volumes.voice <= 0) return false;
if (typeof charId !== 'string' || typeof slot !== 'string') return false;
const line = this.getManifest()?.audio?.voice?.[charId]?.[slot];
const url = Array.isArray(line) ? line[Math.floor(Math.random() * line.length)] : line;
if (typeof url !== 'string' || !url) return false;
// the chosen dub's line (settings 语音语言), with the Chinese file of the same name as its fallback (voiceLine)
const line = voiceLine(this.getManifest()?.audio, charId, slot, 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);
if (verdict === 'drop') return false;
if (verdict === 'preempt') this._stopVoice();
this.voiceGate.start(slot, o.unitKey ?? null, now);
const token = ++this.voiceToken;
this._playVoice(url, token, o.volume);
this._playVoice(line.url, token, o.volume, line.fallback);
return true;
} catch (err) { this._warn('voice', err); return false; }
}
/** Fetch/decode and start one voice line through the voice channel. */
_playVoice(url, token, volume) {
/**
* Fetch/decode and start one voice line through the voice channel. `fallback`: the line to play instead when this one
* cannot be loaded (a JP file the host lacks → the Chinese file of the same name; voiceLine) — same token, same gate.
*/
_playVoice(url, token, volume, fallback = null) {
// `token` is the line's own `voiceToken`. Every deferred step below — the decode, a failed fetch, `onended` and the
// safety timer — can land AFTER this line was taken over or stopped: `voiceToken` has moved on and the channel then
// belongs to the line that replaced it. So each step re-checks its token and, when it is stale, touches NOTHING:
// `_stopVoice` (takeover / stop) and `setFieldUnits` released the gate themselves. An unconditional release here let
// a stale callback free the channel the NEW line had just taken, and the next line walked in on top of it (review
// on #73).
const failed = () => {
if (token !== this.voiceToken) return; // taken over / stopped meanwhile: not ours to release
if (fallback && fallback !== url && this.ctx && this.voiceGain) this._playVoice(fallback, token, volume);
else this.voiceGate.release();
};
this._buffer(url).then((buf) => {
if (token !== this.voiceToken) return; // taken over / stopped while it decoded: not ours to release
if (!buf || !this.ctx || !this.voiceGain) { this.voiceGate.release(); return; }
if (!buf) { failed(); return; }
if (!this.ctx || !this.voiceGain) { this.voiceGate.release(); return; }
try {
const src = this.ctx.createBufferSource();
src.buffer = buf;
@@ -888,7 +990,7 @@ export class AudioManager {
this._warn('voice-play', err);
if (token === this.voiceToken) this.voiceGate.release();
}
}, () => { if (token === this.voiceToken) this.voiceGate.release(); });
}, failed);
}
/** Fade the line on air out (a higher priority line is taking the channel over). */
@@ -912,6 +1014,9 @@ export class AudioManager {
/** Reset the unit map for a new field (m.field.units = UnitInfo[]). */
setFieldUnits(units) {
this.units.clear();
// unit ids belong to one battle: a cast still held for a unit of the field left must not sound for a unit of the new
// one that happens to share its id
this.pendingSkill.clear();
this.lastAttacker.clear();
this.consumed.clear();
// a new field is a new battle: the first operator deployed says 行动出发 again and no cooldown carries over
@@ -923,9 +1028,15 @@ 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) {
this.pendingSkill.delete(u.id);
this.handleBattleEvents([held]);
}
}
/** Play a resolved battle sound for a unit event, limited like unit sounds. */
@@ -952,25 +1063,40 @@ 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') {
const n = Number.isInteger(u.skillIndex) ? Math.min(4, u.skillIndex + 1) : 1;
this.voice(u.def, `skill${n}`, { unitKey: e[1] });
}
} else {
// The unit is not tracked yet. The sim emits the cast of a deployment that fires inside its own first tick
// (`initSp` already at `spCost`, 宴's deploy-timed skill) BEFORE the unit's `['spawn', unitInfo]` — the same
// batch or the next one — so the cue waits here and `_track` plays it when the spawn arrives. Only a skill
// turning ON is a cast; a hold for a unit that never appears goes with the field (`setFieldUnits`).
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]);
@@ -1039,13 +1165,13 @@ export const audio = new AudioManager({ getManifest: () => manifestGetter() });
/**
* Wire the singleton to the app (called once by main.js): manifest source, settings and store-driven BGM.
* @param {{ getManifest: () => any, subscribe: (fn: (s:any, prev:any) => void) => () => void, getState: () => any,
* selectRoute: (s:any) => string, settings?: { bgm:number, sfx:number, voice:number, muted:boolean } }} deps
* selectRoute: (s:any) => string, settings?: { bgm:number, sfx:number, voice:number, muted:boolean, voiceLang?:string } }} deps
*/
export function installAudio(deps) {
try {
manifestGetter = typeof deps?.getManifest === 'function' ? deps.getManifest : manifestGetter;
audio.install();
if (deps?.settings) audio.setVolumes(deps.settings);
if (deps?.settings) { audio.setVolumes(deps.settings); audio.setVoiceLang(deps.settings.voiceLang, deps.settings.voiceOverrides); }
if (typeof deps?.subscribe === 'function' && typeof deps?.getState === 'function') {
const sync = (s) => {
try {
+4 -2
View File
@@ -100,7 +100,7 @@ export function followedScout({ field = null, watching = null, alive = true, spe
return fid;
}
/** Teammates' progress for the waiting pill: [{ playerId, name, killed, total, done, isBot }]. */
/** Teammates' progress for the waiting pill: [{ playerId, name, killed, resolved, total, done, isBot }]. */
export function teammateProgress(pub, myId) {
const out = [];
for (const f of fields(pub)) {
@@ -111,7 +111,9 @@ export function teammateProgress(pub, myId) {
const pr = isObj(f.progress) ? f.progress : null;
out.push({
playerId: pid, name: p?.name || t('队友'), isBot: !!p?.isBot,
killed: Number.isFinite(pr?.killed) ? pr.killed : null, total: Number.isFinite(pr?.total) ? pr.total : null,
killed: Number.isFinite(pr?.killed) ? pr.killed : null,
resolved: Number.isFinite(pr?.resolved) ? pr.resolved : (Number.isFinite(pr?.killed) ? pr.killed : null),
total: Number.isFinite(pr?.total) ? pr.total : null,
done: f.live === false || !!pr?.done,
});
}
+39 -5
View File
@@ -72,6 +72,7 @@ import { net as appNet } from '../net.js';
import { store as appStore } from '../store.js';
import { unitStatsEntry, fxForm } from '../../../shared/protocol.js';
import { spectateEffects } from './observe.js';
import { recordError, setBattleSource } from '../diag.js';
const TICK = 1 / 30;
/** Fast-forward budget per frame (ticks) when far behind. */
@@ -119,6 +120,14 @@ export const LOST_RESULT_CODES = Object.freeze(['DISCONNECTED', 'OFFLINE', 'TIME
/** Ticks per frame at a speed (same cap as the server pacing: server/match/fields.js maxTicksPerInterval). */
export const ticksPerFrameCap = (speed) => Math.max(8, Math.ceil((Number(speed) || 2) * 4));
/**
* What the runner compares between two looks at a 联防 battle (noteUniteLeft) before it reads the leakers' enemies still
* standing again: a knock-out, a leak, a spawn, the end. `total` stands for the spawns no longer — a split child or a summon
* is outside the capsule's denominator (DESIGN §14 顶栏胶囊) — so the length of the enemy list does.
* @param {any} b the battle
*/
export const uniteLeftMark = (b) => `${Number(b.killed) || 0}:${Number(b.leakedCount) || 0}:${Number(b.total) || 0}:${Array.isArray(b.enemies) ? b.enemies.length : 0}:${b.finished ? 1 : 0}`;
function deepFreeze(root) {
const stack = [root];
while (stack.length) {
@@ -156,9 +165,12 @@ export async function loadBrowserSim({ base = '/sim/', dataBase = '/data/', fetc
return { spec, ds: new simdata.DataSource(raw, null) };
}
/** Battle logger: content errors are isolated by the sim; report them as warnings (the server logs its own). */
/**
* Battle logger: content errors are isolated by the sim; report them as warnings (the server logs its own) and keep
* them for the diagnostics a player copies (diag.js).
*/
const SIM_LOGGER = Object.freeze({
error: (...a) => console.warn('[sim]', ...a),
error: (...a) => { console.warn('[sim]', ...a); recordError('sim', a.length === 1 ? a[0] : a.map(String).join(' ')); },
warn: (...a) => console.warn('[sim]', ...a),
info() {},
debug() {},
@@ -288,7 +300,7 @@ export function createBattleRunner(deps) {
function noteUniteLeft(e) {
const b = e.battle;
const mark = `${Number(b.killed) || 0}:${Number(b.leakedCount) || 0}:${Number(b.total) || 0}:${b.finished ? 1 : 0}`;
const mark = uniteLeftMark(b);
if (mark === e.leakMark) return;
e.leakMark = mark;
let left = e.left;
@@ -351,6 +363,7 @@ export function createBattleRunner(deps) {
} catch (err) {
stats.errors++;
console.warn('[runner] battle step failed', err);
recordError('runner', err, 'battle step failed');
try { b.forceEnd('timeout'); } catch { /* ignore */ }
}
const dt = now() - t;
@@ -393,7 +406,7 @@ export function createBattleRunner(deps) {
const list = catchingUp ? s.ev.filter(keepsState) : s.ev;
if (list.length) emit('ev', { t: 'b.ev', fieldId: e.fieldId, gt: s.gt, ev: list });
}
try { emit('snap', frameOf(e)); } catch (err) { console.warn('[runner] snapshot failed', err); }
try { emit('snap', frameOf(e)); } catch (err) { console.warn('[runner] snapshot failed', err); recordError('runner', err, 'snapshot failed'); }
}
/**
@@ -428,9 +441,16 @@ export function createBattleRunner(deps) {
e.lastProgressAt = t;
const p = e.sim.spec.battleProgress(e.battle);
const msg = { battleId: e.battleId, gt: Math.min(1e5, p.gt), killed: Math.min(p.killed, p.total), total: Math.min(1e5, p.total), done: !!p.done };
// the HUD capsule's numerator of this field (shared/protocol.js b.progress `resolved`): the field's own scheduled
// enemies knocked out or leaked. Sent only when the battle reports one (Battle.resolved) — an absent field leaves
// the teammate UI on its `resolved ?? killed` fallback
if (Number.isFinite(p.resolved)) msg.resolved = Math.max(0, Math.min(msg.total, p.resolved));
if (bossLike(e)) {
const pool = e.battle.sharedBoss;
msg.leaks = Math.min(1e6, e.meter.lp);
// the split of that LP between the players' own leaks (the rest is the leader's effects): the server holds the result's
// leaked lists to it before a perfect-payout bounty pays (Match._bossLeaksAgree)
if (p.leaksBy) msg.leaksBy = p.leaksBy;
msg.bossDmg = pool && Number.isFinite(pool.cum) ? pool.cum : 0;
if (pool && pool.byPlayer) {
const by = {};
@@ -457,7 +477,7 @@ export function createBattleRunner(deps) {
result = e.sim.spec.compactResult(e.battle.result());
// an oversized frame would close the socket at the very end of the battle (64 KB inbound limit)
if (typeof e.sim.spec.fitResult === 'function') result = e.sim.spec.fitResult(result, { bossLike: bossLike(e), battleId: e.battleId });
} catch (err) { console.warn('[runner] result failed', err); }
} catch (err) { console.warn('[runner] result failed', err); recordError('runner', err, 'result failed'); }
if (result) {
e.result = result;
// the view answers with the settlement voice of this battle (screens/game.js → audio.voice result*): the
@@ -491,6 +511,7 @@ export function createBattleRunner(deps) {
} else {
e.delivery = 'delivered';
console.warn('[runner] b.result refused', code);
recordError('runner', code, 'b.result refused');
}
return null;
});
@@ -670,6 +691,7 @@ export function createBattleRunner(deps) {
let sim;
try { sim = await ensureSim(); } catch (err) {
console.warn('[runner] simulation unavailable', err);
recordError('runner', err, 'simulation unavailable');
return;
}
if (!wanted()) return;
@@ -678,6 +700,7 @@ export function createBattleRunner(deps) {
battle = sim.spec.createBattleFromSpec(e.spec, sim.ds, { logger });
} catch (err) {
console.warn('[runner] battle construction failed', err);
recordError('runner', err, 'battle construction failed');
return;
}
stats.battles++;
@@ -806,6 +829,16 @@ export function createBattleRunner(deps) {
return () => listeners.get(type)?.delete(fn);
},
state,
/**
* The battle on screen as a report attaches it (diag.js): its b.start fields and its game time; null without one.
* @returns {{ battleId: string, fieldId: string, kind: string, spec: object, time: number|null }|null}
*/
currentBattle() {
const e = cur;
if (!e || !e.spec) return null;
const time = e.battle && Number.isFinite(e.battle.time) ? e.battle.time : null;
return { battleId: e.battleId, fieldId: e.fieldId, kind: e.kind, spec: e.spec, time };
},
stats() {
return { ...stats, avgTickMs: stats.ticks ? stats.stepMs / stats.ticks : 0, entries: entries.size, loadingSim: !!simP };
},
@@ -869,3 +902,4 @@ export const battleRunner = typeof window !== 'undefined' && typeof document !==
? createBattleRunner({ net: appNet, store: appStore })
: null;
if (battleRunner) globalThis.__SP_RUNNER__ = battleRunner; // dev / E2E introspection
if (battleRunner) setBattleSource(() => battleRunner.currentBattle());
+269
View File
@@ -0,0 +1,269 @@
// public/js/diag.js — the client's error log and the diagnostics a player copies into a bug report (设置 → 问题反馈).
//
// Nothing leaves the browser. The log is kept in memory (the last LOG_MAX entries, an identical error in a row counted
// on its entry); the report is plain text the player copies and pastes into a GitHub issue, with the player names,
// the room code and the reconnect token replaced and the page's host cut from every URL. A secret is a whole token in
// the prose and an exact string value in the attached battle JSON, so a name never rewrites a longer id. The battle
// on screen can be attached as its BattleSpec (the b.start a browser receives: player ids, no names), which the dev
// tools replay.
//
// recordError(source, err, detail?) source: 'error' (uncaught) | 'rejection' (unhandled promise) | 'sim' (a content
// error the battle isolated) | 'runner' (the local battle runner) | 'request'
// (a failed request or error push the player was shown)
// errorLog() / errorCount() / clearErrorLog()
// setBattleSource(fn) the battle runner registers how to read the battle on screen
// buildDiagnostics(input) the report text (pure: the state, the environment and the battle are passed in)
// diagnosticsText({ state, settings, attachBattle }) the report of this page (this browser, the battle on screen)
//
// Report keys are English on purpose: the text is read by developers and searched in issues, whatever the UI language.
import { APP_VERSION } from '../../shared/constants.js';
export const LOG_MAX = 40;
export const STACK_LINES = 6;
const MESSAGE_MAX = 300;
/** @type {{ source: string, message: string, stack: string|null, detail: string|null, at: number, lastAt: number, count: number }[]} */
const log = [];
let total = 0;
let dropped = 0;
const pageStart = Date.now();
/** `name: message (code)` of an Error, a NetError, a {code, msg} push, a string or anything else. */
export function messageOf(err) {
let s;
if (err == null) s = String(err);
else if (typeof err === 'string') s = err;
else if (typeof err === 'object') {
const name = typeof err.name === 'string' && err.name && err.name !== 'Error' ? `${err.name}: ` : '';
const text = typeof err.message === 'string' && err.message ? err.message : typeof err.msg === 'string' ? err.msg : '';
const code = typeof err.code === 'string' || typeof err.code === 'number' ? String(err.code) : '';
if (text) s = `${name}${text}${code && !text.includes(code) ? ` (${code})` : ''}`;
else if (code) s = `${name}${code}`;
else { try { s = JSON.stringify(err); } catch { s = Object.prototype.toString.call(err); } }
} else s = String(err);
s = s.replace(/\s+/g, ' ').trim();
return s.length > MESSAGE_MAX ? `${s.slice(0, MESSAGE_MAX - 1)}…` : s;
}
/** The first STACK_LINES frames of `err.stack`, each without the page's scheme and host (`/js/ui/x.js:12:5`). */
export function stackOf(err) {
const raw = err && typeof err === 'object' && typeof err.stack === 'string' ? err.stack : '';
if (!raw) return null;
const lines = raw.split('\n').map((l) => l.trim()).filter(Boolean);
// V8 starts with "Name: message"; Firefox / Safari list frames only
const frames = lines.filter((l, i) => !(i === 0 && !/^at |@|:\d+:\d+\)?$/.test(l)));
const out = frames.slice(0, STACK_LINES).map(stripHosts);
return out.length ? out.join('\n') : null;
}
/** Cut `scheme://host[:port]` from every URL (a LAN server's address, a domain): the path is what locates the code. */
export const stripHosts = (s) => String(s).replace(/\b[a-z][a-z0-9+.-]*:\/\/[^/\s)]+/gi, '');
/**
* Record an error. An entry identical to the newest one (same source, message and stack) raises its count instead.
* @param {string} source @param {any} err @param {string|null} [detail] @param {number} [at] epoch ms
*/
export function recordError(source, err, detail = null, at = Date.now()) {
const message = stripHosts(messageOf(err));
const stack = stackOf(err);
const det = detail == null ? null : stripHosts(String(detail)).slice(0, MESSAGE_MAX);
total++;
const last = log[log.length - 1];
if (last && last.source === source && last.message === message && last.stack === stack && last.detail === det) {
last.count++;
last.lastAt = at;
return last;
}
const e = { source: String(source), message, stack, detail: det, at, lastAt: at, count: 1 };
log.push(e);
while (log.length > LOG_MAX) { log.shift(); dropped++; }
return e;
}
/** The log, oldest first (copies). */
export const errorLog = () => log.map((e) => ({ ...e }));
/** Errors recorded on this page (repeats included). */
export const errorCount = () => total;
export function clearErrorLog() { log.length = 0; total = 0; dropped = 0; }
let battleSource = null;
/** @param {() => ({ battleId: string, fieldId?: string, kind?: string, spec: object, time?: number }|null)} fn */
export function setBattleSource(fn) { battleSource = typeof fn === 'function' ? fn : null; }
/** The battle on screen for a report, or null. */
export function currentBattle() {
try { return battleSource ? battleSource() || null : null; } catch { return null; }
}
/** Secrets worth replacing: non-empty strings, later pair winning when the same value is listed twice. */
function secretMap(pairs) {
const map = new Map();
for (const [v, ph] of pairs) {
if (typeof v === 'string' && v.trim()) map.set(v, ph);
}
return map;
}
/**
* Replace each secret only as a whole token, longest first, in one pass (a placeholder is never scanned again).
* A token is bounded by the start or end of the text, or by anything that is not a letter, a digit or `_`, so
* "char" does not eat `chess_char_…`, "player" does not eat "players", and "W" does not eat "Windows". "W-W" is
* two tokens. The left boundary is a consumed character (or the start): a lookbehind is a SyntaxError in Safari
* before 16.4.
* @param {string} text @param {[string, string][]} pairs [value, placeholder]
*/
export function redact(text, pairs) {
const map = secretMap(pairs);
const list = [...map.keys()].sort((a, b) => b.length - a.length);
if (!list.length) return String(text);
const esc = (v) => v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const word = '[\\p{L}\\p{N}_]';
const re = new RegExp(`(?<pre>^|(?!${word})[\\s\\S])(?<hit>${list.map(esc).join('|')})(?!${word})`, 'gu');
return String(text).replace(re, (...args) => {
const g = args[args.length - 1];
return `${g.pre}${map.get(g.hit)}`;
});
}
/**
* Copy `value`, replacing a string only when it is exactly a secret (object keys included). A longer string that
* merely contains the secret, such as a chess id or a note, is kept. This is what the attached battle JSON uses;
* token replacement would still rewrite `"charge the char"` when the name is `char`.
* @param {any} value @param {[string, string][]} pairs
*/
export function redactValues(value, pairs) {
const map = secretMap(pairs);
const walk = (v) => {
if (typeof v === 'string') return map.has(v) ? map.get(v) : v;
if (Array.isArray(v)) return v.map(walk);
if (v && typeof v === 'object') {
const out = {};
for (const [k, val] of Object.entries(v)) out[map.has(k) ? map.get(k) : k] = walk(val);
return out;
}
return v;
};
return walk(value);
}
/** [value, placeholder] pairs of what a report must not show: player names, the room code, the reconnect token. */
export function secretsOf(state) {
const pairs = [];
const names = new Map();
const addName = (n) => {
if (typeof n !== 'string' || !n.trim() || names.has(n)) return;
names.set(n, n === state?.me?.name ? '<me>' : `<player${names.size + 1}>`);
};
addName(state?.me?.name);
for (const s of state?.room?.seats || []) addName(s?.name);
for (const s of state?.room?.spectators || []) addName(s?.name);
for (const p of state?.match?.public?.players || []) addName(p?.name);
for (const [n, ph] of names) pairs.push([n, ph]);
if (state?.room?.code) pairs.push([String(state.room.code), '<room>']);
if (state?.me?.token) pairs.push([String(state.me.token), '<token>']);
return pairs;
}
const pad2 = (n) => String(n).padStart(2, '0');
const clockOf = (ms) => { const d = new Date(ms); return `${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())}`; };
const spanOf = (ms) => {
const s = Math.max(0, Math.round(ms / 1000));
return s >= 3600 ? `${Math.floor(s / 3600)}h ${pad2(Math.floor(s / 60) % 60)}m` : `${Math.floor(s / 60)}m ${pad2(s % 60)}s`;
};
const join = (...parts) => parts.filter((p) => p != null && p !== '' && p !== false).join(' | ');
/**
* The report text: a ```text block (version, time, browser, screen, connection, where the player is, settings, the
* error log newest first) and, with `battle`, a ```json block of its spec. Secrets of `state` are whole tokens in the
* prose and exact string values in the JSON.
* @param {{ state: any, env?: any, settings?: any, battle?: any, entries?: any[], dropped?: number, total?: number,
* now?: number, pageStart?: number, version?: string }} input
*/
export function buildDiagnostics(input) {
const { state = {}, env = {}, settings = null, battle = null } = input;
const entries = input.entries ?? errorLog();
const now = input.now ?? Date.now();
const lines = [];
lines.push('Stronghold-Protocol diagnostics');
lines.push(`version: ${input.version ?? APP_VERSION}`);
lines.push(`time: ${new Date(now).toISOString()} | page open ${spanOf(now - (input.pageStart ?? pageStart))}`);
if (env.ua) lines.push(`browser: ${env.ua}`);
if (env.gpu) lines.push(`gpu: ${env.gpu}`);
lines.push(`device: ${join(env.viewport, env.dpr ? `@${env.dpr}x` : null, env.touch ? 'touch' : null, env.cores ? `${env.cores} threads` : null,
env.memoryGB ? `${env.memoryGB} GB` : null, env.lang ? `lang ${env.lang}` : null, env.uiLang ? `ui ${env.uiLang}` : null) || 'unknown'}`);
const c = state.connection || {};
lines.push(`connection: ${join(c.status || 'unknown', Number.isFinite(c.ping) ? `ping ${Math.round(c.ping)} ms` : null,
c.attempt ? `retry ${c.attempt}` : null, c.lastError ? `last error ${messageOf(c.lastError)}` : null)}`);
const pub = state.match?.public || null;
const room = state.room || null;
lines.push(`where: ${join(pub?.phase ? `phase ${pub.phase}` : room ? 'room' : state.session?.entered ? 'lobby' : 'title',
Number.isFinite(pub?.round) ? `round ${pub.round}` : null, room?.mode ? `mode ${room.mode}` : null,
room?.difficulty ? `difficulty ${room.difficulty}` : null,
Array.isArray(pub?.players) ? `players ${pub.players.length}` : Array.isArray(room?.seats) ? `seats ${room.seats.filter(Boolean).length}` : null)}`);
const b = state.match?.battle || null;
if (b) {
lines.push(`battle: ${join(b.kind, b.fieldId ? `field ${b.fieldId}` : null, b.authoritative ? 'authoritative' : b.watch ? 'watching' : null,
b.done ? 'done' : b.loading ? 'loading' : 'running', b.paused ? 'paused' : null, Number.isFinite(b.speed) ? `${b.speed}x` : null)}`);
}
if (settings) {
lines.push(`settings: ${join(`quality ${settings.quality}`, settings.damageNumbers ? 'damage numbers' : null, settings.muted ? 'muted' : null)}`);
}
const shown = entries.slice().reverse();
const tot = input.total ?? total;
const drop = input.dropped ?? dropped;
lines.push('');
lines.push(`errors: ${tot ? `${tot} recorded${drop ? `, ${drop} oldest entries dropped` : ''}` : 'none recorded'}`);
shown.forEach((e, i) => {
lines.push(`#${i + 1} ${clockOf(e.at)}${e.count > 1 ? ` (x${e.count}, last ${clockOf(e.lastAt)})` : ''} ${e.source}: ${e.message}`);
if (e.detail) lines.push(` ${e.detail}`);
if (e.stack) for (const l of e.stack.split('\n')) lines.push(` ${l}`);
});
const pairs = secretsOf(state);
let text = redact(`\`\`\`text\n${lines.join('\n')}\n\`\`\``, pairs);
if (battle && battle.spec) {
const head = join(`battle ${battle.battleId}`, battle.kind, Number.isFinite(battle.spec.round) ? `round ${battle.spec.round}` : null,
Number.isFinite(battle.time) ? `copied at ${battle.time.toFixed(2)} s` : null);
const payload = redactValues({
t: 'b.start', battleId: battle.battleId, fieldId: battle.fieldId, kind: battle.kind, spec: battle.spec,
}, pairs);
text += `\n\n${redact(head, pairs)}\n\`\`\`json\n${JSON.stringify(payload)}\n\`\`\``;
}
return text;
}
let gpuName;
/** What the report says about this browser and device (null fields stay out). */
export function browserEnv() {
const g = globalThis;
if (gpuName === undefined) {
gpuName = null;
try {
const cv = g.document?.createElement('canvas');
const gl = cv && (cv.getContext('webgl2') || cv.getContext('webgl'));
const ext = gl && gl.getExtension('WEBGL_debug_renderer_info');
gpuName = gl ? String(ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER)) : 'no WebGL';
} catch { gpuName = null; }
}
let uiLang = null;
try { uiLang = g.document?.documentElement?.lang || null; } catch { /* ignore */ }
return {
ua: g.navigator?.userAgent ?? null,
gpu: gpuName,
viewport: Number.isFinite(g.innerWidth) ? `${g.innerWidth}x${g.innerHeight}` : null,
dpr: g.devicePixelRatio ?? null,
touch: !!(g.matchMedia && g.matchMedia('(pointer: coarse)').matches),
cores: g.navigator?.hardwareConcurrency ?? null,
memoryGB: g.navigator?.deviceMemory ?? null,
lang: g.navigator?.language ?? null,
uiLang,
};
}
/**
* The report of this page: the given store state and settings, this browser, the log and, with `attachBattle`, the
* battle on screen.
* @param {{ state: any, settings?: any, attachBattle?: boolean }} opts
*/
export function diagnosticsText({ state, settings = null, attachBattle = false }) {
return buildDiagnostics({ state, settings, env: browserEnv(), battle: attachBattle ? currentBattle() : null });
}
+23 -2
View File
@@ -49,10 +49,13 @@ import { settingsStore } from './ui/settings.js';
import { GuideHost } from './ui/guide.js';
import { installDeviceSupport } from './ui/device.js';
import { LoadoutHost } from './screens/loadout.js';
import { StatsHost } from './screens/stats.js';
import { recordResult, installStatsRecorder } from './ui/stats.js';
import { installLoadoutSync, installOwnershipSync, installDiySync } from './ui/loadoutSync.js';
import { startBuildGuard } from './ui/buildGuard.js';
import { initLang, useLang, tickerText } from './ui/lang.js';
import { t, N_, translateWire } from '../../shared/i18n.js';
import { recordError } from './diag.js';
const RESTORE_GRACE_MS = 1500;
const JOIN_DELAY_MS = 350;
@@ -133,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();
@@ -142,9 +146,11 @@ 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;
identity.saveName(name);
store.set({ me: { playerId: msg.playerId ?? null, name, token: typeof msg.token === 'string' ? msg.token : null } });
welcomeAt = Date.now();
@@ -185,6 +191,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();
}
@@ -207,7 +215,10 @@ function wireNet() {
});
net.on('clock', (c) => store.set({ clock: { offset: c.offset, rtt: c.rtt, synced: c.synced } }));
net.on('welcome', onWelcome);
net.on('helloError', (err) => toastError(err));
net.on('helloError', (err) => {
if (err.code === 'SESSION_IN_USE') identity.rejectToken();
toastError(err);
});
net.on('replaced', () => toast(t('该身份已在其他页面登录,本页已断开'), 'warn', { ttl: 6000 }));
net.on('unhandledError', (err) => toastError(err));
net.on('room.state', onRoomState);
@@ -219,7 +230,13 @@ function wireNet() {
net.on('m.public', (msg) => { matchAt = Date.now(); store.patch('match', { public: payload(msg) }); maybeFinishRestore(); });
net.on('m.private', (msg) => { matchAt = Date.now(); store.patch('match', { private: payload(msg) }); });
net.on('m.field', (msg) => store.patch('match', { field: payload(msg) }));
net.on('m.result', (msg) => store.patch('match', { result: payload(msg) }));
net.on('m.result', (msg) => {
const res = payload(msg);
store.patch('match', { result: res });
// 本机统计 (PR #323): every arrival, including the lobby's result replay after a reconnect / reload —
// replays dedupe by content id inside recordResult (spectator seats' copies build no record at all)
recordResult(res, { myId: store.get().me.playerId, roomMode: store.get().room?.mode ?? null, now: Date.now() });
});
net.on('m.toast', (msg) => {
const kind = ['info', 'success', 'warn', 'error'].includes(msg.kind) ? msg.kind : 'info';
// msgid + params (server ≥ 0.2.0) or the text itself as a msgid, in the current language
@@ -287,6 +304,7 @@ function App() {
<${UiHosts} />
<${GuideHost} />
<${LoadoutHost} />
<${StatsHost} />
</div>`;
}
@@ -311,12 +329,14 @@ function installGlobalErrorHandlers() {
// expected browser behaviour, not app errors: log quietly, never toast.
if (err && (err.name === 'NotAllowedError' || err.name === 'AbortError')) { console.warn('[app] ignored rejection', err.name); return; }
console.error('[app] unhandled rejection', err);
recordError('rejection', err);
if (err instanceof NetError) toastError(err);
else toast(t('发生意外错误:{error}', { error: describeError(err) }).slice(0, 120), 'error');
});
window.addEventListener('error', (ev) => {
if (!(ev instanceof ErrorEvent)) return; // resource load errors are not script errors
console.error('[app] uncaught error', ev.error || ev.message);
recordError('error', ev.error || ev.message, ev.error ? null : `${ev.filename || '?'}:${ev.lineno || 0}:${ev.colno || 0}`);
});
}
@@ -339,6 +359,7 @@ async function boot() {
}));
wireNet();
installStatsRecorder(store); // follows the match on screen, so a 放弃模拟 can be recorded (ui/stats.js)
installLoadoutSync({ net });
installOwnershipSync({ net });
installDiySync({ net });
+156 -24
View File
@@ -114,6 +114,7 @@ export class Net {
* @param {string} [opts.url] socket URL (default: derived from location at connect time)
* @param {any} [opts.WebSocket] WebSocket constructor (default: globalThis.WebSocket)
* @param {() => (string|null)} [opts.getToken] reconnect-token provider for `hello`
* @param {() => ({ noReplace: boolean, claimAt?: number })} [opts.getTokenClaim] local ownership for this hello
* @param {() => number} [opts.now]
* @param {() => number} [opts.random]
* @param {{setTimeout: Function, clearTimeout: Function, setInterval: Function, clearInterval: Function}} [opts.timers]
@@ -122,6 +123,7 @@ export class Net {
this.url = opts.url || null;
this.WS = opts.WebSocket || null;
this.getToken = typeof opts.getToken === 'function' ? opts.getToken : () => null;
this.getTokenClaim = typeof opts.getTokenClaim === 'function' ? opts.getTokenClaim : () => null;
this.now = opts.now || (() => Date.now());
this.random = opts.random || Math.random;
this.timers = opts.timers || {
@@ -157,6 +159,7 @@ export class Net {
this._helloTimer = null;
this._helloRid = null;
this._helloSentName = null;
this._helloToken = null;
this._lastRx = 0;
this._unansweredSince = null; // time of the oldest ping sent since the last inbound frame
this._clockSamples = []; // [{ offset, rtt }]
@@ -254,8 +257,9 @@ export class Net {
}
/** Drop the current socket and reconnect immediately (fresh hello). */
reconnectNow() {
reconnectNow(name = this.name) {
if (this._manualClose) return;
this.name = name;
const ws = this.ws;
this._teardownSocket();
try { ws?.close(4000, 'reconnect'); } catch { /* ignore */ }
@@ -365,7 +369,13 @@ export class Net {
const msg = { t: 'hello', rid, name: this.name, version: PROTOCOL_VERSION };
let token = null;
try { token = this.getToken(); } catch { token = null; }
if (typeof token === 'string' && token.length > 0 && token.length <= 64) msg.token = token;
if (typeof token === 'string' && token.length > 0 && token.length <= 64) {
msg.token = token;
const claim = this.getTokenClaim();
if (claim?.noReplace) msg.noReplace = true;
if (Number.isFinite(claim?.claimAt)) msg.claimAt = claim.claimAt;
}
this._helloToken = token;
this._helloRid = rid;
this._helloSentName = this.name;
if (this.status !== 'handshaking') this._setStatus('handshaking');
@@ -533,6 +543,9 @@ export class Net {
const isHelloError = t === 'error' && rid != null && rid === this._helloRid;
if (t === 'welcome') {
// A local ownership election can revoke a token after its hello went onto the wire.
// Do not publish or persist that stale welcome, even if the server had already accepted it.
if (this._helloToken !== this.getToken()) { this.reconnectNow(); return; }
this._onWelcome(msg);
} else if (t === 'pong') {
this._onPong(msg);
@@ -654,12 +667,17 @@ export class Net {
// browsers throttle background-tab timers to ~1/min, which would make a busy background tab look
// dead. A duplicated tab (sessionStorage is copied) therefore drops the copied token and becomes a
// new player instead of kicking the original tab off its session (server close 4001). Two tabs
// resolving at the same moment tie-break on tab id. Without BroadcastChannel a tab only ever uses
// resolving shared candidates at the same moment tie-break on tab id. An existing session token
// never falls back to another recent identity. Its welcome stamp survives reload; an earlier
// welcome outranks a later claimant, then a live holder wins over its duplicate, then tab id.
// Without BroadcastChannel a tab only ever uses
// its own token (never another tab's), so it can't steal a live session either.
const K_NAME = 'sp.name';
const K_TOKEN = 'sp.token'; // sessionStorage: this tab's token
const K_WELCOME = 'sp.tokenWelcome'; // sessionStorage: { hash, at } from the first welcome for this 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
@@ -713,20 +731,31 @@ function defaultChannel() {
* Create the identity helper (nickname, "entered" flag and reconnect-token selection; see above).
* @param {{ local?: Storage|null, session?: Storage|null, tabId?: string,
* channel?: { postMessage: Function, addEventListener?: Function, onmessage?: any } | null,
* setTimeout?: (fn: Function, ms: number) => any, queryMs?: number }} [deps]
* setTimeout?: (fn: Function, ms: number) => any, queryMs?: number, now?: () => number,
* onYield?: () => void }} [deps]
*/
export function createIdentity(deps = {}) {
const local = deps.local !== undefined ? deps.local : safeStorage('localStorage');
const session = deps.session !== undefined ? deps.session : safeStorage('sessionStorage');
const tabId = deps.tabId || Math.random().toString(36).slice(2) + Date.now().toString(36);
const setTimer = deps.setTimeout || ((fn, ms) => globalThis.setTimeout(fn, ms));
const now = deps.now || (() => Date.now());
const queryMs = Number.isFinite(deps.queryMs) && deps.queryMs >= 0 ? deps.queryMs : CLAIM_QUERY_MS;
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; // { taken: Set<hash> } while init() waits for claims
let resolving = null; // { hashes: Set<hash>, taken: Set<hash> } while init() waits for claims
let owned = sget(session, K_TOKEN);
if (!isToken(owned)) owned = null;
let welcomeAt = null;
try {
const saved = JSON.parse(sget(session, K_WELCOME) || 'null');
if (owned && saved?.hash === tokenHash(owned) && Number.isFinite(saved.at) && saved.at >= 0) welcomeAt = saved.at;
} catch { /* old / damaged storage: the session token still claims its seat */ }
let welcomed = false; // this document has received welcome (a copied stamp alone is not a live holder)
const readRecent = () => {
try {
@@ -738,22 +767,75 @@ 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 */ }
};
// Session ownership is already valid while init is waiting. Shared candidates, including an
// explicit resume, remain tentative until saveToken receives the server's welcome.
const claimFor = (hash) => {
if (owned && tokenHash(owned) === hash) return { owned: true, at: welcomeAt, live: welcomed };
if ((current && tokenHash(current) === hash) || resolving?.hashes.has(hash)) return { owned: false, at: null, live: false };
return null;
};
const peerWins = (peer, from, ours) => {
if (!peer || !ours) return true; // an older client answering "mine" is conservatively treated as live
const a = Number.isFinite(peer.at) && peer.at >= 0 ? peer.at : Infinity;
const b = Number.isFinite(ours.at) && ours.at >= 0 ? ours.at : Infinity;
if (a !== b) return a < b;
if (!!peer.owned !== !!ours.owned) return !!peer.owned;
if (!!peer.live !== !!ours.live) return !!peer.live;
return from < tabId;
};
const yieldCandidate = (hash) => {
resolving?.taken.add(hash);
// The original page can reappear after resume's query but before its hello. Keep our old
// session in storage and give back the tentative selection immediately in that interval.
if (current && tokenHash(current) === hash) {
if (current === owned) {
owned = null; welcomeAt = null; welcomed = false;
sdel(session, K_WELCOME);
}
current = owned;
if (owned) sset(session, K_TOKEN, owned);
else sdel(session, K_TOKEN);
if (initialized) deps.onYield?.();
}
};
function onChannelMessage(ev) {
const m = ev && ev.data;
if (!m || typeof m !== 'object' || m.from === tabId || typeof m.from !== 'string') return;
if (m.type === 'who' && Array.isArray(m.hashes)) {
if (current) {
const h = tokenHash(current);
if (m.hashes.includes(h)) post({ type: 'mine', hash: h, to: m.from });
for (const h of m.hashes) {
if (typeof h !== 'string') continue;
const ours = claimFor(h);
if (!ours) continue;
const peer = m.claims?.[h] || { owned: false, at: null, live: false };
if (peerWins(peer, m.from, ours)) yieldCandidate(h);
else post({ type: 'mine', hash: h, claim: ours, to: m.from });
}
// Another tab is choosing at the same time: the smaller tab id has precedence.
if (resolving && m.from < tabId) for (const h of m.hashes) if (typeof h === 'string') resolving.taken.add(h);
} else if (m.type === 'mine' && resolving && m.to === tabId && typeof m.hash === 'string') {
resolving.taken.add(m.hash);
} else if (m.type === 'mine' && m.to === tabId && typeof m.hash === 'string') {
if (peerWins(m.claim, m.from, claimFor(m.hash))) yieldCandidate(m.hash);
}
}
@@ -763,13 +845,16 @@ export function createIdentity(deps = {}) {
if (typeof channel.addEventListener === 'function') channel.addEventListener('message', onChannelMessage);
else channel.onmessage = onChannelMessage;
}
const own = sget(session, K_TOKEN);
const ownOk = isToken(own) ? own : null;
// 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;
resolving = { taken: new Set() };
post({ type: 'who', hashes: candidates.map(tokenHash) });
const candidates = owned ? [owned] : channel ? [...new Set(readRecent())] : [];
if (!channel || candidates.length === 0) return owned;
return queryCandidates(candidates);
}
async function queryCandidates(candidates) {
resolving = { hashes: new Set(candidates.map(tokenHash)), taken: new Set() };
post({ type: 'who', hashes: [...resolving.hashes],
claims: Object.fromEntries([...resolving.hashes].map((h) => [h, claimFor(h)])) });
await new Promise((r) => setTimer(r, queryMs));
const { taken } = resolving;
resolving = null;
@@ -786,17 +871,46 @@ export function createIdentity(deps = {}) {
init() {
if (!initPromise) {
initPromise = resolveToken().catch(() => null).then((pick) => {
const own = sget(session, K_TOKEN);
if (pick) sset(session, K_TOKEN, pick);
else if (own) sdel(session, K_TOKEN); // duplicated tab: the copied token belongs to a live tab
// A welcome may already have set a token while we were waiting: that one wins.
if (!current) current = pick;
if (!welcomed) {
current = pick;
if (pick) sset(session, K_TOKEN, pick);
else {
owned = null; welcomeAt = null;
sdel(session, K_TOKEN); sdel(session, K_WELCOME);
}
}
initialized = true;
return current;
});
}
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;
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 */
@@ -808,18 +922,36 @@ export function createIdentity(deps = {}) {
const own = sget(session, K_TOKEN);
return isToken(own) ? own : null;
},
/** A shared candidate must never replace a connected session, including a hello already in flight. */
getTokenClaim() {
return current === owned && owned
? { noReplace: false, ...(welcomeAt != null ? { claimAt: welcomeAt } : {}) }
: { noReplace: true };
},
/** Only this page gives up the rejected claim; the shared recovery list belongs to all tabs. */
rejectToken() {
if (current) yieldCandidate(tokenHash(current));
},
/** @param {string} token from `welcome` */
saveToken(token) {
if (!isToken(token)) return;
if (current !== token) rememberMatch(null);
if (owned !== token || welcomeAt == null) welcomeAt = now();
owned = token;
welcomed = true;
current = token;
sset(session, K_TOKEN, token);
sset(session, K_WELCOME, JSON.stringify({ hash: tokenHash(token), at: welcomeAt }));
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;
owned = null; welcomeAt = null; welcomed = false;
sdel(session, K_TOKEN);
sdel(session, K_WELCOME);
if (t) writeRecent(readRecent().filter((x) => x !== t));
},
/** Whether this tab already passed the title screen (survives reloads, not new tabs). */
@@ -830,7 +962,7 @@ export function createIdentity(deps = {}) {
}
/** Browser identity singleton (main.js awaits `identity.init()` before connecting). */
export const identity = createIdentity();
export const identity = createIdentity({ onYield: () => net.reconnectNow(net.helloName || net.name) });
/** Browser connection singleton (created lazily-safe: nothing touches the network until connect()). */
export const net = new Net({ getToken: () => identity.getToken() });
export const net = new Net({ getToken: () => identity.getToken(), getTokenClaim: () => identity.getTokenClaim() });
+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);
+164 -55
View File
@@ -44,9 +44,10 @@
// clicks and hover (battleUnitAt), the pen (penUnitAt) — is render/pick.js over the tile under the pointer (groundTile:
// raised tops first); battle enemies, which walk between tiles, by their ground position (flying ones by their drawn
// body; a huge boss also anywhere on its hit area — data/enemies.json `hitArea`, user playtest #5). A dragged piece
// is held under the pointer — a unit with its drawn feet DRAG_HOLD_TILES below it (the pointer on its body), an item
// plate centred on it — and drops on the tile under the pointer (render/drag.js); an item dropped on a unit's tile
// equips that unit.
// drops on the tile under the pointer (render/drag.js). A dragged unit stands on that tile while it is a legal target
// (a board tile or a bench slot: drag.js dragStandTile; the official deploy drag, the owner's recording of 2026-10-09,
// PR #403's report) and is held under the pointer elsewhere — its drawn feet DRAG_HOLD_TILES below it, the pointer on
// its body; an item plate stays centred on the pointer. An item dropped on a unit's tile equips that unit.
// Direction step (ui/facingWheel.js, research 09 §1.2):
// view.tileScreen(row, col) → { x, y, s, poly: [[x,y]×4] } (client px: tile-top centre, px per tile, corners) | null
// view.holdPiece(uid, {row,col} | null) keep a dropped prep piece standing on a tile while its direction is
@@ -117,10 +118,10 @@ import { fxForm } from '../../../shared/protocol.js';
import { Camera, presetCamera, lerpCamera, easeInOutCubic, pickTile, normRect } from './projection.js';
import { SnapshotBuffer, frameTime } from './interp.js';
import { TileField } from './tiles.js';
import { UnitView, ItemView, DeviceView, FORMS } from './units.js';
import { UnitView, ItemView, DeviceView, FORMS, syncView } from './units.js';
import { FxSystem, ensureDamageFonts } from './fx.js';
import { createDragController, pieceTile } from './drag.js';
import { backdropTextures, shadowTexture, refreshTierChips, silhouetteTexture } from './textures.js';
import { createDragController, pieceTile, dragStandTile } from './drag.js';
import { backdropTextures, shadowTexture, refreshTierChips, refreshDownLabels, silhouetteTexture } from './textures.js';
import { TILE_H, TIER_COLORS, COLORS } from './style.js';
import { loadBoardArt } from './boardArt.js';
import { ImpostorAtlas } from './impostor.js';
@@ -148,14 +149,17 @@ export { releaseGl } from './app/host.js';
/**
* Create the battlefield view inside `host` (an element sized by CSS; the canvas fills it).
* @param {HTMLElement} host
* @param {{ data?: any, assets?: any, audio?: any, settings?: { damageNumbers?: boolean, quality?: string },
* @param {{ data?: any, assets?: any, audio?: any, settings?: { damageNumbers?: boolean, quality?: string }, signal?: AbortSignal,
* padding?: object|((kind:string, size:{width:number,height:number}) => object),
* hud?: {top:number,bottom:number}|((kind:'prep'|'bossPrep', size:{width:number,height:number}, o:{shop:boolean}) => {top:number,bottom:number}|null) }} [opts]
*/
export async function createFieldView(host, options = {}) {
if (!host || typeof host.appendChild !== 'function') throw new TypeError('createFieldView: host element required');
const opts = options && typeof options === 'object' ? options : {};
const P = await ensurePixi();
const signal = opts.signal;
signal?.throwIfAborted();
const P = await withTimeout(ensurePixi(), null, signal);
signal?.throwIfAborted();
const assets = resolveAssets(opts.assets);
const data = makeData(opts.data);
const settings = { damageNumbers: true, quality: 'high', ...(opts.settings || {}) };
@@ -164,19 +168,28 @@ export async function createFieldView(host, options = {}) {
const want3d = boardPref !== '2d' && webgl2Available(boardPref === '3d');
// three.js (~2 MB) is fetched only when the local-art manifest lists the board atlas (in parallel with the art)
const artListed = want3d ? boardArtListed(assets).catch(() => false) : Promise.resolve(false);
const threePromise = artListed.then((ok) => (ok ? loadThree() : null));
const packPromise = artListed.then((ok) => (ok ? Promise.resolve(assets.ready ? assets.ready() : null).catch(() => null).then(() => loadBoardPack(assets)) : null));
const threePromise = artListed.then((ok) => (ok ? loadThree() : null)).catch(() => null);
const packPromise = artListed.then((ok) => (ok ? Promise.resolve(assets.ready ? assets.ready() : null).catch(() => null).then(() => loadBoardPack(assets)) : null)).catch(() => null);
// the manifest, and the optional local-art manifest in parallel: unit views pick an enemy's local-client model by it
// (assets.js spineEntry, DESIGN §13 — 灼热源石虫 / 炽焰源石虫); absent or slow, they draw the web models
await withTimeout(Promise.all([assets.ready ? assets.ready() : null, assets.local ? assets.local() : null]
.map((p) => Promise.resolve(p).catch(() => {}))), 4000);
.map((p) => Promise.resolve(p).catch(() => {}))), 4000, signal);
signal?.throwIfAborted();
// web fonts for the bitmap damage numbers / tier chips (never block long)
try { if (document.fonts?.load) await withTimeout(Promise.all([document.fonts.load('700 40px Bender'), document.fonts.load('700 40px Oxanium')]), 1500); } catch { /* ignore */ }
try { if (document.fonts?.load) await withTimeout(Promise.all([document.fonts.load('700 40px Bender'), document.fonts.load('700 40px Oxanium')]), 1500, signal); } catch { /* optional fonts */ }
signal?.throwIfAborted();
const size = () => ({ width: Math.max(1, host.clientWidth || 1), height: Math.max(1, host.clientHeight || 1) });
const dpr = () => Math.min(globalThis.devicePixelRatio || 1, QUALITY_RES[settings.quality] || 2);
const boardDpr = () => Math.min(globalThis.devicePixelRatio || 1, BOARD_RES[settings.quality] || 2);
const s0 = size();
// Construction is synchronous until the complete view can own cancellation. If setup throws,
// dispose only the resources acquired by this attempt, never another view's host contents.
const setupCleanup = [];
let destroyed = false;
let view = null;
let onAbort;
try {
const app = new P.Application({
// MSAA only where it pays: dense (DPR ≥ 1.5) screens are sharp enough without it and it would cost 4× the fill
// transparent: the 3D board canvas shows through (the 2D board paints an opaque backdrop itself)
@@ -184,6 +197,11 @@ export async function createFieldView(host, options = {}) {
resolution: dpr(), autoDensity: true, powerPreference: 'high-performance',
});
const canvas = app.view;
setupCleanup.push(() => {
releaseGl(app.renderer);
try { app.destroy(true, { children: true, texture: false, baseTexture: false }); }
finally { canvas.remove(); }
});
canvas.style.display = 'block';
canvas.style.width = '100%';
canvas.style.height = '100%';
@@ -240,7 +258,6 @@ export async function createFieldView(host, options = {}) {
for (const fn of [...set]) { try { fn(payload); } catch (err) { console.error(`[render] ${name} listener failed`, err); } }
};
let destroyed = false;
let mode = 'idle'; // 'idle' | 'prep' | 'battle'
let stageRec = null;
let cam = presetCamera('prep', { width: s0.width, height: s0.height, padding: defaultPadding('prep', s0) }, { hud: hudBands('prep', s0) });
@@ -302,12 +319,14 @@ export async function createFieldView(host, options = {}) {
renderer: app.renderer,
frameNo: () => frameNo,
impostorInterval: () => impInterval,
impostorSlot: () => impSlot++,
clipAllowed: () => clipAllowed,
viewport: () => vp,
loadLevel: () => loadLevel,
surfaceLayer: (row) => tiles.surfaceLayer(row),
};
const tiles = new TileField({ ground: layers.ground, overlay: layers.overlay, props: layers.units, anim: layers.anim });
setupCleanup.push(() => tiles.destroy());
const fx = new FxSystem({
P, layers, cam: () => cam, settings, assets, heightAt, redVignette: bg.red, surfaceLayer: ctx.surfaceLayer,
timeScale: () => ctx.timeScale(),
@@ -322,10 +341,12 @@ export async function createFieldView(host, options = {}) {
return Math.max(80, Math.min(size().height * 0.4, p.y));
},
});
setupCleanup.push(() => fx.destroy());
ctx.fx = fx;
// battle devices (crates / turrets as sim units): the official crate mesh in the 3D scene, else a Pixi box
ctx.createBox = () => switchableBox({ board: () => board3d, pixi: () => tiles.createBox() });
const impostors = new ImpostorAtlas(app.renderer);
setupCleanup.push(() => impostors.destroy());
ctx.impostors = impostors;
tiles.setView(bandFor('prep'), camRect(), fieldRows('prep'));
// the real board art of the local client (optional): wait briefly so the first frame already uses it; a late
@@ -334,7 +355,6 @@ export async function createFieldView(host, options = {}) {
if (art && !destroyed) { tiles.setArt(art); tiles.project(cam, true); }
return art;
}, () => null);
await withTimeout(artPromise, 2500);
// ---- 3D board layer (render/board3d, DESIGN §15) -----------------------------------------------------------
let board3d = null; // BoardScene while the 3D board is on
@@ -342,6 +362,7 @@ export async function createFieldView(host, options = {}) {
let board3dError = null;
// lost-context recovery: the THREE module + art pack of the last 3D board, the pending retry, failures so far
const recover = { THREE: null, pack: null, timer: 0, tries: 0, since: 0, off: false, count: 0 };
setupCleanup.push(() => { clearTimeout(recover.timer); disable3d(); });
function enable3d(THREE, pack) {
if (destroyed || board3d || !THREE || !pack) return false;
recover.THREE = THREE; recover.pack = pack;
@@ -411,13 +432,20 @@ export async function createFieldView(host, options = {}) {
if (!enable3d(recover.THREE, recover.pack)) scheduleRecover();
}, delay);
}
if (want3d) {
const ready = Promise.all([threePromise, packPromise]).then(([THREE, pack]) => (THREE && pack ? enable3d(THREE, pack) : false), () => false);
await withTimeout(ready, 6000);
// Attach the board only after synchronous setup completes; enable3d also guards late arrivals.
const boardReady = Promise.all([threePromise, packPromise]).then(([THREE, pack]) => (THREE && pack ? enable3d(THREE, pack) : false), () => false);
let boardRetry = null;
function retryBoard() {
if (!want3d || destroyed || board3d || recover.off || recover.timer || boardRetry) return;
boardRetry = Promise.all([loadThree(), loadBoardPack(assets)])
.then(([THREE, pack]) => enable3d(THREE, pack), () => false)
.finally(() => { boardRetry = null; });
}
// the official soft shadow sprite replaces the procedural one once loaded (may already be cached; asked again when the
// manifest arrives late)
let shadowAsked = false;
let offShadow = null;
setupCleanup.push(() => offShadow?.());
function loadShadow() {
const shadowUrl = !shadowAsked && assets.ui ? assets.ui('battle/sprite_shadow') : null;
if (!shadowUrl) return;
@@ -430,13 +458,17 @@ export async function createFieldView(host, options = {}) {
for (const v of views.values()) if (v.shadow && !v.destroyed) v.shadow.texture = t;
for (const v of penViews.values()) if (v.shadow && !v.destroyed) v.shadow.texture = t;
};
if (t.baseTexture.valid) use(); else t.baseTexture.once('loaded', use);
if (t.baseTexture.valid) use();
else {
t.baseTexture.once('loaded', use);
offShadow = () => t.baseTexture.off('loaded', use);
}
} catch { /* optional */ }
}
loadShadow();
ensureDamageFonts();
// web fonts may land after the first chips were drawn
if (document.fonts?.ready) document.fonts.ready.then(() => { if (!destroyed) refreshTierChips(); }).catch(() => {});
// web fonts may land after the first chips / redeploy-ring labels were drawn
if (document.fonts?.ready) document.fonts.ready.then(() => { if (!destroyed) { refreshTierChips(); refreshDownLabels(); } }).catch(() => {});
// ---- camera ---------------------------------------------------------------------------------------------
@@ -775,8 +807,16 @@ export async function createFieldView(host, options = {}) {
if (e.area === 'board' && info.dir && !held.has(e.uid) && typeof v.setDir === 'function') v.setDir(info.dir);
}
for (const k of [...views.keys()]) if (String(k).startsWith('p:') && !seen.has(Number(String(k).slice(2)))) dropView(k);
// the boss round's prep: the pair partner's board on its half of the boss field (item 51; display only)
syncMates(prepXf.kind === 'bossPrep' && src.bossMate && Array.isArray(src.bossMate.units) ? src.bossMate.units : []);
// The 外勤医疗 reserve medic is visible before battle but not a movable board piece. Map its board-space slot to
// the displayed boss half exactly as for the player's own pieces; the partner's units already use field coordinates.
const mapChars = Array.isArray(src.prepMapChars) ? src.prepMapChars.map((u) => {
if (!u || !Number.isInteger(u.x) || !Number.isInteger(u.y)) return null;
const p = prepXf.toDisp(u.y, u.x);
const dir = prepXf.dirToDisp(u.dir);
return { ...u, x: p.col, y: p.row, dir, facing: dir === 'LEFT' ? -1 : 1 };
}).filter(Boolean) : [];
const mate = prepXf.kind === 'bossPrep' && src.bossMate && Array.isArray(src.bossMate.units) ? src.bossMate.units : [];
syncReadOnlyPrepUnits([...mapChars, ...mate]);
prepPieces = list.filter((e) => e.key && views.has(e.key));
if (dragState && !views.has(dragState.key)) { drag.reset(); endDragVisual(false); }
holdScene(false); // the prep pieces reference their models now (a battle's hold ends here)
@@ -784,11 +824,10 @@ export async function createFieldView(host, options = {}) {
}
/**
* The pair partner's pieces in the Final Assault / Hidden Core prep (m.private bossMate units: UnitInfo in boss-field
* coordinates, facing as the battle will place them): read-only 'm:<uid>' views — not prep pieces, so they are never
* picked, dragged or swept with the own ones; a view is rebuilt when its body, tile or facing changes.
* The reserve medic and a boss pair's partner pieces: read-only 'm:<uid>' views. These are not prep pieces, so they
* are never picked, dragged or swept with the own board; a view is rebuilt when its body, tile or facing changes.
*/
function syncMates(units) {
function syncReadOnlyPrepUnits(units) {
const keep = new Set();
for (const u of units) {
const info = u && Number.isInteger(u.uid) ? renderInfo({ ...u, id: `m:${u.uid}` }) : null;
@@ -1062,23 +1101,33 @@ export async function createFieldView(host, options = {}) {
function moveDragVisual(p) {
if (!dragState) return;
const v = dragState.view;
// held under the pointer: a unit with its drawn (lifted) feet DRAG_HOLD_TILES of its own px per tile below it (the
// scale at the pointer's ground, refined once at the feet), an item plate centred on it; the drop target
// (p.target, highlighted below) is the tile under the pointer
const item = v instanceof ItemView;
const g = cam.unproject(p.x, p.y, 0);
const hold = item ? 0 : DRAG_HOLD_TILES;
const s0 = g ? cam.scaleAt(g.x, g.y, 0) : cam.scale;
const t = pickTile(cam, p.x, p.y + hold * s0, heightAt, tiles.levels);
const z = t ? heightAt(t.row, t.col) : 0;
const up = item ? z : z + (v.lift || 0) + (v.hover || 0);
let w = cam.unproject(p.x, p.y + hold * s0, up);
if (w && hold) w = cam.unproject(p.x, p.y + hold * cam.scaleAt(w.x, w.y, up), up) || w;
if (w) {
v.x = Math.max(-1, Math.min(21, w.x));
v.y = Math.max(-1, Math.min(19, w.y));
v.z = z;
// a unit over a legal drop target — the tile under the pointer, on the board or the bench (render/drag.js
// dragStandTile) — stands on it, lifted, as in the official deploy drag (the owner's recording of 2026-10-09): the
// world point the drop tween goes to (boardWorld, the prep field transform applied), set at once, no tween
const stand = item ? null : dragStandTile(p);
if (stand) {
const w = boardWorld(stand.row, stand.col, stand.bench);
v.x = w.x; v.y = w.y; v.z = w.z;
} else {
// otherwise held under the pointer (the player still sees what they carry): a unit with its drawn (lifted) feet
// DRAG_HOLD_TILES of its own px per tile below it (the scale at the pointer's ground, refined once at the feet), an
// item plate centred on it
const g = cam.unproject(p.x, p.y, 0);
const hold = item ? 0 : DRAG_HOLD_TILES;
const s0 = g ? cam.scaleAt(g.x, g.y, 0) : cam.scale;
const t = pickTile(cam, p.x, p.y + hold * s0, heightAt, tiles.levels);
const z = t ? heightAt(t.row, t.col) : 0;
const up = item ? z : z + (v.lift || 0) + (v.hover || 0);
let w = cam.unproject(p.x, p.y + hold * s0, up);
if (w && hold) w = cam.unproject(p.x, p.y + hold * cam.scaleAt(w.x, w.y, up), up) || w;
if (w) {
v.x = Math.max(-1, Math.min(21, w.x));
v.y = Math.max(-1, Math.min(19, w.y));
v.z = z;
}
}
// the drop target (p.target, highlighted) is the tile under the pointer
if (p.target && p.target.area !== 'temp') {
const r = p.target.area === 'board' ? p.target.row : GEO.HAND_ROW;
const c = p.target.area === 'board' ? p.target.col : p.target.idx;
@@ -1209,6 +1258,17 @@ export async function createFieldView(host, options = {}) {
// the 收起 toggle of PR #149 moved the discs one button to the right) opened the bond popup instead of selecting the
// unit. As a click target that holds the finger's point the canvas wins: a tap on the board stays on the tile under it.
const onTapTarget = () => {};
const removeInput = () => {
canvas.removeEventListener('pointerdown', onPointerDown);
canvas.removeEventListener('pointermove', onPointerMove);
canvas.removeEventListener('pointerup', onPointerUp);
canvas.removeEventListener('pointercancel', onPointerCancel);
canvas.removeEventListener('pointerleave', onPointerLeave);
canvas.removeEventListener('contextmenu', onContext);
canvas.removeEventListener('touchend', onTouchEnd);
canvas.removeEventListener('click', onTapTarget);
};
setupCleanup.push(removeInput);
canvas.addEventListener('pointerdown', onPointerDown);
canvas.addEventListener('pointermove', onPointerMove);
canvas.addEventListener('pointerup', onPointerUp);
@@ -1415,6 +1475,16 @@ export async function createFieldView(host, options = {}) {
consumedIds.add(e[4].id);
if (consumedIds.size > 200) consumedIds.delete(consumedIds.values().next().value);
}
// 推拉: a push / pull (battle/displacement.js) moves the enemy inside one call; its `displace` fx carries the
// destination ['fx', 'displace', x, y, { id }] and the view slides there (UnitView.slideTo, PR #380) — normally
// started already by syncBattle's look-ahead, which this repeats harmlessly
if (e[1] === 'displace') {
const d = e[4] && typeof e[4] === 'object' ? e[4] : null;
const v = d && d.id != null ? views.get(d.id) : null;
if (v && v.slideTo) v.slideTo(Number(e[2]), Number(e[3]), {
...(Number(d.dur) > 0 ? { dur: Number(d.dur) } : {}), keepFacing: d.keepFacing === true,
});
}
// an enemy's mode change — the `form` of a sim setForm fx (shared/protocol.js fxForm: 掠海漂移体 → 爬行模式, user
// playtest #5 item 1; 转译基底's forms, a 逐火 ember and its revival, the leaders' 重生, 守墓石像 — user report after
// 0.1.0) — switches the view's clip set (UnitView.setForm; a kind without a clip set of that skeleton changes
@@ -1467,10 +1537,27 @@ export async function createFieldView(host, options = {}) {
if (best) { tiles.flashObjective(best[0], best[1]); board3d?.flashObjective(best[0], best[1]); }
}
/** syncBattle's look-ahead: a queued `displace` fx ['fx', 'displace', x, y, { id }] starts that view's slide. */
let slideAt = 0;
function startSlide(e) {
if (!Array.isArray(e) || e[0] !== 'fx' || e[1] !== 'displace') return;
const extra = e[4] && typeof e[4] === 'object' ? e[4] : null;
const v = extra && extra.id != null ? views.get(extra.id) : null;
// `dur`: the push / pull's 失衡 time in game seconds (battle/displacement.js) — the slide's length
if (v && v.slideTo) v.slideTo(Number(e[2]), Number(e[3]), {
at: slideAt, ...(Number(extra.dur) > 0 ? { dur: Number(extra.dur) } : {}), keepFacing: extra.keepFacing === true,
});
}
let renderT0Battle = null; // game time of the first rendered battle frame (spawn puffs skip the initial wave)
let downSeq = 0; // syncBattle pass counter: a view still marked down after a pass left the `down` list
function syncBattle(renderT) {
if (renderT0Battle == null) renderT0Battle = renderT;
// A push / pull's slide starts as soon as the interval sampled below ends on the snapshot that carries the
// destination: its `displace` fx is due only when renderT reaches that snapshot, and by then sample() has already
// lerped (≤ teleport) or snapped the enemy most of the way — no frames in between (PR #380). The fx are still queued
// here (processEvents took only those ≤ renderT); the view holds until `at`, then eases into the sim's position.
slideAt = interp.nextSnapT(renderT);
if (slideAt > renderT) interp.forEachUpcoming(renderT, slideAt, startSlide);
interp.sample(renderT, sample);
for (const [id, s] of sample) {
let v = views.get(id);
@@ -1481,9 +1568,7 @@ export async function createFieldView(host, options = {}) {
if (!v) v = battleView(id) || createUnknown(id, s);
if (!v) continue;
if (!v._seen) { v._seen = true; v.fadeIn = 0; }
if (v.alive || v.info?.kind === 'device') v.sync(s, renderT);
else if (v.dying > 0) { v.x = s.x; v.y = s.y; }
else if (s.anim !== ANIM.DIE && s.hp > 0) { v.revive?.(); v.sync(s, renderT); }
syncView(v, s, renderT);
}
// knocked-out operators waiting to redeploy (b.snap `down`, user playtest #4 item 9): their view stays on the
// field knocked down under a redeploy ring (UnitView.setDown) — made on the spot for one already down when this
@@ -1534,6 +1619,10 @@ export async function createFieldView(host, options = {}) {
let cpuMs = 0;
let frameNo = 0;
let impInterval = 0;
// Each frame the impostor units take slots 0, 1, 2 … in update order (units.js _updateImpostor): a unit refreshes when
// (frame + slot) % interval === 0, so a frame refreshes ⌊n/k⌋ or ⌈n/k⌉ of n units. Random per-unit phases left the
// busiest frame 20–66% above that (16–48 units, intervals 2–6) and set the frame-time peaks of a crowded battle.
let impSlot = 0;
let vp = { width: s0.width, height: s0.height }; // viewport (CSS px) of this frame: unit culling
let culledCount = 0;
// Adaptive load level 0–3: a device that cannot hold the frame rate with the current work switches crowds to
@@ -1550,8 +1639,9 @@ export async function createFieldView(host, options = {}) {
// Crowded fields render skeletons through staggered RenderTexture impostors (units.js): the interval grows with
// the number of Spine units so the per-frame vertex work stays roughly constant (hysteresis: re-evaluated
// every 30 frames). Prep and ordinary fields keep full-rate direct rendering.
// Spine clipping masks (only eyeball clips on the current roster, invisible at chibi scale) each cost a stencil
// render-pass break (~2–5 ms of GPU on tiled GPUs): kept only for a lone clipped skeleton at high quality
// Spine clipping masks (the eyelids on the current roster) each cost a stencil render-pass break (~2–5 ms of GPU on
// tiled GPUs): kept only for a lone clipped skeleton at high quality; without them SpineActor hides the eyeball slots a
// closed lid would cut (GitHub #177, spine.js _eyeMaskFallback)
let clipAllowed = true;
function pickClipping() {
let n = 0;
@@ -1574,6 +1664,7 @@ export async function createFieldView(host, options = {}) {
}
function frameBody(now) {
frameNo++;
impSlot = 0;
if (frameNo % 30 === 1) {
impInterval = pickImpostorInterval(); clipAllowed = pickClipping();
culledCount = 0;
@@ -1658,6 +1749,7 @@ export async function createFieldView(host, options = {}) {
let ro = null;
if (typeof ResizeObserver === 'function') {
ro = new ResizeObserver(() => resize());
setupCleanup.push(() => ro.disconnect());
ro.observe(host);
}
// a DPR change without a size change (window dragged to another monitor, zoom) must re-rasterise too; polled
@@ -1672,6 +1764,7 @@ export async function createFieldView(host, options = {}) {
// load it again at once (their bounded retries never run while hidden: no frames).
function onAssets() {
if (destroyed) return;
retryBoard();
loadShadow();
loadMountains();
if (mode === 'prep' && lastPrep) setPrep(lastPrep.ps, lastPrep.o);
@@ -1680,18 +1773,21 @@ export async function createFieldView(host, options = {}) {
leader?.view.retryAssets?.();
}
const offAssets = typeof assets.onChange === 'function' ? assets.onChange(onAssets) : null;
setupCleanup.push(() => offAssets?.());
const onVisible = () => {
if (destroyed || globalThis.document?.visibilityState !== 'visible') return;
retryBoard();
if (assets.loaded === false && typeof assets.ready === 'function') assets.ready();
for (const v of views.values()) v.retryAssets?.();
for (const v of penViews.values()) v.retryAssets?.();
leader?.view.retryAssets?.();
};
globalThis.document?.addEventListener?.('visibilitychange', onVisible);
setupCleanup.push(() => globalThis.document?.removeEventListener?.('visibilitychange', onVisible));
// ---- public API ---------------------------------------------------------------------------------------------
const view = {
view = {
setStage,
setCamera,
setPrep,
@@ -1830,14 +1926,9 @@ export async function createFieldView(host, options = {}) {
try { ro?.disconnect(); } catch { /* ignore */ }
try { offAssets?.(); } catch { /* ignore */ }
globalThis.document?.removeEventListener?.('visibilitychange', onVisible);
canvas.removeEventListener('pointerdown', onPointerDown);
canvas.removeEventListener('pointermove', onPointerMove);
canvas.removeEventListener('pointerup', onPointerUp);
canvas.removeEventListener('pointercancel', onPointerCancel);
canvas.removeEventListener('pointerleave', onPointerLeave);
canvas.removeEventListener('contextmenu', onContext);
canvas.removeEventListener('touchend', onTouchEnd);
canvas.removeEventListener('click', onTapTarget);
removeInput();
offShadow?.();
signal?.removeEventListener('abort', onAbort);
app.ticker.remove(frame);
app.ticker.remove(preRender);
app.ticker.remove(postRender);
@@ -1857,6 +1948,7 @@ export async function createFieldView(host, options = {}) {
try { board3dCanvas?.remove(); } catch { /* ignore */ }
releaseGl(app.renderer);
try { app.destroy(true, { children: true, texture: false, baseTexture: false }); } catch { /* ignore */ }
canvas.remove();
},
stats() {
return {
@@ -1876,5 +1968,22 @@ export async function createFieldView(host, options = {}) {
pick: { pieceAt, battleUnitAt, penUnitAt, groundTile },
},
};
// All teardown dependencies now exist. Aborting during either asset wait immediately releases
// the renderer, ticker and listeners; late shared asset results see destroyed and do nothing.
setupCleanup.length = 0;
onAbort = () => view.destroy();
signal?.addEventListener('abort', onAbort, { once: true });
signal?.throwIfAborted();
await withTimeout(artPromise, 2500, signal);
if (want3d) await withTimeout(boardReady, 6000, signal);
signal?.throwIfAborted();
return view;
} catch (err) {
if (view) view.destroy();
else {
destroyed = true;
for (const dispose of setupCleanup.reverse()) { try { dispose(); } catch { /* continue cleanup */ } }
}
throw err;
}
}
+16 -2
View File
@@ -4,8 +4,22 @@ import { createAssets, assets as defaultAssets } from '../../assets.js';
import { standInRecord } from '../../../../shared/standIn.js';
import { diyRecordOf } from '../../../../shared/diy.js';
function withTimeout(p, ms) {
return Promise.race([p, new Promise((resolve) => setTimeout(resolve, ms))]);
/** Wait for optional assets, or stop waiting when this view is cancelled. Shared loads keep running. */
function withTimeout(p, ms, signal) {
let timer;
let onAbort;
const stopped = new Promise((resolve, reject) => {
if (ms != null) timer = setTimeout(resolve, ms);
if (signal) {
onAbort = () => reject(signal.reason);
signal.addEventListener('abort', onAbort, { once: true });
if (signal.aborted) onAbort();
}
});
return Promise.race([p, stopped]).finally(() => {
clearTimeout(timer);
if (onAbort) signal.removeEventListener('abort', onAbort);
});
}
/** Wrap whatever the caller passed as `assets` into the store API of public/js/assets.js. */
+5
View File
@@ -37,7 +37,12 @@ export function renderInfo(u) {
// DESIGN §16 loadout of an ally (UnitInfo.skillIndex / moduleId): the Spine actor plays that skill's clip, and a
// tap hands them to the detail card (a teammate's unit shows its owner's skill / module)
skillIndex: Number.isInteger(u.skillIndex) ? u.skillIndex : undefined,
// an ally whose skill is an ammo magazine (UnitInfo.ammoSkill): its rounds are the cells under the HP bar, so no sustained
// skill aura is drawn for it (render/fx/rings.js _aura)
ammoSkill: u.ammoSkill === true ? true : undefined,
moduleId: typeof u.moduleId === 'string' ? u.moduleId : undefined,
// Scouted hand and temp units have details but no field attack range.
area: typeof u.area === 'string' ? u.area : undefined,
// the ally's equipped item ids (UnitInfo.items, DESIGN §16 / §21.11): the detail card needs them for a teammate's
// unit (resolveDetail `unitItems` → the read-only 装备 section and the 变形同构体 pairing chips); the owner's own
// unit takes its items from the piece instead, so only other players' boards ever read this field
+3 -2
View File
@@ -26,8 +26,9 @@ const CHAIN_KINDS = new Set(['chain', 'chainHeal']);
const DROP_PENDING_MS = 1300;
/**
* A dragged unit is held with its drawn feet this many tiles below the pointer — the pointer on its body, the model
* under the finger / mouse (user playtest #4 item 1: as in v2.1; mouse and touch alike). An item plate is centred on it.
* A dragged unit over no legal target is held with its drawn feet this many tiles below the pointer — the pointer on its
* body, the model under the finger / mouse (user playtest #4 item 1: as in v2.1; mouse and touch alike); over a legal
* target it stands on that tile (render/drag.js dragStandTile). An item plate is centred on the pointer.
*/
export const DRAG_HOLD_TILES = 0.45;
+23 -7
View File
@@ -16,10 +16,20 @@ import { parseObj } from './obj.js';
export const THREE_URL = '/vendor/three.module.js';
let threePromise = null;
let threeFailures = 0;
/** Dynamic import of the vendored three.js ESM build (browser only); null when unavailable. */
export function loadThree(url = THREE_URL) {
if (!threePromise) {
threePromise = import(/* @vite-ignore */ url).then((m) => (m && m.WebGLRenderer ? m : null), () => null);
// Browsers also cache failed module imports. Two bounded retry URLs permit a transient download failure to
// recover; clearing only our promise would keep receiving that same cached rejection without another request.
if (threeFailures >= 3) return Promise.resolve(null);
const source = new URL(url, import.meta.url);
if (threeFailures) source.searchParams.set('sp-retry', String(threeFailures));
const pending = import(/* @vite-ignore */ source.href).then((m) => (m && m.WebGLRenderer ? m : null), () => null).then((m) => {
if (!m && threePromise === pending) { threePromise = null; threeFailures++; }
return m;
});
threePromise = pending;
}
return threePromise;
}
@@ -95,7 +105,7 @@ async function fetchJson(url) {
*/
export function loadBoardPack(assets) {
if (cached) return cached;
cached = (async () => {
const pending = (async () => {
if (!assets || typeof assets.local !== 'function' || typeof assets.localUrl !== 'function' || typeof assets.image !== 'function') return null;
const manifest = await assets.local().catch(() => null);
if (!isObj(manifest)) return null;
@@ -106,7 +116,7 @@ export function loadBoardPack(assets) {
await Promise.all(Object.entries(PACK_IMAGES).map(async ([k, [g, n]]) => {
const u = url(g, n);
if (!u) return;
const img = await assets.image(u).catch(() => null);
const img = await assets.image(u, { retry: true }).catch(() => null);
if (img && (img.width || img.naturalWidth) > 0) images[k] = img;
}));
if (!images.D) return null;
@@ -138,9 +148,15 @@ export function loadBoardPack(assets) {
images, meshes, tiles: isObj(tiles) ? tiles : null, uv: resolveUvTable(isObj(tiles) ? tiles : null),
materials: { theme: isObj(theme) ? theme : null, fx: isObj(fxMats) ? fxMats : null },
};
})().catch((err) => { console.warn('[board3d] art load failed', err); return null; });
return cached;
})().catch((err) => { console.warn('[board3d] art load failed', err); return null; }).then((pack) => {
// A timeout / failed atlas is not proof the host lacks 3D art. A later mount or visibility change may retry.
// An older attempt finishing after reset must not discard a newer cached request.
if (!pack && cached === pending) cached = null;
return pack;
});
cached = pending;
return pending;
}
/** Forget the cached pack (tests / hot reload). */
export function resetBoardPack() { cached = null; threePromise = null; }
/** Forget the cached pack and the three.js import attempt (tests / hot reload). */
export function resetBoardPack() { cached = null; threePromise = null; threeFailures = 0; }
+19 -2
View File
@@ -11,8 +11,10 @@
// direction wheel to re-orient it in place (research 09 §1.2 — "drag the unit onto its own tile and swipe").
// Everything is judged at the pointer (user playtest #4 item 1: the ground is drawn as tiles): a press / hover is on the
// piece standing on the tile under it (`hitPiece`, render/pick.js), and while dragging the target, `tileHover` and the
// legality are that tile — an item dropped on a unit's tile goes to that unit; app.js holds the dragged ghost under
// the pointer. A release over DOM UI covering the canvas (the shop bar) is 'outside' (mouse and touch alike).
// legality are that tile — an item dropped on a unit's tile goes to that unit. app.js draws a dragged unit standing on
// that tile while it is a legal target (`dragStandTile`, the official deploy drag) and held under the pointer
// otherwise; an item plate stays on the pointer. A release over DOM UI covering the canvas (the shop bar) is 'outside'
// (mouse and touch alike).
//
// Events emitted through `emit(name, payload)`:
// pieceHover { uid, piece, clientX, clientY } | { uid: null } (mouse/pen hover changes)
@@ -80,6 +82,21 @@ export function resolveDrop(p) {
return { kind: 'drop', target: slot };
}
/**
* The tile a dragged UNIT is drawn standing on, from a `pieceDragMove` payload: the drop target under the pointer while
* it is legal — a board tile, or a bench slot (row HAND_ROW) — as `{ row, col, bench }` in board space; null otherwise
* (no target, an illegal tile, a temp slot, the pointer over DOM UI such as the shop bar): the unit is then held under
* the pointer. As in the official deploy drag (the owner's recording of 2026-10-09) the model stands on the tile the
* finger is on, and moving within that tile leaves it where it is. app.js never snaps an item plate.
*/
export function dragStandTile(move, geo = GEO) {
const t = move && move.legal === true ? move.target : null;
if (!t) return null;
if (t.area === 'board' && Number.isInteger(t.row) && Number.isInteger(t.col)) return { row: t.row, col: t.col, bench: false };
if (t.area === 'hand' && Number.isInteger(t.idx)) return { row: geo.HAND_ROW, col: t.idx, bench: true };
return null;
}
/** Legality via the UI callback (board → (piece,row,col); hand → (piece, HAND_ROW, idx)). Throwing ⇒ illegal. */
export function isLegal(canPlace, piece, slot) {
if (!slot || slot.area === 'temp' || slot.area === 'outside') return false;
+5
View File
@@ -7,6 +7,8 @@
*/
export const FX_KINDS = Object.freeze({
// blasts
// [ASSUMED] Clementia's S3 uses the shared blast placeholder until her official effect is ported.
blast: { a: 'blast', c: 0xffb35c },
aoe: { a: 'blast', c: 0xffb35c }, explode: { a: 'blast', c: 0xff7a33 }, explosion: { a: 'blast', c: 0xff7a33 },
// `pt`: always at the event's (x, y) (its `id` is the shooter); `heavy`: debris + scorch
bombard: { a: 'blast', c: 0xffa04a, r: 1.5, pt: true, heavy: true }, bombardShell: { a: 'shell', c: 0xff5a3a, r: 1.5, pt: true },
@@ -15,6 +17,9 @@ export const FX_KINDS = Object.freeze({
frostNova: { a: 'blast', c: 0x9fe6ff, smoke: 0x1c2630 }, sunBurst: { a: 'blast', c: 0xffe28a }, meltdown: { a: 'blast', c: 0xff5a2a, r: 1.5, heavy: true },
iceSpike: { a: 'blast', c: 0xbfeeff, smoke: 0x1c2630 }, rockfall: { a: 'blast', c: 0xc8a878, smoke: 0x4a3f33 }, rockslide: { a: 'blast', c: 0xc8a878, smoke: 0x4a3f33 },
finale: { a: 'blast', c: 0xffd45a, r: 1.5 }, swordStorm: { a: 'blast', c: 0xdfe8ff }, swordRain: { a: 'blast', c: 0xdfe8ff }, liberate: { a: 'blast', c: 0xffffff },
// 赤刃明霄陈 S3 赤霄·天喟 的龙剑气: a travelling blade, drawn from the sim's per-interval `chen3Wave` events (its own
// position + the previous point + direction); `pt` because it is not at the caster (it roams and passes by her tile)
chen3Wave: { a: 'qi', c: 0xdfe8ff, pt: true },
quadShot: { a: 'volley', c: 0xfff2d0 }, featherArrow: { a: 'counter', c: 0xfff2d0 },
burst: { a: 'element', c: 0xd0a0ff },
// areas
+6 -1
View File
@@ -74,8 +74,13 @@ export class FxRings {
this._aura(view, true);
}
/** Active-skill aura: a soft gold glow and a slowly turning hex on the ground under the unit (fading in / out). */
/**
* Active-skill aura: a soft gold glow and a slowly turning hex on the ground under the unit (fading in / out). An ammo
* skill (UnitInfo.ammoSkill) has none: its magazine is the yellow cells under the HP bar, which shrink with every round
* — a standing glow would read as a buff that never changes. The activation burst (skill()) is the same for all.
*/
_aura(view, on) {
if (on && view.info?.ammoSkill) return;
const P = this.P;
let a = this.auras.get(view.id);
if (on) {
+30 -3
View File
@@ -47,10 +47,11 @@ export class FxSim {
/**
* b.ev 'fx': every kind the sim / content emits has a visual (FX_KINDS archetypes: blast, shell, zone, telegraph,
* heal, sp, shield, shatter, summon, vanish, blink, move, wave, mark, reticle, buff, lift, sleep, crit, dodge,
* counter, dp, coin, crate, down, beam, bolt, strike, volley, pillar, lp, chill, element, flame), except kinds whose
* counter, dp, coin, crate, down, beam, bolt, strike, volley, pillar, lp, chill, element, flame, qi), except kinds whose
* archetype is 'none' (hitCap: a leader hit cancelled by 限伤 draws nothing); unknown kinds get a generic sparkle. `extra` keys used: id (anchor unit — or the shooter of a `pt` kind), r | radius, dur | duration,
* t (shell flight, game s), src / from / to / target / targets (unit ids), fx, fy / fromX, fromY / tx, ty (positions),
* element, n, scale, kind, tiles, hold ('lock': the reticle waits while the shooter's skill runs).
* element, n, scale, kind, tiles, hold ('lock': the reticle waits while the shooter's skill runs), dr / dc (a
* direction in tiles: 'qi' points its blade that way).
*/
simFx(kind, x, y, extra) {
const ex = extra && typeof extra === 'object' ? extra : {};
@@ -100,7 +101,12 @@ export class FxSim {
else this.zone(at.x, at.y, at.z, r, col, d, 'ring', true);
break;
}
case 'chill': this.flashScreen(col, 0.35, 0.8); this.snowfall(col); break;
// 盟约寒风 (谢拉格's field-wide wind — the device emits it with no anchor unit): a blizzard up the field and a
// stronger cold flash (PR #386); an operator's own cold (灵知 S3's start, 麦哲伦 S1's pulse every 3 s: `id` = the
// unit) keeps the light flurry and the softer flash — a full-screen blizzard every pulse would bury the field
case 'chill':
if (ex.id == null) { this.flashScreen(col, 0.45, 1); this.snowfall(col, true); } else { this.flashScreen(col, 0.35, 0.8); this.snowfall(col); }
break;
case 'heal': this.heal(at.v || { x: at.x, y: at.y, z: at.z }, 0); break;
case 'healAoe': {
this.ring(at.x, at.y, at.z, 0.2, r, col, 0.6);
@@ -168,6 +174,27 @@ export class FxSim {
}
break;
}
// a travelling blade (赤刃明霄陈 S3's 龙剑气): a short trail from where it was to where it is, plus a crescent head
// turned the way it moves — the sim sends one of these every 0.12 s, and the trail outlives that gap so the
// events join into one sweeping wave. A procedural stand-in (the official effect is a particle prefab this client
// does not extract). Trail, head and heading share one height; the head is projected after streak(), which
// reuses this._p / this._q (projected before it, the head was drawn at the trail's start, one event behind)
case 'qi': {
const z = at.z + 0.35;
const fx0 = num(ex.fromX, NaN), fy0 = num(ex.fromY, NaN);
if (Number.isFinite(fx0) && Number.isFinite(fy0)) this.streak(fx0, fy0, at.x, at.y, z, col, 0.34);
const h = cam.project(at.x, at.y, z, this._p), hx = h.x, hy = h.y, hs = h.s;
const dr = num(ex.dr, NaN), dc = num(ex.dc, NaN);
let rot = 0;
if (Number.isFinite(dr) && Number.isFinite(dc) && (dr || dc)) {
const q = cam.project(at.x + dc * 0.5, at.y + dr * 0.5, z, this._q);
// the `slash` sprite's crescent bulges along its local −y, so this turns that edge onto the travel direction
rot = Math.atan2(q.x - hx, -(q.y - hy));
}
this.particle('slash', hx, hy, { tint: col, life: 0.3, s0: hs / 128 * 1.3, s1: hs / 128 * 2.0, a0: 0.9, a1: 0, rot });
this.particle('glow', hx, hy, { tint: col, life: 0.26, s0: hs / 128 * 0.8, s1: hs / 128 * 1.5, a0: 0.55, a1: 0 });
break;
}
case 'wave': {
const k = clamp(num(ex.scale, 1), 0.5, 4);
const rr = Math.max(0.8, r * (ex.scale ? Math.min(2, k / 2) : 1));
+25 -4
View File
@@ -180,11 +180,32 @@ export class FxZones {
this.tintSprite.tint = tint;
}
/** A short flurry of snow over the field (cold wind). */
snowfall(tint) {
/**
* Snow over the field (cold wind). `gust` — 盟约寒风, 谢拉格's field-wide wind: a blizzard sweeping the screen from the
* bottom up, the official direction (the owner's confirmation of 2026-10-08; PR #386 by @leiming2333 after the
* community report 「谢拉格的效果特性不明显,暴风雪应该从下向上刮」): snowflakes rise off the bottom edge on a crosswind
* with gust streaks racing along, so it reads as wind driving up the field, not quiet snowfall (flake / streak counts,
* speeds and lives are a visual choice [ASSUMED]; screen y grows downward, so a negative vy rises). Without `gust` (an
* operator's own cold: 灵知 S3, 麦哲伦 S1's pulses) a short light flurry drifting down, as before.
*/
snowfall(tint, gust = false) {
const size = this.ctx.screenSize();
for (let i = 0; i < (this.quality === 'low' ? 10 : 26); i++) {
this.particle('dot', Math.random() * size.width, Math.random() * size.height * 0.7, { tint, vx: 30 + Math.random() * 40, vy: 60 + Math.random() * 60, life: 1 + Math.random() * 0.6, s0: 0.25 + Math.random() * 0.3, s1: 0.1, a0: 0.8, a1: 0, fadeIn: 0.2 });
const W = size.width, H = size.height, low = this.quality === 'low';
if (!gust) {
for (let i = 0; i < (low ? 10 : 26); i++) {
this.particle('dot', Math.random() * W, Math.random() * H * 0.7, { tint, vx: 30 + Math.random() * 40, vy: 60 + Math.random() * 60, life: 1 + Math.random() * 0.6, s0: 0.25 + Math.random() * 0.3, s1: 0.1, a0: 0.8, a1: 0, fadeIn: 0.2 });
}
return;
}
// snowflakes: lifted off the bottom edge (a share starts below it), blown up-right and tumbled by the gust
for (let i = 0; i < (low ? 22 : 48); i++) {
const vy = -H * (0.55 + Math.random() * 0.5), vx = W * (0.04 + Math.random() * 0.1);
this.particle('dot', Math.random() * W, H * (0.55 + Math.random() * 0.55), { tint, vx, vy, life: 1.4 + Math.random() * 0.9, s0: 0.22 + Math.random() * 0.34, s1: 0.08, a0: 0.9, a1: 0, fadeIn: 0.12, spin: (Math.random() - 0.5) * 6 });
}
// gust streaks: thin wind lines racing up through the field along their velocity
for (let i = 0; i < (low ? 4 : 10); i++) {
const vy = -H * (0.9 + Math.random() * 0.6), vx = W * (0.1 + Math.random() * 0.12);
this.particle('streak', Math.random() * W, H * (0.6 + Math.random() * 0.5), { tint, vx, vy, life: 0.7 + Math.random() * 0.4, s0: 0.04 + Math.random() * 0.03, s1: 0.02, sx: 6 + Math.random() * 6, a0: 0.55, a1: 0, fadeIn: 0.08, rot: Math.atan2(vy, vx) });
}
}
+107 -20
View File
@@ -8,7 +8,8 @@
// advancing at `rate` and gently steered back when network jitter pushes it off target (hard snap when it
// is more than `snapAfter` real seconds off),
// * extrapolation guard: renderT never runs more than `maxExtrapolate` real seconds past the newest snapshot;
// positions are extrapolated along the last velocity for at most that long, then freeze,
// positions are extrapolated along the last velocity for at most that long, then freeze — never for a unit the
// newest snapshot shows dead, stunned (frozen, asleep) or blocked, nor for an enemy it shows standing (`stand`),
// * sample(): per unit, lerps x/y/hp/sp between the two snapshots bracketing renderT; flags/anim come from
// the older one. A unit missing from the newer snapshot (died/left mid-buffer) holds its last position
// until renderT reaches the newer snapshot; a unit that only exists in the newer one (spawned mid-buffer)
@@ -20,18 +21,31 @@
// game s behind) are dropped by the caller's choice (`isCosmeticEvent`); state events — an enemy's form fx
// included — are always delivered, and a full queue sheds only cosmetic ones.
//
// Snapshot tuple layout (DESIGN §8.2): [id, x, y, hp, maxHp, sp, spMax, flags, anim]. Two optional lists ride along
// (server/sim/battle/events.js snapshot, user playtest #4 items 8 / 9):
// Snapshot tuple layout (DESIGN §8.2): [id, x, y, hp, maxHp, sp, spMax, flags, anim]. Optional lists ride along
// (server/sim/battle/events.js snapshot; the first two from user playtest #4 items 8 / 9, `stand` / `standCut` from PR #381):
// * `elem` [[id, element, fill, cooldownEnd, cooldown]] — the element gauge a unit shows: appended to that unit's
// normalised tuple (EL…EL_DUR) and handed out by sample() as `el`, `elFill`, `elUntil`, `elDur` (from the older
// snapshot, like flags);
// * `down` [[id, respawnAt, respawnTime, state, row?, col?]] — knocked-out operators waiting to redeploy (they are no
// longer in `units`) and the tile they lie on (where they fell, or their home — sim Battle._layBody; kept only when
// both are integers): downAt(time) returns the list of the snapshot at `time`.
// Game times in both (`cooldownEnd`, `respawnAt`) are on the snapshots' clock, so a view compares them with renderT.
// both are integers): downAt(time) returns the list of the snapshot at `time`;
// * three HP-bar readouts, kept per snapshot beside the tuples (the tuple layout is unchanged) and handed out by sample()
// from the older snapshot like flags, so they step with the skill flag instead of sliding:
// `ammo` [[id, rounds left, rounds in the magazine]] → sample().ammo [left, magazine] | null (whole numbers only)
// `wolves` [[id, 狼影 left, the talent's maximum]] → sample().wolves [left, maximum] | null
// `neg` [[id, fill]] (0.01–1: the negative-HP pool's share of its cap) → sample().neg number (0: none)
// An entry that is malformed, or names a unit the snapshot does not list, is dropped; a snapshot without the list
// clears the readout (render/units.js: the segmented ammo bar, the wolf pips, the red bar of 斩业星熊's 我执).
// * `stand` [[id, until]] — the game time an enemy's attack recovery ends (sim atkStandUntil, PR #381): the position
// holds until then and interpolates the rest of the interval; nothing is inferred from the attack animation, and a
// snapshot without it interpolates linearly;
// * `standCut` [[id, at]] — the latest time that enemy's recovery was cut or ignored: an interval with a cut inside
// it stays linear.
// Game times in all of them (`cooldownEnd`, `respawnAt`, `until`, `at`) are on the snapshots' clock, so a view compares
// them with renderT.
import { fxForm } from '../../../shared/protocol.js';
import { ANIM } from '../../../shared/constants.js';
import { ANIM, UF } from '../../../shared/constants.js';
export const TUPLE = Object.freeze({ ID: 0, X: 1, Y: 2, HP: 3, MAXHP: 4, SP: 5, SPMAX: 6, FLAGS: 7, ANIM: 8, EL: 9, EL_FILL: 10, EL_UNTIL: 11, EL_DUR: 12 });
/** The unit was (re)deployed between tuples `a` and the newer `b`: `b` plays the deploy animation, `a` did not (sim snapshot animOf). */
@@ -43,6 +57,15 @@ const MAX_EVENTS = 6000;
const finite = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
/**
* Game time unit `id` starts moving between snapshot `a` and the newer `b` (interpolation and extrapolation share it):
* the end of `a`'s stand when it falls inside the interval and `b` records no cut there, else `a.t`.
*/
function movementStart(a, b, id) {
const until = a.stand?.get(id), cutAt = b.standCut?.get(id);
return until > a.t && until <= b.t && !(cutAt >= a.t && cutAt <= b.t) ? until : a.t;
}
/** Cosmetic event kinds that may be dropped when far behind (never state-changing). */
export const COSMETIC_EVENTS = new Set(['atk', 'dmg', 'heal', 'fx', 'layer', 'bounty']);
/**
@@ -66,8 +89,11 @@ export function frameTime(msg) {
/**
* Validate & normalise a b.snap payload. Returns `{ t, units: Map<id, tuple>, down: [[id, respawnAt, respawnTime,
* state, row?, col?]] | null, raw }` or null when unusable. Tuples with a non-finite id/x/y are skipped; other numbers default
* to 0; a unit's `elem` entry (see header) is appended to its tuple; malformed `elem` / `down` entries are dropped.
* state, row?, col?]] | null, ammo, wolves: Map id → [left, max] | null, neg: Map id → fill | null, stand: Map<id, until> | null,
* standCut: Map<id, at> | null, raw }` or null when unusable. Tuples with a non-finite id/x/y are skipped; other numbers
* default to 0; a unit's `elem` entry (see header) is appended to its tuple; malformed `elem` / `down` / `ammo` / `wolves` /
* `neg` entries are dropped. `stand` keeps finite end times after `t` of units in the snapshot; `standCut` keeps finite
* times in [0, t] of units in it.
*/
export function normalizeSnapshot(snap) {
if (!snap || typeof snap !== 'object') return null;
@@ -90,6 +116,14 @@ export function normalizeSnapshot(snap) {
if (tu && tu.length === 9) tu.push(e[1], clamp(finite(e[2]), 0, 1), finite(e[3]), Math.max(0, finite(e[4])));
}
}
const ammo = countList(snap.ammo, units), wolves = countList(snap.wolves, units), coins = countList(snap.coins, units);
let neg = null;
if (Array.isArray(snap.neg)) {
for (const e of snap.neg) {
if (!Array.isArray(e) || !units.has(e[0]) || !(typeof e[1] === 'number' && e[1] > 0)) continue;
(neg || (neg = new Map())).set(e[0], Math.min(1, e[1]));
}
}
let down = null;
if (Array.isArray(snap.down)) {
for (const d of snap.down) {
@@ -99,7 +133,36 @@ export function normalizeSnapshot(snap) {
(down || (down = [])).push(e);
}
}
return { t, units, down, raw: snap };
let stand = null;
if (Array.isArray(snap.stand)) {
for (const e of snap.stand) {
if (!Array.isArray(e) || !units.has(e[0]) || !Number.isFinite(e[1]) || e[1] <= t) continue;
(stand || (stand = new Map())).set(e[0], e[1]);
}
}
let standCut = null;
if (Array.isArray(snap.standCut)) {
for (const e of snap.standCut) {
if (!Array.isArray(e) || !units.has(e[0]) || !Number.isFinite(e[1]) || e[1] < 0 || e[1] > t) continue;
(standCut || (standCut = new Map())).set(e[0], e[1]);
}
}
return { t, units, down, ammo, wolves, coins, neg, stand, standCut, raw: snap };
}
/**
* `[[id, left, max]]` → Map id → [left, max] for the ids in `units`: whole numbers with 0 ≤ left ≤ max and max ≥ 1 (the
* `ammo` and `wolves` lists); null without a valid entry.
*/
function countList(list, units) {
if (!Array.isArray(list)) return null;
let m = null;
for (const e of list) {
if (!Array.isArray(e) || !units.has(e[0]) || !Number.isSafeInteger(e[1]) || !Number.isSafeInteger(e[2])
|| e[1] < 0 || e[2] < 1 || e[1] > e[2]) continue;
(m || (m = new Map())).set(e[0], [e[1], e[2]]);
}
return m;
}
export class SnapshotBuffer {
@@ -252,6 +315,16 @@ export class SnapshotBuffer {
return ans;
}
/**
* Game time of the first snapshot after `time` (default renderT) — the newer end of the interval sample() interpolates
* — or NaN when there is none (extrapolating). render/app.js starts a push's slide while that interval is shown.
*/
nextSnapT(time = this.renderT) {
const s = this.snaps;
const i = this._indexAt(time) + 1;
return Number.isFinite(time) && i < s.length ? s[i].t : NaN;
}
/**
* The `down` list (knocked-out operators waiting to redeploy, see header) of the snapshot shown at `time` (default
* renderT) — the same snapshot sample() reads flags from — or null.
@@ -265,8 +338,9 @@ export class SnapshotBuffer {
/**
* Interpolated state at `time` (default renderT). Fills and returns `out` (a Map id → sample object reused
* across calls: `{ id, x, y, hp, maxHp, sp, spMax, flags, anim, vx, vy, seen, el, elFill, elUntil, elDur }` —
* `el` = the shown element gauge (null: none), see header). Samples of units no longer present are deleted from `out`.
* across calls: `{ id, x, y, hp, maxHp, sp, spMax, flags, anim, vx, vy, seen, el, elFill, elUntil, elDur, ammo, wolves,
* neg }` — `el` = the shown element gauge (null: none), `ammo` / `wolves` / `neg` the HP-bar readouts (null / null / 0:
* none), see header). Samples of units no longer present are deleted from `out`.
*/
sample(time = this.renderT, out = new Map()) {
const s = this.snaps;
@@ -282,28 +356,36 @@ export class SnapshotBuffer {
const stamp = A.t;
for (const [id, a] of A.units) {
let o = out.get(id);
if (!o) { o = { id, x: 0, y: 0, hp: 0, maxHp: 0, sp: 0, spMax: 0, flags: 0, anim: 0, vx: 0, vy: 0, seen: 0, el: null, elFill: 0, elUntil: 0, elDur: 0 }; out.set(id, o); }
if (!o) { o = { id, x: 0, y: 0, hp: 0, maxHp: 0, sp: 0, spMax: 0, flags: 0, anim: 0, vx: 0, vy: 0, seen: 0, el: null, elFill: 0, elUntil: 0, elDur: 0, ammo: null, wolves: null, neg: 0 }; out.set(id, o); }
const b = B ? B.units.get(id) : null;
if (b) {
const dx = b[1] - a[1], dy = b[2] - a[2];
// (a deployment in between — the newer snapshot starts its deploy animation — lands on its tile, no slide)
const tele = dx * dx + dy * dy > this.teleport * this.teleport || redeployed(a, b);
o.x = tele ? (alpha < 1 ? a[1] : b[1]) : a[1] + dx * alpha;
o.y = tele ? (alpha < 1 ? a[2] : b[2]) : a[2] + dy * alpha;
o.vx = !tele && span > 0 ? dx / span : 0;
o.vy = !tele && span > 0 ? dy / span : 0;
const start = movementStart(A, B, id);
const moveSpan = B.t - start;
const moveAlpha = moveSpan > 0 ? clamp((time - start) / moveSpan, 0, 1) : 0;
o.x = tele ? (alpha < 1 ? a[1] : b[1]) : a[1] + dx * moveAlpha;
o.y = tele ? (alpha < 1 ? a[2] : b[2]) : a[2] + dy * moveAlpha;
o.vx = !tele && moveSpan > 0 && (start === A.t || time >= start) ? dx / moveSpan : 0;
o.vy = !tele && moveSpan > 0 && (start === A.t || time >= start) ? dy / moveSpan : 0;
o.hp = a[3] + (b[3] - a[3]) * alpha;
o.maxHp = b[4] || a[4];
o.sp = a[5] + (b[5] - a[5]) * alpha;
o.spMax = b[6] || a[6];
} else {
let vx = 0, vy = 0;
if (P && ext > 0) {
// a stand in the newest snapshot: the old velocity cannot predict the walk after it, even once the clock passes
// `until` — the next snapshot confirms the position
if (P && ext > 0 && a[8] !== ANIM.DIE && a[8] !== ANIM.STUN && !(a[7] & UF.BLOCKED) && !A.stand?.has(id)) {
const p = P.units.get(id);
const dtp = A.t - P.t;
if (p && dtp > 0) {
vx = (a[1] - p[1]) / dtp; vy = (a[2] - p[2]) / dtp;
if (vx * vx + vy * vy > (this.teleport / dtp) ** 2 || redeployed(p, a)) { vx = 0; vy = 0; }
const moveSpan = A.t - movementStart(P, A, id);
if (p && dtp > 0 && moveSpan > 0) {
const dx = a[1] - p[1], dy = a[2] - p[2];
if (dx * dx + dy * dy <= this.teleport * this.teleport && !redeployed(p, a)) {
vx = dx / moveSpan; vy = dy / moveSpan;
}
}
}
o.x = a[1] + vx * ext;
@@ -317,6 +399,11 @@ export class SnapshotBuffer {
o.flags = a[7];
o.anim = a[8];
if (a.length > 9) { o.el = a[9]; o.elFill = a[10]; o.elUntil = a[11]; o.elDur = a[12]; } else if (o.el !== null) { o.el = null; o.elFill = 0; o.elUntil = 0; o.elDur = 0; }
// the HP-bar readouts come with the older snapshot, like flags (whole rounds step with the skill flag)
o.ammo = A.ammo ? A.ammo.get(id) || null : null;
o.wolves = A.wolves ? A.wolves.get(id) || null : null;
o.coins = A.coins?.get(id) ?? null;
o.neg = A.neg ? A.neg.get(id) || 0 : 0;
o.seen = stamp;
}
for (const id of out.keys()) if (!A.units.has(id)) out.delete(id);
+160 -10
View File
@@ -31,6 +31,8 @@
// closing clip timed to end `in` s from now (a 重生's last clip ends with the
// 重生), landing in `roles`
// update(dt) advances the skeleton (autoUpdate is off: one clock for everything)
// poseHeld() true while the pose cannot change (render/units.js then neither re-poses nor
// redraws the model, only advances `clock`)
// Attack mode lasts until ~1.4 attack intervals without a new attack (a `once` cast and every attack of a
// `clipPerAttack` actor: to the end of its clip), then the end clip (if any) and base — except the attacks of a skill
// with its own idle clip, which go straight back to that idle: the skill's end clip closes the skill, not each spell of
@@ -62,20 +64,53 @@ export function windUpPlan(loopDur, hit, interval, lead) {
return { ts, tsWind, start };
}
/** Whether any skin of the skeleton data has a clipping attachment (pixi-spine AttachmentType.Clipping = 6). */
/** A clipping attachment (pixi-spine AttachmentType.Clipping = 6). */
const isClip = (a) => !!a && (a.type === 6 || a.constructor?.name === 'ClippingAttachment' || ('endSlot' in a && 'vertices' in a && !('uvs' in a)));
/** Whether any skin of the skeleton data has a clipping attachment. */
export function hasClipping(data) {
try {
for (const skin of data?.skins || []) {
const list = typeof skin.getAttachments === 'function' ? skin.getAttachments() : [];
for (const e of list) {
const a = e && e.attachment;
if (a && (a.type === 6 || a.constructor?.name === 'ClippingAttachment' || ('endSlot' in a && 'vertices' in a && !('uvs' in a)))) return true;
}
for (const e of list) if (isClip(e && e.attachment)) return true;
}
} catch { /* unknown runtime shape: assume none */ }
return false;
}
/**
* Is (x, y) inside the polygon of `n` points `pts` (flat [x0, y0, x1, y1, …])? Ray casting (PR #384 by @Convey123).
*/
export function pointInPolygon(x, y, pts, n) {
let inside = false;
for (let i = 0, j = n - 1; i < n; j = i++) {
const xi = pts[i * 2], yi = pts[i * 2 + 1], xj = pts[j * 2], yj = pts[j * 2 + 1];
if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
}
/**
* World centre of what a slot draws, into `out` ([x, y]); false when it draws nothing measurable. A mesh: the mean of its
* world vertices (`buf` grows to fit); a region: its quad's centre — the attachment's (x, y) through the bone (the four
* corners sit symmetrically around it; RegionAttachment.computeWorldVertices takes a bone in runtime 3.8, a slot in 4.1).
*/
function slotCentre(slot, att, buf, out) {
const n = att.worldVerticesLength | 0;
if (n >= 2 && typeof att.computeWorldVertices === 'function') {
if (buf.v.length < n) buf.v = new Float32Array(n);
const v = buf.v;
att.computeWorldVertices(slot, 0, n, v, 0, 2);
let cx = 0, cy = 0;
for (let k = 0; k < n; k += 2) { cx += v[k]; cy += v[k + 1]; }
out[0] = cx / (n >> 1); out[1] = cy / (n >> 1);
} else if (att.region !== undefined && Number.isFinite(att.x) && Number.isFinite(att.y) && slot.bone) {
const b = slot.bone;
out[0] = b.a * att.x + b.b * att.y + b.worldX; out[1] = b.c * att.x + b.d * att.y + b.worldY;
} else return false;
return Number.isFinite(out[0]) && Number.isFinite(out[1]);
}
export class SpineActor {
/**
* @param {any} spineData PIXI.spine skeleton data
@@ -91,11 +126,12 @@ export class SpineActor {
this.names = new Set((spineData.animations || []).map((a) => a.name));
/**
* Clipping attachments render as stencil masks (≈1.5 ms of GPU each per frame on tiled GPUs): such skeletons
* are drawn through the impostor atlas while clipping is on, and clipping is switched off (unclipped slots,
* visually negligible on battle chibis) when too many of them share a field (app.js budget).
* are drawn through the impostor atlas while clipping is on, and clipping is switched off when too many of them
* share a field (app.js budget) — then _eyeMaskFallback hides what a closed eyelid would cut (GitHub #177).
*/
this.clipped = hasClipping(spineData);
this.clipOn = true;
this._clipHidden = new Set(); // slot indexes the eyelid fallback hid in its last pass (_eyeMaskFallback)
if (this.clipped) {
const sp = this.spine;
const orig = typeof sp.createGraphics === 'function' ? sp.createGraphics.bind(sp) : null;
@@ -126,6 +162,8 @@ export class SpineActor {
*/
this.clipPerAttack = false;
this.wound = false; // clipPerAttack: wound up for the coming attack (windUp → attack)
this._applied = false; // an update has posed the skeleton since its clip last changed (poseHeld)
this._appliedTint = undefined; // the tint that update put on the slots (pixi-spine applies `tint` in update)
this._play(this._idleName(), true);
}
@@ -134,6 +172,7 @@ export class SpineActor {
on = !!on;
if (!this.clipped || on === this.clipOn) return;
this.clipOn = on;
if (on) this._showClipHidden(); // the stencil takes over from the eyelid fallback
for (const slot of this.spine?.skeleton?.slots || []) {
if (!slot.clippingContainer) continue;
slot.clippingContainer.mask = on ? slot.currentGraphics || null : null;
@@ -200,6 +239,41 @@ export class SpineActor {
else if (this.mode === 'stun' && this.has(this.roles.stun?.loop)) this._play(this.roles.stun.loop, true);
}
/**
* Re-pose on the clip set in force right after an immediate form switch (render/units.js STEALTH_FORMS: 假想敌:骨刺's
* stealth bit; PR #365): a running attack goes on in the new set's attack clip at the same point relative to its
* strike frame (a pending wind-up still strikes at windUntil), a stun / the resting state restarts on the new set's
* clip without a crossfade, and the skeleton is posed at once — also while a freeze holds it (`frozen`).
*/
syncFormPose() {
if (this.dead) return;
if (this.mode === 'attack') {
const track = this.spine.state.tracks[0];
const clip = this._attackClip();
if (!track || !clip) return;
const dur = this.dur(clip.loop);
const oldHit = this._hitTime(this.current, this.dur(this.current));
const hit = this._hitTime(clip.loop, dur);
const start = clampN(track.trackTime + hit - oldHit, 0, dur);
const lead = this.wound && this.windUntil != null
? Math.max(0, this.windUntil - this.clock) : 0;
const ts = lead > 0 ? Math.max(0, hit - start) / lead : track.timeScale;
const tailTs = lead > 0 ? this.windTs : ts;
this._play(clip.loop, track.loop, { start, timeScale: ts, mix: 0 });
this.attackUntil = this.clock + lead +
Math.max(0, dur - (lead > 0 ? hit : start)) / tailTs;
} else if (this.mode === 'stun') {
const name = this.has(this.roles.stun?.loop) ? this.roles.stun.loop : this._baseName();
this._play(name, true, { mix: 0 });
} else if (this.mode === 'base') {
this._play(this._baseName(), true, { mix: 0 });
} else {
return;
}
// Apply the new attachments even when normal updates are frozen.
this.spine.update(0);
}
/** Play a form's transition clip once; attacks and the resting state wait for it (mode 'change'). */
_change(clip) {
if (this.mode !== 'change') this.stunWanted = this.mode === 'stun';
@@ -240,6 +314,7 @@ export class SpineActor {
if (start) e.trackTime = start;
}
this.current = name;
this._applied = false;
return true;
}
@@ -247,6 +322,7 @@ export class SpineActor {
if (!this.has(name)) return false;
const e = this.spine.state.addAnimation(0, name, loop, 0);
if (e) e.timeScale = timeScale;
this._applied = false;
return true;
}
@@ -332,9 +408,13 @@ export class SpineActor {
this.windUntil = null;
this.wound = false;
if (!wasAttacking) {
// not wound up (no look-ahead, e.g. a batch that arrived late): the sim already resolved the hit, so show
// the strike frame now
this._play(clip.loop, !single, { timeScale: ts, start: hit, mix: 0.06 });
// not wound up (no look-ahead, e.g. a batch that arrived late): the sim already resolved the hit, so show the
// strike frame now — unless a clip-per-attack actor's rhythm leaves room for the whole clip (interval ≥ clip):
// then the clip plays complete from its wind-up at its own speed, the strike a little late (重犯's iron ball
// lifts before the slam; #246 by @TsangAsuna, accepted by the owner on 2026-10-07)
const whole = per && this.interval >= loopDur;
if (whole) this.attackUntil = this.clock + loopDur / ts;
this._play(clip.loop, !single, { timeScale: ts, start: whole ? 0 : hit, mix: 0.06 });
} else if (single) {
const e = this.spine.state.tracks[0];
if (e) e.timeScale = ts;
@@ -474,6 +554,24 @@ export class SpineActor {
return 0;
}
/**
* True while an update would draw the same pixels: a frozen model, or a dead one whose only clip — track 0, not
* looping, not mixing — has played out (the held end of a death clip; a skeleton without one holds its idle at
* timeScale 0). Only once an update has posed the current clip and put the current tint on the slots (pixi-spine applies
* `tint` in update: the grey of a knocked-out operator, a hit flash). Never while a form's closing clip or a wind-up is
* pending: those act on the clock.
*/
poseHeld() {
if (!this._applied || this.spine.tint !== this._appliedTint || this.endClip || this.windUntil != null) return false;
if (this.frozen) return true;
if (this.mode !== 'die') return false;
const tracks = this.spine.state.tracks;
for (let i = 1; i < tracks.length; i++) if (tracks[i]) return false;
const e = tracks[0];
if (!e || e.loop || e.mixingFrom) return false;
return e.timeScale === 0 || e.trackTime >= e.animationEnd - e.animationStart;
}
/** Revive (redeploy after death). */
revive() {
this.dead = false;
@@ -546,6 +644,58 @@ export class SpineActor {
if (!this.frozen) {
try { this.spine.update(dt); } catch { /* a broken skeleton must not stop the frame */ }
}
if (this.clipped && !this.clipOn) this._eyeMaskFallback();
this._applied = true;
this._appliedTint = this.spine.tint;
}
/**
* The eyelids without the stencil masks (GitHub #177; PR #384 by @Convey123, re-implemented). On this roster the
* clipping attachments are eyelids: the polygon is the eye's opening and the eyeball / eye-white meshes drawn after
* it are clipped to it, so a closing eye (a blink, a knock-down) is hidden by the mask, not by the art. The masks
* are off on a crowded field or below high quality (render/app.js pickClipping), and the eyeballs showed through
* the closed lids (佩佩, 仇白, 隐德来希, 琳琅诗怀雅 …). While they are off, every slot a clip covers — walked in draw
* order from the clipping slot to its endSlot, with the attachments in force (any skin), as pixi-spine does — is
* hidden for the frame when the centre of what it draws lies outside the clip polygon [ASSUMED: all or nothing per
* slot, an approximation of the mask's cut]. pixi-spine shows every attached slot container again on each update.
*/
_eyeMaskFallback() {
const sp = this.spine, skel = sp && sp.skeleton, boxes = sp && sp.slotContainers;
if (!skel || !Array.isArray(skel.drawOrder) || !boxes) return;
this._showClipHidden(); // undo the last pass (a pixi-spine update re-shows them anyway; a frozen one does not)
try {
const buf = this._clipBuf || (this._clipBuf = { v: new Float32Array(64), poly: new Float32Array(16), c: [0, 0] });
let pn = -1, end = null; // pn: points of the clip in force (−1: none; < 3: degenerate, hides nothing)
for (const slot of skel.drawOrder) {
const att = typeof slot.getAttachment === 'function' ? slot.getAttachment() : slot.attachment;
if (isClip(att)) {
const n = att.worldVerticesLength | 0;
if (buf.poly.length < n) buf.poly = new Float32Array(n);
if (n >= 6) att.computeWorldVertices(slot, 0, n, buf.poly, 0, 2);
pn = n >> 1;
end = att.endSlot && att.endSlot !== slot.data ? att.endSlot : null;
continue;
}
if (pn < 0) continue;
const box = boxes[slot.data.index];
if (pn >= 3 && att && box && slotCentre(slot, att, buf, buf.c) && !pointInPolygon(buf.c[0], buf.c[1], buf.poly, pn)) {
box.visible = false;
this._clipHidden.add(slot.data.index);
}
if (end && slot.data === end) { pn = -1; end = null; }
}
} catch { /* an unknown runtime shape must never stop the frame */ }
}
/** Show the slot containers the fallback hid (those still drawing an attachment). */
_showClipHidden() {
if (!this._clipHidden.size) return;
const slots = this.spine?.skeleton?.slots, boxes = this.spine?.slotContainers;
for (const i of this._clipHidden) {
const slot = slots && slots[i];
if (boxes && boxes[i] && slot && (typeof slot.getAttachment === 'function' ? slot.getAttachment() : slot.attachment)) boxes[i].visible = true;
}
this._clipHidden.clear();
}
/** Model height in skeleton units (setup-pose bounds, else a chibi default). */
+5
View File
@@ -60,9 +60,14 @@ export const COLORS = Object.freeze({
hpBoss: 0xff2d55,
hpGhost: 0xfff0c8,
hpBack: 0x0c0f0e,
// the red bar of a negative-HP pool (斩业星熊's 我执, b.snap `neg`) — the same red as an enemy's HP
hpNeg: 0xff4b3e,
sp: 0x6fd3ff,
spReady: 0xffe066,
spActive: 0xffb347,
// the ammo bar's yellow cells (b.snap `ammo`) and 伺夜's 狼影 pips (b.snap `wolves`)
ammo: 0xffd04a,
wolf: 0xe8f0ff,
shield: 0xdfe8ff,
});
+31
View File
@@ -7,6 +7,7 @@
// * FX atlas (one base texture ⇒ particles batch / fit a ParticleContainer),
// * status icons, tier chips, backdrop gradients,
// * HUD rings: the element discs of the element gauge row and the redeploy countdown ring (hudRings / ringArc),
// and the ring's labels (downLabel: one rasterised Text per label, shared by every ring),
// * avatar-in-rarity-diamond composites (cached per unit asset id) for the Spine fallback.
// Everything procedural is deterministic (seeded noise) and generated lazily on first use. Requires
// globalThis.PIXI and a DOM canvas at call time (never at import time).
@@ -1424,3 +1425,33 @@ export function ringArc(frac) {
if (k === 0 && f > 0) k = 1;
return r.arcs[k];
}
/** Redeploy-ring label style (the seconds left, "DP", "!"); `fill` is set per label. */
export const DOWN_LABEL_STYLE = Object.freeze({ fontFamily: 'Bender, Oxanium, "Noto Sans SC", sans-serif', fontSize: 32, fontWeight: '700', fill: '#ffffff', stroke: '#0b0f0e', strokeThickness: 6 });
const _downLabels = new Map();
/**
* The texture of a redeploy-ring label: one rasterised PIXI.Text per (label, fill, resolution), shared by every ring
* that shows it (render/units.js _updateDownRing draws it as a Sprite, anchor 0.5). A ring's own Text re-rasterised its
* canvas and re-uploaded its texture on every change, once a second per knocked-out operator, though rings mostly show the
* same few numbers; the shared one is drawn once and kept, the same pixels. At most 80 counts (the longest respawn in
* chess.json) + "DP" + "!" per resolution, ≈ 2.5 MB at resolution 2.
*/
export function downLabel(label, fill, resolution = 1) {
const key = `${label}|${fill}|${resolution}`;
let t = _downLabels.get(key);
if (!t) {
t = new (PIXI().Text)(label, { ...DOWN_LABEL_STYLE, fill });
t.resolution = resolution;
t.updateText(false);
_downLabels.set(key, t);
}
return t.texture;
}
/** Redraw the shared ring labels in place (web fonts that landed after some were drawn); their textures stay the same. */
export function refreshDownLabels() {
for (const t of _downLabels.values()) {
try { t.updateText(false); } catch { /* keep the old glyphs */ }
}
}
+306 -32
View File
@@ -67,7 +67,7 @@
import { UF, ANIM } from '../../../shared/constants.js';
import { SpineActor } from './spine.js';
import { diamondTexture, shadowTexture, fxAtlas, tierChip, statusTexture, itemTexture, hudRings, ringArc, HUD_DISC, ELEMENT_RING } from './textures.js';
import { diamondTexture, shadowTexture, fxAtlas, tierChip, statusTexture, itemTexture, hudRings, ringArc, downLabel, HUD_DISC, ELEMENT_RING } from './textures.js';
import { COLORS, TIER_COLORS, ENEMY_FRAME, UNIT, PROJ, statusIconKey, statusIconSuppressed } from './style.js';
import { drawCrate, rowDepthKey, ROW_KEY, deviceBoxOf, DEVICE_BOX } from './tiles.js';
@@ -110,6 +110,16 @@ export function dieClipDur(entry) {
const d = Object.hasOwn(durs, name) ? Number(durs[name]) : NaN;
return Number.isFinite(d) && d > 0 ? d : 0;
}
/**
* render/app.js syncBattle's step for one view and its interpolated sample `s` at render time `t`: a living view (or a
* device) takes the whole sample; a dying one only the position — its DIE window in the snapshots; one shown alive
* again (redeployed without an event) stands up first.
*/
export function syncView(v, s, t) {
if (v.alive || v.info?.kind === 'device') v.sync(s, t);
else if (v.dying > 0) { if (typeof v.followSample === 'function') v.followSample(s, t); else { v.x = s.x; v.y = s.y; } }
else if (s.anim !== ANIM.DIE && s.hp > 0) { v.revive?.(); v.sync(s, t); }
}
/** World step (x = col, y = row) of a direction. */
export const DIR_STEP = Object.freeze({ UP: [0, 1], RIGHT: [1, 0], DOWN: [0, -1], LEFT: [-1, 0] });
const nowMs = () => (globalThis.performance ? globalThis.performance.now() : Date.now());
@@ -117,6 +127,12 @@ const nowMs = () => (globalThis.performance ? globalThis.performance.now() : Dat
* 无头像; data/assets.json has no avatar or model for it, so the token fallback showed 圣聆初雪's own face) — the marker of a
* frozen gate. */
const ICE_TOKENS = new Set(['token_10058_sbell2_icetgt']);
/**
* Units that never show an HP bar: 琳琅诗怀雅's 香槟炸弹 — PRTS 香槟炸弹 备注 "即使自身生命值未满,模型下方也不会显示生命值槽"
* (the owner's report of 2026-10-08, 「香槟会掉血,会被治疗」: the bar showed every change). Only the bar: its HP, the
* damage it takes (活性源石 hurts what stands on it) and healing are unchanged — no source makes it invulnerable.
*/
const NO_HP_BAR = new Set(['token_10031_swire2_gdtrap']);
const ICE_FRAME = 0x9fe6ff;
/** How long a view waits for its avatar before showing the image-less placeholder diamond. */
const PIC_WAIT_MS = 400;
@@ -161,6 +177,34 @@ const RAISED_Z = 0.12;
* ride the body (`hover`); range highlights are tiles.
*/
export const FLY_HOVER = 1.3;
/**
* A push / pull slide (推拉, PR #380 by @xcdoge). The sim displaces at once — server/sim/battle/displacement.js walks its
* 0.1-tile steps inside one call — and then holds the enemy in its 失衡 (UNBALANCE) state (PR #392's idea): a push for the
* 位移时间 of its 受力等级 (PRTS 游戏数据基础 推力-位移近似对应表: 0.2 … 35/30 game s), a pull for its force window (推与拉:
* 0.5 / 1 s). The snapshots only ever show the destination, so the view slides there under a constant deceleration
* (UnitView.slideTo) for that same span: the `displace` fx carries it (`dur`, game seconds) and the view divides it by the
* playback rate (ctx.animRate — 2 game s per real second by default: a 0.8 s push slides 0.4 real s) — it lands as the
* sim's state ends. v0 = 2D/T, a = v0/T then run the distance in exactly T. DISPLACE_SLIDE (real s per √tile, kept in
* 0.12–0.45 s) is only the fallback for an fx without `dur` (a recording made before 0.2.2) [ASSUMED].
*/
export const DISPLACE_SLIDE = 0.14;
/**
* Terrain friction factor of a slide: 1 = normal land, < 1 = slippery (ICE_LAND) slides longer. Every tile of this mode
* is normal land (its 11 maps have no ice or slime), so it is always 1.
*/
export const SLIDE_FRICTION = 1;
/**
* Seconds of the death fade that follows the Die clip in `dying` (die(): the clip, then this tail). A flying unit keeps
* its lift for the whole clip and drops only inside this tail (PR #380: the official client removes its fly offset in
* `CharacterAnimator.OnFinish`, when the finish state ends — docs/research/14-death-animations.md).
*/
const DIE_FADE_TAIL = 0.55;
/**
* Longest Die clip a death plays out (s): the longest enemy Die clip of data/assets.json is 盐风主教昆图斯's 7.97 s (234
* enemy Die clips; the old 1.6 s cap cut 25 of them short — the bosses' most), and the official client plays its clip
* out (PR #380). It only bounds a view whose data lies.
*/
export const DIE_CLIP_MAX = 8;
/** b.snap `down` entry states (server/sim/constants.js DOWN_STATE). */
export const DOWN_STATE = Object.freeze({ COUNTING: 0, WAIT_DP: 1, WAIT_TILE: 2 });
@@ -184,12 +228,26 @@ export const ALIAS_TINT = Object.freeze({ enemy_1305_mhslim: 0xffc48a, enemy_130
* between `pulse` and 1 at `pulseHz` (real time).
*/
export const EL_BAR = Object.freeze({ icon: 0.15, min: 8, max: 15, enemy: 0.8, gap: 1, pulse: 0.45, pulseHz: 1.5 });
/**
* The HP-bar readouts under the bars (b.snap `ammo`, `wolves`, `neg`; render/interp.js header):
* - AMMO_BAR — the ammo skill's segmented bar replaces the SP bar: a cell per round, `height` of the HP bar's thickness
* (the SP bar's is 0.6), cells drawn only while they are at least `minCell` px wide;
* - WOLF_PIPS — 伺夜's 狼影 count as `size` (tiles) wide diamonds, clamped to `min`…`max` px, `gap` px apart, `pad` px
* below the rows above; a lit diamond is a 狼影 left, a dim one a spent slot (`dim` alpha).
* [ASSUMED] the look: the count is data (skill_table `attack@trigger_time` 14 for 隐现's S2; the talent text 「至多3只」), the
* official HUD art of neither is in the game data.
*/
export const AMMO_BAR = Object.freeze({ height: 0.85, minCell: 2 });
export const WOLF_PIPS = Object.freeze({ size: 0.1, min: 5, max: 9, gap: 2, pad: 1.5, dim: 0.25 });
/**
* Enemy modes drawn with another clip set of the same skeleton (the `form` of a sim fx — shared/protocol.js fxForm —
* → UnitView.setForm), per Spine id; the mode's roles override the manifest's (data/assets.json anims), `change` plays
* once first:
* - 掠海漂移体 (PRTS: 受晕眩/沉睡/冻结影响后进入爬行模式 — for good) crawls on its *_02 clips after 'Change';
* - 假想敌:骨刺 (GitHub #296, PR #365): not an fx — its form follows the snapshot's stealth bit (STEALTH_FORMS): the
* manifest's *_A (the three-headed snake) while its 隐匿 is on, 'revealed' *_B the moment it is blocked or revealed, *_A
* again when the 隐匿 is back; no change clip, a running attack / stun pose carried over (SpineActor.syncFormPose);
* - 暴鸰 flies on its bomb-less *_2 clips once its one bomb left (the official prefab's mode S1: Move→Move_2, Idle→Idle_2,
* Die→Die_2; user feedback after 0.1.0, D4: the bomb used to stay under the drone — no change clip, the drop is its
* Attack clip);
@@ -224,6 +282,12 @@ export const EL_BAR = Object.freeze({ icon: 0.15, min: 8, max: 15, enemy: 0.8, g
* built for the knock-out — and one rebuilt while it is down — the 替身's death clip; it stands up as the 本体.
* A kind without a clip set of this skeleton (barriers, charges, …) changes nothing. 吉兆飞鳞's 晕眩模式 is its Stun clip.
*/
/**
* Enemy forms that follow the snapshot's stealth bit (UF.STEALTH: the sim sends it only while the enemy's 隐匿 is on —
* not blocked, not revealed) instead of a sim fx, per Spine id: the FORMS kind drawn while the bit is OFF; the manifest's
* clips while it is on. Switched at once (UnitView.sync, and for a model loaded later the load callback).
*/
export const STEALTH_FORMS = Object.freeze({ enemy_9008_acbunn: 'revealed' });
const loop = (name, via = null) => Object.freeze(via ? { begin: null, loop: name, end: null, via } : { begin: null, loop: name, end: null });
const clipSet = (idle, move, die, attack = null) => Object.freeze({
idle, deploy: idle, die, move: loop(move),
@@ -274,6 +338,9 @@ export const FORMS = Object.freeze({
enemy_2025_syufo: Object.freeze({
crawl: Object.freeze({ change: 'Change', roles: clipSet('Idle_02', 'Move_02', 'Die_02', 'Attack_02') }),
}),
enemy_9008_acbunn: Object.freeze({
revealed: Object.freeze({ change: null, roles: clipSet('Idle_B', 'Move_B', 'Die_B', 'Attack_B') }),
}),
enemy_10081_mpplai: Object.freeze({
translator_fuchou: Object.freeze({ change: 'A_Die_B', roles: clipSet('B_Idle', 'B_Move', 'B_Die', 'B_Attack') }),
translator_youling: Object.freeze({ change: 'A_Die_C', roles: clipSet('C_Idle', 'C_Move', 'C_Die') }),
@@ -393,6 +460,7 @@ export class UnitView {
this.tier = clamp(Number(info.tier) || 1, 1, 6);
this.x = Number(info.x) || 0; this.y = Number(info.y) || 0; this.z = 0;
this.zTarget = null; // battle: standing height the feet ease towards (tile top under the unit; 0 for enemies)
this.slide = null; // a push / pull slide (slideTo): held, then easing into the sim's position (update)
/** @type {number|null} */
this.shadowZ = null; // an enemy flyer's shadow height (the tile top under it), else null: the shadow is at z
/** @type {number|null} */
@@ -407,6 +475,11 @@ export class UnitView {
this.visFacing = this.isEnemy ? -1 : this.facing;
this.hp = Number(info.maxHp) || 1; this.maxHp = Number(info.maxHp) || 1; this.ghostHp = this.hp;
this.sp = 0; this.spMax = 0;
this.ammo = null; // [rounds left, rounds in the magazine] while an ammo skill runs (sync; b.snap `ammo`)
this.wolves = null; // [狼影 left, the talent's maximum] of a 狼群 (sync; b.snap `wolves`)
this.coins = null; // [balance, cap] of 琳琅诗怀雅's skill purse, including zero
this.coinHud = null;
this.neg = 0; // the share of its cap a negative-HP pool holds, 0 = none (sync; b.snap `neg`, 斩业星熊's 我执)
this.flags = 0; this.anim = ANIM.IDLE;
this.statuses = new Set();
this.alive = true;
@@ -417,7 +490,9 @@ export class UnitView {
this.gameT = 0; // battle game time of the frame (render clock), for the redeploy ring's countdown and the element bar's refill
this.el = null; this.elFill = 0; this.elUntil = 0; this.elDur = 0; // shown element gauge (sync)
this._elBar = null; // { root, disc, bg, fill } sprites of the element gauge row, built on first use
this._downRing = null; // { disc, track, arc, text } sprites, built on first use
this._wolfPips = null; // { root, back[], lit[] } diamonds of the 狼影 row, built on first use
this.ammoCuts = []; // the thin separators between the ammo bar's cells (_updateAmmoCuts)
this._downRing = null; // { disc, track, arc, text } sprites (text: the shared label, textures.js downLabel), built on first use
this.alpha = 1; this.fadeIn = this.prep ? 1 : 0;
this.lunge = 0; this.lungeDir = { x: 1, y: 0 };
this.flash = 0;
@@ -457,6 +532,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._deployPending = false; // deploy() was requested before the Spine model finished loading
this._spineTries = 0; // failed loads since the last model (SPINE_RETRY_MS)
@@ -578,7 +654,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;
@@ -657,6 +734,8 @@ export class UnitView {
this.hpBg = bar(P, h, COLORS.hpBack, 0.85);
this.hpGhost = bar(P, h, COLORS.hpGhost, 0.9);
this.hpFill = bar(P, h, this.isEnemy ? (this.isBoss ? COLORS.hpBoss : COLORS.hpEnemy) : COLORS.hpAlly);
// a negative-HP pool (斩业星熊's 我执), drawn over the drained HP bar as the red bar
this.negFill = bar(P, h, COLORS.hpNeg);
this.shieldBar = bar(P, h, COLORS.shield, 0.95);
this.spBg = bar(P, h, COLORS.hpBack, 0.85);
this.spFill = bar(P, h, COLORS.sp);
@@ -666,7 +745,7 @@ export class UnitView {
this.spGlow.blendMode = P.BLEND_MODES.ADD;
h.addChild(this.spGlow);
// nothing shows until the first HUD update decides (a culled or prep view never draws bars)
for (const b of [this.hpBg, this.hpGhost, this.hpFill, this.shieldBar, this.spBg, this.spFill, this.spGlow]) b.visible = false;
for (const b of [this.hpBg, this.hpGhost, this.hpFill, this.negFill, this.shieldBar, this.spBg, this.spFill, this.spGlow]) b.visible = false;
this.chip = null;
// operators only: summon tokens have no tier (hand and field alike)
if (!this.isEnemy && !this.isToken && this.info.kind !== 'device') {
@@ -734,13 +813,14 @@ export class UnitView {
// the element gauge shown (b.snap `elem`): element, fill 0..1, cooldown end (game s) and length
this.el = typeof s.el === 'string' ? s.el : null;
this.elFill = this.el ? s.elFill || 0 : 0; this.elUntil = this.el ? s.elUntil || 0 : 0; this.elDur = this.el ? s.elDur || 0 : 0;
this.x = s.x; this.y = s.y;
this.followSample(s, t);
this.flying = !!(s.flags & UF.FLYING) || this.info.motion === 'FLY';
// enemies keep to the road plane: ground enemies only ever walk low tiles (a rounding step onto a block edge must not
// pop them up), and a flyer hovers FLY_HOVER above the road whatever tile it crosses — the official lift is one
// constant over the route (docs/research/12), so a block under it is no step (GitHub #277: a flyer passing over one
// high-ground / forbidden block rose and dropped like stairs). Its shadow still lies on the tile top under it.
const floor = groundZ(this.ctx, s.x, s.y);
// While sliding, the floor is read under the view's own (mid-flight) position, not the destination's.
const floor = this.slide ? groundZ(this.ctx, this.x, this.y) : groundZ(this.ctx, s.x, s.y);
const gz = this.isEnemy ? 0 : floor;
if (this.zTarget == null) this.z = gz;
this.zTarget = gz;
@@ -752,10 +832,23 @@ export class UnitView {
if (hp < this.hp - 0.5 && this.isBoss) this.shake = 0.25;
this.hp = hp;
this.sp = s.sp; this.spMax = s.spMax;
this.ammo = s.ammo || null; this.wolves = s.wolves || null; this.neg = s.neg > 0 ? Math.min(1, s.neg) : 0;
this.coins = s.coins || null;
const prevFlags = this.flags;
this.flags = s.flags | 0;
this.anim = s.anim | 0;
if (this.isEnemy && Math.abs(s.vx) > 0.08) this.visFacing = s.vx < 0 ? -1 : 1;
// a stealth-driven form (STEALTH_FORMS: 假想敌:骨刺) follows this snapshot's stealth bit at once — before a DIE in the
// same snapshot, so it dies in the form it had
const stealthForm = this.isEnemy && this.alive ? STEALTH_FORMS[this.info.spine || this.info.defId] : null;
if (stealthForm) {
const form = this.flags & UF.STEALTH ? null : stealthForm;
if (form !== this.form) {
this.setForm(form);
this.actor?.syncFormPose();
if (this.imp) this.imp.dirty = true;
}
}
if (this.isEnemy && !this.slide?.keepFacing && Math.abs(s.vx) > 0.08) this.visFacing = s.vx < 0 ? -1 : 1;
if ((prevFlags ^ this.flags) & UF.SKILL) this.setSkill(!!(this.flags & UF.SKILL));
if (this.anim === ANIM.DIE && this.alive) this.die();
if (this.actor && this.alive) this.actor.setBase(this._baseFromAnim());
@@ -763,6 +856,52 @@ export class UnitView {
setWorld(x, y, z = 0) { this.x = x; this.y = y; this.z = z; }
/**
* The sampled position: the view's — unless a push / pull slide owns it (slideTo), which only anchors where it eases
* to on it, from the snapshot that carries the destination on (`at`; before that the sample is still sliding or
* snapping there). sync() starts with it; a dying view gets nothing else (syncView: the DIE window of the snapshots),
* so a unit killed on the step of its push still lands where the sim put the body (Grok's review of fb7-render).
*/
followSample(s, t) {
const sl = this.slide;
if (!sl) { this.x = s.x; this.y = s.y; }
else if (!(t < sl.at)) { sl.lx = s.x; sl.ly = s.y; sl.live = true; }
}
/**
* Slide to a displacement's destination (推拉, the `displace` fx at (x, y)) instead of appearing there: `opts.dur` = the
* fx's 失衡 time in game seconds (real seconds = dur / ctx.animRate), else the DISPLACE_SLIDE·√D fallback.
* `at` (game s): the time of the snapshot that carries the destination — render/app.js starts the slide as soon as the
* interpolator shows the interval before it, and until the render clock reaches `at` the view holds where it stood
* (the sample in between is a lerp or a snap towards the destination). From then on it eases from there into the
* sampled position — the destination, and the walk the enemy resumes from it —, so it lands without a jump. Without
* `at` the destination counts as shown. A unit that dies on the way finishes the slide: its body lies where the push
* put it (the sim's position); a dead one is placed there, a knocked-down one is left to setDown.
*/
slideTo(x, y, opts = {}) {
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
// already on the way there: app.js starts the slide ahead of the event, then the event itself asks again
if (this.slide && this.slide.x1 === x && this.slide.y1 === y) return;
if (this.down) { this.slide = null; return; }
const dx = x - this.x, dy = y - this.y;
const D = Math.hypot(dx, dy);
if (!this.alive || !(D > 0.05)) { this.x = x; this.y = y; this.slide = null; return; }
const f = Number.isFinite(opts.friction) && opts.friction > 0 ? opts.friction : SLIDE_FRICTION;
// the sim's 失衡 time (game s) at the playback rate; a slippery tile (f < 1) slides longer
const rate = this.ctx.animRate?.() || 1;
const T = Math.max(0.05, (opts.dur > 0 ? opts.dur / rate : clamp(DISPLACE_SLIDE * Math.sqrt(D), 0.12, 0.45)) / f);
// v0 = 2D/T and a = v0/T run the distance in exactly T with the velocity reaching 0 there, integrated in update().
// Push and pull fx carry keepFacing by default: the victim retains its pre-hit facing through the slide. Raw
// displacements have no flag and still face their travel; snapshots take over when the slide ends.
if (this.isEnemy && !opts.keepFacing) this.visFacing = dx < 0 ? -1 : 1;
const v0 = 2 * D / T;
const at = Number.isFinite(opts.at) ? opts.at : -Infinity;
this.slide = {
x1: x, y1: y, sx: this.x, sy: this.y, ux: dx / D, uy: dy / D, D, done: 0, v: v0, a: v0 / T, t: 0, dur: T,
at, live: at === -Infinity, lx: x, ly: y, ox: 0, oy: 0, run: false, wait: 0, keepFacing: opts.keepFacing === true,
};
}
setFacing(f) {
if (this.isEnemy) { this.facing = f === -1 ? -1 : 1; return; }
// the legacy ±1 only speaks about left / right: an UP / DOWN operator keeps its direction
@@ -833,6 +972,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) {
@@ -930,6 +1070,7 @@ export class UnitView {
*/
die(instant = false) {
if (!this.alive) return;
this._pendingDeployElapsed = null;
this.alive = false;
this._modelDirty = true;
this._dieForm = this._formSpec();
@@ -937,7 +1078,7 @@ export class UnitView {
if (this.actor && !d) d = this._fallDur();
const rate = this.ctx.animRate?.() || 1;
this.dieT = 0;
this.dying = clamp(d / rate, 0.35, 1.6) + 0.55;
this.dying = clamp(d / rate, 0.35, DIE_CLIP_MAX) + DIE_FADE_TAIL;
this.dieDur = this.dying;
if (instant) { this.dieT = 30; if (this.actor) this.actor.update(30); }
}
@@ -976,7 +1117,7 @@ export class UnitView {
if (!d) {
if (!this.down) return;
this.down = null;
if (!this.alive) { this.dying = 0.55; this.dieDur = 0.55; }
if (!this.alive) { this.dying = DIE_FADE_TAIL; this.dieDur = DIE_FADE_TAIL; }
return;
}
if (this.alive) this.die(instant);
@@ -997,6 +1138,33 @@ 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) {
const sl = this.slide;
if (this.down) this.slide = null; // a knocked-down view lies on its tile (setDown)
else if (!sl.live) {
this.x = sl.sx; this.y = sl.sy;
// the destination's snapshot never shown (the unit left the snapshots, a stalled stream): it slides to the
// destination the fx named
if ((sl.wait += dt) > 0.5) sl.live = true;
} else {
if (!sl.run) { sl.run = true; sl.ox = sl.sx - sl.lx; sl.oy = sl.sy - sl.ly; }
const step = Math.min(dt, 0.1); // a hidden tab's catch-up frame must not overshoot
const v0 = sl.v;
sl.v = Math.max(0, sl.v - sl.a * step); // friction: the velocity decays every frame
// the step's mean velocity — exact under a constant deceleration (the end-of-step speed fell ~D·dt/T short:
// 9 % of a 0.18 s slide at 60 fps, then a jump onto the end)
sl.done = Math.min(sl.D, sl.done + (v0 + sl.v) / 2 * step);
sl.t += step;
if (this.isEnemy && !sl.keepFacing) this.visFacing = sl.ux < 0 ? -1 : 1;
const k = 1 - sl.done / sl.D; // the share of the way still to go
this.x = sl.lx + sl.ox * k; this.y = sl.ly + sl.oy * k;
// the clock or the spent velocity lands it on the sampled position
if (sl.t >= sl.dur - 1e-9 || sl.done >= sl.D - 1e-9 || sl.v <= 1e-9) { this.x = sl.lx; this.y = sl.ly; this.slide = null; }
}
}
const P = this.P;
// the model for the state the frame's events and snapshot left (Front ⇄ Back when it went down / stood up: die, revive)
if (this._modelDirty) { this._modelDirty = false; this._syncModel(); }
@@ -1015,7 +1183,9 @@ export class UnitView {
const d = this.shadowZTarget - this.shadowZ;
this.shadowZ = Math.abs(d) < 1e-3 ? this.shadowZTarget : this.shadowZ + d * Math.min(1, dt * 12);
}
const hoverTo = this.flying && this.alive ? FLY_HOVER : 0;
// a flyer keeps its lift through the Die clip and drops only as it fades out: the client removes the fly offset in
// CharacterAnimator.OnFinish, when the finish state ends; a knocked-down flyer lies on the ground
const hoverTo = this.flying && (this.alive || (this.dying > DIE_FADE_TAIL && !this.down)) ? FLY_HOVER : 0;
if (this.hover !== hoverTo) this.hover = Math.abs(hoverTo - this.hover) < 1e-3 ? hoverTo : this.hover + (hoverTo - this.hover) * Math.min(1, dt * 6);
const p = cam.project(this.x, this.y, this.z + this.hover + this.lift, this.screen);
const s = p.s;
@@ -1029,7 +1199,7 @@ export class UnitView {
} else if (this.dying > 0) {
this.dieT += dt;
this.dying -= dt;
const tail = 0.55;
const tail = DIE_FADE_TAIL;
if (this.dying < tail) alpha *= Math.max(0, this.dying / tail);
if (this.dying <= 0) { this.dying = 0; this.remove = true; alpha = 0; }
}
@@ -1083,18 +1253,26 @@ export class UnitView {
this._far = this._far ? s < 60 : s < 52;
if (lvl >= 2 || this._far) interval = Math.max(interval, 2);
}
let clipFlip = false;
if (this.actor.clipped) {
const clip = this.ctx.clipAllowed ? this.ctx.clipAllowed() : true;
const was = this.actor.clipOn;
this.actor.setClipping(clip);
clipFlip = was !== this.actor.clipOn;
if (clip && this.ctx.impostors) interval = Math.max(1, interval);
}
// a pose that cannot change (a knocked-out operator lying in its held Die pose, a frozen model) is neither re-posed
// nor redrawn — it would draw the same pixels again; the actor's clock still runs
const held = !clipFlip && this.actor.poseHeld();
if (interval > 0 && this.ctx.renderer) {
this._updateImpostor(sc, flip, tint, animDt, interval);
if (clipFlip && this.imp) this.imp.dirty = true; // masks on / off: the slot is redrawn either way
this._updateImpostor(sc, flip, tint, animDt, interval, held);
} else {
if (this.imp) this._leaveImpostor();
this.actor.spine.alpha = this.swapT;
this.actor.spine.scale.set(sc * flip, sc * this.modelKY);
this.actor.update(animDt);
if (held) this.actor.clock += animDt;
else this.actor.update(animDt);
if (this._tint !== tint) { this._tint = tint; this.actor.spine.tint = tint; }
}
}
@@ -1181,7 +1359,7 @@ export class UnitView {
const prep = this.prep;
const showBars = !prep && this.alive && this.info.kind !== 'item';
const damaged = this.hp < this.maxHp - 0.5;
const showHp = showBars && (!this.isEnemy || damaged || this.isBoss);
const showHp = showBars && (!this.isEnemy || damaged || this.isBoss) && !NO_HP_BAR.has(this.info.defId);
const bw = clamp(s * (this.isBoss ? UNIT.bossBarWidth : UNIT.barWidth), 24, this.isBoss ? 260 : 96);
const bh = clamp(s * (this.isBoss ? 0.12 : 0.075), 3, this.isBoss ? 12 : 7);
// a knocked-down operator's HUD is its redeploy ring alone, drawn at full strength over the greyed model
@@ -1202,24 +1380,32 @@ export class UnitView {
this.hpFill.position.set(x0, cy); this.hpFill.width = bw * k; this.hpFill.height = bh;
if (!this.isEnemy) this.hpFill.tint = k < 0.3 ? COLORS.hpAllyLow : COLORS.hpAlly;
}
// 业火 我执: her HP sits on the 1-HP floor and the damage past it fills a pool — the red bar over the drained green one
const neg = showHp ? this.neg : 0;
this.negFill.visible = neg > 0;
if (neg > 0) { this.negFill.position.set(x0, cy); this.negFill.width = bw * neg; this.negFill.height = bh; }
const shielded = showHp && (this.flags & UF.SHIELD);
this.shieldBar.visible = !!shielded;
if (shielded) { this.shieldBar.position.set(x0, cy - bh / 2 - 1); this.shieldBar.width = bw; this.shieldBar.height = Math.max(1.5, bh * 0.35); }
// SP
const showSp = showBars && !this.isEnemy && this.spMax > 0;
// SP — or, while an ammo skill runs, its magazine: yellow cells, one per round, emptying from the right (b.snap `ammo`;
// the SP bar's own fraction, ammoLeft / ammoMax, is the same width). Without an `ammo` row the bar is the plain SP bar.
const ammo = !this.isEnemy ? this.ammo : null;
const showSp = showBars && !this.isEnemy && (this.spMax > 0 || !!ammo);
this.spBg.visible = this.spFill.visible = showSp;
const spH = Math.max(2, bh * 0.6);
const spH = Math.max(2, bh * (ammo ? AMMO_BAR.height : 0.6));
let ready = false;
let sy = 0;
if (showSp) {
const active = !!(this.flags & UF.SKILL);
const k = clamp(this.sp / this.spMax, 0, 1);
const k = ammo ? ammo[0] / ammo[1] : clamp(this.sp / this.spMax, 0, 1);
ready = !active && k >= 0.999;
const sy = cy + bh / 2 + spH / 2 + 1.5;
sy = cy + bh / 2 + spH / 2 + 1.5;
this.spBg.position.set(x0 - 1, sy); this.spBg.width = bw + 2; this.spBg.height = spH + 2;
this.spFill.position.set(x0, sy); this.spFill.width = bw * k; this.spFill.height = spH;
this.spFill.tint = active ? COLORS.spActive : ready ? COLORS.spReady : COLORS.sp;
this.spFill.tint = ammo ? COLORS.ammo : active ? COLORS.spActive : ready ? COLORS.spReady : COLORS.sp;
this._spY = sy;
}
this._updateAmmoCuts(showSp && !!ammo, ammo, x0, sy, bw, spH);
this.spGlow.visible = ready;
if (ready) {
const pulse = 0.55 + 0.35 * Math.sin(t * 6);
@@ -1250,8 +1436,12 @@ export class UnitView {
ic.width = ic.height = isz;
ic.position.set(x - ((icons.length - 1) * (isz + 2)) / 2 + i * (isz + 2), iy);
}
// element gauge row under the bars (b.snap `elem`); redeploy ring above a knocked-down operator (b.snap `down`)
this._updateElementBar(showBars && !!this.el, x0, bw, cy + bh / 2 + (showSp ? spH + 1.5 : 0) + 1, spH, s, t);
// 狼影 pips under the bars (b.snap `wolves`), then the element gauge row under them (b.snap `elem`); redeploy ring above a
// knocked-down operator (b.snap `down`)
const rowsTop = cy + bh / 2 + (showSp ? spH + 1.5 : 0);
const wolfH = this._updateWolfPips(showBars && !this.isEnemy && !!this.wolves, x0, rowsTop, s);
const coinH = this._updateCoins(showBars && !this.isEnemy && !!this.coins, x0, rowsTop + wolfH, s);
this._updateElementBar(showBars && !!this.el, x0, bw, rowsTop + wolfH + coinH + 1, spH, s, t);
this._updateDownRing(!prep && !!this.down && !this.alive, x, this.screen.y - DOWN_LOOK.height * s, s, t);
// blocked marker at the feet (enemies held by a blocker)
const blocked = !prep && this.alive && this.isEnemy && (this.flags & UF.BLOCKED);
@@ -1276,6 +1466,82 @@ export class UnitView {
}
}
/**
* The thin dark separators between the ammo bar's cells (see _updateHud): one at every round boundary of the `n`-round
* magazine across the bar `x0` … `x0 + bw` at height `y`, `h` tall; none while a cell would be under AMMO_BAR.minCell px
* (the fill keeps its exact width). Sprites are built as the magazines need them and hidden when not in use.
*/
_updateAmmoCuts(show, ammo, x0, y, bw, h) {
const cuts = this.ammoCuts;
const n = show ? ammo[1] : 0;
const step = n > 1 ? bw / n : 0;
const want = step >= AMMO_BAR.minCell ? n - 1 : 0;
while (cuts.length < want) cuts.push(bar(this.P, this.hud, COLORS.hpBack, 0.9));
for (let i = 0; i < cuts.length; i++) {
const c = cuts[i];
c.visible = i < want;
if (c.visible) { c.position.set(x0 + (i + 1) * step - 0.5, y); c.width = 1; c.height = h; }
}
}
/**
* 伺夜's 狼群 count (b.snap `wolves`, [left, maximum]): a row of diamonds from `x0`, its top at `top`, one per 狼影 the
* talent allows, the first `left` of them lit. Built on the first count, hidden without one. Returns the height the row
* takes (0 while hidden) so the element gauge row can sit below it.
*/
_updateWolfPips(show, x0, top, s) {
let r = this._wolfPips;
if (!show) { if (r) r.root.visible = false; return 0; }
const P = this.P;
const [left, max] = this.wolves;
if (!r) {
r = this._wolfPips = { root: new P.Container(), back: [], lit: [] };
this.hud.addChild(r.root);
}
const mk = (list, color, alpha) => {
const d = new P.Sprite(white());
d.anchor.set(0.5); d.rotation = Math.PI / 4; d.tint = color; d.alpha = alpha;
r.root.addChild(d);
list.push(d);
};
while (r.lit.length < max) { mk(r.back, COLORS.hpBack, 0.85); mk(r.lit, COLORS.wolf, 1); }
const d = clamp(s * WOLF_PIPS.size, WOLF_PIPS.min, WOLF_PIPS.max); // a diamond's diagonal
const side = d / Math.SQRT2;
const cy = top + WOLF_PIPS.pad + d / 2 + 1;
for (let i = 0; i < r.lit.length; i++) {
const on = i < max;
const b = r.back[i], l = r.lit[i];
b.visible = l.visible = on;
if (!on) continue;
const cx = x0 + d / 2 + 1 + i * (d + 2 + WOLF_PIPS.gap);
b.position.set(cx, cy); b.width = b.height = side + 2;
l.position.set(cx, cy); l.width = l.height = side;
l.alpha = i < left ? 1 : WOLF_PIPS.dim;
}
r.root.visible = true;
return d + 2 + WOLF_PIPS.pad;
}
/** Coin icon and balance/cap, read from the current snapshot rather than transient FX. */
_updateCoins(show, x, top, s) {
let r = this.coinHud;
if (!show) { if (r) r.root.visible = false; return 0; }
if (!r) {
const P = this.P, root = new P.Container(), icon = new P.Sprite(fxAtlas().tex.coin);
icon.width = icon.height = 16;
const text = new P.Text('', { fontFamily: 'Bender, Oxanium, sans-serif', fontSize: 16, fontWeight: '700', fill: '#ffd04a', stroke: '#0b0f0e', strokeThickness: 3 });
text.position.set(19, -2); root.addChild(icon, text); this.hud.addChild(root);
r = this.coinHud = { root, text, label: '' };
}
const label = `${this.coins[0]}/${this.coins[1]}`;
if (r.label !== label) { r.label = label; r.text.text = label; }
const size = clamp(s * 0.2, 10, 16);
r.root.scale.set(size / 16); r.root.position.set(x, top + 3); r.root.visible = true;
// Text includes font metrics and stroke beyond its nominal 16 px size; reserve the measured row so the next
// elemental gauge cannot overlap it (especially with a substituted browser font).
return Math.max(16, r.root.getLocalBounds?.().height || 24) * (size / 16) + 8;
}
/** True while the shown gauge is in its 爆发冷却 (b.snap `elem` carries the cooldown's end and length). */
elementCooling() {
return !!this.el && this.elDur > 0 && this.elUntil > 0;
@@ -1345,10 +1611,8 @@ export class UnitView {
const root = new P.Container();
const mk = (tx) => { const sp = new P.Sprite(tx); sp.anchor.set(0.5); root.addChild(sp); return sp; };
const disc = mk(tex.downDisc), track = mk(tex.track), arc = mk(tex.arcs[0]);
const text = new P.Text('', { fontFamily: 'Bender, Oxanium, "Noto Sans SC", sans-serif', fontSize: 32, fontWeight: '700', fill: '#ffffff', stroke: '#0b0f0e', strokeThickness: 6 });
text.anchor.set(0.5);
root.addChild(text);
r = this._downRing = { root, disc, track, arc, text, label: null };
const text = mk(P.Texture.EMPTY);
r = this._downRing = { root, disc, track, arc, text, label: null, res: 0 };
this.hud.addChild(root);
}
const dn = this.down;
@@ -1358,10 +1622,11 @@ export class UnitView {
const color = DOWN_LOOK.ring[dn.state] ?? DOWN_LOOK.ring[DOWN_STATE.COUNTING];
r.arc.tint = color;
const label = counting ? String(Math.ceil(left - 1e-6)) : dn.state === DOWN_STATE.WAIT_DP ? 'DP' : '!';
if (r.label !== label) {
const res = this.ctx.renderer?.resolution || 1;
if (r.label !== label || r.res !== res) {
r.label = label;
r.text.text = label;
r.text.style.fill = counting ? '#ffffff' : '#' + color.toString(16).padStart(6, '0');
r.res = res;
r.text.texture = downLabel(label, counting ? '#ffffff' : '#' + color.toString(16).padStart(6, '0'), res);
}
const d = clamp(s * DOWN_LOOK.size, 22, 52);
const k = d / tex.size;
@@ -1391,7 +1656,7 @@ export class UnitView {
return this._box;
}
_updateImpostor(sc, flip, tint, animDt, interval) {
_updateImpostor(sc, flip, tint, animDt, interval, held = false) {
const P = this.P;
const atlas = this.ctx.impostors || null;
if (!this.imp) {
@@ -1399,16 +1664,25 @@ export class UnitView {
if (this.actor.spine.parent) this.actor.spine.parent.removeChild(this.actor.spine);
if (atlas) atlas.park(this.actor.spine);
this.body.addChild(sprite);
this.imp = { sprite, slot: null, rt: null, sc: 0, acc: 0, phase: (Math.random() * 64) | 0, dirty: true };
this.imp = { sprite, slot: null, rt: null, sc: 0, acc: 0, phase: (Math.random() * 64) | 0, dirty: true, last: 0 };
}
const imp = this.imp;
imp.acc += animDt;
const frame = this.ctx.frameNo ? this.ctx.frameNo() : 0;
const due = imp.dirty || interval <= 1 || (frame + imp.phase) % interval === 0 || Math.abs(sc - imp.sc) > imp.sc * 0.12;
// the view's slot in this frame's update order (app.js impostorSlot) spreads the refreshes evenly over the interval;
// a context without slots keeps the random phase. A unit whose slot keeps moving with the frame (the units before it
// culled on and off in step) is still refreshed after 2 intervals at the latest.
const turn = this.ctx.impostorSlot ? this.ctx.impostorSlot() : imp.phase;
const rescale = Math.abs(sc - imp.sc) > imp.sc * 0.12;
// a held pose (SpineActor.poseHeld) keeps the image its slot already shows; only its clock moves on
const still = held && !imp.dirty && !rescale;
if (still) { this.actor.clock += imp.acc; imp.acc = 0; }
const due = !still && (imp.dirty || interval <= 1 || (frame + turn) % interval === 0 || frame - imp.last >= 2 * interval || rescale);
if (due) {
this.actor.update(imp.acc);
imp.acc = 0;
imp.dirty = false;
imp.last = frame;
this._renderImpostor(sc, atlas);
}
const k = imp.sc > 0 ? sc / imp.sc : 1;
+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>`;
+94
View File
@@ -0,0 +1,94 @@
// 潜能 / 练度 controls of the 干员调配 overlay (0.2.2, the owner's decision of 2026-10-08: 「调配干员里自己设置吧,默认满潜
// 满加成」): per operator (by charId — the normal, elite and 自选 forms share them), 潜能 1–6 and the 自持有 练度 tier
// (effects.json CHAR_MAP aceffect_char_1…4: 未精英化 / 精英阶段1 / 精英阶段2 / 精英阶段2-60级 — their official names and
// texts, localized by the data overlay), default 潜能 6 / 精英阶段2-60级. Official: 调度手册 「卫戍协议中干员潜能由自身已
// 持有干员潜能决定」 and 「提升已持有的干员,将为卫戍协议中同名的干员提供加成」 (an operator the player does not own fights
// at 潜能 1 with no bonus: set by hand for a 特许 one; a NORMAL one not owned is its stand-in, which has neither).
// CultivationSelects: two compact selects (a roster row, a 自选 slot card); CultivationSection: the detail's segmented
// controls with the tier's effect. Hook-free views (the tests draw them). The settings live in ui/loadoutSync.js
// (`ops`, synced with room.loadout); the model is ui/loadoutModel.js opsOf / setOps.
import { html, MicroLabel } from '../ui/components.js';
import { data } from '../data.js';
import { opsOf } from '../ui/loadoutModel.js';
import { CULTIVATE_EFFECTS, POTENTIAL_MIN, POTENTIAL_MAX } from '../../../shared/potential.js';
import { t, N_ } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
const POTENTIALS = Array.from({ length: POTENTIAL_MAX - POTENTIAL_MIN + 1 }, (_, i) => POTENTIAL_MIN + i);
const TIERS = CULTIVATE_EFFECTS.map((_, i) => i);
/** Names while effects.json is not loaded (msgids; the data's own names win). */
const FALLBACK_NAMES = Object.freeze([N_('未精英化'), N_('精英阶段1'), N_('精英阶段2'), N_('精英阶段2-60级')]);
/** The short names a row's select shows (the full official name is its title and the detail's). */
const SHORT_NAMES = Object.freeze([N_('未精英化'), N_('精英1'), N_('精英2'), N_('精英2 Lv.60')]);
/** The official name of 练度 tier `n` (effects.json `name`, localized), else its fallback. */
export function cultivateName(n) {
return data.lookup('effects', CULTIVATE_EFFECTS[n])?.name || t(FALLBACK_NAMES[n] || '');
}
/** The official effect text of 练度 tier `n` (effects.json `desc`: 「攻击力、防御力和最大生命值+10%」 …), or ''. */
export function cultivateDesc(n) {
return data.lookup('effects', CULTIVATE_EFFECTS[n])?.desc || '';
}
/**
* Two compact selects — 潜能 and 练度 — of operator `charId` under the stored settings `ops` (a roster row, a 自选 slot).
* @param {{ charId: string, ops: Record<string, any>, onSet: (charId: string, patch: { potential?: number, cultivate?: number }) => void,
* note?: string|null }} props `note`: why the settings do not apply now (a 补位 stand-in), shown as the tooltip
*/
export function CultivationSelects({ charId, ops, onSet, note = null }) {
if (!charId) return null;
const cur = opsOf(ops, charId);
const set = (patch) => onSet(charId, patch);
// a setting off the default stands out (mint); the default stays quiet — it is the same on every row
return html`<span class=${cx('lo-cult', cur.changed && 'is-changed', note && 'is-moot')} data-char=${charId} title=${note || undefined}>
<span class=${cx('lo-select', 'lo-cult__sel', 'lo-cult__pot', cur.potential !== 6 && 'is-off')}>
<select value=${String(cur.potential)} aria-label=${t('潜能')} data-cult="potential"
onChange=${(e) => set({ potential: Number(e.currentTarget.value) })}>
${POTENTIALS.map((n) => html`<option key=${n} value=${String(n)}>${t('潜能 {n}', { n })}</option>`)}
</select>
</span>
<span class=${cx('lo-select', 'lo-cult__sel', 'lo-cult__tier', cur.cultivate !== TIERS.length - 1 && 'is-off')} title=${note ? undefined : cultivateName(cur.cultivate)}>
<select value=${String(cur.cultivate)} aria-label=${t('练度')} data-cult="cultivate"
onChange=${(e) => set({ cultivate: Number(e.currentTarget.value) })}>
${TIERS.map((n) => html`<option key=${n} value=${String(n)}>${t(SHORT_NAMES[n])}</option>`)}
</select>
</span>
</span>`;
}
/**
* The detail's 潜能 / 练度 section: 潜能 1–6 and the four 练度 tiers as segmented buttons, the tier's official effect,
* the rule in one line, and — for a chess the player marked not owned — that its stand-in has neither.
* @param {{ charId: string, ops: Record<string, any>, onSet: (charId: string, patch: object) => void, standIn?: boolean }} props
*/
export function CultivationSection({ charId, ops, onSet, standIn = false }) {
if (!charId) return null;
const cur = opsOf(ops, charId);
const set = (patch) => onSet(charId, patch);
return html`<section class=${cx('lo-sec', 'lo-sec--cult', standIn && 'is-moot')} aria-label=${t('潜能与练度')} data-char=${charId}>
<header class="lo-sec__head">
<h3>${t('潜能与练度')}<${MicroLabel}>POTENTIAL<//></h3>
${cur.changed ? html`<span class="lo-badge lo-badge--changed">${t('已调整')}</span>` : html`<span class="lo-badge lo-badge--plain">${t('满潜能 · 精英2 Lv.60')}</span>`}
</header>
<div class="lo-cult__row">
<span class="lo-cult__k">${t('潜能')}</span>
<div class="lo-seg lo-seg--pot" role="radiogroup" aria-label=${t('潜能')}>
${POTENTIALS.map((n) => html`<button key=${n} type="button" role="radio" aria-checked=${cur.potential === n ? 'true' : 'false'}
class=${cx(cur.potential === n && 'is-on')} data-potential=${n} onClick=${() => set({ potential: n })}><span class="num">${n}</span></button>`)}
</div>
</div>
<div class="lo-cult__row">
<span class="lo-cult__k">${t('练度')}</span>
<div class="lo-seg lo-seg--tier" role="radiogroup" aria-label=${t('练度')}>
${TIERS.map((n) => html`<button key=${n} type="button" role="radio" aria-checked=${cur.cultivate === n ? 'true' : 'false'}
class=${cx(cur.cultivate === n && 'is-on')} data-cultivate=${n} onClick=${() => set({ cultivate: n })}>${cultivateName(n)}</button>`)}
</div>
</div>
<p class="lo-cult__eff" data-cultivate=${cur.cultivate}><b>${cultivateName(cur.cultivate)}</b> <span>${cultivateDesc(cur.cultivate)}</span></p>
<p class="lo-cult__note">${standIn
? t('干员持有中标记为未持有:替补干员没有潜能与练度;这里的设置在改回「持有」后生效')
: t('默认满潜能、精英2 Lv.60(满加成)。官方的潜能取你自己的潜能,练度是自持有加成(已持有的同名干员的养成,攻击 / 防御 / 生命单独乘算);未持有的特许干员在官方按潜能1、没有加成,要照官方打请手动设为潜能1、未精英化。')}</p>
</section>`;
}
+43 -18
View File
@@ -6,15 +6,19 @@
// 原型 / 已持有 tag —, an operator held by another slot greyed out with where it is. In the match the operator is sold
// only in this player's shop, from 调度中心 level 5 (tier 5) / 6 (tier 6), and fights with the pick (server/match/player
// /diy.js). Out of match: the next match takes the picks. The picks live in ui/loadoutSync.js (localStorage + room.diy);
// the model is ui/diyModel.js. Styles: css/screens/loadout.css (diy-*).
// the model is ui/diyModel.js. Styles: css/screens/loadout.css (diy-*). An owned pick's card carries its operator's 潜能 /
// 练度 selects (0.2.2, screens/cultivation.js — the same per-operator settings as 干员调配, `ops`); a prototype has neither.
import { useEffect, useState } from '../../vendor/hooks.module.js';
import { useLayoutEffect, useState } from '../../vendor/hooks.module.js';
import { html, Icon, Button, TierChip } from '../ui/components.js';
import { Img, RichText, BondGlyph } from '../ui/gameComponents.js';
import { chessAvatarUrl, chessPortraitUrl, profIconUrl, skillRecordIconUrl, moduleTypeIconUrl } from '../ui/assetUrls.js';
import { data } from '../data.js';
import { PROF_NAME, skillLabel, moduleBadge, fullTraitText } from '../ui/loadoutModel.js';
import { PROF_NAME, skillLabel, moduleBadge, fullTraitText, skillTags } from '../ui/loadoutModel.js';
import { diySlotList, pickChoices, pickOptions, slotRecord, defaultPick } from '../ui/diyModel.js';
import { OperatorVoice } from '../ui/operatorVoice.js';
import { LoadoutStats } from '../ui/loadoutStats.js';
import { CultivationSelects } from './cultivation.js';
import { t } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
@@ -38,8 +42,8 @@ function Bonds({ bonds }) {
return html`<span class="diy-bonds">${(bonds || []).map((b) => html`<span key=${b} class="diy-bond"><${BondGlyph} bondId=${b} /><span>${bondName(b)}</span></span>`)}</span>`;
}
/** One slot: the pick (operator, class, bonds, skill, module) or an empty slot to fill. */
function SlotCard({ m, slot, pick, illegal, onOpen, onClear }) {
/** One slot: the pick (operator, class, bonds, skill, module, an owned pick's 潜能 / 练度) or an empty slot to fill. */
function SlotCard({ m, slot, pick, illegal, onOpen, onClear, ops = {}, onOps = null }) {
const D = diyData();
const rec = pick ? slotRecord(slot.slotId, pick, D) : null;
const elite = pick ? slotRecord(slot.slotId, pick, D, { elite: true }) : null;
@@ -73,6 +77,9 @@ function SlotCard({ m, slot, pick, illegal, onOpen, onClear }) {
${mod ? html`<${Img} src=${moduleTypeIconUrl(data.get('local'), mod.type)} class="diy-slot__micon" fallback=${html`<b class="num">${moduleBadge({ typeName: mod.type })}</b>`} />` : html`<span class="diy-slot__micon diy-slot__micon--none">—</span>`}
<span>${mod ? html`<b class="num">${mod.type || ''}</b> ${mod.name || ''}` : t('不装备模组')} <small class="t-dim">${t('(精锐时生效)')}</small></span>
</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">
@@ -101,14 +108,16 @@ function OptionCard({ m, opt, selected, onSel }) {
}
/** A skill choice of an owned pick (or the locked skill of a prototype). */
function SkillRow({ m, s, on, locked, onPick }) {
const rec = s.elite || s.normal;
function SkillRow({ m, s, on, locked, onPick, level }) {
const rec = level === 'elite' ? s.elite || s.normal : s.normal || s.elite;
const tags = skillTags(rec);
return html`<button type="button" role="radio" aria-checked=${on ? 'true' : 'false'} data-skill=${s.index} disabled=${locked}
class=${cx('diy-choice', on && 'is-on')} onClick=${() => onPick(s.index)}>
<span class="diy-choice__icon"><${Img} src=${skillRecordIconUrl(m, rec, { empty: false })} fallback=${html`<b class="num">${skillLabel(s.index)}</b>`} /></span>
<span class="diy-choice__body">
<span class="diy-choice__head"><b class="num">${skillLabel(s.index)}</b><b>${rec?.name || ''}</b>${on ? html`<span class="lo-badge lo-badge--on"><${Icon} name="check" />${locked ? t('锁定') : t('已选择')}</span>` : null}</span>
<${RichText} as="span" class="diy-choice__desc" text=${s.normal?.descRaw || s.normal?.desc || ''} />
<span class="diy-choice__tags"><span>${tags.sp}</span>${!tags.passive ? html`<span>${t('初始')} ${tags.init} · ${t('消耗')} ${tags.cost}</span>` : null}${tags.duration ? html`<span>${tags.duration}</span>` : null}</span>
<${RichText} as="span" class="diy-choice__desc" text=${rec?.descRaw || rec?.desc || ''} />
</span>
</button>`;
}
@@ -137,13 +146,16 @@ function ModuleRow({ id, rec, on, locked, onPick }) {
* @param {{ m: any, slot: any, picks: Record<string, any>, kitted: string[]|null, onDone: (pick: any) => void, onClose: () => void }} props
*/
export function DiyPicker(props) {
const [previewLevel, setPreviewLevel] = useState('elite');
const cur = props.picks[props.slot.slotId] || null;
const [filter, setFilter] = useState('all');
const [query, setQuery] = useState('');
const [draft, setDraft] = useState(cur ? { ...cur } : null);
// Esc cancels only the picker, like its 取消 (GitHub #284, idea from PR #286); the 干员调配 overlay's own Esc skips
// while a picker is open, and a dialog over the picker (导入) still takes Esc first
useEffect(() => {
// while a picker is open, and a dialog over the picker (导入) still takes Esc first. A layout effect: the listener is
// attached in the same commit as the picker (a plain effect waits for the next frame, and an Esc pressed in between
// was swallowed — the overlay skipped it, the picker did not hear it yet; the 0.2.2 full browser pass)
useLayoutEffect(() => {
const onKey = (e) => {
if (e.key !== 'Escape' || e.ctrlKey || e.metaKey || e.altKey || document.querySelector('.modal')) return;
e.preventDefault();
@@ -153,14 +165,14 @@ export function DiyPicker(props) {
window.addEventListener('keydown', onKey, true);
return () => window.removeEventListener('keydown', onKey, true);
}, [props.onClose]);
return DiyPickerView({ ...props, filter, query, draft, onFilter: setFilter, onQuery: setQuery, onDraft: setDraft });
return DiyPickerView({ ...props, previewLevel, onPreviewLevel: setPreviewLevel, filter, query, draft, onFilter: setFilter, onQuery: setQuery, onDraft: setDraft });
}
/**
* The picker's view (no hooks: the tests draw it): `filter` 'all' | 'proto' | 'owned', `query`, `draft` the pick being
* made (null = none chosen yet) and their setters.
*/
export function DiyPickerView({ m, slot, picks, kitted, onDone, onClose, filter, query, draft, onFilter: setFilter, onQuery: setQuery, onDraft: setDraft }) {
export function DiyPickerView({ m, slot, picks, kitted, onDone, onClose, filter, query, draft, onFilter: setFilter, onQuery: setQuery, onDraft: setDraft, ops = {}, onOps = null, previewLevel = 'elite', onPreviewLevel = () => {} }) {
const D = diyData();
const cur = picks[slot.slotId] || null;
const options = pickOptions(slot.slotId, picks, D, kitted);
@@ -168,12 +180,20 @@ export function DiyPickerView({ m, slot, picks, kitted, onDone, onClose, filter,
const list = options.filter((o) => (filter === 'all' || (filter === 'proto') === o.proto)
&& (!q || [o.unit?.name, o.unit?.appellation, o.unit?.subProfessionName, t(PROF_NAME[o.unit?.profession] || ''), ...o.bonds.map(bondName)].some((x) => typeof x === 'string' && x.toLowerCase().includes(q))));
const ch = draft ? pickChoices(draft.charId, slot.slotId, D) : null;
const sel = (charId) => setDraft(cur && cur.charId === charId ? { ...cur } : defaultPick(charId, slot.slotId, D));
const sel = (charId) => {
if (draft?.charId === charId) return;
setDraft(cur && cur.charId === charId ? { ...cur } : defaultPick(charId, slot.slotId, D));
};
const lk = ch?.locked || null;
const skillOn = ch ? (ch.proto ? lk?.skillIndex : draft.skillIndex) : null;
const modOn = ch ? (ch.proto ? lk?.uniEquipId ?? null : draft.uniEquipId ?? null) : null;
const unit = draft ? D.backups?.units?.[draft.charId] : null;
const label = t('{tier}阶 自选', { tier: slot.tier });
const potential = ops[draft?.charId]?.potential ?? 6;
const base = draft ? slotRecord(slot.slotId, draft, D, { potential }) : null;
const golden = draft ? slotRecord(slot.slotId, draft, D, { elite: true, potential }) : null;
const previewEntries = base ? { [base.baseId || base.chessId]: { skill: skillOn, module: modOn || 'none' } } : {};
const previewChess = (id) => id === golden?.chessId ? golden : base;
return html`<section class="diy-pick" role="dialog" aria-label=${t('{slot}:选择干员', { slot: label })} data-testid="diy-picker" data-slot=${slot.slotId}>
<header class="diy-pick__head">
<${TierChip} tier=${slot.tier} size="sm" /><b>${label}</b>
@@ -201,9 +221,12 @@ export function DiyPickerView({ m, slot, picks, kitted, onDone, onClose, filter,
<span class="diy-pick__wtxt"><b>${unit.name}</b><small>${classLine(unit)}</small></span>
<${KindTag} proto=${ch.proto} />
</div>
${!ch.proto && onOps ? html`<section class="lo-sec"><header class="lo-sec__head"><h3>${t('潜能与练度')}</h3></header><${CultivationSelects} charId=${draft.charId} ops=${ops} onSet=${onOps} /></section>` : null}
<${LoadoutStats} base=${base} golden=${golden} entries=${previewEntries} level=${previewLevel}
onLevel=${onPreviewLevel} getChess=${previewChess} ops=${ch.proto ? null : ops} />
<h4 class="diy-pick__sec">${t('技能')}${ch.proto ? html`<small>${t('原型干员的技能与补位时一致,不可更改')}</small>` : null}</h4>
<div role="radiogroup" aria-label=${t('选择技能')} class="diy-pick__choices">
${ch.skills.filter((s) => !ch.proto || s.index === skillOn).map((s) => html`<${SkillRow} key=${s.index} m=${m} s=${s} on=${s.index === skillOn} locked=${ch.proto}
${ch.skills.filter((s) => !ch.proto || s.index === skillOn).map((s) => html`<${SkillRow} key=${s.index} m=${m} s=${s} level=${previewLevel} on=${s.index === skillOn} locked=${ch.proto}
onPick=${(i) => setDraft({ ...draft, skillIndex: i })} />`)}
</div>
<h4 class="diy-pick__sec">${t('模组')}<small>${t('精锐时生效 · 模组等级 {stage}', { stage: ch.stage })}</small></h4>
@@ -226,7 +249,9 @@ export function DiyPickerView({ m, slot, picks, kitted, onDone, onClose, filter,
/**
* The tab's body: the four slots by tier, and the picker of the slot being filled (`picking`, kept here).
* @param {{ m: any, picks: Record<string, any>, legal: Record<string, any>, kitted: string[]|null,
* onSet: (slotId: string, pick: any) => void }} props
* onSet: (slotId: string, pick: any) => void, ops?: Record<string, any>,
* onOps?: ((charId: string, patch: { potential?: number, cultivate?: number }) => void) | null }} props
* `ops` / `onOps`: the stored 潜能 / 练度 and their setter (0.2.2 — an owned pick's card shows its selects)
*/
export function DiyPanel(props) {
const [picking, setPicking] = useState(null);
@@ -234,7 +259,7 @@ export function DiyPanel(props) {
}
/** The tab's view (no hooks: the tests draw it): `picking` = the slot being filled, or null. */
export function DiyPanelView({ m, picks, legal, kitted, onSet, picking, onPicking: setPicking }) {
export function DiyPanelView({ m, picks, legal, kitted, onSet, picking, onPicking: setPicking, ops = {}, onOps = null }) {
const slots = diySlotList(diyData());
const slot = picking ? slots.find((s) => s.slotId === picking) : null;
const tiers = [...new Set(slots.map((s) => s.tier))];
@@ -247,11 +272,11 @@ export function DiyPanelView({ m, picks, legal, kitted, onSet, picking, onPickin
<h3 class="own-tier__head"><${TierChip} tier=${tier} size="sm" /><span class="num">${ROMAN[tier]}</span><span>${t('阶')}</span></h3>
<div class="diy-grid">
${slots.filter((s) => s.tier === tier).map((s) => html`<${SlotCard} key=${s.slotId} m=${m} slot=${s} pick=${picks[s.slotId] || null}
illegal=${!!picks[s.slotId] && !legal[s.slotId]} onOpen=${setPicking} onClear=${(id) => onSet(id, null)} />`)}
illegal=${!!picks[s.slotId] && !legal[s.slotId]} onOpen=${setPicking} onClear=${(id) => onSet(id, null)} ops=${ops} onOps=${onOps} />`)}
</div>
</section>`)}
</div>
${slot ? html`<${DiyPicker} m=${m} slot=${slot} picks=${picks} kitted=${kitted} onClose=${() => setPicking(null)}
${slot ? html`<${DiyPicker} m=${m} slot=${slot} picks=${picks} kitted=${kitted} ops=${ops} onOps=${onOps} onClose=${() => setPicking(null)}
onDone=${(pick) => { onSet(slot.slotId, pick); setPicking(null); }} />` : null}
</main>`;
}
+155 -31
View File
@@ -95,11 +95,11 @@ import { pieceTile } from '../render/drag.js';
import {
phaseMode, phaseBanner, isCombatPhase, showDeadPill, isBossPhase, placementContext, canPlace, boardTargets, dropIntent,
battleOverSfx, uniteResultBox, battleResultBox,
snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers,
terrainInfo,
snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, normalizePersonalChoice, sortedPlayers,
terrainInfo, deviceInfo, deviceTipAt, noteDeviceUnits, deviceUnitsForEntry,
countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason,
previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, fieldTile, panelSide, panelSlots, bondPopupPlace, unitLoadout, deployedRecord,
mergeTarget, modeOffBonds, readyFundsPrompt, ownerBandId, ownDiyRecord, ownStandIn,
mergeTarget, modeOffBonds, readyFundsPrompt, readyShopFold, ownerBandId, ownDiyRecord, ownStandIn,
} from '../ui/gameLogic.js';
import { toast } from '../ui/toasts.js';
import { BriefingScreen } from './briefing.js';
@@ -115,8 +115,9 @@ import { audio, resultSpeaker, resultVoiceSlot } from '../audio.js';
import { useDocClass, FullscreenButton } from '../ui/device.js';
// MatchEnded, PausedOverlay, the highlight styles and keepEarly live in ./game/*.js.
import { HUD_HZ_MS, MERGE_HL, SEL_RANGE, cx } from './game/marks.js';
import { keepEarly } from './game/early.js';
import { keepEarly, audioEarly } from './game/early.js';
import { MatchEnded, PausedOverlay } from './game/overlays.js';
import { inspectRange } from './game/range.js';
import { t, tParts } from '../../../shared/i18n.js';
/** Router for the in-match screens. */
@@ -146,6 +147,9 @@ export function GameScreen() {
// ---- match screen ----------------------------------------------------------------------------------------
/** A card of a unit of the battle on screen: a unit's, or a crate's / turret's (a device card with its `unitId`, #228). */
const inBattleCard = (d) => d?.kind === 'unit' || (d?.kind === 'device' && d.device?.unitId != null);
function MatchScreen() {
useDocClass('sp-in-match');
const pub = useStore((s) => s.match.public);
@@ -156,6 +160,12 @@ function MatchScreen() {
const emotes = useStore((s) => s.emotes);
const roomSolo = useStore((s) => s.room?.mode === 'solo');
const spectator = useStore((s) => isSpectating(s.room, s.me.playerId));
// the room's spectator seats for the top bar's 观战席 capsule (ui/hud.js SpectatorPill; GitHub #120): room.state keeps coming
// during the match, the list is replaced only when a seat changes — shallowEqual keeps an unrelated push from re-rendering
const specFacts = useStore((s) => ({
list: Array.isArray(s.room?.spectators) ? s.room.spectators : null,
isHost: !!s.room && s.room.hostId === s.me.playerId,
}), shallowEqual);
const gd = useGameData();
const hostRef = useRef(null);
@@ -169,6 +179,9 @@ function MatchScreen() {
const [bondOpen, setBondOpen] = useState(null); // { id, ownerId, from }: the bond popup and whose bond it shows
const [bondsCollapsed, setBondsCollapsed] = useState(false);
const [detail, setDetail] = useState(null); // detail target
// every shop / reward card opened by a tap is a new tap — `tap` in the target: two cards of one operator (the pool
// deals duplicates) carry the same chess id and no piece, and each must say its 选中干员 (detailPanel selectVoiceKey)
const cardTap = useRef(0);
const [collapsed, setCollapsed] = useState(false);
const [rewardMin, setRewardMin] = useState(false);
const [emoteOpen, setEmoteOpen] = useState(false);
@@ -184,6 +197,7 @@ function MatchScreen() {
const [resultBox, setResultBox] = useState(null); // the round's result box (ResultDialog), shown at SETTLE
const [readyBusy, setReadyBusy] = useState(false);
const [spBusy, setSpBusy] = useState(null);
const [personalBusy, setPersonalBusy] = useState(null); // { choiceId, idx }: an older request cannot clear a newer pick
const [layer, setLayer] = useState('ALL'); // 联防 / 最终攻势 camera: 'L' | 'ALL' | 'R'
const [pen, setPen] = useState(false); // the camera shows the enemy preview pen (research 09 §2)
const [camKind, setCamKind] = useState('prep'); // kind of the last camera request (data-camera)
@@ -207,7 +221,9 @@ function MatchScreen() {
const scoutPid = watchingOther && field?.prep && typeof field.fieldId === 'string' && field.fieldId.startsWith('n:') ? field.fieldId.slice(2) : null;
const scoutBandId = scoutPid ? ownerBandId(pub, scoutPid) : null;
const scoutBandOwner = scoutPid ? (players.find((p) => p.playerId === scoutPid)?.name || null) : null;
const editable = phase === PHASE.PREP && !!priv && alive && !priv.ready && !watchingOther;
const personalChoice = !spectator && alive ? normalizePersonalChoice(pub, priv, myId) : null;
const hasPersonalChoice = !!personalChoice;
const editable = phase === PHASE.PREP && !!priv && alive && !priv.ready && !watchingOther && !hasPersonalChoice;
const showShop = !!priv && alive && (phase === PHASE.PREP || phase === PHASE.SP_DRAFT || phase === PHASE.ROUND_START) && !watchingOther;
const layersDisabled = phase === PHASE.UNITE || isBossPhase(phase);
const sp = phase === PHASE.SP_DRAFT ? normalizeSp(pub?.sp, players) : null;
@@ -271,7 +287,7 @@ function MatchScreen() {
getChess: (id) => { const c = gd.chess(id); return ownDiyRecord(c, priv, { chess: data.get('chess'), backups: data.get('backups') }) || c; },
getToken: gd.token, getItem: gd.item, getEffect: gd.effect, backups: gd.backups,
}), [priv, pub?.stageId, editable, gd.ready, deployField]);
live.current = { pub, priv, field, editable, placeCtx, watching, watchWho, home, myId, alive, spectator, detail, drawer, bondOpen, emoteOpen, settingsOpen, exitOpen, drag, facing, sel, selBusy, pen, collapsedNow: collapsed, localDone: false, canPause: false, paused };
live.current = { pub, priv, field, editable, hasPersonalChoice, placeCtx, watching, watchWho, home, myId, alive, spectator, detail, drawer, bondOpen, emoteOpen, settingsOpen, exitOpen, drag, facing, sel, selBusy, pen, collapsedNow: collapsed, localDone: false, canPause: false, paused };
// ---- camera: every request goes through setCam, which remembers it for the pen's way back -----------------------
// the own prep board: the normal board, or — in the prep of a boss round — the player's half of the boss field
@@ -286,6 +302,18 @@ function MatchScreen() {
// the shop bar is shown folded (收起; the pen folds it for itself: the player's own state is the one it returns to) —
// the own prep board then takes the official shop-collapsed camera (public issue #5, gameLogic prepCameraFor)
const shopFolded = showShop && (pen ? penRef.current.collapsed : collapsed);
// 准备就绪 folds the shop bar, cancelling it unfolds the bar again (GitHub #138; gameLogic readyShopFold): the board is set,
// the fight is what to look at — the board camera follows the fold like a hand-made one (the effect below). Inside the pen
// the bar is folded for itself: the state it returns to is the one that changes.
const readySeen = useRef(null);
useEffect(() => {
const cur = phase === PHASE.PREP && priv && alive && !watchingOther ? { round: pub?.round, ready: !!priv.ready } : null;
const change = readyShopFold(readySeen.current, cur);
readySeen.current = cur;
if (!change) return;
if (penRef.current.on) penRef.current.collapsed = change === 'fold';
else setCollapsed(change === 'fold');
}, [phase, pub?.round, priv?.ready, !!priv, alive, watchingOther]);
const cancelFacingRef = useRef(() => {});
const setCam = useCallback((kind, opts) => {
camRef.current = { kind, opts: opts || {} };
@@ -335,6 +363,9 @@ function MatchScreen() {
const lastFieldRef = useRef(null);
const viewModeRef = useRef(null);
const snapUnitsRef = useRef(new Map());
// the shown battle's device units (crates / “双眼皮” turrets: UnitInfo by id) — the meta's `units` plus the 'spawn' events,
// for the tap on a device (gameLogic.deviceTipAt); the renderer's pick skips devices, a tap on one lands in tileClick
const deviceUnitsRef = useRef(new Map());
const hudRef = useRef(null);
// b.ev / b.snap that arrive before the UI entered their field (the battle's first ticks race the m.public /
// m.field re-render): kept per fieldId and replayed on enter so no unit's 'spawn' UnitInfo is ever lost.
@@ -372,10 +403,27 @@ function MatchScreen() {
live.current.terrainTile = showPrep && (deployField === 'bossL' || deployField === 'bossR')
? (row, col) => fieldTile(deployField, row, col)
: (row, col) => [row, col];
// a battle on screen (not the prep board, not a scouted prep board): its crates and turrets are device UNITS, found by
// unit; everything else answers by the stage's own device entries (gameLogic.deviceTipAt / deviceInfo, #228)
live.current.deviceBattle = !!shownField && !shownField.prep;
const staleFieldRef = useRef(null);
const enteredFieldRef = useRef(null);
const enteredViewRef = useRef(null);
const pressSel = useRef(null); // the selected piece when the current field press began
useEffect(() => {
// Read before a view change clears the refs. A later refresh of this same field (g.watch / reshow replaces the
// field object) must still know which battle the device units already noted belong to.
const previousFieldId = lastFieldRef.current;
const previousDevices = deviceUnitsRef.current;
if (enteredViewRef.current !== view) {
// A replacement engine has no field, even when the match's m.field object did not change. While it loads,
// buffer frames instead of sending them to a null/destroyed view; its subscription below asks for fresh meta.
enteredViewRef.current = view;
enteredFieldRef.current = null;
lastFieldRef.current = null;
viewModeRef.current = null;
reentryRef.current = null;
}
if (!view) return;
if (showPrep) {
if (field?.prep) staleFieldRef.current = field; // a prep scouting board is never a battle to enter
@@ -402,15 +450,24 @@ function MatchScreen() {
const wanted = combat || mode === 'settle' || watchingOther;
if (!wanted || !field || !field.fieldId || field === staleFieldRef.current || field === enteredFieldRef.current) return;
if (watchingOther && !combat && field.fieldId !== watching) return; // an older push while switching
if (lastFieldRef.current && lastFieldRef.current !== field.fieldId) setDetail((d) => (d?.kind === 'unit' ? null : d));
if (lastFieldRef.current && lastFieldRef.current !== field.fieldId) setDetail((d) => (inBattleCard(d) ? null : d));
enteredFieldRef.current = field;
lastFieldRef.current = field.fieldId;
reentryRef.current = null;
viewModeRef.current = 'battle';
snapUnitsRef.current = new Map();
view.enterBattle(field);
const early = evBufRef.current.get(field.fieldId);
evBufRef.current.delete(field.fieldId);
// Crates and turrets are absent from a start-of-battle meta and arrive once, as spawn events. Re-entering the
// same field from a refresh that repeats that meta (the subscription below asks for one) must not drop them.
const noted = noteDeviceUnits(new Map(), { units: field.units, events: early });
deviceUnitsRef.current = deviceUnitsForEntry(previousDevices, noted, previousFieldId != null && previousFieldId === field.fieldId);
// The renderer clears its unit metadata on entry too. Restore known devices there as well as in the card lookup;
// otherwise their next HP tuple becomes an unknown enemy whose pieceClick consumes the device's tileClick.
const units = Array.isArray(field.units) ? [...field.units] : [];
const ids = new Set(units.map((u) => u?.id));
for (const [id, info] of deviceUnitsRef.current) if (!ids.has(id)) units.push(info);
view.enterBattle({ ...field, units });
const earlySnap = snapBufRef.current.get(field.fieldId);
snapBufRef.current.delete(field.fieldId);
// a scouted prep board frames like the own prep with the shop folded (the bench row included, app.js camRect);
@@ -434,7 +491,7 @@ function MatchScreen() {
if (early && early.length) {
// replay state-bearing events only (a burst of stale hit sparks / damage numbers would look wrong)
view.pushEvents(early);
audio.handleBattleEvents(early.filter((e) => e[0] === 'spawn'));
audio.handleBattleEvents(audioEarly(early)); // (the 'spawn' tuples only: the sound would replay every old death, deploy and cast)
}
if (!earlySnap && (field.prep || !combat) && Array.isArray(field.units)) {
// prep scouting: no battle snapshots follow. A later m.field for this board (the teammate moved) re-enters
@@ -446,6 +503,9 @@ function MatchScreen() {
}
if (earlySnap) {
view.pushSnapshot(earlySnap);
if (Array.isArray(earlySnap.units)) {
snapUnitsRef.current = new Map(earlySnap.units.filter(Array.isArray).map((t) => [t[0], t]));
}
hudRef.current = snapHud(earlySnap);
setHud(hudRef.current);
}
@@ -498,6 +558,7 @@ function MatchScreen() {
return;
}
view?.pushEvents(msg);
noteDeviceUnits(deviceUnitsRef.current, { events: msg.ev });
audio.handleBattleEvents(msg.ev);
};
// 干员语音 (结算): the own battle's result just came in — the operator's line depends on how it went
@@ -526,6 +587,15 @@ function MatchScreen() {
};
const offs = [net.on('m.field', onFieldMeta), net.on('b.snap', onSnap), net.on('b.ev', onEv)];
if (battleRunner) offs.push(battleRunner.on('field', onFieldMeta), battleRunner.on('snap', onSnap), battleRunner.on('ev', onEv), battleRunner.on('result', onResult));
// Metadata must include enemies/tokens spawned before this renderer existed, not only events buffered during
// loading. Subscribe first so the reshow's metadata and snapshot take the ordinary buffered re-entry path.
if (view && !showPrep && field?.fieldId) {
if (field.local) battleRunner?.reshow();
else if (combat || watchingOther) {
const who = watchWho?.fieldId === field.fieldId ? watchWho.playerId : null;
net.request('g.watch', { fieldId: field.fieldId, ...(who ? { playerId: who } : {}) }).catch(() => {});
}
}
return () => { for (const off of offs) { try { off(); } catch { /* ignore */ } } clearTimeout(pending); };
}, [view]);
@@ -585,7 +655,7 @@ function MatchScreen() {
let asked = null;
try { asked = new URLSearchParams(globalThis.location?.search || '').get('render'); } catch { asked = null; }
if (asked === 'fallback' || globalThis.__SP_RENDER__ === 'fallback') return;
toast(t('当前设备无法启用 3D / WebGL 渲染,已切换为简化视图(功能不受影响)'), 'info', { ttl: 5000 });
toast(t('渲染器暂时未能加载,已切换为简化视图(功能不受影响,可刷新重试)'), 'info', { ttl: 5000 });
}, [viewKind]);
// phase changes: banners, sounds, resets
@@ -630,7 +700,7 @@ function MatchScreen() {
// the pen is a 休整期 view: leaving prep returns the camera (the next setCam would, too)
if (phase !== PHASE.PREP && penRef.current.on) togglePenRef.current(false);
// a battle unit's panel (live HP of a unit of the fight that just ended) never outlives its battle
if (!isCombatPhase(phase) && phase !== PHASE.SETTLE) setDetail((d) => (d?.kind === 'unit' ? null : d));
if (!isCombatPhase(phase) && phase !== PHASE.SETTLE) setDetail((d) => (inBattleCard(d) ? null : d));
if (isCombatPhase(phase)) { setCollapsed(false); setDrag(null); view?.highlightTiles(null, null); }
setSel(null);
if (phase !== PHASE.PREP) setRewardMin(false);
@@ -650,6 +720,17 @@ function MatchScreen() {
}, [phase, pub?.round, priv?.board, view]);
useEffect(() => {
setPersonalBusy((b) => b?.choiceId === personalChoice?.id ? b : null);
if (!personalChoice) return;
setSel(null);
setDetail(null);
setArmedCard(null);
setDrag(null);
view?.highlightTiles(null, null);
cancelFacingRef.current();
}, [personalChoice?.id, view]);
// a reload / reconnect while watching a teammate's battle after the own one (client-side combat): the server resends
// the watched field, the fresh screen adopts it as watched once per battle — the observing pill, 返回战场 and the own
// row work again, and the bond strip's "👁 name" matches the HUD (battle/observe.js resumedWatch)
@@ -707,6 +788,7 @@ function MatchScreen() {
// ---- actions ----------------------------------------------------------------------------------------------
const buy = useCallback((i) => actions.buy(i), []);
const removeSpectator = useCallback((playerId) => actions.removeSpectator(playerId), []);
// 准备 with funds left asks first: the prep's end wipes them (community report #4; not 坎诺特, not at 0 funds, not
// under AI 托管 — gameLogic.readyFundsPrompt). The button and Space both come here.
const askingReady = useRef(false);
@@ -941,13 +1023,24 @@ function MatchScreen() {
setSel(wasSel ? null : { uid: e.uid });
if (wasSel) setDetail((d) => (d?.kind === 'piece' && d.uid === e.uid ? null : d));
}),
// a tap on the ground itself: a special terrain tile explains itself (GitHub issue #184 「建议加入对于特殊地形的单击
// 信息提示」) — 活性源石 / 沼泽 / 排气格栅 / 深水区 / 红蓝门 / 传送, with the numbers of the stage behind the board.
// An ordinary tile (road / floor / wall) says nothing, so the press keeps its other meanings (deselect, close).
// a tap on the ground itself: a stage device on the tile (阻隔工事 / “双眼皮” / 射击台 / 源石流发生装置: GitHub #228, PR
// #229) explains itself first — the renderer's pick skips devices, so the tap lands here —, then a special terrain
// tile (GitHub issue #184 「建议加入对于特殊地形的单击信息提示」) — 活性源石 / 沼泽 / 排气格栅 / 深水区 / 红蓝门 /
// 传送, with the numbers of the stage behind the board. An ordinary tile (road / floor / wall) says nothing, so the
// press keeps its other meanings (deselect, close).
view.on('tileClick', (t) => {
if (!t || !Number.isInteger(t.row) || !Number.isInteger(t.col)) return;
const L = live.current;
const [row, col] = L.terrainTile(t.row, t.col);
const device = L.deviceBattle
? deviceTipAt(L.terrainStage, row, col, { units: [...deviceUnitsRef.current.values()], snap: snapUnitsRef.current })
: deviceInfo(L.terrainStage, row, col);
if (device) {
audio.sfx('click', { volume: 0.4 });
setSel(null);
setDetail({ kind: 'device', device });
return;
}
const info = terrainInfo(L.terrainStage, row, col);
if (!info) return;
audio.sfx('click', { volume: 0.4 });
@@ -1014,10 +1107,6 @@ function MatchScreen() {
}, []);
// ---- direction step (research 09 §1.2) and the selected piece's underframe ------------------------------------
// DESIGN §16: previews show the range the unit fights with under the player's loadout (an elite's module grid); a
// chess the player does not own the range of its stand-in (0.2.0 补位, gameLogic deployedRecord)
const lookups = useMemo(() => ({ getChess: ownChess, getToken: gd.token, getItem: gd.item,
chessRecord: (rec) => deployedRecord(rec, live.current.priv, ownChess, gd.backups) }), [gd.ready]);
const heldRef = useRef(new Map()); // uid → { row, col, t } committed placements awaiting m.private
const releaseHold = useCallback((uid) => {
heldRef.current.delete(uid);
@@ -1070,16 +1159,10 @@ function MatchScreen() {
useEffect(() => {
if (facing && (!editable || !placeCtx.pieces.get(facing.uid))) cancelFacing();
}, [editable, placeCtx, facing]);
// the selected piece: gone / not editable → deselect; on the board its range tiles show (rotated to its facing)
// the selected piece: gone / not editable → deselect (its range tiles: the detail card's, cardRange below)
const selEntry = sel ? placeCtx.pieces.get(sel.uid) || null : null;
live.current.showPrep = showPrep;
useEffect(() => { if (sel && (!selEntry || !editable || !showPrep)) setSel(null); }, [sel, selEntry, editable, showPrep]);
const selRangeKey = selEntry && selEntry.area === 'board' ? `${selEntry.piece.uid}:${selEntry.row},${selEntry.col}:${pieceDir(selEntry.piece)}` : '';
useEffect(() => {
if (!view || !selRangeKey) return undefined;
showRange(view, previewGrid(lookups, selEntry.piece), selEntry.row, selEntry.col, pieceDir(selEntry.piece), SEL_RANGE);
return () => showRange(view, null, 0, 0, null, SEL_RANGE);
}, [view, selRangeKey]);
// the selected piece's underframe on screen: the detail card docks on the side away from it (user playtest #2
// item 8 — at some aspect ratios a bench unit's 出售 sat under the left card); the underframe is drawn above every
// panel anyway (css z-index), this keeps it visible too
@@ -1121,6 +1204,13 @@ function MatchScreen() {
const t = id != null ? snapUnitsRef.current.get(id) : null;
return t ? { hp: t[3], max: t[4] } : null;
})();
// a crate's / turret's card closes with it: destroyed (HP gone, or its tuple left the snapshots once the die animation
// is over — before the first snapshot of a battle nothing is judged)
useEffect(() => {
const id = detail?.kind === 'device' ? detail.device?.unitId : null;
const snap = snapUnitsRef.current;
if (id != null && snap.size > 0 && !(snap.get(id)?.[3] > 0)) setDetail(null);
}, [hud, detail]);
// ---- live stats of the detail card (user playtest #4 item 7) ------------------------------------------------------
// battle: the local sim's unit (battle/runner.js unitStats — a getter the panel re-reads 4× a second; any unit of the
@@ -1168,6 +1258,14 @@ function MatchScreen() {
const L = live.current;
// dialogs / the guide own the keyboard; behind the 本局信息 / 敌方情报 drawer only Esc (closing it) acts
if (shortcutBlocked(act, { modal: !!document.querySelector('.modal, .guide'), drawer: !!L.drawer })) return;
if (L.hasPersonalChoice) {
if (act !== 'escape') e.preventDefault();
if (act === 'ready') {
audio.sfx('error', { volume: 0.5 });
toast(shopBlockReason('ready', { priv: L.priv, editable: true }), 'warn');
}
return; // ChoiceOverlay handles Escape without cancelling the server's pending choice.
}
if (act === 'escape') {
if (L.emoteOpen) setEmoteOpen(false);
else if (L.pen && !L.detail) togglePenRef.current(false);
@@ -1259,7 +1357,7 @@ function MatchScreen() {
}, [strip.ownerId]);
// the popup shows the player it was opened for (ui/watchBonds.js popupView): their entry + live layers, their name,
// and as members your pieces or their operators on the field on screen — under client-side combat the battle's own
// (the runner's field meta is taken before they deploy); their hand is never sent
// (the runner's field meta is taken before they deploy); a battle sends no bench (a prep scout does: ownerBoard)
const popOps = cc && battleRunner && field?.local && bondOpen && bondOpen.ownerId !== myId ? battleRunner.ownerOps(bondOpen.ownerId, field.fieldId) : null;
const bondPop = popupView({ open: bondOpen, pub, priv, myId, field, units: popOps, live: liveLayers });
const openBond = (id, ownerId, from) => { setBondOpen((b) => toggleBond(b, id, ownerId, from)); audio.sfx('click', { volume: 0.4 }); };
@@ -1270,7 +1368,7 @@ function MatchScreen() {
const temp = tempInfo(priv);
const tempNotice = temp.count > 0 && !!view && viewKind !== 'loading' && showPrep && alive && !pen && !sp;
// the ready button shows why it is refused under it (ui/hud.js ReadyToggle): the effects column moves down a line
const readyWhy = phase === PHASE.PREP && alive && !priv?.ready && temp.count > 0;
const readyWhy = phase === PHASE.PREP && alive && !priv?.ready && (hasPersonalChoice || temp.count > 0);
// the frame the panels are laid out in: the HUD layer (client px; inside the safe-area insets of a notched phone),
// and the root font size (1rem)
const panelFrame = () => {
@@ -1313,6 +1411,23 @@ function MatchScreen() {
}
return priv?.loadout ?? null; // own pieces, shop / reward / bond-member cards
})();
// the open detail card's unit draws its attack range on the field (screens/game/range.js inspectRange, GitHub PR #281):
// an own board piece in prep (its loadout / stand-in record, its facing); in battle or on a scouted board an ally
// operator / summon on the field — the range it fights with now (the live stats' `range` and `dir`, the snapshot's
// tile) — never an enemy, a dead unit or a bench unit (UnitInfo `area` hand / temp); drag, the wheel and the pen
// draw their own
const cardRange = !drag && !facing && !pen ? inspectRange({
target: detailTarget, detail: resolved, pieces: placeCtx.pieces, showPrep, field,
snapshot: snapUnitsRef.current.get(resolved?.unitId),
live: typeof liveStats === 'function' ? liveStats() : liveStats,
loadout: detailLoadout, getChess: gd.chess, backups: gd.backups,
}) : null;
const cardRangeKey = cardRange ? JSON.stringify(cardRange) : '';
useEffect(() => {
if (!view) return undefined;
showRange(view, cardRange?.grid, cardRange?.row, cardRange?.col, cardRange?.dir, SEL_RANGE);
return () => showRange(view, null, 0, 0, null, SEL_RANGE);
}, [view, cardRangeKey, showPrep, field]);
// the card's bond chips — and the popup a chip opens: a battle / scouted unit's OWNER's bonds (yours, or that
// teammate's — m.public + live layers), your own piece's yours, a popup member card the popup's player; shop / reward
// cards read the strip's (ui/watchBonds.js detailBondOwner)
@@ -1321,7 +1436,7 @@ function MatchScreen() {
// bonds this mode never activates (标准: 10 of 23, 奥术 among them) — shown 本局禁用 on cards, chips and the popup
const offBonds = modeOffBonds(getMode(pub?.modeId));
return html`<div class=${cx('screen', 'gm', `gm--${mode}`, drag && 'is-dragging', collapsed && 'is-collapsed', sp && 'has-sp', pen && 'is-pen', readyWhy && 'has-readywhy')}
return html`<div class=${cx('screen', 'gm', `gm--${mode}`, drag && 'is-dragging', collapsed && 'is-collapsed', (sp || hasPersonalChoice) && 'has-sp', pen && 'is-pen', readyWhy && 'has-readywhy')}
data-camera=${pen ? 'pen' : camKind}>
<div class="gm__field" ref=${hostRef} onContextMenu=${(e) => e.preventDefault()}></div>
${viewKind === 'loading' ? html`<div class="gm__loading"><${Spinner} label="LOADING FIELD" /></div>` : null}
@@ -1336,7 +1451,8 @@ function MatchScreen() {
readyBusy=${readyBusy} readyCount=${readyCount} playerCount=${solo ? 1 : aliveCount}
pen=${pen} penAvail=${penAvail} onPen=${togglePen} config=${gd.config} frozenAt=${frozenAt}
pause=${canPause || paused ? { show: canPause, paused, busy: pauseBusy, onToggle: () => togglePause(!paused) } : null}
live=${liveLpNow} spectator=${spectator} />
live=${liveLpNow} spectator=${spectator}
spectators=${specFacts.list} myId=${myId} isHost=${specFacts.isHost} onRemoveSpectator=${removeSpectator} />
<div class="gm__bonds">
<button type="button" class="bonds-toggle" aria-expanded=${!bondsCollapsed} aria-controls="match-bond-strip"
@@ -1369,7 +1485,7 @@ function MatchScreen() {
${showShop ? html`<${ShopBar} priv=${priv} editable=${editable} collapsed=${collapsed} onCollapse=${setCollapsed}
barRef=${barRef} offBonds=${offBonds}
onBuy=${buy} onLevel=${() => actions.levelUp()} onRefresh=${() => actions.refresh()} onFreeze=${() => actions.freeze()}
onDetail=${(id, kind, hint) => setDetail({ kind: kind === 'item' ? 'item' : 'chess', id, hint: hint || null })}
onDetail=${(id, kind, hint) => setDetail({ kind: kind === 'item' ? 'item' : 'chess', id, hint: hint || null, tap: ++cardTap.current })}
onDetailClose=${() => setDetail((d) => (d?.kind === 'chess' || d?.kind === 'item' ? null : d))}
onRefuse=${(reason) => { toast(reason, 'warn'); audio.sfx('error', { volume: 0.5 }); }}
reward=${phase === PHASE.PREP && !rewardMin ? priv?.shop?.rewardOffer || null : null}
@@ -1405,7 +1521,7 @@ function MatchScreen() {
onClose=${() => setBondOpen(null)} onMember=${(id, items, standInFor, diy) => setDetail({ kind: 'chess', id, owner: bondPop.ownerId, items: items || null, standInFor: standInFor || null, diy: diy || null })} />` : null}
${resolved ? html`<${DetailPanel} detail=${resolved} snapHp=${snapHp} onClose=${() => { setDetail(null); setSel(null); }}
bonds=${detailBonds} offBonds=${offBonds} loadout=${detailLoadout} side=${dSide} shopOpen=${shopOpen} live=${liveStats} voice=${combat}
bonds=${detailBonds} offBonds=${offBonds} loadout=${detailLoadout} ops=${priv?.ops ?? null} side=${dSide} shopOpen=${shopOpen} live=${liveStats} voice=${true}
onBond=${(id) => openBond(id, detailOwner, 'detail')} />` : null}
${selEntry && editable && !facing && !drag && showPrep ? html`<${Underframe} key=${sel.uid} view=${view} uid=${sel.uid}
@@ -1416,6 +1532,14 @@ function MatchScreen() {
${sp ? html`<${ChoiceOverlay} pub=${pub} sp=${sp} myId=${myId} solo=${solo} busyIdx=${spBusy} total=${total}
onPick=${async (i) => { setSpBusy(i); await actions.choice(i); setSpBusy(null); }} />` : null}
${personalChoice ? html`<${ChoiceOverlay} key=${personalChoice.id} pub=${pub} sp=${personalChoice} myId=${myId} solo=${solo} personal=${true}
busyIdx=${personalBusy?.choiceId === personalChoice.id ? personalBusy.idx : null} total=${total}
onPick=${async (i) => {
const choiceId = personalChoice.id;
setPersonalBusy({ choiceId, idx: i });
await actions.choice(i, choiceId);
setPersonalBusy((b) => b?.choiceId === choiceId ? null : b);
}} />` : null}
${banner ? html`<${PhaseBanner} key=${banner.key} mode="overlay" title=${banner.title} sub=${banner.sub} micro=${banner.micro}
tone=${banner.tone} duration=${banner.duration || 1500} onDone=${() => setBanner(null)} />` : null}
+9
View File
@@ -7,3 +7,12 @@ export const STATE_EV = new Set(['spawn', 'die', 'deploy', 'status', 'skill']);
/** Event tuples replayed when a field is entered late: the state-bearing kinds and the fx that change an enemy's model
* form (shared/protocol.js fxForm — the field meta's UnitInfo `form` predates them). */
export const keepEarly = (e) => Array.isArray(e) && (STATE_EV.has(e[0]) || fxForm(e) !== undefined);
/**
* What the SOUND gets of that replay: the 'spawn' tuples alone, which teach the audio who is on the field. The buffered
* list is every spawn, die, deploy, status and skill since the field's m.field — up to 1500 of them, while the view was not
* showing it — so handing it to the audio would play the old deaths, deploy lines and skill cues all at once on entering
* (GitHub PR #292 by @LimitlessHPPK handed over the whole list; its hold of a cast that arrives before its unit,
* audio.js `pendingSkill`, is what the live path needs, and `onEv` already forwards the full list).
*/
export const audioEarly = (early) => (Array.isArray(early) ? early.filter((e) => Array.isArray(e) && e[0] === 'spawn') : []);
+32
View File
@@ -0,0 +1,32 @@
// screens/game/range.js — the attack range the open detail card's unit draws on the field (GitHub PR #281 by
// @IceCodeNew; the field-detail range idea after @BBleae's fork): see game.js cardRange.
import { attackRangeGrid } from '../../../../shared/loadoutRecord.js';
import { chessLoadout, standInLoadout } from '../../ui/gameLogic.js';
import { tokenVariantFor } from '../../ui/gameLogic/loadout.js';
import { normDir } from '../../ui/facing.js';
/** Resolve the inspected field unit without granting placement or sale actions. */
export function inspectRange({ target, detail, pieces, showPrep = false, field, snapshot, live, loadout, getChess, backups }) {
if (!detail || (detail.type !== 'chess' && detail.type !== 'token')) return null;
let row, col, dir;
if (target?.kind === 'piece' && showPrep) {
const e = pieces?.get(target.uid);
if (!e || e.area !== 'board') return null;
row = e.row; col = e.col; dir = normDir(e.piece.dir);
} else if (target?.kind === 'unit' && !showPrep) {
const unit = target.unit;
if (!unit || unit.side !== 'ally' || (unit.kind !== 'op' && unit.kind !== 'token')) return null;
if (unit.area != null && unit.area !== 'board') return null;
// Snapshots exclude undeployed, hidden and removed units. Dead units remain briefly for their death animation.
if (!field?.prep && (!snapshot || snapshot[3] <= 0)) return null;
row = Math.round(snapshot ? snapshot[2] : unit.y);
col = Math.round(snapshot ? snapshot[1] : unit.x);
dir = normDir(live?.dir, null) || normDir(unit.dir, null) || (unit.facing === -1 ? 'LEFT' : 'RIGHT');
} else return null;
if (!Number.isFinite(row) || !Number.isFinite(col)) return null;
const record = detail.type === 'token' ? { ...detail.token, ...tokenVariantFor(detail.token, detail.ownerId) }
: detail.standIn ? standInLoadout(detail.standIn, getChess, backups)?.record || detail.standIn
: chessLoadout(detail.chess, detail.diy ? null : loadout, getChess)?.record || detail.chess;
const grid = Array.isArray(live?.range) ? live.range : attackRangeGrid(record);
return grid?.length ? { grid, row, col, dir } : null;
}
+178 -113
View File
@@ -5,12 +5,16 @@
// present: operator_preset, image_skill_select_outline, skill_select_deco, icon_equip_non; the module type icons of
// groups.module — lettered tiles without them).
//
// Left: roster of the 112 visible chess (tier / class / bond filters, search, 仅看已调整) — each card shows the equipped
// skill (S1–S3) and, when changed, the elite's module badge. Right: the selected chess — skills (icon, name, 默认,
// Left: the 112 visible chess as one list (tier / class / bond filters, search, 仅看已调整) — a row per operator, its columns
// aligned (0.2.2; the quick choices of PR #301 by @farridge, laid out as one list instead of three columns of cards — the
// owner's decision of 2026-10-08): portrait, name and bonds (a click opens the detail), the skills and the elite's modules
// to tap (不装备 first), the 潜能 and 练度 selects (screens/cultivation.js). Right: the selected chess — its 潜能 / 练度,
// its 特质 (the in-match card's block, PR #301), skills (icon, name, 默认,
// SP recovery, 初始 / 消耗 SP, duration, description at 普通 Lv.4 or 精锐 Lv.7), 局内数值 (the stats, 攻击范围, 特性 and
// 天赋 the chosen variant — 精锐 first, 普通 on the toggle — fights with under the chosen skill and module: the detail
// card's own block and pure functions, GitHub issue #64) and the elite's modules (不装备 / X / Y … with the stat bonus,
// the trait upgrade and the talent changes), 恢复默认; 全部恢复默认 in the top bar.
// the trait upgrade and the talent changes), 恢复默认; 全部恢复默认 in the top bar. The detail's 普通 / 精锐 skill toggle is one
// preview for the skills, the 特质 and the rows' skill details (display only: never stored).
// The loadout lives in ui/loadoutSync.js (localStorage + room.loadout); the model is ui/loadoutModel.js.
// The second tab, 干员持有 (0.2.0 补位, screens/ownership.js): which NORMAL chess the player owns — a chess marked 未持有 is
// deployed as its official stand-in (room.ownership; ui/ownershipModel.js) — with its own 导出 / 导入 / 全部持有.
@@ -22,19 +26,25 @@ import { useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.
import { html, Icon, MicroLabel, Button, TierChip, TextField, Countdown, Spinner, confirmDialog, hasDeadline, Modal, Fragment } from '../ui/components.js';
import { Img, RichText, UnitThumb } from '../ui/gameComponents.js';
import { chessAvatarUrl, chessPortraitUrl, subProfIconUrl, bondIconUrl, moduleTypeIconUrl } from '../ui/assetUrls.js';
import { chessStatsBlock, traitText, chessTalents } from '../ui/detailPanel.js';
import { chessLoadout } from '../ui/gameLogic.js';
import { GarrisonBlock } from '../ui/detailPanel.js';
import { data, useData, localAsset, DATA_FILES } from '../data.js';
import { useStore } from '../store.js';
import { PHASE } from '../../../shared/constants.js';
import {
MODULE_NONE, PROF_ORDER, PROF_NAME, rosterOf, filterRoster, recordsOf, chessOptions, effectiveChoice, setChoice, resetChoice,
changedCount, skillLabel, moduleBadge, attrRows, skillTags, traitLines, serializeExport, parseImport, LOADOUT_IMPORT_MAX_BYTES,
changedCount, skillLabel, moduleBadge, attrRows, skillTags, quickSkillTags, traitLines, serializeExport, parseImport, LOADOUT_IMPORT_MAX_BYTES,
opsOf, setOps, resetOps, moduleRecord,
} from '../ui/loadoutModel.js';
import { loadoutStore, openLoadout, closeLoadout, setEntries, applyLoadoutEntries, setNotOwned, applyOwnershipImport, setDiyPicks, applyDiyImport } from '../ui/loadoutSync.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';
import { setOwned, notOwnedCount, serializeOwnership, parseOwnershipImport, OWNERSHIP_IMPORT_MAX_BYTES } from '../ui/ownershipModel.js';
import { OwnershipPanel, useOwnershipRoster } from './ownership.js';
import { DiyPanel, diyData } from './diy.js';
import { LoadoutStats } from '../ui/loadoutStats.js';
export { LoadoutStats, statsPreview } from '../ui/loadoutStats.js';
import { diyCount, sanitizeDiyPicks, setPick, serializeDiy, parseDiyImport, DIY_IMPORT_MAX_BYTES } from '../ui/diyModel.js';
import { t, tParts, N_ } from '../../../shared/i18n.js';
import { copyText } from '../ui/clipboard.js';
@@ -140,38 +150,96 @@ export function ModuleGlyph({ m, rec, id, size = 'md' }) {
</span>`;
}
// ---- roster card ---------------------------------------------------------------------------------------------------
// ---- roster rows -----------------------------------------------------------------------------------------------------
//
// One list, one row per operator, its columns aligned (the head row names them): the quick choices of PR #301 (by
// @farridge) without its three columns of cards. Every row is the same few columns — tap a skill, a module, a 潜能 / 练度 —
// so the eye runs down a column; the portrait and name open the detail (on a phone it slides over; a quick choice keeps
// the list). Hook-free (the tests draw it).
function RosterCard({ m, chess, golden, entries, selected, onPick, notOwned = false }) {
const choice = effectiveChoice(entries, chess, golden);
const opt = chessOptions(chess, golden);
const skillRec = opt.skillOptions.find((s) => s.index === choice.skill)?.normal || chess.skill;
const modRec = golden && choice.module !== MODULE_NONE ? opt.moduleOptions.find((x) => x.id === choice.module)?.rec || null : null;
const modChanged = golden && choice.module !== opt.defaultModule;
return html`<button type="button" role="option" aria-selected=${selected ? 'true' : 'false'} data-chess=${chess.chessId}
class=${cx('lo-card', `lo-card--t${chess.tier}`, selected && 'is-sel', choice.changed && 'is-changed')} onClick=${() => onPick(chess.chessId)}
title=${`${chess.name} · ${skillRec?.name || ''}`}>
<span class="lo-card__art">
<${Img} src=${chessAvatarUrl(m, chess)} fallback=${html`<span class="lo-card__glyph">${[...(chess.name || '?')][0]}</span>`} />
</span>
<${TierChip} tier=${chess.tier} size="sm" class="lo-card__tier" />
${choice.changed ? html`<span class="lo-card__flag" aria-label=${t('已调整')}></span>` : null}
${notOwned ? html`<span class="lo-card__sub" title=${t('未持有(干员持有):由替补干员上场')} aria-label=${t('未持有')}>${t('替补')}</span>` : null}
<span class="lo-card__name">${chess.name}</span>
<span class="lo-card__kit">
<span class=${cx('lo-card__sk', choice.skill !== opt.defaultSkill && 'is-alt')}>
<${SkillIcon} m=${m} rec=${skillRec} index=${choice.skill} size="xs" />
<b class="num">${skillLabel(choice.skill)}</b>
</span>
${golden ? html`<span class=${cx('lo-card__mod', modChanged && 'is-alt')} title=${modRec ? `${modRec.typeName} ${modRec.name}` : t('不装备模组')}>
${choice.module === MODULE_NONE ? '—' : moduleBadge(modRec)}
</span>` : null}
</span>
/** One skill to tap: its icon, the equipped one ringed; the label / tooltip says slot, name, SP and duration (quickSkillTags). */
function QuickSkill({ m, opt, on, elite, onPick }) {
const rec = elite ? opt.elite || opt.normal : opt.normal || opt.elite;
const tags = quickSkillTags(rec);
const label = `${skillLabel(opt.index)} · ${rec?.name || t('未知技能')} · ${tags.sp} · ${t('初始')} ${tags.init ?? '—'} / ${t('消耗')} ${tags.cost ?? '—'} · ${t('持续')} ${tags.duration}`;
return html`<button type="button" class=${cx('lo-q', 'lo-q--skill', on && 'is-on')} data-skill=${opt.index} aria-pressed=${on ? 'true' : 'false'}
aria-label=${label} title=${label} onClick=${() => onPick(opt.index)}>
<${SkillIcon} m=${m} rec=${rec} index=${opt.index} on=${on} size="q" />
</button>`;
}
/** One module to tap (不装备 first): the type icon with its letter in the corner; the elite's equipped one ringed. */
function QuickModule({ m, opt, on, onPick }) {
const label = opt.id === MODULE_NONE ? t('不装备模组') : `${opt.rec?.typeName || ''} · ${opt.rec?.name || opt.id}`;
return html`<button type="button" class=${cx('lo-q', 'lo-q--mod', on && 'is-on')} data-module=${opt.id} aria-pressed=${on ? 'true' : 'false'}
aria-label=${label} title=${label} onClick=${() => onPick(opt.id)}>
<${ModuleGlyph} m=${m} rec=${opt.rec} id=${opt.id} size="q" />
${opt.id !== MODULE_NONE ? html`<b class="lo-q__type num" aria-hidden="true">${moduleBadge(opt.rec, opt.id)}</b>` : null}
</button>`;
}
/** The list's column heads (the rows share its grid). */
export function RosterHead() {
return html`<div class="lo-list__head" aria-hidden="true">
<span class="lo-list__h lo-list__h--op">${t('干员')}</span>
<span class="lo-list__h lo-list__h--skills">${t('技能')}</span>
<span class="lo-list__h lo-list__h--mods" title=${t('模组仅在精锐形态生效')}>${t('模组')}<small>${t('精锐')}</small></span>
<span class="lo-list__h lo-list__h--cult">${t('潜能')} · ${t('练度')}</span>
</div>`;
}
/**
* One operator's row (`.lo-card`, like the cards before it): portrait + name + bonds (`.lo-card__pick`: selects it, opens
* the detail), three skill slots (an empty one when the chess has two skills), the elite's modules, the 潜能 / 练度 selects.
* `level` 'elite': the skills' details at 精锐 Lv.7 (the shared preview). A chess marked not owned (干员持有) shows the
* 「替补」 tag: its stand-in fights with a fixed skill and no 潜能 / 练度, the choices here apply once it is owned again.
*/
export function RosterRow({ m, chess, golden, entries, ops = {}, selected, onPick, onChange, onOps, level = 'normal', notOwned = false }) {
const choice = effectiveChoice(entries, chess, golden);
const opt = chessOptions(chess, golden);
const cv = opsOf(ops, chess.charId);
const changed = choice.changed || cv.changed;
const modules = [...opt.moduleOptions].sort((a, b) => Number(b.id === MODULE_NONE) - Number(a.id === MODULE_NONE));
const elite = level === 'elite' && !!golden;
const slots = [0, 1, 2].map((i) => opt.skillOptions[i] || null);
const standInNote = notOwned ? t('未持有(干员持有):由替补干员上场,替补干员没有潜能与练度') : null;
return html`<div role="listitem" data-chess=${chess.chessId} data-variant=${elite ? 'elite' : 'normal'}
class=${cx('lo-card', `lo-card--t${chess.tier}`, selected && 'is-sel', changed && 'is-changed', notOwned && 'is-standin')}>
<button type="button" class="lo-card__pick" aria-pressed=${selected ? 'true' : 'false'} title=${chess.name} onClick=${() => onPick(chess.chessId)}>
<span class="lo-card__art">
<${Img} src=${chessAvatarUrl(m, chess)} fallback=${html`<span class="lo-card__glyph">${[...(chess.name || '?')][0]}</span>`} />
<${TierChip} tier=${chess.tier} size="sm" class="lo-card__tier" />
</span>
<span class="lo-card__id">
<span class="lo-card__name">${chess.name}</span>
<span class="lo-card__bonds">${(chess.bonds || []).map((b) => html`<${Img} key=${b} src=${bondIconUrl(m, b)} class="lo-card__bond"
alt=${data.lookup('bonds', b)?.name || b} fallback=${html`<i class="lo-bond__dot" title=${data.lookup('bonds', b)?.name || b}></i>`} />`)}</span>
${notOwned ? html`<span class="lo-card__sub" title=${t('未持有(干员持有):由替补干员上场')}>${t('替补')}</span>` : null}
</span>
</button>
${changed ? html`<span class="lo-card__flag" aria-label=${t('已调整')}></span>` : null}
<div class="lo-card__skills lo-quick" role="group" aria-label=${t('选择技能')}>
${slots.map((sk, i) => (sk ? html`<${QuickSkill} key=${sk.index} m=${m} opt=${sk} elite=${elite} on=${sk.index === choice.skill}
onPick=${(skill) => onChange(chess.chessId, { skill })} />` : html`<span key=${`e${i}`} class="lo-q lo-q--empty" aria-hidden="true"></span>`))}
</div>
<div class="lo-card__mods lo-quick" role="group" aria-label=${t('选择模组')} title=${t('模组仅在精锐形态生效')}>
${golden ? modules.map((mo) => html`<${QuickModule} key=${mo.id} m=${m} opt=${mo} on=${mo.id === choice.module}
onPick=${(module) => onChange(chess.chessId, { module })} />`) : null}
</div>
<${CultivationSelects} charId=${chess.charId} ops=${ops} onSet=${onOps} note=${standInNote} />
</div>`;
}
// ---- detail --------------------------------------------------------------------------------------------------------------
/** The same attribute records and rich-text renderer as the in-match card, including literal trigger markers. */
export function LoadoutGarrisons({ chess, m }) {
const records = (chess?.garrisonIds || []).map((id) => data.lookup('garrisons', id)).filter(Boolean);
return records.length ? html`<section class="lo-garrisons" data-variant=${chess.isGolden ? 'elite' : 'normal'}>
${records.map((garrison) => html`<${GarrisonBlock} key=${garrison.garrisonId} garrison=${garrison} m=${m} />`)}
</section>` : null;
}
function SkillOption({ m, opt, on, level, onPick }) {
const rec = level === 'elite' ? opt.elite || opt.normal : opt.normal || opt.elite;
const tags = skillTags(rec);
@@ -198,9 +266,16 @@ function SkillOption({ m, opt, on, level, onPick }) {
</button>`;
}
export function ModuleInfo({ m, golden, opt }) {
/**
* The chosen module's card: its stat bonus, the 特性 it fights with, its talent changes — read at the operator's 潜能
* (`potential`, 0.2.2: the elite composed at it first, shared/potential.js atPotential, as 局内数值 and the battle do; the
* data record is the full-potential one — 余's 闲云隐市 「生命上限2%(+0.5%)」 is 「1.5%」 at 潜能1). null = full potential.
* @param {{ m: any, golden: any, opt: { id: string, rec: any, isDefault?: boolean }, potential?: number|null }} props
*/
export function ModuleInfo({ m, golden, opt, potential = null }) {
if (!golden) return null;
const rec = opt.id === MODULE_NONE ? null : opt.rec;
const at = atPotential(golden, potential);
const rec = opt.id === MODULE_NONE ? null : at === golden ? opt.rec : moduleRecord(at, opt.id) || opt.rec;
if (!rec) {
const traitBase = golden.traitBase || null;
return html`<div class="lo-minfo lo-minfo--none">
@@ -227,67 +302,19 @@ export function ModuleInfo({ m, golden, opt }) {
</div>`;
}
const getChessRec = (id) => data.lookup('chess', id);
/** The name of a chess's 补位 stand-in (data/backups.json), or null. */
const standInName = (chess) => data.get('backups')?.units?.[chess?.backup?.charId]?.name ?? null;
/**
* What 局内数值 shows (GitHub issue #64): the chess variant — the 精锐 record when asked for and the chess has one, else
* the normal one — as the stored loadout makes it. chessLoadout (ui/gameLogic.js) resolves the skill and module the way
* the in-match detail card and the sim do (shared/loadoutRecord.js): the elite's chosen module's stats / 特性 / talents
* (不装备: the base ones), the chosen skill's passive range; a normal chess has no module, the skill does not change its
* stats. Nothing is recomputed here.
* @param {any} base normal chess record @param {any} golden its elite record or null
* @param {Record<string, any>} entries the stored loadout @param {'normal'|'elite'} level
* @param {(id: string) => any} getChess
* @returns {{ elite: boolean, chess: any, lo: any, record: any, trait: string, talents: any[] } | null} null without a record
*/
export function statsPreview(base, golden, entries, level, getChess) {
const elite = level === 'elite' && !!golden;
const chess = elite ? golden : base;
if (!chess) return null;
const lo = chessLoadout(chess, entries, getChess);
const record = lo?.record || chess;
// (the card's own rule: the 特性 line exists when the chess has one; its text follows the chosen module)
return { elite, chess, lo, record, trait: chess.trait?.desc ? traitText(chess, !!chess.isGolden, lo) || '' : '', talents: chessTalents(record) };
}
/**
* 局内数值: the stats, 攻击范围, 特性 and 天赋 of the selected chess under its chosen skill and module — the detail
* card's stats block (ui/detailPanel.js chessStatsBlock) without live numbers, so what a player reads here is what the
* shop / board card shows before a battle (not the equipment, bond or skill-cast changes of a running match). The
* toggle picks the 普通 or the 精锐 record; 精锐 is the default because the module only exists there.
* @param {{ base: any, golden: any, entries: Record<string, any>, level: 'normal'|'elite', onLevel: (l: 'normal'|'elite') => void, getChess?: (id: string) => any }} props
*/
export function LoadoutStats({ base, golden, entries, level, onLevel, getChess = getChessRec }) {
const pv = statsPreview(base, golden, entries, level, getChess);
if (!pv) return null;
return html`<section class="lo-sec lo-sec--stats" aria-label=${t('局内数值')} data-variant=${pv.elite ? 'elite' : 'normal'}>
<header class="lo-sec__head">
<h3>${t('局内数值')}<${MicroLabel}>STATS<//></h3>
<div class="lo-seg" role="tablist" aria-label=${t('数值版本')}>
<button type="button" role="tab" aria-selected=${pv.elite ? 'false' : 'true'} class=${cx(!pv.elite && 'is-on')} data-variant="normal" onClick=${() => onLevel('normal')}>${t('普通')}</button>
<button type="button" role="tab" aria-selected=${pv.elite ? 'true' : 'false'} class=${cx(pv.elite && 'is-on')} data-variant="elite" disabled=${!golden} onClick=${() => onLevel('elite')}>${t('精锐')}</button>
</div>
</header>
${chessStatsBlock({ rec: pv.record, chess: pv.chess })}
${pv.trait || pv.talents.length ? html`<div class="lo-minfo lo-minfo--kit">
${pv.trait ? html`<div class="lo-minfo__row"><span class="lo-minfo__k">${t('特性')}</span><${RichText} class="lo-minfo__v" text=${pv.trait} /></div>` : null}
${pv.talents.map((tal, i) => html`<div key=${i} class="lo-minfo__row"><span class="lo-minfo__k">${t('天赋')}</span>
<span class="lo-minfo__v"><b class="lo-minfo__tname">${tal.name}</b><${RichText} text=${tal.descRaw || tal.desc || ''} /></span></div>`)}
</div>` : null}
<p class="lo-stats__cap">${pv.elite ? t('数值含所选模组;') : golden ? t('普通干员没有模组,所选模组在「精锐」中生效;') : ''}${t('不含技能发动、装备、盟约等局内加成')}</p>
</section>`;
}
function Detail({ m, chess, golden, entries, onChange, onReset, locked, notOwned = false }) {
const [level, setLevel] = useState('normal');
function Detail({ m, chess, golden, entries, ops = {}, onChange, onOps, onReset, locked, level = 'normal', onLevel, notOwned = false }) {
const [statLevel, setStatLevel] = useState('elite'); // 局内数值: the 精锐 shows the chosen module's effect
const setLevel = onLevel;
const bodyRef = useRef(null);
useEffect(() => { if (bodyRef.current) bodyRef.current.scrollTop = 0; }, [chess?.chessId]);
if (!chess) return html`<aside class="lo-detail lo-detail--empty"><p class="t-dim">${t('没有符合条件的干员')}</p></aside>`;
const opt = chessOptions(chess, golden);
const choice = effectiveChoice(entries, chess, golden);
const changed = choice.changed || opsOf(ops, chess.charId).changed;
const modOpt = opt.moduleOptions.find((x) => x.id === choice.module) || null;
const lv = (c) => c?.status?.skillLevel ?? '—';
return html`<aside class="lo-detail" aria-label=${t('{name} 调配', { name: chess.name })}>
@@ -297,7 +324,7 @@ function Detail({ m, chess, golden, entries, onChange, onReset, locked, notOwned
</div>
<div class="lo-dhead__info">
<div class="lo-dhead__chips"><${TierChip} tier=${chess.tier} size="md" />
${choice.changed ? html`<span class="lo-badge lo-badge--changed">${t('已调整')}</span>` : html`<span class="lo-badge lo-badge--plain">${t('默认配置')}</span>`}</div>
${changed ? html`<span class="lo-badge lo-badge--changed">${t('已调整')}</span>` : html`<span class="lo-badge lo-badge--plain">${t('默认配置')}</span>`}</div>
<h2 class="lo-dhead__name">${chess.name}</h2>
<span class="lo-dhead__en">${chess.appellation || ''}</span>
<span class="lo-dhead__class">
@@ -307,9 +334,12 @@ function Detail({ m, chess, golden, entries, onChange, onReset, locked, notOwned
<span class="lo-dhead__bonds">${(chess.bonds || []).map((b) => html`<span key=${b} class="lo-bond">
<${Img} src=${bondIconUrl(m, b)} class="lo-bond__icon" fallback=${html`<i class="lo-bond__dot"></i>`} />${data.lookup('bonds', b)?.name || b}</span>`)}</span>
</div>
<${Button} variant="ghost" size="sm" icon="refresh" class="lo-dhead__reset" disabled=${!choice.changed} onClick=${onReset}>${t('恢复默认')}<//>
<${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">
<header class="lo-sec__head">
<h3>${t('技能')}<${MicroLabel}>SKILL<//></h3>
@@ -323,7 +353,7 @@ function Detail({ m, chess, golden, entries, onChange, onReset, locked, notOwned
onPick=${(i) => onChange({ skill: i })} />`)}
</div>
</section>
<${LoadoutStats} base=${chess} golden=${golden} entries=${entries} level=${statLevel} onLevel=${setStatLevel} />
<${LoadoutStats} base=${chess} golden=${golden} entries=${entries} ops=${ops} level=${statLevel} onLevel=${setStatLevel} />
${golden ? html`<section class="lo-sec lo-sec--mod">
<header class="lo-sec__head">
<h3>${t('模组')}<${MicroLabel}>MODULE<//></h3>
@@ -341,7 +371,7 @@ function Detail({ m, chess, golden, entries, onChange, onReset, locked, notOwned
${mo.isDefault ? html`<span class="lo-badge lo-badge--def lo-mod__def">${t('默认')}</span>` : null}
</button>`)}
</div>
${modOpt ? html`<${ModuleInfo} m=${m} golden=${golden} opt=${modOpt} />` : null}
${modOpt ? html`<${ModuleInfo} m=${m} golden=${golden} opt=${modOpt} potential=${opsOf(ops, chess.charId).potential} />` : null}
</section>` : null}
${notOwned ? html`<p class="lo-locknote lo-locknote--standin" data-testid="loadout-standin-note"><${Icon} name="info" />${standInName(chess) ? t('干员持有中标记为未持有:此棋子由替补干员 {name} 上场,技能与模组固定(补位干员技能不可更改);这里的调配在改回「持有」后生效', { name: standInName(chess) })
: t('干员持有中标记为未持有:此棋子由替补干员上场,技能与模组固定(补位干员技能不可更改);这里的调配在改回「持有」后生效')}</p>` : null}
@@ -433,7 +463,7 @@ export function DataMissing({ files }) {
/** The overlay screen. */
function LoadoutScreen({ st }) {
const ready = useData('chess', 'bonds', 'assets', 'local', 'backups');
const ready = useData('chess', 'bonds', 'assets', 'local', 'backups', 'garrisons', 'effects');
const phase = useStore((s) => s.match?.public?.phase || null);
const inMatch = useStore((s) => !!s.room?.inMatch);
// co-op briefing (INFO_CHECK, 25 s): the overlay covers the briefing's own countdown, so it shows the time left — the
@@ -448,14 +478,16 @@ function LoadoutScreen({ st }) {
return (data.list('bonds') || []).filter((b) => b && used.has(b.bondId))
.sort((a, b) => (b.isCore ? 1 : 0) - (a.isCore ? 1 : 0) || (a.bondOrder ?? 0) - (b.bondOrder ?? 0) || String(a.name).localeCompare(String(b.name), 'zh'));
}, [ready, roster]);
const list = filterRoster(roster, st.filters, st.entries, getChess, getBond);
const list = filterRoster(roster, st.filters, st.entries, getChess, getBond, st.ops);
const selId = st.sel && roster.some((c) => c.chessId === st.sel) ? st.sel : list[0]?.chessId || roster[0]?.chessId || null;
const { base, golden } = selId ? recordsOf(selId, getChess) : { base: null, golden: null };
const nChanged = changedCount(st.entries, getChess);
const nChanged = changedCount(st.entries, getChess, st.ops, roster);
const locked = (inMatch && phase && phase !== PHASE.INFO_CHECK && phase !== PHASE.LOBBY) || st.sync === 'locked';
const gridRef = useRef(null);
const fileRef = useRef(null); // hidden <input type=file> of the 导入 dialog
const [narrowDetail, setNarrowDetail] = useState(false); // phones: the detail slides over the roster
// the detail's 普通 / 精锐 skill toggle: one preview for its skills, its 特质 and the rows' skill details (display only)
const [previewLevel, setPreviewLevel] = useState('normal');
const [io, setIo] = useState(null); // 导出 / 导入 dialog: { mode, text } | null
const tab = st.tab === 'ownership' || st.tab === 'diy' ? st.tab : 'loadout';
@@ -479,12 +511,30 @@ function LoadoutScreen({ st }) {
if (ok) setNotOwned([]);
};
const pick = (id) => { loadoutStore.set({ sel: id }); setNarrowDetail(true); };
// a row's quick choice (PR #301): the same setChoice / setEntries path; the row becomes the selection, a phone stays on the list
const quickChange = (id, patch) => {
const recs = recordsOf(id, getChess);
setEntries(setChoice(loadoutStore.get().entries, recs.base, recs.golden, patch));
loadoutStore.set({ sel: id });
};
const change = (patch) => { if (base) setEntries(setChoice(loadoutStore.get().entries, base, golden, patch)); };
const resetOne = () => { if (base) setEntries(resetChoice(loadoutStore.get().entries, base.chessId)); };
// 0.2.2: an operator's 潜能 / 练度 (by charId: its normal, elite and 自选 forms share them)
const setOpsOf = (charId, patch) => setOpsMap(setOps(loadoutStore.get().ops, charId, patch));
const resetOne = () => {
if (!base) return;
setOpsMap(resetOps(loadoutStore.get().ops, base.charId));
setEntries(resetChoice(loadoutStore.get().entries, base.chessId));
};
const resetAll = async () => {
if (!nChanged) return;
const ok = await confirmDialog({ title: t('全部恢复默认'), text: t('将 {nChanged} 名干员的技能与模组恢复为默认配置?', { nChanged }), okText: t('恢复默认'), danger: true });
if (ok) setEntries({});
const ok = await confirmDialog({ title: t('全部恢复默认'), text: t('将 {nChanged} 名干员的技能、模组、潜能与练度恢复为默认配置?', { nChanged }), okText: t('恢复默认'), danger: true });
if (!ok) return;
// the roster's operators only: a 自选 pick's settings belong to the 自选编队 tab
const rosterChars = new Set(roster.map((c) => c.charId));
const keep = {};
for (const [id, e] of Object.entries(loadoutStore.get().ops || {})) if (!rosterChars.has(id)) keep[id] = e;
setOpsMap(keep);
setEntries({});
};
// 导出 / 导入 the loadout (or, on the 干员持有 tab, the not-owned list) as the versioned payload (a downloaded file,
@@ -495,7 +545,7 @@ function LoadoutScreen({ st }) {
const openExport = () => setIo(tab === 'ownership'
? { mode: 'export', kind: 'ownership', text: serializeOwnership(loadoutStore.get().notOwned) }
: tab === 'diy' ? { mode: 'export', kind: 'diy', text: serializeDiy(loadoutStore.get().diy) }
: { mode: 'export', kind: 'loadout', text: serializeExport(loadoutStore.get().entries) });
: { mode: 'export', kind: 'loadout', text: serializeExport(loadoutStore.get().entries, { ops: loadoutStore.get().ops }) });
const openImport = () => setIo({ mode: 'import', kind: tab, text: '' });
const ioCopy = async () => {
const ok = await copyText(ioText);
@@ -536,13 +586,17 @@ function LoadoutScreen({ st }) {
}
const res = parseImport(ioText);
if (!res.ok) { toast(t('导入失败:{error}', { error: res.error }), 'error'); return; }
const { applied, dropped } = applyLoadoutEntries(res.entries, getChess);
// 0.2.2: a payload with `ops` replaces the 潜能 / 练度 too (an older export leaves them alone)
const opIds = res.ops ? cultivationCharIds(data.get('chess'), data.get('backups')) : null;
const r = applyLoadoutEntries(res.entries, getChess, res.ops ? { ops: res.ops, isOperator: (id) => opIds.has(id) } : {});
const { applied, dropped } = r;
const nOps = r.ops ?? 0;
// nothing survived sanitising (unknown chess, or every choice already the default): keep the current loadout
if (!applied) { toast(t('导入失败:这份数据在当前版本没有可用的调配,未做任何改动'), 'error'); return; }
if (!applied && !nOps) { toast(t('导入失败:这份数据在当前版本没有可用的调配,未做任何改动'), 'error'); return; }
setIo(null);
toast(dropped
? t('已导入 {applied} 名干员(另有 {dropped} 项未导入)', { applied, dropped })
: t('已导入 {applied} 名干员的调配', { applied }), dropped ? 'warn' : 'success');
toast(!applied ? t('已导入 {n} 名干员的潜能与练度', { n: nOps })
: dropped ? t('已导入 {applied} 名干员(另有 {dropped} 项未导入)', { applied, dropped })
: t('已导入 {applied} 名干员的调配', { applied }), dropped ? 'warn' : 'success');
};
// Esc closes; ←/→ browse the filtered roster (not while typing in the search field)
@@ -554,8 +608,10 @@ function LoadoutScreen({ st }) {
const typing = e.target && /^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName);
if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); closeLoadout(); return; }
if (typing) return;
// (a row's quick choices: ←/→ stay with the focused group, PR #301)
if (e.target?.closest?.('.lo-quick') && (e.key === 'ArrowRight' || e.key === 'ArrowLeft')) return;
if ((e.key === 'ArrowRight' || e.key === 'ArrowLeft') && loadoutStore.get().tab !== 'ownership' && loadoutStore.get().tab !== 'diy') {
const ids = filterRoster(rosterOf(data.list('chess')), loadoutStore.get().filters, loadoutStore.get().entries, getChess, getBond).map((c) => c.chessId);
const ids = filterRoster(rosterOf(data.list('chess')), loadoutStore.get().filters, loadoutStore.get().entries, getChess, getBond, loadoutStore.get().ops).map((c) => c.chessId);
if (!ids.length) return;
const cur = Math.max(0, ids.indexOf(loadoutStore.get().sel));
const next = ids[(cur + (e.key === 'ArrowRight' ? 1 : -1) + ids.length) % ids.length];
@@ -577,7 +633,7 @@ function LoadoutScreen({ st }) {
const [syncText, syncCls] = tab === 'ownership' ? OWN_SYNC_TEXT[st.ownSync] || OWN_SYNC_TEXT.idle
: tab === 'diy' ? DIY_SYNC_TEXT[st.diySync] || DIY_SYNC_TEXT.idle : SYNC_TEXT[st.sync] || SYNC_TEXT.idle;
const fromText = st.from === 'briefing' ? t('确认本局信息阶段结束前可调整本局配置') : t('开始模拟前可调整干员携带的技能与模组,干员等级不可调整');
const fromText = st.from === 'briefing' ? t('确认本局信息阶段结束前可调整本局配置') : t('开始模拟前可调整干员携带的技能与模组,以及潜能与练度;干员的局内等级不可调整');
// 干员持有 is out of match: a running match keeps the list its seat had at its start
const ownLocked = inMatch && !!phase && phase !== PHASE.LOBBY;
const ownText = ownLocked ? t('干员持有是局外设置:本局按开局时的设置进行,修改将在下一局生效')
@@ -631,20 +687,25 @@ function LoadoutScreen({ st }) {
: html`<p class=${cx('lo-note', locked && 'is-locked')}><${Icon} name="info" />${locked ? t('本局的调配已锁定(确认本局信息后无法修改),修改将在下一局生效') : fromText}</p>`}
${!ready ? html`<div class="lo-loading"><${Spinner} size="sm" />${t('正在载入干员数据(打开页面后仅载入一次)…')}</div>`
: lost.length ? html`<${DataMissing} files=${lost} />` : tab === 'diy'
? html`<${DiyPanel} m=${m} picks=${st.diy || {}} legal=${diyLegal} kitted=${st.diyKitted} onSet=${setDiySlot} />`
? html`<${DiyPanel} m=${m} picks=${st.diy || {}} legal=${diyLegal} kitted=${st.diyKitted} onSet=${setDiySlot} ops=${st.ops} onOps=${setOpsOf} />`
: tab === 'ownership'
? html`<${OwnershipPanel} m=${m} roster=${ownRoster} notOwned=${st.notOwned} onToggle=${toggleOwned} />`
: html`<main class=${cx('lo-body', narrowDetail && 'is-detail')}>
<section class="lo-roster">
<${Filters} m=${m} filters=${st.filters} bonds=${bonds} onFilters=${(filters) => loadoutStore.set({ filters })} />
<div class="lo-grid" role="listbox" aria-label=${t('干员列表')} ref=${gridRef}>
${list.length ? list.map((c) => html`<${RosterCard} key=${c.chessId} m=${m} chess=${c} golden=${c.goldenId ? getChess(c.goldenId) : null}
entries=${st.entries} selected=${c.chessId === selId} onPick=${pick} notOwned=${(st.notOwned || []).includes(c.chessId)} />`) : html`<p class="lo-empty t-dim">${t('没有符合条件的干员')}</p>`}
<div class="lo-list" ref=${gridRef}>
<${RosterHead} />
<div class="lo-list__rows" role="list" aria-label=${t('干员列表')}>
${list.length ? list.map((c) => html`<${RosterRow} key=${c.chessId} m=${m} chess=${c} golden=${c.goldenId ? getChess(c.goldenId) : null}
entries=${st.entries} ops=${st.ops} selected=${c.chessId === selId} onPick=${pick} onChange=${quickChange} onOps=${setOpsOf}
level=${previewLevel} notOwned=${(st.notOwned || []).includes(c.chessId)} />`) : html`<p class="lo-empty t-dim">${t('没有符合条件的干员')}</p>`}
</div>
</div>
</section>
<div class="lo-detail-wrap">
<button type="button" class="lo-detail-back tapx" onClick=${() => setNarrowDetail(false)}><${Icon} name="chevronLeft" />${t('干员列表')}</button>
<${Detail} m=${m} chess=${base} golden=${golden} entries=${st.entries} onChange=${change} onReset=${resetOne} locked=${locked}
<${Detail} m=${m} chess=${base} golden=${golden} entries=${st.entries} ops=${st.ops} onChange=${change} onOps=${setOpsOf} onReset=${resetOne} locked=${locked}
level=${golden ? previewLevel : 'normal'} onLevel=${setPreviewLevel}
notOwned=${!!base && (st.notOwned || []).includes(base.chessId)} />
</div>
</main>`}
@@ -711,11 +772,13 @@ export function LoadoutHost() {
/**
* The entry button's badge: the same number as the screen's 已调整 N (a stored entry of a chess the data no longer offers
* never applies) once chess.json is loaded; before that the stored entries (the badge alone must not trigger the 1.6 MB
* download). Review fix: it counted stale entries the screen does not.
* download). Review fix: it counted stale entries the screen does not. With `ops` (0.2.2) the operators whose 潜能 / 练度
* differ count too (before chess.json: the stored entries plus the stored settings, an operator possibly twice).
* @param {Record<string, any>} entries @param {((id: string) => any) | null} getChess null while chess.json is not loaded
* @param {Record<string, any>|null} [ops] @param {any[]|null} [roster] rosterOf(…) of the loaded data
*/
export function badgeCount(entries, getChess) {
return getChess ? changedCount(entries, getChess) : Object.keys(entries || {}).length;
export function badgeCount(entries, getChess, ops = null, roster = null) {
return getChess ? changedCount(entries, getChess, ops, roster) : Object.keys(entries || {}).length + Object.keys(ops || {}).length;
}
/**
@@ -725,9 +788,11 @@ export function badgeCount(entries, getChess) {
export function LoadoutButton({ from, size = 'md', variant = 'secondary', class: cls, label = t('干员调配') }) {
useData('local'); // the official preset icon (re-render once the local-art manifest arrives)
const entries = useStore((s) => s.entries, Object.is, loadoutStore);
const ops = useStore((s) => s.ops, Object.is, loadoutStore);
const notOwned = useStore((s) => s.notOwned, Object.is, loadoutStore);
const diy = useStore((s) => s.diy, Object.is, loadoutStore);
const n = badgeCount(entries, data.status('chess') === 'ready' ? (id) => data.lookup('chess', id) : null);
const chessReady = data.status('chess') === 'ready';
const n = badgeCount(entries, chessReady ? (id) => data.lookup('chess', id) : null, ops, chessReady ? rosterOf(data.list('chess')) : null);
// 0.2.0 补位: how many operators the player marked as not owned (the 干员持有 tab) — easy to forget between sessions
const off = Array.isArray(notOwned) ? notOwned.length : 0;
// 0.2.0 自选编队: how many DIY slots the player filled
+16 -5
View File
@@ -11,11 +11,15 @@
// 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';
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';
@@ -305,21 +309,28 @@ export function LobbyScreen() {
<div class="topbar__left">
<${Button} variant="ghost" size="sm" icon="chevronLeft" onClick=${backToTitle} title=${t('返回标题')}>${t('返回')}<//>
<${PingPill} ms=${conn.ping} online=${online} />
<span class="topbar__sep" aria-hidden="true"></span>
<div class="topbar__tools">
<${SettingsButton} class="lobby-settings" variant="secondary" square=${true} />
<${GuideButton} class="lobby-guide" variant="secondary" square=${true} />
<${PwaInstallButton} class="lobby-pwa" square=${true} />
</div>
</div>
<div class="topbar__center">
<${MicroLabel} tone="mint">SIMULATION PROTOCOL SELECT<//>
<h1 class="topbar__title">${t('选择模拟协议')}</h1>
</div>
<div class="topbar__right">
<${GuideButton} class="lobby-guide" variant="secondary" label=${t('玩法说明')} />
<${ResumeMatchButton} />
<${LoadoutButton} from="lobby" size="sm" class="lobby-loadout" label=${t('干员调配')} />
<div class="me-chip">
<button type="button" class="me-chip me-chip--btn stats-entry" onClick=${openStats} title=${t('统计数据')} aria-label=${t('统计数据')}>
<${AvatarFrame} size="sm" name=${me.name} seat=${0} self=${true} />
<div class="me-chip__text">
<span class="me-chip__text">
<span class="me-chip__name">${me.name || t('博士')}</span>
<${MicroLabel}>${me.playerId != null ? `DOCTOR #${doctorNo(me.playerId)}` : 'DOCTOR'}<//>
</div>
</div>
</span>
<${Icon} name="chart" class="me-chip__stats" />
</button>
</div>
</header>
+15 -4
View File
@@ -103,12 +103,22 @@ function PlayerCard({ p, myId, titles, best, solo = false }) {
</article>`;
}
/** RESULT screen. */
/** RESULT screen of the match on screen: reads the store; 返回同盟 / 返回大厅 drops the match. */
export function ResultScreen() {
const res = useStore((s) => s.match.result);
const pub = useStore((s) => s.match.public);
const myId = useStore((s) => s.me.playerId);
const hasRoom = useStore((s) => !!s.room);
const back = () => store.set({ match: emptyMatch() });
return html`<${ResultView} res=${res} pub=${pub} myId=${myId} backLabel=${hasRoom ? t('返回同盟') : t('返回大厅')} onBack=${back} />`;
}
/**
* The settlement view of one m.result payload — the live screen above, and the stats page's re-view of a stored match
* (screens/stats.js, which hands it the payload recordToResult rebuilt; `quiet`: no settlement jingle).
* @param {{ res: any, pub?: any, myId?: string|null, backLabel: string, onBack: () => void, quiet?: boolean }} props
*/
export function ResultView({ res, pub = null, myId = null, backLabel, onBack, quiet = false }) {
const gd = useGameData();
const r = normalizeResult(res, pub);
const titles = Array.isArray(gd.config?.titles) ? gd.config.titles : [];
@@ -118,8 +128,7 @@ export function ResultScreen() {
for (const p of r.players) if (Number.isFinite(p.stats[k]) && p.stats[k] > 0 && (top == null || p.stats[k] > top.v)) top = { v: p.stats[k], id: p.playerId };
if (top && r.players.length > 1) best[k] = top.id;
}
useEffect(() => { audio.sfx(r.victory ? 'settlementSucceed' : 'settlementFail'); }, []);
const back = () => store.set({ match: emptyMatch() });
useEffect(() => { if (!quiet) audio.sfx(r.victory ? 'settlementSucceed' : 'settlementFail'); }, []);
const boss = r.bossId ? gd.boss(r.bossId) : null;
// the Hidden Core medal (and its corrupted leader) only once R15 was actually fought
const hidden = r.hiddenBossId && r.hiddenReached ? gd.boss(r.hiddenBossId) : null;
@@ -131,6 +140,7 @@ export function ResultScreen() {
<div class="result__grid" aria-hidden="true"></div>
<main class="result__main">
<section class="result__hero">
<div class="result__summary">
<div class="result__logo"><${Sprite} k="entry/season_logo_settle" class="result__logoimg" fallback=${html`<${MicroLabel} tone="mint">STRONGHOLD PROTOCOL</${MicroLabel}>`} /></div>
${r.difficulty ? html`<${DifficultyTag} difficulty=${r.difficulty} size="lg" />` : null}
<h1 class="result__headline">${r.victory ? t('模拟完成') : t('模拟失败')}</h1>
@@ -149,8 +159,9 @@ export function ResultScreen() {
<span class="medal__label">${t('隐秘核心')}</span></div>` : null}
</div>
${mins ? html`<p class="result__time t-lo">${tParts('本局耗时 {n} 分钟', { n: html`<b class="num">${mins}</b>`, mins })}</p>` : null}
</div>
<footer class="result__foot">
<${Button} variant="primary" size="xl" icon="chevronLeft" onClick=${back}>${hasRoom ? t('返回同盟') : t('返回大厅')}<//>
<${Button} variant="primary" size="xl" icon="chevronLeft" onClick=${onBack}>${backLabel}<//>
</footer>
</section>
<section class="result__players">
+39 -3
View File
@@ -1,6 +1,6 @@
// Room screen (同盟等待室): 4 seat cards (avatar frame, name, ready state, AI badge, host crown),
// host controls (difficulty picker, add/remove AI in co-op, start), invite code with copy code /
// copy link, ready toggle and leave.
// host controls (difficulty picker, add/remove AI and the 「AI 队友最后选择」 switch in co-op, start), invite code with
// copy code / copy link, ready toggle and leave.
//
// Start rule (server/lobby.js): room.start needs every *other* human connected and ready; the
// host's start counts as the host's ready. So 开始模拟 is enabled exactly then and sends room.start
@@ -19,6 +19,8 @@ import {
import { toast, toastError } from '../ui/toasts.js';
import { copyText } from '../ui/clipboard.js';
import { GuideButton } from '../ui/guide.js';
import { openStats } from './stats.js';
import { SettingsButton } from '../ui/settings.js';
import { LoadoutButton } from './loadout.js';
import { net } from '../net.js';
import { store, useStore, shallowEqual, emptyMatch, isSpectating } from '../store.js';
@@ -69,6 +71,19 @@ export function roomFacts(room, myId) {
};
}
/**
* The co-op room option 「AI 队友最后选择」 (GitHub #338; room.setAiPicksLast, room.state.aiPicksLast): in the strategy and
* 机变 drafts every human picks before the AI teammates. null in a solo room (no AI teammates); otherwise its state (off
* when the server sends none) and whether this player may switch it — the host only, the others see it read-only.
* @param {any} room room.state payload
* @param {any} myId
* @returns {{ on: boolean, editable: boolean } | null}
*/
export function aiLastOption(room, myId) {
if (!room || room.mode === 'solo') return null;
return { on: room.aiPicksLast === true, editable: room.hostId != null && room.hostId === myId };
}
/** Invite link for a room code (current page URL with ?room=CODE). */
export function inviteLink(code) {
const loc = globalThis.location;
@@ -189,6 +204,21 @@ function DifficultyPicker({ room, isHost, busy, onPick }) {
</div>`;
}
/** The 「AI 队友最后选择」 switch (co-op): the host toggles it, everybody else sees its state. */
function AiLastToggle({ option, busy, onToggle }) {
if (!option) return null;
return html`<div class="ailast">
<${Tooltip} text=${t('策略与机变轮选时,所有博士先于 AI 队友选择')}>
<button type="button" role="switch" aria-checked=${option.on ? 'true' : 'false'}
class=${`dpick__opt ailast__opt${option.on ? ' is-active' : ''}`} disabled=${!option.editable || !!busy}
onClick=${() => option.editable && onToggle(!option.on)}>
<${Icon} name="check" class=${option.on ? 'is-on' : ''} />${t('AI 队友最后选择')}
</button>
<//>
${option.editable ? null : html`<span class="t-dim">${t('由创建者设置')}</span>`}
</div>`;
}
/** Room screen component. */
export function RoomScreen() {
const room = useStore((s) => s.room);
@@ -229,6 +259,7 @@ export function RoomScreen() {
if (ok) run(`kick${seat}`, () => net.request('room.kick', { seat, playerId }));
};
const setDifficulty = (difficulty) => run('diff', () => net.request('room.setDifficulty', { difficulty }));
const setAiLast = (on) => run('ailast', () => net.request('room.setAiPicksLast', { on }));
// spectator seats: the host frees one; a spectator takes a free player seat with room.join of this room
const removeSpectator = (playerId) => run(`rs${playerId}`, () => net.request('room.removeSpectator', { playerId }));
const sit = () => run('sit', () => net.request('room.join', { code: room.code }));
@@ -277,6 +308,8 @@ export function RoomScreen() {
<${PingPill} ms=${conn.ping} online=${online} />
<${MicroLabel}>${t('当前延迟')}<//>
</div>
<${Button} variant="secondary" size="sm" icon="chart" class="stats-entry" onClick=${openStats} title=${t('统计数据')} aria-label=${t('统计数据')}>${t('统计')}<//>
<${SettingsButton} class="room-settings" variant="secondary" label=${t('设置')} />
<${GuideButton} class="room-guide" variant="secondary" label=${t('玩法说明')} />
</div>
<div class="topbar__center">
@@ -307,7 +340,10 @@ export function RoomScreen() {
<footer class="room-bar">
<div class="room-bar__left">
<span class="room-bar__label">${t('模拟难度')}<${MicroLabel}>DIFFICULTY<//></span>
<${DifficultyPicker} room=${room} isHost=${facts.isHost} busy=${busy} onPick=${setDifficulty} />
<div class="room-bar__opts">
<${DifficultyPicker} room=${room} isHost=${facts.isHost} busy=${busy} onPick=${setDifficulty} />
<${AiLastToggle} option=${aiLastOption(room, me.playerId)} busy=${busy} onToggle=${setAiLast} />
</div>
</div>
<div class="room-bar__center">
<div class="ready-count" hidden=${!coop}>
+360
View File
@@ -0,0 +1,360 @@
// The local stats viewer (统计数据): this browser's record of the matches the player took part in — see ui/stats.js for
// what is recorded, what counts and how the storage is bounded. A full-screen overlay opened imperatively from the
// title screen / lobby / room (`openStats()`); <StatsHost/> is mounted once by main.js. Ported from PR #323 by
// @2321Robin; reworked for 0.2.2 in the grammar of 干员调配 (top bar with tabs, a note line, a scrolling body).
//
// 总览 – games, wins, win rate, hidden-core reaches / clears, best round, time played; the difficulties; the six
// 评语 (titles) with their counts; what counts and what was left out
// 策略 – per strategy (band): games used / passed (self alive on a winning team) / pass rate
// 战斗累计 – the player's summed combat bookkeeping of the settled matches
// 对局记录 – every stored match, newest first. A settled row re-views that match's settlement screen (the record is
// inverted into an m.result payload for <ResultView/>); a match given up early is marked 中途退出 and,
// when it did not count, 不计入统计
// Everything is 本机数据 (localStorage, `sp.pref.stats`) and the page says so, teammates' names included. 导出 / 导入 (a
// JSON file, merged by record id on the way in) moves it between devices; 清空 resets it after a confirm dialog.
// The host renders nothing until opened and the game data files the replay needs load only then (the lobby alone never
// downloads them): the overview reads config / bands / assets only.
import { useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS } from '../../../shared/constants.js';
import { html, Icon, MicroLabel, Spinner, Button, confirmDialog } from '../ui/components.js';
import { useGameData, makeLookups, BandIcon, Img } from '../ui/gameComponents.js';
import { data, useData } from '../data.js';
import { fmtNum } from '../ui/gameLogic.js';
import { titleIconUrl } from '../ui/assetUrls.js';
import { useDocClass } from '../ui/device.js';
import { createStore, useStore, selectRoute } from '../store.js';
import { ResultView } from './result.js';
import { toast, toastError } from '../ui/toasts.js';
import {
loadStats, saveStats, emptyStats, importStats, exportStats, aggregateStats, countsTowardStats, selfRowOf, roundsOf,
recordToResult, MAX_RECORDS, FULL_KEEP, IMPORT_MAX_CHARS,
} from '../ui/stats.js';
import { t, N_ } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/** Rows of 对局记录 shown at first and added by each 显示更多. */
export const HISTORY_PAGE = 50;
const statsStore = createStore({ open: false, tab: 'overview' });
/** Open the stats viewer (title screen / lobby / room entry points). */
export function openStats() {
data.load('config');
data.load('bands');
data.load('assets');
statsStore.set({ open: true, tab: 'overview' });
}
export const closeStats = () => statsStore.set({ open: false });
const TABS = [
{ id: 'overview', name: N_('总览'), micro: 'OVERVIEW' },
{ id: 'bands', name: N_('策略'), micro: 'STRATEGIES' },
{ id: 'totals', name: N_('战斗累计'), micro: 'COMBAT TOTALS' },
{ id: 'history', name: N_('对局记录'), micro: 'HISTORY' },
];
const pct = (n, d) => (d > 0 ? `${Math.round((n / d) * 100)}%` : '—');
const dateTime = (ms) => {
if (!ms) return '—';
const d = new Date(ms);
const p = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
};
/** Time played: minutes up to 99, then hours with one decimal. */
function playedText(ms) {
const mins = Math.round((ms || 0) / 60000);
return mins < 100 ? t('{n} 分', { n: fmtNum(mins) }) : t('{n} 小时', { n: (mins / 60).toFixed(1) });
}
const difficultyName = (d) => (DIFFICULTY_NAMES[d] ? t(DIFFICULTY_NAMES[d]) : t('未知'));
/** Save `text` as a download. Silent no-op when the browser refuses downloads. */
function downloadText(filename, text) {
try {
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 4000);
} catch { /* ignore */ }
}
/** Read a picked file as text (`File.text()`, with a FileReader fallback for older Safari). */
function readFileText(file) {
if (typeof file?.text === 'function') return file.text();
return new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onload = () => resolve(String(fr.result ?? ''));
fr.onerror = () => reject(fr.error || new Error('read failed'));
fr.readAsText(file);
});
}
/** `stronghold-stats-20261008-2130.json` */
function exportFilename(now = new Date()) {
const p = (n) => String(n).padStart(2, '0');
return `stronghold-stats-${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}.json`;
}
/** One labelled number card. */
function StatCard({ label, micro, value, tone }) {
return html`<div class=${cx('st-card', tone && `st-card--${tone}`)}>
<${MicroLabel} tone=${tone === 'gold' ? 'gold' : 'mint'}>${micro}<//>
<b class="st-card__num num">${value}</b>
<span class="st-card__label">${label}</span>
</div>`;
}
function Section({ title, micro, children, class: cls }) {
return html`<section class=${cx('st-sec', cls)}>
<h3 class="st-sec__h"><span>${title}</span><${MicroLabel}>${micro}<//></h3>
${children}
</section>`;
}
/** 总览: cards, the per-difficulty table, the six titles, and the line about what counts. */
function Overview({ agg, gd }) {
const rows = DIFFICULTIES.filter((d) => agg.byDifficulty[d]).concat(Object.keys(agg.byDifficulty).filter((d) => !DIFFICULTIES.includes(d)));
const titles = (Array.isArray(gd.config?.titles) ? gd.config.titles : []).map((x) => ({ ...x, count: agg.titles[x.id]?.count || 0 }));
return html`<${Section} title=${t('总览')} micro="OVERVIEW">
<div class="st-cards">
<${StatCard} micro="GAMES" value=${fmtNum(agg.count)} label=${t('总局数')} />
<${StatCard} micro="WINS" value=${fmtNum(agg.wins)} label=${t('胜场')} tone="mint" />
<${StatCard} micro="WIN RATE" value=${pct(agg.wins, agg.count)} label=${t('胜率')} tone="gold" />
<${StatCard} micro="HIDDEN CORE" value=${fmtNum(agg.hidden.reached)} label=${t('到达隐秘核心')} />
<${StatCard} micro="HIDDEN CLEAR" value=${fmtNum(agg.hidden.cleared)} label=${t('通关隐秘核心')} />
<${StatCard} micro="BEST ROUND" value=${fmtNum(agg.rounds.max)} label=${t('最远回合')} />
<${StatCard} micro="TIME PLAYED" value=${playedText(agg.duration.totalMs)} label=${t('累计时长')} />
</div>
<p class="st-split"><span>${t('单人')} <b class="num">${fmtNum(agg.byMode.solo)}</b></span><span>${t('同盟')} <b class="num">${fmtNum(agg.byMode.coop)}</b></span></p>
${rows.length ? html`<div class="st-tablewrap"><table class="st-table">
<thead><tr><th>${t('难度')}</th><th class="num">${t('局数')}</th><th class="num">${t('胜场')}</th><th class="num">${t('胜率')}</th><th class="num">${t('隐秘核心通关')}</th></tr></thead>
<tbody>
${rows.map((d) => {
const v = agg.byDifficulty[d];
return html`<tr key=${d}>
<td class="st-diff" style=${`--d-color:${DIFFICULTY_COLORS[d] || 'var(--text-lo)'}`}>${difficultyName(d)}</td>
<td class="num">${fmtNum(v.games)}</td>
<td class="num">${fmtNum(v.wins)}</td>
<td class="num">${pct(v.wins, v.games)}</td>
<td class="num">${fmtNum(v.hiddenCleared)}</td>
</tr>`;
})}
</tbody>
</table></div>` : null}
${titles.length ? html`<h4 class="st-sub">${t('称号')}</h4>
<div class="st-titles">
${titles.map((x) => html`<div key=${x.id} class=${cx('st-title', !x.count && 'is-none')} title=${x.text || ''}>
<${Img} src=${titleIconUrl(gd.m, x.picId)} class="st-title__icon" fallback=${html`<${Icon} name="crown" />`} />
<div class="st-title__text"><span>${x.name}</span><b class="num">${fmtNum(x.count)}</b></div>
</div>`)}
</div>` : null}
<p class="st-rule"><${Icon} name="info" />${t('计入统计:打到结算的对局,以及中途退出前至少清完 1 个回合的对局;更早退出的对局只留在对局记录里。')}${agg.excluded
? html` <b>${t('另有 {n} 局未计入统计', { n: agg.excluded })}</b>` : null}</p>
<//>`;
}
/** 策略: per-band used / passed / pass rate, most used first (the user's headline ask). */
function Bands({ agg, gd }) {
const rows = Object.entries(agg.bands).sort((a, b) => (b[1].games - a[1].games) || (b[1].wins - a[1].wins));
return html`<${Section} title=${t('策略')} micro="STRATEGIES">
${rows.length ? html`<div class="st-tablewrap"><table class="st-table">
<thead><tr><th>${t('策略')}</th><th class="num">${t('使用局数')}</th><th class="num">${t('通过局数')}</th><th class="num">${t('通过率')}</th></tr></thead>
<tbody>
${rows.map(([bandId, v]) => {
const band = gd.band(bandId);
const rate = v.games > 0 ? Math.round((v.wins / v.games) * 100) : 0;
return html`<tr key=${bandId}>
<td><span class="st-band">${band ? html`<${BandIcon} bandId=${band.bandId} size="xs" />${band.name}` : bandId}</span></td>
<td class="num">${fmtNum(v.games)}</td>
<td class="num">${fmtNum(v.wins)}</td>
<td class="num st-rate"><span class="st-bar" aria-hidden="true"><i style=${`width:${rate}%`}></i></span><b>${pct(v.wins, v.games)}</b></td>
</tr>`;
})}
</tbody>
</table></div>` : html`<p class="st-empty">${t('还没有记录 —— 完成一局对局后,这里会开始积累。')}</p>`}
<//>`;
}
const SUM_ROWS = [
['kills', N_('击倒敌人')], ['bossDamage', N_('领袖伤害')], ['dmgDealt', N_('造成伤害')], ['activatedLayers', N_('盟约层数')],
['merges', N_('晋升次数')], ['itemsEquipped', N_('配发装备')], ['gold', N_('消耗资金')], ['perfectRounds', N_('完美作战')],
['refreshes', N_('刷新次数')], ['leaks', N_('未击倒')], ['lpLost', N_('损失生命')],
];
/** 战斗累计: the self player's sums over the settled matches. */
function CombatSums({ agg }) {
const rows = SUM_ROWS.filter(([k]) => Number.isFinite(agg.sums[k]));
return html`<${Section} title=${t('战斗累计')} micro="COMBAT TOTALS">
${rows.length ? html`<div class="st-sums">
${rows.map(([k, label]) => html`<div key=${k} class="st-sum"><span>${t(label)}</span><b class="num">${fmtNum(agg.sums[k])}</b></div>`)}
</div>` : html`<p class="st-empty">${t('还没有记录 —— 完成一局对局后,这里会开始积累。')}</p>`}
<//>`;
}
/** One 对局记录 row: what it was, who won, whether it counted and whether the settlement can be re-viewed. */
function HistoryRow({ rec, gd, onOpen }) {
const self = selfRowOf(rec);
const quit = rec.end === 'quit';
const counted = countsTowardStats(rec);
const won = self ? !!self.victory : !!rec.victory;
const band = self?.bandId ? gd.band(self.bandId) : null;
const rounds = roundsOf(rec);
const known = Number.isFinite(self?.roundsPassed) || Number.isFinite(rec.roundsPassed);
const view = !quit && !rec.compact; // a quit has nothing settled; a compacted record no rows left
const open = () => onOpen(rec);
return html`<tr class=${cx(quit ? 'is-quit' : won ? 'is-win' : 'is-lose', !counted && 'is-excluded', view && 'is-clickable')}
title=${view ? t('点击查看该局结算') : !counted ? t('没有清完第一个回合就退出,不计入统计') : quit ? t('中途退出的对局没有结算可看') : t('较早的对局只保留汇总,不能回看结算')}
tabIndex=${view ? 0 : undefined}
onClick=${view ? open : undefined} onKeyDown=${view ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); } } : undefined}>
<td class="st-t">${dateTime(rec.t)}</td>
<td>${difficultyName(rec.difficulty)}</td>
<td>${rec.roomMode === 'solo' ? t('单人') : rec.roomMode === 'coop' ? t('同盟') : '—'}</td>
<td><span class="st-band">${band ? html`<${BandIcon} bandId=${band.bandId} size="xs" />${band.name}` : self?.bandId || '—'}</span></td>
<td class="st-res">${quit ? html`<span class="t-quit">${t('中途退出')}</span>` : html`<span class=${won ? 't-win' : 't-lose'}>${won ? t('胜') : t('负')}</span>`}
${!counted ? html`<em class="st-chip">${t('不计入统计')}</em>` : null}</td>
<td class="num">${known ? fmtNum(rounds) : '—'}</td>
<td class="st-go" aria-hidden="true">${view ? html`<${Icon} name="chevronRight" />` : null}</td>
</tr>`;
}
/** 对局记录: every stored match, newest first, HISTORY_PAGE rows at a time. */
function History({ records, gd, onOpen }) {
const [shown, setShown] = useState(HISTORY_PAGE);
const rows = records.slice(0, shown);
return html`<${Section} title=${t('对局记录')} micro=${`HISTORY · ${records.length}`}>
${rows.length ? html`<div class="st-tablewrap"><table class="st-table st-table--history">
<thead><tr><th>${t('时间')}</th><th>${t('难度')}</th><th>${t('模式')}</th><th>${t('策略')}</th><th>${t('结果')}</th><th class="num">${t('回合')}</th><th aria-hidden="true"></th></tr></thead>
<tbody>${rows.map((r) => html`<${HistoryRow} key=${r.id} rec=${r} gd=${gd} onOpen=${onOpen} />`)}</tbody>
</table></div>
${records.length > shown ? html`<div class="st-more"><${Button} size="sm" variant="secondary" onClick=${() => setShown(shown + HISTORY_PAGE)}>${t('显示更多(还有 {n} 局)', { n: records.length - shown })}<//></div>` : null}
<p class="st-hint">${t('点击一行,回看该局结算;中途退出的对局只留记录。')} ${t('最多保留最近 {max} 局,只有最新的 {full} 局能回看结算。', { max: MAX_RECORDS, full: FULL_KEEP })}</p>`
: html`<p class="st-empty">${t('还没有记录 —— 完成一局对局后,这里会开始积累。')}</p>`}
<//>`;
}
/** A stored match re-viewed as its settlement screen: the full game data is loaded now (the lobby alone never loads it). */
function Replay({ rec, onBack }) {
const gd = useGameData();
const res = useMemo(() => recordToResult(rec), [rec]);
if (!gd.ready) return html`<div class="st-loading"><${Spinner} size="md" /></div>`;
return html`<${ResultView} res=${res} pub=${null} myId=${rec.selfId} backLabel=${t('返回统计')} onBack=${onBack} quiet=${true} />`;
}
/** The overlay, rendered while open (its data hooks run only then). */
function StatsScreen({ tab }) {
const ready = useData('config', 'bands', 'assets');
const gd = useMemo(() => makeLookups(ready), [ready, data.locale()]);
const [stats, setStats] = useState(() => loadStats());
const [replay, setReplay] = useState(null);
const boxRef = useRef(null);
const fileRef = useRef(null);
const replayRef = useRef(null);
replayRef.current = replay;
const inMatch = useStore((s) => selectRoute(s) === 'game');
useDocClass('sp-stats-open');
// a match starting (the host pressed 开始) takes the player to its briefing: the overlay must not sit on top of it
useEffect(() => { if (inMatch) closeStats(); }, [inMatch]);
useEffect(() => {
const onKey = (e) => {
if (e.key !== 'Escape' || document.querySelector('.modal')) return; // a confirm dialog on top takes the Esc
e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
if (replayRef.current) setReplay(null); else closeStats();
};
window.addEventListener('keydown', onKey, true);
const timer = setTimeout(() => boxRef.current?.focus?.(), 30);
return () => { window.removeEventListener('keydown', onKey, true); clearTimeout(timer); };
}, []);
const agg = useMemo(() => aggregateStats(stats.records), [stats]);
const reload = () => setStats(loadStats());
const doExport = () => {
try {
const payload = exportStats(stats);
downloadText(exportFilename(), JSON.stringify(payload));
toast(t('已导出 {n} 条对局记录', { n: payload.records.length }), 'success');
} catch (err) {
toastError(err);
}
};
const doFile = async (e) => {
const file = e.currentTarget.files?.[0];
e.currentTarget.value = ''; // allow re-picking the same file
if (!file) return;
if (file.size > IMPORT_MAX_CHARS) { toast(t('文件过大,无法导入'), 'error'); return; }
try {
const raw = JSON.parse(await readFileText(file));
const { stats: merged, added, skipped, dropped } = importStats(raw, loadStats());
if (added && !saveStats(merged)) { toast(t('无法写入本机存储,可能已满'), 'error'); return; }
reload();
const tail = (skipped ? t('(跳过重复 {skipped} 条)', { skipped }) : '') + (dropped ? t('({n} 条无法读取)', { n: dropped }) : '');
toast(added ? t('已导入 {added} 条记录{tail}', { added, tail }) : t('没有新记录(全部与现有数据重复)'), 'success');
} catch (err) {
toast(err?.code === 'stats-newer-version' ? t('导入失败:这份文件由更新版本的客户端导出,请先升级客户端') : t('导入失败:不是有效的统计导出文件'), 'error');
}
};
const doClear = async () => {
const ok = await confirmDialog({
title: t('清空统计数据'), micro: 'CLEAR STATISTICS', danger: true, okText: t('清空'),
text: t('将删除本机保存的 {n} 条对局记录,无法恢复。', { n: stats.records.length }),
});
if (!ok) return;
if (!saveStats(emptyStats())) { try { globalThis.localStorage?.removeItem('sp.pref.stats'); } catch { /* ignore */ } }
reload();
toast(t('已清空本机统计数据'), 'success');
};
if (replay) return html`<div class="st st--replay" ref=${boxRef} tabindex="-1" role="dialog" aria-modal="true" aria-label=${t('统计数据')}>
<${Replay} rec=${replay} onBack=${() => setReplay(null)} />
</div>`;
const tabInfo = TABS.find((x) => x.id === tab) || TABS[0];
const empty = !stats.records.length;
return html`<div class="st" role="dialog" aria-modal="true" aria-label=${t('统计数据')} tabindex="-1" ref=${boxRef}>
<div class="st__bg" aria-hidden="true"></div>
<header class="st-top">
<div class="st-top__left">
<${Button} variant="ghost" size="md" icon="chevronLeft" class="st-back" onClick=${closeStats} aria-label=${t('返回')} title=${t('返回 (Esc)')}>${t('返回')}<//>
</div>
<div class="st-top__center">
<${MicroLabel} tone="mint">STATISTICS // ${tabInfo.micro}<//>
<div class="st-tabs" role="tablist" aria-label=${t('统计数据')}>
${TABS.map((x) => html`<button key=${x.id} type="button" role="tab" aria-selected=${x.id === tab ? 'true' : 'false'} data-tab=${x.id}
class=${cx('st-tab', x.id === tab && 'is-on')} onClick=${() => statsStore.set({ tab: x.id })}>${t(x.name)}</button>`)}
</div>
</div>
<div class="st-top__right">
<${Button} variant="ghost" size="sm" data-testid="stats-export" disabled=${empty || stats.newer} onClick=${doExport} title=${t('导出为文件(可在别的设备上导入)')}>${t('导出')}<//>
<${Button} variant="ghost" size="sm" data-testid="stats-import" disabled=${stats.newer} onClick=${() => fileRef.current?.click()} title=${t('从导出的文件导入(与现有记录合并)')}>${t('导入')}<//>
<input ref=${fileRef} type="file" accept="application/json,.json" hidden onChange=${doFile} />
<${Button} variant="secondary" size="sm" data-testid="stats-clear" disabled=${empty || stats.newer} onClick=${doClear} title=${t('清空本机的统计数据')}>${t('清空')}<//>
</div>
</header>
<p class="st-note"><${Icon} name="info" />${t('本机数据:只保存在这个浏览器里,不会上传到服务器;同盟对局里队友的代号也会存在本机(导出文件里也有)。')}</p>
${stats.damaged ? html`<p class="st-note is-warn"><${Icon} name="warn" />${t('本机保存的统计数据无法读取,已另存为备份,从现在起重新记录。')}</p>` : null}
${stats.dropped ? html`<p class="st-note is-warn"><${Icon} name="warn" />${t('有 {n} 条记录已损坏,已忽略。', { n: stats.dropped })}</p>` : null}
<main class="st-body" data-tab=${tab}>
${!ready ? html`<div class="st-loading"><${Spinner} size="md" /></div>`
: stats.newer ? html`<p class="st-empty">${t('检测到更新版本的统计数据,请先升级客户端再查看,以免覆盖数据。')}</p>`
: empty ? html`<p class="st-empty">${t('还没有记录 —— 完成一局对局后,这里会开始积累。')}</p>`
: tab === 'bands' ? html`<${Bands} agg=${agg} gd=${gd} />`
: tab === 'totals' ? html`<${CombatSums} agg=${agg} />`
: tab === 'history' ? html`<${History} records=${stats.records} gd=${gd} onOpen=${setReplay} />`
: html`<${Overview} agg=${agg} gd=${gd} />`}
</main>
</div>`;
}
/** Global stats viewer host — mounted once by main.js; renders nothing (and loads nothing) until opened. */
export function StatsHost() {
const { open, tab } = useStore((s) => s, Object.is, statsStore);
if (!open) return null;
return html`<${StatsScreen} tab=${tab} />`;
}
+9 -2
View File
@@ -10,15 +10,17 @@
// 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';
import { GuideButton } from '../ui/guide.js';
import { openStats } from './stats.js';
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';
@@ -245,7 +247,10 @@ export function TitleScreen() {
</div>
<div class="title-corner title-corner--tr">
<div>
<${LangToggle} class="title-lang" />
<div class="title-corner__tools">
<${Button} variant="ghost" size="sm" icon="chart" class="title-stats" onClick=${openStats} title=${t('统计数据')}>${t('统计')}<//>
<${LangToggle} class="title-lang" />
</div>
<${MicroLabel} tone="hi">TARGET POINT<//><br /><${MicroLabel}>STRONGHOLD PROTOCOL<//>
</div>
</div>
@@ -283,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}
+3 -1
View File
@@ -17,7 +17,9 @@
// already did.
// Watching a teammate (DESIGN §20.15, ui/watchBonds.js) the strip and the popup show THAT player's bonds and layers:
// the strip carries an amber "👁 name" tag (`owner`, the observing pill's spelling, research 09 §3.1) and amber rings,
// the popup a "👁 name 的盟约" line; its member list reads the teammate's operators on the field (no hand: never sent).
// the popup a "👁 name 的盟约" line; its member list reads the teammate's operators on the field — in prep their bench
// too, counted like their own popup counts it (ui/watchBonds.js ownerBoard: hand / temp by the scout's `area`, GitHub
// #385); a battle sends no bench.
// 0.2.0 补位: a member the player fields as its stand-in (the own m.private.standIns; a teammate's unit that says so —
// UnitInfo `standInFor`) is drawn as the stand-in — avatar, name, a small 「替补」 mark on the thumbnail — and its row
// opens the stand-in's card (gameLogic memberStandIn; the owner's recall of the official mode, 2026-10-06).
+14 -12
View File
@@ -91,13 +91,13 @@ export function pickBusy(busyIdx, card, mine) {
/**
* Whether I may pick a card right now: my turn (solo: always), no pick of mine yet, the card free and no pick in flight.
* @param {any} sp normalizeSp(...) @param {{ idx: number, takenBy?: string|null } | null | undefined} card
* @param {any} sp normalizeSp(...) @param {{ idx: number, takenBy?: string|null, soldOut?: boolean } | null | undefined} card
* @param {{ myId: string, solo: boolean, busyIdx?: number|null }} o
*/
export function cardPickable(sp, card, { myId, solo, busyIdx = null }) {
if (!sp || !card) return false;
const myTurn = solo || sp.turnPid === myId;
return myTurn && sp.pickOf.get(myId) == null && !card.takenBy && busyIdx == null;
return myTurn && sp.pickOf.get(myId) == null && !card.takenBy && !card.soldOut && busyIdx == null;
}
/**
@@ -127,7 +127,7 @@ export function armedCard(armed, sp, o) {
/**
* The overlay: keeps the two-tap selection and renders ChoiceView. `onPick(idx)` sends the confirmed card (g.choice).
* @param {{ pub:any, sp:any, myId:string, solo:boolean, onPick:(idx:number)=>void, busyIdx?:number|null, total?:number|null }} props
* @param {{ pub:any, sp:any, myId:string, solo:boolean, personal?:boolean, onPick:(idx:number)=>void, busyIdx?:number|null, total?:number|null }} props
*/
export function ChoiceOverlay(props) {
const { sp, myId, solo, busyIdx = null, onPick } = props;
@@ -154,10 +154,10 @@ export function ChoiceOverlay(props) {
/**
* The overlay's view (pure: no hooks — test/ui renders it as a function).
* @param {{ pub:any, sp:any, myId:string, solo:boolean, busyIdx?:number|null, total?:number|null, armed?:number|null,
* @param {{ pub:any, sp:any, myId:string, solo:boolean, personal?:boolean, busyIdx?:number|null, total?:number|null, armed?:number|null,
* onTap?:(idx:number)=>void, onConfirm?:()=>void, onDisarm?:()=>void }} props
*/
export function ChoiceView({ pub, sp, myId, solo, busyIdx = null, total = null, armed = null, onTap = () => {}, onConfirm = () => {}, onDisarm = () => {} }) {
export function ChoiceView({ pub, sp, myId, solo, personal = false, busyIdx = null, total = null, armed = null, onTap = () => {}, onConfirm = () => {}, onDisarm = () => {} }) {
if (!sp) return null;
const fam = data.get('choices')?.families?.[sp.family] || null;
const rawFam = data.getRaw('choices')?.families?.[sp.family] || null;
@@ -166,8 +166,8 @@ export function ChoiceView({ pub, sp, myId, solo, busyIdx = null, total = null,
const mine = sp.pickOf.get(myId);
const turnName = players.get(sp.turnPid)?.name || t('队友');
const special = /_s$/.test(String(sp.family || ''));
const order = solo ? [] : sp.order;
const timed = !solo && !sp.untimed;
const order = personal || solo ? [] : sp.order;
const timed = personal ? pub?.deadline > 0 : !solo && !sp.untimed;
const armedCardRec = armed != null ? sp.cards.find((c) => c && c.idx === armed) : null;
const armedName = armedCardRec ? resolveSpCard(armedCardRec, sp.family).name : null;
// a press anywhere but a card or the confirm button drops the selection
@@ -177,17 +177,18 @@ export function ChoiceView({ pub, sp, myId, solo, busyIdx = null, total = null,
if (t && typeof t.closest === 'function' && t.closest('.spcard, .spov__confirm')) return;
onDisarm();
};
return html`<div class=${cx('spov', armed != null && 'has-armed')} role="dialog" aria-label=${t('机变阶段')} onPointerDown=${onDown}>
return html`<div class=${cx('spov', armed != null && 'has-armed')} role="dialog" aria-label=${personal ? t('教鞭选择') : t('机变阶段')} onPointerDown=${onDown}>
<div class="spov__veil" aria-hidden="true"></div>
<div class="spov__inner">
<header class="spov__head">
<div class="spov__titles">
<${MicroLabel} tone="mint">${t('CONTINGENCY // 机变阶段')}</${MicroLabel}>
<${MicroLabel} tone="mint">${personal ? t('教鞭选择') : t('CONTINGENCY // 机变阶段')}</${MicroLabel}>
<h2 class=${cx('spov__title', special && 'is-special')}>${sentText(sp.name, rawFam?.name, fam?.name) || fam?.name || t('机变')}<span class="spov__bar">|</span><span class="spov__desc"><${RichText} text=${sentText(sp.desc, rawFam?.desc, fam?.desc) || fam?.desc || t('选择一项')} /></span></h2>
<p class="spov__sub">${timed ? t('倒计时结束后仍未选定将自动分配') : t('选择一项(无时间限制)')}${mine == null && myTurn ? t(' · 点击卡牌选中,再次点击确认') : ''}</p>
<p class="spov__sub">${timed ? (personal ? t('休整期结束时未选择将自动选定') : t('倒计时结束后仍未选定将自动分配')) : t('选择一项(无时间限制)')}${mine == null && myTurn ? t(' · 点击卡牌选中,再次点击确认') : ''}</p>
</div>
<div class="spov__turn">
${mine != null ? html`<span class="spov__turntxt is-done"><${Icon} name="check" />${t('已完成选择')}</span>`
${personal ? html`<span class="spov__turntxt is-mine">${t('从候选中选择一项战术特训')}</span>`
: mine != null ? html`<span class="spov__turntxt is-done"><${Icon} name="check" />${t('已完成选择')}</span>`
: myTurn ? html`<span class="spov__turntxt is-mine">${t('当前轮到你决策')}</span>`
: html`<span class="spov__turntxt">${t('{turnName} 正在决策…', { turnName })}<${Icon} name="hourglass" /></span>`}
${armed != null ? html`<${Button} variant="primary" size="lg" icon="check" class="spov__confirm" data-testid="sp-confirm"
@@ -217,7 +218,7 @@ export function ChoiceView({ pub, sp, myId, solo, busyIdx = null, total = null,
const busy = pickBusy(busyIdx, card, mine);
const isArmed = can && armed === card.idx;
const takerName = taker ? (card.takenBy === myId ? t('你') : taker.name) : null;
return html`<button key=${card.idx} type="button" class=${cx('spcard', `spcard--${r.kind}`, card.takenBy && 'is-taken', card.takenBy === myId && 'is-mine', can && 'is-pickable', isArmed && 'is-armed', busy && 'is-busy')}
return html`<button key=${card.idx} type="button" class=${cx('spcard', `spcard--${r.kind}`, (card.takenBy || card.soldOut) && 'is-taken', card.takenBy === myId && 'is-mine', can && 'is-pickable', isArmed && 'is-armed', busy && 'is-busy')}
aria-busy=${busy ? 'true' : undefined} aria-pressed=${can ? String(isArmed) : undefined} disabled=${!can} onClick=${() => can && onTap(card.idx)}
aria-label=${isArmed ? t('{name},已选中,再次点击确认', { name: r.name }) : takerName ? t('{name},{takerName}已选择', { name: r.name, takerName }) : r.name} title=${`${r.name}\n${richTextPlain(r.desc)}`}>
<span class="spcard__glow" aria-hidden="true"></span>
@@ -232,6 +233,7 @@ export function ChoiceView({ pub, sp, myId, solo, busyIdx = null, total = null,
</span>
</span>
<${RichText} text=${r.desc} class="spcard__desc" />
${card.soldOut && !taker ? html`<span class="spcard__busy" role="status">${t('库存不足')}</span>` : null}
${r.tier ? html`<${TierChip} tier=${r.tier} size="md" class="spcard__tier" />` : null}
${taker ? html`<span class="spcard__taker" title=${t('{name} 已选择', { name: taker.name })}><${PlayerAvatar} player=${taker} size="sm" /></span>` : null}
${isArmed ? html`<span class="spcard__confirm" role="status"><b>${t('确认选择')}</b><small>${t('再次点击')}</small></span>` : null}
+17 -20
View File
@@ -1,35 +1,32 @@
// Clipboard helper shared by the room screen (invite code / link) and 干员调配 (导出 / 导入 payloads).
//
// Lives here rather than in a screen: `screens/room.js` imports `screens/loadout.js` (the 干员调配 button), so loadout
// importing the helper back from room would be a cycle.
/**
* Copy text to the clipboard (async API with a textarea fallback for insecure contexts).
* @param {string} text
* @returns {Promise<boolean>}
*/
// Shared by invite links, loadouts and settings exports, including LAN / insecure contexts.
/** @param {string} text @returns {Promise<boolean>} */
export async function copyText(text) {
try {
if (globalThis.navigator?.clipboard && globalThis.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
}
} catch { /* fall through */ }
} catch { /* use the selected-text fallback */ }
let ta, prev;
try {
const ta = document.createElement('textarea');
prev = document.activeElement;
ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
// 16 px: iOS zooms into smaller focused fields; `readonly` keeps the keyboard away
// Keep the temporary selection inside the active focus trap (#433). The 16 px readonly field avoids iOS zoom
// and its keyboard; do not disable the dialog's focus protection or copy a selection redirected to another field.
ta.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0;font-size:16px';
document.body.appendChild(ta);
const host = prev?.closest?.('[role="dialog"][aria-modal="true"], .guide') || document.body;
host.appendChild(ta);
ta.focus({ preventScroll: true });
ta.select();
// iOS Safari ignores select() on a textarea: an explicit range is what it copies (LAN play over http has no
// navigator.clipboard, so this path is the one iPhones / iPads take)
try { ta.setSelectionRange(0, ta.value.length); } catch { /* ignore */ }
const ok = document.execCommand('copy');
ta.remove();
return ok;
ta.setSelectionRange(0, ta.value.length); // iOS Safari ignores select() alone
if (document.activeElement !== ta || ta.selectionStart !== 0 || ta.selectionEnd !== ta.value.length) return false;
return !!document.execCommand('copy');
} catch {
return false;
} finally {
ta?.remove();
if (prev?.isConnected) prev.focus?.({ preventScroll: true });
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ function ProgressList({ list }) {
<b>${p.name}</b>
${p.done
? html`<span class="chud__prog__ok" aria-label=${t('作战结束')}>✓</span>`
: html`<span class="num">${p.killed != null && p.total != null ? `${p.killed}/${p.total}` : '•••'}</span>`}
: html`<span class="num">${(p.resolved ?? p.killed) != null && p.total != null ? `${p.resolved ?? p.killed}/${p.total}` : '•••'}</span>`}
</span>`)}
</div>`;
}
+37 -11
View File
@@ -55,6 +55,8 @@ export const ICONS = {
users: { d: 'M9 4a3.5 3.5 0 1 1 0 7 3.5 3.5 0 0 1 0-7zM2 20a7 6.5 0 0 1 14 0zM16.5 5a3 3 0 1 1 0 6 3 3 0 0 1 0-6zm.9 8.1A6.5 6 0 0 1 22.5 20H18a8.6 8 0 0 0-2.4-6.1 6 6 0 0 1 1.8-.8z' },
rook: { d: 'M5 3h3v2h2V3h4v2h2V3h3v5l-2 2v7l2 2v2H5v-2l2-2v-7L5 8z' },
signal: { d: 'M2 17h3v4H2zm6-4h3v8H8zm6-4h3v12h-3zm6-4h3v16h-3z' },
// 统计 (the stats page's entry): three columns on a baseline — apart from `signal`, the ping bars
chart: { d: 'M4 12h4v7H4zm6-6h4v13h-4zm6 3h4v10h-4zM3 20h18v2H3z' },
refresh: { d: 'M12 4a8 8 0 0 1 7.4 5H17v2h6V5h-2v2.3A10 10 0 0 0 2 12h2a8 8 0 0 1 8-8zm0 16a8 8 0 0 1-7.4-5H7v-2H1v6h2v-2.3A10 10 0 0 0 22 12h-2a8 8 0 0 1-8 8z' },
snow: { d: 'M11 2h2v4.2l2.3-2.3 1.4 1.4-3.7 3.7v2h2l3.7-3.7 1.4 1.4-2.3 2.3H22v2h-4.2l2.3 2.3-1.4 1.4-3.7-3.7h-2v2l3.7 3.7-1.4 1.4-2.3-2.3V22h-2v-4.2l-2.3 2.3-1.4-1.4 3.7-3.7v-2H9l-3.7 3.7-1.4-1.4L6.2 13H2v-2h4.2L3.9 8.7l1.4-1.4L9 11h2V9L7.3 5.3l1.4-1.4L11 6.2z' },
chevronRight: { d: 'M8.6 5 7.2 6.4 12.8 12l-5.6 5.6L8.6 19l7-7z' },
@@ -74,6 +76,8 @@ export const ICONS = {
wifiOff: { d: 'M2 17h3v4H2zm6-4h3v8H8zm6-4h3v12h-3zm6-4h3v16h-3zM2.4 1 23 21.6 21.6 23 1 2.4z' },
expand: { d: 'M3 3h7v2H5v5H3zm11 0h7v7h-2V5h-5zM3 14h2v5h5v2H3zm16 0h2v7h-7v-2h5z' },
collapse: { d: 'M8 3h2v7H3V8h5zm6 0h2v5h5v2h-7zM3 14h7v7H8v-5H3zm11 0h7v2h-5v5h-2z' },
// 添加到桌面 (add to home screen): a house outline with a + inside
homePlus: { d: 'M12 2 22 10.5V21H2V10.5zm0 2.6-8 6.8V19h16v-7.6zM11 10h2v2.5h2.5v2H13V17h-2v-2.5H8.5v-2H11z', eo: true },
rotate: { d: 'M7 2h8a2 2 0 0 1 2 2v7h-2V4H7v16h4v2H7a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2zm8 12h5a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-5a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2zm0 2v4h5v-4z', eo: true },
book: { d: 'M2 4h7.5A3.5 3.5 0 0 1 12 5.1 3.5 3.5 0 0 1 14.5 4H22v16h-7.5a1.5 1.5 0 0 0-1.5 1.5h-2A1.5 1.5 0 0 0 9.5 20H2zm2 2v12h5.5c.5 0 1 .1 1.5.3V7.5A1.5 1.5 0 0 0 9.5 6zm10.5 0A1.5 1.5 0 0 0 13 7.5v10.8c.5-.2 1-.3 1.5-.3H20V6z', eo: true },
// 观战 (spectator seats; the same eye as gameComponents.js GIcon 'eye')
@@ -386,7 +390,7 @@ export function Countdown({ deadline, seconds, total, warnAt = 10, label = 'COUN
// ---- Modal & dialogs ---------------------------------------------------------------------------
const modalStack = []; // open modals, topmost last: only the topmost reacts to Escape
const modalStack = []; // open modals, topmost last: only the topmost handles keyboard focus / Escape
/**
* Modal dialog (declarative). Esc / backdrop click call onClose.
@@ -397,24 +401,46 @@ export function Modal({ open, title, micro, tone = 'mint', onClose, actions, wid
const boxRef = useRef(null);
const closeRef = useRef(onClose);
closeRef.current = onClose;
useEffect(() => {
useLayoutEffect(() => {
if (!open) return undefined;
const token = {};
modalStack.push(token);
const prevFocus = typeof document !== 'undefined' ? document.activeElement : null;
const box = boxRef.current;
// The independent guide can open above SettingsModal (its z-index is higher than --z-modal).
const topmost = () => modalStack[modalStack.length - 1] === token && !document.querySelector('.guide');
const available = (el) => !el.matches(':disabled') && !el.closest('[inert]') && el.getClientRects().length > 0
&& getComputedStyle(el).visibility !== 'hidden';
const tabbable = () => [...box.querySelectorAll('button, [href], input, select, textarea, [tabindex], summary')]
.filter((el) => el.tabIndex >= 0 && available(el));
const focusFirst = () => (tabbable()[0] || box).focus();
const onKey = (e) => {
if (e.key !== 'Escape' || modalStack[modalStack.length - 1] !== token || !closeRef.current) return;
e.stopPropagation();
closeRef.current();
if (!topmost()) return;
if (e.key === 'Escape' && closeRef.current) {
e.stopPropagation();
closeRef.current();
} else if (e.key === 'Tab') {
const els = tabbable();
const first = els[0] || box, last = els[els.length - 1] || box;
if (!els.includes(document.activeElement) || document.activeElement === (e.shiftKey ? first : last)) {
e.preventDefault();
(e.shiftKey ? last : first).focus();
}
}
};
const onFocus = (e) => {
if (topmost() && !box.contains(e.target)) focusFirst();
};
window.addEventListener('keydown', onKey);
const t = setTimeout(() => {
const el = boxRef.current?.querySelector('[data-autofocus]') || boxRef.current?.querySelector('button, input');
el?.focus?.();
}, 30);
window.addEventListener('focusin', onFocus);
const autofocus = box.querySelector('[data-autofocus]');
if (topmost()) {
if (autofocus && available(autofocus)) autofocus.focus();
else focusFirst();
}
return () => {
window.removeEventListener('keydown', onKey);
clearTimeout(t);
window.removeEventListener('focusin', onFocus);
const i = modalStack.indexOf(token);
if (i >= 0) modalStack.splice(i, 1);
prevFocus?.focus?.();
@@ -423,7 +449,7 @@ export function Modal({ open, title, micro, tone = 'mint', onClose, actions, wid
if (!open) return null;
return html`<div class="modal" role="presentation"
onMouseDown=${(e) => { if (closeOnBackdrop && e.target === e.currentTarget && onClose) onClose(); }}>
<div ref=${boxRef} class=${cx('modal__box', 'brackets', `modal__box--${tone}`, cls)} role="dialog" aria-modal="true"
<div ref=${boxRef} class=${cx('modal__box', 'brackets', `modal__box--${tone}`, cls)} role="dialog" aria-modal="true" tabindex="-1"
style=${width ? `width:${width}` : undefined}>
<div class="modal__stripe" aria-hidden="true"></div>
${title || micro ? html`<header class="modal__head">
+77 -32
View File
@@ -37,7 +37,7 @@
import { useEffect } from '../../vendor/hooks.module.js';
import { html, Icon, TierChip, MicroLabel, Button, confirmDialog, useTicker } from './components.js';
import { Img, RichText, UnitThumb, BondGlyph, GIcon, diyToken } from './gameComponents.js';
import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, briefingBondTip, pieceBondIds, grantedBonds, morphPairings, ownStandIn, standInOf, standInLoadout, standInLabel, standInTip, standInForText, ownDiyRecord, ownDiyPick, diyRecordFor, pickGetter } from './gameLogic.js';
import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, briefingBondTip, pieceBondIds, grantedBonds, morphPairings, ownStandIn, standInOf, standInLoadout, standInLabel, standInTip, standInForText, ownDiyRecord, ownDiyPick, diyRecordFor, pickGetter, unitPick, unitCultivation } from './gameLogic.js';
import { chessPortraitUrl, skillIconUrl, skillRecordIconUrl, profIconUrl, subProfIconUrl, itemIconUrl, enemyIconUrl, tokenAvatarUrl, factionIconUrl, uiUrl, moduleTypeIconUrl } from './assetUrls.js';
import { abilityRows } from './abilityLines.js';
import { data } from '../data.js';
@@ -46,6 +46,7 @@ import { SKILL_SUMMON_START_DEPLOY } from '../../../shared/constants.js';
import { moduleBadge, fullTraitText } from './loadoutModel.js';
import { t, tc, N_ } from '../../../shared/i18n.js';
import { audio } from '../audio.js';
import { tokenVariantFor } from './gameLogic/loadout.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
@@ -315,7 +316,7 @@ export function garrisonTypeIconKey(garrison) {
}
/** The operator's own effect (特质, garrisons.json): trigger chip + description, compact. */
function GarrisonBlock({ garrison, m }) {
export function GarrisonBlock({ garrison, m }) {
return html`<section class="dgarrison" aria-label=${t('特质')} data-garrison=${garrison.garrisonId || ''}>
<div class="dgarrison__head">
<span class="dgarrison__k">${t('特质')}</span>
@@ -383,7 +384,7 @@ function skillTextNote(sk) {
return note ? html`<p class="dhint dhint--rule" data-skill-note=${sk.skillId}><${Icon} name="info" />${t(note)}</p>` : null;
}
export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, offBonds = null, loadout, onBond, live = null, hint = null, unitItems = null, standIn = null, diy = null }) {
export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, offBonds = null, loadout, onBond, live = null, hint = null, unitItems = null, standIn = null, diy = null, cultOpts = null }) {
const m = data.get('assets');
const hp = hpOf(live, snapHp);
// 0.2.0 自选编队: `chess` is then the composed 自选 record (the operator, the slot's tier / price); its skill and module are
@@ -395,7 +396,9 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
// still apply (the owner's recall of the official mode, 2026-10-06)
const si = standIn && standIn.standInFor ? standIn : null;
const body = si || chess;
const lo = si ? standInLoadout(si, getChess, data.get('backups')) : chessLoadout(chess, loadout, getChess);
// 0.2.2: at the operator's 潜能 / 练度 — the player's settings (`cultOpts.ops`) or a teammate's unit's own
// (`cultOpts.cultivation`); a stand-in has neither
const lo = si ? standInLoadout(si, getChess, data.get('backups')) : chessLoadout(chess, loadout, getChess, { ...(cultOpts || {}), effects: data.get('effects') });
const c = chess;
// stats / talents the unit fights with: the chosen module's (or none — statsBase) for an elite (DESIGN §16)
const fr = lo?.record || body;
@@ -580,21 +583,6 @@ export function summonDeployHint(token, startDeploy = SKILL_SUMMON_START_DEPLOY)
: t('所属干员发动技能时才在摆放的位置出现(未摆放则不会出现)');
}
/**
* The token variant of the summon's owner (tokens.json `variants`, keyed by owner chess id): a golden owner's `_b`
* entry (精锐 赫默's drone ATK 114, 精锐 巫恋's doll −30%), else its normal `_a` entry, else the first one.
* @param {any} token tokens.json record
* @param {string|null} ownerId the owner's chess id (null: unknown, e.g. a teammate's summon)
*/
export function tokenVariantFor(token, ownerId = null) {
const vs = token?.variants || {};
if (typeof ownerId === 'string') {
const v = vs[ownerId] || vs[ownerId.replace(/_b$/, '_a')];
if (v) return v;
}
return Object.values(vs)[0] || null;
}
/** Chess id of the operator owning a token piece (`ownerUid`), from the player's own pieces (indexPieces). */
function tokenOwnerId(piece, pieces) {
if (!piece || piece.kind !== 'token' || !Number.isInteger(piece.ownerUid)) return null;
@@ -711,9 +699,36 @@ function TerrainDetail({ terrain }) {
${Array.isArray(terrain.facts) && terrain.facts.length ? html`<${Section} title=${t('这一格')}><p class="dtext">${terrain.facts.join(' · ')}</p><//>` : null}`;
}
/**
* A stage device's tip (GitHub #228, PR #229: 阻隔工事 / “双眼皮” / 射击台 / 源石流发生装置 — the terrain tip's sibling). Opened
* by a tap on the device itself: the game screen resolves it with `gameLogic.deviceInfo` (prep) or `deviceTipAt` (a battle)
* from the stage the board on screen is built from, so the lines carry that stage's own numbers. In a battle the
* crate / turret is a device unit: its live HP (`live` from the battle's own sim, else `snapHp` — the screen reads the latest
* snapshot tuple through `unitId`) draws a HP bar like a unit card's.
* @param {{ name:string, tag:string, lines:string[], facts?:string[], stats?:{k:string,v:any}[] }} device
* @param {{ hp:number, max:number }|null} snapHp
*/
function DeviceDetail({ device, snapHp = null, live = null }) {
const hp = hpOf(live, snapHp);
return html`
<div class="dhead">
<div class="dhead__icon"><${Icon} name="info" /></div>
<div class="dhead__info">
<div class="dhead__chips"><span class="dtag-kind">${device.tag}</span></div>
<h3 class="dhead__name">${device.name}</h3>
${hp ? html`<div class="dhp"><i style=${`width:${Math.max(0, Math.min(100, (hp.hp / Math.max(1, hp.max)) * 100))}%`}></i><span class="num">${fmtNum(hp.hp)} / ${fmtNum(hp.max)}</span></div>` : null}
</div>
</div>
<${Section} title=${t('装置机制')} micro="DEVICE">
${device.lines.map((line, i) => html`<p class="dtext" key=${i}>${line}</p>`)}
<//>
${Array.isArray(device.stats) && device.stats.length ? html`<div class="dstats">${device.stats.map((x) => html`<${Stat} key=${x.k} k=${x.k} v=${x.v} />`)}</div>` : null}
${Array.isArray(device.facts) && device.facts.length ? html`<${Section} title=${t('这一格')}><p class="dtext">${device.facts.join(' · ')}</p><//>` : null}`;
}
/**
* Resolve what a detail target shows.
* @param {{ kind:'piece'|'chess'|'item'|'enemy'|'unit'|'token'|'terrain', id?:string, uid?:number, unit?:any, count?:number }} target
* @param {{ kind:'piece'|'chess'|'item'|'enemy'|'unit'|'token'|'terrain'|'device', id?:string, uid?:number, unit?:any, count?:number }} target
* @param {Map<number, any>} pieces indexPieces(priv)
* @param {{ priv?: any, backups?: any }} [opts] 0.2.0 补位: the player's own pieces and cards of a chess in
* m.private.standIns — a unit carrying `standInFor`, and a teammate's bond popup row that says it (`target.standInFor`)
@@ -726,6 +741,12 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get(
if (!target) return null;
// a special terrain tile (issue #184): the screen resolved the stage's own numbers already (gameLogic.terrainInfo)
if (target.kind === 'terrain') return target.terrain && typeof target.terrain === 'object' ? { type: 'terrain', terrain: target.terrain } : null;
// a stage device (#228): the screen resolved the device's stage entry (gameLogic.deviceInfo / deviceTipAt); in a battle
// `unitId` is the crate / turret unit whose snapshot tuple gives the live HP
if (target.kind === 'device') {
const device = target.device && typeof target.device === 'object' ? target.device : null;
return device ? { type: 'device', device, ...(Number.isInteger(device.unitId) ? { unitId: device.unitId } : {}) } : null;
}
const ownSi = (c) => ownStandIn(c, priv, backups);
const dd = { chess: data.get('chess'), backups };
/** the own card of chess `c`: its 自选 record and pick when the player filled that DIY slot */
@@ -755,9 +776,14 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get(
// (a teammate's 自选 row hands its unit's pick on, `target.diy`: their operator, not the empty 甄选干员 slot — 0.2.1)
const mate = foreign && c && target.diy && typeof target.diy === 'object' ? diyRecordFor(c, target.diy, dd) : null;
const d = mate ? { chess: mate, diy: target.diy } : foreign ? null : ownDiy(c);
if (d) return { type: 'chess', chess: d.chess, hint: target.hint || null, standIn: null, diy: d.diy, ...(items.length ? { unitItems: items } : {}) };
// `tap`: which card tap opened it (game.js numbers every shop / reward card it opens) — selectVoiceKey
const tap = target.tap != null ? { tap: target.tap } : null;
// 0.2.2: a teammate's member card reads its unit's 潜能 / 练度 when the row hands it on (`target.cultivation`), else
// the defaults (never the viewer's own settings)
const cv = foreign ? (target.cultivation !== undefined ? { cultivation: target.cultivation } : { ops: null }) : null;
if (d) return { type: 'chess', chess: d.chess, hint: target.hint || null, standIn: null, diy: d.diy, ...(items.length ? { unitItems: items } : {}), ...(cv ? { cultOpts: cv } : {}), ...tap };
const si = !c ? null : foreign ? (typeof target.standInFor === 'string' && target.standInFor ? standInOf(c, backups) : null) : ownSi(c);
return c ? { type: 'chess', chess: c, hint: target.hint || null, standIn: si, ...(items.length ? { unitItems: items } : {}) } : null;
return c ? { type: 'chess', chess: c, hint: target.hint || null, standIn: si, ...(items.length ? { unitItems: items } : {}), ...(cv ? { cultOpts: cv } : {}), ...tap } : null;
}
if (target.kind === 'item') { const it = data.lookup('items', target.id); return it ? { type: 'item', item: it } : null; }
if (target.kind === 'enemy') { const en = data.lookup('enemies', target.id); return en ? { type: 'enemy', enemy: en, count: target.count } : null; }
@@ -776,10 +802,13 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get(
else if (c && own?.piece) si = ownSi(c);
// 0.2.0 自选编队: a unit says itself which operator fills its DIY slot (UnitInfo diy); an own piece's unit follows
// m.private.diy like the piece
const pick = c && u.diy && typeof u.diy === 'object' ? u.diy : own?.piece ? ownDiyPick(priv, c) : null;
const pick = c && u.diy && typeof u.diy === 'object' ? unitPick(u) : own?.piece ? ownDiyPick(priv, c) : null;
const dr = pick ? diyRecordFor(c, pick, dd) : null;
if (dr) return { type: 'chess', chess: dr, piece: own?.piece || null, unitId: u.id, unitItems: Array.isArray(u.items) ? u.items : null, standIn: null, diy: pick };
if (c) return { type: 'chess', chess: c, piece: own?.piece || null, unitId: u.id, unitItems: Array.isArray(u.items) ? u.items : null, standIn: si };
// 0.2.2: another player's unit says its 潜能 / 练度 itself (UnitInfo potential / cultivate); an own piece's unit follows
// m.private.ops (the panel's `cultOpts`)
const cv = own?.piece ? null : { cultOpts: { cultivation: unitCultivation(u) } };
if (dr) return { type: 'chess', chess: dr, piece: own?.piece || null, unitId: u.id, unitItems: Array.isArray(u.items) ? u.items : null, standIn: null, diy: pick, ...cv };
if (c) return { type: 'chess', chess: c, piece: own?.piece || null, unitId: u.id, unitItems: Array.isArray(u.items) ? u.items : null, standIn: si, ...cv };
const t = data.lookup('tokens', u.defId) || diyToken(u.defId);
if (t) return { type: 'token', token: t, unitId: u.id, ownerId: tokenOwnerId(own?.piece, pieces) };
const en = data.lookup('enemies', u.defId);
@@ -788,6 +817,18 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get(
return null;
}
/**
* The 选中干员 key of a resolved detail (null: nothing to say). The panel says the line once per opened operator and stays
* mounted while its target changes, so the key carries what identifies the opening: the chess record, the piece / battle
* unit, and the card tap (`tap`, game.js) — two shop / reward cards of one operator (the pool deals duplicates) carry the
* same chess id and no piece, so without it the second card's tap said nothing, nor replaced the first one's line.
* @param {any} detail resolveDetail's result
* @returns {string|null}
*/
export function selectVoiceKey(detail) {
return detail?.type === 'chess' ? `${detail.chess?.chessId || ''}:${detail.unitId ?? detail.piece?.uid ?? ''}:${detail.tap ?? ''}` : null;
}
/**
* The panel.
* @param {{ detail:any, editable:boolean, snapHp?:{hp:number,max:number}|null, onClose:Function, onSell:(piece:any)=>void, onDestroy:(piece:any)=>void,
@@ -795,18 +836,21 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get(
* bonds: the owner's m.private.bonds (counts / tiers of the bond chips); offBonds: the bonds this mode never activates
* (gameLogic modeOffBonds — their chips and the 变形同构体 pairing lines read 本局禁用); loadout: m.private.loadout (DESIGN §16) for
* the player's own operators and shop cards; a teammate's unit gets its owner's choice (gameLogic unitLoadout); null
* = the defaults
* = the defaults; ops: m.private.ops (0.2.2 潜能 / 练度 of the player's own operators and cards — a unit the detail
* resolved as another player's carries its own, `detail.cultOpts`)
* live: the unit's live stats (unitStatsEntry + src 'battle' | 'prep') — an object, or a getter the panel re-reads 4×
* a second (the battle's own sim, battle/runner.js unitStats); null ⇒ the record's numbers
* voice: whether the panel may speak — 选中干员 (audio.voice 'select') plays only while a battle runs (user request:
* 整备期不播干员语音), so the game screen passes its combat flag
* voice: whether the panel may speak — 选中干员 (audio.voice 'select') when it opens on an operator the player tapped:
* a piece on the field or in the hand, a shop / reward card, a bond member. The game screen passes true in every phase
* (the owner's request of 2026-10-08 「添加一下干员点击上去的语气一样的语音」 lifted 2026-10-03's 「整备阶段不需要干员语音」
* for this line only; [ASSUMED] the official prep tap says 选中干员 like the battle's FOCUS_CHAR)
*/
export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestroy, bonds = [], offBonds = null, loadout = null, onBond = null, side = 'left', shopOpen = false, live = null, voice = false }) {
export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestroy, bonds = [], offBonds = null, loadout = null, ops = null, onBond = null, side = 'left', shopOpen = false, live = null, voice = false }) {
const getter = typeof live === 'function' ? live : null;
useTicker(detail && getter ? 250 : 0);
// 选中干员 voice (audio.voice 'select'): once per opened operator — the panel stays mounted while the target changes,
// so the key carries what identifies it (its chess record and its piece / battle unit id)
const selectKey = voice && detail?.type === 'chess' ? `${detail.chess?.chessId || ''}:${detail.unitId ?? detail.piece?.uid ?? ''}` : null;
// so the key carries what identifies it (its chess record, its piece / battle unit id, the card tap: selectVoiceKey)
const selectKey = voice && detail?.type === 'chess' ? selectVoiceKey(detail) : null;
// 0.2.0 补位: a chess fielded as its stand-in is spoken for by the stand-in (the operator on the field, whose model, name
// and battle lines the card and audio.js show), never by the operator it replaces; a 自选 record is already the pick's
const selectChar = voice && detail?.type === 'chess' ? detail.standIn?.charId || detail.chess?.charId || null : null;
@@ -834,11 +878,12 @@ export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestr
<div class="dpanel__scroll">
${detail.type === 'chess' ? html`<${ChessDetail} chess=${detail.chess} piece=${detail.piece} snapHp=${snapHp} editable=${editable} onSell=${sellIt}
bonds=${bonds} offBonds=${offBonds} loadout=${loadout} onBond=${onBond} live=${liveNow} hint=${detail.hint || null} unitItems=${detail.unitItems || null}
standIn=${detail.standIn || null} diy=${detail.diy || null} />` : null}
standIn=${detail.standIn || null} diy=${detail.diy || null} cultOpts=${detail.cultOpts || { ops }} />` : null}
${detail.type === 'item' ? html`<${ItemDetail} item=${detail.item} piece=${detail.piece} editable=${editable} onDestroy=${destroyIt} offBonds=${offBonds} />` : null}
${detail.type === 'enemy' ? html`<${EnemyDetail} enemy=${detail.enemy} snapHp=${snapHp} count=${detail.count} live=${liveNow} />` : null}
${detail.type === 'token' ? html`<${TokenDetail} token=${detail.token} piece=${detail.piece} ownerId=${detail.ownerId ?? null} snapHp=${snapHp} live=${liveNow} />` : null}
${detail.type === 'terrain' ? html`<${TerrainDetail} terrain=${detail.terrain} />` : null}
${detail.type === 'device' ? html`<${DeviceDetail} device=${detail.device} snapHp=${snapHp} live=${liveNow} />` : null}
</div>
</aside>`;
}
+27 -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,24 @@ export function FullscreenButton({ class: cls = '' }) {
</button>`;
}
/**
* Only a browser-issued install offer shows a button; installed and unsupported environments stay quiet.
* `square`: the glyph alone (the lobby's top bar, beside ⚙ / 📖), the name stays title / aria-label.
*/
export function PwaInstallButton({ class: cls = '', size = 'sm', square = false }) {
const [available, setAvailable] = useState(pwaInstall.available);
useEffect(() => pwaInstall.subscribe(setAvailable), []);
if (!available) return null;
return html`<${Button} variant="secondary" size=${size} icon="homePlus" square=${square} class=${cls}
title=${t('添加到桌面')} aria-label=${t('添加到桌面')}
data-testid="pwa-install" onClick=${() => pwaInstall.request()}>${square ? null : 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 +272,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;
}
+2 -2
View File
@@ -147,9 +147,9 @@ export function pickOptions(slotId, picks, data, kitted) {
* The record a slot shows for a pick (shared/diy.js diyRecord: the operator at the slot's normal form, or the elite).
* @param {string} slotId @param {any} pick @param {any} data @param {{ elite?: boolean }} [opts]
*/
export function slotRecord(slotId, pick, data, { elite = false } = {}) {
export function slotRecord(slotId, pick, data, { elite = false, potential = null } = {}) {
if (!isObj(pick)) return null;
try { return diyRecord(slotId, pick, { elite, data }); } catch { return null; }
try { return diyRecord(slotId, pick, { elite, data, potential }); } catch { return null; }
}
/** Set (or with null, clear) one slot. Returns a new roster (the input is not changed). */
+2 -1
View File
@@ -111,7 +111,8 @@ export function EquipReplaceDialog({ request, getItem = () => null, busy = false
].join('');
const confirmIcon = localAsset('ui/battle', 'equip_replace_comfirm_icon');
const swapIcon = localAsset('ui/battle', 'equip_replace_replace_icon');
return html`<div class="eqr-host" style=${style} onKeyDown=${(e) => { if (e.key === 'Enter' && chosen && !busy) { e.preventDefault(); onConfirm(chosen.uid); } }}>
// A focused button owns Enter through its native click, including Cancel and the item options.
return html`<div class="eqr-host" style=${style} onKeyDown=${(e) => { if (e.key === 'Enter' && e.target?.tagName !== 'BUTTON' && chosen && !busy) { e.preventDefault(); onConfirm(chosen.uid); } }}>
<${Modal} open=${true} tone="red" class="eqr" width="min(7.6rem, 96vw)" micro="EQUIPMENT · REPLACE" title=${t('替换装备')} onClose=${onCancel}
actions=${html`
<${Button} variant="secondary" icon="close" class="eqr__cancel" onClick=${onCancel}>${t('取消')}<//>
+18 -5
View File
@@ -13,7 +13,8 @@
// LEFT, facing.js boardDir): the chevrons follow the finger, the range / model / intent use the board direction;
// * release outside the centre commits (g.move {uid, to, dir} / g.art {…, dir}); release inside the centre, a tap
// on ✕, a press outside the diamond or Esc cancels (the piece returns to where it came from). Arrow keys preview a
// direction and Enter commits it (keyboard alternative). Mouse, pen and touch all use pointer events.
// direction and Enter commits it (keyboard alternative) — Enter on a focused ✕ cancels, on any other focused
// button does nothing while the wheel is open (GitHub #394). Mouse, pen and touch all use pointer events.
//
// View bridge: the field view (render/app.js or the DOM fallback) may implement `tileScreen(row, col)`,
// `holdPiece(uid, tile|null)` and `setPieceDir(uid, dir)` (render/app.js does, DESIGN §9); a view without them falls
@@ -25,7 +26,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from '../../vendor/hooks
import { html } from './components.js';
import { LocalSprite } from './gameComponents.js';
import { DIRS, DIR_LABEL, DEAD_ZONE_TILES, dirFromDelta, dirFromKey, rangeTiles, normDir, boardDir, viewMirrored } from './facing.js';
import { facingSwallows } from './gameLogic.js';
import { facingSwallows, facingEnter } from './gameLogic.js';
import { settingsStore } from './settings.js';
import { t } from '../../../shared/i18n.js';
@@ -236,24 +237,36 @@ export function FacingWheel({ view, row, col, grid, name = '', onPreview, onComm
};
const onPointerCancel = () => { setDrag(null); setDir(null); };
// keyboard: arrows preview, Enter commits, Esc cancels (capture: the game's own shortcuts must not see them)
// keyboard: arrows preview, Enter commits, Esc cancels (capture: the game's own shortcuts must not see them). Enter
// follows the focus (gameLogic facingEnter, GitHub #394): on ✕ it cancels, on any other button (Tab reaches the HUD
// behind the wheel) it does nothing, elsewhere it commits the previewed direction. The wheel takes the focus when it
// opens, so a button focused before the drop (a click leaves it focused) cannot swallow the confirming Enter; Tab then
// reaches ✕ first. Nothing gives the focus back when it closes (a restored 准备 would take the next Enter / Space).
const rootRef = useRef(null);
useEffect(() => {
const onKey = (e) => {
const L = live.current;
if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); L.onCancel(); return; }
const k = dirFromKey(e.key);
if (k) { e.preventDefault(); e.stopImmediatePropagation(); setDir(k); return; }
if (e.key === 'Enter' && L.dir) { e.preventDefault(); e.stopImmediatePropagation(); L.onCommit(L.bdir); return; }
const enter = facingEnter(e, !!L.dir);
if (enter) {
e.preventDefault(); e.stopImmediatePropagation();
if (enter === 'cancel') L.onCancel();
else if (enter === 'commit') L.onCommit(L.bdir);
return;
}
// no ready / shop while choosing: Space and every key of the player's map (设置 → 快捷键)
if (facingSwallows(e, settingsStore.get().keys)) { e.preventDefault(); e.stopImmediatePropagation(); }
};
window.addEventListener('keydown', onKey, true);
try { rootRef.current?.focus({ preventScroll: true }); } catch { /* no focus: Enter still follows whatever has it */ }
return () => window.removeEventListener('keydown', onKey, true);
}, []);
const outside = !!drag && !!dir;
const box = half * 2;
return html`<div class=${cx('fwheel', drag && 'is-pressed', dir && `is-${dir.toLowerCase()}`)} role="dialog" aria-label=${name ? t('选择「{name}」的朝向', { name }) : t('选择朝向')}
return html`<div ref=${rootRef} tabIndex="-1" class=${cx('fwheel', drag && 'is-pressed', dir && `is-${dir.toLowerCase()}`)} role="dialog" aria-label=${name ? t('选择「{name}」的朝向', { name }) : t('选择朝向')}
onPointerDown=${onDown} onPointerMove=${onMove} onPointerUp=${onUp} onPointerCancel=${onPointerCancel}
onContextMenu=${(e) => { e.preventDefault(); onCancel(); }}>
${g ? html`<${Stripes} tiles=${tiles} view=${view} row=${row} col=${col} />` : null}
+10
View File
@@ -332,6 +332,16 @@ export function createFallbackView(host, opts = {}) {
const now = performance.now();
if (st.mode === 'prep' && st.priv) {
const heldPos = (p) => { const t = st.held.get(p.uid); return t ? tilePos(L, t.row, t.col) : null; };
for (const u of Array.isArray(st.priv.prepMapChars) ? st.priv.prepMapChars : []) {
if (!u || !Number.isInteger(u.x) || !Number.isInteger(u.y) || typeof u.defId !== 'string') continue;
const pos = tilePos(L, u.y, u.x);
const src = tokenAvatarUrl(m(), u.defId);
pieces.push(html`<div key=${`map:${u.uid}`} class="ff-piece ff-piece--token ff-piece--map-char"
style=${`transform:translate(${pos.x}px,${pos.y}px);width:${L.tile}px;height:${L.tile}px;pointer-events:none`}
title=${u.name || u.defId}>
<div class="ff-piece__art">${src ? html`<img src=${src} alt="" draggable=${false} />` : html`<span>${[...(u.name || '?')][0]}</span>`}</div>
</div>`);
}
for (const p of Array.isArray(st.priv.board) ? st.priv.board : []) {
if (!p || !Number.isInteger(p.row)) continue;
const pos = heldPos(p) || tilePos(L, p.row, p.col);
+78 -16
View File
@@ -132,8 +132,32 @@ function renderPref() {
return globalThis.__SP_RENDER__ === 'fallback' ? 'fallback' : 'auto';
}
function withTimeout(p, ms, what) {
return Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(`${what} timed out`)), ms))]);
function abortError(signal) {
return signal?.reason ?? new DOMException('Field view initialization cancelled', 'AbortError');
}
function checkActive(signal) {
if (signal?.aborted) throw abortError(signal);
}
function withTimeout(p, ms, what, signal) {
return new Promise((resolve, reject) => {
let settled = false;
let timer;
const finish = (done, value) => {
if (settled) return;
settled = true;
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
done(value);
};
const onAbort = () => finish(reject, abortError(signal));
signal?.addEventListener('abort', onAbort, { once: true });
timer = setTimeout(() => finish(reject, new Error(`${what} timed out`)), ms);
// Keep a rejection handler attached even when cancellation wins before this promise settles.
Promise.resolve(p).then((value) => finish(resolve, value), (err) => finish(reject, err));
if (signal?.aborted) onAbort();
});
}
/**
@@ -186,34 +210,55 @@ export function seedAssets(store) {
/**
* Create a field view in `host`: the render engine when available, else the DOM fallback.
* @param {HTMLElement} host
* @param {{ signal?: AbortSignal }} [options] Cancels a pending mount without creating a fallback.
* @returns {Promise<ReturnType<typeof guardView>>}
*/
export async function mountFieldView(host) {
export async function mountFieldView(host, { signal } = {}) {
checkActive(signal);
const pref = renderPref();
const opts = { data, assets: data.get('assets'), audio, settings: settingsStore.get(), padding: hudPadding, hud: hudBands };
if (pref !== 'fallback') {
const controller = new AbortController();
const onAbort = () => controller.abort(abortError(signal));
signal?.addEventListener('abort', onAbort, { once: true });
if (signal?.aborted) onAbort();
let engineView = null;
const dispose = (view) => { try { view?.destroy?.(); } catch { /* ignore */ } };
try {
checkActive(controller.signal);
// the shared asset store (public/js/assets.js) keeps its Spine cache across remounts (next match, reconnect)
const am = await withTimeout(import('../assets.js'), LOAD_TIMEOUT_MS, 'asset store import').catch(() => null);
const am = await withTimeout(import('../assets.js'), LOAD_TIMEOUT_MS, 'asset store import', controller.signal).catch(() => {
checkActive(controller.signal);
return null;
});
checkActive(controller.signal);
if (am?.assets && typeof am.assets.ready === 'function') {
opts.assets = am.assets;
seedAssets(am.assets);
}
const mod = await withTimeout(import('../render/app.js'), LOAD_TIMEOUT_MS, 'render engine import');
const mod = await withTimeout(import('../render/app.js'), LOAD_TIMEOUT_MS, 'render engine import', controller.signal);
checkActive(controller.signal);
if (typeof mod?.createFieldView !== 'function') throw new Error('createFieldView missing');
const view = await withTimeout(Promise.resolve(mod.createFieldView(host, opts)), LOAD_TIMEOUT_MS, 'createFieldView');
// A factory that ignores cancellation still belongs to this attempt when it eventually returns.
const pending = Promise.resolve(mod.createFieldView(host, { ...opts, signal: controller.signal })).then((view) => {
if (controller.signal.aborted) dispose(view); else engineView = view;
return view;
});
const view = await withTimeout(pending, LOAD_TIMEOUT_MS, 'createFieldView', controller.signal);
checkActive(controller.signal);
const missing = METHODS.filter((k) => typeof view?.[k] !== 'function');
if (missing.length) {
try { view?.destroy?.(); } catch { /* ignore */ }
throw new Error(`view lacks ${missing.join(', ')}`);
}
if (missing.length) throw new Error(`view lacks ${missing.join(', ')}`);
return guardView(view, 'engine');
} catch (err) {
controller.abort();
dispose(engineView);
checkActive(signal);
console.warn('[field] render engine unavailable, using the simplified view:', err?.message || err);
// anything the engine left behind in the host goes
try { while (host.firstChild) host.removeChild(host.firstChild); } catch { /* ignore */ }
} finally {
signal?.removeEventListener('abort', onAbort);
}
}
checkActive(signal);
return guardView(createFallbackView(host, { ...opts, assets: data.get('assets') }), 'fallback');
}
@@ -223,29 +268,46 @@ export async function mountFieldView(host) {
*/
export function useFieldView(hostRef) {
const [state, setState] = useState({ view: null, kind: 'loading' });
const [attempt, setAttempt] = useState(0);
const retries = useRef(0);
const viewRef = useRef(null);
useEffect(() => {
let dead = false;
let dead = false, retrying = false;
const host = hostRef.current;
if (!host) return undefined;
mountFieldView(host).then((view) => {
setState({ view: null, kind: 'loading' });
const controller = new AbortController();
mountFieldView(host, { signal: controller.signal }).then((view) => {
if (dead) { view.destroy(); return; }
viewRef.current = view;
if (view.kind === 'engine') retries.current = 0;
globalThis.__SP_VIEW__ = view; // dev / E2E introspection (view.raw.stats?.())
setState({ view, kind: view.kind });
}, (err) => console.error('[field] mount failed', err));
}, (err) => { if (!dead && !controller.signal.aborted) console.error('[field] mount failed', err); });
const unsub = settingsStore.subscribe((s) => viewRef.current?.setSettings?.(s));
const onResize = () => viewRef.current?.resize();
// A temporary network/vendor failure must not pin this mounted match to fallback forever. Retry on a useful
// user event, at most twice in succession; the effect cleanup disposes the old view before creating its successor.
const onRetry = () => {
if (dead || retrying || viewRef.current?.kind !== 'fallback' || renderPref() === 'fallback'
|| document.visibilityState !== 'visible' || globalThis.navigator?.onLine === false || retries.current >= 2) return;
retrying = true; retries.current++; setAttempt((n) => n + 1);
};
window.addEventListener('resize', onResize);
window.addEventListener('online', onRetry);
document.addEventListener('visibilitychange', onRetry);
return () => {
dead = true;
controller.abort();
unsub();
window.removeEventListener('resize', onResize);
window.removeEventListener('online', onRetry);
document.removeEventListener('visibilitychange', onRetry);
// the dev hook must not keep the destroyed view — and through its host the whole detached match screen — alive
if (viewRef.current && globalThis.__SP_VIEW__ === viewRef.current) globalThis.__SP_VIEW__ = null;
viewRef.current?.destroy();
viewRef.current = null;
};
}, []);
}, [attempt]);
return state;
}
+8 -2
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 }),
@@ -64,7 +67,7 @@ export const actions = {
art: (itemUid, row, col, dir) => act('g.art', dir ? { itemUid, row, col, dir } : { itemUid, row, col }),
destroy: (uid) => act('g.destroy', { uid }),
reward: (idx) => act('g.reward', { idx }),
choice: (idx) => act('g.choice', { idx }),
choice: (idx, choiceId) => act('g.choice', choiceId === undefined ? { idx } : { idx, choiceId }),
ready: (ready) => act('g.ready', { ready }, { sfx: ready ? 'ready' : 'back' }),
emote: (id) => act('g.emote', { id }, { quiet: true }),
// `playerId`: the player tapped in the team panel (a shared field shows two) — what an eliminated viewer follows
@@ -72,4 +75,7 @@ export const actions = {
autoplay: (on) => act('g.autoplay', { on }),
// solo battles only (ui/matchStatus.js pauseAvailable): m.public.paused follows
pause: (on) => act('g.pause', { on: !!on }, { sfx: on ? 'click' : 'confirm' }),
// room-level intent (NOT g.*): the host frees a spectator seat while the match runs — the server takes room.removeSpectator at
// any time (server/lobby.js removeSpectator), the game screen had no entry for it (ui/hud.js SpectatorPill; GitHub #120)
removeSpectator: (playerId) => act('room.removeSpectator', { playerId }, { sfx: 'back' }),
};
+7 -7
View File
@@ -33,16 +33,16 @@ export { STATUS_META } from './gameLogic/format.js';
export { sortedPlayers } from './gameLogic/shared.js';
export { ownFieldId, homeFieldId, cycleField, watchTarget, switcherLabel, fieldLabel, activeBubbles } from './gameLogic/watch.js';
export { sortBonds, bondTier, nextThreshold, grantedBonds, pieceBondIds, morphPairings, HARMONY_BOND, harmonyMembers, bondMembers, memberHeadCount, bannedPerBond, disabledBondSets, briefingBondTip, modeOffBonds, bandOffBonds, bandOffLine } from './gameLogic/bonds.js';
export { priceTone, mergeProgress, mergeTarget, handFull, completesMerge, offerHeader, shopBlockReason, readyFundsPrompt } from './gameLogic/shop.js';
export { priceTone, mergeProgress, mergeTarget, handFull, completesMerge, offerHeader, shopBlockReason, readyFundsPrompt, readyShopFold } from './gameLogic/shop.js';
export { deploySets, stageOverrides, deployMap, effectiveStage, indexPieces, placementContext, piecePosition, tileAllows, summonRange, summonExcluded, canPlace, equipReplaces, equipMerges, boardTargets, dropIntent, dropFailureReason } from './gameLogic/placement.js';
export { terrainInfo } from './gameLogic/terrain.js';
export { normalizeDraft, normalizeSp } from './gameLogic/draft.js';
export { terrainInfo, deviceInfo, deviceTipAt, noteDeviceUnits, deviceUnitsForEntry } from './gameLogic/terrain.js';
export { normalizeDraft, normalizeSp, normalizePersonalChoice } from './gameLogic/draft.js';
export { groupEnemies, PEN, penZoneTiles, penPlacement, previewEnemyKey, factionTypes } from './gameLogic/enemies.js';
export { snapHud, bossFrac, bossPctText, hasFlag, UF, attackInterval, fmtNum, rangeGridBox } from './gameLogic/format.js';
export { shortcutFor, closesOnFieldPress, shortcutBlocked, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, isBindableCode, hotkeyLabel, sanitizeHotkeys, rebindHotkey, isDefaultHotkeys, hotkeyOf, actionForKey, captureHotkey, facingSwallows } from './gameLogic/shortcuts.js';
export { DEFAULT_SETTINGS, sanitizeSettings } from './gameLogic/settings.js';
export { 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, TEXT_SIZES, sanitizeSettings } from './gameLogic/settings.js';
export { normalizeResult } from './gameLogic/result.js';
export { chessLoadout, unitLoadout } from './gameLogic/loadout.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';
export { diyPicks, ownDiyPick, diyRecordFor, ownDiyRecord, cardDiy, diyGetter, pickGetter, isDiyRecord, diyBannedPieces } from './gameLogic/diy.js';
export { diyPicks, ownDiyPick, diyRecordFor, ownDiyRecord, cardDiy, unitPick, diyGetter, pickGetter, isDiyRecord, diyBannedPieces } from './gameLogic/diy.js';
export { panelSide, panelSlots, PANEL_RIGHT_GAP, PANEL_RIGHT_BOTTOM, PANEL_RIGHT_BOTTOM_SHOP, BPOP, bondPopupPlace } from './gameLogic/panel.js';
+16 -4
View File
@@ -8,6 +8,8 @@
// player's units say it themselves (UnitInfo `diy`: the sim's, Match.prepFieldMeta's for scouting, m.result lineups). The
// composed record is shared/diy.js diyRecordOf over chess.json + backups.json — the one the server and the sim use.
// `data` below = `{ chess, backups }` (the client's data.get('chess') / data.get('backups'), localized copies).
// 0.2.2: a pick may carry `potential` (the owner's 潜能 of the operator — m.private.ops for the player's own, UnitInfo
// `potential` for another player's unit): the record is composed at it (an owned pick; a prototype has none).
import { diyRecordOf } from '../../../../shared/diy.js';
import { isObj } from './shared.js';
@@ -26,7 +28,10 @@ export function ownDiyPick(priv, chess) {
if (!isObj(chess) || !chess.isDiy) return null;
const slot = typeof chess.diyFor === 'string' ? chess.diyFor : chess.baseId || chess.chessId;
const p = diyPicks(priv)[slot];
return isObj(p) && typeof p.charId === 'string' ? p : null;
if (!isObj(p) || typeof p.charId !== 'string') return null;
// 0.2.2: at the player's 潜能 of that operator (m.private.ops; none set = 6)
const pot = isObj(priv?.ops) && isObj(priv.ops[p.charId]) ? priv.ops[p.charId].potential : null;
return Number.isInteger(pot) && pot >= 1 && pot < 6 ? { ...p, potential: pot } : p;
}
/** Per backups object: `chessId|charId|skill|module` → composed record (or null). */
@@ -44,10 +49,11 @@ export function diyRecordFor(chess, pick, data) {
if (!isObj(pick) || !isObj(data?.backups)) return null;
let m = CACHE.get(data.backups);
if (!m) { m = new Map(); CACHE.set(data.backups, m); }
const key = `${chess.chessId}|${pick.charId}|${pick.skillIndex ?? ''}|${pick.uniEquipId ?? ''}`;
const pot = Number.isInteger(pick.potential) ? pick.potential : null;
const key = `${chess.chessId}|${pick.charId}|${pick.skillIndex ?? ''}|${pick.uniEquipId ?? ''}|${pot ?? ''}`;
if (m.has(key)) return m.get(key);
let rec;
try { rec = diyRecordOf(chess, pick, data); } catch { rec = null; }
try { rec = diyRecordOf(chess, pick, data, { potential: pot }); } catch { rec = null; }
m.set(key, rec);
return rec;
}
@@ -65,10 +71,16 @@ export function ownDiyRecord(chess, priv, data) {
*/
export function cardDiy(chess, { priv = null, unit = null, data = null } = {}) {
if (!isObj(chess) || !chess.isDiy) return null;
if (unit) return isObj(unit.diy) ? diyRecordFor(chess, unit.diy, data) : null;
if (unit) return isObj(unit.diy) ? diyRecordFor(chess, unitPick(unit), data) : null;
return ownDiyRecord(chess, priv, data);
}
/** A unit's 自选 pick with its potential (UnitInfo `diy` + `potential`, 0.2.2), or null. */
export function unitPick(unit) {
if (!isObj(unit) || !isObj(unit.diy)) return null;
return Number.isInteger(unit.potential) ? { ...unit.diy, potential: unit.potential } : unit.diy;
}
/**
* id → the player's 自选 record of a filled DIY slot (both forms), else the chess record: the lookup the player's own
* pieces and cards resolve against (names, art, position, the loadout / range of a 自选 piece — shared/protocol.js
+14
View File
@@ -1,6 +1,8 @@
// ui/gameLogic/draft.js — 机变 and band-draft normalisation. Re-exported from ../gameLogic.js.
import { isObj } from './shared.js';
import { PHASE } from '../../../../shared/constants.js';
import { t } from '../../../../shared/i18n.js';
// ---- 机变 / band draft normalisation ----------------------------------------------------------------------
@@ -73,3 +75,15 @@ export function normalizeSp(sp, players = []) {
cards, order, turnPid, pickOf, takenBy, pickedCount: pickOf.size,
};
}
/** Adapt only the recipient's current PREP choice; never write it into the public draft. */
export function normalizePersonalChoice(pub, priv, myId) {
const choice = priv?.personalChoice;
if (pub?.phase !== PHASE.PREP || !priv || priv.playerId !== myId || priv.alive === false
|| !choice || choice.round !== pub.round) return null;
return {
...normalizeSp({ family: 'bounty', name: t('教鞭 · 战术特训'), desc: t('请选择一项战术特训'),
cards: choice.cards, turn: myId, order: [myId], picks: {} }),
id: choice.id,
};
}
+5 -2
View File
@@ -22,7 +22,9 @@ export const STATUS_META = Object.freeze({
// ---- snapshots / battle HUD ---------------------------------------------------------------------------------
/**
* HUD numbers from a b.snap: { killed, total, dp, boss } (boss: { hp, max } when present).
* HUD numbers from a b.snap: { killed, resolved, total, dp, boss } (boss: { hp, max } when present).
* `resolved` is the capsule's numerator — knocked out + leaked among the round's own enemies (Battle.leakedInTotal);
* an older snapshot without it falls back to the kill count.
* @param {any} snap
*/
export function snapHud(snap) {
@@ -30,7 +32,8 @@ export function snapHud(snap) {
const n = (v) => (Number.isFinite(v) ? v : null);
let boss = null;
if (isObj(snap.boss) && Number.isFinite(snap.boss.hp)) boss = { hp: snap.boss.hp, max: n(snap.boss.max) ?? n(snap.boss.maxHp) };
return { killed: n(snap.killed), total: n(snap.total), dp: n(snap.dp), boss };
const killed = n(snap.killed);
return { killed, resolved: n(snap.resolved) ?? killed, total: n(snap.total), dp: n(snap.dp), boss };
}
/** Boss HP fraction 0..1 (null when unknown). */
+53 -6
View File
@@ -2,9 +2,19 @@
import { MODULE_NONE, loadoutOptions, resolveLoadout } from '../../../../shared/protocol.js';
import { loadoutRecord, resolveRecordLoadout } from '../../../../shared/loadoutRecord.js';
import { cultivationOf, cultivateMul, cultivatedStats, isPotential, isCultivate } from '../../../../shared/potential.js';
import { isObj } from './shared.js';
import { t } from '../../../../shared/i18n.js';
/** The summon owner's variant: exact chess id, normal sibling, then the first available variant. */
export function tokenVariantFor(token, ownerId = null) {
const vs = token?.variants || {};
if (typeof ownerId === 'string') {
const v = vs[ownerId] || vs[ownerId.replace(/_b$/, '_a')];
if (v) return v;
}
return Object.values(vs)[0] || null;
}
// ---- operator loadout (DESIGN §16) ----------------------------------------------------------------------------------
@@ -12,14 +22,21 @@ import { t } from '../../../../shared/i18n.js';
* The skill / module a chess fights with under the player's loadout (m.private.loadout, DESIGN §16) — what the shop
* card and the detail panel show. Works with data that already lists every selectable skill (`skills[]`, elite
* `modules[]`) and with older data (only the default `skill` / `module`).
* 0.2.2: the record (stats, talents) is at the operator's 潜能 and its stats carry the 练度 multiplier — the player's
* settings (`opts.ops`: m.private.ops / the 干员调配 store; missing = 潜能 6, 精英2 Lv.60), or a unit's own
* (`opts.cultivation`: a teammate's UnitInfo — null = none); a 补位 stand-in / prototype 自选 record has neither
* (shared/potential.js cultivationOf). The 练度 needs `opts.effects` (data/effects.json). A composed 自选 record is
* already at its potential (gameLogic/diy.js: the pick's `potential`).
* @param {any} chess the chess record (normal or elite)
* @param {any} loadout m.private.loadout `{ [baseChessId]: { skill, module } }` (entries equal to the defaults may be
* missing); null → defaults
* @param {(id: string) => any} [getChess]
* @param {{ ops?: any, cultivation?: { potential?: number|null, cultivate?: number|null } | null, effects?: any }} [opts]
* @returns {{ skill: any, skillIndex: number|null, defaultSkill: boolean, module: { id: string, name: string, typeName: string, none: boolean }|null,
* defaultModule: boolean, changed: boolean, choices: number } | null}
* defaultModule: boolean, changed: boolean, choices: number, record: any,
* cultivation: { potential: number, cultivate: number|null } | null } | null}
*/
export function chessLoadout(chess, loadout, getChess = () => null) {
export function chessLoadout(chess, loadout, getChess = () => null, opts = {}) {
if (!isObj(chess)) return null;
const lo = isObj(loadout) && !Array.isArray(loadout) ? loadout : null;
let r = { skillIndex: null, moduleId: null };
@@ -43,12 +60,42 @@ export function chessLoadout(chess, loadout, getChess = () => null) {
}
defaultModule = opt.defaultModule == null || id === opt.defaultModule;
}
// the record the unit fights with (stats / 特性 / talents of the chosen module or none — the battle's own composition,
// shared/loadoutRecord.js): the detail card must show what the sim runs
// 0.2.2: the operator's 潜能 / 练度 (the player's settings, or the unit's own)
const cv = cultivationFor(chess, opts);
// the record the unit fights with (stats / 特性 / talents of the chosen module or none, at its potential — the
// battle's own composition, shared/loadoutRecord.js): the detail card must show what the sim runs
let record = chess;
try { record = loadoutRecord(chess, resolveRecordLoadout(chess, { skillIndex: r.skillIndex, moduleId: r.moduleId })) || chess; } catch { /* the record as is */ }
try { record = loadoutRecord(chess, resolveRecordLoadout(chess, { skillIndex: r.skillIndex, moduleId: r.moduleId, potential: cv ? cv.potential : null })) || chess; } catch { /* the record as is */ }
const mul = cv && isCultivate(cv.cultivate) ? cultivateMul(opts?.effects, cv.cultivate) : null;
if (mul && record && record.stats) record = { ...record, stats: cultivatedStats(record.stats, mul) };
return { skill, skillIndex: skill?.index ?? null, defaultSkill, module, defaultModule, changed: !defaultSkill || !defaultModule,
choices: Math.max(skills.length, opt.skills?.length || 0), record };
choices: Math.max(skills.length, opt.skills?.length || 0), record, cultivation: cv };
}
/**
* The 潜能 / 练度 of a card's record (chessLoadout `opts`): the unit's own (`cultivation`, null = none) or the player's
* settings (`ops`, shared/potential.js cultivationOf — null for a stand-in / prototype 自选 record).
* @returns {{ potential: number, cultivate: number|null } | null}
*/
function cultivationFor(chess, opts) {
if (opts && opts.cultivation !== undefined) {
const c = opts.cultivation;
if (!isObj(c) || !isObj(chess) || chess.standInFor || chess.diyProto) return null;
return { potential: isPotential(c.potential) ? c.potential : 6, cultivate: isCultivate(c.cultivate) ? c.cultivate : null };
}
return cultivationOf(chess, opts ? opts.ops : null);
}
/**
* The 潜能 / 练度 a battle / scouting unit fights at (UnitInfo `potential` — below 6 only — and `cultivate`, 0.2.2): for
* chessLoadout `opts.cultivation`; null when the unit has neither (a stand-in, a prototype 自选 piece, a raw unit).
* @param {any} unit UnitInfo
*/
export function unitCultivation(unit) {
if (!isObj(unit)) return null;
const pot = isPotential(unit.potential) ? unit.potential : 6;
const cult = isCultivate(unit.cultivate) ? unit.cultivate : null;
return cult == null && pot === 6 ? null : { potential: pot, cultivate: cult };
}
/**
+18 -10
View File
@@ -3,6 +3,7 @@
import { GEO } from '../../../../shared/constants.js';
import { attackRangeGrid } from '../../../../shared/loadoutRecord.js';
import { meleeOnHighGround } from '../../../../shared/highGround.js';
import { summonPlacementGrid } from '../../../../shared/summonPlacement.js';
import { pieceDir, rangeTiles } from '../facing.js';
import { isObj, tileKey } from './shared.js';
import { boardTileOf, fieldTile } from './camera.js';
@@ -250,15 +251,18 @@ export function tileAllows(ctx, piece, row, col) {
/**
* Board tiles ('r,c') where a summon whose text reads "只能部署在召唤者攻击范围内" may stand (tokens.json `ownerRange`:
* 伺夜's 狼群, 缪尔赛思's 流形 — the tactical point; player report #9 after 0.1.0): its owner's attack range (the
* loadout's grid, shared/loadoutRecord.js attackRangeGrid, rotated by the owner's facing around its tile). Null when
* loadout's grid, shared/loadoutRecord.js attackRangeGrid, rotated by the owner's facing around its tile).
* Jessica's shield uses the four-adjacent-tile grid instead, independent of facing/skill/module. Null when
* the piece is not range-bound or its owner is not on the board. `owner` ({ row, col, piece }) replaces the owner's
* current position (a summon swapped with its own owner). Mirror of server/match/PlayerState.js summonRange.
* @param {ReturnType<typeof placementContext>} ctx
* @returns {Set<string> | null}
*/
export function summonRange(ctx, piece, owner = null) {
if (!isObj(piece) || piece.kind !== 'token' || ctx?.getToken?.(piece.id)?.ownerRange !== true) return null;
return ownerRangeOf(ctx, piece, owner);
if (!isObj(piece) || piece.kind !== 'token') return null;
const grid = summonPlacementGrid(piece.id);
if (!grid && ctx?.getToken?.(piece.id)?.ownerRange !== true) return null;
return ownerRangeOf(ctx, piece, owner, grid);
}
/**
@@ -273,14 +277,14 @@ export function summonExcluded(ctx, piece, owner = null) {
return ownerRangeOf(ctx, piece, owner);
}
/** The attack-range tiles of a summon piece's owner on the board (summonRange / summonExcluded), or null. */
function ownerRangeOf(ctx, piece, owner = null) {
/** The attack or fixed placement range of a summon piece's owner on the board, or null. */
function ownerRangeOf(ctx, piece, owner = null, placementGrid = null) {
let at = owner;
if (!at) for (const e of ctx.boardAt.values()) if (e.piece.uid === piece.ownerUid && e.piece.kind === 'chess') { at = e; break; }
const rec = at && ctx.getChess(at.piece.id);
if (!rec) return null;
let grid = null;
try { grid = attackRangeGrid(deployedRecord(rec, ctx.priv, ctx.getChess, ctx.backups) || rec); } catch { /* the data grid */ }
try { grid = placementGrid || attackRangeGrid(deployedRecord(rec, ctx.priv, ctx.getChess, ctx.backups) || rec); } catch { /* the data grid */ }
return new Set(rangeTiles(grid || rec.rangeGrid, at.row, at.col, pieceDir(at.piece)).map(([r, c]) => tileKey(r, c)));
}
@@ -301,7 +305,8 @@ function unitAllowed(ctx, piece, row, col, owner = null) {
* must be legal on the source tile); from hand/temp the deploy cap applies unless a chess occupant swaps out.
* board ← token: legal tile; from the hand the tile must be empty and its summoner deployed; board ↔ board swaps.
* hand ← chess from the board: empty slot (withdraw), chess occupant (swap, legal on the source tile), otherwise
* it goes to any free slot (HAND_FULL when none). hand ← token from the board: always (back onto its stack).
* it uses a free slot or one freed by its own summon stack (HAND_FULL when none). hand ← token from the board:
* always (back onto its stack).
* hand ← hand/temp piece: move / swap. hand ← EQUIP item onto a chess: equip; onto a non-chess: swap.
* board ← EQUIP item: equip the chess on that tile. board ← MAGIC (Arts): used on that tile.
* @param {ReturnType<typeof placementContext>} ctx
@@ -334,8 +339,11 @@ export function canPlace(ctx, uid, target) {
if (!tileAllows(ctx, occ.piece, src.row, src.col)) return no('BAD_TILE', t('交换后的单位无法部署在原位置'));
return { ok: true, action: 'swap' };
}
// the withdrawn operator goes to another free slot
const free = [...Array(GEO.HAND_SIZE).keys()].some((i) => !ctx.handAt.has(i));
// Withdrawal removes the operator's own summon stacks, freeing their hand slots too.
const free = [...Array(GEO.HAND_SIZE).keys()].some((i) => {
const p = ctx.handAt.get(i)?.piece;
return !p || (p.kind === 'token' && p.ownerUid === piece.uid);
});
return free ? { ok: true, action: 'move' } : no('HAND_FULL', t('整备区已满'));
}
return { ok: true, action: 'swap' };
@@ -363,7 +371,7 @@ export function canPlace(ctx, uid, target) {
// dropped onto its own owner (the two swap); an outside-bound one (战术锚点) outside it
const ownerSwap = src.area === 'board' && occ && occ.piece.uid === piece.ownerUid ? { row: src.row, col: src.col, piece: occ.piece } : null;
const range = summonRange(ctx, piece, ownerSwap);
if (range && !range.has(tileKey(row, col))) return no('BAD_TILE', t('只能部署在召唤者攻击范围内'));
if (range && !range.has(tileKey(row, col))) return no('BAD_TILE', summonPlacementGrid(piece.id) ? t('只能部署在召唤者相邻四格') : t('只能部署在召唤者攻击范围内'));
const out = summonExcluded(ctx, piece, ownerSwap);
if (out && out.has(tileKey(row, col))) return no('BAD_TILE', t('只能部署在召唤者攻击范围外'));
if (src.area === 'board') {
+24 -4
View File
@@ -1,20 +1,37 @@
// 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';
// ---- settings ------------------------------------------------------------------------------------------------------
/** keys: the in-match shortcuts' key map (ui/gameLogic/shortcuts.js; settings → 快捷键). */
export const DEFAULT_SETTINGS = Object.freeze({ bgm: 0.6, sfx: 0.8, voice: 0.8, muted: false, damageNumbers: true, quality: 'high', keys: DEFAULT_HOTKEYS });
/**
* The operator voice dubs (settings 语音语言): 'cn' 中文 (`audio.voice`, the default) and 'jp' 日本語 (`audio.voiceJp`,
* falling back to the Chinese line it lacks — public/js/audio.js voiceLine). Not tied to the interface language.
*/
/**
* 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, 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 : {};
@@ -23,9 +40,12 @@ export function sanitizeSettings(raw) {
bgm: vol(r.bgm, DEFAULT_SETTINGS.bgm),
sfx: vol(r.sfx, DEFAULT_SETTINGS.sfx),
voice: vol(r.voice, DEFAULT_SETTINGS.voice),
voiceLang: VOICE_LANGS.includes(r.voiceLang) ? r.voiceLang : DEFAULT_SETTINGS.voiceLang,
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),
};
}
+21 -2
View File
@@ -1,4 +1,5 @@
// ui/gameLogic/shop.js — shop prices, merges, offers, the ready-funds prompt. Re-exported from ../gameLogic.js.
// ui/gameLogic/shop.js — shop prices, merges, offers, the ready-funds prompt, the shop bar's fold on 准备. Re-exported from
// ../gameLogic.js.
import { GEO } from '../../../../shared/constants.js';
import { isObj } from './shared.js';
@@ -139,7 +140,10 @@ export function offerHeader(offer) {
export function shopBlockReason(kind, { priv, editable, slot } = {}) {
if (!priv) return t('尚未就绪');
if (priv.alive === false) return t('你已被淘汰');
if (kind === 'ready') return priv.canReady === false ? t('临时整备区不为空,请先处理溢出的资源') : null;
if (kind === 'ready') {
if (priv.personalChoice) return t('请先完成教鞭选择');
return priv.canReady === false ? t('临时整备区不为空,请先处理溢出的资源') : null;
}
if (!editable) {
if (kind === 'reward') return t('当前无法选择');
return priv.ready ? t('已准备就绪,取消准备后才能操作') : t('当前阶段无法进行该操作');
@@ -183,3 +187,18 @@ export function readyFundsPrompt(priv, { ready = true, keptBands = null, autopla
text: t('还有 {funds} 资金未使用。休整期结束时,本回合的剩余资金将清零。确定准备就绪吗?', { funds }),
};
}
/**
* The shop bar follows the player's ready state (GitHub #138, the owner's decision of 2026-10-07): pressing 准备就绪 folds
* the bar — the board is set, the fight is what to look at —, cancelling it unfolds the bar again. Read from the own CONFIRMED
* state (m.private.ready — a refused 准备 folds nothing) and only a change inside one prep counts: the first state seen (a
* reconnect in the middle of a prep, the prep's start), the new round's reset to not ready and anything outside a prep are
* not a press. The player can still fold / unfold by hand in between.
* @param {{ round: any, ready: boolean }|null|undefined} prev the own prep state seen last (null: none — outside a prep)
* @param {{ round: any, ready: boolean }|null|undefined} cur the own prep state now (null outside a prep)
* @returns {'fold'|'unfold'|null}
*/
export function readyShopFold(prev, cur) {
if (!isObj(prev) || !isObj(cur) || prev.round !== cur.round || !!prev.ready === !!cur.ready) return null;
return cur.ready ? 'fold' : 'unfold';
}
+27 -5
View File
@@ -140,8 +140,8 @@ export function captureHotkey(e) {
* Map a keydown to a game shortcut under the player's key map (defaults: R refresh, F freeze, D level-up, Q retreat,
* X sell, Space ready) or Esc (close; fixed). A shortcut key means its action even while a HUD button has focus (a
* mouse click leaves the shop card / 刷新 focused, and Space must not re-trigger it); the caller prevents the button's
* own activation. Enter still activates buttons (it cannot be a shortcut). Nothing while Ctrl / ⌘ / Alt is held or a
* text field has the focus.
* own activation. Enter still activates buttons (it cannot be a shortcut; behind the facing wheel only its ✕:
* facingEnter). Nothing while Ctrl / ⌘ / Alt is held or a text field has the focus.
* @param {{ key?: string, code?: string, ctrlKey?: boolean, metaKey?: boolean, altKey?: boolean, repeat?: boolean, target?: any }} e
* @param {any} [keys] the player's map (settings `keys`)
* @returns {'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready'|'escape'|null}
@@ -164,14 +164,36 @@ export function shortcutFor(e, keys = DEFAULT_HOTKEYS) {
*/
export const facingSwallows = (e, keys = DEFAULT_HOTKEYS) => e?.key === ' ' || actionForKey(e, keys) != null;
// what a focused element is when Enter would activate it (a button, a link, a form field, an ARIA control)
const CONTROL = 'button, a[href], input, select, textarea, summary, [contenteditable=""], [contenteditable="true"], '
+ '[role="button"], [role="link"], [role="checkbox"], [role="switch"], [role="tab"], [role="menuitem"], [role="option"]';
/**
* What Enter does while the facing wheel is open (GitHub #394, PR #395): on the wheel's own ✕ button it cancels; on
* any other control (准备, 刷新, 设置, 玩法说明, a shop card … — Tab reaches them behind the wheel) nothing happens — the
* wheel holds the keyboard as it holds Space and the shortcuts; with the focus anywhere else (the wheel itself, the
* page) it confirms the previewed direction, and does nothing while none is previewed. null: not Enter.
* @param {{ key?: string, target?: any }} e @param {boolean} chosen a direction is previewed
* @returns {'cancel'|'commit'|'swallow'|null}
*/
export function facingEnter(e, chosen) {
if (e?.key !== 'Enter') return null;
const t = e.target;
const closest = (sel) => (t && typeof t.closest === 'function' ? t.closest(sel) : null);
if (closest('.fwheel__cancel')) return 'cancel';
if (closest(CONTROL)) return 'swallow';
return chosen ? 'commit' : 'swallow';
}
/**
* Whether a press on the field closes the open detail card: a card opened from the field itself (an own piece — tap,
* right-click or long press — or a battle / teammate unit). Shop, reward, bond-member and intel (enemy) cards stay.
* @param {{ kind?: string }|null|undefined} detail
*/
// a card opened BY a field press (a piece, a unit, a special terrain tile: issue #184) closes on the next press of
// the field; the ones opened from the shop / hand / HUD stay until their own close button (or the flow that opened them)
export const closesOnFieldPress = (detail) => detail?.kind === 'piece' || detail?.kind === 'unit' || detail?.kind === 'terrain';
// a card opened BY a field press (a piece, a unit, a special terrain tile: issue #184, a stage device: #228) closes on the
// next press of the field; the ones opened from the shop / hand / HUD stay until their own close button (or the flow that
// opened them)
export const closesOnFieldPress = (detail) => detail?.kind === 'piece' || detail?.kind === 'unit' || detail?.kind === 'terrain' || detail?.kind === 'device';
/**
* Whether an open overlay swallows a game shortcut: a modal / the guide own the keyboard (Esc included — they close
+189 -3
View File
@@ -1,9 +1,11 @@
// ui/gameLogic/terrain.js — special terrain tips (GitHub issue #184: 特殊地形的单击信息提示). Re-exported from
// ../gameLogic.js.
// ui/gameLogic/terrain.js — the tips of a tap on the ground: special terrain tiles (GitHub issue #184: 特殊地形的单击信息
// 提示) and, in the same place, stage devices (阻隔工事 / “双眼皮” / 射击台 / 气流: GitHub #228, PR #229 by @rickylxw —
// deviceInfo / deviceTipAt below). Re-exported from ../gameLogic.js.
//
// The words go through t() (docs/I18N.md: the Chinese text is the msgid, public/i18n/en.json the English): the name /
// tag / fact tables are marked N_() and translated where terrainInfo hands them out, the mechanism lines are built
// with their numbers as params.
// with their numbers as params. A device's NAME is a data text (data/stages.json `devices[].name`, localized by the
// data overlay: "Barricade", "'Double Lids'" …), not a msgid.
import { isObj } from './shared.js';
import { t, N_ } from '../../../../shared/i18n.js';
@@ -123,3 +125,187 @@ export function terrainInfo(stage, row, col) {
else if (tile.groundPassable === true) facts.push(t('地面单位可通过'));
return { key, name: t(tip.name), tag: t(tip.tag), row, col, lines: tip.lines(stage.special).filter((s) => typeof s === 'string' && s), facts };
}
// ---- stage device tips (GitHub #228, PR #229 by @rickylxw: the stage DEVICES, not only the tiles) ---------------------
/** The device roles a tap explains; the rest is invisible (盟约寒风, the 沼泽 / 涨潮 controllers) or not fielded this season. */
const DEVICE_TIP_ROLES = Object.freeze(['crate', 'turret', 'platform', 'blower']);
/** Roles the battle spawns as device UNITS (Battle._spawnStageDevices, devices.js spawnTurret): in a battle a tap finds them by unit. */
const DEVICE_UNIT_ROLES = Object.freeze(['crate', 'turret']);
const TIP_TAG = N_('场地装置');
const DIR_NAME = Object.freeze({ UP: N_('上'), DOWN: N_('下'), LEFT: N_('左'), RIGHT: N_('右') });
const round1 = (v) => Math.round(v * 10) / 10;
/**
* What tapping a stage device says — the same contract as TERRAIN_TIPS, but `lines` read the device's own stage entry
* (data/stages.json `stage.devices[]`: stats / skill blackboard / dir / rangeTiles), the very numbers the sim runs
* (`sim/content/devices.js`, `Battle._spawnStageDevices`), so a tip can never disagree with the battle. The prose is ours
* (docs/PLAYING.md wording; PRTS 阻隔工事 / “双眼皮” / 射击台 / 源石流发生装置 and the sim's behaviour). `stats` is the
* stat grid under the lines (the turret's), `facts` what the tile allows.
*/
const DEVICE_TIPS = Object.freeze({
crate: {
lines: (st, d) => [
t('挡在地面上的工事:地面敌人会绕开它走;只有无路可走时才会撞上来,把它摧毁后继续前进'),
t('生命 {hp} 点,被摧毁后从场上消失', { hp: param(d.stats?.maxHp, 100) }),
],
facts: () => [t(BUILDABILITY.NONE)], // board.js buildDeployMap: an active crate takes its tile off the deploy map
},
turret: {
lines: (st, d) => {
const bb = isObj(d.skill?.bb) ? d.skill.bb : {};
// devices.js spawnTurret: the same blackboard keys and the same `持续N秒` read from the skill text
const dur = /持续(\d+(?:\.\d+)?)秒/.exec(String(d.skill?.desc ?? ''));
const aspdMax = param(bb.max_attack_speed, Infinity);
const fragMax = param(bb.max_damage_scale, Infinity);
return [
t('自动攻击射程内的一名敌人,造成法术伤害,命中的敌人附加脆弱'),
t('按当前层数最高的盟约计算:每 1 层,攻击速度 +{aspd}(至多 +{aspdMax}),附加的脆弱 +{frag}%(至多 +{fragMax}%)', {
aspd: param(bb.attack_speed_per_stack, 1),
aspdMax: Number.isFinite(aspdMax) ? aspdMax : '∞',
frag: round1(param(bb.damage_scale_per_stack, 0.001) * 100),
fragMax: Number.isFinite(fragMax) ? round1((fragMax - 1) * 100) : '∞',
}),
dur ? t('脆弱持续 {sec} 秒', { sec: Number(dur[1]) }) : null,
];
},
stats: (st, d) => {
const s = isObj(d.stats) ? d.stats : {};
const out = [{ k: t('生命上限'), v: param(s.maxHp, 100) }];
if (param(s.atk, 0)) out.push({ k: t('攻击'), v: param(s.atk, 0) });
if (param(s.def, 0)) out.push({ k: t('防御'), v: param(s.def, 0) });
const sec = param(s.bat, 0) > 0 ? param(s.bat, 0) * 100 / (param(s.aspd, 100) > 0 ? param(s.aspd, 100) : 100) : 0;
if (sec) out.push({ k: t('攻击间隔'), v: `${round1(sec)}s` });
return out;
},
facts: () => [t(BUILDABILITY.NONE)],
},
platform: {
lines: () => [
t('高台位装置:地面敌人不能走上这一格'),
t('远程位干员可以部署在其上;站上去的干员在高位,不阻挡敌人'),
],
facts: () => [t(BUILDABILITY.RANGED)], // board.js: an active platform makes its tile a 'ranged' deploy tile
},
blower: {
lines: (st, d) => {
// devices.js buildTerrain: the device's own skill blackboard, else stage.special.blower.bb; rangeTiles = its own tile + the lane
const bb = isObj(d.skill?.bb) ? d.skill.bb : (isObj(st?.blower?.bb) ? st.blower.bb : {});
const dir = DIR_NAME[String(d.dir || 'UP').toUpperCase()] || DIR_NAME.UP;
const lane = Array.isArray(d.rangeTiles) && d.rangeTiles.length > 1 ? d.rangeTiles.length - 1 : 3;
const out = [t('向前方 {n} 格吹出气流(这一台朝{dir})', { n: lane, dir: t(dir) })];
const eq = param(bb['blower_s_character[equal].atk'], 0);
const op = param(bb['blower_s_character[opposite].atk'], 0);
const mods = [];
if (eq) mods.push(t('朝向与风向相同的攻击力 {v}', { v: pctText(eq) }));
if (op) mods.push(t('相反的 {v}', { v: pctText(op) }));
if (mods.length) out.push(t('部署在气流里的干员:{mods}', { mods }));
const eqM = param(bb['blower_s_enemy[equal].move_speed'], 0);
const opM = param(bb['blower_s_enemy[opposite].move_speed'], 0);
const em = [];
if (eqM) em.push(t('顺风移动速度 ×{v}', { v: round2(1 + eqM) }));
if (opM) em.push(t('逆风 ×{v}', { v: round2(1 + opM) }));
if (em.length) out.push(t('在气流里移动的敌人:{mods}', { mods: em }));
return out;
},
facts: () => [t(BUILDABILITY.NONE)],
},
});
const round2 = (v) => Math.round(v * 100) / 100;
/** A stage device entry is drawn: `active` when it carries it, else not `hidden` (render/tiles.js _stageDevices, board3d/layout.js stageDevices). */
const deviceDrawn = (d) => (typeof d.active === 'boolean' ? d.active : !d.hidden);
/** The card of stage device `d` standing at (row, col) — `tip` is DEVICE_TIPS[d.role]. */
function deviceCard(stage, d, row, col) {
const tip = DEVICE_TIPS[d.role];
const lines = tip.lines(stage.special, d).filter((s) => typeof s === 'string' && s);
const stats = typeof tip.stats === 'function' ? tip.stats(stage.special, d) : null;
const name = typeof d.name === 'string' && d.name ? d.name : String(d.role);
return {
key: d.role, name, tag: t(TIP_TAG), row, col, lines, facts: tip.facts(stage.special, d).filter(Boolean),
...(stats && stats.length ? { stats } : {}),
};
}
/**
* The tip a tap on the tile (row, col) opens when a stage DEVICE stands on it (GitHub #228, PR #229), or null: an ordinary
* tile — or a device this client does not draw (`active` false: a turret that stays dormant until 机械援助 / a 机变 card
* switches it on, a crate such a card removed) — falls through to `terrainInfo`. Visibility mirrors what the renderers draw
* (`render/tiles.js _stageDevices`, `board3d/layout.js stageDevices`): `active` when set, else not `hidden`. The stage is
* the shown field's (already the player's own `effectiveStage`, so a card-removed crate is not in the way). The device's
* name is its data text (localized by the data overlay); the lines are in the current language (t()).
* @param {{ devices?: any[], special?: any, rows?: string[], tiles?: Record<string, any> } | null | undefined} stage
* @param {number} row board row (row 0 = the bottom row, DESIGN §1)
* @param {number} col
* @param {{ roles?: readonly string[] }} [opts] `roles`: only these roles answer (default: the four of DEVICE_TIP_ROLES)
* @returns {{ key:string, name:string, tag:string, row:number, col:number, lines:string[], facts:string[], stats?:{k:string,v:string|number}[] } | null}
*/
export function deviceInfo(stage, row, col, { roles = DEVICE_TIP_ROLES } = {}) {
const list = Array.isArray(stage?.devices) ? stage.devices : null;
if (!list || !Number.isInteger(row) || !Number.isInteger(col)) return null;
for (const d of list) {
if (!isObj(d) || !Array.isArray(d.pos) || d.pos[0] !== row || d.pos[1] !== col) continue;
if (!roles.includes(d.role) || !DEVICE_TIPS[d.role] || !deviceDrawn(d)) continue;
return deviceCard(stage, d, row, col);
}
return null;
}
/**
* The tip of a tap on (row, col) while a BATTLE shows the field — the live counterpart of `deviceInfo`: crates and “双眼皮”
* turrets are device UNITS there (kind 'device', one per drawn stage device, `defId` = the device key), so they are
* found by the unit standing on the tile — a turret outside the battle's rect stands on another tile than its stage `pos`
* (devices.js deviceTile) — and what the card says comes from the stage entry of that key. The platforms and blowers are no
* units: they answer by their stage `pos`, as in the prep.
* A destroyed device (HP gone, or no longer in the snapshots once its DIE animation is over) is not there any more: the
* tap belongs to the tile under the wreck (null here, so it falls through to `terrainInfo`). Before the first snapshot of the
* battle has arrived (`snap` empty) a unit counts as standing.
* The result is the same card as `deviceInfo`'s, with `unitId` (the unit, for its live HP: the screen reads `b.snap`
* through it, like a unit card's `snapHp`).
* @param {object|null|undefined} stage the shown field's stage
* @param {number} row @param {number} col
* @param {{ units?: any[]|null, snap?: Map<number, any[]>|null }} battle the field's unit infos (m.field.units) and the
* latest snapshot tuples by unit id ([id, x, y, hp, maxHp, …])
*/
export function deviceTipAt(stage, row, col, { units = null, snap = null } = {}) {
if (!Number.isInteger(row) || !Number.isInteger(col)) return null;
const u = (Array.isArray(units) ? units : []).find((x) => isObj(x) && x.kind === 'device' && Math.round(Number(x.x)) === col && Math.round(Number(x.y)) === row);
if (!u) return deviceInfo(stage, row, col, { roles: DEVICE_TIP_ROLES.filter((r) => !DEVICE_UNIT_ROLES.includes(r)) });
if (snap && snap.size > 0) {
const tuple = snap.get(u.id);
if (!Array.isArray(tuple) || !(tuple[3] > 0)) return null;
}
const list = Array.isArray(stage?.devices) ? stage.devices : [];
const same = list.filter((d) => isObj(d) && d.key === u.defId && DEVICE_UNIT_ROLES.includes(d.role));
const d = same.find((x) => Array.isArray(x.pos) && x.pos[0] === row && x.pos[1] === col) || same[0];
return d ? { ...deviceCard(stage, d, row, col), unitId: u.id } : null;
}
/**
* Fold the device units a battle shows into `map` (UnitInfo by unit id): the field meta's `units` (what stands when the field
* is entered) and the 'spawn' event tuples `['spawn', UnitInfo]` after it — the stage crates and the “双眼皮” turrets come
* into being with the battle's first step (`battleStart`), after the meta of a battle shown from its beginning was taken.
* Only kind 'device' is kept; `deviceTipAt` reads the map's values.
* @param {Map<number, any>} map
* @param {{ units?: any[]|null, events?: any[]|null }} [from]
*/
export function noteDeviceUnits(map, { units = null, events = null } = {}) {
for (const u of Array.isArray(units) ? units : []) if (isObj(u) && u.kind === 'device' && u.id != null) map.set(u.id, u);
for (const e of Array.isArray(events) ? events : []) if (Array.isArray(e) && e[0] === 'spawn' && isObj(e[1]) && e[1].kind === 'device' && e[1].id != null) map.set(e[1].id, e[1]);
return map;
}
/**
* The device-unit map after entering a field. A refresh of the same field whose new meta names no device
* (the start-of-battle list: crates and turrets arrive as `spawn` and are not repeated) keeps the units
* already noted. A meta that names devices replaces the map. A different field does not keep the previous one.
* @param {Map<number, any>|null|undefined} previous
* @param {Map<number, any>} noted
* @param {boolean} sameField
*/
export function deviceUnitsForEntry(previous, noted, sameField) {
if (!(noted instanceof Map) || noted.size > 0 || !sameField) return noted instanceof Map ? noted : new Map();
return previous instanceof Map && previous.size > 0 ? previous : noted;
}
+61 -13
View File
@@ -23,9 +23,9 @@
// 准备就绪 is refused while the temp overflow row (临时整备区) holds pieces: the reason shows under the button
// (user playtest #3 item 3; the row's own label is ui/underframe.js TempRowNotice).
import { useRef } from '../../vendor/hooks.module.js';
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { PHASE } from '../../../shared/constants.js';
import { html, Button, Icon, PingPill, Countdown, Tooltip, MicroLabel, DifficultyTag, useTicker } from './components.js';
import { html, Button, Icon, PingPill, Countdown, Tooltip, MicroLabel, DifficultyTag, Modal, useTicker } from './components.js';
import { Sprite, LpTower, GIcon, LocalSprite } from './gameComponents.js';
import { localAsset } from '../data.js';
import { serverNow } from '../store.js';
@@ -36,6 +36,45 @@ import { t, tParts, N_ } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/**
* 观战席 capsule of the in-match top bar (GitHub #120, PR #120 by @salt-fishes; the spectator seats of community report #26):
* the room's spectators, which the room screen lists in a strip but the game screen — the route after 开始 — used to hide
* completely, so a spectator could only be removed before the match or after it. A small eye + the count beside the latency;
* a tap opens the roster, with the host's ✕ on every row (room.removeSpectator: the server takes it at any time; the removed
* seat gets room.closed {kicked} and no more frames). Renders nothing while no spectator seat is taken.
* @param {{ spectators: any[]|null, myId: any, isHost: boolean, onRemove: ((playerId: any) => any)|null }} props
*/
export function SpectatorPill({ spectators, myId, isHost, onRemove }) {
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(null);
const list = Array.isArray(spectators) ? spectators.filter((s) => s && typeof s === 'object') : [];
// the roster closes with its last seat (the pill is gone: it must not pop up again for the next spectator)
useEffect(() => { if (!list.length) setOpen(false); }, [list.length]);
if (!list.length) return null;
const remove = async (playerId) => {
if (!onRemove) return;
setBusy(playerId);
try { await onRemove(playerId); } finally { setBusy(null); }
};
return html`<${Button} variant="ghost" size="sm" icon="eye" class=${cx('specpill', list.some((s) => s.playerId === myId) && 'is-me')}
onClick=${() => setOpen(true)} data-testid="spectators" title=${t('观战席')} aria-label=${t('观战席')}>
<span class="specpill__num num">${list.length}</span>
<//>
${open ? html`<${Modal} open=${true} title=${t('观战席')} micro="SPECTATORS" width="6.4rem" onClose=${() => setOpen(false)}
actions=${html`<${Button} variant="secondary" onClick=${() => setOpen(false)}>${t('关闭')}<//>`}>
<ul class="spec__roster" data-testid="spectator-roster">
${list.map((s) => html`<li key=${s.playerId} class=${cx('spec__row', s.playerId === myId && 'is-me', s.connected === false && 'is-offline')}>
<${Icon} name=${s.connected === false ? 'wifiOff' : 'eye'} class="spec__ico" />
<span class="spec__name">${s.name || t('博士')}</span>
${s.playerId === myId ? html`<span class="seat__you">${t('你')}</span>` : null}
${isHost && onRemove ? html`<${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === s.playerId} data-testid="spectator-remove"
onClick=${() => remove(s.playerId)} aria-label=${t('移出观战者 {name}', { name: s.name || '' })} title=${t('移出该观战者')} />` : null}
</li>`)}
</ul>
<p class="spec__hint t-lo">${t('观战者不占博士席位,只能观看;创建者可以把观战者移出,被移出的人不再收到战场画面。')}</p>
<//>` : null}`;
}
/**
* Phase capsule: prep label, kills n/m (combat/unite), kills + boss HP bar (boss rounds).
* @param {{ pub:any, hud:any }} props
@@ -49,7 +88,7 @@ export function PhaseCapsule({ pub, hud, miss = null }) {
const frac = bossFrac(boss);
return html`<div class="capsule capsule--boss" role="status">
<${Sprite} k="hudPanel/icon_boss" class="capsule__icon" fallback=${html`<${GIcon} name="skull" class="capsule__icon" />`} />
${hud?.total != null ? html`<span class="capsule__kills num"><b>${hud.killed ?? 0}</b>/${hud.total}</span>` : null}
${hud?.total != null ? html`<span class="capsule__kills num"><b>${hud.resolved ?? hud.killed ?? 0}</b>/${hud.total}</span>` : null}
<div class="bossbar" title=${boss ? `${fmtNum(boss.hp)} / ${fmtNum(boss.max)}` : t('敌方领袖')}>
<div class="bossbar__fill" style=${`width:${frac == null ? 100 : frac * 100}%`}></div>
<span class="bossbar__txt num">${frac == null ? t('敌方领袖') : bossPctText(frac)}</span>
@@ -60,7 +99,7 @@ export function PhaseCapsule({ pub, hud, miss = null }) {
return html`<div class=${cx('capsule', 'capsule--combat', phase === PHASE.UNITE && 'capsule--unite')} role="status">
<${Sprite} k=${phase === PHASE.UNITE ? 'hudPanel/icon_coop' : 'hudPanel/icon_battle'} class="capsule__icon"
fallback=${html`<${Icon} name="sword" class="capsule__icon" />`} />
<span class="capsule__kills num"><b>${hud?.killed ?? 0}</b>/${hud?.total ?? '--'}</span>
<span class="capsule__kills num"><b>${hud?.resolved ?? hud?.killed ?? 0}</b>/${hud?.total ?? '--'}</span>
${phase === PHASE.UNITE ? html`<span class="capsule__tag">${t('联防')}</span>` : null}
${phase === PHASE.UNITE && Number.isFinite(miss) ? html`<${MissTag} n=${miss} />` : null}
</div>`;
@@ -207,24 +246,27 @@ export function tempReadyReason(priv) {
}
/**
* Ready toggle (PREP only). Disabled while the temp row holds pieces — the reason shows under it (not only on hover):
* "临时整备区 N 个单位待处理" (user playtest #3 item 3).
* Ready toggle (PREP only). Disabled while the temp row holds pieces or a personal choice (教鞭) is open — the reason shows
* under it (not only on hover): "临时整备区 N 个单位待处理" (user playtest #3 item 3), "请先完成教鞭选择".
* @param {{ priv:any, onToggle:(ready:boolean)=>void, busy?:boolean, readyCount?:number, total?:number }} props
*/
export function ReadyToggle({ priv, onToggle, busy, readyCount, total }) {
const ready = !!priv?.ready;
const temp = tempInfo(priv);
const reason = !ready ? tempReadyReason(priv) || shopBlockReason('ready', { priv, editable: true }) : null;
const choice = !!priv?.personalChoice;
const reason = !ready ? (choice ? shopBlockReason('ready', { priv, editable: true }) : tempReadyReason(priv) || shopBlockReason('ready', { priv, editable: true })) : null;
const why = !ready && (choice || temp.count > 0);
const btn = html`<button type="button" class=${cx('readybtn', 'tapx', ready && 'is-on', busy && 'is-busy')} disabled=${!!reason || busy}
aria-pressed=${ready ? 'true' : 'false'} aria-describedby=${!ready && temp.count ? 'readywrap-why' : undefined} onClick=${() => onToggle(!ready)}>
aria-pressed=${ready ? 'true' : 'false'} aria-describedby=${why ? 'readywrap-why' : undefined} onClick=${() => onToggle(!ready)}>
<span class="readybtn__box">${ready ? html`<${Icon} name="check" />` : null}</span>
<span class="readybtn__label">${ready ? t('取消准备') : t('准备就绪')}</span>
<kbd class="readybtn__key">${hotkeyLabelOf('ready')}</kbd>
</button>`;
return html`<div class="readywrap">
${reason ? html`<${Tooltip} text=${reason} placement="bottom">${btn}<//>` : btn}
${!ready && temp.count ? html`<span class="readywrap__why" id="readywrap-why" role="status" data-testid="ready-why">
<${Icon} name="warn" /><span>${tParts('临时整备区 {n} 个单位待处理', { n: html`<b class="num">${temp.count}</b>`, count: temp.count })}</span></span>` : null}
${why ? html`<span class="readywrap__why" id="readywrap-why" role="status" data-testid="ready-why">
<${Icon} name="warn" />${choice ? html`<span>${reason}</span>`
: html`<span>${tParts('临时整备区 {n} 个单位待处理', { n: html`<b class="num">${temp.count}</b>`, count: temp.count })}</span>`}</span>` : null}
${Number.isFinite(total) && total > 1 ? html`<span class="readywrap__count">${t('已就绪')} <b class="num">${readyCount}</b>/<span class="num">${total}</span></span>` : null}
</div>`;
}
@@ -306,13 +348,16 @@ export function PauseButton({ paused, busy = false, onToggle }) {
* onReady:(r:boolean)=>void, readyBusy?:boolean, readyCount?:number, playerCount?:number,
* pen?:boolean, penAvail?:boolean, onPen?:(on:boolean)=>void, config?: any, frozenAt?: number|null,
* pause?: { show: boolean, paused: boolean, busy?: boolean, onToggle: () => void } | null,
* live?: { pending: number, unite: boolean, left?: number|null } | null }} props
* live?: { pending: number, unite: boolean, left?: number|null } | null,
* spectators?: any[]|null, myId?: any, isHost?: boolean, onRemoveSpectator?: ((playerId: any) => any)|null }} props
* spectators: the room's spectator seats (room.state) — the 观战席 capsule beside the latency (SpectatorPill; the host removes)
* frozenAt: the server time every clock shows while the solo match is paused (null = live)
* live: the own battle's pending LP loss (liveLp): the tower shows lp − pending in red with a −N tick, 联防中 during 联防;
* `left` (a leaker in 联防): its enemies still standing — the capsule's ×N tag
*/
export function TopBar({ pub, priv, conn, hud, total, drawer, onExit, onDrawer, onReady, readyBusy, readyCount, playerCount, pen = false, penAvail = false, onPen = () => {},
config = null, frozenAt = null, pause = null, live = null, spectator = false }) {
config = null, frozenAt = null, pause = null, live = null, spectator = false,
spectators = null, myId = null, isHost = false, onRemoveSpectator = null }) {
const phase = pub?.phase;
const boss = isBossPhase(phase);
const lp = boss && Number.isFinite(pub?.teamLp) ? pub.teamLp : Number.isFinite(priv?.lp) ? priv.lp : null;
@@ -338,7 +383,10 @@ export function TopBar({ pub, priv, conn, hud, total, drawer, onExit, onDrawer,
<div class="gtop__left">
<${Button} variant="danger" size="lg" square=${true} icon="exit" onClick=${onExit} aria-label=${t('离开')} title=${t('离开 / 暂离')} class="gtop__exit tapx" />
<div class="gtop__meta">
<${PingPill} ms=${conn?.ping} online=${conn?.status === 'online'} />
<div class="gtop__net">
<${PingPill} ms=${conn?.ping} online=${conn?.status === 'online'} />
<${SpectatorPill} spectators=${spectators} myId=${myId} isHost=${isHost} onRemove=${onRemoveSpectator} />
</div>
${pub?.difficulty ? html`<${DifficultyTag} difficulty=${pub.difficulty} size="sm" />` : null}
</div>
</div>
+21 -5
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). */
@@ -39,6 +40,8 @@ const uiLoads = new Map();
let indexLoad = null;
/** Whether the pack index arrived (without it the chain of a pack is taken from its own `_meta` as it loads). */
let indexOk = false;
/** Only the latest boot or explicit selection may apply its loaded language. */
let langRequest = 0;
const INDEX_URL = `${PACKS_URL}${PACK_INDEX_FILE}`;
const defaultFetch = (...a) => globalThis.fetch(...a);
@@ -158,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)
@@ -194,20 +198,23 @@ function applyLang(lang) {
* @returns {Promise<string>} the language in effect
*/
export async function initLang() {
const request = ++langRequest;
wire();
applyDocument(getLang());
const TIMEOUT = Symbol('timeout');
const boot = (async () => {
const listed = await loadLangIndex();
const { lang, fromUrl } = initialLang(undefined, undefined, { tentative: !listed });
if (fromUrl) { savePref(PREF_KEY, lang); stripLangParam(); }
if (fromUrl) stripLangParam();
if (request !== langRequest) return null;
if (fromUrl) savePref(PREF_KEY, lang);
if (lang === DEFAULT_LANG) return null;
return (await loadLangChain(lang)) ? lang : null;
})();
const got = await Promise.race([boot, new Promise((r) => setTimeout(() => r(TIMEOUT), BOOT_WAIT_MS))]);
if (got === TIMEOUT) {
boot.then((lang) => { if (lang && normalizeLang(loadPref(PREF_KEY, null)) === lang) applyLang(lang); });
} else if (got) applyLang(got);
boot.then((lang) => { if (request === langRequest && lang && normalizeLang(loadPref(PREF_KEY, null)) === lang) applyLang(lang); });
} else if (request === langRequest && got) applyLang(got);
return getLang();
}
@@ -218,10 +225,12 @@ export async function initLang() {
* @returns {Promise<string>} the language in effect
*/
export async function switchLang(lang) {
const request = ++langRequest;
wire();
const want = normalizeLang(lang) || DEFAULT_LANG;
savePref(PREF_KEY, want);
if (want !== DEFAULT_LANG && !(await loadLangChain(want))) return getLang();
if (request !== langRequest) return getLang();
applyLang(want);
return getLang();
}
@@ -290,10 +299,17 @@ export function machineTranslationNote() {
export function LangToggle({ class: cls }) {
const lang = useLang();
const menu = langMenuModel(useLangs(), lang);
const pick = (code) => { if (code && code !== getLang()) switchLang(code); };
const pick = async (code, select = null) => {
if (!code) return;
const switched = switchLang(code);
const request = langRequest;
await switched;
// A failed current request keeps the displayed language; stale requests must not reset a newer pending pick.
if (select && request === langRequest) select.value = getLang();
};
if (menu.kind === 'select') {
return html`<label class=${`set-seg lang-toggle lang-select${cls ? ` ${cls}` : ''}`} data-testid="lang-toggle">
<select aria-label=${SWITCH_LABEL} value=${lang} onChange=${(e) => pick(e.currentTarget.value)}>
<select aria-label=${SWITCH_LABEL} value=${lang} onChange=${(e) => pick(e.currentTarget.value, e.currentTarget)}>
${menu.items.map((it) => html`<option key=${it.code} value=${it.code} lang=${it.htmlLang} title=${it.title || undefined} selected=${it.on}>${it.label}</option>`)}
</select>
</label>`;
+131 -15
View File
@@ -8,8 +8,13 @@
// (ModuleRecord: uniEquipId, name, typeName, attr, traitOverride, talentChanges, isDefault) — while the data lacks
// them only the default skill / module is offered. The option rules are shared with the server
// (shared/protocol.js loadoutOptions / checkLoadout), so a sanitised loadout is always accepted.
// 潜能 / 练度 (0.2.2, the owner's decision of 2026-10-08): `ops` = `{ [charId]: { potential?: 1–6, cultivate?: 0–3 } }` —
// per operator, so the normal, elite and 自选 forms share them; only values that differ from the defaults (潜能 6,
// 精英2 Lv.60) are kept; stored beside the entries (`sp.pref.loadout` = { v: 1, entries, ops }; an older stored loadout
// has none = the defaults) and sent with `room.loadout { entries, ops }` (shared/protocol.js checkLoadoutOps).
import { loadoutOptions, checkLoadout, resolveLoadout, MODULE_NONE, LOADOUT_LIMITS } from '../../../shared/protocol.js';
import { loadoutOptions, checkLoadout, checkLoadoutOps, resolveLoadout, MODULE_NONE, LOADOUT_LIMITS } from '../../../shared/protocol.js';
import { isPotential, isCultivate, POTENTIAL_DEFAULT, CULTIVATE_DEFAULT } from '../../../shared/potential.js';
import { t, N_ } from '../../../shared/i18n.js';
export { MODULE_NONE };
@@ -56,8 +61,95 @@ export function parseStored(raw) {
return out;
}
/** Serialised form for localStorage. */
export const toStored = (entries) => ({ v: LOADOUT_VERSION, entries: entries || {} });
/**
* Parse the stored per-operator 潜能 / 练度 (0.2.2; any junk → {}): structurally valid entries only, defaults dropped.
* An older stored loadout (no `ops`) gives {} — every operator at the defaults.
* @param {any} raw `{ v, entries, ops }`
* @returns {Record<string, { potential?: number, cultivate?: number }>}
*/
export function parseStoredOps(raw) {
const src = isObj(raw) && isObj(raw.ops) ? raw.ops : null;
const out = {};
if (!src) return out;
for (const [id, e] of Object.entries(src)) {
if (Object.keys(out).length >= LOADOUT_LIMITS.ops) break;
if (UNSAFE_IDS.has(id) || !/^[A-Za-z0-9_\-.:]{1,64}$/.test(id) || !isObj(e)) continue;
const x = {};
if (isPotential(e.potential) && e.potential !== POTENTIAL_DEFAULT) x.potential = e.potential;
if (isCultivate(e.cultivate) && e.cultivate !== CULTIVATE_DEFAULT) x.cultivate = e.cultivate;
if (Object.keys(x).length) out[id] = x;
}
return out;
}
/** Serialised form for localStorage (`ops`: the per-operator 潜能 / 练度, 0.2.2). */
export const toStored = (entries, ops = {}) => ({ v: LOADOUT_VERSION, entries: entries || {}, ops: ops || {} });
// ---- 潜能 / 练度 (0.2.2) ------------------------------------------------------------------------------------------------
/**
* The effective 潜能 / 练度 of operator `charId` under the stored settings (the defaults for a missing entry or field).
* @param {Record<string, any>|null|undefined} ops @param {string|null|undefined} charId
* @returns {{ potential: number, cultivate: number, changed: boolean }}
*/
export function opsOf(ops, charId) {
const e = charId && isObj(ops) && Object.hasOwn(ops, charId) && isObj(ops[charId]) ? ops[charId] : null;
const potential = e && isPotential(e.potential) ? e.potential : POTENTIAL_DEFAULT;
const cultivate = e && isCultivate(e.cultivate) ? e.cultivate : CULTIVATE_DEFAULT;
return { potential, cultivate, changed: potential !== POTENTIAL_DEFAULT || cultivate !== CULTIVATE_DEFAULT };
}
/**
* Set (part of) one operator's 潜能 / 练度; an entry equal to the defaults is removed. Returns a new map.
* @param {Record<string, any>} ops @param {string} charId @param {{ potential?: number, cultivate?: number }} patch
*/
export function setOps(ops, charId, patch) {
if (typeof charId !== 'string' || !charId) return ops;
const cur = opsOf(ops, charId);
const potential = patch && isPotential(patch.potential) ? patch.potential : cur.potential;
const cultivate = patch && isCultivate(patch.cultivate) ? patch.cultivate : cur.cultivate;
const out = { ...(ops || {}) };
delete out[charId];
const e = {};
if (potential !== POTENTIAL_DEFAULT) e.potential = potential;
if (cultivate !== CULTIVATE_DEFAULT) e.cultivate = cultivate;
if (Object.keys(e).length) out[charId] = e;
return out;
}
/** Remove one operator's 潜能 / 练度 (恢复默认). */
export function resetOps(ops, charId) {
if (!ops || !Object.hasOwn(ops, charId)) return ops;
const out = { ...ops };
delete out[charId];
return out;
}
/**
* The settings to send (`room.loadout.ops`): every stored entry the server would keep for the loaded data (an operator
* of the 干员调配 roster or the 自选 owned pool, `isOperator` — shared/protocol.js cultivationCharIds), the rest dropped one
* by one; defaults dropped.
* @param {Record<string, any>} ops @param {(charId: string) => boolean} isOperator
* @returns {Record<string, { potential?: number, cultivate?: number }>}
*/
export function sanitizeOps(ops, isOperator) {
const out = {};
for (const [id, e] of Object.entries(ops || {})) {
if (Object.keys(out).length >= LOADOUT_LIMITS.ops) break;
const one = {};
if (isPotential(e?.potential)) one.potential = e.potential;
if (isCultivate(e?.cultivate)) one.cultivate = e.cultivate;
if (!Object.keys(one).length) continue;
const res = checkLoadoutOps({ [id]: one }, isOperator);
if (res.ok && res.ops[id]) {
const x = {};
if (res.ops[id].potential !== POTENTIAL_DEFAULT) x.potential = res.ops[id].potential;
if (res.ops[id].cultivate !== CULTIVATE_DEFAULT) x.cultivate = res.ops[id].cultivate;
out[id] = x;
}
}
return out;
}
// ---- export / import ----------------------------------------------------------------------------------------------
@@ -71,19 +163,23 @@ export const LOADOUT_EXPORT_KIND = 'stronghold.loadout';
export const LOADOUT_IMPORT_MAX_BYTES = 256 * 1024;
/**
* Portable payload of a loadout, as downloaded / copied by 导出.
* Portable payload of a loadout, as downloaded / copied by 导出 — with the per-operator 潜能 / 练度 (`ops`, 0.2.2; an
* older build reading it ignores the field).
* @param {Record<string, any>} entries `room.loadout.entries`
* @param {{ now?: number }} [o]
* @param {{ now?: number, ops?: Record<string, any> }} [o]
*/
export function exportPayload(entries, { now = Date.now() } = {}) {
export function exportPayload(entries, { now = Date.now(), ops = {} } = {}) {
const clean = {};
for (const [id, e] of Object.entries(entries || {})) if (isObj(e)) clean[id] = { ...e };
const cleanOps = {};
for (const [id, e] of Object.entries(ops || {})) if (isObj(e)) cleanOps[id] = { ...e };
return {
kind: LOADOUT_EXPORT_KIND,
v: LOADOUT_VERSION,
exportedAt: new Date(Number.isFinite(now) ? now : Date.now()).toISOString(),
count: Object.keys(clean).length,
entries: clean,
ops: cleanOps,
};
}
@@ -97,8 +193,10 @@ export function serializeExport(entries, opts) {
* `{ [chessId]: { skill, module } }` map all work, as does the serialised text of any of them. Parsing is STRUCTURAL
* only — the caller still runs `sanitizeEntries` against the loaded data, because a preset from another season may name
* chess / skills / modules this build does not have. `__proto__` / `constructor` keys are skipped (see parseStored).
* A payload with `ops` (0.2.2) brings the per-operator 潜能 / 练度 too (`ops` in the result — absent when the payload has
* none: an older export leaves the current settings alone); a payload of settings only imports.
* @param {any} input payload object or serialised text
* @returns {{ ok: true, entries: Record<string, any> } | { ok: false, error: string }}
* @returns {{ ok: true, entries: Record<string, any>, ops?: Record<string, any> } | { ok: false, error: string }}
*/
export function parseImport(input) {
let raw = input;
@@ -115,8 +213,9 @@ export function parseImport(input) {
const kind = typeof raw.kind === 'string' ? raw.kind : null;
if (kind && kind !== LOADOUT_EXPORT_KIND) return { ok: false, error: t('这不是干员调配的数据') };
const entries = parseStored(raw);
if (!Object.keys(entries).length) return { ok: false, error: t('里面没有有效的调配条目') };
return { ok: true, entries };
const ops = isObj(raw.ops) ? parseStoredOps(raw) : null;
if (!Object.keys(entries).length && !(ops && Object.keys(ops).length)) return { ok: false, error: t('里面没有有效的调配条目') };
return ops ? { ok: true, entries, ops } : { ok: true, entries };
}
// ---- options & choices ---------------------------------------------------------------------------------------------
@@ -281,8 +380,9 @@ export function rosterOf(list) {
* @param {Record<string, any>} entries stored loadout (for changedOnly)
* @param {(id: string) => any} getChess
* @param {(id: string) => any} [getBond] bond lookup (the search also matches bond names)
* @param {Record<string, any>|null} [ops] the per-operator 潜能 / 练度 (changedOnly counts them too)
*/
export function filterRoster(roster, f = {}, entries = {}, getChess = () => null, getBond = () => null) {
export function filterRoster(roster, f = {}, entries = {}, getChess = () => null, getBond = () => null, ops = null) {
const q = String(f.query || '').trim().toLowerCase();
return roster.filter((c) => {
if (f.tier && c.tier !== f.tier) return false;
@@ -290,7 +390,7 @@ export function filterRoster(roster, f = {}, entries = {}, getChess = () => null
if (f.bond && !(Array.isArray(c.bonds) && c.bonds.includes(f.bond))) return false;
if (f.changedOnly) {
const golden = c.goldenId ? getChess(c.goldenId) : null;
if (!effectiveChoice(entries, c, golden).changed) return false;
if (!effectiveChoice(entries, c, golden).changed && !opsOf(ops, c.charId).changed) return false;
}
if (q) {
const hay = [c.name, c.appellation, c.subProfessionName, t(PROF_NAME[c.profession]), ...(c.bonds || []).map((b) => getBond(b)?.name)]
@@ -302,12 +402,14 @@ export function filterRoster(roster, f = {}, entries = {}, getChess = () => null
}
/** Number of chess whose choice differs from the defaults (only loadout slots of the loaded data count: an entry of a
* retired / hidden chess is never sent nor applied). */
export function changedCount(entries, getChess) {
* retired / hidden chess is never sent nor applied) — with `ops` (0.2.2), also those whose operator's 潜能 / 练度 differ. */
export function changedCount(entries, getChess, ops = null, roster = null) {
const ids = new Set(Object.keys(entries || {}));
if (ops && Object.keys(ops).length && Array.isArray(roster)) for (const c of roster) if (opsOf(ops, c.charId).changed) ids.add(c.chessId);
let n = 0;
for (const id of Object.keys(entries || {})) {
for (const id of ids) {
const { base, golden } = recordsOf(id, getChess);
if (isLoadoutSlot(base) && base.chessId === id && effectiveChoice(entries, base, golden).changed) n++;
if (isLoadoutSlot(base) && base.chessId === id && (effectiveChoice(entries, base, golden).changed || opsOf(ops, base.charId).changed)) n++;
}
return n;
}
@@ -386,3 +488,17 @@ export function skillTags(rec) {
passive,
};
}
/** Compact skill preview; SP and duration come from the selected form's generated record. */
export function quickSkillTags(rec) {
const tags = skillTags(rec);
if (!rec) return { ...tags, recovery: '—', duration: '—' };
// ON_DEPLOY covers constant passives too. A finite duration distinguishes the deploy-and-expire skills;
// some older records carry it only in the blackboard. Never infer it from a localized description.
const seconds = Number(rec.duration) > 0 ? Number(rec.duration) : tags.passive ? Number(rec.bb?.duration) : 0;
const deployment = tags.passive && seconds > 0;
const recovery = deployment ? '—' : tags.passive ? t('被动') : tags.spKind === 'atk' ? t('攻回') : tags.spKind === 'def' ? t('受回') : t('自回');
const duration = rec.durationType === 'AMMO' ? t('弹药') : seconds > 0 ? `${seconds}s`
: tags.passive ? t('常驻') : Number(rec.duration) < 0 ? '∞' : t('瞬发');
return { ...tags, sp: deployment ? t('部署触发') : tags.sp, recovery, duration };
}
+61
View File
@@ -0,0 +1,61 @@
// Shared operator stat preview used by roster loadout and DIY selection.
import { html, MicroLabel } from './components.js';
import { RichText } from './gameComponents.js';
import { chessStatsBlock, traitText, chessTalents } from './detailPanel.js';
import { chessLoadout } from './gameLogic.js';
import { data } from '../data.js';
import { t } from '../../../shared/i18n.js';
const cx = (...parts) => parts.filter(Boolean).join(' ');
const getChessRec = (id) => data.lookup('chess', id);
/**
* What 局内数值 shows (GitHub issue #64): the chess variant — the 精锐 record when asked for and the chess has one, else
* the normal one — as the stored loadout makes it. chessLoadout (ui/gameLogic.js) resolves the skill and module the way
* the in-match detail card and the sim do (shared/loadoutRecord.js): the elite's chosen module's stats / 特性 / talents
* (不装备: the base ones), the chosen skill's passive range; a normal chess has no module, the skill does not change its
* stats. Nothing is recomputed here.
* @param {any} base normal chess record @param {any} golden its elite record or null
* @param {Record<string, any>} entries the stored loadout @param {'normal'|'elite'} level
* @param {(id: string) => any} getChess
* @param {Record<string, any>|null} [ops] the stored 潜能 / 练度 (0.2.2: the numbers at the operator's settings — the
* 练度 multiplier needs effects.json; none set = 潜能 6, 精英2 Lv.60)
* @returns {{ elite: boolean, chess: any, lo: any, record: any, trait: string, talents: any[] } | null} null without a record
*/
export function statsPreview(base, golden, entries, level, getChess, ops = null) {
const elite = level === 'elite' && !!golden;
const chess = elite ? golden : base;
if (!chess) return null;
const lo = chessLoadout(chess, entries, getChess, { ops, effects: data.get('effects') });
const record = lo?.record || chess;
// (the card's own rule: the 特性 line exists when the chess has one; its text follows the chosen module)
return { elite, chess, lo, record, trait: chess.trait?.desc ? traitText(chess, !!chess.isGolden, lo) || '' : '', talents: chessTalents(record) };
}
/**
* 局内数值: the stats, 攻击范围, 特性 and 天赋 of the selected chess under its chosen skill and module — the detail
* card's stats block (ui/detailPanel.js chessStatsBlock) without live numbers, so what a player reads here is what the
* shop / board card shows before a battle (not the equipment, bond or skill-cast changes of a running match). The
* toggle picks the 普通 or the 精锐 record; 精锐 is the default because the module only exists there.
* @param {{ base: any, golden: any, entries: Record<string, any>, level: 'normal'|'elite', onLevel: (l: 'normal'|'elite') => void, getChess?: (id: string) => any }} props
*/
export function LoadoutStats({ base, golden, entries, level, onLevel, getChess = getChessRec, ops = null }) {
const pv = statsPreview(base, golden, entries, level, getChess, ops);
if (!pv) return null;
// (the caption names 潜能 / 练度 when the numbers carry both — the 练度 multiplier needs effects.json)
return html`<section class="lo-sec lo-sec--stats" aria-label=${t('局内数值')} data-variant=${pv.elite ? 'elite' : 'normal'}>
<header class="lo-sec__head">
<h3>${t('局内数值')}<${MicroLabel}>STATS<//></h3>
<div class="lo-seg" role="tablist" aria-label=${t('数值版本')}>
<button type="button" role="tab" aria-selected=${pv.elite ? 'false' : 'true'} class=${cx(!pv.elite && 'is-on')} data-variant="normal" onClick=${() => onLevel('normal')}>${t('普通')}</button>
<button type="button" role="tab" aria-selected=${pv.elite ? 'true' : 'false'} class=${cx(pv.elite && 'is-on')} data-variant="elite" disabled=${!golden} onClick=${() => onLevel('elite')}>${t('精锐')}</button>
</div>
</header>
${chessStatsBlock({ rec: pv.record, chess: pv.chess })}
${pv.trait || pv.talents.length ? html`<div class="lo-minfo lo-minfo--kit">
${pv.trait ? html`<div class="lo-minfo__row"><span class="lo-minfo__k">${t('特性')}</span><${RichText} class="lo-minfo__v" text=${pv.trait} /></div>` : null}
${pv.talents.map((tal, i) => html`<div key=${i} class="lo-minfo__row"><span class="lo-minfo__k">${t('天赋')}</span>
<span class="lo-minfo__v"><b class="lo-minfo__tname">${tal.name}</b><${RichText} text=${tal.descRaw || tal.desc || ''} /></span></div>`)}
</div>` : null}
<p class="lo-stats__cap">${pv.elite ? t('数值含所选模组;') : golden ? t('普通干员没有模组,所选模组在「精锐」中生效;') : ''}${pv.lo?.cultivation && data.get('effects') ? t('含潜能与练度;') : ''}${t('不含技能发动、装备、盟约等局内加成')}</p>
</section>`;
}
+54 -18
View File
@@ -7,9 +7,9 @@
// (called once by main.js) keep the server's copies current — one sync engine (installPrefSync), three settings: after
// every `welcome` (new or resumed
// session — the server keeps them on the session and on the seat, so joining a room needs no resend) and after every
// edit (debounced; a pending edit goes out at once when the overlay closes), they send `room.loadout { entries }`
// edit (debounced; a pending edit goes out at once when the overlay closes), they send `room.loadout { entries, ops }`
// (sanitised against the loaded data/chess.json: ui/loadoutModel.js sanitizeEntries — a stale entry is dropped, never
// the whole loadout), `room.ownership { notOwned }` (sent as stored: the server keeps the droppable chess and drops
// the whole loadout; `ops` = the per-operator 潜能 / 练度 of 0.2.2, sanitizeOps against chess.json + backups.json), `room.ownership { notOwned }` (sent as stored: the server keeps the droppable chess and drops
// the rest, so no data is needed) and `room.diy { picks }` (sent as stored, structurally clean: the server keeps the
// legal picks). Replies: RATE → retried later; WRONG_PHASE / ROOM_STARTED → the running match keeps what it took (the
// loadout locks when INFO_CHECK ends; the ownership and the 自选 picks never change during a match) — stored for the
@@ -18,7 +18,8 @@
import { createStore, loadPref, savePref } from '../store.js';
import { data } from '../data.js';
import { LOADOUT_PREF, parseStored, toStored, sanitizeEntries } from './loadoutModel.js';
import { LOADOUT_PREF, parseStored, parseStoredOps, toStored, sanitizeEntries, sanitizeOps } from './loadoutModel.js';
import { cultivationCharIds } from '../../../shared/protocol.js';
import { OWNERSHIP_PREF, parseStoredOwnership, toStoredOwnership, cleanIds, sanitizeNotOwned } from './ownershipModel.js';
import { DIY_PREF, parseStoredDiy, toStoredDiy, cleanPicks, sanitizeDiyPicks } from './diyModel.js';
import { toast } from './toasts.js';
@@ -30,6 +31,9 @@ export const RETRY_MS = 1500;
function readStored() {
try { return parseStored(loadPref(LOADOUT_PREF, null)); } catch { return {}; }
}
function readStoredOps() {
try { return parseStoredOps(loadPref(LOADOUT_PREF, null)); } catch { return {}; }
}
function readStoredOwnership() {
try { return parseStoredOwnership(loadPref(OWNERSHIP_PREF, null)); } catch { return []; }
}
@@ -40,6 +44,7 @@ function readStoredDiy() {
/** Loadout + ownership + screen state (separate from the app store: it must survive room / match resets). */
export const loadoutStore = createStore({
entries: readStored(),
ops: readStoredOps(), // 0.2.2 潜能 / 练度: { [charId]: { potential?, cultivate? } } ({} = every operator 潜能 6, 精英2 Lv.60)
notOwned: readStoredOwnership(), // 干员持有: base chess ids marked 未持有 (sorted; [] = every operator owned)
diy: readStoredDiy(), // 自选编队: { [slotBaseId]: { charId, skillIndex?, uniEquipId? } } ({} = every slot empty)
diyKitted: null, // the operators a DIY slot may field (welcome.diyKitted; null before the first welcome)
@@ -53,28 +58,46 @@ export const loadoutStore = createStore({
diySync: 'idle',
});
/** Replace the stored entries (persisted at once; the sync picks the change up). */
/** Replace the stored entries (persisted at once with the settings; the sync picks the change up). */
export function setEntries(entries) {
const next = entries && typeof entries === 'object' ? entries : {};
savePref(LOADOUT_PREF, toStored(next));
savePref(LOADOUT_PREF, toStored(next, loadoutStore.get().ops));
loadoutStore.set({ entries: next });
}
/** Replace the stored per-operator 潜能 / 练度 (0.2.2; persisted at once with the entries; the sync picks it up). */
export function setOpsMap(ops) {
const next = ops && typeof ops === 'object' ? ops : {};
savePref(LOADOUT_PREF, toStored(loadoutStore.get().entries, next));
loadoutStore.set({ ops: next });
}
/**
* Apply a parsed entry map (an imported preset). Sanitised against the loaded data first, then persisted and synced
* like any ordinary edit — so a preset from another build never sends the server an entry it would refuse. An import
* that keeps nothing (every chess unknown, or every choice already the default) changes NOTHING: wiping the current
* loadout over it would be a loss the player never asked for.
* With `ops` (an import that carries the per-operator 潜能 / 练度, 0.2.2: `isOperator` from the loaded data) those are
* sanitised and replace the current ones as well; without, the current ones stay.
* @param {Record<string, any>} entries `parseImport(...).entries`
* @param {(id: string) => any} lookup chess lookup
* @returns {{ applied: number, dropped: number }} entries kept / entries that were not imported
* @param {{ ops?: Record<string, any>|null, isOperator?: (charId: string) => boolean }} [o]
* @returns {{ applied: number, dropped: number, ops?: number }} entries kept / entries (and settings) not imported / settings
* kept (only with `ops`)
*/
export function applyLoadoutEntries(entries, lookup) {
export function applyLoadoutEntries(entries, lookup, { ops = null, isOperator = null } = {}) {
const asked = Object.keys(entries || {}).length;
const clean = sanitizeEntries(entries, lookup);
const applied = Object.keys(clean).length;
if (applied) setEntries(clean);
return { applied, dropped: Math.max(0, asked - applied) };
const cleanOps = ops && isOperator ? sanitizeOps(ops, isOperator) : null;
const nOps = cleanOps ? Object.keys(cleanOps).length : 0;
const askedOps = ops ? Object.keys(ops).length : 0;
if (applied || nOps) {
if (cleanOps) loadoutStore.set({ ops: cleanOps });
setEntries(applied ? clean : loadoutStore.get().entries);
}
const dropped = Math.max(0, asked - applied) + Math.max(0, askedOps - nOps);
return cleanOps ? { applied, dropped, ops: nOps } : { applied, dropped };
}
/** Replace the stored not-owned list (干员持有; persisted at once, the sync picks the change up). */
@@ -128,14 +151,17 @@ export function openLoadout(from = 'lobby', sel = null, tab = null) {
data.load('assets');
data.load('local');
data.load('backups');
data.load('effects'); // 0.2.2: the 练度 multipliers of the 局内数值 (effects.json CHAR_MAP)
data.load('garrisons'); // the 特质 at the top of the detail (PR #301)
loadoutStore.set({ open: true, from, ...(sel ? { sel } : {}), ...(tab === 'loadout' || tab === 'ownership' || tab === 'diy' ? { tab } : {}) });
}
export const closeLoadout = () => loadoutStore.set({ open: false });
/**
* One setting kept in sync with the server (see the header): `key` = the store field, `stateKey` = its sync state field,
* `msgType` / `field` = the C2S message, `prepare()` → the payload to send (may await data; null = the data is missing:
* nothing is sent, state 'error'), `lockedText` = what a refused edit tells the player.
* One setting kept in sync with the server (see the header): `key` = the store field (or a list of them: the loadout's
* `entries` and `ops`), `stateKey` = its sync state field, `msgType` / `field` = the C2S message (`field` null: the payload
* is the message body), `prepare()` → the payload to send (may await data; null = the data is missing: nothing is sent,
* state 'error'), `lockedText` = what a refused edit tells the player.
*/
function installPrefSync({ net, timers, target, notify, key, stateKey, msgType, field, prepare, lockedText, tag }) {
const T = timers || { setTimeout: (fn, ms) => globalThis.setTimeout(fn, ms), clearTimeout: (id) => globalThis.clearTimeout(id) };
@@ -177,7 +203,7 @@ function installPrefSync({ net, timers, target, notify, key, stateKey, msgType,
pendingJson = json;
setState('sending');
try {
await net.request(msgType, { [field]: payload });
await net.request(msgType, field ? { [field]: payload } : payload);
if (my !== seq) return;
pendingJson = null;
lastSent = json;
@@ -201,8 +227,9 @@ function installPrefSync({ net, timers, target, notify, key, stateKey, msgType,
}
const offWelcome = net.on('welcome', () => { lastSent = null; pendingJson = null; seq++; schedule(50); });
const keys = Array.isArray(key) ? key : [key];
const offStore = target.subscribe((s, prev) => {
if (s[key] !== prev[key]) { edited = true; schedule(); }
if (keys.some((k) => s[k] !== prev[k])) { edited = true; schedule(); }
// closing the overlay sends a pending edit at once (review fix): the player's next click — 准备就绪 in the solo
// briefing, 开始模拟 in the room — must not overtake the debounced message (the match locks its loadout when
// INFO_CHECK ends, so a late edit would silently only apply to the next match). Same socket ⇒ ordered.
@@ -229,20 +256,29 @@ function installPrefSync({ net, timers, target, notify, key, stateKey, msgType,
* timers?: { setTimeout: Function, clearTimeout: Function }, target?: ReturnType<typeof createStore> }} deps
* @returns {{ flush: () => Promise<void>, dispose: () => void }}
*/
export function installLoadoutSync({ net, getChessReady, lookupChess, timers, target = loadoutStore, notify } = {}) {
export function installLoadoutSync({ net, getChessReady, getBackupsReady, lookupChess, operatorIds, timers, target = loadoutStore, notify } = {}) {
const ready = getChessReady || (() => data.load('chess'));
const readyBackups = getBackupsReady || (() => data.load('backups'));
const lookup = lookupChess || ((id) => data.lookup('chess', id));
// the operators a 潜能 / 练度 may name (shared/protocol.js cultivationCharIds: the roster + the 自选 owned pool)
const opIds = operatorIds || (() => cultivationCharIds(data.get('chess'), data.get('backups')));
return installPrefSync({
net, timers, target, notify, key: 'entries', stateKey: 'sync', msgType: 'room.loadout', field: 'entries', tag: 'loadout',
net, timers, target, notify, key: ['entries', 'ops'], stateKey: 'sync', msgType: 'room.loadout', field: null, tag: 'loadout',
lockedText: N_('本局的干员调配已锁定,修改将在下一局生效'),
async prepare() {
const current = target.get().entries;
const ops = target.get().ops;
const hasOps = !!ops && Object.keys(ops).length > 0;
// an empty loadout needs no data (nothing to sanitise): a player who never opened 干员调配 does not download
// chess.json in the lobby just for this
if (!current || Object.keys(current).length === 0) return {};
if ((!current || Object.keys(current).length === 0) && !hasOps) return { entries: {}, ops: {} };
const loaded = await ready();
if (loaded == null) return null;
return sanitizeEntries(target.get().entries, lookup);
const entries = sanitizeEntries(target.get().entries, lookup);
if (!hasOps) return { entries, ops: {} };
if ((await readyBackups()) == null) return null;
const ids = opIds();
return { entries, ops: sanitizeOps(target.get().ops, (id) => ids.has(id)) };
},
});
}
+11 -1
View File
@@ -12,6 +12,7 @@ import { net } from '../net.js';
import { store, useStore, shallowEqual, emptyMatch, createStore, isSpectating } from '../store.js';
import { GIcon } from './gameComponents.js';
import { GuideButton } from './guide.js';
import { recordQuit } from './stats.js';
import { t, tParts, N_ } from '../../../shared/i18n.js';
/**
@@ -37,18 +38,27 @@ export const awayEnds = (s, prev) => (!!prev?.match?.public && !s?.match?.public
store.subscribe((s, prev) => { if (awayStore.get().away && awayEnds(s, prev)) awayStore.set({ away: false }); });
/**
* Leave the match for good (g.leave → room.leave), then drop local room/match state.
* Leave the match for good (g.leave → room.leave), then drop local room/match state. The server sends a leaver no
* settlement, so the page records the match itself before it is cleared (本机统计, ui/stats.js recordQuit: a 中途退出
* entry of the history; it counts only when a round had been cleared) — once the leave went through.
* @returns {Promise<void>}
*/
export async function quitMatch() {
const before = store.get();
let left = false;
try {
try { await net.request('g.leave', {}); } catch (err) {
if (err?.code !== 'NOT_IN_ROOM' && err?.code !== 'WRONG_PHASE' && err?.code !== 'OFFLINE') throw err;
}
try { await net.request('room.leave', {}); } catch (err) { if (err?.code !== 'NOT_IN_ROOM' && err?.code !== 'OFFLINE') throw err; }
left = true;
} catch (err) {
toastError(err);
} finally {
// (a settlement that arrived while the leave was in flight is the match's record already)
if (left && !store.get().match.result) {
try { recordQuit(before); } catch (err) { console.warn('[stats] quit record failed', err); }
}
store.set({ room: null, match: emptyMatch() });
}
}
+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>`;
}
+45
View File
@@ -0,0 +1,45 @@
// 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, net } from '../net.js';
import { store } from '../store.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.setEntered(true);
store.patch('session', { entered: true });
// Keep the tentative token in this document until welcome persists it. Reloading here
// would either lose the selection or falsely turn it into an established session claim.
net.close();
net.setName(item.name);
net.connect();
setOpen(false);
setBusy(false);
} 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 || '—' })}<//>`)}
<//>`;
}
+131 -13
View File
@@ -1,30 +1,59 @@
// Player settings (BGM/SFX/voice volume, mute, damage numbers, render quality, the shortcut keys): a tiny observable
// store persisted in localStorage (`sp.pref.settings`), applied to the audio manager on every change, plus
// 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).
// 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, useState } from '../../vendor/hooks.module.js';
import { useLayoutEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { html, Modal, Button, Icon, MicroLabel } from './components.js';
import { createStore, useStore, loadPref, savePref } from '../store.js';
import { sanitizeSettings, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, hotkeyLabel, rebindHotkey, isDefaultHotkeys, captureHotkey } from './gameLogic.js';
import { 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, TEXT_SIZES } from './gameLogic.js';
import { audio } from '../audio.js';
import { openGuide } from './guide.js';
import { detectFeatures } from './device.js';
import { LangToggle, machineTranslationNote } from './lang.js';
import { t, tc, N_ } from '../../../shared/i18n.js';
import { errorCount, currentBattle, diagnosticsText } from '../diag.js';
import { copyText } from './clipboard.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/** Settings store: { bgm, sfx, voice, 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, s.voiceOverrides);
applyTextSize(s.textSize);
});
audio.setVolumes(settingsStore.get());
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) {
@@ -60,6 +89,16 @@ 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.
*/
const VOICE_LANG_NAMES = { cn: '中文', jp: '日本語' }; // i18n-ignore
/** The rebindable shortcuts' names (msgids), by action. */
const HOTKEY_NAMES = { refresh: N_('刷新商店'), freeze: N_('冻结 / 解冻商店'), levelUp: N_('升级调度中心'), retreat: N_('撤退选中干员'),
sell: N_('出售选中干员'), ready: N_('准备就绪 / 暂停(独立模拟)') };
@@ -144,6 +183,55 @@ function HotkeySection({ keys, touchUi }) {
</section>`;
}
/** Where a report goes: the upstream issue tracker (shown as text the player can select; a link may open nothing). */
export const ISSUES_URL = 'https://github.com/sganggs/Stronghold-Protocol/issues';
/**
* 问题反馈: copy the diagnostics of this page (diag.js) for a GitHub issue — the errors recorded since the page opened,
* this browser and device, where the player is, and, switched on by default while a battle is on screen, that battle's
* spec (the dev tools replay it). Player names, the room code and the token are replaced; nothing is uploaded. Laid out
* like 快捷键 above it (a head with its button, a hint, the result line); the battle switch is the settings' Toggle, and
* when the clipboard refuses (some in-app browsers), the report is shown selected in a box styled like 干员调配's 导出.
*/
/** The result line of 复制诊断信息, by outcome (msgids: translated when shown, so a language switch reaches it). */
const DIAG_NOTES = { copied: N_('已复制诊断信息,可以粘贴到 GitHub issue 中'), refused: N_('无法写入剪贴板,请手动复制下面的内容') };
function DiagSection() {
const [attach, setAttach] = useState(true);
const [note, setNote] = useState(null); // 'copied' | 'refused' | null: the result line
const [manual, setManual] = useState(null); // the report, when the clipboard refused it
const boxRef = useRef(null);
// the box opens selected for a manual copy, scrolled to its first line (select() leaves it at the end)
useLayoutEffect(() => {
const el = boxRef.current;
if (manual && el) { el.focus(); el.select(); el.scrollTop = 0; }
}, [manual]);
const n = errorCount();
const battle = currentBattle();
const copy = async () => {
const text = diagnosticsText({ state: store.get(), settings: settingsStore.get(), attachBattle: attach && !!battle });
const ok = await copyText(text);
setManual(ok ? null : text);
setNote(ok ? 'copied' : 'refused');
};
return html`<section class="set-diag" aria-labelledby="set-diag-title">
<div class="set-keys__head">
<span class="set-row__label" id="set-diag-title">${t('问题反馈')}<${MicroLabel}>DIAGNOSTICS<//></span>
<${Button} variant="ghost" size="sm" icon="copy" class="set-diag__copy" data-testid="diag-copy" onClick=${copy}>${t('复制诊断信息')}<//>
</div>
<p class="set-hint">${t('遇到问题时,复制诊断信息并粘贴到 GitHub issue 中,开发者就能看到出错时的情况。诊断信息只在本机生成,不会自动上传;玩家名和房间号会被替换。')}</p>
<div class="set-diag__meta">
<span class="set-diag__stat">${t('已记录的错误')}<b class=${cx('set-diag__count num', n > 0 && 'is-warn')} data-testid="diag-count">${n}</b></span>
<span class="set-diag__url">${ISSUES_URL.replace(/^https:\/\//, '')}</span>
</div>
${battle ? html`<${Toggle} label=${t('附上本场战斗')} micro="BATTLE DATA" value=${attach} onChange=${setAttach} />
<p class="set-hint set-diag__battle-hint">${t('开发者可以用附上的战斗数据重现这场战斗。')}</p>` : null}
${note ? html`<p class=${cx('set-keys__note', note === 'refused' && 'is-warn')} role="status" aria-live="polite">${t(DIAG_NOTES[note])}</p>` : null}
${manual ? html`<textarea ref=${boxRef} class="set-diag__text" data-testid="diag-text" spellcheck=${false} readOnly
aria-label=${t('诊断信息')} value=${manual}></textarea>` : null}
</section>`;
}
/**
* Settings modal.
* @param {{ open: boolean, onClose: Function }} props
@@ -154,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>
@@ -164,8 +252,15 @@ export function SettingsModal({ open, onClose }) {
${mtNote ? html`<p class="set-hint set-lang-note" data-testid="lang-mt-note">${mtNote}</p>` : null}
<${Slider} label=${t('背景音乐')} micro="BGM" icon="play" value=${s.bgm} onInput=${(v) => updateSettings({ bgm: v })} />
<${Slider} label=${t('干员语音')} micro="VOICE" icon="mic" value=${s.voice} onInput=${(v) => updateSettings({ voice: v })} />
<div class="set-row">
<span class="set-row__label">${t('语音语言')}<${MicroLabel}>VOICE LANGUAGE<//></span>
<div class="set-seg" role="radiogroup" aria-label=${t('语音语言')} data-testid="voice-lang">
${VOICE_LANGS.map((id) => html`<button key=${id} type="button" role="radio" aria-checked=${s.voiceLang === id ? 'true' : 'false'}
lang=${id === 'jp' ? 'ja' : 'zh'} class=${s.voiceLang === id ? 'is-on' : ''} onClick=${() => updateSettings({ voiceLang: id })}>${VOICE_LANG_NAMES[id]}</button>`)}
</div>
</div>
<${Slider} label=${t('音效')} micro="SFX" icon="signal" value=${s.sfx}
onInput=${(v) => { updateSettings({ sfx: v }); if (!tested) { setTested(true); setTimeout(() => setTested(false), 400); audio.sfx('click'); } }} />
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">
@@ -175,8 +270,31 @@ 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>
</div>
<//>`;
}
/**
* The 设置 button of the lobby and the room (GitHub #238 — before it the settings were reachable only from the title
* screen and a running match): the twin of the 玩法说明 button (ui/guide.js GuideButton), with the settings modal behind it
* (mounted only while open). The same modal as the title screen's and the match's: nothing in it is match-only.
* `square`: the gear alone (the lobby's top bar), the name stays title / aria-label — like GuideButton's.
* @param {{ class?: string, size?: 'sm'|'md'|'lg'|'xl', variant?: string, label?: string, square?: boolean }} props
*/
export function SettingsButton({ class: cls, size = 'sm', variant = 'ghost', label = t('设置'), square = false }) {
const [open, setOpen] = useState(false);
return html`<${Button} variant=${variant} size=${size} square=${square} class=${cx('settings-btn', cls)} onClick=${() => setOpen(true)}
title=${t('设置')} aria-label=${t('设置')} data-testid="settings-btn"><${GIcon} name="gear" class="btn__icon" />${square ? null : label}<//>
${open ? html`<${SettingsModal} open=${true} onClose=${() => setOpen(false)} />` : null}`;
}
+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>`;
}
+30 -8
View File
@@ -57,6 +57,14 @@ export function mergeHint(priv, chessId) {
return mergeTarget(priv, chessId, LOOKUPS.getChess) ? t('精锐干员将出现在作战区原位置') : t('精锐干员将进入整备区');
}
/**
* Whether a shop card shows its frost: its own slot's `frozen` (m.private shop.slots[i].frozen) — the 冻结 toggle copies onto
* every unsold slot, and 梓兰's 猎头顾问 freezes ONE copied card with every active refresh while the toggle stays off
* (GitHub #354: the bar drew only the toggle, so the frozen card showed no frost) — or the toggle itself (a frame without
* per-slot flags). The 冻结 button and the bar's frame keep following the toggle alone.
*/
export const slotFrozen = (slot, toggle = false) => !!slot?.frozen || !!toggle;
/** The armed (first-tapped) card's confirm strip: 确认购买 / 确认选择, or 无法购买 + why. */
function ArmedTag({ reason, free }) {
if (reason) return html`<span class="scard__confirm is-no" role="status"><b>${t('无法购买')}</b><small>${reason}</small></span>`;
@@ -84,7 +92,7 @@ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free
const willMerge = !!hint;
const bonds = Array.isArray(c?.bonds) ? c.bonds : [];
const disabled = !!reason;
const lo = si ? standInLoadout(si, LOOKUPS.getChess, data.get('backups')) : c ? chessLoadout(c, priv?.loadout, dr ? diyGetter(LOOKUPS.getChess, priv, diyData) : LOOKUPS.getChess) : null;
const lo = si ? standInLoadout(si, LOOKUPS.getChess, data.get('backups')) : c ? chessLoadout(c, priv?.loadout, dr ? diyGetter(LOOKUPS.getChess, priv, diyData) : LOOKUPS.getChess, { ops: priv?.ops ?? null, effects: data.get('effects') }) : null;
const tap = () => { if (onTap) onTap(idx); else if (!disabled) onBuy(idx); else onDetail(slot.id, 'chess', hint); };
const name = `${c?.name || t('干员')}${si ? t('({note})', { note: standInForText(c0.name) }) : ''}`;
const card = html`<button type="button" class=${cx('scard', `scard--t${tier}`, frozen && 'is-frozen', disabled && 'is-disabled', willMerge && 'is-merge', armed && 'is-armed', si && 'is-standin')}
@@ -175,6 +183,17 @@ function SoldCard({ item = false }) {
</div>`;
}
/**
* A slot with no card in it: the one a 调度中心 upgrade has just opened (a `null` slot — server/match/player/economy.js
* _openLevelSlots; GitHub #332 / PR #333: the official shop shows the new slot, empty, until the next refresh or round
* start fills it). Never 已招募 / SOLD OUT: nothing was bought there.
*/
function EmptyCard({ item = false }) {
// `scard--sold` too: the same inert frame (no hover lift, not a buyable card for every `:not(.scard--sold)` rule); `scard--empty` only restyles it
return html`<div class=${cx('scard', 'scard--sold', 'scard--empty', item && 'scard--item')} role="img" aria-label=${t('空栏位:刷新或下回合开始时补满')}
title=${t('空栏位:刷新或下回合开始时补满')}></div>`;
}
function LevelCard({ shop, reason, armed = false, onTap }) {
const lv = shop?.level ?? 1;
const max = lv >= (shop?.maxLevel ?? 6);
@@ -196,7 +215,7 @@ function LevelCard({ shop, reason, armed = false, onTap }) {
export const armKey = (kind, idx, slot) => `${kind}:${idx}:${slot?.id ?? ''}`;
/**
* The slot an armed key names (`c` / `i` shop slots, `r` reward slots), or null ('lv', nothing armed, a stale key).
* The slot an armed key names (`c` / `i` shop slots, `r` reward slots), or null (a level card, nothing armed, a stale key).
* @param {string|null} key
* @param {any[]} slots the shop's slots
* @param {any[]|null} rewardSlots the shown reward offer's slots
@@ -288,10 +307,12 @@ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel,
const free = Number(shop.freeRefreshes) || 0;
const showReward = !!(reward && Array.isArray(reward.slots) && reward.slots.length);
// A completed upgrade changes the level, so its confirmation cannot carry into the next upgrade.
const levelKey = `lv:${shop.level ?? 1}`;
// two-tap: the keys that may stay armed right now
const keys = new Set();
if (!collapsed) {
if (!lvReason) keys.add('lv');
if (!lvReason) keys.add(levelKey);
slots.forEach((s, i) => { if (s && !s.sold) keys.add(armKey(s.kind === 'item' ? 'i' : 'c', i, s)); });
if (showReward) reward.slots.forEach((s, i) => { if (s && !s.sold) keys.add(armKey('r', i, s)); });
}
@@ -312,7 +333,7 @@ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel,
buy(idx);
};
const tapLevel = () => {
if (armed !== 'lv') { setArmed('lv'); return; }
if (armed !== levelKey) { setArmed(levelKey); return; }
setArmed(null);
onLevel();
};
@@ -341,14 +362,15 @@ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel,
</button>
</div>
<div class="shopbar__row">
<${LevelCard} shop=${shop} reason=${lvReason} armed=${armed === 'lv'} onTap=${tapLevel} />
<${LevelCard} shop=${shop} reason=${lvReason} armed=${armed === levelKey} onTap=${tapLevel} />
${showReward ? html`<${RewardCards} offer=${reward} priv=${priv} editable=${editable} onPick=${onReward} onDetail=${onDetail} onLater=${onRewardLater}
armed=${armed} onTap=${tapCard} offBonds=${offBonds} />`
: html`<div class="shopbar__cards">
${chessSlots.map(({ s, i }) => {
if (!s || s.sold) return html`<${SoldCard} key=${`s${i}`} />`;
if (!s) return html`<${EmptyCard} key=${`e${i}`} />`;
if (s.sold) return html`<${SoldCard} key=${`s${i}`} />`;
const reason = shopBlockReason('buy', { priv, editable, slot: s, ...LOOKUPS });
return html`<${ChessCard} key=${`c${i}:${s.id}`} slot=${s} idx=${i} priv=${priv} frozen=${frozen} onBuy=${onBuy} onDetail=${onDetail} offBonds=${offBonds}
return html`<${ChessCard} key=${`c${i}:${s.id}`} slot=${s} idx=${i} priv=${priv} frozen=${slotFrozen(s, frozen)} onBuy=${onBuy} onDetail=${onDetail} offBonds=${offBonds}
reason=${reason} armed=${armed === armKey('c', i, s)} onTap=${editable ? (idx) => tapCard('c', idx, s, 'chess', reason, onBuy) : null} />`;
})}
</div>`}
@@ -357,7 +379,7 @@ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel,
? itemSlots.map(({ s, i }) => {
if (s.sold) return html`<${SoldCard} key=${`is${i}`} item=${true} />`;
const reason = shopBlockReason('buy', { priv, editable, slot: s, ...LOOKUPS });
return html`<${ItemCard} key=${`i${i}:${s.id}`} slot=${s} idx=${i} frozen=${frozen} onBuy=${onBuy} onDetail=${onDetail} reason=${reason}
return html`<${ItemCard} key=${`i${i}:${s.id}`} slot=${s} idx=${i} frozen=${slotFrozen(s, frozen)} onBuy=${onBuy} onDetail=${onDetail} reason=${reason}
armed=${armed === armKey('i', i, s)} onTap=${editable ? (idx) => tapCard('i', idx, s, 'item', reason, onBuy) : null} />`;
})
: html`<${SoldCard} item=${true} />`}
+744
View File
@@ -0,0 +1,744 @@
// Local match statistics: every match the local player took part in is appended as one record to localStorage
// (`sp.pref.stats`). Per-browser only — the server keeps matches in memory and has no identity, so there is nowhere
// else for cross-match numbers to live; nothing here is ever sent anywhere. The stats page labels itself 本机数据 and
// offers JSON export / import for moving between devices. (Ported from PR #323 by @2321Robin, reworked for 0.2.2.)
//
// Two kinds of record (`end`):
// 'settled' the match reached its settlement: m.result arrived (server/match/results.js buildResult). The record
// keeps what the settlement offers — the match frame (difficulty, mode, boss, seed, duration, hidden-core
// outcome, reason) and one row per player (band, title, lineup, bonds, the stat block) — so the page can
// re-view the settlement screen (recordToResult).
// 'quit' the player gave up (放弃模拟) before the settlement; the server sends a leaver nothing, so the record is
// built from what the client knew at that moment (buildQuitRecord): the frame, the player's own row, the
// rounds cleared. It is listed in the history, marked 中途退出, and cannot be re-viewed.
//
// What counts (countsTowardStats, the owner's decision of 2026-10-08: a match entered and left right away must not
// count): every aggregate — games, wins, win rate, hidden core, best round, time played, the strategy table, titles
// and the combat totals — is taken over the records that count. A settled record always counts. A quit record counts
// once at least one of the player's own battles had finished — a round cleared (the server's rule: leaving while
// round k runs has passed k − 1 rounds, leaving in its settlement k) or already eliminated. A match left earlier is
// history only.
//
// Bounded storage: at most MAX_RECORDS matches and MAX_CHARS characters of JSON. Only the newest FULL_KEEP records
// keep their full rows (lineups, bonds, teammates' names — what the settlement replay needs); older ones are compacted
// to the player's own summary row, which is all the aggregates read. The oldest records go first when a budget is hit,
// and a write the browser refuses shrinks the list further instead of failing. Teammates' display names are part of
// the newest records and of an export file: they stay on this device unless the player exports them.
//
// Format evolution: the envelope carries a version (`STATS_VERSION`); `migrateStats` walks records through a chain of
// per-version steps and every loaded / imported record passes `normalizeRecord`, a strict schema: missing fields get
// defaults, unknown fields and oversized values are dropped, a row it cannot salvage is dropped. An envelope from a
// NEWER version than this build is refused (never downgraded and written back); an unreadable one is set aside
// (`sp.pref.stats.damaged`) and the history starts again.
//
// Pure logic, no Preact / DOM: Node-testable (test/ui/stats.test.js); localStorage is touched only by loadStats /
// saveStats / their helpers.
import { PHASE } from '../../../shared/constants.js';
/** Current record-envelope version (bump + add a MIGRATIONS step when the record shape changes). */
export const STATS_VERSION = 1;
/** localStorage key under the app's `sp.pref.` prefix (store.js loadPref). */
export const STATS_PREF_KEY = 'stats';
/** Oldest records are dropped beyond this many matches. */
export const MAX_RECORDS = 1000;
/** The newest records that keep their full rows (lineups, bonds, teammates) — what the settlement replay reads. */
export const FULL_KEEP = 30;
/** Budget of the stored JSON in characters (a character costs up to 2 bytes of the browser's ~5 MB quota): the oldest records go first. */
export const MAX_CHARS = 800_000;
/** Largest export file the page reads. */
export const IMPORT_MAX_CHARS = 8_000_000;
const FULL_KEY = `sp.pref.${STATS_PREF_KEY}`;
const DAMAGED_KEY = `${FULL_KEY}.damaged`;
/** @returns {{ v: number, records: Array }} a fresh empty envelope */
export const emptyStats = () => ({ v: STATS_VERSION, records: [] });
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
const int = (v, d) => (Number.isFinite(v) ? Math.round(v) : d);
const num = (v) => (Number.isFinite(v) ? v : null);
const bool = (v, d = false) => (typeof v === 'boolean' ? v : d);
/** A non-empty string, cut to `max` characters. */
const str = (v, max = 64) => (typeof v === 'string' && v.length ? v.slice(0, max) : null);
// per-record bounds (an imported file is foreign data: nothing a row holds may grow without limit)
const MAX_PLAYERS = 6; // the game seats 4
const MAX_LINEUP = 12;
const MAX_ITEMS = 4;
const MAX_BONDS = 24;
const MAX_STAT_KEYS = 24;
const MAX_NAME = 24;
/**
* Copy only the finite numeric keys of a stats block (the settlement's numbers; a numeric field this build does not
* know is kept too, up to MAX_STAT_KEYS — display can lag, data should not).
* @param {any} raw
* @returns {Record<string, number>}
*/
function normalizeStatBlock(raw) {
const out = {};
if (!isObj(raw)) return out;
let n = 0;
for (const [k, v] of Object.entries(raw)) {
if (!Number.isFinite(v) || k.length > 32) continue;
out[k] = v;
if (++n >= MAX_STAT_KEYS) break;
}
return out;
}
/**
* FNV-1a 32-bit, hex — a content hash needs no crypto, only stability across sessions.
* @param {string} s
* @returns {string}
*/
function fnv1a(s) {
let h = 0x811c9dc5;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
}
return h.toString(16).padStart(8, '0');
}
/**
* The record's dedupe id, from match facts the server replays byte-identically on reconnect / reload
* (server/lobby.js result replay): seed + duration + mode + rounds + outcome + who I was. Two distinct
* matches colliding on all of these is not a real scenario; a replayed m.result hits the same id and is
* dropped, which is the point (recording happens on every m.result arrival, replays included).
* @param {any} res raw m.result payload
* @param {string|null|undefined} myId the local player's id at that moment
* @returns {string}
*/
export function recordId(res, myId) {
const r = isObj(res) ? res : {};
return `r1.${fnv1a([r.seed ?? '-', r.durationMs ?? '-', r.modeId ?? '-', r.roundsPassed ?? '-', r.victory ? 1 : 0, myId ?? '-'].join('|'))}`;
}
/**
* Normalize ONE player row: known fields defaulted and bounded, unknown fields dropped. `quit`: the record is a quit
* record, whose row may not know its rounds (`roundsPassed: null`).
* @param {any} raw
* @param {boolean} [quit]
* @returns {any}
*/
function normalizePlayerRow(raw, quit = false) {
const p = isObj(raw) ? raw : {};
// the page and the replay read the title record from the game data by id: the sent texts are not kept
const title = isObj(p.title) && str(p.title.id) ? { id: str(p.title.id) } : null;
const lineup = (Array.isArray(p.lineup) ? p.lineup : []).filter(isObj).slice(0, MAX_LINEUP).map((u) => {
// ids only — the tile position (row/col) is replay detail, not a statistic; items kept (配发装备统计); a 自选
// pick and a 补位 stand-in kept too, or the replayed card would draw the plain operator
const e = {
id: str(u.id) || '', golden: bool(u.golden), tier: int(u.tier, 0),
items: (Array.isArray(u.items) ? u.items : []).filter((i) => typeof i === 'string' && i.length).slice(0, MAX_ITEMS).map((i) => i.slice(0, 64)),
};
if (u.kind === 'token') e.kind = 'token';
if (isObj(u.diy) && str(u.diy.charId)) {
e.diy = { charId: str(u.diy.charId), skillIndex: Number.isInteger(u.diy.skillIndex) ? u.diy.skillIndex : 0, uniEquipId: str(u.diy.uniEquipId) };
}
if (str(u.standInFor)) e.standInFor = str(u.standInFor);
return e;
}).filter((u) => u.id);
const bonds = (Array.isArray(p.bonds) ? p.bonds : []).filter(isObj).slice(0, MAX_BONDS).map((b) => ({
bondId: str(b.bondId) || str(b.id) || '', layers: int(b.layers, 0), active: bool(b.active),
})).filter((b) => b.bondId);
return {
playerId: str(p.playerId) || '',
seat: int(p.seat, 0),
name: str(p.name, MAX_NAME) || '',
isBot: bool(p.isBot),
left: bool(p.left),
alive: bool(p.alive, true),
victory: bool(p.victory), // server semantics: team won AND this player was still in at the end
roundsPassed: quit ? num(p.roundsPassed) : int(p.roundsPassed, 0),
eliminatedRound: num(p.eliminatedRound),
lp: num(p.lp),
bandId: str(p.bandId),
title,
trophies: int(p.trophies, 0),
reward: int(p.reward, 0),
lineup,
bonds,
stats: normalizeStatBlock(p.stats),
};
}
/**
* Normalize ONE match record to the current shape (a strict schema: missing fields get defaults, unknown ones are
* dropped, every string / list is bounded). A record without an `end` is a settled one — the PR #323 build, which
* never recorded anything else, wrote none.
* @param {any} raw
* @returns {any|null} null when the row is not salvageable (no usable player row at all)
*/
export function normalizeRecord(raw) {
const r = isObj(raw) ? raw : {};
// a record IS a played match — no usable players array, no record
if (!Array.isArray(r.players)) return null;
const quit = r.end === 'quit';
const players = r.players.filter(isObj).slice(0, MAX_PLAYERS).map((p) => normalizePlayerRow(p, quit));
if (!players.length) return null;
const rec = {
id: str(r.id),
v: STATS_VERSION,
t: int(r.t, 0),
end: quit ? 'quit' : 'settled',
victory: bool(r.victory),
roundsPassed: quit ? num(r.roundsPassed) : int(r.roundsPassed, 0),
lastRound: num(r.lastRound),
hiddenReached: bool(r.hiddenReached),
hiddenCleared: bool(r.hiddenCleared),
reason: str(r.reason, 32),
modeId: str(r.modeId),
difficulty: str(r.difficulty, 24),
stageId: str(r.stageId),
bossId: str(r.bossId),
hiddenBossId: str(r.hiddenBossId),
seed: num(r.seed),
durationMs: num(r.durationMs),
teamLp: num(r.teamLp),
roomMode: r.roomMode === 'solo' || r.roomMode === 'coop' ? r.roomMode : null,
selfId: str(r.selfId),
players,
};
if (r.compact === true) rec.compact = true;
// an id-less (hand-made) record gets a content id, so both copies of it land on the same key
if (!rec.id) rec.id = `c.${fnv1a(JSON.stringify(rec))}`;
return rec;
}
/**
* The MIGRATIONS chain: `MIGRATIONS[v]` upgrades a v-envelope to v+1, for v = STATS_VERSION and down.
* Empty at v1 — the steps exist so the next format change lands as one function here and every stored /
* imported envelope walks the whole chain.
* @type {Record<number, (stats: any) => any>}
*/
export const MIGRATIONS = {};
/** Newest first (the stored order): by time, then id. */
const newestFirst = (a, b) => (b.t - a.t) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
/**
* Bring any stored / imported envelope to the current version.
* @param {any} raw
* @returns {{ stats: { v: number, records: any[] }, changed: boolean, newer: boolean, dropped: number }}
* `newer` = the data was written by a NEWER build: returned untouched, callers must not save over it.
* `dropped` = rows that were not salvageable, repeated a record or went beyond MAX_RECORDS: gone from `stats`.
*/
export function migrateStats(raw) {
if (!isObj(raw) || !Array.isArray(raw.records)) return { stats: emptyStats(), changed: false, newer: false, dropped: 0 };
if ((raw.v ?? 0) > STATS_VERSION) {
return { stats: { v: raw.v, records: raw.records }, changed: false, newer: true, dropped: 0 };
}
let v = Number.isFinite(raw.v) ? raw.v : 0;
let records = raw.records;
let changed = v !== STATS_VERSION;
while (v < STATS_VERSION) {
const step = MIGRATIONS[v];
if (typeof step === 'function') records = step({ v, records }).records;
v++;
}
// a hand-edited / merged list can be oversized, unsorted or repeat a record: only the newest MAX_RECORDS survive, newest first
const normalized = [];
const seen = new Set(); // a record listed twice (hand-merged data) counts once: the first copy
for (const r of records) {
const rec = normalizeRecord(r);
if (rec && !seen.has(rec.id)) { seen.add(rec.id); normalized.push(rec); }
}
normalized.sort(newestFirst);
const kept = normalized.slice(0, MAX_RECORDS);
const dropped = records.length - kept.length;
// storage is rewritten (self-healed) when a row went, the order changed or an id-less row got its content id
changed ||= dropped > 0 || kept.some((rec, i) => !isObj(records[i]) || rec.id !== records[i].id);
return { stats: { v: STATS_VERSION, records: kept }, changed, newer: false, dropped };
}
/** Read the raw stored string (null when absent or unreadable). */
function readRaw() {
try { return globalThis.localStorage?.getItem(FULL_KEY) ?? null; } catch { return null; }
}
/**
* Read + migrate the persisted envelope; self-heals older shapes back to storage. An unreadable value (not JSON, or
* not an envelope) is moved to `sp.pref.stats.damaged` — kept for a manual recovery — and the history starts again.
* @returns {{ v: number, records: any[], newer: boolean, dropped: number, damaged: boolean }}
*/
export function loadStats() {
const raw = readRaw();
if (raw == null) return { ...emptyStats(), newer: false, dropped: 0, damaged: false };
let parsed;
try { parsed = JSON.parse(raw); } catch { parsed = undefined; }
if (!isObj(parsed) || !Array.isArray(parsed.records)) {
// keep the bad value where a person can find it; if even that write fails the next save simply replaces it
try { globalThis.localStorage?.setItem(DAMAGED_KEY, raw); globalThis.localStorage?.removeItem(FULL_KEY); } catch { /* keep it in place */ }
return { ...emptyStats(), newer: false, dropped: 0, damaged: true };
}
const { stats, changed, newer, dropped } = migrateStats(parsed);
if (!newer && changed) saveStats(stats);
return { ...stats, newer, dropped, damaged: false };
}
// ---- compaction + the storage budget ---------------------------------------------------------------------------
const COMPACT_FRAME = ['id', 'v', 't', 'end', 'victory', 'roundsPassed', 'lastRound', 'hiddenReached', 'hiddenCleared', 'reason', 'modeId',
'difficulty', 'roomMode', 'durationMs', 'selfId'];
/**
* The compact form of a record: the frame and the player's OWN row without lineup, bonds and name — everything the
* aggregates and the history list read, nothing a replay needs. Teammates are gone. Idempotent.
* @param {any} rec a normalized record
* @returns {any}
*/
export function compactRecord(rec) {
const self = selfRowOf(rec);
const out = {};
for (const k of COMPACT_FRAME) if (rec[k] != null) out[k] = rec[k];
out.compact = true;
out.players = self ? [{
playerId: self.playerId, seat: self.seat, alive: self.alive, victory: self.victory, roundsPassed: self.roundsPassed,
eliminatedRound: self.eliminatedRound, bandId: self.bandId, title: self.title, left: self.left, stats: self.stats,
}] : [];
return out;
}
/**
* What goes to storage: the newest FULL_KEEP records as they are, older ones compacted, then the oldest dropped until
* the JSON fits `maxChars` (the newest record is always kept).
* @param {any[]} records newest first
* @param {number} [maxChars]
* @returns {any[]}
*/
export function fitToBudget(records, maxChars = MAX_CHARS) {
const kept = [];
let used = 40; // the envelope itself
records.slice(0, MAX_RECORDS).forEach((r, i) => {
const rec = i < FULL_KEEP ? r : compactRecord(r);
const n = JSON.stringify(rec).length + 1;
if (kept.length && used + n > maxChars) return;
used += n;
kept.push(rec);
});
return kept;
}
/**
* Persist the envelope. Never overwrites an envelope of a newer build. Compacts and trims to the budget first; a write
* the browser refuses (quota — other keys share it) is retried with half the history, down to a handful of records.
* @param {{ v: number, records: any[], newer?: boolean }} stats
* @returns {boolean} whether a write stuck
*/
export function saveStats(stats) {
if (stats.newer) return false;
let recs = fitToBudget(stats.records);
for (;;) {
try {
const ls = globalThis.localStorage;
if (!ls) return false; // no storage at all (disabled by the browser)
ls.setItem(FULL_KEY, JSON.stringify({ v: STATS_VERSION, records: recs }));
return true;
} catch { /* quota or storage disabled — fall through to shrinking */ }
if (recs.length <= 8) return false; // a small history that still does not fit is foreign quota pressure
recs = recs.slice(0, Math.max(8, Math.ceil(recs.length / 2)));
}
}
/**
* Derive the room shape ('solo' | 'coop') the way constants.js modeIdFor builds modeIds.
* @param {string|null|undefined} modeId
* @param {string|null|undefined} roomMode room.state `mode` when known
* @returns {'solo'|'coop'|null}
*/
export function roomModeOf(modeId, roomMode) {
if (roomMode === 'solo' || roomMode === 'coop') return roomMode;
if (typeof modeId !== 'string') return null;
if (modeId.includes('_single_')) return 'solo';
if (modeId.includes('_multi_')) return 'coop';
return null;
}
/**
* Build one settled record from a raw m.result payload (+ context). Null when there is nothing to store: payloads
* without players (the error-path settlement) or matches the local player did not take part in (spectator
* seats get every result pushed too — eliminated players, who ARE in players[], keep recording).
* @param {any} res raw m.result payload
* @param {{ myId?: string|null, roomMode?: string|null, now?: number }} ctx
* @returns {any|null}
*/
export function buildRecord(res, ctx = {}) {
const r = isObj(res) ? res : {};
if (!Array.isArray(r.players) || !r.players.length) return null;
const myId = str(ctx.myId);
const selfRow = r.players.find((p) => isObj(p) && p.playerId === myId);
if (!selfRow) return null; // spectator / observer: not my match
return normalizeRecord({
v: STATS_VERSION,
t: int(ctx.now, 0),
id: recordId(r, myId),
end: 'settled',
victory: bool(r.victory),
roundsPassed: int(r.roundsPassed, 0),
lastRound: num(r.lastRound),
hiddenReached: bool(r.hiddenReached),
hiddenCleared: bool(r.hiddenCleared),
reason: str(r.reason),
modeId: str(r.modeId),
difficulty: str(r.difficulty),
stageId: str(r.stageId),
bossId: str(r.bossId),
hiddenBossId: str(r.hiddenBossId),
seed: num(r.seed),
durationMs: num(r.durationMs),
teamLp: num(r.teamLp),
roomMode: roomModeOf(str(r.modeId), ctx.roomMode),
selfId: myId,
players: r.players,
});
}
// ---- the match left early ----------------------------------------------------------------------------------------
/**
* What the page remembers of the match on screen, so that giving it up can still be recorded: when the page first saw
* it, whether that was at the start (a duration is only kept then), and the last round the player was seen alive in
* (the server tells the others at the settlement, a leaver nothing).
* @returns {{ active: boolean, startedAt: number, sawStart: boolean, aliveRound: number|null }}
*/
export const createLiveMatch = () => ({ active: false, startedAt: 0, sawStart: false, aliveRound: null });
/** The module's own tracker (fed by installStatsRecorder, read by recordQuit). */
export const liveMatch = createLiveMatch();
/**
* Feed the tracker one app state (cheap: called on every store change).
* @param {ReturnType<typeof createLiveMatch>} live
* @param {any} state the app store state
* @param {number} [now]
*/
export function observeMatch(live, state, now = Date.now()) {
const pub = state?.match?.public;
const inMatch = !!pub && !!pub.phase && pub.phase !== PHASE.LOBBY && pub.phase !== PHASE.RESULT && !state.match.result;
if (!inMatch) { live.active = false; return live; }
const round = Number(pub.round) || 0;
if (!live.active) {
live.active = true;
live.startedAt = now;
live.sawStart = round <= 1;
live.aliveRound = null;
}
const me = Array.isArray(pub.players) ? pub.players.find((p) => isObj(p) && p.playerId === state.me?.playerId) : null;
if (me && me.alive !== false) live.aliveRound = Math.max(live.aliveRound ?? 0, round);
return live;
}
/** Start following the store (main.js). Returns the unsubscribe function. */
export function installStatsRecorder(store) {
observeMatch(liveMatch, store.get());
return store.subscribe((s) => observeMatch(liveMatch, s));
}
/**
* The rounds a player who leaves in `phase` of `round` has cleared — the server's rule for a quit (platform.js _quit:
* eliminated in round k ⇒ k − 1 passed; leaving in the settlement of round k counts k).
* @param {string} phase @param {number} round
*/
export function clearedRounds(phase, round) {
const r = Number.isFinite(round) ? round : 0;
return Math.max(0, phase === PHASE.SETTLE ? r : r - 1);
}
/**
* Build the quit record of a match the local player is leaving (放弃模拟), from the app state and the tracker. Null when
* there is nothing of mine to record: no match on screen, its settlement already here (that one is recorded by
* recordResult), a spectator seat, or a match the page never saw.
* @param {any} state the app store state BEFORE the match is cleared
* @param {{ live?: ReturnType<typeof createLiveMatch>, now?: number }} [ctx]
* @returns {any|null}
*/
export function buildQuitRecord(state, ctx = {}) {
const live = ctx.live ?? liveMatch;
const now = int(ctx.now, Date.now());
const pub = state?.match?.public;
const myId = str(state?.me?.playerId);
if (!pub || !myId || state.match.result || !live.active) return null;
const row = Array.isArray(pub.players) ? pub.players.find((p) => isObj(p) && p.playerId === myId) : null;
if (!row) return null; // a spectator seat is no player of the match
const alive = row.alive !== false;
const round = Number(pub.round) || 0;
// alive: the rounds cleared so far; already eliminated: the round it was seen alive in, less one (unknown when the
// page never saw it alive — a reload after the elimination)
const roundsPassed = alive ? clearedRounds(pub.phase, round) : (live.aliveRound != null ? Math.max(0, live.aliveRound - 1) : null);
return normalizeRecord({
v: STATS_VERSION,
t: now,
id: `q.${fnv1a([myId, live.startedAt, pub.modeId ?? '-', state.room?.code ?? '-'].join('|'))}`,
end: 'quit',
victory: false,
roundsPassed,
lastRound: num(pub.lastRound),
hiddenReached: pub.phase === PHASE.HIDDEN_CORE,
hiddenCleared: false,
reason: 'quit',
modeId: str(pub.modeId),
difficulty: str(pub.difficulty),
stageId: str(pub.stageId),
bossId: str(pub.bossId),
hiddenBossId: str(pub.hiddenBossId),
seed: null,
durationMs: live.sawStart ? Math.max(0, now - live.startedAt) : null,
teamLp: num(pub.teamLp),
roomMode: roomModeOf(str(pub.modeId), state.room?.mode),
selfId: myId,
players: [{
playerId: myId, seat: row.seat, name: row.name, isBot: false, left: true, alive, victory: false, roundsPassed,
eliminatedRound: alive ? null : live.aliveRound, lp: row.lp, bandId: row.bandId,
}],
});
}
/**
* The m.result-shaped payload behind a settled record — the inverse of buildRecord. The stats page's history
* rows hand this to the settlement view (screens/result.js reads it through normalizeResult): a finished match can be
* RE-VIEWED because the record kept every field the payload had. Whatever a record never stored is simply absent and
* normalizeResult defaults it, exactly like a tolerant live payload. A quit record (nothing was settled) and a
* compacted one (its rows are gone) have no settlement to show.
* @param {any} rec
* @returns {any|null} null when the row is not a usable, full, settled record
*/
export function recordToResult(rec) {
const r = normalizeRecord(rec);
if (!r || r.end === 'quit' || r.compact) return null;
const res = {
victory: r.victory,
roundsPassed: r.roundsPassed,
hiddenReached: r.hiddenReached,
hiddenCleared: r.hiddenCleared,
reason: r.reason,
modeId: r.modeId,
difficulty: r.difficulty,
bossId: r.bossId,
hiddenBossId: r.hiddenBossId,
seed: r.seed,
durationMs: r.durationMs,
teamLp: r.teamLp,
players: r.players,
};
if (Number.isFinite(r.lastRound)) res.lastRound = r.lastRound;
return res;
}
/**
* Append one record: replays (same content id) are dropped, newest first, capped at MAX_RECORDS.
* @param {{ v: number, records: any[] }} stats
* @param {any} record
* @returns {{ stats: { v: number, records: any[] }, added: boolean }}
*/
export function appendRecord(stats, record) {
if (!record || !record.id) return { stats, added: false };
if (stats.records.some((r) => r.id === record.id)) return { stats, added: false };
const records = [record, ...stats.records].slice(0, MAX_RECORDS);
return { stats: { v: STATS_VERSION, records }, added: true };
}
/** load → append → save of one built record (never over an envelope of a newer build). @returns {any|null} the record when stored */
function storeRecord(rec) {
if (!rec) return null;
const stats = loadStats();
if (stats.newer) return null;
const { stats: next, added } = appendRecord(stats, rec);
if (added && !saveStats(next)) console.warn('[stats] localStorage write failed (quota?), record not persisted');
return added ? rec : null;
}
/**
* Record a finished match end-to-end (load → append → save). Returns the built record, or null when
* nothing was stored (no players / not my match / replay).
* @param {any} res raw m.result payload
* @param {{ myId?: string|null, roomMode?: string|null, now?: number }} [ctx]
* @returns {any|null}
*/
export function recordResult(res, ctx = {}) {
return storeRecord(buildRecord(res, ctx));
}
/**
* Record the match the player is giving up (放弃模拟), before the app clears its state. Returns the record, or null.
* @param {any} state the app store state before the match is cleared
* @param {{ live?: ReturnType<typeof createLiveMatch>, now?: number }} [ctx]
* @returns {any|null}
*/
export function recordQuit(state, ctx = {}) {
const live = ctx.live ?? liveMatch;
const rec = storeRecord(buildQuitRecord(state, ctx));
live.active = false; // one record per match
return rec;
}
/**
* Merge two envelopes (import into existing): union by record id, newest first, capped.
* @param {{ records: any[] }} a current (wins ties)
* @param {{ records: any[] }} b incoming
* @returns {{ v: number, records: any[] }}
*/
export function mergeStats(a, b) {
const byId = new Map();
for (const r of [...(Array.isArray(b?.records) ? b.records : []), ...(Array.isArray(a?.records) ? a.records : [])]) {
const rec = normalizeRecord(r);
if (rec) byId.set(rec.id, rec);
}
const records = [...byId.values()].sort(newestFirst).slice(0, MAX_RECORDS);
return { v: STATS_VERSION, records };
}
/**
* Parse + migrate + merge an exported / pasted payload into the current data.
* @param {any} raw parsed JSON of the imported file
* @param {{ records: any[] }} current current envelope
* @returns {{ stats: { v: number, records: any[] }, added: number, skipped: number, dropped: number }}
* added = records new to this device; skipped = duplicates of records it has; dropped = rows of the file that could
* not be read
*/
export function importStats(raw, current) {
if (isObj(raw) && raw.kind != null && raw.kind !== 'local-stats') {
const err = new Error('not a stats export'); // the page shows its own text
err.code = 'stats-bad-file';
throw err;
}
if (!isObj(raw) || !Array.isArray(raw.records)) {
const err = new Error('not a stats export');
err.code = 'stats-bad-file';
throw err;
}
const { stats: incoming, newer, dropped } = migrateStats(raw);
if (newer) {
const err = new Error('exported by a newer client'); // the page shows its own text
err.code = 'stats-newer-version';
throw err;
}
const have = new Set((Array.isArray(current?.records) ? current.records : []).map((r) => r.id));
const merged = mergeStats(current, incoming);
const added = incoming.records.filter((r) => !have.has(r.id)).length;
return { stats: merged, added, skipped: incoming.records.length - added, dropped };
}
/**
* The payload the export button downloads (envelope + a little provenance).
* @param {{ records: any[] }} stats
* @returns {{ app: string, kind: string, v: number, exportedAt: string, records: any[] }}
*/
export function exportStats(stats) {
return {
app: 'stronghold-protocol',
kind: 'local-stats',
v: STATS_VERSION,
exportedAt: new Date().toISOString(),
records: fitToBudget(Array.isArray(stats?.records) ? stats.records : []),
};
}
// ---- aggregation (what the page shows; records may hold more than any view uses) ------------------------------------
const SELF_STATS = ['dmgDealt', 'kills', 'bossDamage', 'activatedLayers', 'merges', 'itemsEquipped', 'gold', 'perfectRounds', 'refreshes', 'leaks', 'lpLost'];
/**
* The row that "is me" in a record: selfId, else the only human, else seat 0 — records written by
* recordResult always have selfId; the fallbacks only serve imported / hand-made data.
* @param {any} rec
* @returns {any|null}
*/
export function selfRowOf(rec) {
const ps = Array.isArray(rec?.players) ? rec.players : [];
return ps.find((p) => p.playerId && p.playerId === rec.selfId)
|| ps.find((p) => !p.isBot)
|| ps[0]
|| null;
}
/** Whether this record counts as a win for its self player (per-row victory = team won AND still in). */
const selfWon = (rec, self) => (self ? !!self.victory : !!rec.victory);
/**
* The rounds the player passed in a record: its own row's (the table the settlement prints per player), else the
* team's; 0 when the record does not know.
* @param {any} rec
*/
export function roundsOf(rec) {
const self = selfRowOf(rec);
const v = self && Number.isFinite(self.roundsPassed) ? self.roundsPassed : rec?.roundsPassed;
return Number.isFinite(v) ? v : 0;
}
/**
* Whether a record enters the aggregates (see the header). A settled record always does. A quit record does once the
* player had finished at least one battle: cleared a round, or was already eliminated; a match given up before that —
* entered and left right away — is history only.
* @param {any} rec a normalized record
* @returns {boolean}
*/
export function countsTowardStats(rec) {
if (!isObj(rec)) return false;
if (rec.end !== 'quit') return true;
const self = selfRowOf(rec);
if (!self) return false;
return self.alive === false || (Number.isFinite(self.roundsPassed) && self.roundsPassed >= 1);
}
/**
* Aggregate records for the stats page — over the records that count; `total` / `excluded` say how many did not.
* Everything is keyed by ids (bandId / titleId / difficulty); the page maps them to names and icons through the game
* data. The combat totals read the settled records' stat blocks (a quit record has none).
* @param {any[]} records
* @returns {any}
*/
export function aggregateStats(records) {
const all = Array.isArray(records) ? records : [];
const rs = all.filter(countsTowardStats);
const out = {
total: all.length, count: rs.length, excluded: all.length - rs.length, wins: 0,
rounds: { total: 0, max: 0 },
duration: { totalMs: 0 },
hidden: { reached: 0, cleared: 0 },
byDifficulty: {}, byMode: { solo: 0, coop: 0 },
titles: {}, bands: {}, sums: {},
};
for (const rec of rs) {
const self = selfRowOf(rec);
const won = selfWon(rec, self);
if (won) out.wins++;
const rounds = roundsOf(rec);
out.rounds.total += rounds;
out.rounds.max = Math.max(out.rounds.max, rounds);
out.duration.totalMs += num(rec.durationMs) || 0;
if (rec.hiddenReached) out.hidden.reached++;
if (rec.hiddenCleared) out.hidden.cleared++;
const d = rec.difficulty || 'UNKNOWN';
const bd = (out.byDifficulty[d] ||= { games: 0, wins: 0, hiddenCleared: 0 });
bd.games++;
if (won) bd.wins++;
if (rec.hiddenCleared) bd.hiddenCleared++;
if (rec.roomMode === 'solo' || rec.roomMode === 'coop') out.byMode[rec.roomMode]++;
const titleId = self?.title?.id;
if (titleId) {
const t = (out.titles[titleId] ||= { count: 0 });
t.count++;
}
const bandId = self?.bandId;
if (bandId) {
const b = (out.bands[bandId] ||= { games: 0, wins: 0 });
b.games++;
if (won) b.wins++;
}
if (rec.end !== 'quit' && self && isObj(self.stats)) {
for (const k of SELF_STATS) {
const v = self.stats[k];
if (Number.isFinite(v)) out.sums[k] = (out.sums[k] || 0) + v;
}
}
}
return out;
}
+71 -7
View File
@@ -5,7 +5,10 @@
// Observing (client-side combat, `observe` prop — the official flow): tapping a teammate's avatar expands a mint
// "前往查看" button under the row (when that teammate can be observed now; otherwise the reason is toasted through
// onWatch); while observing, the own row shows a "返回战场" button. Without `observe` (server-run combat) a click
// watches that player's field at once.
// watches that player's field at once, and a click while that field is already on screen does not ask again.
// In PHASE.PREP a double-click on a teammate's avatar opens their field (GitHub #131, the 「前往查看」 shortcut).
// It does not open a field the teammate does not have, and it does not open one the click already opened.
// Boss-prep phases (最终攻势 / 隐秘核心) are not PHASE.PREP, so they still use 「前往查看」.
// Live LP (user playtest #3 item 2): during a normal round's battle each row's tower shows lp − the loss that player's
// leaks so far will cost (red, −N): the own row the top bar's live value (`self`, ui/hud.js liveLp), a teammate's row
// m.public players[].pendingLp (server/match/match/views.js, ~1 Hz). 联防 (user playtest #6 item 7): a leaker's row adds the
@@ -15,7 +18,7 @@
// the player watches, so the number moves with the kills on screen), else m.public players[].uniteLeft (the authority's
// report, ~1 Hz).
import { useEffect, useState } from '../../vendor/hooks.module.js';
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { PHASE } from '../../../shared/constants.js';
import { html, Icon, Tooltip } from './components.js';
import { PlayerAvatar, LpTower, GIcon, LocalSprite } from './gameComponents.js';
@@ -55,6 +58,45 @@ export function rowLp(p, pub, self = null, { uniteLocal = null, cap = 10 } = {})
return { lp, pending, unite: (pending > 0 || left != null) && (self ? !!self.unite : pub.phase === PHASE.UNITE), left };
}
/** True when `watching` is already this teammate's field (their live field, or the prep scout `n:<id>`). */
export function teammateFieldShown(watching, player) {
if (!watching || !player) return false;
return watching === player.fieldId || watching === `n:${player.playerId}`;
}
/**
* A single click on a teammate avatar calls onWatch only in server-run combat (no `observe`), and only when
* that field is not already on screen. A double-click fires click first; the second click, and any later click
* while the view is already theirs, must not open it again. Client combat never watches from the avatar click.
* `askedRecently`: this avatar's click already called onWatch in the last moment (the other click of a double-click,
* before `watching` has re-rendered).
* @param {{ observe?: unknown, watching?: string|null, player?: { playerId?: string, fieldId?: string }|null, askedRecently?: boolean }} [o]
* @returns {boolean}
*/
export function teammateClickWatches(o = {}) {
if (o.observe || o.askedRecently) return false;
if (teammateFieldShown(o.watching, o.player)) return false;
return !!o.player;
}
/**
* Double-click in PHASE.PREP opens a teammate's field, skipping 「前往查看」. False — do not call onWatch — when
* the phase is not PREP, the row is your own, there is no client-combat `observe` (the click already watched),
* `canObserve` has no field (do not force a view; the click toasts), or that field is already open.
* @param {{ phase?: string, self?: boolean, observe?: { canObserve?: (p: any) => { fieldId?: string }|null }|null,
* watching?: string|null, player?: any, askedRecently?: boolean }} [o]
* @returns {boolean}
*/
export function prepDblClickWatches(o = {}) {
if (o.phase !== PHASE.PREP || o.self || !o.player || !o.observe) return false;
if (o.askedRecently || teammateFieldShown(o.watching, o.player)) return false;
const view = (typeof o.observe.canObserve === 'function' ? o.observe.canObserve(o.player) : null) || {};
return !!view.fieldId;
}
/** How long the second click of a double-click counts as "already asked" before `watching` re-renders. */
const AVATAR_CLICK_GAP_MS = 500;
/**
* Tooltip of a row's LP tower with a pending loss (null without one).
* @param {{ lp: number|null, pending: number, unite: boolean, left: number|null } | null} lp rowLp(...)
@@ -76,6 +118,8 @@ export function rowLpTip(lp, cap = 10) {
*/
export function TeamPanel({ pub, myId, watching, bubbles, onWatch, compact = false, observe = null, self: selfLive = null, cap = 10, uniteLocal = null }) {
const [openPid, setOpenPid] = useState(null);
// the playerId whose avatar just called onWatch, so the other click of a double-click does not open the view again
const askedAt = useRef(/** @type {{ id: string|null, at: number }} */ ({ id: null, at: 0 }));
const phaseKey = `${pub?.phase}:${pub?.round}`;
useEffect(() => { setOpenPid(null); }, [phaseKey, watching, observe?.observing]);
const players = sortedPlayers(pub);
@@ -84,19 +128,39 @@ export function TeamPanel({ pub, myId, watching, bubbles, onWatch, compact = fal
// tinted like the official green; a plain green ring without the local art)
const team = teamFrameIds(pub, myId);
const frameArt = team.size ? localAsset('ui/battle', 'bg_team_border') : null;
const askedRecently = (p) => askedAt.current.id === p?.playerId && Date.now() - askedAt.current.at < AVATAR_CLICK_GAP_MS;
const noteAsked = (p) => { askedAt.current = { id: p.playerId, at: Date.now() }; };
const click = (p, self) => {
if (!observe) { onWatch(p); return; }
if (!observe) {
// your own row still goes home; a teammate already on screen (or just opened by this double-click) does not
if (self || teammateClickWatches({ watching, player: p, askedRecently: askedRecently(p) })) {
if (!self) noteAsked(p);
onWatch(p);
}
return;
}
if (self) { if (observe.observing) observe.onBack(); setOpenPid(null); return; }
const t = observe.canObserve(p) || {};
if (!t.fieldId) { setOpenPid(null); onWatch(p); return; } // the game screen toasts the reason
const view = observe.canObserve(p) || {};
if (!view.fieldId) {
setOpenPid(null);
if (!askedRecently(p)) { noteAsked(p); onWatch(p); } // the game screen toasts the reason; a double-click toasts once
return;
}
setOpenPid((cur) => (cur === p.playerId ? null : p.playerId));
};
const dblClick = (e, p, self) => {
if (!prepDblClickWatches({ phase: pub?.phase, self, observe, watching, player: p, askedRecently: askedRecently(p) })) return;
e.preventDefault();
setOpenPid(null);
noteAsked(p);
onWatch(p);
};
return html`<aside class=${cx('team', compact && 'team--compact')} aria-label=${t('同盟成员')}>
${players.map((p) => {
const self = p.playerId === myId;
const status = p.alive === false ? 'dead' : p.status;
const meta = STATUS_META[status] || STATUS_META.acting;
const watched = watching && (watching === p.fieldId || watching === `n:${p.playerId}`);
const watched = teammateFieldShown(watching, p);
const bubble = bubbles?.get(p.playerId);
const offline = p.connected === false && !p.isBot;
const open = !!observe && openPid === p.playerId && !self;
@@ -105,7 +169,7 @@ export function TeamPanel({ pub, myId, watching, bubbles, onWatch, compact = fal
const lp = rowLp(p, pub, self ? selfLive : null, { uniteLocal, cap });
const inTeam = team.has(p.playerId);
return html`<div key=${p.playerId} class=${cx('team__row', self && 'is-self', inTeam && 'is-team', watched && 'is-watched', p.alive === false && 'is-dead', open && 'is-open')}>
<button type="button" class="team__btn" onClick=${() => click(p, self)} title=${inTeam && !self ? `${title} · ${t('与你在同一战场')}` : title} aria-expanded=${observe && !self ? String(open) : undefined}>
<button type="button" class="team__btn" onClick=${() => click(p, self)} onDblClick=${(e) => dblClick(e, p, self)} title=${inTeam && !self ? `${title} · ${t('与你在同一战场')}` : title} aria-expanded=${observe && !self ? String(open) : undefined}>
<${PlayerAvatar} player=${p} self=${self} />
${inTeam ? html`<span class=${cx('team__frame', !frameArt && 'team__frame--plain')} style=${frameArt ? `--frame:url("${frameArt}")` : undefined} aria-hidden="true"></span>` : null}
<span class="team__seat num">P${(p.seat ?? 0) + 1}</span>
+2
View File
@@ -9,6 +9,7 @@ import { useEffect, useReducer } from '../../vendor/hooks.module.js';
import htm from '../../vendor/htm.module.js';
import { ERR_TEXT } from '../../../shared/constants.js';
import { t } from '../../../shared/i18n.js';
import { recordError } from '../diag.js';
// no shared static vnodes (see components.js hFresh: htm's static cache would retain unmounted DOM)
function hFresh(type, props, ...children) {
@@ -108,6 +109,7 @@ export function describeError(err) {
* @returns {number}
*/
export function toastError(err) {
recordError('request', err);
return toast(describeError(err), 'error');
}
+19 -16
View File
@@ -151,27 +151,30 @@ export function withLiveLayers(bonds, live) {
* A teammate's board as far as the screen shows it, in the m.private shape the bond popup's member list reads
* (gameLogic bondMembers): their operators on the field meta on screen (the prep scouting board, a server-run battle
* field's units) and, under client-side combat, the battle on screen's (`extra` = battle/runner.js ownerOps: the meta
* the runner publishes is taken before the operators deploy). Their hand is never sent — members count as owned only
* when they are on the field. Their operators keep the items the unit info carries (UnitInfo `items`), so a 变形同构体
* wearer is listed as a member of the bond it grants (gameLogic grantedBonds), and a 补位 unit its `standInFor` (the
* popup draws the stand-in). null without a field or battle.
* @param {any} field @param {string} ownerId @param {any[]|null} [extra] UnitInfo-like { kind, ownerId, defId, items?, standInFor? }
* the runner publishes is taken before the operators deploy). A prep scout also carries their bench, each unit tagged
* with its `area` (server prepFieldMeta): a 'hand' operator goes to the hand list, a 'temp' one to the temp list, so the
* popup counts them as the player's own popup does (GitHub #385, PR #387) — owned, not 在场; a hand operator counts for
* the bonds that count the 整备区 (投资人 远见 奇迹: memberHeadCount), the 5 temporary slots for none. A unit without
* an area (a battle's, an older server's) is on the board; a battle sends no bench. Their operators keep the items the
* unit info carries (UnitInfo `items`), so a 变形同构体 wearer is listed as a member of the bond it grants (gameLogic
* grantedBonds), and a 补位 unit its `standInFor` (the popup draws the stand-in). null without a field or battle.
* @param {any} field @param {string} ownerId @param {any[]|null} [extra] UnitInfo-like { kind, ownerId, defId, area?, items?, standInFor? }
*/
export function ownerBoard(field, ownerId, extra = null) {
const fromField = isObj(field) && Array.isArray(field.units) ? field.units : null;
const fromBattle = Array.isArray(extra) ? extra : null;
if (!fromField && !fromBattle) return null;
const board = [...(fromField || []), ...(fromBattle || [])]
.filter((u) => isObj(u) && u.ownerId === ownerId && u.kind === 'op' && typeof u.defId === 'string')
.map((u) => {
const p = Array.isArray(u.items) && u.items.length ? { kind: 'chess', id: u.defId, items: u.items.filter((x) => typeof x === 'string') } : { kind: 'chess', id: u.defId };
// 0.2.0 自选编队: a DIY slot's unit names its operator (UnitInfo diy) — the popup counts it for that operator's bonds
if (isObj(u.diy) && typeof u.diy.charId === 'string') p.diy = u.diy;
// 0.2.0 补位: a unit fighting as its stand-in says so (UnitInfo standInFor) — the popup draws that member as the stand-in
if (typeof u.standInFor === 'string' && u.standInFor) p.standInFor = u.standInFor;
return p;
});
return { board, hand: [], temp: [] };
const out = { board: /** @type {any[]} */ ([]), hand: /** @type {any[]} */ ([]), temp: /** @type {any[]} */ ([]) };
for (const u of [...(fromField || []), ...(fromBattle || [])]) {
if (!isObj(u) || u.ownerId !== ownerId || u.kind !== 'op' || typeof u.defId !== 'string') continue;
const p = Array.isArray(u.items) && u.items.length ? { kind: 'chess', id: u.defId, items: u.items.filter((x) => typeof x === 'string') } : { kind: 'chess', id: u.defId };
// 0.2.0 自选编队: a DIY slot's unit names its operator (UnitInfo diy) — the popup counts it for that operator's bonds
if (isObj(u.diy) && typeof u.diy.charId === 'string') p.diy = u.diy;
// 0.2.0 补位: a unit fighting as its stand-in says so (UnitInfo standInFor) — the popup draws that member as the stand-in
if (typeof u.standInFor === 'string' && u.standInFor) p.standInFor = u.standInFor;
out[u.area === 'hand' ? 'hand' : u.area === 'temp' ? 'temp' : 'board'].push(p);
}
return out;
}
/**
+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';
}