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:
1 parent
620f88c0d8
commit
82c64086d5
21 files changed
+2087
-57
No files matched your search
@@ -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; }
|
||||
|
||||
@@ -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 ------------------------------------------------------------------------------------------------ */
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
Reference in new issue
Block a user