diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4ffb3469..35adeb97 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -103,7 +103,7 @@ so old imports keep working: `public/js/ui/gameLogic.js` (`public/js/ui/gameLogi | `public/index.html`, `public/js/main.js` | the page and its entry: boot, the router (title → lobby → room → game) | | `public/js/net.js`, `public/js/store.js`, `public/js/data.js` | the socket client, the observable store, the data loader (`/data/*.json`, with the English overlay) | | `public/js/battle/` | `runner.js` (the local battle: loads `/sim/`, steps it, reports), `observe.js` (who may watch which field) | -| `public/js/screens/` | `title.js`, `lobby.js`, `room.js`, `loadout.js` (干员调配), `ownership.js` (干员持有), `diy.js` (自选编队), `briefing.js`, `bandDraft.js`, `game.js` with `public/js/screens/game/`, `result.js` | +| `public/js/screens/` | `title.js`, `lobby.js`, `room.js`, `loadout.js` (干员调配), `cultivation.js` (its 潜能 / 练度 controls), `ownership.js` (干员持有), `diy.js` (自选编队), `briefing.js`, `bandDraft.js`, `game.js` with `public/js/screens/game/`, `result.js` | | `public/js/ui/` | the HUD components (`hud.js`, `shopBar.js`, `detailPanel.js`, `bondStrip.js`, `teamPanel.js` …); `public/js/ui/gameLogic/` the pure in-match logic, unit-tested in Node | | `public/js/render/` | the field view: `app.js` with `public/js/render/app/`, `units.js` and `spine.js` (models), `tiles.js`, `projection.js`, `interp.js`, `pick.js`, `drag.js`, `public/js/render/fx/` (effects; `kinds.js` maps the fx kinds), `public/js/render/board3d/` (the official 3D board) | | `public/css/`, `public/i18n/.json` | the styles; the UI strings of each language pack (English ships) | diff --git a/docs/design/match.md b/docs/design/match.md index e1ef1ebe..1866c1cc 100644 --- a/docs/design/match.md +++ b/docs/design/match.md @@ -99,9 +99,9 @@ Official rule (bwiki 盟约, 更新公告 5114): before a match the player canno **Sim:** `simdata` resolves a unit def for `(chessId, skillIndex, moduleId)` (cached): `skill` = the selected SkillRecord; stats = `statsBase` + module attr (elite) ; trait/talents = base with the selected module's overrides. Kit contract (backward compatible): a kit function `(bb, chess) => { skill?, skills?: { [skillId]: SkillSpec }, talents, trait }` receives `chess.skill` = the **selected** skill and `bb` = its blackboard; the loader uses `skills[selectedSkillId]` if present, else `skill` only when the selected skill is the chess's default, else the **generic** skill spec — talents/trait from the kit always apply. Every visible chess must get hand-authored specs for all of its selectable skills (tracked by a coverage test). -**UI:** a 干员调配 screen reachable from the lobby, room and briefing (INFO_CHECK): the detail starts with the operator's 特质 — the in-match `GarrisonBlock` / RichText of the form its 普通 / 精锐 skill toggle shows, triggers such as 「<进入休整期时>」 kept (0.2.2, PR #301 by @farridge); filter by tier/bond/class, per chess choose skill (icon, name, SP info, desc at Lv4 and Lv7) and elite module (official type icon from the local-client art when extracted (§13), else a lettered tile; name/type, stat bonus, trait/talent changes), 恢复默认, persisted; a 局内数值 section between the skills and the modules (普通 / 精锐 toggle, 精锐 first) draws the detail card's own stats block, 特性 and 天赋 for the chosen skill / module (§22.16); the chosen skill/module shown on shop cards and in the detail panel (a teammate's unit — 前往查看 in prep, 联防 / 最终攻势 fields, an observed battle — shows **its owner's** skill/module from its UnitInfo `skillIndex` / `moduleId`, `gameLogic.js unitLoadout`) — which also shows the stats, 特性, talents and attack range the unit fights with (`shared/loadoutRecord.js` `loadoutRecord` / `attackRangeGrid`, the same composition the sim's `getChess(id, loadout)` uses; the deploy wheel's range preview too) — the range it is deployed with: a selected passive range skill (引星棘刺 S3 3-9) and the 特性's permanent 攻击距离 (信仰搅拌机 SPT-Y +1) included, over all 1284 loadouts equal to the battle's range at deployment; in battle and prep the card draws the live `range` (§21.16). In battle the renderer plays the equipped skill's own Spine clip and ON_SKILL_START sound (`UnitInfo.skillIndex`; `data/assets.json` lists icons, clips and sounds for every skill index). +**UI:** a 干员调配 screen reachable from the lobby, room and briefing (INFO_CHECK): the roster is one list, a row per operator with aligned columns under a head row — portrait / name / bonds (opens the detail), the skills and the elite's modules to tap (不装备 first; the quick choices of PR #301 by @farridge, laid out as one list instead of its three columns of cards, the owner's call of 2026-10-08), the 潜能 / 练度 selects (`screens/loadout.js RosterRow`, `screens/cultivation.js`); the detail beside it (below 1000 px wide it slides over the list, a quick choice keeps the list) opens with the 潜能与练度 section (潜能 1–6, the four official 练度 tiers with their effect texts); the detail then shows the operator's 特质 — the in-match `GarrisonBlock` / RichText of the form its 普通 / 精锐 skill toggle shows, triggers such as 「<进入休整期时>」 kept (0.2.2, PR #301 by @farridge); filter by tier/bond/class, per chess choose skill (icon, name, SP info, desc at Lv4 and Lv7) and elite module (official type icon from the local-client art when extracted (§13), else a lettered tile; name/type, stat bonus, trait/talent changes), 恢复默认, persisted; a 局内数值 section between the skills and the modules (普通 / 精锐 toggle, 精锐 first) draws the detail card's own stats block, 特性 and 天赋 for the chosen skill / module (§22.16); the chosen skill/module shown on shop cards and in the detail panel (a teammate's unit — 前往查看 in prep, 联防 / 最终攻势 fields, an observed battle — shows **its owner's** skill/module from its UnitInfo `skillIndex` / `moduleId`, `gameLogic.js unitLoadout`) — which also shows the stats, 特性, talents and attack range the unit fights with (`shared/loadoutRecord.js` `loadoutRecord` / `attackRangeGrid`, the same composition the sim's `getChess(id, loadout)` uses; the deploy wheel's range preview too) — the range it is deployed with: a selected passive range skill (引星棘刺 S3 3-9) and the 特性's permanent 攻击距离 (信仰搅拌机 SPT-Y +1) included, over all 1284 loadouts equal to the battle's range at deployment; in battle and prep the card draws the live `range` (§21.16). In battle the renderer plays the equipped skill's own Spine clip and ON_SKILL_START sound (`UnitInfo.skillIndex`; `data/assets.json` lists icons, clips and sounds for every skill index). -**导出 / 导入 a loadout:** 导出 hands the current loadout out as a versioned envelope `{ kind: 'stronghold.loadout', v, exportedAt, count, entries }` — copied through `ui/clipboard.js copyText` or downloaded as a file. 导入 accepts that envelope, the stored `{ v, entries }` form and a bare `{ [chessId]: { skill, module } }` map (serialised text too), and refuses another tool's `kind`, junk, an empty result and a **newer** `v` (`parseImport` — a newer payload is never mis-read). Both go through `loadoutSync.applyLoadoutEntries` → `sanitizeEntries`, so a preset from another build never sends the server an entry it would refuse, an import that keeps nothing changes nothing, and the result is reported as N imported / M not (`ui/loadoutModel.js`, `screens/loadout.js`). +**导出 / 导入 a loadout:** 导出 hands the current loadout out as a versioned envelope `{ kind: 'stronghold.loadout', v, exportedAt, count, entries, ops }` (`ops`: the 潜能 / 练度 settings, 0.2.2 — an import without them leaves the current settings alone) — copied through `ui/clipboard.js copyText` or downloaded as a file. 导入 accepts that envelope, the stored `{ v, entries }` form and a bare `{ [chessId]: { skill, module } }` map (serialised text too), and refuses another tool's `kind`, junk, an empty result and a **newer** `v` (`parseImport` — a newer payload is never mis-read). Both go through `loadoutSync.applyLoadoutEntries` → `sanitizeEntries`, so a preset from another build never sends the server an entry it would refuse, an import that keeps nothing changes nothing, and the result is reported as N imported / M not (`ui/loadoutModel.js`, `screens/loadout.js`). **干员持有 (0.2.0 补位):** the overlay's second tab marks operators as not owned (the 55 NORMAL chess; per-browser `sp.pref.ownership` = `{ v, notOwned }`, synced as `room.ownership { notOwned }` by the same sync engine as the loadout, `ui/loadoutSync.js installPrefSync`; 导出 / 导入 as `{ kind: 'stronghold.ownership', v, exportedAt, count, notOwned }`). A not-owned chess fights as its official stand-in with the backup skill / module — the loadout never applies to it ("对于补位干员其技能不可更改") — and is shown as the stand-in wherever the player's copy appears (shop / reward cards, hand and 临时整备区, board, detail card, result lineup, a teammate's scouting and bond popups: portrait, name, model, with a small 「替补」 mark; the owner's recall of the official mode, 2026-10-06), while its bonds, 特质, tier, price and merge stay the chess's; the 0.2.0 补位 design subsections have the rules and sources. **自选编队 (0.2.0):** the overlay's third tab fills the four DIY slots (5阶 ×2, 6阶 ×2) with an owned 6★ outside the chess pool (any of its three skills, any module of its elite form but a 集成战略 one) or a prototype (its locked skill / module); only operators with a kit are offered (`welcome.diyKitted`). Per-browser `sp.pref.diy` = `{ v, picks }`, synced as `room.diy { picks }` by the same sync engine; 导出 / 导入 as `{ kind: 'stronghold.diy', v, exportedAt, count, picks }` (`ui/diyModel.js`, `screens/diy.js`). In the match a slotted piece is the operator for its player (name, art, class, bonds from its factions, no 特质, the pick's skill and module; the slot's tier, price and merge), sold only in that player's shop from the slot's 调度中心 level with its own stock; the shop / reward / detail cards and the own pieces carry a 「自选」 badge (`ui/gameLogic/diy.js`); the 0.2.0 自选编队 design subsection has the rules and sources. diff --git a/public/css/screens/loadout.css b/public/css/screens/loadout.css index abff46b8..a0574cda 100644 --- a/public/css/screens/loadout.css +++ b/public/css/screens/loadout.css @@ -138,56 +138,93 @@ .lo-toggle.is-on .lo-toggle__box { background: var(--mint-500); border-color: var(--mint-400); } .lo-toggle.is-on .lo-toggle__box .icon { opacity: 1; } -/* ---- roster grid ------------------------------------------------------------------------------------------------------------ */ -.lo-grid { +/* ---- roster: one list, one row per operator (0.2.2) ------------------------------------------------------------------------ */ +/* The quick choices of PR #301 (by @farridge) as ONE list instead of three columns of cards (the owner's decision of + 2026-10-08: 「三个竖排有点乱影响观感」): every row has the same four columns — the operator (portrait, name, bonds), the + skills, the elite's modules, 潜能 / 练度 — aligned under a sticky head row, so the eye runs down a column. Sizes: --q a + quick button (a skill / module), --art the portrait; a coarse pointer gets 40 px targets. */ +.lo-list { + --q: max(.4rem, 32px); --qg: max(.05rem, 4px); --art: max(.52rem, 40px); + --cols: minmax(max(2.2rem, 110px), max(3.2rem, 260px)) calc(3 * var(--q) + 2 * var(--qg)) calc(4 * var(--q) + 3 * var(--qg)) max(2.2rem, 160px); flex: 1 1 auto; min-height: 0; overflow: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; - display: grid; grid-template-columns: repeat(auto-fill, minmax(max(1.16rem, 64px), 1fr)); grid-auto-rows: max-content; - align-content: start; gap: .1rem; padding: .06rem .08rem .2rem .02rem; - scrollbar-width: thin; scrollbar-color: var(--line-3) transparent; + padding: 0 .06rem .2rem 0; scrollbar-width: thin; scrollbar-color: var(--line-3) transparent; } -.lo-empty { grid-column: 1 / -1; padding: .4rem; text-align: center; font-size: max(.16rem, 11px); } +.sp-coarse .lo-list { --q: max(.44rem, 40px); } +.lo-list__head, .lo .lo-card { display: grid; grid-template-columns: var(--cols); justify-content: start; align-items: center; column-gap: max(.12rem, 8px); } +/* (the head has the rows' box — the same borders, transparent, and padding — so its columns are the rows' to the pixel) */ +.lo-list__head { + position: sticky; top: 0; z-index: 2; padding: .05rem .1rem .05rem .07rem; border: 1px solid transparent; border-left-width: 3px; + font-size: max(.12rem, 9px); font-weight: 700; letter-spacing: .12em; color: var(--text-dim); + background: linear-gradient(180deg, #0d1110 70%, rgba(13, 17, 16, .85)); box-shadow: inset 0 -1px 0 var(--line); +} +.lo-list__h small { margin-left: .05rem; font-weight: 400; letter-spacing: .04em; color: var(--gold-2); opacity: .8; } +.lo-list__rows { display: flex; flex-direction: column; gap: max(.04rem, 3px); padding-top: max(.05rem, 4px); } +.lo-empty { padding: .4rem; text-align: center; font-size: max(.16rem, 11px); } -.lo-card { +.lo .lo-card { --tc: var(--tier-1); - position: relative; display: flex; flex-direction: column; align-items: stretch; min-width: 0; - padding: 0; font: inherit; color: var(--text-hi); text-align: left; cursor: pointer; - background: linear-gradient(180deg, rgba(34, 41, 38, .95), rgba(14, 17, 16, .98)); - border: 1px solid var(--line); box-shadow: inset 0 -2px 0 var(--tc); - transition: border-color var(--t-fast), transform var(--t-fast), box-shadow var(--t-fast); - touch-action: manipulation; + position: relative; min-width: 0; padding: max(.05rem, 4px) .1rem max(.05rem, 4px) .07rem; + background: rgba(255, 255, 255, .025); border: 1px solid var(--line); border-left: 3px solid var(--tc); + transition: background var(--t-fast), border-color var(--t-fast); } .lo-card--t1 { --tc: var(--tier-1); } .lo-card--t2 { --tc: var(--tier-2); } .lo-card--t3 { --tc: var(--tier-3); } .lo-card--t4 { --tc: var(--tier-4); } .lo-card--t5 { --tc: var(--tier-5); } .lo-card--t6 { --tc: var(--tier-6); } -.lo-card:hover { border-color: var(--line-3); transform: translateY(-1px); } -.lo-card.is-sel { border-color: var(--mint-400); box-shadow: inset 0 -2px 0 var(--tc), 0 0 0 1px var(--mint-500), 0 0 .16rem rgba(23, 249, 183, .35); } -.lo-card:focus-visible { outline: 2px solid var(--mint-400); outline-offset: 1px; } -.lo-card__art { position: relative; display: block; aspect-ratio: 1; overflow: hidden; background: #1b201e; } -.lo-card__art img { width: 100%; height: 100%; object-fit: cover; display: block; } -.lo-card__art::after { content: ''; position: absolute; inset: 55% 0 0; background: linear-gradient(180deg, transparent, rgba(8, 10, 9, .85)); } -.lo-card__glyph { display: grid; place-items: center; width: 100%; height: 100%; font-size: .44rem; font-weight: 900; color: var(--text-dim); } -.lo-card__tier { position: absolute; left: .03rem; top: .03rem; z-index: 1; } -.lo-card__flag { - position: absolute; right: 0; top: 0; z-index: 1; width: .22rem; height: .22rem; - background: linear-gradient(225deg, var(--mint-400) 50%, transparent 50%); -} -/* 0.2.0 补位: a chess marked 未持有 on the 干员持有 tab (its stand-in fights with a fixed skill) */ -.lo-card__sub { position: absolute; right: .03rem; top: .26rem; z-index: 1; padding: 0 .04rem; font-size: max(.11rem, 8px); font-weight: 700; line-height: 1.4; color: var(--ice-900); background: var(--ice); } +.lo .lo-card:hover { background: rgba(255, 255, 255, .045); border-color: var(--line-3); border-left-color: var(--tc); } +.lo .lo-card.is-sel { background: linear-gradient(90deg, rgba(78, 216, 175, .1), rgba(78, 216, 175, .02) 60%); border-color: var(--mint-700); border-left-color: var(--tc); box-shadow: 0 0 0 1px var(--mint-500) inset; } +.lo-card button:focus-visible, .lo-card select:focus-visible { outline: 2px solid var(--mint-400); outline-offset: 1px; } +.lo-card__pick { display: flex; align-items: center; gap: max(.1rem, 6px); min-width: 0; padding: 0; font: inherit; color: inherit; text-align: left; background: none; border: 0; cursor: pointer; touch-action: manipulation; } +.lo-card__art { position: relative; flex: none; width: var(--art); height: var(--art); overflow: hidden; background: #1b201e; border: 1px solid var(--line-2); } +.lo-card__art > img { width: 100%; height: 100%; object-fit: cover; display: block; } +.lo-card__glyph { display: grid; place-items: center; width: 100%; height: 100%; font-size: max(.24rem, 16px); font-weight: 900; color: var(--text-dim); } +.lo-card__tier { position: absolute; left: 1px; top: 1px; z-index: 1; transform-origin: 0 0; transform: scale(.8); } +.lo-card__id { display: flex; flex-direction: column; gap: max(.03rem, 2px); min-width: 0; } +.lo-card__name { font-size: max(.17rem, 12px); font-weight: 700; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.lo-card__bonds { display: flex; flex-wrap: wrap; gap: max(.04rem, 3px); min-height: max(.16rem, 12px); } +.lo-card__bond, .lo-card__bonds .lo-bond__dot { width: max(.18rem, 13px); height: max(.18rem, 13px); object-fit: contain; } +.lo-card__flag { position: absolute; right: 0; top: 0; z-index: 1; width: max(.16rem, 10px); height: max(.16rem, 10px); background: linear-gradient(225deg, var(--mint-400) 50%, transparent 50%); pointer-events: none; } +/* 0.2.0 补位: a chess marked 未持有 on the 干员持有 tab — its stand-in fights with a fixed skill and no 潜能 / 练度; the choices + here apply once it is owned again */ +.lo-card__sub { align-self: flex-start; padding: 0 .04rem; font-size: max(.11rem, 8px); font-weight: 700; line-height: 1.4; color: var(--ice-900); background: var(--ice); } +.lo .lo-card.is-standin .lo-card__skills, .lo .lo-card.is-standin .lo-card__mods, .lo .lo-card.is-standin .lo-cult { opacity: .55; } .lo-locknote--standin { color: var(--ice); } -.lo-card__name { - position: absolute; left: .05rem; right: .05rem; top: calc(100% - max(.64rem, 34px)); z-index: 1; - font-size: max(.15rem, 10px); font-weight: 700; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - text-shadow: 0 1px 2px #000; + +.lo-card__skills, .lo-card__mods { display: flex; align-items: center; gap: var(--qg); min-width: 0; } +.lo-card__mods { overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; } +.lo-q { + position: relative; flex: none; display: grid; place-items: center; width: var(--q); height: var(--q); padding: 0; + font: inherit; color: var(--text-lo); background: none; border: 0; cursor: pointer; touch-action: manipulation; } -.lo-card__kit { display: flex; align-items: center; justify-content: space-between; gap: .04rem; height: max(.3rem, 18px); padding: 0 .04rem; background: rgba(0, 0, 0, .4); } -.lo-card__sk { display: inline-flex; align-items: center; gap: .04rem; font-size: max(.13rem, 9px); color: var(--text-lo); } -.lo-card__sk b { font-family: var(--font-num); letter-spacing: .04em; } -.lo-card__sk.is-alt { color: var(--mint-400); } -.lo-card__mod { - display: inline-grid; place-items: center; min-width: max(.24rem, 15px); height: max(.2rem, 13px); padding: 0 .03rem; - font-family: var(--font-num); font-size: max(.13rem, 9px); font-weight: 700; color: var(--text-lo); - border: 1px solid var(--line-2); background: rgba(255, 255, 255, .04); -} -.lo-card__mod.is-alt { color: var(--text-on-mint); background: var(--gold-2); border-color: var(--gold); } +.lo-q--empty { border: 1px dashed rgba(255, 255, 255, .07); cursor: default; } +.lo-q:not(.is-on):not(.lo-q--empty) { opacity: .62; } +.lo-q:not(.is-on):not(.lo-q--empty):hover { opacity: 1; } +.lo-q__type { position: absolute; left: 0; bottom: 0; padding: 0 2px; font-size: max(.11rem, 8px); line-height: 1.3; color: var(--text-hi); background: rgba(0, 0, 0, .7); pointer-events: none; } +.lo-q--mod.is-on .lo-mglyph { border-color: var(--gold-2); box-shadow: 0 0 .08rem rgba(255, 198, 0, .35); } + +/* 潜能 / 练度 (screens/cultivation.js): two compact selects side by side in the row's last column; at the defaults (潜能 6, + 精英2 Lv.60 — the same on every row) they stay quiet, a changed one is mint */ +.lo-cult { display: flex; align-items: center; gap: max(.04rem, 3px); min-width: 0; } +.lo .lo-cult .lo-select { min-height: 0; height: max(.32rem, 22px); padding-left: 0; border-color: transparent; background: rgba(255, 255, 255, .03); } +.lo .lo-cult .lo-select:hover, .lo .lo-cult .lo-select:focus-within { border-color: var(--line-3); } +.lo .lo-cult .lo-select select { width: 100%; min-width: 0; padding: 0 max(.2rem, 14px) 0 max(.06rem, 4px); font-size: max(.14rem, 10px); color: var(--text-lo); } +.lo-cult__pot { flex: 0 0 auto; } +.lo-cult__tier { flex: 1 1 auto; min-width: 0; } +.lo .lo-cult .lo-select.is-off { border-color: var(--mint-700); background: rgba(78, 216, 175, .08); } +.lo .lo-cult .lo-select.is-off select { color: var(--mint-400); font-weight: 700; } +.sp-coarse .lo .lo-cult .lo-select { height: max(.32rem, 28px); } + +/* the detail's 潜能 / 练度 section */ +.lo-sec--cult { padding: .08rem .1rem .1rem; margin-bottom: .14rem; background: rgba(0, 0, 0, .22); border: 1px solid var(--line); border-left: 3px solid var(--mint-500); } +.lo-sec--cult .lo-sec__head .lo-badge { margin-left: auto; } +.lo-cult__row { display: grid; grid-template-columns: max(.5rem, 30px) minmax(0, 1fr); align-items: center; gap: .08rem; } +.lo-cult__k { font-size: max(.13rem, 10px); color: var(--text-dim); letter-spacing: .08em; } +.lo .lo-cult__row .lo-seg { margin-left: 0; flex-wrap: wrap; justify-self: start; } +.lo-seg--pot button { min-width: max(.36rem, 26px); font-family: var(--font-num); } +.lo-seg--tier button { padding: 0 .1rem; } +.lo-cult__eff { margin: .02rem 0 0; font-size: max(.14rem, 10px); line-height: 1.5; color: var(--text-md); } +.lo-cult__eff b { color: var(--mint-400); margin-right: .04rem; } +.lo-cult__note { margin: 0; font-size: max(.12rem, 9px); line-height: 1.5; color: var(--text-dim); } +.lo-sec--cult.is-moot .lo-cult__row, .lo-sec--cult.is-moot .lo-cult__eff { opacity: .55; } +.lo-sec--cult.is-moot .lo-cult__note { color: var(--ice); } /* ---- skill icon / module glyph ---------------------------------------------------------------------------------------------- */ .lo-sicon { position: relative; flex: none; display: inline-grid; place-items: center; width: .72rem; height: .72rem; background: #151a18; border: 1px solid var(--line-2); } @@ -195,6 +232,7 @@ .lo-sicon--xs { width: max(.2rem, 13px); height: max(.2rem, 13px); border: 0; background: transparent; } .lo-sicon--xs .lo-sicon__glyph { display: none; } .lo-sicon--md { width: max(.72rem, 38px); height: max(.72rem, 38px); } +.lo-sicon--q { width: 86%; height: 86%; } .lo-sicon__glyph { font-family: var(--font-num); font-size: max(.2rem, 12px); font-weight: 700; color: var(--text-lo); letter-spacing: .04em; } .lo-sicon.is-on { border-color: #1ea0ff; } .lo-sicon__outline { position: absolute; inset: -.05rem; width: calc(100% + .1rem) !important; height: calc(100% + .1rem) !important; object-fit: fill; pointer-events: none; } @@ -205,6 +243,7 @@ .lo-mglyph img { width: 78%; height: 78%; object-fit: contain; } .lo-mglyph__t { font-family: var(--font-num); font-size: max(.26rem, 15px); font-weight: 700; color: var(--gold-2); line-height: 1; } .lo-mglyph--none { background: #121615; border-style: dashed; } +.lo-mglyph--q { width: 86%; height: 86%; } .lo-mglyph__slash { position: absolute; inset: 22%; border-radius: 50%; border: 2px solid var(--text-dim); } .lo-mglyph__slash::after { content: ''; position: absolute; left: 50%; top: -10%; bottom: -10%; width: 2px; background: var(--text-dim); transform: translateX(-50%) rotate(45deg); } @@ -430,6 +469,9 @@ .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; } +/* 0.2.2: an owned pick's 潜能 / 练度 (screens/cultivation.js) — a prototype has neither (a dim line instead) */ +.diy-slot__cult { display: block; margin-top: .03rem; font-size: max(.12rem, 9px); } +.diy-slot__cult .lo-cult { max-width: max(2.6rem, 200px); } .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; } @@ -532,9 +574,13 @@ position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } } -/* phones (the root scale is clamped at 40 px: less than ~14 rem of width): the detail slides over the roster */ +/* phones and small tablets (up to 1000 px wide; 0.2.2 — 760 px before the roster became one list with quick choices): the + list takes the whole width and the detail slides over it — a row's quick choice keeps the list, its portrait / name + opens the detail */ @media (max-width: 760px) { .lo-top { padding: 0 .16rem; } +} +@media (max-width: 1000px) { .lo-body { grid-template-columns: minmax(0, 1fr); } .lo-detail-wrap { display: none; } .lo-body.is-detail .lo-roster { display: none; } @@ -545,11 +591,14 @@ } .lo-detail-back .icon { width: .2rem; height: .2rem; } } +/* narrow phones: the row's bonds give their line to the name, the columns keep their place */ +@media (max-width: 700px) { + .lo-card__bonds { display: none; } +} @media (max-height: 520px) { .lo-note { display: none; } .lo-dhead { grid-template-columns: max(1rem, 48px) minmax(0, 1fr); padding: .1rem .14rem; } .lo-dhead__en { display: none; } - .lo-grid { grid-template-columns: repeat(auto-fill, minmax(max(1rem, 58px), 1fr)); } } /* the shared Button / TextField scale with the root (40 px/rem on phones ⇒ 6–7 px text, 14–20 px tall): the overlay's own controls (返回, 全部恢复默认, 恢复默认, the search field) keep a readable size and a tappable height (review fix) */ @@ -560,7 +609,7 @@ .lo .lo-search .field__icon { width: max(.22rem, 14px); height: max(.22rem, 14px); } .sp-coarse .lo-chip, .sp-coarse .lo-toggle, .sp-coarse .lo-select { min-height: 30px; } .sp-coarse .lo-seg button { min-height: 28px; } -@media (prefers-reduced-motion: reduce) { .lo, .lo-card { animation: none; transition: none; } } +@media (prefers-reduced-motion: reduce) { .lo, .lo .lo-card { animation: none; transition: none; } } /* 导出 / 导入 (干员调配 presets): the dialog is the shared Modal, rendered NEXT TO `.lo` (a sibling) rather than inside it — so components.css `.modal { position: fixed }` and its z-index above the overlay apply as they do everywhere diff --git a/public/i18n/en.json b/public/i18n/en.json index 2e5ee1d3..d0cef684 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -1061,5 +1061,26 @@ "自回": "Auto", "常驻": "Always", "瞬发": "Instant", - "部署触发": "On deployment" + "部署触发": "On deployment", + "未精英化": "Non-Elite", + "精英阶段1": "Elite 1", + "精英阶段2": "Elite 2", + "精英阶段2-60级": "Elite 2 Lv.60", + "潜能": "Potential", + "潜能 {n}": "Potential {n}", + "练度": "Development", + "潜能与练度": "Potential & development", + "满潜能 · 精英2 Lv.60": "Max potential · Elite 2 Lv.60", + "干员持有中标记为未持有:替补干员没有潜能与练度;这里的设置在改回「持有」后生效": "Marked as not owned on Operator Roster: the stand-in has no potential or development; these settings apply once it is owned again", + "潜能取你自己的潜能;练度是自持有加成(已持有的同名干员的养成,攻击 / 防御 / 生命单独乘算)。未持有的特许干员按潜能1、未精英化。": "Potential is your own; development is the owned-operator bonus (your training of the same Operator: ATK / DEF / HP, a separate multiplier). A licensed Operator you do not own fights at Potential 1, Non-Elite.", + "原型干员没有潜能与练度": "A prototype Operator has no potential or development", + "模组仅在精锐形态生效": "Modules apply only to the Elite form", + "未持有(干员持有):由替补干员上场,替补干员没有潜能与练度": "Not owned (Operator Roster): its stand-in fights, with no potential or development", + "含潜能与练度;": "Potential and development included; ", + "将 {nChanged} 名干员的技能、模组、潜能与练度恢复为默认配置?": "Reset the skills, modules, potential and development of {nChanged} Operators to their defaults?", + "已导入 {n} 名干员的潜能与练度": "Imported the potential and development of {n} Operators", + "精英1": "E1", + "精英2": "E2", + "精英2 Lv.60": "E2 Lv.60", + "开始模拟前可调整干员携带的技能与模组,以及潜能与练度;干员的局内等级不可调整": "Before the simulation starts you can change the Skills and Modules your Operators carry, and their potential and development; Operator levels in the match cannot be changed" } diff --git a/public/i18n/ja.json b/public/i18n/ja.json index 4cececc1..e8e7661a 100644 --- a/public/i18n/ja.json +++ b/public/i18n/ja.json @@ -1067,5 +1067,26 @@ "自回": "自動", "常驻": "常時", "瞬发": "即時", - "部署触发": "配置時" + "部署触发": "配置時", + "未精英化": "未昇進", + "精英阶段1": "昇進段階1", + "精英阶段2": "昇進段階2", + "精英阶段2-60级": "昇進段階2-Lv60", + "潜能": "潜在", + "潜能 {n}": "潜在{n}", + "练度": "育成度", + "潜能与练度": "潜在と育成度", + "满潜能 · 精英2 Lv.60": "潜在最大・昇進2 Lv60", + "干员持有中标记为未持有:替补干员没有潜能与练度;这里的设置在改回「持有」后生效": "オペレーター所持で未所持に設定中:代役オペレーターには潜在も育成度もありません。ここでの設定は「所持」に戻すと有効になります", + "潜能取你自己的潜能;练度是自持有加成(已持有的同名干员的养成,攻击 / 防御 / 生命单独乘算)。未持有的特许干员按潜能1、未精英化。": "潜在はあなた自身の潜在です。育成度は所持オペレーターボーナス(同名オペレーターの育成度による攻撃・防御・HPの独立した乗算)です。未所持のプリセットオペレーターは潜在1・未昇進です。", + "原型干员没有潜能与练度": "モデルオペレーターには潜在も育成度もありません", + "模组仅在精锐形态生效": "モジュールは精鋭のみ有効です", + "未持有(干员持有):由替补干员上场,替补干员没有潜能与练度": "未所持(オペレーター所持):代役オペレーターが出撃し、潜在も育成度もありません", + "含潜能与练度;": "潜在と育成度を含む。", + "将 {nChanged} 名干员的技能、模组、潜能与练度恢复为默认配置?": "{nChanged}名のオペレーターのスキル・モジュール・潜在・育成度を初期設定に戻しますか?", + "已导入 {n} 名干员的潜能与练度": "{n}名のオペレーターの潜在と育成度をインポートしました", + "精英1": "昇進1", + "精英2": "昇進2", + "精英2 Lv.60": "昇進2 Lv60", + "开始模拟前可调整干员携带的技能与模组,以及潜能与练度;干员的局内等级不可调整": "シミュレーション開始前に、オペレーターのスキルとモジュール、潜在と育成度を調整できます(局内のオペレーターのレベルは変更不可)" } diff --git a/public/i18n/ko.json b/public/i18n/ko.json index 72d11a73..269c4a65 100644 --- a/public/i18n/ko.json +++ b/public/i18n/ko.json @@ -1063,5 +1063,26 @@ "自回": "자동", "常驻": "상시", "瞬发": "즉시", - "部署触发": "배치 시" + "部署触发": "배치 시", + "未精英化": "비정예화", + "精英阶段1": "1차 정예화", + "精英阶段2": "2차 정예화", + "精英阶段2-60级": "2차 정예화 Lv.60", + "潜能": "잠재", + "潜能 {n}": "잠재 {n}", + "练度": "육성도", + "潜能与练度": "잠재와 육성도", + "满潜能 · 精英2 Lv.60": "최대 잠재 · 2차 정예화 Lv.60", + "干员持有中标记为未持有:替补干员没有潜能与练度;这里的设置在改回「持有」后生效": "오퍼레이터 보유에서 미보유로 표시됨: 대체 오퍼레이터는 잠재와 육성도가 없습니다. 이 설정은 다시 '보유'로 바꾸면 적용됩니다", + "潜能取你自己的潜能;练度是自持有加成(已持有的同名干员的养成,攻击 / 防御 / 生命单独乘算)。未持有的特许干员按潜能1、未精英化。": "잠재는 자신의 잠재를 따릅니다. 육성도는 보유 오퍼레이터 보너스(같은 오퍼레이터의 육성도에 따른 공격 / 방어 / HP 별도 배율)입니다. 보유하지 않은 프리셋 오퍼레이터는 잠재 1, 비정예화입니다.", + "原型干员没有潜能与练度": "프로토타입 오퍼레이터는 잠재와 육성도가 없습니다", + "模组仅在精锐形态生效": "모듈은 정예화 형태에서만 적용됩니다", + "未持有(干员持有):由替补干员上场,替补干员没有潜能与练度": "미보유(오퍼레이터 보유): 대체 오퍼레이터가 출전하며 잠재와 육성도가 없습니다", + "含潜能与练度;": "잠재와 육성도 포함. ", + "将 {nChanged} 名干员的技能、模组、潜能与练度恢复为默认配置?": "오퍼레이터 {nChanged}명의 스킬, 모듈, 잠재, 육성도를 기본 설정으로 되돌릴까요?", + "已导入 {n} 名干员的潜能与练度": "오퍼레이터 {n}명의 잠재와 육성도를 가져왔습니다", + "精英1": "정예 1", + "精英2": "정예 2", + "精英2 Lv.60": "정예 2 Lv.60", + "开始模拟前可调整干员携带的技能与模组,以及潜能与练度;干员的局内等级不可调整": "시뮬레이션 시작 전에 오퍼레이터의 스킬과 모듈, 잠재와 육성도를 조정할 수 있습니다. 게임 내 오퍼레이터 레벨은 조정할 수 없습니다." } diff --git a/public/i18n/zh-TW.json b/public/i18n/zh-TW.json index 73effd06..3e2cb753 100644 --- a/public/i18n/zh-TW.json +++ b/public/i18n/zh-TW.json @@ -1065,5 +1065,26 @@ "自回": "自回", "常驻": "常駐", "瞬发": "瞬發", - "部署触发": "部署觸發" + "部署触发": "部署觸發", + "未精英化": "未精英化", + "精英阶段1": "精英階段1", + "精英阶段2": "精英階段2", + "精英阶段2-60级": "精英階段2-60級", + "潜能": "潛能", + "潜能 {n}": "潛能 {n}", + "练度": "練度", + "潜能与练度": "潛能與練度", + "满潜能 · 精英2 Lv.60": "滿潛能 · 精英2 Lv.60", + "干员持有中标记为未持有:替补干员没有潜能与练度;这里的设置在改回「持有」后生效": "幹員持有中標記為未持有:替補幹員沒有潛能與練度;這裡的設定在改回「持有」後生效", + "潜能取你自己的潜能;练度是自持有加成(已持有的同名干员的养成,攻击 / 防御 / 生命单独乘算)。未持有的特许干员按潜能1、未精英化。": "潛能取你自己的潛能;練度是自持有加成(已持有的同名幹員的養成,攻擊 / 防禦 / 生命單獨乘算)。未持有的特許幹員按潛能1、未精英化。", + "原型干员没有潜能与练度": "原型幹員沒有潛能與練度", + "模组仅在精锐形态生效": "模組僅在精銳形態生效", + "未持有(干员持有):由替补干员上场,替补干员没有潜能与练度": "未持有(幹員持有):由替補幹員上場,替補幹員沒有潛能與練度", + "含潜能与练度;": "含潛能與練度;", + "将 {nChanged} 名干员的技能、模组、潜能与练度恢复为默认配置?": "將 {nChanged} 名幹員的技能、模組、潛能與練度恢復為預設配置?", + "已导入 {n} 名干员的潜能与练度": "已匯入 {n} 名幹員的潛能與練度", + "精英1": "精英1", + "精英2": "精英2", + "精英2 Lv.60": "精英2 Lv.60", + "开始模拟前可调整干员携带的技能与模组,以及潜能与练度;干员的局内等级不可调整": "開始模擬前可調整幹員攜帶的技能與模組,以及潛能與練度;幹員的局內等級不可調整" } diff --git a/public/js/screens/cultivation.js b/public/js/screens/cultivation.js new file mode 100644 index 00000000..906a5aac --- /dev/null +++ b/public/js/screens/cultivation.js @@ -0,0 +1,94 @@ +// 潜能 / 练度 controls of the 干员调配 overlay (0.2.2, the owner's decision of 2026-10-08: 「调配干员里自己设置吧,默认满潜 +// 满加成」): per operator (by charId — the normal, elite and 自选 forms share them), 潜能 1–6 and the 自持有 练度 tier +// (effects.json CHAR_MAP aceffect_char_1…4: 未精英化 / 精英阶段1 / 精英阶段2 / 精英阶段2-60级 — their official names and +// texts, localized by the data overlay), default 潜能 6 / 精英阶段2-60级. Official: 调度手册 「卫戍协议中干员潜能由自身已 +// 持有干员潜能决定」 and 「提升已持有的干员,将为卫戍协议中同名的干员提供加成」 (an operator the player does not own fights +// at 潜能 1 with no bonus: set by hand for a 特许 one; a NORMAL one not owned is its stand-in, which has neither). +// CultivationSelects: two compact selects (a roster row, a 自选 slot card); CultivationSection: the detail's segmented +// controls with the tier's effect. Hook-free views (the tests draw them). The settings live in ui/loadoutSync.js +// (`ops`, synced with room.loadout); the model is ui/loadoutModel.js opsOf / setOps. + +import { html, MicroLabel } from '../ui/components.js'; +import { data } from '../data.js'; +import { opsOf } from '../ui/loadoutModel.js'; +import { CULTIVATE_EFFECTS, POTENTIAL_MIN, POTENTIAL_MAX } from '../../../shared/potential.js'; +import { t, N_ } from '../../../shared/i18n.js'; + +const cx = (...p) => p.flat().filter(Boolean).join(' '); +const POTENTIALS = Array.from({ length: POTENTIAL_MAX - POTENTIAL_MIN + 1 }, (_, i) => POTENTIAL_MIN + i); +const TIERS = CULTIVATE_EFFECTS.map((_, i) => i); +/** Names while effects.json is not loaded (msgids; the data's own names win). */ +const FALLBACK_NAMES = Object.freeze([N_('未精英化'), N_('精英阶段1'), N_('精英阶段2'), N_('精英阶段2-60级')]); +/** The short names a row's select shows (the full official name is its title and the detail's). */ +const SHORT_NAMES = Object.freeze([N_('未精英化'), N_('精英1'), N_('精英2'), N_('精英2 Lv.60')]); + +/** The official name of 练度 tier `n` (effects.json `name`, localized), else its fallback. */ +export function cultivateName(n) { + return data.lookup('effects', CULTIVATE_EFFECTS[n])?.name || t(FALLBACK_NAMES[n] || ''); +} + +/** The official effect text of 练度 tier `n` (effects.json `desc`: 「攻击力、防御力和最大生命值+10%」 …), or ''. */ +export function cultivateDesc(n) { + return data.lookup('effects', CULTIVATE_EFFECTS[n])?.desc || ''; +} + +/** + * Two compact selects — 潜能 and 练度 — of operator `charId` under the stored settings `ops` (a roster row, a 自选 slot). + * @param {{ charId: string, ops: Record, onSet: (charId: string, patch: { potential?: number, cultivate?: number }) => void, + * note?: string|null }} props `note`: why the settings do not apply now (a 补位 stand-in), shown as the tooltip + */ +export function CultivationSelects({ charId, ops, onSet, note = null }) { + if (!charId) return null; + const cur = opsOf(ops, charId); + const set = (patch) => onSet(charId, patch); + // a setting off the default stands out (mint); the default stays quiet — it is the same on every row + return html` + + + + + + + `; +} + +/** + * The detail's 潜能 / 练度 section: 潜能 1–6 and the four 练度 tiers as segmented buttons, the tier's official effect, + * the rule in one line, and — for a chess the player marked not owned — that its stand-in has neither. + * @param {{ charId: string, ops: Record, onSet: (charId: string, patch: object) => void, standIn?: boolean }} props + */ +export function CultivationSection({ charId, ops, onSet, standIn = false }) { + if (!charId) return null; + const cur = opsOf(ops, charId); + const set = (patch) => onSet(charId, patch); + return html`
+
+

${t('潜能与练度')}<${MicroLabel}>POTENTIAL

+ ${cur.changed ? html`${t('已调整')}` : html`${t('满潜能 · 精英2 Lv.60')}`} +
+
+ ${t('潜能')} +
+ ${POTENTIALS.map((n) => html``)} +
+
+
+ ${t('练度')} +
+ ${TIERS.map((n) => html``)} +
+
+

${cultivateName(cur.cultivate)} ${cultivateDesc(cur.cultivate)}

+

${standIn + ? t('干员持有中标记为未持有:替补干员没有潜能与练度;这里的设置在改回「持有」后生效') + : t('潜能取你自己的潜能;练度是自持有加成(已持有的同名干员的养成,攻击 / 防御 / 生命单独乘算)。未持有的特许干员按潜能1、未精英化。')}

+
`; +} diff --git a/public/js/screens/diy.js b/public/js/screens/diy.js index 381788c3..74470553 100644 --- a/public/js/screens/diy.js +++ b/public/js/screens/diy.js @@ -6,7 +6,8 @@ // 原型 / 已持有 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-*). +// the model is ui/diyModel.js. Styles: css/screens/loadout.css (diy-*). An owned pick's card carries its operator's 潜能 / +// 练度 selects (0.2.2, screens/cultivation.js — the same per-operator settings as 干员调配, `ops`); a prototype has neither. import { useEffect, useState } from '../../vendor/hooks.module.js'; import { html, Icon, Button, TierChip } from '../ui/components.js'; @@ -15,6 +16,7 @@ import { chessAvatarUrl, chessPortraitUrl, profIconUrl, skillRecordIconUrl, modu import { data } from '../data.js'; import { PROF_NAME, skillLabel, moduleBadge, fullTraitText } from '../ui/loadoutModel.js'; import { diySlotList, pickChoices, pickOptions, slotRecord, defaultPick } from '../ui/diyModel.js'; +import { CultivationSelects } from './cultivation.js'; import { t } from '../../../shared/i18n.js'; const cx = (...p) => p.flat().filter(Boolean).join(' '); @@ -38,8 +40,8 @@ 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 }) { +/** One slot: the pick (operator, class, bonds, skill, module, an owned pick's 潜能 / 练度) or an empty slot to fill. */ +function SlotCard({ m, slot, pick, illegal, onOpen, onClear, ops = {}, onOps = null }) { const D = diyData(); const rec = pick ? slotRecord(slot.slotId, pick, D) : null; const elite = pick ? slotRecord(slot.slotId, pick, D, { elite: true }) : null; @@ -73,6 +75,8 @@ function SlotCard({ m, slot, pick, illegal, onOpen, onClear }) { ${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('(精锐时生效)')} + ${proto ? html`${t('原型干员没有潜能与练度')}` + : onOps ? html`<${CultivationSelects} charId=${rec.charId} ops=${ops} onSet=${onOps} />` : null} ${illegal ? html`

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

` : null}
@@ -226,7 +230,9 @@ export function DiyPickerView({ m, slot, picks, kitted, onDone, onClose, filter, /** * 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 + * onSet: (slotId: string, pick: any) => void, ops?: Record, + * onOps?: ((charId: string, patch: { potential?: number, cultivate?: number }) => void) | null }} props + * `ops` / `onOps`: the stored 潜能 / 练度 and their setter (0.2.2 — an owned pick's card shows its selects) */ export function DiyPanel(props) { const [picking, setPicking] = useState(null); @@ -234,7 +240,7 @@ export function DiyPanel(props) { } /** 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 }) { +export function DiyPanelView({ m, picks, legal, kitted, onSet, picking, onPicking: setPicking, ops = {}, onOps = null }) { const slots = diySlotList(diyData()); const slot = picking ? slots.find((s) => s.slotId === picking) : null; const tiers = [...new Set(slots.map((s) => s.tier))]; @@ -247,7 +253,7 @@ export function DiyPanelView({ m, picks, legal, kitted, onSet, picking, onPickin

<${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)} />`)} + illegal=${!!picks[s.slotId] && !legal[s.slotId]} onOpen=${setPicking} onClear=${(id) => onSet(id, null)} ops=${ops} onOps=${onOps} />`)}
`)}
diff --git a/public/js/screens/loadout.js b/public/js/screens/loadout.js index 6f44aaea..779b5ec9 100644 --- a/public/js/screens/loadout.js +++ b/public/js/screens/loadout.js @@ -5,12 +5,16 @@ // present: operator_preset, image_skill_select_outline, skill_select_deco, icon_equip_non; the module type icons of // groups.module — lettered tiles without them). // -// Left: roster of the 112 visible chess (tier / class / bond filters, search, 仅看已调整) — each card shows the equipped -// skill (S1–S3) and, when changed, the elite's module badge. Right: the selected chess — skills (icon, name, 默认, +// Left: the 112 visible chess as one list (tier / class / bond filters, search, 仅看已调整) — a row per operator, its columns +// aligned (0.2.2; the quick choices of PR #301 by @farridge, laid out as one list instead of three columns of cards — the +// owner's decision of 2026-10-08): portrait, name and bonds (a click opens the detail), the skills and the elite's modules +// to tap (不装备 first), the 潜能 and 练度 selects (screens/cultivation.js). Right: the selected chess — its 潜能 / 练度, +// its 特质 (the in-match card's block, PR #301), skills (icon, name, 默认, // SP recovery, 初始 / 消耗 SP, duration, description at 普通 Lv.4 or 精锐 Lv.7), 局内数值 (the stats, 攻击范围, 特性 and // 天赋 the chosen variant — 精锐 first, 普通 on the toggle — fights with under the chosen skill and module: the detail // card's own block and pure functions, GitHub issue #64) and the elite's modules (不装备 / X / Y … with the stat bonus, -// the trait upgrade and the talent changes), 恢复默认; 全部恢复默认 in the top bar. +// the trait upgrade and the talent changes), 恢复默认; 全部恢复默认 in the top bar. The detail's 普通 / 精锐 skill toggle is one +// preview for the skills, the 特质 and the rows' skill details (display only: never stored). // 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 导出 / 导入 / 全部持有. @@ -29,9 +33,12 @@ import { useStore } from '../store.js'; import { PHASE } from '../../../shared/constants.js'; import { MODULE_NONE, PROF_ORDER, PROF_NAME, rosterOf, filterRoster, recordsOf, chessOptions, effectiveChoice, setChoice, resetChoice, - changedCount, skillLabel, moduleBadge, attrRows, skillTags, traitLines, serializeExport, parseImport, LOADOUT_IMPORT_MAX_BYTES, + changedCount, skillLabel, moduleBadge, attrRows, skillTags, quickSkillTags, traitLines, serializeExport, parseImport, LOADOUT_IMPORT_MAX_BYTES, + opsOf, setOps, resetOps, } from '../ui/loadoutModel.js'; -import { loadoutStore, openLoadout, closeLoadout, setEntries, applyLoadoutEntries, setNotOwned, applyOwnershipImport, setDiyPicks, applyDiyImport } from '../ui/loadoutSync.js'; +import { loadoutStore, openLoadout, closeLoadout, setEntries, setOpsMap, applyLoadoutEntries, setNotOwned, applyOwnershipImport, setDiyPicks, applyDiyImport } from '../ui/loadoutSync.js'; +import { CultivationSelects, CultivationSection } from './cultivation.js'; +import { cultivationCharIds } from '../../../shared/protocol.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'; @@ -140,34 +147,84 @@ export function ModuleGlyph({ m, rec, id, size = 'md' }) { `; } -// ---- roster card --------------------------------------------------------------------------------------------------- +// ---- roster rows ----------------------------------------------------------------------------------------------------- +// +// One list, one row per operator, its columns aligned (the head row names them): the quick choices of PR #301 (by +// @farridge) without its three columns of cards. Every row is the same few columns — tap a skill, a module, a 潜能 / 练度 — +// so the eye runs down a column; the portrait and name open the detail (on a phone it slides over; a quick choice keeps +// the list). Hook-free (the tests draw it). -function RosterCard({ m, chess, golden, entries, selected, onPick, notOwned = false }) { +/** One skill to tap: its icon, the equipped one ringed; the label / tooltip says slot, name, SP and duration (quickSkillTags). */ +function QuickSkill({ m, opt, on, elite, onPick }) { + const rec = elite ? opt.elite || opt.normal : opt.normal || opt.elite; + const tags = quickSkillTags(rec); + const label = `${skillLabel(opt.index)} · ${rec?.name || t('未知技能')} · ${tags.sp} · ${t('初始')} ${tags.init ?? '—'} / ${t('消耗')} ${tags.cost ?? '—'} · ${t('持续')} ${tags.duration}`; + return html``; +} + +/** One module to tap (不装备 first): the type icon with its letter in the corner; the elite's equipped one ringed. */ +function QuickModule({ m, opt, on, onPick }) { + const label = opt.id === MODULE_NONE ? t('不装备模组') : `${opt.rec?.typeName || ''} · ${opt.rec?.name || opt.id}`; + return html``; +} + +/** The list's column heads (the rows share its grid). */ +export function RosterHead() { + return html``; +} + +/** + * One operator's row (`.lo-card`, like the cards before it): portrait + name + bonds (`.lo-card__pick`: selects it, opens + * the detail), three skill slots (an empty one when the chess has two skills), the elite's modules, the 潜能 / 练度 selects. + * `level` 'elite': the skills' details at 精锐 Lv.7 (the shared preview). A chess marked not owned (干员持有) shows the + * 「替补」 tag: its stand-in fights with a fixed skill and no 潜能 / 练度, the choices here apply once it is owned again. + */ +export function RosterRow({ m, chess, golden, entries, ops = {}, selected, onPick, onChange, onOps, level = 'normal', notOwned = false }) { const choice = effectiveChoice(entries, chess, golden); const opt = chessOptions(chess, golden); - const skillRec = opt.skillOptions.find((s) => s.index === choice.skill)?.normal || chess.skill; - const modRec = golden && choice.module !== MODULE_NONE ? opt.moduleOptions.find((x) => x.id === choice.module)?.rec || null : null; - const modChanged = golden && choice.module !== opt.defaultModule; - return html``; + + ${chess.name} + ${(chess.bonds || []).map((b) => html`<${Img} key=${b} src=${bondIconUrl(m, b)} class="lo-card__bond" + alt=${data.lookup('bonds', b)?.name || b} fallback=${html``} />`)} + ${notOwned ? html`${t('替补')}` : null} + + + ${changed ? html`` : null} +
+ ${slots.map((sk, i) => (sk ? html`<${QuickSkill} key=${sk.index} m=${m} opt=${sk} elite=${elite} on=${sk.index === choice.skill} + onPick=${(skill) => onChange(chess.chessId, { skill })} />` : html``))} +
+
+ ${golden ? modules.map((mo) => html`<${QuickModule} key=${mo.id} m=${m} opt=${mo} on=${mo.id === choice.module} + onPick=${(module) => onChange(chess.chessId, { module })} />`) : null} +
+ <${CultivationSelects} charId=${chess.charId} ops=${ops} onSet=${onOps} note=${standInNote} /> + `; } // ---- detail -------------------------------------------------------------------------------------------------------------- @@ -248,13 +305,15 @@ const standInName = (chess) => data.get('backups')?.units?.[chess?.backup?.charI * @param {any} base normal chess record @param {any} golden its elite record or null * @param {Record} entries the stored loadout @param {'normal'|'elite'} level * @param {(id: string) => any} getChess + * @param {Record|null} [ops] the stored 潜能 / 练度 (0.2.2: the numbers at the operator's settings — the + * 练度 multiplier needs effects.json; none set = 潜能 6, 精英2 Lv.60) * @returns {{ elite: boolean, chess: any, lo: any, record: any, trait: string, talents: any[] } | null} null without a record */ -export function statsPreview(base, golden, entries, level, getChess) { +export function statsPreview(base, golden, entries, level, getChess, ops = null) { const elite = level === 'elite' && !!golden; const chess = elite ? golden : base; if (!chess) return null; - const lo = chessLoadout(chess, entries, getChess); + const lo = chessLoadout(chess, entries, getChess, { ops, effects: data.get('effects') }); const record = lo?.record || chess; // (the card's own rule: the 特性 line exists when the chess has one; its text follows the chosen module) return { elite, chess, lo, record, trait: chess.trait?.desc ? traitText(chess, !!chess.isGolden, lo) || '' : '', talents: chessTalents(record) }; @@ -267,9 +326,10 @@ export function statsPreview(base, golden, entries, level, getChess) { * toggle picks the 普通 or the 精锐 record; 精锐 is the default because the module only exists there. * @param {{ base: any, golden: any, entries: Record, level: 'normal'|'elite', onLevel: (l: 'normal'|'elite') => void, getChess?: (id: string) => any }} props */ -export function LoadoutStats({ base, golden, entries, level, onLevel, getChess = getChessRec }) { - const pv = statsPreview(base, golden, entries, level, getChess); +export function LoadoutStats({ base, golden, entries, level, onLevel, getChess = getChessRec, ops = null }) { + const pv = statsPreview(base, golden, entries, level, getChess, ops); if (!pv) return null; + // (the caption names 潜能 / 练度 when the numbers carry both — the 练度 multiplier needs effects.json) return html`

