卫戍协议:盟约
调配资金与干员,与同伴协同布防,抵御多波次进攻,直至击败敌方领袖。
// Title screen: season-style backdrop, big title 卫戍协议:盟约, remembered nickname, 开始. // // Pressing 开始 validates the nickname (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 { useMemo, useState } from '../../vendor/hooks.module.js'; import { NAME_MAX_LEN, APP_VERSION } from '../../../shared/constants.js'; import { html, Button, Icon, MicroLabel, TextField, PingPill } from '../ui/components.js'; import { GuideButton } from '../ui/guide.js'; import { toast } from '../ui/toasts.js'; import { net, identity } from '../net.js'; import { store, useStore, shallowEqual } from '../store.js'; import { data, useData } from '../data.js'; import { FullscreenButton, detectFeatures } from '../ui/device.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`
`; } function Ridges() { return html``; } const STATUS_TEXT = { idle: '准备连接', connecting: '正在连接服务器', connected: '已连接服务器', handshaking: '正在验证身份', online: '已连接服务器', reconnecting: '连接中断,正在重连', closed: '连接已关闭', }; /** Title screen component. */ export function TitleScreen() { const conn = useStore((s) => s.connection, shallowEqual); const pendingJoin = useStore((s) => s.ui.pendingJoin); const [name, setName] = useState(() => store.get().me.name || identity.loadName() || ''); 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 = () => { if (!valid) { toast('请输入博士代号', 'warn'); return; } enterSession(name); }; 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'; // touch screens: no autofocus (it would pop the on-screen keyboard over a landscape phone's whole view) const touchUi = useMemo(() => detectFeatures().coarse, []); return html`调配资金与干员,与同伴协同布防,抵御多波次进攻,直至击败敌方领袖。