// Room screen (同盟等待室): 4 seat cards (avatar frame, name, ready state, AI badge, host crown), // host controls (difficulty picker, add/remove AI 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. 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 { LoadoutButton } from './loadout.js'; import { net } from '../net.js'; import { store, useStore, shallowEqual, emptyMatch, isSpectating } from '../store.js'; import { difficultyInfo } from './lobby.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), }; } /** 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')}
等待博士加入 <${MicroLabel}>AWAITING DOCTOR
${canAdd ? html`<${Button} variant="secondary" size="sm" icon="robot" block=${true} loading=${busy === `add`} onClick=${onAddBot}>添加 AI 队友` : html`空位`}
`; } 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" />创建者` : 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" />AI 队友` : null}
${seat.name || '博士'} ${isMe ? html`你` : null}
<${MicroLabel}>${seat.isBot ? 'AUTONOMOUS UNIT' : `DOCTOR #${doctorNo(seat.playerId)}`}
${state === 'ready' ? html`<${Icon} name="check" />已就绪` : state === 'offline' ? html`<${Icon} name="wifiOff" />连接中断` : state === 'host' ? html`<${Icon} name="crown" />待命中` : html`<${Icon} name="hourglass" />准备中`} ${seat.isBot && facts.isHost ? html`<${Tooltip} text="移除该 AI 队友"> <${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === `rm${index}`} onClick=${() => onRemoveBot(index)} aria-label="移除 AI 队友" /> ` : null} ${!seat.isBot && !isMe && facts.isHost ? html`<${Tooltip} text="将该博士移出同盟"> <${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === `kick${index}`} onClick=${() => onKick(index, seat.name, seat.playerId)} aria-label="移出该博士" /> ` : 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" />观战席${facts.spectators.length}/${MAX_SPECTATORS} ${facts.spectators.map((s) => html` ${s.connected === false ? html`<${Icon} name="wifiOff" />` : null}${s.name || '博士'}${s.playerId === myId ? html`你` : null} ${facts.isHost ? html`<${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === `rs${s.playerId}`} onClick=${() => onRemove(s.playerId)} aria-label=${`移出观战者 ${s.name || ''}`} title="移出该观战者" />` : null} `)} ${facts.spectating && facts.emptySeats > 0 ? html`<${Button} size="sm" icon="user" loading=${busy === 'sit'} onClick=${onSit}>入座` : null}
`; } function InviteBox({ code, name, difficulty }) { const copy = async (what) => { const ok = await copyText(what === 'code' ? code : `${inviteLink(code)} ${name}邀请你加入卫戍协议:盟约【${DIFFICULTY_NAMES[difficulty]}】`); if (ok) toast(what === 'code' ? `已复制同盟密钥 ${code}` : '已复制邀请链接', 'success'); else toast('复制失败,请手动复制', 'warn'); }; return html`
<${Icon} name="key" />同盟密钥<${MicroLabel}>ALLIANCE KEY
${[...String(code)].map((ch, i) => html`${ch}`)}
<${Button} size="sm" icon="copy" onClick=${() => copy('code')}>复制密钥 <${Button} size="sm" icon="link" onClick=${() => copy('link')}>复制链接
`; } function DifficultyPicker({ room, isHost, busy, onPick }) { if (!isHost) { return html`
<${DifficultyTag} difficulty=${room.difficulty} size="lg" code=${difficultyInfo(room.mode, room.difficulty).code} /> 由创建者选择
`; } return html`
${DIFFICULTIES.map((d) => html``)}
`; } /** 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('连接中断,请稍候重试', '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: '移出同盟', text: `确定将「${name || '博士'}」移出同盟吗?对方可以凭同盟密钥重新加入。`, okText: '移出', danger: true }); if (ok) run(`kick${seat}`, () => net.request('room.kick', { seat, playerId })); }; const setDifficulty = (difficulty) => run('diff', () => net.request('room.setDifficulty', { difficulty })); // spectator seats: the host frees one; a spectator takes a free player seat with room.join of this room const removeSpectator = (playerId) => run(`rs${playerId}`, () => net.request('room.removeSpectator', { playerId })); const sit = () => run('sit', () => net.request('room.join', { code: room.code })); const leave = async () => { if (inFlight.current) return; const othersHere = facts.humans.some((s) => s.playerId !== me.playerId); if (facts.isHost && othersHere) { const ok = await confirmDialog({ title: '离开同盟', text: '你是同盟的创建者,离开后创建者身份将移交或同盟解散。确定离开吗?', okText: '离开', 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" />连接中断,正在重连…` : facts.spectating ? html`<${Icon} name="eye" />观战中 · 不占博士席位,模拟开始后可切换观看各位博士` : !coop ? html`*模拟协议已就绪,准许进入模拟` : facts.isHost ? facts.canStart ? html`*同盟人数达标,准许进入模拟` : html`等待所有博士准备就绪` : myReady ? html`已就绪 · 等待创建者开始模拟` : html`准备就绪后,创建者即可开始模拟`; return html`
<${Tooltip} text="离开同盟" placement="bottom"> <${Button} variant="danger" size="lg" square=${true} icon="exit" loading=${busy === 'leave'} onClick=${leave} aria-label="离开同盟" />
<${PingPill} ms=${conn.ping} online=${online} /> <${MicroLabel}>当前延迟
<${GuideButton} class="room-guide" variant="secondary" />
<${MicroLabel} tone="mint">${coop ? 'ALLIANCE LOBBY' : 'SOLO SIMULATION'}

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

${coop ? html`<${InviteBox} code=${room.code} name=${me.name} difficulty=${room.difficulty} />` : html`
<${MicroLabel}>SINGLE OPERATOR仅限 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} />
`; }