// Left team panel (research 06 §11.1, research 09 §3.1): one row per seat — avatar (band icon once picked), name, LP // tower, status glyph (… acting / ✓ ready / ⌛ deciding / ⚔ combat / door left / ✕ dead), AI badge, "you" marker, the // field being watched (eye badge), and emote bubbles. In a boss round the viewer's pair is framed in green from the // round's start (gameLogic teamFrameIds; community report of 2026-10-06, item 51). // Observing (client-side combat, `observe` prop — the official flow): tapping a teammate's avatar expands a mint // "前往查看" button under the row (when that teammate can be observed now; otherwise the reason is toasted through // onWatch); while observing, the own row shows a "返回战场" button. Without `observe` (server-run combat) a click // watches that player's field at once, and a click while that field is already on screen does not ask again. // In PHASE.PREP a double-click on a teammate's avatar opens their field (GitHub #131, the 「前往查看」 shortcut). // It does not open a field the teammate does not have, and it does not open one the click already opened. // Boss-prep phases (最终攻势 / 隐秘核心) are not PHASE.PREP, so they still use 「前往查看」. // Live LP (user playtest #3 item 2): during a normal round's battle each row's tower shows lp − the loss that player's // leaks so far will cost (red, −N): the own row the top bar's live value (`self`, ui/hud.js liveLp), a teammate's row // m.public players[].pendingLp (server/match/match/views.js, ~1 Hz). 联防 (user playtest #6 item 7): a leaker's row adds the // runner tag ×N — its enemies still standing on the 联防 field, uncapped and live (falling as the helpers kill them, // rising when one splits) — next to lp − min(lpCapPerRound, N): the own row the top bar's value, a teammate's row the // local 联防 replica's count while it is on screen (`uniteLocal`: the battle runner's state().uniteLeft, the same battle // the player watches, so the number moves with the kills on screen), else m.public players[].uniteLeft (the authority's // report, ~1 Hz). import { useEffect, useRef, useState } from '../../vendor/hooks.module.js'; import { PHASE } from '../../../shared/constants.js'; import { html, Icon, Tooltip } from './components.js'; import { PlayerAvatar, LpTower, GIcon, LocalSprite } from './gameComponents.js'; import { EmoteBubble } from './emotes.js'; import { STATUS_META, sortedPlayers, teamFrameIds } from './gameLogic.js'; import { MissTag, uniteRemaining } from './hud.js'; import { localAsset } from '../data.js'; import { t } from '../../../shared/i18n.js'; const cx = (...p) => p.flat().filter(Boolean).join(' '); /** Official status glyphs (ui/battle) per m.public players[].status; CSS glyph fallback. */ const STATUS_SPRITE = { ready: 'icon_ready', deciding: 'icon_waiting', done: 'icon_complete', dead: 'icon_dead' }; /** * A row's LP tower: the settled LP and the pending loss of the round's battle (0 outside COMBAT / UNITE). The own row * takes the top bar's live value when there is one (its m.private lp: the same number as the top bar). `left`: during * 联防 a leaker's enemies still standing (the ×N tag), else null — for a teammate the local 联防 replica's count when * one is given (`uniteLocal`, the runner's state().uniteLeft: { [playerId]: n }, absent = none left; the pending loss * is then min(cap, left)), else m.public players[].uniteLeft / pendingLp. * @param {any} p m.public players[] entry @param {any} pub m.public * @param {{ lp?: number|null, pending: number, unite: boolean, left?: number|null } | null} [self] the own live value (only for the own row) * @param {{ uniteLocal?: Record | null, cap?: number }} [opts] * @returns {{ lp: number|null, pending: number, unite: boolean, left: number|null }} */ export function rowLp(p, pub, self = null, { uniteLocal = null, cap = 10 } = {}) { const pubLp = Number.isFinite(p?.lp) ? p.lp : null; if ((pub?.phase !== PHASE.COMBAT && pub?.phase !== PHASE.UNITE) || p?.alive === false) return { lp: pubLp, pending: 0, unite: false, left: null }; const lp = self && Number.isFinite(self.lp) ? self.lp : pubLp; const leaker = pub.phase === PHASE.UNITE && Array.isArray(pub.unite?.leakers) && pub.unite.leakers.includes(p.playerId); const local = !self && leaker && uniteLocal && typeof uniteLocal === 'object' ? uniteRemaining(uniteLocal[p.playerId] ?? 0, null) : null; const rawLeft = pub.phase !== PHASE.UNITE ? null : self ? self.left : local != null ? local : p.uniteLeft; const left = Number.isFinite(rawLeft) && rawLeft >= 0 ? Math.trunc(rawLeft) : null; if (lp == null) return { lp, pending: 0, unite: false, left }; const raw = self ? self.pending : local != null ? Math.min(cap, local) : p.pendingLp; const pending = Math.min(lp, Math.max(0, Math.trunc(Number(raw) || 0))); return { lp, pending, unite: (pending > 0 || left != null) && (self ? !!self.unite : pub.phase === PHASE.UNITE), left }; } /** True when `watching` is already this teammate's field (their live field, or the prep scout `n:`). */ export function teammateFieldShown(watching, player) { if (!watching || !player) return false; return watching === player.fieldId || watching === `n:${player.playerId}`; } /** * A single click on a teammate avatar calls onWatch only in server-run combat (no `observe`), and only when * that field is not already on screen. A double-click fires click first; the second click, and any later click * while the view is already theirs, must not open it again. Client combat never watches from the avatar click. * `askedRecently`: this avatar's click already called onWatch in the last moment (the other click of a double-click, * before `watching` has re-rendered). * @param {{ observe?: unknown, watching?: string|null, player?: { playerId?: string, fieldId?: string }|null, askedRecently?: boolean }} [o] * @returns {boolean} */ export function teammateClickWatches(o = {}) { if (o.observe || o.askedRecently) return false; if (teammateFieldShown(o.watching, o.player)) return false; return !!o.player; } /** * Double-click in PHASE.PREP opens a teammate's field, skipping 「前往查看」. False — do not call onWatch — when * the phase is not PREP, the row is your own, there is no client-combat `observe` (the click already watched), * `canObserve` has no field (do not force a view; the click toasts), or that field is already open. * @param {{ phase?: string, self?: boolean, observe?: { canObserve?: (p: any) => { fieldId?: string }|null }|null, * watching?: string|null, player?: any, askedRecently?: boolean }} [o] * @returns {boolean} */ export function prepDblClickWatches(o = {}) { if (o.phase !== PHASE.PREP || o.self || !o.player || !o.observe) return false; if (o.askedRecently || teammateFieldShown(o.watching, o.player)) return false; const view = (typeof o.observe.canObserve === 'function' ? o.observe.canObserve(o.player) : null) || {}; return !!view.fieldId; } /** How long the second click of a double-click counts as "already asked" before `watching` re-renders. */ const AVATAR_CLICK_GAP_MS = 500; /** * Tooltip of a row's LP tower with a pending loss (null without one). * @param {{ lp: number|null, pending: number, unite: boolean, left: number|null } | null} lp rowLp(...) * @param {number} [cap] lpCapPerRound */ export function rowLpTip(lp, cap = 10) { if (!lp || !(lp.pending > 0)) return null; if (lp.unite && lp.left != null) return t('目标生命值 {lp},联防中:漏过的敌人还剩 {left} 个,按现在结算扣除 {pending} 点(每回合至多 {cap} 点)', { lp: lp.lp, left: lp.left, pending: lp.pending, cap }); return lp.unite ? t('目标生命值 {lp},联防中,结算时扣除至多 {pending} 点', { lp: lp.lp, pending: lp.pending }) : t('目标生命值 {lp},结算时扣除 {pending} 点', { lp: lp.lp, pending: lp.pending }); } /** * @param {{ pub:any, myId:string, watching:string|null, bubbles: Map, onWatch:(p:any)=>void, * compact?: boolean, teamLp?: number|null, self?: { lp?: number|null, pending: number, unite: boolean, left?: number|null } | null, * cap?: number, uniteLocal?: Record | null, * observe?: null | { canObserve: (p:any) => { fieldId?: string, reason?: string|null, back?: boolean }, observing: boolean, onBack: () => void } }} props * uniteLocal: the local 联防 replica's per-leaker counts while it is on screen (battle runner state().uniteLeft), else null */ export function TeamPanel({ pub, myId, watching, bubbles, onWatch, compact = false, observe = null, self: selfLive = null, cap = 10, uniteLocal = null }) { const [openPid, setOpenPid] = useState(null); // the playerId whose avatar just called onWatch, so the other click of a double-click does not open the view again const askedAt = useRef(/** @type {{ id: string|null, at: number }} */ ({ id: null, at: 0 })); const phaseKey = `${pub?.phase}:${pub?.round}`; useEffect(() => { setOpenPid(null); }, [phaseKey, watching, observe?.observing]); const players = sortedPlayers(pub); if (!players.length) return null; // a boss round: the viewer's pair framed in green from the round's start (item 51 — the official bg_team_border, // tinted like the official green; a plain green ring without the local art) const team = teamFrameIds(pub, myId); const frameArt = team.size ? localAsset('ui/battle', 'bg_team_border') : null; const askedRecently = (p) => askedAt.current.id === p?.playerId && Date.now() - askedAt.current.at < AVATAR_CLICK_GAP_MS; const noteAsked = (p) => { askedAt.current = { id: p.playerId, at: Date.now() }; }; const click = (p, self) => { if (!observe) { // your own row still goes home; a teammate already on screen (or just opened by this double-click) does not if (self || teammateClickWatches({ watching, player: p, askedRecently: askedRecently(p) })) { if (!self) noteAsked(p); onWatch(p); } return; } if (self) { if (observe.observing) observe.onBack(); setOpenPid(null); return; } const view = observe.canObserve(p) || {}; if (!view.fieldId) { setOpenPid(null); if (!askedRecently(p)) { noteAsked(p); onWatch(p); } // the game screen toasts the reason; a double-click toasts once return; } setOpenPid((cur) => (cur === p.playerId ? null : p.playerId)); }; const dblClick = (e, p, self) => { if (!prepDblClickWatches({ phase: pub?.phase, self, observe, watching, player: p, askedRecently: askedRecently(p) })) return; e.preventDefault(); setOpenPid(null); noteAsked(p); onWatch(p); }; return html``; }