anvilsign in

collin/mahjong

master / src / ui / Settings.tsx
1import { useState } from 'react';
2import { clamp, type Prefs } from '../game/prefs';
3import { play, setSoundEnabled, setSpatial, setVoiceEnabled, setVolume } from '../game/sound';
4import { DEFAULT_NAMES, type Rules } from '../game/types';
5import { BiEm, BiText } from './Bi';
6import { useT } from './lang';
7import { LanguageToggle } from './LanguageToggle';
8
9interface Props {
10 prefs: Prefs;
11 onSave: (p: Prefs) => void;
12 onClose: () => void;
13 /** Stakes are only editable before the first hand of a game is dealt. */
14 stakesLocked: boolean;
15 /** Seats the computer is playing — there is nobody there to name. */
16 bots: boolean[];
17}
18
19interface Toggle {
20 key: keyof Rules;
21 zh: string;
22 en: string;
23 /** What the rule actually does, in both — the sentence a table argues over. */
24 note: [string, string];
25}
26
27/** The house rules tables actually argue about before they start. */
28const TOGGLES: Toggle[] = [
29 {
30 key: 'sacredDiscard',
31 zh: '過水',
32 en: 'Sacred discard',
33 note: [
34 '放棄可以胡的牌,那張牌對你就死了,要等自己再摸一張才解。',
35 'Pass on a tile you could have won on and it is dead to you until your own next draw.',
36 ],
37 },
38 {
39 key: 'sacredClearedByClaim',
40 zh: '吃碰解過水',
41 en: 'A claim lifts it',
42 note: [
43 '吃 / 碰 / 槓 就算重新開始,不必等到自己摸牌才解過水。',
44 'Taking a 吃 / 碰 / 槓 counts as a fresh start, rather than making you wait to draw.',
45 ],
46 },
47 {
48 key: 'multipleWinners',
49 zh: '一炮多響',
50 en: 'Multiple winners',
51 note: [
52 '一張打出的牌,所有喊胡的家都賠,不是只賠最近的一家。',
53 'One discard pays out to every seat that calls on it, not just the nearest.',
54 ],
55 },
56 {
57 key: 'liability',
58 zh: '包牌',
59 en: 'Liability',
60 note: [
61 '放第三副三元或四喜的碰給大三元 / 大四喜,整副牌由你包。',
62 'Feed the third dragon or wind pung to a 大三元 / 大四喜 and you pay for the whole hand.',
63 ],
64 },
65];
66
67export function Settings({ prefs, onSave, onClose, stakesLocked, bots }: Props) {
68 const t = useT();
69 // Edited locally and committed on 完成, so a half-typed name never reaches
70 // the table and 取消 really does mean cancel.
71 const [draft, setDraft] = useState<Prefs>(prefs);
72 const rules = draft.rules;
73 const setRules = (patch: Partial<Rules>) => setDraft({ ...draft, rules: { ...rules, ...patch } });
74
75 const num = (
76 key: 'base' | 'taiValue' | 'startingScore',
77 zh: string,
78 en: string,
79 min: number,
80 max: number,
81 ) => (
82 <label className="field">
83 <span className="f-label">
84 <BiEm zh={zh} en={en} />
85 </span>
86 <input
87 type="number"
88 inputMode="numeric"
89 min={min}
90 max={max}
91 disabled={stakesLocked}
92 value={rules[key]}
93 onChange={(e) => setRules({ [key]: clamp(Math.round(+e.target.value || 0), min, max) })}
94 />
95 </label>
96 );
97
98 return (
99 <div className="settings-scrim" onClick={onClose}>
100 <div className="settings" onClick={(e) => e.stopPropagation()}>
101 <div className="settings-head">
102 <h3>{t('設定', 'Settings', ' · ')}</h3>
103 <div className="settings-head-btns">
104 <button className="btn ghost" onClick={onClose}>
105 {t('取消', 'Cancel')}
106 </button>
107 <button className="btn" onClick={() => onSave(draft)}>
108 {t('完成', 'Done')}
109 </button>
110 </div>
111 </div>
112
113 <div className="settings-body">
114 <section>
115 <h4>
116 <BiEm zh="語言" en="Language" />
117 </h4>
118 {/* The same button the lobby carries; here it edits the draft, so
119 取消 puts the language back like everything else. */}
120 <LanguageToggle prefs={draft} onPrefs={setDraft} />
121 <p className="note">
122 <BiText
123 zh="雙語兩種都顯示,就像這張桌子一向的樣子;繁體中文和 English 各自只留一種。牌面永遠是中文,那是牌上印的字。"
124 en="雙語 shows both, as the table always has. 繁體中文 and English each drop the other — the tile faces stay Chinese either way, because that is what is printed on them."
125 />
126 </p>
127 </section>
128
129 <section>
130 <h4>
131 <BiEm zh="玩家" en="Players" />
132 </h4>
133 <div className="names">
134 {draft.names.map((n, i) => (
135 <label className="field" key={i}>
136 <span className="f-label">{i + 1}</span>
137 <input
138 value={bots[i] ? t('電腦', 'Computer') : n}
139 disabled={bots[i]}
140 maxLength={12}
141 placeholder={DEFAULT_NAMES[i]}
142 onChange={(e) => {
143 const names = draft.names.slice();
144 names[i] = e.target.value;
145 setDraft({ ...draft, names });
146 }}
147 />
148 </label>
149 ))}
150 </div>
151 </section>
152
153 <section>
154 <h4>
155 <BiEm zh="籌碼" en="Stakes" />
156 </h4>
157 {num('base', '底', 'Base', 0, 999)}
158 {num('taiValue', '台', 'Per tai', 0, 999)}
159 {num('startingScore', '起始籌碼', 'Starting chips', 0, 99999)}
160 {stakesLocked && (
161 <p className="note lock">
162 <BiText
163 zh="對局進行中不能改動籌碼。"
164 en="Stakes are fixed once a game is under way — finish it, or start a new one."
165 />
166 </p>
167 )}
168 </section>
169
170 <section>
171 <h4>
172 <BiEm zh="規則" en="House rules" />
173 </h4>
174 {TOGGLES.map((rule) => {
175 const on = rules[rule.key] as boolean;
176 // 過水 has to be on for the rule about lifting it to mean anything.
177 const dead = rule.key === 'sacredClearedByClaim' && !rules.sacredDiscard;
178 return (
179 <label className={`switch ${dead ? 'dead' : ''}`} key={rule.key}>
180 <input
181 type="checkbox"
182 checked={on}
183 disabled={dead}
184 onChange={(e) => setRules({ [rule.key]: e.target.checked })}
185 />
186 <span className="sw-name">
187 <BiEm zh={rule.zh} en={rule.en} />
188 </span>
189 <span className="note">
190 <BiText zh={rule.note[0]} en={rule.note[1]} />
191 </span>
192 </label>
193 );
194 })}
195 </section>
196
197 <section>
198 <h4>
199 <BiEm zh="音效" en="Sound" />
200 </h4>
201 <label className="switch">
202 <input
203 type="checkbox"
204 checked={draft.sound}
205 onChange={(e) => {
206 // Start the audio hardware inside the click, not after it.
207 setSoundEnabled(e.target.checked);
208 setDraft({ ...draft, sound: e.target.checked });
209 if (e.target.checked) play('claimWindow');
210 }}
211 />
212 <span className="sw-name">
213 <BiEm zh="開啟音效" en="Sound on" />
214 </span>
215 <span className="note">
216 <BiText
217 zh="牌落桌的一聲,還有輪到人喊牌時的提示音。"
218 en="A click as a tile goes down, and a chime when someone's claim window opens."
219 />
220 </span>
221 </label>
222 <label className={`switch ${draft.sound ? '' : 'dead'}`}>
223 <input
224 type="checkbox"
225 checked={draft.voice}
226 disabled={!draft.sound}
227 onChange={(e) => {
228 setVoiceEnabled(e.target.checked);
229 setDraft({ ...draft, voice: e.target.checked });
230 // Give it a beat to fetch the pack before the sample.
231 if (e.target.checked) setTimeout(() => play({ kind: 'pung' }), 350);
232 }}
233 />
234 <span className="sw-name">
235 <BiEm zh="報牌" en="Call the tiles" />
236 </span>
237 {/* The calls themselves stay Chinese in every mode: that is the
238 sound of the table, not a label on it. */}
239 <span className="note">
240 <BiText
241 zh="碰 · 吃 · 槓 · 胡了 都會喊出來,打出的每一張牌也會報 — 三條、五萬、東風。"
242 en="碰 · 吃 · 槓 · 胡了 called out loud, and every tile named as it is discarded — 三條, 五萬, 東風."
243 />
244 </span>
245 </label>
246 <label className={`switch ${draft.sound ? '' : 'dead'}`}>
247 <input
248 type="checkbox"
249 checked={draft.spatial}
250 disabled={!draft.sound}
251 onChange={(e) => {
252 setSpatial(e.target.checked);
253 setDraft({ ...draft, spatial: e.target.checked });
254 // From 上家's edge, which is the left one — the whole point
255 // is that you can tell whose it was without looking.
256 if (e.target.checked) play({ kind: 'pung' }, { pos: 3 });
257 }}
258 />
259 <span className="sw-name">
260 <BiEm zh="方位音" en="Sound by seat" />
261 </span>
262 <span className="note">
263 <BiText
264 zh="每一家的聲音從那一家的方位出來 — 上家在左,下家在右,對家遠而悶。"
265 en="Each seat's noise comes from that seat's edge — 上家 to your left, 下家 to your right, and 對家 far off across the table."
266 />
267 </span>
268 </label>
269 <label className="field">
270 <span className="f-label">
271 <BiEm zh="音量" en="Volume" />
272 </span>
273 <input
274 type="range"
275 min={0}
276 max={100}
277 value={Math.round(draft.volume * 100)}
278 disabled={!draft.sound}
279 onChange={(e) => {
280 const v = +e.target.value / 100;
281 setVolume(v);
282 setDraft({ ...draft, volume: v });
283 }}
284 onMouseUp={() => draft.sound && play('discard')}
285 onTouchEnd={() => draft.sound && play('discard')}
286 />
287 </label>
288 </section>
289 </div>
290 </div>
291 </div>
292 );
293}