| 1 | import { useState } from 'react'; |
| 2 | import { clamp, type Prefs } from '../game/prefs'; |
| 3 | import { play, setSoundEnabled, setVolume } from '../game/sound'; |
| 4 | import { DEFAULT_NAMES, type Rules } from '../game/types'; |
| 5 | |
| 6 | interface 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 | |
| 14 | interface 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. */ |
| 22 | const 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 | |
| 49 | export 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 | } |