// Room screen (同盟等待室): 4 seat cards (avatar frame, name, ready state, AI badge, host crown), // host controls (difficulty picker, add/remove AI and the 「AI 队友最后选择」 switch in co-op, start), invite code with // copy code / copy link, ready toggle and leave. // // Start rule (server/lobby.js): room.start needs every *other* human connected and ready; the // 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. // Texts go through t() (docs/I18N.md). import { useEffect, useRef, useState } from '../../vendor/hooks.module.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'; import { toast, toastError } from '../ui/toasts.js'; import { copyText } from '../ui/clipboard.js'; import { GuideButton } from '../ui/guide.js'; import { openStats } from './stats.js'; import { SettingsButton } from '../ui/settings.js'; import { LoadoutButton } from './loadout.js'; import { net } from '../net.js'; import { store, useStore, shallowEqual, emptyMatch, isSpectating } from '../store.js'; import { difficultyInfo } from './lobby.js'; import { t, tc } from '../../../shared/i18n.js'; /** * Seats padded to the room's capacity (co-op 4, solo 1), each null or a seat record. * @param {any} room room.state payload * @returns {(null | {seat:number, playerId:any, name:string, isBot:boolean, ready:boolean, connected:boolean})[]} */ export function normalizeSeats(room) { const cap = room?.mode === 'solo' ? 1 : MAX_SEATS; const src = Array.isArray(room?.seats) ? room.seats : []; const out = []; for (let i = 0; i < cap; i++) { const s = src[i]; out.push(s && typeof s === 'object' ? { ...s, seat: Number.isInteger(s.seat) ? s.seat : i } : null); } return out; } /** * Derived room facts for the local player. * @param {any} room * @param {any} myId */ export function roomFacts(room, myId) { const seats = normalizeSeats(room); const occupied = seats.filter(Boolean); const humans = occupied.filter((s) => !s.isBot); const mine = occupied.find((s) => s.playerId === myId) || null; const isHost = room?.hostId != null && room.hostId === myId; const others = humans.filter((s) => s.playerId !== myId); // The host never readies: starting the match is the host's ready (server rule), so the count // treats the host as ready — "已就绪 0/1" next to "准许进入模拟" would contradict itself. const isReady = (s) => !!s.ready || s.playerId === room?.hostId; const readyHumans = humans.filter(isReady).length; const othersReady = others.every((s) => s.ready && s.connected !== false); return { seats, occupied, humans, mine, isHost, readyHumans, isReady, 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), }; } /** * The co-op room option 「AI 队友最后选择」 (GitHub #338; room.setAiPicksLast, room.state.aiPicksLast): in the strategy and * 机变 drafts every human picks before the AI teammates. null in a solo room (no AI teammates); otherwise its state (off * when the server sends none) and whether this player may switch it — the host only, the others see it read-only. * @param {any} room room.state payload * @param {any} myId * @returns {{ on: boolean, editable: boolean } | null} */ export function aiLastOption(room, myId) { if (!room || room.mode === 'solo') return null; return { on: room.aiPicksLast === true, editable: room.hostId != null && room.hostId === myId }; } /** Invite link for a room code (current page URL with ?room=CODE). */ export function inviteLink(code) { const loc = globalThis.location; const base = loc ? `${loc.origin}${loc.pathname}` : ''; return `${base}?room=${encodeURIComponent(code)}`; } /** * Copy text to the clipboard (async API with a textarea fallback for insecure contexts). Moved to ui/clipboard.js so * 干员调配 can use it without importing this screen (which imports loadout.js): re-exported here for existing callers. * @param {string} text * @returns {Promise} */ export { copyText }; function SeatCard({ seat, index, room, facts, myId, busy, onAddBot, onRemoveBot, onKick }) { const coop = room.mode !== 'solo'; if (!seat) { const canAdd = coop && facts.isHost; return html`
P${index + 1}<${MicroLabel}>SEAT ${String(index + 1).padStart(2, '0')}
${t('等待博士加入')} <${MicroLabel}>AWAITING DOCTOR
${canAdd ? html`<${Button} variant="secondary" size="sm" icon="robot" block=${true} loading=${busy === `add`} onClick=${onAddBot}>${t('添加 AI 队友')}` : html`${t('空位')}`}
`; } const isMe = seat.playerId === myId; const isHostSeat = seat.playerId === room.hostId; const offline = seat.connected === false && !seat.isBot; // The host never needs to toggle ready: starting the match readies them (server rule). const state = offline ? 'offline' : seat.ready || seat.isBot ? 'ready' : isHostSeat ? 'host' : 'waiting'; return html`
P${index + 1} <${MicroLabel}>SEAT ${String(index + 1).padStart(2, '0')} ${isHostSeat ? html`<${Icon} name="crown" />${t('创建者')}` : null}
<${AvatarFrame} size="xl" name=${seat.name} seat=${index} bot=${seat.isBot} self=${isMe} ready=${state === 'ready'} offline=${offline} /> ${seat.isBot ? html`<${Icon} name="robot" />${t('AI 队友')}` : null}
${seat.name || t('博士')} ${isMe ? html`${t('你')}` : null}
<${MicroLabel}>${seat.isBot ? 'AUTONOMOUS UNIT' : `DOCTOR #${doctorNo(seat.playerId)}`}
${state === 'ready' ? html`<${Icon} name="check" />${t('已就绪')}` : state === 'offline' ? html`<${Icon} name="wifiOff" />${t('连接中断')}` : state === 'host' ? html`<${Icon} name="crown" />${t('待命中')}` : html`<${Icon} name="hourglass" />${t('准备中')}`} ${seat.isBot && facts.isHost ? html`<${Tooltip} text=${t('移除该 AI 队友')}> <${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === `rm${index}`} onClick=${() => onRemoveBot(index)} aria-label=${t('移除 AI 队友')} /> ` : null} ${!seat.isBot && !isMe && facts.isHost ? html`<${Tooltip} text=${t('将该博士移出同盟')}> <${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === `kick${index}`} onClick=${() => onKick(index, seat.name, seat.playerId)} aria-label=${t('移出该博士')} /> ` : null}
`; } /** 观战席: 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" />${t('观战席')}${facts.spectators.length}/${MAX_SPECTATORS} ${facts.spectators.map((s) => html` ${s.connected === false ? html`<${Icon} name="wifiOff" />` : null}${s.name || t('博士')}${s.playerId === myId ? html`${t('你')}` : null} ${facts.isHost ? html`<${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === `rs${s.playerId}`} onClick=${() => onRemove(s.playerId)} aria-label=${t('移出观战者 {name}', { name: s.name || '' })} title=${t('移出该观战者')} />` : null} `)} ${facts.spectating && facts.emptySeats > 0 ? html`<${Button} size="sm" icon="user" loading=${busy === 'sit'} onClick=${onSit}>${t('入座')}` : null}
`; } function InviteBox({ code, name, difficulty }) { const copy = async (what) => { // the copied link carries an invitation line (#103): 「{name}邀请你加入卫戍协议:盟约【{difficulty}】」 const invite = t('{name}邀请你加入卫戍协议:盟约【{difficulty}】', { name: name ?? '', difficulty: DIFFICULTY_NAMES[difficulty] ? t(DIFFICULTY_NAMES[difficulty]) : '' }); const ok = await copyText(what === 'code' ? code : `${inviteLink(code)} ${invite}`); if (ok) toast(what === 'code' ? t('已复制同盟密钥 {code}', { code }) : t('已复制邀请链接'), 'success'); else toast(t('复制失败,请手动复制'), 'warn'); }; return html`
<${Icon} name="key" />${t('同盟密钥')}<${MicroLabel}>ALLIANCE KEY
${[...String(code)].map((ch, i) => html`${ch}`)}
<${Button} size="sm" icon="copy" onClick=${() => copy('code')}>${t('复制密钥')} <${Button} size="sm" icon="link" onClick=${() => copy('link')}>${t('复制链接')}
`; } function DifficultyPicker({ room, isHost, busy, onPick }) { if (!isHost) { return html`
<${DifficultyTag} difficulty=${room.difficulty} size="lg" code=${difficultyInfo(room.mode, room.difficulty).code} /> ${t('由创建者选择')}
`; } return html`
${DIFFICULTIES.map((d) => html``)}
`; } /** The 「AI 队友最后选择」 switch (co-op): the host toggles it, everybody else sees its state. */ function AiLastToggle({ option, busy, onToggle }) { if (!option) return null; return html`
<${Tooltip} text=${t('策略与机变轮选时,所有博士先于 AI 队友选择')}> ${option.editable ? null : html`${t('由创建者设置')}`}
`; } /** Room screen component. */ export function RoomScreen() { const room = useStore((s) => s.room); const me = useStore((s) => s.me, shallowEqual); const conn = useStore((s) => s.connection, shallowEqual); const [busy, setBusy] = useState(null); const alive = useRef(true); const inFlight = useRef(false); // synchronous guard against double clicks (state updates are async) useEffect(() => () => { alive.current = false; }, []); if (!room) return null; const online = conn.status === 'online'; const coop = room.mode !== 'solo'; const facts = roomFacts(room, me.playerId); const myReady = !!facts.mine?.ready; const info = difficultyInfo(room.mode, room.difficulty); const run = async (kind, fn) => { if (inFlight.current) return; if (!online) { toast(t('连接中断,请稍候重试'), 'warn'); return; } inFlight.current = true; setBusy(kind); try { await fn(); } catch (err) { toastError(err); } finally { inFlight.current = false; if (alive.current) setBusy(null); } }; const toggleReady = () => run('ready', () => net.request('room.ready', { ready: !myReady })); const start = () => run('start', () => net.request('room.start', {})); const addBot = () => run('add', () => net.request('room.addBot', {})); const removeBot = (seat) => run(`rm${seat}`, () => net.request('room.removeBot', { seat })); // the host removes a human before the match (community report #17): asked first; the player may join again. The // confirmed player's id goes along: if they left and someone else took the seat meanwhile, the server refuses it. const kick = async (seat, name, playerId) => { if (inFlight.current) return; const ok = await confirmDialog({ title: t('移出同盟'), text: t('确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。', { name: name || t('博士') }), okText: t('移出'), danger: true }); if (ok) run(`kick${seat}`, () => net.request('room.kick', { seat, playerId })); }; const setDifficulty = (difficulty) => run('diff', () => net.request('room.setDifficulty', { difficulty })); const setAiLast = (on) => run('ailast', () => net.request('room.setAiPicksLast', { on })); // 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); if (facts.isHost && othersHere) { const ok = await confirmDialog({ title: t('离开同盟'), text: t('你是同盟的创建者,离开后创建者身份将移交或同盟解散。确定离开吗?'), okText: t('离开'), danger: true }); if (!ok) return; } inFlight.current = true; setBusy('leave'); try { await net.request('room.leave', {}); } catch (err) { if (err?.code !== 'NOT_IN_ROOM') toastError(err); } finally { // Leaving locally is always safe: the server either confirmed or no longer has us in the room. store.set({ room: null, match: emptyMatch() }); inFlight.current = false; if (alive.current) setBusy(null); } }; const statusLine = !online ? html`<${Icon} name="wifiOff" />${t('连接中断,正在重连…')}` : facts.spectating ? html`<${Icon} name="eye" />${t('观战中 · 不占博士席位,模拟开始后可切换观看各位博士')}` : !coop ? html`${t('*模拟协议已就绪,准许进入模拟')}` : facts.isHost ? facts.canStart ? html`${t('*同盟人数达标,准许进入模拟')}` : html`${t('等待所有博士准备就绪')}` : myReady ? html`${t('已就绪 · 等待创建者开始模拟')}` : html`${t('准备就绪后,创建者即可开始模拟')}`; return html`
<${Tooltip} text=${t('离开同盟')} placement="bottom"> <${Button} variant="danger" size="lg" square=${true} icon="exit" loading=${busy === 'leave'} onClick=${leave} aria-label=${t('离开同盟')} />
<${PingPill} ms=${conn.ping} online=${online} /> <${MicroLabel}>${t('当前延迟')}
<${Button} variant="secondary" size="sm" icon="chart" class="stats-entry" onClick=${openStats} title=${t('统计数据')} aria-label=${t('统计数据')}>${t('统计')} <${SettingsButton} class="room-settings" variant="secondary" label=${t('设置')} /> <${GuideButton} class="room-guide" variant="secondary" label=${t('玩法说明')} />
<${MicroLabel} tone="mint">${coop ? 'ALLIANCE LOBBY' : 'SOLO SIMULATION'}

${coop ? t('同盟模拟') : t('独立模拟')}<${DifficultyTag} difficulty=${room.difficulty} size="lg" />

${coop ? html`<${InviteBox} code=${room.code} name=${me.name} difficulty=${room.difficulty} />` : html`
<${MicroLabel}>SINGLE OPERATOR${t('仅限 1 名博士')}
`}
${facts.seats.map((s, i) => html`<${SeatCard} key=${s ? `p${s.playerId}` : `e${i}`} seat=${s} index=${i} room=${room} facts=${facts} myId=${me.playerId} busy=${busy} onAddBot=${addBot} onRemoveBot=${removeBot} onKick=${kick} />`)} ${coop ? null : html``}
<${SpectatorBar} facts=${facts} myId=${me.playerId} busy=${busy} onRemove=${removeSpectator} onSit=${sit} />
`; }