Merge branch 'master' into fix/spine-start-anim
This commit is contained in:
577 files changed
+43650
-10558
No files matched your search
+12
-2
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
@@ -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
@@ -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
@@ -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() });
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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; }
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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
@@ -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). */
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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') : []);
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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} />`;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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). */
|
||||
|
||||
@@ -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('取消')}<//>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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' }),
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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). */
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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)) };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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() });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// Local listening preference, shared by roster and DIY details; deliberately absent from room.loadout.
|
||||
import { html } from './components.js';
|
||||
import { useSettings, updateSettings } from './settings.js';
|
||||
import { t } from '../../../shared/i18n.js';
|
||||
|
||||
const VOICE_LANG_NAMES = { cn: '中文', jp: '日本語' }; // i18n-ignore
|
||||
|
||||
export function OperatorVoice({ charId }) {
|
||||
const settings = useSettings();
|
||||
if (!charId) return null;
|
||||
const value = settings.voiceOverrides[charId] || '';
|
||||
const change = (lang) => {
|
||||
const voiceOverrides = { ...settings.voiceOverrides };
|
||||
if (lang) voiceOverrides[charId] = lang;
|
||||
else delete voiceOverrides[charId];
|
||||
updateSettings({ voiceOverrides });
|
||||
};
|
||||
return html`<label class="lo-voice" data-voice-char=${charId}>
|
||||
<span>${t('此干员语音')}</span>
|
||||
<span class="lo-select"><select aria-label=${t('此干员语音')} value=${value} onChange=${(e) => change(e.currentTarget.value)}>
|
||||
<option value="">${t('跟随全局')}</option><option value="cn">${VOICE_LANG_NAMES.cn}</option><option value="jp">${VOICE_LANG_NAMES.jp}</option>
|
||||
</select></span>
|
||||
<small>${t('仅保存在此浏览器,缺失的日语语音会回退到中文。')}</small>
|
||||
</label>`;
|
||||
}
|
||||
@@ -0,0 +1,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
@@ -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}`;
|
||||
}
|
||||
@@ -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
@@ -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} />`}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// Browser-local dub preferences. Every variant of an operator shares its charId; absent entries follow the global dub.
|
||||
export const VOICE_LANGS = Object.freeze(['cn', 'jp']);
|
||||
export function sanitizeVoiceOverrides(raw) {
|
||||
const out = {};
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
|
||||
for (const [id, lang] of Object.entries(raw).slice(0, 512)) {
|
||||
if (/^char_[0-9]+_[a-z0-9]+$/.test(id) && id.length <= 80 && VOICE_LANGS.includes(lang)) out[id] = lang;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
export function voiceLangFor(charId, globalLang, overrides) {
|
||||
const own = Object.hasOwn(overrides || {}, charId) ? overrides[charId] : null;
|
||||
return VOICE_LANGS.includes(own) ? own : globalLang === 'jp' ? 'jp' : 'cn';
|
||||
}
|
||||
Reference in new issue
Block a user