Ported by hand: - public/js/ui/gameLogic.js is a re-export shim on feedback5: #185's TERRAIN_TIPS / terrainInfo went into the new public/js/ui/gameLogic/terrain.js (the barrel gains one re-export line); closesOnFieldPress 'terrain' into ui/gameLogic/shortcuts.js. - i18n (docs/I18N.md): every new user-visible string goes through t() with an English entry in public/i18n/en.json (42, after the 补位 strings): the terrain names / tags / fact table are N_() and translated where terrainInfo hands them out, the mechanism lines are msgids with their numbers as params (a line with an optional half is two whole msgids, the list joins use an array param), the card's 地形机制 / 这一格 titles in ui/detailPanel.js. Official EN terms where the EN client has them (stage_table tileInfo: Active Originium, Mire, Exhaust Grille, Deep Water Zone, Incursion Point, Protection Objective, Tunnel Entry / Exit, High Ground; Camouflage). The Chinese output is byte-identical to feedback4's terrainInfo on all 5187 tiles of the 13 stages and on synthetic stages. - public/js/screens/game.js (MatchScreen still lives there): the import keeps feedback5's names plus terrainInfo / fieldTile; the terrain stage is the one view.setStage gets, so it includes feedback5's fieldStage — a 联防 field is played on its own map (GitHub #41), and a tap there explains that map's tile, not the round stage's. The tileClick handler and render/app.js emitTileClick / ui/fallbackField.js auto-merged in place. terrainInfo reads the stage only, so a 补位 / 自选 board explains its tiles the same way (their pieces only change a piece's art and name). - public/js/ui/detailPanel.js: the terrain branch of resolveDetail before feedback5's stand-in / 自选 helpers. - public/js/screens/title.js (#183): feedback5's LangToggle / t() imports plus GIcon / SettingsModal; the gear button's aria-label / title are t('设置') (en.json already has "Settings"). - tools/fetch-assets.mjs / tools/assets/downloader.mjs: --add-only (keepExisting, the font skip) beside #24's --asset-source / SP_ASSET_SOURCE and the shared MirrorPolicy; feedback5's extraOperators / moduleTypes and the voice plan unchanged. - data/assets.json: feedback5's manifest plus #134's audio.sfx.battle.leak (after heal, the plan order); stats files 6993 → 6994 and bytes +23 240 as measured on feedback4; the hash recomputed with the generator's contentHash (c01aa739a2d3 → 6a547fc5c937). A field-wise JSON diff against feedback5 shows only those four fields. - docs: DESIGN §24 intro and §24.5 / §24.6 as on feedback4 (the rest of DESIGN.md is feedback5's); ASSETS.md, DEPLOY.md, PLAYING.md auto-merged. - Auto-merged and checked: audio.js (the sim still emits ['leak', id], server/sim/battle/deploy.js), render/style.js, textures.js, tiles.js, board3d/materials.js, boardArt.js, tools/assets/*, tools/setup.mjs, extract.py, the tests. Checks: the PRs' unit tests (audio 22, gameLogic 44, terrain-detail 1, originium-art 4, boardart 15, board3d-extract 9, asset-network 21, fetch-assets-shrink 4), i18n 9 / i18n-data 8 / i18n-server 4, client-static 313, docs-consistency 24, kits_layout 6 pass; lint 0 errors (no new warnings), check:imports, typecheck; golden: all 283 scenarios match, nothing regenerated.
286 lines
13 KiB
JavaScript
286 lines
13 KiB
JavaScript
// Title screen: season-style backdrop, big title 卫戍协议:盟约, remembered nickname, 开始, the language switch
|
||
// (中文 | English, ui/lang.js; in English the big title is the English one and the small wordmark above it hides).
|
||
//
|
||
// 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';
|
||
import { LangToggle, useLang } from '../ui/lang.js';
|
||
import { t, N_ } from '../../../shared/i18n.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);
|
||
const lang = useLang();
|
||
const [name, setName] = useState(() => store.get().me.name || identity.loadName() || '');
|
||
const [settingsOpen, setSettingsOpen] = 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 = () => {
|
||
if (!valid) { toast(t('请输入博士代号'), '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`<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>
|
||
<${LangToggle} class="title-lang" />
|
||
<${MicroLabel} tone="hi">TARGET POINT<//><br /><${MicroLabel}>STRONGHOLD PROTOCOL<//>
|
||
</div>
|
||
</div>
|
||
|
||
<main class="title-main">
|
||
<${Emblem} />
|
||
${lang === 'en' ? 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${lang === 'en' ? ' title-cn--latin' : ''}`}>${t('卫戍协议')}<span class="title-cn__colon">${lang === 'en' ? ': ' : ':'}</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}
|
||
<${TextField} label=${t('博士代号')} micro="CALLSIGN" size="lg" icon="user" value=${name} maxLength=${NAME_MAX_LEN}
|
||
placeholder=${t('输入你的代号(最多 {NAME_MAX_LEN} 字)', { NAME_MAX_LEN })} autoFocus=${!touchUi}
|
||
onInput=${setName} onEnter=${start} />
|
||
<${Button} variant="primary" size="xl" block=${true} iconRight="chevrons" disabled=${!valid} onClick=${start}>${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>
|
||
</main>
|
||
|
||
<${SettingsModal} open=${settingsOpen} onClose=${() => setSettingsOpen(false)} />
|
||
|
||
<footer class="title-foot">
|
||
<span>${t('非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有')}</span>
|
||
<${MicroLabel}>v${APP_VERSION} · WEB SIMULATION<//>
|
||
</footer>
|
||
</div>`;
|
||
}
|