卫戍协议:盟约 web remake — initial import

Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
This commit is contained in:
sganggs committed 2026-09-29 16:21:31 +08:00
commit 01a44cd288
408 files changed
+308986

No files matched your search

+191
View File
@@ -0,0 +1,191 @@
// Asset URL resolution against data/assets.json (docs/ASSETS.md). Pure: every function takes the
// manifest object (or null) and returns a URL string or null — callers draw their own fallback
// (glyph, CSS shape) when null. Only URLs present in the manifest are ever returned, so the client
// never requests files the asset pipeline did not produce (no 404 noise in the console).
const str = (v) => (typeof v === 'string' && v ? v : null);
const obj = (v) => (v && typeof v === 'object' ? v : null);
/** @param {any} m manifest @param {string} key 'group/key' */
export function uiUrl(m, key) {
return str(obj(obj(m)?.ui)?.[key]);
}
/**
* Operator avatar for a chess record (golden → E2 art when present).
* @param {any} m manifest
* @param {any} chess chess.json record (or { assets: { avatar } })
*/
export function chessAvatarUrl(m, chess) {
const chars = obj(obj(m)?.chars);
const id = str(chess?.assets?.avatar) || str(chess?.charId);
if (!chars || !id) return null;
if (id.endsWith('_2') && !chars[id]) {
const base = chars[id.slice(0, -2)];
return str(base?.avatarE2) || str(base?.avatar);
}
return str(chars[id]?.avatar) || (chess?.charId ? str(chars[chess.charId]?.avatar) : null);
}
/**
* Half-body portrait (180×360) for a chess record (golden → E2 portrait when present).
* @param {any} m
* @param {any} chess
*/
export function chessPortraitUrl(m, chess) {
const chars = obj(obj(m)?.chars);
const id = str(chess?.assets?.portrait);
if (!chars) return null;
if (id) {
if (id.endsWith('_2')) {
const base = chars[id.slice(0, -2)];
if (base) return str(base.portraitE2) || str(base.portrait);
}
if (id.endsWith('_1')) {
const base = chars[id.slice(0, -2)];
if (base) return str(base.portrait);
}
}
const byChar = chess?.charId ? chars[chess.charId] : null;
if (!byChar) return null;
return (chess?.isGolden ? str(byChar.portraitE2) : null) || str(byChar.portrait);
}
/** Skill icon of a chess record (manifest `skills[iconId]`, else the empty skill sprite). */
export function skillIconUrl(m, chess) {
const skills = obj(obj(m)?.skills);
const id = str(chess?.assets?.skillIcon) || str(chess?.skill?.iconId) || str(chess?.skill?.skillId);
if (skills && id && skills[id]) return str(skills[id]);
const byId = obj(obj(m)?.skillsById);
const alt = byId && chess?.skill?.skillId ? byId[chess.skill.skillId] : null;
if (skills && alt && skills[alt]) return str(skills[alt]);
return uiUrl(m, 'skillIcon/empty');
}
/**
* Skill icon of one skill record (DESIGN §16 `skills[]`: iconId / skillId), else the empty skill sprite — or null with
* `{ empty: false }` (the asset manifest only carries the default skills' icons: callers then draw a lettered S1–S3
* tile instead of a blank square).
*/
export function skillRecordIconUrl(m, skill, { empty = true } = {}) {
const skills = obj(obj(m)?.skills);
for (const id of [str(skill?.iconId), str(skill?.skillId)]) if (skills && id && skills[id]) return str(skills[id]);
const byId = obj(obj(m)?.skillsById);
const alt = byId && skill?.skillId ? byId[skill.skillId] : null;
if (skills && alt && skills[alt]) return str(skills[alt]);
return empty ? uiUrl(m, 'skillIcon/empty') : null;
}
const PROF_KEY = {
PIONEER: 'pioneer', WARRIOR: 'warrior', TANK: 'tank', SNIPER: 'sniper', CASTER: 'caster', MEDIC: 'medic',
SUPPORT: 'support', SPECIAL: 'special',
};
/** Profession icon (small white glyph). */
export function profIconUrl(m, profession) {
const k = PROF_KEY[String(profession || '').toUpperCase()];
return k ? str(obj(obj(obj(m)?.prof)?.icon)?.[k]) : null;
}
/** Sub-profession icon from a chess record (`assets.subProfIcon` = 'sub_<id>_icon'). */
export function subProfIconUrl(m, chess) {
const sub = obj(obj(obj(m)?.prof)?.sub);
if (!sub) return null;
const raw = str(chess?.assets?.subProfIcon);
const id = raw ? raw.replace(/^sub_/, '').replace(/_icon$/, '') : str(chess?.subProfessionId);
return id ? str(sub[id]) : null;
}
/** Bond glyph (white; tint in CSS). */
export function bondIconUrl(m, bondId) {
return bondId ? str(obj(obj(m)?.bonds)?.[bondId]) : null;
}
/** Band (strategy) icon. */
export function bandIconUrl(m, bandId) {
return bandId ? str(obj(obj(m)?.bands)?.[bandId]) : null;
}
/** Item icon for an items.json record (by trapId / iconId) or a raw trap id. */
export function itemIconUrl(m, item) {
const items = obj(obj(m)?.items);
if (!items) return null;
if (typeof item === 'string') return str(items[item]);
return str(items[item?.iconId]) || str(items[item?.trapId]);
}
/** Enemy icon (manifest keeps fallbacks resolved). */
export function enemyIconUrl(m, enemyKey) {
const e = enemyKey ? obj(obj(obj(m)?.enemies)?.[enemyKey]) : null;
if (e?.icon) return str(e.icon);
// `_2` / `_3` variants fall back to their base enemy
const base = typeof enemyKey === 'string' ? enemyKey.replace(/_\d+$/, '') : null;
return base && base !== enemyKey ? str(obj(obj(obj(m)?.enemies)?.[base])?.icon) : null;
}
/** Token avatar, falling back to its owner operator's avatar. */
export function tokenAvatarUrl(m, tokenId) {
const tokens = obj(obj(m)?.tokens);
const t = tokenId ? obj(tokens?.[tokenId]) : null;
if (t?.avatar) return str(t.avatar);
if (t?.owner) return str(obj(obj(obj(m)?.chars)?.[t.owner])?.avatar);
return null;
}
/** Faction (特训敌人 type) icon: `enemyTypeIcon/<icon>`. */
export function factionIconUrl(m, iconId) {
return iconId ? uiUrl(m, `enemyTypeIcon/${iconId}`) : null;
}
/** Title (评语) icon: `titleIcon/<picId>`. */
export function titleIconUrl(m, picId) {
return picId ? uiUrl(m, `titleIcon/${picId}`) : null;
}
/** Greek type letters of some modules (ISW-α, …) → the Latin letter of the client's icon file names (isw-a). */
const GREEK = { 'α': 'a', 'β': 'b', 'γ': 'g', 'δ': 'd', 'Δ': 'd' };
/** Lower-cased key → path index of a local `groups.module` object (built once per manifest object). */
const moduleIconIndex = new WeakMap();
/**
* Official module (uniequip) TYPE icon from the local-client art (DESIGN §13 / §16): `data/local-assets.json`
* `groups.module[<type>]`, matched case-insensitively against the module's typeName ('MAR-X' → key 'mar-x', 'PRI-X' →
* key 'PRI-X' — the client's file names are mixed case). null when the manifest, the group or the entry is missing
* (callers draw the lettered tile / type text instead).
* @param {any} local the local-art manifest (`data.get('local')`) or null
* @param {string} typeName e.g. 'MAR-X'
*/
export function moduleTypeIconUrl(local, typeName) {
const g = obj(obj(obj(local)?.groups)?.module);
const t = str(typeName)?.trim();
if (!g || !t) return null;
const exact = str(obj(g[t])?.path);
if (exact) return exact;
let idx = moduleIconIndex.get(g);
if (!idx) {
idx = new Map();
for (const [k, v] of Object.entries(g)) {
const p = str(obj(v)?.path);
if (p && !idx.has(k.toLowerCase())) idx.set(k.toLowerCase(), p);
}
moduleIconIndex.set(g, idx);
}
const key = t.replace(/[αβγδΔ]/g, (c) => GREEK[c]).toLowerCase();
return idx.get(key) || null;
}
/**
* Icon for an m.private.effects entry: { iconKind: 'band'|'choice'|'team'|'item'|'garrison', iconId }.
* @param {any} m
* @param {{ iconKind?: string, iconId?: string }} eff
*/
export function effectIconUrl(m, eff) {
const kind = eff?.iconKind;
const id = str(eff?.iconId);
if (kind === 'band') return bandIconUrl(m, id) || (id && id.startsWith('icon_') ? bandIconUrl(m, `band_${id.slice(5)}`) : null);
if (kind === 'item') return itemIconUrl(m, id);
if (kind === 'garrison') return uiUrl(m, `garrisonTypeIcon/${id || 's_icon_bond'}`) || uiUrl(m, 'garrisonTypeIcon/s_icon_bond');
if (kind === 'team') return uiUrl(m, `buffIcon/${id && id.startsWith('icon_') ? id : 'icon_team_buff'}`);
if (kind === 'choice') return uiUrl(m, `buffIcon/${id && id.startsWith('icon_') ? id : 'icon_player_buff'}`);
return id ? uiUrl(m, `buffIcon/${id}`) : null;
}
+102
View File
@@ -0,0 +1,102 @@
// Bond strip (active-bond discs under the top bar) and the bond detail popup: the key facts first (user playtest #2
// item 9: name, members in play / next threshold, layers, reached tier and its threshold row, the current effect with
// layer-resolved numbers), then the full description and the member list with owned / on-board state. Opened from
// the detail panel's bond chips it docks beside that panel (`beside`: the panel's side).
// Research 06 §11.1: round mint discs, stack count over the disc, name below, sorted by stacks; grey =
// present but inactive; in 联防 / boss rounds the strip is dimmed ("层数叠加已禁用").
import { html, BondDisc, Icon, MicroLabel, Tooltip } from './components.js';
import { RichText, UnitThumb, BondGlyph } from './gameComponents.js';
import { sortBonds, bondMembers, nextThreshold, bondTier } from './gameLogic.js';
import { formatBondEffect } from './richText.js';
import { bondIconUrl } from './assetUrls.js';
import { data } from '../data.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/**
* @param {{ bonds: any[], layersDisabled?: boolean, onOpen:(bondId:string)=>void, openId?: string|null, max?: number }} props
*/
export function BondStrip({ bonds, layersDisabled = false, onOpen, openId = null, max = 14, owner = null }) {
const sorted = sortBonds(bonds, (id) => data.lookup('bonds', id));
if (!sorted.length) {
return html`<div class="bstrip bstrip--empty"><${MicroLabel}>BONDS</${MicroLabel}><span>${owner ? `${owner} 尚未激活盟约` : '部署干员以激活盟约'}</span></div>`;
}
const m = data.get('assets');
const shown = sorted.slice(0, max);
const strip = html`<div class=${cx('bstrip', layersDisabled && 'is-frozen', owner && 'is-other')} role="list">
${owner ? html`<span class="bstrip__owner"><${MicroLabel}>${owner}</${MicroLabel}></span>` : null}
${shown.map((b) => {
const rec = data.lookup('bonds', b.bondId);
const th = Array.isArray(b.thresholds) && b.thresholds.length ? b.thresholds : rec?.thresholds || [];
const next = nextThreshold(b.count ?? 0, th);
return html`<div key=${b.bondId} role="listitem" class=${cx('bslot', b.active && 'is-active', openId === b.bondId && 'is-open')}>
<${BondDisc} name=${rec?.name || b.bondId} icon=${bondIconUrl(m, b.bondId)} layers=${b.layers ?? 0}
tier=${b.tier ?? 0} maxTier=${Math.max(1, th.length)} active=${!!b.active} size="sm" showName=${true}
layersDisabled=${layersDisabled} onClick=${() => onOpen(b.bondId)}
title=${`${rec?.name || b.bondId} ${b.count ?? 0}/${next ?? th[th.length - 1] ?? '-'}`} />
<span class=${cx('bslot__count', 'num', next == null && 'is-max')}>${b.count ?? 0}<small>/${next ?? th[th.length - 1] ?? '-'}</small></span>
</div>`;
})}
${sorted.length > shown.length ? html`<span class="bstrip__more num">+${sorted.length - shown.length}</span>` : null}
</div>`;
return layersDisabled ? html`<${Tooltip} text="层数叠加已禁用" placement="bottom">${strip}<//>` : strip;
}
/**
* Bond detail popup.
* @param {{ bondId: string, entry?: any, priv?: any, banned?: string[], onClose: Function, onMember?: (chessId:string)=>void,
* place?: 'left'|'beside'|'besideR'|'right'|null, over?: boolean, beside?: 'left'|'right'|null }} props — `place`: where it
* opens (gameLogic bondPopupPlace; `beside: 'left'` = the older spelling of 'beside'); `over`: above the detail card
*/
export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember, place = null, over = false, beside = null }) {
const b = data.lookup('bonds', bondId);
if (!b) return null;
const count = entry?.count ?? 0;
const layers = entry?.layers ?? 0;
const th = Array.isArray(entry?.thresholds) && entry.thresholds.length ? entry.thresholds : b.thresholds || [];
const tier = entry?.tier ?? bondTier(count, th, b.maxCount);
const active = entry ? !!entry.active : tier > 0;
const members = bondMembers(b, priv, banned, (id) => data.lookup('chess', id));
const countsHand = entry?.countsHand ?? b.countsHand;
const next = nextThreshold(count, th);
const hasNow = !!(b.effectDescRaw || b.effectDesc);
const at = place || (beside === 'left' ? 'beside' : 'left');
return html`<div class=${cx('bpop', 'brackets', `bpop--${at}`, over && 'is-over')} data-place=${at} role="dialog" aria-label=${`盟约:${b.name}`}>
<button type="button" class="bpop__close" aria-label="关闭" onClick=${onClose}><${Icon} name="close" /></button>
<header class="bpop__head">
<div class=${cx('bpop__disc', active && 'is-active')}><${BondGlyph} bondId=${bondId} /></div>
<div class="bpop__titles">
<${MicroLabel} tone="mint">${b.isCore ? 'CORE BOND // 核心盟约' : 'ADD-ON BOND // 附加盟约'}</${MicroLabel}>
<h3 class="bpop__name">${b.name}</h3>
<div class="bpop__facts">
<span>在场 <b class="num">${count}</b>${next != null ? html`<small class="num">/${next}</small>` : null}${countsHand ? html`<small>(含整备区)</small>` : null}</span>
<span>层数 <b class="num t-mint">${layers}</b></span>
<span class=${active ? 't-mint' : 't-lo'}>${active ? `已激活${th.length > 1 ? ` · ${tier} 阶` : ''}` : '未激活'}</span>
</div>
</div>
</header>
<div class="bpop__tiers">
${th.map((n, i) => html`<span key=${i} class=${cx('bpop__tier', i < tier && 'is-on')}><b class="num">${n}</b><small>${b.maxCount != null ? '名及以下' : '名'}</small></span>`)}
</div>
${hasNow ? html`<section class="bpop__sec bpop__sec--now">
<h4>当前效果 <small class="num">(${layers} 层)</small></h4>
<${RichText} as="p" text=${formatBondEffect(b, layers)} class="bpop__desc" />
</section>` : null}
<section class="bpop__sec">
<h4>盟约效果</h4>
<${RichText} as="p" text=${b.descRaw || b.desc} class="bpop__desc" />
</section>
<section class="bpop__sec">
<h4>成员 <small>${members.filter((x) => x.onBoard).length}/${members.length}</small></h4>
<div class="bpop__members">
${members.map((mb) => html`<button key=${mb.id} type="button" class=${cx('bpop__member', mb.onBoard && 'is-on', mb.owned && !mb.onBoard && 'is-owned', mb.banned && 'is-banned')}
onClick=${() => onMember?.(mb.id)} title=${`${mb.name}${mb.banned ? '(本局禁用)' : mb.onBoard ? '(在场)' : mb.owned ? '(整备区)' : ''}`}>
<${UnitThumb} kind="chess" id=${mb.id} size="sm" dim=${!mb.owned || mb.banned} />
<span class="bpop__mname">${mb.name}</span>
${mb.banned ? html`<span class="bpop__ban"><${Icon} name="close" /></span>` : null}
</button>`)}
</div>
</section>
</div>`;
}
+117
View File
@@ -0,0 +1,117 @@
// 机变 draft overlay (research 06 §4.4 / §11.5): family title | description, "倒计时结束后仍未选定将自动分配",
// whose turn ("当前轮到你决策" / "{name} 正在决策…") with countdown, pick order with ✓ / ⌛ / … / door,
// and a 3×2 grid (solo: 3 cards) of cards with icon, title, rich description, tier chip and the taker's
// avatar badge. Clicking an available card on your turn sends g.choice.
import { html, Icon, TierChip, Countdown, MicroLabel, Spinner } from './components.js';
import { Img, RichText, PlayerAvatar, GIcon } from './gameComponents.js';
import { itemIconUrl, enemyIconUrl, uiUrl } from './assetUrls.js';
import { richTextPlain } from './richText.js';
import { sortedPlayers } from './gameLogic.js';
import { data } from '../data.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/**
* Resolve an sp card to display fields.
* @param {any} card normalised card ({ idx, takenBy, effectId?, itemId?, id?, name?, desc?, descRaw?, tier?, team?, enemyKey? })
* @param {string|null} family 'bounty'|'supply'|'shop'|'tactic'
*/
export function resolveSpCard(card, family) {
const choices = data.get('choices');
const byEffect = (list, id) => (Array.isArray(list) && id ? list.find((x) => x && x.effectId === id) : null);
const effectId = card.effectId || (!card.itemId && typeof card.id === 'string' && data.lookup('effects', card.id) ? card.id : null);
const eff = effectId ? data.lookup('effects', effectId) : null;
const itemId = card.itemId || (!effectId && typeof card.id === 'string' && data.lookup('items', card.id) ? card.id : null);
const item = itemId ? data.lookup('items', itemId) : null;
const bounty = byEffect(choices?.cards?.bounty, effectId);
const tactic = byEffect(choices?.cards?.tactic, effectId);
const m = data.get('assets');
const kind = card.kind === 'item' || item ? 'item' : card.kind === 'bounty' || bounty || family === 'bounty' ? 'bounty' : 'tactic';
const enemyKey = card.enemyKey || bounty?.enemyKey || eff?.params?.enemy_id || null;
const team = card.team ?? tactic?.team ?? (eff?.decoIconId === 'icon_team_buff');
let icon = null;
if (item) icon = itemIconUrl(m, item);
else if (kind === 'bounty' && enemyKey) icon = enemyIconUrl(m, enemyKey);
else if ((card.tacticKind || tactic?.kind) === 'terrain') icon = uiUrl(m, 'buffIcon/icon_stage_buff');
else if ((card.tacticKind || tactic?.kind) === 'enemyDebuff') icon = uiUrl(m, 'buffIcon/icon_enemy_debuff');
else icon = uiUrl(m, `buffIcon/${team ? 'icon_team_buff' : 'icon_player_buff'}`);
return {
kind,
name: card.name || item?.name || eff?.name || bounty?.name || tactic?.name || '机变',
desc: card.descRaw || card.desc || item?.descRaw || item?.desc || eff?.descRaw || eff?.desc || bounty?.desc || tactic?.desc || '',
tier: Number.isFinite(card.tier) ? card.tier : item?.tier ?? bounty?.tier ?? null,
icon,
team: !!team,
coin: card.coin ?? bounty?.coin ?? eff?.enemyPrice ?? null,
};
}
/**
* @param {{ pub:any, sp:any, myId:string, solo:boolean, onPick:(idx:number)=>void, busyIdx?:number|null, total?:number|null }} props
*/
export function ChoiceOverlay({ pub, sp, myId, solo, onPick, busyIdx = null, total = null }) {
if (!sp) return null;
const fam = data.get('choices')?.families?.[sp.family] || null;
const players = new Map(sortedPlayers(pub).map((p) => [p.playerId, p]));
const myTurn = solo || sp.turnPid === myId;
const mine = sp.pickOf.get(myId);
const turnName = players.get(sp.turnPid)?.name || '队友';
const special = /_s$/.test(String(sp.family || ''));
const order = solo ? [] : sp.order;
return html`<div class="spov" role="dialog" aria-label="机变阶段">
<div class="spov__veil" aria-hidden="true"></div>
<div class="spov__inner">
<header class="spov__head">
<div class="spov__titles">
<${MicroLabel} tone="mint">CONTINGENCY // 机变阶段</${MicroLabel}>
<h2 class=${cx('spov__title', special && 'is-special')}>${sp.name || fam?.name || '机变'}<span class="spov__bar">|</span><span class="spov__desc"><${RichText} text=${sp.desc || fam?.desc || '选择一项'} /></span></h2>
${!solo ? html`<p class="spov__sub">倒计时结束后仍未选定将自动分配</p>` : html`<p class="spov__sub">选择一项(无时间限制)</p>`}
</div>
<div class="spov__turn">
${mine != null ? html`<span class="spov__turntxt is-done"><${Icon} name="check" />已完成选择</span>`
: myTurn ? html`<span class="spov__turntxt is-mine">当前轮到你决策</span>`
: html`<span class="spov__turntxt">${turnName} 正在决策…<${Icon} name="hourglass" /></span>`}
${!solo && !sp.untimed ? html`<${Countdown} deadline=${pub?.deadline} total=${total ?? undefined} size="sm" />` : null}
</div>
</header>
${order.length ? html`<div class="spov__order" aria-label="决策顺序">
${order.map((pid, i) => {
const p = players.get(pid);
const picked = sp.pickOf.has(pid);
const cur = sp.turnPid === pid && !picked;
const left = p?.status === 'left';
return html`<div key=${pid} class=${cx('spov__who', cur && 'is-cur', picked && 'is-done', pid === myId && 'is-self')}>
<span class="spov__idx num">${i + 1}</span>
<${PlayerAvatar} player=${p || { name: '?' }} size="sm" self=${pid === myId} />
<span class="spov__wname">${p?.name || '博士'}</span>
<span class="spov__wstate">${left ? html`<${Icon} name="exit" />` : picked ? html`<${Icon} name="check" />` : cur ? html`<${Icon} name="hourglass" />` : html`<${Icon} name="dots" />`}</span>
</div>`;
})}
</div>` : null}
<div class=${cx('spov__grid', sp.cards.length <= 3 && 'spov__grid--3')}>
${sp.cards.map((card) => {
const r = resolveSpCard(card, sp.family);
const taker = card.takenBy ? players.get(card.takenBy) : null;
const can = myTurn && mine == null && !card.takenBy && busyIdx == 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', busyIdx === card.idx && 'is-busy')}
disabled=${!can} onClick=${() => can && onPick(card.idx)} aria-label=${r.name} title=${`${r.name}\n${richTextPlain(r.desc)}`}>
<span class="spcard__glow" aria-hidden="true"></span>
${r.tier ? html`<${TierChip} tier=${r.tier} size="md" class="spcard__tier" />` : null}
<span class="spcard__icon"><${Img} src=${r.icon} fallback=${html`<${GIcon} name=${r.kind === 'bounty' ? 'target' : 'bolt'} />`} /></span>
<span class="spcard__body">
<b class="spcard__name">${r.name}</b>
<${RichText} text=${r.desc} class="spcard__desc" />
<span class="spcard__tags">
${r.team ? html`<span class="spcard__tag spcard__tag--team">全队获得</span>` : null}
${r.kind === 'bounty' && r.coin ? html`<span class="spcard__tag spcard__tag--coin">赏金 ${r.coin}</span>` : null}
</span>
</span>
${taker ? html`<span class="spcard__taker" title=${`${taker.name} 已选择`}><${PlayerAvatar} player=${taker} size="sm" /><span>${card.takenBy === myId ? '你' : taker.name}</span></span>` : null}
${busyIdx === card.idx ? html`<span class="spcard__spin"><${Spinner} size="sm" /></span>` : null}
</button>`;
})}
</div>
</div>
</div>`;
}
+86
View File
@@ -0,0 +1,86 @@
// Combat HUD (research 06 §11.3/§11.7, research 09 §3.1 / §6.3): DP counter at the right edge and the bottom-centre
// pills.
// Client-side combat (`client` prop, DESIGN §14 — the official behaviour):
// * own normal battle over: "⌛ 作战结束,等待队友完成作战" with each teammate's live progress (kills / total, ✓);
// * observing a teammate (team row → 前往查看): "👁 name" + 返回战场;
// * 联防 / 最终攻势: the ‹ › pill switches the camera LEFT half / 全景 / RIGHT half ("你自己" / name / "全景");
// * there is NO view switcher during normal combat.
// Server-run combat (legacy streaming mode): the ‹ 自己 › switcher cycling the live fields of m.public.fields.
import { html, Icon } from './components.js';
import { DpCounter } from './hud.js';
import { GIcon } from './gameComponents.js';
import { switcherLabel, cycleField } from './gameLogic.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/** Teammates' progress under the waiting pill (css/screens/game.css .chud__progress / .chud__prog). */
function ProgressList({ list }) {
if (!Array.isArray(list) || !list.length) return null;
return html`<div class="chud__progress" role="list" aria-label="队友作战进度">
${list.map((p) => html`<span key=${p.playerId} role="listitem" class=${cx('chud__prog', p.done && 'is-done')}>
<b>${p.name}</b>
${p.done
? html`<span class="chud__prog__ok" aria-label="作战结束">✓</span>`
: html`<span class="num">${p.killed != null && p.total != null ? `${p.killed}/${p.total}` : '•••'}</span>`}
</span>`)}
</div>`;
}
/**
* @param {{ pub:any, myId:string, watching:string|null, hud:any, myDone:boolean, onWatch:(fieldId:string)=>void, spectating?: boolean,
* client?: null | { progress?: any[]|null, observing?: { name: string }|null, onBack?: () => void,
* layers?: Array<{ key: string, label: string, self: boolean, watch: boolean }>, layer?: string, onLayer?: (k: string) => void } }} props
*/
export function CombatHud({ pub, myId, watching, hud, myDone, onWatch, spectating = false, client = null }) {
if (client) return ClientHud({ hud, myDone, spectating, client });
const fields = (Array.isArray(pub?.fields) ? pub.fields : []).filter((f) => f && f.live !== false);
// the watched field may already have finished (not live): still name it
const label = switcherLabel(pub, watching, myId, spectating);
const canCycle = fields.length > 1;
return html`<div class="chud">
<${DpCounter} dp=${hud?.dp} />
<div class="chud__bottom">
${myDone ? html`<div class="chud__msg" role="status"><${Icon} name="hourglass" /><span>作战结束,等待队友完成作战</span></div>` : null}
${spectating ? html`<div class="chud__msg chud__msg--dead" role="status"><${Icon} name="close" /><span>你已被淘汰,正在观战</span></div>` : null}
${fields.length ? html`<div class=${cx('vswitch', !canCycle && 'is-single')}>
<button type="button" class="vswitch__arrow" disabled=${!canCycle} aria-label="上一个战场"
onClick=${() => { const n = cycleField(fields, watching, -1); if (n && n !== watching) onWatch(n); }}><${Icon} name="chevronLeft" /></button>
<span class="vswitch__label">${label}</span>
<button type="button" class="vswitch__arrow" disabled=${!canCycle} aria-label="下一个战场"
onClick=${() => { const n = cycleField(fields, watching, 1); if (n && n !== watching) onWatch(n); }}><${Icon} name="chevronRight" /></button>
</div>` : null}
</div>
</div>`;
}
function ClientHud({ hud, myDone, spectating, client }) {
const layers = Array.isArray(client.layers) ? client.layers : [];
const idx = Math.max(0, layers.findIndex((l) => l.key === (client.layer || 'ALL')));
const cur = layers[idx] || null;
const step = (d) => {
if (!layers.length || !client.onLayer) return;
const n = Math.min(layers.length - 1, Math.max(0, idx + d));
if (n !== idx) client.onLayer(layers[n].key);
};
const observing = client.observing;
return html`<div class="chud">
<${DpCounter} dp=${hud?.dp} />
<div class="chud__bottom">
${myDone && !observing ? html`<div class="chud__msg chud__wait" role="status"><${Icon} name="hourglass" /><span>作战结束,等待队友完成作战</span></div>
<${ProgressList} list=${client.progress} />` : null}
${spectating && !observing ? html`<div class="chud__msg chud__msg--dead" role="status"><${Icon} name="close" /><span>你已被淘汰,可点击队友头像前往查看</span></div>` : null}
${observing ? html`<div class="vswitch chud__observe is-single" role="status">
<span class="vswitch__label"><${GIcon} name="eye" /><span>${observing.name}</span></span>
${client.onBack ? html`<button type="button" class="btn btn--secondary btn--sm chud__back" onClick=${() => client.onBack()}>
<span class="btn__label">返回战场</span></button>` : null}
</div>` : null}
${!observing && layers.length ? html`<div class="vswitch chud__layers">
<button type="button" class="vswitch__arrow" disabled=${idx <= 0} aria-label="左侧战场" onClick=${() => step(-1)}><${Icon} name="chevronLeft" /></button>
<span class="vswitch__label">
${cur && cur.watch ? html`<${GIcon} name="eye" />` : null}<span>${cur ? cur.label : '全景'}</span></span>
<button type="button" class="vswitch__arrow" disabled=${idx >= layers.length - 1} aria-label="右侧战场" onClick=${() => step(1)}><${Icon} name="chevronRight" /></button>
</div>` : null}
</div>
</div>`;
}
+93
View File
@@ -0,0 +1,93 @@
// Small polyfills for the older browsers the client supports (Safari / iOS 15.0–15.3, Firefox ESR, older Chromium on
// Android tablets). Imported first by main.js (and the dev harnesses), so every later module — including the ones
// loaded on demand (render engine, battle sim) — sees them. Each one is installed only when the browser lacks it
// (feature detection, never UA sniffing) and is a faithful subset of the standard behaviour:
// Object.hasOwn Safari < 15.4, Firefox < 92
// Array/String/TypedArray .at() Safari < 15.4, Firefox < 90
// Array .findLast / .findLastIndex Safari < 15.4, Firefox < 104
// structuredClone Safari < 15.4, Firefox < 94 (JSON-safe values + Map/Set/Date; enough for
// the plain state objects the client clones)
// globalThis.queueMicrotask very old WebViews
// `installCompat(target)` is exported for tests (it patches the given global object instead).
/**
* Install the polyfills on `g` (default globalThis). Returns the names that were missing and got installed.
* @param {any} [g]
* @returns {string[]}
*/
export function installCompat(g = globalThis) {
const added = [];
const def = (obj, name, fn) => {
if (!obj || typeof obj[name] === 'function') return;
try {
Object.defineProperty(obj, name, { value: fn, writable: true, configurable: true, enumerable: false });
added.push(name);
} catch { /* frozen: leave it */ }
};
const O = g.Object || Object;
def(O, 'hasOwn', function hasOwn(obj, key) {
if (obj == null) throw new TypeError('Cannot convert undefined or null to object');
return Object.prototype.hasOwnProperty.call(Object(obj), key);
});
function at(i) {
const len = this.length >>> 0;
let k = Math.trunc(Number(i)) || 0;
if (k < 0) k += len;
if (k < 0 || k >= len) return undefined;
return typeof this === 'string' || this instanceof String ? String(this).charAt(k) : this[k];
}
def((g.Array || Array).prototype, 'at', at);
def((g.String || String).prototype, 'at', at);
const TA = g.Int8Array ? Object.getPrototypeOf(g.Int8Array.prototype) : null;
if (TA) def(TA, 'at', at);
def((g.Array || Array).prototype, 'findLast', function findLast(fn, thisArg) {
for (let i = (this.length >>> 0) - 1; i >= 0; i--) if (fn.call(thisArg, this[i], i, this)) return this[i];
return undefined;
});
def((g.Array || Array).prototype, 'findLastIndex', function findLastIndex(fn, thisArg) {
for (let i = (this.length >>> 0) - 1; i >= 0; i--) if (fn.call(thisArg, this[i], i, this)) return i;
return -1;
});
def(g, 'structuredClone', function structuredClone(value) { return cloneValue(value, new Map()); });
def(g, 'queueMicrotask', function queueMicrotask(fn) { Promise.resolve().then(fn).catch((err) => setTimeout(() => { throw err; })); });
return added;
}
/** Deep clone of plain data (objects, arrays, Map, Set, Date, RegExp, typed arrays), cycles preserved. */
function cloneValue(v, seen) {
if (v === null || typeof v !== 'object') {
if (typeof v === 'function' || typeof v === 'symbol') throw new TypeError('structuredClone: value could not be cloned');
return v;
}
if (seen.has(v)) return seen.get(v);
let out;
if (v instanceof Date) out = new Date(v.getTime());
else if (v instanceof RegExp) out = new RegExp(v.source, v.flags);
else if (ArrayBuffer.isView(v)) out = new v.constructor(v);
else if (v instanceof ArrayBuffer) out = v.slice(0);
else if (v instanceof Map) {
out = new Map();
seen.set(v, out);
for (const [k, x] of v) out.set(cloneValue(k, seen), cloneValue(x, seen));
return out;
} else if (v instanceof Set) {
out = new Set();
seen.set(v, out);
for (const x of v) out.add(cloneValue(x, seen));
return out;
} else if (Array.isArray(v)) {
out = new Array(v.length);
seen.set(v, out);
for (let i = 0; i < v.length; i++) out[i] = cloneValue(v[i], seen);
return out;
} else {
out = {};
seen.set(v, out);
for (const k of Object.keys(v)) out[k] = cloneValue(v[k], seen);
return out;
}
seen.set(v, out);
return out;
}
export const installedPolyfills = installCompat();
+823
View File
@@ -0,0 +1,823 @@
// 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, 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 { 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' },
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 },
};
/**
* 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`<svg class=${cx('icon', cls)} viewBox="0 0 24 24" width=${size || '1em'} height=${size || '1em'}
aria-hidden=${title ? undefined : 'true'} role=${title ? 'img' : undefined} focusable="false">
${title ? html`<title>${title}</title>` : null}
<path d=${ic.d} fill-rule=${ic.eo ? 'evenodd' : undefined} />
</svg>`;
}
// ---- small primitives ------------------------------------------------------------------------
/** Tiny uppercase techno label (e.g. "RHODES ISLAND // STRONGHOLD PROTOCOL"). */
export function MicroLabel({ children, class: cls, tone }) {
return html`<span class=${cx('micro', tone && `micro--${tone}`, cls)}>${children}</span>`;
}
/**
* 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`<span class=${cx('chevrons', `chevrons--${tone}`, dir === 'left' && 'chevrons--left', animated && 'is-animated', cls)} aria-hidden="true">
${Array.from({ length: n }, (_, i) => html`<svg key=${i} viewBox="0 0 12 20" style=${`--i:${dir === 'left' ? n - 1 - i : i}`}><path d="M0 0h5l7 10-7 10H0l7-10z" /></svg>`)}
</span>`;
}
// ---- Button ------------------------------------------------------------------------------------
/**
* Button.
* @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`<button type=${type} title=${title}
class=${cx('btn', `btn--${variant}`, `btn--${size}`, block && 'btn--block', square && 'btn--square',
active && 'is-active', loading && 'is-loading', cls)}
disabled=${isDisabled} aria-busy=${loading ? 'true' : undefined} aria-pressed=${active ? 'true' : undefined}
onClick=${(e) => { if (!isDisabled && onClick) onClick(e); }} ...${rest}>
${loading ? html`<span class="btn__spin" aria-hidden="true"></span>` : icon ? html`<${Icon} name=${icon} class="btn__icon" />` : null}
${children != null && children !== false ? html`<span class="btn__label">${children}</span>` : null}
${iconRight ? html`<${Icon} name=${iconRight} class="btn__icon btn__icon--right" />` : null}
</button>`;
}
// ---- 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`<section class=${cx('panel', `panel--${tone}`, brackets && 'brackets', glow && 'panel--glow', !pad && 'panel--flush', cls)} ...${rest}>
${title || micro || actions ? html`<header class="panel__head">
<div class="panel__titles">
${micro ? html`<${MicroLabel}>${micro}<//>` : null}
${title ? html`<h2 class="panel__title">${title}</h2>` : null}
</div>
${actions ? html`<div class="panel__actions">${actions}</div>` : null}
</header>` : null}
<div class="panel__body">${children}</div>
</section>`;
}
// ---- 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`<span class=${cx('hex', `hex--${tone}`, `hex--${size}`, cls)} title=${title}>
<span class="hex__fill">
${icon ? html`<${Icon} name=${icon} class="hex__icon" />` : null}
<span class="hex__value">${value ?? children}</span>
</span>
</span>`;
}
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 t = Math.max(1, Math.min(6, Number(tier) | 0 || 1));
const src = tierChipUrl(t);
const [bad, setBad] = useState(null);
const img = !!src && bad !== src;
return html`<span class=${cx('tier', `tier--${t}`, `tier--${size}`, golden && 'tier--golden', img && 'tier--img', cls)} aria-label=${`${t}阶`}>
${img ? html`<img src=${src} alt="" draggable=${false} onError=${() => setBad(src)} />` : roman(t)}
</span>`;
}
/**
* 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}>
<span class="bond__disc">
<svg class="bond__ring" viewBox="-50 -50 100 100" aria-hidden="true">
${Array.from({ length: n }, (_, i) => html`<circle key=${i} r=${R}
class=${i < filled ? 'on' : 'off'} stroke-dasharray=${`${seg} ${C - seg}`}
stroke-dashoffset=${-(i * C) / n - gap / 2} transform="rotate(-90)" />`)}
</svg>
<span class="bond__core">
${imgOk
? html`<img class="bond__icon" src=${icon} alt="" draggable=${false} onError=${() => setBadSrc(icon)} />`
: html`<span class="bond__glyph">${glyph}</span>`}
</span>
${stack != null ? html`<span class="bond__count">${stack}</span>` : null}
${disabled ? html`<span class="bond__ban" aria-label="禁用"><${Icon} name="close" /></span>` : null}
</span>
${showName && name ? html`<span class="bond__name">${name}</span>` : 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`<svg class="seg__char seg__char--colon" viewBox="0 0 20 100" aria-hidden="true">
<rect class="on" x="4" y="26" width="12" height="12" /><rect class="on" x="4" y="62" width="12" height="12" /></svg>`;
}
const on = DIGIT_SEGS[ch] ?? '';
return html`<svg class="seg__char" viewBox=${`-4 -2 ${SEG_W + 8} ${SEG_H + 4}`} aria-hidden="true">
<g transform="skewX(-7) translate(6 0)">
${SEG_ORDER.map((s) => html`<polygon key=${s} points=${SEG_POLYS[s]} class=${on.includes(s) ? 'on' : 'off'} />`)}
</g></svg>`;
}
/**
* 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`<span class=${cx('seg', `seg--${tone}`, flicker && 'is-flicker', cls)} style=${size ? `--seg-h:${size}` : undefined}
role="img" aria-label=${s}>
${[...s].map((ch, i) => html`<${SegChar} key=${i} ch=${ch} />`)}
</span>`;
}
// 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.
* @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();
}
});
return html`<div class=${cx('countdown', `countdown--${size}`, warn && 'is-warn', cls)} role="timer" aria-label=${remain == null ? '无倒计时' : `剩余${remain}秒`}>
<div class="countdown__main">
<${SevenSeg} text=${text} tone=${warn ? 'orange' : 'mint'} flicker=${warn && remain > 0} />
${label ? html`<span class="countdown__label">${label}</span>` : null}
</div>
${gauge ? html`<div class="countdown__gauge" aria-hidden="true">
${Array.from({ length: bars }, (_, i) => html`<i key=${i} class=${bars - i <= lit ? 'on' : ''}></i>`)}
</div>` : null}
</div>`;
}
// ---- Modal & dialogs ---------------------------------------------------------------------------
const modalStack = []; // open modals, topmost last: only the topmost reacts to 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;
useEffect(() => {
if (!open) return undefined;
const token = {};
modalStack.push(token);
const prevFocus = typeof document !== 'undefined' ? document.activeElement : null;
const onKey = (e) => {
if (e.key !== 'Escape' || modalStack[modalStack.length - 1] !== token || !closeRef.current) return;
e.stopPropagation();
closeRef.current();
};
window.addEventListener('keydown', onKey);
const t = setTimeout(() => {
const el = boxRef.current?.querySelector('[data-autofocus]') || boxRef.current?.querySelector('button, input');
el?.focus?.();
}, 30);
return () => {
window.removeEventListener('keydown', onKey);
clearTimeout(t);
const i = modalStack.indexOf(token);
if (i >= 0) modalStack.splice(i, 1);
prevFocus?.focus?.();
};
}, [open]);
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"
style=${width ? `width:${width}` : undefined}>
<div class="modal__stripe" aria-hidden="true"></div>
${title || micro ? html`<header class="modal__head">
${micro ? html`<${MicroLabel}>${micro}<//>` : null}
${title ? html`<h2 class="modal__title">${title}</h2>` : null}
</header>` : null}
<div class="modal__body">${children}</div>
${actions ? html`<footer class="modal__actions">${actions}</footer>` : null}
</div>
</div>`;
}
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 <UiHosts/> or <DialogHost/> mounted).
* @param {{ title?: string, text?: any, okText?: string, cancelText?: string, tone?: string, danger?: boolean, micro?: string }} opts
* @returns {Promise<boolean>}
*/
export const confirmDialog = (opts) => openDialog(opts, 'confirm');
/**
* Imperative alert dialog.
* @param {{ title?: string, text?: any, okText?: string, tone?: string, micro?: string }} opts
* @returns {Promise<true>}
*/
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 = '确认', text, okText = '确认', cancelText = '取消', 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`<div onKeyDown=${onKey}>
<${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`<p class="modal__text">${text}</p>` : text}
<//>
</div>`;
}
// ---- 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 <TooltipLayer/> (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`<span ref=${ref} class=${cx('tt-anchor', block && 'tt-anchor--block', cls)}
onMouseEnter=${onMouseEnter} onMouseLeave=${onLeave} onFocusIn=${onFocusIn} onFocusOut=${onLeave} onPointerDown=${onPointerDown}
onPointerUp=${onPointerEnd} onPointerCancel=${onPointerEnd} onContextMenu=${(e) => { if (Date.now() - touchAt.current < 900) e.preventDefault(); }}>
${children}
</span>`;
}
/** 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`<div ref=${boxRef} class=${cx('tooltip', ready && 'is-shown', ready && `tooltip--${pos.place}`)} role="tooltip"
style=${ready ? `top:${pos.top}px;left:${pos.left}px` : 'top:-9999px;left:-9999px'}>${tipState.content}</div>`;
}
// ---- 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`<div class=${cx('pbar', `pbar--${tone}`, `pbar--${size}`, cls)}>
${label != null || showValue ? html`<div class="pbar__meta">
<span class="pbar__label">${label}</span>
${showValue ? html`<span class="pbar__value">${Math.round(v)}<small>/${Math.round(m)}</small></span>` : null}
</div>` : null}
<div class="pbar__track" role="progressbar" aria-valuemin="0" aria-valuemax=${m} aria-valuenow=${v}
style=${segments > 1 ? `--segs:${segments}` : undefined}>
<div class="pbar__fill" style=${`width:${pct}%`}></div>
${segments > 1 ? html`<div class="pbar__segs"></div>` : null}
</div>
</div>`;
}
/**
* 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`<div class=${cx('tabs', `tabs--${size}`, cls)} role="tablist" onKeyDown=${onKey}>
${items.map((it) => html`<button key=${it.id} type="button" role="tab" aria-selected=${it.id === value ? 'true' : 'false'}
tabIndex=${it.id === value ? 0 : -1} disabled=${it.disabled}
class=${cx('tabs__tab', it.id === value && 'is-active')} onClick=${() => !it.disabled && onChange?.(it.id)}>
<span>${it.label}</span>
${it.badge != null ? html`<span class="tabs__badge">${it.badge}</span>` : null}
</button>`)}
</div>`;
}
/**
* 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`<span class=${cx('spinner', `spinner--${size}`, `spinner--${tone}`, cls)} role="status">
<svg viewBox="0 0 50 50" aria-hidden="true">
<polygon class="spinner__track" points="25,3 44,14 44,36 25,47 6,36 6,14" />
<polygon class="spinner__arc" points="25,3 44,14 44,36 25,47 6,36 6,14" />
</svg>
${label ? html`<span class="spinner__label">${label}</span>` : html`<span class="sr-only">加载中</span>`}
</span>`;
}
// ---- 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`<div class=${cx('avatar', `avatar--${size}`, 'brackets', host && 'is-host', bot && 'is-bot', self && 'is-self',
ready && 'is-ready', offline && 'is-offline', dead && 'is-dead', empty && 'is-empty', cls)} style=${`--seat-hue:${hue}`}>
<div class="avatar__img">
${empty ? html`<${Icon} name="plus" class="avatar__empty" />`
: imgOk ? html`<img src=${src} alt="" draggable=${false} onError=${() => setBadSrc(src)} />`
: bot ? html`<${Icon} name="robot" class="avatar__bot" />`
: html`<span class="avatar__glyph">${glyph}</span>`}
</div>
${host ? html`<span class="avatar__badge avatar__badge--host" title="创建者"><${Icon} name="crown" /></span>` : null}
${self ? html`<span class="avatar__badge avatar__badge--self" title="你"><${Icon} name="user" /></span>` : null}
${bot && !empty ? html`<span class="avatar__tag">AI</span>` : null}
</div>`;
}
// ---- 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`<div class=${cx('pbanner', `pbanner--${tone}`, `pbanner--${mode}`, leaving && 'is-leaving', cls)} role="status" aria-live="polite">
<div class="pbanner__band">
<${Chevrons} count=${4} tone=${tone} class="pbanner__chev pbanner__chev--l" />
<div class="pbanner__text">
${micro ? html`<span class="pbanner__micro">${micro}</span>` : null}
<span class="pbanner__title">${title}</span>
${sub ? html`<span class="pbanner__sub">${sub}</span>` : null}
</div>
<${Chevrons} count=${4} tone=${tone} dir="left" class="pbanner__chev pbanner__chev--r" />
</div>
</div>`;
}
// ---- 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`<span class=${cx('ping', `ping--${tier}`, cls)} title=${ok ? `当前延迟 ${ms}ms` : '未连接'}>
<${Icon} name=${ok ? 'signal' : 'wifiOff'} class="ping__icon" />
<span class="ping__value">${ok ? Math.min(9999, Math.round(ms)) : '--'}</span><span class="ping__unit">ms</span>
</span>`;
}
/**
* Official difficulty glyph (data/assets.json `ui['modeIcon/mode_<difficulty>_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 <Icon/>.
* @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`<img class=${cx('icon', 'dicon', cls)} src=${src} alt="" draggable=${false} onError=${() => 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] || difficulty || '—';
const color = DIFFICULTY_COLORS[difficulty] || 'var(--text-lo)';
return html`<span class=${cx('dtag', `dtag--${size}`, cls)} style=${`--d-color:${color}`}>
<${DifficultyIcon} difficulty=${difficulty} class="dtag__icon" />
<span class="dtag__name">${name}</span>
${code ? html`<span class="dtag__code">${code}</span>` : null}
</span>`;
}
/**
* 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`<label class=${cx('field', `field--${size}`, disabled && 'is-disabled', invalid && 'is-invalid', cls)} for=${id}>
${label || micro ? html`<span class="field__label">${label}${micro ? html`<span class="micro">${micro}</span>` : null}</span>` : null}
<span class="field__box brackets">
${icon ? html`<${Icon} name=${icon} class="field__icon" />` : null}
<input id=${id} ref=${ref} class="field__input" name=${name} value=${value} placeholder=${placeholder}
maxLength=${maxLength} disabled=${disabled} autocomplete="off" spellcheck=${false}
onInput=${handle}
oncompositionstart=${() => { composing.current = true; }}
oncompositionend=${(e) => { composing.current = false; handle(e); }}
onKeyDown=${(e) => { if (e.key === 'Enter' && !e.isComposing && !composing.current) onEnter?.(e); }} />
</span>
${hint ? html`<span class="field__hint">${hint}</span>` : null}
</label>`;
}
/** Global overlay hosts for imperative dialogs and tooltips. Mount once near the root. */
export function UiHosts() {
return html`<${Fragment}><${DialogHost} /><${TooltipLayer} /><//>`;
}
+53
View File
@@ -0,0 +1,53 @@
// Connection banner (global chrome, mounted once by main.js): reconnecting / closed / rejected-hello states with
// the matching action, and "正在同步同盟状态…" while a resumed session waits for its room/match state. Outside a
// match it sits at the bottom centre; in a match (html.sp-in-match, set by the match screen) it moves under the top bar
// so it never covers the combat view switcher or the shop bar. While it shows, html.sp-conn moves the toasts below it
// (classes instead of CSS :has(), which Firefox ESR / Safari < 15.4 lack).
import { html, Button, Icon, useTicker } from './components.js';
import { net, CLIENT_ERR_TEXT } from '../net.js';
import { useStore, shallowEqual } from '../store.js';
import { useDocClass } from './device.js';
/** Whether the banner shows for this connection state (mirrors the early returns below). */
export function bannerVisible(conn, entered, restoring) {
if (!entered || !conn) return false;
if (conn.status === 'online') return !!restoring;
if (!conn.everOnline && (conn.status === 'connecting' || conn.status === 'handshaking' || conn.status === 'idle')) return false;
return true;
}
export function ConnectionBanner() {
const conn = useStore((s) => s.connection, shallowEqual);
const entered = useStore((s) => s.session.entered);
const restoring = useStore((s) => s.ui.restoring);
useTicker(conn.status === 'reconnecting' ? 500 : 0);
useDocClass('sp-conn', bannerVisible(conn, entered, restoring));
if (!entered) return null;
if (conn.status === 'online' && !restoring) return null;
if (conn.status === 'online' && restoring) {
return html`<div class="conn-banner" role="status"><${Icon} name="refresh" /><span>正在同步同盟状态…</span></div>`;
}
if (!conn.everOnline && (conn.status === 'connecting' || conn.status === 'handshaking' || conn.status === 'idle')) return null;
const secs = conn.retryAt ? Math.max(0, Math.ceil((conn.retryAt - Date.now()) / 1000)) : 0;
const replaced = conn.status === 'closed' && conn.lastError?.code === 'REPLACED';
const rejected = conn.status === 'connected' && !!conn.lastError; // hello refused (version, server full…)
const versionMismatch = rejected && conn.lastError.text === CLIENT_ERR_TEXT.VERSION;
// Short transitional states (a rename re-sends hello on the live socket) only show if they linger.
const transient = conn.status === 'connecting' || conn.status === 'handshaking' || (conn.status === 'connected' && !rejected);
const text = conn.status === 'reconnecting'
? '与服务器的连接已中断,正在重连'
: replaced ? '该身份已在其他页面登录'
: conn.status === 'closed' ? '连接已关闭'
: rejected ? conn.lastError.text : '正在连接服务器';
const action = conn.status === 'reconnecting' ? { label: '立即重连', run: () => net.retryNow() }
: conn.status === 'closed' ? { label: replaced ? '在此页面继续' : '重新连接', run: () => net.connect() }
: versionMismatch ? { label: '刷新页面', run: () => location.reload() }
: rejected ? { label: '重试', run: () => net.reconnectNow() } : null;
return html`<div class=${`conn-banner${transient ? ' conn-banner--soft' : ''}`} role="alert">
<${Icon} name="wifiOff" />
<span>${text}</span>
${conn.status === 'reconnecting' ? html`<span class="conn-banner__sub">第 ${conn.attempt} 次 · ${secs}s</span>` : null}
${action ? html`<${Button} size="sm" variant="secondary" icon="refresh" onClick=${action.run}>${action.label}<//>` : null}
</div>`;
}
+348
View File
@@ -0,0 +1,348 @@
// Detail panel (click / right-click a piece, shop card, bond member, battle unit or previewed enemy):
// operators — portrait, name, tier, elite, class/subclass and, right under them in the header's right column (no
// scrolling, user playtest #2 item 9), the unit's bonds (阵营 / 盟约: icon, name, member count / next threshold,
// reached tier, active state — tap one for its popup); then stats, range mini-map, skill (the one chosen in the loadout, DESIGN §16: icon, SP info, rich description,
// 已调配 when not the default), elite module (模组: official type icon from the local-client art, else its letter), talents, 特质 (garrison), equipped items; items — icon, tier,
// effect; tokens; enemies — stats, rank, faction tags, abilities. Selling / destroying is the underframe's job in the
// match (research 09 §5, ui/underframe.js): the panel's own 出售 / 销毁 buttons only render for callers that pass
// `editable` + handlers. `side` 'right' docks the panel at the right edge (the game screen picks the side away from a
// selected unit's underframe, gameLogic panelSide).
import { html, Icon, TierChip, MicroLabel, Button, confirmDialog } from './components.js';
import { Img, RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.js';
import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier } from './gameLogic.js';
import { chessPortraitUrl, skillIconUrl, skillRecordIconUrl, profIconUrl, subProfIconUrl, itemIconUrl, enemyIconUrl, tokenAvatarUrl, factionIconUrl, uiUrl, moduleTypeIconUrl } from './assetUrls.js';
import { data } from '../data.js';
import { attackRangeGrid } from '../../../shared/loadoutRecord.js';
import { moduleBadge } from './loadoutModel.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
const PROF_NAME = { PIONEER: '先锋', WARRIOR: '近卫', TANK: '重装', SNIPER: '狙击', CASTER: '术师', MEDIC: '医疗', SUPPORT: '辅助', SPECIAL: '特种', TOKEN: '召唤物' };
const SP_TYPE = { INCREASE_WITH_TIME: '自动回复', INCREASE_WHEN_ATTACK: '攻击回复', INCREASE_WHEN_TAKEN_DAMAGE: '受击回复', ON_DEPLOY: '被动' };
const SKILL_TYPE = { MANUAL: '自动触发', AUTO: '自动触发', PASSIVE: '被动' };
const EVENT_ICON = { IN_BATTLE: 's_icon_battle', SERVER_GAIN: 's_icon_bond', SERVER_PREP_START: 's_icon_support', SERVER_PREP_FIN: 's_icon_support', SERVER_CHESS_SOLD: 's_icon_gold', SERVER_PRICE: 's_icon_gold', SERVER_REFRESH_SHOP: 's_icon_gold' };
const RANK = { NORMAL: '普通', ELITE: '精英', BOSS: '领袖' };
const DMG = { phys: '物理', arts: '法术', heal: '治疗', true: '真实', none: '无' };
/** Mini range map. */
export function RangeGrid({ grid, class: cls }) {
const box = rangeGridBox(grid);
if (!box.cells.size) return html`<span class="t-dim">—</span>`;
const cells = [];
for (let r = box.r0; r > box.r0 - box.rows; r--) {
for (let c = box.c0; c < box.c0 + box.cols; c++) {
const self = r === 0 && c === 0;
cells.push(html`<i key=${`${r},${c}`} class=${cx(box.cells.has(tileKey(r, c)) && 'on', self && 'self')}></i>`);
}
}
return html`<div class=${cx('rgrid', cls)} style=${`grid-template-columns:repeat(${box.cols}, var(--rg))`} aria-label="攻击范围">${cells}</div>`;
}
function Stat({ k, v, sub }) {
return html`<div class="dstat"><span class="dstat__k">${k}</span><b class="dstat__v num">${v}</b>${sub ? html`<small>${sub}</small>` : null}</div>`;
}
function Section({ title, micro, children, class: cls }) {
return html`<section class=${cx('dsec', cls)}>
<h4 class="dsec__title">${title}${micro ? html`<${MicroLabel}>${micro}</${MicroLabel}>` : null}</h4>
${children}
</section>`;
}
function ItemRow({ itemId }) {
const it = data.lookup('items', itemId);
return html`<div class="ditem">
<${UnitThumb} kind="item" id=${itemId} size="sm" />
<div class="ditem__text"><b>${it?.name || itemId}</b><${RichText} text=${it?.descRaw || it?.desc || ''} class="ditem__desc" /></div>
</div>`;
}
/**
* The unit's bonds right under the header: icon, name, the player's member count / next threshold, reached tier.
* @param {{ bondIds: string[], bonds?: any[], onBond?: (bondId: string) => void }} props
*/
export function BondChips({ bondIds, bonds = [], onBond = null }) {
const ids = Array.isArray(bondIds) ? bondIds.filter((b) => typeof b === 'string') : [];
if (!ids.length) return null;
const mine = new Map((Array.isArray(bonds) ? bonds : []).filter((b) => b && typeof b.bondId === 'string').map((b) => [b.bondId, b]));
return html`<div class="dbonds dbonds--top" role="list" aria-label="所属盟约">
${ids.map((id) => {
const rec = data.lookup('bonds', id);
const e = mine.get(id) || null;
const th = Array.isArray(e?.thresholds) && e.thresholds.length ? e.thresholds : Array.isArray(rec?.thresholds) ? rec.thresholds : [];
const count = Number.isFinite(e?.count) ? e.count : 0;
const tier = Number.isFinite(e?.tier) ? e.tier : bondTier(count, th, rec?.maxCount ?? null);
const active = e ? !!e.active : tier > 0;
const next = nextThreshold(count, th);
const cap = next ?? th[th.length - 1] ?? null;
const label = `${rec?.name || id}:在场 ${count}${cap != null ? `/${cap}` : ''}${active ? `,已激活 ${tier} 阶` : ',未激活'}`;
const body = html`
<${BondGlyph} bondId=${id} class="dbond__icon" />
<span class="dbond__name">${rec?.name || id}</span>
<span class=${cx('dbond__count', 'num', next == null && count > 0 && 'is-max')}>${count}${cap != null ? html`<small>/${cap}</small>` : null}</span>
${th.length ? html`<span class="dbond__tiers" aria-hidden="true">${th.map((_, i) => html`<i key=${i} class=${i < tier ? 'on' : ''}></i>`)}</span>` : null}`;
return onBond
? html`<button key=${id} type="button" role="listitem" class=${cx('dbond', active && 'is-active', rec?.isCore && 'is-core')} title=${label} aria-label=${label}
data-bond=${id} onClick=${() => onBond(id)}>${body}</button>`
: html`<span key=${id} role="listitem" class=${cx('dbond', active && 'is-active', rec?.isCore && 'is-core')} title=${label} data-bond=${id}>${body}</span>`;
})}
</div>`;
}
/**
* 特性 text of the record the unit fights with (DESIGN §16: `lo.record` = the chosen module's traitOverride, or the
* no-module traitBase for 不装备 — data `trait` is the default module's).
*/
function traitText(c, golden, lo) {
const t = (lo?.record || c).trait || {};
const base = t.descRaw || t.desc || '';
if (!golden || lo?.record !== c) return base;
return t.moduleDescRaw || base;
}
function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, loadout, onBond }) {
const m = data.get('assets');
const lo = chessLoadout(chess, loadout, (id) => data.lookup('chess', id));
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 || c;
const s = fr.stats || {};
const golden = !!(c.isGolden || piece?.golden);
const interval = attackInterval(s.bat, s.aspd);
const sk = lo?.skill || c.skill || null;
// a chosen skill the manifest has no icon for (only the default skills' icons are fetched): its slot letter
const skIcon = sk && lo && !lo.defaultSkill ? skillRecordIconUrl(m, sk, { empty: false }) : skillIconUrl(m, c);
const skSlot = sk && Number.isInteger(sk.index) ? `S${sk.index + 1}` : null;
const garrison = Array.isArray(c.garrisonIds) && c.garrisonIds[0] ? data.lookup('garrisons', c.garrisonIds[0]) : null;
const items = Array.isArray(piece?.items) ? piece.items : [];
const sell = c.sellPrice ?? 1;
return html`
<div class="dhead">
<div class=${cx('dhead__art', golden && 'is-golden', `dhead__art--t${c.tier}`)}>
<${Img} src=${chessPortraitUrl(m, c)} fallback=${html`<${UnitThumb} kind="chess" id=${c.chessId} size="lg" />`} />
</div>
<div class="dhead__info">
<div class="dhead__chips">
<${TierChip} tier=${c.tier} golden=${golden} size="lg" />
${golden ? html`<span class="dtag-elite">精锐</span>` : null}
${piece?.kind === 'token' ? html`<span class="dtag-token">召唤物</span>` : null}
</div>
<h3 class="dhead__name">${c.name}</h3>
<span class="dhead__en">${c.appellation || ''}</span>
<div class="dhead__class">
<${Img} src=${profIconUrl(m, c.profession)} class="dhead__prof" />
<span>${PROF_NAME[c.profession] || c.profession || ''}</span>
<i class="sep"></i>
<${Img} src=${subProfIconUrl(m, c)} class="dhead__sub" />
<span>${c.subProfessionName || ''}</span>
<span class="dhead__pos">${c.position === 'MELEE' ? '近战位' : '远程位'}</span>
</div>
${snapHp ? html`<div class="dhp"><i style=${`width:${Math.max(0, Math.min(100, (snapHp.hp / Math.max(1, snapHp.max)) * 100))}%`}></i><span class="num">${fmtNum(snapHp.hp)} / ${fmtNum(snapHp.max)}</span></div>` : null}
<${BondChips} bondIds=${c.bonds} bonds=${bonds} onBond=${onBond} />
</div>
</div>
${c.trait?.desc ? html`<p class="dtrait"><${Icon} name="info" /><${RichText} text=${traitText(c, golden, lo)} /></p>` : null}
<div class="dstats-wrap">
<div class="dstats">
<${Stat} k="生命上限" v=${fmtNum(s.maxHp)} />
<${Stat} k="攻击" v=${fmtNum(s.atk)} />
<${Stat} k="防御" v=${fmtNum(s.def)} />
<${Stat} k="法术抗性" v=${s.res ?? 0} />
<${Stat} k="攻击间隔" v=${interval ? `${interval.toFixed(2)}s` : '—'} />
<${Stat} k="阻挡数" v=${s.blockCnt ?? '—'} />
<${Stat} k="部署费用" v=${s.cost ?? '—'} />
<${Stat} k="再部署" v=${s.respawnTime != null ? `${s.respawnTime}s` : '—'} />
</div>
<div class="drange"><span class="dstat__k">攻击范围</span><${RangeGrid} grid=${attackRangeGrid(fr) || c.rangeGrid} /></div>
</div>
${sk ? html`<${Section} title="技能" micro="SKILL" class="dsec--skill">
<div class="dskill" data-skill=${sk.skillId || ''}>
<${Img} src=${skIcon} class="dskill__icon" fallback=${html`<span class="dskill__icon dskill__icon--empty">${skSlot ? html`<b class="num">${skSlot}</b>` : null}</span>`} />
<div class="dskill__meta">
<b class="dskill__name">${skSlot && (lo?.choices || 0) > 1 ? html`<span class="dskill__slot num" title=${`技能 ${skSlot}`}>${skSlot}</span>` : null}${sk.name}${lo && !lo.defaultSkill ? html`<span class="dtag-loadout" title="干员调配中选择的技能">已调配</span>` : null}</b>
<div class="dskill__tags">
<span class="dsp dsp--${sk.spType === 'INCREASE_WHEN_ATTACK' ? 'atk' : sk.spType === 'INCREASE_WHEN_TAKEN_DAMAGE' ? 'def' : 'time'}">${SP_TYPE[sk.spType] || '技力'}</span>
<span class="dsp dsp--trig">${SKILL_TYPE[sk.skillType] || '自动触发'}</span>
${sk.spType !== 'ON_DEPLOY' && sk.skillType !== 'PASSIVE' ? html`<span class="dsp__num"><${GIcon} name="bolt" />初始 <b class="num">${sk.initSp ?? 0}</b> · 消耗 <b class="num">${sk.spCost ?? 0}</b></span>` : null}
${sk.duration > 0 ? html`<span class="dsp__num">持续 <b class="num">${sk.duration}</b>s</span>` : null}
${sk.maxChargeTime > 1 ? html`<span class="dsp__num">充能 <b class="num">${sk.maxChargeTime}</b></span>` : null}
</div>
</div>
</div>
<${RichText} as="p" text=${sk.descRaw || sk.desc} class="dtext" />
<//>` : null}
${golden && lo?.module ? html`<${Section} title="模组" micro="MODULE" class="dsec--module">
<div class=${cx('dmodule', lo.module.none && 'is-none')} data-module=${lo.module.id}>
${!lo.module.none && lo.module.typeName ? html`<span class="dmodule__icon" data-type=${lo.module.typeName}>
<${Img} src=${moduleTypeIconUrl(data.get('local'), lo.module.typeName)} fallback=${html`<b class="num">${moduleBadge(lo.module)}</b>`} /></span>` : null}
<b class="dmodule__name">${lo.module.name}</b>
${lo.module.typeName ? html`<span class="dmodule__type">${lo.module.typeName}</span>` : null}
${!lo.defaultModule ? html`<span class="dtag-loadout" title="干员调配中选择的模组">已调配</span>` : null}
</div>
<//>` : null}
${Array.isArray(fr.talents) && fr.talents.some((t) => t && t.name && !t.hidden) ? html`<${Section} title="天赋" micro="TALENT">
${fr.talents.filter((t) => t && t.name && !t.hidden).map((t, i) => html`<div key=${i} class="dtalent"><b>${t.name}</b><${RichText} text=${t.descRaw || t.desc} class="dtext" /></div>`)}
<//>` : null}
${garrison ? html`<${Section} title="特质" micro="GARRISON">
<div class="dgarrison">
<span class="dgarrison__type">
<${Img} src=${uiUrl(m, `garrisonTypeIcon/${EVENT_ICON[garrison.eventType] || 's_icon_bond'}`)} class="dgarrison__icon" />
${garrison.eventTypeDesc || ''}
</span>
<${RichText} as="p" text=${garrison.descRaw || garrison.desc} class="dtext" />
</div>
<//>` : null}
${piece?.kind === 'chess' ? html`<${Section} title="装备" micro=${`EQUIP ${items.length}/2`}>
${items.length ? items.map((it) => html`<${ItemRow} key=${it.uid} itemId=${it.id} />`) : html`<p class="t-dim dempty">拖拽装备至该干员以配发(最多 2 件)</p>`}
<//>` : null}
${piece && editable && piece.kind !== 'item' ? html`<div class="dactions">
<${Button} variant="amber" icon="close" class="dpanel__sell" onClick=${() => onSell(piece, c)}>出售<span class="dsell num">+${sell}</span><//>
</div>` : null}`;
}
function ItemDetail({ item, piece, editable, onDestroy }) {
const m = data.get('assets');
return html`
<div class="dhead dhead--item">
<div class=${cx('dhead__icon', item.isGolden && 'is-golden')}><${Img} src=${itemIconUrl(m, item)} fallback=${html`<${GIcon} name="bolt" />`} /></div>
<div class="dhead__info">
<div class="dhead__chips"><${TierChip} tier=${item.tier} golden=${item.isGolden} size="lg" />${item.isGolden ? html`<span class="dtag-elite">进阶</span>` : null}
<span class="dtag-kind">${item.itemType === 'MAGIC' ? '奇术' : '装备'}</span></div>
<h3 class="dhead__name">${item.name}</h3>
${item.flavor ? html`<span class="dhead__flavor">${item.flavor}</span>` : null}
</div>
</div>
<${Section} title="效果" micro="EFFECT"><${RichText} as="p" text=${item.descRaw || item.desc} class="dtext" /><//>
${item.itemType === 'MAGIC'
? html`<p class="dhint"><${Icon} name="info" />将其拖拽至战场上的格子使用</p>`
: html`<p class="dhint"><${Icon} name="info" />拖拽至干员身上进行配发(每名干员最多 2 件,配发后无法取下)${item.mergeable ? ';2 件相同装备自动合成进阶装备' : ''}</p>`}
${piece && editable ? html`<div class="dactions"><${Button} variant="danger" onClick=${() => onDestroy(piece, item)}>销毁道具<//></div>` : null}`;
}
function EnemyDetail({ enemy, snapHp, count }) {
const m = data.get('assets');
const s = enemy.stats || {};
const types = Array.isArray(enemy.acTypes) ? enemy.acTypes : enemy.acType ? [enemy.acType] : [];
const factions = data.get('factions')?.types || {};
const imm = Object.entries(s.immunities || {}).filter(([, v]) => v).map(([k]) => ({ stun: '晕眩', silence: '沉默', sleep: '沉睡', frozen: '冻结', levitate: '浮空' }[k] || k));
const interval = attackInterval(s.bat, s.aspd);
return html`
<div class="dhead dhead--enemy">
<div class=${cx('dhead__icon', 'dhead__icon--enemy', enemy.rank === 'BOSS' && 'is-boss', enemy.rank === 'ELITE' && 'is-elite')}>
<${Img} src=${enemyIconUrl(m, enemy.key)} fallback=${html`<${GIcon} name="skull" />`} />
</div>
<div class="dhead__info">
<div class="dhead__chips">
<span class=${cx('drank', `drank--${(enemy.rank || 'NORMAL').toLowerCase()}`)}>${RANK[enemy.rank] || '普通'}</span>
<span class="dtag-kind">${s.motion === 'FLY' ? '空中' : '地面'}</span>
${count ? html`<span class="dtag-kind num">×${count}</span>` : null}
</div>
<h3 class="dhead__name">${enemy.name}</h3>
<div class="dfactions">${types.map((t) => html`<span key=${t} class="dfaction"><${Img} src=${factionIconUrl(m, factions[t]?.icon)} />${factions[t]?.name || t}</span>`)}</div>
${snapHp ? html`<div class="dhp dhp--enemy"><i style=${`width:${Math.max(0, Math.min(100, (snapHp.hp / Math.max(1, snapHp.max)) * 100))}%`}></i><span class="num">${fmtNum(snapHp.hp)} / ${fmtNum(snapHp.max)}</span></div>` : null}
</div>
</div>
<div class="dstats">
<${Stat} k="生命上限" v=${fmtNum(s.maxHp)} />
<${Stat} k="攻击" v=${fmtNum(s.atk)} sub=${DMG[s.dmgType] || ''} />
<${Stat} k="防御" v=${fmtNum(s.def)} />
<${Stat} k="法术抗性" v=${s.res ?? 0} />
<${Stat} k="移动速度" v=${s.moveSpeed ?? '—'} />
<${Stat} k="攻击间隔" v=${interval ? `${interval.toFixed(1)}s` : '—'} />
<${Stat} k="攻击范围" v=${s.rangeRadius > 0 ? s.rangeRadius : '近战'} />
<${Stat} k="目标价值" v=${s.lpr ?? 1} />
</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>
<//>` : enemy.descRaw || enemy.desc ? html`<${Section} title="说明"><${RichText} as="p" text=${enemy.descRaw || enemy.desc} class="dtext" /><//>` : null}`;
}
function TokenDetail({ token, piece }) {
const m = data.get('assets');
const s = token.stats || {};
return html`
<div class="dhead dhead--item">
<div class="dhead__icon"><${Img} src=${tokenAvatarUrl(m, token.tokenId)} fallback=${html`<${GIcon} name="target" />`} /></div>
<div class="dhead__info">
<div class="dhead__chips"><span class="dtag-token">召唤物</span>${piece?.count > 1 ? html`<span class="dtag-kind num">×${piece.count}</span>` : null}</div>
<h3 class="dhead__name">${token.name}</h3>
</div>
</div>
<div class="dstats">
<${Stat} k="生命上限" v=${fmtNum(s.maxHp)} /><${Stat} k="攻击" v=${fmtNum(s.atk)} />
<${Stat} k="防御" v=${fmtNum(s.def)} /><${Stat} k="阻挡数" v=${s.blockCnt ?? '—'} />
</div>
${token.descRaw || token.desc ? html`<${Section} title="说明"><${RichText} as="p" text=${token.descRaw || token.desc} class="dtext" /><//>` : null}`;
}
/**
* Resolve what a detail target shows.
* @param {{ kind:'piece'|'chess'|'item'|'enemy'|'unit'|'token', id?:string, uid?:number, unit?:any, count?:number }} target
* @param {Map<number, any>} pieces indexPieces(priv)
*/
export function resolveDetail(target, pieces) {
if (!target) return null;
if (target.kind === 'piece') {
const e = pieces?.get(target.uid);
if (!e) return null;
const p = e.piece;
if (p.kind === 'item') { const it = data.lookup('items', p.id); return it ? { type: 'item', item: it, piece: p } : null; }
if (p.kind === 'token') { const t = data.lookup('tokens', p.id); return t ? { type: 'token', token: t, piece: p } : null; }
const c = data.lookup('chess', p.id);
return c ? { type: 'chess', chess: c, piece: p } : null;
}
if (target.kind === 'chess') { const c = data.lookup('chess', target.id); return c ? { type: 'chess', chess: c } : 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; }
if (target.kind === 'token') { const t = data.lookup('tokens', target.id); return t ? { type: 'token', token: t } : null; }
if (target.kind === 'unit') {
const u = target.unit || {};
const own = Number.isInteger(u.uid) ? pieces?.get(u.uid) : null;
if (u.side === 'enemy') { const en = data.lookup('enemies', u.defId); return en ? { type: 'enemy', enemy: en, unitId: u.id } : null; }
const c = data.lookup('chess', u.defId);
if (c) return { type: 'chess', chess: c, piece: own?.piece || null, unitId: u.id };
const t = data.lookup('tokens', u.defId);
if (t) return { type: 'token', token: t, unitId: u.id };
const en = data.lookup('enemies', u.defId);
return en ? { type: 'enemy', enemy: en, unitId: u.id } : null;
}
return null;
}
/**
* The panel.
* @param {{ detail:any, editable:boolean, snapHp?:{hp:number,max:number}|null, onClose:Function, onSell:(piece:any)=>void, onDestroy:(piece:any)=>void,
* bonds?: any[], loadout?: any, onBond?: (bondId:string)=>void, side?: 'left'|'right', shopOpen?: boolean }} props
* bonds: the owner's m.private.bonds (counts / tiers of the bond chips); 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
*/
export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestroy, bonds = [], loadout = null, onBond = null, side = 'left', shopOpen = false }) {
if (!detail) return null;
const sellIt = async (piece, chess) => {
const golden = piece.golden || chess?.isGolden;
if (golden) {
const ok = await confirmDialog({ title: '出售精锐干员', text: `确定要出售精锐干员「${chess?.name || ''}」吗?出售后获得 ${chess?.sellPrice ?? 1} 资金。`, okText: '出售', danger: true });
if (!ok) return;
}
onSell(piece);
};
const destroyIt = async (piece, item) => {
const ok = await confirmDialog({ title: '销毁道具', text: `道具无法出售。确定要销毁「${item?.name || ''}」吗?`, okText: '销毁', danger: true });
if (ok) onDestroy(piece);
};
return html`<aside class=${cx('dpanel', 'brackets', `dpanel--${detail.type}`, side === 'right' && 'dpanel--right', side === 'right' && shopOpen && 'is-shop')} role="dialog" aria-label="详情"
data-side=${side === 'right' ? 'right' : 'left'}>
<button type="button" class="dpanel__close" aria-label="关闭" onClick=${onClose}><${Icon} name="close" /></button>
<div class="dpanel__scroll">
${detail.type === 'chess' ? html`<${ChessDetail} chess=${detail.chess} piece=${detail.piece} snapHp=${snapHp} editable=${editable} onSell=${sellIt}
bonds=${bonds} loadout=${loadout} onBond=${onBond} />` : null}
${detail.type === 'item' ? html`<${ItemDetail} item=${detail.item} piece=${detail.piece} editable=${editable} onDestroy=${destroyIt} />` : null}
${detail.type === 'enemy' ? html`<${EnemyDetail} enemy=${detail.enemy} snapHp=${snapHp} count=${detail.count} />` : null}
${detail.type === 'token' ? html`<${TokenDetail} token=${detail.token} piece=${detail.piece} />` : null}
</div>
</aside>`;
}
+257
View File
@@ -0,0 +1,257 @@
// Multi-device support (desktop Chrome / Edge / Firefox / Safari, iPad and Android tablets in landscape, phones in
// landscape ≥ 640×360 CSS px). Everything is feature-detected — never UA-sniffed:
//
// detectFeatures() { touch, coarse, hover, fullscreen, standalone, reducedMotion, importMaps, … } of this browser
// installDeviceSupport() once at boot (main.js): <html> classes (sp-touch / sp-coarse / sp-hover / sp-fs /
// sp-standalone / sp-reduced-motion / sp-no-hover / sp-rotatable), kept current when an input device or a
// preference changes; blocks page zoom gestures (iOS Safari ignores user-scalable=no:
// gesturestart / gesturechange are cancelled, ctrl+wheel / trackpad pinch too; touch-action in
// css/devices.css handles the rest); re-fires `resize` once the viewport
// settles after a rotation (iOS reports the old size for a few frames) so the field view and
// the rem scale re-layout; keeps --sp-vh (the real visible height) for the CSS.
// fullscreen { supported(), active(), toggle(), exit() }: Fullscreen API with the webkit-prefixed
// variant (iPadOS / older Safari); iPhone Safari has no element fullscreen → unsupported and the
// button hides. Android: after entering, the orientation is locked to landscape when allowed.
// FullscreenButton HUD / title button (hidden where unsupported), follows fullscreenchange.
// long-press a still touch of LONG_PRESS_MS on a DOM control → a synthetic `contextmenu` (= detail) when
// the browser sends none (iOS Safari); when a handler took it (preventDefault: detail card,
// tooltip) the click of the release is swallowed — otherwise the slow tap stays a tap.
// reducedMotion() prefers-reduced-motion (the CSS side lives in css/theme.css + css/devices.css).
// 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';
/** A touch held this long without moving opens the detail (contextmenu) on DOM controls. */
export const LONG_PRESS_MS = 520;
const LONG_PRESS_SLOP = 10;
const mq = (win, q) => { try { return !!win.matchMedia?.(q)?.matches; } catch { return false; } };
/**
* Whether the SCREEN (not the viewport) is in landscape: a split-view iPad or a narrow desktop window has a portrait
* viewport on a landscape screen — rotating would not help there. screen.orientation (Chrome, Firefox, Safari 16.4+),
* else window.orientation (older iOS); iOS reports screen.width/height in portrait always, so no size guess there.
* @param {any} [win]
* @returns {boolean|null} null when unknown
*/
export function screenLandscape(win = globalThis) {
try {
const t = win.screen?.orientation?.type;
if (typeof t === 'string' && t) return t.startsWith('landscape');
} catch { /* ignore */ }
const o = Number(win.orientation);
if (win.orientation != null && Number.isFinite(o)) return Math.abs(o) === 90;
return null;
}
/**
* What this browser / device can do.
* @param {any} [win]
*/
export function detectFeatures(win = globalThis) {
const doc = win.document;
const nav = win.navigator || {};
const touchPoints = Number(nav.maxTouchPoints) || 0;
const coarse = mq(win, '(any-pointer: coarse)') || mq(win, '(pointer: coarse)');
const fine = mq(win, '(any-pointer: fine)');
const hover = mq(win, '(any-hover: hover)') || mq(win, '(hover: hover)');
const el = doc?.documentElement;
const fsEnabled = !!(doc && (doc.fullscreenEnabled || doc.webkitFullscreenEnabled))
&& !!(el && (typeof el.requestFullscreen === 'function' || typeof el.webkitRequestFullscreen === 'function'));
let importMaps = false;
try { importMaps = typeof win.HTMLScriptElement?.supports === 'function' && win.HTMLScriptElement.supports('importmap'); } catch { importMaps = false; }
return {
touch: touchPoints > 0 || coarse || 'ontouchstart' in (win || {}),
coarse,
fine,
hover,
fullscreen: fsEnabled,
standalone: mq(win, '(display-mode: standalone)') || mq(win, '(display-mode: fullscreen)') || nav.standalone === true,
reducedMotion: mq(win, '(prefers-reduced-motion: reduce)'),
screenLandscape: screenLandscape(win),
importMaps,
broadcastChannel: typeof win.BroadcastChannel === 'function',
pointerEvents: typeof win.PointerEvent === 'function',
gestureEvents: typeof win.GestureEvent === 'function', // WebKit only (pinch zoom has to be cancelled by hand)
visualViewport: !!win.visualViewport,
};
}
/** The classes installDeviceSupport puts on <html> for a feature set (pure; tested). */
export function featureClasses(f) {
return {
'sp-touch': !!f.touch,
'sp-coarse': !!f.coarse,
'sp-hover': !!f.hover,
'sp-no-hover': !f.hover,
'sp-fs': !!f.fullscreen,
'sp-standalone': !!f.standalone,
'sp-reduced-motion': !!f.reducedMotion,
// the rotate hint (css/theme.css) is for a device you can turn: a touch screen whose screen is not in landscape —
// never a desktop browser window that happens to be narrow, nor a split-view iPad
'sp-rotatable': !!f.coarse && f.screenLandscape !== true,
};
}
/** prefers-reduced-motion: reduce */
export function reducedMotion(win = globalThis) { return mq(win, '(prefers-reduced-motion: reduce)'); }
/**
* Keep a class on <html> while a component is mounted and `on` holds (replaces CSS :has(), which Firefox ESR 115 and
* Safari < 15.4 lack): the match screen sets sp-in-match, the connection banner sp-conn.
* @param {string} name
* @param {boolean} [on]
*/
export function useDocClass(name, on = true) {
useEffect(() => {
const el = globalThis.document?.documentElement;
if (!el || !on) return undefined;
el.classList.add(name);
return () => el.classList.remove(name);
}, [name, !!on]);
}
// ---- fullscreen ----------------------------------------------------------------------------------------------------
export const fullscreen = {
supported(win = globalThis) { return detectFeatures(win).fullscreen; },
active(win = globalThis) { const d = win.document; return !!(d && (d.fullscreenElement || d.webkitFullscreenElement)); },
async enter(win = globalThis) {
const el = win.document?.documentElement;
if (!el) return false;
try {
if (typeof el.requestFullscreen === 'function') await el.requestFullscreen({ navigationUI: 'hide' });
else if (typeof el.webkitRequestFullscreen === 'function') el.webkitRequestFullscreen();
else return false;
} catch { return false; }
// Android Chrome: a landscape game may lock the orientation while fullscreen (refused elsewhere — ignored)
try { await win.screen?.orientation?.lock?.('landscape'); } catch { /* not allowed here */ }
return true;
},
async exit(win = globalThis) {
const d = win.document;
try {
if (d?.fullscreenElement && typeof d.exitFullscreen === 'function') await d.exitFullscreen();
else if (d?.webkitFullscreenElement && typeof d.webkitExitFullscreen === 'function') d.webkitExitFullscreen();
} catch { return false; }
try { win.screen?.orientation?.unlock?.(); } catch { /* ignore */ }
return true;
},
toggle(win = globalThis) { return this.active(win) ? this.exit(win) : this.enter(win); },
};
/** Fullscreen toggle (hidden where the browser has no element fullscreen, e.g. iPhone Safari). */
export function FullscreenButton({ class: cls = '' }) {
const [on, setOn] = useState(() => fullscreen.active());
const [ok] = useState(() => fullscreen.supported());
useEffect(() => {
const d = globalThis.document;
if (!d) return undefined;
const upd = () => setOn(fullscreen.active());
d.addEventListener('fullscreenchange', upd);
d.addEventListener('webkitfullscreenchange', upd);
return () => { d.removeEventListener('fullscreenchange', upd); d.removeEventListener('webkitfullscreenchange', upd); };
}, []);
if (!ok) return null;
const label = on ? '退出全屏' : '全屏';
return html`<button type="button" class=${`fsbtn tapx ${cls}`} aria-label=${label} title=${label} aria-pressed=${on ? 'true' : 'false'}
onClick=${() => fullscreen.toggle()}>
<${Icon} name=${on ? 'collapse' : 'expand'} />
</button>`;
}
// ---- boot-time installation ----------------------------------------------------------------------------------------
let installed = null;
/**
* Install the device handling on this page (idempotent). Returns a disposer (tests).
* @param {any} [win]
*/
export function installDeviceSupport(win = globalThis) {
if (installed) return installed;
const doc = win.document;
if (!doc?.documentElement) return () => {};
const root = doc.documentElement;
const offs = [];
const on = (target, ev, fn, opts) => { if (!target?.addEventListener) return; target.addEventListener(ev, fn, opts); offs.push(() => target.removeEventListener(ev, fn, opts)); };
const apply = () => {
const cls = featureClasses(detectFeatures(win));
for (const [k, v] of Object.entries(cls)) root.classList.toggle(k, v);
};
apply();
for (const q of ['(any-pointer: coarse)', '(pointer: coarse)', '(any-hover: hover)', '(prefers-reduced-motion: reduce)', '(display-mode: standalone)']) {
let m = null;
try { m = win.matchMedia?.(q); } catch { m = null; }
if (!m) continue;
if (typeof m.addEventListener === 'function') on(m, 'change', apply);
else if (typeof m.addListener === 'function') { m.addListener(apply); offs.push(() => m.removeListener(apply)); } // Safari < 14
}
// visible height (the URL bar of mobile browsers comes and goes): --sp-vh = 1 % of the real visible height
const setVh = () => {
const h = win.visualViewport?.height || win.innerHeight || 0;
if (h > 0) root.style.setProperty('--sp-vh', `${h / 100}px`);
};
setVh();
on(win, 'resize', setVh, { passive: true });
on(win.visualViewport, 'resize', setVh, { passive: true });
// rotation: the new viewport size settles a few frames late on iOS → one more resize after it settles
let rotTimer = 0;
const onRotate = () => {
apply();
clearTimeout(rotTimer);
rotTimer = setTimeout(() => { apply(); setVh(); try { win.dispatchEvent(new win.Event('resize')); } catch { /* ignore */ } }, 350);
};
on(win, 'orientationchange', onRotate);
on(win.screen?.orientation, 'change', onRotate);
// no page zoom while playing (iOS Safari ignores user-scalable=no): pinch gestures and ctrl+wheel are cancelled
const cancel = (e) => { if (e.cancelable) e.preventDefault(); };
on(doc, 'gesturestart', cancel, { passive: false });
on(doc, 'gesturechange', cancel, { passive: false });
on(doc, 'gestureend', cancel, { passive: false });
on(doc, 'wheel', (e) => { if (e.ctrlKey && e.cancelable) e.preventDefault(); }, { passive: false });
// (double-tap zoom and two-finger pinch on Chromium / Firefox / iOS 13+: `touch-action` in css/devices.css — no
// scroll-blocking touchmove listener, so lists keep scrolling smoothly)
// long-press = detail on DOM controls: Android fires `contextmenu` on a long press by itself, iOS Safari never does.
// A touch held still for LONG_PRESS_MS without a native contextmenu gets a synthetic one (the shop cards, reward cards
// and the fallback board open their detail cards on it), and the click the release would cause is swallowed. The field
// canvas handles its own long-press (render/drag.js) and is skipped.
const lp = { timer: 0, x: 0, y: 0, target: null, native: false, fired: false, swallowUntil: 0 };
const lpCancel = () => { clearTimeout(lp.timer); lp.timer = 0; lp.target = null; };
on(doc, 'pointerdown', (e) => {
lpCancel();
lp.fired = false;
if (e.pointerType !== 'touch' || !(e.target instanceof win.Element)) return;
if (e.target.closest('canvas, .gm__field, .fwheel, .ewheel__viewport, input, textarea, select, [contenteditable="true"]')) return;
lp.x = e.clientX; lp.y = e.clientY; lp.target = e.target; lp.native = false;
lp.timer = setTimeout(() => {
const t = lp.target;
lp.timer = 0;
if (!t || lp.native || !t.isConnected) return;
// Only a HANDLED long press (a detail card / a tooltip took it: the handler called preventDefault) swallows the
// release. A slow tap on a control without a long-press meaning (准备就绪, 撤退, 出售 …) stays a tap — iOS Safari
// still sends its click, and swallowing it made those buttons ignore any tap held ≥ LONG_PRESS_MS.
try {
const ev = new win.MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: lp.x, clientY: lp.y, button: 2, buttons: 0 });
lp.fired = t.dispatchEvent(ev) === false || ev.defaultPrevented === true;
} catch { lp.fired = false; }
}, LONG_PRESS_MS);
}, { passive: true, capture: true });
on(doc, 'pointermove', (e) => { if (lp.timer && Math.hypot(e.clientX - lp.x, e.clientY - lp.y) > LONG_PRESS_SLOP) lpCancel(); }, { passive: true, capture: true });
// the release of a long press (however long it was held) is not a tap: the click that follows it is swallowed
on(doc, 'pointerup', () => { if (lp.fired) { lp.fired = false; lp.swallowUntil = Date.now() + 700; } lpCancel(); }, { passive: true, capture: true });
on(doc, 'pointercancel', () => { lp.fired = false; lpCancel(); }, { passive: true, capture: true });
on(doc, 'contextmenu', (e) => { if (e.isTrusted) lp.native = true; }, { capture: true });
on(doc, 'click', (e) => {
if (lp.swallowUntil && Date.now() < lp.swallowUntil) { lp.swallowUntil = 0; e.preventDefault(); e.stopPropagation(); }
}, { capture: true });
installed = () => { for (const off of offs.splice(0)) { try { off(); } catch { /* ignore */ } } clearTimeout(rotTimer); installed = null; };
return installed;
}
+28
View File
@@ -0,0 +1,28 @@
// Active effects list (m.private.effects: band / 机变 / team / item / garrison effects with counters):
// compact icon column at the right edge, rich-text tooltip per effect.
import { html, Tooltip, MicroLabel } from './components.js';
import { Img, RichText, GIcon } from './gameComponents.js';
import { effectIconUrl } from './assetUrls.js';
import { data } from '../data.js';
const KIND = { band: '策略', choice: '机变', team: '团队增益', item: '道具', garrison: '特质' };
/** @param {{ effects: any[] }} props */
export function EffectsList({ effects }) {
const list = (Array.isArray(effects) ? effects : []).filter((e) => e && (e.name || e.desc));
if (!list.length) return null;
const m = data.get('assets');
return html`<div class="effects" aria-label="生效中的效果">
<${MicroLabel}>EFFECTS</${MicroLabel}>
${list.slice(0, 10).map((e, i) => html`<${Tooltip} key=${e.id ?? i} placement="bottom" text=${html`<div class="efftip">
<b>${e.name || '效果'}</b><span class="efftip__kind">${KIND[e.iconKind] || ''}${e.counter != null ? ` · ${e.counter}` : ''}</span>
<${RichText} as="p" text=${e.desc || ''} />
</div>`}>
<span class=${`effect effect--${e.iconKind || 'x'}`}>
<${Img} src=${effectIconUrl(m, e)} fallback=${html`<${GIcon} name="bolt" />`} />
${e.counter != null && e.counter !== '' ? html`<b class="effect__n num">${e.counter}</b>` : null}
</span>
<//>`)}
</div>`;
}
+318
View File
@@ -0,0 +1,318 @@
// Emotes (交流, research 09 §4): the 36 official in-match emotes of 盟约 — 6 themes × 6 (shared/constants.js
// EMOTE_THEMES / EMOTES, generated reference data/emotes.json). Official emotes are pictures only: nothing here ever
// renders an emote's text (our labels are aria-labels only).
//
// EmoteArt the emote picture (local-client art, data/local-assets.json → emoticon/<dir>/<picId>); a neutral glyph
// when the art is missing or fails to load, an empty box while the manifest is still loading.
// 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
// bubble mounted late resumes its timeline instead of restarting it.
// EmoteWheel bottom-left 交流 button (official emoji_btn / emoji_btn_disable) + panel (emoji_bkg): one page per theme
// with 3×2 cells (emoji_cell_bkg), pager dots, swipe / drag / arrow keys / wheel to change the page (the
// wheel turns one page per gesture, a plain vertical mouse wheel included, like Unity's ScrollRect). The
// panel reopens on the theme of the last emote sent (localStorage). Sending closes the panel and greys
// the button for the 1 s cooldown (chatCD), counted from the send so a remount keeps it.
// Official UI sprites come from the local ui/battle extraction when present; CSS draws look-alikes otherwise.
// Styles: public/css/emotes.css (injected on first use when the page does not link it).
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { EMOTE_THEMES, EMOTE_COOLDOWN_MS, EMOTE_BUBBLE_MS, emoteInfo, emoteArtGroup } from '../../../shared/constants.js';
import { html } from './components.js';
import { GIcon } from './gameComponents.js';
import { data, useData, localAsset } from '../data.js';
import { loadPref, savePref } from '../store.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
export const EMOTE_CSS_HREF = '/css/emotes.css';
const PREF_THEME = 'emoteTheme';
const SWIPE_PX = 40; // horizontal drag distance that turns the page
const DRAG_SLOP_PX = 8; // below this a press is a tap, not a drag
export const WHEEL_STEP_PX = 60; // wheel / trackpad scroll that turns the page
export const WHEEL_GAP_MS = 180; // a pause this long ends a wheel gesture (a trackpad's momentum tail included)
// Time of the last send, shared by every wheel on the page: the 1 s cooldown survives a remount of the wheel (the
// server enforces chatCD too and silently drops an emote sent early).
let lastSentAt = -Infinity;
/** Link public/css/emotes.css once when the host page does not (dev harnesses); no-op outside a browser. */
export function ensureEmoteCss(doc = globalThis.document) {
if (!doc?.head || typeof doc.querySelector !== 'function') return false;
if (doc.querySelector(`link[rel="stylesheet"][href$="${EMOTE_CSS_HREF}"]`)) return false;
const link = doc.createElement('link');
link.rel = 'stylesheet';
link.href = EMOTE_CSS_HREF;
doc.head.appendChild(link);
return true;
}
/**
* URL of an emote's official art, or null when the id is unknown or the local manifest does not list the picture
* (a path that is not listed is never requested).
* @param {string} id official emoji id
*/
export function emoteArtUrl(id) {
const e = emoteInfo(id);
return e ? localAsset(emoteArtGroup(id), e.picId) : null;
}
/** Official emote UI sprite (ui/battle: emoji_bubble_bkg, emoji_bkg, emoji_cell_bkg, emoji_btn, emoji_btn_disable). */
export const emoteUiSprite = (name) => localAsset('ui/battle', name);
/** Index of a theme in the wheel (0 when unknown). */
export function themeIndex(themeId) {
const i = EMOTE_THEMES.findIndex((t) => t.themeId === themeId);
return i < 0 ? 0 : i;
}
/** Wheel page to open on: the theme of the last emote sent (remembered in localStorage), else the first. */
export function lastThemeIndex() {
const v = loadPref(PREF_THEME, null);
return typeof v === 'string' ? themeIndex(v) : 0;
}
/** Remember the theme of a sent emote. */
export function rememberTheme(themeId) {
if (EMOTE_THEMES.some((t) => t.themeId === themeId)) savePref(PREF_THEME, themeId);
}
/** Clamp a page index into the wheel. */
export const clampPage = (i) => Math.max(0, Math.min(EMOTE_THEMES.length - 1, Math.trunc(Number(i)) || 0));
/**
* Page change for a finished horizontal drag / swipe: -1 (previous), +1 (next) or 0.
* @param {number} dx pointer travel in px (negative = leftwards = next page)
* @param {number} [threshold]
*/
export const swipeStep = (dx, threshold = SWIPE_PX) => (dx <= -threshold ? 1 : dx >= threshold ? -1 : 0);
/**
* Scroll distance of a wheel event along the pager, in px. Like Unity's horizontal-only ScrollRect, a mostly vertical
* wheel (a plain mouse wheel) drives the horizontal pager too: down / right = next page.
* @param {{ deltaX?: number, deltaY?: number, deltaMode?: number }} e
* @param {number} [pagePx] px per "page" delta (deltaMode 2)
*/
export function wheelDelta(e, pagePx = 300) {
const x = Number(e?.deltaX) || 0;
const y = Number(e?.deltaY) || 0;
const d = Math.abs(x) >= Math.abs(y) ? x : y;
const unit = e?.deltaMode === 1 ? 16 : e?.deltaMode === 2 ? pagePx : 1; // lines / pages → px
return d * unit;
}
/**
* Wheel / trackpad paging, at most one page per gesture. A gesture is a run of wheel events less than WHEEL_GAP_MS
* apart; once it has turned the page the rest of it (the momentum tail of a trackpad swipe) is ignored, so one swipe
* never skips several themes.
* @param {{ x: number, t: number, spent: boolean }} acc gesture state (mutated)
* @param {number} d scroll distance of this event (wheelDelta)
* @param {number} now event time (ms)
* @returns {-1|0|1} page change
*/
export function wheelStep(acc, d, now) {
if (!(now - acc.t < WHEEL_GAP_MS) || now < acc.t) { acc.x = 0; acc.spent = false; } // a new gesture
acc.t = now;
if (acc.spent || !d) return 0;
acc.x += d;
if (Math.abs(acc.x) < WHEEL_STEP_PX) return 0;
acc.spent = true;
return acc.x > 0 ? 1 : -1;
}
/**
* Cooldown left (ms) of the 交流 button after a send at `sentAt` (chatCD).
* @param {number} sentAt
* @param {number} now
* @param {number} [cooldownMs]
*/
export function cooldownLeft(sentAt, now, cooldownMs = EMOTE_COOLDOWN_MS) {
const left = sentAt + cooldownMs - now;
return left > 0 && left <= cooldownMs ? left : 0; // a clock that went backwards never locks the button
}
/**
* Age (ms) of a bubble received at `at` — so a bubble mounted late (the team panel re-rendered from scratch) resumes
* its animation instead of popping in again for a full chatTime. 0 when unknown.
* @param {number|undefined} at receipt time (Date.now() clock)
* @param {number} now
* @param {number} ttl
*/
export function bubbleAge(at, now, ttl = EMOTE_BUBBLE_MS) {
const age = Number.isFinite(at) && Number.isFinite(now) ? now - at : 0;
return age > 0 ? Math.min(age, Math.max(0, ttl)) : 0;
}
/** Neutral stand-in for a missing emote picture (a glyph, never text). */
function EmoteGlyph({ class: cls }) {
return html`<span class=${cx('eart', 'eart--glyph', cls)} aria-hidden="true"><${GIcon} name="emote" /></span>`;
}
/**
* Official emote picture; neutral glyph when unavailable.
* @param {{ id: string, class?: string }} props
*/
export function EmoteArt({ id, class: cls }) {
useData('local');
const [bad, setBad] = useState(null);
const src = emoteArtUrl(id);
if (src && bad !== src) {
return html`<img class=${cx('eart', cls)} src=${src} alt="" draggable=${false} decoding="async" onError=${() => setBad(src)} />`;
}
const st = data.status('local');
if (!src && emoteInfo(id) && (st === 'loading' || st === 'idle')) return html`<span class=${cx('eart', 'eart--pending', cls)} aria-hidden="true"></span>`;
return html`<${EmoteGlyph} class=${cls} />`;
}
/**
* Pop bubble (picture only) beside the sender's avatar. Unknown ids show the neutral glyph. Key it by the emote's seq
* (a newer emote replaces the bubble and pops again).
* @param {{ id: string, class?: string, ttl?: number, at?: number }} props ttl = display time in ms (fade-out at the
* end); at = when the emote arrived (Date.now() clock): a bubble mounted after that resumes its pop / fade timeline
* instead of restarting it.
*/
export function EmoteBubble({ id, class: cls, ttl = EMOTE_BUBBLE_MS, at }) {
useData('local');
useEffect(() => { ensureEmoteCss(); }, []);
const life = Math.max(300, Number(ttl) || EMOTE_BUBBLE_MS);
const [age] = useState(() => bubbleAge(at, Date.now(), life)); // fixed at mount: the CSS animation runs from there
const bg = emoteUiSprite('emoji_bubble_bkg');
const e = emoteInfo(id);
const style = [`--ebubble-ttl:${life}ms`, age && `--ebubble-age:${Math.round(age)}ms`, bg && `--ebubble-bg:url("${bg}")`].filter(Boolean).join(';');
return html`<div class=${cx('ebubble', bg && 'has-sprite', cls)} style=${style} role="img"
aria-label=${e ? e.label : '表情'} data-emote=${e ? e.id : ''}>
<span class="ebubble__icon"><${EmoteArt} id=${id} /></span>
</div>`;
}
/**
* 交流 button + emote panel.
* @param {{ onSend: (id:string)=>void, open: boolean, onToggle: (open:boolean)=>void, disabled?: boolean,
* cooldownMs?: number }} props
*/
export function EmoteWheel({ onSend, open, onToggle, disabled = false, cooldownMs = EMOTE_COOLDOWN_MS }) {
useData('local');
useEffect(() => { ensureEmoteCss(); }, []);
const [page, setPage] = useState(lastThemeIndex);
const [dir, setDir] = useState(0); // direction of the last page change (slide-in animation)
const [dx, setDx] = useState(0); // live drag offset (px)
const [cooling, setCooling] = useState(() => cooldownLeft(lastSentAt, Date.now(), cooldownMs) > 0);
const drag = useRef(null); // { id, x0, moved }
const swallowClick = useRef(false);
const wheelAcc = useRef({ x: 0, t: -Infinity, spent: false });
const live = useRef({});
live.current = { page, onToggle };
const go = (to) => {
const next = clampPage(to);
if (next === live.current.page) return;
setDir(next > live.current.page ? 1 : -1);
setPage(next);
live.current.page = next; // several key presses before the next render still step one page each
};
// reopen on the last used theme
useEffect(() => { if (open) { setPage(lastThemeIndex()); setDir(0); setDx(0); } }, [open]);
// cooldown: the button is greyed for chatCD after a send (from the send time, so a remounted wheel keeps it)
useEffect(() => {
if (!cooling) return undefined;
const t = setTimeout(() => setCooling(false), cooldownLeft(lastSentAt, Date.now(), cooldownMs));
return () => clearTimeout(t);
}, [cooling]);
// outside press closes; ← / → change the page
useEffect(() => {
if (!open) return undefined;
const onDown = (e) => { if (!(e.target instanceof Element) || !e.target.closest('.ewheel')) live.current.onToggle(false); };
const onKey = (e) => {
if (e.ctrlKey || e.metaKey || e.altKey) return;
const t = e.target;
if (t instanceof Element && t.closest('input, textarea, select, [contenteditable="true"]')) return;
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
go(live.current.page + (e.key === 'ArrowRight' ? 1 : -1));
}
};
window.addEventListener('pointerdown', onDown, true);
window.addEventListener('keydown', onKey);
return () => { window.removeEventListener('pointerdown', onDown, true); window.removeEventListener('keydown', onKey); };
}, [open]);
const send = (id) => {
if (swallowClick.current) { swallowClick.current = false; return; }
const e = emoteInfo(id);
const now = Date.now();
if (!e || cooling || disabled || cooldownLeft(lastSentAt, now, cooldownMs) > 0) return;
lastSentAt = now;
rememberTheme(e.themeId);
setCooling(true);
onSend(id);
onToggle(false);
};
// swipe / drag (touch, pen and mouse)
const onPointerDown = (e) => {
if (e.button != null && e.button !== 0) return;
drag.current = { id: e.pointerId, x0: e.clientX, moved: false };
swallowClick.current = false;
};
const onPointerMove = (e) => {
const d = drag.current;
if (!d || d.id !== e.pointerId) return;
if (e.pointerType === 'mouse' && !(e.buttons & 1)) { drag.current = null; setDx(0); return; } // released outside
const off = e.clientX - d.x0;
if (!d.moved && Math.abs(off) < DRAG_SLOP_PX) return;
if (!d.moved) { d.moved = true; try { e.currentTarget.setPointerCapture(e.pointerId); } catch { /* ignore */ } }
const edge = (off > 0 && live.current.page === 0) || (off < 0 && live.current.page === EMOTE_THEMES.length - 1);
setDx(edge ? off / 3 : off); // rubber band at the first / last page
};
const endDrag = (e, cancelled) => {
const d = drag.current;
if (!d || d.id !== e.pointerId) return;
drag.current = null;
if (!d.moved) return;
swallowClick.current = true; // the click that follows a drag is not a send
setTimeout(() => { swallowClick.current = false; }, 0);
setDx(0);
if (!cancelled) { const step = swipeStep(e.clientX - d.x0); if (step) go(live.current.page + step); }
};
const onWheel = (e) => {
const d = wheelDelta(e, e.currentTarget?.clientWidth || 300);
if (!d) return;
e.preventDefault();
const step = wheelStep(wheelAcc.current, d, performance.now());
if (step) go(live.current.page + step);
};
const theme = EMOTE_THEMES[clampPage(page)];
const btnSprite = emoteUiSprite(disabled || cooling ? 'emoji_btn_disable' : 'emoji_btn');
const panelBg = emoteUiSprite('emoji_bkg');
const cellBg = emoteUiSprite('emoji_cell_bkg');
const panelStyle = [panelBg && `--ewheel-bg:url("${panelBg}")`, cellBg && `--ewheel-cell:url("${cellBg}")`].filter(Boolean).join(';');
return html`<div class="ewheel">
<button type="button" class=${cx('ewheel__btn', btnSprite && 'has-sprite', open && 'is-on', cooling && 'is-cooling')}
style=${btnSprite ? `--ewheel-btn:url("${btnSprite}")` : ''} onClick=${() => onToggle(!open)}
aria-expanded=${open ? 'true' : 'false'} aria-haspopup="dialog" disabled=${disabled || cooling}>
${btnSprite ? null : html`<${GIcon} name="emote" />`}<span class="ewheel__label">交流</span>
</button>
${open ? html`<div class=${cx('ewheel__panel', panelBg && 'has-sprite', cellBg && 'has-cell')} style=${panelStyle} role="dialog" aria-label="交流">
<div class="ewheel__viewport" onPointerDown=${onPointerDown} onPointerMove=${onPointerMove}
onPointerUp=${(e) => endDrag(e, false)} onPointerCancel=${(e) => endDrag(e, true)} onWheel=${onWheel}>
<div key=${theme.themeId} class=${cx('ewheel__page', dir > 0 && 'is-from-right', dir < 0 && 'is-from-left', dx !== 0 && 'is-dragging')}
style=${dx ? `transform:translateX(${dx}px)` : ''} role="group" aria-label=${theme.name} data-theme=${theme.themeId}>
${theme.emotes.map((e) => html`<button key=${e.id} type="button" class="ewheel__item" data-emote=${e.id} aria-label=${e.label}
disabled=${cooling || disabled} onClick=${() => send(e.id)}>
<${EmoteArt} id=${e.id} />
</button>`)}
</div>
</div>
<button type="button" class="ewheel__nav is-prev" aria-label="上一组表情" disabled=${page <= 0} onClick=${() => go(page - 1)}><${GIcon} name="chevronLeft" /></button>
<button type="button" class="ewheel__nav is-next" aria-label="下一组表情" disabled=${page >= EMOTE_THEMES.length - 1} onClick=${() => go(page + 1)}><${GIcon} name="chevronRight" /></button>
<div class="ewheel__dots" role="tablist" aria-label="表情主题">
${EMOTE_THEMES.map((t, i) => html`<button key=${t.themeId} type="button" role="tab" class=${cx('ewheel__dot', i === page && 'is-on')}
aria-selected=${i === page ? 'true' : 'false'} aria-label=${`${t.name} ${i + 1}/${EMOTE_THEMES.length}`} onClick=${() => go(i)}></button>`)}
</div>
</div>` : null}
</div>`;
}
+100
View File
@@ -0,0 +1,100 @@
// 本局信息 dialog under the top bar (opened by the left 🔍, official btn_check_player / autochess_hud_player_info_dialog,
// research 09 §2.1) with two tabs:
// 本局信息 — own strategy, stage, disabled bonds with banned-member counts and the banned operator list (default);
// 敌方情报 — the secondary enemy list: m.private.nextEnemies (icons, names, counts, tags, 特训 faction tags) + this
// match's factions and the enemy leader on boss rounds. The primary enemy preview is the pen on the board
// (the right 🔍▶▶ pans the camera there; tapping an enemy in the pen opens its detail card) — research 09 §6.2.
import { html, Icon, Tabs, MicroLabel } from './components.js';
import { Img, UnitThumb, BondGlyph, BandIcon, RichText, GIcon } from './gameComponents.js';
import { groupEnemies, factionTypes, bannedPerBond, disabledBondSets, briefingBondTip } from './gameLogic.js';
import { factionIconUrl } from './assetUrls.js';
import { data } from '../data.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
const TAG = { boss: '领袖', bounty: '悬赏', escort: '护卫' };
function FactionChips({ types }) {
const f = data.get('factions')?.types || {};
const m = data.get('assets');
return html`<div class="fchips">${types.map((t) => html`<span key=${t} class="fchip" title=${f[t]?.desc || ''}>
<${Img} src=${factionIconUrl(m, f[t]?.icon)} class="fchip__icon" /><span>${f[t]?.name || t}</span></span>`)}</div>`;
}
function EnemiesTab({ pub, priv, onEnemy }) {
const rows = groupEnemies(priv?.nextEnemies, (k) => data.lookup('enemies', k));
const types = factionTypes(pub?.factions);
const f = data.get('factions')?.types || {};
const total = rows.reduce((s, r) => s + r.count, 0);
const boss = pub?.bossId ? data.lookup('bosses', pub.bossId) : null;
return html`<div class="edrawer__body">
<div class="edrawer__sum">
<span>第 <b class="num">${pub?.round ?? '-'}</b> 回合 · 即将迎击 <b class="num t-orange">${total}</b> 名敌人</span>
${types.length ? html`<${FactionChips} types=${types} />` : null}
</div>
${rows.length ? html`<div class="elist">
${rows.map((r) => html`<button key=${`${r.enemyKey}|${r.tag}`} type="button" class=${cx('erow', r.tag && `erow--${r.tag}`, `erow--${r.rank.toLowerCase()}`)}
onClick=${() => onEnemy(r.enemyKey, r.count)}>
<${UnitThumb} kind="enemy" id=${r.enemyKey} size="md" />
<span class="erow__text">
<b class="erow__name">${r.name}</b>
<span class="erow__tags">
${r.tag ? html`<span class=${`etag etag--${r.tag}`}>${TAG[r.tag] || r.tag}</span>` : null}
${r.rank === 'ELITE' ? html`<span class="etag etag--elite">精英</span>` : null}
${r.fly ? html`<span class="etag etag--fly">空中</span>` : null}
${r.acTypes.map((t) => html`<span key=${t} class="etag etag--faction">${(f[t]?.name || t).replace('特训敌人·', '')}</span>`)}
</span>
</span>
<b class="erow__count num">×${r.count}</b>
</button>`)}
</div>` : html`<p class="edrawer__empty"><${GIcon} name="target" />暂无敌方情报</p>`}
${boss ? html`<div class="eboss">
<${UnitThumb} kind="enemy" id=${boss.enemyKey} size="md" />
<div><${MicroLabel} tone="gold">ENEMY LEADER // 敌方领袖</${MicroLabel}><b>${boss.name}</b>
<span class="t-lo">第 ${data.get('config')?.modes?.[pub.modeId]?.bossRound ?? pub.lastRound ?? 14} 回合 · 最终攻势</span></div>
</div>` : null}
</div>`;
}
function InfoTab({ pub, priv, onChess }) {
const sets = disabledBondSets(pub, data.get('config')?.modes?.[pub?.modeId]?.inactiveBondIds);
const disabled = new Set([...sets.drawn, ...sets.off]);
const stateOf = (id) => (sets.off.has(id) ? 'off' : sets.drawn.has(id) ? 'drawn' : null);
const bonds = data.list('bonds');
const banned = Array.isArray(pub?.bannedChess) ? pub.bannedChess : [];
const perBond = bannedPerBond(bonds, banned);
const band = priv?.bandId ? data.lookup('bands', priv.bandId) : null;
const stage = pub?.stageId ? data.lookup('stages', pub.stageId) : null;
const withBans = bonds.filter((b) => disabled.has(b.bondId) || (perBond.get(b.bondId) || 0) > 0)
.sort((a, b) => (disabled.has(b.bondId) - disabled.has(a.bondId)) || ((perBond.get(b.bondId) || 0) - (perBond.get(a.bondId) || 0)));
return html`<div class="edrawer__body">
${band ? html`<div class="iband">
<${BandIcon} bandId=${band.bandId} size="md" />
<div><${MicroLabel} tone="mint">STRATEGY // 我的策略</${MicroLabel}><b>${band.name} <small class="t-lo">${band.effectName}</small></b>
<${RichText} text=${band.descRaw || band.desc} class="iband__desc" /></div>
</div>` : null}
${stage ? html`<p class="istage"><${Icon} name="rook" />战场:<b>${stage.name || stage.id}</b></p>` : null}
<h4 class="ihead">本局禁用干员情况 <small class="num">${banned.length}</small></h4>
<div class="ibonds">
${withBans.length ? withBans.map((b) => html`<span key=${b.bondId} class=${cx('ibond', disabled.has(b.bondId) && 'is-off')} title=${briefingBondTip(b.name, stateOf(b.bondId), perBond.get(b.bondId) || 0)}>
<${BondGlyph} bondId=${b.bondId} /><span>${b.name}</span>
${perBond.get(b.bondId) ? html`<span class="ibond__ban num" title="该盟约中被禁用的干员数"><${Icon} name="user" />${perBond.get(b.bondId)}</span>` : null}
</span>`) : html`<span class="t-dim">本局没有禁用盟约</span>`}
</div>
${banned.length ? html`<div class="ibanned">${banned.map((id) => html`<button key=${id} type="button" class="ibanned__one" onClick=${() => onChess(id)}>
<${UnitThumb} kind="chess" id=${id} size="sm" dim=${true} /></button>`)}</div>` : null}
</div>`;
}
/**
* @param {{ tab:'enemies'|'info', onTab:(t:string)=>void, pub:any, priv:any, onClose:Function, onEnemy:(key:string, count:number)=>void, onChess:(id:string)=>void }} props
*/
export function EnemyDrawer({ tab, onTab, pub, priv, onClose, onEnemy, onChess }) {
return html`<div class="edrawer brackets" role="dialog" aria-label=${tab === 'info' ? '本局信息' : '敌方情报'}>
<div class="edrawer__top">
<${Tabs} size="sm" value=${tab} onChange=${onTab} items=${[{ id: 'info', label: '本局信息' }, { id: 'enemies', label: '敌方情报' }]} />
<button type="button" class="edrawer__close tapx" aria-label="关闭" onClick=${onClose}><${Icon} name="close" /></button>
</div>
${tab === 'info' ? html`<${InfoTab} pub=${pub} priv=${priv} onChess=${onChess} />` : html`<${EnemiesTab} pub=${pub} priv=${priv} onEnemy=${onEnemy} />`}
</div>`;
}
+135
View File
@@ -0,0 +1,135 @@
// Equip-replace dialog (research 04 §"Replacement" / 09 §1.2 UseEquipUp { charChessInstId, equipChessInstId,
// isChangeEquip, unloadInstId }; PRTS 帮助 "达到上限强行佩戴会改为替换装备"): an item dropped on an operator whose two
// equipment slots are used opens this dialog. It shows the operator, the incoming item and the two equipped items
// (icon, tier, name, effect); the player picks the one to replace — it is DESTROYED — and confirms: g.equip
// {itemUid, targetUid, replaceUid}. 取消 / ✕ / Esc / the backdrop change nothing (no intent is sent).
// Official look from the local ui/battle sprites (equip_replace_bg, _opiton_bg, _opition_select_01/02_bg, _arrow_line /
// _arrow_head, _avatart_bg / _frame, _round, _close, _comfirm_icon, _replace_icon), CSS look-alikes without them
// (css/screens/game-panels.css .eqr).
//
// replaceRequest(ctx, intent, getChess, getItem) → { targetUid, targetId, targetName, item: {uid,id,name}, options }
// | null (pure; the data the dialog shows — null = no dialog)
// replaceIntent(intent, replaceUid) → { t: 'g.equip', fields: { itemUid, targetUid, replaceUid } }
// <EquipReplaceDialog request onConfirm(replaceUid) onCancel />
import { useEffect, useState } from '../../vendor/hooks.module.js';
import { html, Modal, Button, TierChip, MicroLabel } from './components.js';
import { UnitThumb, RichText, GIcon } from './gameComponents.js';
import { localAsset } from '../data.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
const isObj = (v) => !!v && typeof v === 'object';
/**
* What the replace dialog shows for a g.equip drop intent that needs a replacement (`intent.confirmReplace`).
* @param {{ pieces: Map<number, any> }} ctx placementContext
* @param {{ t: string, fields: { itemUid: number, targetUid: number }, confirmReplace?: boolean }|null} intent gameLogic dropIntent
* @param {(id: string) => any} [getChess]
* @param {(id: string) => any} [getItem]
*/
export function replaceRequest(ctx, intent, getChess = () => null, getItem = () => null) {
if (!intent || intent.t !== 'g.equip' || !intent.confirmReplace || !isObj(intent.fields)) return null;
const target = ctx?.pieces?.get(intent.fields.targetUid)?.piece;
const item = ctx?.pieces?.get(intent.fields.itemUid)?.piece;
if (!isObj(target) || target.kind !== 'chess' || !isObj(item) || item.kind !== 'item') return null;
const equipped = (Array.isArray(target.items) ? target.items : []).filter((it) => isObj(it) && Number.isInteger(it.uid));
if (equipped.length < 2) return null;
const rec = (id) => getItem(id) || null;
return {
targetUid: target.uid,
targetId: target.id,
targetName: getChess(target.id)?.name || '该干员',
golden: !!target.golden,
item: { uid: item.uid, id: item.id, name: rec(item.id)?.name || '装备' },
options: equipped.map((it) => ({ uid: it.uid, id: it.id, name: rec(it.id)?.name || it.id })),
};
}
/**
* The g.equip intent that replaces `replaceUid` (one of the target's equipped items, or the intent is unchanged).
* @param {{ t: string, fields: { itemUid: number, targetUid: number } }} intent
* @param {number} replaceUid
*/
export function replaceIntent(intent, replaceUid) {
const f = intent?.fields || {};
const fields = { itemUid: f.itemUid, targetUid: f.targetUid };
if (Number.isInteger(replaceUid)) fields.replaceUid = replaceUid;
return { t: 'g.equip', fields };
}
/** One official sprite as a CSS custom property (empty when the local-client art is not installed). */
const spriteVar = (name, sprite) => {
const url = localAsset('ui/battle', sprite);
return url ? `${name}:url("${url}");` : '';
};
/** An item card of the dialog (the incoming item on top, the two equipped options below). */
function ItemCard({ id, item, rec, selected = false, option = false, onPick }) {
const tier = rec?.tier;
const body = html`
<span class="eqr__icon">
<${UnitThumb} kind="item" id=${id} size="md" showTier=${false} />
</span>
<span class="eqr__meta">
<span class="eqr__chips">${tier ? html`<${TierChip} tier=${tier} golden=${!!rec?.isGolden} size="sm" />` : null}
${rec?.isGolden ? html`<span class="eqr__elite">进阶</span>` : null}</span>
<b class="eqr__name">${rec?.name || item?.name || id}</b>
</span>
<${RichText} as="p" class="eqr__desc" text=${rec?.descRaw || rec?.desc || ''} />`;
if (!option) return html`<div class="eqr__card eqr__card--new">${body}</div>`;
return html`<button type="button" role="radio" aria-checked=${selected ? 'true' : 'false'} data-uid=${item.uid}
class=${cx('eqr__card', 'eqr__opt', selected && 'is-sel')} onClick=${() => onPick(item.uid)}>
${body}
<span class="eqr__flag" aria-hidden=${selected ? 'false' : 'true'}>
<${GIcon} name="trash" class="eqr__flag-icon" /><span>将被销毁</span>
</span>
</button>`;
}
/**
* The dialog.
* @param {{ request: ReturnType<typeof replaceRequest>, getItem?: (id: string) => any, busy?: boolean,
* onConfirm: (replaceUid: number) => void, onCancel: () => void }} props
*/
export function EquipReplaceDialog({ request, getItem = () => null, busy = false, onConfirm, onCancel }) {
const [sel, setSel] = useState(null);
const key = request ? `${request.targetUid}:${request.item.uid}` : '';
useEffect(() => { setSel(null); }, [key]);
if (!request) return null;
const chosen = request.options.find((o) => o.uid === sel) || null;
const style = [
spriteVar('--eqr-bg', 'equip_replace_bg'), spriteVar('--eqr-opt', 'equip_replace_opiton_bg'),
spriteVar('--eqr-sel', 'equip_replace_opition_select_02_bg'), spriteVar('--eqr-selbar', 'equip_replace_opition_select_01_bg'),
spriteVar('--eqr-round', 'equip_replace_round'), spriteVar('--eqr-ava', 'equip_replace_avatart_bg'),
spriteVar('--eqr-avaf', 'equip_replace_avatart_frame'), spriteVar('--eqr-aline', 'equip_replace_arrow_line'),
spriteVar('--eqr-ahead', 'equip_replace_arrow_head'),
].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); } }}>
<${Modal} open=${true} tone="red" class="eqr" width="min(7.6rem, 96vw)" micro="EQUIPMENT · REPLACE" title="替换装备" onClose=${onCancel}
actions=${html`
<${Button} variant="secondary" icon="close" class="eqr__cancel" onClick=${onCancel}>取消<//>
<${Button} variant="danger" class="eqr__ok" disabled=${!chosen} loading=${busy} data-autofocus onClick=${() => chosen && onConfirm(chosen.uid)}>
${confirmIcon ? html`<img class="eqr__ok-icon" src=${confirmIcon} alt="" draggable=${false} />` : null}确认替换<//>`}>
<p class="eqr__lead">「<b>${request.targetName}</b>」的装备栏已满(2/2)。选择一件装备进行替换,<b class="t-red">被替换的装备将被销毁</b>。</p>
<div class="eqr__top">
<div class="eqr__op" title=${request.targetName}>
<span class="eqr__ava"><${UnitThumb} kind="chess" id=${request.targetId} golden=${request.golden} size="md" /></span>
<span class="eqr__opname">${request.targetName}</span>
</div>
<span class="eqr__swap" aria-hidden="true">${swapIcon ? html`<img src=${swapIcon} alt="" draggable=${false} />` : html`<${GIcon} name="refresh" />`}</span>
<div class="eqr__incoming">
<${MicroLabel} tone="mint">新装备</${MicroLabel}>
<${ItemCard} id=${request.item.id} item=${request.item} rec=${getItem(request.item.id)} />
</div>
</div>
<div class="eqr__arrow" aria-hidden="true"><i class="eqr__aline"></i><i class="eqr__ahead eqr__ahead--l"></i><i class="eqr__ahead eqr__ahead--r"></i></div>
<div class="eqr__opts" role="radiogroup" aria-label="选择被替换的装备">
${request.options.map((o) => html`<${ItemCard} key=${o.uid} id=${o.id} item=${o} rec=${getItem(o.id)} option=${true}
selected=${o.uid === sel} onPick=${(uid) => setSel((s) => (s === uid ? null : uid))} />`)}
</div>
<p class="eqr__hint">${chosen ? html`将销毁「<b>${chosen.name}</b>」并配发「<b>${request.item.name}</b>」` : '点击选择要替换的装备'}</p>
<//>
</div>`;
}
+239
View File
@@ -0,0 +1,239 @@
// Facing (direction) rules of the prep board — pure logic, no DOM (research 09 §1.2, DESIGN §3 "Facing (corrected)").
//
// Every board piece has a direction `dir ∈ UP|RIGHT|DOWN|LEFT`, chosen with the standard Arknights 4-direction deploy
// wheel after a legal drop on a board tile (also the piece's own tile: re-orienting in place). Range grids are
// `[dRow, dCol]` offsets relative to facing RIGHT (row 0 = the bottom row, nearest the camera) and rotate as
// RIGHT (dr, dc) · UP (dc, −dr) · LEFT (−dr, −dc) · DOWN (−dc, dr).
// Screen space of the wheel: +x right, +y down; the camera looks "up" the board, so screen-up = towards higher rows =
// UP. A swipe shorter than the dead-zone (~0.5 tile) chooses nothing (release there = cancel). On the mirrored right
// half of the Final Assault prep a screen direction maps to its board direction through `boardDir(dir, mirror)`.
//
// Also here: which drops enter the direction step, which range grid previews, and the underframe (selection diamond)
// actions of a tapped piece — 撤退 / 出售 +N on a board operator, 撤退 on a board summon, 出售 +N on a bench operator,
// 销毁 on items and Arts (research 09 §5 / §6.5).
import { GEO } from '../../../shared/constants.js';
import { attackRangeGrid } from '../../../shared/loadoutRecord.js';
export const DIRS = Object.freeze(['UP', 'RIGHT', 'DOWN', 'LEFT']);
export const DEFAULT_DIR = 'RIGHT';
/** Dead-zone radius of the wheel, in tiles (research 09 §1.2: ~0.5 tile). */
export const DEAD_ZONE_TILES = 0.5;
/** Row / column unit step of a direction (row 0 = bottom). */
export const DIR_VEC = Object.freeze({
UP: Object.freeze({ dr: 1, dc: 0 }),
RIGHT: Object.freeze({ dr: 0, dc: 1 }),
DOWN: Object.freeze({ dr: -1, dc: 0 }),
LEFT: Object.freeze({ dr: 0, dc: -1 }),
});
/** Chinese label (for aria / tooltips). */
export const DIR_LABEL = Object.freeze({ UP: '上', RIGHT: '右', DOWN: '下', LEFT: '左' });
/** Normalise any direction spelling ('up', 'Right', 1/−1 facing) to UP|RIGHT|DOWN|LEFT; unknown → `fallback`. */
export function normDir(d, fallback = DEFAULT_DIR) {
if (typeof d === 'string') {
const u = d.trim().toUpperCase();
if (DIRS.includes(u)) return u;
}
if (d === -1) return 'LEFT';
if (d === 1) return 'RIGHT';
return fallback;
}
/** Rotate a range offset given relative to facing RIGHT (DESIGN §3). */
export function rotateOffset(dr, dc, dir) {
const z = (v) => (v === 0 ? 0 : v); // no −0
switch (normDir(dir)) {
case 'UP': return [z(dc), z(-dr)];
case 'LEFT': return [z(-dr), z(-dc)];
case 'DOWN': return [z(-dc), z(dr)];
default: return [z(dr), z(dc)];
}
}
/**
* Absolute board tiles of a range grid for a piece standing on (row, col) facing `dir` (clipped to the stage grid,
* deduplicated). `extend` adds columns along +dCol before rotating (rangeExtend, DESIGN §3).
* @param {Array<[number, number]>} grid
* @returns {Array<[number, number]>}
*/
export function rangeTiles(grid, row, col, dir, { extend = 0, rows = GEO.ROWS, cols = GEO.COLS } = {}) {
const out = [];
const seen = new Set();
const list = Array.isArray(grid) ? grid : [];
const ext = Number.isInteger(extend) && extend > 0 ? extend : 0;
const push = (dr, dc) => {
const [rr, rc] = rotateOffset(dr, dc, dir);
const r = row + rr, c = col + rc;
if (r < 0 || r >= rows || c < 0 || c >= cols) return;
const k = `${r},${c}`;
if (seen.has(k)) return;
seen.add(k);
out.push([r, c]);
};
for (const g of list) {
if (!Array.isArray(g) || !Number.isFinite(g[0]) || !Number.isFinite(g[1])) continue;
push(g[0], g[1]);
}
if (ext) {
// rangeExtend: every row of the grid grows by `ext` columns at its far (+dCol) end
const rowsOf = new Map();
for (const g of list) if (Array.isArray(g) && Number.isFinite(g[0]) && Number.isFinite(g[1])) rowsOf.set(g[0], Math.max(rowsOf.get(g[0]) ?? -Infinity, g[1]));
for (const [dr, far] of rowsOf) for (let k = 1; k <= ext; k++) push(dr, far + k);
}
return out;
}
/**
* Direction of a swipe from the wheel centre. `dx`, `dy` in screen px (+y down); `dead` = dead-zone radius in px.
* The dominant axis decides (ties go to the horizontal axis, where the lanes are).
* @returns {'UP'|'RIGHT'|'DOWN'|'LEFT'|null}
*/
export function dirFromDelta(dx, dy, dead = 0) {
if (!Number.isFinite(dx) || !Number.isFinite(dy)) return null;
if (Math.hypot(dx, dy) <= Math.max(0, dead)) return null;
if (Math.abs(dx) >= Math.abs(dy)) return dx >= 0 ? 'RIGHT' : 'LEFT';
return dy < 0 ? 'UP' : 'DOWN';
}
const MIRROR = Object.freeze({ UP: 'UP', RIGHT: 'LEFT', DOWN: 'DOWN', LEFT: 'RIGHT' });
/**
* Board direction of a direction picked on screen (the wheel's chevrons / arrow keys are screen-oriented). On the
* right half of the Final Assault prep the board is drawn mirrored (research 09 §1.2: col c → 20 − c, RIGHT ↔ LEFT;
* render/prepfield.js), so a swipe to the screen's right means board LEFT there; UP / DOWN are unchanged. The mapping
* is its own inverse: `boardDir(boardDir(d, m), m) === d` (also the screen chevron of a board direction).
* @param {'UP'|'RIGHT'|'DOWN'|'LEFT'|null} dir
* @param {boolean} mirror `tileScreen(row, col).mirror` / `view.prepField().mirror` of the render view
*/
export function boardDir(dir, mirror = false) {
if (dir == null) return null;
const d = normDir(dir, null);
if (!d) return null;
return mirror ? MIRROR[d] : d;
}
/** Whether the view draws the prep board mirrored (right-hand Final Assault prep); false for views without the hook. */
export function viewMirrored(view) {
const raw = view && (view.raw || view);
try { return !!(raw && typeof raw.prepField === 'function' && raw.prepField()?.mirror); } catch { return false; }
}
/** Direction of an arrow key (keyboard alternative of the swipe). */
export function dirFromKey(key) {
return { ArrowUp: 'UP', ArrowRight: 'RIGHT', ArrowDown: 'DOWN', ArrowLeft: 'LEFT' }[key] || null;
}
const isObj = (v) => !!v && typeof v === 'object';
/** The piece's stored facing (m.private board pieces carry `dir`; absent ⇒ RIGHT, the server default). */
export const pieceDir = (piece) => normDir(isObj(piece) ? piece.dir : null);
/**
* Range grid previewed by the wheel / on a selected unit: the operator's own grid (heal range for medics — the data
* grid is the one the unit uses; an elite's equipped "攻击范围扩大" module grid, DESIGN §16 — `chessRecord` resolves
* the player's loadout), a summon's grid, an Art's grid (画卷 `1-1`). Null when there is nothing to show.
* @param {{ getChess:(id:string)=>any, getToken:(id:string)=>any, getItem:(id:string)=>any, chessRecord?:(rec:any)=>any }} lookups
*/
export function previewGrid(lookups, piece) {
if (!isObj(piece) || !lookups) return null;
let rec = null;
if (piece.kind === 'chess') {
rec = lookups.getChess?.(piece.id);
if (rec && typeof lookups.chessRecord === 'function') { try { rec = lookups.chessRecord(rec) || rec; } catch { /* the data record */ } }
} else if (piece.kind === 'token') rec = lookups.getToken?.(piece.id);
else if (piece.kind === 'item') { rec = lookups.getItem?.(piece.id); if (rec?.itemType !== 'MAGIC') return null; }
const grid = !rec ? null : piece.kind === 'chess' ? attackRangeGrid(rec) : (Array.isArray(rec.rangeGrid) ? rec.rangeGrid : null);
return grid && grid.length ? grid : null;
}
/** Whether an Art is placed with a direction (its range reaches beyond its own tile, e.g. 画卷 `[[0,0],[0,1]]`). */
export function artHasFacing(item) {
const g = Array.isArray(item?.rangeGrid) ? item.rangeGrid : [];
return g.some((x) => Array.isArray(x) && (x[0] !== 0 || x[1] !== 0));
}
/**
* Whether dropping `uid` on `target` enters the direction step (instead of sending the intent at once): operators and
* summons dropped on a board tile (move, swap or their own tile), and directional Arts used on a tile.
* @param {{ pieces: Map<number, any>, getItem?: (id:string)=>any }} ctx placementContext (gameLogic.js)
* @param {number} uid
* @param {any} target
* @param {{ ok: boolean, action?: string }} res canPlace(ctx, uid, target)
*/
export function needsFacing(ctx, uid, target, res) {
if (!res || !res.ok || !isObj(target) || target.area !== 'board') return false;
const piece = ctx?.pieces?.get(uid)?.piece;
if (!piece) return false;
if (piece.kind === 'chess' || piece.kind === 'token') return res.action === 'move' || res.action === 'swap' || res.action === 'orient';
if (piece.kind === 'item' && res.action === 'art') return artHasFacing(ctx.getItem?.(piece.id));
return false;
}
/**
* The intent that commits a direction step. g.move carries the direction twice: top-level `dir` (the protocol field)
* and `to.dir`, which server/match/PlayerState.move reads when the top-level one does not reach it — Match.handle
* currently forwards only `msg.to` (`ps.move(msg.uid, msg.to)`), and without the copy every placement was stored
* facing RIGHT and the model snapped back after the wheel. g.art has no such fallback (see the review report).
* @returns {{ t: 'g.move', fields: { uid, to: { area, row, col, dir }, dir } } | { t: 'g.art', fields: { itemUid, row, col, dir } }}
*/
export function facingIntent(piece, target, dir) {
const d = normDir(dir);
if (piece?.kind === 'item') return { t: 'g.art', fields: { itemUid: piece.uid, row: target.row, col: target.col, dir: d } };
return { t: 'g.move', fields: { uid: piece.uid, to: { area: 'board', row: target.row, col: target.col, dir: d }, dir: d } };
}
/**
* Whether 销毁 may be offered for item `uid`: a loose item in the hand or the temp slots. Items equipped on an operator
* (a piece's `items`, or an entry some other index marks `equipped`) are locked — g.destroy would be refused.
* @param {{ pieces: Map<number, any> }} ctx placementContext / indexPieces view
* @param {number} uid
*/
export function itemDestroyable(ctx, uid) {
const e = ctx?.pieces?.get(uid);
if (!e || e.piece?.kind !== 'item' || e.equipped) return false;
if (e.area !== 'hand' && e.area !== 'temp') return false;
for (const o of ctx.pieces.values()) {
if (o.piece?.kind === 'chess' && Array.isArray(o.piece.items) && o.piece.items.some((it) => it && it.uid === uid)) return false;
}
return true;
}
/**
* Underframe (selection diamond) of a tapped own piece (research 09 §1.2 / §5): the buttons it offers.
* board operator → 撤退 (to the bench) + 出售 +N · board summon → 撤退 · bench / temp operator → 出售 +N ·
* item or Art (bench / temp) → 销毁 · bench summon → nothing.
* @param {{ pieces: Map<number, any>, getChess?: (id:string)=>any }} ctx
* @returns {{ retreat: boolean, sell: number|null, destroy: boolean } | null} sell = the price shown ("+N")
*/
export function underframeActions(ctx, uid) {
const e = ctx?.pieces?.get(uid);
if (!e) return null;
const p = e.piece;
const onBoard = e.area === 'board';
// only a loose item (bench / temp) can be destroyed: an equipped one is locked (the server refuses g.destroy —
// research 04 §2; replacing it is the equip-replace dialog's job, g.equip replaceUid)
if (p.kind === 'item') return itemDestroyable(ctx, uid) ? { retreat: false, sell: null, destroy: true } : null;
if (p.kind === 'token') return onBoard ? { retreat: true, sell: null, destroy: false } : null;
if (p.kind !== 'chess') return null;
const price = ctx.getChess?.(p.id)?.sellPrice;
return { retreat: onBoard, sell: Number.isFinite(price) ? price : 1, destroy: false };
}
/**
* Where 撤退 puts a board piece: the first free bench slot (a summon goes back onto its stack — any slot works; an
* operator whose own summon stacks sit on the bench frees them). Null when the bench is full.
* @param {{ pieces: Map<number, any>, handAt: Map<number, any> }} ctx
*/
export function retreatSlot(ctx, uid) {
const e = ctx?.pieces?.get(uid);
if (!e || e.area !== 'board') return null;
for (let i = 0; i < GEO.HAND_SIZE; i++) if (!ctx.handAt?.has(i)) return { area: 'hand', idx: i };
if (e.piece.kind === 'token') return { area: 'hand', idx: 0 };
for (const [i, h] of ctx.handAt || []) {
if (h?.piece?.kind === 'token' && h.piece.ownerUid === e.piece.uid) return { area: 'hand', idx: i };
}
return null;
}
+383
View File
@@ -0,0 +1,383 @@
// Direction step of a board placement — the standard Arknights 4-direction deploy wheel (research 09 §1.2 / §6.1 5).
//
// After a legal drop of an operator / summon on a board tile (also its own tile: re-orient in place) — or of a
// directional Art (画卷) — the piece stays on the tile as a ghost and this overlay opens:
// * a white diamond centred on the tile, ~3 tiles across its diagonal, a smaller inner diamond (the centre
// dead-zone, ~0.5 tile) and 4 chevrons (UP / RIGHT / DOWN / LEFT);
// * a red "✕ 点击取消" tag on the diamond's upper-left edge;
// * press and drag from the centre: past the dead-zone that side's chevron lights and the unit's range grid, rotated
// to that direction (DESIGN §3), shows as orange striped tiles (view.highlightTiles group 'facing' under the units
// + a striped SVG layer — slotted between the 3D board canvas and the Pixi canvas when the 3D board is on, so the
// units stand on the stripes; an overlay otherwise); outside the centre the tooltip "拖回中心区域取消" shows;
// * on the mirrored right half of the Final Assault prep a screen direction maps to its board direction (RIGHT ↔
// 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.
//
// 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
// back to the engine's dev hooks (`view.debug.cam / tiles / views / app`) for the same effect (projection of tile corners, pinning
// the dropped piece's view on the target tile, re-orienting its model). All of it is guarded: a missing hook only
// loses the visual, never the interaction.
import { useEffect, useLayoutEffect, useRef, useState } from '../../vendor/hooks.module.js';
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';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
const rawOf = (view) => (view && view.raw) || view || null;
/** Range highlight of the wheel / a selected unit (orange, drawn under the units by the render engine). */
export const FACING_STYLE = Object.freeze({ group: 'facing', color: 0xff9c33, fill: 0.36, line: 1 });
// ---- view bridge ---------------------------------------------------------------------------------------------------
/**
* Screen geometry of a tile's top face, in client px: centre, px per tile at that depth, the 4 corners.
* @returns {{ x: number, y: number, s: number, poly: Array<[number, number]> } | null}
*/
export function tileScreen(view, row, col) {
const raw = rawOf(view);
if (!raw || !Number.isInteger(row) || !Number.isInteger(col)) return null;
try {
if (typeof raw.tileScreen === 'function') {
const r = raw.tileScreen(row, col);
if (r && Number.isFinite(r.x) && Number.isFinite(r.y)) return r;
}
const dbg = raw.debug;
const cam = dbg && dbg.cam;
const canvas = dbg && dbg.app && dbg.app.view;
if (cam && typeof cam.project === 'function' && canvas && typeof canvas.getBoundingClientRect === 'function') {
const z = dbg.tiles && typeof dbg.tiles.heightAt === 'function' ? dbg.tiles.heightAt(row, col) : 0;
const cr = canvas.getBoundingClientRect();
const c = cam.project(col, row, z);
const corner = (dx, dy) => { const p = cam.project(col + dx, row + dy, z); return [cr.left + p.x, cr.top + p.y]; };
return { x: cr.left + c.x, y: cr.top + c.y, s: c.s, poly: [corner(-0.5, 0.5), corner(0.5, 0.5), corner(0.5, -0.5), corner(-0.5, -0.5)] };
}
} catch { /* no geometry: the caller hides its visuals */ }
return null;
}
/** Per-view pins of the engine fallback (dev hooks): uid → { row, col }. */
const pins = new WeakMap();
function pinLoop(raw) {
const st = pins.get(raw);
if (!st || st.raf) return;
const step = () => {
st.raf = 0;
if (!st.map.size) return;
try {
const views = raw.debug && raw.debug.views;
const heightAt = raw.debug && raw.debug.tiles && raw.debug.tiles.heightAt ? (r, c) => raw.debug.tiles.heightAt(r, c) : () => 0;
for (const [uid, t] of st.map) {
const v = views && views.get('p:' + uid);
if (!v || v.destroyed) continue;
const w = { x: t.col, y: t.row, z: heightAt(t.row, t.col) };
v._home = w;
if (!v._tween || v._tween.tx !== w.x || v._tween.ty !== w.y) {
if (Math.abs(v.x - w.x) + Math.abs(v.y - w.y) > 1e-3) v._tween = { fx: v.x, fy: v.y, fz: v.z, tx: w.x, ty: w.y, tz: w.z, t: 0 };
else v._tween = null;
}
v.lift = 0;
}
} catch { /* view torn down */ }
st.raf = requestAnimationFrame(step);
};
st.raf = requestAnimationFrame(step);
}
/**
* Keep a dropped piece standing on `tile` while its direction is being chosen / the move is in flight (`tile` null
* releases it; the caller re-applies the prep state so the piece settles where the server says).
*/
export function holdPiece(view, uid, tile) {
const raw = rawOf(view);
if (!raw || !Number.isInteger(uid)) return;
try {
if (typeof raw.holdPiece === 'function') { raw.holdPiece(uid, tile || null); return; }
} catch { return; }
if (!raw.debug || !raw.debug.views) return;
let st = pins.get(raw);
if (!st) { st = { map: new Map(), raf: 0 }; pins.set(raw, st); }
if (tile && Number.isInteger(tile.row) && Number.isInteger(tile.col)) {
st.map.set(uid, { row: tile.row, col: tile.col });
pinLoop(raw);
} else {
st.map.delete(uid);
if (!st.map.size && st.raf) { cancelAnimationFrame(st.raf); st.raf = 0; }
}
}
/** Show a piece facing `dir` (live wheel preview, or the stored m.private `dir`). */
export function setPieceDir(view, uid, dir) {
const raw = rawOf(view);
if (!raw || !Number.isInteger(uid)) return;
try {
if (typeof raw.setPieceDir === 'function') { raw.setPieceDir(uid, normDir(dir)); return; }
const v = raw.debug && raw.debug.views && raw.debug.views.get('p:' + uid);
if (v && typeof v.setDir === 'function') v.setDir(normDir(dir));
} catch { /* cosmetic only */ }
}
/**
* Apply the stored facing of every own piece (board: `piece.dir`, default RIGHT; bench: RIGHT) to the view, except
* `skip` (the piece whose direction the wheel is previewing).
*/
export function syncPieceDirs(view, priv, skip = null) {
if (!view || !priv) return;
for (const p of Array.isArray(priv.board) ? priv.board : []) if (p && p.uid !== skip) setPieceDir(view, p.uid, p.dir);
for (const list of [priv.hand, priv.temp]) for (const p of Array.isArray(list) ? list : []) if (p && p.uid !== skip && p.kind !== 'item') setPieceDir(view, p.uid, 'RIGHT');
}
/** Light the rotated range of a piece on (row, col) facing `dir` (no grid / dir clears the style's group). */
export function showRange(view, grid, row, col, dir, style = FACING_STYLE) {
if (!view || typeof view.highlightTiles !== 'function') return [];
const tiles = grid && dir ? rangeTiles(grid, row, col, dir) : [];
try { view.highlightTiles(tiles, style); } catch { /* cosmetic */ }
return tiles;
}
/** Follow a tile's screen position (the camera may still be moving, the window may resize). */
export function useTileScreen(view, row, col) {
const [g, setG] = useState(() => tileScreen(view, row, col));
const last = useRef('');
useEffect(() => {
let raf = 0;
let alive = true;
// no tile (nothing selected): nothing to follow
if (!view || !Number.isInteger(row) || !Number.isInteger(col)) { last.current = ''; setG(null); return undefined; }
const tick = () => {
if (!alive) return;
const t = tileScreen(view, row, col);
const key = t ? `${t.x.toFixed(1)},${t.y.toFixed(1)},${t.s.toFixed(2)}` : '';
if (key !== last.current) { last.current = key; setG(t); }
raf = requestAnimationFrame(tick);
};
tick();
return () => { alive = false; cancelAnimationFrame(raf); };
}, [view, row, col]);
return g;
}
// ---- the wheel -------------------------------------------------------------------------------------------------------
/** Chevron centre (viewBox units, diamond half-diagonal = 100) and rotation per direction. */
const CHEV = { UP: [0, -64, -90], RIGHT: [64, 0, 0], DOWN: [0, 64, 90], LEFT: [-64, 0, 180] };
function Chevron({ dir, on }) {
const [x, y, rot] = CHEV[dir];
return html`<g class=${cx('fwheel__chev', on && 'is-on')} transform=${`translate(${x} ${y}) rotate(${rot})`}>
<path d="M-9 -15 L3 0 L-9 15 L-3 15 L9 0 L-3 -15 Z" />
</g>`;
}
/**
* The direction wheel overlay.
* @param {{ view: any, row: number, col: number, grid: Array<[number, number]>|null, name?: string,
* onPreview?: (dir: string|null) => void, onCommit: (dir: string) => void, onCancel: () => void }} props
*/
export function FacingWheel({ view, row, col, grid, name = '', onPreview, onCommit, onCancel }) {
const g = useTileScreen(view, row, col);
const [dir, setDir] = useState(null);
const [drag, setDrag] = useState(null); // { id } while a pointer is down
const live = useRef({});
const s = g && g.s > 0 ? g.s : 64;
const half = s * 1.5; // half-diagonal: ~3 tiles across
const dead = s * DEAD_ZONE_TILES; // centre dead-zone radius
// `dir` is the SCREEN direction (chevrons, swipe, arrow keys). On the mirrored right half of the Final Assault prep
// (research 09 §1.2: col c → 20 − c, RIGHT ↔ LEFT) the board direction differs: the range, the model preview and
// the committed g.move / g.art use the BOARD direction (render/prepfield.js maps it back for display).
const mirror = viewMirrored(view) || !!(g && g.mirror);
const bdir = boardDir(dir, mirror);
live.current = { g, half, dead, dir, bdir, mirror, onCommit, onCancel, onPreview };
// preview: model / wedge direction and the rotated range under the units
const tiles = useRangeHighlight(view, grid, row, col, bdir);
useEffect(() => { try { onPreview?.(bdir); } catch { /* ignore */ } }, [bdir]);
useEffect(() => () => { showRange(view, null, row, col, null); }, []);
const choose = (clientX, clientY) => {
const L = live.current;
if (!L.g) return null;
const d = dirFromDelta(clientX - L.g.x, clientY - L.g.y, L.dead);
setDir(d);
return d;
};
const inside = (clientX, clientY) => {
const L = live.current;
if (!L.g) return true;
return Math.abs(clientX - L.g.x) + Math.abs(clientY - L.g.y) <= L.half * 1.18;
};
const onDown = (e) => {
if (e.button != null && e.button !== 0) { e.preventDefault(); return; }
if (e.target && e.target.closest && e.target.closest('.fwheel__cancel')) return;
e.preventDefault();
if (!inside(e.clientX, e.clientY)) { onCancel(); return; }
try { e.currentTarget.setPointerCapture(e.pointerId); } catch { /* ignore */ }
setDrag({ id: e.pointerId });
choose(e.clientX, e.clientY);
};
const onMove = (e) => {
if (!drag || e.pointerId !== drag.id) return;
choose(e.clientX, e.clientY);
};
const onUp = (e) => {
if (!drag || e.pointerId !== drag.id) return;
setDrag(null);
const d = choose(e.clientX, e.clientY);
if (d) onCommit(boardDir(d, live.current.mirror)); else onCancel();
};
const onPointerCancel = () => { setDrag(null); setDir(null); };
// keyboard: arrows preview, Enter commits, Esc cancels (capture: the game's own shortcuts must not see them)
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; }
if (e.key === ' ' || /^Key[RFD]$/.test(e.code || '')) { e.preventDefault(); e.stopImmediatePropagation(); } // no ready / shop while choosing
};
window.addEventListener('keydown', onKey, true);
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 ? `「${name}」的` : ''}朝向`}
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}
${g ? html`<div class="fwheel__dia" style=${`left:${g.x}px;top:${g.y}px;width:${box}px;height:${box}px`}>
<svg class="fwheel__svg" viewBox="-110 -110 220 220" aria-hidden="true">
<path class="fwheel__outer" d="M0 -100 L100 0 L0 100 L-100 0 Z" />
<path class=${cx('fwheel__quad', dir === 'UP' && 'is-on')} d="M0 -100 L50 -50 L0 0 L-50 -50 Z" />
<path class=${cx('fwheel__quad', dir === 'RIGHT' && 'is-on')} d="M100 0 L50 50 L0 0 L50 -50 Z" />
<path class=${cx('fwheel__quad', dir === 'DOWN' && 'is-on')} d="M0 100 L-50 50 L0 0 L50 50 Z" />
<path class=${cx('fwheel__quad', dir === 'LEFT' && 'is-on')} d="M-100 0 L-50 -50 L0 0 L-50 50 Z" />
<path class="fwheel__inner" d=${`M0 ${-DEAD_ZONE_TILES / 1.5 * 100} L${DEAD_ZONE_TILES / 1.5 * 100} 0 L0 ${DEAD_ZONE_TILES / 1.5 * 100} L${-DEAD_ZONE_TILES / 1.5 * 100} 0 Z`} />
${DIRS.map((d) => html`<${Chevron} key=${d} dir=${d} on=${dir === d} />`)}
</svg>
<button type="button" class="fwheel__cancel" onPointerDown=${(e) => e.stopPropagation()}
onClick=${(e) => { e.stopPropagation(); onCancel(); }} aria-label="点击取消">
<${LocalSprite} name="cancel_icon" class="fwheel__x" fallback=${html`<span class="fwheel__x fwheel__x--txt">✕</span>`} />
<span>点击取消</span>
</button>
${outside ? html`<span class="fwheel__tip" role="status">拖回中心区域取消</span>` : null}
<span class="fwheel__sr" aria-live="polite">${dir ? `朝向:${DIR_LABEL[dir]}` : ''}</span>
</div>` : null}
</div>`;
}
/** Keep the view's 'facing' highlight group on the rotated range; returns the tiles. */
function useRangeHighlight(view, grid, row, col, dir) {
const [tiles, setTiles] = useState([]);
useLayoutEffect(() => { setTiles(showRange(view, grid, row, col, dir)); }, [view, grid, row, col, dir]);
return tiles;
}
/**
* Where the striped range layer goes (client geometry is shared): with the 3D board on, UNDER the transparent Pixi
* canvas and above the three.js board canvas — the units stand on the stripes like in the original. Otherwise (2D
* board: the Pixi canvas is opaque; DOM fallback) the stripes are an overlay above the field and skip the piece's
* own tile. Returns null for the overlay case.
* @returns {{ host: HTMLElement, before: HTMLElement } | null}
*/
export function stripeMount(view) {
const raw = rawOf(view);
try {
const dbg = raw && raw.debug;
const canvas = dbg && dbg.app && dbg.app.view;
const host = canvas && canvas.parentElement;
if (!host || !dbg.board3d || typeof host.insertBefore !== 'function') return null;
return { host, before: canvas };
} catch { return null; }
}
const SVG_NS = 'http://www.w3.org/2000/svg';
let stripeSeq = 0;
/** Screen polygons (client px) of range tiles, as SVG `points` strings, offset by (ox, oy). */
export function stripePolys(view, tiles, ox = 0, oy = 0) {
const out = [];
for (const [r, c] of tiles) {
const t = tileScreen(view, r, c);
if (t && Array.isArray(t.poly) && t.poly.length >= 3) out.push({ k: `${r},${c}`, pts: t.poly.map((p) => `${(p[0] - ox).toFixed(1)},${(p[1] - oy).toFixed(1)}`).join(' ') });
}
return out;
}
/**
* Striped orange range tiles (the official look), re-projected every frame (the camera may still move). The solid
* orange fill is the render view's highlight group under the units; these are the light stripes on top of it.
*/
function Stripes({ tiles, view, row, col }) {
const [, setN] = useState(0);
const mount = stripeMount(view);
const key = tiles.map((t) => t.join(',')).join(';');
// under the units (3D board): an imperatively managed <svg> between the two canvases
useEffect(() => {
if (!mount || !tiles.length) return undefined;
const id = `fwheel-stripe-u${++stripeSeq}`;
const svg = document.createElementNS(SVG_NS, 'svg');
svg.setAttribute('class', 'fwheel__stripes fwheel__stripes--under');
svg.setAttribute('aria-hidden', 'true');
Object.assign(svg.style, { position: 'absolute', left: '0', top: '0', width: '100%', height: '100%', zIndex: '0', pointerEvents: 'none', overflow: 'visible' });
svg.innerHTML = `<defs><pattern id="${id}" width="10" height="10" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">`
+ '<rect width="3.5" height="10" fill="rgba(255,178,70,.5)" /></pattern></defs><g></g>';
const g = svg.lastChild;
mount.host.insertBefore(svg, mount.before);
let raf = 0;
let last = '';
const tick = () => {
const hr = mount.host.getBoundingClientRect();
const polys = stripePolys(view, tiles, hr.left, hr.top);
const sig = polys.map((p) => p.pts).join('|');
if (sig !== last) {
last = sig;
g.replaceChildren(...polys.map((p) => {
const el = document.createElementNS(SVG_NS, 'polygon');
el.setAttribute('points', p.pts);
el.setAttribute('fill', `url(#${id})`);
el.setAttribute('stroke', 'rgba(255,176,70,.9)');
el.setAttribute('stroke-width', '1.5');
return el;
}));
}
raf = requestAnimationFrame(tick);
};
tick();
return () => { cancelAnimationFrame(raf); svg.remove(); };
}, [key, view, !!mount]);
// overlay (2D board / fallback): never over the piece's own tile, so the unit being placed stays visible
const over = mount ? [] : tiles.filter(([r, c]) => r !== row || c !== col);
const overKey = over.map((t) => t.join(',')).join(';');
useEffect(() => {
if (!over.length) return undefined;
let raf = 0;
let last = '';
const tick = () => {
const t = tileScreen(view, over[0][0], over[0][1]);
const k = t ? `${t.x.toFixed(1)},${t.y.toFixed(1)}` : '';
if (k !== last) { last = k; setN((n) => n + 1); }
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [overKey, view]);
if (!over.length) return null;
const polys = stripePolys(view, over);
return html`<svg class="fwheel__stripes" aria-hidden="true">
<defs>
<pattern id="fwheel-stripe" width="10" height="10" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
<rect width="3.5" height="10" fill="rgba(255,178,70,.42)" />
</pattern>
</defs>
${polys.map((p) => html`<polygon key=${p.k} points=${p.pts} fill="url(#fwheel-stripe)" stroke="rgba(255,176,70,.9)" stroke-width="1.5" />`)}
</svg>`;
}
+507
View File
@@ -0,0 +1,507 @@
// DOM fallback for the Pixi field view (DESIGN §9 API). Used when public/js/render/app.js is missing or
// fails to initialise, so the match stays playable: a flat tile board + 整备区, pieces as avatars with
// tier chips, pointer drag & drop (same events as the render engine), and a simple battle view (units as
// avatars with HP/SP bars, interpolated by CSS transitions, optional damage numbers).
//
// API (same as createFieldView): setStage, setCamera, setPrep, enterBattle, pushSnapshot, pushEvents,
// highlightTiles, on, off, pieceScreenRect, resize, destroy (+ setSettings), plus the direction-step hooks of
// ui/facingWheel.js: tileScreen(row, col) (client geometry of a tile), holdPiece(uid, tile|null) (a dropped piece
// stays drawn on the target tile while its direction is chosen) and setPieceDir(uid, dir) (the facing wedge of a
// board piece). highlightTiles accepts `{ group }` styles: an empty list with a group clears only that group.
// setCamera('pen') shows the enemy preview pen (research 09 §2): the coming round's enemies (m.private.nextEnemies, or the
// scouted teammate's m.field { prep: true, nextEnemies }) placed
// like the official client and exactly where the render engine puts them (gameLogic penPlacement = render/pen.js
// layoutPen: rows 14–18 × cols 7–13, upper gate rows 17–18, lower gate 14–15, row 16 empty, ≤ 50 models, ≤ 3 per tile);
// tapping one emits pieceClick { enemyKey, preview: true, unit: { side: 'enemy', defId } }. setCamera('bossPrep') (the
// Final Assault prep) keeps the own board here — every coordinate the UI exchanges is a board coordinate anyway.
import { render } from '../../vendor/preact.module.js';
import { html, TierChip } from './components.js';
import { GEO } from '../../../shared/constants.js';
import { chessAvatarUrl, itemIconUrl, tokenAvatarUrl, enemyIconUrl } from './assetUrls.js';
import { tileKey, hasFlag, UF, penPlacement, PEN } from './gameLogic.js';
const DRAG_PX = 6;
const DMG_TTL = 900;
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/** Avatar URL for a UnitInfo `avatar` asset id (chars / E2 / tokens / enemies). */
function unitArt(m, info) {
const id = info?.avatar || info?.defId;
if (!m || !id) return null;
if (info.side === 'enemy' || String(id).startsWith('enemy_')) return enemyIconUrl(m, id);
if (String(id).startsWith('token_')) return tokenAvatarUrl(m, id);
const chars = m.chars || {};
if (chars[id]?.avatar) return chars[id].avatar;
if (String(id).endsWith('_2') && chars[id.slice(0, -2)]) return chars[id.slice(0, -2)].avatarE2 || chars[id.slice(0, -2)].avatar;
return null;
}
/**
* Create the DOM fallback view.
* @param {HTMLElement} host
* @param {{ data: any, assets?: any, settings?: any }} opts
*/
export function createFallbackView(host, opts = {}) {
const dataStore = opts.data;
const m = () => opts.assets || dataStore?.get?.('assets') || null;
const lookup = (file, id) => dataStore?.lookup?.(file, id) ?? null;
const listeners = new Map();
const root = document.createElement('div');
root.className = 'ff';
host.appendChild(root);
const st = {
stage: null, camera: 'prep', rect: { ...GEO.NORMAL_RECT }, side: 'L',
mode: 'prep', priv: null, editable: false, canPlace: null,
field: null, units: new Map(), snapUnits: new Map(), highlight: new Map(), hlGroups: new Map(), held: new Map(), dirs: new Map(),
drag: null, hoverTarget: null, floats: [], settings: { damageNumbers: true, ...(opts.settings || {}) },
size: { w: 0, h: 0 }, destroyed: false, pen: [],
};
const emit = (type, payload) => {
for (const fn of [...(listeners.get(type) || [])]) {
try { fn(payload); } catch (err) { console.error('[fallback-field] listener failed', err); }
}
};
let raf = 0;
const schedule = () => {
if (raf || st.destroyed) return;
raf = requestAnimationFrame(() => { raf = 0; paint(); });
};
// ---- geometry ----------------------------------------------------------------------------------------
function layout() {
const w = st.size.w || host.clientWidth || 1;
const h = st.size.h || host.clientHeight || 1;
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 100;
const r = st.rect;
const cols = r.c1 - r.c0 + 1;
const prep = st.mode === 'prep';
const hasTemp = prep && Array.isArray(st.priv?.temp) && st.priv.temp.some(Boolean);
const rows = r.r1 - r.r0 + 1 + (prep ? 2.6 + (hasTemp ? 1.2 : 0) : 0); // + gap + hand (+ temp) rows
// keep clear of the HUD: top bar + bond strip above, shop bar (prep) / switcher (combat) below
const top0 = rem * 2.0;
const bottom0 = h - rem * (prep ? 3.3 : 1.1);
const availW = w * (prep ? 0.58 : 0.8);
const availH = Math.max(rem, bottom0 - top0);
const tile = Math.max(24, Math.floor(Math.min(availW / cols, availH / rows)));
const bw = tile * cols;
const bh = tile * rows;
const left = Math.round(w * (prep ? 0.44 : 0.5) - bw / 2);
const top = Math.round(top0 + (availH - bh) / 2);
return { tile, left, top, cols, rows: r.r1 - r.r0 + 1, bw, bh };
}
const tilePos = (L, row, col) => ({ x: (col - st.rect.c0) * L.tile, y: (st.rect.r1 - row) * L.tile });
const handPos = (L, idx) => ({ x: idx * L.tile, y: (L.rows + 0.6) * L.tile });
const tempPos = (L, idx) => ({ x: (GEO.TEMP_C0 + idx) * L.tile, y: (L.rows + 1.72) * L.tile });
// ---- pieces (prep) ---------------------------------------------------------------------------------------
function pieceArt(p) {
const mm = m();
if (p.kind === 'item') return itemIconUrl(mm, lookup('items', p.id));
if (p.kind === 'token') return tokenAvatarUrl(mm, p.id);
return chessAvatarUrl(mm, lookup('chess', p.id));
}
function pieceName(p) {
if (p.kind === 'item') return lookup('items', p.id)?.name || '道具';
if (p.kind === 'token') return lookup('tokens', p.id)?.name || '召唤物';
return lookup('chess', p.id)?.name || '干员';
}
function Piece({ p, x, y, L, area }) {
const src = pieceArt(p);
const dragging = st.drag?.uid === p.uid && st.drag.moved;
const golden = !!p.golden;
const tier = p.tier || lookup('chess', p.id)?.tier || lookup('items', p.id)?.tier;
const dir = st.dirs.get(p.uid) || (typeof p.dir === 'string' ? p.dir.toUpperCase() : 'RIGHT');
const wedge = (area === 'board' || st.held.has(p.uid)) && p.kind !== 'item';
return html`<div class=${cx('ff-piece', `ff-piece--${p.kind}`, golden && 'is-golden', dragging && 'is-lifted', st.editable && 'is-draggable', area === 'temp' && 'is-temp', wedge && `ff-dir--${dir.toLowerCase()}`)}
data-uid=${p.uid} style=${`transform:translate(${x}px,${y}px);width:${L.tile}px;height:${L.tile}px`}
onPointerDown=${(e) => onDown(e, p)} onContextMenu=${(e) => { e.preventDefault(); emit('pieceClick', { uid: p.uid, button: 2, clientX: e.clientX, clientY: e.clientY }); }}
onPointerEnter=${() => emit('pieceHover', { uid: p.uid })} onPointerLeave=${() => emit('pieceHover', { uid: null })}
title=${pieceName(p)}>
<div class="ff-piece__art">${src ? html`<img src=${src} alt="" draggable=${false} />` : html`<span>${[...pieceName(p)][0]}</span>`}</div>
${tier && p.kind !== 'token' ? html`<${TierChip} tier=${tier} golden=${golden} size="sm" class="ff-piece__tier" />` : null}
${p.kind === 'chess' && Array.isArray(p.items) && p.items.length ? html`<div class="ff-piece__items">${p.items.slice(0, 2).map((it) => html`<i key=${it.uid}></i>`)}</div>` : null}
${p.kind === 'token' && p.count > 1 ? html`<b class="ff-piece__count">×${p.count}</b>` : null}
${wedge ? html`<i class="ff-piece__dir" aria-hidden="true"></i>` : null}
</div>`;
}
function onDown(e, p) {
if (e.button !== 0) return;
e.preventDefault();
st.drag = { uid: p.uid, sx: e.clientX, sy: e.clientY, x: e.clientX, y: e.clientY, moved: false, pointerId: e.pointerId, piece: p };
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onCancel);
}
function targetAt(clientX, clientY) {
const els = document.elementsFromPoint(clientX, clientY);
for (const el of els) {
if (!root.contains(el)) continue;
const t = el.closest?.('[data-drop]');
if (!t) continue;
if (t.dataset.drop === 'board') return { area: 'board', row: Number(t.dataset.row), col: Number(t.dataset.col) };
if (t.dataset.drop === 'hand') return { area: 'hand', idx: Number(t.dataset.idx) };
}
return { area: 'outside', clientX, clientY };
}
function onMove(e) {
const d = st.drag;
if (!d) return;
d.x = e.clientX; d.y = e.clientY;
if (!d.moved && Math.hypot(d.x - d.sx, d.y - d.sy) > DRAG_PX) {
if (!st.editable) return;
d.moved = true;
emit('pieceDragStart', { uid: d.uid });
}
if (d.moved) {
const t = targetAt(e.clientX, e.clientY);
st.hoverTarget = t;
emit('tileHover', t.area === 'board' ? { row: t.row, col: t.col } : null);
schedule();
}
}
function endDrag() {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onCancel);
}
function onUp(e) {
const d = st.drag;
endDrag();
st.drag = null;
st.hoverTarget = null;
if (!d) return;
if (!d.moved) emit('pieceClick', { uid: d.uid, button: 0, clientX: e.clientX, clientY: e.clientY });
else emit('pieceDrop', { uid: d.uid, target: targetAt(e.clientX, e.clientY) });
schedule();
}
function onCancel() {
const d = st.drag;
endDrag();
st.drag = null;
st.hoverTarget = null;
if (d?.moved) emit('pieceDrop', { uid: d.uid, target: { area: 'outside', clientX: -1, clientY: -1 } });
schedule();
}
// ---- tiles ----------------------------------------------------------------------------------------------
function tileClass(row, col) {
const rows = st.stage?.rows;
const glyph = Array.isArray(rows) && typeof rows[row] === 'string' ? rows[row][col] : 'r';
const t = st.stage?.tiles?.[glyph];
if (glyph === 'S') return 'gate';
if (glyph === 'E') return 'goal';
if (!t) return 'void';
if (t.tileKey === 'tile_forbidden') return 'void';
if (t.height === 'HIGH') return t.buildable === 'NONE' ? 'void' : 'high';
if (t.special === 'mire' || glyph === 'm') return 'mire';
if (glyph === 'g') return 'smog';
if (glyph === 'd') return 'deep';
if (glyph === 'i') return 'infect';
if (t.buildable === 'NONE') return 'lane';
return 'floor';
}
function dropState(target) {
if (!st.drag?.moved || !target || typeof st.canPlace !== 'function') return null;
try { return st.canPlace(st.drag.uid, target) ? 'legal' : 'illegal'; } catch { return null; }
}
// ---- battle ---------------------------------------------------------------------------------------------------
function BattleUnit({ id, L }) {
const info = st.units.get(id);
const s = st.snapUnits.get(id);
if (!info || !s) return null;
const [, x, y, hp, maxHp, sp, spMax, flags, anim] = s;
const px = (x - st.rect.c0 + 0.5) * L.tile;
const py = (st.rect.r1 - y + 0.5) * L.tile;
const enemy = info.side === 'enemy';
const size = info.boss ? L.tile * 1.3 : L.tile * 0.86;
const src = unitArt(m(), info);
const dead = anim === 4;
const hpPct = maxHp > 0 ? Math.max(0, Math.min(100, (hp / maxHp) * 100)) : 0;
const spPct = spMax > 0 ? Math.max(0, Math.min(100, (sp / spMax) * 100)) : 0;
return html`<div class=${cx('ff-unit', enemy ? 'is-enemy' : 'is-ally', info.golden && 'is-golden', info.boss && 'is-boss', dead && 'is-dead',
hasFlag(flags, UF.SKILL) && 'is-skill', hasFlag(flags, UF.STUNNED | UF.FROZEN | UF.SLEEP) && 'is-stunned', hasFlag(flags, UF.FLYING) && 'is-fly')}
style=${`transform:translate(${px - size / 2}px,${py - size / 2}px);width:${size}px;height:${size}px`}
onPointerDown=${(e) => { if (e.button === 0) emit('pieceClick', { unitId: id, uid: info.uid ?? null, unit: info, button: 0, clientX: e.clientX, clientY: e.clientY }); }}
onContextMenu=${(e) => { e.preventDefault(); emit('pieceClick', { unitId: id, uid: info.uid ?? null, unit: info, button: 2, clientX: e.clientX, clientY: e.clientY }); }}>
<div class="ff-unit__art">${src ? html`<img src=${src} alt="" draggable=${false} />` : html`<span>${[...(info.name || '?')][0]}</span>`}</div>
<div class="ff-unit__bars"><i class="hp" style=${`width:${hpPct}%`}></i>${!enemy && spMax > 0 ? html`<i class="sp" style=${`width:${spPct}%`}></i>` : null}</div>
</div>`;
}
// ---- enemy preview pen (setCamera('pen')) ------------------------------------------------------------------------
function penView() {
const w = st.size.w || host.clientWidth || 1;
const h = st.size.h || host.clientHeight || 1;
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 100;
const cols = PEN.c1 - PEN.c0 + 1;
const rows = PEN.r1 - PEN.r0 + 1;
const top0 = rem * 1.3;
const tile = Math.max(28, Math.floor(Math.min((w * 0.62) / cols, (h - top0 - rem * 0.5) / rows)));
const bw = tile * cols;
const left = Math.round(w / 2 - bw / 2);
const top = Math.round(top0 + (h - top0 - rem * 0.5 - tile * rows) / 2);
const models = penPlacement(st.pen, { stage: st.stage });
const cells = [];
for (let r = PEN.r1; r >= PEN.r0; r--) {
for (let c = PEN.c0; c <= PEN.c1; c++) {
// the pen's own start tiles (15,7) / (18,7) are its gates (research 09 §2.1)
const gate = c === PEN.c0 && (r === PEN.anchors.upper[0] || r === PEN.anchors.lower[0]);
const cls = gate ? 'gate' : r === PEN.emptyRow ? 'void' : 'floor';
cells.push(html`<div key=${`${r},${c}`} class=${cx('ff-tile', `ff-tile--${cls}`)}
style=${`transform:translate(${(c - PEN.c0) * tile}px,${(PEN.r1 - r) * tile}px);width:${tile}px;height:${tile}px`}></div>`);
}
}
const size = tile * 0.62;
const off = [[0, 0], [-0.22, 0.14], [0.22, 0.14], [-0.2, -0.16], [0.2, -0.16]];
const figs = models.map((mdl) => {
const o = off[mdl.slot % off.length];
const x = (mdl.col - PEN.c0 + 0.5 + o[0]) * tile - size / 2;
const y = (PEN.r1 - mdl.row + 0.5 + o[1]) * tile - size / 2;
const src = unitArt(m(), { side: 'enemy', defId: mdl.enemyKey, avatar: mdl.enemyKey });
const name = lookup('enemies', mdl.enemyKey)?.name || mdl.enemyKey;
const tap = (e) => { if (e.button == null || e.button === 0) emit('pieceClick', { enemyKey: mdl.enemyKey, preview: true, unit: { side: 'enemy', defId: mdl.enemyKey, name }, button: 0, clientX: e.clientX, clientY: e.clientY }); };
return html`<button key=${mdl.k} type="button" class=${cx('ff-unit', 'is-enemy', 'ff-pen__enemy', (mdl.elite || mdl.boss) && 'is-boss', mdl.fly && 'is-fly')}
data-enemy=${mdl.enemyKey} title=${name} aria-label=${name}
style=${`transform:translate(${x}px,${y}px);width:${size}px;height:${size}px;z-index:${10 + mdl.slot}`} onClick=${tap}>
<div class="ff-unit__art">${src ? html`<img src=${src} alt="" draggable=${false} />` : html`<span>${[...name][0]}</span>`}</div>
</button>`;
});
return html`<div class="ff-board ff-board--pen" style=${`left:${left}px;top:${top}px;width:${bw}px;height:${tile * rows}px;--tile:${tile}px`}>
${cells}${figs}
${models.length ? null : html`<p class="ff-pen__empty">暂无敌方情报</p>`}
</div>`;
}
// ---- paint -----------------------------------------------------------------------------------------------------
function paint() {
if (st.destroyed) return;
const L = layout();
const r = st.rect;
const tiles = [];
for (let row = r.r1; row >= r.r0; row--) {
for (let col = r.c0; col <= r.c1; col++) {
const p = tilePos(L, row, col);
const k = tileKey(row, col);
let hl = st.highlight.get(k);
for (const g of st.hlGroups.values()) if (g.tiles.has(k)) hl = g.name;
const hov = st.hoverTarget?.area === 'board' && st.hoverTarget.row === row && st.hoverTarget.col === col ? dropState(st.hoverTarget) : null;
tiles.push(html`<div key=${k} class=${cx('ff-tile', `ff-tile--${tileClass(row, col)}`, hl && `is-${hl}`, hov && `is-hover-${hov}`)}
data-drop="board" data-row=${row} data-col=${col}
style=${`transform:translate(${p.x}px,${p.y}px);width:${L.tile}px;height:${L.tile}px`}></div>`);
}
}
const pieces = [];
const hand = [];
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 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);
pieces.push(html`<${Piece} key=${p.uid} p=${p} x=${pos.x} y=${pos.y} L=${L} area="board" />`);
}
for (let i = 0; i < GEO.HAND_SIZE; i++) {
const pos = handPos(L, i);
const hov = st.hoverTarget?.area === 'hand' && st.hoverTarget.idx === i ? dropState(st.hoverTarget) : null;
hand.push(html`<div key=${`h${i}`} class=${cx('ff-slot', hov && `is-hover-${hov}`)} data-drop="hand" data-idx=${i}
style=${`transform:translate(${pos.x}px,${pos.y}px);width:${L.tile}px;height:${L.tile}px`}><span class="num">${i + 1}</span></div>`);
const p = st.priv.hand?.[i];
if (p) { const hp = heldPos(p) || pos; pieces.push(html`<${Piece} key=${p.uid} p=${p} x=${hp.x} y=${hp.y} L=${L} area="hand" />`); }
}
const temp = Array.isArray(st.priv.temp) ? st.priv.temp : [];
if (temp.some(Boolean)) {
for (let i = 0; i < GEO.TEMP_SIZE; i++) {
const pos = tempPos(L, i);
hand.push(html`<div key=${`t${i}`} class="ff-slot ff-slot--temp" style=${`transform:translate(${pos.x}px,${pos.y}px);width:${L.tile}px;height:${L.tile}px`}></div>`);
const p = temp[i];
if (p) { const hp = heldPos(p) || pos; pieces.push(html`<${Piece} key=${p.uid} p=${p} x=${hp.x} y=${hp.y} L=${L} area="temp" />`); }
}
}
}
const units = st.mode === 'battle' ? [...st.snapUnits.keys()].map((id) => html`<${BattleUnit} key=${id} id=${id} L=${L} />`) : null;
st.floats = st.floats.filter((f) => now - f.at < DMG_TTL);
const floats = st.mode === 'battle' && st.settings.damageNumbers ? st.floats.map((f) => {
const s = st.snapUnits.get(f.id);
if (!s) return null;
const px = (s[1] - r.c0 + 0.5) * L.tile;
const py = (r.r1 - s[2] + 0.5) * L.tile - L.tile * 0.4;
return html`<span key=${f.key} class=${`ff-float ff-float--${f.type}`} style=${`left:${px}px;top:${py}px`}>${f.text}</span>`;
}) : null;
const d = st.drag;
const ghost = d?.moved ? (() => {
const hr = root.getBoundingClientRect();
const src = pieceArt(d.piece);
return html`<div class=${cx('ff-ghost', d.piece.golden && 'is-golden')} style=${`left:${d.x - hr.left - L.tile / 2}px;top:${d.y - hr.top - L.tile / 2}px;width:${L.tile}px;height:${L.tile}px`}>
${src ? html`<img src=${src} alt="" />` : null}</div>`;
})() : null;
if (st.camera === 'pen') { render(html`${penView()}<div class="ff-badge">SIMPLIFIED VIEW</div>`, root); return; }
render(html`<div class=${cx('ff-board', `ff-board--${st.mode}`, `ff-cam--${st.camera}`)} style=${`left:${L.left}px;top:${L.top}px;width:${L.bw}px;height:${L.bh}px;--tile:${L.tile}px`}>
${st.mode === 'prep' ? html`<div class="ff-hand-label" style=${`top:${(L.rows + 0.18) * L.tile}px`}><span>整备区</span><i></i></div>` : null}
${tiles}${hand}${units}${pieces}${floats}
</div>${ghost}
<div class="ff-badge">SIMPLIFIED VIEW</div>`, root);
if (st.floats.length) schedule();
}
// ---- public API -------------------------------------------------------------------------------------------------
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => view.resize()) : null;
ro?.observe(host);
const view = {
kind: 'fallback',
setStage(stage) { st.stage = stage || null; schedule(); },
setCamera(kind, o = {}) {
st.camera = kind || 'prep';
if (kind === 'pen') { schedule(); return; } // the board keeps its rect for the way back
if (o && o.rect && Number.isFinite(o.rect.r0)) st.rect = { ...o.rect };
else if (kind === 'prep' || kind === 'bossPrep' || kind === 'normal') st.rect = { ...GEO.NORMAL_RECT };
if (kind === 'bossPrep') st.camera = 'prep';
else if (kind === 'unite') st.rect = { ...GEO.UNITE_RECT };
else if (kind === 'boss') st.rect = { ...GEO.BOSS_RECT };
st.side = o?.side || 'L';
schedule();
},
setPrep(priv, o = {}) {
st.mode = 'prep';
st.priv = priv || null;
st.pen = Array.isArray(priv?.nextEnemies) ? priv.nextEnemies : [];
st.editable = !!o.editable;
st.canPlace = typeof o.canPlace === 'function' ? o.canPlace : null;
if (st.camera !== 'prep' && st.camera !== 'pen') { st.camera = 'prep'; st.rect = { ...GEO.NORMAL_RECT }; }
if (!st.editable && st.drag) { endDrag(); st.drag = null; st.hoverTarget = null; }
schedule();
},
enterBattle(field) {
st.mode = 'battle';
st.field = field || null;
// a scouted teammate's prep board brings their pen; a real battle empties it
st.pen = field?.prep && Array.isArray(field.nextEnemies) ? field.nextEnemies : [];
st.units.clear();
st.snapUnits.clear();
st.floats = [];
if (field?.rect && Number.isFinite(field.rect.r0)) st.rect = { ...field.rect };
for (const u of Array.isArray(field?.units) ? field.units : []) if (u && u.id != null) {
st.units.set(u.id, u);
st.snapUnits.set(u.id, [u.id, u.x, u.y, u.maxHp, u.maxHp, 0, 0, 0, 0]);
}
schedule();
},
pushSnapshot(snap) {
if (st.mode !== 'battle' || !snap || !Array.isArray(snap.units)) return;
if (st.field && snap.fieldId && st.field.fieldId && snap.fieldId !== st.field.fieldId) return;
const seen = new Set();
for (const t of snap.units) {
if (!Array.isArray(t) || t.length < 9) continue;
seen.add(t[0]);
st.snapUnits.set(t[0], t);
}
for (const id of [...st.snapUnits.keys()]) if (!seen.has(id)) st.snapUnits.delete(id);
schedule();
},
pushEvents(payload) {
const ev = Array.isArray(payload) ? payload : payload && Array.isArray(payload.ev) ? payload.ev : null;
if (!ev) return;
if (!Array.isArray(payload) && st.field?.fieldId && payload.fieldId && payload.fieldId !== st.field.fieldId) return;
const now = performance.now();
for (const e of ev) {
if (!Array.isArray(e)) continue;
if (e[0] === 'spawn' && e[1] && e[1].id != null) {
st.units.set(e[1].id, e[1]);
if (!st.snapUnits.has(e[1].id)) st.snapUnits.set(e[1].id, [e[1].id, e[1].x, e[1].y, e[1].maxHp, e[1].maxHp, 0, 0, 0, 6]);
} else if ((e[0] === 'dmg' || e[0] === 'heal') && st.settings.damageNumbers && st.floats.length < 60) {
const amount = Math.round(Number(e[2]) || 0);
if (amount <= 0) continue;
const type = e[0] === 'heal' ? 'heal' : (e[3] === 'arts' ? 'arts' : e[3] === 'true' ? 'true' : 'phys');
st.floats.push({ key: `${now}:${Math.random()}`, id: e[1], text: e[0] === 'heal' ? `+${amount}` : String(amount), type, at: now });
}
}
schedule();
},
highlightTiles(tiles, style) {
if (!tiles || !style) { st.highlight.clear(); st.hlGroups.clear(); schedule(); return; }
if (typeof style === 'object' && style.group) {
// a named group (direction wheel / selected unit range): replaced as a whole, drawn as a range tile
const set = new Set();
for (const t of Array.isArray(tiles) ? tiles : []) {
const row = Array.isArray(t) ? t[0] : t?.row;
const col = Array.isArray(t) ? t[1] : t?.col;
if (Number.isInteger(row) && Number.isInteger(col)) set.add(tileKey(row, col));
}
if (set.size) st.hlGroups.set(style.group, { name: 'range', tiles: set }); else st.hlGroups.delete(style.group);
schedule();
return;
}
for (const t of Array.isArray(tiles) ? tiles : []) {
const row = Array.isArray(t) ? t[0] : t?.row;
const col = Array.isArray(t) ? t[1] : t?.col;
if (Number.isInteger(row) && Number.isInteger(col)) st.highlight.set(tileKey(row, col), style);
}
schedule();
},
setSettings(s) { st.settings = { ...st.settings, ...(s || {}) }; schedule(); },
on(type, fn) {
if (typeof fn !== 'function') return () => {};
if (!listeners.has(type)) listeners.set(type, new Set());
listeners.get(type).add(fn);
return () => listeners.get(type)?.delete(fn);
},
off(type, fn) { listeners.get(type)?.delete(fn); },
pieceScreenRect(uid) {
const el = root.querySelector(`[data-uid="${Number(uid)}"]`);
return el ? el.getBoundingClientRect() : null;
},
/** Client geometry of a tile: centre, px per tile, corners (board tiles and 整备区 slots of the prep layout). */
tileScreen(row, col) {
const L = layout();
const br = root.querySelector('.ff-board')?.getBoundingClientRect();
if (!br) return null;
let p = null;
if (row === GEO.HAND_ROW && col >= 0 && col < GEO.HAND_SIZE && st.mode === 'prep') p = handPos(L, col);
else if (row === GEO.TEMP_ROW && col >= GEO.TEMP_C0 && col < GEO.TEMP_C0 + GEO.TEMP_SIZE && st.mode === 'prep') p = tempPos(L, col - GEO.TEMP_C0);
else if (row >= st.rect.r0 && row <= st.rect.r1 && col >= st.rect.c0 && col <= st.rect.c1) p = tilePos(L, row, col);
if (!p) return null;
const x0 = br.left + p.x, y0 = br.top + p.y, s = L.tile;
return { x: x0 + s / 2, y: y0 + s / 2, s, poly: [[x0, y0], [x0 + s, y0], [x0 + s, y0 + s], [x0, y0 + s]] };
},
holdPiece(uid, tile) {
if (tile && Number.isInteger(tile.row) && Number.isInteger(tile.col)) st.held.set(uid, { row: tile.row, col: tile.col });
else st.held.delete(uid);
schedule();
},
setPieceDir(uid, dir) {
const d = typeof dir === 'string' ? dir.toUpperCase() : null;
if (d && ['UP', 'RIGHT', 'DOWN', 'LEFT'].includes(d)) st.dirs.set(uid, d); else st.dirs.delete(uid);
schedule();
},
resize() {
st.size = { w: host.clientWidth, h: host.clientHeight };
schedule();
},
destroy() {
if (st.destroyed) return;
st.destroyed = true;
endDrag();
ro?.disconnect();
if (raf) cancelAnimationFrame(raf);
try { render(null, root); } catch { /* ignore */ }
root.remove();
listeners.clear();
},
};
view.resize();
return view;
}
+140
View File
@@ -0,0 +1,140 @@
// Field view loader: mounts the Pixi render engine (public/js/render/app.js → createFieldView, DESIGN §9)
// into a host element, falling back to the DOM view (fallbackField.js) when the engine is missing, times
// out or throws. Every call into the view goes through a guard so a render bug can never crash the HUD.
//
// `?render=fallback` (or globalThis.__SP_RENDER__ = 'fallback') skips the engine (dev / mock harness);
// `?render=engine` never falls back silently (errors are logged and the fallback still mounts).
// relative, like every other client module (the import map only resolves to this same URL; no bare specifier here)
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { createFallbackView } from './fallbackField.js';
import { data } from '../data.js';
import { audio } from '../audio.js';
import { settingsStore } from './settings.js';
const LOAD_TIMEOUT_MS = 12000;
const METHODS = ['setStage', 'setCamera', 'setPrep', 'enterBattle', 'pushSnapshot', 'pushEvents', 'highlightTiles', 'on', 'resize', 'destroy'];
// direction-step hooks (ui/facingWheel.js): optional — the wheel falls back to the engine's dev hooks when absent;
// setPen (enemy preview pen list), prepField ({ kind, side, mirror } of the Final Assault prep), stripesUnder (the view
// stripes range previews under the units itself) — render/app.js; the DOM fallback lacks them (→ null)
const OPTIONAL = ['pieceScreenRect', 'setSettings', 'off', 'tileScreen', 'holdPiece', 'setPieceDir', 'setPen', 'prepField', 'stripesUnder'];
/**
* Camera padding (px) that keeps the field clear of the DOM HUD (top bar + bond strip, team panel, shop bar /
* combat switcher, effects column). Sizes follow the rem scale of css/theme.css.
* The Final Assault prep ('bossPrep') has the prep HUD (shop bar below) on the boss field: prep padding; the enemy pen
* ('pen') is viewed with the shop collapsed.
* @param {string} kind 'prep'|'bossPrep'|'pen'|'normal'|'unite'|'boss'|'hidden'
* @param {{ width: number, height: number }} size
*/
export function hudPadding(kind, size) {
let rem = 100;
try { rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 100; } catch { /* ignore */ }
const h = size?.height || 1080;
const clampH = (v) => Math.min(v, h * 0.4);
if (kind === 'prep' || kind === 'bossPrep') return { top: clampH(rem * 1.95), bottom: clampH(rem * 3.2), left: rem * 2.25, right: rem * 0.9 };
if (kind === 'boss' || kind === 'hidden' || kind === 'unite') return { top: clampH(rem * 1.9), bottom: clampH(rem * 0.95), left: rem * 2.1, right: rem * 0.95 };
return { top: clampH(rem * 1.95), bottom: clampH(rem * 1.0), left: rem * 2.25, right: rem * 1.05 };
}
function renderPref() {
try {
const q = new URLSearchParams(globalThis.location?.search || '').get('render');
if (q === 'fallback' || q === 'engine') return q;
} catch { /* ignore */ }
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))]);
}
/**
* Wrap a view so no method can throw into the UI (each failing method is logged once).
* @param {any} view
* @param {'engine'|'fallback'} kind
*/
export function guardView(view, kind) {
const warned = new Set();
const safe = { kind, raw: view };
for (const name of [...METHODS, ...OPTIONAL]) {
safe[name] = (...args) => {
const fn = view && view[name];
if (typeof fn !== 'function') return name === 'on' ? () => {} : null;
try {
const out = fn.apply(view, args);
if (out && typeof out.then === 'function') out.catch((err) => { if (!warned.has(name)) { warned.add(name); console.warn(`[field] ${name} failed`, err); } });
if (name === 'on') return typeof out === 'function' ? out : () => { try { view.off?.(args[0], args[1]); } catch { /* ignore */ } };
return out;
} catch (err) {
if (!warned.has(name)) { warned.add(name); console.warn(`[field] ${kind} view ${name} failed`, err); }
return name === 'on' ? () => {} : null;
}
};
}
return safe;
}
/**
* Create a field view in `host`: the render engine when available, else the DOM fallback.
* @param {HTMLElement} host
* @returns {Promise<ReturnType<typeof guardView>>}
*/
export async function mountFieldView(host) {
const pref = renderPref();
const opts = { data, assets: data.get('assets'), audio, settings: settingsStore.get(), padding: hudPadding };
if (pref !== 'fallback') {
try {
// 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);
if (am?.assets && typeof am.assets.ready === 'function') opts.assets = am.assets;
const mod = await withTimeout(import('../render/app.js'), LOAD_TIMEOUT_MS, 'render engine import');
if (typeof mod?.createFieldView !== 'function') throw new Error('createFieldView missing');
const view = await withTimeout(Promise.resolve(mod.createFieldView(host, opts)), LOAD_TIMEOUT_MS, 'createFieldView');
const missing = METHODS.filter((k) => typeof view?.[k] !== 'function');
if (missing.length) {
try { view?.destroy?.(); } catch { /* ignore */ }
throw new Error(`view lacks ${missing.join(', ')}`);
}
return guardView(view, 'engine');
} catch (err) {
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 */ }
}
}
return guardView(createFallbackView(host, { ...opts, assets: data.get('assets') }), 'fallback');
}
/**
* Preact hook: mount a field view into `hostRef` once; returns { view, kind } (view null while loading).
* @param {{ current: HTMLElement|null }} hostRef
*/
export function useFieldView(hostRef) {
const [state, setState] = useState({ view: null, kind: 'loading' });
const viewRef = useRef(null);
useEffect(() => {
let dead = false;
const host = hostRef.current;
if (!host) return undefined;
mountFieldView(host).then((view) => {
if (dead) { view.destroy(); return; }
viewRef.current = view;
globalThis.__SP_VIEW__ = view; // dev / E2E introspection (view.raw.stats?.())
setState({ view, kind: view.kind });
}, (err) => console.error('[field] mount failed', err));
const unsub = settingsStore.subscribe((s) => viewRef.current?.setSettings?.(s));
const onResize = () => viewRef.current?.resize();
window.addEventListener('resize', onResize);
return () => {
dead = true;
unsub();
window.removeEventListener('resize', onResize);
// 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;
};
}, []);
return state;
}
+74
View File
@@ -0,0 +1,74 @@
// In-match intents (`g.*`, DESIGN §8.2) with uniform error handling: every request goes through
// `act()`, which plays a UI sound on success, shows the ERR_TEXT toast on failure and never throws.
// The UI never mutates state optimistically — it waits for the next m.private / m.public push.
// `net.request` is looked up at call time so the dev mock harness can stub it.
import { net } from '../net.js';
import { toastError } from './toasts.js';
import { audio } from '../audio.js';
const SUCCESS_SFX = {
'g.buy': 'buy', 'g.sell': 'sell', 'g.refresh': 'refresh', 'g.freeze': 'freeze', 'g.levelUp': 'levelup',
'g.move': 'drop', 'g.equip': 'equip', 'g.art': 'artPlace', 'g.reward': 'pick', 'g.choice': 'pick',
'g.band': 'confirm', 'g.bandSkip': 'back', 'g.infoReady': 'ready', 'g.emote': 'emote', 'g.destroy': 'back',
};
let inflight = 0;
const busyListeners = new Set();
const emitBusy = () => { for (const fn of [...busyListeners]) { try { fn(inflight); } catch { /* ignore */ } } };
/** Subscribe to the number of in-flight intents (for subtle busy indicators). */
export function onBusy(fn) { busyListeners.add(fn); return () => busyListeners.delete(fn); }
/**
* Send an intent. Resolves true on `ok`, false on error (already toasted).
* @param {string} t
* @param {object} [fields]
* @param {{ sfx?: string|false, quiet?: boolean }} [opts]
* @returns {Promise<boolean>}
*/
export async function act(t, fields = {}, opts = {}) {
inflight += 1;
emitBusy();
try {
await net.request(t, fields);
const s = opts.sfx === undefined ? SUCCESS_SFX[t] : opts.sfx;
if (s) audio.sfx(s);
return true;
} catch (err) {
if (!opts.quiet) {
toastError(err);
audio.sfx('error', { volume: 0.6 });
}
return false;
} finally {
inflight = Math.max(0, inflight - 1);
emitBusy();
}
}
export const actions = {
infoReady: () => act('g.infoReady'),
band: (bandId) => act('g.band', { bandId }),
bandSkip: () => act('g.bandSkip'),
buy: (slot) => act('g.buy', { slot }),
refresh: () => act('g.refresh'),
freeze: () => act('g.freeze'),
levelUp: () => act('g.levelUp'),
sell: (uid) => act('g.sell', { uid }),
// `dir` (UP|RIGHT|DOWN|LEFT): the direction chosen with the deploy wheel (research 09 §6.1 — g.move / g.art carry it;
// the server defaults to RIGHT when absent)
move: (uid, to, dir) => act('g.move', dir ? { uid, to, dir } : { uid, to }),
// `replaceUid`: the equipped item the replace dialog picked (ui/equipReplace.js) — it is destroyed; absent ⇒ none needed
equip: (itemUid, targetUid, replaceUid) => act('g.equip', Number.isInteger(replaceUid) ? { itemUid, targetUid, replaceUid } : { itemUid, targetUid }),
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 }),
ready: (ready) => act('g.ready', { ready }, { sfx: ready ? 'ready' : 'back' }),
emote: (id) => act('g.emote', { id }, { quiet: true }),
watch: (fieldId) => act('g.watch', { fieldId }, { sfx: 'tab' }),
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' }),
};
+225
View File
@@ -0,0 +1,225 @@
// 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 {
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. */
export const GAME_FILES = ['config', 'assets', 'chess', 'bonds', 'items', 'bands', 'enemies', 'bosses', 'stages', 'tokens',
'choices', 'effects', 'garrisons', 'factions', 'local'];
/**
* 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, list: (name:string)=>any[] }}
*/
export function useGameData() {
const ready = useData(...GAME_FILES);
return useMemo(() => makeLookups(ready), [ready]);
}
/** 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),
token: (id) => data.lookup('tokens', id),
effect: (id) => data.lookup('effects', id),
garrison: (id) => data.lookup('garrisons', id),
factions: data.get('factions'),
choices: data.get('choices'),
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 }} props
*/
export function UnitThumb({ kind = 'chess', id, golden, size = 'md', tier, showTier = true, class: cls, dim = false, badge = null, title }) {
const m = data.get('assets');
let src = null;
let name = '';
let t = tier;
if (kind === 'chess') {
const c = data.lookup('chess', id);
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">召唤</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} />`;
}
/** Extra 24×24 glyphs used by the in-match HUD (original shapes). */
const GLYPHS = {
gear: 'M10.3 2h3.4l.5 2.6c.6.2 1.2.5 1.7.9l2.5-.9 1.7 2.9-2 1.8c.1.6.1 1.2 0 1.8l2 1.8-1.7 2.9-2.5-.9c-.5.4-1.1.7-1.7.9l-.5 2.6h-3.4l-.5-2.6c-.6-.2-1.2-.5-1.7-.9l-2.5.9L2 14.9l2-1.8a6 6 0 0 1 0-1.8L2 9.5l1.7-2.9 2.5.9c.5-.4 1.1-.7 1.7-.9zM12 8.8a3.2 3.2 0 1 0 0 6.4 3.2 3.2 0 0 0 0-6.4z',
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. */
export function LpTower({ value, size = 'md', class: cls, tone }) {
return html`<span class=${cx('lp', `lp--${size}`, tone && `lp--${tone}`, cls)} title="目标生命值">
<${Sprite} k="hudPanel/icon_hp" class="lp__icon" fallback=${html`<${Icon} name="rook" class="lp__icon" />`} />
<b class="num">${Number.isFinite(value) ? value : '--'}</b>
</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="已淘汰"><${Icon} name="close" /></span>` : null}
${left ? html`<span class="pavatar__door" aria-label="已离开"><${Icon} name="exit" /></span>` : null}
</span>`;
}
File diff suppressed because it is too large. Load diff
+168
View File
@@ -0,0 +1,168 @@
// 玩法说明 (How to play): viewer for the 19 official tutorial pages of the mode (local-client art, DESIGN §13:
// data/local-assets.json → guide/autochess_{home 1–9, shop 1–6, handbook 1–4}). The pages are stored squashed to
// 1024² and are displayed stretched back to 16:9. Three chapters (基础规则 / 调度手册 / 进阶图鉴), ‹ › buttons,
// ←/→ (A/D) keys, page dots, thumbnails; Esc or the backdrop closes. Adjacent pages are preloaded.
// Without the local art the viewer shows the official loading-screen tips (config.tips) instead.
//
// Global & imperative so every screen can open it: `openGuide(page?)`; <GuideHost/> is mounted once by main.js
// (and by the dev mock harness); <GuideButton/> is the standard trigger (title, lobby, room, in-match menu).
import { useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
import { html, Icon, MicroLabel, Button, Spinner } from './components.js';
import { createStore, useStore } from '../store.js';
import { data, useData, localAsset } from '../data.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/** Page catalogue in reading order (titles transcribed from the pages). */
export const GUIDE_CHAPTERS = [
{ id: 'home', name: '基础规则', micro: 'BASICS', pages: [
['autochess_home_1', '卫戍协议已运行'], ['autochess_home_2', '攻防战'], ['autochess_home_3', '休整期 · 区域'],
['autochess_home_4', '休整期 · 资金与调度'], ['autochess_home_5', '机变阶段'], ['autochess_home_6', '限时战斗'],
['autochess_home_7', '作战期'], ['autochess_home_8', '协同战斗'], ['autochess_home_9', '盟约'],
] },
{ id: 'shop', name: '调度手册', micro: 'HANDBOOK', pages: [
['autochess_shop_1', '调度手册'], ['autochess_shop_2', '干员晋级'], ['autochess_shop_3', '加成情况'],
['autochess_shop_4', '卫戍能力'], ['autochess_shop_5', '盟约'], ['autochess_shop_6', '助战及自选编队'],
] },
{ id: 'handbook', name: '进阶图鉴', micro: 'ADVANCED', pages: [
['autochess_handbook_1', '敌人类型'], ['autochess_handbook_2', '盟约激活与叠加'], ['autochess_handbook_3', '追加盟约'],
['autochess_handbook_4', '策略与轮选'],
] },
];
/**
* Flat page list with URLs (only pages the local manifest lists).
* @returns {Array<{ key: string, title: string, chapter: number, url: string }>}
*/
export function guidePages() {
const out = [];
GUIDE_CHAPTERS.forEach((ch, ci) => {
for (const [key, title] of ch.pages) {
const url = localAsset('guide', key);
if (url) out.push({ key, title, chapter: ci, url });
}
});
return out;
}
/** Open/closed + current page. */
export const guideStore = createStore({ open: false, page: 0 });
/** Open the viewer (optionally at a page index). */
export function openGuide(page = 0) {
data.load('local');
data.load('config');
guideStore.set({ open: true, page: Math.max(0, page | 0) });
}
export const closeGuide = () => guideStore.set({ open: false });
/** Standard 玩法说明 trigger button. */
export function GuideButton({ class: cls, size = 'sm', variant = 'ghost', label = '玩法说明', square = false }) {
return html`<${Button} variant=${variant} size=${size} icon="book" square=${square} class=${cx('guide-btn', cls)}
onClick=${() => openGuide(0)} title="玩法说明" aria-label="玩法说明">${square ? null : label}<//>`;
}
function preload(url) {
if (!url || typeof Image === 'undefined') return;
const img = new Image();
img.decoding = 'async';
img.src = url;
}
/** Fallback body when the tutorial art is not installed: the official tips as a numbered list. */
function TipsFallback() {
const tips = (Array.isArray(data.get('config')?.tips) ? data.get('config').tips : []).map((t) => t?.tip).filter(Boolean);
return html`<div class="guide__tips">
<${MicroLabel} tone="mint">TIPS // 模拟要点</${MicroLabel}>
<ol>${tips.map((t, i) => html`<li key=${i}>${t}</li>`)}</ol>
${!tips.length ? html`<p class="t-lo">暂无说明内容</p>` : null}
</div>`;
}
/** The viewer (mounted once near the root). */
export function GuideHost() {
const { open, page } = useStore((s) => s, Object.is, guideStore);
const ready = useData('local', 'config');
const pages = useMemo(() => (ready ? guidePages() : []), [ready]);
const [loaded, setLoaded] = useState(() => new Set());
const [failed, setFailed] = useState(() => new Set());
const boxRef = useRef(null);
const n = pages.length;
const i = n ? Math.min(Math.max(0, page), n - 1) : 0;
const cur = pages[i] || null;
const go = (k) => { if (n) guideStore.set({ open: true, page: ((k % n) + n) % n }); };
useEffect(() => {
if (!open) return undefined;
const onKey = (e) => {
if (e.ctrlKey || e.metaKey || e.altKey) return;
const k = e.key;
let handled = true;
if (k === 'Escape') closeGuide();
else if (k === 'ArrowRight' || k === 'd' || k === 'D' || k === 'PageDown') go(guideStore.get().page + 1);
else if (k === 'ArrowLeft' || k === 'a' || k === 'A' || k === 'PageUp') go(guideStore.get().page - 1);
else if (k === 'Home') go(0);
else if (k === 'End') go(n - 1);
else handled = (!!k && k.length === 1) || k === ' '; // swallow game shortcuts (R/F/D/Space) while open
if (handled) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); }
};
window.addEventListener('keydown', onKey, true);
const t = setTimeout(() => boxRef.current?.focus?.(), 30);
return () => { window.removeEventListener('keydown', onKey, true); clearTimeout(t); };
}, [open, n]);
useEffect(() => {
if (!open || !n) return;
preload(pages[(i + 1) % n]?.url);
preload(pages[(i + n - 1) % n]?.url);
}, [open, i, n]);
if (!open) return null;
const chapter = cur ? GUIDE_CHAPTERS[cur.chapter] : null;
const firstOf = (ci) => pages.findIndex((p) => p.chapter === ci);
const isLoaded = cur && loaded.has(cur.url);
return html`<div class="guide" role="presentation" onMouseDown=${(e) => { if (e.target === e.currentTarget) closeGuide(); }}>
<div class="guide__box brackets" role="dialog" aria-modal="true" aria-label="玩法说明" tabindex="-1" ref=${boxRef}>
<header class="guide__head">
<div class="guide__titles">
<${MicroLabel} tone="mint">HOW TO PLAY // STRONGHOLD PROTOCOL</${MicroLabel}>
<h2 class="guide__title">玩法说明</h2>
</div>
${n ? html`<nav class="guide__chapters" aria-label="章节">
${GUIDE_CHAPTERS.map((ch, ci) => {
const at = firstOf(ci);
if (at < 0) return null;
return html`<button key=${ch.id} type="button" class=${cx('guide__chapter', cur?.chapter === ci && 'is-on')} onClick=${() => go(at)}>
<span class="guide__chname">${ch.name}</span><span class="guide__chmicro">${ch.micro}</span>
</button>`;
})}
</nav>` : null}
<button type="button" class="guide__close" aria-label="关闭" title="关闭 (Esc)" onClick=${closeGuide}><${Icon} name="close" /></button>
</header>
${n ? html`<div class="guide__stage">
<button type="button" class="guide__nav guide__prev" aria-label="上一页" onClick=${() => go(i - 1)}><${Icon} name="chevronLeft" /></button>
<div class=${cx('guide__page', isLoaded && 'is-loaded')}>
${cur && !failed.has(cur.url) ? html`<img key=${cur.url} src=${cur.url} alt=${cur.title} draggable=${false}
onLoad=${() => setLoaded((s) => new Set(s).add(cur.url))}
onError=${() => setFailed((s) => new Set(s).add(cur.url))} />` : html`<div class="guide__missing"><${Icon} name="info" />该页面暂时无法显示</div>`}
${!isLoaded && cur && !failed.has(cur.url) ? html`<span class="guide__loading"><${Spinner} size="md" /></span>` : null}
</div>
<button type="button" class="guide__nav guide__next" aria-label="下一页" onClick=${() => go(i + 1)}><${Icon} name="chevronRight" /></button>
</div>` : html`<div class="guide__stage guide__stage--text"><${TipsFallback} /></div>`}
${n ? html`<footer class="guide__foot">
<div class="guide__label">
<span class="guide__chtag">${chapter?.name || ''}</span>
<b class="guide__ptitle">${cur?.title || ''}</b>
</div>
<div class="guide__dots" role="tablist" aria-label="页码">
${pages.map((p, k) => html`<button key=${p.key} type="button" role="tab" aria-selected=${k === i ? 'true' : 'false'} title=${p.title}
class=${cx('guide__dot', k === i && 'is-on', k > 0 && pages[k - 1].chapter !== p.chapter && 'is-first')} onClick=${() => go(k)}></button>`)}
</div>
<span class="guide__count num">${i + 1} / ${n}</span>
</footer>` : null}
</div>
</div>`;
}
+227
View File
@@ -0,0 +1,227 @@
// In-match top bar (research 06 §11.1): exit + ping (left); round box, phase capsule (prep label /
// kills n/m / boss HP bar), LP tower, match-info and enemy-preview buttons (centre, bracket frame);
// 7-segment countdown with gauge and the 准备就绪 toggle (right).
// The two 🔍 buttons follow the official HUD bindings (research 09 §2.1 / §6.2 item 3):
// left btn_check_player — mint [🔍] opens the 本局信息 dialog (ui/enemyDrawer.js; its 敌方情报 tab is the secondary
// enemy list); in the pen view it becomes [🔍◀◀] (btn_check_player_back) and returns the camera;
// right btn_check_enemy — amber [🔍▶▶] pans the camera to the enemy preview pen (休整期 only); in the pen view (and
// outside prep) it is the grey [🔍] (btn_check_enemy_unfold) — pressing it in the pen returns too.
// Official sprites (local ui/battle extraction) when installed, CSS look-alikes otherwise.
// Boss rounds (最终攻势 / 隐秘核心): the countdown is the level's 120 s maxPlayTime (m.public.deadline, gauge total from
// gameLogic phaseTotalSeconds) and the red DOT overtime warning under it keys off m.public.overtimeAt (ui/matchStatus.js
// overtimeState): "NN 秒后全队生命值开始流失" once the level time ran out, then a live "生命值 −1/秒" indicator while the
// merged team LP drains (the LP tower turns red). Solo battles: a pause / resume button beside the countdown (g.pause);
// while m.public.paused every clock here is frozen at the pause moment (`frozenAt`).
import { useRef } 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 { Sprite, LpTower, GIcon, LocalSprite } from './gameComponents.js';
import { localAsset } from '../data.js';
import { serverNow } from '../store.js';
import { isCombatPhase, isBossPhase, prepCapsuleLabel, bossFrac, fmtNum, shopBlockReason } from './gameLogic.js';
import { overtimeState, overtimeDrainPerSec, remainAt } from './matchStatus.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/**
* Phase capsule: prep label, kills n/m (combat/unite), kills + boss HP bar (boss rounds).
* @param {{ pub:any, hud:any }} props
*/
export function PhaseCapsule({ pub, hud }) {
const phase = pub?.phase;
if (isBossPhase(phase)) {
const boss = pub.bossHp || hud?.boss || 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}
<div class="bossbar" title=${boss ? `${fmtNum(boss.hp)} / ${fmtNum(boss.max)}` : '敌方领袖'}>
<div class="bossbar__fill" style=${`width:${frac == null ? 100 : frac * 100}%`}></div>
<span class="bossbar__txt num">${frac == null ? '敌方领袖' : `${(frac * 100).toFixed(frac < 0.1 ? 1 : 0)}%`}</span>
</div>
</div>`;
}
if (isCombatPhase(phase) || (phase === PHASE.SETTLE && hud?.total != 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>
${phase === PHASE.UNITE ? html`<span class="capsule__tag">联防</span>` : null}
</div>`;
}
return html`<div class="capsule capsule--prep" role="status">
<${Sprite} k="hudPanel/icon_rest" class="capsule__icon" fallback=${html`<${Icon} name="rook" class="capsule__icon" />`} />
<span class="capsule__label">${prepCapsuleLabel(phase)}</span>
</div>`;
}
/**
* Ready toggle (PREP only). Disabled with a reason while the temp hand is non-empty.
* @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 reason = !ready ? shopBlockReason('ready', { priv, editable: true }) : null;
const btn = html`<button type="button" class=${cx('readybtn', 'tapx', ready && 'is-on', busy && 'is-busy')} disabled=${!!reason || busy}
aria-pressed=${ready ? 'true' : 'false'} onClick=${() => onToggle(!ready)}>
<span class="readybtn__box">${ready ? html`<${Icon} name="check" />` : null}</span>
<span class="readybtn__label">${ready ? '取消准备' : '准备就绪'}</span>
<kbd class="readybtn__key">Space</kbd>
</button>`;
return html`<div class="readywrap">
${reason ? html`<${Tooltip} text=${reason} placement="bottom">${btn}<//>` : btn}
${Number.isFinite(total) && total > 1 ? html`<span class="readywrap__count">已就绪 <b class="num">${readyCount}</b>/<span class="num">${total}</span></span>` : null}
</div>`;
}
/**
* Sprite + look of the two 🔍 HUD buttons for a state (pure; tested in test/ui/uiFixes.test.js).
* @param {{ pen: boolean, penAvail: boolean, infoOpen: boolean }} st
* @returns {{ left: { sprite: string, back: boolean, label: string, tip: string }, right: { sprite: string, grey: boolean, label: string, tip: string } }}
*/
export function checkButtons({ pen, penAvail, infoOpen }) {
const left = pen
? { sprite: 'btn_check_player_back', back: true, label: '返回', tip: '返回战场' }
: { sprite: infoOpen ? 'btn_check_player_unfold' : 'btn_check_player_normal', back: false, label: '本局信息', tip: '本局信息(策略 / 禁用盟约 / 干员)' };
const right = pen
? { sprite: 'btn_check_enemy_unfold', grey: true, label: '返回', tip: '返回' }
: penAvail
? { sprite: 'btn_check_enemy', grey: false, label: '敌方情报', tip: '查看即将迎击的敌方单位' }
: { sprite: 'btn_check_enemy_unfold', grey: true, label: '敌方情报', tip: '休整期可以查看即将迎击的敌方单位' };
return { left, right };
}
/** One official 🔍 button: the sprite when installed, a CSS look-alike (icon + chevrons) otherwise. */
function CheckBtn({ sprite, cls, label, chev, on, disabled, onClick, testid }) {
const url = localAsset('ui/battle', sprite);
return html`<button type="button" class=${cx(cls, 'tapx', url && 'has-sprite', chev && 'is-wide', on && 'is-on')}
style=${url ? `--chk-sprite:url("${url}")` : ''} aria-label=${label} aria-disabled=${disabled ? 'true' : 'false'}
data-sprite=${sprite} data-testid=${testid} onClick=${onClick}>
${url ? null : html`<${Icon} name="search" />${chev ? html`<span class="enemybtn__chev">${chev}</span>` : null}`}
</button>`;
}
/**
* The red overtime (DOT) warning of a boss round (pure view of ui/matchStatus.js overtimeState).
* @param {{ ot: ReturnType<typeof overtimeState> }} props
*/
export function OvertimeWarning({ ot }) {
if (!ot) return null;
if (ot.state === 'pending') {
return html`<div class="otwarn otwarn--pending" role="alert" data-state="pending">
<${LocalSprite} name="icon_warn" class="otwarn__icon" fallback=${html`<${Icon} name="warn" class="otwarn__icon" />`} />
<span class="otwarn__tag">DOT</span>
<span class="otwarn__txt"><b class="num">${ot.secs}</b> 秒后全队生命值开始流失</span>
</div>`;
}
return html`<div class="otwarn otwarn--drain" role="alert" data-state="drain">
<span class="otwarn__blood">
<${LocalSprite} name="blood_icon" class="otwarn__icon" fallback=${html`<${Icon} name="rook" class="otwarn__icon" />`} />
</span>
<span class="otwarn__tag">DOT</span>
<span class="otwarn__txt">超时 · 生命值 <b class="num">−${ot.perSec}</b>/秒</span>
${ot.lost > 0 ? html`<span class="otwarn__lost">已流失 <b class="num">${ot.lost}</b></span>` : null}
<span key=${ot.secs} class="otwarn__tick num" aria-hidden="true">−${ot.perSec}</span>
</div>`;
}
/** Two-bar pause glyph (the official battle pause button). */
function PauseGlyph() {
return html`<svg class="pausebtn__glyph" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 4h4.5v16H6zm7.5 0H18v16h-4.5z" /></svg>`;
}
/**
* Solo pause / resume control (g.pause): pressed while paused.
* @param {{ paused: boolean, busy?: boolean, onToggle: () => void }} props
*/
export function PauseButton({ paused, busy = false, onToggle }) {
const label = paused ? '继续作战' : '暂停';
return html`<${Tooltip} text=${paused ? '继续作战(Space)' : '暂停作战(Space)'} placement="bottom">
<button type="button" class=${cx('pausebtn', 'tapx', paused && 'is-on', busy && 'is-busy')} aria-pressed=${paused ? 'true' : 'false'}
aria-label=${label} disabled=${busy} data-testid="pause" onClick=${() => onToggle?.()}>
${paused ? html`<${Icon} name="play" class="pausebtn__glyph" />` : html`<${PauseGlyph} />`}
</button>
<//>`;
}
/**
* Top bar.
* @param {{ pub:any, priv:any, conn:any, hud:any, total:number|null, drawer:string|null, onExit:Function, onDrawer:(tab:string)=>void,
* 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 }} props
* frozenAt: the server time every clock shows while the solo match is paused (null = live)
*/
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 }) {
const phase = pub?.phase;
const boss = isBossPhase(phase);
const lp = boss && Number.isFinite(pub?.teamLp) ? pub.teamLp : Number.isFinite(priv?.lp) ? priv.lp : null;
const hidden = phase === PHASE.HIDDEN_CORE || (Number.isFinite(pub?.lastRound) && pub.round > pub.lastRound);
const roundText = hidden ? '??' : pub?.round > 0 ? String(pub.round) : '--';
const showReady = phase === PHASE.PREP && priv?.alive !== false;
// boss rounds: the overtime warning follows the clock (4 Hz while live; frozen while paused)
const otLive = boss && Number(pub?.overtimeAt) > 0;
useTicker(otLive && frozenAt == null ? 250 : 0);
const now = Number.isFinite(frozenAt) ? frozenAt : serverNow();
const ot = otLive ? overtimeState(pub, now, { perSec: overtimeDrainPerSec(config) }) : null;
const draining = ot?.state === 'drain';
const lowLp = (Number.isFinite(lp) && lp <= 5) || draining;
const frozenSecs = Number.isFinite(frozenAt) ? remainAt(pub?.deadline, frozenAt) : null;
const btn = checkButtons({ pen, penAvail, infoOpen: !!drawer });
const onLeft = () => (pen ? onPen(false) : onDrawer('info'));
const onRight = () => (pen ? onPen(false) : penAvail ? onPen(true) : null);
return html`<header class=${cx('gtop', pen && 'is-pen')}>
<div class="gtop__left">
<${Button} variant="danger" size="lg" square=${true} icon="exit" onClick=${onExit} aria-label="离开" title="离开 / 暂离" class="gtop__exit tapx" />
<div class="gtop__meta">
<${PingPill} ms=${conn?.ping} online=${conn?.status === 'online'} />
${pub?.difficulty ? html`<${DifficultyTag} difficulty=${pub.difficulty} size="sm" />` : null}
</div>
</div>
<div class="gtop__center brackets">
<${Tooltip} text=${btn.left.tip} placement="bottom">
<${CheckBtn} sprite=${btn.left.sprite} cls=${cx('gtop__iconbtn', btn.left.back && 'is-back')} label=${btn.left.label}
chev=${btn.left.back ? '◀◀' : null} on=${!!drawer && !pen} onClick=${onLeft} testid="check-player" />
<//>
<div class="roundbox">
<span class="roundbox__label">回合</span>
<b class="roundbox__num num">${roundText}</b>
</div>
<${PhaseCapsule} pub=${pub} hud=${hud} />
<${LpTower} value=${lp} size="lg" tone=${lowLp ? 'danger' : boss ? 'team' : null} />
<${Tooltip} text=${btn.right.tip} placement="bottom">
<${CheckBtn} sprite=${btn.right.sprite} cls=${cx('enemybtn', btn.right.grey && 'is-grey')} label=${btn.right.label}
chev=${btn.right.grey ? null : '▶▶'} disabled=${btn.right.grey && !pen} onClick=${onRight} testid="check-enemy" />
<//>
</div>
<div class="gtop__right">
<div class="gtop__clock">
${frozenSecs != null
? html`<${Countdown} seconds=${frozenSecs} total=${total ?? undefined} size="md" label="PAUSED" />`
: html`<${Countdown} deadline=${pub?.deadline} total=${total ?? undefined} size="md" />`}
${pause && (pause.show || pause.paused) ? html`<${PauseButton} paused=${!!pause.paused} busy=${pause.busy} onToggle=${pause.onToggle} />` : null}
</div>
<${OvertimeWarning} ot=${ot} />
${showReady ? html`<${ReadyToggle} priv=${priv} onToggle=${onReady} busy=${readyBusy} readyCount=${readyCount} total=${playerCount} />` : null}
</div>
</header>`;
}
/** DP counter shown at the right edge during combat. */
export function DpCounter({ dp }) {
if (!Number.isFinite(dp)) return null;
return html`<div class="dpbox" title="部署费用(再部署消耗)">
<${GIcon} name="dp" class="dpbox__icon" /><b class="num">${Math.floor(dp)}</b><${MicroLabel}>COST</${MicroLabel}>
</div>`;
}
/** Hook: stable callback ref helper. */
export function useLatest(v) {
const r = useRef(v);
r.current = v;
return r;
}
+302
View File
@@ -0,0 +1,302 @@
// Operator loadout model (DESIGN §16) — pure logic of the 干员调配 screen (screens/loadout.js), shared with the sync
// (ui/loadoutSync.js) and usable by the in-match UI (shop cards / detail panel: `effectiveChoice`, `selectedSkill`).
//
// The per-browser loadout is `{ [baseChessId]: { skill?: skillIndex, module?: uniEquipId | 'none' } }`, persisted in
// localStorage (`sp.pref.loadout` = { v: 1, entries }) and sent with C2S `room.loadout { entries }`. Only choices that
// differ from the chess's defaults are kept. The legal choices come from data/chess.json (DESIGN §16): every chess has
// `skills[]` (SkillRecord at its own skill level — the normal chess Lv4, the elite Lv7) and elites `modules[]`
// (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.
import { loadoutOptions, checkLoadout, resolveLoadout, MODULE_NONE, LOADOUT_LIMITS } from '../../../shared/protocol.js';
export { MODULE_NONE };
/** localStorage key (store.js loadPref/savePref prefix `sp.pref.`) and format version. */
export const LOADOUT_PREF = 'loadout';
export const LOADOUT_VERSION = 1;
export const PROF_ORDER = ['PIONEER', 'WARRIOR', 'TANK', 'SNIPER', 'CASTER', 'MEDIC', 'SUPPORT', 'SPECIAL'];
export const PROF_NAME = Object.freeze({ PIONEER: '先锋', WARRIOR: '近卫', TANK: '重装', SNIPER: '狙击', CASTER: '术师', MEDIC: '医疗', SUPPORT: '辅助', SPECIAL: '特种' });
export const SP_TYPE = Object.freeze({ INCREASE_WITH_TIME: '自动回复', INCREASE_WHEN_ATTACK: '攻击回复', INCREASE_WHEN_TAKEN_DAMAGE: '受击回复', ON_DEPLOY: '被动', 8: '被动' });
/** Module attribute keys (ModuleRecord.attr / battle_equip attributeBlackboard) → label + unit. */
export const ATTR_LABEL = Object.freeze({
maxHp: ['生命上限', ''], max_hp: ['生命上限', ''], atk: ['攻击力', ''], def: ['防御力', ''], res: ['法术抗性', ''],
magic_resistance: ['法术抗性', ''], aspd: ['攻击速度', ''], attack_speed: ['攻击速度', ''], cost: ['部署费用', ''],
blockCnt: ['阻挡数', ''], block_cnt: ['阻挡数', ''], respawnTime: ['再部署时间', '秒'], respawn_time: ['再部署时间', '秒'],
baseAttackTime: ['攻击间隔', '秒'], base_attack_time: ['攻击间隔', '秒'], moveSpeed: ['移动速度', ''], hpRecoveryPerSec: ['每秒回复', ''],
});
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
const isInt = (v) => Number.isInteger(v);
// ---- storage -----------------------------------------------------------------------------------------------------
/**
* Parse a stored loadout (any junk → {}): keeps structurally valid entries only (ids, skill ints, module ids).
* @param {any} raw `{ v, entries }` (or a bare entries map from an older build)
* @returns {Record<string, { skill?: number, module?: string }>}
*/
export function parseStored(raw) {
const src = isObj(raw) && isObj(raw.entries) ? raw.entries : isObj(raw) && raw.v == null ? raw : null;
const out = {};
if (!src) return out;
for (const [id, e] of Object.entries(src)) {
if (Object.keys(out).length >= LOADOUT_LIMITS.entries) break;
if (!/^[A-Za-z0-9_\-.:]{1,64}$/.test(id) || !isObj(e)) continue;
const x = {};
if (isInt(e.skill) && e.skill >= 0 && e.skill <= LOADOUT_LIMITS.skillIndex) x.skill = e.skill;
if (typeof e.module === 'string' && /^[A-Za-z0-9_\-.:]{1,64}$/.test(e.module)) x.module = e.module;
if (Object.keys(x).length) out[id] = x;
}
return out;
}
/** Serialised form for localStorage. */
export const toStored = (entries) => ({ v: LOADOUT_VERSION, entries: entries || {} });
// ---- options & choices ---------------------------------------------------------------------------------------------
/**
* The chess records of one loadout slot.
* @param {string} baseId normal chess id
* @param {(id: string) => any} getChess
* @returns {{ base: any, golden: any }}
*/
export function recordsOf(baseId, getChess) {
const base = getChess(baseId) || null;
const golden = base && base.goldenId ? getChess(base.goldenId) || null : null;
return { base, golden };
}
/** SkillRecord of a chess record by skill index (data `skills[]`, else the default `skill`). */
export function skillRecord(chess, index) {
if (!chess) return null;
if (Array.isArray(chess.skills)) {
const s = chess.skills.find((x) => x && x.index === index);
if (s) return s;
}
return chess.skill && chess.skill.index === index ? chess.skill : null;
}
/** ModuleRecord of an elite by uniEquipId (data `modules[]`, else a minimal record from the default `module`). */
export function moduleRecord(golden, id) {
if (!golden || !id || id === MODULE_NONE) return null;
if (Array.isArray(golden.modules)) {
const m = golden.modules.find((x) => x && x.uniEquipId === id);
if (m) return m;
}
const d = golden.module;
return d && d.id === id ? { uniEquipId: d.id, name: d.name, typeName: d.type, isDefault: true, attr: null, traitOverride: null, talentChanges: [] } : null;
}
/**
* Everything the screen shows for one chess: its skill options (normal Lv4 + elite Lv7 records) and module options.
* @param {any} base normal chess record
* @param {any} golden elite record (or null)
*/
export function chessOptions(base, golden) {
const opt = loadoutOptions(base, golden);
const skills = opt.skills.map((index) => ({
index,
normal: skillRecord(base, index),
elite: skillRecord(golden, index),
isDefault: index === opt.defaultSkill,
}));
const modules = opt.modules.map((id) => ({
id,
rec: moduleRecord(golden, id),
isDefault: id === opt.defaultModule,
}));
return { ...opt, skillOptions: skills, moduleOptions: modules };
}
/**
* The effective choice of a chess under a stored loadout (defaults for missing / unavailable choices).
* @returns {{ skill: number|null, module: string|null, changed: boolean }}
*/
export function effectiveChoice(entries, base, golden) {
const opt = loadoutOptions(base, golden);
const e = base && entries && Object.hasOwn(entries, base.chessId) ? entries[base.chessId] : null;
const skill = e && opt.skills.includes(e.skill) ? e.skill : opt.defaultSkill;
const module = golden ? (e && opt.modules.includes(e.module) ? e.module : opt.defaultModule) : null;
return { skill, module, changed: skill !== opt.defaultSkill || module !== opt.defaultModule };
}
/**
* Set (part of) one chess's choice; an entry equal to the defaults is removed. Returns a new entries map.
* @param {Record<string, any>} entries
* @param {any} base @param {any} golden
* @param {{ skill?: number, module?: string }} patch
*/
export function setChoice(entries, base, golden, patch) {
if (!base) return entries;
const opt = loadoutOptions(base, golden);
const cur = effectiveChoice(entries, base, golden);
const skill = patch && patch.skill !== undefined && opt.skills.includes(patch.skill) ? patch.skill : cur.skill;
const module = golden && patch && patch.module !== undefined && opt.modules.includes(patch.module) ? patch.module : cur.module;
const out = { ...(entries || {}) };
delete out[base.chessId];
const e = {};
if (skill !== opt.defaultSkill && skill != null) e.skill = skill;
if (golden && module !== opt.defaultModule && module != null) e.module = module;
if (Object.keys(e).length) out[base.chessId] = e;
return out;
}
/** Remove one chess's entry (恢复默认). */
export function resetChoice(entries, baseId) {
if (!entries || !Object.hasOwn(entries, baseId)) return entries;
const out = { ...entries };
delete out[baseId];
return out;
}
/**
* The entries to send (`room.loadout.entries`): every stored entry that is still legal for the loaded data, the
* rest dropped one by one (a stale browser loadout never gets the whole message refused). Defaults are dropped.
* @param {Record<string, any>} entries
* @param {(id: string) => any} getChess
* @returns {Record<string, { skill?: number, module?: string }>}
*/
export function sanitizeEntries(entries, getChess) {
const out = {};
for (const [id, e] of Object.entries(entries || {})) {
if (Object.keys(out).length >= LOADOUT_LIMITS.entries) break;
const one = {};
if (isInt(e?.skill)) one.skill = e.skill;
if (typeof e?.module === 'string') one.module = e.module;
if (!Object.keys(one).length) continue;
const res = checkLoadout({ [id]: one }, getChess);
if (!res.ok) {
// keep the part that is still legal (e.g. the skill when a module disappeared)
for (const k of ['skill', 'module']) {
if (one[k] === undefined) continue;
const r = checkLoadout({ [id]: { [k]: one[k] } }, getChess);
if (r.ok && r.loadout[id]) out[id] = { ...(out[id] || {}), [k]: one[k] };
}
continue;
}
if (res.loadout[id]) out[id] = one;
}
return out;
}
/** Selected SkillRecord of a board / shop chess under a loadout (the elite gets its Lv7 record). */
export function selectedSkill(loadout, chess, getChess) {
const r = resolveLoadout(loadout, chess, getChess);
return skillRecord(chess, r.skillIndex) || chess?.skill || null;
}
/** Selected ModuleRecord of an elite under a loadout (null: none / normal chess). */
export function selectedModule(loadout, chess, getChess) {
if (!chess || !chess.isGolden) return null;
const r = resolveLoadout(loadout, chess, getChess);
return moduleRecord(chess, r.moduleId);
}
// ---- roster & filters ------------------------------------------------------------------------------------------------
/**
* Visible normal chess (the loadout slots), in shop order: tier, then shopSortId.
* @param {any[]} list data.list('chess')
*/
/** Whether a chess record is a loadout slot (a visible normal chess — what the server's checkLoadout accepts). */
export const isLoadoutSlot = (c) => !!c && !c.isGolden && c.visible !== false && !c.isHidden && !c.isDiy && (!c.baseId || c.baseId === c.chessId);
export function rosterOf(list) {
return (Array.isArray(list) ? list : [])
.filter(isLoadoutSlot)
.sort((a, b) => (a.tier ?? 0) - (b.tier ?? 0) || (a.shopSortId ?? 0) - (b.shopSortId ?? 0) || String(a.chessId).localeCompare(String(b.chessId)));
}
/**
* Apply the screen's filters.
* @param {any[]} roster rosterOf(...)
* @param {{ tier?: number|null, prof?: string|null, bond?: string|null, query?: string, changedOnly?: boolean }} f
* @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)
*/
export function filterRoster(roster, f = {}, entries = {}, getChess = () => null, getBond = () => null) {
const q = String(f.query || '').trim().toLowerCase();
return roster.filter((c) => {
if (f.tier && c.tier !== f.tier) return false;
if (f.prof && c.profession !== f.prof) return false;
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 (q) {
const hay = [c.name, c.appellation, c.subProfessionName, PROF_NAME[c.profession], ...(c.bonds || []).map((b) => getBond(b)?.name)]
.filter(Boolean).join(' ').toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
}
/** 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) {
let n = 0;
for (const id of Object.keys(entries || {})) {
const { base, golden } = recordsOf(id, getChess);
if (isLoadoutSlot(base) && base.chessId === id && effectiveChoice(entries, base, golden).changed) n++;
}
return n;
}
// ---- display helpers -----------------------------------------------------------------------------------------------------
/** "S2" style label of a skill index. */
export const skillLabel = (index) => (isInt(index) ? `S${index + 1}` : '—');
/** Short type badge of a module ("MAR-X" → "X", "ISW-α" → "α"); 'none' → "—". */
export function moduleBadge(rec, id = null) {
if (!rec) return id === MODULE_NONE || id == null ? '—' : '?';
const t = String(rec.typeName || rec.type || '');
const m = t.match(/-([^-\s]+)$/);
return m ? m[1] : t.slice(-1) || '?';
}
/**
* Module stat bonus as display rows (non-zero entries only).
* @param {Record<string, number> | null | undefined} attr
* @returns {Array<{ key: string, label: string, text: string, positive: boolean }>}
*/
export function attrRows(attr) {
const out = [];
if (!isObj(attr)) return out;
for (const [k, v] of Object.entries(attr)) {
if (typeof v !== 'number' || !Number.isFinite(v) || v === 0) continue;
const [label, unit] = ATTR_LABEL[k] || [k, ''];
const n = Math.abs(v) < 10 && !Number.isInteger(v) ? Number(v.toFixed(2)) : Math.round(v);
out.push({ key: k, label, text: `${v > 0 ? '+' : ''}${n}${unit}`, positive: k === 'cost' || k === 'respawnTime' || k === 'respawn_time' || k === 'baseAttackTime' || k === 'base_attack_time' ? v < 0 : v > 0 });
}
return out;
}
/**
* Tags of a SkillRecord: SP recovery, SP numbers, duration / ammo, charges.
* @returns {{ sp: string, spKind: 'time'|'atk'|'def'|'passive', init: number|null, cost: number|null, duration: string|null, charges: number|null, passive: boolean }}
*/
export function skillTags(rec) {
if (!rec) return { sp: '—', spKind: 'time', init: null, cost: null, duration: null, charges: null, passive: false };
const passive = rec.skillType === 'PASSIVE' || rec.spType === 'ON_DEPLOY' || rec.spType === 8;
const spKind = passive ? 'passive' : rec.spType === 'INCREASE_WHEN_ATTACK' ? 'atk' : rec.spType === 'INCREASE_WHEN_TAKEN_DAMAGE' ? 'def' : 'time';
let duration = null;
if (rec.durationType === 'AMMO') duration = '弹药';
else if (Number(rec.duration) > 0) duration = `${Number(rec.duration)}秒`;
return {
sp: SP_TYPE[rec.spType] || (passive ? '被动' : '技力'),
spKind,
init: passive ? null : Number.isFinite(rec.initSp) ? rec.initSp : 0,
cost: passive ? null : Number.isFinite(rec.spCost) ? rec.spCost : 0,
duration,
charges: Number(rec.maxChargeTime) > 1 ? Number(rec.maxChargeTime) : null,
passive,
};
}
+149
View File
@@ -0,0 +1,149 @@
// Operator loadout state + server sync (DESIGN §16).
//
// `loadoutStore` holds the per-browser loadout (`entries`, persisted in localStorage through store.js savePref) and the
// 干员调配 screen state (open / origin / selection / filters). `installLoadoutSync()` (called once by main.js) keeps the
// server's copy current: after every `welcome` (new or resumed session — the server keeps it 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), it sends `room.loadout { entries }` with
// the entries sanitised against the loaded data/chess.json (ui/loadoutModel.js sanitizeEntries: a stale entry is
// dropped, never the whole loadout). Replies: RATE → retried later; WRONG_PHASE / ROOM_STARTED → the running match has
// locked its loadout (it applies to the next match, the server stored it) — not an error for the player; anything else
// is logged. `sync.state` ∈ 'idle' | 'pending' | 'sending' | 'synced' | 'locked' | 'error' is mirrored into the store
// for the screen's status line.
import { createStore, loadPref, savePref } from '../store.js';
import { data } from '../data.js';
import { LOADOUT_PREF, parseStored, toStored, sanitizeEntries } from './loadoutModel.js';
import { toast } from './toasts.js';
export const SYNC_DEBOUNCE_MS = 500;
export const RETRY_MS = 1500;
function readStored() {
try { return parseStored(loadPref(LOADOUT_PREF, null)); } catch { return {}; }
}
/** Loadout + screen state (separate from the app store: it must survive room / match resets). */
export const loadoutStore = createStore({
entries: readStored(),
open: false,
from: null, // 'lobby' | 'room' | 'briefing'
sel: null, // selected base chess id
filters: { tier: null, prof: null, bond: null, query: '', changedOnly: false },
sync: 'idle',
});
/** Replace the stored entries (persisted at once; the sync picks the change up). */
export function setEntries(entries) {
const next = entries && typeof entries === 'object' ? entries : {};
savePref(LOADOUT_PREF, toStored(next));
loadoutStore.set({ entries: next });
}
/** Open the 干員调配 screen. @param {'lobby'|'room'|'briefing'} from @param {string|null} [sel] */
export function openLoadout(from = 'lobby', sel = null) {
data.load('chess');
data.load('bonds');
data.load('assets');
data.load('local');
loadoutStore.set({ open: true, from, ...(sel ? { sel } : {}) });
}
export const closeLoadout = () => loadoutStore.set({ open: false });
/**
* Wire the sync once. Dependencies are injectable for tests.
* @param {{ net: any, getChessReady?: () => Promise<any>, lookupChess?: (id: string) => any,
* 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 } = {}) {
const T = timers || { setTimeout: (fn, ms) => globalThis.setTimeout(fn, ms), clearTimeout: (id) => globalThis.clearTimeout(id) };
const ready = getChessReady || (() => data.load('chess'));
const lookup = lookupChess || ((id) => data.lookup('chess', id));
const tell = notify || ((text) => toast(text, 'warn'));
let timer = null;
let seq = 0; // room.loadout requests sent (the reply of an older one never overrides a newer one's state)
let pendingJson = null; // JSON of the newest request still awaiting its reply
let lastSent = null; // JSON of the last entries the server accepted (on this session)
let edited = false; // an edit is waiting to be sent (a lock refusal is then worth telling the player)
let disposed = false;
const setState = (sync) => { if (target.get().sync !== sync) target.set({ sync }); };
const schedule = (ms = SYNC_DEBOUNCE_MS) => {
if (disposed) return;
T.clearTimeout(timer);
setState('pending');
timer = T.setTimeout(() => { timer = null; void flush(); }, ms);
};
// Review fix: a send is never held back behind one still in flight. The socket is ordered and the server applies
// room.loadout frames in order, so the newest entries always win; holding the edit until the previous reply arrived let
// a click right after closing the overlay (准备就绪 → INFO_CHECK ends) overtake it, and the edit silently missed the match.
async function flush() {
if (disposed) return;
if (net.status !== 'online') { setState('idle'); return; } // the next welcome resends
try {
const current = target.get().entries;
// 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
const empty = !current || Object.keys(current).length === 0;
const loaded = empty ? true : await ready();
if (disposed) return;
// never sanitise against missing data: every entry would be dropped and the server's copy cleared
if (loaded == null) { setState('error'); return; }
const entries = empty ? {} : sanitizeEntries(target.get().entries, lookup);
const json = JSON.stringify(entries);
if (json === pendingJson) return; // the same content is already on its way
if (json === lastSent && pendingJson == null) { edited = false; setState('synced'); return; }
const my = ++seq;
const wasEdit = edited;
edited = false;
pendingJson = json;
setState('sending');
try {
await net.request('room.loadout', { entries });
if (my !== seq) return;
pendingJson = null;
lastSent = json;
setState('synced');
} catch (err) {
if (my !== seq) return;
pendingJson = null;
const code = err && err.code;
if (code === 'WRONG_PHASE' || code === 'ROOM_STARTED') {
// the server stored it for the next match; the running one keeps the loadout it locked
lastSent = json;
setState('locked');
if (wasEdit) tell('本局的干员调配已锁定,修改将在下一局生效');
} else if (code === 'RATE' || code === 'TIMEOUT' || code === 'OFFLINE') { edited = edited || wasEdit; schedule(RETRY_MS); }
else { console.warn('[loadout] room.loadout refused', code, err && err.detail); setState('error'); }
}
} catch (e) {
console.warn('[loadout] sync failed', e);
setState('error');
}
}
const offWelcome = net.on('welcome', () => { lastSent = null; pendingJson = null; seq++; schedule(50); });
const offStore = target.subscribe((s, prev) => {
if (s.entries !== prev.entries) { 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 room.loadout (the match locks its loadout when
// INFO_CHECK ends, so a late edit would silently only apply to the next match). Same socket ⇒ ordered.
if (prev.open && !s.open && timer != null) { T.clearTimeout(timer); timer = null; void flush(); }
});
// a match leaving INFO_CHECK locks the loadout; a new match (the room back in LOBBY / a new INFO_CHECK) accepts it again
const offRoom = net.on('room.state', (msg) => { if (msg && !msg.inMatch && target.get().sync === 'locked') { lastSent = null; schedule(); } });
return {
flush,
dispose() {
disposed = true;
T.clearTimeout(timer);
offWelcome?.();
offStore?.();
offRoom?.();
},
};
}
+126
View File
@@ -0,0 +1,126 @@
// Chrome shared by every in-match screen: the step header of the pre-game screens (exit + ping +
// difficulty | "1/2 确认本局信息" | countdown) and the exit flow (confirm with 暂离 / 放弃, AI 托管 overlay).
//
// 暂离 (co-op): turns on AI 托管 (g.autoplay on) so the seat keeps playing; the overlay's 返回模拟 turns it
// off. 放弃模拟: g.leave then room.leave (the platform treats it as a quit), back to the lobby.
import { useState } from '../../vendor/hooks.module.js';
import { html, Button, Modal, PingPill, DifficultyTag, Countdown, MicroLabel } from './components.js';
import { actions } from './gameActions.js';
import { toastError } from './toasts.js';
import { net } from '../net.js';
import { store, useStore, shallowEqual, emptyMatch, createStore } from '../store.js';
import { GIcon } from './gameComponents.js';
import { GuideButton } from './guide.js';
/**
* Exit dialog lines. 放弃模拟 (g.leave → room.leave) ends the run on the server as 'abandoned' and returns to the
* lobby straight away — no settlement screen follows, so the solo text must not promise one.
*/
export const EXIT_TEXT = Object.freeze({
soloRest: '独立模拟的休整期没有时间限制,你可以随时继续。',
soloQuit: '放弃模拟将立即结束本局并返回大厅,本局进度不会保留,也不会进行结算。',
});
/** Local "暂离 / AI 托管" flag (the server keeps no per-client autoplay view). */
export const awayStore = createStore({ away: false });
/**
* Whether a store change ends the match the 暂离 flag belongs to: its state was cleared (result → 返回同盟, 放弃模拟, a
* new match starting, the room closing, a new server session) or its settlement arrived. The next match must never
* open under the previous one's "AI 托管中" overlay (the server starts every match with autoplay off).
* @param {any} s store state
* @param {any} prev previous store state
*/
export const awayEnds = (s, prev) => (!!prev?.match?.public && !s?.match?.public) || (!!s?.match?.result && !prev?.match?.result);
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.
* @returns {Promise<void>}
*/
export async function quitMatch() {
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; }
} catch (err) {
toastError(err);
} finally {
store.set({ room: null, match: emptyMatch() });
}
}
/**
* Exit confirmation modal.
* @param {{ open: boolean, onClose: Function, solo: boolean, onAway?: Function }} props
*/
export function ExitModal({ open, onClose, solo, onAway }) {
const [busy, setBusy] = useState(null);
const quit = async () => {
setBusy('quit');
await quitMatch();
setBusy(null);
onClose();
};
const away = async () => {
setBusy('away');
const ok = await actions.autoplay(true);
setBusy(null);
onClose();
if (ok) { awayStore.set({ away: true }); onAway?.(); }
};
return html`<${Modal} open=${open} onClose=${onClose} tone="red" title="离开模拟" micro="LEAVE SIMULATION" width="6.8rem"
actions=${html`
<${Button} variant="secondary" onClick=${onClose}>取消<//>
${!solo ? html`<${Button} variant="ice" icon="robot" loading=${busy === 'away'} onClick=${away}>暂离(AI 托管)<//>` : null}
<${Button} variant="danger" icon="exit" loading=${busy === 'quit'} onClick=${quit}>放弃模拟<//>`}>
<div class="exitm">
${solo
? html`<p>${EXIT_TEXT.soloRest}</p><p class="t-lo">${EXIT_TEXT.soloQuit}</p>`
: html`<p><b class="t-ice">暂离</b>:由 AI 托管你的席位(自动部署、准备与选择),随时可以返回。</p>
<p><b class="t-red">放弃模拟</b>:离开后无法返回本局,你的干员将回到共享卡池。</p>`}
</div>
<//>`;
}
/** Full-screen "AI 托管中" overlay with 返回模拟. */
export function AwayOverlay({ onBack = () => {} }) {
const [busy, setBusy] = useState(false);
return html`<div class="awayov" role="dialog" aria-label="AI 托管中">
<div class="awayov__box brackets">
<${GIcon} name="robot" class="awayov__icon" />
<${MicroLabel} tone="mint">AUTOPILOT // AI 托管</${MicroLabel}>
<h2>AI 托管中</h2>
<p class="t-lo">AI 正在代为操作你的席位</p>
<${Button} variant="primary" size="lg" icon="play" loading=${busy} onClick=${async () => { setBusy(true); const ok = await actions.autoplay(false); setBusy(false); if (ok) { awayStore.set({ away: false }); onBack(); } }}>返回模拟<//>
</div>
</div>`;
}
/**
* Pre-game step header.
* @param {{ step: number, of: number, title: string, micro: string, pub: any, total?: number|null, onExit: Function }} props
*/
export function StepHeader({ step, of, title, micro, pub, total, onExit }) {
const conn = useStore((s) => s.connection, shallowEqual);
return html`<header class="stephead">
<div class="stephead__left">
<${Button} variant="danger" size="lg" square=${true} icon="exit" onClick=${onExit} aria-label="离开" title="离开" />
<div class="stephead__meta">
<${PingPill} ms=${conn.ping} online=${conn.status === 'online'} />
${pub?.difficulty ? html`<${DifficultyTag} difficulty=${pub.difficulty} />` : null}
</div>
</div>
<div class="stephead__center">
<${MicroLabel} tone="mint">${micro}</${MicroLabel}>
<h1 class="stephead__title"><b class="num">${step}</b><span class="num stephead__of">/${of}</span>${title}</h1>
</div>
<div class="stephead__right">
<${GuideButton} class="stephead__guide" variant="secondary" />
<${Countdown} deadline=${pub?.deadline} total=${total ?? undefined} size="md" />
</div>
</header>`;
}
+108
View File
@@ -0,0 +1,108 @@
// Pure match-status helpers of the in-match HUD (no DOM, no Preact — unit-tested in test/ui/leftovers.test.js):
//
// * Boss-round clock (最终攻势 / 隐秘核心, research 00-INDEX §5, 01 §10): m.public.deadline is the level's
// maxPlayTime countdown (120 real s, config modes[…].rounds[r].levelMaxPlayTime) — "计时结束后战斗仍然会继续" —
// and m.public.overtimeAt is when the overtime drain starts (bossTurnHpReduceTime, 150 real s): from then on the
// merged team LP loses config.bossOvertimeDrainPerSec (1) per whole real second (server gamedata.js
// bossOvertimeDue: the first point at overtimeAt + 1 s). `overtimeState` says what the red DOT warning shows.
// * Solo pause (official 独立模拟 battles): C2S g.pause {on}; the server says so in m.public.paused (optionally
// m.public.pausedAt, ms epoch). Only solo battles offer it (`pauseAvailable`); while paused every HUD clock is
// frozen at the pause moment (`frozenNow`).
import { PHASE } from '../../../shared/constants.js';
const isObj = (v) => !!v && typeof v === 'object';
const BOSS_PHASES = new Set([PHASE.FINAL_ASSAULT, PHASE.HIDDEN_CORE]);
/** Battle phases a solo player may pause (a solo run has no 联防). */
const PAUSE_PHASES = new Set([PHASE.COMBAT, PHASE.FINAL_ASSAULT, PHASE.HIDDEN_CORE]);
/** Level time of a boss round when the config does not say (research 01 §3: maxPlayTime 120 s). */
export const BOSS_LEVEL_SECONDS = 120;
/** The pending overtime warning shows this many seconds before the drain even while the level countdown still runs. */
export const OVERTIME_WARN_BEFORE = 30;
/**
* The level's countdown length (s) of a boss round — the gauge total of the 120 s countdown.
* @param {any} pub m.public
* @param {any} config data/config.json
* @returns {number|null} null outside boss rounds
*/
export function bossLevelSeconds(pub, config) {
if (!isObj(pub) || !BOSS_PHASES.has(pub.phase)) return null;
const mode = isObj(config?.modes) ? config.modes[pub.modeId] : null;
const v = mode?.rounds?.[String(pub.round)]?.levelMaxPlayTime;
return Number.isFinite(v) && v > 0 ? v : BOSS_LEVEL_SECONDS;
}
/** Team LP lost per real second of overtime (config.bossOvertimeDrainPerSec, default 1). */
export function overtimeDrainPerSec(config) {
const v = config?.bossOvertimeDrainPerSec;
return Number.isFinite(v) && v >= 0 ? v : 1;
}
/**
* What the red overtime (DOT) warning of a boss round shows at `now` (server-corrected ms epoch):
* null nothing (not a boss round, no overtimeAt, or the drain is still far away)
* { state: 'pending', secs } the drain starts in `secs` s (shown once the level countdown ran out, or
* within OVERTIME_WARN_BEFORE s of the drain)
* { state: 'drain', secs, lost, perSec } draining for `secs` whole seconds: `lost` LP so far, `perSec` LP/s
* @param {any} pub m.public
* @param {number} now
* @param {{ perSec?: number, warnBefore?: number }} [o]
*/
export function overtimeState(pub, now, { perSec = 1, warnBefore = OVERTIME_WARN_BEFORE } = {}) {
if (!isObj(pub) || !BOSS_PHASES.has(pub.phase) || !Number.isFinite(now)) return null;
const at = Number(pub.overtimeAt);
if (!(Number.isFinite(at) && at > 0)) return null;
if (now < at) {
const secs = Math.ceil((at - now) / 1000);
const levelOver = Number.isFinite(pub.deadline) && pub.deadline > 0 && now >= pub.deadline;
if (!levelOver && secs > warnBefore) return null;
return { state: 'pending', secs };
}
const secs = Math.floor((now - at) / 1000);
const rate = Number.isFinite(perSec) && perSec >= 0 ? perSec : 1;
return { state: 'drain', secs, lost: secs * rate, perSec: rate };
}
/**
* Whether the solo pause control is offered: solo runs only (hidden in co-op), while the player's own battle runs
* (server Match.setPause: WRONG_PHASE once no battle is live). While paused the control stays (to resume).
* @param {any} pub m.public
* @param {{ solo?: boolean, alive?: boolean, done?: boolean }} [o] done: the own battle is over
*/
export function pauseAvailable(pub, { solo = false, alive = true, done = false } = {}) {
if (!solo || !isObj(pub) || alive === false) return false;
if (done && !pub.paused) return false;
return PAUSE_PHASES.has(pub.phase);
}
/** Whether m.public says the (solo) match is paused. */
export const isPaused = (pub) => isObj(pub) && !!pub.paused;
/** The pause intent (C2S g.pause {on}). */
export const pauseIntent = (on) => ({ t: 'g.pause', fields: { on: !!on } });
/**
* The clock every HUD countdown reads: the live server time, or — while paused — the moment the pause began
* (m.public.pausedAt when the server sends it, else the first time this client saw `paused`).
* @param {any} pub m.public
* @param {number} now live server-corrected ms epoch
* @param {number|null} seenAt when this client first saw the pause (ms epoch), or null
*/
export function frozenNow(pub, now, seenAt = null) {
if (!isPaused(pub)) return now;
const at = Number(pub.pausedAt);
if (Number.isFinite(at) && at > 0) return Math.min(now, at);
return Number.isFinite(seenAt) ? Math.min(now, seenAt) : now;
}
/**
* Seconds left on a deadline at `now` (ceil, ≥ 0), or null without a deadline.
* @param {number|null|undefined} deadline ms epoch
* @param {number} now ms epoch
*/
export function remainAt(deadline, now) {
if (!(Number.isFinite(deadline) && deadline > 0) || !Number.isFinite(now)) return null;
return Math.max(0, Math.ceil((deadline - now) / 1000));
}
+17
View File
@@ -0,0 +1,17 @@
// Merge reward reminder (research 00 §3): the reward cards themselves live in the shop bar (shopBar.js RewardCards —
// after a promotion they replace the bar's operator cards, like the original). When the player puts the pick off
// (稍后选择) this pill above the bar brings the cards back; it disappears once the server clears `shop.rewardOffer`
// (picked, or the round ended).
import { html, Icon, TierChip } from './components.js';
/**
* @param {{ priv:any, minimized:boolean, onMinimize:(m:boolean)=>void }} props
*/
export function RewardOverlay({ priv, minimized, onMinimize }) {
const offer = priv?.shop?.rewardOffer;
if (!offer || !Array.isArray(offer.slots) || !offer.slots.length || !minimized) return null;
return html`<button type="button" class="rewardpill" onClick=${() => onMinimize(false)}>
<${Icon} name="crown" /><span>晋升奖励待选择</span><${TierChip} tier=${offer.tier || 1} size="sm" />
</button>`;
}
+182
View File
@@ -0,0 +1,182 @@
// Rich-text description formatting (pure, no DOM / Preact — unit-tested in Node).
//
// Official descriptions (data/*.json `descRaw`, DATA.md §0) carry Arknights markup:
// <@ba.vup>+15%</> styled span (class `ba.vup`: value-up highlight)
// <$ba.stun>晕眩</> term span (a status keyword with a glossary entry)
// <@autochess.dgreen>3</> autochess-specific colours
// Tags nest. Anything else that looks like a tag — `<获得时>`, `<在场6名不同【炎】干员>` — is literal
// text and is kept verbatim (its closing `>` included). `\n` (real newline or the two-char escape)
// is a line break.
//
// API:
// parseRichText(src) → Seg[] where Seg = { text, cls: string[], term: boolean } | { br: true }
// richTextPlain(src) → string (markup stripped, newlines kept)
// rtClassName(cls) → CSS class for an official style id ('ba.vup' → 'rt-vup')
// formatBondEffect(bond, layers)→ bond `effectDescRaw` with its `{i:fmt}` placeholders resolved
// formatPlaceholder(value, fmt) → '15%' / '3' for the official number formats ('0%', '0.0%', '0', '0.0')
// fillPlaceholders(src, values) → replace `{i:fmt}` / `{i}` by values[i] (unknown indexes kept)
/** Official style id → our CSS modifier (css/screens/game-panels.css `.rt-*`). */
const STYLE_CLASS = {
'ba.vup': 'rt-vup',
'ba.vdown': 'rt-vdown',
'ba.rem': 'rt-rem',
'ba.acrem': 'rt-note',
'ba.kw': 'rt-kw',
'ba.talpu': 'rt-vup',
'autochess.gray': 'rt-note',
'autochess.dgreen': 'rt-mint',
'autochess.green': 'rt-mint',
'autochess.red': 'rt-vdown',
'autochess.yellow': 'rt-rem',
'eb.key': 'rt-kw',
'eb.danger': 'rt-vdown',
};
/**
* CSS class for an official style id. Term tags (`$…`) map to `rt-term`, unknown styles to `rt-hl`.
* @param {string} cls style id without the sigil, e.g. 'ba.vup'
* @param {boolean} [term]
* @returns {string}
*/
export function rtClassName(cls, term = false) {
if (term) return 'rt-term';
return STYLE_CLASS[cls] || 'rt-hl';
}
const TAG_OPEN = /^<([@$])([A-Za-z0-9_.\-]{1,48})>/;
const TAG_CLOSE = '</>';
/**
* Parse official rich text into flat styled segments.
* @param {any} src
* @returns {Array<{ text: string, cls: string[], term: boolean } | { br: true }>}
*/
export function parseRichText(src) {
if (src == null) return [];
const s = String(src).replace(/\\n/g, '\n').replace(/\r\n?/g, '\n');
/** @type {Array<{ cls: string, term: boolean }>} */
const stack = [];
const out = [];
let buf = '';
const flush = () => {
if (!buf) return;
const cls = stack.map((t) => t.cls);
const term = stack.some((t) => t.term);
const prev = out[out.length - 1];
// merge with the previous segment when styles are identical
if (prev && !prev.br && prev.term === term && prev.cls.length === cls.length && prev.cls.every((c, i) => c === cls[i])) {
prev.text += buf;
} else {
out.push({ text: buf, cls, term });
}
buf = '';
};
let i = 0;
while (i < s.length) {
const ch = s[i];
if (ch === '\n') {
flush();
out.push({ br: true });
i += 1;
continue;
}
if (ch === '<') {
if (s.startsWith(TAG_CLOSE, i)) {
if (stack.length) {
flush();
stack.pop();
} else {
buf += TAG_CLOSE; // stray closer: keep literally
}
i += TAG_CLOSE.length;
continue;
}
const m = TAG_OPEN.exec(s.slice(i, i + 52));
if (m) {
flush();
stack.push({ cls: m[2], term: m[1] === '$' });
i += m[0].length;
continue;
}
}
buf += ch;
i += 1;
}
flush();
return out;
}
/**
* Markup-free text (newlines kept).
* @param {any} src
* @returns {string}
*/
export function richTextPlain(src) {
return parseRichText(src).map((seg) => (seg.br ? '\n' : seg.text)).join('');
}
/**
* Format a number with an official format string.
* '0%' → rounded percent, '0.0%' → 1 decimal percent, '0' → integer, '0.0' → 1 decimal. Unknown → trimmed number.
* @param {number} value
* @param {string} [fmt]
* @returns {string}
*/
export function formatPlaceholder(value, fmt = '0') {
const v = Number(value);
if (!Number.isFinite(v)) return '?';
const f = typeof fmt === 'string' ? fmt : '0';
const pct = f.endsWith('%');
const core = pct ? f.slice(0, -1) : f;
const dot = core.indexOf('.');
const decimals = dot >= 0 ? Math.min(4, core.length - dot - 1) : 0;
const n = pct ? v * 100 : v;
const fixed = n.toFixed(decimals);
// avoid "-0"
const clean = Number(fixed) === 0 ? (0).toFixed(decimals) : fixed;
return pct ? `${clean}%` : clean;
}
/**
* Replace `{i:fmt}` / `{i}` placeholders with values (unknown indexes are kept as-is).
* @param {string} src
* @param {Array<number|string|null|undefined>} values
* @param {string[]} [formats] optional per-index format override
* @returns {string}
*/
export function fillPlaceholders(src, values, formats = []) {
if (src == null) return '';
return String(src).replace(/\{(\d{1,2})(?::([^{}]{1,12}))?\}/g, (whole, idx, fmt) => {
const i = Number(idx);
const v = Array.isArray(values) ? values[i] : undefined;
if (v == null) return whole;
if (typeof v === 'string') return v;
return formatPlaceholder(v, formats[i] || fmt || '0');
});
}
/**
* The bond's in-battle effect text with its layer-dependent numbers resolved (DATA.md §3:
* `{i:fmt}` = `bb[base] + bb[perStack] × layers`).
* @param {any} bond bonds.json record
* @param {number} [layers]
* @returns {string} rich text (markup kept)
*/
export function formatBondEffect(bond, layers = 0) {
if (!bond || typeof bond !== 'object') return '';
const src = bond.effectDescRaw || bond.effectDesc || '';
const params = Array.isArray(bond.effectDescParams) ? bond.effectDescParams : [];
const bb = bond.bb && typeof bond.bb === 'object' ? bond.bb : {};
const L = Number.isFinite(Number(layers)) ? Math.max(0, Number(layers)) : 0;
const values = [];
const formats = [];
for (const p of params) {
if (!p || !Number.isInteger(p.index)) continue;
const base = Number(bb[p.base]) || 0;
const per = Number(bb[p.perStack]) || 0;
values[p.index] = base + per * L;
formats[p.index] = p.format || '0';
}
return fillPlaceholders(src, values, formats);
}
+79
View File
@@ -0,0 +1,79 @@
// Player settings (BGM/SFX volume, mute, damage numbers, render quality): a tiny observable store
// persisted in localStorage (`sp.pref.settings`), applied to the audio manager on every change, plus
// the settings modal.
import { 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 } from './gameLogic.js';
import { audio } from '../audio.js';
import { openGuide } from './guide.js';
import { detectFeatures } from './device.js';
/** Settings store: { bgm, sfx, muted, damageNumbers, quality }. */
export const settingsStore = createStore(sanitizeSettings(loadPref('settings', null)));
settingsStore.subscribe((s) => {
savePref('settings', sanitizeSettings(s));
audio.setVolumes(s);
});
audio.setVolumes(settingsStore.get());
/** @param {Partial<ReturnType<typeof sanitizeSettings>>} patch */
export function updateSettings(patch) {
settingsStore.set(sanitizeSettings({ ...settingsStore.get(), ...patch }));
}
/** Preact hook: current settings. */
export const useSettings = () => useStore((s) => s, Object.is, settingsStore);
function Slider({ label, micro, value, onInput, icon }) {
const pct = Math.round(value * 100);
return html`<label class="set-row">
<span class="set-row__label"><${Icon} name=${icon} />${label}<${MicroLabel}>${micro}<//></span>
<input class="set-range" type="range" min="0" max="100" step="5" value=${pct} style=${`--pct:${pct}%`}
onInput=${(e) => onInput(Number(e.currentTarget.value) / 100)} />
<span class="set-row__val num">${pct}</span>
</label>`;
}
function Toggle({ label, micro, value, onChange }) {
return html`<div class="set-row">
<span class="set-row__label">${label}<${MicroLabel}>${micro}<//></span>
<button type="button" class=${`set-toggle${value ? ' is-on' : ''}`} role="switch" aria-checked=${value ? 'true' : 'false'}
onClick=${() => onChange(!value)}><i></i><span>${value ? '开启' : '关闭'}</span></button>
</div>`;
}
const QUALITY = [['high', '高'], ['medium', '中'], ['low', '低']];
/**
* Settings modal.
* @param {{ open: boolean, onClose: Function }} props
*/
export function SettingsModal({ open, onClose }) {
const s = useSettings();
const [tested, setTested] = useState(false);
const [touchUi] = useState(() => detectFeatures().coarse && !detectFeatures().fine);
return html`<${Modal} open=${open} onClose=${onClose} title="设置" micro="SETTINGS" width="7.4rem"
actions=${html`<${Button} variant="secondary" icon="book" class="set-guide" onClick=${() => openGuide(0)}>玩法说明<//>
<${Button} variant="primary" icon="check" onClick=${onClose}>完成<//>`}>
<div class="set-list">
<${Slider} label="背景音乐" micro="BGM" icon="play" value=${s.bgm} onInput=${(v) => updateSettings({ bgm: v })} />
<${Slider} label="音效" micro="SFX" icon="signal" value=${s.sfx}
onInput=${(v) => { updateSettings({ sfx: v }); if (!tested) { setTested(true); setTimeout(() => setTested(false), 400); audio.sfx('click'); } }} />
<${Toggle} label="静音" micro="MUTE" value=${s.muted} onChange=${(v) => updateSettings({ muted: v })} />
<${Toggle} label="显示伤害数字" micro="DAMAGE NUMBERS" value=${s.damageNumbers} onChange=${(v) => updateSettings({ damageNumbers: v })} />
<div class="set-row">
<span class="set-row__label">画面质量<${MicroLabel}>QUALITY<//></span>
<div class="set-seg" role="radiogroup">
${QUALITY.map(([id, label]) => html`<button key=${id} type="button" role="radio" aria-checked=${s.quality === id ? 'true' : 'false'}
class=${s.quality === id ? 'is-on' : ''} onClick=${() => updateSettings({ quality: id })}>${label}</button>`)}
</div>
</div>
${touchUi
? html`<p class="set-hint">触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向</p>`
: html`<p class="set-hint">快捷键:<kbd>R</kbd> 刷新 · <kbd>F</kbd> 冻结 · <kbd>D</kbd> 升级 · <kbd>Space</kbd> 准备就绪 · <kbd>Esc</kbd> 关闭弹窗 · 右键查看详情</p>`}
</div>
<//>`;
}
+304
View File
@@ -0,0 +1,304 @@
// Bottom shop bar (research 06 §11.2 / D3): LEVEL card (upgrade price hex, 升级), 3–5 operator cards
// (tier chip, price hex with discount/markup colours, portrait, bonds, class, frozen overlay, sold state,
// merge progress), the item card, the funds card with ✕ 收起; above it 剩余可放置角色, 冻结/解冻 and 刷新.
// Buying and upgrading take two taps (research 09 §5 / §6.5, official `EventOnFirstClick` → `EventOnConfirm` /
// `EventOnUpgrade`): the first tap selects a card — it lifts and enlarges, its detail opens and it shows 确认购买
// (无法购买 + the reason when it cannot be bought) — and a second tap on the same card buys it; the LEVEL card's first
// tap arms 确认升级 and the second upgrades (已满级 at level 6). A tap anywhere else, a shop change or the end of the
// editable phase disarms. The D / R / F keys stay one-press shortcuts. There is no drag-to-sell zone: selling is the
// 出售 +N button of a tapped unit's underframe (ui/underframe.js). After a promotion the operator cards are replaced by
// the 晋升奖励 cards (3 free operators, pick 1 — also two taps) until picked or put off (稍后 → rewardOverlay.js pill).
// Every operator card shows the skill it will fight with — the player's 干员调配 loadout (m.private.loadout, DESIGN
// §16): the skill icon above the name, mint-framed with 已调配 in its title when it is not the default skill (and the
// module type of an elite card, with its official type icon when the local-client art has it).
import { useEffect, useState } from '../../vendor/hooks.module.js';
import { html, Icon, HexBadge, TierChip, Tooltip, MicroLabel } from './components.js';
import { Img, BondGlyph, CoinGlyph, GIcon, RichText } from './gameComponents.js';
import { priceTone, mergeProgress, shopBlockReason, chessLoadout } from './gameLogic.js';
import { chessPortraitUrl, itemIconUrl, profIconUrl, uiUrl, skillIconUrl, skillRecordIconUrl, moduleTypeIconUrl } from './assetUrls.js';
import { data } from '../data.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
function PriceHex({ slot, free, poor = false }) {
const tone = priceTone(slot);
const price = Number(slot?.price) || 0;
if (free || price === 0) return html`<span class="scard__free">FREE</span>`;
// like the original (img_bg_price_not_enough) the price turns grey while the funds don't cover it
return html`<${HexBadge} value=${price} tone=${poor ? 'dark' : tone} size="md" class=${cx('scard__price', poor && 'is-poor')}
title=${poor ? '资金不足' : tone === 'discount' ? `折扣价(原价 ${slot.basePrice})` : tone === 'premium' ? `加价(原价 ${slot.basePrice})` : '价格'} />`;
}
/** Data lookups for shopBlockReason (full-hand purchases that complete a merge stay allowed). */
const LOOKUPS = { getChess: (id) => data.lookup('chess', id), getItem: (id) => data.lookup('items', id) };
/** 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>无法购买</b><small>${reason}</small></span>`;
return html`<span class="scard__confirm" role="status"><b>${free ? '确认选择' : '确认购买'}</b><small>再次点击</small></span>`;
}
/**
* Operator card (shop and merge reward). `armed`: first tap done (second tap = `onBuy(idx)`).
* @param {{ slot:any, idx:number, priv:any, frozen?:boolean, reason?:string|null, free?:boolean, armed?:boolean,
* onTap?:(idx:number)=>void, onBuy:Function, onDetail:Function }} props
*/
export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free = false, armed = false, onTap = null, onBuy, onDetail }) {
const c = data.lookup('chess', slot.id);
const m = data.get('assets');
const tier = c?.tier ?? 1;
const prog = mergeProgress(priv, slot.id, (id) => data.lookup('chess', id));
const willMerge = prog.copies + 1 >= prog.need && prog.copies > 0;
const bonds = Array.isArray(c?.bonds) ? c.bonds : [];
const disabled = !!reason;
const lo = c ? chessLoadout(c, priv?.loadout, LOOKUPS.getChess) : null;
const tap = () => { if (onTap) onTap(idx); else if (!disabled) onBuy(idx); };
const card = html`<button type="button" class=${cx('scard', `scard--t${tier}`, frozen && 'is-frozen', disabled && 'is-disabled', willMerge && 'is-merge', armed && 'is-armed')}
onClick=${tap} onContextMenu=${(e) => { e.preventDefault(); onDetail(slot.id); }}
aria-label=${`${c?.name || '干员'},价格 ${slot.price}${armed ? (disabled ? ',无法购买' : ',再次点击确认') : ''}`} aria-disabled=${disabled && !onTap ? 'true' : 'false'}
aria-pressed=${onTap ? String(!!armed) : undefined}>
<span class="scard__bg" aria-hidden="true"></span>
<span class="scard__water" aria-hidden="true">${bonds[0] ? html`<${BondGlyph} bondId=${bonds[0]} />` : null}</span>
<${Img} src=${chessPortraitUrl(m, c)} class="scard__art" />
<span class="scard__top">
<${TierChip} tier=${tier} size="md" />
<${PriceHex} slot=${slot} free=${free} poor=${reason === '资金不足'} />
${prog.copies > 0 ? html`<span class="scard__pips" title=${`已拥有 ${prog.copies}/${prog.need}`}>
${Array.from({ length: prog.need }, (_, i) => html`<i key=${i} class=${i < prog.copies ? 'on' : ''}></i>`)}
</span>` : null}
</span>
<span class="scard__body">
${lo?.skill ? html`<${SkillBadge} chess=${c} lo=${lo} />` : null}
<span class="scard__name">${c?.name || slot.id}</span>
<span class="scard__bonds">
${bonds.slice(0, 3).map((b) => html`<span key=${b} class="scard__bond"><${BondGlyph} bondId=${b} /><span>${data.lookup('bonds', b)?.name || b}</span></span>`)}
</span>
<span class="scard__class">
<${Img} src=${profIconUrl(m, c?.profession)} class="scard__prof" />
<span>${c?.subProfessionName || ''}</span>
</span>
</span>
${willMerge ? html`<span class="scard__mergetag">可晋升</span>` : null}
${frozen ? html`<span class="scard__ice" aria-hidden="true"><${Icon} name="snow" /></span>` : null}
${armed ? html`<${ArmedTag} reason=${reason} free=${free} />` : null}
<span class="scard__detail" role="button" tabindex="-1" aria-label="详情" title="查看详情"
onClick=${(e) => { e.stopPropagation(); onDetail(slot.id); }}><${Icon} name="info" /></span>
</button>`;
return reason && reason !== '已售出' && !armed ? html`<${Tooltip} text=${reason} block=${true} class="scard-wrap">${card}<//>` : card;
}
/** The loadout's skill (icon; name + 已调配 in the tooltip) and an elite's module type, on an operator card. */
function SkillBadge({ chess, lo }) {
const m = data.get('assets');
const custom = !lo.defaultSkill;
// a chosen skill without an icon in the manifest: its slot letter (S1–S3) instead of the blank skill sprite
const src = custom ? skillRecordIconUrl(m, lo.skill, { empty: false }) : skillIconUrl(m, chess);
const slot = Number.isInteger(lo.skill.index) ? `S${lo.skill.index + 1}` : null;
const mod = lo.module && !lo.module.none ? lo.module : null;
const tip = `技能${slot ? ` ${slot}` : ''}:${lo.skill.name || ''}${custom ? '(已调配)' : ''}${mod ? ` · 模组:${mod.name}` : lo.module?.none ? ' · 未装备模组' : ''}`;
return html`<span class=${cx('scard__skill', custom && 'is-custom')} title=${tip} aria-label=${tip} data-skill=${lo.skill.skillId || ''}>
<${Img} src=${src} fallback=${slot ? html`<span class="scard__sglyph num">${slot}</span>` : html`<${GIcon} name="bolt" />`} />
${mod && mod.typeName ? html`<span class="scard__mod" data-type=${mod.typeName}><${Img} src=${moduleTypeIconUrl(data.get('local'), mod.typeName)} class="scard__modicon" />${mod.typeName}</span>` : null}
</span>`;
}
function ItemCard({ slot, idx, frozen, reason, armed = false, onTap = null, onBuy, onDetail }) {
const it = data.lookup('items', slot.id);
const m = data.get('assets');
const disabled = !!reason;
const tap = () => { if (onTap) onTap(idx); else if (!disabled) onBuy(idx); };
const card = html`<button type="button" class=${cx('scard', 'scard--item', frozen && 'is-frozen', disabled && 'is-disabled', armed && 'is-armed')}
onClick=${tap} onContextMenu=${(e) => { e.preventDefault(); onDetail(slot.id, 'item'); }}
aria-label=${`${it?.name || '装备'},价格 ${slot.price}${armed ? (disabled ? ',无法购买' : ',再次点击确认') : ''}`}
aria-pressed=${onTap ? String(!!armed) : undefined}>
<span class="scard__bg" aria-hidden="true"></span>
<span class="scard__top">
<${TierChip} tier=${it?.tier ?? 1} size="md" />
<${PriceHex} slot=${slot} poor=${reason === '资金不足'} />
</span>
<span class="scard__itemart"><${Img} src=${itemIconUrl(m, it)} fallback=${html`<${GIcon} name="bolt" />`} /></span>
<span class="scard__body">
<span class="scard__name">${it?.name || slot.id}</span>
<span class="scard__idesc"><${RichText} text=${it?.descRaw || it?.desc || ''} /></span>
</span>
${frozen ? html`<span class="scard__ice" aria-hidden="true"><${Icon} name="snow" /></span>` : null}
${armed ? html`<${ArmedTag} reason=${reason} />` : null}
<span class="scard__detail" role="button" tabindex="-1" aria-label="详情" onClick=${(e) => { e.stopPropagation(); onDetail(slot.id, 'item'); }}><${Icon} name="info" /></span>
</button>`;
return reason && reason !== '已售出' && !armed ? html`<${Tooltip} text=${reason} block=${true} class="scard-wrap">${card}<//>` : card;
}
function SoldCard({ item = false }) {
return html`<div class=${cx('scard', 'scard--sold', item && 'scard--item')} aria-label="已售出">
<span class="scard__soldtxt"><${MicroLabel}>SOLD OUT</${MicroLabel}><span>${item ? '已购买' : '已招募'}</span></span>
</div>`;
}
function LevelCard({ shop, reason, armed = false, onTap }) {
const lv = shop?.level ?? 1;
const max = lv >= (shop?.maxLevel ?? 6);
const price = shop?.upgradePrice ?? 0;
return html`<button type="button" class=${cx('lvcard', max && 'is-max', reason && 'is-disabled', armed && 'is-armed')} onClick=${() => !reason && onTap()}
title=${reason || (armed ? `再次点击确认升级(${price} 资金)` : `升级调度中心(${price} 资金) · D`)} aria-disabled=${reason ? 'true' : 'false'}
aria-pressed=${String(!!armed)}>
${!max ? html`<${HexBadge} value=${price} tone=${reason && reason !== '调度中心已达最高等级' ? 'dark' : 'gold'} size="md" class="lvcard__price" />` : null}
<span class="lvcard__frame">
<span class="lvcard__micro">LEVEL</span>
<b class="lvcard__num num">${lv}</b>
</span>
<span class="lvcard__label">${max ? '已满级' : armed ? '确认升级' : '升级'}</span>
<kbd class="lvcard__key">D</kbd>
</button>`;
}
/** Key of an armed shop card: kind + slot index + the id it showed (a reroll / sale changes it ⇒ disarm). */
export const armKey = (kind, idx, slot) => `${kind}:${idx}:${slot?.id ?? ''}`;
/**
* Two-tap state of the bar (first tap arms, second confirms). Disarms on a tap outside the shop / detail panel, on
* Escape, when the armed card changes or disappears, and when the bar stops being editable.
* @param {{ editable: boolean, keys: Set<string> }} o the keys that are currently valid
*/
export function useTwoTap({ editable, keys }) {
const [armed, setArmed] = useState(null);
const valid = armed == null || (editable && keys.has(armed));
useEffect(() => { if (!valid) setArmed(null); }, [valid]);
useEffect(() => {
if (armed == null) return undefined;
const onDown = (e) => {
const t = e.target;
if (t && t.closest && t.closest('.scard, .lvcard, .dpanel, .modal')) return;
setArmed(null);
};
const onKey = (e) => { if (e.key === 'Escape') setArmed(null); };
window.addEventListener('pointerdown', onDown, true);
window.addEventListener('keydown', onKey);
return () => { window.removeEventListener('pointerdown', onDown, true); window.removeEventListener('keydown', onKey); };
}, [armed]);
return [valid ? armed : null, setArmed];
}
/**
* Promotion reward (research 00 §3): after a merge the operator cards of the bar are replaced by 3 free operators of
* tier min(level+1, 6); pick 1 (g.reward idx) or put it off (稍后选择 → the normal shop + a reminder pill).
*/
function RewardCards({ offer, priv, editable, onPick, onDetail, onLater, armed, onTap }) {
return html`<div class="shopbar__reward" role="group" aria-label="晋升奖励">
<div class="rwtag">
<${Icon} name="crown" class="rwtag__icon" />
<b class="rwtag__title">晋升奖励</b>
<span class="rwtag__micro">PROMOTION</span>
<span class="rwtag__sub">免费选择 1 名</span>
<button type="button" class="rwtag__later" onClick=${onLater} title="稍后选择(回合结束后消失)"><${Icon} name="minus" />稍后</button>
</div>
<div class="shopbar__rwcards">
${offer.slots.map((s, i) => (s && !s.sold
? html`<${ChessCard} key=${`rw${i}:${s.id}`} slot=${{ ...s, price: 0 }} idx=${i} priv=${priv} free=${true}
armed=${armed === armKey('r', i, s)} onTap=${(idx) => onTap('r', idx, s, 'chess', shopBlockReason('reward', { priv, editable, slot: s, ...LOOKUPS }), onPick)}
reason=${shopBlockReason('reward', { priv, editable, slot: s, ...LOOKUPS })} onBuy=${onPick} onDetail=${onDetail} />`
: html`<div key=${`rw${i}`} class="scard scard--sold"><span class="scard__soldtxt"><span>已选择</span></span></div>`))}
</div>
</div>`;
}
/**
* The bar.
* @param {{ priv:any, editable:boolean, collapsed:boolean, onCollapse:(c:boolean)=>void,
* onBuy:(i:number)=>void, onLevel:Function, onRefresh:Function, onFreeze:Function, onDetail:(id:string, kind?:string)=>void,
* onDetailClose?: () => void, onRefuse?: (reason: string) => void, barRef:any,
* reward?: any, onReward?: (idx:number)=>void, onRewardLater?: Function }} props
*/
export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel, onRefresh, onFreeze, onDetail, onDetailClose, onRefuse, barRef,
reward = null, onReward, onRewardLater }) {
const shop = priv?.shop || {};
const slots = Array.isArray(shop.slots) ? shop.slots : [];
const chessSlots = slots.map((s, i) => ({ s, i })).filter(({ s }) => !s || s.kind !== 'item');
const itemSlots = slots.map((s, i) => ({ s, i })).filter(({ s }) => s && s.kind === 'item');
const frozen = !!shop.frozen;
const funds = Number(priv?.funds) || 0;
const remaining = Math.max(0, (priv?.deployCap ?? 8) - (priv?.deployCount ?? 0));
const lvReason = shopBlockReason('levelUp', { priv, editable });
const refReason = shopBlockReason('refresh', { priv, editable });
const frzReason = editable ? null : shopBlockReason('freeze', { priv, editable });
const free = Number(shop.freeRefreshes) || 0;
const showReward = !!(reward && Array.isArray(reward.slots) && reward.slots.length);
// two-tap: the keys that may stay armed right now
const keys = new Set();
if (!collapsed) {
if (!lvReason) keys.add('lv');
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)); });
}
const [armed, setArmed] = useTwoTap({ editable, keys });
/** First tap arms + opens the detail; the second buys (or says why it can't). */
const tapCard = (kind, idx, slot, detailKind, reason, buy) => {
const key = armKey(kind, idx, slot);
if (armed !== key) { setArmed(key); onDetail(slot.id, detailKind); return; }
if (reason) { onRefuse?.(reason); return; }
setArmed(null);
onDetailClose?.();
buy(idx);
};
const tapLevel = () => {
if (armed !== 'lv') { setArmed('lv'); return; }
setArmed(null);
onLevel();
};
if (collapsed) {
return html`<div class="shopbar-tab" ref=${barRef}>
<div class="shopbar-tab__funds"><${CoinGlyph} /><b class="num">${funds}</b></div>
<button type="button" class="shopbar-tab__btn" onClick=${() => onCollapse(false)}><${Icon} name="chevronLeft" />展开商店</button>
</div>`;
}
return html`<section class=${cx('shopbar', frozen && 'is-frozen', !editable && 'is-locked', showReward && 'has-reward', armed && 'has-armed')} ref=${barRef} aria-label="调度中心">
<div class="shopbar__tools">
<span class="shopbar__remain">剩余可放置角色:<b class=${cx('num', remaining === 0 && 't-orange')}>${remaining}</b></span>
<button type="button" class=${cx('toolbtn', 'toolbtn--ice', frozen && 'is-on')} disabled=${!!frzReason} onClick=${onFreeze}
title=${frzReason || (frozen ? '解冻商店 · F' : '冻结商店(下回合保留) · F')}>
<${Img} src=${uiUrl(data.get('assets'), frozen ? 'shopPanel/frozen_icon2' : 'shopPanel/frozen_icon')} class="toolbtn__img" fallback=${html`<${Icon} name="snow" />`} />
<span>${frozen ? '解冻' : '冻结'}</span><kbd>F</kbd>
</button>
<button type="button" class="toolbtn toolbtn--amber" disabled=${!!refReason} onClick=${onRefresh} title=${refReason || '刷新商店 · R'}>
<${Img} src=${uiUrl(data.get('assets'), 'shopPanel/refresh_icon')} class="toolbtn__img" fallback=${html`<${Icon} name="refresh" />`} />
<span>刷新</span>
${free > 0 ? html`<span class="toolbtn__free">免费 ×${free}</span>` : html`<${HexBadge} value=${shop.refreshPrice ?? 1} tone=${refReason ? 'dark' : 'gold'} size="sm" />`}
<kbd>R</kbd>
</button>
</div>
<div class="shopbar__row">
<${LevelCard} shop=${shop} reason=${lvReason} armed=${armed === 'lv'} onTap=${tapLevel} />
${showReward ? html`<${RewardCards} offer=${reward} priv=${priv} editable=${editable} onPick=${onReward} onDetail=${onDetail} onLater=${onRewardLater}
armed=${armed} onTap=${tapCard} />`
: html`<div class="shopbar__cards">
${chessSlots.map(({ s, i }) => {
if (!s || 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}
reason=${reason} armed=${armed === armKey('c', i, s)} onTap=${editable ? (idx) => tapCard('c', idx, s, 'chess', reason, onBuy) : null} />`;
})}
</div>`}
<div class="shopbar__item">
${itemSlots.length
? 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}
armed=${armed === armKey('i', i, s)} onTap=${editable ? (idx) => tapCard('i', idx, s, 'item', reason, onBuy) : null} />`;
})
: html`<${SoldCard} item=${true} />`}
</div>
<div class="funds">
<div class="funds__hex">
<${CoinGlyph} class="funds__coin" />
<b class="funds__num num">${funds}</b>
</div>
<span class="funds__label">目前资金</span>
<button type="button" class="funds__collapse" onClick=${() => onCollapse(true)}><${Icon} name="close" />收起</button>
</div>
</div>
</section>`;
}
+79
View File
@@ -0,0 +1,79 @@
// Left team panel (research 06 §11.1, research 09 §3.1): one row per seat — avatar (band icon once picked), name, LP
// tower, status glyph (… acting / ✓ ready / ⌛ deciding / ⚔ combat / door left / ✕ dead), AI badge, "you" marker, the
// field being watched (eye badge), and emote bubbles.
// 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.
import { useEffect, useState } from '../../vendor/hooks.module.js';
import { html, Icon, Tooltip } from './components.js';
import { PlayerAvatar, LpTower, GIcon, LocalSprite } from './gameComponents.js';
import { EmoteBubble } from './emotes.js';
import { STATUS_META, sortedPlayers } from './gameLogic.js';
import { localAsset } from '../data.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/** Official status glyphs (ui/battle) per m.public players[].status; CSS glyph fallback. */
const STATUS_SPRITE = { ready: 'icon_ready', deciding: 'icon_waiting', done: 'icon_complete', dead: 'icon_dead' };
/**
* @param {{ pub:any, myId:string, watching:string|null, bubbles: Map<string,{id:string,seq:number}>, onWatch:(p:any)=>void,
* compact?: boolean, teamLp?: number|null,
* observe?: null | { canObserve: (p:any) => { fieldId?: string, reason?: string|null, back?: boolean }, observing: boolean, onBack: () => void } }} props
*/
export function TeamPanel({ pub, myId, watching, bubbles, onWatch, compact = false, observe = null }) {
const [openPid, setOpenPid] = useState(null);
const phaseKey = `${pub?.phase}:${pub?.round}`;
useEffect(() => { setOpenPid(null); }, [phaseKey, watching, observe?.observing]);
const players = sortedPlayers(pub);
if (!players.length) return null;
const click = (p, self) => {
if (!observe) { 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
setOpenPid((cur) => (cur === p.playerId ? null : p.playerId));
};
return html`<aside class=${cx('team', compact && 'team--compact')} aria-label="同盟成员">
${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 bubble = bubbles?.get(p.playerId);
const offline = p.connected === false && !p.isBot;
const open = !!observe && openPid === p.playerId && !self;
const back = !!observe && self && observe.observing;
const title = observe ? (self ? (observe.observing ? '返回战场' : '你自己') : `查看 ${p.name} 的战场`) : (self ? '查看自己的阵地' : `查看 ${p.name} 的阵地`);
return html`<div key=${p.playerId} class=${cx('team__row', self && 'is-self', watched && 'is-watched', p.alive === false && 'is-dead', open && 'is-open')}>
<button type="button" class="team__btn" onClick=${() => click(p, self)} title=${title} aria-expanded=${observe && !self ? String(open) : undefined}>
<${PlayerAvatar} player=${p} self=${self} />
<span class="team__seat num">P${(p.seat ?? 0) + 1}</span>
${p.isBot ? html`<span class="team__ai">AI</span>` : null}
${self ? html`<span class="team__you"><${Icon} name="user" /></span>` : null}
</button>
<div class="team__info">
<span class="team__name">${p.name || '博士'}</span>
<div class="team__line">
<${LpTower} value=${p.lp} size="sm" tone=${Number.isFinite(p.lp) && p.lp <= 5 ? 'danger' : null} />
<${Tooltip} text=${offline ? '连接已断开' : meta.text} placement="right">
<span class=${cx('team__status', `is-${meta.tone}`, offline && 'is-offline', (offline || STATUS_SPRITE[status]) && localAsset('ui/battle', offline ? 'icon_lost_connect' : STATUS_SPRITE[status]) && 'has-sprite')} aria-label=${meta.text}>
${offline ? html`<${LocalSprite} name="icon_lost_connect" fallback=${html`<${Icon} name="wifiOff" />`} />`
: STATUS_SPRITE[status] ? html`<${LocalSprite} name=${STATUS_SPRITE[status]} fallback=${html`<${GIcon} name=${meta.glyph} />`} />`
: html`<${GIcon} name=${meta.glyph} />`}
</span>
<//>
${watched && !self ? html`<span class="team__eye" title="正在查看"><${GIcon} name="eye" /></span>` : null}
</div>
${open ? html`<button type="button" class="btn btn--primary btn--sm team__ob"
onClick=${() => { setOpenPid(null); onWatch(p); }}><span class="btn__label">前往查看</span></button>` : null}
${back ? html`<button type="button" class="btn btn--secondary btn--sm team__back"
onClick=${() => observe.onBack()}><span class="btn__label">返回战场</span></button>` : null}
</div>
${bubble ? html`<${EmoteBubble} key=${bubble.seq} id=${bubble.id} class="team__bubble" />` : null}
</div>`;
})}
</aside>`;
}
+51
View File
@@ -0,0 +1,51 @@
// Broadcast ticker (m.ticker): a strip under the top bar; each line slides in from the right, stays
// TICKER_MS and leaves; queued lines play in order (only the newest QUEUE_MAX are kept).
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { html } from './components.js';
import { GIcon, RichText } from './gameComponents.js';
import { useStore } from '../store.js';
import { audio } from '../audio.js';
const TICKER_MS = 5200;
const QUEUE_MAX = 4;
/** Ticker strip bound to store.ticker. */
export function Ticker() {
const items = useStore((s) => s.ticker);
const seen = useRef(null);
const queue = useRef([]);
const [cur, setCur] = useState(null);
const timer = useRef(null);
useEffect(() => {
const list = Array.isArray(items) ? items : [];
if (seen.current == null) {
// lines received before this screen mounted are history, not news
seen.current = list.length ? list[list.length - 1].id : 0;
return;
}
const fresh = list.filter((t) => t && t.id > seen.current && Date.now() - t.at < 30000);
if (!fresh.length) return;
seen.current = fresh[fresh.length - 1].id;
queue.current = [...queue.current, ...fresh].slice(-QUEUE_MAX);
if (!cur) next();
}, [items]);
useEffect(() => () => clearTimeout(timer.current), []);
function next() {
const it = queue.current.shift() || null;
setCur(it);
if (it) {
audio.sfx('broadcast', { volume: 0.5 });
clearTimeout(timer.current);
timer.current = setTimeout(next, TICKER_MS);
}
}
if (!cur) return null;
return html`<div class="ticker" role="status" aria-live="polite">
<div key=${cur.id} class="ticker__line"><${GIcon} name="flag" class="ticker__icon" /><${RichText} text=${cur.text} /></div>
</div>`;
}
+136
View File
@@ -0,0 +1,136 @@
// Global toast notifications (top-centre stack).
//
// Imperative API usable from anywhere: `toast(text, kind)`, `toastError(err)`; render <ToastHost/>
// once near the app root. Identical messages shown within a short window are merged (with a ×N
// counter) instead of stacking, and the stack is capped so an error storm cannot flood the screen.
import { h } from '../../vendor/preact.module.js';
import { useEffect, useReducer } from '../../vendor/hooks.module.js';
import htm from '../../vendor/htm.module.js';
import { ERR_TEXT } from '../../../shared/constants.js';
// no shared static vnodes (see components.js hFresh: htm's static cache would retain unmounted DOM)
function hFresh(type, props, ...children) {
if (this && typeof this === 'object') this[0] = 3;
return h(type, props, ...children);
}
const html = htm.bind(hFresh);
export const TOAST_KINDS = ['info', 'success', 'warn', 'error'];
const MAX_TOASTS = 4;
const DEFAULT_MS = { info: 2600, success: 2400, warn: 3200, error: 3600 };
const MERGE_WINDOW_MS = 2500;
let seq = 0;
/** @type {{id:number, text:string, kind:string, count:number, at:number, ttl:number, leaving:boolean}[]} */
let toasts = [];
const listeners = new Set();
const timers = new Map();
const emit = () => { for (const fn of [...listeners]) { try { fn(toasts); } catch (err) { console.error(err); } } };
function schedule(t) {
clearTimeout(timers.get(t.id));
timers.set(t.id, setTimeout(() => dismissToast(t.id), t.ttl));
}
/**
* Show a toast.
* @param {string} text message (plain text)
* @param {'info'|'success'|'warn'|'error'} [kind]
* @param {{ ttl?: number }} [opts]
* @returns {number} toast id
*/
export function toast(text, kind = 'info', opts = {}) {
const k = TOAST_KINDS.includes(kind) ? kind : 'info';
const msg = String(text ?? '').slice(0, 200).trim();
if (!msg) return -1;
const now = Date.now();
const ttl = Number.isFinite(opts.ttl) && opts.ttl > 0 ? opts.ttl : DEFAULT_MS[k];
const same = toasts.find((t) => !t.leaving && t.text === msg && t.kind === k && now - t.at < MERGE_WINDOW_MS + t.ttl);
if (same) {
toasts = toasts.map((t) => (t === same ? { ...t, count: t.count + 1, at: now, ttl } : t));
schedule(toasts.find((t) => t.id === same.id));
emit();
return same.id;
}
const t = { id: ++seq, text: msg, kind: k, count: 1, at: now, ttl, leaving: false };
toasts = [...toasts, t];
// Cap the stack: drop the oldest immediately.
while (toasts.length > MAX_TOASTS) {
const old = toasts[0];
clearTimeout(timers.get(old.id));
timers.delete(old.id);
toasts = toasts.slice(1);
}
schedule(t);
emit();
return t.id;
}
/**
* Dismiss a toast (plays the exit animation, then removes it).
* @param {number} id
*/
export function dismissToast(id) {
const t = toasts.find((x) => x.id === id);
if (!t) return;
clearTimeout(timers.get(id));
if (t.leaving) return;
toasts = toasts.map((x) => (x.id === id ? { ...x, leaving: true } : x));
emit();
timers.set(id, setTimeout(() => {
timers.delete(id);
toasts = toasts.filter((x) => x.id !== id);
emit();
}, 200));
}
/**
* Text for an error-ish value: NetError / {code,msg} / Error / string.
* @param {any} err
* @returns {string}
*/
export function describeError(err) {
if (!err) return '发生未知错误';
if (typeof err === 'string') return ERR_TEXT[err] || err;
if (err.code && ERR_TEXT[err.code]) return ERR_TEXT[err.code];
if (typeof err.message === 'string' && err.message) return err.message;
if (typeof err.msg === 'string' && err.msg) return err.msg;
return '发生未知错误';
}
/**
* Show an error toast for a failed request / error push.
* @param {any} err
* @returns {number}
*/
export function toastError(err) {
return toast(describeError(err), 'error');
}
const ICONS = {
info: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm1 15h-2v-6h2v6Zm0-8h-2V7h2v2Z',
success: 'M9.5 16.2 5.3 12l-1.4 1.4 5.6 5.6L20.1 8.4 18.7 7z',
warn: 'M1 21h22L12 2 1 21Zm12-3h-2v-2h2v2Zm0-4h-2v-4h2v4Z',
error: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm5 13.6L15.6 17 12 13.4 8.4 17 7 15.6 10.6 12 7 8.4 8.4 7 12 10.6 15.6 7 17 8.4 13.4 12 17 15.6Z',
};
/** Renders the toast stack. Mount once. */
export function ToastHost() {
const [, force] = useReducer((c) => c + 1, 0);
useEffect(() => {
listeners.add(force);
return () => listeners.delete(force);
}, []);
return html`
<div class="toast-host" role="status" aria-live="polite">
${toasts.map((t) => html`
<div key=${t.id} class=${`toast toast--${t.kind}${t.leaving ? ' is-leaving' : ''}`}
onClick=${() => dismissToast(t.id)}>
<svg class="toast__icon" viewBox="0 0 24 24" aria-hidden="true"><path d=${ICONS[t.kind]} /></svg>
<span class="toast__text">${t.text}</span>
${t.count > 1 ? html`<span class="toast__count">×${t.count}</span>` : null}
</div>`)}
</div>`;
}
+102
View File
@@ -0,0 +1,102 @@
// Underframe — the selection diamond of a tapped own piece (research 09 §1.2 "Tap a deployed unit", §5, §6.5;
// official `act2autochess_panel_character_menu`: `util_btn` icon_sell + price "+1", funcId autochessSale; the
// destroy variant `_underFramePanelBtnParamDestroyPos` with icon_destory).
//
// board operator → 撤退 (upper-left: back to the bench) + 出售 +N (upper-right)
// board summon → 撤退
// bench operator → 出售 +N
// item / Art → 销毁
// The diamond frames the piece's tile; only its buttons take pointer events, so the unit under it can still be
// dragged (moving it — or dropping it back on its own tile to re-orient it with the wheel). The game screen shows the
// unit's range tiles (rotated to its facing) and the detail card beside it while it is selected (on the side away
// from the unit, `underframeRect` / gameLogic `panelSide`), and the underframe sits above every HUD panel, so its
// buttons are never covered.
//
// Button look (user playtest #2 item 7): the official sprites icon_sell / icon_destory are WHITE octagon plates with
// the glyph cut out — tinted in the client. Drawn as is they read as a plain white block, so each button is a coloured
// octagon plate: the sprite is used as a CSS mask over the plate colour (出售 amber like every money action, 销毁 /
// 撤退 red) on a dark backing that shows through the glyph cut-out. Without the local sprites the same plate carries
// the built-in glyph.
import { html, HexBadge } from './components.js';
import { GIcon } from './gameComponents.js';
import { useTileScreen } from './facingWheel.js';
import { localAsset } from '../data.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/** Runner glyph of 撤退 (a figure leaving through a door; original shape). */
function RetreatGlyph() {
return html`<svg class="uframe__glyph" viewBox="0 0 24 24" aria-hidden="true">
<path d="M13.5 3.2a2 2 0 1 1 0 4 2 2 0 0 1 0-4zM9.6 8.3l3.7-.6 2.2 3.3 2.7 1-.6 1.8-3.4-1.2-.9-1.3-.8 3.2 2.6 2.6V22h-2v-4.7l-2.4-2.3-.9 3.6-4.4-1 .5-1.9 2.4.5 1.6-6.6-1 .3-1.4 2.8-1.8-.9 1.9-3.6z" />
<path d="M2 5h5v2H4v10h3v2H2z" opacity=".7" />
</svg>`;
}
/**
* A tinted octagon plate: the official sprite (white plate, glyph cut out) as a mask over the tone colour, or the
* built-in glyph on the same plate when the local sprite is not installed.
* @param {{ sprite: string, glyph: string, tone: 'sell'|'destroy' }} props
*/
export function PlateIcon({ sprite, glyph, tone }) {
const url = localAsset('ui/battle', sprite);
return html`<span class=${cx('uframe__plate', `uframe__plate--${tone}`, url ? 'has-mask' : 'is-glyph')}
style=${url ? `--uf-mask:url("${url}")` : ''} data-sprite=${url ? sprite : null} aria-hidden="true">
${url ? null : html`<${GIcon} name=${glyph} class="uframe__pglyph" />`}
</span>`;
}
/**
* Client-px rect the underframe of a tile covers: the diamond plus its buttons (plates, labels, the +N price) — the
* geometry of the css below (.uframe__btn--retreat / --sell: .56rem plates at 25 % / 75 % across, 25 % down, shifted
* −80 % / −20 % horizontally and −90 % vertically). Used for the detail panel placement.
* @param {{ x: number, y: number, s: number }|null} g view.tileScreen(row, col)
* @param {number} [rem] root font size (px)
* @returns {{ left: number, right: number, top: number, bottom: number }|null}
*/
export function underframeRect(g, rem = 100) {
if (!g || !Number.isFinite(g.x) || !Number.isFinite(g.y)) return null;
const s = g.s > 0 ? g.s : 64;
const half = s * 1.05;
const P = rem * 0.56; // plate
const H = P + rem * 0.26; // plate + label
const q = half / 2; // 25 % / 75 % of the diamond box, from its centre
const btnTop = g.y - q - 0.9 * H;
const left = Math.min(g.x - half, g.x - q - 0.8 * P);
const right = Math.max(g.x + half, g.x + q - 0.2 * P + P + rem * 0.14);
return { left, right, top: Math.min(g.y - half, btnTop - rem * 0.08), bottom: g.y + half };
}
/**
* @param {{ view: any, uid?: number|null, row: number, col: number, actions: { retreat: boolean, sell: number|null, destroy: boolean },
* name?: string, busy?: boolean, onRetreat?: () => void, onSell?: () => void, onDestroy?: () => void }} props
*/
export function Underframe({ view, uid = null, row, col, actions, name = '', busy = false, onRetreat, onSell, onDestroy }) {
const g = useTileScreen(view, row, col);
if (!g || !actions) return null;
const s = g.s > 0 ? g.s : 64;
const half = s * 1.05;
const stop = (e) => e.stopPropagation();
return html`<div class="uframe" data-uid=${uid} style=${`left:${g.x}px;top:${g.y}px;width:${half * 2}px;height:${half * 2}px`} role="group"
aria-label=${`${name || '单位'} 操作`}>
<svg class="uframe__dia" viewBox="-110 -110 220 220" aria-hidden="true">
<path class="uframe__outer" d="M0 -100 L100 0 L0 100 L-100 0 Z" />
<path class="uframe__corner" d="M-100 0 L-86 -14 M-100 0 L-86 14 M100 0 L86 -14 M100 0 L86 14 M0 -100 L-14 -86 M0 -100 L14 -86 M0 100 L-14 86 M0 100 L14 86" />
</svg>
${actions.retreat ? html`<button type="button" class="uframe__btn uframe__btn--retreat" disabled=${busy} onPointerDown=${stop}
onClick=${(e) => { stop(e); onRetreat?.(); }} title="撤退至整备区" aria-label="撤退">
<${RetreatGlyph} /><span class="uframe__label">撤退</span>
</button>` : null}
${actions.sell != null ? html`<button type="button" class="uframe__btn uframe__btn--sell" disabled=${busy} onPointerDown=${stop}
onClick=${(e) => { stop(e); onSell?.(); }} title=${`出售(+${actions.sell} 资金)`} aria-label=${`出售,获得 ${actions.sell} 资金`}>
<${PlateIcon} sprite="icon_sell" glyph="sell" tone="sell" />
<span class="uframe__label">出售</span>
<${HexBadge} value=${`+${actions.sell}`} tone="gold" size="sm" class="uframe__price" />
</button>` : null}
${actions.destroy ? html`<button type="button" class=${cx('uframe__btn', 'uframe__btn--destroy')} disabled=${busy} onPointerDown=${stop}
onClick=${(e) => { stop(e); onDestroy?.(); }} title="销毁道具" aria-label="销毁">
<${PlateIcon} sprite="icon_destory" glyph="trash" tone="destroy" />
<span class="uframe__label">销毁</span>
</button>` : null}
</div>`;
}