anvilsign in

collin/mahjong

1import { useState } from 'react';
2import { clamp, type Prefs } from '../game/prefs';
3import { play, setSoundEnabled, setVolume } from '../game/sound';
4import { DEFAULT_NAMES, type Rules } from '../game/types';
5
6interface Props {
7 prefs: Prefs;
8 onSave: (p: Prefs) => void;
9 onClose: () => void;
10 /** Stakes are only editable before the first hand of a game is dealt. */
11 stakesLocked: boolean;
12}
13
14interface Toggle {
15 key: keyof Rules;
16 zh: string;
17 en: string;
18 note: string;
19}
20
21/** The house rules tables actually argue about before they start. */
22const TOGGLES: Toggle[] = [
23 {
24 key: 'sacredDiscard',
25 zh: '過水',
26 en: 'Sacred discard',
27 note: 'Pass on a tile you could have won on and it is dead to you until your own next draw.',
28 },
29 {
30 key: 'sacredClearedByClaim',
31 zh: '吃碰解過水',
32 en: 'A claim lifts it',
33 note: 'Taking a 吃 / 碰 / 槓 counts as a fresh start, rather than making you wait to draw.',
34 },
35 {
36 key: 'multipleWinners',
37 zh: '一炮多響',
38 en: 'Multiple winners',
39 note: 'One discard pays out to every seat that calls on it, not just the nearest.',
40 },
41 {
42 key: 'liability',
43 zh: '包牌',
44 en: 'Liability',
45 note: 'Feed the third dragon or wind pung to a 大三元 / 大四喜 and you pay for the whole hand.',
46 },
47];
48
49export function Settings({ prefs, onSave, onClose, stakesLocked }: Props) {
50 // Edited locally and committed on 完成, so a half-typed name never reaches
51 // the table and 取消 really does mean cancel.
52 const [draft, setDraft] = useState<Prefs>(prefs);
53 const rules = draft.rules;
54 const setRules = (patch: Partial<Rules>) => setDraft({ ...draft, rules: { ...rules, ...patch } });
55
56 const num = (
57 key: 'base' | 'taiValue' | 'startingScore',
58 zh: string,
59 en: string,
60 min: number,
61 max: number,
62 ) => (
63 <label className="field">
64 <span className="f-label">
65 {zh}
66 <em>{en}</em>
67 </span>
68 <input
69 type="number"
70 inputMode="numeric"
71 min={min}
72 max={max}
73 disabled={stakesLocked}
74 value={rules[key]}
75 onChange={(e) => setRules({ [key]: clamp(Math.round(+e.target.value || 0), min, max) })}
76 />
77 </label>
78 );
79
80 return (
81 <div className="settings-scrim" onClick={onClose}>
82 <div className="settings" onClick={(e) => e.stopPropagation()}>
83 <div className="settings-head">
84 <h3>設定 · Settings</h3>
85 <div className="settings-head-btns">
86 <button className="btn ghost" onClick={onClose}>
87 取消 Cancel
88 </button>
89 <button className="btn" onClick={() => onSave(draft)}>
90 完成 Done
91 </button>
92 </div>
93 </div>
94
95 <div className="settings-body">
96 <section>
97 <h4>
98 玩家 <em>Players</em>
99 </h4>
100 <div className="names">
101 {draft.names.map((n, i) => (
102 <label className="field" key={i}>
103 <span className="f-label">{i + 1}</span>
104 <input
105 value={n}
106 maxLength={12}
107 placeholder={DEFAULT_NAMES[i]}
108 onChange={(e) => {
109 const names = draft.names.slice();
110 names[i] = e.target.value;
111 setDraft({ ...draft, names });
112 }}
113 />
114 </label>
115 ))}
116 </div>
117 </section>
118
119 <section>
120 <h4>
121 籌碼 <em>Stakes</em>
122 </h4>
123 {num('base', '底', 'Base', 0, 999)}
124 {num('taiValue', '台', 'Per tai', 0, 999)}
125 {num('startingScore', '起始籌碼', 'Starting chips', 0, 99999)}
126 {stakesLocked && (
127 <p className="note lock">
128 對局進行中不能改動籌碼。
129 <br />
130 Stakes are fixed once a game is under way — finish it, or start a new one.
131 </p>
132 )}
133 </section>
134
135 <section>
136 <h4>
137 規則 <em>House rules</em>
138 </h4>
139 {TOGGLES.map((t) => {
140 const on = rules[t.key] as boolean;
141 // 過水 has to be on for the rule about lifting it to mean anything.
142 const dead = t.key === 'sacredClearedByClaim' && !rules.sacredDiscard;
143 return (
144 <label className={`switch ${dead ? 'dead' : ''}`} key={t.key}>
145 <input
146 type="checkbox"
147 checked={on}
148 disabled={dead}
149 onChange={(e) => setRules({ [t.key]: e.target.checked })}
150 />
151 <span className="sw-name">
152 {t.zh}
153 <em>{t.en}</em>
154 </span>
155 <span className="note">{t.note}</span>
156 </label>
157 );
158 })}
159 </section>
160
161 <section>
162 <h4>
163 音效 <em>Sound</em>
164 </h4>
165 <label className="switch">
166 <input
167 type="checkbox"
168 checked={draft.sound}
169 onChange={(e) => {
170 // Start the audio hardware inside the click, not after it.
171 setSoundEnabled(e.target.checked);
172 setDraft({ ...draft, sound: e.target.checked });
173 if (e.target.checked) play('claimWindow');
174 }}
175 />
176 <span className="sw-name">
177 開啟音效
178 <em>Sound on</em>
179 </span>
180 <span className="note">
181 A click as a tile goes down, and a chime when someone's claim window opens.
182 </span>
183 </label>
184 <label className="field">
185 <span className="f-label">
186 音量
187 <em>Volume</em>
188 </span>
189 <input
190 type="range"
191 min={0}
192 max={100}
193 value={Math.round(draft.volume * 100)}
194 disabled={!draft.sound}
195 onChange={(e) => {
196 const v = +e.target.value / 100;
197 setVolume(v);
198 setDraft({ ...draft, volume: v });
199 }}
200 onMouseUp={() => draft.sound && play('discard')}
201 onTouchEnd={() => draft.sound && play('discard')}
202 />
203 </label>
204 </section>
205 </div>
206 </div>
207 </div>
208 );
209}