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