From ef101647739978e3847f240213d798e1a9a35d30 Mon Sep 17 00:00:00 2001 From: sganggs Date: Wed, 7 Oct 2026 02:29:49 +0800 Subject: [PATCH] =?UTF-8?q?feedback5=20CB5:=20the=20boss=20round's=20prep?= =?UTF-8?q?=20shows=20both=20players=20of=20a=20pair=20together=20?= =?UTF-8?q?=E2=80=94=20the=20partner's=20pieces=20on=20the=20other=20half,?= =?UTF-8?q?=20as=20the=20battle=20places=20them=20=E2=80=94=20and=20frames?= =?UTF-8?q?=20the=20pair's=20two=20avatars=20in=20the=20official=20green?= =?UTF-8?q?=20(community=20report=20of=202026-10-06,=20item=2051)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The own half was already the official one (the right-hand player on the right half with the right_boss_*_camera_param camera, its layout mirrored col c -> 20 - c with RIGHT <-> LEFT, so its operators face the leader as before: research 09 §1.2, DESIGN §15); the partner's half stood empty and nothing framed the pair. Server: in a boss round's prep m.private.bossMate { playerId, side, units } = the partner's board as UnitInfo on its half (Match.bossMateView, finalAssault.js bossFieldPlacement); markPrivate marks the partner's m.private with the mover's, so it follows live; a scout of either player (Match.prepFieldMeta) carries both halves (`mate`), and its signature covers the partner's board. Client: render/app.js syncMates draws the partner's units read-only ('m:' views: never picked, dragged or swept with the own pieces) and the whole boss field is lit; the team panel frames the pair's avatars (gameLogic teamFrameIds: the players still in by seat, two by two, as prepCamera — the boss field in the fight) from the round start through the Final Assault / Hidden Core, with the official ui/battle bg_team_border tinted the autochess green (#4ed8af; a plain ring without the local art); none for a lone player, an eliminated player, a spectator seat, or 联防. Overlap with PR #191 (same-field frames, issue #190; not merged): it published m.public bossPairing and framed the partner alone in gold, 联防 included, plus a banner naming the partner; here the pairing is read as the prep camera already does, both avatars get the official green frame, boss rounds only. [ASSUMED] the partner's bench is not shown; one frame per avatar. Golden unchanged. Tests: test/match/feedback5-boss-pair-prep.test.js (new, 4), the item 55 test now checks the scouted player's own pieces (the partner's stand on the other half); SP_E2E test/ui/feedback5-boss-pair.e2e.test.js (new, 2: both halves and the frames in prep and fight; item 55's eliminated guest on the boss field with the leader). Docs: design/client.md §10 / §15, design/network.md, PLAYING.md. --- docs/PLAYING.md | 1 + docs/design/client.md | 4 +- docs/design/network.md | 2 +- public/css/screens/game.css | 9 ++ public/i18n/en.json | 1 + public/js/render/app.js | 38 ++++- public/js/ui/gameLogic.js | 2 +- public/js/ui/gameLogic/camera.js | 33 ++++- public/js/ui/teamPanel.js | 15 +- server/match/match/messaging.js | 13 +- server/match/match/views.js | 62 ++++++-- server/match/match/watch.js | 15 +- server/match/player/views.js | 6 + test/match/feedback5-boss-pair-prep.test.js | 116 +++++++++++++++ test/match/feedback5-boss-prep-scout.test.js | 6 +- test/ui/feedback5-boss-pair.e2e.test.js | 146 +++++++++++++++++++ 16 files changed, 436 insertions(+), 33 deletions(-) create mode 100644 test/match/feedback5-boss-pair-prep.test.js create mode 100644 test/ui/feedback5-boss-pair.e2e.test.js diff --git a/docs/PLAYING.md b/docs/PLAYING.md index 43f280c5..f97754e9 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -118,6 +118,7 @@ - **最终攻势**(第 14 回合;独立标准为第 9 回合):按座位两两配对,每对共享一个战场,一人守左半边、一人守右半边(右侧玩家的棋盘左右镜像)。所有人**共同削减同一条领袖血条**(血量 = 该难度的官方领袖生命值 × 开战时存活的玩家人数:领袖生命值取自当前版本的游戏数据,例如终极「假想敌:胄」3 600 000,4 人存活时为 14 400 000、2 人为 7 200 000;AI 队友和掉线托管的玩家都算,已被淘汰或中途退出的不算;独立模拟按 1 人计。这是作者 2026-10-06 采纳社区 PR #209 的决定,取代之前「保持固定血量」的做法),看不到另一组的战场。全队 LP 合并成一个池,不再有每回合 10 点上限。顶部的 120 秒倒计时结束后战斗**继续**(不会超时判负);从开战第 150 秒起(实际秒数)全队每秒扣 1 点 LP。领袖血量归零即胜利(血条显示 0 时领袖已倒下,战斗立即结束),LP 归零即失败——以先归零的一方为准,LP 耗尽后打出的伤害不再计入。此阶段盟约不叠层。 - **限伤**(最终攻势与隐秘核心,与官方相同):对领袖的**单次伤害达到 300000 或以上时整次作废**——这一下造成 0 点伤害,不计入领袖血条,也不显示伤害数字;不是截到 300000,而是一点都不算(299999 照常生效)。只针对领袖本体(含两人战场里的另一个领袖),领袖的部件、护卫、无人机和其他小怪不受影响,普通回合和联防也不受影响;部件把伤害等量转给领袖时,这一下也按一次伤害检查;无人机死亡令领袖失去的 2% 血条不算一次伤害,不受限伤影响(隐秘核心终极「假想敌:胄」3 或 4 人存活时每架为 43.2 万 / 57.6 万,按限伤会整次作废,本作仍然计入,属推断)。层数极高时领袖仍可能几秒内倒下(例如每名玩家的两个盟约都在 999 层,或所有激活的盟约都在 999 层),这来自官方按层数计算、没有上限的伤害倍率(谢拉格、奥术、精准等),官方社区也有「boss一秒死」的说法。 +- 最终攻势 / 隐秘核心的休整期和战斗中一样,**同组两人在同一个关底战场上**:你在自己的半场摆位(右侧玩家看到自己在右边,摆位左右镜像,干员相对领袖的朝向不变),队友当前的阵容显示在另一半场(只能看,随他调整实时更新);左侧成员栏里同组两人的头像带**绿框**,从回合开始一直到战斗结束。观战者查看其中一人时也会看到两人的阵容和站在关底的领袖。 - 最终攻势 / 隐秘核心的休整期按你在**关底战场上的半场**判定可部署地块(右侧玩家左右镜像),拖动时高亮的就是可放置的格子:例如「源石流发生装置」关底图中被 1×3 橙色管子围起的地块可以部署。在最终攻势放在这些格子上的干员进入隐秘核心时会留在原位。 - **领袖的部件**:「假想敌:胄」的无人机死亡时(不论是谁击落;漏过去不算),胄失去领袖血条总量的 2%(例如终极 4 人存活 14 400 000 → 每架 288 000;官方没写明这个「最大生命值」指哪一个,本作按血条总量处理,属推断);隐秘核心的「斩胄之剑 / 破胄之锤」「碎铳之簧」受到的每一点伤害都会等量扣在领袖血条上(剑 / 锤被击落后一定算;俯冲途中挨的打也算,这一点官方两处文字不一致,属推断)。剑 / 锤平时悬浮在胄身边、无敌;掷出后闪现到固定路线,冲向攻击力最低的干员,途中被打中 15 次就会【瘫痪】(晕眩 10 秒、落地、受到的伤害 ×1.3);落地或瘫痪结束后,会以剩余生命值换成一把新的回到原位。「灭顶之灾」每次只发射 1 枚刺胄之弹(低于 20% 生命也一样),只打攻击范围内攻击力最高的干员。 - **巨型领袖**(假想敌:胄 / 管、盐风主教昆图斯、阿利斯泰尔、“萨米的意志”)的受击判定是一片约 5×3 格的区域(整个管道区块):攻击范围碰到其中任意一格就能打到它;它们**自缚、无法被阻挡**,不会离开原地。溅射按敌人的中心判定,打在旁边小怪身上的溅射碰不到巨型领袖。 diff --git a/docs/design/client.md b/docs/design/client.md index bad024d8..1466b9be 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -37,7 +37,7 @@ Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highl Look & feel: research 06 §11–§12 (near-black green-grey panels, mint `#4ed8af` accents, gold prices, bracket frames, hexagon badges, bond discs, 7-segment countdown). Fonts: Noto Sans SC (Google Fonts with local fallback), Bender/Novecento self-hosted for numerals and Latin micro-labels. -Screens: **Title** (season art backdrop, 开始 → nickname), **Lobby** (独立模拟 / 同盟模拟 → create or join; recent rooms), **Room** (4 seats, invite code + copy link, difficulty picker (host), add/remove AI, ready, start), **Briefing** (INFO_CHECK: stage, factions, disabled bonds, banned chess, boss silhouette, ready x/4), **Band draft** (grid of 40 bands with icon/name/LP/effect; turn order list; skip; 查看禁用盟约与干员 under the order list opens the briefing's bond rows, legend and banned operators again in a read-only dialog — one component, `ui/matchInfo.js`, §21.28), **Game** (Pixi field + DOM HUD: top bar with round/phase capsule/LP (live during the own battle, §17.5)/timer/ready; bond strip + popups; left team panel with LP/status/emote bubbles, click to watch; bottom shop bar (level card, 3–5 chess cards, item card, funds, refresh, freeze, collapse); detail panel on click/hover (header with bonds, then 特质 — the operator's own effect — right under it, 特性, stats (live: the battle's own numbers / the start-of-battle ones in prep, §18.5), skill, module, equipped items, talents: §17.5); enemy preview drawer (next round enemies with counts, factions; its 本局信息 tab lists the briefing's disabled bonds and banned operators, §21.28); 机变 overlay (the official card: framed icon + name, the effect text — data `descRaw` — under it, the taker's round avatar at the top-right; two taps, §18.2, §20.7); merge reward overlay (the promotion reward, or a special offer — 凯瑟琳's 定向投放 … — as item / chess cards under its source's label, with the count of offers queued behind it, §21.2); phase banners; ticker (by broadcast priority, highest first; a leader-damage line plays only in its round and a player's newer one replaces the older, §21.10); toasts; emote wheel; settings (volume, quality, show damage numbers)), **Result** (victory/defeat, rounds, per-player lineup, stats, titles, back to room). +Screens: **Title** (season art backdrop, 开始 → nickname), **Lobby** (独立模拟 / 同盟模拟 → create or join; recent rooms), **Room** (4 seats, invite code + copy link, difficulty picker (host), add/remove AI, ready, start), **Briefing** (INFO_CHECK: stage, factions, disabled bonds, banned chess, boss silhouette, ready x/4), **Band draft** (grid of 40 bands with icon/name/LP/effect; turn order list; skip; 查看禁用盟约与干员 under the order list opens the briefing's bond rows, legend and banned operators again in a read-only dialog — one component, `ui/matchInfo.js`, §21.28), **Game** (Pixi field + DOM HUD: top bar with round/phase capsule/LP (live during the own battle, §17.5)/timer/ready; bond strip + popups; left team panel with LP/status/emote bubbles, click to watch (a boss round frames the viewer's pair in green, §15); bottom shop bar (level card, 3–5 chess cards, item card, funds, refresh, freeze, collapse); detail panel on click/hover (header with bonds, then 特质 — the operator's own effect — right under it, 特性, stats (live: the battle's own numbers / the start-of-battle ones in prep, §18.5), skill, module, equipped items, talents: §17.5); enemy preview drawer (next round enemies with counts, factions; its 本局信息 tab lists the briefing's disabled bonds and banned operators, §21.28); 机变 overlay (the official card: framed icon + name, the effect text — data `descRaw` — under it, the taker's round avatar at the top-right; two taps, §18.2, §20.7); merge reward overlay (the promotion reward, or a special offer — 凯瑟琳's 定向投放 … — as item / chess cards under its source's label, with the count of offers queued behind it, §21.2); phase banners; ticker (by broadcast priority, highest first; a leader-damage line plays only in its round and a player's newer one replaces the older, §21.10); toasts; emote wheel; settings (volume, quality, show damage numbers)), **Result** (victory/defeat, rounds, per-player lineup, stats, titles, back to room). 盟约栏支持「收起/展开盟约」(GitHub #142):收起时隐藏完整列表并关闭由列表打开的浮层,让原图标区域的指针事件到达棋盘,只保留至少 44×44 CSS px 的恢复按钮。折叠状态在本局阶段变化、观看队友时保留;展开后仍按当前观看对象显示最新盟约。按钮支持 Enter / Space,聚焦按钮时 Space 不触发准备或暂停。 @@ -72,6 +72,6 @@ Target: a **real 3D board layer** rendered with three.js (vendored ESM, no CDN) - One camera model drives both layers: `public/js/render/projection.js` keeps its public API (`worldToScreen`, `screenToWorld`/tile picking, presets, transitions) but derives it from a three.js `PerspectiveCamera` initialised from the official configBlackBoard framing, so Spine units, FX, drag/drop and hit-testing stay aligned with the 3D board. - Must degrade gracefully: when local-client art or WebGL2 features are missing, fall back to the current 2D atlas board; keep 60 fps (static geometry merged into few draw calls; textures mipmapped; DPR-aware). - Implemented (`public/js/render/board3d/*`, `render/app.js`): three.js is downloaded only when the local-art manifest lists the board atlas; the built 3D area, the drawn 2D rows and the lit rect follow `viewKind(kind, {rect})` (the field the camera actually shows: a 'prep' camera on rows ≤ 6 = `bossPrep`); the background plane is extended with mirrored copies and the clear colour matches the fog (no visible edge at any aspect); battle crates live in their own group (survive scene rebuilds) and follow the board mode (`switchableBox`: 3D mesh ⇄ 2D box on a mode switch or a lost WebGL context); quality 'low' drops shadows; `?board=2d|3d` overrides. The direction wheel's range stripes sit between the board canvas and the unit canvas. -- Done: Final Assault / Hidden Core prep takes place on your own half of the boss field (research 09 §1.2): pieces shown at boss-field positions (row − 7; right-hand player mirrored col → 20 − col, RIGHT ↔ LEFT), drop targets and wheel directions mapped back to board coordinates (`prepCamera` → `bossPrep` in public/js/ui/gameLogic.js; covered by the playtest2 browser tests). A scout of such a board (`m.field` prep of a boss round) comes in boss-field coordinates with the player's `side` and is framed the same way, the leader standing at its spawn (0.2.0, community report item 55). +- Done: Final Assault / Hidden Core prep takes place on your own half of the boss field (research 09 §1.2): pieces shown at boss-field positions (row − 7; right-hand player mirrored col → 20 − col, RIGHT ↔ LEFT), drop targets and wheel directions mapped back to board coordinates (`prepCamera` → `bossPrep` in public/js/ui/gameLogic.js; covered by the playtest2 browser tests). A scout of such a board (`m.field` prep of a boss round) comes in boss-field coordinates with the player's `side` and is framed the same way, the leader standing at its spawn (0.2.0, community report item 55). Both players of a pair are shown together, as in the battle (0.2.0, community report item 51): the partner's board stands on the other half (`m.private.bossMate` units, already in boss-field coordinates: mirrored on the right half, RIGHT ↔ LEFT; read-only `m:` views of `render/app.js syncMates`), the whole boss field is lit, a scout carries both halves (`mate`), and the team panel frames the pair's avatars in the official green (`ui/battle bg_team_border` tinted, a plain ring without the local art; `gameLogic teamFrameIds`) from the round's start through the fight. --- diff --git a/docs/design/network.md b/docs/design/network.md index 37fad38e..0d8a2748 100644 --- a/docs/design/network.md +++ b/docs/design/network.md @@ -18,7 +18,7 @@ Platform additions (implemented): `welcome.{resumed, version}`, `welcome.diyKitt C→S (all carry `rid`): `g.infoReady` · `g.band {bandId}` · `g.bandSkip` · `g.bandFocus {bandId?}` (the strategy highlighted in the draft screen — what a timed-out turn takes; absent / null clears it; `WRONG_PHASE` outside BAND_DRAFT, `ALREADY` after the pick, `BAD_TARGET` for a band not allowed; §18.2) · `g.buy {slot}` · `g.refresh` · `g.freeze` · `g.levelUp` · `g.sell {uid}` · `g.move {uid, to:{area:'board', row, col}|{area:'hand', idx}, dir?}` · `g.equip {itemUid, targetUid, replaceUid?}` (`replaceUid`: which equipped item a third one replaces, §6.2) · `g.art {itemUid, row, col, dir?}` (`dir` ∈ UP|RIGHT|DOWN|LEFT from the deploy wheel, §3; absent ⇒ `to.dir`, then RIGHT; `g.move` onto the piece's own tile re-orients it; board pieces in `m.private` / `m.field {prep:true}` carry `dir`) · `g.destroy {uid}` (hand / temp items only; equipped items are locked, §6.2) · `g.reward {idx}` · `g.choice {idx}` · `g.ready {ready}` · `g.emote {id}` · `g.watch {fieldId, playerId?}` (`playerId`: the player tapped — a shared 联防 / boss pair field shows two; what an eliminated viewer follows, §25) · `g.autoplay {on}` · `g.pause {on}` (solo battles only, §14 Solo pause) · `g.unitStats {seq?}` (ROUND_START / SP_DRAFT / PREP, else `WRONG_PHASE`; eliminated: `ELIMINATED`) → push `m.unitStats` (§18.5) · `g.leave` S→C: - `m.public` — full public state on every change (throttled ≤ 10/s): `{ phase, round, lastRound, deadline, serverNow, modeId, difficulty, stageId, factions, disabledBonds, bannedChess, bossId, hiddenBossId?, teamLp?, bossHp?:{hp,max}, overtimeAt? /* FA / HC: ms epoch the overtime drain starts, §4 */, paused /* solo pause, §14 */, draft?:{order,turn,picks,skipsLeft,turnDeadline,turnSeconds /* one turn's length, 0 untimed; deadline = turnDeadline (§18.2) */,untimed}, sp?:{family,cards /* bounty cards carry descRaw?, §20.6 */,turn,picks}, players:[{playerId, seat, name, isBot, connected, alive, lp, bandId, shopLevel, boardCount, ready, bonds:[{bondId,count,active,tier,layers,harmony? /* 调和's +1 is in count, §21.26 */}], fieldId, status, pendingLp? /* COMBAT / 联防 of a normal round: the LP the player's own battle will cost at settlement so far, min(lpCapPerRound, counted leaks) — in 联防 min(lpCapPerRound, uniteLeft) — omitted when 0 and in every other phase (§17.5 live LP) */, uniteLeft? /* 联防: the leaker's enemies still standing — not spawned yet + alive + counted leaks, uncapped, live (§20.6) */}] , fields:[{fieldId, kind, players:[playerId...]}] }` -- `m.private` — the recipient's full PlayerState view (§6.2 minus server-only data) on every change. +- `m.private` — the recipient's full PlayerState view (§6.2 minus server-only data) on every change; in a boss round's prep also `bossMate { playerId, side, units }` — the pair partner's board on its half of the boss field (UnitInfo, mirrored on the right half), re-sent when the partner moves (0.2.0, community report item 51). - `m.field` — field meta when watching starts: `{ fieldId, kind, rect, stageId, units: [UnitInfo], effects? }` (the units on the field, knocked-out operators waiting to redeploy included — `Battle.fieldMeta`, §18.3). The prep-scouting field (`Match.prepFieldMeta`) puts the scouted player's **hand (整备区) and 临时整备区 into `units` as well**: held pieces as units on the hand row (row 7, col = hand slot) and temp pieces on the temp row (row 8, cols 4..8 = the temp slots; PRTS 帮助 counts the temp area with the hand; a held item is a `kind: 'item'` unit the client draws as its floating plate) — the scout renders exactly like the own prep bench, for every watcher alike, the spectator seat's copy equal to a teammate's (§23.19's "never a private view" stays the no-`m.private` rule); the scout signature `_prepScoutSig` carries the hand / temp slots, so a piece moved to another slot is a change — and carries `effects`: the scouted player's effects view (策略 / 机变 / 悬赏 …), the watched player's effects column ready-made - NOTE: game time in `b.snap`/`b.ev` is carried as **`gt`** (the frame's `t` is the message type). - Active finite zero-SP timed skills display `sp=timeLeft`, `spMax=duration` and `UF.SKILL`; after end they display diff --git a/public/css/screens/game.css b/public/css/screens/game.css index 8f58f30b..6eee175a 100644 --- a/public/css/screens/game.css +++ b/public/css/screens/game.css @@ -307,6 +307,15 @@ .team__row.is-self .pavatar { box-shadow: 0 0 0 2px var(--mint-500), 0 0 .18rem rgba(23, 249, 183, .35); } .team__row.is-watched .pavatar { box-shadow: 0 0 0 2px var(--amber), 0 0 .18rem rgba(246, 163, 41, .45); } .team__row.is-dead .team__name { color: var(--text-dim); text-decoration: line-through; } +/* 最终攻势 / 隐秘核心: the viewer's pair framed in the official green from the round's start (item 51) — the official + ui/battle bg_team_border (a white frame with its glow) tinted through a mask; a plain green ring without the local art. + It replaces the own row's mint ring; a watched row keeps its amber one inside the frame. */ +.team__frame { + position: absolute; inset: -.07rem; pointer-events: none; z-index: 1; + background: var(--mint-500); -webkit-mask: var(--frame) center / 100% 100% no-repeat; mask: var(--frame) center / 100% 100% no-repeat; +} +.team__frame--plain { inset: -.04rem; background: none; -webkit-mask: none; mask: none; border: 2px solid var(--mint-500); box-shadow: 0 0 .14rem rgba(78, 216, 175, .65); } +.team__row.is-self.is-team .pavatar { box-shadow: none; } .team__bubble { position: absolute; left: calc(100% + .06rem); top: 50%; transform: translateY(-50%); width: max-content; max-width: 3.2rem; } /* 前往查看 / 返回战场 (research 09 §3.1: tapping a teammate's avatar expands a mint button under it) */ .team__row.is-open .pavatar { box-shadow: 0 0 0 2px var(--mint-400), 0 0 .2rem rgba(23, 249, 183, .45); } diff --git a/public/i18n/en.json b/public/i18n/en.json index 18112329..d37762c4 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -677,6 +677,7 @@ "刷新商店 · R": "Refresh Shop · R", "免费 ×{free}": "Free ×{free}", "查看 {name} 的战场": "View {name}'s Battlefield", + "与你在同一战场": "On your field", "查看自己的阵地": "View your Field", "查看 {name} 的阵地": "View {name}'s Field", "连接已断开": "Disconnected", diff --git a/public/js/render/app.js b/public/js/render/app.js index a1d4ad3c..2a3237e3 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -78,7 +78,10 @@ // boss rows 2–5, bench 7 → 0, temp 8 → 1, side 'R' mirrored col c → 20 − c with RIGHT ↔ LEFT. Every public // coordinate stays in BOARD space (pieceDrop targets, canPlace, highlightTiles, tileScreen, holdPiece, setPieceDir, // stored `dir`); `view.prepField()` → { kind, side, mirror } and `tileScreen(...).mirror` tell the direction wheel -// that a screen-right swipe means board LEFT on the mirrored half. +// that a screen-right swipe means board LEFT on the mirrored half. The pair partner's board stands on the other half +// (`priv.bossMate` units, already in boss-field coordinates — server/match/match/views.js bossMateView), read-only +// ('m:' views: never picked or dragged), and the lit rect is the whole boss field: both players of a pair are +// shown together, as in the battle (community report of 2026-10-06, item 51). // Lost WebGL context of the 3D board: the 2D board takes over at once and the 3D board is rebuilt on a fresh context // a moment later (up to 3 tries; a context lost again right away counts as a failure; `setBoardMode('2d')` stops it). // @@ -458,7 +461,8 @@ export async function createFieldView(host, options = {}) { if (k === 'pen') return { r0: 14, r1: 18, c0: 7, c1: 13 }; // prep lights the bench (hand row 7 / temp row 8) with the field, like the official prep view if (camOpts.rect) { const r = normRect(camOpts.rect); return k === 'prep' ? { ...r, r0: Math.min(r.r0, GEO.HAND_ROW) } : r; } - if (k === 'bossPrep') return camOpts.side === 'R' ? { r0: 0, r1: 5, c0: 10, c1: 20 } : { r0: 0, r1: 5, c0: 0, c1: 10 }; + // the boss round's prep lights the whole boss field: the pair partner's half is shown with its pieces (item 51) + if (k === 'bossPrep') return { ...GEO.BOSS_RECT }; return k === 'boss' ? { ...GEO.BOSS_RECT } : k === 'unite' ? { ...GEO.UNITE_RECT } : k === 'prep' ? { r0: 7, r1: 12, c0: 0, c1: 10 } : { ...GEO.NORMAL_RECT }; } @@ -768,12 +772,42 @@ export async function createFieldView(host, options = {}) { if (e.area === 'board' && info.dir && !held.has(e.uid) && typeof v.setDir === 'function') v.setDir(info.dir); } for (const k of [...views.keys()]) if (String(k).startsWith('p:') && !seen.has(Number(String(k).slice(2)))) dropView(k); + // the boss round's prep: the pair partner's board on its half of the boss field (item 51; display only) + syncMates(prepXf.kind === 'bossPrep' && src.bossMate && Array.isArray(src.bossMate.units) ? src.bossMate.units : []); prepPieces = list.filter((e) => e.key && views.has(e.key)); if (dragState && !views.has(dragState.key)) { drag.reset(); endDragVisual(false); } holdScene(false); // the prep pieces reference their models now (a battle's hold ends here) return true; } + /** + * The pair partner's pieces in the Final Assault / Hidden Core prep (m.private bossMate units: UnitInfo in boss-field + * coordinates, facing as the battle will place them): read-only 'm:' views — not prep pieces, so they are never + * picked, dragged or swept with the own ones; a view is rebuilt when its body, tile or facing changes. + */ + function syncMates(units) { + const keep = new Set(); + for (const u of units) { + const info = u && Number.isInteger(u.uid) ? renderInfo({ ...u, id: `m:${u.uid}` }) : null; + if (!info) continue; + keep.add(info.id); + const sig = `${info.defId}|${info.golden ? 1 : 0}|${info.spine || ''}|${info.x},${info.y}|${info.dir || ''}|${(info.items || []).join(',')}`; + let v = views.get(info.id); + if (v && v._sig !== sig) { dropView(info.id); v = null; } + if (v) continue; + v = new UnitView(ctx, { ...info, maxHp: 1 }, { prep: true }); + v._sig = sig; + v.setWorld(info.x, info.y, heightAt(info.y, info.x)); + if (info.dir && typeof v.setDir === 'function') v.setDir(info.dir); + v._showFacing = true; + if (v.setItems && Array.isArray(info.items) && info.items.length) { + v.setItems(info.items.map((it) => { const r = data.item(it); return assets.itemIcon ? assets.itemIcon(r ? { trapId: r.trapId, iconId: r.iconId } : it) : null; })); + } + views.set(info.id, v); + } + for (const k of [...views.keys()]) if (String(k).startsWith('m:') && !keep.has(k)) dropView(k); + } + /** * A summon stack of several copies (凯瑟琳's 2 devices, user playtest #6) dropped on a board tile stays in the hand * and one copy — a new piece of the same token — lands there: the state already shows the move, the stack goes home. diff --git a/public/js/ui/gameLogic.js b/public/js/ui/gameLogic.js index 86688c57..fd6043a8 100644 --- a/public/js/ui/gameLogic.js +++ b/public/js/ui/gameLogic.js @@ -26,7 +26,7 @@ export { clamp, tileKey } from './gameLogic/shared.js'; export { phaseMode, isCombatPhase, showDeadPill, isBossPhase } from './gameLogic/phases.js'; -export { prepCamera, prepCameraFor, foldCamera, deployFieldOf, fieldTile, boardTileOf, ownerBandId } from './gameLogic/camera.js'; +export { prepCamera, prepCameraFor, foldCamera, deployFieldOf, fieldTile, boardTileOf, ownerBandId, teamFrameIds } from './gameLogic/camera.js'; export { phaseBanner, prepCapsuleLabel, countdownState, phaseTotalSeconds } from './gameLogic/phases.js'; export { STATUS_META } from './gameLogic/format.js'; export { sortedPlayers } from './gameLogic/shared.js'; diff --git a/public/js/ui/gameLogic/camera.js b/public/js/ui/gameLogic/camera.js index 1d0d7f11..ce0a59ac 100644 --- a/public/js/ui/gameLogic/camera.js +++ b/public/js/ui/gameLogic/camera.js @@ -1,6 +1,6 @@ // ui/gameLogic/camera.js — prep camera, boss-field tile mapping, owner band. Re-exported from ../gameLogic.js. -import { GEO } from '../../../../shared/constants.js'; +import { GEO, PHASE } from '../../../../shared/constants.js'; import { BOSS_ROW_SHIFT, MAX_COL } from '../../render/prepfield.js'; import { int, isObj } from './shared.js'; @@ -26,6 +26,37 @@ export function prepCamera(pub, myId) { return { kind: 'bossPrep', opts: { side: idx % 2 === 1 ? 'R' : 'L' } }; } +const BOSS_PREP_PHASES = new Set([PHASE.ROUND_START, PHASE.SP_DRAFT, PHASE.PREP]); + +/** + * The players framed in green on the team panel (community report of 2026-10-06, item 51: 「同组两个玩家屏幕左边的头像也像原版 + * 那样用绿框框起来」 — the official co-op frame, ui/battle `bg_team_border`): in a boss round (最终攻势 / 隐秘核心) the viewer + * and its pair partner — from the round's start (回合开始 / 机变 / 休整期: the pairing `prepCamera` reads, the players still + * in by seat, two by two — server/match/finalAssault.js pairPlayers) through the fight (the boss field listing the + * viewer). Empty for a lone player, an eliminated player or a spectator seat (no pair of its own) and in every other + * round. PR #191 framed the partner alone, in gold (not merged). + * @param {any} pub m.public @param {string|null} myId + * @returns {Set} + */ +export function teamFrameIds(pub, myId) { + const none = new Set(); + if (!pub || !myId) return none; + const players = Array.isArray(pub.players) ? pub.players.filter(isObj) : []; + const me = players.find((p) => p.playerId === myId); + if (!me || me.alive === false) return none; + const r = pub.round; + const bossRound = Number.isInteger(r) && r > 0 && (r === pub.bossRound || r === pub.hiddenRound); + if (pub.phase === PHASE.FINAL_ASSAULT || pub.phase === PHASE.HIDDEN_CORE) { + const f = (Array.isArray(pub.fields) ? pub.fields : []).find((x) => isObj(x) && (x.kind === 'boss' || x.kind === 'hidden') && Array.isArray(x.players) && x.players.includes(myId)); + return f && f.players.length > 1 ? new Set(f.players) : none; + } + if (!bossRound || !BOSS_PREP_PHASES.has(pub.phase)) return none; + const alive = players.filter((p) => p.alive !== false).sort((a, b) => int(a.seat, 99) - int(b.seat, 99)).map((p) => p.playerId); + const idx = alive.indexOf(myId); + const pair = alive.slice(idx - (idx % 2), idx - (idx % 2) + 2); + return pair.length > 1 ? new Set(pair) : none; +} + /** * The own prep board's camera request with the shop bar's state (public issue #5 "准备阶段,收起商店界面时,界面并不会进行 * 缩放"): `prepCamera` plus `shop: !folded` — a folded shop (收起) takes the official shop-collapsed camera diff --git a/public/js/ui/teamPanel.js b/public/js/ui/teamPanel.js index 8ef00f23..54c0a396 100644 --- a/public/js/ui/teamPanel.js +++ b/public/js/ui/teamPanel.js @@ -1,6 +1,7 @@ // Left team panel (research 06 §11.1, research 09 §3.1): one row per seat — avatar (band icon once picked), name, LP // tower, status glyph (… acting / ✓ ready / ⌛ deciding / ⚔ combat / door left / ✕ dead), AI badge, "you" marker, the -// field being watched (eye badge), and emote bubbles. +// field being watched (eye badge), and emote bubbles. In a boss round the viewer's pair is framed in green from the +// round's start (gameLogic teamFrameIds; community report of 2026-10-06, item 51). // Observing (client-side combat, `observe` prop — the official flow): tapping a teammate's avatar expands a mint // "前往查看" button under the row (when that teammate can be observed now; otherwise the reason is toasted through // onWatch); while observing, the own row shows a "返回战场" button. Without `observe` (server-run combat) a click @@ -19,7 +20,7 @@ import { PHASE } from '../../../shared/constants.js'; import { html, Icon, Tooltip } from './components.js'; import { PlayerAvatar, LpTower, GIcon, LocalSprite } from './gameComponents.js'; import { EmoteBubble } from './emotes.js'; -import { STATUS_META, sortedPlayers } from './gameLogic.js'; +import { STATUS_META, sortedPlayers, teamFrameIds } from './gameLogic.js'; import { MissTag, uniteRemaining } from './hud.js'; import { localAsset } from '../data.js'; import { t } from '../../../shared/i18n.js'; @@ -79,6 +80,10 @@ export function TeamPanel({ pub, myId, watching, bubbles, onWatch, compact = fal useEffect(() => { setOpenPid(null); }, [phaseKey, watching, observe?.observing]); const players = sortedPlayers(pub); if (!players.length) return null; + // a boss round: the viewer's pair framed in green from the round's start (item 51 — the official bg_team_border, + // tinted like the official green; a plain green ring without the local art) + const team = teamFrameIds(pub, myId); + const frameArt = team.size ? localAsset('ui/battle', 'bg_team_border') : null; const click = (p, self) => { if (!observe) { onWatch(p); return; } if (self) { if (observe.observing) observe.onBack(); setOpenPid(null); return; } @@ -98,9 +103,11 @@ export function TeamPanel({ pub, myId, watching, bubbles, onWatch, compact = fal const back = !!observe && self && observe.observing; const title = observe ? (self ? (observe.observing ? t('返回战场') : t('你自己')) : t('查看 {name} 的战场', { name: p.name })) : (self ? t('查看自己的阵地') : t('查看 {name} 的阵地', { name: p.name })); const lp = rowLp(p, pub, self ? selfLive : null, { uniteLocal, cap }); - return html`
-