| 1 | import { useState } from 'react'; |
| 2 | import { clamp, type Prefs } from '../game/prefs'; |
| 3 | import { play, setSoundEnabled, setVoiceEnabled, setVolume } from '../game/sound'; |
| 4 | import { DEFAULT_NAMES, type Rules } from '../game/types'; |
| 5 | import { BiEm, BiText } from './Bi'; |
| 6 | import { useT } from './lang'; |
| 7 | import { LanguageToggle } from './LanguageToggle'; |
| 8 | |
| 9 | interface 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 | |
| 19 | interface 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. */ |
| 28 | const 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 | |
| 67 | export 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="field"> |
| 247 | <span className="f-label"> |
| 248 | <BiEm zh="音量" en="Volume" /> |
| 249 | </span> |
| 250 | <input |
| 251 | type="range" |
| 252 | min={0} |
| 253 | max={100} |
| 254 | value={Math.round(draft.volume * 100)} |
| 255 | disabled={!draft.sound} |
| 256 | onChange={(e) => { |
| 257 | const v = +e.target.value / 100; |
| 258 | setVolume(v); |
| 259 | setDraft({ ...draft, volume: v }); |
| 260 | }} |
| 261 | onMouseUp={() => draft.sound && play('discard')} |
| 262 | onTouchEnd={() => draft.sound && play('discard')} |
| 263 | /> |
| 264 | </label> |
| 265 | </section> |
| 266 | </div> |
| 267 | </div> |
| 268 | </div> |
| 269 | ); |
| 270 | } |