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, 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 { SoundToggle } from './ui/SoundToggle';
13import './styles.css';
14
15const SEATS: SeatId[] = [0, 1, 2, 3];
16
17export default function App() {
18 const [prefs, setPrefs] = useState<Prefs>(loadPrefs);
19 // Built once, from the preferences as they stood at startup; after that the
20 // settings screen edits the live game rather than replacing it.
21 const [game] = useState(() => new Game(prefs.names, prefs.rules));
22 useSyncExternalStore(game.subscribe, game.getSnapshot);
23 const [helpSeat, setHelpSeat] = useState<SeatId | null>(null);
24 const [settingsOpen, setSettingsOpen] = useState(false);
25 // Read once at startup; after that the live game is the source of truth.
26 const [saved] = useState(() => loadSave());
27 const phase = game.state.phase;
28
29 // Names and stakes live in the game state as well as in the preferences —
30 // saved games keep the settings they were played under.
31 const commitSettings = (p: Prefs) => {
32 setPrefs(p);
33 game.applySettings(p.names, p.rules);
34 setSettingsOpen(false);
35 };
36 // The panel auditions sound changes as you make them, so cancelling has to
37 // put the audio back the way it was.
38 const cancelSettings = () => {
39 setSoundEnabled(prefs.sound);
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 setVolume(prefs.volume);
57 }, [prefs]);
58 useEffect(() => game.onSound(play), [game]);
59
60 if (phase === 'lobby') {
61 const round = saved ? Math.floor(saved.state.rotations / 4) % 4 : 0;
62 return (
63 <div className="lobby">
64 <h1>台灣麻將</h1>
65 <p className="sub">Taiwanese Mahjong · 16 tiles · four players, one screen</p>
66 <p className="hint">
67 把擋板放在螢幕四邊,各自遮住自己的牌。
68 <br />
69 Stand a card over your edge of the screen to hide your hand.
70 </p>
71
72 {saved && (
73 <div className="resume">
74 <div className="resume-line">
75 {WIND_NAMES[round]}圈 · 第 {saved.state.handNumber} 局 · {WIND_EN[round]} round, hand{' '}
76 {saved.state.handNumber}
77 </div>
78 <div className="resume-scores">
79 {saved.state.players.map((p, i) => (
80 <span key={i}>
81 {p.name} <b>{p.score}</b>
82 </span>
83 ))}
84 </div>
85 <div className="resume-when">存於 saved {relativeTime(saved.savedAt)}</div>
86 </div>
87 )}
88
89 <div className="lobby-buttons">
90 {saved && (
91 <button className="btn big-btn" onClick={() => game.restore(saved.state)}>
92 繼續對局 Resume
93 </button>
94 )}
95 <button className={`btn big-btn ${saved ? 'ghost' : ''}`} onClick={() => game.newGame()}>
96 {saved ? '開新局 New game' : '開始 Start'}
97 </button>
98 </div>
99
100 <div className="lobby-extras">
101 <button className="btn ghost sound-toggle" onClick={() => setSettingsOpen(true)}>
102 ⚙ 設定 Settings
103 </button>
104 <SoundToggle prefs={prefs} onPrefs={setPrefs} />
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}