diff --git a/README.md b/README.md index 260d3c64..2d7d75a5 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,7 @@ npm start # 启动服务器:http://localhost:3000 | 方向轮盘键盘操作 | 方向键预览 · `Enter` 确认 · `Esc` 取消 | | 暂停(独立模拟) | 作战中(含最终攻势 / 隐秘核心)点顶栏的「暂停」或按 `Space`,再点「继续作战」(或 `Space`)继续;同盟模拟的作战不能暂停 | | 表情 | 左下角「交流」,左右滑动(或方向键)换主题,冷却 1 秒 | -| 观战 | 自己的作战结束后(或休整期)点左侧队友头像 →「前往查看」 | +| 观战 | 自己的作战结束后(或休整期)点左侧队友头像 →「前往查看」;不参战的朋友可以在大厅输入同盟密钥点「观战」(每个同盟最多 2 名观战者,本作新增) | 完整的规则、数值和小技巧见 **[docs/PLAYING.md](docs/PLAYING.md)**(游戏内左下角也有「玩法说明」)。 diff --git a/docs/DESIGN.md b/docs/DESIGN.md index de5595ba..dd6a7633 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -412,6 +412,8 @@ S→C: `welcome {playerId, token, name, serverNow}` · `room.state {code, hostId Platform additions (implemented): `welcome.{resumed, version}`, `room.state.inMatch`, `error.detail`; `room.closed.reason ∈ 'timeout'|'kicked'|'shutdown'`; WebSocket close codes 4001 (session taken over by another socket — client must not auto-reconnect), 4002 (no hello in time), 1008 (flooding), 1001 (shutdown). The host's `room.start` counts as the host being ready. After any (re)`hello` the server pushes `room.state` and, if a match runs, the match resends `m.public`/`m.private` (+`m.field`, or the field's `b.start` under client-side combat). **Reconnect window:** a dropped session stays resumable for 10 min (`net.js NET_DEFAULTS.reconnectWindowMs`; co-op seats keep playing their last lineup meanwhile, §6.6); a session that drops while its **solo** room's match runs stays resumable for the official `config.constants.singleReconnectTime` (86 400 s = 24 h; lobby option `soloReconnectWindowMs`; research 01 §1 "24小时内随时返回") — the untimed solo run simply waits. Only when the window expires does the drop become `match.onLeave` (co-op: 中途退出 = elimination; solo: the run ends `'abandoned'`). The client keeps its token in sessionStorage plus a recent-token list in localStorage, so reopening the tab resumes. Everything lives in server memory: a server restart ends every room and run. The lobby⇄match interface (constructor opts, `start/handle/onDisconnect/onReconnect/onLeave/dispose`) is documented verbatim at the top of `server/match/Match.js` and is normative. `server/data.js` provides `getData()` + index getters (`getChess`, `getBond`, `getGarrison`, `getItem`, `getBand`, `getEffect`, `getEnemy`, `getWave`, `getStage`, `getBoss`, `getToken`, `getConfig`, `getMode`). Note: the skill trigger rule lives at `chess.skill.trigger.rule` in data. +**Spectator seats** (a remake feature — the official room has none; community report #26, owner's decision 2026-10-04; the spectator-seat subsection of §23): `room.spectate {code}` takes one of a co-op room's `MAX_SPECTATORS` (2) spectator seats, in its lobby or during its match; `room.removeSpectator {playerId}` (host) frees one (`room.closed {reason:'kicked'}` to it); `room.state.spectators: [{playerId, name, connected}]`. A spectator is never a player (not in `seats`, not counted for the start, never host, keeps no room alive — `room.closed {empty}` when the last human leaves), receives the room's broadcasts and watches like an eliminated player (`Match.addSpectator`: `b.start` watch / `m.field`, never `m.private`); it may only `g.watch`, leave, and store a `room.loadout` — anything else answers `error SPECTATOR`. Its seat is kept and given back on reconnect like a player's. + ### 8.2 Match C→S (all carry `rid`): `g.infoReady` · `g.band {bandId}` · `g.bandSkip` · `g.bandFocus {bandId?}` (the strategy highlighted in the draft screen — what a timed-out turn takes; absent / null clears it; `WRONG_PHASE` outside BAND_DRAFT, `ALREADY` after the pick, `BAD_TARGET` for a band not allowed; §18.2) · `g.buy {slot}` · `g.refresh` · `g.freeze` · `g.levelUp` · `g.sell {uid}` · `g.move {uid, to:{area:'board', row, col}|{area:'hand', idx}, dir?}` · `g.equip {itemUid, targetUid, replaceUid?}` (`replaceUid`: which equipped item a third one replaces, §6.2) · `g.art {itemUid, row, col, dir?}` (`dir` ∈ UP|RIGHT|DOWN|LEFT from the deploy wheel, §3; absent ⇒ `to.dir`, then RIGHT; `g.move` onto the piece's own tile re-orients it; board pieces in `m.private` / `m.field {prep:true}` carry `dir`) · `g.destroy {uid}` (hand / temp items only; equipped items are locked, §6.2) · `g.reward {idx}` · `g.choice {idx}` · `g.ready {ready}` · `g.emote {id}` · `g.watch {fieldId}` · `g.autoplay {on}` · `g.pause {on}` (solo battles only, §14 Solo pause) · `g.unitStats {seq?}` (ROUND_START / SP_DRAFT / PREP, else `WRONG_PHASE`; eliminated: `ELIMINATED`) → push `m.unitStats` (§18.5) · `g.leave` S→C: diff --git a/docs/META.md b/docs/META.md index 079beef5..2b972467 100644 --- a/docs/META.md +++ b/docs/META.md @@ -203,6 +203,12 @@ player) sends no `g.watch` (it would be refused with `BAD_TARGET 'no such field' teammate's battle after the own one gets the watched field again (`_resendBattle`, `b.start watch: true`); the fresh screen adopts it as the watched field once per battle (`battle/observe.js resumedWatch`: a living player's teammate normal battle in COMBAT, first seen while watching nothing), so the observing pill, 返回战场 and the own row work again. +Spectator seats (a remake feature, community report #26; server/lobby.js): `opts.spectators` / `addSpectator(id)` register +a spectator — no PlayerState, a stand-in with `alive: false` — that every watch path treats like an eliminated human +(`_viewers()`: the first field of each battle, the 联防 spec, the first boss field, `g.watch` anything; `addSpectator` +also resends the state on a join mid-match and each resume); it never gets `m.private` / `m.toast` / `m.unitStats`, is +never a field's player or authority, its `b.start` spec omits the players' `contentInfo.funds` (`_spectatorSpec`: read by +no battle effect), it gets the settlement's `m.result`, and `handle()` answers only its `g.watch` (else `SPECTATOR`). The rest of this section is the legacy server-run mode (`SP_COMBAT=server`): `g.watch { fieldId }`: any live field during COMBAT / 联防; while no battle field is up (PREP, drafts, SETTLE) diff --git a/docs/PLAYING.md b/docs/PLAYING.md index 83eb0df6..c8c2fae4 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -117,6 +117,7 @@ - 左下角「交流」打开表情面板:6 套主题 × 6 个表情,左右滑动(或方向键)换主题。表情以气泡形式显示在你头像旁 3 秒,发送冷却 1 秒。 - 休整期、或自己的作战结束后(显示「作战结束,等待队友完成作战」),点左侧队友头像 →「前往查看」观看他的战场;自己的作战进行中不能观战。联防和最终攻势中可以用 ‹ › 切换左半 / 全景 / 右半。 - 被淘汰后可以自由观看任何战场。 +- **观战席**(本作新增,原版没有):每个同盟房间另有 2 个观战席。在大厅输入同盟密钥后点「观战」进入(模拟进行中也可以),不占博士席位、不算参战人数,也不能购买、部署、准备或发送表情;观看方式与被淘汰的玩家相同——作战时自动观看第一个战场,休整期自动显示第一位博士的阵地,点左侧成员头像可切换查看任何人。断线重连会回到观战席;创建者可以在等待室用观战者名字旁的 ✕ 将其移出;观战者在等待室有空位时可以点「入座」成为博士。 - **观看谁,盟约栏就显示谁的盟约**:查看队友的阵地或战场时(休整期、作战结束后、被淘汰后自动观战),左上的盟约栏换成**该队友的盟约、人数和层数**,前面有琥珀色的「👁 队友名」标签,点开的盟约详情也写着「队友名 的盟约」(成员一栏只标出他在场上的干员,整备区看不到);作战中层数实时增长(与他自己看到的一致)。联防和最终攻势里跟随 ‹ ›:镜头在队友的半场时显示他的盟约,在你自己的半场时显示你自己的;「全景」时,你在这个战场上作战就显示你自己的,你不在这个战场上(漏怪后观看联防、被淘汰后观战)则显示你点「前往查看」选中的那名队友,没选过就显示该战场的第一名玩家(1 号联防者 / 这一组的第一位),**不会显示你自己的**。在这样的战场上点某名队友的头像 →「前往查看」,镜头会直接移到他的半场,‹ › 上的「👁 队友名」与盟约栏的标签一致;‹ › 旁边有「返回战场」(被淘汰时没有)。盟约栏换人时,从盟约栏打开的盟约详情会自动关闭。点开场上某个干员的详情卡时,卡上的盟约(以及点它打开的盟约详情)属于**这个干员的主人**,与盟约栏当前显示谁无关——例如在「全景」点队友的干员,看到的是「队友名 的盟约」及他的人数和层数;在队友的半场点你自己的干员,看到的是你自己的。回到自己的阵地 / 战场后恢复成你自己的盟约。作战结束后观看队友时刷新页面 / 断线重连,仍会回到正在观看的队友战场,「👁 队友名」和「返回战场」都还在。已被淘汰的队友不再显示盟约(也无法被查看)。 ## 9. 结算称号 diff --git a/public/css/screens/game.css b/public/css/screens/game.css index 933f5ca0..869f65ff 100644 --- a/public/css/screens/game.css +++ b/public/css/screens/game.css @@ -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; } diff --git a/public/css/screens/room.css b/public/css/screens/room.css index 19878d23..fb84ee6f 100644 --- a/public/css/screens/room.css +++ b/public/css/screens/room.css @@ -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; diff --git a/public/js/main.js b/public/js/main.js index 283c412e..0159e018 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -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; diff --git a/public/js/screens/bandDraft.js b/public/js/screens/bandDraft.js index 8a2003d0..e16e78a3 100644 --- a/public/js/screens/bandDraft.js +++ b/public/js/screens/bandDraft.js @@ -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]); diff --git a/public/js/screens/briefing.js b/public/js/screens/briefing.js index 1819692a..819f0640 100644 --- a/public/js/screens/briefing.js +++ b/public/js/screens/briefing.js @@ -92,8 +92,8 @@ export function BriefingScreen() { 已就绪 ${readyN}/${players.length} ${players.map((p) => html`<${Icon} name="user" />`)} - <${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 ? '准备就绪' : '观战中'} <${ExitModal} open=${exit} onClose=${() => setExit(false)} solo=${solo} /> `; diff --git a/public/js/screens/game.js b/public/js/screens/game.js index d782ff6f..b4522d84 100644 --- a/public/js/screens/game.js +++ b/public/js/screens/game.js @@ -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; @@ -722,6 +728,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:', 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; @@ -1206,7 +1224,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} />
<${BondStrip} bonds=${stripBonds} layersDisabled=${layersDisabled} openId=${bondPop && bondPop.ownerId === strip.ownerId ? bondPop.bondId : null} @@ -1221,7 +1239,7 @@ function MatchScreen() { ${watchingOther && !combat ? html`
<${GIcon} name="eye" />正在查看 ${watchedName} 的阵地(只读) - <${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 })}>返回自己`}
` : null} ${showShop ? html`<${ShopBar} priv=${priv} editable=${editable} collapsed=${collapsed} onCollapse=${setCollapsed} @@ -1237,15 +1255,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`
<${Icon} name="close" />你已被淘汰 · 可继续观战队友
` : null} + ${showDeadPill(alive, phase) ? (spectator + ? html`
<${GIcon} name="eye" />观战中 · 点击左侧成员头像切换查看
` + : html`
<${Icon} name="close" />你已被淘汰 · 可继续观战队友
`) : null} <${Ticker} />
- <${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'} />`} <${FullscreenButton} class="gm__gear gm__fs" /> diff --git a/public/js/screens/lobby.js b/public/js/screens/lobby.js index 7abd6a2a..096cc3e7 100644 --- a/public/js/screens/lobby.js +++ b/public/js/screens/lobby.js @@ -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}>观战 +
${recent.length ? html`最近的同盟 diff --git a/public/js/screens/room.js b/public/js/screens/room.js index 675fc9ff..b14cb989 100644 --- a/public/js/screens/room.js +++ b/public/js/screens/room.js @@ -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), }; } @@ -133,6 +139,20 @@ function SeatCard({ seat, index, room, facts, myId, busy, onAddBot, onRemoveBot, `; } +/** 观战席: 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`
+ <${Icon} name="eye" />观战席${facts.spectators.length}/${MAX_SPECTATORS} + ${facts.spectators.map((s) => html` + ${s.connected === false ? html`<${Icon} name="wifiOff" />` : null}${s.name || '博士'}${s.playerId === myId ? html`你` : 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} + `)} + ${facts.spectating && facts.emptySeats > 0 ? html`<${Button} size="sm" icon="user" loading=${busy === 'sit'} onClick=${onSit}>入座` : null} +
`; +} + function InviteBox({ code }) { const copy = async (what) => { const ok = await copyText(what === 'code' ? code : inviteLink(code)); @@ -205,6 +225,9 @@ export function RoomScreen() { if (ok) run(`kick${seat}`, () => net.request('room.kick', { seat, playerId })); }; 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); @@ -228,6 +251,8 @@ export function RoomScreen() { const statusLine = !online ? html`<${Icon} name="wifiOff" />连接中断,正在重连…` + : facts.spectating + ? html`<${Icon} name="eye" />观战中 · 不占博士席位,模拟开始后可切换观看各位博士` : !coop ? html`*模拟协议已就绪,准许进入模拟` : facts.isHost @@ -273,6 +298,7 @@ export function RoomScreen() { `} + <${SpectatorBar} facts=${facts} myId=${me.playerId} busy=${busy} onRemove=${removeSpectator} onSit=${sit} />