实时对局选你的对手
加入
212 行 · 10.4 KB 原始文件 永久链接 历史
1// The viewer: wmzy/sanguosha's engine views and action building, drawn as 无名杀 draws a 三国杀 table (noname/Table.tsx),
2// fed by Shoal instead of the upstream's server. Replay mode plays a replay file from referee/ (the upstream format,
3// sanguosha-replay v2) with the upstream's replay hook. Live mode (?live) gets a room's messages through bridge.js,
4// keeps the public view and the controlled seats' own views up to date with the upstream client's own view maintainer,
5// and posts the seat's moves back with shoalAct.
6import './storage';
7import '../../src/client/animations.css';
8import './noname/noname.css';
9import { useEffect, useReducer, useState } from 'react';
10import { createRoot } from 'react-dom/client';
11import { useReplay } from '../../src/client/hooks/useReplay';
12import { applyServerMessage } from '../../src/client/headless/viewMaintainer';
13import { resourceManager } from '../../src/client/resources';
14import type { GameView, GameEventEnvelope } from '../../src/engine/types';
15import type { ReplayFile } from '../../src/client/replay/types';
16import type { ActionMsg } from '../../src/client/types';
17import type { QueuedEvent } from '../../src/client/hooks/useEventPlayback';
18import { Table } from './noname/Table';
19import { ReplayBar, ReplayView, SeatSwitch, Result, inferWinner } from './noname/Controls';
20
21type Msg = { n?: number; to?: string; version?: number; obs?: Record<string, unknown>; legal?: Array<{ description: string; message: unknown; validTargets: number[]; category: string }>; event?: Record<string, unknown>; end?: { result: string; winner?: string; identities?: Record<string, string>; scores?: Record<string, number> } };
22type Live = { views: Record<string, { view: GameView; seq: number }>; fresh: Record<string, QueuedEvent[]>; asks: Record<string, Msg & { at: number }>; you: string[]; end?: Msg['end']; n: number };
23type Shoal = Window & {
24 shoalReplay?: (start: (text: string, name?: string) => void) => void;
25 shoalLive?: (fn: (data: unknown, you?: string[]) => void) => void;
26 shoalAct?: (seat: string, version: number, action: unknown) => void;
27};
28const w = window as Shoal;
29// Until kit/bridge.js has the live half, the same contract here: {shoal: 'live', room, you, messages} in, {shoal: 'act'} out.
30w.shoalLive ??= (fn) => {
31 addEventListener('message', (e) => {
32 if (e.data?.shoal === 'live') fn(e.data);
33 });
34 if (parent !== window) parent.postMessage({ shoal: 'ready', live: true }, '*');
35};
36w.shoalAct ??= (seat, version, action) => parent.postMessage({ shoal: 'act', seat, version, action }, '*');
37
38const noop = () => {};
39const PUBLIC = 'public';
40// The upstream's resource packs (art and sound it doesn't ship) stay empty: the art pack is this viewer's own (art/).
41// Loading an empty index up front keeps it from fetching one.
42resourceManager.loadIndex({ packs: [] });
43// The art pack's own stylesheet (its fonts and theme pieces), beside the art it names.
44document.head.append(Object.assign(document.createElement('link'), { rel: 'stylesheet', href: 'art/art.css' }));
45
46function apply(v: { view: GameView; seq: number } | undefined, events: GameEventEnvelope[]) {
47 if (!v) return v;
48 let { view, seq } = v;
49 for (const env of events) {
50 const prompt = env.view?.prompt as { cardIds?: string[] } | undefined;
51 if (prompt?.cardIds?.some((id) => id.startsWith('hidden-'))) {
52 view = { ...view, cardMap: { ...view.cardMap } };
53 for (const id of prompt.cardIds) if (id.startsWith('hidden-')) view.cardMap[id] = { id, name: '暗牌', type: '基本牌', subtype: '暗牌', suit: '', rank: '', color: '无色', description: '对方的手牌;只能盲选。' } as GameView['cardMap'][string];
54 }
55 const r = applyServerMessage(view, seq, { type: 'event', epoch: 0, ...env } as never);
56 if (r.view) view = r.view;
57 seq = r.lastSeq;
58 }
59 return { view, seq };
60}
61
62/** The engine events of a batch, as the upstream's event playback queues them (for the table's animations). */
63const queued = (events: GameEventEnvelope[]): QueuedEvent[] => events.filter((e) => e.view).map((e) => ({ seq: e.seq, event: e.view! }));
64
65function reduce(s: Live, batch: { messages: Msg[]; you?: string[] }): Live {
66 const first = s.n < 0;
67 const next: Live = { ...s, views: { ...s.views }, fresh: {}, asks: { ...s.asks }, you: batch.you ?? s.you };
68 const fresh = (key: string, events: GameEventEnvelope[]) => {
69 if (!first) next.fresh[key] = [...(next.fresh[key] ?? []), ...queued(events)];
70 };
71 for (const m of batch.messages) {
72 if (m.n !== undefined) {
73 if (m.n <= next.n) continue;
74 next.n = m.n;
75 }
76 const e = m.event;
77 if (e?.type === 'start') next.views[PUBLIC] = { view: e.view as GameView, seq: e.seq as number };
78 else if (e?.type === 'events') (next.views[PUBLIC] = apply(next.views[PUBLIC], e.events as GameEventEnvelope[])!), fresh(PUBLIC, e.events as GameEventEnvelope[]);
79 else if ((e?.type === 'moved' || e?.type === 'control') && next.asks[e.seat as string]?.version === (e.version ?? next.asks[e.seat as string]?.version)) delete next.asks[e.seat as string];
80 else if (m.end) (next.end = m.end), (next.asks = {});
81 else if (m.to !== undefined && m.version !== undefined) next.asks[m.to] = { ...m, at: Date.now() };
82 else if (m.to !== undefined && m.obs?.view) next.views[m.to] = { view: m.obs.view as GameView, seq: m.obs.seq as number };
83 else if (m.to !== undefined && m.obs?.events) (next.views[m.to] = apply(next.views[m.to], m.obs.events as GameEventEnvelope[])!), fresh(m.to, m.obs.events as GameEventEnvelope[]);
84 }
85 return next;
86}
87
88/** The room's limit as the upstream countdown: the engine's own deadlines run on the room's virtual clock. */
89function withDeadline(view: GameView, ask?: Msg & { at: number }): GameView {
90 const ms = ask ? Number(ask.obs?.limit ?? 30) * 1000 : 0;
91 const deadline = ask ? ask.at + ms : null;
92 return { ...view, deadline, deadlineTotalMs: ms, pending: view.pending ? { ...view.pending, deadline: deadline ?? undefined, totalMs: ms } : null };
93}
94
95const winnerCamp = (end: NonNullable<Live['end']>) => {
96 const w0 = Object.entries(end.scores ?? {}).find(([, v]) => v > 0)?.[0] ?? end.winner;
97 const id = w0 !== undefined ? end.identities?.[w0] : undefined;
98 return id === '忠臣' ? '主公' : (id ?? '');
99};
100
101function LiveTable() {
102 const [s, dispatch] = useReducer(reduce, { views: {}, fresh: {}, asks: {}, you: [], n: -1 });
103 const [pick, setPick] = useState<string | null>(null);
104 const [open, setOpen] = useState(false);
105 useEffect(() => {
106 w.shoalLive!((data, you) => {
107 const d = data as { messages?: Msg[]; you?: string[] } | Msg[];
108 dispatch(Array.isArray(d) ? { messages: d, you } : { messages: d.messages ?? [], you: d.you ?? you });
109 });
110 }, []);
111 const seat = pick && s.you.includes(pick) ? pick : (s.you.find((x) => s.views[x]) ?? s.you[0]);
112 const mine = seat !== undefined ? s.views[seat] : undefined;
113 const key = mine ? seat! : PUBLIC;
114 const base = mine ?? s.views[PUBLIC];
115 if (!base) return <div className="shoal-wait">等待房间开局…</div>;
116 const ask = seat !== undefined ? s.asks[seat] : undefined;
117 const act = (action: ActionMsg | number) => {
118 if (!ask || seat === undefined) return;
119 w.shoalAct!(seat, ask.version!, action);
120 dispatch({ messages: [{ event: { type: 'moved', seat, version: ask.version } }] });
121 };
122 const view = withDeadline(base.view, ask);
123 const moves = ask?.legal?.map((l, i) => ({ l, i })).filter(({ l }) => complete(l)) ?? [];
124 const top = (
125 <>
126 <SeatSwitch seats={s.you} seat={seat} view={view} onPick={setPick} />
127 {moves.length > 0 && (
128 <div className="nm-moves">
129 <button className="nm-mini" onClick={() => setOpen(!open)} title="The room's list of moves for this seat">
130 {open ? '收起' : `操作 ${moves.length}`}
131 </button>
132 {open && (
133 <div className="nm-moves-list">
134 {moves.map(({ l, i }) => (
135 <button key={i} onClick={() => act(i)} title={JSON.stringify(l.message)}>
136 {l.description}
137 </button>
138 ))}
139 </div>
140 )}
141 </div>
142 )}
143 </>
144 );
145 return (
146 <div className="nm-root">
147 <Table
148 view={view}
149 onAction={act}
150 readOnly={!mine || !ask || !!s.end}
151 ingestedEvents={s.fresh[key]}
152 top={top}
153 overlay={s.end ? <Result view={view} camp={winnerCamp(s.end)} identities={s.end.identities} me={view.viewer} /> : null}
154 />
155 </div>
156 );
157}
158
159/** A listed move that needs nothing more (no targets or cards left to pick): one click plays it. */
160function complete(l: { message: unknown; validTargets: number[] }): boolean {
161 const p = ((l.message as { params?: Record<string, unknown> }).params ?? {}) as Record<string, unknown>;
162 if (l.validTargets.length && !Array.isArray(p.targets)) return false;
163 return !Object.values(p).some((v) => Array.isArray(v) && !v.length);
164}
165
166function Replay({ file }: { file: ReplayFile & { shoal?: { seat?: number; names?: string[] } } }) {
167 const r = useReplay(file);
168 const focus = file.shoal?.seat;
169 useEffect(() => {
170 if (focus !== undefined && r.seats.includes(focus)) r.setSeat(focus);
171 // once, on load
172 }, []); // eslint-disable-line react-hooks/exhaustive-deps
173 if (!r.view) return <div className="shoal-wait">…</div>;
174 const names = file.shoal?.names ?? r.view.players.map((p) => p.name);
175 const ended = r.step >= r.total && r.total > 0;
176 return (
177 <div className="nm-root">
178 <Table
179 view={r.view}
180 onAction={noop}
181 readOnly
182 currentEvent={r.currentEvent}
183 ingestedEvents={r.ingestedEvents}
184 labels={file.shoal?.names}
185 top={<ReplayBar r={r} />}
186 side={<ReplayView r={r} names={names} />}
187 overlay={ended ? <Result view={r.view} camp={inferWinner(r.view)} me={r.view.viewer} labels={file.shoal?.names} /> : null}
188 />
189 </div>
190 );
191}
192
193function App() {
194 const live = new URLSearchParams(location.search).has('live');
195 const [file, setFile] = useState<ReplayFile | null>(null);
196 const [error, setError] = useState('');
197 useEffect(() => {
198 if (live) return;
199 w.shoalReplay?.((text) => {
200 try {
201 setFile(JSON.parse(text));
202 } catch {
203 setError('这个文件不是三国杀录像');
204 }
205 });
206 }, [live]);
207 if (live) return <LiveTable />;
208 if (error) return <div className="shoal-wait">{error}</div>;
209 return file ? <Replay file={file} /> : <div className="shoal-wait">等待录像…</div>;
210}
211
212createRoot(document.getElementById('root')!).render(<App />);