Files
ark-stronghold-protocol/public/js/screens/title.js
T
herryyun 7c25c89992
CI / test (ubuntu-latest, node 22) (push) Failing after 33m10s
CI / test (ubuntu-latest, node 24) (push) Failing after 34m41s
CI / tooling (lint, imports, typecheck) (push) Failing after 36m11s
CI / test (windows-latest, node 22) (push) Canceled after 0s
CI / test (windows-latest, node 24) (push) Canceled after 0s
实现账号绑定、持久登录与跨设备对局恢复
2026-10-14 23:22:50 +08:00

309 lines
15 KiB
JavaScript
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.
// Title screen: season-style backdrop, big title 卫戍协议:盟约, bound role callsign, 开始, the language menu
// (中文 | English | every pack in public/i18n/, ui/lang.js; a title in an alphabetic script — English — is the big one and
// the small wordmark above it hides).
//
// Binding reads the role nickname; pressing 开始 validates it (1..NAME_MAX_LEN chars, no control characters), stores it,
// marks this tab as "entered" (so reloads skip the title) and hands the name to net.js, which
// sends `hello` (now, or as soon as the socket is open). The router then shows the lobby.
//
// Backdrop art: if data/assets.json lists a UI backdrop (`ui.titleBackdrop`, or one of the
// entry/loading illustration names) it is layered under the CSS art; otherwise the screen is
// pure CSS/SVG (radar, ridgelines, glow), so it never issues a request that can 404.
import { AccountResumeMatchButton } from '../skland/resume.js';
import { AccountBindingEntry, BoundCallsign } from '../skland/entry.js';
import { enterBoundAccount, restoreBoundEntry } from '../skland/gate.js';
import { useMemo, useState } from '../../vendor/hooks.module.js';
import { NAME_MAX_LEN, APP_VERSION, DEV_BUILD } from '../../../shared/constants.js';
import { html, Button, Icon, MicroLabel, PingPill } from '../ui/components.js';
import { GuideButton } from '../ui/guide.js';
import { openStats } from './stats.js';
import { net, identity } from '../net.js';
import { store, useStore, shallowEqual } from '../store.js';
import { data, useData } from '../data.js';
import { FullscreenButton, PwaInstallButton } from '../ui/device.js';
import { LangToggle, useLang } from '../ui/lang.js';
import { t, N_ } from '../../../shared/i18n.js';
import { scriptOf } from '../../../shared/i18nPacks.js';
import { GIcon } from '../ui/gameComponents.js';
import { SettingsModal } from '../ui/settings.js';
// Same character classes as server/net.js sanitizeName (control, zero-width, bidi, BOM), so a name
// the client accepts is never rejected by the server's hello validation.
const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u00ad\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff]/g;
// Lone surrogates are removed by a scan, not a regex: the lookbehind such a regex needs is a *syntax error* in Safari
// < 16.4, which would stop the whole client from loading there.
export function stripLoneSurrogates(str) {
let out = '';
for (let i = 0; i < str.length; i++) {
const c = str.charCodeAt(i);
if (c >= 0xd800 && c <= 0xdbff) {
const n = i + 1 < str.length ? str.charCodeAt(i + 1) : 0;
if (n >= 0xdc00 && n <= 0xdfff) { out += str[i] + str[i + 1]; i++; }
continue;
}
if (c >= 0xdc00 && c <= 0xdfff) continue;
out += str[i];
}
return out;
}
/**
* Normalise a nickname like the server does (NFC, strip lone surrogates / control / invisible /
* bidi characters, collapse whitespace, trim), then clamp to NAME_MAX_LEN UTF-16 code units — the
* protocol's `hello.name` limit — without splitting a surrogate pair.
* @param {any} raw
* @returns {string}
*/
export function sanitizeName(raw) {
let s = String(raw ?? '');
try { s = s.normalize('NFC'); } catch { /* keep as is */ }
s = stripLoneSurrogates(s).replace(/\s+/g, ' ').replace(CONTROL_CHARS, '').replace(/ {2,}/g, ' ').trim();
if (s.length > NAME_MAX_LEN) {
s = s.slice(0, NAME_MAX_LEN);
// Don't leave half a surrogate pair at the end.
if (/[\ud800-\udbff]$/.test(s)) s = s.slice(0, -1);
s = s.trim();
}
return s;
}
/** @param {any} raw @returns {boolean} */
export const isValidName = (raw) => sanitizeName(raw).length > 0;
/**
* Enter the game shell with a nickname (title → lobby).
* @param {string} rawName
* @returns {boolean} false when the name is invalid
*/
export function enterSession(rawName) {
const name = sanitizeName(rawName);
if (!name) return false;
identity.saveName(name);
identity.setEntered(true);
store.set((s) => ({ me: { ...s.me, name }, session: { ...s.session, entered: true } }));
net.setName(name);
return true;
}
// data/assets.json `ui` keys are 'group/key' (docs/ASSETS.md).
const BACKDROP_KEYS = ['titleBackdrop', 'entry/bkg_01', 'entry/bkg_02'];
const RIDGE_KEYS = ['titleRidges', 'entry/bg_mountains_tiled'];
/**
* Find a UI image URL in data/assets.json (tolerant of a few plausible shapes).
* @param {any} assets
* @param {string[]} names
* @returns {string|null}
*/
export function findUiAsset(assets, names) {
if (!assets || typeof assets !== 'object') return null;
const asUrl = (v) => {
if (typeof v === 'string') return v;
if (v && typeof v === 'object') return v.url || v.path || v.src || null;
return null;
};
const ui = assets.ui;
if (ui && typeof ui === 'object' && !Array.isArray(ui)) {
for (const n of names) {
const u = asUrl(ui[n]);
if (u) return u;
}
}
const lists = [Array.isArray(ui) ? ui : null, Array.isArray(assets.files) ? assets.files : null].filter(Boolean);
for (const list of lists) {
for (const n of names) {
const hit = list.map(asUrl).find((u) => typeof u === 'string' && u.includes('/ui/') && u.toLowerCase().split('/').pop().startsWith(n.toLowerCase()));
if (hit) return hit;
}
}
return null;
}
// Dot-matrix watchtower emblem (13×14 bitmap; dots grow toward the base for depth).
const EMBLEM = [
'XXX..XXX..XXX',
'XXX..XXX..XXX',
'XXXXXXXXXXXXX',
'.XXXXXXXXXXX.',
'..XXXXXXXXX..',
'..XXXXXXXXX..',
'..XXXX.XXXX..',
'..XXXX.XXXX..',
'..XXXXXXXXX..',
'..XXXXXXXXX..',
'..XXXXXXXXX..',
'.XXXXXXXXXXX.',
'XXXXXXXXXXXXX',
'XXXXXXXXXXXXX',
];
function Emblem() {
const dots = useMemo(() => {
const out = [];
EMBLEM.forEach((row, r) => {
[...row].forEach((ch, c) => {
if (ch !== 'X') return;
const rad = 0.2 + (r / (EMBLEM.length - 1)) * 0.2;
const accent = (r === 6 || r === 7) && (c === 5 || c === 7);
out.push({ cx: c + 0.5, cy: r + 0.5, r: rad, accent, d: (r * 13 + c) % 7 });
});
});
return out;
}, []);
return html`<div class="emblem" aria-hidden="true">
<span class="emblem__bracket emblem__bracket--l"></span>
<svg class="emblem__svg" viewBox="-0.5 -0.5 14 15">
${dots.map((d, i) => html`<circle key=${i} cx=${d.cx} cy=${d.cy} r=${d.r} class=${d.accent ? 'is-accent' : `d${d.d}`} />`)}
</svg>
<span class="emblem__bracket emblem__bracket--r"></span>
</div>`;
}
function Ridges() {
return html`<svg class="title-bg__ridges" viewBox="0 0 1920 420" preserveAspectRatio="none" aria-hidden="true">
<defs>
<linearGradient id="ridge-far" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#16231f" /><stop offset="1" stop-color="#0a0e0d" />
</linearGradient>
<linearGradient id="ridge-near" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#0f1714" /><stop offset=".6" stop-color="#080b0a" />
</linearGradient>
<linearGradient id="ridge-edge" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#17f9b7" stop-opacity="0" />
<stop offset=".3" stop-color="#17f9b7" stop-opacity=".55" />
<stop offset=".7" stop-color="#17f9b7" stop-opacity=".55" />
<stop offset="1" stop-color="#17f9b7" stop-opacity="0" />
</linearGradient>
</defs>
<path class="ridge ridge--far" fill="url(#ridge-far)" stroke="url(#ridge-edge)"
d="M0 420V250l120-40 90 30 90-70 60 20 80-70 80 55 80-25 90 70 90-20 80 50 100-15 90 25 90-55 90-55 70-55 70 45 80-20 90 65 80-20 100 55 100-20 100 40v180z" />
<path class="ridge ridge--near" fill="url(#ridge-near)" stroke="url(#ridge-edge)"
d="M0 420V322l160-32 100 20 120-50 90 40 130-20 120 50 140-30 140 35 120-35 140 20 140-50 120 30 120-20 120 40 160-20v147z" />
</svg>`;
}
const STATUS_TEXT = {
idle: N_('准备连接'), connecting: N_('正在连接服务器'), connected: N_('已连接服务器'), handshaking: N_('正在验证身份'),
online: N_('已连接服务器'), reconnecting: N_('连接中断,正在重连'), closed: N_('连接已关闭'),
};
/** Title screen component. */
export function TitleScreen() {
const conn = useStore((s) => s.connection, shallowEqual);
const pendingJoin = useStore((s) => s.ui.pendingJoin);
useLang(); // re-render on a language switch
const [name, setName] = useState('');
const [bindingOpen, setBindingOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [entering, setEntering] = useState(false);
const assetsSettled = useData('assets');
const assets = data.get('assets');
const backdrop = findUiAsset(assets, BACKDROP_KEYS);
const ridges = findUiAsset(assets, RIDGE_KEYS);
// Track load/fail per URL (not as booleans reset in effects: an image can load before an effect runs).
const [bgLoadedUrl, setBgLoadedUrl] = useState(null);
const [ridgesLoadedUrl, setRidgesLoadedUrl] = useState(null);
const [ridgesFailedUrl, setRidgesFailedUrl] = useState(null);
const bgLoaded = !!backdrop && bgLoadedUrl === backdrop;
const ridgesLoaded = !!ridges && ridgesLoadedUrl === ridges;
const ridgesFailed = !!ridges && ridgesFailedUrl === ridges;
// CSS ridgelines only when there is no ridge art (avoids a swap flash when the art arrives).
const cssRidges = assetsSettled && (!ridges || ridgesFailed);
const valid = isValidName(name);
const start = async () => {
if (entering) return;
if (!valid) { setBindingOpen(true); return; }
setEntering(true);
if (!await restoreBoundEntry(sanitizeName)) { setName(''); setBindingOpen(true); }
setEntering(false);
};
const online = conn.status === 'online' || conn.status === 'connected';
const dotClass = online ? 'is-on' : conn.status === 'reconnecting' || conn.status === 'connecting' || conn.status === 'handshaking' ? 'is-warn' : 'is-bad';
// a title in an alphabetic script (English, French …) is the big one in the display face and the wordmark above it
// hides; a CJK / kana / Hangul title keeps the Chinese layout (shared/i18nPacks.js scriptOf — a pack needs no flag)
const alphabetic = scriptOf(t('卫戍协议')) === 'alphabetic';
return html`<div class="screen title-screen">
<div class=${`title-bg${bgLoaded ? ' has-art' : ''}${ridgesLoaded ? ' has-ridges' : ''}`} aria-hidden="true">
${backdrop ? html`<img class="title-bg__art" src=${backdrop} alt="" draggable=${false}
onLoad=${() => setBgLoadedUrl(backdrop)} />` : null}
<div class="title-bg__glow"></div>
<div class="title-bg__radar"><div class="title-bg__sweep"></div></div>
<div class="title-bg__target"></div>
${cssRidges ? html`<${Ridges} />` : null}
${ridges && !ridgesFailed ? html`<div class="title-bg__ridge-art" style=${`background-image:url("${ridges}")`}>
<img src=${ridges} alt="" hidden onLoad=${() => setRidgesLoadedUrl(ridges)} onError=${() => setRidgesFailedUrl(ridges)} />
</div>` : null}
<div class="title-bg__haze"></div>
<span class="cross" style="left:7%;top:22%"></span>
<span class="cross" style="left:93%;top:30%"></span>
<span class="cross" style="left:14%;top:70%"></span>
<span class="cross" style="left:88%;top:62%"></span>
<span class="cross" style="left:60%;top:12%"></span>
</div>
<div class="title-corner title-corner--tl">
<span class="title-corner__mark"></span>
<div><${MicroLabel} tone="mint">RHODES ISLAND // SIMULATION SERVICE<//><br /><${MicroLabel}>TACTICAL CO-OP NODE · 02<//></div>
</div>
<div class="title-corner title-corner--tr">
<div>
<div class="title-corner__tools">
<${Button} variant="ghost" size="sm" icon="chart" class="title-stats" onClick=${openStats} title=${t('统计数据')}>${t('统计')}<//>
<${LangToggle} class="title-lang" />
</div>
<${MicroLabel} tone="hi">TARGET POINT<//><br /><${MicroLabel}>STRONGHOLD PROTOCOL<//>
</div>
</div>
<main class="title-main">
<${Emblem} />
${alphabetic ? null : html`<div class="title-en">
<span class="title-en__a">STRONGHOLD PROTOCOL</span>
<span class="title-en__b">ALLIANCE</span>
</div>`}
<h1 class=${`title-cn${alphabetic ? ' title-cn--latin' : ''}`}>${t('卫戍协议')}<span class="title-cn__colon">${alphabetic ? ': ' : ':'}</span><em>${t('盟约')}</em></h1>
<p class="title-tag">${t('调配资金与干员,与同伴协同布防,抵御多波次进攻,直至击败敌方领袖。')}</p>
<div class="title-login">
${pendingJoin ? html`<div class="title-invite">
<${Icon} name="key" />
<span>${t('收到同盟邀请')}</span><b class="num">${pendingJoin}</b><span class="t-lo">${t('· 绑定账号并开始后将自动加入')}</span>
</div>` : null}
<${BoundCallsign} name=${name} />
<${Button} variant="primary" size="xl" block=${true} iconRight="chevrons" data-testid="binding-start" disabled=${entering} onClick=${start}>${valid ? t('开始') : t('绑定账号')}<//>
<div class="title-conn">
<span class=${`status-dot ${dotClass}`}></span>
<span>${STATUS_TEXT[conn.status] ? t(STATUS_TEXT[conn.status]) : conn.status}</span>
${conn.status === 'online' ? html`<${PingPill} ms=${conn.ping} />` : null}
<${GuideButton} class="title-guide" label=${t('玩法说明')} />
<button type="button" class="title-settings fsbtn tapx" aria-label=${t('设置')} title=${t('设置')}
onClick=${() => setSettingsOpen(true)}><${GIcon} name="gear" /></button>
<${FullscreenButton} class="title-fs" />
</div>
</div>
<${AccountBindingEntry} name=${name} open=${bindingOpen} onOpen=${() => setBindingOpen(true)} onClose=${() => setBindingOpen(false)}
onNickname=${(nickname) => {
const value = sanitizeName(nickname); setName(value); identity.saveName(value);
if (!value) identity.setEntered(false);
}} />
</main>
<${SettingsModal} open=${settingsOpen} onClose=${() => setSettingsOpen(false)} />
<footer class="title-foot">
<${AccountResumeMatchButton} bound=${valid} onResume=${(id) => enterBoundAccount(sanitizeName, { recoveryId: id })} />
<${PwaInstallButton} />
<span>${t('非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有')}</span>
<span class="title-foot__meta">
<a class="title-repo" href="https://github.com/sganggs/Stronghold-Protocol" target="_blank" rel="noopener noreferrer">${t('项目仓库')}</a>
<${MicroLabel}>v${APP_VERSION} · WEB SIMULATION<//>
${DEV_BUILD ? html`<span class="title-dev" role="note">${t('开发版 · 不稳定,请勿用于公开服务器')}</span>` : null}
</span>
</footer>
</div>`;
}