diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 9ee8f239..c672134c 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -421,14 +421,14 @@ C→S (all carry `rid`): `g.infoReady` · `g.band {bandId}` · `g.bandSkip` · ` 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.field` — field meta when watching starts: `{ fieldId, kind, rect, stageId, units: [UnitInfo] }` (the units on the field, knocked-out operators waiting to redeploy included — `Battle.fieldMeta`, §18.3) +- `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). - `b.snap` — `{ fieldId, gt, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]] , dp, killed, total, down?, elem? }` 20 Hz. Optional, only when non-empty (§18.3): `down: [[id, respawnAt (game s), respawnTime (s), state, row, col]]` — knocked-out operators waiting to redeploy, `state` 0 counting | 1 timer done, waiting for DP | 2 timer done, its tile taken — a safeguard, no ally deploys there (sim/constants.js `DOWN_STATE`); `row, col` = the tile it lies on and comes back on (§21.24); `elem: [[id, element, fill, cooldownEnd (game s), cooldown (s)]]` — the one gauge a unit shows: the fullest (ties by the official element id 神经 < 侵蚀 < 灼燃 < 凋亡), fill with 2 decimals rounded DOWN and kept at 0.01–0.99 outside a burst (§20.8); during a 爆发冷却 the bursting element with fill 1 and the cooldown numbers (else 0, 0) - `b.ev` — `{ fieldId, ev: [ ... ] }` event tuples: `['spawn', UnitInfo]`, `['atk', srcId, tgtId, projKind]`, `['dmg', tgtId, amount, type]`, `['heal', tgtId, amount]`, `['skill', id, on:0|1]`, `['die', id, reason]` (reason `'killed'`, `'retreat'`, `'expired'` …; only `'killed'` plays an operator's knock-down sound), `['status', id, key, on]`, `['fx', kind, x, y, extra]` (`'hitCap' {id, n}` = a cancelled 限伤 hit, drawn as nothing, §20.12; `extra.form` = the unit's model form from then on (an enemy's mode, a 傀儡师's 替身 §22.11) — `shared/protocol.js fxForm`, state like a spawn: never dropped by the client, §21.4), `['layer', playerId, bondId, n]`, `['bounty', playerId, coins]`, `['deploy', id]` - `m.toast {kind, text}` · `m.ticker {text, id, type, priority, playerId}` (the strip plays the highest priority first, §21.10) · `m.emote {playerId, id}` · `m.result {...}` - `m.unitStats {seq, round, units: [unitStatsEntry]}` — the answer to `g.unitStats` (`seq` echoed): every own board operator / summon by uid with `{id, uid, defId, hp, alive, maxHp, atk, def, res, interval, blockCnt, moveSpeed, range?, base: {…same}}` (shared/protocol.js `unitStatsEntry`; `range` = an ally's live range grid, facing RIGHT, never a target-selection grid, §21.16) as its next battle starts (§18.5) -`UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, skillIndex?, moduleId?, items?, form? }` (`dir` = the ally's deploy direction; the renderer picks the model (Back for UP, mirrored for LEFT) and the ground wedge from it; `skillIndex` = the ally's equipped skill (§16): the renderer plays that skill's Spine clip (`anims.skills[i]`) and the audio its own ON_SKILL_START sound; `moduleId` = an elite ally's equipped module (uniEquipId | 'none'); `items` = an ally operator's equipped item ids (absent without any; never on enemies or summons): the bond popup lists a 变形同构体 wearer from them and the detail card shows its 同构 chip and 装备 (§21.11); all three also on the prep-scouting `m.field` units (`Match.prepFieldMeta`), so a teammate's unit shows its owner's loadout in the detail card; `form` = the unit's current model form (an enemy's mode §21.4, a 傀儡师's 替身 §22.11; a view built mid-battle starts in it)). +`UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, skillIndex?, moduleId?, items?, form? }` (`dir` = the ally's deploy direction; the renderer picks the model (Back for UP, mirrored for LEFT) and the ground wedge from it; `skillIndex` = the ally's equipped skill (§16): the renderer plays that skill's Spine clip (`anims.skills[i]`) and the audio its own ON_SKILL_START sound; `moduleId` = an elite ally's equipped module (uniEquipId | 'none'); `items` = an ally operator's equipped item ids (absent without any; never on enemies or summons): the bond popup lists a 变形同构体 wearer from them and the detail card shows its 同构 chip and 装备 (§21.11); all three also on the prep-scouting `m.field` units (`Match.prepFieldMeta`, the held pieces included), so a teammate's unit shows its owner's loadout in the detail card and its equipped items as item pips on the unit (prep surfaces; user playtest #2 item 3); `form` = the unit's current model form (an enemy's mode §21.4, a 傀儡师's 替身 §22.11; a view built mid-battle starts in it)). ### 8.3 Exact view shapes (normative for match ⇄ UI) @@ -561,7 +561,7 @@ Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer **Solo pause (official `PauseUp` / `ResumeUp`; `Match.setPause`).** C→S `g.pause { on }`: solo matches only (co-op → `WRONG_PHASE 'co-op battles never pause'`), and `on: true` only while a battle runs (COMBAT / FINAL_ASSAULT / HIDDEN_CORE with a live field, not once the Final Assault is ending — else `WRONG_PHASE 'no battle running'`); `on: false` is always accepted. While `m.public.paused` is true everything of the battle is frozen: the field clock (`b.start elapsed`, the boss plausibility budgets and the overtime drain), the authority deadline and the server-run release timers, the boss clock, the HUD `deadline` / `overtimeAt` (shifted by the paused time on resume) and the server-run pacers (FieldRunner / HeadlessPacer skip their intervals); the browser's runner stops its local clock. In solo battles the top bar shows a 暂停 / 继续作战 button (`ui/hud.js PauseButton`, `ui/matchStatus.js pauseAvailable`; `Space` toggles it), a paused overlay offers 继续作战 / 放弃模拟, and the HUD clocks freeze at the pause moment. A disconnect or leave resumes (the server takes the field over — nobody is left to resume it); the battle phase ending drops the pause. -**Spectating (research 09 §3.1 / §6.3).** No live spectating while your own normal battle runs (`g.watch` → `WRONG_PHASE 'own battle running'`; the UI toasts "作战中无法查看队友"). After your own battle ends you see "⌛ 作战结束,等待队友完成作战" with each teammate's live progress (`m.public.fields[].progress`); tapping a teammate's avatar expands **前往查看** → `g.watch` → `b.start` (display replica fast-forwarded to the field clock); the own row shows **返回战场**. Prep: the same flow shows the teammate's board (`m.field` with `prep: true`) and sends it again when that board changes (GitHub #87). 联防: every human receives the unite spec (helpers + observers). Final Assault: each human sees its own pair field; the other pair → `BAD_TARGET 'other group hidden'`. In 联防/FA fields the ‹ › pill switches the camera LEFT half / 全景 / RIGHT half ("你自己" / "👁 name" / "全景" / "无人在家"); there is **no** view switcher during normal combat. Eliminated players start each combat phase on the first field (auto-observe) and may watch any field. +**Spectating (research 09 §3.1 / §6.3).** No live spectating while your own normal battle runs (`g.watch` → `WRONG_PHASE 'own battle running'`; the UI toasts "作战中无法查看队友"). After your own battle ends you see "⌛ 作战结束,等待队友完成作战" with each teammate's live progress (`m.public.fields[].progress`); tapping a teammate's avatar expands **前往查看** → `g.watch` → `b.start` (display replica fast-forwarded to the field clock); the own row shows **返回战场**. Prep: the same flow shows the teammate's board (`m.field` with `prep: true`) and sends it again when that board changes (GitHub #87). The scout is framed by the prep camera in its shop-folded form and renders the teammate’s hand (整备区) and 临时整备区 as units on their rows — the own prep bench’s exact look (user playtest #2 item 1, the GitHub #44 suggestion; the spectator seat's scout view is the same, §23.19's never-a-private-view staying the no-`m.private` rule). While scouting, the 本局信息 drawer shows the watched player's 策略 in place of one's own (`game.js scoutPid` → `enemyDrawer.js InfoTab bandId / bandOwner`, labelled "X 的策略"; user playtest #2 item 2), and the right effects column follows the watched field too: the scout meta carries the player's effects view, a display-replica battle derives it from the spec's `playerEffects` (`battle/observe.js spectateEffects`) — undefined (empty column, never one's own) for 联防 / boss pair fields and server-run watched fields. 联防: every human receives the unite spec (helpers + observers). Final Assault: each human sees its own pair field; the other pair → `BAD_TARGET 'other group hidden'`. In 联防/FA fields the ‹ › pill switches the camera LEFT half / 全景 / RIGHT half ("你自己" / "👁 name" / "全景" / "无人在家"); there is **no** view switcher during normal combat. Eliminated players start each combat phase on the first field (auto-observe) and may watch any field. **Client runner.** `public/js/battle/runner.js` owns the local Battles: fixed-step clock at the battle speed (60 ticks per real second at 2×, at most `max(8, 4·speed)` ticks per frame, bounded fast-forward of ≤ 240 ticks per frame when far behind; a new battle is caught up silently before it is shown), feeds the existing renderer through the same `snapshot()`/`drainEvents()` wire formats every frame (`view.setLocalFeed` shrinks the interpolation buffer to ~2 frames), publishes the field meta (m.field shape + `local`, `battleId`, `players`, `sides`, `speed`) into `store.match.field` and its state into `store.match.battle` (with `leaks: { [fieldId]: counted leaks so far }` of every normal field it simulates and, while the 联防 field is on screen, `uniteLeft` per leaker (§20.6), authoritative or display replica — the settle rule's count, `/sim/spec.js battleProgress`; published in the frame a count changes: the live LP of §17.5), sends `b.progress`/`b.result` when authoritative (results retried once on timeout; a result lost with the socket — the request failed `DISCONNECTED` / `OFFLINE` or timed out twice — is kept and sent again when the session is back online and whenever a `b.start` still names that finished battle `authoritative: true`; the server answers a duplicate `ok` and changes nothing; a server refusal is final), follows `m.public.paused` (solo pause: every local clock stands still, resume shifts it by the paused time), handles `b.pool`/`b.end`, fast-forwards on reconnect/observe, and survives tab backgrounding (a 250 ms interval pump keeps authoritative battles on their clock while hidden; the server deadline + takeover cover extreme cases). Catch-up frames keep the state events and every enemy form fx (`keepsState`); while hidden, the battle on screen holds them for the first frame back (`hold`; past `HELD_MAX` the superseded status / skill toggles are dropped, `compactHeld`, and only a backlog still past it re-enters the view from the field meta), and a battle that ended while hidden delivers them when the tab is shown; a catch-up frame or a hidden step drains its events every `EV_SLICE` (15) ticks, each batch stamped with its own game time (`gt`), so a late form fx reaches the render engine as late as it is; the game screen's pre-entry buffer (`keepEarly`, also while a re-sent field meta re-enters the field on screen) and the render engine's event queue (`render/interp.js isCosmeticEvent`) never drop a form fx either (§21.4). Sim content errors are logged as console warnings (the server logs its own). The sim loader fetches the ten data files (one retry each) and fails when any is missing — a battle on partial data would report a plausible wrong result — so the next `b.start` retries and the server's deadline covers the field meanwhile. `public/js/battle/observe.js` holds the observing rules and labels. @@ -635,7 +635,7 @@ v2.2 answered "a click selected the unit one row lower" with body picking: every - **Live LP (#2)**: in a normal round the top bar's LP tower and the own team row show `lp − min(lpCapPerRound, counted leaks)` as soon as the own battle's enemies enter the blue gate — the number in red followed by a −N plate that pops again on every leak, clamped at 0, with a tooltip (`ui/hud.js liveLp` / `pendingLoss` / `pendingTip`). The count is `ownLeaks(local, server)` = the larger of the local runner's `state().leaks[own field]` (§14) and `m.public players[].pendingLp` (§8.2; the authority's `b.progress` leaks, the field's result, the recorded result in 联防) — both only grow within a round, so a paused display replica never freezes the number. The pending part is dropped as soon as the settled `m.private` lands (its `lp` or `stats.leaks` changed; `Match.flush` sends it before the SETTLE `m.public`), the phase leaves COMBAT / 联防 or the round changes — never subtracted twice. 联防 (**revised by §20.6**, user playtest #6 item 7): a leaker's count is its enemies still standing — the local replica's while the 联防 field is on screen, else m.public `uniteLeft` — shown uncapped as ×N in the capsule and in the leakers' team rows, and the LP tower shows lp − min(10, N), tagged 联防中. Teammates' rows use their `pendingLp` (`teamPanel.js rowLp`); server-run fields (bots, taken-over fields) report none before their result is released. Boss rounds are unchanged (the team LP moves live through `b.pool`). - **Temp row (#3, UI)**: while `m.private temp` holds pieces during the own prep, a dashed red frame surrounds the 临时整备区 row on the board and a label says how many are waiting, what to do and when they go (`underframe.js TempRowNotice` / `tempRowFrame`, placed through `view.tileScreen` — it follows the camera, the mirrored Final Assault prep and the DOM fallback; no pointer events; the label hides while dragging or placing). Its wording follows the server rule (§6.2, `tempRowRule(ready)`): not ready — "放入整备区或战场、配发或使用后才能准备;休整期结束时仍在此处的将被销毁"; ready in PREP (so whatever lies there arrived after Ready) — "已准备就绪后进入的单位保留到下个休整期,届时仍在此处的将被销毁(取消准备则在本休整期结束时销毁)". The 准备就绪 button shows "临时整备区 N 个单位待处理" under it (not only on hover; the effects column moves down a line meanwhile, `.gm.has-readywhy`) and Space toasts the reason (`hud.js TEMP_RULE` / `tempReadyReason`). -- **Detail card order (#8)** (`detailPanel.js CHESS_SECTIONS`): header (portrait, name, tier, class, bonds) → **特质** (the operator's own effect — garrison trigger chip such as 休整期结束时 + description, a compact gold block, visible without scrolling at 1920×1080, 1280×720, iPhone 14 and 640×360) → 特性 (class trait) → stats (a compact 4×2 grid) + attack range → 技能 → 模组 → 装备 → 天赋. On phones ≤ 460 px tall the portrait is shorter and the romanised name is hidden. +- **Detail card order (#8)** (`detailPanel.js CHESS_SECTIONS`): header (portrait, name, tier, class, bonds) → **特质** (the operator's own effect — garrison trigger chip such as 休整期结束时 + description, a compact gold block, visible without scrolling at 1920×1080, 1280×720, iPhone 14 and 640×360) → 特性 (class trait) → stats (a compact 4×2 grid) + attack range → 技能 → 模组 → 装备 → 天赋. On phones ≤ 460 px tall the portrait is shorter and the romanised name is hidden. A teammate's 策略 is not on the card — while scouting, the 本局信息 drawer shows the watched player's band in place of one's own (§14). - **Busy indicators and texts (#9)**: every text of the game (operators, skills, bonds, items, enemies, 特质 …) is static data, `/data/*.json` loaded once per page and never fetched during a match; the "spinners" the player saw were busy indicators of requests in flight next to texts — and the 特质 chip's type icon: it was picked by the trigger, so every <休整期开始时 / 结束时> 特质 showed the spoked 特异化 glyph (it reads like a loading spinner) before its text; the chip now shows the garrison's own official `eventTypeIcon` (`detailPanel.js garrisonTypeIconKey`: crossed swords 作战能力, gold 整备能力, chain link 持续 / 单次叠加, spokes only for 特异化). Now: no `cursor: wait / progress` anywhere; a loading `Button` keeps its icon and shows a thin sweeping bar along its bottom edge after 250 ms (no spinner ring); a 机变 card in flight shows a 选择中 strip until the pick appears in `m.public` (`choiceOverlay.js pickBusy`); the 作战结束,等待队友完成作战 hourglass is still; the hexagon `Spinner` is kept for genuine loading screens only (entering the match, the field view, connecting, guide images, the first 干员调配 load, which says the data loads once per page). `main.js warmGameData` downloads every in-match file (`gameComponents GAME_FILES`) in the background once the player is in a room, and the match screen waits for them; `data.js` retries a transient failure (network error, HTTP 5xx / 408 / 429) after 600 ms and 2 s (`RETRY_DELAYS_MS`) — a 404 or invalid JSON is final. - Tests: `test/ui/playtest3.test.js`, `test/match/runner.test.js` (live leak counts), `test/match/temp.test.js` (§6.2), `test/docs-consistency.test.js`. diff --git a/public/js/battle/observe.js b/public/js/battle/observe.js index fbf88dd2..e7fa93da 100644 --- a/public/js/battle/observe.js +++ b/public/js/battle/observe.js @@ -9,6 +9,7 @@ // * eliminated: anything. import { PHASE } from '../../../shared/constants.js'; +import { data } from '../data.js'; const isObj = (v) => !!v && typeof v === 'object'; const COMBAT = new Set([PHASE.COMBAT, PHASE.UNITE, PHASE.FINAL_ASSAULT, PHASE.HIDDEN_CORE]); @@ -130,3 +131,32 @@ export function layerCamera(field, layer, mySide = 'L') { if (layer === 'L' || layer === 'R') return { rect, side: layer, half: true }; return { rect, side: mySide }; } + +/** + * The watched player's effects column for a battle watched as a display replica (user playtest #2: while spectating, + * the right column shows the spectated player's effects, not one's own). The spec's raw playerEffects (Battle spec: + * `{ id, source = iconKind, counter, … }`) resolved client-side — a band effect through bands.json (its effectId), + * everything else through effects.json; `effectIconUrl`'s per-kind fallbacks cover the missing icon ids. Undefined + * where whose column would be ambiguous (联防 / boss pairs) or there are no effects data (a server-run field's meta + * comes from the server without effects — the column stays empty rather than showing one's own). + */ +export function spectateEffects(spec, members) { + if (!Array.isArray(members) || members.length !== 1 || !isObj(spec)) return undefined; + const p = Array.isArray(spec.players) ? spec.players.find((x) => isObj(x) && x.playerId === members[0]) : null; + if (!p || !Array.isArray(p.playerEffects)) return undefined; + const out = []; + for (const pe of p.playerEffects) { + if (!isObj(pe) || typeof pe.id !== 'string' || !pe.id) continue; + const counter = pe.counter != null ? { counter: pe.counter } : {}; + if (pe.source === 'band') { + const band = data.list('bands').find((b) => b && b.effectId === pe.id); + if (band) { + out.push({ id: pe.id, name: band.effectName || band.name, desc: band.desc || '', iconKind: 'band', iconId: band.iconId || band.bandId, ...counter }); + continue; + } + } + const rec = data.lookup('effects', pe.id); + out.push({ id: pe.id, name: rec?.name || pe.id, desc: rec?.desc || rec?.descRaw || '', iconKind: pe.source || 'choice', iconId: pe.id, ...counter }); + } + return out; +} diff --git a/public/js/battle/runner.js b/public/js/battle/runner.js index aad7db34..585a6e67 100644 --- a/public/js/battle/runner.js +++ b/public/js/battle/runner.js @@ -65,6 +65,7 @@ import { net as appNet } from '../net.js'; import { store as appStore } from '../store.js'; import { unitStatsEntry, fxForm } from '../../../shared/protocol.js'; +import { spectateEffects } from './observe.js'; const TICK = 1 / 30; /** Fast-forward budget per frame (ticks) when far behind. */ @@ -561,6 +562,8 @@ export function createBattleRunner(deps) { const field = { t: 'm.field', ...meta, fieldId: e.fieldId, kind: e.kind, rect: meta.rect ?? e.spec.rect, stageId: meta.stageId ?? e.spec.stageId, live: !e.done, battleId: e.battleId, players: e.members.slice(), local: true, speed: e.speed, + // the watched player's effects column (user playtest #2; undefined for 联防 / boss pairs and server-run fields) + effects: spectateEffects(e.spec, e.members), // which half each player holds (联防: the first helper takes the right half; boss pairs: L / R) sides: Object.fromEntries((e.spec.players || []).filter((p) => p && p.playerId).map((p) => [p.playerId, p.side === 'R' || Number(p.colOffset) >= 8 ? 'R' : 'L'])), }; diff --git a/public/js/render/app.js b/public/js/render/app.js index 2686cb4f..97df1c9d 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -294,6 +294,10 @@ export function renderInfo(u) { // tap hands them to the detail card (a teammate's unit shows its owner's skill / module) skillIndex: Number.isInteger(u.skillIndex) ? u.skillIndex : undefined, moduleId: typeof u.moduleId === 'string' ? u.moduleId : undefined, + // the ally's equipped item ids (UnitInfo.items, DESIGN §16 / §21.11): the detail card needs them for a teammate's + // unit (resolveDetail `unitItems` → the read-only 装备 section and the 变形同构体 pairing chips); the owner's own + // unit takes its items from the piece instead, so only other players' boards ever read this field + items: Array.isArray(u.items) ? u.items.filter((x) => typeof x === 'string') : undefined, }; } @@ -1445,12 +1449,29 @@ export async function createFieldView(host, options = {}) { return info; } + // a hand item on a scouted prep board (UnitInfo kind 'item'): the plate's icon and colour resolve client-side, + // exactly like the own prep bench (pieceInfo) + function scoutItemInfo(info) { + const rec = data.item(info.defId); + const tier = rec?.tier || info.tier || 1; + return { ...info, + icon: assets.itemIcon ? assets.itemIcon(rec ? { trapId: rec.trapId, iconId: rec.iconId } : info.defId) : null, + color: (info.golden || rec?.isGolden) ? 0xffc600 : TIER_COLORS[tier] || TIER_COLORS[1] }; + } + function battleView(id) { let v = views.get(id); if (v) return v; const info = infos.get(id); if (!info || gone.has(id)) return null; - v = info.kind === 'device' ? new DeviceView(ctx, info) : new UnitView(ctx, info, { prep: !!battleMeta?.prep && info.side === 'ally' }); + v = info.kind === 'device' ? new DeviceView(ctx, info) + : info.kind === 'item' ? new ItemView(ctx, scoutItemInfo(info)) + : new UnitView(ctx, info, { prep: !!battleMeta?.prep && info.side === 'ally' }); + // a teammate's operator shows its equipped items like the own prep bench does (item pips; user playtest #2: + // at the unit, not only in the detail card) — prep surfaces only, the battle HUD stays as it is + if (v.setItems && battleMeta?.prep && 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; })); + } v.setWorld(info.x, info.y, 0); v._seen = false; v._born = performance.now(); diff --git a/public/js/screens/game.js b/public/js/screens/game.js index 90c7fee4..47a1d602 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, chessLoadout, unitLoadout, - mergeTarget, modeOffBonds, readyFundsPrompt, + mergeTarget, modeOffBonds, readyFundsPrompt, ownerBandId, } from '../ui/gameLogic.js'; import { toast } from '../ui/toasts.js'; import { BriefingScreen } from './briefing.js'; @@ -234,6 +234,10 @@ function MatchScreen() { const alive = spectator ? false : priv ? priv.alive !== false : meP?.alive !== false; const home = homeFieldId(pub, myId); const watchingOther = !!watching && watching !== home && watching !== ownFieldId(myId); + // watching a teammate's prep board: the 本局信息 drawer shows THEIR 策略 in place of one's own (user playtest #2 item 2) + const scoutPid = watchingOther && field?.prep && typeof field.fieldId === 'string' && field.fieldId.startsWith('n:') ? field.fieldId.slice(2) : null; + const scoutBandId = scoutPid ? ownerBandId(pub, scoutPid) : null; + const scoutBandOwner = scoutPid ? (players.find((p) => p.playerId === scoutPid)?.name || null) : null; const editable = phase === PHASE.PREP && !!priv && alive && !priv.ready && !watchingOther; const showShop = !!priv && alive && (phase === PHASE.PREP || phase === PHASE.SP_DRAFT || phase === PHASE.ROUND_START) && !watchingOther; const layersDisabled = phase === PHASE.UNITE || isBossPhase(phase); @@ -404,7 +408,9 @@ function MatchScreen() { evBufRef.current.delete(field.fieldId); const earlySnap = snapBufRef.current.get(field.fieldId); snapBufRef.current.delete(field.fieldId); - const kind = field.kind === 'hidden' ? 'boss' : field.kind || 'normal'; + // a scouted prep board frames like the own prep with the shop folded (the bench row included, app.js camRect); + // 'prep' expands the rect's top to the hand row + const kind = field.prep ? 'prep' : (field.kind === 'hidden' ? 'boss' : field.kind || 'normal'); const pf = (Array.isArray(pub?.fields) ? pub.fields : []).find((f) => f && f.fieldId === field.fieldId); const members = Array.isArray(pf?.players) ? pf.players : Array.isArray(field.players) ? field.players : []; const sides = field.sides && typeof field.sides === 'object' ? field.sides : null; @@ -415,7 +421,7 @@ function MatchScreen() { // a lone player's boss field (solo modes, the odd player of a co-op Final Assault: the `_s` templates route every // enemy to the left objective) is framed on its own half like the ‹ › half view; pairs start on 全景 const lone = kind === 'boss' && members.length === 1; - setCam(kind, lone ? { rect: field.rect, side, half: true } : { rect: field.rect, side }); + setCam(kind, lone ? { rect: field.rect, side, half: true } : { rect: field.rect, side, ...(field.prep ? { shop: false } : {}) }); audio.setFieldUnits(field.units); if (early && early.length) { // replay state-bearing events only (a burst of stale hit sparks / damage numbers would look wrong) @@ -1246,7 +1252,7 @@ function MatchScreen() { self=${Number.isFinite(priv?.lp) ? { lp: priv.lp, pending: liveLpNow.pending, unite: liveLpNow.unite, left: liveLpNow.left } : null} observe=${cc ? { canObserve: (p) => observeTarget(p, pub, myId, { observing: watchingOther, ownDone: localDone }), observing: watchingOther, onBack: backHome } : null} /> -
<${Icon} name="rook" />战场:${stage.name || stage.id}
` : null} @@ -88,14 +89,17 @@ function InfoTab({ pub, priv, onChess }) { } /** - * @param {{ tab:'enemies'|'info', onTab:(t:string)=>void, pub:any, priv:any, onClose:Function, onEnemy:(key:string, count:number)=>void, onChess:(id:string)=>void }} props + * @param {{ tab:'enemies'|'info', onTab:(t:string)=>void, pub:any, priv:any, onClose:Function, onEnemy:(key:string, count:number)=>void, onChess:(id:string)=>void, + * bandId?: string|null, bandOwner?: string|null }} props + * bandId / bandOwner: while scouting a teammate's prep board, the watched player's 策略 (m.public players[].bandId) + * replaces one's own in the 本局信息 tab (game.js scoutBandId; user playtest #2 item 2) */ -export function EnemyDrawer({ tab, onTab, pub, priv, onClose, onEnemy, onChess }) { +export function EnemyDrawer({ tab, onTab, pub, priv, onClose, onEnemy, onChess, bandId = null, bandOwner = null }) { return html`