diff --git a/docs/DATA.md b/docs/DATA.md index 4f69b7f5..fa334a98 100644 --- a/docs/DATA.md +++ b/docs/DATA.md @@ -650,13 +650,15 @@ backup selection) and `loadoutRecord` read it unchanged; null for a PRESET or DI which chess a player may mark as not owned (the 55 NORMAL base chess; `shared/protocol.js checkNotOwned` keeps those of a `room.ownership` list). **In the match** (`server/match/player/basics.js`: `PlayerState.standIns`, `fieldsStandIn`, `fieldRecord` = `gd.standIn(id)`) the player's piece keeps the chess's identity for every meta rule (price, bonds, -特质, merges, pools) and is deployed as the stand-in (placement class, summon / bot ranges, the prep scouting art; no -summons — none of the 17 has one). **In battle** a PlayerBattleInput entry with `standIn: true` (PlayerState.battleInput: +特质, merges, pools) and is deployed as the stand-in (placement class, summon / bot ranges; no summons — none of the 17 +has one); what shows the piece shows the stand-in (the prep scouting art of board and bench pieces, the m.result +lineup's `standInFor`, the elite and gift tickers' names — the owner's recall of the official mode, 2026-10-06). **In battle** a PlayerBattleInput entry with `standIn: true` (PlayerState.battleInput: the player's own field, 联防 and the boss fields alike) is fielded as `getChess(chessId, { standIn: true })` (docs/SIM.md §12: this record, normalised), and its kit is found by its `charId` (`server/sim/content/index.js kitOf`; kits/README.md "Stand-in kits") — never by the chess id it keeps, which names the replaced operator's kit. The client -composes the same record (`public/js/ui/gameLogic/standIn.js standInOf`, the renderer's `data.standIn`) for the cards' -「替补:X」 badge, the board model and the detail card. +composes the same record (`public/js/ui/gameLogic/standIn.js standInOf`, the renderer's `data.standIn`) to show the +stand-in on the shop / reward cards, the own pieces' models (hand, 临时整备区, board), the detail card, bond popups and the +result lineup, with a small 「替补」 mark. A 自选 piece is composed by `shared/diy.js`: `checkDiyPick(slotId, pick, data)` checks one pick `{ charId, skillIndex?, uniEquipId? }` against a slot (a pick of the slot's tier; a prototype takes its `diy.locked` selection, another skill is refused; an owned pick names one of its three diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 931f6e3e..eb0e8819 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -642,7 +642,7 @@ Official rule (bwiki 盟约, 更新公告 5114): before a match the player canno **UI:** a 干员调配 screen reachable from the lobby, room and briefing (INFO_CHECK): 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`). -**干员持有 (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 ("对于补位干员其技能不可更改") — while keeping its card (name, bonds, 特质, tier, price, merge); the 0.2.0 补位 design subsection has the rules and sources. +**干员持有 (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/docs/PLAYING.md b/docs/PLAYING.md index 059da9cf..2fbf3ad0 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -59,7 +59,7 @@ - **位置上限**:棋盘最多部署 **8** 名;整备区 **10** 格。整备区满时**不能购买或领取**任何干员、装备(包括能凑齐 3 名合成精锐的那一张,和官方一样),先出售、部署或配发腾出格子;新获得的单位(合成的精锐、奖励、退回的装备等)进入**临时整备区**(整备区前面的 5 格),整备区一空出格子,临时整备区的单位就**自动补进去**(从右往左)。临时整备区有单位时**不能准备就绪**,棋盘上会用红色虚线框标出这一排并说明规则:把它们放进整备区或战场、配发或使用,或者出售 / 销毁。休整期中进入临时整备区的单位,若到**本休整期结束**时仍留在那里就会被自动处理(干员返还卡池、装备销毁);作战、结算期间或准备就绪之后才进入的(例如战斗奖励、结算时合成的精锐),会保留到**下一个休整期**结束,你一定有机会处理它们。 - **晋升精锐**:3 名同名干员(无论是否部署)自动合成**精锐**(等级、技能、模组大幅提升;风丸只需 2 名),精锐不会再合成。如果被合成的干员里有**已经部署在战场上**的,精锐会直接出现在**那名干员的位置**(保持原来的朝向,不多占部署位;有两三名都在场上时,取最先部署的那名——最左一列优先,同一列从上到下);否则精锐进入整备区(满了进入临时整备区)。原先配发的装备都退回整备区,需要重新配发;被合成干员的召唤物随之移除,场上的精锐会把自己的召唤物送进整备区。点选一张能合成的商店卡片时,精锐将出现的那一格会亮起金色边框,同时打开的详情卡里也会写明去向(「可晋升:精锐干员将出现在作战区原位置 / 将进入整备区」;详情卡会自动停靠在不遮挡这一格的一侧)。在回合开始或结算时因策略、奖励等完成的合成,精锐出现时同样会播放金色的晋升特效。 - **晋级奖励**:每次晋升后获得一次特殊招募:从 3 名 **高一阶**(最高 VI)、**互不相同**的随机干员中**免费**挑 1 名(该阶剩余干员不足 3 名时由低一阶补足)。它不能刷新、不能冻结、本回合结束失效。 -- **干员持有(补位)**:大厅、房间和「确认本局信息」里的「干员调配」按钮打开的界面有两个标签页:「干员调配」选择每名干员携带的技能和精锐的模组;「**干员持有**」可以把你没有的干员**下掉**(点一下卡片切换「持有 / 未持有」)。和官方一样只有 55 张「普通」棋子能下掉(界面按阶级列出本赛季商店里的 53 名,每张写着由谁替补、用几技能);74 张「预设」棋子(多是 1–2 阶的常见干员)无论是否持有都由本人上场,不在其中。默认全部持有。下掉的干员照常出现在商店和整备区,**名字、盟约、特质、阶级、价格、合成都按原干员**,卡片和整备区里的单位下方标着「替补:Sharp」这样的标记;部署到战场上后换成**替补干员**的模型,作战时的属性、技能、天赋、模组、攻击范围、近战位 / 远程位都是替补干员的(详情卡显示替补的技能和天赋,例如银灰 → Sharp 的 2 技能「亮剑」)。替补的技能和模组由棋子固定,「干员调配」对它不生效;替补干员没有召唤物(下掉缪尔赛思就没有流形)。这是**局外设置**:下一局开始时生效,对局中修改只保存到下一局;同盟模拟中只影响你自己的棋子,队友的棋子按队友的设置。没有官方的好友借用:想用本人上场,改回「持有」即可。「全部持有」一键恢复,「导出 / 导入」可以把设置带到别的浏览器;大厅的「干员调配」按钮上会显示「替补 N」提醒你有几名干员被下掉。 +- **干员持有(补位)**:大厅、房间和「确认本局信息」里的「干员调配」按钮打开的界面有两个标签页:「干员调配」选择每名干员携带的技能和精锐的模组;「**干员持有**」可以把你没有的干员**下掉**(点一下卡片切换「持有 / 未持有」)。和官方一样只有 55 张「普通」棋子能下掉(界面按阶级列出本赛季商店里的 53 名,每张写着由谁替补、用几技能);74 张「预设」棋子(多是 1–2 阶的常见干员)无论是否持有都由本人上场,不在其中。默认全部持有。下掉的干员和官方一样**显示为替补干员**:商店和晋升奖励的卡片、整备区和临时整备区里的单位、作战区、结算界面的阵容,都是替补干员的头像、名字和模型(例如银灰显示为 Sharp),带一个小小的「替补」标记;详情卡显示替补干员的立绘、名字、技能、天赋和模组,名字下面写着「银灰的替补」。**盟约、特质、阶级、价格、合成仍按原干员**(卡片和详情卡上的盟约、特质就是原干员的),作战时的属性、技能、天赋、模组、攻击范围、近战位 / 远程位都是替补干员的(例如银灰 → Sharp 的 2 技能「亮剑」)。同一名替补干员可能替补好几名干员(Sharp 替补忍冬、银灰、隐德来希等),它们按各自的原干员分别合成:看阶级和盟约区分,点开详情卡能看到替补的是谁。同盟模拟中队友查看你的整备区和作战区、你的盟约成员时,看到的也是替补干员。替补的技能和模组由棋子固定,「干员调配」对它不生效;替补干员没有召唤物(下掉缪尔赛思就没有流形)。这是**局外设置**:下一局开始时生效,对局中修改只保存到下一局;同盟模拟中只影响你自己的棋子,队友的棋子按队友的设置。没有官方的好友借用:想用本人上场,改回「持有」即可。「全部持有」一键恢复,「导出 / 导入」可以把设置带到别的浏览器;大厅的「干员调配」按钮上会显示「替补 N」提醒你有几名干员被下掉。 - **自选编队**:同一个界面的第三个标签页「**自选编队**」有 4 个名额:5 阶两个、6 阶两个。点名额打开选择列表,每个名额可以放一名**你持有的 6★ 干员**(棋子池里没有的那些,标「已持有」:3 个技能任选其一,精锐的模组任选其一或不装备;只在集成战略里生效的 ISW-A 模组不能选)或一名**原型干员**(标「原型」:Sharp、Mechanist 等 9 名 6★,5 阶另有 6 名 4★ 预备干员;技能和模组与补位时相同,不能更改)。同一名原型干员可以同时放进一个 5 阶和一个 6 阶名额;你持有的干员只能放一个名额;同一阶的两个名额不能放同一人。列表只列出已经做好战斗逻辑的干员(原型干员全部可选,持有的 6★ 随战斗逻辑完成陆续加入)。对局中,自选干员**只出现在你自己的商店里**:调度中心升到 5 级后才会刷出 5 阶名额的干员,6 级后才会刷出 6 阶的;刷新概率和同阶干员一样,每个名额有自己的库存(5 阶 8 张、6 阶 5 张,不与队友共用),价格与同阶干员相同,晋升奖励里也可能出现。三张合成精锐,精锐时所选模组生效(5 阶 1 级、6 阶 3 级)。自选干员**没有特质**,盟约按干员的实际阵营分配(推进之王是维多利亚,煌同时是炎和维多利亚,没有对应阵营的是协防干员),和普通棋子一样计入盟约人数;本局禁用了它全部的盟约时,它不会出现在商店里(协防不会被禁用)。商店卡片、整备区、作战区和详情卡上都标着「自选」,详情卡显示所选的技能和模组。这同样是**局外设置**:下一局开始时生效,对局中修改只保存到下一局;AI 队友不使用自选;信标不会把自选干员送给队友。「导出 / 导入」「全部清空」可用,大厅的「干员调配」按钮上会显示「自选 N」。 - 每回合商店会自动刷新未冻结的格子。 - **「本回合首次主动刷新」按每名干员各自计算**(拉普兰德):她在本回合遇到的第一次主动刷新生效,在整备区里也算——先刷新、再买下拉普兰德,下一次刷新她仍会叠层;同一回合内用已经生效过的拉普兰德合成的精锐,本回合不再生效。 diff --git a/docs/SIM.md b/docs/SIM.md index c81008a4..6dc2251e 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -1258,7 +1258,7 @@ Unknown subprofessions fall back to the profession default (test `professions.te `['fx', kind, x, y, extra]` (`hitCap` `{ id, n }`: a leader's hit cancelled by 限伤 — the renderer draws nothing; `extra.form` = the unit's model form from then on — an enemy's `content/enemies/helpers.js setForm`, a 傀儡师's 替身 — `shared/protocol.js fxForm`), `['layer', playerId, bondId, n]` (n = the layers actually added, capped at 999), `['bounty', playerId, coins]`. -- `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, form?, skillIndex?, moduleId?, items?, standInFor?, diy? }` (`standInFor`: a 补位 stand-in's replaced operator charId — `spine` / `avatar` / `name` are the stand-in's, DATA.md §18; the prep scouting units of `Match.prepFieldMeta` carry it too: a board piece with the stand-in's art, a held one with the chess's own; `diy`: a 自选 piece's pick `{ charId, skillIndex, uniEquipId }` — `defId` is its slot, `spine` / `avatar` / `name` the operator's; the prep scouting units of `Match.prepFieldMeta` and the m.result lineups carry it too (server/match/player/diy.js, the client composes the operator's card from it); `skillIndex`: an ally's equipped skill, DESIGN §16; `form`: the unit's current model form — `content/enemies/helpers.js setForm`: 掠海漂移体 `'crawl'`, 暴鸰 `'bombed'` after its drop, 转译基底·α's forms …; a 傀儡师 fighting as its 替身 `'doll'` — so a view built mid-battle from `fieldMeta()` starts on that clip set; `items`: an ally operator's equipped item ids — a 变形同构体 wearer is a member of the bond it grants on the client too, the bond popup and the detail card's chips) +- `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, form?, skillIndex?, moduleId?, items?, standInFor?, diy? }` (`standInFor`: a 补位 stand-in's replaced operator charId — `spine` / `avatar` / `name` are the stand-in's, DATA.md §18; the prep scouting units of `Match.prepFieldMeta` carry it too, board and held pieces alike with the stand-in's art (the owner's recall of the official mode, 2026-10-06), and so do the m.result lineups; `diy`: a 自选 piece's pick `{ charId, skillIndex, uniEquipId }` — `defId` is its slot, `spine` / `avatar` / `name` the operator's; the prep scouting units of `Match.prepFieldMeta` and the m.result lineups carry it too (server/match/player/diy.js, the client composes the operator's card from it); `skillIndex`: an ally's equipped skill, DESIGN §16; `form`: the unit's current model form — `content/enemies/helpers.js setForm`: 掠海漂移体 `'crawl'`, 暴鸰 `'bombed'` after its drop, 转译基底·α's forms …; a 傀儡师 fighting as its 替身 `'doll'` — so a view built mid-battle from `fieldMeta()` starts on that clip set; `items`: an ally operator's equipped item ids — a 变形同构体 wearer is a member of the bond it grants on the client too, the bond popup and the detail card's chips) (`dir` = the unit direction, allies meaningful, enemies 'RIGHT'; `facing` = its horizontal sign for sprite flipping) (`spine`/`avatar` are asset ids from data). - flags: UF bits (blocked 1, stunned 2, frozen 4, stealth 8 — 隐匿 (an enemy's only while not blocked / revealed and not within 3 s of a block's end) or an ally's 迷彩 — skill 16, shield 32, invuln 64, cold 128, sleep 256, flying 512); diff --git a/public/css/screens/game-panels.css b/public/css/screens/game-panels.css index aee835ec..ffc75b31 100644 --- a/public/css/screens/game-panels.css +++ b/public/css/screens/game-panels.css @@ -38,6 +38,9 @@ .uthumb.is-golden::after { content: ''; position: absolute; inset: -2px; border: 1px solid rgba(255, 198, 0, .7); pointer-events: none; clip-path: inherit; } .uthumb.is-dim .uthumb__art img { filter: grayscale(.85) brightness(.55); } .uthumb__tag { position: absolute; right: 0; bottom: 0; font-size: calc(var(--ut) * .17); line-height: 1.2; padding: 0 .03rem; background: #a9c3d8; color: #0b1520; font-weight: 700; } +/* 0.2.0 补位: the small 「替补」 mark of a stand-in's thumbnail (the result lineup, bond popup members — screens/result.js, + ui/bondStrip.js) */ +.uthumb__si { position: absolute; right: 0; bottom: 0; font-size: max(calc(var(--ut) * .2), 8px); line-height: 1.2; padding: 0 .03rem; background: var(--ice); color: var(--ice-900); font-weight: 700; opacity: .9; pointer-events: none; } .bglyph { display: inline-grid; place-items: center; width: .24rem; height: .24rem; flex: none; } .bglyph img { width: 100%; height: 100%; object-fit: contain; filter: brightness(0) invert(.86) sepia(.4) hue-rotate(110deg) saturate(2.5); } @@ -82,6 +85,8 @@ .dhead__chips { display: flex; align-items: center; gap: .08rem; flex-wrap: wrap; } .dhead__name { font-size: .32rem; font-weight: 900; letter-spacing: .04em; line-height: 1.2; } .dhead__en { font-family: var(--font-display); font-size: .12rem; letter-spacing: .2em; color: var(--text-dim); text-transform: uppercase; } +/* 0.2.0 补位: the replaced operator under a stand-in's name (「银灰的替补」, ui/detailPanel.js ChessDetail) */ +.dhead__for { display: block; font-size: max(.13rem, 9px); letter-spacing: .04em; color: var(--ice); } .dhead__flavor { font-size: .13rem; color: var(--text-lo); font-style: italic; } .dhead__class { display: flex; align-items: center; gap: .06rem; flex-wrap: wrap; font-size: .15rem; color: var(--text-md); margin-top: .06rem; } .dhead__class .sep { width: 1px; height: .16rem; background: var(--line-2); } @@ -90,9 +95,9 @@ .dtag-elite, .dtag-token, .dtag-kind { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; letter-spacing: .08em; } .dtag-elite { background: var(--gold); color: #1d1400; } .dtag-token { background: #a9c3d8; color: #0b1520; } -/* 0.2.0 补位: the card of a chess fielded as its stand-in (ui/detailPanel.js ChessDetail) */ +/* 0.2.0 自选编队 / 补位: the card of a 自选 piece, 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-standin { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; letter-spacing: .08em; background: var(--ice); color: var(--ice-900); } .dtag-kind { border: 1px solid var(--line-2); color: var(--text-lo); font-weight: 500; } .drank { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; } .drank--normal { background: var(--bg-4); color: var(--text-md); } diff --git a/public/css/screens/game-shop.css b/public/css/screens/game-shop.css index 3ac8c551..f62b8ba1 100644 --- a/public/css/screens/game-shop.css +++ b/public/css/screens/game-shop.css @@ -130,9 +130,10 @@ .scard__class { display: flex; align-items: center; gap: .05rem; font-size: .13rem; color: var(--text-lo); } .scard__prof { width: .18rem; height: .18rem; object-fit: contain; opacity: .85; } .scard__mergetag { position: absolute; z-index: 3; right: 0; top: .38rem; font-size: .12rem; font-weight: 700; padding: .01rem .06rem; background: var(--gold); color: #1d1400; letter-spacing: .08em; } -/* 0.2.0 补位: a chess the player does not own — its card stays, the stand-in fights (ui/shopBar.js ChessCard) */ -.scard__standin { position: absolute; z-index: 3; left: 0; top: .38rem; max-width: calc(100% - .1rem); font-size: .12rem; font-weight: 700; padding: .01rem .06rem; - background: var(--ice); color: var(--ice-900); letter-spacing: .04em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; box-shadow: 0 1px 3px rgba(0, 0, 0, .6); } +/* 0.2.0 补位: a chess the player does not own shows its stand-in (ui/shopBar.js ChessCard) — a small 「替补」 mark */ +.scard__standin { position: absolute; z-index: 3; left: 0; top: .38rem; max-width: calc(100% - .1rem); font-size: max(.11rem, 8px); font-weight: 700; line-height: 1.4; + padding: 0 .05rem; background: var(--ice); color: var(--ice-900); letter-spacing: .08em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + opacity: .9; 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); } diff --git a/public/i18n/en.json b/public/i18n/en.json index 53291a16..727fe090 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -875,6 +875,10 @@ "自选": "Custom", "自选编队:所选技能与模组,没有特质,盟约按所属阵营分配": "Custom Squad: the chosen skill and module, no Attribute, Alliances by its factions", "自选编队:{name}": "Custom Squad: {name}", + "替补": "Stand-in", + "{name}的替补": "Stand-in for {name}", + "未持有{name}:由替补干员 {standIn} 上场(盟约、特质、阶级与价格不变)": "{name} not owned: the stand-in {standIn} takes the field (Alliances, Attribute, tier and price unchanged)", + "未持有的干员由官方指定的替补干员上场:商店、整备区、作战区与结算都显示替补干员(模型、技能、天赋、攻击范围),盟约、特质、阶级、价格与合成仍按原干员。预设干员无论是否持有都由本人上场,不在此列。": "An Operator you do not own takes the field as its official stand-in: the shop, the Bench, the battlefield and the results show the stand-in (model, skills, talents, attack range), while Alliances, Attribute, tier, price and promotion follow the original Operator. Preset Operators always take the field themselves, owned or not, and are not listed.", "全部清空": "Clear All", "清空全部 {n} 个自选名额?": "Clear all {n} Custom Squad {n|slot|slots}?", "文件过大,请选择「导出」下载的自选编队文件": "The file is too large. Choose a Custom Squad file downloaded with \"Export\".", diff --git a/public/js/battle/runner.js b/public/js/battle/runner.js index 38b58468..57e580a5 100644 --- a/public/js/battle/runner.js +++ b/public/js/battle/runner.js @@ -55,9 +55,9 @@ // 0.1.0). Read-only: it takes the stats the sim computed last (`unit._s`) and the range grid it // keeps, and never makes the unit recompute them, so looking never changes the battle's floats. // battleRunner.unitIdOf(uid, ownerId, fieldId?) → the id of an own board piece's unit in that battle | null -// battleRunner.ownerOps(ownerId, fieldId?) → [{ kind: 'op', ownerId, defId, items? }] that player's operators in the -// battle on screen with their equipment (a teammate's bond popup: the members in play, DESIGN -// §20.15, 变形同构体 wearers included) | [] +// battleRunner.ownerOps(ownerId, fieldId?) → [{ kind: 'op', ownerId, defId, items?, standInFor? }] that player's +// operators in the battle on screen with their equipment (a teammate's bond popup: the members in +// play, DESIGN §20.15, 变形同构体 wearers included; a 补位 stand-in names the replaced charId) | [] // // createBattleRunner(deps) builds an instance with injectable net / store / clock / frame scheduler / sim loader // (test/match/runner.test.js drives it under Node). @@ -793,7 +793,11 @@ export function createBattleRunner(deps) { if (!e || typeof ownerId !== 'string' || !ownerId || (fieldId != null && e.fieldId !== fieldId)) return []; const list = Array.isArray(e.battle.allyUnits) ? e.battle.allyUnits : []; return list.filter((u) => u && u.kind === 'op' && u.ownerId === ownerId && typeof u.defId === 'string') - .map((u) => (Array.isArray(u.items) && u.items.length ? { kind: 'op', ownerId, defId: u.defId, items: [...u.items] } : { kind: 'op', ownerId, defId: u.defId })); + .map((u) => { + const o = Array.isArray(u.items) && u.items.length ? { kind: 'op', ownerId, defId: u.defId, items: [...u.items] } : { kind: 'op', ownerId, defId: u.defId }; + const si = u.def && typeof u.def.standInFor === 'string' ? u.def.standInFor : null; + return si ? { ...o, standInFor: si } : o; + }); }, /** Re-show the current battle (the game screen remounted). */ reshow() { if (cur) show(cur); }, diff --git a/public/js/render/app.js b/public/js/render/app.js index a4f86610..2e33cb06 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -275,7 +275,7 @@ export async function createFieldView(host, options = {}) { let penHidden = true; // the pen's figures are shown only by the pen camera (setPenHidden) let penList = null; let ownPen = null; // the own m.private.nextEnemies (fallback composition of a scouted teammate's pen) - let standInList = []; // the own m.private.standIns (0.2.0 补位): board pieces of these chess draw the stand-in + let standInList = []; // the own m.private.standIns (0.2.0 补位): 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) @@ -631,9 +631,9 @@ export async function createFieldView(host, options = {}) { return { kind: 'token', side: 'ally', defId: piece.id, spine: rec?.assets?.spine || piece.id, avatar: rec?.assets?.avatar || piece.id, tier: piece.tier || 1, golden: false, dir }; } const chess = data.chess(piece.id); - // 0.2.0 补位: a board piece of a chess the player does not own (m.private.standIns) is deployed as its stand-in — - // the stand-in's model; on the bench it keeps the chess's own (the hand shows the original operator) - const si = area === 'board' && chess && standInList.includes(chess.baseId || chess.chessId) ? data.standIn(piece.id) : null; + // 0.2.0 补位: a piece of a chess the player does not own (m.private.standIns) is its stand-in's model, in the hand, + // the 临时整备区 and on the board alike (the owner's recall of the official mode, 2026-10-06) + const si = chess && standInList.includes(chess.baseId || chess.chessId) ? data.standIn(piece.id) : null; // 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; @@ -720,7 +720,7 @@ export async function createFieldView(host, options = {}) { const key = 'p:' + e.uid; e.key = key; const info = pieceInfo(e.piece, e.area); - // (the model is part of it: a 补位 chess changes body between the bench and the board — pieceInfo) + // (the model is part of it: a piece whose body changes — a merge, an own 补位 / 自选 setting arriving — is rebuilt) const sig = `${info.kind}|${info.defId}|${info.golden ? 1 : 0}|${info.spine || ''}`; let v = views.get(key); if (v && v._sig !== sig) { dropView(key); v = null; } diff --git a/public/js/screens/game.js b/public/js/screens/game.js index a2e0dd1c..bc03e9b2 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, ownDiyRecord, + mergeTarget, modeOffBonds, readyFundsPrompt, ownerBandId, ownDiyRecord, ownStandIn, } from '../ui/gameLogic.js'; import { toast } from '../ui/toasts.js'; import { BriefingScreen } from './briefing.js'; @@ -240,6 +240,10 @@ function MatchScreen() { // 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; }; + // what the own pieces show (names and art of the facing wheel, the underframe, the equip-replace dialog): a chess the + // player does not own is its stand-in (0.2.0 补位 — the owner's recall of the official mode, 2026-10-06); rules keep + // ownChess (the composed record carries the chess's identity anyway) + const ownShown = (id) => { const c = ownChess(id); return ownStandIn(c, live.current.priv, data.get('backups')) || c; }; const placeCtx = useMemo(() => placementContext({ priv, stage: gd.stage(pub?.stageId), editable, field: deployField, getChess: (id) => { const c = gd.chess(id); return ownDiyRecord(c, priv, { chess: data.get('chess'), backups: data.get('backups') }) || c; }, @@ -729,7 +733,7 @@ function MatchScreen() { 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, ownChess, gd.item); + const request = replaceRequest(L.placeCtx, intent, ownShown, gd.item); if (request) { const uid = await openReplaceRef.current(request); if (!Number.isInteger(uid)) { audio.sfx('back', { volume: 0.5 }); return; } @@ -755,7 +759,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) : ownChess(piece.id); + const rec = piece.kind === 'item' ? gd.item(piece.id) : piece.kind === 'token' ? gd.token(piece.id) : ownShown(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 || '' }); @@ -1279,7 +1283,7 @@ function MatchScreen() { ${bondPop ? html`<${BondPopup} bondId=${bondPop.bondId} entry=${bondPop.entry} priv=${bondPop.priv} banned=${pub?.bannedChess || []} owner=${bondPop.name} off=${offBonds.has(bondPop.bondId)} place=${bpPlace} over=${!!resolved && bpPlace === dSide} - onClose=${() => setBondOpen(null)} onMember=${(id, items) => setDetail({ kind: 'chess', id, owner: bondPop.ownerId, items: items || null })} />` : null} + onClose=${() => setBondOpen(null)} onMember=${(id, items, standInFor) => setDetail({ kind: 'chess', id, owner: bondPop.ownerId, items: items || null, standInFor: standInFor || null })} />` : null} ${resolved ? html`<${DetailPanel} detail=${resolved} snapHp=${snapHp} onClose=${() => { setDetail(null); setSel(null); }} bonds=${detailBonds} offBonds=${offBonds} loadout=${detailLoadout} side=${dSide} shopOpen=${shopOpen} live=${liveStats} @@ -1287,7 +1291,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) : ownChess(selEntry.piece.id))?.name || ''} + name=${(selEntry.piece.kind === 'item' ? gd.item(selEntry.piece.id) : selEntry.piece.kind === 'token' ? gd.token(selEntry.piece.id) : ownShown(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 7e49e264..3753655d 100644 --- a/public/js/screens/game/standInTags.js +++ b/public/js/screens/game/standInTags.js @@ -1,20 +1,21 @@ -// 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. 0.2.0 自选编队: the 「自选」 tag under the player's own pieces of a DIY slot it filled +// public/js/screens/game/standInTags.js — 0.2.0 补位: the small 「替补」 tag under the player's own prep pieces (hand, +// 临时整备区, board) of a chess they do not own (m.private.standIns): the model is the stand-in's everywhere +// (render/app.js pieceInfo — the owner's recall of the official mode, 2026-10-06), the tag says it fields for another +// chess (the detail card names it). 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, ownDiyRecord } from '../../ui/gameLogic.js'; +import { ownStandIn, standInLabel, standInTip, ownDiyRecord } from '../../ui/gameLogic.js'; import { t } from '../../../../shared/i18n.js'; const rawOf = (view) => (view && view.raw) || view || null; /** - * The player's own prep pieces that fight as stand-ins: [{ uid, label, name, area }] (hand, temp, board). + * The player's own prep pieces that fight as stand-ins: [{ uid, label, name, area, tip }] (hand, temp, board; `name` = + * the stand-in's, `tip` = which chess it fields for). * @param {any} priv m.private @param {(id: string) => any} getChess @param {any} backups data/backups.json */ export function standInPieces(priv, getChess, backups) { @@ -23,9 +24,8 @@ export function standInPieces(priv, getChess, backups) { const add = (p, area) => { if (!p || p.kind !== 'chess' || !Number.isInteger(p.uid)) return; const c = getChess(p.id); - if (!fieldsStandIn(priv, c)) return; - const si = standInOf(c, backups); - if (si) out.push({ uid: p.uid, label: standInLabel(si), name: si.name, area }); + const si = ownStandIn(c, priv, backups); + if (si) out.push({ uid: p.uid, label: standInLabel(si), name: si.name, area, tip: standInTip(si, c.name) }); }; 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'); @@ -85,8 +85,8 @@ export function StandInTags({ view, priv, getChess, backups, diyData = null }) { return html``; diff --git a/public/js/screens/ownership.js b/public/js/screens/ownership.js index bb6e4b42..4a997b95 100644 --- a/public/js/screens/ownership.js +++ b/public/js/screens/ownership.js @@ -1,8 +1,10 @@ // 干员持有 (operator ownership, 0.2.0 补位 — the approved plan, owner's decision 2026-10-05): the second tab of the // 干员调配 overlay (screens/loadout.js). The 53 NORMAL chess of this season's shop, grouped by tier — each card shows the // operator and its official stand-in (avatar, name, the skill it fields) and switches between 持有 and 未持有 with a -// tap. A chess marked 未持有 keeps its shop / hand card (name, bonds, price, with a 「替补:X」 badge) and is deployed -// as the stand-in (model, skills, talents, range). PRESET chess always field their own operator and are not listed. +// tap. A chess marked 未持有 is shown as its stand-in in the match — shop and reward cards, hand, board, detail card, +// result (portrait, name, model, skills, talents, range; 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. PRESET chess always field their own +// operator and are not listed. // Default: everything owned; 全部持有 resets. Out of match: the next match takes the list (in co-op it only affects the // player's own pieces). The list lives in ui/loadoutSync.js (localStorage + room.ownership); the model is // ui/ownershipModel.js. Styles: css/screens/loadout.css (own-*). @@ -14,6 +16,7 @@ import { chessAvatarUrl } from '../ui/assetUrls.js'; import { data } from '../data.js'; import { PROF_NAME } from '../ui/loadoutModel.js'; import { ownershipRoster, rosterByTier, isOwned, standInSummary } from '../ui/ownershipModel.js'; +import { t } from '../../../shared/i18n.js'; const cx = (...p) => p.flat().filter(Boolean).join(' '); const ROMAN = ['', 'I', 'II', 'III', 'IV', 'V', 'VI']; @@ -62,7 +65,7 @@ export function OwnershipPanel({ m, roster, notOwned, onToggle }) { const groups = rosterByTier(list); return html`
-

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

+

${t('未持有的干员由官方指定的替补干员上场:商店、整备区、作战区与结算都显示替补干员(模型、技能、天赋、攻击范围),盟约、特质、阶级、价格与合成仍按原干员。预设干员无论是否持有都由本人上场,不在此列。')}

diff --git a/public/js/screens/result.js b/public/js/screens/result.js index 0fea546a..4d10272f 100644 --- a/public/js/screens/result.js +++ b/public/js/screens/result.js @@ -8,7 +8,9 @@ // title: 'comment_1' | { id, name? } | null, // 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) */ }], +// draws the operator (gameLogic diyRecordFor) */, +// standInFor? /* 0.2.0 补位: the replaced operator's charId — the card draws the stand-in +// with a small 「替补」 mark (gameLogic standInOf; the owner's recall, 2026-10-06) */ }], // bonds?: [{ bondId, layers, active }], // stats: { dmgDealt, kills, leaks, gold /* funds SPENT */, refreshes, merges, bossDamage?, itemsEquipped?, // activatedLayers?, lpLost?, perfectRounds? } }] } @@ -16,7 +18,7 @@ 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, diyRecordFor } from '../ui/gameLogic.js'; +import { normalizeResult, fmtNum, diyRecordFor, cardStandIn, standInForText } from '../ui/gameLogic.js'; import { data } from '../data.js'; import { enemyIconUrl, titleIconUrl, uiUrl } from '../ui/assetUrls.js'; import { store, useStore, emptyMatch } from '../store.js'; @@ -32,6 +34,18 @@ const STAT_ROWS = [ ['refreshes', '刷新次数'], ['leaks', '未击倒'], ['lpLost', '损失生命'], ]; +/** + * One unit of a final lineup: a 自选 piece draws its operator; a 补位 piece (`standInFor`) its stand-in, with the small + * 「替补」 mark — the title names the chess it fielded for. + */ +export function LineupThumb({ u, gd }) { + const chess = u.kind === 'token' ? null : gd.chess(u.id); + const dr = u.diy && chess ? diyRecordFor(chess, u.diy, { chess: data.get('chess'), backups: data.get('backups') }) : null; + const si = !dr && chess ? cardStandIn(chess, { unit: u, backups: gd.backups }) : null; + return html`<${UnitThumb} kind=${u.kind === 'token' ? 'token' : 'chess'} id=${u.id} golden=${!!u.golden} tier=${u.tier} size="sm" rec=${dr || si} + title=${si ? `${si.name}(${standInForText(chess.name)})` : undefined} />`; +} + function PlayerCard({ p, myId, titles, best, solo = false }) { const gd = useGameData(); const titleRec = p.title ? titles.find((t) => t.id === p.title.id) || null : null; @@ -49,8 +63,7 @@ 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" - rec=${u.diy ? diyRecordFor(gd.chess(u.id), u.diy, { chess: data.get('chess'), backups: data.get('backups') }) : null} />`) + ${lineup.length ? lineup.map((u, i) => html`<${LineupThumb} key=${i} u=${u} gd=${gd} />`) : 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 d17a3b0d..8d557d68 100644 --- a/public/js/ui/bondStrip.js +++ b/public/js/ui/bondStrip.js @@ -18,10 +18,13 @@ // Watching a teammate (DESIGN §20.15, ui/watchBonds.js) the strip and the popup show THAT player's bonds and layers: // the strip carries an amber "👁 name" tag (`owner`, the observing pill's spelling, research 09 §3.1) and amber rings, // the popup a "👁 name 的盟约" line; its member list reads the teammate's operators on the field (no hand: never sent). +// 0.2.0 补位: a member the player fields as its stand-in (the own m.private.standIns; a teammate's unit that says so — +// UnitInfo `standInFor`) is drawn as the stand-in — avatar, name, a small 「替补」 mark on the thumbnail — and its row +// opens the stand-in's card (gameLogic memberStandIn; the owner's recall of the official mode, 2026-10-06). 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, diyGetter, diyRecordFor } from './gameLogic.js'; +import { sortBonds, bondMembers, nextThreshold, bondTier, harmonyMembers, HARMONY_BOND, memberHeadCount, briefingBondTip, diyGetter, diyRecordFor, memberStandIn, standInForText } from './gameLogic.js'; import { formatBondEffect } from './richText.js'; import { bondIconUrl } from './assetUrls.js'; import { data } from '../data.js'; @@ -83,11 +86,12 @@ export function BondStrip({ bonds, layersDisabled = false, onOpen, openId = null /** * Bond detail popup. * @param {{ bondId: string, entry?: any, priv?: any, banned?: string[], onClose: Function, - * onMember?: (chessId:string, items?:string[]|null)=>void, + * onMember?: (chessId:string, items?:string[]|null, standInFor?:string|null)=>void, * place?: 'left'|'beside'|'besideR'|'right'|null, over?: boolean, beside?: 'left'|'right'|null, owner?: string|null, off?: boolean }} props — * `place`: where it opens (gameLogic bondPopupPlace; `beside: 'left'` = the older spelling of 'beside'); `over`: above the * detail card; `owner`: the watched teammate's name (`entry` / `priv` are then theirs: ui/watchBonds.js); `onMember` - * gets a 变形同构体 row's item ids too (its card shows the pair and the granted chip), null for a plain member; `off`: the + * gets a 变形同构体 row's item ids too (its card shows the pair and the granted chip), null for a plain member, and a + * stand-in row's `standInFor` (0.2.0 补位: a teammate's card then shows the stand-in too); `off`: the * mode never activates this bond (gameLogic modeOffBonds — 标准's 10 inactive bonds): 本局禁用 instead of 未激活, with a * note, no 在场 count and no 当前效果 block (its numbers would promise an effect the mode never gives; the bond text stays). * `entry.harmony` (the server's bond views: the +1 调和 added to `count`): 在场 n(含调和 +1) and the 调和 row @@ -105,13 +109,19 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember, 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); + // 补位: the stand-in a row shows (a 自选 row is its operator already) + const rawChess = (id) => data.lookup('chess', id); + const siOf = (mb) => (mb.diy ? null : memberStandIn(priv, rawChess(mb.id), data.get('backups'), rawChess)); const countsHand = entry?.countsHand ?? b.countsHand; const next = nextThreshold(count, th); const hasNow = !off && !!(b.effectDescRaw || b.effectDesc); const at = place || (beside === 'left' ? 'beside' : 'left'); // 调和's +1 in this count (the server's word), and the 调和 operators on that board who give it const harmony = !off && Number.isInteger(entry?.harmony) && entry.harmony > 0 ? entry.harmony : 0; - const harmonyBy = harmony ? harmonyMembers(priv, getChess) : []; + const harmonyBy = harmony ? harmonyMembers(priv, getChess).map((x) => { + const si = memberStandIn(priv, rawChess(x.id), data.get('backups'), rawChess); + return si ? { ...x, name: si.name } : x; + }) : []; const harmonyName = data.lookup('bonds', HARMONY_BOND)?.name || '调和'; const harmonyText = `${harmonyBy.length ? `${harmonyBy.map((x) => x.name).join('、')} 在场` : `${harmonyName}已激活`}:核心盟约激活人数 +${harmony}`; return html`
<${BondGlyph} bondId=${HARMONY_BOND} />${harmonyName} +${harmony}${harmonyText}
`) : null}
- ${members.map((mb) => html``)} + ${members.map((mb) => { + const si = siOf(mb); + const name = si ? si.name : mb.name; + const items = mb.granted && Array.isArray(mb.items) ? mb.items : null; + return html``; + })}
`; diff --git a/public/js/ui/detailPanel.js b/public/js/ui/detailPanel.js index 9115991e..74174911 100644 --- a/public/js/ui/detailPanel.js +++ b/public/js/ui/detailPanel.js @@ -33,7 +33,7 @@ import { html, Icon, TierChip, MicroLabel, Button, confirmDialog, useTicker } from './components.js'; import { Img, RichText, UnitThumb, BondGlyph, GIcon, diyToken } from './gameComponents.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 { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, briefingBondTip, pieceBondIds, grantedBonds, morphPairings, ownStandIn, standInOf, standInLoadout, standInLabel, standInTip, standInForText, 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'; @@ -361,8 +361,10 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond // 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) + // 0.2.0 补位: a chess fielded as its stand-in shows the stand-in — portrait, name, class, 特性, stats, range, skill, + // talents, module (its backup selection, no loadout) — under a small 「替补」 tag, with the replaced operator's name + // where the English name usually is (「银灰的替补」, [ASSUMED] placement); the chess's tier, bonds, 特质 and sell price + // still apply (the owner's recall of the official mode, 2026-10-06) const si = standIn && standIn.standInFor ? standIn : null; const body = si || chess; const lo = si ? standInLoadout(si, getChess, data.get('backups')) : chessLoadout(chess, loadout, getChess); @@ -387,18 +389,18 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond blocks.head = html`
- <${Img} src=${chessPortraitUrl(m, body)} fallback=${html`<${UnitThumb} kind="chess" id=${c.chessId} size="lg" />`} /> + <${Img} src=${chessPortraitUrl(m, body)} fallback=${html`<${UnitThumb} kind="chess" id=${c.chessId} size="lg" rec=${si} />`} />
<${TierChip} tier=${c.tier} golden=${golden} size="lg" /> ${golden ? html`精锐` : null} ${piece?.kind === 'token' ? html`召唤物` : null} - ${si ? html`${standInLabel(si)}` : 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 || ''} +

${si ? si.name : c.name}

+ ${si ? html`${standInForText(c.name)}` : html`${c.appellation || ''}`}
<${Img} src=${profIconUrl(m, body.profession)} class="dhead__prof" /> ${PROF_NAME[body.profession] || body.profession || ''} @@ -453,7 +455,7 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond ${talents.map((t, i) => html`
${t.name}<${RichText} text=${t.descRaw || t.desc} class="dtext" />
`)} ` : null; blocks.actions = piece && editable && piece.kind !== 'item' ? html`
- <${Button} variant="amber" icon="close" class="dpanel__sell" onClick=${() => onSell(piece, c)}>出售+${sell} + <${Button} variant="amber" icon="close" class="dpanel__sell" onClick=${() => onSell(piece, si || c)}>出售+${sell}
` : null; const out = CHESS_SECTIONS.map((k) => blocks[k]).filter(Boolean); // a merge-completing shop / reward card: where the elite goes (shopBar mergeHint), right under the header @@ -616,13 +618,14 @@ export function TokenDetail({ token, piece, ownerId = null, snapHp = null, live * @param {{ kind:'piece'|'chess'|'item'|'enemy'|'unit'|'token', id?:string, uid?:number, unit?:any, count?:number }} target * @param {Map} pieces indexPieces(priv) * @param {{ priv?: any, backups?: any }} [opts] 0.2.0 补位: the player's own pieces and cards of a chess in - * m.private.standIns — and a unit carrying `standInFor` — resolve with `standIn` (the composed stand-in record the - * card shows as the body); 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 + * m.private.standIns — a unit carrying `standInFor`, and a teammate's bond popup row that says it (`target.standInFor`) + * — resolve with `standIn` (the composed stand-in record the card shows: portrait, name, body, with the chess's + * bonds and 特质); 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 ownSi = (c) => ownStandIn(c, priv, backups); 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) => { @@ -638,18 +641,20 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get( 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; + return c ? { type: 'chess', chess: c, piece: p, standIn: ownSi(c) } : null; } if (target.kind === 'chess') { // a bond popup's 变形同构体 row hands the wearer's item ids on (bondStrip onMember): the card shows the pair and the chip const c = data.lookup('chess', target.id); const items = Array.isArray(target.items) ? target.items.filter((x) => typeof x === 'string') : []; // (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) + // do not read the viewer's 补位 list: a teammate's row shows the stand-in only when its unit says it is one — + // `target.standInFor`, from UnitInfo through ui/watchBonds.js ownerBoard) 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; + const si = !c ? null : foreign ? (typeof target.standInFor === 'string' && target.standInFor ? standInOf(c, backups) : null) : ownSi(c); + return c ? { type: 'chess', chess: c, hint: target.hint || null, standIn: si, ...(items.length ? { unitItems: items } : {}) } : null; } if (target.kind === 'item') { const it = data.lookup('items', target.id); return it ? { type: 'item', item: it } : null; } if (target.kind === 'enemy') { const en = data.lookup('enemies', target.id); return en ? { type: 'enemy', enemy: en, count: target.count } : null; } @@ -665,7 +670,7 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get( // follows m.private.standIns like the piece let si = null; if (c && typeof u.standInFor === 'string' && u.standInFor) si = standInOf(c, backups); - else if (c && own?.piece) si = ownStandIn(c); + else if (c && own?.piece) si = ownSi(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; diff --git a/public/js/ui/equipReplace.js b/public/js/ui/equipReplace.js index 0dce7844..e6f65fc7 100644 --- a/public/js/ui/equipReplace.js +++ b/public/js/ui/equipReplace.js @@ -7,8 +7,9 @@ // _arrow_head, _avatart_bg / _frame, _round, _close, _comfirm_icon, _replace_icon), CSS look-alikes without them // (css/screens/game-panels.css .eqr). // -// replaceRequest(ctx, intent, getChess, getItem) → { targetUid, targetId, targetName, item: {uid,id,name}, options } -// | null (pure; the data the dialog shows — null = no dialog) +// replaceRequest(ctx, intent, getChess, getItem) → { targetUid, targetId, targetName, targetRec, item: {uid,id,name}, +// options } | null (pure; the data the dialog shows — null = no dialog; +// `getChess` = what the own pieces show: a 补位 piece's stand-in record) // replaceIntent(intent, replaceUid) → { t: 'g.equip', fields: { itemUid, targetUid, replaceUid } } // @@ -35,10 +36,13 @@ export function replaceRequest(ctx, intent, getChess = () => null, getItem = () const equipped = (Array.isArray(target.items) ? target.items : []).filter((it) => isObj(it) && Number.isInteger(it.uid)); if (equipped.length < 2) return null; const rec = (id) => getItem(id) || null; + const shown = getChess(target.id) || null; return { targetUid: target.uid, targetId: target.id, - targetName: getChess(target.id)?.name || '该干员', + targetName: shown?.name || '该干员', + // the record the avatar draws (a 补位 stand-in's or a 自选 operator's composed record; the data's for any other chess) + targetRec: shown && (shown.standInFor || shown.diyFor) ? shown : null, golden: !!target.golden, item: { uid: item.uid, id: item.id, name: rec(item.id)?.name || '装备' }, options: equipped.map((it) => ({ uid: it.uid, id: it.id, name: rec(it.id)?.name || it.id })), @@ -115,7 +119,7 @@ export function EquipReplaceDialog({ request, getItem = () => null, busy = false

「${request.targetName}」的装备栏已满(2/2)。选择一件装备进行替换,被替换的装备将被销毁。

- <${UnitThumb} kind="chess" id=${request.targetId} golden=${request.golden} size="md" /> + <${UnitThumb} kind="chess" id=${request.targetId} golden=${request.golden} size="md" rec=${request.targetRec || null} /> ${request.targetName}
diff --git a/public/js/ui/fallbackField.js b/public/js/ui/fallbackField.js index 690785f3..9fd13295 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, ownDiyRecord } from './gameLogic.js'; +import { tileKey, hasFlag, UF, penPlacement, PEN, fieldTile, ownStandIn, ownDiyRecord } from './gameLogic.js'; const DRAG_PX = 6; const DMG_TTL = 900; @@ -119,14 +119,16 @@ export function createFallbackView(host, opts = {}) { const tempPos = (L, idx) => ({ x: (GEO.TEMP_C0 + idx) * L.tile, y: (L.rows + 1.72) * L.tile }); // ---- pieces (prep) --------------------------------------------------------------------------------------- - function pieceArt(p, area = null) { + function pieceArt(p) { const mm = m(); if (p.kind === 'item') return itemIconUrl(mm, lookup('items', p.id)); if (p.kind === 'token') return tokenAvatarUrl(mm, p.id); 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 || ownDiy(chess) || chess); + return chessAvatarUrl(mm, ownSi(chess) || ownDiy(chess) || chess); + } + /** 0.2.0 补位: the player's own piece of a chess it does not own is its stand-in, bench and board alike (render/app.js pieceInfo) */ + function ownSi(chess) { + return chess ? ownStandIn(chess, st.priv, dataStore?.get?.('backups') ?? null) : null; } /** 0.2.0 自选编队: the player's own piece of a DIY slot it filled is its operator (gameLogic ownDiyRecord) */ function ownDiy(chess) { @@ -136,11 +138,11 @@ export function createFallbackView(host, opts = {}) { if (p.kind === 'item') return lookup('items', p.id)?.name || '道具'; if (p.kind === 'token') return lookup('tokens', p.id)?.name || '召唤物'; const chess = lookup('chess', p.id); - return (ownDiy(chess) || chess)?.name || '干员'; + return (ownSi(chess) || ownDiy(chess) || chess)?.name || '干员'; } function Piece({ p, x, y, L, area }) { - const src = pieceArt(p, area); + const src = pieceArt(p); const dragging = st.drag?.uid === p.uid && st.drag.moved; const golden = !!p.golden; const tier = p.tier || lookup('chess', p.id)?.tier || lookup('items', p.id)?.tier; diff --git a/public/js/ui/gameComponents.js b/public/js/ui/gameComponents.js index 43ad27e0..0552017c 100644 --- a/public/js/ui/gameComponents.js +++ b/public/js/ui/gameComponents.js @@ -5,6 +5,7 @@ import { useState, useMemo } from '../../vendor/hooks.module.js'; import { html, Icon, TierChip, Tooltip } from './components.js'; import { data, useData, localAsset } from '../data.js'; import { parseRichText, rtClassName } from './richText.js'; +import { t as tr } from '../../../shared/i18n.js'; import { uiUrl, chessAvatarUrl, chessPortraitUrl, itemIconUrl, tokenAvatarUrl, enemyIconUrl, bondIconUrl, bandIconUrl, } from './assetUrls.js'; @@ -101,15 +102,18 @@ 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, 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) + * `rec`: the chess record to draw instead of the data's (a 自选 piece's composed record — 0.2.0, gameLogic/diy.js; a + * 补位 stand-in's — gameLogic/standIn.js: drawn with a small 「替补」 mark in the corner) */ 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; + let si = null; if (kind === 'chess') { const c = rec || data.lookup('chess', id); + si = rec && typeof rec.standInFor === 'string' && rec.standInFor ? rec : null; src = chessAvatarUrl(m, c); name = c?.name || ''; t = t ?? c?.tier; @@ -137,6 +141,7 @@ export function UnitThumb({ kind = 'chess', id, golden, size = 'md', tier, showT ${showTier && t && kind !== 'enemy' && kind !== 'token' ? html`<${TierChip} tier=${t} golden=${golden} size="sm" class="uthumb__tier" />` : null} ${kind === 'token' ? html`召唤` : null} + ${si ? html`` : null} ${badge} `; } diff --git a/public/js/ui/gameLogic.js b/public/js/ui/gameLogic.js index 2b29e4be..2ed7a5bf 100644 --- a/public/js/ui/gameLogic.js +++ b/public/js/ui/gameLogic.js @@ -41,6 +41,6 @@ export { shortcutFor, closesOnFieldPress, shortcutBlocked } from './gameLogic/sh export { DEFAULT_SETTINGS, sanitizeSettings } from './gameLogic/settings.js'; export { normalizeResult } from './gameLogic/result.js'; export { chessLoadout, unitLoadout } from './gameLogic/loadout.js'; -export { standInIds, fieldsStandIn, standInOf, cardStandIn, standInGetter, standInLoadout, deployedRecord, deployedModuleId, standInLabel } from './gameLogic/standIn.js'; +export { standInIds, fieldsStandIn, standInOf, ownStandIn, cardStandIn, memberStandIn, standInGetter, standInLoadout, deployedRecord, deployedModuleId, standInLabel, standInForText, standInTip } 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/standIn.js b/public/js/ui/gameLogic/standIn.js index 7bb3f8b7..61b06c66 100644 --- a/public/js/ui/gameLogic/standIn.js +++ b/public/js/ui/gameLogic/standIn.js @@ -1,18 +1,23 @@ // ui/gameLogic/standIn.js — 补位 stand-ins in the match UI (0.2.0, the approved plan — owner's decision 2026-10-05). // Re-exported from ../gameLogic.js. // -// A chess the player marked as not owned (干员持有) keeps its card — name, bonds, price, the shop / reward / hand art — -// with a 「替补:」 badge, and is deployed as its official stand-in: the board model, the battle unit and the -// detail card's body (portrait, class, 特性, stats, range, skill, talents, module) are the stand-in's, its identity -// (name, tier, bonds, 特质) the chess's. Whose pieces: the player's own come from m.private.standIns (the list the seat -// had when the match started — the server's, not this browser's current setting, which applies from the next match); -// another player's units say it themselves (UnitInfo `standInFor`: the sim's, and Match.prepFieldMeta's for scouting). -// The composed record is shared/standIn.js standInRecord over data/backups.json — the one the sim fields. +// A chess the player marked as not owned (干员持有) is SHOWN as its official stand-in wherever the player's copy of it +// appears — the shop and reward cards, the hand (整备区) and 临时整备区 pieces, the board, the detail card, the result +// screen, a teammate's prep scouting and bond popups — the owner's first-hand recall of the official mode, 2026-10-06 +// (it reverses the first 0.2.0 build, where cards and the bench kept the chess's art and name with a 「替补:X」 badge). +// Only what is shown changes: the record (shared/standIn.js standInRecord over data/backups.json — the one the sim +// fields) keeps the chess's identity — ids, tier, bonds, 特质, price, merge — so every rule still reads the chess; its +// body — portrait / avatar, name, class, model, 特性, stats, range, skill, talents, module — is the stand-in's; a small +// 「替补」 mark (standInLabel) and the replaced operator's name on the detail card (standInForText) tell which chess it +// is. Whose pieces: the player's own come from m.private.standIns (the list the seat had when the match started — the +// server's, not this browser's current setting, which applies from the next match); another player's units say it +// themselves (UnitInfo `standInFor`: the sim's, Match.prepFieldMeta's for scouting, the m.result lineups). import { standInRecord } from '../../../../shared/standIn.js'; import { resolveLoadout } from '../../../../shared/protocol.js'; import { chessLoadout } from './loadout.js'; import { isObj } from './shared.js'; +import { t } from '../../../../shared/i18n.js'; /** Base chess ids the player fields as stand-ins in this match (m.private.standIns; [] when absent). */ export function standInIds(priv) { @@ -33,10 +38,13 @@ const CACHE = new WeakMap(); /** * The composed 补位 record of a chess record (shared/standIn.js standInRecord: the chess's identity, the stand-in's * body; `standInFor` = the replaced charId), cached per data object; null for a PRESET / 自选 chess or missing data. + * A composed record is its own stand-in (a lookup that already hands out stand-in records stays idempotent). * @param {any} chess data/chess.json record @param {any} backups data/backups.json */ export function standInOf(chess, backups) { - if (!isObj(chess) || !isObj(backups)) return null; + if (!isObj(chess)) return null; + if (typeof chess.standInFor === 'string' && chess.standInFor) return chess; + if (!isObj(backups)) return null; let m = CACHE.get(backups); if (!m) { m = new WeakMap(); CACHE.set(backups, m); } if (m.has(chess)) return m.get(chess); @@ -47,8 +55,18 @@ export function standInOf(chess, backups) { } /** - * The stand-in record the card of a chess shows for this viewer, or null: the player's own piece / card (m.private - * standIns), or a unit that carries `standInFor` (another player's — the sim's UnitInfo, prep scouting). + * The stand-in record the player's own card / piece of `chess` shows (the player fields its stand-in: m.private + * standIns), or null. + * @param {any} chess @param {any} priv m.private @param {any} backups + */ +export function ownStandIn(chess, priv, backups) { + return fieldsStandIn(priv, chess) ? standInOf(chess, backups) : null; +} + +/** + * The stand-in record the card of a chess shows for this viewer, or null: a unit / lineup entry that carries + * `standInFor` (another player's — the sim's UnitInfo, prep scouting, m.result), else the player's own piece / card + * (m.private standIns). * @param {any} chess @param {{ priv?: any, unit?: any, backups?: any }} o */ export function cardStandIn(chess, { priv = null, unit = null, backups = null } = {}) { @@ -57,6 +75,27 @@ export function cardStandIn(chess, { priv = null, unit = null, backups = null } return mine ? standInOf(chess, backups) : null; } +/** + * The stand-in a bond popup's member row of base chess `chess` shows for the board `priv`, or null: a chess the player + * fields as its stand-in (m.private.standIns — any copy, in play or not, as the shop shows it), or one whose piece on a + * teammate's board says it is a stand-in (ui/watchBonds.js ownerBoard: the units' `standInFor`). + * @param {any} priv m.private, or a teammate's board ({ board, hand, temp } of pieces with `standInFor`) + * @param {any} chess the member's chess record @param {any} backups + * @param {(id: string) => any} [getChess] the pieces' records (their base ids) + */ +export function memberStandIn(priv, chess, backups, getChess = () => null) { + if (!isObj(chess) || typeof chess.chessId !== 'string') return null; + if (fieldsStandIn(priv, chess)) return standInOf(chess, backups); + const base = chess.baseId || chess.chessId; + const baseOf = (id) => getChess(id)?.baseId || (typeof id === 'string' ? id.replace(/_b$/, '_a') : id); + for (const list of [priv?.board, priv?.hand, priv?.temp]) { + for (const p of Array.isArray(list) ? list : []) { + if (isObj(p) && typeof p.standInFor === 'string' && p.standInFor && baseOf(p.id) === base) return standInOf(chess, backups); + } + } + return null; +} + /** id → stand-in record (else the chess record): the lookup a stand-in's loadout / options resolve against. */ export function standInGetter(getChess, backups) { return (id) => { @@ -104,7 +143,17 @@ export function deployedModuleId(chess, priv, getChess, backups) { } catch { return null; } } -/** The 「替补:X」 badge text of a stand-in record (null without one). */ +/** The small 「替补」 mark of a stand-in record (null without one): cards, the own pieces' tags, thumbnails. */ export function standInLabel(rec) { - return isObj(rec) && rec.standInFor && rec.name ? `替补:${rec.name}` : null; + return isObj(rec) && rec.standInFor ? t('替补') : null; +} + +/** The replaced operator's line of a stand-in's detail card: 「银灰的替补」 (`chessName` = the chess's own name). */ +export function standInForText(chessName) { + return t('{name}的替补', { name: chessName || '' }); +} + +/** The 「替补」 mark's title: which chess the stand-in fields for, and what stays the chess's ('' without a stand-in). */ +export function standInTip(rec, chessName) { + return isObj(rec) && rec.standInFor ? t('未持有{name}:由替补干员 {standIn} 上场(盟约、特质、阶级与价格不变)', { name: chessName || '', standIn: rec.name || '' }) : ''; } diff --git a/public/js/ui/ownershipModel.js b/public/js/ui/ownershipModel.js index 9b31fc76..41ba8b83 100644 --- a/public/js/ui/ownershipModel.js +++ b/public/js/ui/ownershipModel.js @@ -2,8 +2,9 @@ // shared with the sync (ui/loadoutSync.js) and the tests. The approved plan (owner's decision 2026-10-05): a player marks // operators as not owned ("下掉"); only the 55 NORMAL chess can be dropped (shared/standIn.js isDroppableChess — the 74 // PRESET chess always field their own operator); default = everything owned; out of match (the next match takes it); -// co-op: the player's own pieces only; no friend borrowing. A dropped chess keeps its card (name, bonds, price) and is -// deployed as its official stand-in (data/backups.json). +// co-op: the player's own pieces only; no friend borrowing. A dropped chess is deployed and shown as its official +// stand-in (data/backups.json; the owner's recall of 2026-10-06: cards, pieces and the result show the stand-in) while +// its bonds, 特质, tier, price and merge stay the chess's. // // The per-browser setting is a sorted list of base chess ids, persisted in localStorage (`sp.pref.ownership` = // { v: 1, notOwned }) and sent with C2S `room.ownership { notOwned }` (the server keeps the droppable ids and drops the diff --git a/public/js/ui/shopBar.js b/public/js/ui/shopBar.js index b60fd627..67c48f73 100644 --- a/public/js/ui/shopBar.js +++ b/public/js/ui/shopBar.js @@ -18,15 +18,16 @@ // Every operator card shows the skill it will fight with — the player's 干员调配 loadout (m.private.loadout, DESIGN // §16): the skill icon above the name, mint-framed with 已调配 in its title when it is not the default skill (and the // module type of an elite card, with its official type icon when the local-client art has it). 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). 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. +// not own (0.2.0 补位, m.private.standIns) shows its stand-in — portrait, name, class and its backup skill, the one that +// fights — with the chess's bonds, tier and price and a small ice 「替补」 mark (the owner's recall of the official mode, +// 2026-10-06; the replaced operator's name is in the mark's title and on the detail card the first tap opens). 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, ownDiyRecord, diyGetter } from './gameLogic.js'; +import { priceTone, mergeProgress, mergeTarget, shopBlockReason, chessLoadout, offerHeader, briefingBondTip, ownStandIn, standInLoadout, standInLabel, standInTip, standInForText, 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'; @@ -71,7 +72,10 @@ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free // 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; + // 0.2.0 补位: the player's not-owned chess shows its stand-in (art, name, class, its backup skill); the composed record + // keeps the chess's identity — tier, bonds, price (a DIY slot is never droppable: one or the other) + const si = c0 && !dr ? ownStandIn(c0, priv, data.get('backups')) : null; + const c = dr || si || c0; const m = data.get('assets'); const tier = c?.tier ?? 1; const prog = mergeProgress(priv, slot.id, (id) => data.lookup('chess', id)); @@ -79,13 +83,12 @@ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free const willMerge = !!hint; const bonds = Array.isArray(c?.bonds) ? c.bonds : []; const disabled = !!reason; - // 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, 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`