diff --git a/docs/PLAYING.md b/docs/PLAYING.md index 75ff4ae3..682d2b87 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -133,7 +133,7 @@ - 左下角「交流」打开表情面板:6 套主题 × 6 个表情,左右滑动(或方向键)换主题。表情以气泡形式显示在你头像旁 3 秒,发送冷却 1 秒。 - 休整期、或自己的作战结束后(显示「作战结束,等待队友完成作战」),点左侧队友头像 →「前往查看」观看他的战场;自己的作战进行中不能观战。联防和最终攻势中可以用 ‹ › 切换左半 / 全景 / 右半。 - 被淘汰后可以自由观看任何战场,而且**不用每回合重选**:你上次点「前往查看」的是谁,之后每个阶段(回合开始、机变、休整期、作战、最终攻势)都会自动跟到他的阵地或战场;没选过或他也被淘汰时,跟随座位顺序第一位还在场的博士(包括 AI 队友)。阶段中途换人立即生效,之后就跟随新选的人。 -- **观战席**(本作新增,原版没有):每个同盟房间另有 2 个观战席。在大厅输入同盟密钥后点「观战」进入(模拟进行中也可以),不占博士席位、不算参战人数,也不能购买、部署、准备或发送表情;观看方式与被淘汰的玩家相同——自动跟随你上次查看的博士(没查看过则是座位顺序第一位还在场的博士):休整期显示他的阵地,作战时观看他的战场,点左侧成员头像可切换查看任何人。断线重连会回到观战席;创建者可以在等待室用观战者名字旁的 ✕ 将其移出;观战者在等待室有空位时可以点「入座」成为博士。 +- **观战席**(本作新增,原版没有):每个同盟房间另有 2 个观战席。在大厅输入同盟密钥后点「观战」进入(模拟进行中也可以),不占博士席位、不算参战人数,也不能购买、部署、准备或发送表情;观看方式与被淘汰的玩家相同——自动跟随你上次查看的博士(没查看过则是座位顺序第一位还在场的博士):休整期显示他的阵地,作战时观看他的战场,点左侧成员头像可切换查看任何人。断线重连会回到观战席;创建者可以在等待室用观战者名字旁的 ✕ 将其移出;模拟开始后,顶栏延迟旁会出现一个「👁 人数」的小胶囊(没有观战者时不显示),点开是观战者名单(离线的有标记),创建者可以在名单里用 ✕ 把观战者移出,被移出的人立刻回到大厅、不再收到战场画面(观战席上限仍是 2 个,不能调整);观战者在等待室有空位时可以点「入座」成为博士。 - **观看谁,盟约栏就显示谁的盟约**:查看队友的阵地或战场时(休整期、作战结束后、被淘汰后自动观战),左上的盟约栏换成**该队友的盟约、人数和层数**,前面有琥珀色的「👁 队友名」标签,点开的盟约详情也写着「队友名 的盟约」(成员一栏只标出他在场上的干员,整备区看不到);作战中层数实时增长(与他自己看到的一致)。联防和最终攻势里跟随 ‹ ›:镜头在队友的半场时显示他的盟约,在你自己的半场时显示你自己的;「全景」时,你在这个战场上作战就显示你自己的,你不在这个战场上(漏怪后观看联防、被淘汰后观战)则显示你点「前往查看」选中的那名队友,没选过就显示该战场的第一名玩家(1 号联防者 / 这一组的第一位),**不会显示你自己的**。在这样的战场上点某名队友的头像 →「前往查看」,镜头会直接移到他的半场,‹ › 上的「👁 队友名」与盟约栏的标签一致;‹ › 旁边有「返回战场」(被淘汰时没有)。盟约栏换人时,从盟约栏打开的盟约详情会自动关闭。点开场上某个干员的详情卡时,卡上的盟约(以及点它打开的盟约详情)属于**这个干员的主人**,与盟约栏当前显示谁无关——例如在「全景」点队友的干员,看到的是「队友名 的盟约」及他的人数和层数;在队友的半场点你自己的干员,看到的是你自己的。回到自己的阵地 / 战场后恢复成你自己的盟约。作战结束后观看队友时刷新页面 / 断线重连,仍会回到正在观看的队友战场,「👁 队友名」和「返回战场」都还在。已被淘汰的队友不再显示盟约(也无法被查看)。 ## 9. 结算称号 diff --git a/docs/design/network.md b/docs/design/network.md index 384b3c9c..4cef9155 100644 --- a/docs/design/network.md +++ b/docs/design/network.md @@ -12,7 +12,7 @@ S→C: `welcome {playerId, token, name, serverNow}` · `room.state {code, hostId Platform additions (implemented): `welcome.{resumed, version}`, `welcome.diyKitted` (0.2.0: the operators a 自选 slot may field — server/sim/content/kits/index.js `KITTED_CHARS`, the picker's list), `room.state.inMatch`, `error.detail`; `room.closed.reason ∈ 'timeout'|'kicked'|'empty'|'shutdown'` (`kicked`: room.kick / room.removeSpectator; `empty`: a spectator whose room lost its last player); 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. +**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, at any time — the room screen's strip and, once the match runs, the game screen's 观战席 capsule in the top bar, GitHub #120) frees one (`room.closed {reason:'kicked'}` to it, no match frame after it); `room.state.spectators: [{playerId, name, connected}]` (still pushed while the match runs). 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, playerId?}` (`playerId`: the player tapped — a shared 联防 / boss pair field shows two; what an eliminated viewer follows, §25) · `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` diff --git a/public/css/screens/game.css b/public/css/screens/game.css index 6283006e..9226da1a 100644 --- a/public/css/screens/game.css +++ b/public/css/screens/game.css @@ -30,6 +30,21 @@ .gtop > * { pointer-events: auto; } .gtop__left { display: flex; align-items: center; gap: .16rem; } .gtop__meta { display: flex; flex-direction: column; align-items: flex-start; gap: .06rem; } +/* the latency pill and, beside it, the 观战席 capsule (ui/hud.js SpectatorPill: an eye + how many are watching; opens the roster) */ +.gtop__net { display: flex; align-items: center; gap: .08rem; } +.specpill { --btn-bg: rgba(0, 0, 0, .5); --btn-bg-hover: rgba(0, 0, 0, .66); --btn-bd: var(--line); height: .32rem; min-width: 0; padding: 0 .12rem; gap: .05rem; border-radius: 99px; } +.specpill .btn__icon { width: .18rem; height: .18rem; color: var(--mint-500); } +.specpill__num { font-size: .18rem; font-weight: 700; color: var(--mint-400); } +.specpill.is-me { --btn-bd: var(--mint-600); } +/* the roster inside its modal */ +.spec__roster { display: flex; flex-direction: column; gap: .06rem; margin: 0 0 .1rem; padding: 0; list-style: none; } +.spec__row { display: flex; align-items: center; gap: .1rem; min-height: .44rem; padding: .04rem .1rem; background: rgba(255, 255, 255, .03); border: 1px solid transparent; } +.spec__row.is-me { border-color: var(--mint-700); background: rgba(45, 212, 191, .08); } +.spec__row.is-offline .spec__name { color: var(--text-dim); } +.spec__ico { width: .18rem; height: .18rem; flex: none; color: var(--mint-500); } +.spec__row.is-offline .spec__ico { color: var(--text-dim); } +.spec__name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .19rem; color: var(--text-hi); } +.spec__hint { margin: .04rem 0 0; font-size: .15rem; line-height: 1.45; } .gtop__exit { box-shadow: 0 .06rem .2rem rgba(0, 0, 0, .5); } .gtop__center { --bk-c: var(--mint-700); diff --git a/public/dev/game-mock.js b/public/dev/game-mock.js index 5ced1d2b..98e2760c 100644 --- a/public/dev/game-mock.js +++ b/public/dev/game-mock.js @@ -724,6 +724,15 @@ async function mockRequest(t, f = {}) { return {}; } case 'g.autoplay': case 'g.leave': case 'room.leave': return {}; + // the host frees a spectator seat (server/lobby.js removeSpectator; ?variant=spectators seats two, ?variant=notHost makes + // another player the host) + case 'room.removeSpectator': { + const room = store.get().room; + if (room?.hostId !== ME) fail('NOT_HOST'); + if (!(room.spectators || []).some((x) => x.playerId === f.playerId)) fail('BAD_TARGET'); + store.set({ room: { ...room, spectators: room.spectators.filter((x) => x.playerId !== f.playerId) } }); + return {}; + } default: return {}; } } @@ -798,7 +807,11 @@ async function boot() { me: { playerId: ME, name: '凯尔希', token: null }, connection: { status: 'online', ping: 42, attempt: 0, retryAt: 0, lastError: null, everOnline: true }, clock: { offset: 0, rtt: 20, synced: true }, - room: { code: 'MOCK', hostId: ME, mode: 'coop', difficulty: 'HARD', inMatch: true, seats: PLAYERS.map((p) => ({ seat: p.seat, playerId: p.playerId, name: p.name, isBot: p.isBot, ready: true, connected: true })) }, + room: { + code: 'MOCK', hostId: VARIANTS.has('notHost') ? 'ai_2' : ME, mode: 'coop', difficulty: 'HARD', inMatch: true, + seats: PLAYERS.map((p) => ({ seat: p.seat, playerId: p.playerId, name: p.name, isBot: p.isBot, ready: true, connected: true })), + ...(VARIANTS.has('spectators') ? { spectators: [{ playerId: 'sp_1', name: '观战者甲', connected: true }, { playerId: 'sp_2', name: '观战者乙', connected: false }] } : {}), + }, }); await data.loadAll(GAME_FILES); installDeviceSupport(); diff --git a/public/i18n/en.json b/public/i18n/en.json index 58874640..d44b7702 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -777,6 +777,7 @@ "观战席": "Spectators", "移出观战者 {name}": "Remove spectator {name}", "移出该观战者": "Remove this spectator", + "观战者不占博士席位,只能观看;创建者可以把观战者移出,被移出的人不再收到战场画面。": "Spectators take no Doctor seat and can only watch; the Host can remove a spectator, who then no longer receives the battlefield.", "入座": "Take a Seat", "移出同盟": "Remove from Alliance", "确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。": "Remove \"{name}\" from the Alliance? They can rejoin with the Alliance Key.", diff --git a/public/i18n/ja.json b/public/i18n/ja.json index 1b57c823..e3c7d410 100644 --- a/public/i18n/ja.json +++ b/public/i18n/ja.json @@ -778,6 +778,7 @@ "观战席": "観戦席", "移出观战者 {name}": "観戦者{name}を除外", "移出该观战者": "この観戦者を除外", + "观战者不占博士席位,只能观看;创建者可以把观战者移出,被移出的人不再收到战场画面。": "観戦者はドクター枠を使わず、観戦のみ可能です。ホストは観戦者を除外でき、除外された人には戦場の映像が届かなくなります。", "入座": "着席", "移出同盟": "同盟から除外", "确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。": "「{name}」を同盟から除外しますか?相手は同盟コードで再参加できます。", diff --git a/public/i18n/ko.json b/public/i18n/ko.json index c5831639..2bca9a9f 100644 --- a/public/i18n/ko.json +++ b/public/i18n/ko.json @@ -774,6 +774,7 @@ "观战席": "관전석", "移出观战者 {name}": "관전자 {name} 내보내기", "移出该观战者": "이 관전자 내보내기", + "观战者不占博士席位,只能观看;创建者可以把观战者移出,被移出的人不再收到战场画面。": "관전자는 박사 자리를 차지하지 않고 관전만 할 수 있으며, 방장은 관전자를 내보낼 수 있습니다. 내보내진 사람은 더 이상 전장 화면을 받지 않습니다.", "入座": "착석", "移出同盟": "동맹에서 내보내기", "确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。": "{name} 님을 동맹에서 내보내시겠습니까? 동맹 코드로 다시 참가할 수 있습니다.", diff --git a/public/i18n/zh-TW.json b/public/i18n/zh-TW.json index 7c521a72..2a83859c 100644 --- a/public/i18n/zh-TW.json +++ b/public/i18n/zh-TW.json @@ -776,6 +776,7 @@ "观战席": "觀戰席", "移出观战者 {name}": "移出觀戰者 {name}", "移出该观战者": "移出該觀戰者", + "观战者不占博士席位,只能观看;创建者可以把观战者移出,被移出的人不再收到战场画面。": "觀戰者不佔博士席位,只能觀看;建立者可以把觀戰者移出,被移出的人不再收到戰場畫面。", "入座": "入座", "移出同盟": "移出同盟", "确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。": "確定將「{name}」移出同盟嗎?對方可以憑同盟金鑰重新加入。", diff --git a/public/js/screens/game.js b/public/js/screens/game.js index f836cb23..d2ae11fe 100644 --- a/public/js/screens/game.js +++ b/public/js/screens/game.js @@ -159,6 +159,12 @@ function MatchScreen() { const emotes = useStore((s) => s.emotes); const roomSolo = useStore((s) => s.room?.mode === 'solo'); const spectator = useStore((s) => isSpectating(s.room, s.me.playerId)); + // the room's spectator seats for the top bar's 观战席 capsule (ui/hud.js SpectatorPill; GitHub #120): room.state keeps coming + // during the match, the list is replaced only when a seat changes — shallowEqual keeps an unrelated push from re-rendering + const specFacts = useStore((s) => ({ + list: Array.isArray(s.room?.spectators) ? s.room.spectators : null, + isHost: !!s.room && s.room.hostId === s.me.playerId, + }), shallowEqual); const gd = useGameData(); const hostRef = useRef(null); @@ -717,6 +723,7 @@ function MatchScreen() { // ---- actions ---------------------------------------------------------------------------------------------- const buy = useCallback((i) => actions.buy(i), []); + const removeSpectator = useCallback((playerId) => actions.removeSpectator(playerId), []); // 准备 with funds left asks first: the prep's end wipes them (community report #4; not 坎诺特, not at 0 funds, not // under AI 托管 — gameLogic.readyFundsPrompt). The button and Space both come here. const askingReady = useRef(false); @@ -1363,7 +1370,8 @@ function MatchScreen() { readyBusy=${readyBusy} readyCount=${readyCount} playerCount=${solo ? 1 : aliveCount} pen=${pen} penAvail=${penAvail} onPen=${togglePen} config=${gd.config} frozenAt=${frozenAt} pause=${canPause || paused ? { show: canPause, paused, busy: pauseBusy, onToggle: () => togglePause(!paused) } : null} - live=${liveLpNow} spectator=${spectator} /> + live=${liveLpNow} spectator=${spectator} + spectators=${specFacts.list} myId=${myId} isHost=${specFacts.isHost} onRemoveSpectator=${removeSpectator} />