Files
ark-stronghold-protocol/public/js/ui/gameComponents.js
T
sganggs 221e6b8dd2 0.2.5-dev: community round 30 and follow-ups
- Stock: fixed trait gifts (缪尔赛思 / 变形者集群 变形同构体, 耶拉 不融冰, 蜜蜡 / 卡涅利安 迅捷作战粮, 诗怀雅 盟约之币) still occupy stock but are granted past the cap; an operator card already shown in the shop or a reward can be taken after the pool runs out and overdraws (GitHub #498, the owner's decision); item buys and item rewards keep the stock check. 洛洛 / 焰尾 sub-pools use the measured weights (PR #497 by @2321Robin; the occupancy idea of PR #496 by @2321Robin re-implemented differently).
- Boss pairs are drawn at random once per match and fixed to battle start; the Hidden Core reuses them; a leaver does not split a full pair (the owner's decision).
- 阿戈尔 in 联防 no longer re-applies the opening devour HP loss or a false revive; Touch's team medic stays for the match after its holder leaves (owner-approved, official lifetime unverified).
- Auto-skill ranges: 司霆惊蛰 S2, 斩业星熊 S3 against air, 予愿安洁莉娜 S3; 自在's post-rebirth invulnerability is also untargetable (#510); Horn / Clementia / catapult / 忍冬 first hit (PR #502 by @Xinglan233); 不融冰 / tear gas trigger on real damage.
- Bond layer-up sound in round start / draft / prep / battle (#482): one channel, a new cue replaces the old, no extra cooldown; a prep update that adds layers and activates a bond plays one cue.
- Client: multi-swing fix (#506), refresh single-flight (PR #507 by @robll-v1), LP tower keeps the settled value with a −N tick (PR #504 by @robll-v1), export with only 潜能 / 练度 changes (PR #503 by @Xinglan233), spectator rosters list DIY members (PR #505 by @Xinglan233), banned members beside bond tooltips (PR #494 by @YuriRestia), recovery action above the away overlay (PR #511 by @RainoutOvO), the /dev/sim-check.html consistency page (PR #492 by @Cloudnyco), Korean (PR #490 by @D-Roote) and English Victoria wording (PR #499 by @YuriRestia).
- golden: 45 of 287 scenarios move against 0.2.4 (roster 17, bonds 3, fields 6, matches 17, standins 2), each attributed in docs/history/0.2.5.md §30.9 / §30.16.
2026-10-12 14:07:08 +08:00

274 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Shared in-match building blocks (Preact + htm): data hook, images with fallbacks, rich text,
// unit thumbnails, LP tower, coin badge, official UI sprites. Styles: css/screens/game*.css.
import { useState, useMemo } from '../../vendor/hooks.module.js';
import { html, Icon, TierChip, Tooltip } from './components.js';
import { data, useData, localAsset } from '../data.js';
import { parseRichText, rtClassName } from './richText.js';
import { t as tr } from '../../../shared/i18n.js';
import {
uiUrl, chessAvatarUrl, chessPortraitUrl, itemIconUrl, tokenAvatarUrl, enemyIconUrl, bondIconUrl, bandIconUrl,
} from './assetUrls.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/** Data files the in-match screens use (`backups`: the 补位 stand-ins' bodies — cards, the board model, the detail card). */
export const GAME_FILES = ['config', 'assets', 'chess', 'bonds', 'items', 'bands', 'enemies', 'bosses', 'stages', 'tokens',
'choices', 'effects', 'garrisons', 'factions', 'local', 'backups'];
/**
* Load every in-match data file; returns lookups (sync, null until loaded).
* @returns {{ ready: boolean, m: any, config: any, chess: (id:string)=>any, bond: (id:string)=>any, item: (id:string)=>any,
* band: (id:string)=>any, enemy: (k:string)=>any, boss: (id:string)=>any, stage: (id:string)=>any, token: (id:string)=>any,
* effect: (id:string)=>any, garrison: (id:string)=>any, factions: any, choices: any, backups: any, list: (name:string)=>any[] }}
*/
export function useGameData() {
const ready = useData(...GAME_FILES);
// (config / factions / choices are read once per memo: a language switch reads them again in the new locale)
return useMemo(() => makeLookups(ready), [ready, data.locale()]);
}
/** A 自选 operator's summon record (data/backups.json `tokens`, 0.2.0), or null. */
export function diyToken(id) {
const t = typeof id === 'string' ? data.get('backups')?.tokens : null;
return t && Object.hasOwn(t, id) ? t[id] : null;
}
/** Non-hook lookups (for event handlers). */
export function makeLookups(ready = true) {
return {
ready,
m: data.get('assets'),
config: data.get('config'),
chess: (id) => data.lookup('chess', id),
bond: (id) => data.lookup('bonds', id),
item: (id) => data.lookup('items', id),
band: (id) => data.lookup('bands', id),
enemy: (k) => data.lookup('enemies', k),
boss: (id) => data.lookup('bosses', id),
stage: (id) => data.lookup('stages', id),
// (a 自选 operator's summons are data/backups.json tokens — 0.2.0, the hand piece of a placeable one)
token: (id) => data.lookup('tokens', id) || diyToken(id),
effect: (id) => data.lookup('effects', id),
garrison: (id) => data.lookup('garrisons', id),
factions: data.get('factions'),
choices: data.get('choices'),
backups: data.get('backups'),
list: (name) => data.list(name),
};
}
/**
* <img> that swaps to a fallback node when the URL is missing or fails.
* @param {{ src?: string|null, class?: string, alt?: string, fallback?: any, style?: string }} props
*/
export function Img({ src, class: cls, alt = '', fallback = null, style }) {
const [bad, setBad] = useState(null);
if (!src || bad === src) return fallback;
return html`<img class=${cls} src=${src} alt=${alt} draggable=${false} loading="lazy" style=${style} onError=${() => setBad(src)} />`;
}
/** Official UI sprite by 'group/key' with a fallback. */
export function Sprite({ k, class: cls, fallback = null }) {
return html`<${Img} src=${uiUrl(data.get('assets'), k)} class=${cx('sprite', cls)} fallback=${fallback} />`;
}
/**
* Official sprite from the local-client extraction (data/local-assets.json, DESIGN §13) with a fallback node when the
* manifest / entry is missing or the image fails.
* @param {{ group?: string, name: string, class?: string, fallback?: any }} props
*/
export function LocalSprite({ group = 'ui/battle', name, class: cls, fallback = null }) {
return html`<${Img} src=${localAsset(group, name)} class=${cx('lsprite', cls)} fallback=${fallback} />`;
}
/**
* Rich text (official markup) → spans. Newlines become <br>.
* @param {{ text: any, class?: string, as?: string }} props
*/
export function RichText({ text, class: cls, as = 'span' }) {
const segs = useMemo(() => parseRichText(text), [text]);
const Tag = as;
return html`<${Tag} class=${cx('rt', cls)}>${segs.map((s, i) => (s.br
? html`<br key=${i} />`
: s.cls.length || s.term
? html`<span key=${i} class=${cx(s.cls.map((c) => rtClassName(c)), s.term && 'rt-term')}>${s.text}</span>`
: s.text))}<//>`;
}
/** Golden (elite) chess? */
export const isGoldenPiece = (piece, chess) => !!(piece?.golden || chess?.isGolden || (typeof piece?.id === 'string' && /_b$/.test(piece.id) && piece.kind !== 'item'));
/**
* Square unit thumbnail for a piece / chess / item / token / enemy: art + tier chip + elite frame.
* @param {{ kind?: 'chess'|'item'|'token'|'enemy', id: string, golden?: boolean, size?: 'xs'|'sm'|'md'|'lg', tier?: number,
* showTier?: boolean, class?: string, dim?: boolean, badge?: any, title?: string, rec?: any }} props
* `rec`: the chess record to draw instead of the data's (a 自选 piece's composed record — 0.2.0, gameLogic/diy.js; a
* 补位 stand-in's — gameLogic/standIn.js: drawn with a small 「替补」 mark in the corner)
*/
export function UnitThumb({ kind = 'chess', id, golden, size = 'md', tier, showTier = true, class: cls, dim = false, badge = null, title, rec = null }) {
const m = data.get('assets');
let src = null;
let name = '';
let t = tier;
let si = null;
if (kind === 'chess') {
const c = rec || data.lookup('chess', id);
si = rec && typeof rec.standInFor === 'string' && rec.standInFor ? rec : null;
src = chessAvatarUrl(m, c);
name = c?.name || '';
t = t ?? c?.tier;
golden = golden ?? !!c?.isGolden;
} else if (kind === 'item') {
const it = data.lookup('items', id);
src = itemIconUrl(m, it);
name = it?.name || '';
t = t ?? it?.tier;
golden = golden ?? !!it?.isGolden;
} else if (kind === 'token') {
const tk = data.lookup('tokens', id);
src = tokenAvatarUrl(m, id);
name = tk?.name || '';
} else if (kind === 'enemy') {
const e = data.lookup('enemies', id);
src = enemyIconUrl(m, id);
name = e?.name || '';
}
const glyph = [...(name || '?')][0] || '?';
return html`<span class=${cx('uthumb', `uthumb--${size}`, `uthumb--${kind}`, golden && 'is-golden', dim && 'is-dim', t && `uthumb--t${Math.max(1, Math.min(6, t | 0))}`, cls)}
title=${title ?? name}>
<span class="uthumb__art">
<${Img} src=${src} fallback=${html`<span class="uthumb__glyph">${glyph}</span>`} />
</span>
${showTier && t && kind !== 'enemy' && kind !== 'token' ? html`<${TierChip} tier=${t} golden=${golden} size="sm" class="uthumb__tier" />` : null}
${kind === 'token' ? html`<span class="uthumb__tag">${tr('召唤')}</span>` : null}
${si ? html`<span class="uthumb__si" data-standin=${si.charId} aria-hidden="true">${tr('替补')}</span>` : null}
${badge}
</span>`;
}
/** Thumbnail for an m.private Piece. */
export function PieceThumb({ piece, size = 'md', class: cls, badge }) {
if (!piece) return null;
return html`<${UnitThumb} kind=${piece.kind === 'item' ? 'item' : piece.kind === 'token' ? 'token' : 'chess'} id=${piece.id}
golden=${!!piece.golden} tier=${piece.tier} size=${size} class=${cls} badge=${badge} />`;
}
/**
* A regular gear centred in the 24×24 box (even-odd path): `teeth` trapezoid teeth (width tipW at the tip circle
* rTip, rootW at the root circle rRoot; the first one straight up), arcs along both circles, and a round hole. The
* settings button's gear (user playtest #5 item 8): the hand-written path it replaces had teeth of different sizes
* and spacing on a rim that was not round (it bulged at 9 o'clock and was cut flat elsewhere) — a deformed icon.
* @param {{ teeth?: number, rTip?: number, rRoot?: number, tipW?: number, rootW?: number, rHole?: number }} [o]
*/
export function gearPath({ teeth = 8, rTip = 10, rRoot = 7.4, tipW = 3.2, rootW = 4.4, rHole = 3.3 } = {}) {
const c = 12;
const f = (v) => String(Math.round(v * 100) / 100 + 0);
const pt = (a, r) => `${f(c + r * Math.cos(a))} ${f(c + r * Math.sin(a))}`;
const at = Math.asin(tipW / 2 / rTip), ar = Math.asin(rootW / 2 / rRoot), step = (2 * Math.PI) / teeth;
let d = `M${pt(-Math.PI / 2 - ar, rRoot)}`;
for (let i = 0; i < teeth; i++) {
const t = -Math.PI / 2 + i * step;
d += `L${pt(t - at, rTip)}A${f(rTip)} ${f(rTip)} 0 0 1 ${pt(t + at, rTip)}L${pt(t + ar, rRoot)}`
+ `A${f(rRoot)} ${f(rRoot)} 0 0 1 ${pt(t + step - ar, rRoot)}`;
}
return `${d}ZM${f(c)} ${f(c - rHole)}A${f(rHole)} ${f(rHole)} 0 1 0 ${f(c)} ${f(c + rHole)}`
+ `A${f(rHole)} ${f(rHole)} 0 1 0 ${f(c)} ${f(c - rHole)}Z`;
}
/** Extra 24×24 glyphs used by the in-match HUD (original shapes). */
export const GLYPHS = Object.freeze({
gear: gearPath(),
eye: 'M12 5c5 0 9 4.5 10 7-1 2.5-5 7-10 7S3 14.5 2 12c1-2.5 5-7 10-7zm0 3a4 4 0 1 0 0 8 4 4 0 0 0 0-8zm0 2a2 2 0 1 1 0 4 2 2 0 0 1 0-4z',
emote: 'M8.2 2h7.6L22 8.2v7.6L15.8 22H8.2L2 15.8V8.2zM8.5 8.5v3h2v-3zm5 0v3h2v-3zM7.8 14.2a5.5 5.5 0 0 0 8.4 0l-1.5-1.3a3.5 3.5 0 0 1-5.4 0z',
dp: 'M13 2 4 13.5h6.2L9 22l11-12.5h-6.4z',
skull: 'M12 2a8.5 8.5 0 0 0-5 15.4V21h3v-2h1v2h2v-2h1v2h3v-3.6A8.5 8.5 0 0 0 12 2zM8.5 10a2 2 0 1 1 0 4 2 2 0 0 1 0-4zm7 0a2 2 0 1 1 0 4 2 2 0 0 1 0-4z',
bolt: 'M13 2 4 14h7l-1 8 9-12h-7z',
sell: 'M12 2 3 7v10l9 5 9-5V7zm-1 5h2v2.1c1.3.3 2.3 1.2 2.4 2.4h-2c-.1-.4-.6-.7-1.4-.7-.9 0-1.3.3-1.3.7 0 .4.3.6 1.6.9 1.9.4 3.2 1 3.2 2.6 0 1.2-1 2.1-2.5 2.4V19h-2v-1.6c-1.4-.3-2.5-1.2-2.6-2.6h2c.1.5.7.9 1.6.9 1 0 1.5-.3 1.5-.8 0-.4-.4-.6-1.8-.9-1.8-.4-3-1-3-2.5 0-1.2 1-2 2.3-2.3z',
trash: 'M9 2h6l1 2h4v2H4V4h4zM5 7h14l-1.2 15H6.2zm4 3v9h2v-9zm4 0v9h2v-9z',
back: 'M10 5 3 12l7 7 1.4-1.4L6.8 13H21v-2H6.8l4.6-4.6z',
flag: 'M5 2h2v20H5zm3 1h11l-2.5 4.5L19 12H8z',
target: 'M11 2h2v3.1A7 7 0 0 1 18.9 11H22v2h-3.1A7 7 0 0 1 13 18.9V22h-2v-3.1A7 7 0 0 1 5.1 13H2v-2h3.1A7 7 0 0 1 11 5.1zm1 5a5 5 0 1 0 0 10 5 5 0 0 0 0-10zm0 3a2 2 0 1 1 0 4 2 2 0 0 1 0-4z',
});
/** Extra HUD glyph. */
export function GIcon({ name, class: cls, title }) {
const d = GLYPHS[name];
if (!d) return html`<${Icon} name=${name} class=${cls} title=${title} />`;
return html`<svg class=${cx('icon', cls)} viewBox="0 0 24 24" width="1em" height="1em" aria-hidden=${title ? undefined : 'true'} focusable="false">
${title ? html`<title>${title}</title>` : null}<path d=${d} fill-rule="evenodd" /></svg>`;
}
/**
* Green LP tower + number. `pending` (> 0): the loss the running battle's leaks will cost at settlement (user
* playtest #3 item 2) — the number keeps the settled LP (owner-approved #479 / #504),
* shown in red with a −N tick that pops again on every change (keyed); `note` a
* small tag after it (联防中); `tip` the title text.
* @param {{ value: any, size?: 'sm'|'md'|'lg', class?: string, tone?: string|null, pending?: number, note?: string|null, tip?: string|null }} props
*/
export function LpTower({ value, size = 'md', class: cls, tone, pending = 0, note = null, tip = null }) {
const ok = Number.isFinite(value);
const p = ok && Number(pending) > 0 ? Math.min(value, Math.trunc(Number(pending))) : 0;
return html`<span class=${cx('lp', `lp--${size}`, tone && `lp--${tone}`, p > 0 && 'is-pending', cls)} title=${tip || tr('目标生命值')}
data-pending=${p > 0 ? p : null}>
<${Sprite} k="hudPanel/icon_hp" class="lp__icon" fallback=${html`<${Icon} name="rook" class="lp__icon" />`} />
<b class="num lp__val">${ok ? Math.max(0, value) : '--'}</b>
${p > 0 ? html`<span key=${p} class="lp__pend num" aria-label=${tr('结算时扣除 {p}', { p })}>−${p}</span>` : null}
${note ? html`<span class="lp__note">${note}</span>` : null}
</span>`;
}
/** Coin glyph (three stacked gold triangles). */
export function CoinGlyph({ class: cls }) {
return html`<svg class=${cx('coin-glyph', cls)} viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 3.5 16.2 10H7.8z" /><path d="M7 11.5 11.2 18H2.8z" /><path d="M17 11.5 21.2 18h-8.4z" />
</svg>`;
}
/** Bond glyph image (white mask tinted by CSS) or first-letter fallback. */
export function BondGlyph({ bondId, class: cls }) {
const b = data.lookup('bonds', bondId);
const src = bondIconUrl(data.get('assets'), bondId);
return html`<span class=${cx('bglyph', cls)} title=${b?.name || bondId}>
<${Img} src=${src} fallback=${html`<span class="bglyph__txt">${[...(b?.name || '?')][0]}</span>`} />
</span>`;
}
/** Band (strategy) icon. */
export function BandIcon({ bandId, class: cls, size = 'md' }) {
const b = data.lookup('bands', bandId);
return html`<span class=${cx('bandicon', `bandicon--${size}`, cls)} title=${b?.name || ''}>
<${Img} src=${bandIconUrl(data.get('assets'), bandId)} fallback=${html`<span class="bandicon__txt">${[...(b?.name || '?')][0]}</span>`} />
</span>`;
}
/** Item (equipment) icon with tier chip. */
export function ItemIcon({ itemId, class: cls, size = 'md', showTier = true }) {
return html`<${UnitThumb} kind="item" id=${itemId} size=${size} class=${cls} showTier=${showTier} />`;
}
/** Tooltip-wrapped help text with rich markup. */
export function RichTip({ text, children, placement = 'top' }) {
return html`<${Tooltip} text=${text ? html`<${RichText} text=${text} />` : null} placement=${placement}>${children}<//>`;
}
/**
* Seat-coloured player avatar: band icon when a band is picked, else glyph/robot.
* @param {{ player: any, size?: 'sm'|'md', self?: boolean, class?: string }} props
*/
export function PlayerAvatar({ player, size = 'md', self = false, class: cls }) {
const src = player?.bandId ? bandIconUrl(data.get('assets'), player.bandId) : null;
const glyph = [...(player?.name || '').trim()][0] || '?';
const dead = player?.alive === false || player?.status === 'dead';
const left = player?.status === 'left';
const hue = [162, 196, 38, 280][((player?.seat | 0) % 4 + 4) % 4];
return html`<span class=${cx('pavatar', `pavatar--${size}`, self && 'is-self', dead && 'is-dead', left && 'is-left', player?.isBot && 'is-bot',
player?.connected === false && !player?.isBot && 'is-offline', cls)} style=${`--seat-hue:${hue}`}>
<span class="pavatar__img">
<${Img} src=${src} fallback=${player?.isBot ? html`<${Icon} name="robot" class="pavatar__bot" />` : html`<span class="pavatar__glyph">${glyph}</span>`} />
</span>
${dead ? html`<span class="pavatar__x" aria-label=${tr('已淘汰')}><${Icon} name="close" /></span>` : null}
${left ? html`<span class="pavatar__door" aria-label=${tr('已离开')}><${Icon} name="exit" /></span>` : null}
</span>`;
}