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:
sganggs committed 2026-10-04 17:39:30 +08:00
1 parent 42766172d1
commit 976475efff
21 files changed
+352 -30

No files matched your search

+1 -1
View File
@@ -156,7 +156,7 @@ npm start # 启动服务器:http://localhost:3000
| 方向轮盘键盘操作 | 方向键预览 · `Enter` 确认 · `Esc` 取消 | | 方向轮盘键盘操作 | 方向键预览 · `Enter` 确认 · `Esc` 取消 |
| 暂停(独立模拟) | 作战中(含最终攻势 / 隐秘核心)点顶栏的「暂停」或按 `Space`,再点「继续作战」(或 `Space`)继续;同盟模拟的作战不能暂停 | | 暂停(独立模拟) | 作战中(含最终攻势 / 隐秘核心)点顶栏的「暂停」或按 `Space`,再点「继续作战」(或 `Space`)继续;同盟模拟的作战不能暂停 |
| 表情 | 左下角「交流」,左右滑动(或方向键)换主题,冷却 1 秒 | | 表情 | 左下角「交流」,左右滑动(或方向键)换主题,冷却 1 秒 |
| 观战 | 自己的作战结束后(或休整期)点左侧队友头像 →「前往查看」 | | 观战 | 自己的作战结束后(或休整期)点左侧队友头像 →「前往查看」;不参战的朋友可以在大厅输入同盟密钥点「观战」(每个同盟最多 2 名观战者,本作新增) |
完整的规则、数值和小技巧见 **[docs/PLAYING.md](docs/PLAYING.md)**(游戏内左下角也有「玩法说明」)。 完整的规则、数值和小技巧见 **[docs/PLAYING.md](docs/PLAYING.md)**(游戏内左下角也有「玩法说明」)。
+2
View File
@@ -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'|'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. Platform additions (implemented): `welcome.{resumed, version}`, `room.state.inMatch`, `error.detail`; `room.closed.reason ∈ 'timeout'|'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 ### 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` 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: S→C:
+6
View File
@@ -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 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 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. 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 The rest of this section is the legacy server-run mode
(`SP_COMBAT=server`): (`SP_COMBAT=server`):
`g.watch { fieldId }`: any live field during COMBAT / 联防; while no battle field is up (PREP, drafts, SETTLE) `g.watch { fieldId }`: any live field during COMBAT / 联防; while no battle field is up (PREP, drafts, SETTLE)
+1
View File
@@ -117,6 +117,7 @@
- 左下角「交流」打开表情面板:6 套主题 × 6 个表情,左右滑动(或方向键)换主题。表情以气泡形式显示在你头像旁 3 秒,发送冷却 1 秒。 - 左下角「交流」打开表情面板:6 套主题 × 6 个表情,左右滑动(或方向键)换主题。表情以气泡形式显示在你头像旁 3 秒,发送冷却 1 秒。
- 休整期、或自己的作战结束后(显示「作战结束,等待队友完成作战」),点左侧队友头像 →「前往查看」观看他的战场;自己的作战进行中不能观战。联防和最终攻势中可以用 ‹ › 切换左半 / 全景 / 右半。 - 休整期、或自己的作战结束后(显示「作战结束,等待队友完成作战」),点左侧队友头像 →「前往查看」观看他的战场;自己的作战进行中不能观战。联防和最终攻势中可以用 ‹ › 切换左半 / 全景 / 右半。
- 被淘汰后可以自由观看任何战场。 - 被淘汰后可以自由观看任何战场。
- **观战席**(本作新增,原版没有):每个同盟房间另有 2 个观战席。在大厅输入同盟密钥后点「观战」进入(模拟进行中也可以),不占博士席位、不算参战人数,也不能购买、部署、准备或发送表情;观看方式与被淘汰的玩家相同——作战时自动观看第一个战场,休整期自动显示第一位博士的阵地,点左侧成员头像可切换查看任何人。断线重连会回到观战席;创建者可以在等待室用观战者名字旁的 ✕ 将其移出;观战者在等待室有空位时可以点「入座」成为博士。
- **观看谁,盟约栏就显示谁的盟约**:查看队友的阵地或战场时(休整期、作战结束后、被淘汰后自动观战),左上的盟约栏换成**该队友的盟约、人数和层数**,前面有琥珀色的「👁 队友名」标签,点开的盟约详情也写着「队友名 的盟约」(成员一栏只标出他在场上的干员,整备区看不到);作战中层数实时增长(与他自己看到的一致)。联防和最终攻势里跟随 ‹ ›:镜头在队友的半场时显示他的盟约,在你自己的半场时显示你自己的;「全景」时,你在这个战场上作战就显示你自己的,你不在这个战场上(漏怪后观看联防、被淘汰后观战)则显示你点「前往查看」选中的那名队友,没选过就显示该战场的第一名玩家(1 号联防者 / 这一组的第一位),**不会显示你自己的**。在这样的战场上点某名队友的头像 →「前往查看」,镜头会直接移到他的半场,‹ › 上的「👁 队友名」与盟约栏的标签一致;‹ › 旁边有「返回战场」(被淘汰时没有)。盟约栏换人时,从盟约栏打开的盟约详情会自动关闭。点开场上某个干员的详情卡时,卡上的盟约(以及点它打开的盟约详情)属于**这个干员的主人**,与盟约栏当前显示谁无关——例如在「全景」点队友的干员,看到的是「队友名 的盟约」及他的人数和层数;在队友的半场点你自己的干员,看到的是你自己的。回到自己的阵地 / 战场后恢复成你自己的盟约。作战结束后观看队友时刷新页面 / 断线重连,仍会回到正在观看的队友战场,「👁 队友名」和「返回战场」都还在。已被淘汰的队友不再显示盟约(也无法被查看)。 - **观看谁,盟约栏就显示谁的盟约**:查看队友的阵地或战场时(休整期、作战结束后、被淘汰后自动观战),左上的盟约栏换成**该队友的盟约、人数和层数**,前面有琥珀色的「👁 队友名」标签,点开的盟约详情也写着「队友名 的盟约」(成员一栏只标出他在场上的干员,整备区看不到);作战中层数实时增长(与他自己看到的一致)。联防和最终攻势里跟随 ‹ ›:镜头在队友的半场时显示他的盟约,在你自己的半场时显示你自己的;「全景」时,你在这个战场上作战就显示你自己的,你不在这个战场上(漏怪后观看联防、被淘汰后观战)则显示你点「前往查看」选中的那名队友,没选过就显示该战场的第一名玩家(1 号联防者 / 这一组的第一位),**不会显示你自己的**。在这样的战场上点某名队友的头像 →「前往查看」,镜头会直接移到他的半场,‹ › 上的「👁 队友名」与盟约栏的标签一致;‹ › 旁边有「返回战场」(被淘汰时没有)。盟约栏换人时,从盟约栏打开的盟约详情会自动关闭。点开场上某个干员的详情卡时,卡上的盟约(以及点它打开的盟约详情)属于**这个干员的主人**,与盟约栏当前显示谁无关——例如在「全景」点队友的干员,看到的是「队友名 的盟约」及他的人数和层数;在队友的半场点你自己的干员,看到的是你自己的。回到自己的阵地 / 战场后恢复成你自己的盟约。作战结束后观看队友时刷新页面 / 断线重连,仍会回到正在观看的队友战场,「👁 队友名」和「返回战场」都还在。已被淘汰的队友不再显示盟约(也无法被查看)。
## 9. 结算称号 ## 9. 结算称号
+2
View File
@@ -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; 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; } .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 { position: absolute; inset: 0; pointer-events: none; }
.chud > * { pointer-events: auto; } .chud > * { pointer-events: auto; }
+29
View File
@@ -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 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); } .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 ----------------------------------------------------------------------------------------- */ /* ---- bottom bar ----------------------------------------------------------------------------------------- */
.room-bar { .room-bar {
position: relative; position: relative;
+3 -3
View File
@@ -32,7 +32,7 @@ import { html, UiHosts, Button, MicroLabel, closeAllDialogs } from './ui/compone
import { ConnectionBanner } from './ui/connBanner.js'; import { ConnectionBanner } from './ui/connBanner.js';
import { ToastHost, toast, toastError, describeError } from './ui/toasts.js'; import { ToastHost, toast, toastError, describeError } from './ui/toasts.js';
import { net, identity, NetError } from './net.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 { data } from './data.js';
import { GAME_FILES } from './ui/gameComponents.js'; import { GAME_FILES } from './ui/gameComponents.js';
import { TitleScreen, sanitizeName } from './screens/title.js'; import { TitleScreen, sanitizeName } from './screens/title.js';
@@ -168,8 +168,8 @@ function onRoomState(msg) {
roomStateAt = Date.now(); roomStateAt = Date.now();
const myId = store.get().me.playerId; const myId = store.get().me.playerId;
const seats = Array.isArray(room.seats) ? room.seats : []; const seats = Array.isArray(room.seats) ? room.seats : [];
if (myId != null && seats.length && !seats.some((s) => s && s.playerId === myId)) { if (myId != null && seats.length && !seats.some((s) => s && s.playerId === myId) && !isSpectating(room, myId)) {
// We are no longer seated (kicked / left elsewhere). // We are no longer seated (kicked / left elsewhere) — neither in a player seat nor a spectator seat.
if (store.get().room) toast('你已不在该同盟中', 'warn'); if (store.get().room) toast('你已不在该同盟中', 'warn');
store.set({ room: null, match: emptyMatch() }); store.set({ room: null, match: emptyMatch() });
return; return;
+2 -1
View File
@@ -212,7 +212,8 @@ export function BandDraftScreen() {
const timed = !solo && !!pub?.draft && !pub.draft.untimed; const timed = !solo && !!pub?.draft && !pub.draft.untimed;
const focusSent = useRef(null); const focusSent = useRef(null);
useEffect(() => { 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; focusSent.current = sel;
act('g.bandFocus', { bandId: sel }, { sfx: false, quiet: true }); act('g.bandFocus', { bandId: sel }, { sfx: false, quiet: true });
}, [sel, timed, myPick]); }, [sel, timed, myPick]);
+2 -2
View File
@@ -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__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> <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> </div>
<${Button} variant="primary" size="xl" icon=${me?.ready ? 'check' : 'play'} active=${!!me?.ready} loading=${busy} <${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 ? '已就绪' : '准备就绪'}<//> disabled=${!!me?.ready || !me} onClick=${ready}>${me?.ready ? '已就绪' : me ? '准备就绪' : '观战中'}<//>
</footer> </footer>
<${ExitModal} open=${exit} onClose=${() => setExit(false)} solo=${solo} /> <${ExitModal} open=${exit} onClose=${() => setExit(false)} solo=${solo} />
</div>`; </div>`;
+27 -7
View File
@@ -58,6 +58,10 @@
// the battle on screen (battle/runner.js state().bondLayers). Picking a teammate on such a field (a 联防 leaker, an // 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 // eliminated spectator) frames their half and keeps the ‹ › pill (with 返回战场); an open bond popup closes when the
// strip changes hands. // 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 { useCallback, useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
import { PHASE, GEO } from '../../../shared/constants.js'; import { PHASE, GEO } from '../../../shared/constants.js';
@@ -99,7 +103,7 @@ import { BriefingScreen } from './briefing.js';
import { BandDraftScreen } from './bandDraft.js'; import { BandDraftScreen } from './bandDraft.js';
import { ResultScreen } from './result.js'; import { ResultScreen } from './result.js';
import { net } from '../net.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 { battleRunner } from '../battle/runner.js';
import { isClientCombat, observeTarget, teammateProgress, cameraLayers, layerCamera, sidesOf, resumedWatch } from '../battle/observe.js'; import { isClientCombat, observeTarget, teammateProgress, cameraLayers, layerCamera, sidesOf, resumedWatch } from '../battle/observe.js';
import { screenStrip, playerBonds, playerLayer, detailBondOwner, toggleBond, popupView } from '../ui/watchBonds.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 conn = useStore((s) => s.connection, shallowEqual);
const emotes = useStore((s) => s.emotes); const emotes = useStore((s) => s.emotes);
const roomSolo = useStore((s) => s.room?.mode === 'solo'); const roomSolo = useStore((s) => s.room?.mode === 'solo');
const spectator = useStore((s) => isSpectating(s.room, s.me.playerId));
const gd = useGameData(); const gd = useGameData();
const hostRef = useRef(null); const hostRef = useRef(null);
@@ -225,7 +230,8 @@ function MatchScreen() {
const solo = roomSolo || String(pub?.modeId || '').includes('single'); const solo = roomSolo || String(pub?.modeId || '').includes('single');
const players = sortedPlayers(pub); const players = sortedPlayers(pub);
const meP = players.find((p) => p.playerId === myId) || null; 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 home = homeFieldId(pub, myId);
const watchingOther = !!watching && watching !== home && watching !== ownFieldId(myId); const watchingOther = !!watching && watching !== home && watching !== ownFieldId(myId);
const editable = phase === PHASE.PREP && !!priv && alive && !priv.ready && !watchingOther; const editable = phase === PHASE.PREP && !!priv && alive && !priv.ready && !watchingOther;
@@ -706,6 +712,18 @@ function MatchScreen() {
const watchField = useCallback((fid) => { requestWatch(fid); }, []); 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) ---------------------------------------------------------------------- // ---- view events (drag & drop, clicks) ----------------------------------------------------------------------
useEffect(() => { useEffect(() => {
if (!view) return undefined; if (!view) return undefined;
@@ -1190,7 +1208,7 @@ function MatchScreen() {
readyBusy=${readyBusy} readyCount=${readyCount} playerCount=${solo ? 1 : aliveCount} readyBusy=${readyBusy} readyCount=${readyCount} playerCount=${solo ? 1 : aliveCount}
pen=${pen} penAvail=${penAvail} onPen=${togglePen} config=${gd.config} frozenAt=${frozenAt} pen=${pen} penAvail=${penAvail} onPen=${togglePen} config=${gd.config} frozenAt=${frozenAt}
pause=${canPause || paused ? { show: canPause, paused, busy: pauseBusy, onToggle: () => togglePause(!paused) } : null} pause=${canPause || paused ? { show: canPause, paused, busy: pauseBusy, onToggle: () => togglePause(!paused) } : null}
live=${liveLpNow} /> live=${liveLpNow} spectator=${spectator} />
<div class="gm__bonds"> <div class="gm__bonds">
<${BondStrip} bonds=${stripBonds} layersDisabled=${layersDisabled} openId=${bondPop && bondPop.ownerId === strip.ownerId ? bondPop.bondId : null} <${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"> ${watchingOther && !combat ? html`<div class="gm__watching" role="status">
<${GIcon} name="eye" /><span>正在查看 <b>${watchedName}</b> 的阵地(只读)</span> <${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} </div>` : null}
${showShop ? html`<${ShopBar} priv=${priv} editable=${editable} collapsed=${collapsed} onCollapse=${setCollapsed} ${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} 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} ${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} 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} /> <${Ticker} />
<div class="gm__corner"> <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" 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> <button type="button" class="gm__gear gm__guide" aria-label="玩法说明" title="玩法说明" onClick=${() => openGuide(0)}><${Icon} name="book" /></button>
<${FullscreenButton} class="gm__gear gm__fs" /> <${FullscreenButton} class="gm__gear gm__fs" />
+13 -2
View File
@@ -1,5 +1,7 @@
// Lobby screen: pick 独立模拟 / 同盟模拟 and a difficulty (标准/险境/绝境/终极), create a room, // 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 // Difficulty descriptions come from data/config.json `modes[modeId]` when present, else from the
// official act2autochess `modeDataDict` texts embedded below (desc + effectDescList), so 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). // plays 战场#01, 险境 draws one of 8, 绝境 / 终极 one of 7 (m01 excluded).
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js'; 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 { html, Button, Icon, MicroLabel, Panel, TextField, PingPill, AvatarFrame, Tooltip, Spinner, DifficultyIcon, doctorNo } from '../ui/components.js';
import { toast, toastError } from '../ui/toasts.js'; import { toast, toastError } from '../ui/toasts.js';
import { GuideButton } from '../ui/guide.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; } if (!CODE_RE.test(k)) { toast(`同盟密钥为 ${ROOM_CODE_LEN} 位字母或数字`, 'warn'); return; }
run('join', () => net.request('room.join', { code: k })); 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 = () => { const backToTitle = () => {
identity.setEntered(false); identity.setEntered(false);
store.set((s) => ({ session: { ...s.session, entered: false } })); store.set((s) => ({ session: { ...s.session, entered: false } }));
@@ -291,6 +299,9 @@ export function LobbyScreen() {
<${TextField} size="code" icon="key" value=${code} placeholder="输入同盟密钥 / 粘贴邀请链接" <${TextField} size="code" icon="key" value=${code} placeholder="输入同盟密钥 / 粘贴邀请链接"
transform=${normalizeCode} onInput=${(v) => setCode(normalizeCode(v))} onEnter=${() => join()} /> transform=${normalizeCode} onInput=${(v) => setCode(normalizeCode(v))} onEnter=${() => join()} />
<${Button} variant="amber" size="lg" icon="users" loading=${busy === 'join'} disabled=${!codeOk || !online} onClick=${() => 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>
<div class="join-foot"> <div class="join-foot">
${recent.length ? html`<span class="t-lo">最近的同盟</span> ${recent.length ? html`<span class="t-lo">最近的同盟</span>
+30 -2
View File
@@ -6,9 +6,12 @@
// host's start counts as the host's ready. So 开始模拟 is enabled exactly then and sends room.start // 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). // alone (no separate room.ready round trip that could leave the host "ready" after a failed start).
// Solo rooms show a single seat. // 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 { 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 { import {
html, Button, Icon, MicroLabel, PingPill, AvatarFrame, DifficultyTag, DifficultyIcon, Tooltip, confirmDialog, doctorNo, html, Button, Icon, MicroLabel, PingPill, AvatarFrame, DifficultyTag, DifficultyIcon, Tooltip, confirmDialog, doctorNo,
} from '../ui/components.js'; } from '../ui/components.js';
@@ -17,7 +20,7 @@ import { copyText } from '../ui/clipboard.js';
import { GuideButton } from '../ui/guide.js'; import { GuideButton } from '../ui/guide.js';
import { LoadoutButton } from './loadout.js'; import { LoadoutButton } from './loadout.js';
import { net } from '../net.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'; import { difficultyInfo } from './lobby.js';
/** /**
@@ -58,6 +61,9 @@ export function roomFacts(room, myId) {
emptySeats: seats.filter((s) => !s).length, emptySeats: seats.filter((s) => !s).length,
canStart: isHost && othersReady && !!mine, canStart: isHost && othersReady && !!mine,
othersReady, 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>`; </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 }) { function InviteBox({ code }) {
const copy = async (what) => { const copy = async (what) => {
const ok = await copyText(what === 'code' ? code : inviteLink(code)); 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 addBot = () => run('add', () => net.request('room.addBot', {}));
const removeBot = (seat) => run(`rm${seat}`, () => net.request('room.removeBot', { seat })); const removeBot = (seat) => run(`rm${seat}`, () => net.request('room.removeBot', { seat }));
const setDifficulty = (difficulty) => run('diff', () => net.request('room.setDifficulty', { difficulty })); 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 () => { const leave = async () => {
if (inFlight.current) return; if (inFlight.current) return;
const othersHere = facts.humans.some((s) => s.playerId !== me.playerId); const othersHere = facts.humans.some((s) => s.playerId !== me.playerId);
@@ -218,6 +241,8 @@ export function RoomScreen() {
const statusLine = !online const statusLine = !online
? html`<span class="t-orange"><${Icon} name="wifiOff" />连接中断,正在重连…</span>` ? html`<span class="t-orange"><${Icon} name="wifiOff" />连接中断,正在重连…</span>`
: facts.spectating
? html`<span class="t-lo"><${Icon} name="eye" />观战中 · 不占博士席位,模拟开始后可切换观看各位博士</span>`
: !coop : !coop
? html`<span class="t-mint">*模拟协议已就绪,准许进入模拟</span>` ? html`<span class="t-mint">*模拟协议已就绪,准许进入模拟</span>`
: facts.isHost : facts.isHost
@@ -263,6 +288,7 @@ export function RoomScreen() {
</ul> </ul>
</aside>`} </aside>`}
</main> </main>
<${SpectatorBar} facts=${facts} myId=${me.playerId} busy=${busy} onRemove=${removeSpectator} onSit=${sit} />
<footer class="room-bar"> <footer class="room-bar">
<div class="room-bar__left"> <div class="room-bar__left">
@@ -285,6 +311,8 @@ export function RoomScreen() {
? html`<${Tooltip} text=${facts.canStart ? null : '仍有博士未准备就绪'}> ? html`<${Tooltip} text=${facts.canStart ? null : '仍有博士未准备就绪'}>
<${Button} variant="primary" size="xl" icon="play" loading=${busy === 'start'} disabled=${!facts.canStart || !online} onClick=${start}>开始模拟<//> <${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} : html`<${Button} variant=${myReady ? 'primary' : 'secondary'} size="xl" icon=${myReady ? 'check' : 'hourglass'} active=${myReady}
loading=${busy === 'ready'} disabled=${!online || !facts.mine} onClick=${toggleReady}>${myReady ? '已就绪' : '准备就绪'}<//>`} loading=${busy === 'ready'} disabled=${!online || !facts.mine} onClick=${toggleReady}>${myReady ? '已就绪' : '准备就绪'}<//>`}
</div> </div>
+11
View File
@@ -103,6 +103,17 @@ export function selectRoute(s) {
return 'lobby'; 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 * 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 * on it: a new playerId) while a room or a match was on screen — everything shown is gone. Null when nothing is lost
+8 -5
View File
@@ -34,8 +34,9 @@ function ProgressList({ list }) {
* client?: null | { progress?: any[]|null, observing?: { name: string }|null, onBack?: () => void, * 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 * 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 }) { export function CombatHud({ pub, myId, watching, hud, myDone, onWatch, spectating = false, spectator = false, client = null }) {
if (client) return ClientHud({ hud, myDone, spectating, client }); // `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); 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 // the watched field may already have finished (not live): still name it
const label = switcherLabel(pub, watching, myId, spectating); 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} /> <${DpCounter} dp=${hud?.dp} />
<div class="chud__bottom"> <div class="chud__bottom">
${myDone ? html`<div class="chud__msg" role="status"><${Icon} name="hourglass" /><span>作战结束,等待队友完成作战</span></div>` : null} ${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')}> ${fields.length ? html`<div class=${cx('vswitch', !canCycle && 'is-single')}>
<button type="button" class="vswitch__arrow" disabled=${!canCycle} aria-label="上一个战场" <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> 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>`; </div>`;
} }
function ClientHud({ hud, myDone, spectating, client }) { function ClientHud({ hud, myDone, spectating, spectator = false, client }) {
const layers = Array.isArray(client.layers) ? client.layers : []; const layers = Array.isArray(client.layers) ? client.layers : [];
const idx = Math.max(0, layers.findIndex((l) => l.key === (client.layer || 'ALL'))); const idx = Math.max(0, layers.findIndex((l) => l.key === (client.layer || 'ALL')));
const cur = layers[idx] || null; const cur = layers[idx] || null;
@@ -72,7 +74,8 @@ function ClientHud({ hud, myDone, spectating, client }) {
<div class="chud__bottom"> <div class="chud__bottom">
${myDone && !observing ? html`<div class="chud__msg chud__wait" role="status"><${Icon} name="hourglass" /><span>作战结束,等待队友完成作战</span></div> ${myDone && !observing ? html`<div class="chud__msg chud__wait" role="status"><${Icon} name="hourglass" /><span>作战结束,等待队友完成作战</span></div>
<${ProgressList} list=${client.progress} />` : null} <${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"> ${observing && !halves ? html`<div class="vswitch chud__observe is-single" role="status">
<span class="vswitch__label"><${GIcon} name="eye" /><span>${observing.name}</span></span> <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()}> ${client.onBack ? html`<button type="button" class="btn btn--secondary btn--sm chud__back" onClick=${() => client.onBack()}>
+2
View File
@@ -74,6 +74,8 @@ export const ICONS = {
collapse: { d: 'M8 3h2v7H3V8h5zm6 0h2v5h5v2h-7zM3 14h7v7H8v-5H3zm11 0h7v2h-5v5h-2z' }, 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 }, 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 }, 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
View File
@@ -308,7 +308,7 @@ export function PauseButton({ paused, busy = false, onToggle }) {
* `left` (a leaker in 联防): its enemies still standing — the capsule's ×N tag * `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 = () => {}, 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 phase = pub?.phase;
const boss = isBossPhase(phase); const boss = isBossPhase(phase);
const lp = boss && Number.isFinite(pub?.teamLp) ? pub.teamLp : Number.isFinite(priv?.lp) ? priv.lp : null; 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 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 hidden = phase === PHASE.HIDDEN_CORE || (Number.isFinite(pub?.lastRound) && pub.round > pub.lastRound);
const roundText = hidden ? '??' : pub?.round > 0 ? String(pub.round) : '--'; 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) // boss rounds: the overtime warning follows the clock (4 Hz while live; frozen while paused)
const otLive = boss && Number(pub?.overtimeAt) > 0; const otLive = boss && Number(pub?.overtimeAt) > 0;
useTicker(otLive && frozenAt == null ? 250 : 0); useTicker(otLive && frozenAt == null ? 250 : 0);
+10 -1
View File
@@ -9,7 +9,7 @@ import { html, Button, Modal, PingPill, DifficultyTag, Countdown, MicroLabel } f
import { actions } from './gameActions.js'; import { actions } from './gameActions.js';
import { toastError } from './toasts.js'; import { toastError } from './toasts.js';
import { net } from '../net.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 { GIcon } from './gameComponents.js';
import { GuideButton } from './guide.js'; import { GuideButton } from './guide.js';
@@ -58,12 +58,21 @@ export async function quitMatch() {
*/ */
export function ExitModal({ open, onClose, solo, onAway }) { export function ExitModal({ open, onClose, solo, onAway }) {
const [busy, setBusy] = useState(null); 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 () => { const quit = async () => {
setBusy('quit'); setBusy('quit');
await quitMatch(); await quitMatch();
setBusy(null); setBusy(null);
onClose(); 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 () => { const away = async () => {
setBusy('away'); setBusy('away');
const ok = await actions.autoplay(true); const ok = await actions.autoplay(true);
+7 -4
View File
@@ -422,11 +422,14 @@ export class Lobby {
if (room.hostId !== session.playerId) return fail(ERR.NOT_HOST); if (room.hostId !== session.playerId) return fail(ERR.NOT_HOST);
if (!room.spectatorOf(playerId)) return fail(ERR.BAD_TARGET, 'not a spectator of this room'); if (!room.spectatorOf(playerId)) return fail(ERR.BAD_TARGET, 'not a spectator of this room');
const target = this.registry.byId(playerId); const target = this.registry.byId(playerId);
if (target && target.roomCode === room.code) { const wasHere = !!target && target.roomCode === room.code;
if (target.connected) sendSession(target, { t: 'room.closed', reason: 'kicked' }); const replay = this.replayFor(room, playerId);
else target.notice = 'kicked';
}
this.removeMember(room, playerId); this.removeMember(room, playerId);
if (wasHere) {
// like room.kick: now, or on the next resume (with the result replay, as after the grace timeout)
if (target.connected) sendSession(target, { t: 'room.closed', reason: 'kicked' });
else { target.notice = 'kicked'; target.pendingResult = replay; }
}
return OK; return OK;
} }
+25
View File
@@ -1047,6 +1047,31 @@ describe('screen helpers', () => {
assert.match(inviteLink('ABCD'), /\?room=ABCD$/); assert.match(inviteLink('ABCD'), /\?room=ABCD$/);
}); });
test('room: spectator seats (community report #26) — isSpectating; roomFacts never counts a spectator as a player', async () => {
const { roomFacts } = await mod('screens/room.js');
const { isSpectating } = await mod('store.js');
const room = {
code: 'ABCD', hostId: 'h', mode: 'coop', difficulty: 'HARD',
seats: [{ seat: 0, playerId: 'h', name: 'Host', isBot: false, ready: false, connected: true }, null, null, null],
spectators: [{ playerId: 's', name: 'Spec', connected: true }],
};
assert.equal(isSpectating(room, 's'), true);
assert.equal(isSpectating(room, 'h'), false);
assert.equal(isSpectating({ ...room, spectators: undefined }, 's'), false, 'a room.state without the list');
assert.equal(isSpectating(room, null), false);
assert.equal(isSpectating(null, 's'), false);
const f = roomFacts(room, 's');
assert.equal(f.spectating, true);
assert.equal(f.mine, null);
assert.equal(f.humans.length, 1, 'never a player');
assert.equal(f.emptySeats, 3, 'a free player seat stays free (入座)');
assert.deepEqual(f.spectators.map((x) => x.playerId), ['s']);
const hf = roomFacts(room, 'h');
assert.equal(hf.canStart, true, 'a spectator never blocks the start');
assert.equal(hf.spectating, false);
assert.deepEqual(roomFacts({ ...room, spectators: 'bad' }, 'h').spectators, []);
});
test('components: roman / doctorNo / secondsLeft', async () => { test('components: roman / doctorNo / secondsLeft', async () => {
const { roman, doctorNo, secondsLeft } = await mod('ui/components.js'); const { roman, doctorNo, secondsLeft } = await mod('ui/components.js');
assert.deepEqual([1, 2, 3, 4, 5, 6].map(roman), ['I', 'II', 'III', 'IV', 'V', 'VI']); assert.deepEqual([1, 2, 3, 4, 5, 6].map(roman), ['I', 'II', 'III', 'IV', 'V', 'VI']);
+10
View File
@@ -661,6 +661,16 @@ describe('websocket lobby', () => {
assert.equal((await s4.waitFor('room.closed')).reason, 'kicked'); assert.equal((await s4.waitFor('room.closed')).reason, 'kicked');
await host.waitFor('room.state', (s) => s.spectators.length === 0); await host.waitFor('room.state', (s) => s.spectators.length === 0);
await expectError(s4, { t: 'room.leave' }, ERR.NOT_IN_ROOM); await expectError(s4, { t: 'room.leave' }, ERR.NOT_IN_ROOM);
// an offline spectator removed: freed at once, told on its next resume
const s5 = await pool.player('Watcher5');
await expectOk(s5, { t: 'room.spectate', code: st.code });
await s5.terminate();
await host.waitFor('room.state', (s) => s.spectators.find((x) => x.playerId === s5.id)?.connected === false);
await expectOk(host, { t: 'room.removeSpectator', playerId: s5.id });
const s5back = await pool.connect();
await s5back.hello('Watcher5', s5.token);
assert.equal((await s5back.waitFor('room.closed')).reason, 'kicked');
await s5back.expectNone('room.state');
// solo rooms have no spectator seat; unknown codes // solo rooms have no spectator seat; unknown codes
const solo = await pool.player('Solo'); const solo = await pool.player('Solo');
const soloSt = await createRoom(solo, 'solo', 'FUNNY'); const soloSt = await createRoom(solo, 'solo', 'FUNNY');
+158
View File
@@ -0,0 +1,158 @@
// Real-server browser E2E — spectator seats (community report #26, a remake feature: the official room has none). The
// lobby's 观战 takes a spectator seat; the room lists it under 观战席 (the host's ✕ frees one — that spectator is told
// room.closed {kicked}); the spectator's room view has 观战中 instead of the ready button; in the match it has no shop,
// no ready toggle, no emote wheel and reads 观战中 (never "你已被淘汰"), is shown the first player's board in 休整期 by
// itself and the first field in battle, never holds an m.private, sends nothing but g.watch (plus its own room.spectate /
// room.loadout), gets its seat back after a reload, and 离开观战 returns it to the lobby.
// Server side: test/lobby.test.js, test/match/spectator.test.js, test/match/lobby-integration.test.js.
//
// SP_E2E=1 node --test test/ui/spectator.e2e.test.js
//
// Screenshots: test/e2e/out/spectator-*.png.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { Client, ROOT, sleep, hasChrome, startRealServer, problemsOf } from '../e2e/client.mjs';
import { TestClient } from '../helpers/wsClient.js';
const ENABLED = process.env.SP_E2E === '1' && hasChrome() && existsSync(path.join(ROOT, 'public/assets'));
const ALLOWED = new Set(['room.spectate', 'room.loadout', 'g.watch', 'g.leave', 'room.leave']);
/** The spectator's own view of the store: still a spectator, never a private view. */
const specState = (c) => c.page.evaluate(() => {
const s = globalThis.__SP__.store.get();
return {
spectating: !!s.room?.spectators?.some((x) => x.playerId === s.me.playerId),
priv: s.match.private != null,
battle: s.match.battle ? { watch: !!s.match.battle.watch, authoritative: !!s.match.battle.authoritative, fieldId: s.match.battle.fieldId } : null,
field: s.match.field ? { fieldId: s.match.field.fieldId, prep: !!s.match.field.prep } : null,
};
});
describe('spectator seats (community report #26, real server)', { skip: !ENABLED && 'set SP_E2E=1 (Chrome + public/assets)' }, () => {
test('观战 from the lobby → 观战席 in the room → the match: watching only, no private view, resume, 离开观战', { timeout: 6 * 60 * 1000 }, async () => {
const srv = await startRealServer({ fast: { timerScale: 1, combatSpeed: 4, startRound: 1, kit: 3, autoPlace: true } });
const P = (await import('puppeteer-core')).default;
const host = new Client(P, srv.base, 'host', { prefix: 'spectator' });
const spec = new Client(P, srv.base, 'spec', { prefix: 'spectator', w: 1280, h: 720 });
let extra = null;
try {
await host.open();
await host.enter('凯尔希');
await host.click('.mode-card', '同盟模拟');
await host.click('.diff-card', '标准模拟');
await host.click('.create-box button', '创建同盟');
const room = (await host.waitFor((s) => !!s.room?.code, 'room created')).room;
await host.click('.seat--empty button', '添加 AI 队友');
// the lobby's 观战 entry
await spec.open();
await spec.hookRequests();
await spec.enter('阿米娅');
await spec.click('.join-row input');
await spec.page.keyboard.type(room.code);
await spec.click('.join-row button', '观战');
await spec.page.waitForSelector('.room-screen .specbar__who.is-me', { timeout: 10000 });
const roomView = await spec.page.evaluate(() => ({
bar: document.querySelector('.room-bar__right .btn--xl')?.textContent || '',
barDisabled: !!document.querySelector('.room-bar__right .btn--xl')?.disabled,
status: document.querySelector('.room-bar__status')?.textContent || '',
seats: [...document.querySelectorAll('.seat__name')].map((e) => e.textContent),
}));
assert.match(roomView.bar, /观战中/);
assert.equal(roomView.barDisabled, true);
assert.match(roomView.status, /观战中/);
assert.ok(!roomView.seats.includes('阿米娅'), 'never in a player seat');
await spec.shot('room-spectator');
// a second spectator (a bare socket) — the host's ✕ removes it: room.closed {kicked}
extra = await TestClient.connect(`ws://127.0.0.1:${srv.port}/ws`);
await extra.hello('华法琳');
assert.equal((await extra.request({ t: 'room.spectate', code: room.code })).t, 'ok');
await host.page.waitForFunction(() => document.querySelectorAll('.specbar__who').length === 2, { timeout: 8000 });
await host.shot('room-host-specbar');
await host.click('.specbar__who button[aria-label*="华法琳"]');
assert.equal((await extra.waitFor('room.closed', () => true, 5000)).reason, 'kicked');
await host.page.waitForFunction(() => document.querySelectorAll('.specbar__who').length === 1, { timeout: 8000 });
// the match: a spectator is no player to wait for
await host.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
for (const c of [host, spec]) await c.waitFor((s) => s.phase === 'INFO_CHECK', 'briefing', 30000);
assert.match(await spec.page.evaluate(() => document.querySelector('.brief__foot .btn--primary')?.textContent || ''), /观战中/);
await host.click('.brief__foot .btn--primary', '准备就绪');
const t0 = Date.now();
for (;;) {
const s = await host.st();
if (s.phase === 'PREP') break;
assert.ok(Date.now() - t0 < 120000, `host stuck in ${s.phase}`);
if (s.phase === 'BAND_DRAFT' && s.draft?.turn === s.me) {
await host.click('.dband:not(.is-taken)', null, { nth: 2, optional: true, timeout: 2000 });
await sleep(200);
await host.click('.draft-detail__btns .btn--primary', '确认选择', { optional: true, timeout: 2000 });
}
await sleep(300);
}
// 休整期: the first player's board by itself, read-only; no shop / ready / 交流
await spec.waitFor((s) => s.phase === 'PREP', 'spectator in prep', 30000);
await spec.page.waitForSelector('.gm__watching', { timeout: 10000 });
await spec.page.waitForSelector('.gm__dead--spectator', { timeout: 10000 });
const prep = await spec.page.evaluate(() => ({
watching: document.querySelector('.gm__watching')?.textContent || '',
back: [...document.querySelectorAll('.gm__watching button')].length,
pill: document.querySelector('.gm__dead')?.textContent || '',
shop: !!document.querySelector('.shopbar, .shopbar-tab'),
ready: !!document.querySelector('.readybtn'),
emote: !!document.querySelector('.ewheel'),
}));
assert.match(prep.watching, /凯尔希/, 'the host\'s board is shown');
assert.equal(prep.back, 0, 'no 返回自己 — a spectator has no board');
assert.match(prep.pill, /观战中/);
assert.doesNotMatch(prep.pill, /淘汰/);
assert.deepEqual([prep.shop, prep.ready, prep.emote], [false, false, false]);
const sp1 = await specState(spec);
assert.deepEqual([sp1.spectating, sp1.priv], [true, false]);
assert.equal(sp1.field?.prep, true);
await sleep(1200);
await spec.shot('prep');
// battle: the first field, a display replica (watch), never the authority
await host.click('.readybtn', null, { timeout: 10000 });
await spec.waitFor((s) => s.phase === 'COMBAT', 'combat', 60000);
await spec.page.waitForFunction(() => !!globalThis.__SP__.store.get().match.battle?.watch, { timeout: 20000 });
const sp2 = await specState(spec);
assert.deepEqual([sp2.priv, sp2.battle.watch, sp2.battle.authoritative], [false, true, false]);
// the auto-observed field reads "👁 name" (as for an eliminated player), with no 返回战场 — never "你已被淘汰"
await spec.page.waitForSelector('.chud__observe', { timeout: 10000 });
const hud = await spec.page.evaluate(() => ({
text: document.querySelector('.chud')?.textContent || '',
back: !!document.querySelector('.chud__back'),
emote: !!document.querySelector('.ewheel'),
}));
assert.match(hud.text, /凯尔希/);
assert.doesNotMatch(hud.text, /淘汰/);
assert.deepEqual([hud.back, hud.emote], [false, false]);
await sleep(1500);
await spec.shot('battle');
// a reload: the seat comes back with the match
await spec.page.reload({ waitUntil: 'domcontentloaded' });
await spec.page.waitForFunction(() => !!globalThis.__SP__ && !!globalThis.__SP__.store.get().match.public, { timeout: 30000 });
const sp3 = await specState(spec);
assert.deepEqual([sp3.spectating, sp3.priv], [true, false]);
// 离开观战: back to the lobby, the match goes on
await spec.click('.gtop__exit', null, { timeout: 15000 });
await spec.click('.modal button', '离开观战');
await spec.page.waitForSelector('.lobby-screen', { timeout: 10000 });
await host.page.waitForFunction(() => (globalThis.__SP__.store.get().room?.spectators || []).length === 0, { timeout: 8000 });
const sent = await spec.requests();
assert.deepEqual([...new Set(sent.map((r) => r[0]))].filter((t) => !ALLOWED.has(t)), [], 'a spectator sends no player action');
assert.deepEqual(problemsOf([host, spec]), []);
} finally {
await extra?.terminate().catch(() => {});
for (const c of [host, spec]) await c.close();
await srv.stop();
}
});
});