anvilsign in

collin/mahjong

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