${t('局内数值')}<${MicroLabel}>STATS

@@ -284,18 +344,19 @@ export function LoadoutStats({ base, golden, entries, level, onLevel, getChess = ${pv.talents.map((tal, i) => html`
${t('天赋')} ${tal.name}<${RichText} text=${tal.descRaw || tal.desc || ''} />
`)} ` : null} -

${pv.elite ? t('数值含所选模组;') : golden ? t('普通干员没有模组,所选模组在「精锐」中生效;') : ''}${t('不含技能发动、装备、盟约等局内加成')}

+

${pv.elite ? t('数值含所选模组;') : golden ? t('普通干员没有模组,所选模组在「精锐」中生效;') : ''}${pv.lo?.cultivation && data.get('effects') ? t('含潜能与练度;') : ''}${t('不含技能发动、装备、盟约等局内加成')}

`; } -function Detail({ m, chess, golden, entries, onChange, onReset, locked, notOwned = false }) { - const [level, setLevel] = useState('normal'); +function Detail({ m, chess, golden, entries, ops = {}, onChange, onOps, onReset, locked, level = 'normal', onLevel, notOwned = false }) { const [statLevel, setStatLevel] = useState('elite'); // 局内数值: the 精锐 shows the chosen module's effect + const setLevel = onLevel; const bodyRef = useRef(null); useEffect(() => { if (bodyRef.current) bodyRef.current.scrollTop = 0; }, [chess?.chessId]); if (!chess) return html``; const opt = chessOptions(chess, golden); const choice = effectiveChoice(entries, chess, golden); + const changed = choice.changed || opsOf(ops, chess.charId).changed; const modOpt = opt.moduleOptions.find((x) => x.id === choice.module) || null; const lv = (c) => c?.status?.skillLevel ?? '—'; return html`