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:
1 parent
7fb41f4ff6
commit
d7c665dbf6
13 files changed
+273
-9
No files matched your search
@@ -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
@@ -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();
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -778,6 +778,7 @@
|
||||
"观战席": "観戦席",
|
||||
"移出观战者 {name}": "観戦者{name}を除外",
|
||||
"移出该观战者": "この観戦者を除外",
|
||||
"观战者不占博士席位,只能观看;创建者可以把观战者移出,被移出的人不再收到战场画面。": "観戦者はドクター枠を使わず、観戦のみ可能です。ホストは観戦者を除外でき、除外された人には戦場の映像が届かなくなります。",
|
||||
"入座": "着席",
|
||||
"移出同盟": "同盟から除外",
|
||||
"确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。": "「{name}」を同盟から除外しますか?相手は同盟コードで再参加できます。",
|
||||
|
||||
@@ -774,6 +774,7 @@
|
||||
"观战席": "관전석",
|
||||
"移出观战者 {name}": "관전자 {name} 내보내기",
|
||||
"移出该观战者": "이 관전자 내보내기",
|
||||
"观战者不占博士席位,只能观看;创建者可以把观战者移出,被移出的人不再收到战场画面。": "관전자는 박사 자리를 차지하지 않고 관전만 할 수 있으며, 방장은 관전자를 내보낼 수 있습니다. 내보내진 사람은 더 이상 전장 화면을 받지 않습니다.",
|
||||
"入座": "착석",
|
||||
"移出同盟": "동맹에서 내보내기",
|
||||
"确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。": "{name} 님을 동맹에서 내보내시겠습니까? 동맹 코드로 다시 참가할 수 있습니다.",
|
||||
|
||||
@@ -776,6 +776,7 @@
|
||||
"观战席": "觀戰席",
|
||||
"移出观战者 {name}": "移出觀戰者 {name}",
|
||||
"移出该观战者": "移出該觀戰者",
|
||||
"观战者不占博士席位,只能观看;创建者可以把观战者移出,被移出的人不再收到战场画面。": "觀戰者不佔博士席位,只能觀看;建立者可以把觀戰者移出,被移出的人不再收到戰場畫面。",
|
||||
"入座": "入座",
|
||||
"移出同盟": "移出同盟",
|
||||
"确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。": "確定將「{name}」移出同盟嗎?對方可以憑同盟金鑰重新加入。",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user