anvilsign in

collin/mahjong

1import { useEffect, useRef, 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 { useCompact } from './ui/compact';
11import { FullscreenToggle } from './ui/FullscreenToggle';
12import { enterFullscreen, useFullscreen } from './ui/fullscreen';
13import { Help } from './ui/Help';
14import { Seat } from './ui/Seat';
15import { Settings } from './ui/Settings';
16import './styles.css';
17
18const SEATS: SeatId[] = [0, 1, 2, 3];
19/** The human always takes the bottom seat; the other three are the computer's. */
20const SOLO_SEATS = [false, true, true, true];
21const HOTSEAT_SEATS = [false, false, false, false];
22
23export default function App() {
24 const [prefs, setPrefs] = useState<Prefs>(loadPrefs);
25 // Phone-sized: everything reads upright for the one person holding it.
26 const compact = useCompact();
27 const fullscreen = useFullscreen();
28 // Built once, from the preferences as they stood at startup; after that the
29 // settings screen edits the live game rather than replacing it.
30 const [game] = useState(() => new Game(prefs.names, prefs.rules));
31 useSyncExternalStore(game.subscribe, game.getSnapshot);
32 const [helpSeat, setHelpSeat] = useState<SeatId | null>(null);
33 const [settingsOpen, setSettingsOpen] = useState(false);
34 // Read once at startup; after that the live game is the source of truth.
35 const [saved] = useState(() => loadSave());
36 const phase = game.state.phase;
37
38 // Names and stakes live in the game state as well as in the preferences —
39 // saved games keep the settings they were played under.
40 const commitSettings = (p: Prefs) => {
41 setPrefs(p);
42 game.applySettings(p.names, p.rules);
43 setSettingsOpen(false);
44 };
45 // The panel auditions sound changes as you make them, so cancelling has to
46 // put the audio back the way it was.
47 const cancelSettings = () => {
48 setSoundEnabled(prefs.sound);
49 setVoiceEnabled(prefs.voice);
50 setVolume(prefs.volume);
51 setSettingsOpen(false);
52 };
53 const settingsPanel = settingsOpen && (
54 <Settings
55 prefs={prefs}
56 onSave={commitSettings}
57 onClose={cancelSettings}
58 stakesLocked={phase !== 'lobby' && phase !== 'gameEnd'}
59 bots={game.state.bots}
60 />
61 );
62
63 // On <html> rather than passed around, so the panels that render outside the
64 // table — settings, the rules sheet, the lobby — can style themselves off it.
65 useEffect(() => {
66 document.documentElement.classList.toggle('compact', compact);
67 }, [compact]);
68
69 // Leaving fullscreen by the phone's own gesture is a decision too, and should
70 // not be undone the next time a hand starts. A reload never reaches here:
71 // there is no true-to-false to watch on a fresh mount, which is exactly what
72 // lets the preference survive one.
73 const wasFullscreen = useRef(false);
74 useEffect(() => {
75 // Same object back when it is already off, so the toggle's own click does
76 // not get written through a second time.
77 if (wasFullscreen.current && !fullscreen) {
78 setPrefs((p) => (p.fullscreen ? { ...p, fullscreen: false } : p));
79 }
80 wasFullscreen.current = fullscreen;
81 }, [fullscreen]);
82
83 // The browser hands the screen back on every reload, and this game is built
84 // to be put down and picked up again. Starting a hand is a tap, and a tap is
85 // the only place the request is allowed — so that is where it goes back on.
86 const startGame = (bots: boolean[]) => {
87 if (compact && prefs.fullscreen && !fullscreen) enterFullscreen();
88 game.newGame(bots, prefs.names);
89 };
90 const resumeGame = () => {
91 if (compact && prefs.fullscreen && !fullscreen) enterFullscreen();
92 game.restore(saved!.state);
93 };
94
95 // Preferences are written straight through, so a mute survives a refresh.
96 useEffect(() => {
97 savePrefs(prefs);
98 setSoundEnabled(prefs.sound);
99 setVoiceEnabled(prefs.voice);
100 setVolume(prefs.volume);
101 }, [prefs]);
102 useEffect(() => game.onSound(play), [game]);
103 // Watches the table for the whole session and only stirs when a seat the
104 // computer holds is the one being waited on.
105 useEffect(() => new AutoPlay(game).start(), [game]);
106
107 if (phase === 'lobby') {
108 const round = saved ? Math.floor(saved.state.rotations / 4) % 4 : 0;
109 const savedSolo = saved?.state.bots.some(Boolean);
110 return (
111 <div className="lobby">
112 <h1>台灣麻將</h1>
113 <p className="sub">Taiwanese Mahjong · 16 tiles · four players, one screen</p>
114 <p className="hint">
115 把擋板放在螢幕四邊,各自遮住自己的牌。
116 <br />
117 Stand a card over your edge of the screen to hide your hand.
118 </p>
119
120 {saved && (
121 <div className="resume">
122 <div className="resume-line">
123 {savedSolo ? '單人 Solo' : '同桌 Hotseat'} · {WIND_NAMES[round]}圈 · 第{' '}
124 {saved.state.handNumber} 局 · {WIND_EN[round]} round, hand {saved.state.handNumber}
125 </div>
126 <div className="resume-scores">
127 {saved.state.players.map((p, i) => (
128 <span key={i}>
129 {p.name} <b>{p.score}</b>
130 </span>
131 ))}
132 </div>
133 <div className="resume-when">存於 saved {relativeTime(saved.savedAt)}</div>
134 </div>
135 )}
136
137 <div className="lobby-buttons">
138 {saved && (
139 <button className="btn big-btn" onClick={resumeGame}>
140 繼續對局 Resume
141 </button>
142 )}
143 <button
144 className={`btn big-btn ${saved ? 'ghost' : ''}`}
145 onClick={() => startGame(SOLO_SEATS)}
146 >
147 單人對局 Single player
148 <em>你坐東家,其餘三家由電腦代打 · you at 東, three computer players</em>
149 </button>
150 <button className="btn big-btn ghost" onClick={() => startGame(HOTSEAT_SEATS)}>
151 四人同桌 Hotseat
152 <em>四個人共用一個螢幕 · four people, one screen</em>
153 </button>
154 </div>
155
156 <div className="lobby-extras">
157 <button className="btn ghost pill" onClick={() => setSettingsOpen(true)}>
158 ⚙ 設定 Settings
159 </button>
160 {/* Only where the address bar is actually in the way. A desktop has
161 F11, and the lobby there does not need another button. */}
162 {compact && <FullscreenToggle prefs={prefs} onPrefs={setPrefs} />}
163 </div>
164 {settingsPanel}
165 </div>
166 );
167 }
168
169 return (
170 <div className="table">
171 {SEATS.map((s) => (
172 <Seat key={s} game={game} seat={s} compact={compact} onHelp={() => setHelpSeat(s)} />
173 ))}
174 <Center
175 game={game}
176 prefs={prefs}
177 compact={compact}
178 onPrefs={setPrefs}
179 onSettings={() => setSettingsOpen(true)}
180 />
181 {helpSeat !== null && <Help seat={helpSeat} compact={compact} onClose={() => setHelpSeat(null)} />}
182 {settingsPanel}
183 </div>
184 );
185}
186
187function relativeTime(ts: number): string {
188 const mins = Math.round((Date.now() - ts) / 60000);
189 if (mins < 1) return '剛剛 just now';
190 if (mins < 60) return `${mins} 分鐘前 · ${mins} min ago`;
191 const hours = Math.round(mins / 60);
192 if (hours < 24) return `${hours} 小時前 · ${hours} h ago`;
193 const days = Math.round(hours / 24);
194 return `${days} 天前 · ${days} d ago`;
195}