feedback3 WE: spectator seats in the browser — 观战 in the lobby, the 观战席 strip in the room (host removes, 入座), watch-only match screen; docs (community report #26)
This commit is contained in:
1 parent
42766172d1
commit
976475efff
21 files changed
+352
-30
No files matched your search
@@ -421,6 +421,8 @@
|
||||
padding: .1rem .26rem; background: rgba(40, 8, 6, .9); border: 1px solid rgba(255, 84, 84, .5); color: var(--red-premium); font-size: .18rem; font-weight: 700;
|
||||
}
|
||||
.gm__dead .icon { width: .22rem; height: .22rem; }
|
||||
/* a spectator seat (community report #26): the same place, the watching pill's amber — nobody was eliminated */
|
||||
.gm__dead--spectator { background: rgba(24, 16, 4, .92); border-color: rgba(246, 163, 41, .6); color: var(--amber); }
|
||||
|
||||
.chud { position: absolute; inset: 0; pointer-events: none; }
|
||||
.chud > * { pointer-events: auto; }
|
||||
|
||||
@@ -208,6 +208,35 @@
|
||||
.solo-brief li::before { content: ''; display: inline-block; width: .06rem; height: .06rem; margin: 0 .12rem .02rem 0; background: var(--mint-600); transform: rotate(45deg); vertical-align: middle; }
|
||||
.solo-brief b { color: var(--text-hi); }
|
||||
|
||||
/* ---- spectator seats (观战席, community report #26) -------------------------------------------------------------- */
|
||||
.specbar {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: .14rem;
|
||||
padding: 0 .6rem .16rem;
|
||||
font-size: .15rem;
|
||||
color: var(--text-lo);
|
||||
}
|
||||
.specbar__label { display: flex; align-items: baseline; gap: .06rem; font-weight: 700; color: var(--text-md); }
|
||||
.specbar__label .icon { width: .18rem; height: .18rem; align-self: center; color: var(--mint-500); }
|
||||
.specbar__label b { color: var(--mint-400); }
|
||||
.specbar__who {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .06rem;
|
||||
min-height: .36rem;
|
||||
padding: 0 .14rem;
|
||||
border: 1px solid var(--line-2);
|
||||
background: rgba(0, 0, 0, .32);
|
||||
color: var(--text-hi);
|
||||
}
|
||||
.specbar__who.is-me { border-color: var(--mint-600); }
|
||||
.specbar__who.is-offline { color: var(--text-dim); }
|
||||
.specbar__who > .icon { width: .16rem; height: .16rem; }
|
||||
|
||||
/* ---- bottom bar ----------------------------------------------------------------------------------------- */
|
||||
.room-bar {
|
||||
position: relative;
|
||||
|
||||
+3
-3
@@ -32,7 +32,7 @@ import { html, UiHosts, Button, MicroLabel, closeAllDialogs } from './ui/compone
|
||||
import { ConnectionBanner } from './ui/connBanner.js';
|
||||
import { ToastHost, toast, toastError, describeError } from './ui/toasts.js';
|
||||
import { net, identity, NetError } from './net.js';
|
||||
import { store, useStore, emptyMatch, selectRoute, sessionResetNotice } from './store.js';
|
||||
import { store, useStore, emptyMatch, selectRoute, sessionResetNotice, isSpectating } from './store.js';
|
||||
import { data } from './data.js';
|
||||
import { GAME_FILES } from './ui/gameComponents.js';
|
||||
import { TitleScreen, sanitizeName } from './screens/title.js';
|
||||
@@ -168,8 +168,8 @@ function onRoomState(msg) {
|
||||
roomStateAt = Date.now();
|
||||
const myId = store.get().me.playerId;
|
||||
const seats = Array.isArray(room.seats) ? room.seats : [];
|
||||
if (myId != null && seats.length && !seats.some((s) => s && s.playerId === myId)) {
|
||||
// We are no longer seated (kicked / left elsewhere).
|
||||
if (myId != null && seats.length && !seats.some((s) => s && s.playerId === myId) && !isSpectating(room, myId)) {
|
||||
// We are no longer seated (kicked / left elsewhere) — neither in a player seat nor a spectator seat.
|
||||
if (store.get().room) toast('你已不在该同盟中', 'warn');
|
||||
store.set({ room: null, match: emptyMatch() });
|
||||
return;
|
||||
|
||||
@@ -212,7 +212,8 @@ export function BandDraftScreen() {
|
||||
const timed = !solo && !!pub?.draft && !pub.draft.untimed;
|
||||
const focusSent = useRef(null);
|
||||
useEffect(() => {
|
||||
if (!timed || myPick || !sel || focusSent.current === sel) return;
|
||||
// (a spectator seat — community report #26 — is in no draft order: it never reports)
|
||||
if (!timed || myPick || !sel || focusSent.current === sel || !draft.order.includes(myId)) return;
|
||||
focusSent.current = sel;
|
||||
act('g.bandFocus', { bandId: sel }, { sfx: false, quiet: true });
|
||||
}, [sel, timed, myPick]);
|
||||
|
||||
@@ -92,8 +92,8 @@ export function BriefingScreen() {
|
||||
<span class="brief-ready__txt">已就绪 <b class="num">${readyN}</b><span class="num">/${players.length}</span></span>
|
||||
<span class="brief-ready__pips">${players.map((p) => html`<i key=${p.playerId} class=${cx(p.ready && 'on', p.playerId === myId && 'me')} title=${p.name}><${Icon} name="user" /></i>`)}</span>
|
||||
</div>
|
||||
<${Button} variant="primary" size="xl" icon=${me?.ready ? 'check' : 'play'} active=${!!me?.ready} loading=${busy}
|
||||
disabled=${!!me?.ready || !me} onClick=${ready}>${me?.ready ? '已就绪' : '准备就绪'}<//>
|
||||
<${Button} variant="primary" size="xl" icon=${me?.ready ? 'check' : me ? 'play' : 'eye'} active=${!!me?.ready} loading=${busy}
|
||||
disabled=${!!me?.ready || !me} onClick=${ready}>${me?.ready ? '已就绪' : me ? '准备就绪' : '观战中'}<//>
|
||||
</footer>
|
||||
<${ExitModal} open=${exit} onClose=${() => setExit(false)} solo=${solo} />
|
||||
</div>`;
|
||||
|
||||
@@ -58,6 +58,10 @@
|
||||
// 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.
|
||||
// Spectator seats (community report #26, a remake feature): a spectator has no m.private and plays like an ELIMINATED
|
||||
// player — no shop, no ready, no emotes (▸ [ASSUMED] off), the server auto-observes the first field of every battle, a
|
||||
// team row → 前往查看 any player; in 休整期 / 机变 / round start it is shown the first player's board by itself (once per
|
||||
// phase). Its pill reads 观战中, never "你已被淘汰"; its exit only leaves the seat (ui/matchChrome.js ExitModal).
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
|
||||
import { PHASE, GEO } from '../../../shared/constants.js';
|
||||
@@ -99,7 +103,7 @@ import { BriefingScreen } from './briefing.js';
|
||||
import { BandDraftScreen } from './bandDraft.js';
|
||||
import { ResultScreen } from './result.js';
|
||||
import { net } from '../net.js';
|
||||
import { store, useStore, shallowEqual, serverNow, emptyMatch } from '../store.js';
|
||||
import { store, useStore, shallowEqual, serverNow, emptyMatch, isSpectating } from '../store.js';
|
||||
import { battleRunner } from '../battle/runner.js';
|
||||
import { isClientCombat, observeTarget, teammateProgress, cameraLayers, layerCamera, sidesOf, resumedWatch } from '../battle/observe.js';
|
||||
import { screenStrip, playerBonds, playerLayer, detailBondOwner, toggleBond, popupView } from '../ui/watchBonds.js';
|
||||
@@ -184,6 +188,7 @@ function MatchScreen() {
|
||||
const conn = useStore((s) => s.connection, shallowEqual);
|
||||
const emotes = useStore((s) => s.emotes);
|
||||
const roomSolo = useStore((s) => s.room?.mode === 'solo');
|
||||
const spectator = useStore((s) => isSpectating(s.room, s.me.playerId));
|
||||
const gd = useGameData();
|
||||
|
||||
const hostRef = useRef(null);
|
||||
@@ -225,7 +230,8 @@ function MatchScreen() {
|
||||
const solo = roomSolo || String(pub?.modeId || '').includes('single');
|
||||
const players = sortedPlayers(pub);
|
||||
const meP = players.find((p) => p.playerId === myId) || null;
|
||||
const alive = priv ? priv.alive !== false : meP?.alive !== false;
|
||||
// a spectator seat watches like an eliminated player (it has no m.private and no row in m.public)
|
||||
const alive = spectator ? false : priv ? priv.alive !== false : meP?.alive !== false;
|
||||
const home = homeFieldId(pub, myId);
|
||||
const watchingOther = !!watching && watching !== home && watching !== ownFieldId(myId);
|
||||
const editable = phase === PHASE.PREP && !!priv && alive && !priv.ready && !watchingOther;
|
||||
@@ -706,6 +712,18 @@ function MatchScreen() {
|
||||
|
||||
const watchField = useCallback((fid) => { requestWatch(fid); }, []);
|
||||
|
||||
// a spectator seat has no board of its own: in 休整期 / 机变 / round start it is shown the first player still in (as a
|
||||
// tap on that row would — g.watch 'n:<pid>', the read-only board), once per phase; a row switches to another player
|
||||
const scoutedRef = useRef(null);
|
||||
useEffect(() => {
|
||||
if (!spectator || watching || !pub || scoutedRef.current === phaseKey) return;
|
||||
if (phase !== PHASE.PREP && phase !== PHASE.SP_DRAFT && phase !== PHASE.ROUND_START) return;
|
||||
const first = players.find((p) => p.alive !== false && p.status !== 'left');
|
||||
if (!first) return;
|
||||
scoutedRef.current = phaseKey;
|
||||
requestWatch(ownFieldId(first.playerId), first.playerId);
|
||||
}, [spectator, phaseKey, watching]);
|
||||
|
||||
// ---- view events (drag & drop, clicks) ----------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
if (!view) return undefined;
|
||||
@@ -1190,7 +1208,7 @@ 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} />
|
||||
live=${liveLpNow} spectator=${spectator} />
|
||||
|
||||
<div class="gm__bonds">
|
||||
<${BondStrip} bonds=${stripBonds} layersDisabled=${layersDisabled} openId=${bondPop && bondPop.ownerId === strip.ownerId ? bondPop.bondId : null}
|
||||
@@ -1205,7 +1223,7 @@ function MatchScreen() {
|
||||
|
||||
${watchingOther && !combat ? html`<div class="gm__watching" role="status">
|
||||
<${GIcon} name="eye" /><span>正在查看 <b>${watchedName}</b> 的阵地(只读)</span>
|
||||
<${Button} size="sm" variant="primary" icon="back" onClick=${() => watchPlayer({ playerId: myId })}>返回自己<//>
|
||||
${spectator ? null : html`<${Button} size="sm" variant="primary" icon="back" onClick=${() => watchPlayer({ playerId: myId })}>返回自己<//>`}
|
||||
</div>` : null}
|
||||
|
||||
${showShop ? html`<${ShopBar} priv=${priv} editable=${editable} collapsed=${collapsed} onCollapse=${setCollapsed}
|
||||
@@ -1221,15 +1239,17 @@ function MatchScreen() {
|
||||
onMinimize=${(m) => { setRewardMin(m); if (!m) setCollapsed(false); }} />` : null}
|
||||
|
||||
${combat || mode === 'settle' ? html`<${CombatHud} pub=${pub} myId=${myId} watching=${watchingNow} hud=${hud} myDone=${!!myDone && alive}
|
||||
spectating=${!alive} onWatch=${watchField}
|
||||
spectating=${!alive} spectator=${spectator} onWatch=${watchField}
|
||||
client=${cc ? { progress, observing: observingName ? { name: observingName } : null, onBack: alive ? backHome : null, layers, layer, onLayer: setLayer } : null} />` : null}
|
||||
|
||||
${showDeadPill(alive, phase) ? html`<div class="gm__dead" role="status"><${Icon} name="close" />你已被淘汰 · 可继续观战队友</div>` : null}
|
||||
${showDeadPill(alive, phase) ? (spectator
|
||||
? html`<div class="gm__dead gm__dead--spectator" role="status"><${GIcon} name="eye" />观战中 · 点击左侧成员头像切换查看</div>`
|
||||
: html`<div class="gm__dead" role="status"><${Icon} name="close" />你已被淘汰 · 可继续观战队友</div>`) : null}
|
||||
|
||||
<${Ticker} />
|
||||
|
||||
<div class="gm__corner">
|
||||
<${EmoteWheel} open=${emoteOpen} onToggle=${setEmoteOpen} onSend=${(id) => actions.emote(id)} disabled=${conn.status !== 'online'} />
|
||||
${spectator ? null : html`<${EmoteWheel} open=${emoteOpen} onToggle=${setEmoteOpen} onSend=${(id) => actions.emote(id)} disabled=${conn.status !== 'online'} />`}
|
||||
<button type="button" class="gm__gear" aria-label="设置" title="设置" onClick=${() => setSettingsOpen(true)}><${GIcon} name="gear" /></button>
|
||||
<button type="button" class="gm__gear gm__guide" aria-label="玩法说明" title="玩法说明" onClick=${() => openGuide(0)}><${Icon} name="book" /></button>
|
||||
<${FullscreenButton} class="gm__gear gm__fs" />
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Lobby screen: pick 独立模拟 / 同盟模拟 and a difficulty (标准/险境/绝境/终极), create a room,
|
||||
// or join one with a 同盟密钥 (recent codes remembered). Shows connection status + ping.
|
||||
// or join one with a 同盟密钥 (recent codes remembered) — as a player (加入同盟) or in one of its MAX_SPECTATORS
|
||||
// spectator seats (观战: room.spectate, also while its match runs; community report #26, a remake feature — the
|
||||
// official room has none). Shows connection status + ping.
|
||||
//
|
||||
// Difficulty descriptions come from data/config.json `modes[modeId]` when present, else from the
|
||||
// official act2autochess `modeDataDict` texts embedded below (desc + effectDescList), so the
|
||||
@@ -8,7 +10,7 @@
|
||||
// plays 战场#01, 险境 draws one of 8, 绝境 / 终极 one of 7 (m01 excluded).
|
||||
|
||||
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
|
||||
import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS, ROOM_CODE_LEN, MAX_SEATS, modeIdFor } from '../../../shared/constants.js';
|
||||
import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS, ROOM_CODE_LEN, MAX_SEATS, MAX_SPECTATORS, modeIdFor } from '../../../shared/constants.js';
|
||||
import { html, Button, Icon, MicroLabel, Panel, TextField, PingPill, AvatarFrame, Tooltip, Spinner, DifficultyIcon, doctorNo } from '../ui/components.js';
|
||||
import { toast, toastError } from '../ui/toasts.js';
|
||||
import { GuideButton } from '../ui/guide.js';
|
||||
@@ -250,6 +252,12 @@ export function LobbyScreen() {
|
||||
if (!CODE_RE.test(k)) { toast(`同盟密钥为 ${ROOM_CODE_LEN} 位字母或数字`, 'warn'); return; }
|
||||
run('join', () => net.request('room.join', { code: k }));
|
||||
};
|
||||
// a spectator seat: no player seat taken, nothing to do but watch (also a match already running)
|
||||
const spectate = () => {
|
||||
const k = normalizeCode(code);
|
||||
if (!CODE_RE.test(k)) { toast(`同盟密钥为 ${ROOM_CODE_LEN} 位字母或数字`, 'warn'); return; }
|
||||
run('spectate', () => net.request('room.spectate', { code: k }));
|
||||
};
|
||||
const backToTitle = () => {
|
||||
identity.setEntered(false);
|
||||
store.set((s) => ({ session: { ...s.session, entered: false } }));
|
||||
@@ -291,6 +299,9 @@ export function LobbyScreen() {
|
||||
<${TextField} size="code" icon="key" value=${code} placeholder="输入同盟密钥 / 粘贴邀请链接"
|
||||
transform=${normalizeCode} onInput=${(v) => setCode(normalizeCode(v))} onEnter=${() => join()} />
|
||||
<${Button} variant="amber" size="lg" icon="users" loading=${busy === 'join'} disabled=${!codeOk || !online} onClick=${() => join()}>加入同盟<//>
|
||||
<${Tooltip} text=${`以观战者身份进入:不占博士席位,只能观看(每个同盟最多 ${MAX_SPECTATORS} 名,模拟进行中也可进入)`}>
|
||||
<${Button} variant="secondary" size="lg" icon="eye" class="join-spectate" loading=${busy === 'spectate'} disabled=${!codeOk || !online} onClick=${spectate}>观战<//>
|
||||
<//>
|
||||
</div>
|
||||
<div class="join-foot">
|
||||
${recent.length ? html`<span class="t-lo">最近的同盟</span>
|
||||
|
||||
@@ -6,9 +6,12 @@
|
||||
// host's start counts as the host's ready. So 开始模拟 is enabled exactly then and sends room.start
|
||||
// alone (no separate room.ready round trip that could leave the host "ready" after a failed start).
|
||||
// Solo rooms show a single seat.
|
||||
// Spectator seats (community report #26, a remake feature): a co-op room with spectators shows the 观战席 strip under
|
||||
// the seats — names, offline marks, the host's ✕ (room.removeSpectator) — and a spectator's own view swaps the ready
|
||||
// button for 观战中 and offers 入座 (room.join of the room) while a player seat is free.
|
||||
|
||||
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
|
||||
import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS, MAX_SEATS } from '../../../shared/constants.js';
|
||||
import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS, MAX_SEATS, MAX_SPECTATORS } from '../../../shared/constants.js';
|
||||
import {
|
||||
html, Button, Icon, MicroLabel, PingPill, AvatarFrame, DifficultyTag, DifficultyIcon, Tooltip, confirmDialog, doctorNo,
|
||||
} from '../ui/components.js';
|
||||
@@ -17,7 +20,7 @@ import { copyText } from '../ui/clipboard.js';
|
||||
import { GuideButton } from '../ui/guide.js';
|
||||
import { LoadoutButton } from './loadout.js';
|
||||
import { net } from '../net.js';
|
||||
import { store, useStore, shallowEqual, emptyMatch } from '../store.js';
|
||||
import { store, useStore, shallowEqual, emptyMatch, isSpectating } from '../store.js';
|
||||
import { difficultyInfo } from './lobby.js';
|
||||
|
||||
/**
|
||||
@@ -58,6 +61,9 @@ export function roomFacts(room, myId) {
|
||||
emptySeats: seats.filter((s) => !s).length,
|
||||
canStart: isHost && othersReady && !!mine,
|
||||
othersReady,
|
||||
// spectator seats (never players: not in `humans`, never counted for ready / start)
|
||||
spectators: Array.isArray(room?.spectators) ? room.spectators.filter((s) => s && typeof s === 'object') : [],
|
||||
spectating: isSpectating(room, myId),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -130,6 +136,20 @@ function SeatCard({ seat, index, room, facts, myId, busy, onAddBot, onRemoveBot
|
||||
</article>`;
|
||||
}
|
||||
|
||||
/** 观战席: the room's spectators (host: ✕ frees a seat), and 入座 for a spectator while a player seat is free. */
|
||||
function SpectatorBar({ facts, myId, busy, onRemove, onSit }) {
|
||||
if (!facts.spectators.length) return null;
|
||||
return html`<section class="specbar" aria-label="观战席">
|
||||
<span class="specbar__label"><${Icon} name="eye" />观战席<b class="num">${facts.spectators.length}</b><span class="num t-dim">/${MAX_SPECTATORS}</span></span>
|
||||
${facts.spectators.map((s) => html`<span key=${s.playerId} class=${`specbar__who${s.playerId === myId ? ' is-me' : ''}${s.connected === false ? ' is-offline' : ''}`}>
|
||||
${s.connected === false ? html`<${Icon} name="wifiOff" />` : null}${s.name || '博士'}${s.playerId === myId ? html`<span class="seat__you">你</span>` : null}
|
||||
${facts.isHost ? html`<${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === `rs${s.playerId}`}
|
||||
onClick=${() => onRemove(s.playerId)} aria-label=${`移出观战者 ${s.name || ''}`} title="移出该观战者" />` : null}
|
||||
</span>`)}
|
||||
${facts.spectating && facts.emptySeats > 0 ? html`<${Button} size="sm" icon="user" loading=${busy === 'sit'} onClick=${onSit}>入座<//>` : null}
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function InviteBox({ code }) {
|
||||
const copy = async (what) => {
|
||||
const ok = await copyText(what === 'code' ? code : inviteLink(code));
|
||||
@@ -195,6 +215,9 @@ export function RoomScreen() {
|
||||
const addBot = () => run('add', () => net.request('room.addBot', {}));
|
||||
const removeBot = (seat) => run(`rm${seat}`, () => net.request('room.removeBot', { seat }));
|
||||
const setDifficulty = (difficulty) => run('diff', () => net.request('room.setDifficulty', { difficulty }));
|
||||
// spectator seats: the host frees one; a spectator takes a free player seat with room.join of this room
|
||||
const removeSpectator = (playerId) => run(`rs${playerId}`, () => net.request('room.removeSpectator', { playerId }));
|
||||
const sit = () => run('sit', () => net.request('room.join', { code: room.code }));
|
||||
const leave = async () => {
|
||||
if (inFlight.current) return;
|
||||
const othersHere = facts.humans.some((s) => s.playerId !== me.playerId);
|
||||
@@ -218,6 +241,8 @@ export function RoomScreen() {
|
||||
|
||||
const statusLine = !online
|
||||
? html`<span class="t-orange"><${Icon} name="wifiOff" />连接中断,正在重连…</span>`
|
||||
: facts.spectating
|
||||
? html`<span class="t-lo"><${Icon} name="eye" />观战中 · 不占博士席位,模拟开始后可切换观看各位博士</span>`
|
||||
: !coop
|
||||
? html`<span class="t-mint">*模拟协议已就绪,准许进入模拟</span>`
|
||||
: facts.isHost
|
||||
@@ -263,6 +288,7 @@ export function RoomScreen() {
|
||||
</ul>
|
||||
</aside>`}
|
||||
</main>
|
||||
<${SpectatorBar} facts=${facts} myId=${me.playerId} busy=${busy} onRemove=${removeSpectator} onSit=${sit} />
|
||||
|
||||
<footer class="room-bar">
|
||||
<div class="room-bar__left">
|
||||
@@ -285,6 +311,8 @@ export function RoomScreen() {
|
||||
? html`<${Tooltip} text=${facts.canStart ? null : '仍有博士未准备就绪'}>
|
||||
<${Button} variant="primary" size="xl" icon="play" loading=${busy === 'start'} disabled=${!facts.canStart || !online} onClick=${start}>开始模拟<//>
|
||||
<//>`
|
||||
: facts.spectating
|
||||
? html`<${Button} variant="secondary" size="xl" icon="eye" disabled=${true}>观战中<//>`
|
||||
: html`<${Button} variant=${myReady ? 'primary' : 'secondary'} size="xl" icon=${myReady ? 'check' : 'hourglass'} active=${myReady}
|
||||
loading=${busy === 'ready'} disabled=${!online || !facts.mine} onClick=${toggleReady}>${myReady ? '已就绪' : '准备就绪'}<//>`}
|
||||
</div>
|
||||
|
||||
@@ -103,6 +103,17 @@ export function selectRoute(s) {
|
||||
return 'lobby';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the player holds one of the room's spectator seats (room.state `spectators`; community report #26, a remake
|
||||
* feature): it is no player of the room or its match, watches like an eliminated player and may not act.
|
||||
* @param {any} room room.state payload
|
||||
* @param {string|null|undefined} playerId
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isSpectating(room, playerId) {
|
||||
return playerId != null && Array.isArray(room?.spectators) && room.spectators.some((s) => s && s.playerId === playerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The notice after a `welcome` that starts a brand-new server session (the server restarted, or this session expired
|
||||
* on it: a new playerId) while a room or a match was on screen — everything shown is gone. Null when nothing is lost
|
||||
|
||||
@@ -34,8 +34,9 @@ function ProgressList({ list }) {
|
||||
* client?: null | { progress?: any[]|null, observing?: { name: string }|null, onBack?: () => void,
|
||||
* layers?: Array<{ key: string, label: string, self: boolean, watch: boolean }>, layer?: string, onLayer?: (k: string) => void } }} props
|
||||
*/
|
||||
export function CombatHud({ pub, myId, watching, hud, myDone, onWatch, spectating = false, client = null }) {
|
||||
if (client) return ClientHud({ hud, myDone, spectating, client });
|
||||
export function CombatHud({ pub, myId, watching, hud, myDone, onWatch, spectating = false, spectator = false, client = null }) {
|
||||
// `spectator`: a spectator seat (community report #26) watches like an eliminated player, under its own caption
|
||||
if (client) return ClientHud({ hud, myDone, spectating, spectator, client });
|
||||
const fields = (Array.isArray(pub?.fields) ? pub.fields : []).filter((f) => f && f.live !== false);
|
||||
// the watched field may already have finished (not live): still name it
|
||||
const label = switcherLabel(pub, watching, myId, spectating);
|
||||
@@ -44,7 +45,8 @@ export function CombatHud({ pub, myId, watching, hud, myDone, onWatch, spectatin
|
||||
<${DpCounter} dp=${hud?.dp} />
|
||||
<div class="chud__bottom">
|
||||
${myDone ? html`<div class="chud__msg" role="status"><${Icon} name="hourglass" /><span>作战结束,等待队友完成作战</span></div>` : null}
|
||||
${spectating ? html`<div class="chud__msg chud__msg--dead" role="status"><${Icon} name="close" /><span>你已被淘汰,正在观战</span></div>` : null}
|
||||
${spectating ? (spectator ? html`<div class="chud__msg" role="status"><${GIcon} name="eye" /><span>观战中</span></div>`
|
||||
: html`<div class="chud__msg chud__msg--dead" role="status"><${Icon} name="close" /><span>你已被淘汰,正在观战</span></div>`) : null}
|
||||
${fields.length ? html`<div class=${cx('vswitch', !canCycle && 'is-single')}>
|
||||
<button type="button" class="vswitch__arrow" disabled=${!canCycle} aria-label="上一个战场"
|
||||
onClick=${() => { const n = cycleField(fields, watching, -1); if (n && n !== watching) onWatch(n); }}><${Icon} name="chevronLeft" /></button>
|
||||
@@ -56,7 +58,7 @@ export function CombatHud({ pub, myId, watching, hud, myDone, onWatch, spectatin
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function ClientHud({ hud, myDone, spectating, client }) {
|
||||
function ClientHud({ hud, myDone, spectating, spectator = false, client }) {
|
||||
const layers = Array.isArray(client.layers) ? client.layers : [];
|
||||
const idx = Math.max(0, layers.findIndex((l) => l.key === (client.layer || 'ALL')));
|
||||
const cur = layers[idx] || null;
|
||||
@@ -72,7 +74,8 @@ function ClientHud({ hud, myDone, spectating, client }) {
|
||||
<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}
|
||||
${spectating && !observing ? (spectator ? html`<div class="chud__msg" role="status"><${GIcon} name="eye" /><span>观战中,可点击左侧成员头像前往查看</span></div>`
|
||||
: html`<div class="chud__msg chud__msg--dead" role="status"><${Icon} name="close" /><span>你已被淘汰,可点击队友头像前往查看</span></div>`) : null}
|
||||
${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()}>
|
||||
|
||||
@@ -74,6 +74,8 @@ export const ICONS = {
|
||||
collapse: { d: 'M8 3h2v7H3V8h5zm6 0h2v5h5v2h-7zM3 14h7v7H8v-5H3zm11 0h7v2h-5v5h-2z' },
|
||||
rotate: { d: 'M7 2h8a2 2 0 0 1 2 2v7h-2V4H7v16h4v2H7a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2zm8 12h5a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-5a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2zm0 2v4h5v-4z', eo: true },
|
||||
book: { d: 'M2 4h7.5A3.5 3.5 0 0 1 12 5.1 3.5 3.5 0 0 1 14.5 4H22v16h-7.5a1.5 1.5 0 0 0-1.5 1.5h-2A1.5 1.5 0 0 0 9.5 20H2zm2 2v12h5.5c.5 0 1 .1 1.5.3V7.5A1.5 1.5 0 0 0 9.5 6zm10.5 0A1.5 1.5 0 0 0 13 7.5v10.8c.5-.2 1-.3 1.5-.3H20V6z', eo: true },
|
||||
// 观战 (spectator seats; the same eye as gameComponents.js GIcon 'eye')
|
||||
eye: { d: 'M12 5c5 0 9 4.5 10 7-1 2.5-5 7-10 7S3 14.5 2 12c1-2.5 5-7 10-7zm0 3a4 4 0 1 0 0 8 4 4 0 0 0 0-8zm0 2a2 2 0 1 1 0 4 2 2 0 0 1 0-4z', eo: true },
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+3
-2
@@ -308,7 +308,7 @@ export function PauseButton({ paused, busy = false, onToggle }) {
|
||||
* `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 }) {
|
||||
config = null, frozenAt = null, pause = null, live = null, spectator = false }) {
|
||||
const phase = pub?.phase;
|
||||
const boss = isBossPhase(phase);
|
||||
const lp = boss && Number.isFinite(pub?.teamLp) ? pub.teamLp : Number.isFinite(priv?.lp) ? priv.lp : null;
|
||||
@@ -316,7 +316,8 @@ export function TopBar({ pub, priv, conn, hud, total, drawer, onExit, onDrawer,
|
||||
const pending = !boss && Number.isFinite(lp) && live && live.pending > 0 ? Math.min(lp, live.pending) : 0;
|
||||
const hidden = phase === PHASE.HIDDEN_CORE || (Number.isFinite(pub?.lastRound) && pub.round > pub.lastRound);
|
||||
const roundText = hidden ? '??' : pub?.round > 0 ? String(pub.round) : '--';
|
||||
const showReady = phase === PHASE.PREP && priv?.alive !== false;
|
||||
// a spectator seat (no m.private, community report #26) never readies
|
||||
const showReady = phase === PHASE.PREP && !spectator && priv?.alive !== false;
|
||||
// boss rounds: the overtime warning follows the clock (4 Hz while live; frozen while paused)
|
||||
const otLive = boss && Number(pub?.overtimeAt) > 0;
|
||||
useTicker(otLive && frozenAt == null ? 250 : 0);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { html, Button, Modal, PingPill, DifficultyTag, Countdown, MicroLabel } f
|
||||
import { actions } from './gameActions.js';
|
||||
import { toastError } from './toasts.js';
|
||||
import { net } from '../net.js';
|
||||
import { store, useStore, shallowEqual, emptyMatch, createStore } from '../store.js';
|
||||
import { store, useStore, shallowEqual, emptyMatch, createStore, isSpectating } from '../store.js';
|
||||
import { GIcon } from './gameComponents.js';
|
||||
import { GuideButton } from './guide.js';
|
||||
|
||||
@@ -58,12 +58,21 @@ export async function quitMatch() {
|
||||
*/
|
||||
export function ExitModal({ open, onClose, solo, onAway }) {
|
||||
const [busy, setBusy] = useState(null);
|
||||
// a spectator seat (community report #26) only leaves its seat: nothing of the match is given up
|
||||
const spectator = useStore((s) => isSpectating(s.room, s.me.playerId));
|
||||
const quit = async () => {
|
||||
setBusy('quit');
|
||||
await quitMatch();
|
||||
setBusy(null);
|
||||
onClose();
|
||||
};
|
||||
if (spectator) {
|
||||
return html`<${Modal} open=${open} onClose=${onClose} tone="red" title="离开观战" micro="LEAVE SPECTATING" width="6.8rem"
|
||||
actions=${html`<${Button} variant="secondary" onClick=${onClose}>取消<//>
|
||||
<${Button} variant="danger" icon="exit" loading=${busy === 'quit'} onClick=${quit}>离开观战<//>`}>
|
||||
<div class="exitm"><p>离开观战席并返回大厅,本局模拟不受影响;观战席空着时可以凭同盟密钥再次观战。</p></div>
|
||||
<//>`;
|
||||
}
|
||||
const away = async () => {
|
||||
setBusy('away');
|
||||
const ok = await actions.autoplay(true);
|
||||
|
||||
Reference in new issue
Block a user