Files
ark-stronghold-protocol/public/js/ui/teamPanel.js
T
shadiaojumao-hyb 073507eca3 feedback7 G-SMALL: double-click a teammate avatar in prep to open their field (PR #374 by @shadiaojumao-hyb)
Applied the PHASE.PREP double-click on a teammate's avatar. 「前往查看」 stays. Boss-prep phases still use that button.

A teammate with no field is not opened: the click still toasts, and the double-click does not call onWatch. A click while that teammate is already on screen does not open the view again, including the second click of a double-click in server-run combat, where the first click already watches.

Tests: node --test test/ui/prep-dblclick.test.js test/ui/playtest6-unite.test.js — 13 pass. node --test test/ui/playtest3.test.js — 25 pass (same run, before the source-assertion fix; that file was not changed again). npx eslint public/js/ui/teamPanel.js test/ui/prep-dblclick.test.js — 0 errors.
2026-10-08 20:00:57 +08:00

204 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<string, number> | 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:<id>`). */
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<string,{id:string,seq:number}>, 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<string, number> | 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`<aside class=${cx('team', compact && 'team--compact')} aria-label=${t('同盟成员')}>
${players.map((p) => {
const self = p.playerId === myId;
const status = p.alive === false ? 'dead' : p.status;
const meta = STATUS_META[status] || STATUS_META.acting;
const watched = teammateFieldShown(watching, p);
const bubble = bubbles?.get(p.playerId);
const offline = p.connected === false && !p.isBot;
const open = !!observe && openPid === p.playerId && !self;
const back = !!observe && self && observe.observing;
const title = observe ? (self ? (observe.observing ? t('返回战场') : t('你自己')) : t('查看 {name} 的战场', { name: p.name })) : (self ? t('查看自己的阵地') : t('查看 {name} 的阵地', { name: p.name }));
const lp = rowLp(p, pub, self ? selfLive : null, { uniteLocal, cap });
const inTeam = team.has(p.playerId);
return html`<div key=${p.playerId} class=${cx('team__row', self && 'is-self', inTeam && 'is-team', watched && 'is-watched', p.alive === false && 'is-dead', open && 'is-open')}>
<button type="button" class="team__btn" onClick=${() => click(p, self)} onDblClick=${(e) => dblClick(e, p, self)} title=${inTeam && !self ? `${title} · ${t('与你在同一战场')}` : title} aria-expanded=${observe && !self ? String(open) : undefined}>
<${PlayerAvatar} player=${p} self=${self} />
${inTeam ? html`<span class=${cx('team__frame', !frameArt && 'team__frame--plain')} style=${frameArt ? `--frame:url("${frameArt}")` : undefined} aria-hidden="true"></span>` : null}
<span class="team__seat num">P${(p.seat ?? 0) + 1}</span>
${p.isBot ? html`<span class="team__ai">AI</span>` : null}
${self ? html`<span class="team__you"><${Icon} name="user" /></span>` : null}
</button>
<div class="team__info">
<span class="team__name">${p.name || t('博士')}</span>
<div class="team__line">
<${LpTower} value=${lp.lp} size="sm" tone=${Number.isFinite(lp.lp) && lp.lp - lp.pending <= 5 ? 'danger' : null} pending=${lp.pending}
tip=${rowLpTip(lp, cap)} />
${lp.left != null ? html`<${MissTag} n=${lp.left} name=${self ? null : p.name || t('博士')} />` : null}
<${Tooltip} text=${offline ? t('连接已断开') : t(meta.text)} placement="right">
<span class=${cx('team__status', `is-${meta.tone}`, offline && 'is-offline', (offline || STATUS_SPRITE[status]) && localAsset('ui/battle', offline ? 'icon_lost_connect' : STATUS_SPRITE[status]) && 'has-sprite')} aria-label=${t(meta.text)}>
${offline ? html`<${LocalSprite} name="icon_lost_connect" fallback=${html`<${Icon} name="wifiOff" />`} />`
: STATUS_SPRITE[status] ? html`<${LocalSprite} name=${STATUS_SPRITE[status]} fallback=${html`<${GIcon} name=${meta.glyph} />`} />`
: html`<${GIcon} name=${meta.glyph} />`}
</span>
<//>
${watched && !self ? html`<span class="team__eye" title=${t('正在查看')}><${GIcon} name="eye" /></span>` : null}
</div>
${open ? html`<button type="button" class="btn btn--primary btn--sm team__ob"
onClick=${() => { setOpenPid(null); onWatch(p); }}><span class="btn__label">${t('前往查看')}</span></button>` : null}
${back ? html`<button type="button" class="btn btn--secondary btn--sm team__back"
onClick=${() => observe.onBack()}><span class="btn__label">${t('返回战场')}</span></button>` : null}
</div>
${bubble ? html`<${EmoteBubble} key=${bubble.seq} id=${bubble.id} class="team__bubble" />` : null}
</div>`;
})}
</aside>`;
}