feedback3: merge WJ — 高台 only for elite 歌蕾蒂娅 with HOK-Y, live prep watch, silenced 折射, grant toasts, emote timeout glyph

This commit is contained in:
sganggs committed 2026-10-04 21:26:46 +08:00
commit c9dd2a6392
41 files changed
+700 -216

No files matched your search

+83 -17
View File
@@ -6,7 +6,10 @@
// GAME_FILES) in the background once the player is in a room, and the match screen waits for them, so no text of the
// match UI ever appears late. A transient failure (network error, HTTP 5xx) is retried twice (RETRY_DELAYS_MS); files
// that stay unavailable (404, repeated failures, bad JSON) resolve to `null` and are reported once on the console — the
// UI must degrade gracefully while data is being generated.
// UI must degrade gracefully while data is being generated. The emote manifests (`local`, `assets`) are the exception:
// one attempt is abandoned after ART_MANIFEST_TIMEOUT_MS (GitHub #99) and reported `missing` at once, so the 交流 button
// draws its glyph instead of staying blank; a later retry that succeeds replaces the glyph. Other files stay `loading`
// across their retries.
//
// Each file is indexed tolerantly so the getters work whether a file is
// - an array of records carrying an id field (id / chessId / bondId / itemId / …),
@@ -78,6 +81,16 @@ export function buildIndex(name, json) {
/** Waits (ms) before retrying a data file whose download failed transiently (network error, HTTP 5xx / 408 / 429). */
export const RETRY_DELAYS_MS = Object.freeze([600, 2000]);
/**
* How long one attempt at an emote manifest (`local`, `assets`) may hang before it counts as a failure.
* [ASSUMED] 8 s: long enough for a slow link, short enough that the 交流 button does not stay blank for the session
* (GitHub #99). Other files are not on this clock.
*/
export const ART_MANIFEST_TIMEOUT_MS = 8000;
/** Manifests the emote button waits on. A hang here used to leave every cell blank (GitHub #99). */
const ART_MANIFESTS = new Set(['local', 'assets']);
/**
* A failed load worth retrying: the request itself failed (network error) or the server answered 5xx / 408 / 429 — not a
* definite 4xx (the file is not there) nor a delivered file that is not valid JSON (`badJson`).
@@ -92,14 +105,19 @@ const transientFailure = (err) => {
* Create a data store bound to a fetch implementation (injectable for tests).
* A file is downloaded once per page (the texts of the game are static data, never fetched again during a match —
* user playtest #3 item 9); a transient failure is retried (RETRY_DELAYS_MS) while the file stays 'loading', so a
* network hiccup does not leave the texts of a whole session missing.
* @param {{ fetch?: typeof fetch, base?: string, retryDelays?: number[], wait?: (ms: number) => Promise<void> }} [opts]
* network hiccup does not leave the texts of a whole session missing. `local` and `assets` are reported `missing` on
* the first failure or timeout (the emote glyph) and stay that way through a retry; a later success is `ready`.
* @param {{ fetch?: typeof fetch, base?: string, retryDelays?: number[], wait?: (ms: number) => Promise<void>, timeoutMs?: number, setTimeout?: typeof setTimeout, clearTimeout?: typeof clearTimeout }} [opts]
* `timeoutMs` 0 turns the art-manifest clock off. `setTimeout` / `clearTimeout` let a test fire that clock.
*/
export function createDataStore(opts = {}) {
const base = opts.base ?? '/data/';
const doFetch = opts.fetch || ((...a) => globalThis.fetch(...a));
const retryDelays = Array.isArray(opts.retryDelays) ? opts.retryDelays : RETRY_DELAYS_MS;
const wait = opts.wait || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
const timeoutMs = opts.timeoutMs === undefined ? ART_MANIFEST_TIMEOUT_MS : Number(opts.timeoutMs);
const armTimer = opts.setTimeout || ((fn, ms) => setTimeout(fn, ms));
const disarmTimer = opts.clearTimeout || ((id) => clearTimeout(id));
/** @type {Map<string, { status: 'loading'|'ready'|'missing', promise: Promise<any>, value: any, index: Map<string, any>|null }>} */
const entries = new Map();
const listeners = new Set();
@@ -113,40 +131,88 @@ export function createDataStore(opts = {}) {
const urlFor = (name) => base + (DATA_FILES[name] || `${name}.json`);
/**
* One attempt at a file. Art manifests reject with a transient `{ timeout: true }` when the clock fires; a response
* that arrives after that is ignored (the retry is a new request). [ASSUMED]
*/
function readJson(name) {
const run = async () => {
const res = await doFetch(urlFor(name), { cache: 'no-cache' });
if (!res || !res.ok) throw Object.assign(new Error(`HTTP ${res ? res.status : '???'}`), { status: res ? res.status : null });
try {
return await res.json();
} catch (err) {
throw Object.assign(err instanceof Error ? err : new Error(String(err)), { badJson: true });
}
};
if (!ART_MANIFESTS.has(name) || !(timeoutMs > 0)) return run();
let timer = null;
let done = false;
const finish = () => {
if (done) return false;
done = true;
if (timer != null) disarmTimer(timer);
return true;
};
const timed = new Promise((_, reject) => {
timer = armTimer(() => {
if (!finish()) return;
reject(Object.assign(new Error('timeout'), { timeout: true }));
}, timeoutMs);
});
const req = run().then(
(json) => { finish(); return json; },
(err) => { finish(); throw err; },
);
return Promise.race([req, timed]);
}
function load(name) {
if (typeof name !== 'string' || !/^[A-Za-z0-9_-]+$/.test(name)) return Promise.resolve(null);
const cur = entries.get(name);
if (cur) return cur.promise;
const entry = { status: 'loading', promise: null, value: null, index: null };
const art = ART_MANIFESTS.has(name);
entry.promise = (async () => {
let toldMissing = false;
for (let attempt = 0; ; attempt++) {
try {
const res = await doFetch(urlFor(name), { cache: 'no-cache' });
if (!res || !res.ok) throw Object.assign(new Error(`HTTP ${res ? res.status : '???'}`), { status: res ? res.status : null });
let json;
try { json = await res.json(); } catch (err) { throw Object.assign(err instanceof Error ? err : new Error(String(err)), { badJson: true }); }
entry.value = json;
entry.value = await readJson(name);
entry.status = 'ready';
break;
} catch (err) {
// a transient failure is tried again (still 'loading'), unless the load was superseded meanwhile
if (transientFailure(err) && attempt < retryDelays.length && entries.get(name) === entry) {
const current = entries.get(name) === entry;
// Decided before notify. Invalidating inside that notify leaves `again` true; the wait then sees
// the superseded entry and stops, so this attempt does not fetch again.
const again = transientFailure(err) && attempt < retryDelays.length && current;
// Emote art: glyph now. Stay `missing` through the retry wait — flipping back to `loading` blanks the button.
if (art && current && entry.status !== 'missing') {
entry.value = null;
entry.index = null;
entry.status = 'missing';
toldMissing = true;
notify(name);
}
if (again) {
await wait(retryDelays[attempt]);
if (entries.get(name) === entry) continue;
entry.status = 'missing'; // superseded by invalidate() meanwhile: the new load reports for itself
break;
}
if (!warned.has(name)) {
warned.add(name);
console.warn(`[data] ${urlFor(name)} unavailable (${err?.message || err}); continuing without it`);
if (current) {
if (!warned.has(name)) {
warned.add(name);
console.warn(`[data] ${urlFor(name)} unavailable (${err?.message || err}); continuing without it`);
}
entry.value = null;
entry.index = null;
entry.status = 'missing';
}
entry.value = null;
entry.status = 'missing';
break;
}
}
// A load superseded by invalidate() must not announce itself (its entry is no longer cached).
if (entries.get(name) === entry) notify(name);
// An art manifest already announced `missing` does not announce that same status again.
if (entries.get(name) === entry && !(toldMissing && entry.status === 'missing')) notify(name);
return entry.value;
})();
entries.set(name, entry);
+2 -1
View File
@@ -12,7 +12,7 @@
// (left_prepare / *_boss_prepare) clear of the folded shop's band (public
// issue #5: folding the shop did not grow the board)
// view.setPrep(privateState, { editable, canPlace }) hand/temp/board pieces; editable enables drag & drop; a
// new board piece flashes (fx.deploy); a merge's elite — on the tile of
// new board piece flashes (fx.deploy), and so does a card that just arrived in the hand or temp; a merge's elite — on the tile of
// the deployed copy it replaced, or on its bench slot — gets the
// promotion cue instead (render/promote.js, fx.promote)
// view.enterBattle(fieldMeta) m.field { fieldId, kind, rect, stageId, units: [UnitInfo] } — each
@@ -975,6 +975,7 @@ export async function createFieldView(host, options = {}) {
promotions.push({ uid: e.uid, id: e.piece.id, area: e.area, row: e.row ?? null, col: e.col ?? null, idx: e.idx ?? null, copies: promoFrom.get(e.uid).length });
if (promotions.length > 20) promotions.shift();
} else if (e.area === 'board' && prevBoard.size && !prevBoard.has(e.uid)) { v.onDeploy?.(); fx.deploy(v); }
else if (before.length && (e.area === 'hand' || e.area === 'temp') && !before.some((g) => g.uid === e.uid)) fx.deploy(v);
} else {
const prevHome = v._home;
const moved = !prevHome || prevHome.x !== w.x || prevHome.y !== w.y || prevHome.z !== w.z;
+14
View File
@@ -129,6 +129,8 @@ export const STATUS_ICON = Object.freeze({
substitute: 'doll',
// 禁疗 (sim status 'healFree': 史尔特尔's 余烬 — no heal reaches her until she leaves)
healFree: 'healFree',
// 折射 (sim buff 'ab:refraction', visible while the RES bonus is on). The tail of 'ab:refraction' hits this key.
refraction: 'refraction',
});
/** Keyword fallbacks for namespaced / content status keys ('ab:frost', 'reed2:scorch', 'skill:shotst_shred' …). */
@@ -140,6 +142,18 @@ const STATUS_GUESS = [
[/neural/i, 'neural'], [/necro|apopt|erosion/i, 'necrosis'],
];
/**
* The 折射 icon is not drawn while the unit is silenced: the RES bonus is already off (enemies.js refraction)
* and the status must not keep looking active. Other icons stay.
* @param {string} key a b.ev status key
* @param {Set<string>|string[]|null} statuses
*/
export function statusIconSuppressed(key, statuses) {
if (key !== 'ab:refraction' && key !== 'refraction') return false;
if (!statuses) return false;
return typeof statuses.has === 'function' ? statuses.has('silence') : Array.isArray(statuses) && statuses.includes('silence');
}
/** Icon key (textures STATUS_KEYS) for a b.ev status key or flag name; null when it has no icon. */
export function statusIconKey(key) {
if (typeof key !== 'string' || !key) return null;
+8 -1
View File
@@ -835,7 +835,7 @@ export function fxAtlas() {
return _fx;
}
export const STATUS_KEYS = ['stun', 'freeze', 'cold', 'stealth', 'shield', 'fragile', 'sleep', 'invuln', 'silence', 'slow', 'bind', 'fear', 'weaken', 'levitate', 'taunt', 'burn', 'neural', 'necrosis', 'blocked', 'skill', 'doll', 'healFree'];
export const STATUS_KEYS = ['stun', 'freeze', 'cold', 'stealth', 'shield', 'fragile', 'sleep', 'invuln', 'silence', 'slow', 'bind', 'fear', 'weaken', 'levitate', 'taunt', 'burn', 'neural', 'necrosis', 'blocked', 'skill', 'doll', 'healFree', 'refraction'];
function drawStatusIcon(c, key, x, y, s) {
const cx = x + s / 2, cy = y + s / 2;
@@ -883,6 +883,13 @@ function drawStatusIcon(c, key, x, y, s) {
c.fillStyle = '#d8e0ff'; c.font = 'bold 14px sans-serif'; c.textAlign = 'center'; c.textBaseline = 'middle'; c.fillText('Zz', cx, cy + 1);
break;
}
case 'refraction': {
// 折射: a split beam. Drawn only while the buff is on; units.js drops it under silence.
disc('rgba(12,36,48,0.92)', '#7ee0ff');
c.strokeStyle = '#d8f7ff'; c.lineWidth = 2;
c.beginPath(); c.moveTo(cx - 8, cy + 6); c.lineTo(cx - 1, cy - 8); c.lineTo(cx + 8, cy + 6); c.moveTo(cx + 2, cy - 2); c.lineTo(cx + 9, cy - 8); c.stroke();
break;
}
case 'silence': {
disc('rgba(45,20,55,0.92)', '#e08bff');
c.strokeStyle = '#f0c4ff'; c.lineWidth = 2.2;
+2 -1
View File
@@ -68,7 +68,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 { COLORS, TIER_COLORS, ENEMY_FRAME, UNIT, PROJ, statusIconKey } from './style.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';
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
@@ -1359,6 +1359,7 @@ export class UnitView {
// a burst's lock ('burnBurst', 'neuralBurst' … — the 爆发冷却) is shown by the element gauge row under the bars
// (b.snap `elem`); only a feed without gauges (an older recording) shows it as a status
if (this.el && k.endsWith('Burst')) continue;
if (statusIconSuppressed(k, this.statuses)) continue; // 折射 while silenced
const icon = statusIconKey(k);
// flag-driven states are authoritative (a stale 'stun' status must not outlive the flag)
if (!icon || icon === 'stun' || icon === 'freeze' || icon === 'sleep' || icon === 'stealth' || icon === 'invuln') continue;
+3 -2
View File
@@ -422,8 +422,9 @@ function MatchScreen() {
view.pushEvents(early);
audio.handleBattleEvents(early.filter((e) => e[0] === 'spawn'));
}
if (!earlySnap && (field.prep || !combat) && Array.isArray(field.units) && field.units.length) {
// prep scouting (read-only teammate board): no snapshots follow, so place the units once
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
// above and places the units again, including an empty board (GitHub #87).
view.pushSnapshot({
fieldId: field.fieldId, gt: 0,
units: field.units.filter((u) => u && u.id != null).map((u) => [u.id, Number(u.x) || 0, Number(u.y) || 0, u.maxHp || 1, u.maxHp || 1, 0, 0, 0, 0]),
+17
View File
@@ -0,0 +1,17 @@
// Enemy-card ability lines (detailPanel EnemyDetail). A line whose data format is SILENCE
// (折射: `<$ba.refraction>折射</>`, format "SILENCE") is off while the enemy is silenced: the card greys it.
// The sim already drops the RES bonus; this is only the line.
/**
* @param {any[]|null} abilities enemies.json abilities
* @param {boolean} silenced live unit (`unitStatsEntry.silenced`)
* @returns {Array<{ text: string, off: boolean }>}
*/
export function abilityRows(abilities, silenced) {
const list = Array.isArray(abilities) ? abilities : [];
return list.map((a) => {
const text = typeof a === 'string' ? a : (a && (a.textRaw || a.text)) || '';
const off = !!(silenced && a && typeof a === 'object' && a.format === 'SILENCE');
return { text, off };
});
}
+2 -1
View File
@@ -35,6 +35,7 @@ import { html, Icon, TierChip, MicroLabel, Button, confirmDialog, useTicker } fr
import { Img, RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.js';
import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, briefingBondTip, pieceBondIds, grantedBonds, morphPairings } 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';
import { attackRangeGrid } from '../../../shared/loadoutRecord.js';
import { SKILL_SUMMON_START_DEPLOY } from '../../../shared/constants.js';
@@ -522,7 +523,7 @@ function EnemyDetail({ enemy, snapHp, count, live = null }) {
</div>
${imm.length ? html`<p class="dhint"><${Icon} name="shield" />免疫:${imm.join('、')}</p>` : null}
${Array.isArray(enemy.abilities) && enemy.abilities.length ? html`<${Section} title="能力" micro="ABILITIES">
<ul class="dabil">${enemy.abilities.map((a, i) => html`<li key=${i}><${RichText} text=${typeof a === 'string' ? a : a.textRaw || a.text} /></li>`)}</ul>
<ul class="dabil">${abilityRows(enemy.abilities, !!live?.silenced).map((a, i) => html`<li key=${i} class=${a.off ? 'is-off' : null}><${RichText} text=${a.text} /></li>`)}</ul>
<//>` : enemy.descRaw || enemy.desc ? html`<${Section} title="说明"><${RichText} as="p" text=${enemy.descRaw || enemy.desc} class="dtext" /><//>` : null}`;
}
+4 -3
View File
@@ -4,8 +4,9 @@
//
// EmoteArt the emote picture: the local-client art (data/local-assets.json → emoticon/<dir>/<picId>) first, else
// the copy setup downloads from the public mirror (data/assets.json → ui['emoticon/<dir>/<picId>'];
// GitHub issue #42), each tried in turn when one fails to load; a neutral glyph when neither is there,
// an empty box while a manifest is still loading.
// GitHub issue #42), each tried in turn when one fails to load; a neutral glyph when neither is there.
// An empty box only while a manifest is still in flight; a timeout or a failed manifest (GitHub #99)
// uses that glyph, and a later success replaces it.
// EmoteBubble the pop bubble beside the sender's avatar in the team panel (official emoji_bubble_bkg: a dark rounded
// square with a tail pointing left + the icon only); pop-in, 3 s, fade. The parent keys it by the emote's
// seq so a newer emote replaces the old one and pops again, and passes the arrival time (`at`) so a
@@ -68,7 +69,7 @@ export function emoteArtUrl(id) {
return emoteArtUrls(id)[0] || null;
}
/** True while the local-art manifest or the asset manifest has not settled (EmoteArt shows an empty box meanwhile). */
/** True while local or assets is still in flight (idle or loading). A timeout or a failure is `missing`: the glyph. */
const artManifestsPending = () => ['local', 'assets'].some((n) => { const st = data.status(n); return st === 'loading' || st === 'idle'; });
/** Official emote UI sprite (ui/battle: emoji_bubble_bkg, emoji_bkg, emoji_cell_bkg, emoji_btn, emoji_btn_disable). */
+9 -6
View File
@@ -4,8 +4,8 @@
// light legal tiles while dragging; the server stays authoritative and may still refuse a move.
//
// Board = own normal field (GEO.FIELD rows 9–12, cols 2–10). Melee chess stand on `melee` deploy tiles
// (LOW, buildable ALL/MELEE) — a 钩索师 / 推击手 ("可以放置于远程位", chess.json `placement` 'all') on any deploy
// tile, the 高台 included (piecePosition 'ALL'); ranged chess on `melee ∪ rangedOnly` (stages.json → deployTiles.normal,
// (LOW, buildable ALL/MELEE) — elite 歌蕾蒂娅 carrying HOK-Y (shared/highGround.js, the player's loadout) on any
// deploy tile, the 高台 included (piecePosition 'ALL'); ranged chess on `melee ∪ rangedOnly` (stages.json → deployTiles.normal,
// derived from the tile legend when missing — the legend's `buildable` is the effective type: 深水区 tile_deepsea
// refuses deployment, PRTS 深水区 地形信息 "拒绝部署(待补充)", player report #3 after 0.1.0). Tokens follow their
// own `position`; a summon whose text reads "只能部署在召唤者攻击范围内" (tokens.json `ownerRange`: 伺夜's 狼群,
@@ -24,6 +24,7 @@
import { GEO, PHASE, UF } from '../../../shared/constants.js';
import { resolveLoadout, loadoutOptions, MODULE_NONE } from '../../../shared/protocol.js';
import { resolveRecordLoadout, loadoutRecord, attackRangeGrid } from '../../../shared/loadoutRecord.js';
import { meleeOnHighGround } from '../../../shared/highGround.js';
import { rangeTiles, pieceDir } from './facing.js';
import { layoutPen } from '../render/pen.js';
import { BOSS_ROW_SHIFT, MAX_COL } from '../render/prepfield.js';
@@ -1041,15 +1042,17 @@ export function placementContext({ priv, stage, editable, field = 'normal', getC
}
/**
* Deploy position ('MELEE'|'RANGED'|'ALL') of a chess/token piece, or null for items. A MELEE operator whose branch
* trait reads "可以放置于远程位" (钩索师, 推击手: chess.json `placement` 'all') is 'ALL': any deployable tile, the 高台
* included (server/match/board.js positionClass; GitHub issue #32 item 4, DESIGN §22.6 [ASSUMED]).
* Deploy position ('MELEE'|'RANGED'|'ALL') of a chess/token piece, or null for items. Elite 歌蕾蒂娅 carrying HOK-Y
* (the viewer's loadout, shared/highGround.js) is 'ALL': any deployable tile, the 高台 included
* (server/match/board.js placeClass; owner's decision 2026-10-04). Every other MELEE chess is ground-only.
*/
export function piecePosition(ctx, piece) {
if (!isObj(piece)) return null;
if (piece.kind === 'chess') {
const rec = ctx.getChess(piece.id);
if (rec?.placement === 'all') return 'ALL';
let moduleId = null;
try { moduleId = resolveLoadout(ctx.priv?.loadout ?? null, rec, ctx.getChess)?.moduleId ?? null; } catch { moduleId = null; }
if (meleeOnHighGround(rec, moduleId)) return 'ALL';
return rec?.position === 'MELEE' ? 'MELEE' : 'RANGED';
}
// tokens: MELEE → ground only; RANGED / ALL → any deployable tile