Bond strip follows the watched teammate (v2.5.2): prep scouting, after the own battle, 联防, Final Assault, eliminated observers

- 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)
This commit is contained in:
sganggs committed 2026-10-02 12:11:48 +08:00
1 parent 620f88c0d8
commit 82c64086d5
21 files changed
+2087 -57

No files matched your search

+25
View File
@@ -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 = [];
+71 -5
View File
@@ -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. */
+103 -19
View File
@@ -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`<div class=${cx('screen', 'gm', `gm--${mode}`, drag && 'is-dragging', collapsed && 'is-collapsed', sp && 'has-sp', pen && 'is-pen', readyWhy && 'has-readywhy')}
data-camera=${pen ? 'pen' : camKind}>
@@ -1069,8 +1153,8 @@ function MatchScreen() {
live=${liveLpNow} />
<div class="gm__bonds">
<${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')} />
</div>
<${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}
+26 -10
View File
@@ -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`<span class="bstrip__owner" title=${`正在查看 ${owner} 的盟约`} data-owner=${owner}>
<${GIcon} name="eye" class="bstrip__eye" /><${MicroLabel}>${owner}</${MicroLabel}>
</span>`;
}
/**
* @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`<div class="bstrip bstrip--empty"><${MicroLabel}>BONDS</${MicroLabel}><span>${owner ? `${owner} 尚未激活盟约` : '部署干员以激活盟约'}</span></div>`;
return html`<div class=${cx('bstrip', 'bstrip--empty', owner && 'is-other')} data-owner=${owner || null}>
${owner ? html`<${OwnerTag} owner=${owner} />` : html`<${MicroLabel}>BONDS</${MicroLabel}>`}<span>${owner ? `${owner} 尚未激活盟约` : '部署干员以激活盟约'}</span></div>`;
}
const m = data.get('assets');
const shown = sorted.slice(0, max);
const strip = html`<div class=${cx('bstrip', layersDisabled && 'is-frozen', owner && 'is-other')} role="list">
${owner ? html`<span class="bstrip__owner"><${MicroLabel}>${owner}</${MicroLabel}></span>` : null}
const strip = html`<div class=${cx('bstrip', layersDisabled && 'is-frozen', owner && 'is-other')} role="list"
aria-label=${owner ? `${owner} 的盟约` : '我的盟约'} data-owner=${owner || null}>
${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`<div key=${b.bondId} role="listitem" class=${cx('bslot', b.active && 'is-active', openId === b.bondId && 'is-open')}>
return html`<div key=${b.bondId} role="listitem" data-bond=${b.bondId} class=${cx('bslot', b.active && 'is-active', openId === b.bondId && 'is-open')}>
<${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`<div class=${cx('bpop', 'brackets', `bpop--${at}`, over && 'is-over')} data-place=${at} role="dialog" aria-label=${`盟约:${b.name}`}>
return html`<div class=${cx('bpop', 'brackets', `bpop--${at}`, over && 'is-over', owner && 'is-other')} data-place=${at} data-owner=${owner || null}
role="dialog" aria-label=${owner ? `${owner} 的盟约:${b.name}` : `盟约:${b.name}`}>
<button type="button" class="bpop__close" aria-label="关闭" onClick=${onClose}><${Icon} name="close" /></button>
<header class="bpop__head">
<div class=${cx('bpop__disc', active && 'is-active')}><${BondGlyph} bondId=${bondId} /></div>
<div class="bpop__titles">
<${MicroLabel} tone="mint">${b.isCore ? 'CORE BOND // 核心盟约' : 'ADD-ON BOND // 附加盟约'}</${MicroLabel}>
${owner ? html`<span class="bpop__owner"><${GIcon} name="eye" /><b>${owner}</b> 的盟约</span>` : null}
<h3 class="bpop__name">${b.name}</h3>
<div class="bpop__facts">
<span>在场 <b class="num">${count}</b>${next != null ? html`<small class="num">/${next}</small>` : null}${countsHand ? html`<small>(含整备区)</small>` : null}</span>
+8 -3
View File
@@ -3,7 +3,9 @@
// Client-side combat (`client` prop, DESIGN §14 — the official behaviour):
// * own normal battle over: "⌛ 作战结束,等待队友完成作战" with each teammate's live progress (kills / total, ✓);
// * observing a teammate (team row → 前往查看): "👁 name" + 返回战场;
// * 联防 / 最终攻势: the ‹ › pill switches the camera LEFT half / 全景 / RIGHT half ("你自己" / name / "全景");
// * 联防 / 最终攻势: the ‹ › pill switches the camera LEFT half / 全景 / RIGHT half ("你自己" / name / "全景"); it
// replaces the observing pill on such a field watched with 前往查看 (a 联防 leaker, an eliminated spectator — the
// official non-helper pill "‹ 👁 helper# ›"), with 返回战场 beside the arrows (DESIGN §20.15);
// * there is NO view switcher during normal combat.
// Server-run combat (legacy streaming mode): the ‹ 自己 › switcher cycling the live fields of m.public.fields.
@@ -64,22 +66,25 @@ function ClientHud({ hud, myDone, spectating, client }) {
if (n !== idx) client.onLayer(layers[n].key);
};
const observing = client.observing;
const halves = layers.length > 0;
return html`<div class="chud">
<${DpCounter} dp=${hud?.dp} />
<div class="chud__bottom">
${myDone && !observing ? html`<div class="chud__msg chud__wait" role="status"><${Icon} name="hourglass" /><span>作战结束,等待队友完成作战</span></div>
<${ProgressList} list=${client.progress} />` : null}
${spectating && !observing ? html`<div class="chud__msg chud__msg--dead" role="status"><${Icon} name="close" /><span>你已被淘汰,可点击队友头像前往查看</span></div>` : null}
${observing ? html`<div class="vswitch chud__observe is-single" role="status">
${observing && !halves ? html`<div class="vswitch chud__observe is-single" role="status">
<span class="vswitch__label"><${GIcon} name="eye" /><span>${observing.name}</span></span>
${client.onBack ? html`<button type="button" class="btn btn--secondary btn--sm chud__back" onClick=${() => client.onBack()}>
<span class="btn__label">返回战场</span></button>` : null}
</div>` : null}
${!observing && layers.length ? html`<div class="vswitch chud__layers">
${halves ? html`<div class=${cx('vswitch', 'chud__layers', observing && 'is-observing')}>
<button type="button" class="vswitch__arrow" disabled=${idx <= 0} aria-label="左侧战场" onClick=${() => step(-1)}><${Icon} name="chevronLeft" /></button>
<span class="vswitch__label">
${cur && cur.watch ? html`<${GIcon} name="eye" />` : null}<span>${cur ? cur.label : '全景'}</span></span>
<button type="button" class="vswitch__arrow" disabled=${idx >= layers.length - 1} aria-label="右侧战场" onClick=${() => step(1)}><${Icon} name="chevronRight" /></button>
${observing && client.onBack ? html`<button type="button" class="btn btn--secondary btn--sm chud__back" onClick=${() => client.onBack()}>
<span class="btn__label">返回战场</span></button>` : null}
</div>` : null}
</div>
</div>`;
+254
View File
@@ -0,0 +1,254 @@
// Whose bonds the bond strip shows (DESIGN §20.15, user report after playtest #6: "观看队友时…盟约栏应该变成当前队友的
// 盟约以及他的层数") — pure helpers for the game screen (screens/game.js), the strip and its popup (ui/bondStrip.js).
//
// The strip follows the player whose field / half the camera shows:
// * the own board (prep) and the own battle → your own bonds (m.private bonds);
// * a teammate's board in prep (前往查看, research 09 §3.1 "their board, read-only, including their pen and bonds"), a
// teammate's battle watched after the own one ended, the field an eliminated player auto-observes → THAT player's
// bonds (m.public players[].bonds);
// * a shared field (联防 'u', the Final Assault / Hidden Core pair 'b1' / 'b2') [ASSUMED — no source shows the strip
// there]: the player holding the half the ‹ › pill points at ("你自己" → yours, "👁 name" → theirs). On 全景 (or an
// empty half, "无人在家"): yourself when you fight on that field; a viewer who does not (a 联防 leaker, an eliminated
// spectator) never sees their own bonds there — the teammate they picked with 前往查看 (official observing targets a
// player: ObserveUp / DeadAutoObDn { obIndex }), else the field's first player (helper 1 / the pair's first seat;
// the official non-helper pill reads "‹ 👁 helper# ›"). 前往查看 of a player on a two-half field also moves the
// camera to their half (playerLayer), so the pill and the strip name the same player.
// In battle the layers are live: the local simulation of the battle on screen (the own one, a teammate's display
// replica — battle/runner.js state().bondLayers, absolute counts ≤ BOND_LAYER_CAP) is laid over the view's start-of-
// round counts; the server's views add the finished normal battle's gains from the end of COMBAT until the settlement
// (server/match/bondsMeta.js bondsWithGains), so the counts never fall back during 联防.
// A detail card's bond chips follow the UNIT's owner instead (detailBondOwner: a shared field shows both halves' units
// whoever the strip follows), and a bond popup carries the player it was opened for (toggleBond / popupView): the
// strip's owner, or the chip's — its entry, member list and "👁 name" label always belong to that one player.
import { BOND_LAYER_CAP } from '../../../shared/constants.js';
import { sidesOf, nameOf, cameraLayers } from '../battle/observe.js';
const isObj = (v) => !!v && typeof v === 'object';
const playersOf = (pub) => (Array.isArray(pub?.players) ? pub.players.filter(isObj) : []);
const fieldsOf = (pub) => (Array.isArray(pub?.fields) ? pub.fields.filter(isObj) : []);
/**
* The field on screen whose player the strip follows; null = the own prep board.
* @param {{ combat?: boolean, settle?: boolean, watchingOther?: boolean, watching?: string|null,
* battleFieldId?: string|null, home?: string|null }} o — battleFieldId: the battle the view shows (client-side combat:
* the runner's battle on screen, which is also the field an eliminated player auto-observes; server-run combat: the
* field the view entered)
* @returns {string|null}
*/
export function screenFieldId({ combat = false, settle = false, watchingOther = false, watching = null, battleFieldId = null, home = null } = {}) {
if (watchingOther && typeof watching === 'string' && watching) return watching;
if (!combat && !settle) return null;
if (typeof battleFieldId === 'string' && battleFieldId) return battleFieldId;
return typeof home === 'string' && home ? home : null;
}
/**
* The player whose bonds the strip shows (see the header).
* @param {{ pub: any, myId: string, fieldId?: string|null, field?: any, layer?: 'L'|'ALL'|'R', halves?: boolean,
* watched?: string|null }} o — field: the field meta on screen (store.match.field: `sides` / `players`), consulted when
* it is `fieldId`; halves: the ‹ › pill is offered (battle/observe.js cameraLayers non-empty); watched: the player
* picked with 前往查看 (watchedPlayer), consulted on a shared field the viewer does not fight on
* @returns {string}
*/
export function bondOwnerId({ pub, myId, fieldId = null, field = null, layer = 'ALL', halves = false, watched = null } = {}) {
const known = new Set(playersOf(pub).map((p) => p.playerId));
const ok = (pid) => typeof pid === 'string' && pid !== '' && known.has(pid);
if (typeof fieldId !== 'string' || !fieldId) return myId;
// a normal field / a prep board: 'n:<playerId>' (DESIGN §8.3)
if (fieldId.startsWith('n:')) {
const pid = fieldId.slice(2);
return ok(pid) ? pid : myId;
}
const meta = isObj(field) && field.fieldId === fieldId ? field : null;
const sides = meta ? sidesOf(meta) : {};
if (halves && meta && (layer === 'L' || layer === 'R')) {
const pid = Object.keys(sides).find((id) => sides[id] === layer);
if (ok(pid)) return pid;
}
// 全景, an empty half, no ‹ › pill: the field's members (m.public.fields, else the meta)
const listed = fieldsOf(pub).find((f) => f.fieldId === fieldId);
const members = Array.isArray(listed?.players) ? listed.players : Array.isArray(meta?.players) ? meta.players : [];
if (members.includes(myId)) return myId;
// not on that field (a 联防 leaker, an eliminated spectator): never your own — the picked teammate, else the first
if (ok(watched) && members.includes(watched)) return watched;
const first = members.find(ok);
return first || myId;
}
/**
* The player picked with 前往查看 while that watch is on screen: `who` = { fieldId, playerId } remembered by the game
* screen when a team row was tapped (a shared field shows more than one player, the fieldId alone cannot tell).
* @param {{ fieldId?: string, playerId?: string }|null} who @param {string|null} watching the watched fieldId
* @returns {string|null}
*/
export function watchedPlayer(who, watching) {
if (!isObj(who) || typeof who.playerId !== 'string' || !who.playerId) return null;
return typeof watching === 'string' && watching && who.fieldId === watching ? who.playerId : null;
}
/**
* The ‹ › layer that shows `playerId`'s half of a two-half shared field (联防 with two helpers, a pair's boss field),
* or null (a normal / lone field, a player not on it). 前往查看 of such a player moves the camera there.
* @param {any} field the field meta on screen @param {any} pub @param {string} myId @param {string|null} playerId
* @returns {'L'|'R'|null}
*/
export function playerLayer(field, pub, myId, playerId) {
if (!isObj(field) || typeof playerId !== 'string' || !playerId) return null;
if (!cameraLayers(field, pub, myId).length) return null;
const side = sidesOf(field)[playerId];
return side === 'L' || side === 'R' ? side : null;
}
/**
* A player's bond list as the views carry it: your own m.private bonds (thresholds / countsHand included), a teammate's
* m.public players[].bonds ({ bondId, count, active, tier, layers }).
* @returns {any[]}
*/
export function ownerBonds({ pub, priv = null, myId, ownerId }) {
const row = (pid) => playersOf(pub).find((p) => p.playerId === pid) || null;
if (ownerId === myId) {
if (Array.isArray(priv?.bonds)) return priv.bonds;
const own = row(myId)?.bonds;
return Array.isArray(own) ? own : [];
}
const list = row(ownerId)?.bonds;
return Array.isArray(list) ? list : [];
}
/**
* Lay the live layers of the battle on screen over a bond list: `live` = { [bondId]: n } absolute counts (the runner's
* bondLayers of that player). A count only ever grows in a battle — the higher of the two wins (the view may already
* carry the settled gains) — and never shows above BOND_LAYER_CAP; a bond that gained without being listed joins the
* list (no members, not active). Returns the list itself when there is nothing to lay over.
* @param {any[]} bonds @param {Record<string, number>|null|undefined} live
*/
export function withLiveLayers(bonds, live) {
const list = Array.isArray(bonds) ? bonds : [];
if (!isObj(live)) return list;
const cap = BOND_LAYER_CAP > 0 ? BOND_LAYER_CAP : Infinity;
const ids = Object.keys(live).filter((id) => Number.isFinite(live[id]) && live[id] > 0);
if (!ids.length) return list;
let changed = false;
const out = list.map((b) => {
const n = isObj(b) && typeof b.bondId === 'string' ? live[b.bondId] : undefined;
if (!Number.isFinite(n)) return b;
const v = Math.min(cap, n);
if (!(v > (Number(b.layers) || 0))) return b;
changed = true;
return { ...b, layers: v };
});
for (const id of ids) {
if (out.some((b) => isObj(b) && b.bondId === id)) continue;
changed = true;
out.push({ bondId: id, count: 0, active: false, tier: 0, layers: Math.min(cap, live[id]) });
}
return changed ? out : list;
}
/**
* A teammate's board as far as the screen shows it, in the m.private shape the bond popup's member list reads
* (gameLogic bondMembers): their operators on the field meta on screen (the prep scouting board, a server-run battle
* field's units) and, under client-side combat, the battle on screen's (`extra` = battle/runner.js ownerOps: the meta
* the runner publishes is taken before the operators deploy). Their hand is never sent — members count as owned only
* when they are on the field. null without a field or battle.
* @param {any} field @param {string} ownerId @param {any[]|null} [extra] UnitInfo-like { kind, ownerId, defId }
*/
export function ownerBoard(field, ownerId, extra = null) {
const fromField = isObj(field) && Array.isArray(field.units) ? field.units : null;
const fromBattle = Array.isArray(extra) ? extra : null;
if (!fromField && !fromBattle) return null;
const board = [...(fromField || []), ...(fromBattle || [])]
.filter((u) => isObj(u) && u.ownerId === ownerId && u.kind === 'op' && typeof u.defId === 'string')
.map((u) => ({ kind: 'chess', id: u.defId }));
return { board, hand: [], temp: [] };
}
/**
* Everything the strip needs: whose bonds, their name (null for your own) and the list with the live layers laid over.
* @param {{ pub: any, priv?: any, myId: string, fieldId?: string|null, field?: any, layer?: 'L'|'ALL'|'R', halves?: boolean,
* watched?: string|null, live?: Record<string, Record<string, number>>|null }} o — live: the runner's bondLayers (null
* outside a battle)
* @returns {{ ownerId: string, self: boolean, name: string|null, bonds: any[] }}
*/
export function stripView({ pub, priv = null, myId, fieldId = null, field = null, layer = 'ALL', halves = false, watched = null, live = null }) {
const ownerId = bondOwnerId({ pub, myId, fieldId, field, layer, halves, watched });
const self = ownerId === myId;
const bonds = withLiveLayers(ownerBonds({ pub, priv, myId, ownerId }), isObj(live) ? live[ownerId] : null);
return { ownerId, self, name: self ? null : nameOf(pub, ownerId), bonds };
}
/**
* The game screen's strip from its state (screens/game.js): the field on screen, whether the ‹ › pill is offered for
* it, the picked teammate, the live layers (battle phases and SETTLE only) — then stripView. Also returns `fieldId`.
* @param {{ pub: any, priv?: any, myId: string, combat?: boolean, settle?: boolean, watchingOther?: boolean,
* watching?: string|null, home?: string|null, battleFieldId?: string|null, field?: any, layers?: any[],
* layer?: 'L'|'ALL'|'R', who?: { fieldId?: string, playerId?: string }|null,
* bondLayers?: Record<string, Record<string, number>>|null }} o — layers: the ‹ › pill's layers on offer
* (battle/observe.js cameraLayers of `field`, [] when not offered); bondLayers: the runner's state().bondLayers
* @returns {{ fieldId: string|null, ownerId: string, self: boolean, name: string|null, bonds: any[] }}
*/
export function screenStrip({ pub, priv = null, myId, combat = false, settle = false, watchingOther = false, watching = null, home = null,
battleFieldId = null, field = null, layers = [], layer = 'ALL', who = null, bondLayers = null }) {
const fieldId = screenFieldId({ combat, settle, watchingOther, watching, home, battleFieldId });
const halves = Array.isArray(layers) && layers.length > 0 && isObj(field) && field.fieldId === fieldId;
const live = (combat || settle) && isObj(bondLayers) ? bondLayers : null;
const watched = watchingOther ? watchedPlayer(who, watching) : null;
return { fieldId, ...stripView({ pub, priv, myId, fieldId, field, layer, halves, watched, live }) };
}
/**
* The bonds of any player with the live layers laid over (the detail card's bond chips of a teammate's unit).
* @param {{ pub: any, priv?: any, myId: string, ownerId: string, live?: Record<string, Record<string, number>>|null }} o
*/
export function playerBonds({ pub, priv = null, myId, ownerId, live = null }) {
return withLiveLayers(ownerBonds({ pub, priv, myId, ownerId }), isObj(live) ? live[ownerId] : null);
}
/**
* Whose bonds a detail card's bond chips show — and so the popup a chip opens: a unit on the field → its owner (yours or
* a teammate's, when a known player: a 联防 / 最终攻势 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 owner (`target.owner`); anything
* else (shop / reward / drawer cards, a unit without a known owner) → the strip's owner.
* @param {any} target the detail target (screens/game.js detailTarget) @param {{ pub: any, myId: string,
* stripOwnerId?: string|null }} o
* @returns {string}
*/
export function detailBondOwner(target, { pub, myId, stripOwnerId = null }) {
const known = (pid) => typeof pid === 'string' && pid !== '' && playersOf(pub).some((p) => p.playerId === pid);
if (isObj(target)) {
if (target.kind === 'unit' && known(target.unit?.ownerId)) return target.unit.ownerId;
if (target.kind === 'piece') return myId;
if (target.kind === 'chess' && known(target.owner)) return target.owner;
}
return typeof stripOwnerId === 'string' && stripOwnerId ? stripOwnerId : myId;
}
/**
* Open / close a bond popup: `open` = { id, ownerId, from } — the bond, the player it shows (the strip's owner, or the
* detail card's: detailBondOwner) and where it was opened ('strip' | 'detail'). The same bond of the same player closes it.
* @param {{ id: string, ownerId: string, from?: string }|null} open @param {string} id @param {string} ownerId
* @param {'strip'|'detail'} from
*/
export function toggleBond(open, id, ownerId, from) {
if (isObj(open) && open.id === id && open.ownerId === ownerId) return null;
return { id, ownerId, from };
}
/**
* The bond popup's data, for the player it was opened for (never simply the strip's: a chip of the other half's unit
* opens THAT player's bond): their entry with the live layers (as the strip and the chips show it), the member list's
* board (your pieces, or their operators on the field on screen — ownerBoard) and their name (null for your own).
* @param {{ open: { id: string, ownerId?: string }|null, pub: any, priv?: any, myId: string, field?: any,
* units?: any[]|null, live?: Record<string, Record<string, number>>|null }} o — units: the owner's operators in the
* battle on screen (battle/runner.js ownerOps; null outside client-side combat); live: the runner's bondLayers (null
* outside a battle)
* @returns {{ bondId: string, ownerId: string, self: boolean, name: string|null, entry: any, priv: any }|null}
*/
export function popupView({ open, pub, priv = null, myId, field = null, units = null, live = null }) {
if (!isObj(open) || typeof open.id !== 'string' || !open.id) return null;
const ownerId = typeof open.ownerId === 'string' && open.ownerId ? open.ownerId : myId;
const self = ownerId === myId;
const entry = playerBonds({ pub, priv, myId, ownerId, live }).find((b) => isObj(b) && b.bondId === open.id) || null;
return { bondId: open.id, ownerId, self, name: self ? null : nameOf(pub, ownerId), entry, priv: self ? priv : ownerBoard(field, ownerId, units) };
}