| 1 | |
| 2 | |
| 3 | |
| 4 | import { useEffect, useMemo, useState } from 'react'; |
| 5 | import type { ReactNode } from 'react'; |
| 6 | import { createRoot } from 'react-dom/client'; |
| 7 | import './deskpark/deskpark.css'; |
| 8 | import './shoal.css'; |
| 9 | import { Board } from './Board'; |
| 10 | import type { Msg, Replay as ReplayData } from './model'; |
| 11 | import { liveView, replayView } from './model'; |
| 12 | |
| 13 | interface Batch { |
| 14 | room: string; |
| 15 | you?: (string | number)[]; |
| 16 | messages: Msg[]; |
| 17 | } |
| 18 | |
| 19 | declare global { |
| 20 | interface Window { |
| 21 | shoalReplay(start: (text: string, name: string) => void): void; |
| 22 | shoalLive(fn: (batch: Batch) => void): void; |
| 23 | shoalAct(seat: string, version: number, action: string): void; |
| 24 | } |
| 25 | } |
| 26 | |
| 27 | function Shell({ sub, right, children }: { sub: string; right?: ReactNode; children?: ReactNode }) { |
| 28 | return ( |
| 29 | <div className="table"> |
| 30 | <header className="table__top"> |
| 31 | <div className="table__top-left"> |
| 32 | <div className="table__title"> |
| 33 | <strong>斗地主</strong> |
| 34 | <span title="RLCard 1.2.0 对局:0 号座位固定地主,无叫牌;炸弹、王炸和春天翻倍。">固定地主 · 无叫牌</span> |
| 35 | <span>{sub}</span> |
| 36 | </div> |
| 37 | </div> |
| 38 | <div className="table__top-right">{right}</div> |
| 39 | </header> |
| 40 | <div className="board">{children}</div> |
| 41 | </div> |
| 42 | ); |
| 43 | } |
| 44 | |
| 45 | function ReplayMode() { |
| 46 | const [R, setR] = useState<ReplayData | null>(null); |
| 47 | const [t, setT] = useState(0); |
| 48 | const [playing, setPlaying] = useState(false); |
| 49 | useEffect( |
| 50 | () => |
| 51 | window.shoalReplay((text) => { |
| 52 | setR(JSON.parse(text)); |
| 53 | setT(0); |
| 54 | setPlaying(true); |
| 55 | }), |
| 56 | [], |
| 57 | ); |
| 58 | const n = R ? R.moveHistory.length : 0; |
| 59 | useEffect(() => { |
| 60 | if (!R || !playing) return; |
| 61 | if (t >= n) return setPlaying(false); |
| 62 | const id = setTimeout(() => setT(t + 1), t === 0 ? 1200 : 850); |
| 63 | return () => clearTimeout(id); |
| 64 | }, [R, t, n, playing]); |
| 65 | if (!R) return <Shell sub="回放" />; |
| 66 | const go = (x: number) => { |
| 67 | setPlaying(false); |
| 68 | setT(Math.max(0, Math.min(n, x))); |
| 69 | }; |
| 70 | const controls = ( |
| 71 | <div className="replay-bar"> |
| 72 | <button className="btn btn--felt btn--sm" aria-label="上一手" onClick={() => go(t - 1)}> |
| 73 | ◀ |
| 74 | </button> |
| 75 | <button |
| 76 | className="btn btn--felt btn--sm" |
| 77 | aria-label={playing ? '暂停' : '播放'} |
| 78 | onClick={() => (playing ? setPlaying(false) : (t >= n && setT(0), setPlaying(true)))} |
| 79 | > |
| 80 | {playing ? '❚❚' : '▶'} |
| 81 | </button> |
| 82 | <button className="btn btn--felt btn--sm" aria-label="下一手" onClick={() => go(t + 1)}> |
| 83 | ▶▏ |
| 84 | </button> |
| 85 | <input type="range" min={0} max={n} value={t} aria-label="第几手" onChange={(e) => go(Number(e.target.value))} /> |
| 86 | <span className="num"> |
| 87 | {t} / {n} |
| 88 | </span> |
| 89 | </div> |
| 90 | ); |
| 91 | return ( |
| 92 | <Shell sub="回放" right={controls}> |
| 93 | <Board v={replayView(R, t)} me={0} peek mine={[]} req={null} /> |
| 94 | </Shell> |
| 95 | ); |
| 96 | } |
| 97 | |
| 98 | function LiveMode() { |
| 99 | const [s, setS] = useState({ room: '', you: [] as string[], msgs: new Map<number, Msg>() }); |
| 100 | useEffect( |
| 101 | () => |
| 102 | window.shoalLive((b) => |
| 103 | setS((old) => { |
| 104 | const msgs = new Map(old.msgs); |
| 105 | for (const m of b.messages ?? []) msgs.set(m.n, m); |
| 106 | return { room: b.room, you: (b.you ?? []).map(String), msgs }; |
| 107 | }), |
| 108 | ), |
| 109 | [], |
| 110 | ); |
| 111 | const { v, reqs } = useMemo(() => liveView([...s.msgs.values()].sort((a, b) => a.n - b.n), s.you), [s]); |
| 112 | const req = reqs[0] ?? null; |
| 113 | const mine = s.you.map(Number); |
| 114 | const me = req ? req.seat : mine.length ? mine[0] : (v.landlord ?? 0); |
| 115 | const status = v.result ? '已结束' : req ? '轮到你出牌' : v.landlord === null ? '等待开局' : '对局中'; |
| 116 | return ( |
| 117 | <Shell sub={`直播${s.room ? ` · 房间 ${s.room}` : ''}`} right={<span className="live-dot">{status}</span>}> |
| 118 | <Board |
| 119 | v={v} |
| 120 | me={me} |
| 121 | peek={false} |
| 122 | mine={mine} |
| 123 | req={req} |
| 124 | onAct={(a) => req && window.shoalAct(String(req.seat), req.version, a)} |
| 125 | /> |
| 126 | </Shell> |
| 127 | ); |
| 128 | } |
| 129 | |
| 130 | const live = new URLSearchParams(location.search).has('live'); |
| 131 | createRoot(document.getElementById('root')!).render(live ? <LiveMode /> : <ReplayMode />); |