// 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), }; } /** * 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`${alt} 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
. * @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`
` : s.cls.length || s.term ? html` rtClassName(c)), s.term && 'rt-term')}>${s.text}` : 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` <${Img} src=${src} fallback=${html`${glyph}`} /> ${showTier && t && kind !== 'enemy' && kind !== 'token' ? html`<${TierChip} tier=${t} golden=${golden} size="sm" class="uthumb__tier" />` : null} ${kind === 'token' ? html`${tr('召唤')}` : null} ${si ? html`` : null} ${badge} `; } /** 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` ${title ? html`${title}` : null}`; } /** * Green LP tower + number. `pending` (> 0): the loss the running battle's leaks will cost at settlement (user * playtest #3 item 2) — the number shows value − pending in red followed by 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` 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" />`} /> ${ok ? Math.max(0, value - p) : '--'} ${p > 0 ? html`−${p}` : null} ${note ? html`${note}` : null} `; } /** Coin glyph (three stacked gold triangles). */ export function CoinGlyph({ class: cls }) { return html``; } /** 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` <${Img} src=${src} fallback=${html`${[...(b?.name || '?')][0]}`} /> `; } /** Band (strategy) icon. */ export function BandIcon({ bandId, class: cls, size = 'md' }) { const b = data.lookup('bands', bandId); return html` <${Img} src=${bandIconUrl(data.get('assets'), bandId)} fallback=${html`${[...(b?.name || '?')][0]}`} /> `; } /** 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` <${Img} src=${src} fallback=${player?.isBot ? html`<${Icon} name="robot" class="pavatar__bot" />` : html`${glyph}`} /> ${dead ? html`<${Icon} name="close" />` : null} ${left ? html`<${Icon} name="exit" />` : null} `; }