anvilsign in

collin/mahjong

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