collin/mahjong · feaee21e
Three languages, one switch: the whole table follows the picker
Collin Richards · 2026-08-21 17:52 UTC · feaee21e05450d71ebf554f2ffbd4e61f1e83e51 · parent f857279a · browse files
added.claude/commands/triage.md+58 −0
| 1 | + | --- | |
| 2 | + | description: Field raw feedback notes and turn them into work items for the worker session | |
| 3 | + | allowed-tools: Bash(aq:*), Bash(rg:*), Read, Glob, Grep | |
| 4 | + | --- | |
| 5 | + | ||
| 6 | + | You are the triage half of a two-session setup. A worker Claude session is busy | |
| 7 | + | implementing things in this repo. Raw feedback arrives from a phone or browser | |
| 8 | + | into an inbox; your job is to turn it into work items precise enough that the | |
| 9 | + | worker can act without asking follow-up questions. | |
| 10 | + | ||
| 11 | + | Run `aq inbox --json` to see what is waiting. | |
| 12 | + | ||
| 13 | + | For each raw note: | |
| 14 | + | ||
| 15 | + | 1. Read it. Voice notes (`input_mode: "voice"`) are dictated, so expect run-on | |
| 16 | + | sentences, missing punctuation, and transcription errors on identifiers. | |
| 17 | + | Reconstruct the intent; do not preserve the disfluency. | |
| 18 | + | 2. Work out what it actually refers to. Search the codebase for the screen, | |
| 19 | + | component, or symbol involved. A note like "the login button overlaps the | |
| 20 | + | footer on mobile" should end up naming the file that renders it. | |
| 21 | + | 3. Classify it: `bug`, `feature`, `note`, or `question`. Priority is `urgent` | |
| 22 | + | only when it blocks the worker's current task or breaks something shipped. | |
| 23 | + | 4. Answer it yourself when you can: | |
| 24 | + | ||
| 25 | + | aq answer <id> --body "The port comes from portless.json; it is 7331 by default." | |
| 26 | + | ||
| 27 | + | This qualifies when the note is a question *and* the answer is already | |
| 28 | + | knowable from the codebase, the README, or the state of the queue, *and* | |
| 29 | + | nothing in the repo has to change. The reply goes straight back to whoever | |
| 30 | + | asked and never costs the worker a turn. Anything that needs a decision the | |
| 31 | + | user has to make, or any change to the repo however small, is not an answer: | |
| 32 | + | promote it. | |
| 33 | + | 5. Otherwise promote it: | |
| 34 | + | ||
| 35 | + | aq promote <id> --title "..." --body "..." --kind bug --priority normal \ | |
| 36 | + | --files "src/components/Login.tsx" --repro "320px viewport, logged out" | |
| 37 | + | ||
| 38 | + | The body is what the worker reads. Write it as an instruction, not a report: | |
| 39 | + | say what should change and where. | |
| 40 | + | 6. If a note is pure noise or a duplicate of something already in `pending`, | |
| 41 | + | use `aq drop <id> --reason "duplicate of ..."` instead. This includes | |
| 42 | + | duplicates within the batch you are looking at right now: several notes | |
| 43 | + | arriving in one burst often describe the same change (a person typing it, | |
| 44 | + | pausing, then adding "also make it blue"). Promote one item for it and drop | |
| 45 | + | the rest with `--reason "duplicate of <the id you promoted>"`, so the | |
| 46 | + | worker gets one task instead of several for the same thing. | |
| 47 | + | ||
| 48 | + | Never edit files, run builds, or start implementing. The worker owns the code; | |
| 49 | + | you own the queue. If a note is too vague to act on, promote it as a | |
| 50 | + | `question` with the specific thing you need clarified. | |
| 51 | + | ||
| 52 | + | Untriaged notes are delivered to the worker raw after five minutes, so triage | |
| 53 | + | promptly rather than batching. | |
| 54 | + | ||
| 55 | + | This file is the template. The copy you are running is `.claude/commands/triage.md` | |
| 56 | + | inside the project; re-run `aq install` there to refresh it after this changes. | |
| 57 | + | ||
| 58 | + | $ARGUMENTS |
modified.gitignore+3 −0
| ⋯ 24 unchanged lines | |||
| 25 | 25 | ||
| 26 | 26 | # the feedback queue another session tends | |
| 27 | 27 | .agent-queue/ | |
| 28 | + | ||
| 29 | + | # a dev server's pid, not the project's | |
| 30 | + | http.pid | |
modifiedindex.html+5 −1
| 1 | 1 | <!doctype html> | |
| 2 | - | <html lang="zh-TW"> | |
| 2 | + | <html lang="zh-TW" translate="no"> | |
| 3 | 3 | <head> | |
| 4 | 4 | <meta charset="UTF-8" /> | |
| 5 | + | <!-- The UI is bilingual by design (see src/ui/Bi.tsx), so Chrome sees | |
| 6 | + | off-language text next to lang="zh-TW" and offers to translate on | |
| 7 | + | every load. This tells it not to. --> | |
| 8 | + | <meta name="google" content="notranslate" /> | |
| 5 | 9 | <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> | |
| 6 | 10 | <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" /> | |
| 7 | 11 | <!-- Added to the home screen, the game launches without the browser's own | |
| ⋯ 12 unchanged lines | |||
modifiedsrc/App.tsx+85 −32
| ⋯ 1 unchanged line | |||
| 2 | 2 | import { AutoPlay } from './game/autoplay'; | |
| 3 | 3 | import type { TableCtl } from './game/ctl'; | |
| 4 | 4 | import { Game } from './game/engine'; | |
| 5 | - | import { loadPrefs, savePrefs, type Prefs } from './game/prefs'; | |
| 5 | + | import { loadPrefs, savePrefs, type Lang, type Prefs } from './game/prefs'; | |
| 6 | 6 | import { loadSave } from './game/save'; | |
| 7 | 7 | import { play, setSoundEnabled, setVoiceEnabled, setVolume } from './game/sound'; | |
| 8 | 8 | import { WIND_EN, WIND_NAMES } from './game/tiles'; | |
| 9 | 9 | import type { SeatId } from './game/types'; | |
| 10 | 10 | import { roomFromUrl, roomLink, seatFromUrl } from './net/protocol'; | |
| 11 | 11 | import { NetSession } from './net/session'; | |
| 12 | + | import { Bi, BiText } from './ui/Bi'; | |
| 12 | 13 | import { Center } from './ui/Center'; | |
| 13 | 14 | import { useCompact } from './ui/compact'; | |
| 14 | 15 | import { Controller } from './ui/Controller'; | |
| 15 | 16 | import { FullscreenToggle } from './ui/FullscreenToggle'; | |
| 16 | 17 | import { enterFullscreen, useFullscreen } from './ui/fullscreen'; | |
| 17 | 18 | import { Help } from './ui/Help'; | |
| 19 | + | import { pick, setLang, useLang, useT } from './ui/lang'; | |
| 20 | + | import { LanguageToggle } from './ui/LanguageToggle'; | |
| 18 | 21 | import { OnlineLobby } from './ui/OnlineLobby'; | |
| 19 | 22 | import { TablePool } from './table/pool'; | |
| 20 | 23 | import { Pool } from './ui/Pool'; | |
| ⋯ 18 unchanged lines | |||
| 39 | 42 | ||
| 40 | 43 | export default function App() { | |
| 41 | 44 | const [prefs, setPrefs] = useState<Prefs>(loadPrefs); | |
| 45 | + | const t = useT(); | |
| 46 | + | const lang = useLang(); | |
| 42 | 47 | // Phone-sized: everything reads upright for the one person holding it. | |
| 43 | 48 | const compact = useCompact(); | |
| 44 | 49 | const fullscreen = useFullscreen(); | |
| ⋯ 127 unchanged lines | |||
| 172 | 177 | document.documentElement.classList.toggle('compact', compact); | |
| 173 | 178 | }, [compact]); | |
| 174 | 179 | ||
| 180 | + | // Every <Bi> on the table reads the store rather than a prop, so this is the | |
| 181 | + | // one place the saved preference has to reach it. It also sets <html lang>. | |
| 182 | + | useEffect(() => setLang(prefs.lang), [prefs.lang]); | |
| 183 | + | ||
| 175 | 184 | // Leaving fullscreen by the phone's own gesture is a decision too, and should | |
| 176 | 185 | // not be undone the next time a hand starts. A reload never reaches here: | |
| 177 | 186 | // there is no true-to-false to watch on a fresh mount, which is exactly what | |
| ⋯ 62 unchanged lines | |||
| 240 | 249 | const netIdle = !session?.mode; | |
| 241 | 250 | return ( | |
| 242 | 251 | <div className="lobby"> | |
| 243 | - | <h1>台灣麻將</h1> | |
| 244 | - | <p className="sub">Taiwanese Mahjong · 16 tiles · four players</p> | |
| 252 | + | {/* The title is already the bilingual pair: the name in Chinese, the | |
| 253 | + | English underneath. Only the single-language modes change it. */} | |
| 254 | + | <h1>{lang === 'en' ? 'Taiwanese Mahjong' : '台灣麻將'}</h1> | |
| 255 | + | <p className="sub"> | |
| 256 | + | {lang === 'zh' | |
| 257 | + | ? '十六張 · 四人' | |
| 258 | + | : lang === 'en' | |
| 259 | + | ? // The name is already the heading above; here it would be twice. | |
| 260 | + | '16 tiles · four players' | |
| 261 | + | : 'Taiwanese Mahjong · 16 tiles · four players'} | |
| 262 | + | </p> | |
| 245 | 263 | ||
| 246 | 264 | {saved && ( | |
| 247 | 265 | <div className="resume"> | |
| 248 | 266 | <div className="resume-line"> | |
| 249 | - | {savedSolo ? '單人 Solo' : '同桌 Hotseat'} · {WIND_NAMES[round]}圈 · 第{' '} | |
| 250 | - | {saved.state.handNumber} 局 · {WIND_EN[round]} round, hand {saved.state.handNumber} | |
| 267 | + | {savedSolo ? t('單人', 'Solo') : t('同桌', 'Hotseat')} ·{' '} | |
| 268 | + | {t( | |
| 269 | + | `${WIND_NAMES[round]}圈 · 第 ${saved.state.handNumber} 局`, | |
| 270 | + | `${WIND_EN[round]} round, hand ${saved.state.handNumber}`, | |
| 271 | + | ' · ', | |
| 272 | + | )} | |
| 251 | 273 | </div> | |
| 252 | 274 | <div className="resume-scores"> | |
| 253 | 275 | {saved.state.players.map((p, i) => ( | |
| ⋯ 2 unchanged lines | |||
| 256 | 278 | </span> | |
| 257 | 279 | ))} | |
| 258 | 280 | </div> | |
| 259 | - | <div className="resume-when">存於 saved {relativeTime(saved.savedAt)}</div> | |
| 281 | + | <div className="resume-when"> | |
| 282 | + | {t('存於', 'saved')} {relativeTime(saved.savedAt, prefs.lang)} | |
| 283 | + | </div> | |
| 260 | 284 | </div> | |
| 261 | 285 | )} | |
| 262 | 286 | ||
| 263 | 287 | <div className="lobby-buttons"> | |
| 264 | 288 | {saved && ( | |
| 265 | 289 | <button className="btn big-btn" onClick={resumeGame}> | |
| 266 | - | 繼續對局 Resume | |
| 290 | + | <Bi zh="繼續對局" en="Resume" /> | |
| 267 | 291 | </button> | |
| 268 | 292 | )} | |
| 269 | 293 | <button | |
| 270 | 294 | className={`btn big-btn ${saved ? 'ghost' : ''}`} | |
| 271 | 295 | onClick={() => startGame(SOLO_SEATS)} | |
| 272 | 296 | > | |
| 273 | - | 單人對局 Single player | |
| 274 | - | <em>你坐東家,其餘三家由電腦代打 · you at 東, three computer players</em> | |
| 297 | + | <Bi zh="單人對局" en="Single player" /> | |
| 298 | + | <em> | |
| 299 | + | <BiText | |
| 300 | + | zh="你坐東家,其餘三家由電腦代打" | |
| 301 | + | en="you at 東, three computer players" | |
| 302 | + | /> | |
| 303 | + | </em> | |
| 275 | 304 | </button> | |
| 276 | 305 | <button className="btn big-btn ghost" onClick={() => startGame(HOTSEAT_SEATS)}> | |
| 277 | - | 四人同桌 Hotseat | |
| 278 | - | <em>四個人共用一個螢幕 · four people, one screen</em> | |
| 306 | + | <Bi zh="四人同桌" en="Hotseat" /> | |
| 307 | + | <em> | |
| 308 | + | <BiText zh="四個人共用一個螢幕" en="four people, one screen" /> | |
| 309 | + | </em> | |
| 279 | 310 | </button> | |
| 280 | 311 | {(!session || (session.isHost && netIdle)) && ( | |
| 281 | 312 | <> | |
| 282 | 313 | <button className="btn big-btn ghost" onClick={openOnline} disabled={connecting}> | |
| 283 | - | 線上對戰 Online game | |
| 284 | - | <em>各自用自己的裝置 · everyone joins from their own device</em> | |
| 314 | + | <Bi zh="線上對戰" en="Online game" /> | |
| 315 | + | <em> | |
| 316 | + | <BiText zh="各自用自己的裝置" en="everyone joins from their own device" /> | |
| 317 | + | </em> | |
| 285 | 318 | </button> | |
| 286 | 319 | <button className="btn big-btn ghost" onClick={openParty} disabled={connecting}> | |
| 287 | - | 手機派對 Party mode | |
| 288 | - | <em>這螢幕當桌,手機掃碼入座 · this screen is the table, scan the QR to join</em> | |
| 320 | + | <Bi zh="手機派對" en="Party mode" /> | |
| 321 | + | <em> | |
| 322 | + | <BiText | |
| 323 | + | zh="這螢幕當桌,手機掃碼入座" | |
| 324 | + | en="this screen is the table, scan the QR to join" | |
| 325 | + | /> | |
| 326 | + | </em> | |
| 289 | 327 | </button> | |
| 290 | 328 | </> | |
| 291 | 329 | )} | |
| 292 | 330 | {session && !session.isHost && netIdle && ( | |
| 293 | 331 | <p className="hint"> | |
| 294 | - | 已連線 {session.room.code} · {session.playerCount} 人在房,等房主選模式 | |
| 295 | - | <br /> | |
| 296 | - | connected — waiting for the host to pick a mode | |
| 332 | + | <BiText | |
| 333 | + | zh={`已連線 ${session.room.code} · ${session.playerCount} 人在房,等房主選模式`} | |
| 334 | + | en={`connected ${session.room.code} — waiting for the host to pick a mode`} | |
| 335 | + | /> | |
| 297 | 336 | </p> | |
| 298 | 337 | )} | |
| 299 | 338 | </div> | |
| ⋯ 4 unchanged lines | |||
| 304 | 343 | should say why rather than doing nothing. */} | |
| 305 | 344 | <p className="hint"> | |
| 306 | 345 | {connecting | |
| 307 | - | ? '連線中 connecting…' | |
| 346 | + | ? t('連線中', 'connecting…') | |
| 308 | 347 | : session | |
| 309 | - | ? `已連線 connected · ${session.room.code} · ${session.playerCount} 人 · ${ | |
| 310 | - | session.isHost ? '你是房主 you host' : '房主在別台 host elsewhere' | |
| 348 | + | ? `${t('已連線', 'connected')} · ${session.room.code} · ${session.playerCount} ${t( | |
| 349 | + | '人', | |
| 350 | + | session.playerCount === 1 ? 'player' : 'players', | |
| 351 | + | )} · ${ | |
| 352 | + | session.isHost | |
| 353 | + | ? t('你是房主', 'you host') | |
| 354 | + | : t('房主在別台', 'host elsewhere') | |
| 311 | 355 | }` | |
| 312 | 356 | : roomFromUrl() | |
| 313 | - | ? '未連線 not connected' | |
| 314 | - | : '本機 offline · 線上模式會自動連線 online modes connect on tap'} | |
| 357 | + | ? t('未連線', 'not connected') | |
| 358 | + | : t('本機 · 線上模式會自動連線', 'offline · online modes connect on tap', ' · ')} | |
| 315 | 359 | </p> | |
| 316 | 360 | ||
| 317 | 361 | <div className="lobby-extras"> | |
| 318 | 362 | <button className="btn ghost pill" onClick={() => setSettingsOpen(true)}> | |
| 319 | - | ⚙ 設定 Settings | |
| 363 | + | ⚙ {t('設定', 'Settings')} | |
| 320 | 364 | </button> | |
| 365 | + | {/* A setting, but out here where it can be found without first | |
| 366 | + | reading the button that hides it. */} | |
| 367 | + | <LanguageToggle prefs={prefs} onPrefs={setPrefs} compact={compact} /> | |
| 321 | 368 | {/* Only where the address bar is actually in the way. A desktop has | |
| 322 | 369 | F11, and the lobby there does not need another button. */} | |
| 323 | 370 | {compact && <FullscreenToggle prefs={prefs} onPrefs={setPrefs} />} | |
| ⋯ 16 unchanged lines | |||
| 340 | 387 | local: held === 0, | |
| 341 | 388 | remote: { | |
| 342 | 389 | // More phones than one on a hand is a pair playing it together. | |
| 343 | - | holder: held > 0 ? `${slot.name || '手機 phone'}${held > 1 ? ` ×${held}` : ''}` : null, | |
| 390 | + | holder: | |
| 391 | + | held > 0 | |
| 392 | + | ? `${slot.name || t('手機', 'phone')}${held > 1 ? ` ×${held}` : ''}` | |
| 393 | + | : null, | |
| 344 | 394 | onPeek: setQrPeek, | |
| 345 | 395 | }, | |
| 346 | 396 | }; | |
| ⋯ 39 unchanged lines | |||
| 386 | 436 | <div className="qr-dock"> | |
| 387 | 437 | <SeatQr url={roomLink(session!.room.code, session!.room.publicBase)} /> | |
| 388 | 438 | <span className="qr-hint"> | |
| 389 | - | 掃碼入座,用手機拿牌 | |
| 390 | - | <em>scan to join from your phone</em> | |
| 439 | + | {lang !== 'en' && '掃碼入座,用手機拿牌'} | |
| 440 | + | {lang !== 'zh' && <em>scan to join from your phone</em>} | |
| 391 | 441 | </span> | |
| 392 | 442 | </div> | |
| 393 | 443 | )} | |
| ⋯ 12 unchanged lines | |||
| 406 | 456 | ); | |
| 407 | 457 | } | |
| 408 | 458 | ||
| 409 | - | function relativeTime(ts: number): string { | |
| 459 | + | function relativeTime(ts: number, lang: Lang): string { | |
| 460 | + | // The number is the same in both, so it is said once and only the unit is | |
| 461 | + | // picked — otherwise bilingual would read '5 分鐘前 · 5 min ago'. | |
| 462 | + | const t = (zh: string, en: string) => pick(lang, zh, en, ' · '); | |
| 410 | 463 | const mins = Math.round((Date.now() - ts) / 60000); | |
| 411 | - | if (mins < 1) return '剛剛 just now'; | |
| 412 | - | if (mins < 60) return `${mins} 分鐘前 · ${mins} min ago`; | |
| 464 | + | if (mins < 1) return t('剛剛', 'just now'); | |
| 465 | + | if (mins < 60) return `${mins} ${t('分鐘前', 'min ago')}`; | |
| 413 | 466 | const hours = Math.round(mins / 60); | |
| 414 | - | if (hours < 24) return `${hours} 小時前 · ${hours} h ago`; | |
| 467 | + | if (hours < 24) return `${hours} ${t('小時前', 'h ago')}`; | |
| 415 | 468 | const days = Math.round(hours / 24); | |
| 416 | - | return `${days} 天前 · ${days} d ago`; | |
| 469 | + | return `${days} ${t('天前', 'd ago')}`; | |
| 417 | 470 | } | |
modifiedsrc/game/prefs.ts+16 −0
| ⋯ 13 unchanged lines | |||
| 14 | 14 | const AUDIO_GENERATION = 2; | |
| 15 | 15 | ||
| 16 | 16 | /** | |
| 17 | + | * How the bilingual labels read: both languages stacked as the game has always | |
| 18 | + | * shown them, Chinese alone, or English alone. See ui/lang.ts for the store | |
| 19 | + | * that carries it to <Bi>. | |
| 20 | + | */ | |
| 21 | + | export type Lang = 'bi' | 'zh' | 'en'; | |
| 22 | + | ||
| 23 | + | export const LANGS: Lang[] = ['bi', 'zh', 'en']; | |
| 24 | + | ||
| 25 | + | /** | |
| 17 | 26 | * Preferences belong to the table, not to a hand: they outlive a save, survive | |
| 18 | 27 | * 開新局, and are what a fresh `Game` is built from. | |
| 19 | 28 | */ | |
| ⋯ 12 unchanged lines | |||
| 32 | 41 | * ui/fullscreen.ts. Not an audio setting: it is a choice, and it carries. | |
| 33 | 42 | */ | |
| 34 | 43 | fullscreen: boolean; | |
| 44 | + | /** | |
| 45 | + | * Which languages the labels show. Not an audio setting either: somebody | |
| 46 | + | * picked the language they can read, and it carries. | |
| 47 | + | */ | |
| 48 | + | lang: Lang; | |
| 35 | 49 | names: string[]; | |
| 36 | 50 | rules: Rules; | |
| 37 | 51 | } | |
| ⋯ 4 unchanged lines | |||
| 42 | 56 | volume: 0.7, | |
| 43 | 57 | voice: true, | |
| 44 | 58 | fullscreen: false, | |
| 59 | + | lang: 'bi', | |
| 45 | 60 | names: DEFAULT_NAMES, | |
| 46 | 61 | rules: DEFAULT_RULES, | |
| 47 | 62 | }; | |
| ⋯ 10 unchanged lines | |||
| 58 | 73 | volume: clamp(typeof p.volume === 'number' ? p.volume : DEFAULT_PREFS.volume, 0, 1), | |
| 59 | 74 | voice: !stale && typeof p.voice === 'boolean' ? p.voice : DEFAULT_PREFS.voice, | |
| 60 | 75 | fullscreen: typeof p.fullscreen === 'boolean' ? p.fullscreen : DEFAULT_PREFS.fullscreen, | |
| 76 | + | lang: LANGS.includes(p.lang as Lang) ? (p.lang as Lang) : DEFAULT_PREFS.lang, | |
| 61 | 77 | names: | |
| 62 | 78 | Array.isArray(p.names) && p.names.length === 4 && p.names.every((n) => typeof n === 'string') | |
| 63 | 79 | ? p.names | |
| ⋯ 17 unchanged lines | |||
modifiedsrc/styles.css+24 −0
| ⋯ 1144 unchanged lines | |||
| 1145 | 1145 | line-height: 1; | |
| 1146 | 1146 | } | |
| 1147 | 1147 | ||
| 1148 | + | /* ---------- language ---------- */ | |
| 1149 | + | /* Sized to stand beside the sound and fullscreen pills in both their forms. */ | |
| 1150 | + | .lang-toggle { | |
| 1151 | + | min-height: 34px; | |
| 1152 | + | padding: 2px 14px; | |
| 1153 | + | } | |
| 1154 | + | .lang-toggle.compact { | |
| 1155 | + | min-height: 28px; | |
| 1156 | + | padding: 2px 9px; | |
| 1157 | + | } | |
| 1158 | + | /* A boxed glyph, so 雙 / 中 / A all take the same room and the row does not | |
| 1159 | + | shuffle sideways as the mode steps round. */ | |
| 1160 | + | .lang-toggle .lang-icon { | |
| 1161 | + | display: inline-grid; | |
| 1162 | + | place-items: center; | |
| 1163 | + | width: 18px; | |
| 1164 | + | height: 18px; | |
| 1165 | + | flex: none; | |
| 1166 | + | border: 1px solid rgba(230, 226, 214, 0.45); | |
| 1167 | + | border-radius: 4px; | |
| 1168 | + | font-size: 12px; | |
| 1169 | + | line-height: 1; | |
| 1170 | + | } | |
| 1171 | + | ||
| 1148 | 1172 | /* ---------- shared table controls ---------- */ | |
| 1149 | 1173 | /* Bottom-right of the centre square, opposite the log. Unrotated on purpose: | |
| 1150 | 1174 | it belongs to the table, not to a seat. */ | |
| ⋯ 1097 unchanged lines | |||
modifiedsrc/table/physics.test.ts+43 −0
| ⋯ 15 unchanged lines | |||
| 16 | 16 | addBody, | |
| 17 | 17 | createWorld, | |
| 18 | 18 | faceOn, | |
| 19 | + | FIXED_DT, | |
| 19 | 20 | liftFor, | |
| 20 | 21 | outside, | |
| 21 | 22 | overlapping, | |
| ⋯ 158 unchanged lines | |||
| 180 | 181 | expect(outside(b, POOL)).toBeLessThan(0.5); | |
| 181 | 182 | }); | |
| 182 | 183 | ||
| 184 | + | it('does not jerk a tile inwards at the moment it crosses onto the felt', () => { | |
| 185 | + | // Coming in from a hand, the tile is outside the bounds and passes through | |
| 186 | + | // them — that is what lets it be thrown in at all. But the edge is only a | |
| 187 | + | // fence once it is in, and which step that happens on must not be visible: | |
| 188 | + | // a tile that gets snapped half its own length up the table, and loses half | |
| 189 | + | // its speed doing it, has hit something that is not drawn anywhere. | |
| 190 | + | const world = createWorld(POOL); | |
| 191 | + | const b = addBody(world, { | |
| 192 | + | tile: 0, | |
| 193 | + | seat: 0, | |
| 194 | + | // From below, as seat 0 throws, low enough to be inside the wall's height | |
| 195 | + | // the whole way — a skid rather than a lob. | |
| 196 | + | x: POOL.x + POOL.w / 2, | |
| 197 | + | y: POOL.y + POOL.h + 120, | |
| 198 | + | vx: 0, | |
| 199 | + | vy: -1600, | |
| 200 | + | z: 0, | |
| 201 | + | vz: liftFor(0.18), | |
| 202 | + | angle: 0, | |
| 203 | + | spin: 0, | |
| 204 | + | w: TILE.w, | |
| 205 | + | h: TILE.h, | |
| 206 | + | }); | |
| 207 | + | ||
| 208 | + | // The furthest it moves in any one step, against the furthest it should: | |
| 209 | + | // a step of travel, and nothing else. | |
| 210 | + | const perStep = 1600 * FIXED_DT; | |
| 211 | + | let jump = 0; | |
| 212 | + | let lost = 0; | |
| 213 | + | let prev = { x: b.x, y: b.y, v: Math.hypot(b.vx, b.vy) }; | |
| 214 | + | for (let i = 0; i < 60; i++) { | |
| 215 | + | step(world); | |
| 216 | + | const v = Math.hypot(b.vx, b.vy); | |
| 217 | + | jump = Math.max(jump, Math.hypot(b.x - prev.x, b.y - prev.y)); | |
| 218 | + | // Only while it is still crossing in — the far side is meant to stop it. | |
| 219 | + | if (b.y > POOL.y + TILE.h) lost = Math.max(lost, prev.v - v); | |
| 220 | + | prev = { x: b.x, y: b.y, v }; | |
| 221 | + | } | |
| 222 | + | expect(jump).toBeLessThan(perStep * 1.2); | |
| 223 | + | expect(lost).toBeLessThan(60); | |
| 224 | + | }); | |
| 225 | + | ||
| 183 | 226 | it('says which edge a tile was thrown into, and whose tile it was', () => { | |
| 184 | 227 | // The middle stops where a player's tiles start, so an edge taken at speed | |
| 185 | 228 | // is a discard thrown into somebody's hand — which is a thing they answer. | |
| ⋯ 443 unchanged lines | |||
modifiedsrc/table/physics.ts+71 −25
| ⋯ 94 unchanged lines | |||
| 95 | 95 | */ | |
| 96 | 96 | const ENTRY_GRACE = 240; | |
| 97 | 97 | ||
| 98 | + | /** | |
| 99 | + | * How hard a tile that merely *arrived* counts as having arrived: enough for | |
| 100 | + | * the seat it came in past to notice, deliberately under the threshold the | |
| 101 | + | * renderer plays a clack at. Nothing was hit, so nothing should be heard. | |
| 102 | + | */ | |
| 103 | + | const ARRIVED = 0.1; | |
| 104 | + | ||
| 98 | 105 | /** How fast a tile drawn at hand size shrinks to the size it is in the pool. */ | |
| 99 | 106 | const SCALE_EASE = 6; | |
| 100 | 107 | ||
| ⋯ 67 unchanged lines | |||
| 168 | 175 | * over the wall from a seat's edge; once it has, the pool keeps it. | |
| 169 | 176 | */ | |
| 170 | 177 | entered: boolean; | |
| 178 | + | /** | |
| 179 | + | * Which side of the table it is still on its way in over. Every tile is | |
| 180 | + | * thrown from outside, so every tile has one until it lands; it is what the | |
| 181 | + | * table reports when the tile finally gets in, since by then there is no | |
| 182 | + | * longer an edge to read off its position. | |
| 183 | + | */ | |
| 184 | + | over?: Edge; | |
| 171 | 185 | resting: boolean; | |
| 172 | 186 | /** Consecutive slow steps, counting towards sleep. */ | |
| 173 | 187 | still: number; | |
| ⋯ 178 unchanged lines | |||
| 352 | 366 | ||
| 353 | 367 | // ---- stepping ------------------------------------------------------------- | |
| 354 | 368 | ||
| 355 | - | const inside = (r: Rect, x: number, y: number) => | |
| 356 | - | x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h; | |
| 369 | + | /** | |
| 370 | + | * Where a tile's *centre* may be for the whole of it to be inside `r`. | |
| 371 | + | * | |
| 372 | + | * One place rather than three, because the three have to agree: the edge holds | |
| 373 | + | * a tile at these limits, and the moment it starts holding it is the moment the | |
| 374 | + | * tile reaches them. Deciding that on the centre instead would mean the tile is | |
| 375 | + | * declared in while it is still half out, and the first thing the edge did | |
| 376 | + | * would be to shove it the rest of the way — half a tile, in one frame, at a | |
| 377 | + | * line that is nowhere near the one you can see. | |
| 378 | + | * | |
| 379 | + | * A pool narrower than a tile would otherwise fight itself, so it centres. | |
| 380 | + | */ | |
| 381 | + | function limitsOf(r: Rect, b: Box) { | |
| 382 | + | const half = aabbHalf(b); | |
| 383 | + | return { | |
| 384 | + | minX: r.w <= half.x * 2 ? r.x + r.w / 2 : r.x + half.x, | |
| 385 | + | maxX: r.w <= half.x * 2 ? r.x + r.w / 2 : r.x + r.w - half.x, | |
| 386 | + | minY: r.h <= half.y * 2 ? r.y + r.h / 2 : r.y + half.y, | |
| 387 | + | maxY: r.h <= half.y * 2 ? r.y + r.h / 2 : r.y + r.h - half.y, | |
| 388 | + | }; | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | /** Whether the whole tile has made it in — the edge's own test for `entered`. */ | |
| 392 | + | function wholly(r: Rect, b: Body): boolean { | |
| 393 | + | const l = limitsOf(r, b); | |
| 394 | + | return b.x >= l.minX && b.x <= l.maxX && b.y >= l.minY && b.y <= l.maxY; | |
| 395 | + | } | |
| 357 | 396 | ||
| 358 | 397 | /** | |
| 359 | 398 | * Whose side of the table a tile has ended up off, if it is off any of them. | |
| ⋯ 80 unchanged lines | |||
| 440 | 479 | } | |
| 441 | 480 | } | |
| 442 | 481 | ||
| 443 | - | if (inside(world.bounds, b.x, b.y)) b.entered = true; | |
| 444 | - | else if (b.age > ENTRY_GRACE) { | |
| 445 | - | // Out of time going the long way round. Put it in, gently — but this is a | |
| 446 | - | // tile that ended up off the table on somebody's side, which is worth | |
| 447 | - | // saying so even though nothing was actually struck. | |
| 448 | - | const edge = outsideEdge(world.bounds, b); | |
| 449 | - | if (edge) impacts.push({ x: b.x, y: b.y, strength: 0.3, edge, seat: b.seat }); | |
| 450 | - | b.entered = true; | |
| 451 | - | b.z = 0; | |
| 452 | - | b.vz = 0; | |
| 453 | - | clampInto(world.bounds, b); | |
| 482 | + | if (!b.entered) { | |
| 483 | + | // Kept from step to step: on the step it finally gets in it is inside | |
| 484 | + | // already, and there would be nothing left to read. | |
| 485 | + | b.over = outsideEdge(world.bounds, b) ?? b.over; | |
| 486 | + | // In means all of it in. Anything less and keepInBounds below would | |
| 487 | + | // finish the job in a single frame, which is a collision with nothing. | |
| 488 | + | if (wholly(world.bounds, b)) { | |
| 489 | + | b.entered = true; | |
| 490 | + | // It came in over somebody's side of the table. Said quietly — nothing | |
| 491 | + | // was struck, so there is nothing to hear — but said, because a tile | |
| 492 | + | // arriving past a seat is that seat's to complain about. Its own | |
| 493 | + | // thrower's edge is every tile's way in, and the caller drops that one. | |
| 494 | + | if (b.over) { | |
| 495 | + | impacts.push({ x: b.x, y: b.y, strength: ARRIVED, edge: b.over, seat: b.seat }); | |
| 496 | + | } | |
| 497 | + | b.over = undefined; | |
| 498 | + | } else if (b.age > ENTRY_GRACE) { | |
| 499 | + | // Out of time going the long way round. Put it in, gently — but this is | |
| 500 | + | // still a tile that ended up off the table on somebody's side. | |
| 501 | + | if (b.over) { | |
| 502 | + | impacts.push({ x: b.x, y: b.y, strength: ARRIVED, edge: b.over, seat: b.seat }); | |
| 503 | + | } | |
| 504 | + | b.entered = true; | |
| 505 | + | b.over = undefined; | |
| 506 | + | b.z = 0; | |
| 507 | + | b.vz = 0; | |
| 508 | + | clampInto(world.bounds, b); | |
| 509 | + | } | |
| 454 | 510 | } | |
| 455 | 511 | ||
| 456 | 512 | // --- on the felt --- | |
| ⋯ 106 unchanged lines | |||
| 563 | 619 | /** The pool's edge. Once a tile is in, it stays in — this is the invariant the | |
| 564 | 620 | * tests pin, since a discard that slid off the table would be lost. */ | |
| 565 | 621 | function keepInBounds(world: World, b: Body, impacts: Impact[]) { | |
| 566 | - | const half = aabbHalf(b); | |
| 567 | - | const { x, y, w, h } = world.bounds; | |
| 568 | - | // A pool narrower than a tile would otherwise fight itself; centre instead. | |
| 569 | - | const minX = w <= half.x * 2 ? x + w / 2 : x + half.x; | |
| 570 | - | const maxX = w <= half.x * 2 ? x + w / 2 : x + w - half.x; | |
| 571 | - | const minY = h <= half.y * 2 ? y + h / 2 : y + half.y; | |
| 572 | - | const maxY = h <= half.y * 2 ? y + h / 2 : y + h - half.y; | |
| 622 | + | const { minX, maxX, minY, maxY } = limitsOf(world.bounds, b); | |
| 573 | 623 | ||
| 574 | 624 | let hit = 0; | |
| 575 | 625 | let edge: Edge | undefined; | |
| ⋯ 177 unchanged lines | |||
| 753 | 803 | } | |
| 754 | 804 | ||
| 755 | 805 | function clampInto(r: Rect, b: Body) { | |
| 756 | - | const half = aabbHalf(b); | |
| 757 | - | const minX = r.w <= half.x * 2 ? r.x + r.w / 2 : r.x + half.x; | |
| 758 | - | const maxX = r.w <= half.x * 2 ? r.x + r.w / 2 : r.x + r.w - half.x; | |
| 759 | - | const minY = r.h <= half.y * 2 ? r.y + r.h / 2 : r.y + half.y; | |
| 760 | - | const maxY = r.h <= half.y * 2 ? r.y + r.h / 2 : r.y + r.h - half.y; | |
| 806 | + | const { minX, maxX, minY, maxY } = limitsOf(r, b); | |
| 761 | 807 | b.x = Math.min(maxX, Math.max(minX, b.x)); | |
| 762 | 808 | b.y = Math.min(maxY, Math.max(minY, b.y)); | |
| 763 | 809 | } | |
| ⋯ 89 unchanged lines | |||
modifiedsrc/ui/Bi.tsx+51 −2
| 1 | 1 | import type { ClaimType } from '../game/engine'; | |
| 2 | + | import { useLang } from './lang'; | |
| 2 | 3 | ||
| 3 | 4 | /** | |
| 4 | 5 | * Bilingual label: the Chinese term players call at the table, with the English | |
| 5 | 6 | * underneath for anyone still learning it. Tile faces stay pure Chinese — those | |
| 6 | 7 | * are the tiles themselves, not UI chrome. | |
| 8 | + | * | |
| 9 | + | * A table that reads only one of the two says so in the lobby, and this is | |
| 10 | + | * where that choice lands: one span or the other, never neither. | |
| 7 | 11 | */ | |
| 8 | 12 | export function Bi({ zh, en, sub }: { zh: string; en: string; sub?: string }) { | |
| 13 | + | const lang = useLang(); | |
| 9 | 14 | return ( | |
| 10 | 15 | <span className="bi"> | |
| 11 | - | <span className="zh">{zh}</span> | |
| 12 | - | <span className="en">{en}</span> | |
| 16 | + | {lang !== 'en' && ( | |
| 17 | + | <span className="zh" lang="zh-TW"> | |
| 18 | + | {zh} | |
| 19 | + | </span> | |
| 20 | + | )} | |
| 21 | + | {lang !== 'zh' && ( | |
| 22 | + | <span className="en" lang="en"> | |
| 23 | + | {en} | |
| 24 | + | </span> | |
| 25 | + | )} | |
| 26 | + | {/* Tile names, near enough always — 五萬, 東風 — so it stays put. */} | |
| 13 | 27 | {sub && <span className="bi-sub">{sub}</span>} | |
| 14 | 28 | </span> | |
| 15 | 29 | ); | |
| 16 | 30 | } | |
| 17 | 31 | ||
| 32 | + | /** | |
| 33 | + | * Two lines of prose rather than a label: the hint paragraphs, which put the | |
| 34 | + | * break between the languages instead of a middot. One of them in either of the | |
| 35 | + | * single-language modes, and then there is nothing to break. | |
| 36 | + | */ | |
| 37 | + | export function BiText({ zh, en }: { zh: string; en: string }) { | |
| 38 | + | const lang = useLang(); | |
| 39 | + | if (lang === 'zh') return <>{zh}</>; | |
| 40 | + | if (lang === 'en') return <>{en}</>; | |
| 41 | + | return ( | |
| 42 | + | <> | |
| 43 | + | <span lang="zh-TW">{zh}</span> | |
| 44 | + | <br /> | |
| 45 | + | <span lang="en">{en}</span> | |
| 46 | + | </> | |
| 47 | + | ); | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | /** | |
| 51 | + | * The other bilingual shape in the app: the Chinese, with the English beside it | |
| 52 | + | * in an <em> that the CSS shrinks and spaces — headings, field labels, the name | |
| 53 | + | * on a switch. The <em> is the pairing, so in one language there is none. | |
| 54 | + | */ | |
| 55 | + | export function BiEm({ zh, en }: { zh: string; en: string }) { | |
| 56 | + | const lang = useLang(); | |
| 57 | + | if (lang === 'zh') return <>{zh}</>; | |
| 58 | + | if (lang === 'en') return <>{en}</>; | |
| 59 | + | return ( | |
| 60 | + | <> | |
| 61 | + | <span lang="zh-TW">{zh}</span> | |
| 62 | + | <em lang="en">{en}</em> | |
| 63 | + | </> | |
| 64 | + | ); | |
| 65 | + | } | |
| 66 | + | ||
| 18 | 67 | export const CLAIM_LABEL: Record<ClaimType, [string, string]> = { | |
| 19 | 68 | hu: ['胡', 'Win'], | |
| 20 | 69 | kong: ['槓', 'Kong'], | |
| ⋯ 3 unchanged lines | |||
modifiedsrc/ui/Center.tsx+34 −16
| ⋯ 1 unchanged line | |||
| 2 | 2 | import type { Prefs } from '../game/prefs'; | |
| 3 | 3 | import type { SeatId } from '../game/types'; | |
| 4 | 4 | import { labelOf } from '../game/tiles'; | |
| 5 | - | import { Bi } from './Bi'; | |
| 5 | + | import { Bi, BiEm } from './Bi'; | |
| 6 | + | import { useLang, useT } from './lang'; | |
| 6 | 7 | import { FullscreenToggle } from './FullscreenToggle'; | |
| 7 | 8 | import { SoundToggle } from './SoundToggle'; | |
| 8 | 9 | import { TileView } from './TileView'; | |
| ⋯ 15 unchanged lines | |||
| 24 | 25 | } | |
| 25 | 26 | ||
| 26 | 27 | export function Center({ game, prefs, compact, viewSeat = 0, preferSeat = null, onPrefs, onSettings }: Props) { | |
| 28 | + | const t = useT(); | |
| 29 | + | const lang = useLang(); | |
| 27 | 30 | const s = game.state; | |
| 28 | 31 | const r = s.result; | |
| 29 | 32 | // Point the live readout at whoever can move the game along. | |
| ⋯ 18 unchanged lines | |||
| 48 | 51 | (compact ? ( | |
| 49 | 52 | <div className="wall-pill"> | |
| 50 | 53 | <b>{game.wallLeft}</b> | |
| 51 | - | <em>剩牌 left</em> | |
| 54 | + | <em>{t('剩牌', 'left')}</em> | |
| 52 | 55 | </div> | |
| 53 | 56 | ) : ( | |
| 54 | 57 | <WallRing state={s} /> | |
| ⋯ 12 unchanged lines | |||
| 67 | 70 | phone has no pile, and still needs telling. */} | |
| 68 | 71 | {compact && s.lastDiscard && s.phase !== 'handEnd' && ( | |
| 69 | 72 | <div className="last-discard"> | |
| 70 | - | <div className="ld-label">{s.players[s.lastDiscard.from].name} 打 discarded</div> | |
| 73 | + | <div className="ld-label"> | |
| 74 | + | {s.players[s.lastDiscard.from].name} {t('打', 'discarded')} | |
| 75 | + | </div> | |
| 71 | 76 | <TileView tile={s.lastDiscard.tile} size="hand" highlight /> | |
| 72 | 77 | </div> | |
| 73 | 78 | )} | |
| 74 | 79 | ||
| 75 | 80 | {s.phase === 'claims' && ( | |
| 76 | 81 | <div className="prompt"> | |
| 77 | - | {pending.length} 家可吃碰 · {pending.length} may claim | |
| 82 | + | {pending.length} {t('家可吃碰', 'may claim')} | |
| 78 | 83 | </div> | |
| 79 | 84 | )} | |
| 80 | - | {s.phase === 'robkong' && <div className="prompt warn">搶槓機會 · Rob the kong?</div>} | |
| 85 | + | {s.phase === 'robkong' && ( | |
| 86 | + | <div className="prompt warn">{t('搶槓機會', 'Rob the kong?', ' · ')}</div> | |
| 87 | + | )} | |
| 81 | 88 | </div> | |
| 82 | 89 | ||
| 83 | 90 | {r && s.phase !== 'gameEnd' && ( | |
| 84 | 91 | <div className="result"> | |
| 85 | 92 | {r.drawGame ? ( | |
| 86 | 93 | <> | |
| 87 | - | <h2>流局 · Draw</h2> | |
| 94 | + | <h2>{t('流局', 'Draw', ' · ')}</h2> | |
| 88 | 95 | {/* Nobody won, so the thing everyone wants to see is who was | |
| 89 | 96 | there and on what — the same reveal as turning the hands over | |
| 90 | 97 | at a real table. */} | |
| ⋯ 5 unchanged lines | |||
| 96 | 103 | <span className="ready-name">{p.name}</span> | |
| 97 | 104 | {waiting.length > 0 ? ( | |
| 98 | 105 | <> | |
| 99 | - | <span className="tag ting">聽 Ready</span> | |
| 106 | + | <span className="tag ting">{t('聽', 'Ready')}</span> | |
| 100 | 107 | <span className="ready-tiles"> | |
| 101 | 108 | {waiting.map((t) => ( | |
| 102 | 109 | <TileView key={t} tile={t} size="small" /> | |
| ⋯ 1 unchanged line | |||
| 104 | 111 | </span> | |
| 105 | 112 | </> | |
| 106 | 113 | ) : ( | |
| 107 | - | <span className="ready-no">未聽 · not ready</span> | |
| 114 | + | <span className="ready-no">{t('未聽', 'not ready', ' · ')}</span> | |
| 108 | 115 | )} | |
| 109 | 116 | </div> | |
| 110 | 117 | ); | |
| ⋯ 6 unchanged lines | |||
| 117 | 124 | {r.winners.map((w) => ( | |
| 118 | 125 | <div className="win" key={w.seat}> | |
| 119 | 126 | <h2> | |
| 120 | - | {s.players[w.seat].name} {w.selfDraw ? '自摸' : '胡牌'} · {w.tai} 台 | |
| 127 | + | {s.players[w.seat].name} {w.selfDraw ? t('自摸', 'self-draw') : t('胡牌', 'wins')}{' '} | |
| 128 | + | · {w.tai} {t('台', 'tai')} | |
| 121 | 129 | <span className="wt"> {labelOf(w.winTile)}</span> | |
| 122 | 130 | </h2> | |
| 123 | 131 | {w.liable !== null && ( | |
| 124 | 132 | <div className="liable"> | |
| 125 | - | 包牌 · {s.players[w.liable].name} pays the whole hand | |
| 133 | + | {t('包牌', 'Liability')} · {s.players[w.liable].name}{' '} | |
| 134 | + | {t('一人付全部', 'pays the whole hand')} | |
| 126 | 135 | </div> | |
| 127 | 136 | )} | |
| 128 | 137 | <div className="tai-list"> | |
| 129 | 138 | {w.items.map((it, i) => ( | |
| 130 | 139 | <div key={i} className="tai-item"> | |
| 131 | 140 | <span className="tai-name"> | |
| 132 | - | {it.name} | |
| 133 | - | <em>{it.en}</em> | |
| 141 | + | <BiEm zh={it.name} en={it.en} /> | |
| 134 | 142 | </span> | |
| 135 | 143 | <span className="tai-val">{it.tai}</span> | |
| 136 | 144 | </div> | |
| ⋯ 2 unchanged lines | |||
| 139 | 147 | </div> | |
| 140 | 148 | ))} | |
| 141 | 149 | {r.winners.length > 1 && ( | |
| 142 | - | <div className="multi">一炮多響 · one discard, {r.winners.length} winners</div> | |
| 150 | + | <div className="multi"> | |
| 151 | + | {t('一炮多響', `one discard, ${r.winners.length} winners`, ' · ')} | |
| 152 | + | </div> | |
| 143 | 153 | )} | |
| 144 | 154 | <div className="deltas"> | |
| 145 | 155 | {r.deltas.map((d, i) => ( | |
| ⋯ 12 unchanged lines | |||
| 158 | 168 | ||
| 159 | 169 | {s.phase === 'gameEnd' && ( | |
| 160 | 170 | <div className="result"> | |
| 161 | - | <h2>一將終了 · Game over</h2> | |
| 171 | + | <h2>{t('一將終了', 'Game over', ' · ')}</h2> | |
| 162 | 172 | <div className="deltas"> | |
| 163 | 173 | {[...s.players] | |
| 164 | 174 | .map((p, i) => ({ p, i })) | |
| ⋯ 23 unchanged lines | |||
| 188 | 198 | ↺ | |
| 189 | 199 | </span> | |
| 190 | 200 | <span className="undo-what"> | |
| 191 | - | <span className="zh">復原 {undoable.zh}</span> | |
| 192 | - | <span className="en">Undo · {undoable.en}</span> | |
| 201 | + | {lang !== 'en' && ( | |
| 202 | + | <span className="zh" lang="zh-TW"> | |
| 203 | + | 復原 {undoable.zh} | |
| 204 | + | </span> | |
| 205 | + | )} | |
| 206 | + | {lang !== 'zh' && ( | |
| 207 | + | <span className="en" lang="en"> | |
| 208 | + | Undo · {undoable.en} | |
| 209 | + | </span> | |
| 210 | + | )} | |
| 193 | 211 | </span> | |
| 194 | 212 | </button> | |
| 195 | 213 | )} | |
| ⋯ 7 unchanged lines | |||
modifiedsrc/ui/Controller.tsx+35 −18
| ⋯ 4 unchanged lines | |||
| 5 | 5 | import type { SeatId } from '../game/types'; | |
| 6 | 6 | import type { NetSession } from '../net/session'; | |
| 7 | 7 | import { seatFromUrl } from '../net/protocol'; | |
| 8 | - | import { Bi, CLAIM_LABEL } from './Bi'; | |
| 8 | + | import { Bi, BiEm, CLAIM_LABEL } from './Bi'; | |
| 9 | + | import { useLang, useT } from './lang'; | |
| 9 | 10 | import { FullscreenToggle } from './FullscreenToggle'; | |
| 10 | 11 | import { Hand, type Throw } from './Hand'; | |
| 11 | 12 | import { TileView } from './TileView'; | |
| ⋯ 38 unchanged lines | |||
| 50 | 51 | } | |
| 51 | 52 | ||
| 52 | 53 | function SeatPick({ session, joining, prefs, onPrefs }: CtlProps & { joining?: boolean }) { | |
| 54 | + | const t = useT(); | |
| 55 | + | const lang = useLang(); | |
| 53 | 56 | const seats = session.seats; | |
| 54 | 57 | const s = session.table.state; | |
| 55 | 58 | return ( | |
| 56 | 59 | <div className="ctl ctl-pick"> | |
| 57 | - | <h1>台灣麻將</h1> | |
| 60 | + | <h1>{lang === 'en' ? 'Taiwanese Mahjong' : '台灣麻將'}</h1> | |
| 58 | 61 | <p className="sub"> | |
| 59 | - | {joining ? '入座中… taking your seat — 或先選別的 or pick another' : '選一個座位 · pick your seat'} | |
| 62 | + | {joining | |
| 63 | + | ? `${t('入座中…', 'taking your seat')} — ${t('或先選別的', 'or pick another')}` | |
| 64 | + | : t('選一個座位', 'pick your seat', ' · ')} | |
| 60 | 65 | </p> | |
| 61 | 66 | <div className="pick-grid"> | |
| 62 | 67 | {SEATS.map((i) => { | |
| ⋯ 1 unchanged line | |||
| 64 | 69 | const wind = (i - s.dealer + 4) % 4; | |
| 65 | 70 | return ( | |
| 66 | 71 | <button key={i} className="btn big-btn" onClick={() => session.claimSeat(i)}> | |
| 67 | - | {WIND_NAMES[wind]} {WIND_EN[wind]} | |
| 72 | + | {t(WIND_NAMES[wind], WIND_EN[wind])} | |
| 68 | 73 | <em> | |
| 69 | 74 | {held > 0 | |
| 70 | - | ? `${seats[i].name} — 一起打 join this hand` | |
| 71 | - | : seats[i].name || '空位 open'} | |
| 75 | + | ? `${seats[i].name} — ${t('一起打', 'join this hand')}` | |
| 76 | + | : seats[i].name || t('空位', 'open')} | |
| 72 | 77 | </em> | |
| 73 | 78 | </button> | |
| 74 | 79 | ); | |
| ⋯ 15 unchanged lines | |||
| 90 | 95 | } | |
| 91 | 96 | ||
| 92 | 97 | function SeatStrip({ session, seat, prefs, onPrefs }: CtlProps & { seat: SeatId }) { | |
| 98 | + | const t = useT(); | |
| 93 | 99 | const table = session.table; | |
| 94 | 100 | const s = table.state; | |
| 95 | 101 | const p = s.players[seat]; | |
| ⋯ 50 unchanged lines | |||
| 146 | 152 | ? { zh: '輪到你', en: 'your turn', hot: true } | |
| 147 | 153 | : opts | |
| 148 | 154 | ? { zh: '要叫牌嗎?', en: 'claim it?', hot: true } | |
| 149 | - | : { zh: `等 ${s.players[s.turn].name}`, en: 'waiting', hot: false }; | |
| 155 | + | : { | |
| 156 | + | zh: `等 ${s.players[s.turn].name}`, | |
| 157 | + | en: `waiting for ${s.players[s.turn].name}`, | |
| 158 | + | hot: false, | |
| 159 | + | }; | |
| 150 | 160 | ||
| 151 | 161 | return ( | |
| 152 | 162 | <div className="ctl"> | |
| 153 | 163 | <div className="ctl-top"> | |
| 154 | 164 | <span className="wind"> | |
| 155 | - | {WIND_NAMES[seatWind]} | |
| 156 | - | <em>{WIND_EN[seatWind]}</em> | |
| 165 | + | <BiEm zh={WIND_NAMES[seatWind]} en={WIND_EN[seatWind]} /> | |
| 157 | 166 | </span> | |
| 158 | 167 | <span className="pname">{p.name}</span> | |
| 159 | - | {seat === s.dealer && <span className="tag">莊</span>} | |
| 160 | - | {tenpai.length > 0 && <span className="tag ting">聽 {tenpai.map(labelOf).join('·')}</span>} | |
| 168 | + | {seat === s.dealer && <span className="tag">{t('莊', 'Dealer')}</span>} | |
| 169 | + | {tenpai.length > 0 && ( | |
| 170 | + | <span className="tag ting"> | |
| 171 | + | {t('聽', 'Ready')} {tenpai.map(labelOf).join('·')} | |
| 172 | + | </span> | |
| 173 | + | )} | |
| 161 | 174 | <span className={`ctl-status ${status.hot ? 'hot' : ''}`}> | |
| 162 | - | {status.zh} <em>{status.en}</em> | |
| 175 | + | <BiEm zh={status.zh} en={status.en} /> | |
| 163 | 176 | </span> | |
| 164 | 177 | {s.lastDiscard && s.phase !== 'handEnd' && ( | |
| 165 | 178 | <span className="ctl-last"> | |
| 166 | - | {s.players[s.lastDiscard.from].name} 打<TileView tile={s.lastDiscard.tile} size="small" /> | |
| 179 | + | {s.players[s.lastDiscard.from].name} {t('打', 'threw')} | |
| 180 | + | <TileView tile={s.lastDiscard.tile} size="small" /> | |
| 167 | 181 | </span> | |
| 168 | 182 | )} | |
| 169 | 183 | <span className="chips">{p.score}</span> | |
| 170 | 184 | <button className="btn ghost sort" onClick={() => table.sortHand(seat)}> | |
| 171 | - | 理牌 | |
| 185 | + | {t('理牌', 'Sort')} | |
| 172 | 186 | </button> | |
| 173 | 187 | <FullscreenToggle prefs={prefs} onPrefs={onPrefs} compact /> | |
| 174 | 188 | </div> | |
| ⋯ 41 unchanged lines | |||
| 216 | 230 | <Bi zh="打出" en="Discard" sub={labelOf(selTile)} /> | |
| 217 | 231 | </button> | |
| 218 | 232 | )} | |
| 219 | - | {myTurn && <span className="ctl-hint">往上甩出去 · flick a tile up to throw it</span>} | |
| 233 | + | {myTurn && ( | |
| 234 | + | <span className="ctl-hint">{t('往上甩出去', 'flick a tile up to throw it', ' · ')}</span> | |
| 235 | + | )} | |
| 220 | 236 | </div> | |
| 221 | 237 | ||
| 222 | 238 | <div className="ctl-hand"> | |
| ⋯ 24 unchanged lines | |||
| 247 | 263 | {r && s.phase === 'handEnd' && ( | |
| 248 | 264 | <div className="ctl-result"> | |
| 249 | 265 | {r.drawGame ? ( | |
| 250 | - | <h2>流局 · Draw</h2> | |
| 266 | + | <h2>{t('流局', 'Draw', ' · ')}</h2> | |
| 251 | 267 | ) : ( | |
| 252 | 268 | r.winners.map((w) => ( | |
| 253 | 269 | <h2 key={w.seat}> | |
| 254 | - | {s.players[w.seat].name} {w.selfDraw ? '自摸' : '胡牌'} · {w.tai} 台 | |
| 270 | + | {s.players[w.seat].name} {w.selfDraw ? t('自摸', 'self-draw') : t('胡牌', 'wins')}{' '} | |
| 271 | + | · {w.tai} {t('台', 'tai')} | |
| 255 | 272 | </h2> | |
| 256 | 273 | )) | |
| 257 | 274 | )} | |
| ⋯ 12 unchanged lines | |||
| 270 | 287 | ||
| 271 | 288 | {s.phase === 'gameEnd' && ( | |
| 272 | 289 | <div className="ctl-result"> | |
| 273 | - | <h2>一將終了 · Game over</h2> | |
| 290 | + | <h2>{t('一將終了', 'Game over', ' · ')}</h2> | |
| 274 | 291 | <div className="deltas"> | |
| 275 | 292 | {s.players.map((q, i) => ( | |
| 276 | 293 | <span key={i}> | |
| ⋯ 12 unchanged lines | |||
modifiedsrc/ui/FullscreenToggle.tsx+5 −1
| 1 | 1 | import type { Prefs } from '../game/prefs'; | |
| 2 | 2 | import { enterFullscreen, exitFullscreen, fullscreenSupported, useFullscreen } from './fullscreen'; | |
| 3 | + | import { useT } from './lang'; | |
| 3 | 4 | ||
| 4 | 5 | interface Props { | |
| 5 | 6 | prefs: Prefs; | |
| ⋯ 32 unchanged lines | |||
| 38 | 39 | * button every single time. | |
| 39 | 40 | */ | |
| 40 | 41 | export function FullscreenToggle({ prefs, onPrefs, compact }: Props) { | |
| 42 | + | const t = useT(); | |
| 41 | 43 | const active = useFullscreen(); | |
| 42 | 44 | if (!fullscreenSupported()) return null; | |
| 43 | 45 | ||
| ⋯ 9 unchanged lines | |||
| 53 | 55 | }} | |
| 54 | 56 | > | |
| 55 | 57 | <Corners out={!active} /> | |
| 56 | - | {!compact && <span>{active ? '離開全螢幕 Exit full screen' : '全螢幕 Full screen'}</span>} | |
| 58 | + | {!compact && ( | |
| 59 | + | <span>{active ? t('離開全螢幕', 'Exit full screen') : t('全螢幕', 'Full screen')}</span> | |
| 60 | + | )} | |
| 57 | 61 | </button> | |
| 58 | 62 | ); | |
| 59 | 63 | } | |
modifiedsrc/ui/Hand.tsx+5 −3
| 1 | 1 | import { useRef, useState } from 'react'; | |
| 2 | 2 | import type { Tile } from '../game/tiles'; | |
| 3 | + | import { useT } from './lang'; | |
| 3 | 4 | import { TileView } from './TileView'; | |
| 4 | 5 | ||
| 5 | 6 | export interface Throw { | |
| ⋯ 152 unchanged lines | |||
| 158 | 159 | onMove, | |
| 159 | 160 | carry, | |
| 160 | 161 | }: Props) { | |
| 162 | + | const t = useT(); | |
| 161 | 163 | const ref = useRef<HTMLDivElement>(null); | |
| 162 | 164 | const drag = useRef<Drag | null>(null); | |
| 163 | 165 | const [sorting, setSorting] = useState<number | null>(null); | |
| ⋯ 131 unchanged lines | |||
| 295 | 297 | ||
| 296 | 298 | return ( | |
| 297 | 299 | <div className="hand" ref={ref} onPointerMove={move} onPointerUp={up} onPointerCancel={up}> | |
| 298 | - | {tiles.map((t, i) => ( | |
| 300 | + | {tiles.map((tile, i) => ( | |
| 299 | 301 | <div | |
| 300 | 302 | key={i} | |
| 301 | 303 | data-idx={i} | |
| ⋯ 7 unchanged lines | |||
| 309 | 311 | } | |
| 310 | 312 | onPointerDown={(e) => down(e, i)} | |
| 311 | 313 | > | |
| 312 | - | {i === drawnIndex && <span className="drawn-tag">摸 Drew</span>} | |
| 313 | - | <TileView tile={t} raised={selected === i} /> | |
| 314 | + | {i === drawnIndex && <span className="drawn-tag">{t('摸', 'Drew')}</span>} | |
| 315 | + | <TileView tile={tile} raised={selected === i} /> | |
| 314 | 316 | </div> | |
| 315 | 317 | ))} | |
| 316 | 318 | </div> | |
| ⋯ 23 unchanged lines | |||
modifiedsrc/ui/Help.tsx+29 −14
| 1 | 1 | import type { SeatId } from '../game/types'; | |
| 2 | + | import { BiEm } from './Bi'; | |
| 3 | + | import { useLang, useT } from './lang'; | |
| 2 | 4 | import { SEAT_ROT, SEAT_SLOT } from './rotation'; | |
| 3 | 5 | ||
| 4 | 6 | interface Section { | |
| ⋯ 27 unchanged lines | |||
| 32 | 34 | en: 'Your turn', | |
| 33 | 35 | body: [ | |
| 34 | 36 | ['點一張牌抬起,再點一次打出。', 'Tap a tile to lift it, tap again to discard it.'], | |
| 35 | - | ['剛摸的牌會分開放在最右邊,標著「摸」。', 'The tile you just drew sits apart on the right, marked 摸.'], | |
| 36 | - | ['牌可以拖曳重排,或按「理牌」自動排序。', 'Drag tiles to rearrange, or press 理牌 to sort.'], | |
| 37 | + | ['剛摸的牌會分開放在最右邊,標著「摸」。', 'The tile you just drew sits apart on the right, marked 摸 / Drew.'], | |
| 38 | + | ['牌可以拖曳重排,或按「理牌」自動排序。', 'Drag tiles to rearrange, or press 理牌 / Sort to sort.'], | |
| 37 | 39 | ], | |
| 38 | 40 | }, | |
| 39 | 41 | { | |
| ⋯ 15 unchanged lines | |||
| 55 | 57 | en: 'Kongs', | |
| 56 | 58 | body: [ | |
| 57 | 59 | ['四張相同:明槓、暗槓、或碰後加槓。', 'Four of a kind, claimed, concealed, or added to a pung.'], | |
| 58 | - | ['槓完從牌尾補一張,胡了算「槓上開花」。', 'You draw from the tail; winning on it scores 槓上開花.'], | |
| 60 | + | ['槓完從牌尾補一張,胡了算「槓上開花」。', 'You draw from the tail; winning on it scores 槓上開花 — a win off a kong.'], | |
| 59 | 61 | ['別人加槓時你剛好能胡,可以搶槓。', 'You may rob an added kong if that tile completes your hand.'], | |
| 60 | 62 | ], | |
| 61 | 63 | }, | |
| ⋯ 41 unchanged lines | |||
| 103 | 105 | zh: '拿錯了', | |
| 104 | 106 | en: 'Mis-taps', | |
| 105 | 107 | body: [ | |
| 106 | - | ['桌子中央有「復原」,可以退回上一步。', 'The 復原 button in the middle takes back the last action.'], | |
| 108 | + | ['桌子中央有「復原」,可以退回上一步。', 'The 復原 / Undo button in the middle takes back the last action.'], | |
| 107 | 109 | ['誰看到誰按,不限於按錯的那個人。', 'Anyone can press it — not just whoever made the mistake.'], | |
| 108 | 110 | ], | |
| 109 | 111 | }, | |
| ⋯ 21 unchanged lines | |||
| 131 | 133 | compact?: boolean; | |
| 132 | 134 | onClose: () => void; | |
| 133 | 135 | }) { | |
| 136 | + | const t = useT(); | |
| 137 | + | const lang = useLang(); | |
| 138 | + | /* Stacked rather than side by side, and the <em> is the second of the two — | |
| 139 | + | so on its own a line is a <span>, never the dimmed one. */ | |
| 140 | + | const line = (zh: string, en: string) => | |
| 141 | + | lang === 'bi' ? ( | |
| 142 | + | <> | |
| 143 | + | <span lang="zh-TW">{zh}</span> | |
| 144 | + | <em lang="en">{en}</em> | |
| 145 | + | </> | |
| 146 | + | ) : ( | |
| 147 | + | <span>{lang === 'zh' ? zh : en}</span> | |
| 148 | + | ); | |
| 149 | + | ||
| 134 | 150 | return ( | |
| 135 | 151 | <div className={`help-slot ${SEAT_SLOT[seat]}`} onClick={onClose}> | |
| 136 | 152 | <div | |
| ⋯ 6 unchanged lines | |||
| 143 | 159 | onClick={(e) => e.stopPropagation()} | |
| 144 | 160 | > | |
| 145 | 161 | <div className="help-head"> | |
| 146 | - | <h3>台灣麻將規則 · How to play</h3> | |
| 162 | + | <h3>{t('台灣麻將規則', 'How to play', ' · ')}</h3> | |
| 147 | 163 | <button className="btn ghost" onClick={onClose}> | |
| 148 | - | 關閉 Close | |
| 164 | + | {t('關閉', 'Close')} | |
| 149 | 165 | </button> | |
| 150 | 166 | </div> | |
| 151 | 167 | <div className="help-body"> | |
| 152 | 168 | {SECTIONS.map((sec) => ( | |
| 153 | 169 | <section key={sec.zh}> | |
| 154 | 170 | <h4> | |
| 155 | - | {sec.zh} <em>{sec.en}</em> | |
| 171 | + | <BiEm zh={sec.zh} en={sec.en} /> | |
| 156 | 172 | </h4> | |
| 157 | 173 | {sec.body.map(([zh, en], i) => ( | |
| 158 | - | <p key={i}> | |
| 159 | - | <span>{zh}</span> | |
| 160 | - | <em>{en}</em> | |
| 161 | - | </p> | |
| 174 | + | <p key={i}>{line(zh, en)}</p> | |
| 162 | 175 | ))} | |
| 163 | 176 | </section> | |
| 164 | 177 | ))} | |
| 165 | 178 | <section className="credit"> | |
| 166 | 179 | <h4> | |
| 167 | - | 牌面 <em>Tile art</em> | |
| 180 | + | <BiEm zh="牌面" en="Tile art" /> | |
| 168 | 181 | </h4> | |
| 169 | 182 | <p> | |
| 170 | - | <span>gnome-mahjongg「postmodern」牌組</span> | |
| 171 | - | <em>From gnome-mahjongg’s postmodern theme, GPL-2.0-or-later.</em> | |
| 183 | + | {line( | |
| 184 | + | 'gnome-mahjongg「postmodern」牌組', | |
| 185 | + | 'From gnome-mahjongg’s postmodern theme, GPL-2.0-or-later.', | |
| 186 | + | )} | |
| 172 | 187 | </p> | |
| 173 | 188 | </section> | |
| 174 | 189 | </div> | |
| ⋯ 4 unchanged lines | |||
addedsrc/ui/LanguageToggle.tsx+45 −0
| 1 | + | import { LANGS, type Lang, type Prefs } from '../game/prefs'; | |
| 2 | + | import { useT } from './lang'; | |
| 3 | + | ||
| 4 | + | interface Props { | |
| 5 | + | prefs: Prefs; | |
| 6 | + | onPrefs: (p: Prefs) => void; | |
| 7 | + | /** Icon only, for the cramped row of controls in the middle of the table. */ | |
| 8 | + | compact?: boolean; | |
| 9 | + | } | |
| 10 | + | ||
| 11 | + | /** Glyph, and what the button says it is currently showing. Each language is | |
| 12 | + | * named in itself, the way a language picker should be; only 'both' is a word | |
| 13 | + | * in whatever is being read. */ | |
| 14 | + | const MODES: Record<Lang, { icon: string; label: [string, string] }> = { | |
| 15 | + | bi: { icon: '雙', label: ['雙語', 'Bilingual'] }, | |
| 16 | + | zh: { icon: '中', label: ['繁體中文', '繁體中文'] }, | |
| 17 | + | en: { icon: 'A', label: ['English', 'English'] }, | |
| 18 | + | }; | |
| 19 | + | ||
| 20 | + | /** | |
| 21 | + | * Bilingual · 繁體中文 · English. Three states rather than two, so it steps | |
| 22 | + | * round on each tap instead of flipping — and names the mode it is in now, | |
| 23 | + | * not the one the tap would take you to. | |
| 24 | + | * | |
| 25 | + | * It sits on the lobby beside 設定 rather than only inside it: the player who | |
| 26 | + | * needs it most is the one who cannot read the button that opens Settings. | |
| 27 | + | */ | |
| 28 | + | export function LanguageToggle({ prefs, onPrefs, compact }: Props) { | |
| 29 | + | const t = useT(); | |
| 30 | + | const mode = MODES[prefs.lang]; | |
| 31 | + | const label = t(...mode.label); | |
| 32 | + | const next = LANGS[(LANGS.indexOf(prefs.lang) + 1) % LANGS.length]; | |
| 33 | + | return ( | |
| 34 | + | <button | |
| 35 | + | className={`btn ghost pill lang-toggle ${compact ? 'compact' : ''}`} | |
| 36 | + | aria-label={`${t('語言', 'Language')}: ${label}`} | |
| 37 | + | onClick={() => onPrefs({ ...prefs, lang: next })} | |
| 38 | + | > | |
| 39 | + | <span className="lang-icon" aria-hidden> | |
| 40 | + | {mode.icon} | |
| 41 | + | </span> | |
| 42 | + | {!compact && <span>{label}</span>} | |
| 43 | + | </button> | |
| 44 | + | ); | |
| 45 | + | } |
modifiedsrc/ui/OnlineLobby.tsx+29 −12
| ⋯ 2 unchanged lines | |||
| 3 | 3 | import { WIND_EN, WIND_NAMES } from '../game/tiles'; | |
| 4 | 4 | import type { SeatId } from '../game/types'; | |
| 5 | 5 | import type { NetSession } from '../net/session'; | |
| 6 | - | import { Bi } from './Bi'; | |
| 6 | + | import { Bi, BiText } from './Bi'; | |
| 7 | + | import { useLang, useT } from './lang'; | |
| 7 | 8 | ||
| 8 | 9 | const SEATS: SeatId[] = [0, 1, 2, 3]; | |
| 9 | 10 | ||
| ⋯ 3 unchanged lines | |||
| 13 | 14 | * two friends and two bots is a game, not a wait. | |
| 14 | 15 | */ | |
| 15 | 16 | export function OnlineLobby({ session, prefs }: { session: NetSession; prefs: Prefs }) { | |
| 17 | + | const t = useT(); | |
| 18 | + | const lang = useLang(); | |
| 16 | 19 | const seats = session.seats; | |
| 17 | 20 | const me = session.room.me.id; | |
| 18 | 21 | ||
| ⋯ 13 unchanged lines | |||
| 32 | 35 | ||
| 33 | 36 | return ( | |
| 34 | 37 | <div className="lobby"> | |
| 35 | - | <h1>台灣麻將</h1> | |
| 36 | - | <p className="sub">線上對戰 · online game · {session.playerCount} 人在房 in the room</p> | |
| 38 | + | <h1>{lang === 'en' ? 'Taiwanese Mahjong' : '台灣麻將'}</h1> | |
| 39 | + | <p className="sub"> | |
| 40 | + | {t('線上對戰', 'online game', ' · ')} · {session.playerCount}{' '} | |
| 41 | + | {t('人在房', 'in the room')} | |
| 42 | + | </p> | |
| 37 | 43 | <p className="hint"> | |
| 38 | - | 把這頁的連結傳給朋友,他們一開就進同一房。 | |
| 39 | - | <br /> | |
| 40 | - | Share this page's invite link — anyone who opens it lands at this table. | |
| 44 | + | <BiText | |
| 45 | + | zh="把這頁的連結傳給朋友,他們一開就進同一房。" | |
| 46 | + | en="Share this page's invite link — anyone who opens it lands at this table." | |
| 47 | + | /> | |
| 41 | 48 | </p> | |
| 42 | 49 | ||
| 43 | 50 | <div className="pick-grid"> | |
| ⋯ 7 unchanged lines | |||
| 51 | 58 | className={`btn big-btn ${mine ? '' : 'ghost'}`} | |
| 52 | 59 | onClick={() => !mine && session.claimSeat(i)} | |
| 53 | 60 | > | |
| 54 | - | {WIND_NAMES[i]} {WIND_EN[i]} | |
| 61 | + | {t(WIND_NAMES[i], WIND_EN[i])} | |
| 55 | 62 | <em> | |
| 56 | 63 | {held === 0 | |
| 57 | - | ? '空位,開局時由電腦代打 · open — a bot if nobody sits' | |
| 64 | + | ? t('空位,開局時由電腦代打', 'open — a bot if nobody sits', ' · ') | |
| 58 | 65 | : mine | |
| 59 | - | ? `${slot.name || '你'} · you${held > 1 ? ` +${held - 1}` : ''}` | |
| 60 | - | : `${slot.name}${held > 1 ? ` +${held - 1}` : ''} · 可同坐 join them`} | |
| 66 | + | ? `${slot.name || t('你', 'you')}${ | |
| 67 | + | held > 1 ? ` +${held - 1}` : '' | |
| 68 | + | } · ${t('你', 'you')}` | |
| 69 | + | : `${slot.name}${held > 1 ? ` +${held - 1}` : ''} · ${t('可同坐', 'join them')}`} | |
| 61 | 70 | </em> | |
| 62 | 71 | </button> | |
| 63 | 72 | ); | |
| ⋯ 7 unchanged lines | |||
| 71 | 80 | disabled={seated === 0} | |
| 72 | 81 | onClick={() => session.startOnline(prefs.rules)} | |
| 73 | 82 | > | |
| 74 | - | <Bi zh="開局" en="Deal" sub={seated < 4 ? `${4 - seated} 個電腦 bots fill in` : undefined} /> | |
| 83 | + | <Bi | |
| 84 | + | zh="開局" | |
| 85 | + | en="Deal" | |
| 86 | + | sub={ | |
| 87 | + | seated < 4 | |
| 88 | + | ? t(`${4 - seated} 個電腦`, `${4 - seated} bots fill in`) | |
| 89 | + | : undefined | |
| 90 | + | } | |
| 91 | + | /> | |
| 75 | 92 | </button> | |
| 76 | 93 | ) : ( | |
| 77 | - | <p className="hint">等房主開局 · waiting for the host to deal</p> | |
| 94 | + | <p className="hint">{t('等房主開局', 'waiting for the host to deal', ' · ')}</p> | |
| 78 | 95 | )} | |
| 79 | 96 | </div> | |
| 80 | 97 | {session.err && <p className="hint">{session.err}</p>} | |
| ⋯ 3 unchanged lines | |||
modifiedsrc/ui/Seat.tsx+26 −12
| ⋯ 2 unchanged lines | |||
| 3 | 3 | import { labelOf, WIND_EN, WIND_NAMES } from '../game/tiles'; | |
| 4 | 4 | import type { SeatId } from '../game/types'; | |
| 5 | 5 | import type { TablePool } from '../table/pool'; | |
| 6 | - | import { Bi, CLAIM_LABEL } from './Bi'; | |
| 6 | + | import { Bi, BiEm, CLAIM_LABEL } from './Bi'; | |
| 7 | + | import { useT } from './lang'; | |
| 7 | 8 | import { Hand } from './Hand'; | |
| 8 | 9 | import { SEAT_ROT, SEAT_SLOT } from './rotation'; | |
| 9 | 10 | import { TileView } from './TileView'; | |
| ⋯ 37 unchanged lines | |||
| 47 | 48 | } | |
| 48 | 49 | ||
| 49 | 50 | export function Seat({ game, seat, pos = seat, local = true, remote, compact, pool, onHelp }: Props) { | |
| 51 | + | const t = useT(); | |
| 50 | 52 | const s = game.state; | |
| 51 | 53 | const p = s.players[seat]; | |
| 52 | 54 | const [sel, setSel] = useState<number | null>(null); | |
| ⋯ 181 unchanged lines | |||
| 234 | 236 | </button> | |
| 235 | 237 | )} | |
| 236 | 238 | {!claiming && !canProceed && responded && s.phase !== 'turn' && ( | |
| 237 | - | <div className="waiting">已回應 Responded</div> | |
| 239 | + | <div className="waiting">{t('已回應', 'Responded')}</div> | |
| 238 | 240 | )} | |
| 239 | 241 | {deserted && ( | |
| 240 | 242 | <button className="btn ghost" onClick={remote!.onHandToBot}> | |
| ⋯ 41 unchanged lines | |||
| 282 | 284 | ||
| 283 | 285 | <div className="nameplate"> | |
| 284 | 286 | <span className={`wind ${isDealer ? 'dealer' : ''}`}> | |
| 285 | - | {WIND_NAMES[seatWind]} | |
| 286 | - | <em>{WIND_EN[seatWind]}</em> | |
| 287 | + | <BiEm zh={WIND_NAMES[seatWind]} en={WIND_EN[seatWind]} /> | |
| 287 | 288 | </span> | |
| 288 | 289 | <span className="pname">{p.name}</span> | |
| 289 | 290 | {remote?.holder != null && ( | |
| 290 | 291 | <span | |
| 291 | 292 | className="tag phone" | |
| 292 | - | title="這手牌在手機上 · played from a phone. 按住看 QR · hold to show the QR again" | |
| 293 | + | title={`${t('這手牌在手機上', 'played from a phone', ' · ')}. ${t( | |
| 294 | + | '按住看 QR', | |
| 295 | + | 'hold to show the QR again', | |
| 296 | + | ' · ', | |
| 297 | + | )}`} | |
| 293 | 298 | onPointerDown={() => remote.onPeek?.(true)} | |
| 294 | 299 | onPointerUp={() => remote.onPeek?.(false)} | |
| 295 | 300 | onPointerCancel={() => remote.onPeek?.(false)} | |
| ⋯ 2 unchanged lines | |||
| 298 | 303 | 📱 {remote.holder} | |
| 299 | 304 | </span> | |
| 300 | 305 | )} | |
| 301 | - | {deserted && <span className="tag sacred">已離線 offline</span>} | |
| 306 | + | {deserted && <span className="tag sacred">{t('已離線', 'offline')}</span>} | |
| 302 | 307 | {isDealer && ( | |
| 303 | 308 | <span className="tag"> | |
| 304 | - | 莊 Dealer{s.dealerStreak > 0 ? ` 連${s.dealerStreak}` : ''} | |
| 309 | + | {t('莊', 'Dealer')} | |
| 310 | + | {s.dealerStreak > 0 ? ` ${t(`連${s.dealerStreak}`, `×${s.dealerStreak + 1}`)}` : ''} | |
| 305 | 311 | </span> | |
| 306 | 312 | )} | |
| 307 | 313 | {tenpai.length > 0 && ( | |
| 308 | - | <span className="tag ting">聽 Ready {tenpai.map(labelOf).join('·')}</span> | |
| 314 | + | <span className="tag ting"> | |
| 315 | + | {t('聽', 'Ready')} {tenpai.map(labelOf).join('·')} | |
| 316 | + | </span> | |
| 309 | 317 | )} | |
| 310 | 318 | {/* Without this the 胡 button just quietly fails to appear. */} | |
| 311 | 319 | {sacred.length > 0 && ( | |
| 312 | - | <span className="tag sacred" title="過水 — passed on winning with these until your next draw"> | |
| 313 | - | 過水 {sacred.map(labelOf).join('·')} | |
| 320 | + | <span | |
| 321 | + | className="tag sacred" | |
| 322 | + | title={t( | |
| 323 | + | '過水 — 放棄過這幾張的胡,到自己再摸牌前不能胡', | |
| 324 | + | '過水 — passed on winning with these until your next draw', | |
| 325 | + | )} | |
| 326 | + | > | |
| 327 | + | {t('過水', 'Sacred')} {sacred.map(labelOf).join('·')} | |
| 314 | 328 | </span> | |
| 315 | 329 | )} | |
| 316 | 330 | <span className="chips">{p.score}</span> | |
| 317 | 331 | {p.hand.length > 0 && s.phase !== 'handEnd' && acts && ( | |
| 318 | 332 | <button className="btn ghost sort" onClick={() => game.sortHand(seat)}> | |
| 319 | - | 理牌 Sort | |
| 333 | + | {t('理牌', 'Sort')} | |
| 320 | 334 | </button> | |
| 321 | 335 | )} | |
| 322 | - | <button className="helpbtn" onClick={onHelp} aria-label="規則 rules"> | |
| 336 | + | <button className="helpbtn" onClick={onHelp} aria-label={t('規則', 'rules')}> | |
| 323 | 337 | ? | |
| 324 | 338 | </button> | |
| 325 | 339 | </div> | |
| ⋯ 4 unchanged lines | |||
modifiedsrc/ui/Settings.tsx+72 −35
| ⋯ 1 unchanged line | |||
| 2 | 2 | import { clamp, type Prefs } from '../game/prefs'; | |
| 3 | 3 | import { play, setSoundEnabled, setVoiceEnabled, setVolume } from '../game/sound'; | |
| 4 | 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'; | |
| 5 | 8 | ||
| 6 | 9 | interface Props { | |
| 7 | 10 | prefs: Prefs; | |
| ⋯ 9 unchanged lines | |||
| 17 | 20 | key: keyof Rules; | |
| 18 | 21 | zh: string; | |
| 19 | 22 | en: string; | |
| 20 | - | note: string; | |
| 23 | + | /** What the rule actually does, in both — the sentence a table argues over. */ | |
| 24 | + | note: [string, string]; | |
| 21 | 25 | } | |
| 22 | 26 | ||
| 23 | 27 | /** The house rules tables actually argue about before they start. */ | |
| ⋯ 2 unchanged lines | |||
| 26 | 30 | key: 'sacredDiscard', | |
| 27 | 31 | zh: '過水', | |
| 28 | 32 | 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.', | |
| 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 | + | ], | |
| 30 | 37 | }, | |
| 31 | 38 | { | |
| 32 | 39 | key: 'sacredClearedByClaim', | |
| 33 | 40 | zh: '吃碰解過水', | |
| 34 | 41 | en: 'A claim lifts it', | |
| 35 | - | note: 'Taking a 吃 / 碰 / 槓 counts as a fresh start, rather than making you wait to draw.', | |
| 42 | + | note: [ | |
| 43 | + | '吃 / 碰 / 槓 就算重新開始,不必等到自己摸牌才解過水。', | |
| 44 | + | 'Taking a 吃 / 碰 / 槓 counts as a fresh start, rather than making you wait to draw.', | |
| 45 | + | ], | |
| 36 | 46 | }, | |
| 37 | 47 | { | |
| 38 | 48 | key: 'multipleWinners', | |
| 39 | 49 | zh: '一炮多響', | |
| 40 | 50 | en: 'Multiple winners', | |
| 41 | - | note: 'One discard pays out to every seat that calls on it, not just the nearest.', | |
| 51 | + | note: [ | |
| 52 | + | '一張打出的牌,所有喊胡的家都賠,不是只賠最近的一家。', | |
| 53 | + | 'One discard pays out to every seat that calls on it, not just the nearest.', | |
| 54 | + | ], | |
| 42 | 55 | }, | |
| 43 | 56 | { | |
| 44 | 57 | key: 'liability', | |
| 45 | 58 | zh: '包牌', | |
| 46 | 59 | en: 'Liability', | |
| 47 | - | note: 'Feed the third dragon or wind pung to a 大三元 / 大四喜 and you pay for the whole hand.', | |
| 60 | + | note: [ | |
| 61 | + | '放第三副三元或四喜的碰給大三元 / 大四喜,整副牌由你包。', | |
| 62 | + | 'Feed the third dragon or wind pung to a 大三元 / 大四喜 and you pay for the whole hand.', | |
| 63 | + | ], | |
| 48 | 64 | }, | |
| 49 | 65 | ]; | |
| 50 | 66 | ||
| 51 | 67 | export function Settings({ prefs, onSave, onClose, stakesLocked, bots }: Props) { | |
| 68 | + | const t = useT(); | |
| 52 | 69 | // Edited locally and committed on 完成, so a half-typed name never reaches | |
| 53 | 70 | // the table and 取消 really does mean cancel. | |
| 54 | 71 | const [draft, setDraft] = useState<Prefs>(prefs); | |
| ⋯ 9 unchanged lines | |||
| 64 | 81 | ) => ( | |
| 65 | 82 | <label className="field"> | |
| 66 | 83 | <span className="f-label"> | |
| 67 | - | {zh} | |
| 68 | - | <em>{en}</em> | |
| 84 | + | <BiEm zh={zh} en={en} /> | |
| 69 | 85 | </span> | |
| 70 | 86 | <input | |
| 71 | 87 | type="number" | |
| ⋯ 11 unchanged lines | |||
| 83 | 99 | <div className="settings-scrim" onClick={onClose}> | |
| 84 | 100 | <div className="settings" onClick={(e) => e.stopPropagation()}> | |
| 85 | 101 | <div className="settings-head"> | |
| 86 | - | <h3>設定 · Settings</h3> | |
| 102 | + | <h3>{t('設定', 'Settings', ' · ')}</h3> | |
| 87 | 103 | <div className="settings-head-btns"> | |
| 88 | 104 | <button className="btn ghost" onClick={onClose}> | |
| 89 | - | 取消 Cancel | |
| 105 | + | {t('取消', 'Cancel')} | |
| 90 | 106 | </button> | |
| 91 | 107 | <button className="btn" onClick={() => onSave(draft)}> | |
| 92 | - | 完成 Done | |
| 108 | + | {t('完成', 'Done')} | |
| 93 | 109 | </button> | |
| 94 | 110 | </div> | |
| 95 | 111 | </div> | |
| ⋯ 1 unchanged line | |||
| 97 | 113 | <div className="settings-body"> | |
| 98 | 114 | <section> | |
| 99 | 115 | <h4> | |
| 100 | - | 玩家 <em>Players</em> | |
| 116 | + | <BiEm zh="語言" en="Language" /> | |
| 101 | 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> | |
| 102 | 133 | <div className="names"> | |
| 103 | 134 | {draft.names.map((n, i) => ( | |
| 104 | 135 | <label className="field" key={i}> | |
| 105 | 136 | <span className="f-label">{i + 1}</span> | |
| 106 | 137 | <input | |
| 107 | - | value={bots[i] ? '電腦 Computer' : n} | |
| 138 | + | value={bots[i] ? t('電腦', 'Computer') : n} | |
| 108 | 139 | disabled={bots[i]} | |
| 109 | 140 | maxLength={12} | |
| 110 | 141 | placeholder={DEFAULT_NAMES[i]} | |
| ⋯ 10 unchanged lines | |||
| 121 | 152 | ||
| 122 | 153 | <section> | |
| 123 | 154 | <h4> | |
| 124 | - | 籌碼 <em>Stakes</em> | |
| 155 | + | <BiEm zh="籌碼" en="Stakes" /> | |
| 125 | 156 | </h4> | |
| 126 | 157 | {num('base', '底', 'Base', 0, 999)} | |
| 127 | 158 | {num('taiValue', '台', 'Per tai', 0, 999)} | |
| 128 | 159 | {num('startingScore', '起始籌碼', 'Starting chips', 0, 99999)} | |
| 129 | 160 | {stakesLocked && ( | |
| 130 | 161 | <p className="note lock"> | |
| 131 | - | 對局進行中不能改動籌碼。 | |
| 132 | - | <br /> | |
| 133 | - | Stakes are fixed once a game is under way — finish it, or start a new one. | |
| 162 | + | <BiText | |
| 163 | + | zh="對局進行中不能改動籌碼。" | |
| 164 | + | en="Stakes are fixed once a game is under way — finish it, or start a new one." | |
| 165 | + | /> | |
| 134 | 166 | </p> | |
| 135 | 167 | )} | |
| 136 | 168 | </section> | |
| 137 | 169 | ||
| 138 | 170 | <section> | |
| 139 | 171 | <h4> | |
| 140 | - | 規則 <em>House rules</em> | |
| 172 | + | <BiEm zh="規則" en="House rules" /> | |
| 141 | 173 | </h4> | |
| 142 | - | {TOGGLES.map((t) => { | |
| 143 | - | const on = rules[t.key] as boolean; | |
| 174 | + | {TOGGLES.map((rule) => { | |
| 175 | + | const on = rules[rule.key] as boolean; | |
| 144 | 176 | // 過水 has to be on for the rule about lifting it to mean anything. | |
| 145 | - | const dead = t.key === 'sacredClearedByClaim' && !rules.sacredDiscard; | |
| 177 | + | const dead = rule.key === 'sacredClearedByClaim' && !rules.sacredDiscard; | |
| 146 | 178 | return ( | |
| 147 | - | <label className={`switch ${dead ? 'dead' : ''}`} key={t.key}> | |
| 179 | + | <label className={`switch ${dead ? 'dead' : ''}`} key={rule.key}> | |
| 148 | 180 | <input | |
| 149 | 181 | type="checkbox" | |
| 150 | 182 | checked={on} | |
| 151 | 183 | disabled={dead} | |
| 152 | - | onChange={(e) => setRules({ [t.key]: e.target.checked })} | |
| 184 | + | onChange={(e) => setRules({ [rule.key]: e.target.checked })} | |
| 153 | 185 | /> | |
| 154 | 186 | <span className="sw-name"> | |
| 155 | - | {t.zh} | |
| 156 | - | <em>{t.en}</em> | |
| 187 | + | <BiEm zh={rule.zh} en={rule.en} /> | |
| 157 | 188 | </span> | |
| 158 | - | <span className="note">{t.note}</span> | |
| 189 | + | <span className="note"> | |
| 190 | + | <BiText zh={rule.note[0]} en={rule.note[1]} /> | |
| 191 | + | </span> | |
| 159 | 192 | </label> | |
| 160 | 193 | ); | |
| 161 | 194 | })} | |
| ⋯ 1 unchanged line | |||
| 163 | 196 | ||
| 164 | 197 | <section> | |
| 165 | 198 | <h4> | |
| 166 | - | 音效 <em>Sound</em> | |
| 199 | + | <BiEm zh="音效" en="Sound" /> | |
| 167 | 200 | </h4> | |
| 168 | 201 | <label className="switch"> | |
| 169 | 202 | <input | |
| ⋯ 7 unchanged lines | |||
| 177 | 210 | }} | |
| 178 | 211 | /> | |
| 179 | 212 | <span className="sw-name"> | |
| 180 | - | 開啟音效 | |
| 181 | - | <em>Sound on</em> | |
| 213 | + | <BiEm zh="開啟音效" en="Sound on" /> | |
| 182 | 214 | </span> | |
| 183 | 215 | <span className="note"> | |
| 184 | - | A click as a tile goes down, and a chime when someone's claim window opens. | |
| 216 | + | <BiText | |
| 217 | + | zh="牌落桌的一聲,還有輪到人喊牌時的提示音。" | |
| 218 | + | en="A click as a tile goes down, and a chime when someone's claim window opens." | |
| 219 | + | /> | |
| 185 | 220 | </span> | |
| 186 | 221 | </label> | |
| 187 | 222 | <label className={`switch ${draft.sound ? '' : 'dead'}`}> | |
| ⋯ 9 unchanged lines | |||
| 197 | 232 | }} | |
| 198 | 233 | /> | |
| 199 | 234 | <span className="sw-name"> | |
| 200 | - | 報牌 | |
| 201 | - | <em>Call the tiles</em> | |
| 235 | + | <BiEm zh="報牌" en="Call the tiles" /> | |
| 202 | 236 | </span> | |
| 237 | + | {/* The calls themselves stay Chinese in every mode: that is the | |
| 238 | + | sound of the table, not a label on it. */} | |
| 203 | 239 | <span className="note"> | |
| 204 | - | 碰 · 吃 · 槓 · 胡了 called out loud, and every tile named as it is discarded — | |
| 205 | - | 三條, 五萬, 東風. | |
| 240 | + | <BiText | |
| 241 | + | zh="碰 · 吃 · 槓 · 胡了 都會喊出來,打出的每一張牌也會報 — 三條、五萬、東風。" | |
| 242 | + | en="碰 · 吃 · 槓 · 胡了 called out loud, and every tile named as it is discarded — 三條, 五萬, 東風." | |
| 243 | + | /> | |
| 206 | 244 | </span> | |
| 207 | 245 | </label> | |
| 208 | 246 | <label className="field"> | |
| 209 | 247 | <span className="f-label"> | |
| 210 | - | 音量 | |
| 211 | - | <em>Volume</em> | |
| 248 | + | <BiEm zh="音量" en="Volume" /> | |
| 212 | 249 | </span> | |
| 213 | 250 | <input | |
| 214 | 251 | type="range" | |
| ⋯ 19 unchanged lines | |||
modifiedsrc/ui/SoundToggle.tsx+3 −1
| 1 | 1 | import type { Prefs } from '../game/prefs'; | |
| 2 | + | import { useT } from './lang'; | |
| 2 | 3 | import { play, setSoundEnabled } from '../game/sound'; | |
| 3 | 4 | ||
| 4 | 5 | interface Props { | |
| ⋯ 9 unchanged lines | |||
| 14 | 15 | * only start the audio hardware from inside a click like this one. | |
| 15 | 16 | */ | |
| 16 | 17 | export function SoundToggle({ prefs, onPrefs, compact }: Props) { | |
| 18 | + | const t = useT(); | |
| 17 | 19 | const on = prefs.sound; | |
| 18 | 20 | return ( | |
| 19 | 21 | <button | |
| ⋯ 10 unchanged lines | |||
| 30 | 32 | <span className="spk" aria-hidden> | |
| 31 | 33 | {on ? '🔊' : '🔇'} | |
| 32 | 34 | </span> | |
| 33 | - | {!compact && <span>{on ? '音效開 Sound on' : '靜音 Muted'}</span>} | |
| 35 | + | {!compact && <span>{on ? t('音效開', 'Sound on') : t('靜音', 'Muted')}</span>} | |
| 34 | 36 | </button> | |
| 35 | 37 | ); | |
| 36 | 38 | } | |
modifiedsrc/ui/WallRing.tsx+3 −1
| ⋯ 7 unchanged lines | |||
| 8 | 8 | type RingCapacity, | |
| 9 | 9 | } from '../game/wall'; | |
| 10 | 10 | import type { GameState } from '../game/engine'; | |
| 11 | + | import { useT } from './lang'; | |
| 11 | 12 | ||
| 12 | 13 | /** | |
| 13 | 14 | * What is left of the wall, built as a square in the middle of the table. | |
| ⋯ 11 unchanged lines | |||
| 25 | 26 | * that sizes them. | |
| 26 | 27 | */ | |
| 27 | 28 | export function WallRing({ state }: { state: GameState }) { | |
| 29 | + | const t = useT(); | |
| 28 | 30 | const ring = useRef<HTMLDivElement>(null); | |
| 29 | 31 | const { cap, opening } = useSquare(ring); | |
| 30 | 32 | const sides = ringLayout(wallStacks(state), cap); | |
| ⋯ 2 unchanged lines | |||
| 33 | 35 | <div | |
| 34 | 36 | className="wall-ring" | |
| 35 | 37 | ref={ring} | |
| 36 | - | aria-label="牌牆" | |
| 38 | + | aria-label={t('牌牆', 'The wall')} | |
| 37 | 39 | style={opening ? { width: opening.w, height: opening.h } : undefined} | |
| 38 | 40 | > | |
| 39 | 41 | {/* One cell, never drawn: the ring is measured in stacks, so something has | |
| ⋯ 70 unchanged lines | |||
addedsrc/ui/lang.ts+65 −0
| 1 | + | import { useCallback, useSyncExternalStore } from 'react'; | |
| 2 | + | import { DEFAULT_PREFS, loadPrefs, type Lang } from '../game/prefs'; | |
| 3 | + | ||
| 4 | + | /** | |
| 5 | + | * Which languages the labels are showing, held module-side rather than passed | |
| 6 | + | * down: <Bi> is used at twenty-odd places across the table, and none of them | |
| 7 | + | * has any other reason to know about the setting. | |
| 8 | + | * | |
| 9 | + | * The preference is the source of truth — App writes it here whenever it | |
| 10 | + | * changes — but the first value is read straight from storage so a player who | |
| 11 | + | * chose English does not watch the lobby repaint itself on every load. | |
| 12 | + | */ | |
| 13 | + | let lang: Lang = typeof window === 'undefined' ? DEFAULT_PREFS.lang : loadPrefs().lang; | |
| 14 | + | ||
| 15 | + | const listeners = new Set<() => void>(); | |
| 16 | + | ||
| 17 | + | const subscribe = (cb: () => void) => { | |
| 18 | + | listeners.add(cb); | |
| 19 | + | return () => { | |
| 20 | + | listeners.delete(cb); | |
| 21 | + | }; | |
| 22 | + | }; | |
| 23 | + | ||
| 24 | + | export const getLang = () => lang; | |
| 25 | + | ||
| 26 | + | export function setLang(next: Lang) { | |
| 27 | + | // <html lang> follows the labels, so a page showing English is not still | |
| 28 | + | // claiming to be Chinese. index.html's notranslate stands either way. | |
| 29 | + | if (typeof document !== 'undefined') { | |
| 30 | + | document.documentElement.lang = next === 'en' ? 'en' : 'zh-TW'; | |
| 31 | + | } | |
| 32 | + | if (next === lang) return; | |
| 33 | + | lang = next; | |
| 34 | + | for (const cb of listeners) cb(); | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | export function useLang(): Lang { | |
| 38 | + | return useSyncExternalStore(subscribe, getLang, () => DEFAULT_PREFS.lang); | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /** | |
| 42 | + | * One label in the language the table is being read in. | |
| 43 | + | * | |
| 44 | + | * The two strings stay side by side at the place they are used, which is how | |
| 45 | + | * this codebase has always written them — there is no catalogue of keys to go | |
| 46 | + | * and look a phrase up in, and adding one would put every label a file away | |
| 47 | + | * from the thing it labels. | |
| 48 | + | * | |
| 49 | + | * `sep` is only ever seen in bilingual, and is whatever the line already used: | |
| 50 | + | * a space for a button, ' · ' for a sentence with two halves. | |
| 51 | + | */ | |
| 52 | + | export function pick(lang: Lang, zh: string, en: string, sep = ' '): string { | |
| 53 | + | if (lang === 'zh') return zh || en; | |
| 54 | + | if (lang === 'en') return en || zh; | |
| 55 | + | return zh && en ? zh + sep + en : zh || en; | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /** | |
| 59 | + | * `pick` for a component, and the subscription that goes with it: a label built | |
| 60 | + | * with this follows the setting rather than whatever it was on first render. | |
| 61 | + | */ | |
| 62 | + | export function useT() { | |
| 63 | + | const lang = useLang(); | |
| 64 | + | return useCallback((zh: string, en: string, sep?: string) => pick(lang, zh, en, sep), [lang]); | |
| 65 | + | } |