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 { 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 setVoiceEnabled(prefs.voice);
41 setVolume(prefs.volume);
42 setSettingsOpen(false);
43 };
44 const settingsPanel = settingsOpen && (
45 <Settings
46 prefs={prefs}
47 onSave={commitSettings}
48 onClose={cancelSettings}
49 stakesLocked={phase !== 'lobby' && phase !== 'gameEnd'}
50 />
51 );
52
53 // Preferences are written straight through, so a mute survives a refresh.
54 useEffect(() => {
55 savePrefs(prefs);
56 setSoundEnabled(prefs.sound);
57 setVoiceEnabled(prefs.voice);
58 setVolume(prefs.volume);
59 }, [prefs]);
60 useEffect(() => game.onSound(play), [game]);
61
62 if (phase === 'lobby') {
63 const round = saved ? Math.floor(saved.state.rotations / 4) % 4 : 0;
64 return (
65 <div className="lobby">
66 <h1>台灣麻將</h1>
67 <p className="sub">Taiwanese Mahjong · 16 tiles · four players, one screen</p>
68 <p className="hint">
69 把擋板放在螢幕四邊,各自遮住自己的牌。
70 <br />
71 Stand a card over your edge of the screen to hide your hand.
72 </p>
73
74 {saved && (
75 <div className="resume">
76 <div className="resume-line">
77 {WIND_NAMES[round]}圈 · 第 {saved.state.handNumber} 局 · {WIND_EN[round]} round, hand{' '}
78 {saved.state.handNumber}
79 </div>
80 <div className="resume-scores">
81 {saved.state.players.map((p, i) => (
82 <span key={i}>
83 {p.name} <b>{p.score}</b>
84 </span>
85 ))}
86 </div>
87 <div className="resume-when">存於 saved {relativeTime(saved.savedAt)}</div>
88 </div>
89 )}
90
91 <div className="lobby-buttons">
92 {saved && (
93 <button className="btn big-btn" onClick={() => game.restore(saved.state)}>
94 繼續對局 Resume
95 </button>
96 )}
97 <button className={`btn big-btn ${saved ? 'ghost' : ''}`} onClick={() => game.newGame()}>
98 {saved ? '開新局 New game' : '開始 Start'}
99 </button>
100 </div>
101
102 <div className="lobby-extras">
103 <button className="btn ghost sound-toggle" onClick={() => setSettingsOpen(true)}>
104 ⚙ 設定 Settings
105 </button>
106 <SoundToggle prefs={prefs} onPrefs={setPrefs} />
107 </div>
108 {settingsPanel}
109 </div>
110 );
111 }
112
113 return (
114 <div className="table">
115 {SEATS.map((s) => (
116 <Seat key={s} game={game} seat={s} onHelp={() => setHelpSeat(s)} />
117 ))}
118 <Center game={game} prefs={prefs} onPrefs={setPrefs} onSettings={() => setSettingsOpen(true)} />
119 {helpSeat !== null && <Help seat={helpSeat} onClose={() => setHelpSeat(null)} />}
120 {settingsPanel}
121 </div>
122 );
123}
124
125function relativeTime(ts: number): string {
126 const mins = Math.round((Date.now() - ts) / 60000);
127 if (mins < 1) return '剛剛 just now';
128 if (mins < 60) return `${mins} 分鐘前 · ${mins} min ago`;
129 const hours = Math.round(mins / 60);
130 if (hours < 24) return `${hours} 小時前 · ${hours} h ago`;
131 const days = Math.round(hours / 24);
132 return `${days} 天前 · ${days} d ago`;
133}