// Chrome shared by every in-match screen: the step header of the pre-game screens (exit + ping + // difficulty | "1/2 确认本局信息" | countdown) and the exit flow (confirm with 暂离 / 放弃, AI 托管 overlay). // // 暂离 (co-op): turns on AI 托管 (g.autoplay on) so the seat keeps playing; the overlay's 返回模拟 turns it // off. 放弃模拟: g.leave then room.leave (the platform treats it as a quit), back to the lobby. import { useState } from '../../vendor/hooks.module.js'; import { html, Button, Modal, PingPill, DifficultyTag, Countdown, MicroLabel } from './components.js'; import { actions } from './gameActions.js'; import { toastError } from './toasts.js'; import { net } from '../net.js'; import { store, useStore, shallowEqual, emptyMatch, createStore, isSpectating } from '../store.js'; import { GIcon } from './gameComponents.js'; import { GuideButton } from './guide.js'; /** * Exit dialog lines. 放弃模拟 (g.leave → room.leave) ends the run on the server as 'abandoned' and returns to the * lobby straight away — no settlement screen follows, so the solo text must not promise one. */ export const EXIT_TEXT = Object.freeze({ soloRest: '独立模拟的休整期没有时间限制,你可以随时继续。', soloQuit: '放弃模拟将立即结束本局并返回大厅,本局进度不会保留,也不会进行结算。', }); /** Local "暂离 / AI 托管" flag (the server keeps no per-client autoplay view). */ export const awayStore = createStore({ away: false }); /** * Whether a store change ends the match the 暂离 flag belongs to: its state was cleared (result → 返回同盟, 放弃模拟, a * new match starting, the room closing, a new server session) or its settlement arrived. The next match must never * open under the previous one's "AI 托管中" overlay (the server starts every match with autoplay off). * @param {any} s store state * @param {any} prev previous store state */ export const awayEnds = (s, prev) => (!!prev?.match?.public && !s?.match?.public) || (!!s?.match?.result && !prev?.match?.result); store.subscribe((s, prev) => { if (awayStore.get().away && awayEnds(s, prev)) awayStore.set({ away: false }); }); /** * Leave the match for good (g.leave → room.leave), then drop local room/match state. * @returns {Promise} */ export async function quitMatch() { try { try { await net.request('g.leave', {}); } catch (err) { if (err?.code !== 'NOT_IN_ROOM' && err?.code !== 'WRONG_PHASE' && err?.code !== 'OFFLINE') throw err; } try { await net.request('room.leave', {}); } catch (err) { if (err?.code !== 'NOT_IN_ROOM' && err?.code !== 'OFFLINE') throw err; } } catch (err) { toastError(err); } finally { store.set({ room: null, match: emptyMatch() }); } } /** * Exit confirmation modal. * @param {{ open: boolean, onClose: Function, solo: boolean, onAway?: Function }} props */ export function ExitModal({ open, onClose, solo, onAway }) { 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 () => { setBusy('quit'); await quitMatch(); setBusy(null); 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}>离开观战`}>

离开观战席并返回大厅,本局模拟不受影响;观战席空着时可以凭同盟密钥再次观战。

`; } const away = async () => { setBusy('away'); const ok = await actions.autoplay(true); setBusy(null); onClose(); if (ok) { awayStore.set({ away: true }); onAway?.(); } }; return html`<${Modal} open=${open} onClose=${onClose} tone="red" title="离开模拟" micro="LEAVE SIMULATION" width="6.8rem" actions=${html` <${Button} variant="secondary" onClick=${onClose}>取消 ${!solo ? html`<${Button} variant="ice" icon="robot" loading=${busy === 'away'} onClick=${away}>暂离(AI 托管)` : null} <${Button} variant="danger" icon="exit" loading=${busy === 'quit'} onClick=${quit}>放弃模拟`}>
${solo ? html`

${EXIT_TEXT.soloRest}

${EXIT_TEXT.soloQuit}

` : html`

暂离:由 AI 托管你的席位(自动部署、准备与选择),随时可以返回。

放弃模拟:离开后无法返回本局,你的干员将回到共享卡池。

`}
`; } /** Full-screen "AI 托管中" overlay with 返回模拟. */ export function AwayOverlay({ onBack = () => {} }) { const [busy, setBusy] = useState(false); return html``; } /** * Pre-game step header. * @param {{ step: number, of: number, title: string, micro: string, pub: any, total?: number|null, onExit: Function }} props */ export function StepHeader({ step, of, title, micro, pub, total, onExit }) { const conn = useStore((s) => s.connection, shallowEqual); return html`
<${Button} variant="danger" size="lg" square=${true} icon="exit" onClick=${onExit} aria-label="离开" title="离开" />
<${PingPill} ms=${conn.ping} online=${conn.status === 'online'} /> ${pub?.difficulty ? html`<${DifficultyTag} difficulty=${pub.difficulty} />` : null}
<${MicroLabel} tone="mint">${micro}

${step}/${of}${title}

<${GuideButton} class="stephead__guide" variant="secondary" /> <${Countdown} deadline=${pub?.deadline} total=${total ?? undefined} size="md" />
`; }