// 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. import { useEffect, useRef, useState } from '../../vendor/hooks.module.js'; import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS, MAX_SEATS } 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 { GuideButton } from '../ui/guide.js'; import { LoadoutButton } from './loadout.js'; import { net } from '../net.js'; import { store, useStore, shallowEqual, emptyMatch } 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, }; } /** 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). * @param {string} text * @returns {Promise} */ export async function copyText(text) { try { if (globalThis.navigator?.clipboard && globalThis.isSecureContext) { await navigator.clipboard.writeText(text); return true; } } catch { /* fall through */ } try { const ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', ''); // 16 px: iOS zooms into smaller focused fields; `readonly` keeps the keyboard away ta.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0;font-size:16px'; document.body.appendChild(ta); ta.select(); // iOS Safari ignores select() on a textarea: an explicit range is what it copies (LAN play over http has no // navigator.clipboard, so this path is the one iPhones / iPads take) try { ta.setSelectionRange(0, ta.value.length); } catch { /* ignore */ } const ok = document.execCommand('copy'); ta.remove(); return ok; } catch { return false; } } function SeatCard({ seat, index, room, facts, myId, busy, onAddBot, onRemoveBot }) { 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}
`; } function InviteBox({ code }) { const copy = async (what) => { const ok = await copyText(what === 'code' ? code : inviteLink(code)); 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 })); const setDifficulty = (difficulty) => run('diff', () => net.request('room.setDifficulty', { difficulty })); 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" />连接中断,正在重连…` : !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} />` : 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} />`)} ${coop ? null : html``}
`; }