From 82c64086d5028735aed717793777000c08d7e397 Mon Sep 17 00:00:00 2001 From: sganggs Date: Fri, 2 Oct 2026 12:11:48 +0800 Subject: [PATCH] =?UTF-8?q?Bond=20strip=20follows=20the=20watched=20teamma?= =?UTF-8?q?te=20(v2.5.2):=20prep=20scouting,=20after=20the=20own=20battle,?= =?UTF-8?q?=20=E8=81=94=E9=98=B2,=20Final=20Assault,=20eliminated=20observ?= =?UTF-8?q?ers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the strip, its counts / layers / tiers and the bond popups show the player whose field or half is on screen, with an amber "👁 name" tag; live in-battle layers (runner bondLayers, ≤ 999) and the finished battle's gains until SETTLE - shared fields follow the ‹ › half's player; 全景 shows your own when you fight there, else the picked teammate [ASSUMED] - a card's bond chips and popups belong to the unit's owner; a teammate's popup lists their operators on the field - m.public players[].bonds for living players only; a reload while watching after the own battle returns to it - DESIGN §20.15, META, PLAYING; tests (unit, UI logic, 4 browser scenarios) --- docs/DESIGN.md | 82 +++- docs/META.md | 29 +- docs/PLAYING.md | 3 +- public/css/screens/game-panels.css | 3 + public/css/screens/game.css | 18 +- public/js/battle/observe.js | 25 ++ public/js/battle/runner.js | 76 +++- public/js/screens/game.js | 122 +++++- public/js/ui/bondStrip.js | 36 +- public/js/ui/combatHud.js | 11 +- public/js/ui/watchBonds.js | 254 ++++++++++++ server/match/Match.js | 13 +- server/match/PlayerState.js | 16 +- server/match/bondsMeta.js | 25 ++ test/docs-consistency.test.js | 2 +- test/e2e/client.mjs | 8 +- test/e2e/fastServer.mjs | 78 +++- test/match/observe.test.js | 38 +- test/match/watch-bonds.test.js | 243 +++++++++++ test/ui/watch-bonds.e2e.test.js | 625 +++++++++++++++++++++++++++++ test/ui/watch-bonds.test.js | 437 ++++++++++++++++++++ 21 files changed, 2087 insertions(+), 57 deletions(-) create mode 100644 public/js/ui/watchBonds.js create mode 100644 test/match/watch-bonds.test.js create mode 100644 test/ui/watch-bonds.e2e.test.js create mode 100644 test/ui/watch-bonds.test.js diff --git a/docs/DESIGN.md b/docs/DESIGN.md index dafe44cd..2806fccd 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -820,7 +820,7 @@ Nineteen reports after playing v2.4 (#1–#3 from a friend of the user). Where e Eight workstreams were merged into `playtest6` (WH → WG → WD → WC → WB → WA → WF → WE); the reviews' last rounds and the cross-workstream notes were applied on top: a stocked skill summon deploys as soon as its owner is back (§20.1), the AUTO DP skills 焰尾 S1 / 凛御银灰 S1 fire on full SP (§20.2), 锏 S3's slashes give 无敌 and 晕眩 / 冻结免疫 (§20.3), the `AUTO_OP_COOLDOWN` comment and the SKILL_RANGE count (13 skills / 26 records) were corrected. The user then answered the open questions (2026-10-01); the answers are **settled decisions**, recorded in their sections and in §20.9, each with the one-line change that flips it: the placed skill summons deploy once at the battle start and 凯瑟琳's device stays a hand card (§20.1), every blocker hits what it blocks (§20.3), 萨卡兹枯朽战车 follows the re-research — ranged enemies reach an operator by its 0.25 collider (§20.4) — and multi-round bounties last two battles (§20.6). Normative lines rewritten with it: §2 (`fear.js`), §3 (the enemies' reach), §5.2 (ASPD floor 20), §5.3 (`camou`, `fear`, `noHeal`, `isolated`), §5.4 (push / pull / ally-selection helpers), §5.5 (initial deployment, blocking, operator attacks, healers, enemy AI, end), §5.6 (skill triggers, the operation cooldown), §6.1 / §6.2 (the Final Assault verdict, summon stacks, summons in the hand, the promotion reward), §7 (bounty draft, 教鞭), §8.2 / §8.3 (`uniteLeft`, `descRaw`, `counterText`, the `elem` fill, the stealth bit), §9 (gauge row, model scale), §10 (机变 card, the card tap), §14 (`left`, the pool floor, the verdict, `uniteLeft`); superseded or revised: §17.5 (the 联防 count), §18.3 (the gauge look), §18.6 #11 (skill summons), §19.2 (the melee-only blocked-first rule; 灰毫 S2), §19.5 (the gauge look). -**Follow-up (`playtest6b`, 2026-10-02).** Four more workstreams were merged into `playtest6b` in this order: the elite-to-board rule (§20.11), the 假想敌:胄 kit audit (§20.13), the Final Assault leader HP report (§20.10) and the official limits read from the client binary (§20.12). Reports: co-op 终极 / 绝境 leaders died within seconds although the official leader never dies that fast with such layers (§20.10); "官方就是合成精锐时,如果消耗了场上的干员,精锐会出现在场上那个位置" (§20.11, user-confirmed). The boss-HP and the limits workstreams both capped bond layers at 999 — one implementation remains (`shared/constants.js BOND_LAYER_CAP` / `layerGainRoom`, used by every writer), and the boss-HP workstream's reading of 限伤 as an engine fixed-point overflow "not modelled" is superseded by the binary evidence (`MAX_BATTLE_DAMAGE` 300000, the hit is cancelled). Normative lines rewritten with it: §2 (`promote.js`), §5.1 (`layerGains` capped), §5.2 (直接乘算 in `Σpct`), §5.3 (same-named debuffs), §5.4 (`addLayers`, `loseHp`), §5.5 (限伤 between the multipliers and the shields), §6.2 (where a merge's elite goes), §6.3 (the layer cap), §7 (leader parts, drones), §8.2 (`hitCap`), §9 (the promotion cue), §14 (the layer bound of client results); §20.9 records the decisions. Two QA verifiers then checked the integrated build; their residual issues are handled in §20.14. +**Follow-up (`playtest6b`, 2026-10-02).** Four more workstreams were merged into `playtest6b` in this order: the elite-to-board rule (§20.11), the 假想敌:胄 kit audit (§20.13), the Final Assault leader HP report (§20.10) and the official limits read from the client binary (§20.12). Reports: co-op 终极 / 绝境 leaders died within seconds although the official leader never dies that fast with such layers (§20.10); "官方就是合成精锐时,如果消耗了场上的干员,精锐会出现在场上那个位置" (§20.11, user-confirmed). The boss-HP and the limits workstreams both capped bond layers at 999 — one implementation remains (`shared/constants.js BOND_LAYER_CAP` / `layerGainRoom`, used by every writer), and the boss-HP workstream's reading of 限伤 as an engine fixed-point overflow "not modelled" is superseded by the binary evidence (`MAX_BATTLE_DAMAGE` 300000, the hit is cancelled). Normative lines rewritten with it: §2 (`promote.js`), §5.1 (`layerGains` capped), §5.2 (直接乘算 in `Σpct`), §5.3 (same-named debuffs), §5.4 (`addLayers`, `loseHp`), §5.5 (限伤 between the multipliers and the shields), §6.2 (where a merge's elite goes), §6.3 (the layer cap), §7 (leader parts, drones), §8.2 (`hitCap`), §9 (the promotion cue), §14 (the layer bound of client results); §20.9 records the decisions. Two QA verifiers then checked the integrated build; their residual issues are handled in §20.14. A later report after v2.5.1 — the bond strip should follow the watched teammate in every watch situation — → §20.15. ### 20.1 Summons placed by hand (#1, #2) — `tools/build-data.mjs`, `match/gamedata.js placeableTokens`, `PlayerState`, `match/bot.js`, `sim/Battle.js`, `content/tokens.js`, kits tier2/3/4, `ui/detailPanel.js`, `shared/constants.js SKILL_SUMMON_START_DEPLOY` @@ -1015,3 +1015,83 @@ Two QA verifiers ran the integrated `playtest6b` (real 4-AI and 2-human co-op ma - **Not changed — per-source talent debuffs [ASSUMED].** Some talents key their debuff with the source unit (`etlchi:dot:`, `ines:atkSteal:`, `ines:sentry:`, and similar slows / DoTs in other kits), so two copies of one operator (a pair field where both players own it) each apply theirs. PRTS 作战机制 says only that same-named buffs show one instance under the *default* overlap strategy; whether these talents use it across copies is not in the data the project has, and the effect on leader kill times is small (an arts DoT, an ATK steal, a slow). The rule stays applied to the debuffs that move leader kills (奥术, 灵知 坚冰, the RES cuts — §20.10). - **Not changed — temp items behind a full hand.** With the hand full, items overflowing into the 临时整备区 (row 8) stand behind the bench row's operator models and are hard to see; the row's red frame, the 「临时整备区 N 件道具待处理」 notice and the refused Ready still show them. A layout change of the temp row is left for a later pass. - Tests: `test/match/clientCombat.test.js` (the held result; a 'cleared' result not covering the pool is still handed over), `test/render/playtest6-promote.test.js`, opt-in `RENDER_E2E=1 node --test test/render/browser.test.js` (a merge between two preps is cued), `test/ui/playtest6-elite.test.js` (the detail hint), opt-in `SP_E2E=1 node --test test/ui/playtest6-elite.e2e.test.js` (the hint in the real detail card). + +### 20.15 The bond strip follows the watched player (user report after playtest #6, 2026-10-02) — `ui/watchBonds.js`, `ui/bondStrip.js`, `ui/combatHud.js`, `screens/game.js` (`watchWho`, `bondOpen`), `battle/runner.js bondLayers / ownerOps`, `battle/observe.js resumedWatch`, `match/bondsMeta.js bondsWithGains`, `PlayerState.pendingLayerGains / bondsView`, `Match.publicView / _finishCombat / settle` + +User: 「观看队友时(不仅是休整时候,还有自己战斗结束时,联防时,最终boss战)盟约栏应该变成当前队友的盟约以及他的层数」. + +**Before this change:** +- Only 前往查看 of a teammate's normal field switched the strip, and it showed the start-of-round layers. +- An eliminated player's auto-observed field, the 联防 field and the Final Assault pair field kept the viewer's own bonds. +- A teammate's popup listed the viewer's pieces as its members. + +**Whose bonds** (`watchBonds.screenFieldId` / `bondOwnerId` / `screenStrip`): the player whose field or half the camera shows. + +The field on screen is, in order of preference: +- the watched field (`g.watch`); +- else the battle the view shows. Under client-side combat this is the runner's battle on screen, which is also an eliminated player's auto-observed field; under server-run combat it is the field the view entered; +- else home. + +In SETTLE it is the field the last battle left on screen. In prep without watching, it is the own board. + +- **The own board / own battle** → your bonds (`m.private bonds`). +- **A normal field `'n:'`** → its owner's bonds (`m.public players[].bonds`). This covers: + - a teammate's board in prep (前往查看). Research 09 §3.1: "their board, read-only, including their pen and bonds". Official clients receive every board's bonds (`ChangePositionDn.boardStatus.updatedBonds`); + - a teammate's battle watched after the own one; + - the field an eliminated player auto-observes; + - a field picked in the legacy view switcher. +- **A shared field** (联防 `'u'`, boss / hidden `'b1'` / `'b2'`) **[ASSUMED — no source shows the strip there]**: + 1. If the ‹ › pill points at a half that a player holds → that player. The pill's "你自己" means yours; "👁 name" means theirs. + 2. Else (全景, an empty half "无人在家", or no pill), if you fight on that field → yours. + 3. Else (you are not on the field: a 联防 leaker, an eliminated spectator) → never your own. The strip shows the teammate you picked with 前往查看 if they are on that field, else the field's first listed player (helper 1 / the pair's first seat). + + Basis: official observing targets a player (`ObserveUp { obIndex }`, `DeadAutoObDn { obIndex }`, `m_leftBattleObIdx / m_rightBattleObIdx`), and the official non-helper pill reads "‹ 👁 helper# ›" (research 09 §3.1). +- **The picked teammate:** the game screen remembers `watchWho = { fieldId, playerId }` when a team row is tapped (`requestWatch(fid, playerId)`). The `g.watch` request still names only the field. `watchWho` counts only while that field is the watched one (`watchedPlayer`). It is cleared by 返回战场, the own row and a phase change, and restored if the server refuses the watch. +- **Camera:** picking a teammate on a two-half shared field moves the camera to their half (`playerLayer`), once per pick and per field entered. The ‹ › pill is also offered on a shared field watched with 前往查看, so the pill and the strip tag always name the same player. + - On such a field the ‹ › pill replaces the observing pill and carries 返回战场 (absent for an eliminated player). + - On a normal field the observing pill "👁 name" takes the strip owner's name. + - The default camera on entering a field is still 全景. +- **返回战场 without an own field** (client-side combat: a 联防 leaker, an eliminated player): no `g.watch` is sent, because it would be refused with `BAD_TARGET 'no such field'` (an error toast). The screen keeps the field it shows, and the strip falls back to the rules above. +- **A reload / reconnect while watching** a teammate's battle after the own one (client-side combat): the server already resends the watched field (`Match._resendBattle`, `b.start watch: true`). But a fresh screen starts with no watched field, so before this the HUD showed 「作战结束,等待队友完成作战」 with no observing pill and no 返回战场, and the own row did nothing. + - The screen now adopts that field as the watched one, once per battle (`observe.resumedWatch`, decided at the first sight of its battleId). + - It does so only for a teammate's normal battle in COMBAT, shown to a living player who watches nothing. + - A battle first seen while a watch is already set is only marked as seen. So a 返回战场 tapped while the own `b.start` is still on its way never brings the teammate back. + - A loading battle waits until it runs (its state carries no `watch` yet). + - 联防 leakers and eliminated spectators keep their own rules. + +**Labelling:** +- A teammate's strip leads with an amber "👁 name" pill (the observing pill's spelling) and gets amber disc rings, `aria-label` "name 的盟约" and `data-owner`; slots carry `data-bond`. +- The pill is horizontal at every width, with a pixel floor: 10 px on desktop and tablet, 9 px below 768 CSS px (1rem is clamped at 40 px there). +- An empty teammate strip reads "name 尚未激活盟约". + +**Detail card chips and the popup (whose bond a popup shows):** +- A unit card's bond chips use the bonds of `watchBonds.detailBondOwner`: + - a unit on the field → its owner, when that owner is a known player (yours or a teammate's: a shared field shows both halves' units, whoever the strip follows); + - your own piece (board / bench) → yours; + - a bond-member card opened from a popup → that popup's player; + - anything else (shop / reward / drawer cards, a unit without a known owner) → the strip's player. +- A bond popup carries the player it was opened for: `bondOpen = { id, ownerId, from: 'strip' | 'detail' }` (`toggleBond`). From the strip, that is the strip's player; from a chip, it is the same player the chips show. The same bond of the same player closes it. +- `popupView` builds the popup from that player, never simply from the strip: + - the entry with the live layers (as the strip and the chips show it); + - "👁 name 的盟约" for a teammate; + - the member list: your pieces, or that teammate's operators on the field on screen (`ownerBoard`: the prep scouting board's or a server-run battle field's units). Under client-side combat it also uses the battle on screen's operators (`battleRunner.ownerOps(ownerId, fieldId)`), because the field meta the runner publishes is taken before they deploy. + - Their hand is never sent, so a teammate's member counts as owned only when it is on the field. +- The strip marks a disc as open only for a popup of its own player. +- A popup opened from the strip closes when the strip changes hands: another teammate, a ‹ › half, or back to your own. A popup opened from a chip stays open, labelled with its own player. + +**Layers:** +- **In battle:** the battle runner reports `state().bondLayers = { [playerId]: { [bondId]: n } }`. These are absolute live counts of every bond that grew in a battle it simulates (the own battle, a teammate's display replica), capped at `BOND_LAYER_CAP` (999). They are published when one grows and kept through the round. +- The client lays them over the view's counts with `withLiveLayers`: the higher value wins (a count only grows) and never shows above 999. They apply in the battle phases and SETTLE only. +- 联防 and boss battles disable gains, so they report none. +- **After COMBAT** (`_finishCombat`): until SETTLE, both `m.private bonds` and `m.public players[].bonds` add the finished normal battle's IN_BATTLE gains (`PlayerState.pendingLayerGains` = the result's `layerGains`; `bondsMeta.bondsWithGains`: floored, at most up to the cap, like `settle()`). The strip of a player, and of a teammate watching him in the 联防, therefore keeps the layers his battle reached. +- This is views only: `ps.bonds` / `ps.layers` are untouched. SETTLE clears the pending gains as it adds them (once), and the next round start clears them too. + +**Data:** +- `m.public players[].bonds = ps.alive ? bondList(gd, ps.bondsView()) : []`: every bond with members, layers or an active tier. It is the same list and order as the player's own `m.private bonds`, minus `thresholds` / `countsHand` (the client reads those from bonds.json). +- An eliminated player's list is `[]`, for these reasons: + - nobody can watch them: `g.watch` refuses them and they have no field; + - the result screen reads `m.result`'s own bonds; + - their remaining layers would otherwise cost every `m.public` for the rest of the match. +- Measured in a 4-AI NORMAL harness match (seed 11, round 13, 3 players eliminated): `players[].bonds` is 771 B, against 2424 B without this rule. The review measured 705 B on v2.5.1; the difference is the living player's layers-only entries, which the strip needs. +- There is no new message and no new field. `m.public` stays throttled and deduplicated, so the extra traffic is the living players' layers-only entries plus one change at the end of COMBAT. +- Nothing is exposed that scouting (research 09 §3.1) or watching the battle does not already show. diff --git a/docs/META.md b/docs/META.md index 73125098..62ae4972 100644 --- a/docs/META.md +++ b/docs/META.md @@ -47,7 +47,8 @@ LOBBY → INFO_CHECK (co-op 25 s; solo and single-human matches untimed; all hum (band_cannot keeps them), boss round: Σ activated layers recorded for the hidden-core check COMBAT one Battle per alive player (FieldRunner, 2× game speed; limit = 2 × maxPlayTime game s, §3) UNITE co-op, ≥ 1 leaker and ≥ 1 perfect player (§4) - SETTLE (3 s) LP, coins, layers, bounties, eliminations, onBattleResult + SETTLE (3 s) LP, coins, layers (the views showed them since the end of COMBAT, §5), bounties, eliminations, + onBattleResult boss round → FINAL_ASSAULT (instead of COMBAT/UNITE/SETTLE) hidden round → HIDDEN_CORE → RESULT (m.result to every human, then onEnd once) ``` @@ -149,7 +150,18 @@ fields' `startAt` / `lastProgressAt` and the boss start by the paused time and r Client-side combat (the default, DESIGN §14 Spectating; `Match._watchClient`): `g.watch` answers with the field's `b.start` (a display replica fast-forwarded to the field clock); an alive player may not watch another normal field while its own normal battle runs (`WRONG_PHASE 'own battle running'`) nor the other pair's boss field (`BAD_TARGET 'other -group hidden'`); eliminated players watch anything. The rest of this section is the legacy server-run mode +group hidden'`); eliminated players watch anything. Whatever is watched, the viewer's bond strip shows the bonds of the +player on screen from that player's `m.public players[].bonds` (§5) plus the replica's live layers (DESIGN §20.15) — no +extra message. On a shared field (联防 / a pair's boss field) the client remembers the teammate picked with 前往查看 +(the `g.watch` request still names the field) and frames that player's half; a viewer who does not fight on the field +never gets its own strip there. A detail card's bond chips — and the popup a chip opens — follow the unit's owner +(the popup carries the player it was opened for); a teammate's popup lists as members their operators in the battle on +screen (the runner's `ownerOps`, its field meta predating their deploy). A client-side 返回战场 without an own field to return to (a 联防 leaker, an eliminated +player) sends no `g.watch` (it would be refused with `BAD_TARGET 'no such field'`). A reload / reconnect while watching a +teammate's battle after the own one gets the watched field again (`_resendBattle`, `b.start watch: true`); the fresh +screen adopts it as the watched field once per battle (`battle/observe.js resumedWatch`: a living player's teammate +normal battle in COMBAT, first seen while watching nothing), so the observing pill, 返回战场 and the own row work again. +The rest of this section is the legacy server-run mode (`SP_COMBAT=server`): `g.watch { fieldId }`: any live field during COMBAT / 联防; while no battle field is up (PREP, drafts, SETTLE) `'n:'` returns a one-shot board view — during a battle phase an `'n:'` id must name a live field @@ -534,7 +546,11 @@ leaker's own capsule / row (`ui/hud.js uniteRemaining`) and the leakers' team ro drawn set; `disabledBonds` = drawn ∪ the mode's static list), `hiddenBossId`, `bossRound`, `hiddenRound`, `spRound`, `combatMode` (`'client'` | `'server'`), `fields[].progress { killed, total, done }` (teammates' progress UI), `paused` (solo pause, §1.3a), -`players[].autoplay`, `players[].uniteLeft` (UNITE, leakers only: their enemies still standing, uncapped — §4), and per phase: `draft { order, turn, picks, skipsLeft, turnDeadline, turnSeconds, untimed }` (BAND_DRAFT), +`players[].autoplay`, `players[].uniteLeft` (UNITE, leakers only: their enemies still standing, uncapped — §4), +`players[].bonds` = `ps.alive ? bondList(gd, ps.bondsView()) : []` — every bond with members, layers or an active tier, the same list +and order as the player's own `m.private bonds` minus `thresholds` / `countsHand` (the client reads those from +bonds.json): a teammate watching the player shows it in the bond strip (DESIGN §20.15); `[]` once the player is +eliminated (nobody can watch them; the result screen reads `m.result`'s own bonds), and per phase: `draft { order, turn, picks, skipsLeft, turnDeadline, turnSeconds, untimed }` (BAND_DRAFT), `sp { family, name, desc, eventId, cards:[{ idx, kind:'bounty'|'item'|'tactic', id, name, desc, tier, descRaw?, coin?, payout?, rounds?, enemyKey?, count?, price?, team?, tacticKind? }], order, turn, picks:{pid: idx}, taken:{idx: pid}, untimed }` (SP_DRAFT), `teamLp` / `bossHp {hp,max}` (Final Assault on), `overtimeAt` (最终攻势 / 隐秘核心: ms epoch when the @@ -545,6 +561,13 @@ PREP ready/acting · COMBAT/boss combat/done · UNITE helping/done · others don `m.private` = DESIGN §8.3 exactly (sent per player whenever it changed). `nextEnemies` = the current round's wave (+ the player's bounty enemies, tag `bounty`; boss rounds: the player's boss field, tag `boss`, + its bounties). +Bond layers in the views (DESIGN §20.15): from the end of COMBAT (`_finishCombat`, every normal result in) until SETTLE, +`m.private bonds` and `m.public players[].bonds` add the finished battle's IN_BATTLE gains (`PlayerState.pendingLayerGains` += the result's `layerGains`; `bondsMeta.bondsWithGains`: floored, at most up to `BOND_LAYER_CAP`, like the settlement), so +the strip keeps the layers the battle reached through the COMBAT_END pause and the 联防. Views only: `ps.bonds` / +`ps.layers` (rules, the 联防 spec, `activatedLayers`) are untouched; SETTLE clears the pending gains as it adds them to +`ps.layers` (once); the next round start clears them too. + `m.field` = `{ fieldId, kind, rect, stageId, units, live }`; during prep `g.watch 'n:'` returns a one-shot board view with `prep: true` (scouting a teammate). diff --git a/docs/PLAYING.md b/docs/PLAYING.md index fd9d8870..8774fff9 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -95,7 +95,7 @@ - **核心盟约**(8 个势力:炎、萨尔贡、维多利亚、谢拉格、拉特兰、阿戈尔、叙拉古、卡西米尔):3 名激活,6 名获得强力的额外效果。 - **附加盟约**(精准、迅捷、灵巧、奥术、坚守、助力、远见、奇迹、投资人、突袭、不屈、调和、协防干员、独行、绝技):多为 2 / 3 名激活;「独行」需要场上**恰好 1 名**,「绝技」按场上的精锐数量计算,没有势力盟约的干员算「协防干员」。 - **炎佑**:场上 6 名以上「炎」干员时召唤炎佑(9 名时两只)。它追踪全场仇恨最高的敌人并悬停在其上方,技能「祛恶之焰」锁定 1 个目标**持续喷火**(最长 20 秒,每秒对目标周围的敌人造成法术伤害并附带灼燃,喷火时不普攻,目标死亡或被沉默时结束);场上暂时没有敌人时停在原地等待。它持有官方的「孤立」:医疗干员不会治疗它,友方的增益、光环和天赋也不会选中它(敌人照常可以攻击它)。 -- **层数**:盟约通过各种效果叠加层数,层数越高效果越强(例如「炎」攻击力随层数提升、「远见」每 10 层给资金)。**层数整局保留,但只有盟约激活时才生效**,多数情况下也只有激活的盟约能叠层。每个盟约最多 **999 层**(与官方相同,取自官方客户端的常量):到 999 后再获得的层数直接作废,按层数发放的奖励(如「远见」的资金)也随之停止。 +- **层数**:盟约通过各种效果叠加层数,层数越高效果越强(例如「炎」攻击力随层数提升、「远见」每 10 层给资金)。**层数整局保留,但只有盟约激活时才生效**,多数情况下也只有激活的盟约能叠层。每个盟约最多 **999 层**(与官方相同,取自官方客户端的常量):到 999 后再获得的层数直接作废,按层数发放的奖励(如「远见」的资金)也随之停止。作战中获得的层数在盟约栏里**实时**显示,作战结束后一直保留到结算(联防期间也不会退回去)。 - **加成怎么叠**:盟约、策略、装备、机变卡和特质给的「攻击力 / 防御力 / 生命值 +X%」与技能的「攻击力 +X%」**相加**后再乘到属性上(官方:直接乘算),例如精准 +130%、独行 +60%、一件 +40% 的装备合计 +230%,而不是 ×2.3×1.6×1.4。「伤害提升至 X%」「受到的伤害 +X%」(维多利亚、谢拉格、奥术、脆弱等)这类伤害倍率之间仍然相乘;但同名效果只算一个——同一战场两名玩家都开了「奥术」时,敌人身上只挂一层奥术易伤,取两人中较高的那一个(不再相乘);两名「灵知」的坚冰、两名「莱恩哈特」/「缄默德克萨斯」的法抗削减也一样只算最强的一个。「坚守」的反伤计入被打的那名成员(个人对领袖伤害统计里能看到)。 - 每局会随机**禁用**若干盟约(标准模拟较少),它们的干员不会出现在商店;开局的「确认本局信息」会列出。 - 两件同名装备会合成一件更强的装备;特定的装备组合可以为干员赋予额外的盟约效果。 @@ -114,6 +114,7 @@ - 左下角「交流」打开表情面板:6 套主题 × 6 个表情,左右滑动(或方向键)换主题。表情以气泡形式显示在你头像旁 3 秒,发送冷却 1 秒。 - 休整期、或自己的作战结束后(显示「作战结束,等待队友完成作战」),点左侧队友头像 →「前往查看」观看他的战场;自己的作战进行中不能观战。联防和最终攻势中可以用 ‹ › 切换左半 / 全景 / 右半。 - 被淘汰后可以自由观看任何战场。 +- **观看谁,盟约栏就显示谁的盟约**:查看队友的阵地或战场时(休整期、作战结束后、被淘汰后自动观战),左上的盟约栏换成**该队友的盟约、人数和层数**,前面有琥珀色的「👁 队友名」标签,点开的盟约详情也写着「队友名 的盟约」(成员一栏只标出他在场上的干员,整备区看不到);作战中层数实时增长(与他自己看到的一致)。联防和最终攻势里跟随 ‹ ›:镜头在队友的半场时显示他的盟约,在你自己的半场时显示你自己的;「全景」时,你在这个战场上作战就显示你自己的,你不在这个战场上(漏怪后观看联防、被淘汰后观战)则显示你点「前往查看」选中的那名队友,没选过就显示该战场的第一名玩家(1 号联防者 / 这一组的第一位),**不会显示你自己的**。在这样的战场上点某名队友的头像 →「前往查看」,镜头会直接移到他的半场,‹ › 上的「👁 队友名」与盟约栏的标签一致;‹ › 旁边有「返回战场」(被淘汰时没有)。盟约栏换人时,从盟约栏打开的盟约详情会自动关闭。点开场上某个干员的详情卡时,卡上的盟约(以及点它打开的盟约详情)属于**这个干员的主人**,与盟约栏当前显示谁无关——例如在「全景」点队友的干员,看到的是「队友名 的盟约」及他的人数和层数;在队友的半场点你自己的干员,看到的是你自己的。回到自己的阵地 / 战场后恢复成你自己的盟约。作战结束后观看队友时刷新页面 / 断线重连,仍会回到正在观看的队友战场,「👁 队友名」和「返回战场」都还在。已被淘汰的队友不再显示盟约(也无法被查看)。 ## 9. 结算称号 diff --git a/public/css/screens/game-panels.css b/public/css/screens/game-panels.css index 1ba448f6..394a7d5f 100644 --- a/public/css/screens/game-panels.css +++ b/public/css/screens/game-panels.css @@ -232,6 +232,9 @@ .bpop__disc.is-active .bglyph img { filter: brightness(0) opacity(.85); } .bpop__titles { display: flex; flex-direction: column; gap: .02rem; min-width: 0; } .bpop__name { font-size: .32rem; font-weight: 900; letter-spacing: .08em; } +.bpop__owner { display: inline-flex; align-items: center; gap: .06rem; font-size: .14rem; color: var(--amber); } +.bpop__owner b { color: #fff; font-weight: 700; max-width: 2.4rem; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +.bpop.is-other .bpop__disc { box-shadow: 0 0 0 1px rgba(246, 163, 41, .55); } .bpop__facts { display: flex; gap: .16rem; font-size: .15rem; color: var(--text-lo); } .bpop__facts b { color: #fff; font-size: .18rem; } .bpop__facts small { font-size: .12rem; } diff --git a/public/css/screens/game.css b/public/css/screens/game.css index b3f9131b..933f5ca0 100644 --- a/public/css/screens/game.css +++ b/public/css/screens/game.css @@ -229,8 +229,20 @@ .bstrip { display: flex; align-items: flex-start; gap: .1rem; flex-wrap: nowrap; } .bstrip.is-frozen .bslot { opacity: .5; filter: saturate(.4); } .bstrip--empty { display: inline-flex; align-items: center; gap: .12rem; padding: .08rem .16rem; font-size: .15rem; color: var(--text-dim); background: rgba(8, 11, 10, .6); border: 1px dashed var(--line-2); } -.bstrip__owner { align-self: center; writing-mode: vertical-rl; padding: .06rem .02rem; border: 1px solid rgba(246, 163, 41, .5); background: rgba(40, 26, 6, .7); } -.bstrip__owner .micro { color: var(--amber); letter-spacing: .1em; font-size: .12rem; } +/* a watched teammate's bonds (DESIGN §20.15): an amber "👁 name" pill in front of the discs, amber rings. Horizontal at + every width with a pixel floor (a vertical .12rem name was ~6-8 px per glyph on desktop / tablet, ~5 px on phones + where 1rem is clamped at 40 px); centred on the discs, clear of the team panel below */ +.bstrip__owner { align-self: center; flex: none; display: inline-flex; flex-direction: row; align-items: center; gap: max(.04rem, 3px); + padding: max(.03rem, 2px) max(.06rem, 4px); border: 1px solid rgba(246, 163, 41, .5); background: rgba(40, 26, 6, .7); } +.bstrip__owner .micro { color: var(--amber); font-size: max(.15rem, 10px); letter-spacing: .04em; max-width: max(1.3rem, 72px); + overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +.bstrip__eye { color: var(--amber); font-size: max(.17rem, 11px); flex: none; } +.bstrip--empty .bstrip__owner .micro { max-width: max(1.6rem, 80px); } +.bstrip--empty.is-other { border-color: rgba(246, 163, 41, .5); } +@media (max-width: 767px) { + .bstrip__owner .micro { font-size: max(.12rem, 9px); } + .bstrip__eye { font-size: max(.15rem, 10px); } +} .bstrip.is-other .bond__core { box-shadow: 0 0 0 1px rgba(246, 163, 41, .45); } .bstrip__more { align-self: center; font-size: .16rem; color: var(--text-lo); padding: .04rem .08rem; border: 1px solid var(--line-2); background: rgba(8, 11, 10, .7); } .bslot { position: relative; display: flex; flex-direction: column; align-items: center; width: .74rem; } @@ -454,6 +466,8 @@ .chud__layers .vswitch__label .icon { width: .22rem; height: .22rem; color: var(--amber); } .chud__back { flex: none; } .chud__layers { border-color: var(--mint-700); } +/* a 联防 / 最终攻势 field watched with 前往查看: the ‹ › pill carries 返回战场 (DESIGN §20.15) */ +.chud__layers.is-observing { gap: .1rem; padding-right: .08rem; border-color: rgba(246, 163, 41, .55); } @keyframes spin { to { transform: rotate(360deg); } } /* ---- settings modal ------------------------------------------------------------------------------------------------ */ diff --git a/public/js/battle/observe.js b/public/js/battle/observe.js index 9a2f3ac2..fbf88dd2 100644 --- a/public/js/battle/observe.js +++ b/public/js/battle/observe.js @@ -55,6 +55,31 @@ export function observeTarget(p, pub, myId, { observing = false, ownDone = false return { fieldId: target.fieldId }; } +/** + * A screen reloaded (or a socket reconnected) while it watched a teammate's battle after the own one: the server + * resends the watched field (Match._resendBattle, b.start `watch: true`), but a fresh screen starts at home — so the + * observing pill, 返回战场 and the own row would be missing. Decided once per battle, at the first sight of its + * battleId (`seen` = the battleId already decided): adopt `fieldId` as the watched field when it is a teammate's normal + * battle in 各自行动 shown to a living player who watches nothing. Never for a battle first seen while watching (a + * 前往查看 under way — and its 返回战场 must not bring it back while the own b.start is on its way), the own field, a + * 联防 leaker's field or an eliminated player's auto-observed one (those keep their own rules). A loading battle (its + * state carries no `watch` yet) is decided once it runs, unless the screen already watches (then it is marked seen). + * @param {any} b battleRunner.state() @param {{ pub?: any, myId?: string, alive?: boolean, watching?: string|null, + * seen?: string|null }} [o] + * @returns {{ seen: string|null, fieldId: string|null }} seen: the new value to remember + */ +export function resumedWatch(b, { pub = null, myId = '', alive = true, watching = null, seen = null } = {}) { + const keep = { seen, fieldId: null }; + if (!isObj(pub) || !isObj(b) || typeof b.battleId !== 'string' || !b.battleId || b.battleId === seen) return keep; + if (b.loading && !watching) return keep; + const done = { seen: b.battleId, fieldId: null }; + if (b.loading || watching || !alive || !isClientCombat(pub) || pub.phase !== PHASE.COMBAT) return done; + if (!b.watch || b.kind !== 'normal' || typeof b.fieldId !== 'string' || !b.fieldId) return done; + const f = fields(pub).find((x) => x.fieldId === b.fieldId); + if (!f || f.kind !== 'normal' || !Array.isArray(f.players) || f.players.includes(myId)) return done; + return { seen: b.battleId, fieldId: b.fieldId }; +} + /** Teammates' progress for the waiting pill: [{ playerId, name, killed, total, done, isBot }]. */ export function teammateProgress(pub, myId) { const out = []; diff --git a/public/js/battle/runner.js b/public/js/battle/runner.js index c92ca1b7..58f2a0fc 100644 --- a/public/js/battle/runner.js +++ b/public/js/battle/runner.js @@ -26,6 +26,10 @@ // own field's min(lpCapPerRound, n) as the LP about to be lost (ui/hud.js liveLp). A 联防 field keeps each leaker's // enemies still standing (/sim/spec.js uniteLeft; user playtest #6 item 7) as state().uniteLeft { [playerId]: n }, // published whenever it changes (it falls as the helpers kill them), and its authority reports it as b.progress `left`. +// Live bond layers (DESIGN §20.15): every battle simulated here keeps the bonds whose layers grew in it (IN_BATTLE gains, +// Battle.addLayers — normal battles only) as absolute counts per player, published as state().bondLayers +// { [playerId]: { [bondId]: n } } (every battle of the round: the own one, the teammates' replicas) whenever one grows; +// the bond strip of the player on screen — the own one, or a watched teammate's — shows them live (ui/watchBonds.js). // // The sim (≈ 0.2–1 ms per tick) runs on the main thread: one battle at a time is stepped for display (plus an // authoritative one if it is not the one on screen). stats() exposes the measured cost. @@ -33,7 +37,7 @@ // import { battleRunner } from './battle/runner.js' (browser singleton wired to net.js + store.js; null in Node) // battleRunner.on('snap' | 'ev' | 'field' | 'state', fn) → off // battleRunner.state() → { battleId, fieldId, kind, authoritative, watch, done, own, members, loading, paused, leaks, -// uniteLeft } | null +// uniteLeft, bondLayers } | null // battleRunner.stats() → { ticks, stepMs, avgTickMs, maxFrameMs, catchups, errors, battles } // battleRunner.unitStats(unitId, fieldId?) → the live stats of a unit of the battle on screen (shared/protocol.js // unitStatsEntry: current HP, effective max HP / ATK / DEF / RES / interval / block / move @@ -41,6 +45,8 @@ // playtest #4 item 7). Read-only: it takes the stats the sim computed last (`unit._s`) 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 }] that player's operators in the battle on +// screen (a teammate's bond popup: the members in play, DESIGN §20.15) | [] // // createBattleRunner(deps) builds an instance with injectable net / store / clock / frame scheduler / sim loader // (test/match/runner.test.js drives it under Node). @@ -147,7 +153,7 @@ export function createBattleRunner(deps) { let lastPool = null; /** solo pause: the runner clock's instant when m.public.paused turned true (null while running) */ let pausedAt = null; - /** a normal field's leak count changed since the last publishState() */ + /** a normal field's leak count (or a battle's bond layers) changed since the last publishState() */ let leaksDirty = false; const stats = { ticks: 0, stepMs: 0, maxFrameMs: 0, catchups: 0, errors: 0, battles: 0, frames: 0 }; @@ -170,6 +176,19 @@ export function createBattleRunner(deps) { return out; } + /** + * Live bond layers of every battle simulated here (DESIGN §20.15): { [playerId]: { [bondId]: n } } — only the bonds + * whose layers grew in this round's battle, as absolute counts (the battle's live copy, ≤ BOND_LAYER_CAP). + */ + function layerMap() { + const out = {}; + for (const e of entries.values()) { + if (!e.live) continue; + for (const [pid, m] of Object.entries(e.live)) out[pid] = { ...(out[pid] || {}), ...m }; + } + return out; + } + /** * The 联防 field on screen (never an older round's kept entry, nor while a new battle is being prepared): each * leaker's enemies still standing { [playerId]: n } (absent = none left), else null. @@ -181,11 +200,11 @@ export function createBattleRunner(deps) { function state() { const e = cur; - if (!e) return loading ? { loading: true, battleId: loading.battleId, fieldId: loading.fieldId, kind: loading.kind, leaks: leakMap(), uniteLeft: uniteLeftMap() } : null; + if (!e) return loading ? { loading: true, battleId: loading.battleId, fieldId: loading.fieldId, kind: loading.kind, leaks: leakMap(), uniteLeft: uniteLeftMap(), bondLayers: layerMap() } : null; return { battleId: e.battleId, fieldId: e.fieldId, kind: e.kind, authoritative: e.authoritative, watch: e.watch, done: e.done, own: e.own, members: e.members.slice(), loading: !!loading, speed: e.speed, paused: pausedAt != null, - leaks: leakMap(), uniteLeft: uniteLeftMap(), + leaks: leakMap(), uniteLeft: uniteLeftMap(), bondLayers: layerMap(), }; } @@ -203,6 +222,7 @@ export function createBattleRunner(deps) { * changed them. */ function noteLeaks(e) { + noteLayers(e); if (e.kind === 'unite') { noteUniteLeft(e); return; } if (e.kind !== 'normal') return; const b = e.battle; @@ -224,7 +244,37 @@ export function createBattleRunner(deps) { if (JSON.stringify(left) !== JSON.stringify(e.left)) { e.left = left; leaksDirty = true; } } - /** Publish the state when a leak count changed since the last publish. */ + /** + * Re-read an entry's live bond layers (DESIGN §20.15): per player of its spec, the bonds whose layers in the battle's + * live copy (Battle.addLayers, clamped at BOND_LAYER_CAP like the client's AddBondCount) exceed the spec's start + * count. Nothing to do when the battle disables gains (联防, boss rounds). + */ + function noteLayers(e) { + const b = e.battle; + if (!b || (b.flags && b.flags.layerGainsEnabled === false) || typeof b.getPlayer !== 'function') return; + let sum = 0; + let live = null; + for (const sp of Array.isArray(e.spec.players) ? e.spec.players : []) { + const ps = sp && sp.playerId != null ? b.getPlayer(sp.playerId) : null; + if (!ps || !ps.bonds || typeof ps.bonds !== 'object') continue; + const start = sp.bonds && typeof sp.bonds === 'object' ? sp.bonds : {}; + for (const id of Object.keys(ps.bonds)) { + const n = Number(ps.bonds[id] && ps.bonds[id].layers) || 0; + const from = Number(start[id] && start[id].layers) || 0; + if (!(n > from)) continue; + sum += n - from; + if (!live) live = {}; + if (!live[sp.playerId]) live[sp.playerId] = {}; + live[sp.playerId][id] = n; + } + } + if (sum === e.layerSum) return; + e.layerSum = sum; + e.live = live; + leaksDirty = true; + } + + /** Publish the state when a leak count (or a battle's bond layers) changed since the last publish. */ function flushLeaks() { if (leaksDirty) publishState(); } /** Target tick of an entry on its clock. */ @@ -486,6 +536,8 @@ export function createBattleRunner(deps) { // counted leaks so far (normal fields; noteLeaks) and the Battle state they were counted at; 联防 fields: each // leaker's enemies still standing (noteUniteLeft) leaks: 0, leakMark: '', left: null, + // live bond layers grown in this battle { [playerId]: { [bondId]: n } } and their total gain (noteLayers) + live: null, layerSum: 0, }; if (lastPool && battle.sharedBoss && typeof battle.sharedBoss.sync === 'function') { battle.sharedBoss.sync(lastPool.hp, lastPool.acked ? lastPool.acked[e.fieldId] : undefined); @@ -622,6 +674,20 @@ export function createBattleRunner(deps) { const u = list.find((x) => x && x.uid === uid && x.ownerId === ownerId); return u && Number.isInteger(u.id) ? u.id : null; }, + /** + * The operators `ownerId` fields in the battle on screen — their board in that battle, waiting to deploy, deployed + * or knocked out (the field meta published by show() is taken before they deploy) — as UnitInfo-like + * { kind: 'op', ownerId, defId }: a teammate's bond popup lists them as the members in play (ui/watchBonds.js + * ownerBoard). [] when no such battle is on screen. + * @param {string} ownerId @param {string|null} [fieldId] + */ + ownerOps(ownerId, fieldId = null) { + const e = cur; + 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) => ({ kind: 'op', ownerId, defId: u.defId })); + }, /** Re-show the current battle (the game screen remounted). */ reshow() { if (cur) show(cur); }, /** Test / debug hooks. */ diff --git a/public/js/screens/game.js b/public/js/screens/game.js index ea4c7609..55c855a2 100644 --- a/public/js/screens/game.js +++ b/public/js/screens/game.js @@ -48,6 +48,13 @@ // reward card armed for a purchase that completes a merge lights, in gold, the board tile its elite will take (the // deployed copy that deploys first, gameLogic.mergeTarget — none when no copy is deployed: the elite goes to the hand); // the elite then appears there with the promotion cue (render/app.js setPrep → fx.promote). +// The bond strip, its popup and a unit card's bond chips follow the player on screen (DESIGN §20.15, ui/watchBonds.js): +// a scouted / watched / auto-observed teammate's field → that teammate's bonds (m.public players[].bonds, tagged +// "👁 name"); 联防 / 最终攻势 → the player on the ‹ › half (全景: yours when you fight there, else the teammate picked +// with 前往查看 — `watchWho` — or the field's first player, never the viewer's own); in battle with the live layers of +// the battle on screen (battle/runner.js state().bondLayers). Picking a teammate on such a field (a 联防 leaker, an +// eliminated spectator) frames their half and keeps the ‹ › pill (with 返回战场); an open bond popup closes when the +// strip changes hands. import { useCallback, useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js'; import { PHASE, GEO } from '../../../shared/constants.js'; @@ -89,7 +96,8 @@ import { ResultScreen } from './result.js'; import { net } from '../net.js'; import { store, useStore, shallowEqual, serverNow, emptyMatch } from '../store.js'; import { battleRunner } from '../battle/runner.js'; -import { isClientCombat, observeTarget, teammateProgress, cameraLayers, layerCamera, sidesOf } from '../battle/observe.js'; +import { isClientCombat, observeTarget, teammateProgress, cameraLayers, layerCamera, sidesOf, resumedWatch } from '../battle/observe.js'; +import { screenStrip, playerBonds, playerLayer, detailBondOwner, toggleBond, popupView } from '../ui/watchBonds.js'; import { data, localAsset } from '../data.js'; import { audio } from '../audio.js'; import { useDocClass, FullscreenButton } from '../ui/device.js'; @@ -176,8 +184,9 @@ function MatchScreen() { const { view, kind: viewKind } = useFieldView(hostRef); const [watching, setWatching] = useState(null); // fieldId the player chose to watch (null = home) + const [watchWho, setWatchWho] = useState(null); // { fieldId, playerId }: the teammate picked with 前往查看 const [drawer, setDrawer] = useState(null); // 'enemies' | 'info' | null - const [bondOpen, setBondOpen] = useState(null); + const [bondOpen, setBondOpen] = useState(null); // { id, ownerId, from }: the bond popup and whose bond it shows const [detail, setDetail] = useState(null); // detail target const [collapsed, setCollapsed] = useState(false); const [rewardMin, setRewardMin] = useState(false); @@ -249,7 +258,7 @@ function MatchScreen() { priv, stage: gd.stage(pub?.stageId), editable, field: deployField, getChess: gd.chess, getToken: gd.token, getItem: gd.item, getEffect: gd.effect, }), [priv, pub?.stageId, editable, gd.ready, deployField]); - live.current = { pub, priv, field, editable, placeCtx, watching, home, myId, detail, drawer, bondOpen, emoteOpen, settingsOpen, exitOpen, drag, facing, sel, pen, collapsedNow: collapsed, localDone: false, canPause: false, paused }; + live.current = { pub, priv, field, editable, placeCtx, watching, watchWho, home, myId, detail, drawer, bondOpen, emoteOpen, settingsOpen, exitOpen, drag, facing, sel, pen, collapsedNow: collapsed, localDone: false, canPause: false, paused }; // ---- camera: every request goes through setCam, which remembers it for the pen's way back ----------------------- // the own prep board: the normal board, or — in the prep of a boss round — the player's half of the boss field @@ -470,6 +479,23 @@ function MatchScreen() { setCam(kind, layerCamera(field, layer, sidesOf(field)[myId] || 'L')); }, [layer]); + // 前往查看 of a teammate on a two-half shared field (a 联防 leaker / an eliminated spectator tapping a helper or a pair + // player): the camera goes to that player's half, so the ‹ › pill reads "👁 name" like the strip's tag (DESIGN §20.15; + // official observing targets a player, research 09 §3.1). Once per pick and per field entered (entering resets 全景). + const whoAppliedRef = useRef({ who: null, field: null }); + useEffect(() => { + const who = watchWho; + if (!view || !who || !field || field.fieldId !== who.fieldId || !field.local || !isCombatPhase(phase)) return; + const A = whoAppliedRef.current; + if (A.who === who && A.field === field) return; + whoAppliedRef.current = { who, field }; + const k = playerLayer(field, pub, myId, who.playerId); + if (!k) return; + setLayer(k); + // the camera directly too: when the layer state already equals k the [layer] effect would not run + setCam(field.kind === 'hidden' ? 'boss' : field.kind, layerCamera(field, k, sidesOf(field)[myId] || 'L')); + }, [view, watchWho, field, phase]); + // graceful degradation: without WebGL / the render engine the DOM view takes over — say so once per match useEffect(() => { if (viewKind !== 'fallback') return; @@ -496,6 +522,7 @@ function MatchScreen() { else if (phase === PHASE.HIDDEN_CORE) audio.sfx('bossRoundSecret'); else if (phase === PHASE.SP_DRAFT) audio.sfx('draft'); setWatching(null); // the server resets every watcher to its own field on phase changes + setWatchWho(null); // the pen is a 休整期 view: leaving prep returns the camera (the next setCam would, too) if (phase !== PHASE.PREP && penRef.current.on) togglePenRef.current(false); // a battle unit's panel (live HP of a unit of the fight that just ended) never outlives its battle @@ -506,6 +533,16 @@ function MatchScreen() { setSpBusy(null); }, [phaseKey]); + // a reload / reconnect while watching a teammate's battle after the own one (client-side combat): the server resends + // the watched field, the fresh screen adopts it as watched once per battle — the observing pill, 返回战场 and the own + // row work again, and the bond strip's "👁 name" matches the HUD (battle/observe.js resumedWatch) + const seenBattleRef = useRef(null); + useEffect(() => { + const r = resumedWatch(battleState, { pub, myId, alive, watching, seen: seenBattleRef.current }); + seenBattleRef.current = r.seen; + if (r.fieldId) setWatching(r.fieldId); + }, [battleState?.battleId, battleState?.loading, !!pub]); + // timer ticks (≤ 10 s) while the player still has something to do const cd = countdownState(pub?.deadline, serverNow(), total); useTicker(cd.remain != null && cd.remain <= 10 ? 1000 : 0); @@ -576,19 +613,34 @@ function MatchScreen() { // The watched field only changes once the server accepted g.watch: a refused target (an eliminated teammate, the // other pair's boss field) must not move the eye icon / switcher label away from what is actually on screen. - const requestWatch = useCallback(async (fid) => { + // `playerId`: the teammate picked (a team row) — a shared field shows two players, the strip follows the picked one + // (DESIGN §20.15); null for a field picked as such (the legacy switcher). + const requestWatch = useCallback(async (fid, playerId = null) => { const prev = live.current.watching; + const prevWho = live.current.watchWho; + const who = playerId ? { fieldId: fid, playerId } : null; setWatching(fid); + setWatchWho(who); const ok = await actions.watch(fid); - if (!ok) setWatching((w) => (w === fid ? prev : w)); + if (!ok) { + setWatching((w) => (w === fid ? prev : w)); + setWatchWho((w) => (w === who ? prevWho : w)); + } return ok; }, []); /** Back to the own field (返回战场 / the own row). */ const backHome = useCallback(() => { const L = live.current; - if (L.watching && L.watching !== L.home) actions.watch(isCombatPhase(L.pub?.phase) ? L.home : ownFieldId(L.myId)); + if (L.watching && L.watching !== L.home) { + const target = isCombatPhase(L.pub?.phase) ? L.home : ownFieldId(L.myId); + // client-side combat without an own field to go back to (a 联防 leaker, an eliminated player): the screen keeps + // the field it shows — g.watch of a field that does not exist would only be refused (an error toast) + const exists = !isClientCombat(L.pub) || !isCombatPhase(L.pub?.phase) || (Array.isArray(L.pub?.fields) && L.pub.fields.some((f) => f && f.fieldId === target)); + if (exists) actions.watch(target); + } setWatching(null); + setWatchWho(null); }, []); const watchPlayer = useCallback((p) => { @@ -598,18 +650,19 @@ function MatchScreen() { const t = observeTarget(p, L.pub, L.myId, { observing, ownDone: L.localDone }); if (t.back) { backHome(); return; } if (t.reason) { toast(t.reason, 'warn'); audio.sfx('error', { volume: 0.5 }); return; } - if (t.fieldId) requestWatch(t.fieldId); + if (t.fieldId) requestWatch(t.fieldId, p.playerId); return; } const self = p.playerId === L.myId; if (self) { if (L.watching && L.watching !== L.home) actions.watch(isCombatPhase(L.pub?.phase) ? L.home : ownFieldId(L.myId)); setWatching(null); + setWatchWho(null); return; } const t = watchTarget(p, L.pub, L.myId); if (t.reason) { toast(t.reason, 'warn'); audio.sfx('error', { volume: 0.5 }); return; } - requestWatch(t.fieldId); + requestWatch(t.fieldId, p.playerId); }, []); const watchField = useCallback((fid) => { requestWatch(fid); }, []); @@ -993,14 +1046,40 @@ function MatchScreen() { // client-side combat: observing a teammate's battle (research 09 §3.1) and the 联防 / 最终攻势 camera halves // (an eliminated player auto-observes a teammate's normal field — research 09 "keep-watching" — without asking) const watchedFid = watchingOther ? watching : (cc && combat && !alive && battleState && battleState.watch && battleState.kind === 'normal' ? battleState.fieldId : null); - const observingName = cc && combat && watchedFid ? (players.find((p) => p.fieldId === watchedFid || ownFieldId(p.playerId) === watchedFid)?.name || '队友') : null; - const layers = cc && combat && field && field.local && !watchingOther ? cameraLayers(field, pub, myId) : []; + // the ‹ › pill: on the 联防 / 最终攻势 field on screen — also one watched with 前往查看 (a leaker, an eliminated spectator) + const layers = cc && combat && field && field.local && (!watchingOther || field.fieldId === watching) ? cameraLayers(field, pub, myId) : []; const progress = cc && phase === PHASE.COMBAT ? teammateProgress(pub, myId) : null; - // watching a teammate's normal field: the strip shows their bonds (m.public players[].bonds) + // the bond strip follows the player on screen (DESIGN §20.15, ui/watchBonds.js): a teammate's board / battle (前往查看, + // an eliminated player's auto-observed field) → their bonds; a 联防 / 最终攻势 field → the player on the ‹ › half (全景: + // yours when you fight there, else the teammate picked with 前往查看 / the field's first player); in battle with the + // live layers of the battle on screen (the runner's bondLayers) + const settleMode = mode === 'settle'; + const strip = screenStrip({ + pub, priv, myId, combat, settle: settleMode, watchingOther, watching, home, + battleFieldId: cc ? (battleState?.fieldId || null) : (combat || settleMode ? lastFieldRef.current : null), + field, layers, layer, who: watchWho, bondLayers: battleState?.bondLayers || null, + }); + const stripFid = strip.fieldId; + const liveLayers = (combat || settleMode) && battleState?.bondLayers ? battleState.bondLayers : null; + // the observing pill names the player whose bonds the strip shows (the same teammate as the strip's "👁 name" tag) + const observingName = cc && combat && watchedFid ? (!strip.self && stripFid === watchedFid ? strip.name : (players.find((p) => p.fieldId === watchedFid || ownFieldId(p.playerId) === watchedFid)?.name || '队友')) : null; const watchedP = watchingOther ? (players.find((p) => p.playerId !== myId && (watching === ownFieldId(p.playerId) || (watching === p.fieldId && String(watching).startsWith('n:')))) || null) : null; const watchedName = watchingOther ? (watchedP?.name || players.find((p) => watching === p.fieldId)?.name || '队友') : null; - const stripBonds = watchedP ? (watchedP.bonds || []) : (priv?.bonds || meP?.bonds || []); - const bondEntry = bondOpen ? stripBonds.find((b) => b.bondId === bondOpen) : null; + const stripBonds = strip.bonds; + // a popup opened from the strip closes when the strip changes hands (another teammate scouted / a ‹ › half / back to + // the own bonds); one opened from a card's chip keeps its unit owner (it carries its own player either way) + const stripOwnerRef = useRef(strip.ownerId); + useEffect(() => { + if (stripOwnerRef.current === strip.ownerId) return; + stripOwnerRef.current = strip.ownerId; + setBondOpen((b) => (b && b.from === 'strip' ? null : b)); + }, [strip.ownerId]); + // the popup shows the player it was opened for (ui/watchBonds.js popupView): their entry + live layers, their name, + // and as members your pieces or their operators on the field on screen — under client-side combat the battle's own + // (the runner's field meta is taken before they deploy); their hand is never sent + const popOps = cc && battleRunner && field?.local && bondOpen && bondOpen.ownerId !== myId ? battleRunner.ownerOps(bondOpen.ownerId, field.fieldId) : null; + const bondPop = popupView({ open: bondOpen, pub, priv, myId, field, units: popOps, live: liveLayers }); + const openBond = (id, ownerId, from) => { setBondOpen((b) => toggleBond(b, id, ownerId, from)); audio.sfx('click', { volume: 0.4 }); }; const watchingNow = combat ? (watching || field?.fieldId || home) : watching; const shopOpen = showShop && !collapsed; const ufShown = !!(selEntry && editable && !facing && !drag && showPrep && ufGeo); @@ -1051,6 +1130,11 @@ function MatchScreen() { } return priv?.loadout ?? null; // own pieces, shop / reward / bond-member cards })(); + // the card's bond chips — and the popup a chip opens: a battle / scouted unit's OWNER's bonds (yours, or that + // teammate's — m.public + live layers), your own piece's yours, a popup member card the popup's player; shop / reward + // cards read the strip's (ui/watchBonds.js detailBondOwner) + const detailOwner = detailBondOwner(detailTarget, { pub, myId, stripOwnerId: strip.ownerId }); + const detailBonds = detailOwner === strip.ownerId ? stripBonds : playerBonds({ pub, priv, myId, ownerId: detailOwner, live: liveLayers }); return html`
@@ -1069,8 +1153,8 @@ function MatchScreen() { live=${liveLpNow} />
- <${BondStrip} bonds=${stripBonds} layersDisabled=${layersDisabled} openId=${bondOpen} owner=${watchedP ? watchedP.name : null} - onOpen=${(id) => { setBondOpen((b) => (b === id ? null : id)); audio.sfx('click', { volume: 0.4 }); }} /> + <${BondStrip} bonds=${stripBonds} layersDisabled=${layersDisabled} openId=${bondPop && bondPop.ownerId === strip.ownerId ? bondPop.bondId : null} + owner=${strip.name} onOpen=${(id) => openBond(id, strip.ownerId, 'strip')} />
<${TeamPanel} pub=${pub} myId=${myId} watching=${watchingNow} bubbles=${bubbles} onWatch=${watchPlayer} cap=${gd.config?.lpCapPerRound ?? 10} uniteLocal=${uniteLocal} @@ -1114,13 +1198,13 @@ function MatchScreen() { ${drawer ? html`<${EnemyDrawer} tab=${drawer} onTab=${setDrawer} pub=${pub} priv=${priv} onClose=${() => setDrawer(null)} onEnemy=${(k, n) => setDetail({ kind: 'enemy', id: k, count: n })} onChess=${(id) => setDetail({ kind: 'chess', id })} />` : null} - ${bondOpen ? html`<${BondPopup} bondId=${bondOpen} entry=${bondEntry} priv=${priv} banned=${pub?.bannedChess || []} + ${bondPop ? html`<${BondPopup} bondId=${bondPop.bondId} entry=${bondPop.entry} priv=${bondPop.priv} banned=${pub?.bannedChess || []} owner=${bondPop.name} place=${bpPlace} over=${!!resolved && bpPlace === dSide} - onClose=${() => setBondOpen(null)} onMember=${(id) => setDetail({ kind: 'chess', id })} />` : null} + onClose=${() => setBondOpen(null)} onMember=${(id) => setDetail({ kind: 'chess', id, owner: bondPop.ownerId })} />` : null} ${resolved ? html`<${DetailPanel} detail=${resolved} snapHp=${snapHp} onClose=${() => { setDetail(null); setSel(null); }} - bonds=${stripBonds} loadout=${detailLoadout} side=${dSide} shopOpen=${shopOpen} live=${liveStats} - onBond=${(id) => { setBondOpen((b) => (b === id ? null : id)); audio.sfx('click', { volume: 0.4 }); }} />` : null} + bonds=${detailBonds} loadout=${detailLoadout} side=${dSide} shopOpen=${shopOpen} live=${liveStats} + onBond=${(id) => openBond(id, detailOwner, 'detail')} />` : null} ${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} diff --git a/public/js/ui/bondStrip.js b/public/js/ui/bondStrip.js index 2644ef44..8ce5628e 100644 --- a/public/js/ui/bondStrip.js +++ b/public/js/ui/bondStrip.js @@ -4,9 +4,12 @@ // the detail panel's bond chips it docks beside that panel (`beside`: the panel's side). // Research 06 §11.1: round mint discs, stack count over the disc, name below, sorted by stacks; grey = // present but inactive; in 联防 / boss rounds the strip is dimmed ("层数叠加已禁用"). +// 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). import { html, BondDisc, Icon, MicroLabel, Tooltip } from './components.js'; -import { RichText, UnitThumb, BondGlyph } from './gameComponents.js'; +import { RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.js'; import { sortBonds, bondMembers, nextThreshold, bondTier } from './gameLogic.js'; import { formatBondEffect } from './richText.js'; import { bondIconUrl } from './assetUrls.js'; @@ -14,23 +17,33 @@ import { data } from '../data.js'; const cx = (...p) => p.flat().filter(Boolean).join(' '); +/** The "👁 name" tag of a teammate's strip (DESIGN §20.15). */ +function OwnerTag({ owner }) { + return html` + <${GIcon} name="eye" class="bstrip__eye" /><${MicroLabel}>${owner} + `; +} + /** - * @param {{ bonds: any[], layersDisabled?: boolean, onOpen:(bondId:string)=>void, openId?: string|null, max?: number }} props + * @param {{ bonds: any[], layersDisabled?: boolean, onOpen:(bondId:string)=>void, openId?: string|null, max?: number, + * owner?: string|null }} props — owner: the watched teammate's name (null = your own bonds) */ export function BondStrip({ bonds, layersDisabled = false, onOpen, openId = null, max = 14, owner = null }) { const sorted = sortBonds(bonds, (id) => data.lookup('bonds', id)); if (!sorted.length) { - return html`
<${MicroLabel}>BONDS${owner ? `${owner} 尚未激活盟约` : '部署干员以激活盟约'}
`; + return html`
+ ${owner ? html`<${OwnerTag} owner=${owner} />` : html`<${MicroLabel}>BONDS`}${owner ? `${owner} 尚未激活盟约` : '部署干员以激活盟约'}
`; } const m = data.get('assets'); const shown = sorted.slice(0, max); - const strip = html`
- ${owner ? html`<${MicroLabel}>${owner}` : null} + const strip = html`
+ ${owner ? html`<${OwnerTag} owner=${owner} />` : null} ${shown.map((b) => { const rec = data.lookup('bonds', b.bondId); const th = Array.isArray(b.thresholds) && b.thresholds.length ? b.thresholds : rec?.thresholds || []; const next = nextThreshold(b.count ?? 0, th); - return html`
+ return html`
<${BondDisc} name=${rec?.name || b.bondId} icon=${bondIconUrl(m, b.bondId)} layers=${b.layers ?? 0} tier=${b.tier ?? 0} maxTier=${Math.max(1, th.length)} active=${!!b.active} size="sm" showName=${true} layersDisabled=${layersDisabled} onClick=${() => onOpen(b.bondId)} @@ -46,10 +59,11 @@ 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)=>void, - * place?: 'left'|'beside'|'besideR'|'right'|null, over?: boolean, beside?: 'left'|'right'|null }} props — `place`: where it - * opens (gameLogic bondPopupPlace; `beside: 'left'` = the older spelling of 'beside'); `over`: above the detail card + * place?: 'left'|'beside'|'besideR'|'right'|null, over?: boolean, beside?: 'left'|'right'|null, owner?: string|null }} 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) */ -export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember, place = null, over = false, beside = null }) { +export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember, place = null, over = false, beside = null, owner = null }) { const b = data.lookup('bonds', bondId); if (!b) return null; const count = entry?.count ?? 0; @@ -62,12 +76,14 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember, const next = nextThreshold(count, th); const hasNow = !!(b.effectDescRaw || b.effectDesc); const at = place || (beside === 'left' ? 'beside' : 'left'); - return html`