From 4b57f8120c17878487b17a59c1ef3d175b83e9e4 Mon Sep 17 00:00:00 2001 From: sganggs Date: Mon, 5 Oct 2026 19:34:52 +0800 Subject: [PATCH] =?UTF-8?q?feedback5=20WG:=20=E8=A1=A5=E4=BD=8D=20in=20the?= =?UTF-8?q?=20browser=20=E2=80=94=20the=20=E5=B9=B2=E5=91=98=E6=8C=81?= =?UTF-8?q?=E6=9C=89=20tab=20next=20to=20=E5=B9=B2=E5=91=98=E8=B0=83?= =?UTF-8?q?=E9=85=8D=20(the=2053=20NORMAL=20chess=20by=20tier,=20operator?= =?UTF-8?q?=20=E2=86=92=20stand-in,=20a=20switch=20each,=20=E5=85=A8?= =?UTF-8?q?=E9=83=A8=E6=8C=81=E6=9C=89,=20export=20/=20import,=20localStor?= =?UTF-8?q?age=20+=20room.ownership=20through=20the=20shared=20sync=20engi?= =?UTF-8?q?ne),=20=E3=80=8C=E6=9B=BF=E8=A1=A5=EF=BC=9AX=E3=80=8D=20on=20sh?= =?UTF-8?q?op=20/=20reward=20cards=20and=20own=20prep=20pieces,=20the=20st?= =?UTF-8?q?and-in's=20model=20on=20the=20board,=20its=20body=20on=20the=20?= =?UTF-8?q?detail=20card=20(own=20pieces,=20cards,=20any=20unit=20with=20s?= =?UTF-8?q?tandInFor),=20placement=20and=20ranges=20by=20the=20stand-in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/css/screens/game-panels.css | 8 + public/css/screens/game-shop.css | 3 + public/css/screens/loadout.css | 61 ++++++ public/js/data.js | 2 + public/js/main.js | 6 +- public/js/render/app.js | 20 +- public/js/render/app/host.js | 17 ++ public/js/render/app/info.js | 3 + public/js/render/fx/numbers.js | 2 +- public/js/screens/game.js | 15 +- public/js/screens/game/standInTags.js | 71 +++++++ public/js/screens/loadout.js | 120 +++++++++--- public/js/screens/ownership.js | 78 ++++++++ public/js/ui/detailPanel.js | 59 ++++-- public/js/ui/gameComponents.js | 7 +- public/js/ui/gameLogic.js | 1 + public/js/ui/gameLogic/placement.js | 16 +- public/js/ui/gameLogic/standIn.js | 110 +++++++++++ public/js/ui/loadoutSync.js | 150 ++++++++++----- public/js/ui/ownershipModel.js | 168 ++++++++++++++++ public/js/ui/shopBar.js | 19 +- test/ui/standin-ui.test.js | 265 ++++++++++++++++++++++++++ test/ui/standin.e2e.test.js | 152 +++++++++++++++ 23 files changed, 1227 insertions(+), 126 deletions(-) create mode 100644 public/js/screens/game/standInTags.js create mode 100644 public/js/screens/ownership.js create mode 100644 public/js/ui/gameLogic/standIn.js create mode 100644 public/js/ui/ownershipModel.js create mode 100644 test/ui/standin-ui.test.js create mode 100644 test/ui/standin.e2e.test.js diff --git a/public/css/screens/game-panels.css b/public/css/screens/game-panels.css index a3199e84..a7c3fd65 100644 --- a/public/css/screens/game-panels.css +++ b/public/css/screens/game-panels.css @@ -90,6 +90,8 @@ .dtag-elite, .dtag-token, .dtag-kind { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; letter-spacing: .08em; } .dtag-elite { background: var(--gold); color: #1d1400; } .dtag-token { background: #a9c3d8; color: #0b1520; } +/* 0.2.0 补位: the card of a chess fielded as its stand-in (ui/detailPanel.js ChessDetail) */ +.dtag-standin { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; letter-spacing: .04em; background: var(--ice); color: var(--ice-900); } .dtag-kind { border: 1px solid var(--line-2); color: var(--text-lo); font-weight: 500; } .drank { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; } .drank--normal { background: var(--bg-4); color: var(--text-md); } @@ -551,6 +553,12 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; } .tempnote__rule { font-size: .12rem; line-height: 1.45; color: rgba(255, 220, 214, .88); } @media (prefers-reduced-motion: reduce) { .tempnote__frame polygon, .tempnote__label { animation: none; } } .sp-reduced-motion .tempnote__frame polygon, .sp-reduced-motion .tempnote__label { animation: none; } +/* 0.2.0 补位: 「替补:X」 under the player's own prep pieces of a chess they do not own (screens/game/standInTags.js) */ +.sitags { position: fixed; inset: 0; z-index: 2; pointer-events: none; } +.sitag { position: absolute; transform: translate(-50%, .02rem); pointer-events: none; white-space: nowrap; font-size: max(.12rem, 9px); font-weight: 700; + line-height: 1.35; padding: 0 .05rem; letter-spacing: .03em; background: var(--ice); color: var(--ice-900); box-shadow: 0 1px 3px rgba(0, 0, 0, .7); opacity: .92; } +.sitag--board { background: #c2e3ff; } +.gm.is-dragging .sitags { display: none; } /* ---- underframe: the selection diamond of a tapped own piece (ui/underframe.js): 撤退 / 出售 +N / 销毁 ---- It sits above every HUD panel (detail card z 9, bond popup, drawer, shop bar): only its buttons take pointer events, diff --git a/public/css/screens/game-shop.css b/public/css/screens/game-shop.css index b064250b..6fa003fe 100644 --- a/public/css/screens/game-shop.css +++ b/public/css/screens/game-shop.css @@ -130,6 +130,9 @@ .scard__class { display: flex; align-items: center; gap: .05rem; font-size: .13rem; color: var(--text-lo); } .scard__prof { width: .18rem; height: .18rem; object-fit: contain; opacity: .85; } .scard__mergetag { position: absolute; z-index: 3; right: 0; top: .38rem; font-size: .12rem; font-weight: 700; padding: .01rem .06rem; background: var(--gold); color: #1d1400; letter-spacing: .08em; } +/* 0.2.0 补位: a chess the player does not own — its card stays, the stand-in fights (ui/shopBar.js ChessCard) */ +.scard__standin { position: absolute; z-index: 3; left: 0; top: .38rem; max-width: calc(100% - .1rem); font-size: .12rem; font-weight: 700; padding: .01rem .06rem; + background: var(--ice); color: var(--ice-900); letter-spacing: .04em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; box-shadow: 0 1px 3px rgba(0, 0, 0, .6); } .scard:not(.is-disabled):not(.scard--sold):hover { transform: translateY(-.08rem); border-color: var(--mint-400); box-shadow: 0 0 0 1px var(--mint-400), 0 .1rem .24rem rgba(23, 249, 183, .22); } .scard:not(.is-disabled):not(.scard--sold):active { transform: translateY(-.04rem); } .scard.is-disabled { cursor: not-allowed; } diff --git a/public/css/screens/loadout.css b/public/css/screens/loadout.css index dfd7f234..8f6b8861 100644 --- a/public/css/screens/loadout.css +++ b/public/css/screens/loadout.css @@ -162,6 +162,9 @@ position: absolute; right: 0; top: 0; z-index: 1; width: .22rem; height: .22rem; background: linear-gradient(225deg, var(--mint-400) 50%, transparent 50%); } +/* 0.2.0 补位: a chess marked 未持有 on the 干员持有 tab (its stand-in fights with a fixed skill) */ +.lo-card__sub { position: absolute; right: .03rem; top: .26rem; z-index: 1; padding: 0 .04rem; font-size: max(.11rem, 8px); font-weight: 700; line-height: 1.4; color: var(--ice-900); background: var(--ice); } +.lo-locknote--standin { color: var(--ice); } .lo-card__name { position: absolute; left: .05rem; right: .05rem; top: calc(100% - max(.64rem, 34px)); z-index: 1; font-size: max(.15rem, 10px); font-weight: 700; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -326,6 +329,62 @@ .lo-locknote { display: flex; align-items: center; gap: .06rem; margin: .16rem 0 0; font-size: max(.14rem, 10px); color: var(--amber); } .lo-locknote .icon { width: .16rem; height: .16rem; } +/* ---- tabs: 干员调配 / 干员持有 (0.2.0 补位) ---------------------------------------------------------------------------- */ +.lo-tabs { display: flex; align-items: stretch; gap: .04rem; } +.lo-tab { + display: inline-flex; align-items: center; gap: .08rem; padding: .02rem .16rem; font: inherit; font-size: max(.24rem, 13px); font-weight: 900; + letter-spacing: .12em; line-height: 1.2; color: var(--text-lo); background: transparent; border: 0; border-bottom: 2px solid transparent; cursor: pointer; + touch-action: manipulation; white-space: nowrap; +} +.lo-tab .lo-top__icon { width: max(.24rem, 14px); height: max(.24rem, 14px); } +.lo-tab:hover { color: var(--text-hi); } +.lo-tab.is-on { color: var(--text-hi); border-bottom-color: var(--mint-500); } +.lo-tab__n { display: inline-grid; place-items: center; min-width: 1.5em; height: 1.5em; padding: 0 .3em; font-size: .62em; font-weight: 700; letter-spacing: 0; color: var(--ice-900); background: var(--ice); border-radius: 1em; } + +/* ---- 干员持有 (screens/ownership.js): the NORMAL chess by tier, each operator → its stand-in, a 持有 switch -------------- */ +.own { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; gap: .12rem; padding: .16rem .3rem .2rem; } +.own__bar { flex: none; display: flex; align-items: center; gap: .2rem; } +.own__lead { flex: 1; margin: 0; font-size: max(.15rem, 10px); line-height: 1.5; color: var(--text-md); } +.own__lead b { color: var(--ice); font-weight: 700; } +.own__list { flex: 1 1 auto; min-height: 0; overflow: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; display: flex; flex-direction: column; gap: .18rem; padding: 0 .06rem .2rem 0; + scrollbar-width: thin; scrollbar-color: var(--line-3) transparent; } +.own-tier__head { display: flex; align-items: center; gap: .08rem; margin: 0 0 .08rem; font-size: max(.17rem, 12px); font-weight: 700; color: var(--text-md); letter-spacing: .06em; } +.own-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(max(4.4rem, 270px), 1fr)); gap: .08rem; } +.own-card { + display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto; align-items: center; gap: .08rem; min-width: 0; + padding: .07rem .1rem .07rem .07rem; font: inherit; color: var(--text-hi); text-align: left; cursor: pointer; touch-action: manipulation; + background: rgba(255, 255, 255, .03); border: 1px solid var(--line); border-left: 3px solid var(--tc, var(--line-3)); + transition: background var(--t-fast), border-color var(--t-fast); +} +.own-card--t1 { --tc: var(--tier-1); } .own-card--t2 { --tc: var(--tier-2); } .own-card--t3 { --tc: var(--tier-3); } +.own-card--t4 { --tc: var(--tier-4); } .own-card--t5 { --tc: var(--tier-5); } .own-card--t6 { --tc: var(--tier-6); } +.own-card:hover { background: rgba(255, 255, 255, .06); border-color: var(--line-3); } +.own-card__op, .own-card__sub { display: flex; align-items: center; gap: .07rem; min-width: 0; } +.own-card__ava { flex: none; display: grid; place-items: center; width: max(.46rem, 28px); height: max(.46rem, 28px); overflow: hidden; background: #0a0d0c; border: 1px solid var(--line-2); } +.own-card__ava img { width: 100%; height: 100%; object-fit: cover; } +.own-card__ava b { font-size: max(.18rem, 12px); color: var(--text-lo); } +.own-card__txt { display: flex; flex-direction: column; min-width: 0; } +.own-card__name { font-size: max(.16rem, 11px); font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.own-card__txt small { font-size: max(.12rem, 9px); color: var(--text-lo); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.own-card__arrow .icon { width: max(.18rem, 12px); height: max(.18rem, 12px); color: var(--text-dim); } +.own-card__sub { opacity: .55; } +.own-card__state { display: inline-flex; flex-direction: column; align-items: center; gap: .03rem; min-width: max(.5rem, 30px); font-size: max(.12rem, 9px); font-weight: 700; color: var(--mint-400); white-space: nowrap; } +.own-switch { position: relative; display: inline-block; width: max(.36rem, 24px); height: max(.2rem, 13px); border-radius: 1em; background: var(--mint-700); } +.own-switch > i { position: absolute; top: 2px; right: 2px; width: calc(max(.2rem, 13px) - 4px); height: calc(max(.2rem, 13px) - 4px); border-radius: 50%; background: #fff; transition: right var(--t-fast), left var(--t-fast); } +/* 未持有: the operator greys out, the stand-in (who is deployed) lights up */ +.own-card.is-off { background: rgba(159, 212, 255, .07); border-color: rgba(159, 212, 255, .35); } +.own-card.is-off .own-card__op { opacity: .5; filter: grayscale(.8); } +.own-card.is-off .own-card__sub { opacity: 1; } +.own-card.is-off .own-card__sub .own-card__ava { border-color: var(--ice); } +.own-card.is-off .own-card__state { color: var(--ice); } +.own-card.is-off .own-switch { background: var(--bg-4); } +.own-card.is-off .own-switch > i { right: auto; left: 2px; background: var(--ice); } +@media (max-width: 760px) { + .own { padding: .1rem .16rem .16rem; } + .own__lead { display: none; } + .lo-tab { padding: .02rem .08rem; letter-spacing: .04em; } +} + /* ---- entry button (lobby / room / briefing) --------------------------------------------------------------------------- */ .lo-entry { position: relative; gap: .08rem; } .lo-entry__icon { width: 1.25em; height: 1.25em; object-fit: contain; flex: none; } @@ -333,6 +392,8 @@ display: inline-grid; place-items: center; min-width: 1.5em; height: 1.5em; padding: 0 .3em; margin-left: .02rem; font-size: .8em; font-weight: 700; color: var(--text-on-mint); background: var(--mint-500); border-radius: 1em; } +/* 0.2.0 补位: operators marked 未持有 on the 干员持有 tab */ +.lo-entry__own { display: inline-flex; align-items: center; gap: .2em; height: 1.5em; padding: 0 .4em; font-size: .75em; font-weight: 700; color: var(--ice-900); background: var(--ice); border-radius: 1em; white-space: nowrap; } /* Untimed phases (user playtest #11 / #4 item 3: the official 独立模拟 shows no countdown outside combat — the server sends deadline 0 for them, Match.soloUntimed): ui/components.js Countdown now renders nothing without a deadline; diff --git a/public/js/data.js b/public/js/data.js index 6b37be0a..b1b615a2 100644 --- a/public/js/data.js +++ b/public/js/data.js @@ -33,6 +33,8 @@ export const DATA_FILES = Object.freeze({ choices: 'choices.json', config: 'config.json', assets: 'assets.json', + // 补位 stand-ins (DATA.md §18): the bodies the 干员持有 screen, the cards and the detail card compose (shared/standIn.js) + backups: 'backups.json', // Optional art extracted from a local game client (DESIGN §13): { groups: { '': { name: { path, w, h } } } }. // The emotes and the 玩法说明 pages are in data/assets.json too (downloaded from the mirror): artUrls(). local: 'local-assets.json', diff --git a/public/js/main.js b/public/js/main.js index 0159e018..0942fa3c 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -20,7 +20,8 @@ // Multi-device support (ui/device.js + css/devices.css): feature classes on , no page zoom, safe areas, rotation // re-layout; ui/compat.js polyfills are imported before anything else. // 干员调配 (DESIGN §16): an overlay over any route (, opened from lobby / room / briefing); its loadout is -// kept in sync with the server by installLoadoutSync (room.loadout after every welcome and edit). +// kept in sync with the server by installLoadoutSync (room.loadout after every welcome and edit), its 干员持有 tab's +// not-owned list (0.2.0 补位) by installOwnershipSync (room.ownership, likewise). // Game data: every text of the game is static data (/data/*.json) downloaded once per page; the in-match files are // warmed in the background as soon as the player is in a room (warmGameData), before the match needs them. @@ -44,7 +45,7 @@ import { settingsStore } from './ui/settings.js'; import { GuideHost } from './ui/guide.js'; import { installDeviceSupport } from './ui/device.js'; import { LoadoutHost } from './screens/loadout.js'; -import { installLoadoutSync } from './ui/loadoutSync.js'; +import { installLoadoutSync, installOwnershipSync } from './ui/loadoutSync.js'; import { startBuildGuard } from './ui/buildGuard.js'; const RESTORE_GRACE_MS = 1500; @@ -329,6 +330,7 @@ async function boot() { wireNet(); installLoadoutSync({ net }); + installOwnershipSync({ net }); net.attachBrowserHooks(); // Audio: unlock on first gesture, BGM follows the route / match phase (js/audio.js). installAudio({ getManifest: () => data.get('assets'), subscribe: store.subscribe, getState: store.get, selectRoute, settings: settingsStore.get() }); diff --git a/public/js/render/app.js b/public/js/render/app.js index 0b0098f7..613260ac 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -275,6 +275,7 @@ export async function createFieldView(host, options = {}) { let penHidden = true; // the pen's figures are shown only by the pen camera (setPenHidden) let penList = null; let ownPen = null; // the own m.private.nextEnemies (fallback composition of a scouted teammate's pen) + let standInList = []; // the own m.private.standIns (0.2.0 补位): board pieces of these chess draw the stand-in let camBeforePen = null; // { kind, opts } the camera the pen returns to let leader = null; // { key, view, stand, area } the round leader standing on the boss field in the prep (setLeader) let leaderHidden = true; // shown only by the boss-field prep camera (leaderShown) @@ -285,7 +286,9 @@ export async function createFieldView(host, options = {}) { cam: () => cam, heightAt, animRate: () => (mode === 'battle' ? interp.rate : 1), timeScale: () => (mode === 'battle' ? interp.rate : 1), - lookupDef: (info) => (info.side === 'enemy' ? data.enemy(info.defId) : data.chess(info.defId) || data.token(info.defId)), + // (a chess fighting as its 补位 stand-in — `standInFor` — reads the stand-in's record: its attack interval) + lookupDef: (info) => (info.side === 'enemy' ? data.enemy(info.defId) + : (info.standInFor && data.standIn(info.defId)) || data.chess(info.defId) || data.token(info.defId)), crowded: () => views.size > 90, renderer: app.renderer, frameNo: () => frameNo, @@ -301,7 +304,7 @@ export async function createFieldView(host, options = {}) { timeScale: () => ctx.timeScale(), loadLevel: () => loadLevel, fieldRect: () => (mode === 'battle' && battleMeta ? battleMeta.rect : null), - subProfOf: (defId) => data.chess(defId)?.subProfessionId || null, + subProfOf: (defId, info = null) => ((info && info.standInFor && data.standIn(defId)) || data.chess(defId))?.subProfessionId || null, view: (id) => views.get(id) || null, screenSize: size, fieldTop: () => { @@ -626,11 +629,16 @@ export async function createFieldView(host, options = {}) { const rec = data.token(piece.id); return { kind: 'token', side: 'ally', defId: piece.id, spine: rec?.assets?.spine || piece.id, avatar: rec?.assets?.avatar || piece.id, tier: piece.tier || 1, golden: false, dir }; } - const rec = data.chess(piece.id); + const chess = data.chess(piece.id); + // 0.2.0 补位: a board piece of a chess the player does not own (m.private.standIns) is deployed as its stand-in — + // the stand-in's model; on the bench it keeps the chess's own (the hand shows the original operator) + const si = area === 'board' && chess && standInList.includes(chess.baseId || chess.chessId) ? data.standIn(piece.id) : null; + const rec = si || chess; return { kind: 'op', side: 'ally', defId: piece.id, spine: rec?.assets?.spine || rec?.charId || null, avatar: rec?.assets?.avatar || rec?.charId || null, - tier: rec?.tier || piece.tier || 1, golden: !!(piece.golden || rec?.isGolden), dir, + tier: chess?.tier || piece.tier || 1, golden: !!(piece.golden || chess?.isGolden), dir, + ...(si ? { standInFor: si.standInFor } : null), }; } @@ -678,6 +686,7 @@ export async function createFieldView(host, options = {}) { }); }; const src = ps && typeof ps === 'object' ? ps : {}; + standInList = Array.isArray(src.standIns) ? src.standIns.filter((x) => typeof x === 'string') : []; ownPen = Array.isArray(src.nextEnemies) ? src.nextEnemies : null; setPenList(ownPen); addList(src.hand, 'hand'); @@ -705,7 +714,8 @@ export async function createFieldView(host, options = {}) { const key = 'p:' + e.uid; e.key = key; const info = pieceInfo(e.piece, e.area); - const sig = `${info.kind}|${info.defId}|${info.golden ? 1 : 0}`; + // (the model is part of it: a 补位 chess changes body between the bench and the board — pieceInfo) + const sig = `${info.kind}|${info.defId}|${info.golden ? 1 : 0}|${info.spine || ''}`; let v = views.get(key); if (v && v._sig !== sig) { dropView(key); v = null; } const w = slotWorld(e); diff --git a/public/js/render/app/host.js b/public/js/render/app/host.js index 5e551cfa..4c93c19f 100644 --- a/public/js/render/app/host.js +++ b/public/js/render/app/host.js @@ -1,6 +1,7 @@ // public/js/render/app/host.js — asset lookup, data lookup and GL teardown for one field view. import { createAssets, assets as defaultAssets } from '../../assets.js'; +import { standInRecord } from '../../../../shared/standIn.js'; function withTimeout(p, ms) { return Promise.race([p, new Promise((resolve) => setTimeout(resolve, ms))]); @@ -24,9 +25,25 @@ function makeData(src) { } catch { /* ignore */ } return null; }; + // 0.2.0 补位: the composed stand-in record of a chess (shared/standIn.js over data/backups.json), cached per chess + const standIns = new Map(); + const backups = () => { + try { return (src && typeof src.get === 'function' ? src.get('backups') : src && src.backups) || null; } catch { return null; } + }; + const standIn = (id) => { + const c = look('chess', id); + const b = backups(); + if (!c || !b) return null; + if (standIns.has(c)) return standIns.get(c); + let rec; + try { rec = standInRecord(c, b); } catch { rec = null; } + standIns.set(c, rec); + return rec; + }; return { chess: (id) => look('chess', id), token: (id) => look('tokens', id), item: (id) => look('items', id), enemy: (id) => look('enemies', id), stage: (id) => look('stages', id), bond: (id) => look('bonds', id), + standIn, }; } diff --git a/public/js/render/app/info.js b/public/js/render/app/info.js index f5d3e27f..a51d0c1a 100644 --- a/public/js/render/app/info.js +++ b/public/js/render/app/info.js @@ -42,5 +42,8 @@ export function renderInfo(u) { // unit (resolveDetail `unitItems` → the read-only 装备 section and the 变形同构体 pairing chips); the owner's own // unit takes its items from the piece instead, so only other players' boards ever read this field items: Array.isArray(u.items) ? u.items.filter((x) => typeof x === 'string') : undefined, + // 0.2.0 补位: the replaced operator's charId of a chess fighting as its stand-in (UnitInfo.standInFor; spine / avatar / + // name are already the stand-in's) — the detail card and the view's data lookups (attack interval, splash FX) follow it + standInFor: typeof u.standInFor === 'string' && u.standInFor ? u.standInFor : undefined, }; } diff --git a/public/js/render/fx/numbers.js b/public/js/render/fx/numbers.js index 54322612..38ff6307 100644 --- a/public/js/render/fx/numbers.js +++ b/public/js/render/fx/numbers.js @@ -59,7 +59,7 @@ export class FxNumbers { this.particle('glow', px, py, { tint, life: 0.18, s0: (s / 128) * (big ? 1.0 : 0.6), s1: (s / 128) * (big ? 1.5 : 0.9), a0: 0.9, a1: 0 }); if (melee) { this._slashAt = null; this._slash(view, srcView, px, py, s, style, big); } this.burst(px, py, s, big ? 8 : melee ? 6 : 4, tint, { speed: melee ? 2.8 : 2.4, size: big ? 0.6 : 0.46 }); - if (srcView && this.ctx.subProfOf && SPLASH_SUBS.has(this.ctx.subProfOf(srcView.info?.defId))) { + if (srcView && this.ctx.subProfOf && SPLASH_SUBS.has(this.ctx.subProfOf(srcView.info?.defId, srcView.info))) { if (!this._lastRing || this.time - this._lastRing > 0.08) { this._lastRing = this.time; this.ring(view.x, view.y, view.z || 0, 0.1, 1.1, tint, 0.3); diff --git a/public/js/screens/game.js b/public/js/screens/game.js index d8cb3035..5aafe29b 100644 --- a/public/js/screens/game.js +++ b/public/js/screens/game.js @@ -86,6 +86,7 @@ import { openGuide } from '../ui/guide.js'; import { actions } from '../ui/gameActions.js'; import { FacingWheel, holdPiece, setPieceDir, syncPieceDirs, showRange, useTileScreen } from '../ui/facingWheel.js'; import { Underframe, underframeRect, TempRowNotice } from '../ui/underframe.js'; +import { StandInTags } from './game/standInTags.js'; import { needsFacing, facingIntent, previewGrid, pieceDir, underframeActions, retreatSlot, itemDestroyable } from '../ui/facing.js'; import { EquipReplaceDialog, replaceRequest, replaceIntent } from '../ui/equipReplace.js'; import { pauseAvailable, isPaused, frozenNow } from '../ui/matchStatus.js'; @@ -94,7 +95,7 @@ import { phaseMode, phaseBanner, isCombatPhase, showDeadPill, isBossPhase, placementContext, canPlace, boardTargets, dropIntent, snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers, countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason, - previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, panelSide, panelSlots, bondPopupPlace, chessLoadout, unitLoadout, + previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, panelSide, panelSlots, bondPopupPlace, unitLoadout, deployedRecord, mergeTarget, modeOffBonds, readyFundsPrompt, ownerBandId, } from '../ui/gameLogic.js'; import { toast } from '../ui/toasts.js'; @@ -237,7 +238,7 @@ function MatchScreen() { const deployField = deployFieldOf(pub, myId); const placeCtx = useMemo(() => placementContext({ priv, stage: gd.stage(pub?.stageId), editable, field: deployField, - getChess: gd.chess, getToken: gd.token, getItem: gd.item, getEffect: gd.effect, + getChess: gd.chess, getToken: gd.token, getItem: gd.item, getEffect: gd.effect, backups: gd.backups, }), [priv, pub?.stageId, editable, gd.ready, deployField]); live.current = { pub, priv, field, editable, placeCtx, watching, watchWho, home, myId, detail, drawer, bondOpen, emoteOpen, settingsOpen, exitOpen, drag, facing, sel, selBusy, pen, collapsedNow: collapsed, localDone: false, canPause: false, paused }; @@ -718,7 +719,7 @@ function MatchScreen() { // the drop target is the tile under the pointer (render/drag.js; an item dropped on a unit's tile equips that unit — // user playtest #4 item 1); `tile` = the last target tile (tileHover), `released` = the pointer went up (not a cancel) const ptr = { released: false, tile: null }; - const lookups = { getChess: gd.chess, getToken: gd.token, getItem: gd.item, chessRecord: (rec) => chessLoadout(rec, live.current.priv?.loadout ?? null, gd.chess)?.record }; + const lookups = { getChess: gd.chess, getToken: gd.token, getItem: gd.item, chessRecord: (rec) => deployedRecord(rec, live.current.priv, gd.chess, gd.backups) }; const runIntent = async (intent) => { const L = live.current; if (intent.confirmReplace) { @@ -886,9 +887,10 @@ function MatchScreen() { }, []); // ---- direction step (research 09 §1.2) and the selected piece's underframe ------------------------------------ - // DESIGN §16: previews show the range the unit fights with under the player's loadout (an elite's module grid) + // DESIGN §16: previews show the range the unit fights with under the player's loadout (an elite's module grid); a + // chess the player does not own the range of its stand-in (0.2.0 补位, gameLogic deployedRecord) const lookups = useMemo(() => ({ getChess: gd.chess, getToken: gd.token, getItem: gd.item, - chessRecord: (rec) => chessLoadout(rec, live.current.priv?.loadout ?? null, gd.chess)?.record }), [gd.ready]); + chessRecord: (rec) => deployedRecord(rec, live.current.priv, gd.chess, gd.backups) }), [gd.ready]); const heldRef = useRef(new Map()); // uid → { row, col, t } committed placements awaiting m.private const releaseHold = useCallback((uid) => { heldRef.current.delete(uid); @@ -984,7 +986,7 @@ function MatchScreen() { const u = (Array.isArray(field?.units) ? field.units : []).find((x) => x && x.id === detail.unitId); return u ? { ...detail, unit: u } : detail; }, [detail, field]); - const resolved = useMemo(() => resolveDetail(detailTarget, placeCtx.pieces), [detailTarget, placeCtx]); + const resolved = useMemo(() => resolveDetail(detailTarget, placeCtx.pieces, { priv, backups: gd.backups }), [detailTarget, placeCtx, gd.ready]); useEffect(() => { if (detail && !resolved && detail.kind === 'piece') setDetail(null); }, [resolved]); const snapHp = (() => { const id = resolved?.unitId; @@ -1198,6 +1200,7 @@ function MatchScreen() { ${tempNotice ? html`<${TempRowNotice} view=${view} count=${temp.count} items=${temp.items} label=${!drag && !facing} ready=${phase === PHASE.PREP && !!priv?.ready} />` : null} + ${showPrep && view && viewKind !== 'loading' && priv && !pen ? html`<${StandInTags} view=${view} priv=${priv} getChess=${gd.chess} backups=${gd.backups} />` : null}
<${TopBar} pub=${pub} priv=${priv} conn=${conn} hud=${hud} total=${total} drawer=${drawer} diff --git a/public/js/screens/game/standInTags.js b/public/js/screens/game/standInTags.js new file mode 100644 index 00000000..92ad2837 --- /dev/null +++ b/public/js/screens/game/standInTags.js @@ -0,0 +1,71 @@ +// public/js/screens/game/standInTags.js — 0.2.0 补位: the 「替补:X」 tag under the player's own prep pieces (hand, +// 临时整备区, board) of a chess they do not own (m.private.standIns). The hand keeps the chess's own model (the card shows +// the original operator); on the board the model is the stand-in's (render/app.js pieceInfo) — the tag says which +// chess it is either way. Anchored to each piece's drawn body (view.pieceScreenRect, followed every frame like the +// direction wheel follows its tile); never takes the pointer, so the pieces stay draggable. + +import { useEffect, useRef, useState } from '../../../vendor/hooks.module.js'; +import { html } from '../../ui/components.js'; +import { fieldsStandIn, standInOf, standInLabel } from '../../ui/gameLogic.js'; + +const rawOf = (view) => (view && view.raw) || view || null; + +/** + * The player's own prep pieces that fight as stand-ins: [{ uid, label, name, area }] (hand, temp, board). + * @param {any} priv m.private @param {(id: string) => any} getChess @param {any} backups data/backups.json + */ +export function standInPieces(priv, getChess, backups) { + const out = []; + if (!priv || !Array.isArray(priv.standIns) || !priv.standIns.length) return out; + const add = (p, area) => { + if (!p || p.kind !== 'chess' || !Number.isInteger(p.uid)) return; + const c = getChess(p.id); + if (!fieldsStandIn(priv, c)) return; + const si = standInOf(c, backups); + if (si) out.push({ uid: p.uid, label: standInLabel(si), name: si.name, area }); + }; + for (const p of Array.isArray(priv.hand) ? priv.hand : []) add(p, 'hand'); + for (const p of Array.isArray(priv.temp) ? priv.temp : []) add(p, 'temp'); + for (const p of Array.isArray(priv.board) ? priv.board : []) add(p, 'board'); + return out; +} + +/** @param {{ view: any, priv: any, getChess: (id: string) => any, backups: any }} props */ +export function StandInTags({ view, priv, getChess, backups }) { + const items = standInPieces(priv, getChess, backups); + const [pos, setPos] = useState({}); + const last = useRef(''); + const key = items.map((it) => it.uid).join(','); + useEffect(() => { + const raw = rawOf(view); + if (!raw || typeof raw.pieceScreenRect !== 'function' || !key) { last.current = ''; setPos({}); return undefined; } + const uids = key.split(',').map(Number); + let raf = 0; + let alive = true; + const tick = () => { + if (!alive) return; + const next = {}; + let sig = ''; + for (const uid of uids) { + let r; + try { r = raw.pieceScreenRect(uid); } catch { r = null; } + if (!r || !(r.width > 0)) continue; + // under the feet: the bottom centre of the drawn body + next[uid] = { x: (r.left + r.right) / 2, y: r.bottom }; + sig += `${uid}:${next[uid].x.toFixed(1)},${next[uid].y.toFixed(1)};`; + } + if (sig !== last.current) { last.current = sig; setPos(next); } + raf = requestAnimationFrame(tick); + }; + tick(); + return () => { alive = false; cancelAnimationFrame(raf); }; + }, [view, key]); + if (!items.length) return null; + return html``; +} diff --git a/public/js/screens/loadout.js b/public/js/screens/loadout.js index 72f99e35..de01b249 100644 --- a/public/js/screens/loadout.js +++ b/public/js/screens/loadout.js @@ -12,7 +12,9 @@ // card's own block and pure functions, GitHub issue #64) and the elite's modules (不装备 / X / Y … with the stat bonus, // the trait upgrade and the talent changes), 恢复默认; 全部恢复默认 in the top bar. // The loadout lives in ui/loadoutSync.js (localStorage + room.loadout); the model is ui/loadoutModel.js. -// Keyboard: Esc closes, ←/→ move through the (filtered) roster when focus is not in the search field. +// The second tab, 干员持有 (0.2.0 补位, screens/ownership.js): which NORMAL chess the player owns — a chess marked 未持有 is +// deployed as its official stand-in (room.ownership; ui/ownershipModel.js) — with its own 导出 / 导入 / 全部持有. +// Keyboard: Esc closes, ←/→ move through the (filtered) roster when focus is not in the search field (干员调配 tab). import { useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js'; import { html, Icon, MicroLabel, Button, TierChip, TextField, Countdown, Spinner, confirmDialog, hasDeadline, Modal, Fragment } from '../ui/components.js'; @@ -27,7 +29,9 @@ import { MODULE_NONE, PROF_ORDER, PROF_NAME, rosterOf, filterRoster, recordsOf, chessOptions, effectiveChoice, setChoice, resetChoice, changedCount, skillLabel, moduleBadge, attrRows, skillTags, serializeExport, parseImport, LOADOUT_IMPORT_MAX_BYTES, } from '../ui/loadoutModel.js'; -import { loadoutStore, openLoadout, closeLoadout, setEntries, applyLoadoutEntries } from '../ui/loadoutSync.js'; +import { loadoutStore, openLoadout, closeLoadout, setEntries, applyLoadoutEntries, setNotOwned, applyOwnershipImport } from '../ui/loadoutSync.js'; +import { setOwned, notOwnedCount, serializeOwnership, parseOwnershipImport, OWNERSHIP_IMPORT_MAX_BYTES } from '../ui/ownershipModel.js'; +import { OwnershipPanel, useOwnershipRoster } from './ownership.js'; import { copyText } from '../ui/clipboard.js'; import { toast } from '../ui/toasts.js'; @@ -67,10 +71,10 @@ function readFileText(file) { }); } -/** `stronghold-loadout-20261003-1245.json` */ -function exportFilename(now = new Date()) { +/** `stronghold-loadout-20261003-1245.json` (`stronghold-ownership-…` for the 干员持有 list) */ +function exportFilename(now = new Date(), what = 'loadout') { const p = (n) => String(n).padStart(2, '0'); - return `stronghold-loadout-${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}.json`; + return `stronghold-${what}-${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}.json`; } /** Square profession glyph (manifest prof.large: black glyph on white, drawn as a white glyph by loadout.css). */ @@ -133,7 +137,7 @@ export function ModuleGlyph({ m, rec, id, size = 'md' }) { // ---- roster card --------------------------------------------------------------------------------------------------- -function RosterCard({ m, chess, golden, entries, selected, onPick }) { +function RosterCard({ m, chess, golden, entries, selected, onPick, notOwned = false }) { const choice = effectiveChoice(entries, chess, golden); const opt = chessOptions(chess, golden); const skillRec = opt.skillOptions.find((s) => s.index === choice.skill)?.normal || chess.skill; @@ -147,6 +151,7 @@ function RosterCard({ m, chess, golden, entries, selected, onPick }) { <${TierChip} tier=${chess.tier} size="sm" class="lo-card__tier" /> ${choice.changed ? html`` : null} + ${notOwned ? html`替补` : null} ${chess.name} @@ -216,6 +221,8 @@ function ModuleInfo({ m, golden, opt }) { } const getChessRec = (id) => data.lookup('chess', id); +/** The name of a chess's 补位 stand-in (data/backups.json), or null. */ +const standInName = (chess) => data.get('backups')?.units?.[chess?.backup?.charId]?.name ?? null; /** * What 局内数值 shows (GitHub issue #64): the chess variant — the 精锐 record when asked for and the chess has one, else @@ -266,7 +273,7 @@ export function LoadoutStats({ base, golden, entries, level, onLevel, getChess = `; } -function Detail({ m, chess, golden, entries, onChange, onReset, locked }) { +function Detail({ m, chess, golden, entries, onChange, onReset, locked, notOwned = false }) { const [level, setLevel] = useState('normal'); const [statLevel, setStatLevel] = useState('elite'); // 局内数值: the 精锐 shows the chosen module's effect const bodyRef = useRef(null); @@ -329,6 +336,7 @@ function Detail({ m, chess, golden, entries, onChange, onReset, locked }) {
${modOpt ? html`<${ModuleInfo} m=${m} golden=${golden} opt=${modOpt} />` : null} ` : null} + ${notOwned ? html`

<${Icon} name="info" />干员持有中标记为未持有:此棋子由替补干员${standInName(chess) ? ` ${standInName(chess)} ` : ''}上场,技能与模组固定(补位干员技能不可更改);这里的调配在改回「持有」后生效

` : null} ${locked ? html`

<${Icon} name="info" />本局的调配已锁定,修改将在下一局生效

` : null} `; @@ -373,6 +381,8 @@ const SYNC_TEXT = { idle: ['', ''], pending: ['保存中…', 'is-busy'], sending: ['同步中…', 'is-busy'], synced: ['已同步', 'is-ok'], locked: ['本局已锁定 · 下一局生效', 'is-warn'], error: ['同步失败', 'is-bad'], }; +/** The 干员持有 tab's status line: the setting never applies to a running match. */ +const OWN_SYNC_TEXT = { ...SYNC_TEXT, locked: ['下一局生效', 'is-warn'] }; /** The overlay screen. */ function LoadoutScreen({ st }) { @@ -401,6 +411,16 @@ function LoadoutScreen({ st }) { const [narrowDetail, setNarrowDetail] = useState(false); // phones: the detail slides over the roster const [io, setIo] = useState(null); // 导出 / 导入 dialog: { mode, text } | null + const tab = st.tab === 'ownership' ? 'ownership' : 'loadout'; + const ownRoster = useOwnershipRoster(ready); + const nNotOwned = notOwnedCount(st.notOwned, ownRoster); + const setTab = (t) => loadoutStore.set({ tab: t }); + const toggleOwned = (id, owned) => setNotOwned(setOwned(loadoutStore.get().notOwned, id, owned)); + const ownAll = async () => { + if (!nNotOwned) return; + const ok = await confirmDialog({ title: '全部持有', text: `将 ${nNotOwned} 名未持有的干员恢复为持有(由本人上场)?`, okText: '全部持有' }); + if (ok) setNotOwned([]); + }; const pick = (id) => { loadoutStore.set({ sel: id }); setNarrowDetail(true); }; const change = (patch) => { if (base) setEntries(setChoice(loadoutStore.get().entries, base, golden, patch)); }; const resetOne = () => { if (base) setEntries(resetChoice(loadoutStore.get().entries, base.chessId)); }; @@ -410,27 +430,39 @@ function LoadoutScreen({ st }) { if (ok) setEntries({}); }; - // 导出 / 导入 the loadout as the versioned payload (a downloaded file, the clipboard, or the textarea) + // 导出 / 导入 the loadout (or, on the 干员持有 tab, the not-owned list) as the versioned payload (a downloaded file, + // the clipboard, or the textarea); `io.kind` says which const ioText = io?.text ?? ''; - const openExport = () => setIo({ mode: 'export', text: serializeExport(loadoutStore.get().entries) }); - const openImport = () => setIo({ mode: 'import', text: '' }); + const ioOwn = io?.kind === 'ownership'; + const openExport = () => setIo(tab === 'ownership' + ? { mode: 'export', kind: 'ownership', text: serializeOwnership(loadoutStore.get().notOwned) } + : { mode: 'export', kind: 'loadout', text: serializeExport(loadoutStore.get().entries) }); + const openImport = () => setIo({ mode: 'import', kind: tab, text: '' }); const ioCopy = async () => { const ok = await copyText(ioText); toast(ok ? '已复制到剪贴板' : '复制失败,请在文本框中手动全选复制', ok ? 'success' : 'warn'); }; - const ioDownload = () => downloadText(exportFilename(), ioText); + const ioDownload = () => downloadText(exportFilename(new Date(), ioOwn ? 'ownership' : 'loadout'), ioText); const ioPick = () => fileRef.current?.click(); const ioFile = async (e) => { const f = e.currentTarget.files && e.currentTarget.files[0]; e.currentTarget.value = ''; // picking the same file twice must fire again if (!f) return; // refuse a huge pick before reading it into memory (a real payload is a few KB) - if (f.size > LOADOUT_IMPORT_MAX_BYTES) { toast('文件过大,请选择「导出」下载的调配文件', 'error'); return; } - try { setIo({ mode: 'import', text: await readFileText(f) }); } catch { toast('读取文件失败', 'error'); } + if (f.size > (ioOwn ? OWNERSHIP_IMPORT_MAX_BYTES : LOADOUT_IMPORT_MAX_BYTES)) { toast(`文件过大,请选择「导出」下载的${ioOwn ? '干员持有' : '调配'}文件`, 'error'); return; } + try { setIo({ ...io, mode: 'import', text: await readFileText(f) }); } catch { toast('读取文件失败', 'error'); } }; const ioApply = () => { // an import before chess.json is loaded would sanitise every entry away — refuse instead of wiping the loadout if (!ready) { toast('干员数据仍在载入,请稍候再导入', 'warn'); return; } + if (ioOwn) { + const r = parseOwnershipImport(ioText); + if (!r.ok) { toast(`导入失败:${r.error}`, 'error'); return; } + const { applied, dropped } = applyOwnershipImport(r.notOwned, getChess); + setIo(null); + toast(applied ? `已导入:${applied} 名干员未持有${dropped ? `(另有 ${dropped} 项无效,未导入)` : ''}` : '已导入:全部持有', dropped ? 'warn' : 'success'); + return; + } const res = parseImport(ioText); if (!res.ok) { toast(`导入失败:${res.error}`, 'error'); return; } const { applied, dropped } = applyLoadoutEntries(res.entries, getChess); @@ -450,7 +482,7 @@ function LoadoutScreen({ st }) { const typing = e.target && /^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName); if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); closeLoadout(); return; } if (typing) return; - if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') { + if ((e.key === 'ArrowRight' || e.key === 'ArrowLeft') && loadoutStore.get().tab !== 'ownership') { const ids = filterRoster(rosterOf(data.list('chess')), loadoutStore.get().filters, loadoutStore.get().entries, getChess, getBond).map((c) => c.chessId); if (!ids.length) return; const cur = Math.max(0, ids.indexOf(loadoutStore.get().sel)); @@ -471,8 +503,12 @@ function LoadoutScreen({ st }) { if (el && typeof el.scrollIntoView === 'function') el.scrollIntoView({ block: 'nearest' }); }, [selId]); - const [syncText, syncCls] = SYNC_TEXT[st.sync] || SYNC_TEXT.idle; + const [syncText, syncCls] = tab === 'ownership' ? OWN_SYNC_TEXT[st.ownSync] || OWN_SYNC_TEXT.idle : SYNC_TEXT[st.sync] || SYNC_TEXT.idle; const fromText = st.from === 'briefing' ? '确认本局信息阶段结束前可调整本局配置' : '开始模拟前可调整干员携带的技能与模组,干员等级不可调整'; + // 干员持有 is out of match: a running match keeps the list its seat had at its start + const ownLocked = inMatch && !!phase && phase !== PHASE.LOBBY; + const ownText = ownLocked ? '干员持有是局外设置:本局按开局时的设置进行,修改将在下一局生效' + : '局外设置,下一局生效 · 联机时只影响你自己的棋子 · 默认全部持有'; return html`<${Fragment}>
- <${MicroLabel} tone="mint">OPERATOR LOADOUT -

<${Img} src=${localAsset('ui/outer', 'operator_preset')} class="lo-top__icon" fallback=${html`<${Icon} name="edit" class="lo-top__icon" />`} />干员调配

+ <${MicroLabel} tone="mint">${tab === 'ownership' ? 'OPERATOR ROSTER' : 'OPERATOR LOADOUT'} +
+ + +
-
+ ${tab === 'ownership' ? html`
+ ${syncText ? html`${syncText}` : null} + 未持有 ${nNotOwned}/${ownRoster.length} + <${Button} variant="ghost" size="sm" data-testid="ownership-export" disabled=${!nNotOwned} onClick=${openExport} title="导出干员持有(可复制或下载)">导出 + <${Button} variant="ghost" size="sm" data-testid="ownership-import" disabled=${!ready} onClick=${openImport} title="导入干员持有(粘贴或选择文件)">导入 + <${Button} variant="secondary" size="sm" icon="refresh" data-testid="ownership-reset" disabled=${!nNotOwned} onClick=${ownAll}>全部持有 +
` : html`
${inMatch && hasDeadline(infoDeadline) ? html`<${Countdown} deadline=${infoDeadline} size="sm" gauge=${false} label="调配截止" class="lo-deadline" />` : null} ${syncText ? html`${syncText}` : null} 已调整 ${nChanged}/${roster.length} <${Button} variant="ghost" size="sm" data-testid="loadout-export" disabled=${!nChanged} onClick=${openExport} title="导出当前调配(可复制或下载)">导出 <${Button} variant="ghost" size="sm" data-testid="loadout-import" disabled=${!ready} onClick=${openImport} title="导入调配(粘贴或选择文件)">导入 <${Button} variant="secondary" size="sm" icon="refresh" disabled=${!nChanged} onClick=${resetAll}>全部恢复默认 -
+
`} -

<${Icon} name="info" />${locked ? '本局的调配已锁定(确认本局信息后无法修改),修改将在下一局生效' : fromText}

- ${!ready ? html`
<${Spinner} size="sm" />正在载入干员数据(打开页面后仅载入一次)…
` : html`
+ ${tab === 'ownership' + ? html`

<${Icon} name="info" />${ownText}

` + : html`

<${Icon} name="info" />${locked ? '本局的调配已锁定(确认本局信息后无法修改),修改将在下一局生效' : fromText}

`} + ${!ready ? html`
<${Spinner} size="sm" />正在载入干员数据(打开页面后仅载入一次)…
` : tab === 'ownership' + ? html`<${OwnershipPanel} m=${m} roster=${ownRoster} notOwned=${st.notOwned} onToggle=${toggleOwned} />` + : html`
<${Filters} m=${m} filters=${st.filters} bonds=${bonds} onFilters=${(filters) => loadoutStore.set({ filters })} />
${list.length ? list.map((c) => html`<${RosterCard} key=${c.chessId} m=${m} chess=${c} golden=${c.goldenId ? getChess(c.goldenId) : null} - entries=${st.entries} selected=${c.chessId === selId} onPick=${pick} />`) : html`

没有符合条件的干员

`} + entries=${st.entries} selected=${c.chessId === selId} onPick=${pick} notOwned=${(st.notOwned || []).includes(c.chessId)} />`) : html`

没有符合条件的干员

`}
- <${Detail} m=${m} chess=${base} golden=${golden} entries=${st.entries} onChange=${change} onReset=${resetOne} locked=${locked} /> + <${Detail} m=${m} chess=${base} golden=${golden} entries=${st.entries} onChange=${change} onReset=${resetOne} locked=${locked} + notOwned=${!!base && (st.notOwned || []).includes(base.chessId)} />
`} ${io ? html`<${Modal} open=${true} onClose=${() => setIo(null)} - title=${io.mode === 'export' ? '导出干员调配' : '导入干员调配'} micro="OPERATOR LOADOUT" + title=${`${io.mode === 'export' ? '导出' : '导入'}${ioOwn ? '干员持有' : '干员调配'}`} micro=${ioOwn ? 'OPERATOR ROSTER' : 'OPERATOR LOADOUT'} actions=${io.mode === 'export' ? html`<${Button} variant="ghost" onClick=${() => setIo(null)}>关闭 <${Button} variant="secondary" icon="copy" data-testid="loadout-io-copy" onClick=${ioCopy}>复制 @@ -518,12 +570,16 @@ function LoadoutScreen({ st }) { : html`<${Button} variant="ghost" onClick=${() => setIo(null)}>取消 <${Button} variant="secondary" data-testid="loadout-io-pick" onClick=${ioPick}>选择文件 <${Button} variant="primary" icon="check" data-testid="loadout-io-apply" disabled=${!ioText.trim() || !ready} onClick=${ioApply}>导入`}> -

${io.mode === 'export' - ? html`共 ${nChanged} 名干员已调整。复制或下载这份数据,即可在别的设备或浏览器上导入。` - : html`把导出的内容粘贴到下方,或点「选择文件」。${nChanged ? html`导入会覆盖当前的 ${nChanged} 名干员调配。` : null}`}

+

${ioOwn + ? (io.mode === 'export' + ? html`共 ${nNotOwned} 名干员未持有。复制或下载这份数据,即可在别的设备或浏览器上导入。` + : html`把导出的干员持有数据粘贴到下方,或点「选择文件」。导入会覆盖当前的干员持有设置。`) + : io.mode === 'export' + ? html`共 ${nChanged} 名干员已调整。复制或下载这份数据,即可在别的设备或浏览器上导入。` + : html`把导出的内容粘贴到下方,或点「选择文件」。${nChanged ? html`导入会覆盖当前的 ${nChanged} 名干员调配。` : null}`}

+ placeholder=${io.mode === 'export' ? '' : ioOwn ? '在此粘贴导出的干员持有内容…' : '在此粘贴导出的调配内容…'} + onInput=${(e) => setIo({ ...io, text: e.currentTarget.value })}> ` : null} `; @@ -575,11 +631,15 @@ export function badgeCount(entries, getChess) { export function LoadoutButton({ from, size = 'md', variant = 'secondary', class: cls, label = '干员调配' }) { useData('local'); // the official preset icon (re-render once the local-art manifest arrives) const entries = useStore((s) => s.entries, Object.is, loadoutStore); + const notOwned = useStore((s) => s.notOwned, Object.is, loadoutStore); const n = badgeCount(entries, data.status('chess') === 'ready' ? (id) => data.lookup('chess', id) : null); + // 0.2.0 补位: how many operators the player marked as not owned (the 干员持有 tab) — easy to forget between sessions + const off = Array.isArray(notOwned) ? notOwned.length : 0; return html``; } diff --git a/public/js/screens/ownership.js b/public/js/screens/ownership.js new file mode 100644 index 00000000..bb6e4b42 --- /dev/null +++ b/public/js/screens/ownership.js @@ -0,0 +1,78 @@ +// 干员持有 (operator ownership, 0.2.0 补位 — the approved plan, owner's decision 2026-10-05): the second tab of the +// 干员调配 overlay (screens/loadout.js). The 53 NORMAL chess of this season's shop, grouped by tier — each card shows the +// operator and its official stand-in (avatar, name, the skill it fields) and switches between 持有 and 未持有 with a +// tap. A chess marked 未持有 keeps its shop / hand card (name, bonds, price, with a 「替补:X」 badge) and is deployed +// as the stand-in (model, skills, talents, range). PRESET chess always field their own operator and are not listed. +// Default: everything owned; 全部持有 resets. Out of match: the next match takes the list (in co-op it only affects the +// player's own pieces). The list lives in ui/loadoutSync.js (localStorage + room.ownership); the model is +// ui/ownershipModel.js. Styles: css/screens/loadout.css (own-*). + +import { useMemo, useState } from '../../vendor/hooks.module.js'; +import { html, TierChip, Icon } from '../ui/components.js'; +import { Img } from '../ui/gameComponents.js'; +import { chessAvatarUrl } from '../ui/assetUrls.js'; +import { data } from '../data.js'; +import { PROF_NAME } from '../ui/loadoutModel.js'; +import { ownershipRoster, rosterByTier, isOwned, standInSummary } from '../ui/ownershipModel.js'; + +const cx = (...p) => p.flat().filter(Boolean).join(' '); +const ROMAN = ['', 'I', 'II', 'III', 'IV', 'V', 'VI']; + +/** The roster of the tab (data.list('chess'), memoised on the data being ready). */ +export function useOwnershipRoster(ready) { + return useMemo(() => ownershipRoster(data.list('chess')), [ready]); +} + +/** One chess: the operator → its stand-in, and the 持有 switch (the whole card is the switch). */ +export function OwnCard({ m, chess, backups, owned, onToggle }) { + const si = standInSummary(chess, backups); + const skill = si?.skill; + const slot = skill && Number.isInteger(skill.index) ? `S${skill.index + 1}` : ''; + const label = `${chess.name}:${owned ? '持有' : `未持有,由 ${si?.name || '替补干员'} 上场`}`; + return html``; +} + +/** + * The tab's body. + * @param {{ m: any, roster: any[], notOwned: string[], onToggle: (chessId: string, owned: boolean) => void }} props + */ +export function OwnershipPanel({ m, roster, notOwned, onToggle }) { + const [offOnly, setOffOnly] = useState(false); + const backups = data.get('backups'); + const list = offOnly ? roster.filter((c) => !isOwned(notOwned, c.chessId)) : roster; + const groups = rosterByTier(list); + return html`
+
+

未持有的干员由官方指定的替补干员上场:棋子的名字、盟约、特质、阶级与价格不变,上场的是替补干员(模型、技能、天赋、攻击范围)。预设干员无论是否持有都由本人上场,不在此列。

+ +
+
+ ${groups.length ? groups.map((g) => html`
+

<${TierChip} tier=${g.tier} size="sm" />${ROMAN[g.tier]}阶${g.list.length}

+
+ ${g.list.map((c) => html`<${OwnCard} key=${c.chessId} m=${m} chess=${c} backups=${backups} owned=${isOwned(notOwned, c.chessId)} onToggle=${onToggle} />`)} +
+
`) : html`

${offOnly ? '全部持有:没有由替补干员上场的棋子' : '没有可下掉的干员'}

`} +
+
`; +} diff --git a/public/js/ui/detailPanel.js b/public/js/ui/detailPanel.js index a72dfab7..8ca31780 100644 --- a/public/js/ui/detailPanel.js +++ b/public/js/ui/detailPanel.js @@ -33,7 +33,7 @@ import { html, Icon, TierChip, MicroLabel, Button, confirmDialog, useTicker } from './components.js'; import { Img, RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.js'; -import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, briefingBondTip, pieceBondIds, grantedBonds, morphPairings } from './gameLogic.js'; +import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, briefingBondTip, pieceBondIds, grantedBonds, morphPairings, fieldsStandIn, standInOf, standInLoadout, standInLabel } from './gameLogic.js'; import { chessPortraitUrl, skillIconUrl, skillRecordIconUrl, profIconUrl, subProfIconUrl, itemIconUrl, enemyIconUrl, tokenAvatarUrl, factionIconUrl, uiUrl, moduleTypeIconUrl } from './assetUrls.js'; import { abilityRows } from './abilityLines.js'; import { data } from '../data.js'; @@ -354,17 +354,22 @@ export function chessStatsBlock({ rec, chess, live = null }) { `; } -export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, offBonds = null, loadout, onBond, live = null, hint = null, unitItems = null }) { +export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, offBonds = null, loadout, onBond, live = null, hint = null, unitItems = null, standIn = null }) { const m = data.get('assets'); const hp = hpOf(live, snapHp); - const lo = chessLoadout(chess, loadout, (id) => data.lookup('chess', id)); + const getChess = (id) => data.lookup('chess', id); + // 0.2.0 补位: a chess fielded as its stand-in keeps its identity (name, tier, bonds, 特质, sell price) and shows the + // stand-in's body — portrait, class, 特性, stats, range, skill, talents, module (its backup selection, no loadout) + const si = standIn && standIn.standInFor ? standIn : null; + const body = si || chess; + const lo = si ? standInLoadout(si, getChess, data.get('backups')) : chessLoadout(chess, loadout, getChess); 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 fr = lo?.record || body; const golden = !!(c.isGolden || piece?.golden); - const sk = lo?.skill || c.skill || null; + const sk = lo?.skill || body.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 skIcon = sk && lo && !lo.defaultSkill ? skillRecordIconUrl(m, sk, { empty: false }) : skillIconUrl(m, body); 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 : []; @@ -379,36 +384,38 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond blocks.head = html`
- <${Img} src=${chessPortraitUrl(m, c)} fallback=${html`<${UnitThumb} kind="chess" id=${c.chessId} size="lg" />`} /> + <${Img} src=${chessPortraitUrl(m, body)} fallback=${html`<${UnitThumb} kind="chess" id=${c.chessId} size="lg" />`} />
<${TierChip} tier=${c.tier} golden=${golden} size="lg" /> ${golden ? html`精锐` : null} ${piece?.kind === 'token' ? html`召唤物` : null} + ${si ? html`${standInLabel(si)}` : null}

${c.name}

- ${c.appellation || ''} + ${si ? `${si.name}${si.appellation && si.appellation !== si.name ? ` · ${si.appellation}` : ''}` : c.appellation || ''}
- <${Img} src=${profIconUrl(m, c.profession)} class="dhead__prof" /> - ${PROF_NAME[c.profession] || c.profession || ''} + <${Img} src=${profIconUrl(m, body.profession)} class="dhead__prof" /> + ${PROF_NAME[body.profession] || body.profession || ''} - <${Img} src=${subProfIconUrl(m, c)} class="dhead__sub" /> - ${c.subProfessionName || ''} - ${c.position === 'MELEE' ? '近战位' : '远程位'} + <${Img} src=${subProfIconUrl(m, body)} class="dhead__sub" /> + ${body.subProfessionName || ''} + ${body.position === 'MELEE' ? '近战位' : '远程位'}
${hp ? html`
${fmtNum(hp.hp)} / ${fmtNum(hp.max)}
` : null} <${BondChips} bondIds=${bondIds} bonds=${bonds} off=${offBonds} onBond=${onBond} granted=${grantedIds} />
`; blocks.garrison = garrison ? html`<${GarrisonBlock} key="garrison" garrison=${garrison} m=${m} />` : null; - blocks.trait = c.trait?.desc ? html`

<${Icon} name="info" /><${RichText} text=${traitText(c, golden, lo)} />

` : null; + blocks.trait = body.trait?.desc ? html`

<${Icon} name="info" /><${RichText} text=${traitText(body, golden, lo)} />

` : null; + // (`fr` is the body the unit fights with — a stand-in's record for a 补位 chess; `chess` is only the range's fallback) blocks.stats = chessStatsBlock({ rec: fr, chess: c, live }); blocks.skill = sk ? html`<${Section} key="skill" title="技能" micro="SKILL" class="dsec--skill">
<${Img} src=${skIcon} class="dskill__icon" fallback=${html`${skSlot ? html`${skSlot}` : null}`} />
- ${skSlot && (lo?.choices || 0) > 1 ? html`${skSlot}` : null}${sk.name}${lo && !lo.defaultSkill ? html`已调配` : null} + ${skSlot && (lo?.choices || 0) > 1 ? html`${skSlot}` : null}${sk.name}${lo && !lo.defaultSkill && !si ? html`已调配` : null}
${SP_TYPE[sk.spType] || '技力'} ${SKILL_TYPE[sk.skillType] || '自动触发'} @@ -426,7 +433,7 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond <${Img} src=${moduleTypeIconUrl(data.get('local'), lo.module.typeName)} fallback=${html`${moduleBadge(lo.module)}`} />` : null} ${lo.module.name} ${lo.module.typeName ? html`${lo.module.typeName}` : null} - ${!lo.defaultModule ? html`已调配` : null} + ${!lo.defaultModule && !si ? html`已调配` : null}
` : null; // (a 变形同构体 / bond item row shows its pairing against what this operator carries: ItemRow `carried`) @@ -604,9 +611,13 @@ export function TokenDetail({ token, piece, ownerId = null, snapHp = null, live * 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} pieces indexPieces(priv) + * @param {{ priv?: any, backups?: any }} [opts] 0.2.0 补位: the player's own pieces and cards of a chess in + * m.private.standIns — and a unit carrying `standInFor` — resolve with `standIn` (the composed stand-in record the + * card shows as the body) */ -export function resolveDetail(target, pieces) { +export function resolveDetail(target, pieces, { priv = null, backups = data.get('backups') } = {}) { if (!target) return null; + const ownStandIn = (c) => (fieldsStandIn(priv, c) ? standInOf(c, backups) : null); if (target.kind === 'piece') { const e = pieces?.get(target.uid); if (!e) return null; @@ -614,13 +625,13 @@ export function resolveDetail(target, pieces) { 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, ownerId: tokenOwnerId(p, pieces) } : null; } const c = data.lookup('chess', p.id); - return c ? { type: 'chess', chess: c, piece: p } : null; + return c ? { type: 'chess', chess: c, piece: p, standIn: ownStandIn(c) } : null; } if (target.kind === 'chess') { // a bond popup's 变形同构体 row hands the wearer's item ids on (bondStrip onMember): the card shows the pair and the chip const c = data.lookup('chess', target.id); const items = Array.isArray(target.items) ? target.items.filter((x) => typeof x === 'string') : []; - return c ? { type: 'chess', chess: c, hint: target.hint || null, ...(items.length ? { unitItems: items } : {}) } : null; + return c ? { type: 'chess', chess: c, hint: target.hint || null, standIn: target.foreign ? null : ownStandIn(c), ...(items.length ? { unitItems: items } : {}) } : 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; } @@ -632,7 +643,12 @@ export function resolveDetail(target, pieces) { // a hand item on a scouted prep board (m.field units, kind 'item'): the item's own card if (u.kind === 'item') { const it = data.lookup('items', u.defId); return it ? { type: 'item', item: it } : null; } const c = data.lookup('chess', u.defId); - if (c) return { type: 'chess', chess: c, piece: own?.piece || null, unitId: u.id, unitItems: Array.isArray(u.items) ? u.items : null }; + // a unit says itself whether it is a stand-in (UnitInfo standInFor: the sim's, prep scouting's); an own piece's unit + // follows m.private.standIns like the piece + let si = null; + if (c && typeof u.standInFor === 'string' && u.standInFor) si = standInOf(c, backups); + else if (c && own?.piece) si = ownStandIn(c); + if (c) return { type: 'chess', chess: c, piece: own?.piece || null, unitId: u.id, unitItems: Array.isArray(u.items) ? u.items : null, standIn: si }; const t = data.lookup('tokens', u.defId); if (t) return { type: 'token', token: t, unitId: u.id, ownerId: tokenOwnerId(own?.piece, pieces) }; const en = data.lookup('enemies', u.defId); @@ -675,7 +691,8 @@ export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestr
${detail.type === 'chess' ? html`<${ChessDetail} chess=${detail.chess} piece=${detail.piece} snapHp=${snapHp} editable=${editable} onSell=${sellIt} - bonds=${bonds} offBonds=${offBonds} loadout=${loadout} onBond=${onBond} live=${liveNow} hint=${detail.hint || null} unitItems=${detail.unitItems || null} />` : null} + bonds=${bonds} offBonds=${offBonds} loadout=${loadout} onBond=${onBond} live=${liveNow} hint=${detail.hint || null} unitItems=${detail.unitItems || null} + standIn=${detail.standIn || null} />` : null} ${detail.type === 'item' ? html`<${ItemDetail} item=${detail.item} piece=${detail.piece} editable=${editable} onDestroy=${destroyIt} offBonds=${offBonds} />` : null} ${detail.type === 'enemy' ? html`<${EnemyDetail} enemy=${detail.enemy} snapHp=${snapHp} count=${detail.count} live=${liveNow} />` : null} ${detail.type === 'token' ? html`<${TokenDetail} token=${detail.token} piece=${detail.piece} ownerId=${detail.ownerId ?? null} snapHp=${snapHp} live=${liveNow} />` : null} diff --git a/public/js/ui/gameComponents.js b/public/js/ui/gameComponents.js index 755f2054..d0bc46f4 100644 --- a/public/js/ui/gameComponents.js +++ b/public/js/ui/gameComponents.js @@ -11,15 +11,15 @@ import { const cx = (...p) => p.flat().filter(Boolean).join(' '); -/** Data files the in-match screens use. */ +/** Data files the in-match screens use (`backups`: the 补位 stand-ins' bodies — cards, the board model, the detail card). */ export const GAME_FILES = ['config', 'assets', 'chess', 'bonds', 'items', 'bands', 'enemies', 'bosses', 'stages', 'tokens', - 'choices', 'effects', 'garrisons', 'factions', 'local']; + 'choices', 'effects', 'garrisons', 'factions', 'local', 'backups']; /** * 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[] }} + * effect: (id:string)=>any, garrison: (id:string)=>any, factions: any, choices: any, backups: any, list: (name:string)=>any[] }} */ export function useGameData() { const ready = useData(...GAME_FILES); @@ -44,6 +44,7 @@ export function makeLookups(ready = true) { garrison: (id) => data.lookup('garrisons', id), factions: data.get('factions'), choices: data.get('choices'), + backups: data.get('backups'), list: (name) => data.list(name), }; } diff --git a/public/js/ui/gameLogic.js b/public/js/ui/gameLogic.js index aeac5ce1..4c62eca3 100644 --- a/public/js/ui/gameLogic.js +++ b/public/js/ui/gameLogic.js @@ -41,4 +41,5 @@ export { shortcutFor, closesOnFieldPress, shortcutBlocked } from './gameLogic/sh export { DEFAULT_SETTINGS, sanitizeSettings } from './gameLogic/settings.js'; export { normalizeResult } from './gameLogic/result.js'; export { chessLoadout, unitLoadout } from './gameLogic/loadout.js'; +export { standInIds, fieldsStandIn, standInOf, cardStandIn, standInGetter, standInLoadout, deployedRecord, deployedModuleId, standInLabel } from './gameLogic/standIn.js'; export { panelSide, panelSlots, PANEL_RIGHT_GAP, PANEL_RIGHT_BOTTOM, PANEL_RIGHT_BOTTOM_SHOP, BPOP, bondPopupPlace } from './gameLogic/panel.js'; diff --git a/public/js/ui/gameLogic/placement.js b/public/js/ui/gameLogic/placement.js index 1329b409..a6ff5ce9 100644 --- a/public/js/ui/gameLogic/placement.js +++ b/public/js/ui/gameLogic/placement.js @@ -1,13 +1,12 @@ // ui/gameLogic/placement.js — deploy maps and the canPlace mirror of the server rules. Re-exported from ../gameLogic.js. import { GEO } from '../../../../shared/constants.js'; -import { resolveLoadout } from '../../../../shared/protocol.js'; import { attackRangeGrid } from '../../../../shared/loadoutRecord.js'; import { meleeOnHighGround } from '../../../../shared/highGround.js'; import { pieceDir, rangeTiles } from '../facing.js'; import { isObj, tileKey } from './shared.js'; import { boardTileOf, fieldTile } from './camera.js'; -import { chessLoadout } from './loadout.js'; +import { deployedRecord, deployedModuleId, fieldsStandIn, standInOf } from './standIn.js'; // ---- placement (canPlace mirror) ------------------------------------------------------------------------ @@ -194,7 +193,7 @@ export function indexPieces(priv) { * @param {{ priv:any, stage:any, editable:boolean, field?:'normal'|'bossL'|'bossR', getChess?:(id:string)=>any, * getToken?:(id:string)=>any, getItem?:(id:string)=>any, getEffect?:(id:string)=>any }} o */ -export function placementContext({ priv, stage, editable, field = 'normal', getChess = () => null, getToken = () => null, getItem = () => null, getEffect = () => null }) { +export function placementContext({ priv, stage, editable, field = 'normal', getChess = () => null, getToken = () => null, getItem = () => null, getEffect = () => null, backups = null }) { const pieces = indexPieces(priv); const boardAt = new Map(); for (const e of pieces.values()) if (e.area === 'board') boardAt.set(tileKey(e.row, e.col), e); @@ -210,7 +209,7 @@ export function placementContext({ priv, stage, editable, field = 'normal', getC const deploy = field === 'bossL' || field === 'bossR' ? deploySets(stage, field, ov) : deploySets(effectiveStage(stage, ov)); - return { priv, pieces, boardAt, handAt, deploy, cap, count, field, editable: !!editable, getChess, getToken, getItem }; + return { priv, pieces, boardAt, handAt, deploy, cap, count, field, editable: !!editable, getChess, getToken, getItem, backups }; } /** @@ -221,9 +220,10 @@ export function placementContext({ priv, stage, editable, field = 'normal', getC export function piecePosition(ctx, piece) { if (!isObj(piece)) return null; if (piece.kind === 'chess') { - const rec = ctx.getChess(piece.id); - let moduleId = null; - try { moduleId = resolveLoadout(ctx.priv?.loadout ?? null, rec, ctx.getChess)?.moduleId ?? null; } catch { moduleId = null; } + const chess = ctx.getChess(piece.id); + // 0.2.0 补位: a chess the player fields as its stand-in is placed by the stand-in's position (server placeClass) + const rec = (fieldsStandIn(ctx.priv, chess) && standInOf(chess, ctx.backups)) || chess; + const moduleId = deployedModuleId(chess, ctx.priv, ctx.getChess, ctx.backups); if (meleeOnHighGround(rec, moduleId)) return 'ALL'; return rec?.position === 'MELEE' ? 'MELEE' : 'RANGED'; } @@ -256,7 +256,7 @@ export function summonRange(ctx, piece, owner = null) { const rec = at && ctx.getChess(at.piece.id); if (!rec) return null; let grid = null; - try { grid = attackRangeGrid(chessLoadout(rec, ctx.priv?.loadout ?? null, ctx.getChess)?.record || rec); } catch { /* the data grid */ } + try { grid = attackRangeGrid(deployedRecord(rec, ctx.priv, ctx.getChess, ctx.backups) || rec); } catch { /* the data grid */ } return new Set(rangeTiles(grid || rec.rangeGrid, at.row, at.col, pieceDir(at.piece)).map(([r, c]) => tileKey(r, c))); } diff --git a/public/js/ui/gameLogic/standIn.js b/public/js/ui/gameLogic/standIn.js new file mode 100644 index 00000000..645833f7 --- /dev/null +++ b/public/js/ui/gameLogic/standIn.js @@ -0,0 +1,110 @@ +// ui/gameLogic/standIn.js — 补位 stand-ins in the match UI (0.2.0, the approved plan — owner's decision 2026-10-05). +// Re-exported from ../gameLogic.js. +// +// A chess the player marked as not owned (干员持有) keeps its card — name, bonds, price, the shop / reward / hand art — +// with a 「替补:」 badge, and is deployed as its official stand-in: the board model, the battle unit and the +// detail card's body (portrait, class, 特性, stats, range, skill, talents, module) are the stand-in's, its identity +// (name, tier, bonds, 特质) the chess's. Whose pieces: the player's own come from m.private.standIns (the list the seat +// had when the match started — the server's, not this browser's current setting, which applies from the next match); +// another player's units say it themselves (UnitInfo `standInFor`: the sim's, and Match.prepFieldMeta's for scouting). +// The composed record is shared/standIn.js standInRecord over data/backups.json — the one the sim fields. + +import { standInRecord } from '../../../../shared/standIn.js'; +import { resolveLoadout } from '../../../../shared/protocol.js'; +import { chessLoadout } from './loadout.js'; +import { isObj } from './shared.js'; + +/** Base chess ids the player fields as stand-ins in this match (m.private.standIns; [] when absent). */ +export function standInIds(priv) { + const list = isObj(priv) && Array.isArray(priv.standIns) ? priv.standIns : []; + return list.filter((x) => typeof x === 'string'); +} + +/** Whether the player's own piece / card of chess record `chess` (normal or elite) fields its stand-in. */ +export function fieldsStandIn(priv, chess) { + if (!isObj(chess) || typeof chess.chessId !== 'string') return false; + const ids = standInIds(priv); + return ids.length > 0 && ids.includes(chess.baseId || chess.chessId); +} + +/** Per backups object: chess record → composed stand-in record (or null). */ +const CACHE = new WeakMap(); + +/** + * The composed 补位 record of a chess record (shared/standIn.js standInRecord: the chess's identity, the stand-in's + * body; `standInFor` = the replaced charId), cached per data object; null for a PRESET / 自选 chess or missing data. + * @param {any} chess data/chess.json record @param {any} backups data/backups.json + */ +export function standInOf(chess, backups) { + if (!isObj(chess) || !isObj(backups)) return null; + let m = CACHE.get(backups); + if (!m) { m = new WeakMap(); CACHE.set(backups, m); } + if (m.has(chess)) return m.get(chess); + let rec; + try { rec = standInRecord(chess, backups); } catch { rec = null; } + m.set(chess, rec); + return rec; +} + +/** + * The stand-in record the card of a chess shows for this viewer, or null: the player's own piece / card (m.private + * standIns), or a unit that carries `standInFor` (another player's — the sim's UnitInfo, prep scouting). + * @param {any} chess @param {{ priv?: any, unit?: any, backups?: any }} o + */ +export function cardStandIn(chess, { priv = null, unit = null, backups = null } = {}) { + if (!isObj(chess)) return null; + const mine = unit ? typeof unit.standInFor === 'string' && !!unit.standInFor : fieldsStandIn(priv, chess); + return mine ? standInOf(chess, backups) : null; +} + +/** id → stand-in record (else the chess record): the lookup a stand-in's loadout / options resolve against. */ +export function standInGetter(getChess, backups) { + return (id) => { + const c = getChess(id); + return standInOf(c, backups) || c; + }; +} + +/** + * The skill / module / record a stand-in fights with (chessLoadout of the composed record, no player loadout — + * "对于补位干员其技能不可更改"): its backup skill, the elite's backup module (or none). + * @param {any} rec standInOf(...) @param {(id: string) => any} getChess @param {any} backups + */ +export function standInLoadout(rec, getChess, backups) { + return rec ? chessLoadout(rec, null, standInGetter(getChess, backups)) : null; +} + +/** + * The record the player's own piece of `chess` is deployed with — the stand-in record when the player fields its + * stand-in, else the chess as the player's loadout makes it (chessLoadout `.record`): its range (board overlay, deploy + * wheel), its position (legal tiles), the board model. + * @param {any} chess @param {any} priv m.private @param {(id: string) => any} getChess @param {any} backups + */ +export function deployedRecord(chess, priv, getChess, backups) { + if (!isObj(chess)) return chess; + if (fieldsStandIn(priv, chess)) { + const si = standInOf(chess, backups); + if (si) return standInLoadout(si, getChess, backups)?.record || si; + } + return chessLoadout(chess, priv?.loadout ?? null, getChess)?.record || chess; +} + +/** + * The module id a deployed piece carries (the placement rule of elite 歌蕾蒂娅's HOK-Y reads it): a stand-in's backup + * module, else the player's loadout. + */ +export function deployedModuleId(chess, priv, getChess, backups) { + if (!isObj(chess)) return null; + try { + if (fieldsStandIn(priv, chess)) { + const si = standInOf(chess, backups); + if (si) return resolveLoadout(null, si, standInGetter(getChess, backups))?.moduleId ?? null; + } + return resolveLoadout(priv?.loadout ?? null, chess, getChess)?.moduleId ?? null; + } catch { return null; } +} + +/** The 「替补:X」 badge text of a stand-in record (null without one). */ +export function standInLabel(rec) { + return isObj(rec) && rec.standInFor && rec.name ? `替补:${rec.name}` : null; +} diff --git a/public/js/ui/loadoutSync.js b/public/js/ui/loadoutSync.js index 6f2c738d..fa4199b5 100644 --- a/public/js/ui/loadoutSync.js +++ b/public/js/ui/loadoutSync.js @@ -1,19 +1,22 @@ -// Operator loadout state + server sync (DESIGN §16). +// Operator loadout + ownership state and server sync (DESIGN §16; 干员持有 — 0.2.0 补位). // -// `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. +// `loadoutStore` holds the per-browser loadout (`entries`, persisted in localStorage through store.js savePref), the +// per-browser not-owned list of the 干员持有 tab (`notOwned`, likewise) and the overlay's screen state (open / origin / +// tab / selection / filters). `installLoadoutSync()` / `installOwnershipSync()` (called once by main.js) keep the +// server's copies current — one sync engine (installPrefSync), two settings: after every `welcome` (new or resumed +// session — the server keeps them 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), they send `room.loadout { entries }` +// (sanitised against the loaded data/chess.json: ui/loadoutModel.js sanitizeEntries — a stale entry is dropped, never +// the whole loadout) and `room.ownership { notOwned }` (sent as stored: the server keeps the droppable chess and drops +// the rest, so no data is needed). Replies: RATE → retried later; WRONG_PHASE / ROOM_STARTED → the running match keeps +// what it took (the loadout locks when INFO_CHECK ends; the ownership never changes during a match) — stored for the +// next match, not an error for the player; anything else is logged. `sync` / `ownSync` ∈ 'idle' | 'pending' | +// 'sending' | 'synced' | 'locked' | 'error' are 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 { OWNERSHIP_PREF, parseStoredOwnership, toStoredOwnership, cleanIds, sanitizeNotOwned } from './ownershipModel.js'; import { toast } from './toasts.js'; export const SYNC_DEBOUNCE_MS = 500; @@ -22,15 +25,21 @@ export const RETRY_MS = 1500; function readStored() { try { return parseStored(loadPref(LOADOUT_PREF, null)); } catch { return {}; } } +function readStoredOwnership() { + try { return parseStoredOwnership(loadPref(OWNERSHIP_PREF, null)); } catch { return []; } +} -/** Loadout + screen state (separate from the app store: it must survive room / match resets). */ +/** Loadout + ownership + screen state (separate from the app store: it must survive room / match resets). */ export const loadoutStore = createStore({ entries: readStored(), + notOwned: readStoredOwnership(), // 干员持有: base chess ids marked 未持有 (sorted; [] = every operator owned) open: false, from: null, // 'lobby' | 'room' | 'briefing' + tab: 'loadout', // 'loadout' (干员调配) | 'ownership' (干员持有) sel: null, // selected base chess id filters: { tier: null, prof: null, bond: null, query: '', changedOnly: false }, sync: 'idle', + ownSync: 'idle', }); /** Replace the stored entries (persisted at once; the sync picks the change up). */ @@ -57,35 +66,56 @@ export function applyLoadoutEntries(entries, lookup) { return { applied, dropped: Math.max(0, asked - applied) }; } -/** Open the 干員调配 screen. @param {'lobby'|'room'|'briefing'} from @param {string|null} [sel] */ -export function openLoadout(from = 'lobby', sel = null) { +/** Replace the stored not-owned list (干员持有; persisted at once, the sync picks the change up). */ +export function setNotOwned(list) { + const next = cleanIds(list); + savePref(OWNERSHIP_PREF, toStoredOwnership(next)); + loadoutStore.set({ notOwned: next }); +} + +/** + * Apply an imported not-owned list (parseOwnershipImport(...).notOwned), sanitised against the loaded data (droppable + * chess only), persisted and synced like an edit. An empty list is a valid import (= every operator owned). + * @param {string[]} list @param {(id: string) => any} lookup chess lookup + * @returns {{ applied: number, dropped: number }} + */ +export function applyOwnershipImport(list, lookup) { + const asked = cleanIds(list).length; + const clean = sanitizeNotOwned(list, lookup); + setNotOwned(clean); + return { applied: clean.length, dropped: Math.max(0, asked - clean.length) }; +} + +/** + * Open the 干员调配 overlay. @param {'lobby'|'room'|'briefing'} from @param {string|null} [sel] + * @param {'loadout'|'ownership'|null} [tab] the tab to show (default: the last one) + */ +export function openLoadout(from = 'lobby', sel = null, tab = null) { data.load('chess'); data.load('bonds'); data.load('assets'); data.load('local'); - loadoutStore.set({ open: true, from, ...(sel ? { sel } : {}) }); + data.load('backups'); + loadoutStore.set({ open: true, from, ...(sel ? { sel } : {}), ...(tab === 'loadout' || tab === 'ownership' ? { tab } : {}) }); } export const closeLoadout = () => loadoutStore.set({ open: false }); /** - * Wire the sync once. Dependencies are injectable for tests. - * @param {{ net: any, getChessReady?: () => Promise, lookupChess?: (id: string) => any, - * timers?: { setTimeout: Function, clearTimeout: Function }, target?: ReturnType }} deps - * @returns {{ flush: () => Promise, dispose: () => void }} + * One setting kept in sync with the server (see the header): `key` = the store field, `stateKey` = its sync state field, + * `msgType` / `field` = the C2S message, `prepare()` → the payload to send (may await data; null = the data is missing: + * nothing is sent, state 'error'), `lockedText` = what a refused edit tells the player. */ -export function installLoadoutSync({ net, getChessReady, lookupChess, timers, target = loadoutStore, notify } = {}) { +function installPrefSync({ net, timers, target, notify, key, stateKey, msgType, field, prepare, lockedText, tag }) { 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 seq = 0; // 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 lastSent = null; // JSON of the last payload 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 setState = (sync) => { if (target.get()[stateKey] !== sync) target.set({ [stateKey]: sync }); }; const schedule = (ms = SYNC_DEBOUNCE_MS) => { if (disposed) return; @@ -95,22 +125,17 @@ export function installLoadoutSync({ net, getChessReady, lookupChess, timers, ta }; // 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. + // the frames in order, so the newest payload always wins; 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(); + const payload = await prepare(); 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 (payload == null) { setState('error'); return; } + const json = JSON.stringify(payload); 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; @@ -119,7 +144,7 @@ export function installLoadoutSync({ net, getChessReady, lookupChess, timers, ta pendingJson = json; setState('sending'); try { - await net.request('room.loadout', { entries }); + await net.request(msgType, { [field]: payload }); if (my !== seq) return; pendingJson = null; lastSent = json; @@ -129,29 +154,29 @@ export function installLoadoutSync({ net, getChessReady, lookupChess, timers, ta 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 + // the server stored it for the next match; the running one keeps what it took lastSent = json; setState('locked'); - if (wasEdit) tell('本局的干员调配已锁定,修改将在下一局生效'); + if (wasEdit) tell(lockedText); } 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'); } + else { console.warn(`[${tag}] ${msgType} refused`, code, err && err.detail); setState('error'); } } } catch (e) { - console.warn('[loadout] sync failed', e); + console.warn(`[${tag}] 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(); } + if (s[key] !== prev[key]) { 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 + // briefing, 开始模拟 in the room — must not overtake the debounced message (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(); } }); + const offRoom = net.on('room.state', (msg) => { if (msg && !msg.inMatch && target.get()[stateKey] === 'locked') { lastSent = null; schedule(); } }); return { flush, @@ -164,3 +189,42 @@ export function installLoadoutSync({ net, getChessReady, lookupChess, timers, ta }, }; } + +/** + * Wire the loadout sync once. Dependencies are injectable for tests. + * @param {{ net: any, getChessReady?: () => Promise, lookupChess?: (id: string) => any, + * timers?: { setTimeout: Function, clearTimeout: Function }, target?: ReturnType }} deps + * @returns {{ flush: () => Promise, dispose: () => void }} + */ +export function installLoadoutSync({ net, getChessReady, lookupChess, timers, target = loadoutStore, notify } = {}) { + const ready = getChessReady || (() => data.load('chess')); + const lookup = lookupChess || ((id) => data.lookup('chess', id)); + return installPrefSync({ + net, timers, target, notify, key: 'entries', stateKey: 'sync', msgType: 'room.loadout', field: 'entries', tag: 'loadout', + lockedText: '本局的干员调配已锁定,修改将在下一局生效', + async prepare() { + 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 + if (!current || Object.keys(current).length === 0) return {}; + const loaded = await ready(); + if (loaded == null) return null; + return sanitizeEntries(target.get().entries, lookup); + }, + }); +} + +/** + * Wire the 干员持有 sync once (0.2.0 补位): `room.ownership { notOwned }` after every welcome and edit. The list goes as + * stored (structurally clean); the server keeps its droppable chess. During a match the server stores it for the next + * one (ROOM_STARTED → 'locked'): the setting is out of match. + * @param {{ net: any, timers?: { setTimeout: Function, clearTimeout: Function }, target?: ReturnType, + * notify?: (text: string) => void }} deps + */ +export function installOwnershipSync({ net, timers, target = loadoutStore, notify } = {}) { + return installPrefSync({ + net, timers, target, notify, key: 'notOwned', stateKey: 'ownSync', msgType: 'room.ownership', field: 'notOwned', tag: 'ownership', + lockedText: '干员持有是局外设置,修改将在下一局生效', + prepare: async () => cleanIds(target.get().notOwned), + }); +} diff --git a/public/js/ui/ownershipModel.js b/public/js/ui/ownershipModel.js new file mode 100644 index 00000000..9b31fc76 --- /dev/null +++ b/public/js/ui/ownershipModel.js @@ -0,0 +1,168 @@ +// 干员持有 (operator ownership, 0.2.0 补位) — pure logic of the 干员持有 tab of the 干员调配 overlay (screens/ownership.js), +// shared with the sync (ui/loadoutSync.js) and the tests. The approved plan (owner's decision 2026-10-05): a player marks +// operators as not owned ("下掉"); only the 55 NORMAL chess can be dropped (shared/standIn.js isDroppableChess — the 74 +// PRESET chess always field their own operator); default = everything owned; out of match (the next match takes it); +// co-op: the player's own pieces only; no friend borrowing. A dropped chess keeps its card (name, bonds, price) and is +// deployed as its official stand-in (data/backups.json). +// +// The per-browser setting is a sorted list of base chess ids, persisted in localStorage (`sp.pref.ownership` = +// { v: 1, notOwned }) and sent with C2S `room.ownership { notOwned }` (the server keeps the droppable ids and drops the +// rest — shared/protocol.js checkNotOwned — so a stale list never gets refused). 导出 / 导入 use a versioned envelope +// like the loadout presets. + +import { checkNotOwned, OWNERSHIP_LIMITS } from '../../../shared/protocol.js'; +import { isDroppableChess, standInRecord } from '../../../shared/standIn.js'; + +/** localStorage key (store.js loadPref/savePref prefix `sp.pref.`) and format version. */ +export const OWNERSHIP_PREF = 'ownership'; +export const OWNERSHIP_VERSION = 1; +/** `kind` of an exported ownership envelope. */ +export const OWNERSHIP_EXPORT_KIND = 'stronghold.ownership'; +/** A picked file / pasted payload longer than this is refused before parsing (a real payload is under 4 KB). */ +export const OWNERSHIP_IMPORT_MAX_BYTES = 64 * 1024; + +const ID_RE = /^[A-Za-z0-9_\-.:]{1,64}$/; +const UNSAFE_IDS = new Set(['__proto__', 'constructor', 'prototype']); +const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v); + +/** + * Structurally valid, deduplicated, sorted ids of a raw list (junk dropped; at most OWNERSHIP_LIMITS.notOwned). + * @param {any} list + * @returns {string[]} + */ +export function cleanIds(list) { + const out = new Set(); + for (const id of Array.isArray(list) ? list : []) { + if (out.size >= OWNERSHIP_LIMITS.notOwned) break; + if (typeof id === 'string' && ID_RE.test(id) && !UNSAFE_IDS.has(id)) out.add(id); + } + return [...out].sort(); +} + +/** + * Parse a stored setting (any junk → []): `{ v, notOwned }` or a bare array from an older build. + * @param {any} raw + * @returns {string[]} + */ +export function parseStoredOwnership(raw) { + if (Array.isArray(raw)) return cleanIds(raw); + return isObj(raw) ? cleanIds(raw.notOwned) : []; +} + +/** Serialised form for localStorage. */ +export const toStoredOwnership = (notOwned) => ({ v: OWNERSHIP_VERSION, notOwned: cleanIds(notOwned) }); + +/** + * The ids to keep / send against the loaded data: droppable chess only (checkNotOwned), sorted. + * @param {any} notOwned @param {(id: string) => any} getChess + * @returns {string[]} + */ +export function sanitizeNotOwned(notOwned, getChess) { + const res = checkNotOwned(cleanIds(notOwned), getChess); + return res && res.ok ? res.notOwned : []; +} + +/** Whether a chess is owned under a not-owned list (its base id is absent). */ +export function isOwned(notOwned, chessId) { + return !(Array.isArray(notOwned) && notOwned.includes(chessId)); +} + +/** + * Toggle one chess: owned ↔ not owned. Returns a new sorted list (the input is not changed). + * @param {string[]} notOwned @param {string} chessId base chess id + * @param {boolean} [owned] the wanted state (default: the other one) + */ +export function setOwned(notOwned, chessId, owned = !isOwned(notOwned, chessId)) { + const set = new Set(cleanIds(notOwned)); + if (owned) set.delete(chessId); else set.add(chessId); + return [...set].sort(); +} + +// ---- roster ------------------------------------------------------------------------------------------------------- + +/** + * The chess the screen lists: droppable chess in this season's shop (visible), in shop order (tier, shopSortId). + * @param {any[]} list data.list('chess') + */ +export function ownershipRoster(list) { + return (Array.isArray(list) ? list : []) + .filter((c) => isDroppableChess(c) && c.visible !== false && !c.isHidden) + .sort((a, b) => (a.tier ?? 0) - (b.tier ?? 0) || (a.shopSortId ?? 0) - (b.shopSortId ?? 0) || String(a.chessId).localeCompare(String(b.chessId))); +} + +/** The roster grouped by tier: [{ tier, list }] in tier order. */ +export function rosterByTier(roster) { + const by = new Map(); + for (const c of roster) { + if (!by.has(c.tier)) by.set(c.tier, []); + by.get(c.tier).push(c); + } + return [...by.entries()].sort((a, b) => a[0] - b[0]).map(([tier, list]) => ({ tier, list })); +} + +/** + * What the screen shows of a chess's stand-in: the composed record (shared/standIn.js — the stand-in at the chess's + * normal status with its backup skill) reduced to the card's fields; null without the data. + * @param {any} chess normal chess record @param {any} backups data/backups.json + * @returns {{ charId: string, name: string, appellation: string, profession: string, subProfessionName: string, + * rarity: number, skill: any, record: any } | null} + */ +export function standInSummary(chess, backups) { + let rec; + try { rec = standInRecord(chess, backups); } catch { rec = null; } + if (!rec) return null; + return { + charId: rec.charId, name: rec.name, appellation: rec.appellation || '', profession: rec.profession, + subProfessionName: rec.subProfessionName || '', rarity: rec.rarity, skill: rec.skill, record: rec, + }; +} + +/** Number of listed chess marked as not owned (ids of retired / unknown chess do not count). */ +export function notOwnedCount(notOwned, roster) { + const ids = new Set(Array.isArray(notOwned) ? notOwned : []); + let n = 0; + for (const c of roster || []) if (ids.has(c.chessId)) n++; + return n; +} + +// ---- export / import ------------------------------------------------------------------------------------------------- + +/** Portable payload of a not-owned list, as downloaded / copied by 导出. */ +export function exportOwnership(notOwned, { now = Date.now() } = {}) { + const list = cleanIds(notOwned); + return { + kind: OWNERSHIP_EXPORT_KIND, + v: OWNERSHIP_VERSION, + exportedAt: new Date(Number.isFinite(now) ? now : Date.now()).toISOString(), + count: list.length, + notOwned: list, + }; +} + +/** Pretty JSON of `exportOwnership`. */ +export const serializeOwnership = (notOwned, opts) => JSON.stringify(exportOwnership(notOwned, opts), null, 2); + +/** + * Parse an imported list: the envelope, the stored `{ v, notOwned }` form, a bare array of ids, or the text of any of + * them. STRUCTURAL only — the caller sanitises against the loaded data (sanitizeNotOwned). An envelope may hold an empty + * list (= every operator owned). + * @param {any} input payload or text + * @returns {{ ok: true, notOwned: string[] } | { ok: false, error: string }} + */ +export function parseOwnershipImport(input) { + let raw = input; + if (typeof raw === 'string') { + if (raw.length > OWNERSHIP_IMPORT_MAX_BYTES) return { ok: false, error: '内容过长,无法导入' }; + const text = raw.trim(); + if (!text) return { ok: false, error: '没有可导入的内容' }; + try { raw = JSON.parse(text); } catch { return { ok: false, error: '无法识别的内容' }; } + } + if (Array.isArray(raw)) return { ok: true, notOwned: cleanIds(raw) }; + if (!isObj(raw)) return { ok: false, error: '无法识别的格式' }; + const v = Number.isInteger(raw.v) ? raw.v : null; + if (v != null && v > OWNERSHIP_VERSION) return { ok: false, error: `这份数据来自更新的版本(v${v}),请先更新游戏` }; + const kind = typeof raw.kind === 'string' ? raw.kind : null; + if (kind && kind !== OWNERSHIP_EXPORT_KIND) return { ok: false, error: '这不是干员持有的数据' }; + if (!Array.isArray(raw.notOwned)) return { ok: false, error: '里面没有干员持有的数据' }; + return { ok: true, notOwned: cleanIds(raw.notOwned) }; +} diff --git a/public/js/ui/shopBar.js b/public/js/ui/shopBar.js index f4c16081..967bbdb2 100644 --- a/public/js/ui/shopBar.js +++ b/public/js/ui/shopBar.js @@ -17,12 +17,14 @@ // item slot drawn as an item card (player report #6 after 0.1.0). // 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). +// module type of an elite card, with its official type icon when the local-client art has it). A chess the player does +// not own (0.2.0 补位, m.private.standIns) keeps its card — name, portrait, bonds, price — with an ice 「替补:X」 badge, +// and the skill icon is the stand-in's (its backup skill: the one that fights). 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, mergeTarget, shopBlockReason, chessLoadout, offerHeader, briefingBondTip } from './gameLogic.js'; +import { priceTone, mergeProgress, mergeTarget, shopBlockReason, chessLoadout, offerHeader, briefingBondTip, fieldsStandIn, standInOf, standInLoadout, standInLabel } from './gameLogic.js'; import { chessPortraitUrl, itemIconUrl, profIconUrl, uiUrl, skillIconUrl, skillRecordIconUrl, moduleTypeIconUrl } from './assetUrls.js'; import { data } from '../data.js'; @@ -70,7 +72,9 @@ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free const willMerge = !!hint; const bonds = Array.isArray(c?.bonds) ? c.bonds : []; const disabled = !!reason; - const lo = c ? chessLoadout(c, priv?.loadout, LOOKUPS.getChess) : null; + // 0.2.0 补位: the player's not-owned chess is deployed as its stand-in — badge + the stand-in's skill + const si = c && fieldsStandIn(priv, c) ? standInOf(c, data.get('backups')) : null; + const lo = si ? standInLoadout(si, LOOKUPS.getChess, data.get('backups')) : c ? chessLoadout(c, priv?.loadout, LOOKUPS.getChess) : null; const tap = () => { if (onTap) onTap(idx); else if (!disabled) onBuy(idx); else onDetail(slot.id, 'chess', hint); }; const card = html`