anvilsign in

collin/mahjong

1import { useEffect, useState, useSyncExternalStore } from 'react';
2import { AutoPlay } from './game/autoplay';
3import { Game } from './game/engine';
4import { loadPrefs, savePrefs, type Prefs } from './game/prefs';
5import { loadSave } from './game/save';
6import { play, setSoundEnabled, setVoiceEnabled, setVolume } from './game/sound';
7import { WIND_EN, WIND_NAMES } from './game/tiles';
8import type { SeatId } from './game/types';
9import { Center } from './ui/Center';
10import { Help } from './ui/Help';
11import { Seat } from './ui/Seat';
12import { Settings } from './ui/Settings';
13import './styles.css';
14
15const SEATS: SeatId[] = [0, 1, 2, 3];
16/** The human always takes the bottom seat; the other three are the computer's. */
17const SOLO_SEATS = [false, true, true, true];
18const HOTSEAT_SEATS = [false, false, false, false];
19
20export default function App() {
21 const [prefs, setPrefs] = useState<Prefs>(loadPrefs);
22 // Built once, from the preferences as they stood at startup; after that the
23 // settings screen edits the live game rather than replacing it.
24 const [game] = useState(() => new Game(prefs.names, prefs.rules));
25 useSyncExternalStore(game.subscribe, game.getSnapshot);
26 const [helpSeat, setHelpSeat] = useState<SeatId | null>(null);
27 const [settingsOpen, setSettingsOpen] = useState(false);
28 // Read once at startup; after that the live game is the source of truth.
29 const [saved] = useState(() => loadSave());
30 const phase = game.state.phase;
31
32 // Names and stakes live in the game state as well as in the preferences —
33 // saved games keep the settings they were played under.
34 const commitSettings = (p: Prefs) => {
35 setPrefs(p);
36 game.applySettings(p.names, p.rules);
37 setSettingsOpen(false);
38 };
39 // The panel auditions sound changes as you make them, so cancelling has to
40 // put the audio back the way it was.
41 const cancelSettings = () => {
42 setSoundEnabled(prefs.sound);
43 setVoiceEnabled(prefs.voice);
44 setVolume(prefs.volume);
45 setSettingsOpen(false);
46 };
47 const settingsPanel = settingsOpen && (
48 <Settings
49 prefs={prefs}
50 onSave={commitSettings}
51 onClose={cancelSettings}
52 stakesLocked={phase !== 'lobby' && phase !== 'gameEnd'}
53 bots={game.state.bots}
54 />
55 );
56
57 // Preferences are written straight through, so a mute survives a refresh.
58 useEffect(() => {
59 savePrefs(prefs);
60 setSoundEnabled(prefs.sound);
61 setVoiceEnabled(prefs.voice);
62 setVolume(prefs.volume);
63 }, [prefs]);
64 useEffect(() => game.onSound(play), [game]);
65 // Watches the table for the whole session and only stirs when a seat the
66 // computer holds is the one being waited on.
67 useEffect(() => new AutoPlay(game).start(), [game]);
68
69 if (phase === 'lobby') {
70 const round = saved ? Math.floor(saved.state.rotations / 4) % 4 : 0;
71 const savedSolo = saved?.state.bots.some(Boolean);
72 return (
73 <div className="lobby">
74 <h1>台灣麻將</h1>
75 <p className="sub">Taiwanese Mahjong · 16 tiles · four players, one screen</p>
76 <p className="hint">
77 把擋板放在螢幕四邊,各自遮住自己的牌。
78 <br />
79 Stand a card over your edge of the screen to hide your hand.
80 </p>
81
82 {saved && (
83 <div className="resume">
84 <div className="resume-line">
85 {savedSolo ? '單人 Solo' : '同桌 Hotseat'} · {WIND_NAMES[round]}圈 · 第{' '}
86 {saved.state.handNumber} 局 · {WIND_EN[round]} round, hand {saved.state.handNumber}
87 </div>
88 <div className="resume-scores">
89 {saved.state.players.map((p, i) => (
90 <span key={i}>
91 {p.name} <b>{p.score}</b>
92 </span>
93 ))}
94 </div>
95 <div className="resume-when">存於 saved {relativeTime(saved.savedAt)}</div>
96 </div>
97 )}
98
99 <div className="lobby-buttons">
100 {saved && (
101 <button className="btn big-btn" onClick={() => game.restore(saved.state)}>
102 繼續對局 Resume
103 </button>
104 )}
105 <button
106 className={`btn big-btn ${saved ? 'ghost' : ''}`}
107 onClick={() => game.newGame(SOLO_SEATS, prefs.names)}
108 >
109 單人對局 Single player
110 <em>你坐東家,其餘三家由電腦代打 · you at 東, three computer players</em>
111 </button>
112 <button className="btn big-btn ghost" onClick={() => game.newGame(HOTSEAT_SEATS, prefs.names)}>
113 四人同桌 Hotseat
114 <em>四個人共用一個螢幕 · four people, one screen</em>
115 </button>
116 </div>
117
118 <div className="lobby-extras">
119 <button className="btn ghost pill" onClick={() => setSettingsOpen(true)}>
120 ⚙ 設定 Settings
121 </button>
122 </div>
123 {settingsPanel}
124 </div>
125 );
126 }
127
128 return (
129 <div className="table">
130 {SEATS.map((s) => (
131 <Seat key={s} game={game} seat={s} onHelp={() => setHelpSeat(s)} />
132 ))}
133 <Center game={game} prefs={prefs} onPrefs={setPrefs} onSettings={() => setSettingsOpen(true)} />
134 {helpSeat !== null && <Help seat={helpSeat} onClose={() => setHelpSeat(null)} />}
135 {settingsPanel}
136 </div>
137 );
138}
139
140function relativeTime(ts: number): string {
141 const mins = Math.round((Date.now() - ts) / 60000);
142 if (mins < 1) return '剛剛 just now';
143 if (mins < 60) return `${mins} 分鐘前 · ${mins} min ago`;
144 const hours = Math.round(mins / 60);
145 if (hours < 24) return `${hours} 小時前 · ${hours} h ago`;
146 const days = Math.round(hours / 24);
147 return `${days} 天前 · ${days} d ago`;
148}