卫戍协议:盟约 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:
commit
01a44cd288
408 files changed
+308986
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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} /><//>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' }),
|
||||
};
|
||||
@@ -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
@@ -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>`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
<//>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
Reference in new issue
Block a user