feedback7 UIBACK: the match's top bar lists the spectator seats — a 观战席 capsule (eye + count) beside the latency opens the roster, the host removes a spectator with its ✕ while the match runs (PR #120 by @salt-fishes, the part the owner accepted: the cap stays 2, no per-room spectator option); the server already took room.removeSpectator at any time — test/match/lobby-integration.test.js now covers the host removing one mid-match (room.closed kicked, the other spectator and the host told, no frame after it, the seat refilled), test/ui/spectator-roster.e2e.test.js the capsule; one new UI string in the four packs; the dev mock seats spectators on ?variant=spectators; golden: unchanged (client only)

This commit is contained in:
sganggs committed 2026-10-08 19:19:09 +08:00
1 parent 7fb41f4ff6
commit d7c665dbf6
13 files changed
+273 -9

No files matched your search

+15
View File
@@ -30,6 +30,21 @@
.gtop > * { pointer-events: auto; }
.gtop__left { display: flex; align-items: center; gap: .16rem; }
.gtop__meta { display: flex; flex-direction: column; align-items: flex-start; gap: .06rem; }
/* the latency pill and, beside it, the 观战席 capsule (ui/hud.js SpectatorPill: an eye + how many are watching; opens the roster) */
.gtop__net { display: flex; align-items: center; gap: .08rem; }
.specpill { --btn-bg: rgba(0, 0, 0, .5); --btn-bg-hover: rgba(0, 0, 0, .66); --btn-bd: var(--line); height: .32rem; min-width: 0; padding: 0 .12rem; gap: .05rem; border-radius: 99px; }
.specpill .btn__icon { width: .18rem; height: .18rem; color: var(--mint-500); }
.specpill__num { font-size: .18rem; font-weight: 700; color: var(--mint-400); }
.specpill.is-me { --btn-bd: var(--mint-600); }
/* the roster inside its modal */
.spec__roster { display: flex; flex-direction: column; gap: .06rem; margin: 0 0 .1rem; padding: 0; list-style: none; }
.spec__row { display: flex; align-items: center; gap: .1rem; min-height: .44rem; padding: .04rem .1rem; background: rgba(255, 255, 255, .03); border: 1px solid transparent; }
.spec__row.is-me { border-color: var(--mint-700); background: rgba(45, 212, 191, .08); }
.spec__row.is-offline .spec__name { color: var(--text-dim); }
.spec__ico { width: .18rem; height: .18rem; flex: none; color: var(--mint-500); }
.spec__row.is-offline .spec__ico { color: var(--text-dim); }
.spec__name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .19rem; color: var(--text-hi); }
.spec__hint { margin: .04rem 0 0; font-size: .15rem; line-height: 1.45; }
.gtop__exit { box-shadow: 0 .06rem .2rem rgba(0, 0, 0, .5); }
.gtop__center {
--bk-c: var(--mint-700);
+14 -1
View File
@@ -724,6 +724,15 @@ async function mockRequest(t, f = {}) {
return {};
}
case 'g.autoplay': case 'g.leave': case 'room.leave': return {};
// the host frees a spectator seat (server/lobby.js removeSpectator; ?variant=spectators seats two, ?variant=notHost makes
// another player the host)
case 'room.removeSpectator': {
const room = store.get().room;
if (room?.hostId !== ME) fail('NOT_HOST');
if (!(room.spectators || []).some((x) => x.playerId === f.playerId)) fail('BAD_TARGET');
store.set({ room: { ...room, spectators: room.spectators.filter((x) => x.playerId !== f.playerId) } });
return {};
}
default: return {};
}
}
@@ -798,7 +807,11 @@ async function boot() {
me: { playerId: ME, name: '凯尔希', token: null },
connection: { status: 'online', ping: 42, attempt: 0, retryAt: 0, lastError: null, everOnline: true },
clock: { offset: 0, rtt: 20, synced: true },
room: { code: 'MOCK', hostId: ME, mode: 'coop', difficulty: 'HARD', inMatch: true, seats: PLAYERS.map((p) => ({ seat: p.seat, playerId: p.playerId, name: p.name, isBot: p.isBot, ready: true, connected: true })) },
room: {
code: 'MOCK', hostId: VARIANTS.has('notHost') ? 'ai_2' : ME, mode: 'coop', difficulty: 'HARD', inMatch: true,
seats: PLAYERS.map((p) => ({ seat: p.seat, playerId: p.playerId, name: p.name, isBot: p.isBot, ready: true, connected: true })),
...(VARIANTS.has('spectators') ? { spectators: [{ playerId: 'sp_1', name: '观战者甲', connected: true }, { playerId: 'sp_2', name: '观战者乙', connected: false }] } : {}),
},
});
await data.loadAll(GAME_FILES);
installDeviceSupport();
+1
View File
@@ -777,6 +777,7 @@
"观战席": "Spectators",
"移出观战者 {name}": "Remove spectator {name}",
"移出该观战者": "Remove this spectator",
"观战者不占博士席位,只能观看;创建者可以把观战者移出,被移出的人不再收到战场画面。": "Spectators take no Doctor seat and can only watch; the Host can remove a spectator, who then no longer receives the battlefield.",
"入座": "Take a Seat",
"移出同盟": "Remove from Alliance",
"确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。": "Remove \"{name}\" from the Alliance? They can rejoin with the Alliance Key.",
+1
View File
@@ -778,6 +778,7 @@
"观战席": "観戦席",
"移出观战者 {name}": "観戦者{name}を除外",
"移出该观战者": "この観戦者を除外",
"观战者不占博士席位,只能观看;创建者可以把观战者移出,被移出的人不再收到战场画面。": "観戦者はドクター枠を使わず、観戦のみ可能です。ホストは観戦者を除外でき、除外された人には戦場の映像が届かなくなります。",
"入座": "着席",
"移出同盟": "同盟から除外",
"确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。": "「{name}」を同盟から除外しますか?相手は同盟コードで再参加できます。",
+1
View File
@@ -774,6 +774,7 @@
"观战席": "관전석",
"移出观战者 {name}": "관전자 {name} 내보내기",
"移出该观战者": "이 관전자 내보내기",
"观战者不占博士席位,只能观看;创建者可以把观战者移出,被移出的人不再收到战场画面。": "관전자는 박사 자리를 차지하지 않고 관전만 할 수 있으며, 방장은 관전자를 내보낼 수 있습니다. 내보내진 사람은 더 이상 전장 화면을 받지 않습니다.",
"入座": "착석",
"移出同盟": "동맹에서 내보내기",
"确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。": "{name} 님을 동맹에서 내보내시겠습니까? 동맹 코드로 다시 참가할 수 있습니다.",
+1
View File
@@ -776,6 +776,7 @@
"观战席": "觀戰席",
"移出观战者 {name}": "移出觀戰者 {name}",
"移出该观战者": "移出該觀戰者",
"观战者不占博士席位,只能观看;创建者可以把观战者移出,被移出的人不再收到战场画面。": "觀戰者不佔博士席位,只能觀看;建立者可以把觀戰者移出,被移出的人不再收到戰場畫面。",
"入座": "入座",
"移出同盟": "移出同盟",
"确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。": "確定將「{name}」移出同盟嗎?對方可以憑同盟金鑰重新加入。",
+9 -1
View File
@@ -159,6 +159,12 @@ function MatchScreen() {
const emotes = useStore((s) => s.emotes);
const roomSolo = useStore((s) => s.room?.mode === 'solo');
const spectator = useStore((s) => isSpectating(s.room, s.me.playerId));
// the room's spectator seats for the top bar's 观战席 capsule (ui/hud.js SpectatorPill; GitHub #120): room.state keeps coming
// during the match, the list is replaced only when a seat changes — shallowEqual keeps an unrelated push from re-rendering
const specFacts = useStore((s) => ({
list: Array.isArray(s.room?.spectators) ? s.room.spectators : null,
isHost: !!s.room && s.room.hostId === s.me.playerId,
}), shallowEqual);
const gd = useGameData();
const hostRef = useRef(null);
@@ -717,6 +723,7 @@ function MatchScreen() {
// ---- actions ----------------------------------------------------------------------------------------------
const buy = useCallback((i) => actions.buy(i), []);
const removeSpectator = useCallback((playerId) => actions.removeSpectator(playerId), []);
// 准备 with funds left asks first: the prep's end wipes them (community report #4; not 坎诺特, not at 0 funds, not
// under AI 托管 — gameLogic.readyFundsPrompt). The button and Space both come here.
const askingReady = useRef(false);
@@ -1363,7 +1370,8 @@ function MatchScreen() {
readyBusy=${readyBusy} readyCount=${readyCount} playerCount=${solo ? 1 : aliveCount}
pen=${pen} penAvail=${penAvail} onPen=${togglePen} config=${gd.config} frozenAt=${frozenAt}
pause=${canPause || paused ? { show: canPause, paused, busy: pauseBusy, onToggle: () => togglePause(!paused) } : null}
live=${liveLpNow} spectator=${spectator} />
live=${liveLpNow} spectator=${spectator}
spectators=${specFacts.list} myId=${myId} isHost=${specFacts.isHost} onRemoveSpectator=${removeSpectator} />
<div class="gm__bonds">
<button type="button" class="bonds-toggle" aria-expanded=${!bondsCollapsed} aria-controls="match-bond-strip"
+3
View File
@@ -72,4 +72,7 @@ export const actions = {
autoplay: (on) => act('g.autoplay', { on }),
// solo battles only (ui/matchStatus.js pauseAvailable): m.public.paused follows
pause: (on) => act('g.pause', { on: !!on }, { sfx: on ? 'click' : 'confirm' }),
// room-level intent (NOT g.*): the host frees a spectator seat while the match runs — the server takes room.removeSpectator at
// any time (server/lobby.js removeSpectator), the game screen had no entry for it (ui/hud.js SpectatorPill; GitHub #120)
removeSpectator: (playerId) => act('room.removeSpectator', { playerId }, { sfx: 'back' }),
};
+50 -5
View File
@@ -23,9 +23,9 @@
// 准备就绪 is refused while the temp overflow row (临时整备区) holds pieces: the reason shows under the button
// (user playtest #3 item 3; the row's own label is ui/underframe.js TempRowNotice).
import { useRef } from '../../vendor/hooks.module.js';
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { PHASE } from '../../../shared/constants.js';
import { html, Button, Icon, PingPill, Countdown, Tooltip, MicroLabel, DifficultyTag, useTicker } from './components.js';
import { html, Button, Icon, PingPill, Countdown, Tooltip, MicroLabel, DifficultyTag, Modal, useTicker } from './components.js';
import { Sprite, LpTower, GIcon, LocalSprite } from './gameComponents.js';
import { localAsset } from '../data.js';
import { serverNow } from '../store.js';
@@ -36,6 +36,45 @@ import { t, tParts, N_ } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/**
* 观战席 capsule of the in-match top bar (GitHub #120, PR #120 by @salt-fishes; the spectator seats of community report #26):
* the room's spectators, which the room screen lists in a strip but the game screen — the route after 开始 — used to hide
* completely, so a spectator could only be removed before the match or after it. A small eye + the count beside the latency;
* a tap opens the roster, with the host's ✕ on every row (room.removeSpectator: the server takes it at any time; the removed
* seat gets room.closed {kicked} and no more frames). Renders nothing while no spectator seat is taken.
* @param {{ spectators: any[]|null, myId: any, isHost: boolean, onRemove: ((playerId: any) => any)|null }} props
*/
export function SpectatorPill({ spectators, myId, isHost, onRemove }) {
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(null);
const list = Array.isArray(spectators) ? spectators.filter((s) => s && typeof s === 'object') : [];
// the roster closes with its last seat (the pill is gone: it must not pop up again for the next spectator)
useEffect(() => { if (!list.length) setOpen(false); }, [list.length]);
if (!list.length) return null;
const remove = async (playerId) => {
if (!onRemove) return;
setBusy(playerId);
try { await onRemove(playerId); } finally { setBusy(null); }
};
return html`<${Button} variant="ghost" size="sm" icon="eye" class=${cx('specpill', list.some((s) => s.playerId === myId) && 'is-me')}
onClick=${() => setOpen(true)} data-testid="spectators" title=${t('观战席')} aria-label=${t('观战席')}>
<span class="specpill__num num">${list.length}</span>
<//>
${open ? html`<${Modal} open=${true} title=${t('观战席')} micro="SPECTATORS" width="6.4rem" onClose=${() => setOpen(false)}
actions=${html`<${Button} variant="secondary" onClick=${() => setOpen(false)}>${t('关闭')}<//>`}>
<ul class="spec__roster" data-testid="spectator-roster">
${list.map((s) => html`<li key=${s.playerId} class=${cx('spec__row', s.playerId === myId && 'is-me', s.connected === false && 'is-offline')}>
<${Icon} name=${s.connected === false ? 'wifiOff' : 'eye'} class="spec__ico" />
<span class="spec__name">${s.name || t('博士')}</span>
${s.playerId === myId ? html`<span class="seat__you">${t('你')}</span>` : null}
${isHost && onRemove ? html`<${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === s.playerId} data-testid="spectator-remove"
onClick=${() => remove(s.playerId)} aria-label=${t('移出观战者 {name}', { name: s.name || '' })} title=${t('移出该观战者')} />` : null}
</li>`)}
</ul>
<p class="spec__hint t-lo">${t('观战者不占博士席位,只能观看;创建者可以把观战者移出,被移出的人不再收到战场画面。')}</p>
<//>` : null}`;
}
/**
* Phase capsule: prep label, kills n/m (combat/unite), kills + boss HP bar (boss rounds).
* @param {{ pub:any, hud:any }} props
@@ -306,13 +345,16 @@ export function PauseButton({ paused, busy = false, onToggle }) {
* onReady:(r:boolean)=>void, readyBusy?:boolean, readyCount?:number, playerCount?:number,
* pen?:boolean, penAvail?:boolean, onPen?:(on:boolean)=>void, config?: any, frozenAt?: number|null,
* pause?: { show: boolean, paused: boolean, busy?: boolean, onToggle: () => void } | null,
* live?: { pending: number, unite: boolean, left?: number|null } | null }} props
* live?: { pending: number, unite: boolean, left?: number|null } | null,
* spectators?: any[]|null, myId?: any, isHost?: boolean, onRemoveSpectator?: ((playerId: any) => any)|null }} props
* spectators: the room's spectator seats (room.state) — the 观战席 capsule beside the latency (SpectatorPill; the host removes)
* frozenAt: the server time every clock shows while the solo match is paused (null = live)
* live: the own battle's pending LP loss (liveLp): the tower shows lp − pending in red with a −N tick, 联防中 during 联防;
* `left` (a leaker in 联防): its enemies still standing — the capsule's ×N tag
*/
export function TopBar({ pub, priv, conn, hud, total, drawer, onExit, onDrawer, onReady, readyBusy, readyCount, playerCount, pen = false, penAvail = false, onPen = () => {},
config = null, frozenAt = null, pause = null, live = null, spectator = false }) {
config = null, frozenAt = null, pause = null, live = null, spectator = false,
spectators = null, myId = null, isHost = false, onRemoveSpectator = null }) {
const phase = pub?.phase;
const boss = isBossPhase(phase);
const lp = boss && Number.isFinite(pub?.teamLp) ? pub.teamLp : Number.isFinite(priv?.lp) ? priv.lp : null;
@@ -338,7 +380,10 @@ export function TopBar({ pub, priv, conn, hud, total, drawer, onExit, onDrawer,
<div class="gtop__left">
<${Button} variant="danger" size="lg" square=${true} icon="exit" onClick=${onExit} aria-label=${t('离开')} title=${t('离开 / 暂离')} class="gtop__exit tapx" />
<div class="gtop__meta">
<${PingPill} ms=${conn?.ping} online=${conn?.status === 'online'} />
<div class="gtop__net">
<${PingPill} ms=${conn?.ping} online=${conn?.status === 'online'} />
<${SpectatorPill} spectators=${spectators} myId=${myId} isHost=${isHost} onRemove=${onRemoveSpectator} />
</div>
${pub?.difficulty ? html`<${DifficultyTag} difficulty=${pub.difficulty} size="sm" />` : null}
</div>
</div>