diff --git a/public/css/screens/game-panels.css b/public/css/screens/game-panels.css index a7c3fd65..aee835ec 100644 --- a/public/css/screens/game-panels.css +++ b/public/css/screens/game-panels.css @@ -91,6 +91,7 @@ .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-diy { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; letter-spacing: .08em; background: var(--mint-500); color: var(--text-on-mint); } .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; } @@ -558,6 +559,7 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; } .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; } +.sitag--diy { background: var(--mint-500); color: var(--text-on-mint); } .gm.is-dragging .sitags { display: none; } /* ---- underframe: the selection diamond of a tapped own piece (ui/underframe.js): 撤退 / 出售 +N / 销毁 ---- diff --git a/public/css/screens/game-shop.css b/public/css/screens/game-shop.css index 6fa003fe..3ac8c551 100644 --- a/public/css/screens/game-shop.css +++ b/public/css/screens/game-shop.css @@ -133,6 +133,9 @@ /* 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); } +/* 0.2.0 自选编队: a DIY slot the player filled — the card is its operator, only in this player's shop (ui/shopBar.js) */ +.scard__diy { position: absolute; z-index: 3; left: 0; top: .38rem; font-size: .12rem; font-weight: 700; padding: .01rem .06rem; + background: var(--mint-500); color: var(--text-on-mint); letter-spacing: .08em; white-space: nowrap; 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 8f6b8861..910842aa 100644 --- a/public/css/screens/loadout.css +++ b/public/css/screens/loadout.css @@ -385,6 +385,104 @@ .lo-tab { padding: .02rem .08rem; letter-spacing: .04em; } } +/* ---- 自选编队 (screens/diy.js): four DIY slots by tier, the slot picker (operator list + skill / module) --------------- */ +.diy { position: relative; flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; gap: .12rem; padding: .16rem .3rem .2rem; } +.diy__body { 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; } +.diy-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(max(5.2rem, 300px), 1fr)); gap: .1rem; } +.diy-slot { + position: relative; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: .1rem; align-items: start; min-width: 0; + padding: .08rem; background: rgba(255, 255, 255, .03); border: 1px solid var(--line); border-left: 3px solid var(--tc, var(--line-3)); +} +.diy-slot--t5 { --tc: var(--tier-5); } .diy-slot--t6 { --tc: var(--tier-6); } +.diy-slot.is-empty { display: flex; flex-direction: column; padding: 0; border-style: dashed; border-left-style: solid; } +.diy-slot__fill { + display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .05rem; width: 100%; min-height: max(1.3rem, 84px); + padding: .1rem; font: inherit; color: var(--text-md); background: none; border: 0; cursor: pointer; touch-action: manipulation; +} +.diy-slot__fill:hover { background: rgba(255, 255, 255, .05); color: var(--text-hi); } +.diy-slot__fill b { font-size: max(.16rem, 11px); } +.diy-slot__fill small { font-size: max(.12rem, 9px); color: var(--text-lo); } +.diy-slot__plus { display: grid; place-items: center; width: max(.4rem, 26px); height: max(.4rem, 26px); border: 1px solid var(--line-3); border-radius: 50%; } +.diy-slot__plus .icon { width: 60%; height: 60%; } +.diy-slot__art { display: grid; place-items: center; width: max(.9rem, 56px); height: max(1.3rem, 84px); overflow: hidden; background: #0a0d0c; border: 1px solid var(--line-2); } +.diy-slot__art img { width: 100%; height: 100%; object-fit: cover; object-position: top; } +.diy-slot__art b { font-size: max(.3rem, 18px); color: var(--text-lo); } +.diy-slot__body { display: flex; flex-direction: column; gap: .03rem; min-width: 0; } +.diy-slot__head { display: flex; align-items: center; gap: .05rem; } +.diy-slot__label { font-size: max(.12rem, 9px); color: var(--text-lo); letter-spacing: .06em; } +.diy-slot__name { font-size: max(.17rem, 12px); font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.diy-slot__class { font-size: max(.12rem, 9px); color: var(--text-lo); } +.diy-slot__kit { display: flex; align-items: center; gap: .05rem; min-width: 0; font-size: max(.13rem, 9px); color: var(--text-md); } +.diy-slot__kit > span { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.diy-slot__sicon, .diy-slot__micon { flex: none; display: inline-grid; place-items: center; width: max(.24rem, 16px); height: max(.24rem, 16px); object-fit: contain; font-size: .8em; color: var(--text-lo); } +.diy-slot__micon--none { border: 1px solid var(--line-2); } +.diy-slot__acts { display: flex; flex-direction: column; gap: .04rem; } +.diy-slot__bad { display: flex; align-items: center; gap: .04rem; margin: .02rem 0 0; font-size: max(.12rem, 9px); color: var(--amber); } +.diy-slot.is-bad { border-color: rgba(255, 196, 0, .45); } +.diy-tag { display: inline-flex; align-items: center; height: 1.5em; padding: 0 .4em; font-size: max(.11rem, 8px); font-weight: 700; border-radius: 1em; white-space: nowrap; } +.diy-tag--proto { color: var(--ice-900); background: var(--ice); } +.diy-tag--own { color: var(--text-on-mint); background: var(--mint-500); } +.diy-bonds { display: flex; flex-wrap: wrap; gap: .03rem .06rem; } +.diy-bond { display: inline-flex; align-items: center; gap: .03rem; font-size: max(.12rem, 9px); color: var(--text-md); } +.diy-bond .bglyph, .diy-bond img { width: max(.16rem, 11px); height: max(.16rem, 11px); } +/* the picker covers the tab: the list of the slot's tier on the left, the chosen operator's skill / module on the right */ +.diy-pick { position: absolute; inset: 0; z-index: 3; display: flex; flex-direction: column; gap: .08rem; padding: .12rem .3rem .14rem; background: var(--bg-1, #0d1110); } +.diy-pick__head { flex: none; display: flex; align-items: center; gap: .08rem; font-size: max(.16rem, 11px); } +.diy-pick__note { flex: 1; min-width: 0; font-size: max(.12rem, 9px); color: var(--text-lo); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.diy-pick__x { display: grid; place-items: center; width: max(.4rem, 28px); height: max(.4rem, 28px); color: var(--text-md); background: none; border: 1px solid var(--line-2); cursor: pointer; } +.diy-pick__bar { flex: none; display: flex; align-items: center; gap: .1rem; flex-wrap: wrap; } +.diy-pick__search { flex: 1 1 2rem; min-width: 120px; height: max(.36rem, 26px); padding: 0 .08rem; font: inherit; font-size: max(.13rem, 10px); color: var(--text-hi); background: rgba(255, 255, 255, .04); border: 1px solid var(--line-2); } +.diy-pick__main { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: .12rem; } +.diy-pick__list, .diy-pick__detail { min-height: 0; overflow: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: var(--line-3) transparent; } +.diy-pick__list { display: grid; grid-template-columns: repeat(auto-fill, minmax(max(3.2rem, 200px), 1fr)); align-content: start; gap: .06rem; } +.diy-opt { + position: relative; display: flex; align-items: center; gap: .07rem; min-width: 0; padding: .06rem; font: inherit; color: var(--text-hi); text-align: left; + background: rgba(255, 255, 255, .03); border: 1px solid var(--line); cursor: pointer; touch-action: manipulation; +} +.diy-opt:hover { background: rgba(255, 255, 255, .06); } +.diy-opt.is-sel { border-color: var(--mint-500); background: rgba(52, 211, 153, .08); } +.diy-opt.is-taken { opacity: .45; cursor: not-allowed; } +.diy-opt__ava { flex: none; display: grid; place-items: center; width: max(.5rem, 32px); height: max(.5rem, 32px); overflow: hidden; background: #0a0d0c; border: 1px solid var(--line-2); } +.diy-opt__ava img { width: 100%; height: 100%; object-fit: cover; } +.diy-opt__txt { display: flex; flex-direction: column; gap: .02rem; min-width: 0; } +.diy-opt__top { display: flex; align-items: center; gap: .04rem; min-width: 0; } +.diy-opt__name { font-size: max(.15rem, 11px); font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.diy-opt__class { display: inline-flex; align-items: center; gap: .03rem; font-size: max(.11rem, 8px); color: var(--text-lo); } +.diy-opt__prof { width: max(.14rem, 10px); height: max(.14rem, 10px); } +.diy-opt__taken { position: absolute; right: .04rem; bottom: .03rem; font-size: max(.1rem, 8px); color: var(--amber); } +.diy-pick__detail { display: flex; flex-direction: column; gap: .06rem; padding: .06rem; background: rgba(255, 255, 255, .02); border: 1px solid var(--line); } +.diy-pick__who { display: flex; align-items: center; gap: .08rem; } +.diy-pick__ava { display: grid; place-items: center; width: max(.6rem, 40px); height: max(.6rem, 40px); overflow: hidden; background: #0a0d0c; border: 1px solid var(--line-2); } +.diy-pick__ava img { width: 100%; height: 100%; object-fit: cover; } +.diy-pick__wtxt { display: flex; flex-direction: column; flex: 1; min-width: 0; } +.diy-pick__wtxt b { font-size: max(.17rem, 12px); } +.diy-pick__wtxt small { font-size: max(.12rem, 9px); color: var(--text-lo); } +.diy-pick__sec { display: flex; align-items: baseline; gap: .06rem; margin: .04rem 0 0; font-size: max(.14rem, 10px); color: var(--text-md); } +.diy-pick__sec small { font-size: max(.11rem, 8px); color: var(--text-lo); font-weight: 400; } +.diy-pick__choices { display: flex; flex-direction: column; gap: .04rem; } +.diy-choice { + display: flex; align-items: flex-start; gap: .07rem; padding: .05rem; font: inherit; color: var(--text-hi); text-align: left; + background: rgba(255, 255, 255, .03); border: 1px solid var(--line); cursor: pointer; touch-action: manipulation; +} +.diy-choice:disabled { cursor: default; } +.diy-choice.is-on { border-color: var(--mint-500); background: rgba(52, 211, 153, .08); } +.diy-choice__icon { flex: none; display: grid; place-items: center; width: max(.36rem, 24px); height: max(.36rem, 24px); color: var(--text-lo); } +.diy-choice__icon img { width: 100%; height: 100%; object-fit: contain; } +.diy-choice__body { display: flex; flex-direction: column; gap: .02rem; min-width: 0; } +.diy-choice__head { display: flex; align-items: center; gap: .05rem; flex-wrap: wrap; font-size: max(.14rem, 10px); } +.diy-choice__desc { font-size: max(.12rem, 9px); line-height: 1.45; color: var(--text-md); } +.diy-pick__foot { flex: none; display: flex; justify-content: flex-end; gap: .08rem; } +/* 0.2.0 自选编队: the slots the player filled (the entry button's chip) */ +.lo-entry__diy { color: var(--text-on-mint); background: var(--mint-500); } +@media (max-width: 760px) { + .diy { padding: .1rem .16rem .16rem; } + .diy-pick { padding: .08rem .12rem .1rem; } + .diy-pick__main { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) minmax(0, 1.1fr); } + .diy-slot { grid-template-columns: auto minmax(0, 1fr); } + .diy-slot__acts { grid-column: 1 / -1; flex-direction: row; justify-content: flex-end; } +} + /* ---- 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; } diff --git a/public/i18n/en.json b/public/i18n/en.json index 411a899b..0b02eff0 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -845,5 +845,59 @@ "【奇迹】下次刷新不消耗资金": "[Marvel] Your next refresh is free", "【维多利亚】获得{n}件维式重锤": "[Victoria] Obtained {n} {n|Victorian Hammer|Victorian Hammers}", "【远见】所有干员购买价格永久降低": "[Foresight] Every Operator's purchase price is permanently reduced", - "【远见】远见干员购买价格永久降低": "[Foresight] [Foresight] Operators' purchase price is permanently reduced" + "【远见】远见干员购买价格永久降低": "[Foresight] [Foresight] Operators' purchase price is permanently reduced", + "原型": "Prototype", + "已持有": "Owned", + "{tier}阶 自选": "Tier {tier} Custom", + "{slot}:选择干员": "{slot}: choose an Operator", + "选择干员": "Choose an Operator", + "调度中心等级 {level} 起出现在商店": "In the shop from Dispatch Center level {level}", + "这项自选在当前版本不可用,开局时会被移除": "This pick is not available in this version; it is dropped when the match starts", + "(锁定)": "(locked)", + "(精锐时生效)": "(as an Elite)", + "更换": "Change", + "清空": "Clear", + "已在{tier}阶自选中": "Already in a Tier {tier} Custom slot", + "锁定": "Locked", + "6★ 原型干员与持有的 6★ 干员,另有 4★ 原型干员(先锋、特种除外)": "6★ prototype Operators and the 6★ Operators you own, plus 4★ Reserve Operators (no Vanguards or Specialists)", + "6★ 原型干员与持有的 6★ 干员": "6★ prototype Operators and the 6★ Operators you own", + "筛选": "Filter", + "可选干员": "Operators to pick", + "连接服务器后才能选择自选干员": "Connect to the server to pick Custom Squad Operators", + "原型干员的技能与补位时一致,不可更改": "A prototype carries the skill it uses as a stand-in; it cannot be changed", + "精锐时生效 · 模组等级 {stage}": "As an Elite · module stage {stage}", + "从左侧选择一名干员": "Choose an Operator on the left", + "5阶与6阶各有两个自选名额:选择持有的 6★ 干员(任选技能与模组)或原型干员(技能锁定)。自选干员只出现在你自己的商店里,调度中心达到该阶等级后才会刷新,价格与同阶干员相同;盟约按干员实际所属阵营分配,没有特质。": "Tiers 5 and 6 have two Custom Squad slots each: pick a 6★ Operator you own (any skill and module) or a prototype Operator (skill locked). A Custom Squad Operator appears only in your own shop, once the Dispatch Center reaches its tier, at the price of any Operator of that tier; its Alliances follow its actual factions, and it has no Attribute.", + "{tier}阶": "Tier {tier}", + "阶": "Tier", + "自选编队:{name}(只在你的商店出现)": "Custom Squad: {name} (only in your shop)", + "自选": "Custom", + "自选编队:所选技能与模组,没有特质,盟约按所属阵营分配": "Custom Squad: the chosen skill and module, no Attribute, Alliances by its factions", + "自选编队:{name}": "Custom Squad: {name}", + "全部清空": "Clear All", + "清空全部 {n} 个自选名额?": "Clear all {n} Custom Squad {n|slot|slots}?", + "文件过大,请选择「导出」下载的自选编队文件": "The file is too large. Choose a Custom Squad file downloaded with \"Export\".", + "导入失败:{error}": "Import failed: {error}", + "已导入 {n} 个自选名额": "Imported {n} Custom Squad {n|slot|slots}", + "(另有 {n} 项不可用,未导入)": " ({n} unavailable {n|entry was|entries were} not imported)", + "已导入:全部名额为空": "Imported: every slot empty", + "自选编队是局外设置:本局按开局时的设置进行,修改将在下一局生效": "The Custom Squad is set outside matches: this match keeps the picks it started with; changes apply from the next match", + "局外设置,下一局生效 · 联机时只进入你自己的商店": "Set outside matches, applies from the next match · in team play only in your own shop", + "干员调配 / 干员持有 / 自选编队": "Operator Loadout / Operator Roster / Custom Squad", + "5阶、6阶的自选名额:持有的 6★ 干员或原型干员": "The Custom Squad slots of tiers 5 and 6: 6★ Operators you own or prototype Operators", + "自选编队": "Custom Squad", + "{n} 个自选名额已选择": "{n} Custom Squad {n|slot|slots} filled", + "已选": "Filled", + "导出自选编队(可复制或下载)": "Export the Custom Squad (copy or download)", + "导入自选编队(粘贴或选择文件)": "Import a Custom Squad (paste or choose a file)", + "共 {n} 个自选名额已选择。复制或下载这份数据,即可在别的设备或浏览器上导入。": "{n} Custom Squad {n|slot|slots} filled. Copy or download this data to import it on another device or browser.", + "把导出的自选编队数据粘贴到下方,或点「选择文件」。导入会覆盖当前的自选编队。": "Paste exported Custom Squad data below, or click \"Choose File\". Importing replaces the current Custom Squad.", + "在此粘贴导出的自选编队内容…": "Paste the exported Custom Squad here…", + "导出自选编队": "Export Custom Squad", + "导入自选编队": "Import Custom Squad", + "自选编队是局外设置,修改将在下一局生效": "The Custom Squad is set outside matches; changes apply from the next match", + "下一局生效": "Applies next match", + "这份数据来自更新的版本(v{v}),请先更新游戏": "This data comes from a newer version (v{v}). Update the game first.", + "这不是自选编队的数据": "This is not Custom Squad data", + "里面没有自选编队的数据": "It holds no Custom Squad data" } diff --git a/public/js/main.js b/public/js/main.js index e275d341..5b109e91 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -21,7 +21,8 @@ // 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), its 干员持有 tab's -// not-owned list (0.2.0 补位) by installOwnershipSync (room.ownership, likewise). +// not-owned list (0.2.0 补位) by installOwnershipSync (room.ownership, likewise), the 自选编队 picks (0.2.0 DIY) by +// installDiySync (room.diy, likewise; it also keeps welcome.diyKitted for the picker). // 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. // Language (ui/lang.js, docs/I18N.md): chosen before the first render (initLang); App re-renders on a switch (useLang). @@ -48,7 +49,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, installOwnershipSync } from './ui/loadoutSync.js'; +import { installLoadoutSync, installOwnershipSync, installDiySync } from './ui/loadoutSync.js'; import { startBuildGuard } from './ui/buildGuard.js'; import { initLang, useLang, tickerText } from './ui/lang.js'; import { t, N_, translateWire } from '../../shared/i18n.js'; @@ -340,6 +341,7 @@ async function boot() { wireNet(); installLoadoutSync({ net }); installOwnershipSync({ net }); + installDiySync({ 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 613260ac..a4f86610 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -276,6 +276,7 @@ export async function createFieldView(host, options = {}) { 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 diyPicks = {}; // the own m.private.diy (0.2.0 自选编队): pieces of these DIY slots draw the operator 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) @@ -288,7 +289,7 @@ export async function createFieldView(host, options = {}) { timeScale: () => (mode === 'battle' ? interp.rate : 1), // (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)), + : (info.standInFor && data.standIn(info.defId)) || (info.diy && data.diy(info.defId, info.diy)) || data.chess(info.defId) || data.token(info.defId)), crowded: () => views.size > 90, renderer: app.renderer, frameNo: () => frameNo, @@ -304,7 +305,7 @@ export async function createFieldView(host, options = {}) { timeScale: () => ctx.timeScale(), loadLevel: () => loadLevel, fieldRect: () => (mode === 'battle' && battleMeta ? battleMeta.rect : null), - subProfOf: (defId, info = null) => ((info && info.standInFor && data.standIn(defId)) || data.chess(defId))?.subProfessionId || null, + subProfOf: (defId, info = null) => ((info && info.standInFor && data.standIn(defId)) || (info && info.diy && data.diy(defId, info.diy)) || data.chess(defId))?.subProfessionId || null, view: (id) => views.get(id) || null, screenSize: size, fieldTop: () => { @@ -633,12 +634,16 @@ export async function createFieldView(host, options = {}) { // 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; + // 0.2.0 自选编队: a DIY slot the player filled is its operator, on the board and on the bench alike + const pick = chess && chess.isDiy ? diyPicks[chess.baseId || chess.chessId] : null; + const dr = pick ? data.diy(piece.id, pick) : null; + const rec = si || dr || chess; return { kind: 'op', side: 'ally', defId: piece.id, spine: rec?.assets?.spine || rec?.charId || null, avatar: rec?.assets?.avatar || rec?.charId || null, tier: chess?.tier || piece.tier || 1, golden: !!(piece.golden || chess?.isGolden), dir, ...(si ? { standInFor: si.standInFor } : null), + ...(dr ? { diy: { charId: pick.charId, skillIndex: pick.skillIndex ?? null, uniEquipId: pick.uniEquipId ?? null } } : null), }; } @@ -687,6 +692,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') : []; + diyPicks = src.diy && typeof src.diy === 'object' ? src.diy : {}; ownPen = Array.isArray(src.nextEnemies) ? src.nextEnemies : null; setPenList(ownPen); addList(src.hand, 'hand'); diff --git a/public/js/render/app/host.js b/public/js/render/app/host.js index 4c93c19f..8be7e73a 100644 --- a/public/js/render/app/host.js +++ b/public/js/render/app/host.js @@ -2,6 +2,7 @@ import { createAssets, assets as defaultAssets } from '../../assets.js'; import { standInRecord } from '../../../../shared/standIn.js'; +import { diyRecordOf } from '../../../../shared/diy.js'; function withTimeout(p, ms) { return Promise.race([p, new Promise((resolve) => setTimeout(resolve, ms))]); @@ -40,10 +41,26 @@ function makeData(src) { standIns.set(c, rec); return rec; }; + // 0.2.0 自选编队: the composed 自选 record of a DIY slot filled with a pick (shared/diy.js over chess.json + backups.json) + const diys = new Map(); + const chessMap = () => { + try { return (src && typeof src.get === 'function' ? src.get('chess') : src && src.chess) || null; } catch { return null; } + }; + const diy = (id, pick) => { + const c = look('chess', id); + const b = backups(); + if (!c || !c.isDiy || !b || !pick || typeof pick.charId !== 'string') return null; + const key = `${id}|${pick.charId}|${pick.skillIndex ?? ''}|${pick.uniEquipId ?? ''}`; + if (diys.has(key)) return diys.get(key); + let rec; + try { rec = diyRecordOf(c, pick, { chess: chessMap(), backups: b }); } catch { rec = null; } + diys.set(key, 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, + standIn, diy, }; } diff --git a/public/js/render/app/info.js b/public/js/render/app/info.js index a51d0c1a..1eaa8306 100644 --- a/public/js/render/app/info.js +++ b/public/js/render/app/info.js @@ -45,5 +45,10 @@ export function renderInfo(u) { // 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, + // 0.2.0 自选编队: the pick of a DIY slot's unit (UnitInfo.diy { charId, skillIndex, uniEquipId }; spine / avatar / name + // are already the operator's) — the detail card composes the operator from it, the data lookups follow it + diy: u.diy && typeof u.diy === 'object' && typeof u.diy.charId === 'string' + ? { charId: u.diy.charId, skillIndex: Number.isInteger(u.diy.skillIndex) ? u.diy.skillIndex : null, uniEquipId: typeof u.diy.uniEquipId === 'string' ? u.diy.uniEquipId : null } + : undefined, }; } diff --git a/public/js/screens/diy.js b/public/js/screens/diy.js new file mode 100644 index 00000000..00c8a7aa --- /dev/null +++ b/public/js/screens/diy.js @@ -0,0 +1,244 @@ +// 自选编队 (0.2.0 DIY; research 0.2.0 §2, the owner's decisions of 2026-10-05): the third tab of the 干员调配 overlay +// (screens/loadout.js). Four slots — 5阶 ×2, 6阶 ×2 — each filled with an operator: a 6★ the player owns outside the +// chess pool (any of its three skills, any module of its elite form but a 集成战略 one) or a prototype (原型干员, its skill +// and module locked as when it stands in). Only operators with a kit are listed (the server's welcome.diyKitted). The +// slot picker lists the legal picks of the slot's tier — avatar, name, class, the bonds derived from its factions, a +// 原型 / 已持有 tag —, an operator held by another slot greyed out with where it is. In the match the operator is sold +// only in this player's shop, from 调度中心 level 5 (tier 5) / 6 (tier 6), and fights with the pick (server/match/player +// /diy.js). Out of match: the next match takes the picks. The picks live in ui/loadoutSync.js (localStorage + room.diy); +// the model is ui/diyModel.js. Styles: css/screens/loadout.css (diy-*). + +import { useState } from '../../vendor/hooks.module.js'; +import { html, Icon, Button, TierChip } from '../ui/components.js'; +import { Img, RichText, BondGlyph } from '../ui/gameComponents.js'; +import { chessAvatarUrl, chessPortraitUrl, profIconUrl, skillRecordIconUrl, moduleTypeIconUrl } from '../ui/assetUrls.js'; +import { data } from '../data.js'; +import { PROF_NAME, skillLabel, moduleBadge } from '../ui/loadoutModel.js'; +import { diySlotList, pickChoices, pickOptions, slotRecord, defaultPick } from '../ui/diyModel.js'; +import { t } from '../../../shared/i18n.js'; + +const cx = (...p) => p.flat().filter(Boolean).join(' '); +const ROMAN = ['', 'I', 'II', 'III', 'IV', 'V', 'VI']; + +/** The data the 自选 rules read (shared/diy.js): chess.json + backups.json (localized copies). */ +export const diyData = () => ({ chess: data.get('chess'), backups: data.get('backups') }); + +/** A unit summary's avatar / portrait URL (a backups.json unit or a composed record). */ +const avatarOf = (m, unit, elite = false) => chessAvatarUrl(m, unit && { charId: unit.charId, assets: { avatar: elite ? unit.assets?.avatarGolden : unit.assets?.avatar } }); +const classLine = (u) => `${t(PROF_NAME[u?.profession] || '') || ''}${u?.subProfessionName ? ` · ${u.subProfessionName}` : ''}`; +const bondName = (id) => data.lookup('bonds', id)?.name || id; + +/** The 原型 / 已持有 tag of a pick. */ +function KindTag({ proto }) { + return html`${proto ? t('原型') : t('已持有')}`; +} + +/** Bond chips (the bonds derived from the operator's factions; 协防 when none matches). */ +function Bonds({ bonds }) { + return html`${(bonds || []).map((b) => html`<${BondGlyph} bondId=${b} />${bondName(b)}`)}`; +} + +/** One slot: the pick (operator, class, bonds, skill, module) or an empty slot to fill. */ +function SlotCard({ m, slot, pick, illegal, onOpen, onClear }) { + const D = diyData(); + const rec = pick ? slotRecord(slot.slotId, pick, D) : null; + const elite = pick ? slotRecord(slot.slotId, pick, D, { elite: true }) : null; + const label = t('{tier}阶 自选', { tier: slot.tier }); + if (!rec) { + return html`
+ + ${pick ? html`

<${Icon} name="warn" />${t('这项自选在当前版本不可用,开局时会被移除')}

` : null} +
`; + } + const proto = !!pick && D.backups?.diy?.prototypes?.[slot.tier]?.includes(pick.charId); + const sk = rec.skill; + const mod = elite?.module?.active ? elite.module : null; + return html`
+
<${Img} src=${chessPortraitUrl(m, elite || rec)} fallback=${html`${[...(rec.name || '?')][0]}`} />
+
+
<${TierChip} tier=${slot.tier} size="sm" />${label}<${KindTag} proto=${proto} />
+ ${rec.name} + ${classLine(rec)} + <${Bonds} bonds=${rec.bonds} /> + + <${Img} src=${skillRecordIconUrl(m, sk, { empty: false })} class="diy-slot__sicon" fallback=${html`${skillLabel(sk?.index)}`} /> + ${skillLabel(sk?.index)} ${sk?.name || ''}${proto ? html` ${t('(锁定)')}` : null} + + + ${mod ? html`<${Img} src=${moduleTypeIconUrl(data.get('local'), mod.type)} class="diy-slot__micon" fallback=${html`${moduleBadge({ typeName: mod.type })}`} />` : html`—`} + ${mod ? html`${mod.type || ''} ${mod.name || ''}` : t('不装备模组')} ${t('(精锐时生效)')} + + ${illegal ? html`

<${Icon} name="warn" />${t('这项自选在当前版本不可用,开局时会被移除')}

` : null} +
+
+ <${Button} variant="secondary" size="sm" icon="refresh" data-testid="diy-change" onClick=${() => onOpen(slot.slotId)}>${t('更换')} + <${Button} variant="ghost" size="sm" icon="close" data-testid="diy-clear" onClick=${() => onClear(slot.slotId)}>${t('清空')} +
+
`; +} + +/** One operator of the picker's list. */ +function OptionCard({ m, opt, selected, onSel }) { + const u = opt.unit; + const name = u?.name || opt.charId; + const takenBy = opt.taken ? diySlotList(diyData()).find((s) => s.slotId === opt.taken) : null; + return html``; +} + +/** A skill choice of an owned pick (or the locked skill of a prototype). */ +function SkillRow({ m, s, on, locked, onPick }) { + const rec = s.elite || s.normal; + return html``; +} + +/** A module choice of an owned pick (不装备 or a module of the elite form). */ +function ModuleRow({ id, rec, on, locked, onPick }) { + const none = id == null; + const talents = rec && Array.isArray(rec.talentChanges) ? rec.talentChanges.filter((x) => x && x.desc && !x.hidden) : []; + const trait = rec?.traitOverride ? (rec.traitOverride.moduleDescRaw || rec.traitOverride.moduleDesc || rec.traitOverride.descRaw || rec.traitOverride.desc) : null; + return html``; +} + +/** + * The slot picker: the legal operators of the slot's tier (prototypes first, then the owned 6★; a 原型 / 已持有 filter and + * a search), the chosen one's skill / module (an owned pick) or its locked selection (a prototype), 确认 / 取消. The + * state (filter, search, the draft pick) lives here; DiyPickerView draws it. + * @param {{ m: any, slot: any, picks: Record, kitted: string[]|null, onDone: (pick: any) => void, onClose: () => void }} props + */ +export function DiyPicker(props) { + const cur = props.picks[props.slot.slotId] || null; + const [filter, setFilter] = useState('all'); + const [query, setQuery] = useState(''); + const [draft, setDraft] = useState(cur ? { ...cur } : null); + return DiyPickerView({ ...props, filter, query, draft, onFilter: setFilter, onQuery: setQuery, onDraft: setDraft }); +} + +/** + * The picker's view (no hooks: the tests draw it): `filter` 'all' | 'proto' | 'owned', `query`, `draft` the pick being + * made (null = none chosen yet) and their setters. + */ +export function DiyPickerView({ m, slot, picks, kitted, onDone, onClose, filter, query, draft, onFilter: setFilter, onQuery: setQuery, onDraft: setDraft }) { + const D = diyData(); + const cur = picks[slot.slotId] || null; + const options = pickOptions(slot.slotId, picks, D, kitted); + const q = query.trim().toLowerCase(); + const list = options.filter((o) => (filter === 'all' || (filter === 'proto') === o.proto) + && (!q || [o.unit?.name, o.unit?.appellation, o.unit?.subProfessionName, t(PROF_NAME[o.unit?.profession] || ''), ...o.bonds.map(bondName)].some((x) => typeof x === 'string' && x.toLowerCase().includes(q)))); + const ch = draft ? pickChoices(draft.charId, slot.slotId, D) : null; + const sel = (charId) => setDraft(cur && cur.charId === charId ? { ...cur } : defaultPick(charId, slot.slotId, D)); + const lk = ch?.locked || null; + const skillOn = ch ? (ch.proto ? lk?.skillIndex : draft.skillIndex) : null; + const modOn = ch ? (ch.proto ? lk?.uniEquipId ?? null : draft.uniEquipId ?? null) : null; + const unit = draft ? D.backups?.units?.[draft.charId] : null; + const label = t('{tier}阶 自选', { tier: slot.tier }); + return html``; +} + +/** + * The tab's body: the four slots by tier, and the picker of the slot being filled (`picking`, kept here). + * @param {{ m: any, picks: Record, legal: Record, kitted: string[]|null, + * onSet: (slotId: string, pick: any) => void }} props + */ +export function DiyPanel(props) { + const [picking, setPicking] = useState(null); + return DiyPanelView({ ...props, picking, onPicking: setPicking }); +} + +/** The tab's view (no hooks: the tests draw it): `picking` = the slot being filled, or null. */ +export function DiyPanelView({ m, picks, legal, kitted, onSet, picking, onPicking: setPicking }) { + const slots = diySlotList(diyData()); + const slot = picking ? slots.find((s) => s.slotId === picking) : null; + const tiers = [...new Set(slots.map((s) => s.tier))]; + return html`
+
+

${t('5阶与6阶各有两个自选名额:选择持有的 6★ 干员(任选技能与模组)或原型干员(技能锁定)。自选干员只出现在你自己的商店里,调度中心达到该阶等级后才会刷新,价格与同阶干员相同;盟约按干员实际所属阵营分配,没有特质。')}

+
+
+ ${tiers.map((tier) => html`
+

<${TierChip} tier=${tier} size="sm" />${ROMAN[tier]}${t('阶')}

+
+ ${slots.filter((s) => s.tier === tier).map((s) => html`<${SlotCard} key=${s.slotId} m=${m} slot=${s} pick=${picks[s.slotId] || null} + illegal=${!!picks[s.slotId] && !legal[s.slotId]} onOpen=${setPicking} onClear=${(id) => onSet(id, null)} />`)} +
+
`)} +
+ ${slot ? html`<${DiyPicker} m=${m} slot=${slot} picks=${picks} kitted=${kitted} onClose=${() => setPicking(null)} + onDone=${(pick) => { onSet(slot.slotId, pick); setPicking(null); }} />` : null} +
`; +} diff --git a/public/js/screens/game.js b/public/js/screens/game.js index 374cf484..c1e662aa 100644 --- a/public/js/screens/game.js +++ b/public/js/screens/game.js @@ -96,7 +96,7 @@ import { snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers, countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason, previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, panelSide, panelSlots, bondPopupPlace, unitLoadout, deployedRecord, - mergeTarget, modeOffBonds, readyFundsPrompt, ownerBandId, + mergeTarget, modeOffBonds, readyFundsPrompt, ownerBandId, ownDiyRecord, } from '../ui/gameLogic.js'; import { toast } from '../ui/toasts.js'; import { BriefingScreen } from './briefing.js'; @@ -236,9 +236,13 @@ function MatchScreen() { // the field the own pieces are deployed on: the own board, or the player's half of the boss field in a boss round's // prep (user playtest #5 item 7: legality and the legal-tile highlights read THOSE tiles, like the server) const deployField = deployFieldOf(pub, myId); + // the own pieces' records: a DIY slot the player filled is its 自选 operator (0.2.0, m.private.diy — position, range, + // name; gameLogic/diy.js), every other chess its data record + const ownChess = (id) => { const c = gd.chess(id); return ownDiyRecord(c, live.current.priv, { chess: data.get('chess'), backups: data.get('backups') }) || c; }; 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, backups: gd.backups, + getChess: (id) => { const c = gd.chess(id); return ownDiyRecord(c, priv, { chess: data.get('chess'), backups: data.get('backups') }) || c; }, + 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 }; @@ -719,12 +723,12 @@ 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) => deployedRecord(rec, live.current.priv, gd.chess, gd.backups) }; + const lookups = { getChess: ownChess, getToken: gd.token, getItem: gd.item, chessRecord: (rec) => deployedRecord(rec, live.current.priv, ownChess, gd.backups) }; const runIntent = async (intent) => { const L = live.current; if (intent.confirmReplace) { // both slots used: the player picks the equipped item to destroy (cancel ⇒ nothing is sent) - const request = replaceRequest(L.placeCtx, intent, gd.chess, gd.item); + const request = replaceRequest(L.placeCtx, intent, ownChess, gd.item); if (request) { const uid = await openReplaceRef.current(request); if (!Number.isInteger(uid)) { audio.sfx('back', { volume: 0.5 }); return; } @@ -750,7 +754,7 @@ function MatchScreen() { /** Open the direction wheel for a legal board drop (the piece stays on the tile meanwhile). */ const openFacing = (entry, t) => { const piece = entry.piece; - const rec = piece.kind === 'item' ? gd.item(piece.id) : piece.kind === 'token' ? gd.token(piece.id) : gd.chess(piece.id); + const rec = piece.kind === 'item' ? gd.item(piece.id) : piece.kind === 'token' ? gd.token(piece.id) : ownChess(piece.id); holdPiece(view, piece.uid, { row: t.row, col: t.col }); setSel(null); setFacing({ uid: piece.uid, piece, row: t.row, col: t.col, grid: previewGrid(lookups, piece), name: rec?.name || '' }); @@ -889,8 +893,8 @@ 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); 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) => deployedRecord(rec, live.current.priv, gd.chess, gd.backups) }), [gd.ready]); + const lookups = useMemo(() => ({ getChess: ownChess, getToken: gd.token, getItem: gd.item, + chessRecord: (rec) => deployedRecord(rec, live.current.priv, ownChess, 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); @@ -1200,7 +1204,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} + ${showPrep && view && viewKind !== 'loading' && priv && !pen ? html`<${StandInTags} view=${view} priv=${priv} getChess=${gd.chess} backups=${gd.backups} diyData=${{ chess: data.get('chess'), backups: data.get('backups') }} />` : null}
<${TopBar} pub=${pub} priv=${priv} conn=${conn} hud=${hud} total=${total} drawer=${drawer} @@ -1270,7 +1274,7 @@ function MatchScreen() { ${selEntry && editable && !facing && !drag && showPrep ? html`<${Underframe} key=${sel.uid} view=${view} uid=${sel.uid} row=${pieceTile(selEntry)?.row} col=${pieceTile(selEntry)?.col} actions=${underframeActions(placeCtx, sel.uid)} busy=${selBusy} - name=${(selEntry.piece.kind === 'item' ? gd.item(selEntry.piece.id) : selEntry.piece.kind === 'token' ? gd.token(selEntry.piece.id) : gd.chess(selEntry.piece.id))?.name || ''} + name=${(selEntry.piece.kind === 'item' ? gd.item(selEntry.piece.id) : selEntry.piece.kind === 'token' ? gd.token(selEntry.piece.id) : ownChess(selEntry.piece.id))?.name || ''} onRetreat=${retreatSel} onSell=${sellSel} onDestroy=${sellSel} />` : null}
diff --git a/public/js/screens/game/standInTags.js b/public/js/screens/game/standInTags.js index 92ad2837..7e49e264 100644 --- a/public/js/screens/game/standInTags.js +++ b/public/js/screens/game/standInTags.js @@ -1,12 +1,15 @@ // 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. +// chess it is either way. 0.2.0 自选编队: the 「自选」 tag under the player's own pieces of a DIY slot it filled +// (m.private.diy) — the model is the operator's everywhere. 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'; +import { fieldsStandIn, standInOf, standInLabel, ownDiyRecord } from '../../ui/gameLogic.js'; +import { t } from '../../../../shared/i18n.js'; const rawOf = (view) => (view && view.raw) || view || null; @@ -30,9 +33,27 @@ export function standInPieces(priv, getChess, backups) { 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); +/** + * The player's own prep pieces of a DIY slot it filled (0.2.0 自选编队): [{ uid, label, name, area, diy: true }]. + * @param {any} priv m.private @param {(id: string) => any} getChess @param {{ chess: any, backups: any }} data + */ +export function diyPieces(priv, getChess, data) { + const out = []; + if (!priv || !priv.diy || typeof priv.diy !== 'object' || !Object.keys(priv.diy).length) return out; + const add = (p, area) => { + if (!p || p.kind !== 'chess' || !Number.isInteger(p.uid)) return; + const rec = ownDiyRecord(getChess(p.id), priv, data); + if (rec) out.push({ uid: p.uid, label: t('自选'), name: rec.name, area, diy: true }); + }; + 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, diyData?: { chess: any, backups: any } | null }} props */ +export function StandInTags({ view, priv, getChess, backups, diyData = null }) { + const items = [...standInPieces(priv, getChess, backups), ...(diyData ? diyPieces(priv, getChess, diyData) : [])]; const [pos, setPos] = useState({}); const last = useRef(''); const key = items.map((it) => it.uid).join(','); @@ -64,7 +85,8 @@ export function StandInTags({ view, priv, getChess, backups }) { return html``; diff --git a/public/js/screens/loadout.js b/public/js/screens/loadout.js index de01b249..c3c51534 100644 --- a/public/js/screens/loadout.js +++ b/public/js/screens/loadout.js @@ -14,6 +14,8 @@ // The loadout lives in ui/loadoutSync.js (localStorage + room.loadout); the model is ui/loadoutModel.js. // 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 导出 / 导入 / 全部持有. +// The third tab, 自选编队 (0.2.0 DIY, screens/diy.js): the operators of the four DIY slots (room.diy; ui/diyModel.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'; @@ -29,9 +31,12 @@ 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, setNotOwned, applyOwnershipImport } from '../ui/loadoutSync.js'; +import { loadoutStore, openLoadout, closeLoadout, setEntries, applyLoadoutEntries, setNotOwned, applyOwnershipImport, setDiyPicks, applyDiyImport } from '../ui/loadoutSync.js'; import { setOwned, notOwnedCount, serializeOwnership, parseOwnershipImport, OWNERSHIP_IMPORT_MAX_BYTES } from '../ui/ownershipModel.js'; import { OwnershipPanel, useOwnershipRoster } from './ownership.js'; +import { DiyPanel, diyData } from './diy.js'; +import { diyCount, sanitizeDiyPicks, setPick, serializeDiy, parseDiyImport, DIY_IMPORT_MAX_BYTES } from '../ui/diyModel.js'; +import { t } from '../../../shared/i18n.js'; import { copyText } from '../ui/clipboard.js'; import { toast } from '../ui/toasts.js'; @@ -71,7 +76,7 @@ function readFileText(file) { }); } -/** `stronghold-loadout-20261003-1245.json` (`stronghold-ownership-…` for the 干员持有 list) */ +/** `stronghold-loadout-20261003-1245.json` (`stronghold-ownership-…` for the 干员持有 list, `stronghold-diy-…` for 自选编队) */ function exportFilename(now = new Date(), what = 'loadout') { const p = (n) => String(n).padStart(2, '0'); return `stronghold-${what}-${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}.json`; @@ -383,10 +388,12 @@ const SYNC_TEXT = { }; /** The 干员持有 tab's status line: the setting never applies to a running match. */ const OWN_SYNC_TEXT = { ...SYNC_TEXT, locked: ['下一局生效', 'is-warn'] }; +/** The 自选编队 tab's status line (msgids, translated where shown): out of match, like 干员持有. */ +const DIY_SYNC_TEXT = { ...OWN_SYNC_TEXT }; /** The overlay screen. */ function LoadoutScreen({ st }) { - const ready = useData('chess', 'bonds', 'assets', 'local'); + const ready = useData('chess', 'bonds', 'assets', 'local', 'backups'); const phase = useStore((s) => s.match?.public?.phase || null); const inMatch = useStore((s) => !!s.room?.inMatch); // co-op briefing (INFO_CHECK, 25 s): the overlay covers the briefing's own countdown, so it shows the time left — the @@ -411,9 +418,18 @@ 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 tab = st.tab === 'ownership' || st.tab === 'diy' ? st.tab : 'loadout'; const ownRoster = useOwnershipRoster(ready); const nNotOwned = notOwnedCount(st.notOwned, ownRoster); + // 自选编队 (0.2.0 DIY): the stored picks, those the server would keep (the kit list of the last welcome) + const nDiy = diyCount(st.diy); + const diyLegal = ready ? sanitizeDiyPicks(st.diy, diyData(), st.diyKitted || []) : {}; + const setDiySlot = (slotId, pick) => setDiyPicks(setPick(loadoutStore.get().diy, slotId, pick)); + const clearDiy = async () => { + if (!nDiy) return; + const ok = await confirmDialog({ title: t('全部清空'), text: t('清空全部 {n} 个自选名额?', { n: nDiy }), okText: t('全部清空') }); + if (ok) setDiyPicks({}); + }; const setTab = (t) => loadoutStore.set({ tab: t }); const toggleOwned = (id, owned) => setNotOwned(setOwned(loadoutStore.get().notOwned, id, owned)); const ownAll = async () => { @@ -434,27 +450,38 @@ function LoadoutScreen({ st }) { // the clipboard, or the textarea); `io.kind` says which const ioText = io?.text ?? ''; const ioOwn = io?.kind === 'ownership'; + const ioDiy = io?.kind === 'diy'; const openExport = () => setIo(tab === 'ownership' ? { mode: 'export', kind: 'ownership', text: serializeOwnership(loadoutStore.get().notOwned) } - : { mode: 'export', kind: 'loadout', text: serializeExport(loadoutStore.get().entries) }); + : tab === 'diy' ? { mode: 'export', kind: 'diy', text: serializeDiy(loadoutStore.get().diy) } + : { 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(new Date(), ioOwn ? 'ownership' : 'loadout'), ioText); + const ioDownload = () => downloadText(exportFilename(new Date(), ioOwn ? 'ownership' : ioDiy ? 'diy' : '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 (ioDiy && f.size > DIY_IMPORT_MAX_BYTES) { toast(t('文件过大,请选择「导出」下载的自选编队文件'), 'error'); return; } 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 (ioDiy) { + const r = parseDiyImport(ioText); + if (!r.ok) { toast(t('导入失败:{error}', { error: t(r.error, r.params) }), 'error'); return; } + const { applied, dropped } = applyDiyImport(r.picks, diyData(), loadoutStore.get().diyKitted || []); + setIo(null); + toast(applied ? t('已导入 {n} 个自选名额', { n: applied }) + (dropped ? t('(另有 {n} 项不可用,未导入)', { n: dropped }) : '') : t('已导入:全部名额为空'), dropped ? 'warn' : 'success'); + return; + } if (ioOwn) { const r = parseOwnershipImport(ioText); if (!r.ok) { toast(`导入失败:${r.error}`, 'error'); return; } @@ -482,7 +509,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') && loadoutStore.get().tab !== 'ownership') { + if ((e.key === 'ArrowRight' || e.key === 'ArrowLeft') && loadoutStore.get().tab !== 'ownership' && loadoutStore.get().tab !== 'diy') { 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)); @@ -503,12 +530,15 @@ function LoadoutScreen({ st }) { if (el && typeof el.scrollIntoView === 'function') el.scrollIntoView({ block: 'nearest' }); }, [selId]); - const [syncText, syncCls] = tab === 'ownership' ? OWN_SYNC_TEXT[st.ownSync] || OWN_SYNC_TEXT.idle : SYNC_TEXT[st.sync] || SYNC_TEXT.idle; + const [syncText, syncCls] = tab === 'ownership' ? OWN_SYNC_TEXT[st.ownSync] || OWN_SYNC_TEXT.idle + : tab === 'diy' ? DIY_SYNC_TEXT[st.diySync] || DIY_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 ? '干员持有是局外设置:本局按开局时的设置进行,修改将在下一局生效' : '局外设置,下一局生效 · 联机时只影响你自己的棋子 · 默认全部持有'; + const diyText = ownLocked ? t('自选编队是局外设置:本局按开局时的设置进行,修改将在下一局生效') + : t('局外设置,下一局生效 · 联机时只进入你自己的商店'); return html`<${Fragment}>
- <${MicroLabel} tone="mint">${tab === 'ownership' ? 'OPERATOR ROSTER' : 'OPERATOR LOADOUT'} -
+ <${MicroLabel} tone="mint">${tab === 'ownership' ? 'OPERATOR ROSTER' : tab === 'diy' ? 'SELF-SELECT SQUAD' : 'OPERATOR LOADOUT'} +
+
- ${tab === 'ownership' ? html`
+ ${tab === 'diy' ? html`
+ ${syncText ? html`${t(syncText)}` : null} + ${t('已选')} ${nDiy}/4 + <${Button} variant="ghost" size="sm" data-testid="diy-export" disabled=${!nDiy} onClick=${openExport} title=${t('导出自选编队(可复制或下载)')}>${t('导出')} + <${Button} variant="ghost" size="sm" data-testid="diy-import" disabled=${!ready} onClick=${openImport} title=${t('导入自选编队(粘贴或选择文件)')}>${t('导入')} + <${Button} variant="secondary" size="sm" icon="refresh" data-testid="diy-reset" disabled=${!nDiy} onClick=${clearDiy}>${t('全部清空')} +
` : tab === 'ownership' ? html`
${syncText ? html`${syncText}` : null} 未持有 ${nNotOwned}/${ownRoster.length} <${Button} variant="ghost" size="sm" data-testid="ownership-export" disabled=${!nNotOwned} onClick=${openExport} title="导出干员持有(可复制或下载)">导出 @@ -541,10 +579,14 @@ function LoadoutScreen({ st }) { <${Button} variant="secondary" size="sm" icon="refresh" disabled=${!nChanged} onClick=${resetAll}>全部恢复默认
`} - ${tab === 'ownership' + ${tab === 'diy' + ? html`

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

` + : tab === 'ownership' ? html`

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

` : html`

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

`} - ${!ready ? html`
<${Spinner} size="sm" />正在载入干员数据(打开页面后仅载入一次)…
` : tab === 'ownership' + ${!ready ? html`
<${Spinner} size="sm" />正在载入干员数据(打开页面后仅载入一次)…
` : tab === 'diy' + ? html`<${DiyPanel} m=${m} picks=${st.diy || {}} legal=${diyLegal} kitted=${st.diyKitted} onSet=${setDiySlot} />` + : tab === 'ownership' ? html`<${OwnershipPanel} m=${m} roster=${ownRoster} notOwned=${st.notOwned} onToggle=${toggleOwned} />` : html`
@@ -562,7 +604,8 @@ function LoadoutScreen({ st }) {
`}
${io ? html`<${Modal} open=${true} onClose=${() => setIo(null)} - title=${`${io.mode === 'export' ? '导出' : '导入'}${ioOwn ? '干员持有' : '干员调配'}`} micro=${ioOwn ? 'OPERATOR ROSTER' : 'OPERATOR LOADOUT'} + title=${ioDiy ? t(io.mode === 'export' ? '导出自选编队' : '导入自选编队') : `${io.mode === 'export' ? '导出' : '导入'}${ioOwn ? '干员持有' : '干员调配'}`} + micro=${ioOwn ? 'OPERATOR ROSTER' : ioDiy ? 'SELF-SELECT SQUAD' : '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}>复制 @@ -570,7 +613,11 @@ 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}>导入`}> -

${ioOwn +

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

` : null} @@ -632,14 +679,18 @@ export function LoadoutButton({ from, size = 'md', variant = 'secondary', class: 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 diy = useStore((s) => s.diy, 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; + // 0.2.0 自选编队: how many DIY slots the player filled + const nDiy = diyCount(diy); return html``; } diff --git a/public/js/screens/result.js b/public/js/screens/result.js index 8d9857c5..0fea546a 100644 --- a/public/js/screens/result.js +++ b/public/js/screens/result.js @@ -6,7 +6,9 @@ // { victory, roundsPassed, lastRound?, hiddenCleared?, bossId?, hiddenBossId?, difficulty?, modeId?, durationMs?, // players: [{ playerId, seat, name, isBot, alive, lp, bandId, roundsPassed?, // title: 'comment_1' | { id, name? } | null, -// lineup: [{ id /* chessId, golden id if elite */, golden?, tier?, items?: [itemId] }], +// lineup: [{ id /* chessId, golden id if elite */, golden?, tier?, items?: [itemId], +// diy? /* 0.2.0 自选编队: a DIY slot's pick { charId, skillIndex, uniEquipId } — the card +// draws the operator (gameLogic diyRecordFor) */ }], // bonds?: [{ bondId, layers, active }], // stats: { dmgDealt, kills, leaks, gold /* funds SPENT */, refreshes, merges, bossDamage?, itemsEquipped?, // activatedLayers?, lpLost?, perfectRounds? } }] } @@ -14,7 +16,8 @@ import { useEffect } from '../../vendor/hooks.module.js'; import { html, Button, Icon, MicroLabel, DifficultyTag } from '../ui/components.js'; import { useGameData, Img, UnitThumb, BandIcon, PlayerAvatar, BondGlyph, LpTower, Sprite } from '../ui/gameComponents.js'; -import { normalizeResult, fmtNum } from '../ui/gameLogic.js'; +import { normalizeResult, fmtNum, diyRecordFor } from '../ui/gameLogic.js'; +import { data } from '../data.js'; import { enemyIconUrl, titleIconUrl, uiUrl } from '../ui/assetUrls.js'; import { store, useStore, emptyMatch } from '../store.js'; import { audio } from '../audio.js'; @@ -46,7 +49,8 @@ function PlayerCard({ p, myId, titles, best, solo = false }) {
- ${lineup.length ? lineup.map((u, i) => html`<${UnitThumb} key=${i} kind=${u.kind === 'token' ? 'token' : 'chess'} id=${u.id} golden=${!!u.golden} tier=${u.tier} size="sm" />`) + ${lineup.length ? lineup.map((u, i) => html`<${UnitThumb} key=${i} kind=${u.kind === 'token' ? 'token' : 'chess'} id=${u.id} golden=${!!u.golden} tier=${u.tier} size="sm" + rec=${u.diy ? diyRecordFor(gd.chess(u.id), u.diy, { chess: data.get('chess'), backups: data.get('backups') }) : null} />`) : html`${p.alive === false ? '阵容已撤离' : 'NO INFO'}`}
${bonds.length ? html`
${bonds.map((b) => html` diff --git a/public/js/ui/bondStrip.js b/public/js/ui/bondStrip.js index 2d331d72..03dee155 100644 --- a/public/js/ui/bondStrip.js +++ b/public/js/ui/bondStrip.js @@ -21,10 +21,11 @@ import { html, BondDisc, Icon, MicroLabel, Tooltip } from './components.js'; import { RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.js'; -import { sortBonds, bondMembers, nextThreshold, bondTier, harmonyMembers, HARMONY_BOND, memberHeadCount, briefingBondTip } from './gameLogic.js'; +import { sortBonds, bondMembers, nextThreshold, bondTier, harmonyMembers, HARMONY_BOND, memberHeadCount, briefingBondTip, diyGetter, diyRecordFor } from './gameLogic.js'; import { formatBondEffect } from './richText.js'; import { bondIconUrl } from './assetUrls.js'; import { data } from '../data.js'; +import { t } from '../../../shared/i18n.js'; const cx = (...p) => p.flat().filter(Boolean).join(' '); @@ -99,8 +100,11 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember, const th = Array.isArray(entry?.thresholds) && entry.thresholds.length ? entry.thresholds : b.thresholds || []; const tier = off ? 0 : entry?.tier ?? bondTier(count, th, b.maxCount); const active = !off && (entry ? !!entry.active : tier > 0); - const getChess = (id) => data.lookup('chess', id); - const members = bondMembers(b, priv, banned, getChess, (id) => data.lookup('items', id)); + // (自选 pieces are their operators: the own ones by m.private.diy, a watched teammate's by the pick its unit carries) + const dd = { chess: data.get('chess'), backups: data.get('backups') }; + const getChess = diyGetter((id) => data.lookup('chess', id), priv, dd); + const pieceRecord = (p) => (p && p.diy && typeof p.diy === 'object' ? diyRecordFor(data.lookup('chess', p.id), p.diy, dd) : null) || getChess(p.id); + const members = bondMembers(b, priv, banned, getChess, (id) => data.lookup('items', id), pieceRecord); const countsHand = entry?.countsHand ?? b.countsHand; const next = nextThreshold(count, th); const hasNow = !off && !!(b.effectDescRaw || b.effectDesc); @@ -149,10 +153,11 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember, ${members.map((mb) => html``)}
diff --git a/public/js/ui/detailPanel.js b/public/js/ui/detailPanel.js index eaf509f9..00a3019d 100644 --- a/public/js/ui/detailPanel.js +++ b/public/js/ui/detailPanel.js @@ -33,13 +33,14 @@ 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, fieldsStandIn, standInOf, standInLoadout, standInLabel } from './gameLogic.js'; +import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, briefingBondTip, pieceBondIds, grantedBonds, morphPairings, fieldsStandIn, standInOf, standInLoadout, standInLabel, ownDiyRecord, ownDiyPick, diyRecordFor, pickGetter } 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'; import { attackRangeGrid } from '../../../shared/loadoutRecord.js'; import { SKILL_SUMMON_START_DEPLOY } from '../../../shared/constants.js'; import { moduleBadge } from './loadoutModel.js'; +import { t } from '../../../shared/i18n.js'; const cx = (...p) => p.flat().filter(Boolean).join(' '); @@ -354,10 +355,12 @@ 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, standIn = null }) { +export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, offBonds = null, loadout, onBond, live = null, hint = null, unitItems = null, standIn = null, diy = null }) { const m = data.get('assets'); const hp = hpOf(live, snapHp); - const getChess = (id) => data.lookup('chess', id); + // 0.2.0 自选编队: `chess` is then the composed 自选 record (the operator, the slot's tier / price); its skill and module are + // the pick's — the lookups resolve the slot's ids to the same pick (shared/diy.js), so the loadout reads them as defaults + const getChess = diy ? pickGetter((id) => data.lookup('chess', id), diy, { chess: data.get('chess'), backups: data.get('backups') }) : (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; @@ -392,6 +395,7 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond ${golden ? html`精锐` : null} ${piece?.kind === 'token' ? html`召唤物` : null} ${si ? html`${standInLabel(si)}` : null} + ${diy ? html`${t('自选')}` : null}

${c.name}

${si ? `${si.name}${si.appellation && si.appellation !== si.name ? ` · ${si.appellation}` : ''}` : c.appellation || ''} @@ -613,11 +617,18 @@ export function TokenDetail({ token, piece, ownerId = null, snapHp = null, live * @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) + * card shows as the body); 0.2.0 自选编队: the player's own pieces and cards of a DIY slot it filled (m.private.diy) — + * and a unit carrying `diy` — resolve to the composed 自选 record (`chess`, the operator) with `diy` = the pick */ 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); + const dd = { chess: data.get('chess'), backups }; + /** the own card of chess `c`: its 自选 record and pick when the player filled that DIY slot */ + const ownDiy = (c) => { + const rec = c ? ownDiyRecord(c, priv, dd) : null; + return rec ? { chess: rec, diy: ownDiyPick(priv, c) } : null; + }; if (target.kind === 'piece') { const e = pieces?.get(target.uid); if (!e) return null; @@ -625,6 +636,8 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get( 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); + const d = ownDiy(c); + if (d) return { type: 'chess', chess: d.chess, piece: p, standIn: null, diy: d.diy }; return c ? { type: 'chess', chess: c, piece: p, standIn: ownStandIn(c) } : null; } if (target.kind === 'chess') { @@ -634,6 +647,8 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get( // (a bond popup's member card of a teammate's strip — `owner` another player — and the mode's banned list (`foreign`) // show the chess as it is: the viewer's 补位 list is not theirs) const foreign = !!target.foreign || (target.owner != null && !!priv && target.owner !== priv.playerId); + const d = foreign ? null : ownDiy(c); + if (d) return { type: 'chess', chess: d.chess, hint: target.hint || null, standIn: null, diy: d.diy, ...(items.length ? { unitItems: items } : {}) }; return c ? { type: 'chess', chess: c, hint: target.hint || null, standIn: 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; } @@ -651,6 +666,11 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get( let si = null; if (c && typeof u.standInFor === 'string' && u.standInFor) si = standInOf(c, backups); else if (c && own?.piece) si = ownStandIn(c); + // 0.2.0 自选编队: a unit says itself which operator fills its DIY slot (UnitInfo diy); an own piece's unit follows + // m.private.diy like the piece + const pick = c && u.diy && typeof u.diy === 'object' ? u.diy : own?.piece ? ownDiyPick(priv, c) : null; + const dr = pick ? diyRecordFor(c, pick, dd) : null; + if (dr) return { type: 'chess', chess: dr, piece: own?.piece || null, unitId: u.id, unitItems: Array.isArray(u.items) ? u.items : null, standIn: null, diy: pick }; 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) }; @@ -695,7 +715,7 @@ 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} - standIn=${detail.standIn || null} />` : null} + standIn=${detail.standIn || null} diy=${detail.diy || 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/diyModel.js b/public/js/ui/diyModel.js new file mode 100644 index 00000000..adcb2299 --- /dev/null +++ b/public/js/ui/diyModel.js @@ -0,0 +1,200 @@ +// 自选编队 (0.2.0 DIY; research 0.2.0 §2, the owner's decisions of 2026-10-05) — pure logic of the 自选编队 tab of the +// 干员调配 overlay (screens/diy.js), shared with the sync (ui/loadoutSync.js), the match UI (ui/gameLogic/diy.js) and +// the tests. Four DIY slots (data/backups.json `diy.slots`: two at tier 5, two at tier 6, sold from 调度中心 level 5 / 6); +// each takes an operator: a 6★ the player owns outside the chess pool (`diy.ownedPool`) — any of its three skills and +// any module of its elite form but a 集成战略 one (shared/diy.js isDiyModule) — or a prototype (原型干员) with its locked +// skill / module; only operators with a kit are offered (`kitted`: the server's `welcome.diyKitted`). A prototype may +// fill a tier-5 and a tier-6 slot, an owned operator one slot; the picks of a tier differ (shared/diy.js +// validateDiyPicks). +// +// The per-browser setting is `{ [slotBaseId]: { charId, skillIndex?, uniEquipId? } }`, persisted in localStorage +// (`sp.pref.diy` = { v: 1, picks }) and sent with C2S `room.diy { picks }` (the server keeps the legal picks and drops +// the rest — shared/protocol.js checkDiyPicks — so a stale pick never gets the roster refused). Out of match: the next +// match takes it. 导出 / 导入 use a versioned envelope like the 干员持有 list. + +import { DIY_LIMITS, checkDiyPicks } from '../../../shared/protocol.js'; +import { diySlotIds, diySlot, diyPool, isPrototypePick, lockedSelection, isDiyModule, diyRecord } from '../../../shared/diy.js'; +import { unitForm } from '../../../shared/standIn.js'; + +/** localStorage key (store.js loadPref/savePref prefix `sp.pref.`) and format version. */ +export const DIY_PREF = 'diy'; +export const DIY_VERSION = 1; +/** `kind` of an exported 自选编队 envelope. */ +export const DIY_EXPORT_KIND = 'stronghold.diy'; +/** A picked file / pasted payload longer than this is refused before parsing (a real payload is under 1 KB). */ +export const DIY_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); +const isId = (v) => typeof v === 'string' && ID_RE.test(v) && !UNSAFE_IDS.has(v); + +/** + * The structurally valid picks of a raw map (junk dropped, empty slots left out; at most DIY_LIMITS.slots): each pick + * `{ charId, skillIndex?, uniEquipId? }` (skill 0–9, module an id; absent / null = the locked or no module). + * @param {any} raw + * @returns {Record} + */ +export function cleanPicks(raw) { + const out = {}; + if (!isObj(raw)) return out; + for (const [slotId, p] of Object.entries(raw)) { + if (Object.keys(out).length >= DIY_LIMITS.slots) break; + if (!isId(slotId) || !isObj(p) || !isId(p.charId)) continue; + const pick = { charId: p.charId }; + if (Number.isInteger(p.skillIndex) && p.skillIndex >= 0 && p.skillIndex <= 9) pick.skillIndex = p.skillIndex; + if (isId(p.uniEquipId)) pick.uniEquipId = p.uniEquipId; + else if (p.uniEquipId === null) pick.uniEquipId = null; + out[slotId] = pick; + } + return out; +} + +/** Parse a stored setting (any junk → {}): `{ v, picks }`. @param {any} raw */ +export function parseStoredDiy(raw) { + return isObj(raw) ? cleanPicks(raw.picks) : {}; +} + +/** Serialised form for localStorage. */ +export const toStoredDiy = (picks) => ({ v: DIY_VERSION, picks: cleanPicks(picks) }); + +/** Number of filled slots. */ +export const diyCount = (picks) => Object.keys(cleanPicks(picks)).length; + +/** + * The legal picks of a stored roster against the loaded data and the kit list (checkDiyPicks: what the server keeps). + * @param {any} picks @param {any} data `{ chess, backups }` @param {Iterable|null} kitted + * @returns {Record} + */ +export function sanitizeDiyPicks(picks, data, kitted) { + const res = checkDiyPicks(cleanPicks(picks), { data, kitted }); + return res && 'ok' in res ? res.picks : {}; +} + +/** + * The four slots in data order: [{ slotId, goldenId, tier, shopLevel }]. + * @param {any} data + */ +export function diySlotList(data) { + return diySlotIds(data).map((id) => diySlot(id, data)).filter(Boolean) + .map((s) => ({ slotId: s.baseId, goldenId: s.goldenId, tier: s.tier, shopLevel: s.shopLevel })); +} + +const backupsOf = (data) => (isObj(data?.backups) ? data.backups : null); + +/** + * What a slot's tier offers of an operator: its two forms (normal E2 Lv1 skill rank 4; elite E2 Lv60 rank 7, module + * stage 1 at tier 5 / 3 at tier 6), the skills (normal + elite record per index), the modules it may carry (the elite + * form's, 集成战略 ones left out) and, for a prototype, its locked selection. Null when the data lacks a form. + * @param {string} charId @param {string} slotId a slot base id @param {any} data + */ +export function pickChoices(charId, slotId, data) { + const slot = diySlot(slotId, data); + const backups = backupsOf(data); + if (!slot || !backups || !slot.normal || !slot.golden) return null; + const normal = unitForm(backups, charId, slot.normal.status); + const elite = unitForm(backups, charId, slot.golden.status); + if (!normal || !elite) return null; + const proto = isPrototypePick(data, slot.tier, charId); + const skills = (normal.skills || []).filter(Boolean).map((s) => ({ index: s.index, normal: s, elite: (elite.skills || []).find((e) => e && e.index === s.index) || s })); + const modules = (elite.modules || []).filter(isDiyModule).map((m) => ({ uniEquipId: m.uniEquipId, rec: m })); + return { tier: slot.tier, stage: slot.golden.status?.equipLevel ?? 0, proto, locked: proto ? lockedSelection(data, slot.tier, charId) : null, skills, modules, normal, elite }; +} + +/** + * The pick a newly chosen operator starts with: a prototype's locked selection (`{ charId }` — the server completes it), + * an owned operator's third skill (its last) and the first module it may carry (none when it has none). + * @param {string} charId @param {string} slotId @param {any} data + */ +export function defaultPick(charId, slotId, data) { + const ch = pickChoices(charId, slotId, data); + if (!ch) return { charId }; + if (ch.proto) return { charId }; + const last = ch.skills.length ? ch.skills[ch.skills.length - 1].index : 0; + return { charId, skillIndex: last, uniEquipId: ch.modules[0]?.uniEquipId ?? null }; +} + +/** + * The operators a slot lists (shared/diy.js diyPool of its tier with the kit list — prototypes first, then the owned + * 6★), each with what the picker card shows and whether another slot holds it: `taken` names that slot (an owned + * operator fills one slot; the two picks of a tier differ). + * @param {string} slotId @param {Record} picks the current roster @param {any} data + * @param {Iterable|null} kitted + * @returns {Array<{ charId: string, unit: any, bonds: string[], proto: boolean, taken: string|null }>} + */ +export function pickOptions(slotId, picks, data, kitted) { + const slot = diySlot(slotId, data); + const backups = backupsOf(data); + if (!slot || !backups) return []; + const kit = kitted ? [...kitted] : []; + const tierOf = (id) => diySlot(id, data)?.tier ?? null; + const roster = cleanPicks(picks); + return diyPool(slot.tier, { data, kitted: kit }).map((charId) => { + const proto = isPrototypePick(data, slot.tier, charId); + let taken = null; + for (const [other, p] of Object.entries(roster)) { + if (other === slot.baseId || p.charId !== charId) continue; + // the same tier: never twice; another tier: an owned operator only once (a prototype may repeat across tiers) + if (tierOf(other) === slot.tier || !isPrototypePick(data, tierOf(other), charId)) { taken = other; break; } + } + return { charId, unit: backups.units?.[charId] ?? null, bonds: backups.diy?.operators?.[charId]?.bonds ?? [], proto, taken }; + }); +} + +/** + * The record a slot shows for a pick (shared/diy.js diyRecord: the operator at the slot's normal form, or the elite). + * @param {string} slotId @param {any} pick @param {any} data @param {{ elite?: boolean }} [opts] + */ +export function slotRecord(slotId, pick, data, { elite = false } = {}) { + if (!isObj(pick)) return null; + try { return diyRecord(slotId, pick, { elite, data }); } catch { return null; } +} + +/** Set (or with null, clear) one slot. Returns a new roster (the input is not changed). */ +export function setPick(picks, slotId, pick) { + const next = { ...cleanPicks(picks) }; + if (isObj(pick) && isId(pick.charId)) next[slotId] = cleanPicks({ [slotId]: pick })[slotId]; + else delete next[slotId]; + return next; +} + +// ---- export / import ------------------------------------------------------------------------------------------------- + +/** Portable payload of a roster, as downloaded / copied by 导出. */ +export function exportDiy(picks, { now = Date.now() } = {}) { + const clean = cleanPicks(picks); + return { + kind: DIY_EXPORT_KIND, + v: DIY_VERSION, + exportedAt: new Date(Number.isFinite(now) ? now : Date.now()).toISOString(), + count: Object.keys(clean).length, + picks: clean, + }; +} + +/** Pretty JSON of `exportDiy`. */ +export const serializeDiy = (picks, opts) => JSON.stringify(exportDiy(picks, opts), null, 2); + +/** + * Parse an imported roster: the envelope, the stored `{ v, picks }` form, or the text of either. STRUCTURAL only — the + * caller sanitises against the loaded data (sanitizeDiyPicks). An envelope may hold no picks (= every slot empty). + * Errors are msgids (shown through t()). + * @param {any} input payload or text + * @returns {{ ok: true, picks: Record } | { ok: false, error: string, params?: Record }} + */ +export function parseDiyImport(input) { + let raw = input; + if (typeof raw === 'string') { + if (raw.length > DIY_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 (!isObj(raw)) return { ok: false, error: '无法识别的格式' }; + const v = Number.isInteger(raw.v) ? raw.v : null; + if (v != null && v > DIY_VERSION) return { ok: false, error: '这份数据来自更新的版本(v{v}),请先更新游戏', params: { v } }; + const kind = typeof raw.kind === 'string' ? raw.kind : null; + if (kind && kind !== DIY_EXPORT_KIND) return { ok: false, error: '这不是自选编队的数据' }; + if (!isObj(raw.picks)) return { ok: false, error: '里面没有自选编队的数据' }; + return { ok: true, picks: cleanPicks(raw.picks) }; +} diff --git a/public/js/ui/fallbackField.js b/public/js/ui/fallbackField.js index c9f70a23..690785f3 100644 --- a/public/js/ui/fallbackField.js +++ b/public/js/ui/fallbackField.js @@ -21,7 +21,7 @@ import { render } from '../../vendor/preact.module.js'; import { html, TierChip } from './components.js'; import { GEO } from '../../../shared/constants.js'; import { chessAvatarUrl, itemIconUrl, tokenAvatarUrl, enemyIconUrl } from './assetUrls.js'; -import { tileKey, hasFlag, UF, penPlacement, PEN, fieldTile, fieldsStandIn, standInOf } from './gameLogic.js'; +import { tileKey, hasFlag, UF, penPlacement, PEN, fieldTile, fieldsStandIn, standInOf, ownDiyRecord } from './gameLogic.js'; const DRAG_PX = 6; const DMG_TTL = 900; @@ -126,12 +126,17 @@ export function createFallbackView(host, opts = {}) { const chess = lookup('chess', p.id); // 0.2.0 补位: on the board a chess the player does not own is its stand-in (render/app.js pieceInfo) const si = area === 'board' && fieldsStandIn(st.priv, chess) ? standInOf(chess, dataStore?.get?.('backups') ?? null) : null; - return chessAvatarUrl(mm, si || chess); + return chessAvatarUrl(mm, si || ownDiy(chess) || chess); + } + /** 0.2.0 自选编队: the player's own piece of a DIY slot it filled is its operator (gameLogic ownDiyRecord) */ + function ownDiy(chess) { + return chess ? ownDiyRecord(chess, st.priv, { chess: dataStore?.get?.('chess') ?? null, backups: dataStore?.get?.('backups') ?? null }) : null; } function pieceName(p) { if (p.kind === 'item') return lookup('items', p.id)?.name || '道具'; if (p.kind === 'token') return lookup('tokens', p.id)?.name || '召唤物'; - return lookup('chess', p.id)?.name || '干员'; + const chess = lookup('chess', p.id); + return (ownDiy(chess) || chess)?.name || '干员'; } function Piece({ p, x, y, L, area }) { diff --git a/public/js/ui/gameComponents.js b/public/js/ui/gameComponents.js index d0bc46f4..6ebc22f2 100644 --- a/public/js/ui/gameComponents.js +++ b/public/js/ui/gameComponents.js @@ -93,15 +93,16 @@ export const isGoldenPiece = (piece, chess) => !!(piece?.golden || chess?.isGold /** * Square unit thumbnail for a piece / chess / item / token / enemy: art + tier chip + elite frame. * @param {{ kind?: 'chess'|'item'|'token'|'enemy', id: string, golden?: boolean, size?: 'xs'|'sm'|'md'|'lg', tier?: number, - * showTier?: boolean, class?: string, dim?: boolean, badge?: any, title?: string }} props + * showTier?: boolean, class?: string, dim?: boolean, badge?: any, title?: string, rec?: any }} props + * `rec`: the chess record to draw instead of the data's (a 自选 piece's composed record — 0.2.0, gameLogic/diy.js) */ -export function UnitThumb({ kind = 'chess', id, golden, size = 'md', tier, showTier = true, class: cls, dim = false, badge = null, title }) { +export function UnitThumb({ kind = 'chess', id, golden, size = 'md', tier, showTier = true, class: cls, dim = false, badge = null, title, rec = null }) { const m = data.get('assets'); let src = null; let name = ''; let t = tier; if (kind === 'chess') { - const c = data.lookup('chess', id); + const c = rec || data.lookup('chess', id); src = chessAvatarUrl(m, c); name = c?.name || ''; t = t ?? c?.tier; diff --git a/public/js/ui/gameLogic.js b/public/js/ui/gameLogic.js index 4c62eca3..ff50239f 100644 --- a/public/js/ui/gameLogic.js +++ b/public/js/ui/gameLogic.js @@ -42,4 +42,5 @@ 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 { diyPicks, ownDiyPick, diyRecordFor, ownDiyRecord, cardDiy, diyGetter, pickGetter, isDiyRecord } from './gameLogic/diy.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/bonds.js b/public/js/ui/gameLogic/bonds.js index c8e281fe..dca2ba9d 100644 --- a/public/js/ui/gameLogic/bonds.js +++ b/public/js/ui/gameLogic/bonds.js @@ -155,14 +155,18 @@ export function harmonyMembers(priv, getChess = () => null) { * operators that are members through 变形同构体 (grantedBonds; `granted: true` and `items`: the item ids of the copy that * wears the pair — the card the row opens shows them; one row per operator — normal and elite copies are one member, * like the count's distinct members), so "成员 x/y" agrees with the count the server reports (在场; memberHeadCount). + * 0.2.0 自选编队: the 自选 pieces of the bond (a DIY slot filled with an operator, whose bonds come from its factions — the + * own pieces through `getChess` (gameLogic/diy.js diyGetter), a teammate's through `pieceRecord` (its UnitInfo `diy` + * pick) are rows too (`diy: true`, the operator's name and record `rec`). * `inHand`: in the 整备区 (hand), not the 5 temporary slots — what BOARD_AND_DECK bonds (投资人 远见 奇迹) count. * @param {any} bond bonds.json record * @param {any} priv m.private (hand/board/temp) — or a teammate's field operators (ui/watchBonds.js ownerBoard) * @param {Set|string[]} [banned] banned base chess ids * @param {(id:string)=>any} [getChess] * @param {(id:string)=>any} [getItem] items.json lookup (the 变形同构体 pairings) + * @param {((p: any) => any)|null} [pieceRecord] the record of one of the pieces (default: getChess(p.id)) */ -export function bondMembers(bond, priv, banned = [], getChess = () => null, getItem = () => null) { +export function bondMembers(bond, priv, banned = [], getChess = () => null, getItem = () => null, pieceRecord = null) { const bannedSet = banned instanceof Set ? banned : new Set(Array.isArray(banned) ? banned : []); const members = Array.isArray(bond?.visibleMembers) && bond.visibleMembers.length ? bond.visibleMembers : (Array.isArray(bond?.members) ? bond.members : []); const baseOf = (id) => getChess(id)?.baseId || (typeof id === 'string' ? id.replace(/_b$/, '_a') : id); @@ -173,11 +177,22 @@ export function bondMembers(bond, priv, banned = [], getChess = () => null, getI /** base id → { on: on the board?, hand: in the hand?, items: the wearer's item ids } — operators of the player that join this bond through 变形同构体 */ const granted = new Map(); const grants = (p) => typeof bond?.bondId === 'string' && grantedBonds(p.items, getItem).includes(bond.bondId); + /** base id → { on, hand, rec } — the player's 自选 pieces whose operator carries this bond */ + const diy = new Map(); + const diyOf = (p, on, hand) => { + const rec = pieceRecord ? pieceRecord(p) : getChess(p.id); + if (!rec || typeof rec.diyFor !== 'string' || !Array.isArray(rec.bonds) || !rec.bonds.includes(bond?.bondId)) return false; + const g = diy.get(rec.diyFor); + if (!g) diy.set(rec.diyFor, { on, hand, rec }); + else { g.on = g.on || on; g.hand = g.hand || hand; } + return true; + }; const itemIds = (p) => p.items.map((it) => (typeof it === 'string' ? it : it?.id)).filter((x) => typeof x === 'string'); for (const p of Array.isArray(priv?.board) ? priv.board : []) { if (p?.kind !== 'chess') continue; const base = baseOf(p.id); onBoard.add(base); owned.add(base); + if (diyOf(p, true, false)) continue; if (!memberSet.has(base) && !granted.has(base) && grants(p)) granted.set(base, { on: true, hand: false, items: itemIds(p) }); } for (const [list, hand] of [[priv?.hand, true], [priv?.temp, false]]) { @@ -186,6 +201,7 @@ export function bondMembers(bond, priv, banned = [], getChess = () => null, getI const base = baseOf(p.id); owned.add(base); if (hand) inHand.add(base); + if (diyOf(p, false, hand)) continue; if (memberSet.has(base) || !grants(p)) continue; const g = granted.get(base); if (!g) granted.set(base, { on: false, hand, items: itemIds(p) }); @@ -200,6 +216,7 @@ export function bondMembers(bond, priv, banned = [], getChess = () => null, getI const c = getChess(id); rows.push({ id, tier: c?.tier ?? 0, name: c?.name ?? id, onBoard: g.on, owned: true, inHand: g.hand, banned: false, granted: true, items: g.items }); } + for (const [id, g] of diy) rows.push({ id, tier: g.rec.tier ?? 0, name: g.rec.name ?? id, onBoard: g.on, owned: true, inHand: g.hand, banned: false, diy: true, rec: g.rec }); return rows.sort((a, b) => (b.onBoard - a.onBoard) || (b.owned - a.owned) || (a.tier - b.tier) || (a.id < b.id ? -1 : 1)); } diff --git a/public/js/ui/gameLogic/diy.js b/public/js/ui/gameLogic/diy.js new file mode 100644 index 00000000..545c01f4 --- /dev/null +++ b/public/js/ui/gameLogic/diy.js @@ -0,0 +1,94 @@ +// ui/gameLogic/diy.js — 自选编队 pieces in the match UI (0.2.0 DIY; the owner's decisions of 2026-10-05). Re-exported +// from ../gameLogic.js. +// +// A DIY slot (chess_char_5_diy1_a …) has no body of its own (甄选干员): each player's piece of it is the operator that +// player slotted — its name, art, class, bonds (from its factions), the pick's skill and module, no 特质; the slot's tier, +// price and merge. Whose pick: the player's own pieces and cards follow m.private.diy (the picks its 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 `diy`: the sim's, Match.prepFieldMeta's for scouting, m.result lineups). The +// composed record is shared/diy.js diyRecordOf over chess.json + backups.json — the one the server and the sim use. +// `data` below = `{ chess, backups }` (the client's data.get('chess') / data.get('backups'), localized copies). + +import { diyRecordOf } from '../../../../shared/diy.js'; +import { isObj } from './shared.js'; + +/** The player's 自选 picks in this match (m.private.diy; {} when absent). */ +export function diyPicks(priv) { + return isObj(priv) && isObj(priv.diy) ? priv.diy : {}; +} + +/** + * The pick the player's own piece / card of chess record `chess` fields: its slot's in m.private.diy, or null (not a DIY + * slot, or one the player did not fill). A composed record (`diyFor`) names its slot itself. + * @param {any} priv @param {any} chess + */ +export function ownDiyPick(priv, chess) { + if (!isObj(chess) || !chess.isDiy) return null; + const slot = typeof chess.diyFor === 'string' ? chess.diyFor : chess.baseId || chess.chessId; + const p = diyPicks(priv)[slot]; + return isObj(p) && typeof p.charId === 'string' ? p : null; +} + +/** Per backups object: `chessId|charId|skill|module` → composed record (or null). */ +const CACHE = new WeakMap(); + +/** + * The 自选 record of DIY slot record `chess` (normal or elite) filled with `pick` (shared/diy.js diyRecordOf), cached + * per data; null for an illegal pick or missing data. + * @param {any} chess the slot's own chess.json record @param {any} pick `{ charId, skillIndex, uniEquipId }` + * @param {{ chess?: any, backups?: any }} data + */ +export function diyRecordFor(chess, pick, data) { + if (!isObj(chess) || !chess.isDiy) return null; + if (chess.diyFor) return chess; // already composed + if (!isObj(pick) || !isObj(data?.backups)) return null; + let m = CACHE.get(data.backups); + if (!m) { m = new Map(); CACHE.set(data.backups, m); } + const key = `${chess.chessId}|${pick.charId}|${pick.skillIndex ?? ''}|${pick.uniEquipId ?? ''}`; + if (m.has(key)) return m.get(key); + let rec; + try { rec = diyRecordOf(chess, pick, data); } catch { rec = null; } + m.set(key, rec); + return rec; +} + +/** The record the player's own piece / card of `chess` shows: its 自选 record (own pick), or null. */ +export function ownDiyRecord(chess, priv, data) { + const pick = ownDiyPick(priv, chess); + return pick ? diyRecordFor(chess, pick, data) : null; +} + +/** + * The 自选 record a card of `chess` shows for this viewer, or null: a unit that carries `diy` (another player's — the + * sim's UnitInfo, prep scouting, a result lineup entry), else the player's own piece / card (m.private.diy). + * @param {any} chess @param {{ priv?: any, unit?: any, data?: any }} o + */ +export function cardDiy(chess, { priv = null, unit = null, data = null } = {}) { + if (!isObj(chess) || !chess.isDiy) return null; + if (unit) return isObj(unit.diy) ? diyRecordFor(chess, unit.diy, data) : null; + return ownDiyRecord(chess, priv, data); +} + +/** + * id → the player's 自选 record of a filled DIY slot (both forms), else the chess record: the lookup the player's own + * pieces and cards resolve against (names, art, position, the loadout / range of a 自选 piece — shared/protocol.js + * resolveLoadout then reads the pick's skill and module as the record's defaults). + * @param {(id: string) => any} getChess @param {any} priv m.private (or a getter of the current one) @param {any} data + */ +export function diyGetter(getChess, priv, data) { + return (id) => { + const c = getChess(id); + return ownDiyRecord(c, typeof priv === 'function' ? priv() : priv, typeof data === 'function' ? data() : data) || c; + }; +} + +/** id → the 自选 record of a slot filled with `pick` (another player's unit), else the chess record. */ +export function pickGetter(getChess, pick, data) { + return (id) => { + const c = getChess(id); + return (isObj(pick) && c && c.isDiy ? diyRecordFor(c, pick, data) : null) || c; + }; +} + +/** Whether a record is a composed 自选 record. */ +export const isDiyRecord = (rec) => isObj(rec) && typeof rec.diyFor === 'string'; diff --git a/public/js/ui/loadoutSync.js b/public/js/ui/loadoutSync.js index fa4199b5..72abe481 100644 --- a/public/js/ui/loadoutSync.js +++ b/public/js/ui/loadoutSync.js @@ -1,23 +1,28 @@ -// Operator loadout + ownership state and server sync (DESIGN §16; 干员持有 — 0.2.0 补位). +// Operator loadout + ownership + 自选编队 state and server sync (DESIGN §16; 干员持有 — 0.2.0 补位; 自选编队 — 0.2.0 DIY). // // `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 +// per-browser not-owned list of the 干员持有 tab (`notOwned`, likewise), the 自选编队 picks (`diy`, likewise; with +// `diyKitted` = the operators the server lets a DIY slot field, from every `welcome`) and the overlay's screen state +// (open / origin / tab / selection / filters). `installLoadoutSync()` / `installOwnershipSync()` / `installDiySync()` +// (called once by main.js) keep the server's copies current — one sync engine (installPrefSync), three 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. +// the whole loadout), `room.ownership { notOwned }` (sent as stored: the server keeps the droppable chess and drops +// the rest, so no data is needed) and `room.diy { picks }` (sent as stored, structurally clean: the server keeps the +// legal picks). Replies: RATE → retried later; WRONG_PHASE / ROOM_STARTED → the running match keeps what it took (the +// loadout locks when INFO_CHECK ends; the ownership and the 自选 picks never change during a match) — stored for the +// next match, not an error for the player; anything else is logged. `sync` / `ownSync` / `diySync` ∈ '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 { DIY_PREF, parseStoredDiy, toStoredDiy, cleanPicks, sanitizeDiyPicks } from './diyModel.js'; import { toast } from './toasts.js'; +import { t } from '../../../shared/i18n.js'; export const SYNC_DEBOUNCE_MS = 500; export const RETRY_MS = 1500; @@ -28,18 +33,24 @@ function readStored() { function readStoredOwnership() { try { return parseStoredOwnership(loadPref(OWNERSHIP_PREF, null)); } catch { return []; } } +function readStoredDiy() { + try { return parseStoredDiy(loadPref(DIY_PREF, null)); } catch { return {}; } +} /** 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) + diy: readStoredDiy(), // 自选编队: { [slotBaseId]: { charId, skillIndex?, uniEquipId? } } ({} = every slot empty) + diyKitted: null, // the operators a DIY slot may field (welcome.diyKitted; null before the first welcome) open: false, from: null, // 'lobby' | 'room' | 'briefing' - tab: 'loadout', // 'loadout' (干员调配) | 'ownership' (干员持有) + tab: 'loadout', // 'loadout' (干员调配) | 'ownership' (干员持有) | 'diy' (自选编队) sel: null, // selected base chess id filters: { tier: null, prof: null, bond: null, query: '', changedOnly: false }, sync: 'idle', ownSync: 'idle', + diySync: 'idle', }); /** Replace the stored entries (persisted at once; the sync picks the change up). */ @@ -86,9 +97,30 @@ export function applyOwnershipImport(list, lookup) { return { applied: clean.length, dropped: Math.max(0, asked - clean.length) }; } +/** Replace the stored 自选编队 picks (persisted at once, the sync picks the change up). */ +export function setDiyPicks(picks) { + const next = cleanPicks(picks); + try { savePref(DIY_PREF, toStoredDiy(next)); } catch { /* private mode: the session keeps it */ } + loadoutStore.set({ diy: next }); +} + +/** + * Apply an imported roster (parseDiyImport(...).picks), sanitised against the loaded data and the kit list (the picks + * the server would keep), persisted and synced like an edit. An empty roster is a valid import (= every slot empty). + * @param {Record} picks @param {any} data `{ chess, backups }` @param {Iterable|null} kitted + * @returns {{ applied: number, dropped: number }} + */ +export function applyDiyImport(picks, data, kitted) { + const asked = Object.keys(cleanPicks(picks)).length; + const clean = sanitizeDiyPicks(picks, data, kitted); + setDiyPicks(clean); + const applied = Object.keys(clean).length; + return { applied, dropped: Math.max(0, asked - applied) }; +} + /** * 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) + * @param {'loadout'|'ownership'|'diy'|null} [tab] the tab to show (default: the last one) */ export function openLoadout(from = 'lobby', sel = null, tab = null) { data.load('chess'); @@ -96,7 +128,7 @@ export function openLoadout(from = 'lobby', sel = null, tab = null) { data.load('assets'); data.load('local'); data.load('backups'); - loadoutStore.set({ open: true, from, ...(sel ? { sel } : {}), ...(tab === 'loadout' || tab === 'ownership' ? { tab } : {}) }); + loadoutStore.set({ open: true, from, ...(sel ? { sel } : {}), ...(tab === 'loadout' || tab === 'ownership' || tab === 'diy' ? { tab } : {}) }); } export const closeLoadout = () => loadoutStore.set({ open: false }); @@ -107,7 +139,8 @@ export const closeLoadout = () => loadoutStore.set({ open: false }); */ 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 tell = notify || ((text) => toast(text, 'warn')); + // (`lockedText` is a msgid: the toast is translated when it shows — docs/I18N.md) + const tell = notify || ((text) => toast(t(text), 'warn')); let timer = null; 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 @@ -228,3 +261,24 @@ export function installOwnershipSync({ net, timers, target = loadoutStore, notif prepare: async () => cleanIds(target.get().notOwned), }); } + +/** + * Wire the 自选编队 sync once (0.2.0 DIY): `room.diy { picks }` after every welcome and edit. The picks go as stored + * (structurally clean); the server keeps the legal ones. During a match the server stores them for the next one + * (ROOM_STARTED → 'locked'): the setting is out of match. Every `welcome` also brings the operators a DIY slot may field + * (`diyKitted`, the picker's list). + * @param {{ net: any, timers?: { setTimeout: Function, clearTimeout: Function }, target?: ReturnType, + * notify?: (text: string) => void }} deps + */ +export function installDiySync({ net, timers, target = loadoutStore, notify } = {}) { + const offKit = net.on('welcome', (msg) => { + const list = msg && Array.isArray(msg.diyKitted) ? msg.diyKitted.filter((x) => typeof x === 'string') : null; + target.set({ diyKitted: list }); + }); + const sync = installPrefSync({ + net, timers, target, notify, key: 'diy', stateKey: 'diySync', msgType: 'room.diy', field: 'picks', tag: 'diy', + lockedText: '自选编队是局外设置,修改将在下一局生效', + prepare: async () => cleanPicks(target.get().diy), + }); + return { flush: sync.flush, dispose() { offKit?.(); sync.dispose(); } }; +} diff --git a/public/js/ui/shopBar.js b/public/js/ui/shopBar.js index 967bbdb2..b60fd627 100644 --- a/public/js/ui/shopBar.js +++ b/public/js/ui/shopBar.js @@ -19,14 +19,17 @@ // §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). 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). +// and the skill icon is the stand-in's (its backup skill: the one that fights). A 自选 piece (0.2.0, m.private.diy — a DIY +// slot the player filled, only ever in its own shop) shows the operator — name, portrait, class, the bonds of its +// factions, the pick's skill and module — with a mint 「自选」 badge. 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, fieldsStandIn, standInOf, standInLoadout, standInLabel } from './gameLogic.js'; +import { priceTone, mergeProgress, mergeTarget, shopBlockReason, chessLoadout, offerHeader, briefingBondTip, fieldsStandIn, standInOf, standInLoadout, standInLabel, ownDiyRecord, diyGetter } from './gameLogic.js'; import { chessPortraitUrl, itemIconUrl, profIconUrl, uiUrl, skillIconUrl, skillRecordIconUrl, moduleTypeIconUrl } from './assetUrls.js'; import { data } from '../data.js'; +import { t } from '../../../shared/i18n.js'; const cx = (...p) => p.flat().filter(Boolean).join(' '); @@ -64,7 +67,11 @@ function ArmedTag({ reason, free }) { * onTap?:(idx:number)=>void, onBuy:Function, onDetail:Function }} props */ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free = false, armed = false, onTap = null, onBuy, onDetail, offBonds = null }) { - const c = data.lookup('chess', slot.id); + const c0 = data.lookup('chess', slot.id); + // 0.2.0 自选编队: a DIY slot the player filled is its operator (shared/diy.js, the pick of m.private.diy) + const diyData = { chess: data.get('chess'), backups: data.get('backups') }; + const dr = c0 ? ownDiyRecord(c0, priv, diyData) : null; + const c = dr || c0; const m = data.get('assets'); const tier = c?.tier ?? 1; const prog = mergeProgress(priv, slot.id, (id) => data.lookup('chess', id)); @@ -74,7 +81,7 @@ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free const disabled = !!reason; // 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 lo = si ? standInLoadout(si, LOOKUPS.getChess, data.get('backups')) : c ? chessLoadout(c, priv?.loadout, dr ? diyGetter(LOOKUPS.getChess, priv, diyData) : LOOKUPS.getChess) : null; const tap = () => { if (onTap) onTap(idx); else if (!disabled) onBuy(idx); else onDetail(slot.id, 'chess', hint); }; const card = html`