// Design-system components (Preact + htm, no build step). Styles live in css/components.css. // // Exports: html (bound htm), Icon, Button, Panel, MicroLabel, Chevrons, HexBadge, TierChip, // BondDisc, SevenSeg, Countdown, Modal, confirmDialog/alertDialog + DialogHost, Tooltip + // TooltipLayer, ProgressBar, Tabs, Spinner, AvatarFrame, PhaseBanner, ResultDialog, PingPill, DifficultyTag, // DifficultyIcon, TextField, UiHosts (mount once: dialogs + tooltips), useTicker, secondsLeft/hasDeadline, // roman(), doctorNo(). // // Look: research 06 §11–§12 — near-black green-grey panels, 1px lines, mint accents, bracket // corners, hexagon badges, roman tier chips, bond discs with segmented rings, 7-segment countdown // (orange ≤10 s), chevron phase banners. Motion 150–250 ms. All sizes are rem (1rem = 100 design // px at 1920×1080, see css/theme.css). import { h, Fragment } from '../../vendor/preact.module.js'; import { useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState } from '../../vendor/hooks.module.js'; import htm from '../../vendor/htm.module.js'; import { DIFFICULTY_NAMES, DIFFICULTY_COLORS } from '../../../shared/constants.js'; import { t } from '../../../shared/i18n.js'; import { serverNow } from '../store.js'; import { data, useData, localAsset } from '../data.js'; import { uiUrl } from './assetUrls.js'; /** * h() for htm with its static-subtree cache switched off. htm keeps the vnode it built for a fully static subtree in * the template's op list and hands the SAME object out on every render; Preact mounts that shared vnode (it gets * its DOM node and parent chain written into it), so after the screen unmounts the module-level template cache * still points at the detached DOM, its listeners and the old component tree — whole stale screens pile up over a * session. `this[0] = 3` marks the element dynamic (htm's documented opt-out), so every render gets fresh vnodes. */ export function hFresh(type, props, ...children) { if (this && typeof this === 'object') this[0] = 3; return h(type, props, ...children); } /** Tagged-template JSX replacement bound to Preact's h() (no shared static vnodes, see hFresh). */ export const html = htm.bind(hFresh); export { h, Fragment }; const cx = (...parts) => parts.flat().filter(Boolean).join(' '); // ---- icons --------------------------------------------------------------------------------------- /** 24×24 glyph paths (original, simple geometric shapes). `eo` = even-odd fill rule. */ export const ICONS = { check: { d: 'M9.5 16.2 5.3 12l-1.4 1.4 5.6 5.6L20.1 8.4 18.7 7z' }, close: { d: 'M6.4 5 5 6.4 10.6 12 5 17.6 6.4 19 12 13.4 17.6 19 19 17.6 13.4 12 19 6.4 17.6 5 12 10.6z' }, exit: { d: 'M20 3H10v2h8v14h-8v2h10zM8.4 7.4 7 6l-6 6 6 6 1.4-1.4L4.8 13H15v-2H4.8z' }, crown: { d: 'M3 7l4.6 4.2L12 4l4.4 7.2L21 7l-1.8 10H4.8zM5 19h14v2H5z' }, robot: { d: 'M11 2h2v3h4a3 3 0 0 1 3 3v8a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V8a3 3 0 0 1 3-3h4zM8.5 9.5a1.75 1.75 0 1 0 0 3.5 1.75 1.75 0 0 0 0-3.5zm7 0a1.75 1.75 0 1 0 0 3.5 1.75 1.75 0 0 0 0-3.5zM9 15v1.6h6V15zM1 10h2v5H1zm20 0h2v5h-2z', eo: true }, copy: { d: 'M8 3h11v13h-2V5H8zM5 7h10v14H5zm2 2v10h6V9z', eo: true }, link: { d: 'M9 7H6.5a5 5 0 0 0 0 10H9v-2H6.5a3 3 0 0 1 0-6H9zm6 0h2.5a5 5 0 0 1 0 10H15v-2h2.5a3 3 0 0 0 0-6H15zM8 11h8v2H8z' }, plus: { d: 'M11 4h2v7h7v2h-7v7h-2v-7H4v-2h7z' }, minus: { d: 'M4 11h16v2H4z' }, user: { d: 'M12 3a4 4 0 1 1 0 8 4 4 0 0 1 0-8zM4 21a8 7 0 0 1 16 0z' }, 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' }, 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' }, chevronLeft: { d: 'M15.4 5l1.4 1.4-5.6 5.6 5.6 5.6-1.4 1.4-7-7z' }, chevrons: { d: 'M5.6 5 4.2 6.4 9.8 12l-5.6 5.6L5.6 19l7-7zm7 0-1.4 1.4 5.6 5.6-5.6 5.6 1.4 1.4 7-7z' }, hourglass: { d: 'M6 2h12v2h-1v3.5L13.5 12l3.5 4.5V20h1v2H6v-2h1v-3.5l3.5-4.5L7 7.5V4H6zm3 2v2.8l3 3.9 3-3.9V4zm0 16h6v-2.8l-3-3.9-3 3.9z', eo: true }, dots: { d: 'M5 10.3a1.7 1.7 0 1 1 0 3.4 1.7 1.7 0 0 1 0-3.4zm7 0a1.7 1.7 0 1 1 0 3.4 1.7 1.7 0 0 1 0-3.4zm7 0a1.7 1.7 0 1 1 0 3.4 1.7 1.7 0 0 1 0-3.4z' }, key: { d: 'M14.5 3a6.5 6.5 0 1 1-2.6 12.5l-1.1 1.1H9v2H7v2H3v-3.6l5.5-5.5A6.5 6.5 0 0 1 14.5 3zm1.5 3a2 2 0 1 0 0 4 2 2 0 0 0 0-4z', eo: true }, search: { d: 'M10 3a7 7 0 0 1 5.6 11.2l5.6 5.6-1.4 1.4-5.6-5.6A7 7 0 1 1 10 3zm0 2a5 5 0 1 0 0 10 5 5 0 0 0 0-10z', eo: true }, info: { d: 'M12 2a10 10 0 1 1 0 20 10 10 0 0 1 0-20zm-1 8v7h2v-7zm0-3v2h2V7z', eo: true }, warn: { d: 'M12 2 1 21h22zm-1 7v6h2V9zm0 7.5v2h2v-2z', eo: true }, play: { d: 'M7 4v16l13-8z' }, mic: { d: 'M12 14a3 3 0 0 0 3-3V5a3 3 0 0 0-6 0v6a3 3 0 0 0 3 3zm5-3a5 5 0 0 1-10 0H5a7 7 0 0 0 6 6.9V21H8v2h8v-2h-3v-3.1A7 7 0 0 0 19 11z' }, edit: { d: 'M3 17.2V21h3.8l11-11-3.8-3.8zM20.7 7.1a1 1 0 0 0 0-1.4l-2.4-2.4a1 1 0 0 0-1.4 0l-1.8 1.8 3.8 3.8z' }, sword: { d: 'M20 3h1v4L10.4 17.6l1.4 1.4-1.4 1.4-2.1-2.1-3.5 3.5-1.4-1.4 3.5-3.5L4.8 14.8l1.4-1.4 1.4 1.4L18 4z' }, shield: { d: 'M12 2 4 5v6c0 5 3.4 9.3 8 11 4.6-1.7 8-6 8-11V5z' }, 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' }, 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') eye: { d: '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', eo: true }, }; /** * Inline SVG icon. * @param {{ name: keyof typeof ICONS, size?: string, class?: string, title?: string }} props */ export function Icon({ name, size, class: cls, title }) { const ic = ICONS[name]; if (!ic) return null; return html` ${title ? html`${title}` : null} `; } // ---- small primitives ------------------------------------------------------------------------ /** Tiny uppercase techno label (e.g. "RHODES ISLAND // STRONGHOLD PROTOCOL"). */ export function MicroLabel({ children, class: cls, tone }) { return html`${children}`; } /** * Row of chevrons (»»») with an optional flowing animation. * @param {{ count?: number, dir?: 'right'|'left', tone?: string, animated?: boolean, class?: string }} props */ export function Chevrons({ count = 3, dir = 'right', tone = 'mint', animated = true, class: cls }) { const n = Math.max(1, Math.min(8, count | 0)); return html``; } // ---- Button ------------------------------------------------------------------------------------ /** * Button. `loading` (a request in flight): disabled and dimmed, and after a short delay a thin bar sweeps along its * bottom edge (css .btn__busy) — never a spinning ring or a wait cursor, which read as "this text is still loading from * the server" (user playtest #3 item 9); the icon stays. * @param {{ variant?: 'primary'|'secondary'|'danger'|'amber'|'ice'|'ghost', size?: 'sm'|'md'|'lg'|'xl', * icon?: string, iconRight?: string, loading?: boolean, disabled?: boolean, block?: boolean, square?: boolean, * active?: boolean, onClick?: Function, type?: string, title?: string, class?: string, children?: any }} props */ export function Button({ variant = 'secondary', size = 'md', icon, iconRight, loading = false, disabled = false, block = false, square = false, active = false, onClick, type = 'button', title, class: cls, children, ...rest }) { const isDisabled = disabled || loading; return html``; } // ---- Panel ------------------------------------------------------------------------------------- /** * Framed panel with bracket corners, optional header (title + micro label) and actions. * @param {{ title?: any, micro?: string, tone?: string, brackets?: boolean, glow?: boolean, pad?: boolean, * actions?: any, class?: string, children?: any, as?: string }} props */ export function Panel({ title, micro, tone = 'mint', brackets = true, glow = false, pad = true, actions, class: cls, children, ...rest }) { return html`
${title || micro || actions ? html`
${micro ? html`<${MicroLabel}>${micro}` : null} ${title ? html`

${title}

` : null}
${actions ? html`
${actions}
` : null}
` : null}
${children}
`; } // ---- badges & chips ---------------------------------------------------------------------------- /** * Hexagon badge (prices, costs, funds). Width grows with content. * @param {{ value?: any, tone?: 'gold'|'mint'|'red'|'amber'|'ice'|'dark'|'discount'|'premium', size?: 'sm'|'md'|'lg', * icon?: string, class?: string, title?: string }} props */ export function HexBadge({ value, tone = 'gold', size = 'md', icon, class: cls, title, children }) { return html` ${icon ? html`<${Icon} name=${icon} class="hex__icon" />` : null} ${value ?? children} `; } const ROMAN = ['', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X']; /** @param {number} n 1..10 @returns {string} roman numeral ('' when out of range) */ export const roman = (n) => ROMAN[n | 0] || ''; /** * Official tier chip sprite URL (web manifest `shopCard/img_chess_level_N`, else the local-client * `ui/battle/img_chess_level_N`), or null (CSS fallback). * @param {number} t 1..6 */ export function tierChipUrl(t) { return uiUrl(data.get('assets'), `shopCard/img_chess_level_${t}`) || localAsset('ui/battle', `img_chess_level_${t}`); } /** * Roman tier chip (I–VI): the official octagon sprite, or a CSS octagon in the official tier colours. * Golden = elite (精锐) variant (gold glow). * @param {{ tier: number, golden?: boolean, size?: 'sm'|'md'|'lg', class?: string }} props */ export function TierChip({ tier, golden = false, size = 'md', class: cls }) { const n = Math.max(1, Math.min(6, Number(tier) | 0 || 1)); const src = tierChipUrl(n); const [bad, setBad] = useState(null); const img = !!src && bad !== src; return html` ${img ? html` setBad(src)} />` : roman(n)} `; } /** * Bond disc: round glyph disc with segmented tier ring, stack count and name. * @param {{ name?: string, icon?: string, count?: number, layers?: number, tier?: number, maxTier?: number, * active?: boolean, disabled?: boolean, layersDisabled?: boolean, size?: 'sm'|'md'|'lg', showName?: boolean, * onClick?: Function, class?: string, title?: string }} props */ export function BondDisc({ name = '', icon, count, layers, tier = 0, maxTier = 3, active = false, disabled = false, layersDisabled = false, size = 'md', showName = true, onClick, class: cls, title, }) { // Remember which URL failed (a per-URL flag can't race a reset effect). const [badSrc, setBadSrc] = useState(null); const imgOk = !!icon && badSrc !== icon; const n = Math.max(1, Math.min(8, maxTier | 0 || 3)); const filled = Math.max(0, Math.min(n, tier | 0)); const R = 46; const C = 2 * Math.PI * R; const gap = n > 1 ? 5 : 0; const seg = C / n - gap; const stack = layers ?? count; const glyph = (name || '?').trim().charAt(0) || '?'; const Tag = onClick ? 'button' : 'div'; return html`<${Tag} type=${onClick ? 'button' : undefined} class=${cx('bond', `bond--${size}`, active && 'is-active', disabled && 'is-disabled', layersDisabled && 'is-frozen', onClick && 'is-clickable', cls)} onClick=${onClick} title=${title || name}> ${imgOk ? html` setBadSrc(icon)} />` : html`${glyph}`} ${stack != null ? html`${stack}` : null} ${disabled ? html`<${Icon} name="close" />` : null} ${showName && name ? html`${name}` : null} `; } // ---- seven-segment display & countdown --------------------------------------------------------- const SEG_W = 56; const SEG_H = 100; const SEG_T = 12; const SEG_GAP = 2.2; const pts = (arr) => arr.map(([x, y]) => `${x},${y}`).join(' '); function hSeg(y) { const t = SEG_T / 2; const x1 = t + SEG_GAP; const x2 = SEG_W - t - SEG_GAP; return pts([[x1, y], [x1 + t, y - t], [x2 - t, y - t], [x2, y], [x2 - t, y + t], [x1 + t, y + t]]); } function vSeg(x, y1, y2) { const t = SEG_T / 2; return pts([[x, y1], [x + t, y1 + t], [x + t, y2 - t], [x, y2], [x - t, y2 - t], [x - t, y1 + t]]); } const SEG_POLYS = (() => { const t = SEG_T / 2; const mid = SEG_H / 2; return { a: hSeg(t), g: hSeg(mid), d: hSeg(SEG_H - t), f: vSeg(t, t + SEG_GAP, mid - SEG_GAP), e: vSeg(t, mid + SEG_GAP, SEG_H - t - SEG_GAP), b: vSeg(SEG_W - t, t + SEG_GAP, mid - SEG_GAP), c: vSeg(SEG_W - t, mid + SEG_GAP, SEG_H - t - SEG_GAP), }; })(); const SEG_ORDER = ['a', 'b', 'c', 'd', 'e', 'f', 'g']; const DIGIT_SEGS = { 0: 'abcdef', 1: 'bc', 2: 'abged', 3: 'abgcd', 4: 'fgbc', 5: 'afgcd', 6: 'afgedc', 7: 'abc', 8: 'abcdefg', 9: 'abcdfg', '-': 'g', ' ': '', _: 'd', A: 'abcefg', b: 'cdefg', C: 'adef', d: 'bcdeg', E: 'adefg', F: 'aefg', H: 'bcefg', L: 'def', P: 'abefg', o: 'cdeg', r: 'eg', U: 'bcdef', }; function SegChar({ ch }) { if (ch === ':') { return html``; } const on = DIGIT_SEGS[ch] ?? ''; return html``; } /** * Seven-segment text (digits, '-', ':' and a few letters). * @param {{ text: string|number, tone?: 'mint'|'orange'|'gold'|'red'|'ice', size?: string, class?: string, flicker?: boolean }} props * size = CSS height (default via --seg-h) */ export function SevenSeg({ text, tone = 'mint', size, class: cls, flicker = false }) { const s = String(text ?? ''); return html` ${[...s].map((ch, i) => html`<${SegChar} key=${i} ch=${ch} />`)} `; } // Shared ticker so every live countdown re-renders from one interval per period. const tickers = new Map(); // ms -> { subs:Set, id } /** * Re-render the calling component every `ms` milliseconds (0/undefined = never). * @param {number} ms */ export function useTicker(ms) { const [, force] = useReducer((c) => c + 1, 0); useEffect(() => { if (!(ms > 0)) return undefined; let tk = tickers.get(ms); if (!tk) { tk = { subs: new Set(), id: null }; tk.id = setInterval(() => { for (const fn of [...tk.subs]) fn(); }, ms); tickers.set(ms, tk); } tk.subs.add(force); return () => { tk.subs.delete(force); if (tk.subs.size === 0) { clearInterval(tk.id); tickers.delete(ms); } }; }, [ms]); } /** * Whether a server deadline is set (servers send 0 / null for untimed phases). * @param {any} deadline * @returns {boolean} */ export const hasDeadline = (deadline) => Number.isFinite(deadline) && deadline > 0; /** * Seconds left until a server deadline (ceil, ≥ 0), or null when there is no deadline (null / 0). * @param {number|null|undefined} deadline server epoch ms * @returns {number|null} */ export function secondsLeft(deadline) { if (!hasDeadline(deadline)) return null; return Math.max(0, Math.ceil((deadline - serverNow()) / 1000)); } /** * 7-segment countdown with "COUNTDOWN" micro label and a 5-bar gauge; turns orange ≤ warnAt s. * Untimed (no `seconds` and no deadline — the server sends deadline 0 for every phase outside the battles of a solo * match, Match.soloUntimed): renders nothing at all. It used to draw a "--" placeholder with the COUNTDOWN label, * which reads as a timer (user playtest #4 item 3; css/screens/loadout.css keeps hiding the old placeholder). * @param {{ deadline?: number, seconds?: number, total?: number, warnAt?: number, label?: string, * size?: 'sm'|'md'|'lg', gauge?: boolean, onExpire?: Function, class?: string }} props * deadline: server epoch ms (live); seconds: static value (overrides deadline); total: seconds for the gauge. */ export function Countdown({ deadline, seconds, total, warnAt = 10, label = 'COUNTDOWN', size = 'md', gauge = true, onExpire, class: cls }) { const live = seconds == null && hasDeadline(deadline); useTicker(live ? 200 : 0); const remain = seconds != null && Number.isFinite(Number(seconds)) ? Math.max(0, Math.floor(Number(seconds))) : live ? secondsLeft(deadline) : null; const warn = remain != null && remain <= warnAt; const text = remain == null ? '--' : String(Math.min(999, remain)).padStart(2, '0'); const bars = 5; const lit = remain == null ? 0 : Number.isFinite(total) && total > 0 ? Math.max(0, Math.min(bars, Math.ceil((remain / total) * bars))) : bars; const expiredFor = useRef(null); useEffect(() => { if (!onExpire || !live) return; if (remain === 0 && expiredFor.current !== deadline) { expiredFor.current = deadline; onExpire(); } }); // untimed phase: no timer on screen (hooks above run unconditionally, so the component may toggle freely) if (remain == null) return null; return html`
<${SevenSeg} text=${text} tone=${warn ? 'orange' : 'mint'} flicker=${warn && remain > 0} /> ${label ? html`${label}` : null}
${gauge ? html`` : null}
`; } // ---- Modal & dialogs --------------------------------------------------------------------------- const modalStack = []; // open modals, topmost last: only the topmost handles keyboard focus / Escape /** * Modal dialog (declarative). Esc / backdrop click call onClose. * @param {{ open: boolean, title?: any, micro?: string, tone?: string, onClose?: Function, actions?: any, * width?: string, closeOnBackdrop?: boolean, class?: string, children?: any }} props */ export function Modal({ open, title, micro, tone = 'mint', onClose, actions, width, closeOnBackdrop = true, class: cls, children }) { const boxRef = useRef(null); const closeRef = useRef(onClose); closeRef.current = onClose; 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 (!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); 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); window.removeEventListener('focusin', onFocus); const i = modalStack.indexOf(token); if (i >= 0) modalStack.splice(i, 1); prevFocus?.focus?.(); }; }, [open]); if (!open) return null; return html``; } let dialogSeq = 0; let dialogs = []; const dialogListeners = new Set(); const emitDialogs = () => { for (const fn of [...dialogListeners]) fn(); }; function openDialog(opts, kind) { return new Promise((resolve) => { dialogs = [...dialogs, { id: ++dialogSeq, kind, opts: opts || {}, resolve }]; emitDialogs(); }); } function closeDialog(id, result) { const d = dialogs.find((x) => x.id === id); if (!d) return; dialogs = dialogs.filter((x) => x.id !== id); emitDialogs(); try { d.resolve(result); } catch { /* ignore */ } } /** * Imperative confirm dialog (requires or mounted). * @param {{ title?: string, text?: any, okText?: string, cancelText?: string, tone?: string, danger?: boolean, micro?: string }} opts * @returns {Promise} */ export const confirmDialog = (opts) => openDialog(opts, 'confirm'); /** * Imperative alert dialog. * @param {{ title?: string, text?: any, okText?: string, tone?: string, micro?: string }} opts * @returns {Promise} */ export const alertDialog = (opts) => openDialog(opts, 'alert'); /** * Dismiss every queued imperative dialog (confirm → false, alert → true) — e.g. the server session was reset and the * screen they belonged to is gone. */ export function closeAllDialogs() { const all = dialogs; if (!all.length) return; dialogs = []; emitDialogs(); for (const d of all) { try { d.resolve(d.kind !== 'confirm'); } catch { /* ignore */ } } } /** Renders queued imperative dialogs (one at a time). */ export function DialogHost() { const [, force] = useReducer((c) => c + 1, 0); useEffect(() => { dialogListeners.add(force); return () => dialogListeners.delete(force); }, []); const d = dialogs[0]; if (!d) return null; const { title = t('确认'), text, okText = t('确认'), cancelText = t('取消'), tone, danger, micro = 'CONFIRMATION' } = d.opts; const isConfirm = d.kind === 'confirm'; // Enter confirms unless a specific button has focus (then the native click decides). const onKey = (e) => { if (e.key === 'Enter' && e.target?.tagName !== 'BUTTON') { e.preventDefault(); closeDialog(d.id, true); } }; return html`
<${Modal} key=${d.id} open=${true} title=${title} micro=${micro} tone=${tone || (danger ? 'red' : 'mint')} onClose=${() => closeDialog(d.id, isConfirm ? false : true)} actions=${html` ${isConfirm ? html`<${Button} variant="secondary" onClick=${() => closeDialog(d.id, false)}>${cancelText}` : null} <${Button} variant=${danger ? 'danger' : 'primary'} icon="check" data-autofocus onClick=${() => closeDialog(d.id, true)}>${okText}`}> ${typeof text === 'string' ? html`` : text}
`; } // ---- Tooltip ----------------------------------------------------------------------------------- let tipState = null; // { id, content, rect, placement } let tipSeq = 0; const tipListeners = new Set(); const setTip = (s) => { tipState = s; for (const fn of [...tipListeners]) fn(); }; /** * Hover/focus tooltip around its children. Content renders in (fixed, viewport-clamped). * @param {{ text: any, placement?: 'top'|'bottom', delay?: number, block?: boolean, class?: string, children?: any }} props */ const TOUCH_TIP_MS = 450; export function Tooltip({ text, placement = 'top', delay = 120, block = false, class: cls, children }) { const ref = useRef(null); const idRef = useRef(0); const timer = useRef(null); const textRef = useRef(text); const pressed = useRef(false); // pointer pressed inside: the focus that follows is not a keyboard focus textRef.current = text; if (!idRef.current) idRef.current = ++tipSeq; const show = () => { clearTimeout(timer.current); if (textRef.current == null || textRef.current === '') return; timer.current = setTimeout(() => { const el = ref.current; const content = textRef.current; // the text of the render current when the delay ends (a click may change it) if (!el || !el.isConnected || content == null || content === '') return; setTip({ id: idRef.current, content, rect: el.getBoundingClientRect(), placement }); }, delay); }; const hide = () => { clearTimeout(timer.current); if (tipState?.id === idRef.current) setTip(null); }; // A click hides the tooltip until the pointer leaves: the focus it gives the button must not bring it back // (with the text of the click's closure) over whatever the click opened. Keyboard focus still shows it. // Touch: a tap never shows it (the compatibility mouseenter that follows a tap is ignored); a long press // (≥ 450 ms) does, and it goes away shortly after the finger lifts. const touchAt = useRef(0); const longTimer = useRef(null); const onPointerDown = (e) => { pressed.current = true; hide(); clearTimeout(longTimer.current); if (e && e.pointerType && e.pointerType !== 'mouse') { touchAt.current = Date.now(); longTimer.current = setTimeout(() => { pressed.current = false; show(); }, TOUCH_TIP_MS); } }; const onPointerEnd = (e) => { if (!e || e.pointerType === 'mouse') return; touchAt.current = Date.now(); clearTimeout(longTimer.current); if (tipState?.id === idRef.current) { clearTimeout(timer.current); timer.current = setTimeout(hide, 1600); } }; const onMouseEnter = () => { if (Date.now() - touchAt.current > 900) show(); }; const onFocusIn = () => { if (!pressed.current && Date.now() - touchAt.current > 900) show(); }; const onLeave = () => { pressed.current = false; if (Date.now() - touchAt.current > 900) hide(); }; useEffect(() => () => { clearTimeout(longTimer.current); hide(); }, []); // Keep the visible tooltip's content fresh when `text` changes (and hide it when text goes away). useEffect(() => { if (tipState?.id !== idRef.current) return; if (text == null || text === '') setTip(null); else setTip({ ...tipState, content: text }); }, [text]); return html` { if (Date.now() - touchAt.current < 900) e.preventDefault(); }}> ${children} `; } /** Renders the active tooltip. Mount once (UiHosts does). */ export function TooltipLayer() { const [, force] = useReducer((c) => c + 1, 0); const boxRef = useRef(null); const [pos, setPos] = useState(null); useEffect(() => { tipListeners.add(force); return () => tipListeners.delete(force); }, []); useLayoutEffect(() => { const el = boxRef.current; if (!tipState || !el) { if (pos) setPos(null); return; } const r = tipState.rect; const b = el.getBoundingClientRect(); const vw = window.innerWidth; const vh = window.innerHeight; const m = 8; let place = tipState.placement; let top = place === 'bottom' ? r.bottom + m : r.top - b.height - m; if (place === 'top' && top < m) { place = 'bottom'; top = r.bottom + m; } if (place === 'bottom' && top + b.height > vh - m) { place = 'top'; top = Math.max(m, r.top - b.height - m); } let left = r.left + r.width / 2 - b.width / 2; left = Math.max(m, Math.min(vw - b.width - m, left)); const next = { top: Math.round(top), left: Math.round(left), place, id: tipState.id }; if (!pos || pos.top !== next.top || pos.left !== next.left || pos.id !== next.id || pos.place !== next.place) setPos(next); }); if (!tipState) return null; const ready = pos && pos.id === tipState.id; return html``; } // ---- ProgressBar, Tabs, Spinner ---------------------------------------------------------------- /** * Progress bar. * @param {{ value: number, max?: number, tone?: 'mint'|'gold'|'amber'|'orange'|'red'|'ice', segments?: number, * label?: any, showValue?: boolean, size?: 'sm'|'md'|'lg', class?: string }} props */ export function ProgressBar({ value, max = 100, tone = 'mint', segments = 0, label, showValue = false, size = 'md', class: cls }) { const m = Number.isFinite(max) && max > 0 ? max : 100; const v = Number.isFinite(value) ? Math.max(0, Math.min(m, value)) : 0; const pct = (v / m) * 100; return html`
${label != null || showValue ? html`
${label} ${showValue ? html`${Math.round(v)}/${Math.round(m)}` : null}
` : null}
1 ? `--segs:${segments}` : undefined}>
${segments > 1 ? html`
` : null}
`; } /** * Tabs (controlled). * @param {{ items: {id: string, label: any, badge?: any, disabled?: boolean}[], value: string, * onChange: (id: string) => void, size?: 'sm'|'md', class?: string }} props */ export function Tabs({ items = [], value, onChange, size = 'md', class: cls }) { const onKey = (e) => { if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return; const enabled = items.filter((i) => !i.disabled); const idx = enabled.findIndex((i) => i.id === value); if (idx < 0 || enabled.length === 0) return; const next = enabled[(idx + (e.key === 'ArrowRight' ? 1 : enabled.length - 1)) % enabled.length]; onChange?.(next.id); e.preventDefault(); }; return html`
${items.map((it) => html``)}
`; } /** * Loading spinner (rotating hexagon). * @param {{ size?: 'sm'|'md'|'lg', label?: any, tone?: string, class?: string }} props */ export function Spinner({ size = 'md', label, tone = 'mint', class: cls }) { return html` ${label ? html`${label}` : html`${t('加载中')}`} `; } // ---- Avatar frame ------------------------------------------------------------------------------ const SEAT_HUES = [162, 196, 38, 280]; /** * Square avatar frame with bracket corners. Falls back to a glyph (first letter / robot). * @param {{ name?: string, src?: string, size?: 'sm'|'md'|'lg'|'xl', seat?: number, host?: boolean, bot?: boolean, * self?: boolean, ready?: boolean, offline?: boolean, dead?: boolean, empty?: boolean, class?: string }} props */ export function AvatarFrame({ name = '', src, size = 'md', seat = 0, host, bot, self, ready, offline, dead, empty, class: cls }) { const [badSrc, setBadSrc] = useState(null); const imgOk = !!src && badSrc !== src; const hue = SEAT_HUES[((seat | 0) % 4 + 4) % 4]; const glyph = [...(name || '').trim()][0] || '?'; return html`
${empty ? html`<${Icon} name="plus" class="avatar__empty" />` : imgOk ? html` setBadSrc(src)} />` : bot ? html`<${Icon} name="robot" class="avatar__bot" />` : html`${glyph}`}
${host ? html`<${Icon} name="crown" />` : null} ${self ? html`<${Icon} name="user" />` : null} ${bot && !empty ? html`AI` : null}
`; } // ---- PhaseBanner ------------------------------------------------------------------------------- /** * Chevron phase banner (full-width strip that wipes in horizontally). * @param {{ title: any, sub?: any, micro?: string, tone?: 'mint'|'orange'|'red'|'gold'|'ice', mode?: 'overlay'|'inline', * duration?: number, onDone?: Function, class?: string }} props * mode 'overlay' = fixed over the screen; duration (ms) > 0 auto-hides then calls onDone. Re-key to replay. */ export function PhaseBanner({ title, sub, micro, tone = 'mint', mode = 'inline', duration = 0, onDone, class: cls }) { const [leaving, setLeaving] = useState(false); const [gone, setGone] = useState(false); useEffect(() => { if (!(duration > 0)) return undefined; const t1 = setTimeout(() => setLeaving(true), duration); const t2 = setTimeout(() => { setGone(true); onDone?.(); }, duration + 260); return () => { clearTimeout(t1); clearTimeout(t2); }; }, [duration]); if (gone) return null; return html`
<${Chevrons} count=${4} tone=${tone} class="pbanner__chev pbanner__chev--l" />
${micro ? html`${micro}` : null} ${title} ${sub ? html`${sub}` : null}
<${Chevrons} count=${4} tone=${tone} dir="left" class="pbanner__chev pbanner__chev--r" />
`; } /** * The round's result box (the official round result dialog, shown at settlement; GitHub #235, PR #112 by @Convey123): * a centred framed plate — tone-coloured frame and corner ticks, chevrons either side of the title — that opens, holds * `duration` ms and closes by itself. Purely presentational: ui/gameLogic/phases.js (roundResultBox / uniteResultBox / * battleResultBox) picks the words, screens/game.js the moment (SETTLE). `pointer-events: none`: it never takes a click. * @param {{ title: any, sub?: any, micro?: string, tone?: 'mint'|'orange'|'red', duration?: number, onDone?: Function }} props * duration (ms) > 0 auto-hides then calls onDone. Re-key to replay. */ export function ResultDialog({ title, sub, micro, tone = 'mint', duration = 2800, onDone }) { const [leaving, setLeaving] = useState(false); const [gone, setGone] = useState(false); useEffect(() => { if (!(duration > 0)) return undefined; const t1 = setTimeout(() => setLeaving(true), duration); const t2 = setTimeout(() => { setGone(true); onDone?.(); }, duration + 300); return () => { clearTimeout(t1); clearTimeout(t2); }; }, [duration]); if (gone) return null; return html`
${micro ? html`${micro}` : null}
<${Chevrons} count=${3} tone=${tone} class="rdialog__chev" /> ${title} <${Chevrons} count=${3} tone=${tone} dir="left" class="rdialog__chev" />
${sub ? html`${sub}` : null}
`; } // ---- domain helpers ---------------------------------------------------------------------------- /** * Stable 4-digit "博士 #1234" tag for a player id (FNV-1a hash; ids themselves are opaque strings). * @param {any} id * @returns {string} */ export function doctorNo(id) { let h = 2166136261; for (const ch of String(id ?? '')) { h ^= ch.codePointAt(0); h = Math.imul(h, 16777619) >>> 0; } return String(h % 10000).padStart(4, '0'); } /** * Latency pill ("58ms"), coloured by research 06 §3.4 tiers (<60 mint, <200 amber, else red). * @param {{ ms?: number|null, online?: boolean, class?: string }} props */ export function PingPill({ ms, online = true, class: cls }) { const ok = online && Number.isFinite(ms); const tier = !ok ? 'off' : ms < 60 ? 'low' : ms < 200 ? 'medium' : 'high'; return html` <${Icon} name=${ok ? 'signal' : 'wifiOff'} class="ping__icon" /> ${ok ? Math.min(9999, Math.round(ms)) : '--'}ms `; } /** * Official difficulty glyph (data/assets.json `ui['modeIcon/mode__icon']`: rook, rook + * swords, castle + swords, castle + warning) — the SVG rook while assets are missing or fail to load. * Rendered with class "icon" so it sizes like an . * @param {{ difficulty: string, class?: string }} props */ export function DifficultyIcon({ difficulty, class: cls }) { useData('assets'); const [badSrc, setBadSrc] = useState(null); const ui = DIFFICULTY_NAMES[difficulty] ? data.get('assets')?.ui : null; const src = ui && typeof ui === 'object' ? ui[`modeIcon/mode_${String(difficulty).toLowerCase()}_icon`] : null; if (typeof src === 'string' && src && badSrc !== src) { return html` setBadSrc(src)} />`; } return html`<${Icon} name="rook" class=${cls} />`; } /** * Difficulty tag: coloured glyph + name (标准/险境/绝境/终极模拟). * @param {{ difficulty: string, size?: 'sm'|'md'|'lg', class?: string, code?: string }} props */ export function DifficultyTag({ difficulty, size = 'md', class: cls, code }) { const name = DIFFICULTY_NAMES[difficulty] ? t(DIFFICULTY_NAMES[difficulty]) : difficulty || '—'; const color = DIFFICULTY_COLORS[difficulty] || 'var(--text-lo)'; return html` <${DifficultyIcon} difficulty=${difficulty} class="dtag__icon" /> ${name} ${code ? html`${code}` : null} `; } /** * Bracketed text input. * @param {{ label?: any, micro?: string, value: string, onInput: (v: string) => void, onEnter?: Function, * placeholder?: string, maxLength?: number, transform?: (v: string) => string, autoFocus?: boolean, * disabled?: boolean, size?: 'md'|'lg'|'code', icon?: string, class?: string, inputRef?: any, name?: string }} props */ export function TextField({ label, micro, value, onInput, onEnter, placeholder, maxLength, transform, autoFocus, disabled, size = 'md', icon, class: cls, inputRef, name, hint, invalid }) { const localRef = useRef(null); const ref = inputRef || localRef; const composing = useRef(false); useEffect(() => { if (autoFocus) setTimeout(() => ref.current?.focus(), 60); }, []); const handle = (e) => { let v = e.currentTarget.value; if (!composing.current && transform) { const t = transform(v); if (t !== v) { v = t; e.currentTarget.value = t; } } onInput?.(v); }; const id = useMemo(() => `tf-${Math.random().toString(36).slice(2, 8)}`, []); return html``; } /** Global overlay hosts for imperative dialogs and tooltips. Mount once near the root. */ export function UiHosts() { return html`<${Fragment}><${DialogHost} /><${TooltipLayer} />`; }