Files
ark-stronghold-protocol/public/dev/uikit.html
T
sganggs 01a44cd288 卫戍协议:盟约 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).
2026-09-29 16:21:31 +08:00

349 lines
22 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<!--
UI kit gallery (visual QA for js/ui/components.js + css). Standalone page: /dev/uikit.html
Renders every design-system component in its variants/states. Uses real bond icons from
data/assets.json + names from data/bonds.json when those files exist.
-->
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>UI Kit · 卫戍协议:盟约</title>
<link rel="icon" href="data:," />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" media="print" onload="this.media='all'"
href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700;900&family=Oxanium:wght@400;500;600;700&family=Rajdhani:wght@500;600;700&display=swap" />
<link rel="stylesheet" href="/fonts/fonts.css" />
<link rel="stylesheet" href="/css/theme.css" />
<link rel="stylesheet" href="/css/components.css" />
<link rel="stylesheet" href="/css/emotes.css" />
<link rel="stylesheet" href="/css/devices.css" />
<script type="importmap">
{ "imports": { "preact": "/vendor/preact.module.js", "preact/hooks": "/vendor/hooks.module.js", "htm": "/vendor/htm.module.js" } }
</script>
<style>
/* Gallery page only: a scrollable document instead of the fixed game viewport. */
html { font-size: clamp(40px, calc(100vw / 19.2), 140px); }
body { overflow: auto; height: auto; min-height: 100%; }
.kit { position: relative; z-index: 1; max-width: 18rem; margin: 0 auto; padding: .5rem .5rem 1.2rem; }
.kit-bg { position: fixed; inset: 0; z-index: 0; }
.kit-head { display: flex; align-items: flex-end; justify-content: space-between; gap: .4rem; padding-bottom: .24rem; border-bottom: 1px solid var(--line); }
.kit-head h1 { font-size: .5rem; font-weight: 900; letter-spacing: .06em; }
.kit-head h1 em { font-style: normal; color: var(--mint-500); }
.kit-sec { margin-top: .5rem; }
.kit-sec > h2 { display: flex; align-items: baseline; gap: .14rem; font-size: .24rem; margin-bottom: .2rem; }
.kit-sec > h2 .num { color: var(--mint-500); font-size: .18rem; }
.row { display: flex; flex-wrap: wrap; align-items: center; gap: .24rem; margin-bottom: .22rem; }
.row--top { align-items: flex-start; }
.cap { font-size: .12rem; color: var(--text-dim); font-family: var(--font-display); letter-spacing: .14em; text-transform: uppercase; width: 100%; margin-bottom: -.1rem; }
.grid3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .24rem; }
.grid2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .24rem; }
.swatch { display: flex; flex-direction: column; gap: .06rem; width: 1.3rem; font-size: .12rem; color: var(--text-lo); }
.swatch i { display: block; height: .56rem; border: 1px solid var(--line); }
.swatch b { color: var(--text-hi); font-family: var(--font-num); font-weight: 700; font-size: .14rem; }
.type-demo > * { display: block; margin-bottom: .08rem; }
.stack { display: flex; flex-direction: column; gap: .14rem; min-width: 3rem; }
/* 13 emotes */
.emo-stage { position: relative; height: 3.3rem; padding: .2rem; background: rgba(0, 0, 0, .35); border: 1px solid var(--line); }
.emo-stage .ewheel { position: absolute; left: .2rem; bottom: .2rem; }
.emo-team { position: absolute; left: 3.7rem; top: .3rem; display: flex; flex-direction: column; gap: .14rem; }
.emo-row { position: relative; display: flex; align-items: center; gap: .1rem; }
.emo-row .pav { width: .8rem; height: .8rem; display: grid; place-items: center; background: linear-gradient(135deg, #2c4a40, #10201b); border: 1px solid var(--line-2); font-size: .3rem; font-weight: 900; color: var(--mint-400); }
.emo-row .ebubble { position: absolute; left: calc(100% + .06rem); top: 50%; transform: translateY(-50%); }
.emo-bubbles { display: flex; flex-wrap: wrap; gap: .3rem; align-items: center; }
.emo-bubbles figure { margin: 0; display: flex; flex-direction: column; align-items: flex-start; gap: .06rem; font-size: .11rem; color: var(--text-dim); }
.emo-cat { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: .12rem .2rem; }
.emo-cat figure { margin: 0; display: flex; flex-direction: column; align-items: center; gap: .04rem; font-size: .1rem; color: var(--text-dim); font-family: var(--font-mono); text-align: center; }
.emo-cat .cell { width: .85rem; height: .85rem; padding: .07rem; box-sizing: border-box; background: rgba(255, 255, 255, .12); border-radius: .08rem; }
.emo-cat h3 { grid-column: 1 / -1; font-size: .14rem; color: var(--text-md); margin: .06rem 0 0; }
</style>
</head>
<body>
<div class="app-bg kit-bg" aria-hidden="true"></div>
<div id="kit"></div>
<script type="module">
import '/js/ui/compat.js';
import { render } from '/vendor/preact.module.js';
import { useState } from '/vendor/hooks.module.js';
import {
html, Button, Panel, MicroLabel, Chevrons, HexBadge, TierChip, BondDisc, SevenSeg, Countdown, Modal, confirmDialog,
alertDialog, Tooltip, ProgressBar, Tabs, Spinner, AvatarFrame, PhaseBanner, PingPill, DifficultyTag, TextField,
UiHosts, Icon, ICONS,
} from '/js/ui/components.js';
import { ToastHost, toast } from '/js/ui/toasts.js';
import { data, useData, getBond } from '/js/data.js';
import { DIFFICULTIES, EMOTE_THEMES, EMOTES } from '/shared/constants.js';
import { EmoteWheel, EmoteBubble, EmoteArt } from '/js/ui/emotes.js';
const SWATCHES = [
['bg-0', '#0c0f0e'], ['bg-1', '#141816'], ['bg-2', '#1e2421'], ['line', '#2f3a35'],
['mint-500', '#4ed8af'], ['mint-400', '#59f4ca'], ['mint-600', '#35d8b4'], ['mint-glow', '#17f9b7'],
['gold', '#ffc600'], ['amber', '#f6a329'], ['orange', '#e85a1a'], ['red', '#e73118'], ['abyss', '#ff0024'],
['premium', '#ff5454'], ['ice', '#9fd4ff'], ['text-lo', '#8a948f'],
];
const BONDS = ['yanShip', 'sargonShip', 'victoriaShip', 'kjeragShip', 'preciShip', 'swiftShip', 'deputShip', 'investShip'];
function Sec({ n, title, micro, children }) {
return html`<section class="kit-sec">
<h2><span class="num">${n}</span>${title}<${MicroLabel}>${micro}<//></h2>
${children}
</section>`;
}
/** 13 · official emotes: wheel (pager per theme) → bubble beside the sender's avatar; bubbles per theme; catalog. */
function EmoteDemo() {
useData('local');
const [open, setOpen] = useState(true);
const [offline, setOffline] = useState(false);
const [bubbles, setBubbles] = useState(() => ({ me: null, p2: { id: 'slug_autochess_battle_nice_work', seq: 1 } }));
const [replay, setReplay] = useState(0);
const say = (who, id) => setBubbles((b) => ({ ...b, [who]: { id, seq: Date.now() + Math.random(), at: Date.now() } }));
const rows = [['me', '我'], ['p2', '2P']];
return html`
<div class="grid2">
<div class="emo-stage" id="emo-stage">
<div class="emo-team">
${rows.map(([who, t]) => html`<div class="emo-row" key=${who}><span class="pav">${t}</span>
${bubbles[who] ? html`<${EmoteBubble} key=${bubbles[who].seq} id=${bubbles[who].id} at=${bubbles[who].at} class="team__bubble" />` : null}</div>`)}
</div>
<${EmoteWheel} open=${open} onToggle=${setOpen} disabled=${offline} onSend=${(id) => say('me', id)} />
</div>
<div class="stack">
<div class="row">
<${Button} size="sm" onClick=${() => setOpen((o) => !o)}>${open ? '收起面板' : '展开面板'}<//>
<${Button} size="sm" variant="amber" onClick=${() => say('p2', EMOTES[Math.floor(Math.random() * EMOTES.length)])}>队友发送表情<//>
<${Button} size="sm" variant="ghost" onClick=${() => say('p2', 'not_an_emote')}>缺图回退<//>
<${Button} size="sm" variant=${offline ? 'danger' : 'ghost'} onClick=${() => setOffline((v) => !v)}>${offline ? '已断线' : '断线'}<//>
</div>
<p class="t-lo" style="font-size:.14rem">左右滑动 / 拖动 / ← → / 点击圆点切换主题;发送后按钮冷却 1 秒;气泡 3 秒后淡出,新表情替换旧表情。</p>
<div class="emo-bubbles" key=${replay}>
${EMOTE_THEMES.map((t) => html`<figure key=${t.themeId}><${EmoteBubble} id=${t.emotes[0].id} ttl=${600000} /><figcaption>${t.dir}</figcaption></figure>`)}
<figure><${EmoteBubble} id="missing_art" ttl=${600000} /><figcaption>fallback</figcaption></figure>
</div>
<div class="row"><${Button} size="sm" icon="refresh" onClick=${() => setReplay((r) => r + 1)}>重播气泡<//></div>
</div>
</div>
<div class="emo-cat" style="margin-top:.3rem">
${EMOTE_THEMES.map((t, ti) => [
html`<h3 key=${`h${t.themeId}`}>${ti + 1}. ${t.name} <span class="t-lo">(${t.themeId})</span></h3>`,
...t.emotes.map((e) => html`<figure key=${e.id}><div class="cell"><${EmoteArt} id=${e.id} /></div><figcaption>${e.sortId} · ${e.picId}</figcaption></figure>`),
])}
</div>`;
}
function Kit() {
useData('assets', 'bonds');
const assets = data.get('assets');
const [tab, setTab] = useState('shop');
const [modal, setModal] = useState(false);
const [banner, setBanner] = useState(0);
const [name, setName] = useState('凯尔希');
const [code, setCode] = useState('');
const [deadline] = useState(() => Date.now() + 25000);
const [progress, setProgress] = useState(62);
const bond = (id) => ({ id, name: getBond(id)?.name || id.replace('Ship', ''), icon: assets?.bonds?.[id] || null });
return html`<div class="kit">
<header class="kit-head">
<div>
<${MicroLabel} tone="mint">STRONGHOLD PROTOCOL // DESIGN SYSTEM<//>
<h1>UI Kit · 卫戍协议<em>:盟约</em></h1>
</div>
<div class="row" style="margin:0">
<${PingPill} ms=${38} /><${PingPill} ms=${120} /><${PingPill} ms=${260} /><${PingPill} online=${false} />
</div>
</header>
<${Sec} n="01" title="色板" micro="PALETTE">
<div class="row">${SWATCHES.map(([n, c]) => html`<div class="swatch" key=${n}><i style=${`background:${c}`}></i><b>${n}</b>${c}</div>`)}</div>
<//>
<${Sec} n="02" title="字体" micro="TYPOGRAPHY">
<div class="grid2">
<div class="type-demo">
<span style="font-size:.56rem;font-weight:900">卫戍协议:盟约</span>
<span style="font-size:.26rem;font-weight:700">休整期 · 调度中心 · 联防阶段</span>
<span style="font-size:.18rem">正文 Noto Sans SC — 调配资金与干员,抵御多波次进攻。</span>
<span class="t-lo" style="font-size:.15rem">次要文本 text-lo · 说明与提示</span>
</div>
<div class="type-demo">
<span class="display" style="font-size:.34rem">STRONGHOLD PROTOCOL</span>
<span class="num" style="font-size:.5rem;font-weight:700">0123456789 <span class="t-gold">+12</span></span>
<${MicroLabel} tone="mint">RHODES ISLAND // SIMULATION SERVICE<//>
<${MicroLabel}>COUNTDOWN · LEVEL · STAGE 04<//>
</div>
</div>
<//>
<${Sec} n="03" title="按钮" micro="BUTTON">
${['primary', 'secondary', 'amber', 'ice', 'danger', 'ghost'].map((v) => html`<div class="row" key=${v}>
<span class="cap">${v}</span>
<${Button} variant=${v} size="sm">小按钮<//>
<${Button} variant=${v} icon="check">准备就绪<//>
<${Button} variant=${v} size="lg" iconRight="chevrons">开始模拟<//>
<${Button} variant=${v} square=${true} icon=${v === 'danger' ? 'exit' : v === 'ice' ? 'snow' : v === 'amber' ? 'refresh' : 'plus'} aria-label="icon" />
<${Button} variant=${v} disabled=${true}>禁用<//>
<${Button} variant=${v} loading=${true}>加载中<//>
</div>`)}
<div class="row"><span class="cap">xl / active / block</span>
<${Button} variant="primary" size="xl" iconRight="chevrons">开始<//>
<${Button} variant="secondary" active=${true} icon="check">已选定<//>
<div style="width:3rem"><${Button} variant="amber" block=${true} icon="users">加入同盟<//></div>
</div>
<//>
<${Sec} n="04" title="面板" micro="PANEL · BRACKET FRAME">
<div class="grid3">
<${Panel} title="调度中心" micro="SHOP · LEVEL 3" actions=${html`<${HexBadge} value=${5} size="sm" />`}>
<p class="t-lo">带括号角框的面板,标题栏含微型技术标签与操作区。</p>
<//>
<${Panel} title="联防阶段" micro="UNITE" tone="orange"><p class="t-lo">tone = orange</p><//>
<${Panel} title="模拟结束" micro="RESULT" tone="gold" glow=${true}><p class="t-lo">tone = gold, glow</p><//>
</div>
<//>
<${Sec} n="05" title="徽记" micro="HEX BADGE · TIER CHIP · DIFFICULTY">
<div class="row"><span class="cap">hex badge</span>
<${HexBadge} value=${2} /><${HexBadge} value=${4} tone="discount" /><${HexBadge} value=${5} tone="premium" />
<${HexBadge} value=${0} tone="mint" /><${HexBadge} value=${1} tone="amber" icon="refresh" /><${HexBadge} value=${'VI'} tone="dark" />
<${HexBadge} value=${13} size="lg" icon="rook" tone="gold" /><${HexBadge} value=${3} size="sm" tone="ice" />
</div>
<div class="row"><span class="cap">tier chip</span>
${[1, 2, 3, 4, 5, 6].map((t) => html`<${TierChip} key=${t} tier=${t} />`)}
${[1, 3, 6].map((t) => html`<${TierChip} key=${`g${t}`} tier=${t} golden=${true} />`)}
<${TierChip} tier=${2} size="sm" /><${TierChip} tier=${5} size="lg" /><${TierChip} tier=${6} size="lg" golden=${true} />
</div>
<div class="row"><span class="cap">difficulty</span>
${DIFFICULTIES.map((d) => html`<${DifficultyTag} key=${d} difficulty=${d} code=${`AC-${DIFFICULTIES.indexOf(d) + 1}`} />`)}
<${DifficultyTag} difficulty="ABYSS" size="lg" />
</div>
<//>
<${Sec} n="06" title="盟约" micro="BOND DISC">
<div class="row">
${BONDS.map((id, i) => {
const b = bond(id);
return html`<${BondDisc} key=${id} name=${b.name} icon=${b.icon} layers=${[358, 155, 144, 34, 14, 10, 3, 0][i]}
tier=${[3, 2, 2, 1, 1, 1, 0, 0][i]} maxTier=${3} active=${i < 6} disabled=${i === 7} onClick=${() => toast(`${b.name}:查看盟约详情`)} />`;
})}
</div>
<div class="row"><span class="cap">sizes · layers disabled · glyph fallback</span>
<${BondDisc} name="炎" size="sm" layers=${4} tier=${1} active=${true} />
<${BondDisc} name="萨尔贡" size="lg" layers=${12} tier=${2} maxTier=${4} active=${true} icon=${bond('sargonShip').icon} />
<${BondDisc} name="助力" layers=${34} tier=${1} active=${true} layersDisabled=${true} />
<${BondDisc} name="谢拉格" layers=${0} maxTier=${2} />
</div>
<//>
<${Sec} n="07" title="倒计时" micro="SEVEN SEGMENT · COUNTDOWN">
<div class="row row--top">
<${Countdown} seconds=${59} total=${65} />
<${Countdown} seconds=${8} total=${65} />
<${Countdown} deadline=${deadline} total=${25} size="lg" />
<${Countdown} seconds=${195} total=${195} size="sm" />
<${Countdown} />
</div>
<div class="row">
<${SevenSeg} text="12:45" size=".5rem" /><${SevenSeg} text="-99" tone="orange" size=".5rem" />
<${SevenSeg} text="0123456789" tone="gold" size=".4rem" /><${SevenSeg} text="HELP" tone="ice" size=".4rem" />
<${SevenSeg} text="Err" tone="red" size=".4rem" />
</div>
<//>
<${Sec} n="08" title="横幅" micro="PHASE BANNER">
<div class="stack">
<${PhaseBanner} key=${`a${banner}`} title="休整期" micro="PHASE // PREP" sub="第 3 回合" />
<${PhaseBanner} key=${`b${banner}`} title="联防阶段" micro="PHASE // UNITE" sub="层数叠加已禁用" tone="orange" />
<${PhaseBanner} key=${`c${banner}`} title="最终攻势" micro="PHASE // FINAL ASSAULT" tone="red" />
</div>
<div class="row" style="margin-top:.2rem">
<${Button} icon="refresh" onClick=${() => setBanner((b) => b + 1)}>重播动画<//>
<${Button} variant="amber" onClick=${() => setBanner((b) => -(Math.abs(b) + 1))}>全屏横幅 (2.2s)<//>
<${Chevrons} /><${Chevrons} tone="orange" count=${5} /><${Chevrons} tone="gold" dir="left" />
</div>
${banner < 0 ? html`<${PhaseBanner} key=${`o${banner}`} mode="overlay" duration=${2200} title="机变阶段" micro="PHASE // SP DRAFT"
sub="当前轮到你决策" tone="gold" onDone=${() => setBanner(0)} />` : null}
<//>
<${Sec} n="09" title="进度 · 标签页 · 加载" micro="PROGRESS · TABS · SPINNER">
<div class="grid2">
<div class="stack">
<${ProgressBar} value=${progress} label="首领生命值" showValue=${true} tone="orange" />
<${ProgressBar} value=${7} max=${9} segments=${9} label="已部署" showValue=${true} />
<${ProgressBar} value=${35} tone="ice" size="sm" />
<${ProgressBar} value=${90} tone="gold" size="lg" label="经验" />
<div class="row"><${Button} size="sm" onClick=${() => setProgress((p) => (p + 17) % 101)}>改变进度<//></div>
</div>
<div class="stack">
<${Tabs} value=${tab} onChange=${setTab} items=${[
{ id: 'shop', label: '调度中心' }, { id: 'bonds', label: '盟约', badge: 6 }, { id: 'enemy', label: '敌方情报' },
{ id: 'locked', label: '未解锁', disabled: true },
]} />
<${Tabs} size="sm" value=${tab} onChange=${setTab} items=${[{ id: 'shop', label: '自己' }, { id: 'bonds', label: '队友' }]} />
<div class="row"><${Spinner} size="sm" /><${Spinner} label="LOADING" /><${Spinner} size="lg" tone="gold" label="SYNCING" /></div>
</div>
</div>
<//>
<${Sec} n="10" title="头像框" micro="AVATAR FRAME">
<div class="row">
<${AvatarFrame} name="凯尔希" size="lg" host=${true} self=${true} ready=${true} />
<${AvatarFrame} name="Doctor" size="lg" seat=${1} />
<${AvatarFrame} name="AI·华法琳" size="lg" seat=${2} bot=${true} />
<${AvatarFrame} name="阿米娅" size="lg" seat=${3} offline=${true} />
<${AvatarFrame} size="lg" empty=${true} />
<${AvatarFrame} name="W" size="md" seat=${2} dead=${true} />
<${AvatarFrame} name="惊蛰" size="sm" seat=${1} self=${true} />
<${AvatarFrame} name="艾雅法拉" size="xl" seat=${0} host=${true} />
</div>
<//>
<${Sec} n="11" title="输入 · 提示 · 对话框" micro="FIELD · TOOLTIP · MODAL · TOAST">
<div class="grid2">
<div class="stack">
<${TextField} label="博士代号" micro="CALLSIGN" icon="user" value=${name} onInput=${setName} maxLength=${12} />
<${TextField} size="code" icon="key" value=${code} placeholder="输入同盟密钥" onInput=${(v) => setCode(v.toUpperCase().slice(0, 4))} />
<${TextField} label="无效输入" value="!!" onInput=${() => {}} invalid=${true} hint="仅限字母与数字" />
</div>
<div class="stack">
<div class="row">
<${Tooltip} text="刷新调度中心(消耗 1 资金)"><${Button} variant="amber" icon="refresh">刷新<//><//>
<${Tooltip} text=${'冻结后,调度中心在下一回合开始时保留\n快捷键 F'} placement="bottom"><${Button} variant="ice" icon="snow">冻结<//><//>
<${Tooltip} text="只有房主可以操作"><${TierChip} tier=${4} /><//>
</div>
<div class="row">
<${Button} onClick=${() => setModal(true)}>打开模态框<//>
<${Button} variant="danger" onClick=${async () => toast((await confirmDialog({ title: '放弃模拟', text: '确定要离开吗?', danger: true, okText: '离开' })) ? '已确认' : '已取消')}>确认对话框<//>
<${Button} variant="ghost" onClick=${() => alertDialog({ title: '提示', text: '同盟人数达标,准许进入模拟。' })}>提示对话框<//>
</div>
<div class="row">
<${Button} size="sm" onClick=${() => toast('已复制同盟密钥 ABCD', 'success')}>成功<//>
<${Button} size="sm" onClick=${() => toast('新的回合即将开始')}>信息<//>
<${Button} size="sm" onClick=${() => toast('尚未连接到服务器,请稍候', 'warn')}>警告<//>
<${Button} size="sm" onClick=${() => toast('资金不足', 'error')}>错误<//>
</div>
</div>
</div>
<${Modal} open=${modal} title="装备替换" micro="EQUIPMENT" onClose=${() => setModal(false)}
actions=${html`<${Button} onClick=${() => setModal(false)}>取消<//><${Button} variant="primary" icon="check" onClick=${() => setModal(false)}>替换<//>`}>
<p class="modal__text">该干员已装备 2 件装备。继续配发将<b class="t-orange">销毁</b>较早的装备,是否继续?</p>
<//>
<//>
<${Sec} n="12" title="图标" micro="ICONS">
<div class="row">${Object.keys(ICONS).map((n) => html`<${Tooltip} key=${n} text=${n}><span style="font-size:.3rem;color:var(--text-md)"><${Icon} name=${n} /></span><//>`)}</div>
<//>
<${Sec} n="13" title="表情" micro="EMOTES · 6 THEMES × 6 · PICTURE ONLY">
<${EmoteDemo} />
<//>
</div>
<${ToastHost} />
<${UiHosts} />`;
}
render(html`<${Kit} />`, document.getElementById('kit'));
</script>
</body>
</html>