anvilsign in

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
2525
2626 # the feedback queue another session tends
2727 .agent-queue/
28+
29+# a dev server's pid, not the project's
30+http.pid
modifiedindex.html+5 −1
11 <!doctype html>
2-<html lang="zh-TW">
2+<html lang="zh-TW" translate="no">
33 <head>
44 <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" />
59 <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
610 <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
711 <!-- Added to the home screen, the game launches without the browser's own
⋯ 12 unchanged lines
modifiedsrc/App.tsx+85 −32
⋯ 1 unchanged line
22 import { AutoPlay } from './game/autoplay';
33 import type { TableCtl } from './game/ctl';
44 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';
66 import { loadSave } from './game/save';
77 import { play, setSoundEnabled, setVoiceEnabled, setVolume } from './game/sound';
88 import { WIND_EN, WIND_NAMES } from './game/tiles';
99 import type { SeatId } from './game/types';
1010 import { roomFromUrl, roomLink, seatFromUrl } from './net/protocol';
1111 import { NetSession } from './net/session';
12+import { Bi, BiText } from './ui/Bi';
1213 import { Center } from './ui/Center';
1314 import { useCompact } from './ui/compact';
1415 import { Controller } from './ui/Controller';
1516 import { FullscreenToggle } from './ui/FullscreenToggle';
1617 import { enterFullscreen, useFullscreen } from './ui/fullscreen';
1718 import { Help } from './ui/Help';
19+import { pick, setLang, useLang, useT } from './ui/lang';
20+import { LanguageToggle } from './ui/LanguageToggle';
1821 import { OnlineLobby } from './ui/OnlineLobby';
1922 import { TablePool } from './table/pool';
2023 import { Pool } from './ui/Pool';
⋯ 18 unchanged lines
3942
4043 export default function App() {
4144 const [prefs, setPrefs] = useState<Prefs>(loadPrefs);
45+ const t = useT();
46+ const lang = useLang();
4247 // Phone-sized: everything reads upright for the one person holding it.
4348 const compact = useCompact();
4449 const fullscreen = useFullscreen();
⋯ 127 unchanged lines
172177 document.documentElement.classList.toggle('compact', compact);
173178 }, [compact]);
174179
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+
175184 // Leaving fullscreen by the phone's own gesture is a decision too, and should
176185 // not be undone the next time a hand starts. A reload never reaches here:
177186 // there is no true-to-false to watch on a fresh mount, which is exactly what
⋯ 62 unchanged lines
240249 const netIdle = !session?.mode;
241250 return (
242251 <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>
245263
246264 {saved && (
247265 <div className="resume">
248266 <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+ )}
251273 </div>
252274 <div className="resume-scores">
253275 {saved.state.players.map((p, i) => (
⋯ 2 unchanged lines
256278 </span>
257279 ))}
258280 </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>
260284 </div>
261285 )}
262286
263287 <div className="lobby-buttons">
264288 {saved && (
265289 <button className="btn big-btn" onClick={resumeGame}>
266- 繼續對局 Resume
290+ <Bi zh="繼續對局" en="Resume" />
267291 </button>
268292 )}
269293 <button
270294 className={`btn big-btn ${saved ? 'ghost' : ''}`}
271295 onClick={() => startGame(SOLO_SEATS)}
272296 >
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>
275304 </button>
276305 <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>
279310 </button>
280311 {(!session || (session.isHost && netIdle)) && (
281312 <>
282313 <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>
285318 </button>
286319 <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>
289327 </button>
290328 </>
291329 )}
292330 {session && !session.isHost && netIdle && (
293331 <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+ />
297336 </p>
298337 )}
299338 </div>
⋯ 4 unchanged lines
304343 should say why rather than doing nothing. */}
305344 <p className="hint">
306345 {connecting
307- ? '連線中 connecting…'
346+ ? t('連線中', 'connecting…')
308347 : 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')
311355 }`
312356 : roomFromUrl()
313- ? '未連線 not connected'
314- : '本機 offline · 線上模式會自動連線 online modes connect on tap'}
357+ ? t('未連線', 'not connected')
358+ : t('本機 · 線上模式會自動連線', 'offline · online modes connect on tap', ' · ')}
315359 </p>
316360
317361 <div className="lobby-extras">
318362 <button className="btn ghost pill" onClick={() => setSettingsOpen(true)}>
319- ⚙ 設定 Settings
363+ ⚙ {t('設定', 'Settings')}
320364 </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} />
321368 {/* Only where the address bar is actually in the way. A desktop has
322369 F11, and the lobby there does not need another button. */}
323370 {compact && <FullscreenToggle prefs={prefs} onPrefs={setPrefs} />}
⋯ 16 unchanged lines
340387 local: held === 0,
341388 remote: {
342389 // 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,
344394 onPeek: setQrPeek,
345395 },
346396 };
⋯ 39 unchanged lines
386436 <div className="qr-dock">
387437 <SeatQr url={roomLink(session!.room.code, session!.room.publicBase)} />
388438 <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>}
391441 </span>
392442 </div>
393443 )}
⋯ 12 unchanged lines
406456 );
407457 }
408458
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, ' · ');
410463 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')}`;
413466 const hours = Math.round(mins / 60);
414- if (hours < 24) return `${hours} 小時前 · ${hours} h ago`;
467+ if (hours < 24) return `${hours} ${t('小時前', 'h ago')}`;
415468 const days = Math.round(hours / 24);
416- return `${days} 天前 · ${days} d ago`;
469+ return `${days} ${t('天前', 'd ago')}`;
417470 }
modifiedsrc/game/prefs.ts+16 −0
⋯ 13 unchanged lines
1414 const AUDIO_GENERATION = 2;
1515
1616 /**
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+/**
1726 * Preferences belong to the table, not to a hand: they outlive a save, survive
1827 * 開新局, and are what a fresh `Game` is built from.
1928 */
⋯ 12 unchanged lines
3241 * ui/fullscreen.ts. Not an audio setting: it is a choice, and it carries.
3342 */
3443 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;
3549 names: string[];
3650 rules: Rules;
3751 }
⋯ 4 unchanged lines
4256 volume: 0.7,
4357 voice: true,
4458 fullscreen: false,
59+ lang: 'bi',
4560 names: DEFAULT_NAMES,
4661 rules: DEFAULT_RULES,
4762 };
⋯ 10 unchanged lines
5873 volume: clamp(typeof p.volume === 'number' ? p.volume : DEFAULT_PREFS.volume, 0, 1),
5974 voice: !stale && typeof p.voice === 'boolean' ? p.voice : DEFAULT_PREFS.voice,
6075 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,
6177 names:
6278 Array.isArray(p.names) && p.names.length === 4 && p.names.every((n) => typeof n === 'string')
6379 ? p.names
⋯ 17 unchanged lines
modifiedsrc/styles.css+24 −0
⋯ 1144 unchanged lines
11451145 line-height: 1;
11461146 }
11471147
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+
11481172 /* ---------- shared table controls ---------- */
11491173 /* Bottom-right of the centre square, opposite the log. Unrotated on purpose:
11501174 it belongs to the table, not to a seat. */
⋯ 1097 unchanged lines
modifiedsrc/table/physics.test.ts+43 −0
⋯ 15 unchanged lines
1616 addBody,
1717 createWorld,
1818 faceOn,
19+ FIXED_DT,
1920 liftFor,
2021 outside,
2122 overlapping,
⋯ 158 unchanged lines
180181 expect(outside(b, POOL)).toBeLessThan(0.5);
181182 });
182183
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+
183226 it('says which edge a tile was thrown into, and whose tile it was', () => {
184227 // The middle stops where a player's tiles start, so an edge taken at speed
185228 // 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
9595 */
9696 const ENTRY_GRACE = 240;
9797
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+
98105 /** How fast a tile drawn at hand size shrinks to the size it is in the pool. */
99106 const SCALE_EASE = 6;
100107
⋯ 67 unchanged lines
168175 * over the wall from a seat's edge; once it has, the pool keeps it.
169176 */
170177 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;
171185 resting: boolean;
172186 /** Consecutive slow steps, counting towards sleep. */
173187 still: number;
⋯ 178 unchanged lines
352366
353367 // ---- stepping -------------------------------------------------------------
354368
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+}
357396
358397 /**
359398 * Whose side of the table a tile has ended up off, if it is off any of them.
⋯ 80 unchanged lines
440479 }
441480 }
442481
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+ }
454510 }
455511
456512 // --- on the felt ---
⋯ 106 unchanged lines
563619 /** The pool's edge. Once a tile is in, it stays in — this is the invariant the
564620 * tests pin, since a discard that slid off the table would be lost. */
565621 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);
573623
574624 let hit = 0;
575625 let edge: Edge | undefined;
⋯ 177 unchanged lines
753803 }
754804
755805 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);
761807 b.x = Math.min(maxX, Math.max(minX, b.x));
762808 b.y = Math.min(maxY, Math.max(minY, b.y));
763809 }
⋯ 89 unchanged lines
modifiedsrc/ui/Bi.tsx+51 −2
11 import type { ClaimType } from '../game/engine';
2+import { useLang } from './lang';
23
34 /**
45 * Bilingual label: the Chinese term players call at the table, with the English
56 * underneath for anyone still learning it. Tile faces stay pure Chinese — those
67 * 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.
711 */
812 export function Bi({ zh, en, sub }: { zh: string; en: string; sub?: string }) {
13+ const lang = useLang();
914 return (
1015 <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. */}
1327 {sub && <span className="bi-sub">{sub}</span>}
1428 </span>
1529 );
1630 }
1731
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+
1867 export const CLAIM_LABEL: Record<ClaimType, [string, string]> = {
1968 hu: ['胡', 'Win'],
2069 kong: ['槓', 'Kong'],
⋯ 3 unchanged lines
modifiedsrc/ui/Center.tsx+34 −16
⋯ 1 unchanged line
22 import type { Prefs } from '../game/prefs';
33 import type { SeatId } from '../game/types';
44 import { labelOf } from '../game/tiles';
5-import { Bi } from './Bi';
5+import { Bi, BiEm } from './Bi';
6+import { useLang, useT } from './lang';
67 import { FullscreenToggle } from './FullscreenToggle';
78 import { SoundToggle } from './SoundToggle';
89 import { TileView } from './TileView';
⋯ 15 unchanged lines
2425 }
2526
2627 export function Center({ game, prefs, compact, viewSeat = 0, preferSeat = null, onPrefs, onSettings }: Props) {
28+ const t = useT();
29+ const lang = useLang();
2730 const s = game.state;
2831 const r = s.result;
2932 // Point the live readout at whoever can move the game along.
⋯ 18 unchanged lines
4851 (compact ? (
4952 <div className="wall-pill">
5053 <b>{game.wallLeft}</b>
51- <em>剩牌 left</em>
54+ <em>{t('剩牌', 'left')}</em>
5255 </div>
5356 ) : (
5457 <WallRing state={s} />
⋯ 12 unchanged lines
6770 phone has no pile, and still needs telling. */}
6871 {compact && s.lastDiscard && s.phase !== 'handEnd' && (
6972 <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>
7176 <TileView tile={s.lastDiscard.tile} size="hand" highlight />
7277 </div>
7378 )}
7479
7580 {s.phase === 'claims' && (
7681 <div className="prompt">
77- {pending.length} 家可吃碰 · {pending.length} may claim
82+ {pending.length} {t('家可吃碰', 'may claim')}
7883 </div>
7984 )}
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+ )}
8188 </div>
8289
8390 {r && s.phase !== 'gameEnd' && (
8491 <div className="result">
8592 {r.drawGame ? (
8693 <>
87- <h2>流局 · Draw</h2>
94+ <h2>{t('流局', 'Draw', ' · ')}</h2>
8895 {/* Nobody won, so the thing everyone wants to see is who was
8996 there and on what — the same reveal as turning the hands over
9097 at a real table. */}
⋯ 5 unchanged lines
96103 <span className="ready-name">{p.name}</span>
97104 {waiting.length > 0 ? (
98105 <>
99- <span className="tag ting">聽 Ready</span>
106+ <span className="tag ting">{t('聽', 'Ready')}</span>
100107 <span className="ready-tiles">
101108 {waiting.map((t) => (
102109 <TileView key={t} tile={t} size="small" />
⋯ 1 unchanged line
104111 </span>
105112 </>
106113 ) : (
107- <span className="ready-no">未聽 · not ready</span>
114+ <span className="ready-no">{t('未聽', 'not ready', ' · ')}</span>
108115 )}
109116 </div>
110117 );
⋯ 6 unchanged lines
117124 {r.winners.map((w) => (
118125 <div className="win" key={w.seat}>
119126 <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')}
121129 <span className="wt"> {labelOf(w.winTile)}</span>
122130 </h2>
123131 {w.liable !== null && (
124132 <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')}
126135 </div>
127136 )}
128137 <div className="tai-list">
129138 {w.items.map((it, i) => (
130139 <div key={i} className="tai-item">
131140 <span className="tai-name">
132- {it.name}
133- <em>{it.en}</em>
141+ <BiEm zh={it.name} en={it.en} />
134142 </span>
135143 <span className="tai-val">{it.tai}</span>
136144 </div>
⋯ 2 unchanged lines
139147 </div>
140148 ))}
141149 {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>
143153 )}
144154 <div className="deltas">
145155 {r.deltas.map((d, i) => (
⋯ 12 unchanged lines
158168
159169 {s.phase === 'gameEnd' && (
160170 <div className="result">
161- <h2>一將終了 · Game over</h2>
171+ <h2>{t('一將終了', 'Game over', ' · ')}</h2>
162172 <div className="deltas">
163173 {[...s.players]
164174 .map((p, i) => ({ p, i }))
⋯ 23 unchanged lines
188198 ↺
189199 </span>
190200 <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+ )}
193211 </span>
194212 </button>
195213 )}
⋯ 7 unchanged lines
modifiedsrc/ui/Controller.tsx+35 −18
⋯ 4 unchanged lines
55 import type { SeatId } from '../game/types';
66 import type { NetSession } from '../net/session';
77 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';
910 import { FullscreenToggle } from './FullscreenToggle';
1011 import { Hand, type Throw } from './Hand';
1112 import { TileView } from './TileView';
⋯ 38 unchanged lines
5051 }
5152
5253 function SeatPick({ session, joining, prefs, onPrefs }: CtlProps & { joining?: boolean }) {
54+ const t = useT();
55+ const lang = useLang();
5356 const seats = session.seats;
5457 const s = session.table.state;
5558 return (
5659 <div className="ctl ctl-pick">
57- <h1>台灣麻將</h1>
60+ <h1>{lang === 'en' ? 'Taiwanese Mahjong' : '台灣麻將'}</h1>
5861 <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', ' · ')}
6065 </p>
6166 <div className="pick-grid">
6267 {SEATS.map((i) => {
⋯ 1 unchanged line
6469 const wind = (i - s.dealer + 4) % 4;
6570 return (
6671 <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])}
6873 <em>
6974 {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')}
7277 </em>
7378 </button>
7479 );
⋯ 15 unchanged lines
9095 }
9196
9297 function SeatStrip({ session, seat, prefs, onPrefs }: CtlProps & { seat: SeatId }) {
98+ const t = useT();
9399 const table = session.table;
94100 const s = table.state;
95101 const p = s.players[seat];
⋯ 50 unchanged lines
146152 ? { zh: '輪到你', en: 'your turn', hot: true }
147153 : opts
148154 ? { 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+ };
150160
151161 return (
152162 <div className="ctl">
153163 <div className="ctl-top">
154164 <span className="wind">
155- {WIND_NAMES[seatWind]}
156- <em>{WIND_EN[seatWind]}</em>
165+ <BiEm zh={WIND_NAMES[seatWind]} en={WIND_EN[seatWind]} />
157166 </span>
158167 <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+ )}
161174 <span className={`ctl-status ${status.hot ? 'hot' : ''}`}>
162- {status.zh} <em>{status.en}</em>
175+ <BiEm zh={status.zh} en={status.en} />
163176 </span>
164177 {s.lastDiscard && s.phase !== 'handEnd' && (
165178 <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" />
167181 </span>
168182 )}
169183 <span className="chips">{p.score}</span>
170184 <button className="btn ghost sort" onClick={() => table.sortHand(seat)}>
171- 理牌
185+ {t('理牌', 'Sort')}
172186 </button>
173187 <FullscreenToggle prefs={prefs} onPrefs={onPrefs} compact />
174188 </div>
⋯ 41 unchanged lines
216230 <Bi zh="打出" en="Discard" sub={labelOf(selTile)} />
217231 </button>
218232 )}
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+ )}
220236 </div>
221237
222238 <div className="ctl-hand">
⋯ 24 unchanged lines
247263 {r && s.phase === 'handEnd' && (
248264 <div className="ctl-result">
249265 {r.drawGame ? (
250- <h2>流局 · Draw</h2>
266+ <h2>{t('流局', 'Draw', ' · ')}</h2>
251267 ) : (
252268 r.winners.map((w) => (
253269 <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')}
255272 </h2>
256273 ))
257274 )}
⋯ 12 unchanged lines
270287
271288 {s.phase === 'gameEnd' && (
272289 <div className="ctl-result">
273- <h2>一將終了 · Game over</h2>
290+ <h2>{t('一將終了', 'Game over', ' · ')}</h2>
274291 <div className="deltas">
275292 {s.players.map((q, i) => (
276293 <span key={i}>
⋯ 12 unchanged lines
modifiedsrc/ui/FullscreenToggle.tsx+5 −1
11 import type { Prefs } from '../game/prefs';
22 import { enterFullscreen, exitFullscreen, fullscreenSupported, useFullscreen } from './fullscreen';
3+import { useT } from './lang';
34
45 interface Props {
56 prefs: Prefs;
⋯ 32 unchanged lines
3839 * button every single time.
3940 */
4041 export function FullscreenToggle({ prefs, onPrefs, compact }: Props) {
42+ const t = useT();
4143 const active = useFullscreen();
4244 if (!fullscreenSupported()) return null;
4345
⋯ 9 unchanged lines
5355 }}
5456 >
5557 <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+ )}
5761 </button>
5862 );
5963 }
modifiedsrc/ui/Hand.tsx+5 −3
11 import { useRef, useState } from 'react';
22 import type { Tile } from '../game/tiles';
3+import { useT } from './lang';
34 import { TileView } from './TileView';
45
56 export interface Throw {
⋯ 152 unchanged lines
158159 onMove,
159160 carry,
160161 }: Props) {
162+ const t = useT();
161163 const ref = useRef<HTMLDivElement>(null);
162164 const drag = useRef<Drag | null>(null);
163165 const [sorting, setSorting] = useState<number | null>(null);
⋯ 131 unchanged lines
295297
296298 return (
297299 <div className="hand" ref={ref} onPointerMove={move} onPointerUp={up} onPointerCancel={up}>
298- {tiles.map((t, i) => (
300+ {tiles.map((tile, i) => (
299301 <div
300302 key={i}
301303 data-idx={i}
⋯ 7 unchanged lines
309311 }
310312 onPointerDown={(e) => down(e, i)}
311313 >
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} />
314316 </div>
315317 ))}
316318 </div>
⋯ 23 unchanged lines
modifiedsrc/ui/Help.tsx+29 −14
11 import type { SeatId } from '../game/types';
2+import { BiEm } from './Bi';
3+import { useLang, useT } from './lang';
24 import { SEAT_ROT, SEAT_SLOT } from './rotation';
35
46 interface Section {
⋯ 27 unchanged lines
3234 en: 'Your turn',
3335 body: [
3436 ['點一張牌抬起,再點一次打出。', '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.'],
3739 ],
3840 },
3941 {
⋯ 15 unchanged lines
5557 en: 'Kongs',
5658 body: [
5759 ['四張相同:明槓、暗槓、或碰後加槓。', '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.'],
5961 ['別人加槓時你剛好能胡,可以搶槓。', 'You may rob an added kong if that tile completes your hand.'],
6062 ],
6163 },
⋯ 41 unchanged lines
103105 zh: '拿錯了',
104106 en: 'Mis-taps',
105107 body: [
106- ['桌子中央有「復原」,可以退回上一步。', 'The 復原 button in the middle takes back the last action.'],
108+ ['桌子中央有「復原」,可以退回上一步。', 'The 復原 / Undo button in the middle takes back the last action.'],
107109 ['誰看到誰按,不限於按錯的那個人。', 'Anyone can press it — not just whoever made the mistake.'],
108110 ],
109111 },
⋯ 21 unchanged lines
131133 compact?: boolean;
132134 onClose: () => void;
133135 }) {
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+
134150 return (
135151 <div className={`help-slot ${SEAT_SLOT[seat]}`} onClick={onClose}>
136152 <div
⋯ 6 unchanged lines
143159 onClick={(e) => e.stopPropagation()}
144160 >
145161 <div className="help-head">
146- <h3>台灣麻將規則 · How to play</h3>
162+ <h3>{t('台灣麻將規則', 'How to play', ' · ')}</h3>
147163 <button className="btn ghost" onClick={onClose}>
148- 關閉 Close
164+ {t('關閉', 'Close')}
149165 </button>
150166 </div>
151167 <div className="help-body">
152168 {SECTIONS.map((sec) => (
153169 <section key={sec.zh}>
154170 <h4>
155- {sec.zh} <em>{sec.en}</em>
171+ <BiEm zh={sec.zh} en={sec.en} />
156172 </h4>
157173 {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>
162175 ))}
163176 </section>
164177 ))}
165178 <section className="credit">
166179 <h4>
167- 牌面 <em>Tile art</em>
180+ <BiEm zh="牌面" en="Tile art" />
168181 </h4>
169182 <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+ )}
172187 </p>
173188 </section>
174189 </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
33 import { WIND_EN, WIND_NAMES } from '../game/tiles';
44 import type { SeatId } from '../game/types';
55 import type { NetSession } from '../net/session';
6-import { Bi } from './Bi';
6+import { Bi, BiText } from './Bi';
7+import { useLang, useT } from './lang';
78
89 const SEATS: SeatId[] = [0, 1, 2, 3];
910
⋯ 3 unchanged lines
1314 * two friends and two bots is a game, not a wait.
1415 */
1516 export function OnlineLobby({ session, prefs }: { session: NetSession; prefs: Prefs }) {
17+ const t = useT();
18+ const lang = useLang();
1619 const seats = session.seats;
1720 const me = session.room.me.id;
1821
⋯ 13 unchanged lines
3235
3336 return (
3437 <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>
3743 <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+ />
4148 </p>
4249
4350 <div className="pick-grid">
⋯ 7 unchanged lines
5158 className={`btn big-btn ${mine ? '' : 'ghost'}`}
5259 onClick={() => !mine && session.claimSeat(i)}
5360 >
54- {WIND_NAMES[i]} {WIND_EN[i]}
61+ {t(WIND_NAMES[i], WIND_EN[i])}
5562 <em>
5663 {held === 0
57- ? '空位,開局時由電腦代打 · open — a bot if nobody sits'
64+ ? t('空位,開局時由電腦代打', 'open — a bot if nobody sits', ' · ')
5865 : 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')}`}
6170 </em>
6271 </button>
6372 );
⋯ 7 unchanged lines
7180 disabled={seated === 0}
7281 onClick={() => session.startOnline(prefs.rules)}
7382 >
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+ />
7592 </button>
7693 ) : (
77- <p className="hint">等房主開局 · waiting for the host to deal</p>
94+ <p className="hint">{t('等房主開局', 'waiting for the host to deal', ' · ')}</p>
7895 )}
7996 </div>
8097 {session.err && <p className="hint">{session.err}</p>}
⋯ 3 unchanged lines
modifiedsrc/ui/Seat.tsx+26 −12
⋯ 2 unchanged lines
33 import { labelOf, WIND_EN, WIND_NAMES } from '../game/tiles';
44 import type { SeatId } from '../game/types';
55 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';
78 import { Hand } from './Hand';
89 import { SEAT_ROT, SEAT_SLOT } from './rotation';
910 import { TileView } from './TileView';
⋯ 37 unchanged lines
4748 }
4849
4950 export function Seat({ game, seat, pos = seat, local = true, remote, compact, pool, onHelp }: Props) {
51+ const t = useT();
5052 const s = game.state;
5153 const p = s.players[seat];
5254 const [sel, setSel] = useState<number | null>(null);
⋯ 181 unchanged lines
234236 </button>
235237 )}
236238 {!claiming && !canProceed && responded && s.phase !== 'turn' && (
237- <div className="waiting">已回應 Responded</div>
239+ <div className="waiting">{t('已回應', 'Responded')}</div>
238240 )}
239241 {deserted && (
240242 <button className="btn ghost" onClick={remote!.onHandToBot}>
⋯ 41 unchanged lines
282284
283285 <div className="nameplate">
284286 <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]} />
287288 </span>
288289 <span className="pname">{p.name}</span>
289290 {remote?.holder != null && (
290291 <span
291292 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+ )}`}
293298 onPointerDown={() => remote.onPeek?.(true)}
294299 onPointerUp={() => remote.onPeek?.(false)}
295300 onPointerCancel={() => remote.onPeek?.(false)}
⋯ 2 unchanged lines
298303 📱 {remote.holder}
299304 </span>
300305 )}
301- {deserted && <span className="tag sacred">已離線 offline</span>}
306+ {deserted && <span className="tag sacred">{t('已離線', 'offline')}</span>}
302307 {isDealer && (
303308 <span className="tag">
304- 莊 Dealer{s.dealerStreak > 0 ? ` 連${s.dealerStreak}` : ''}
309+ {t('莊', 'Dealer')}
310+ {s.dealerStreak > 0 ? ` ${t(`連${s.dealerStreak}`, `×${s.dealerStreak + 1}`)}` : ''}
305311 </span>
306312 )}
307313 {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>
309317 )}
310318 {/* Without this the 胡 button just quietly fails to appear. */}
311319 {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('·')}
314328 </span>
315329 )}
316330 <span className="chips">{p.score}</span>
317331 {p.hand.length > 0 && s.phase !== 'handEnd' && acts && (
318332 <button className="btn ghost sort" onClick={() => game.sortHand(seat)}>
319- 理牌 Sort
333+ {t('理牌', 'Sort')}
320334 </button>
321335 )}
322- <button className="helpbtn" onClick={onHelp} aria-label="規則 rules">
336+ <button className="helpbtn" onClick={onHelp} aria-label={t('規則', 'rules')}>
323337 ?
324338 </button>
325339 </div>
⋯ 4 unchanged lines
modifiedsrc/ui/Settings.tsx+72 −35
⋯ 1 unchanged line
22 import { clamp, type Prefs } from '../game/prefs';
33 import { play, setSoundEnabled, setVoiceEnabled, setVolume } from '../game/sound';
44 import { DEFAULT_NAMES, type Rules } from '../game/types';
5+import { BiEm, BiText } from './Bi';
6+import { useT } from './lang';
7+import { LanguageToggle } from './LanguageToggle';
58
69 interface Props {
710 prefs: Prefs;
⋯ 9 unchanged lines
1720 key: keyof Rules;
1821 zh: string;
1922 en: string;
20- note: string;
23+ /** What the rule actually does, in both — the sentence a table argues over. */
24+ note: [string, string];
2125 }
2226
2327 /** The house rules tables actually argue about before they start. */
⋯ 2 unchanged lines
2630 key: 'sacredDiscard',
2731 zh: '過水',
2832 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+ ],
3037 },
3138 {
3239 key: 'sacredClearedByClaim',
3340 zh: '吃碰解過水',
3441 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+ ],
3646 },
3747 {
3848 key: 'multipleWinners',
3949 zh: '一炮多響',
4050 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+ ],
4255 },
4356 {
4457 key: 'liability',
4558 zh: '包牌',
4659 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+ ],
4864 },
4965 ];
5066
5167 export function Settings({ prefs, onSave, onClose, stakesLocked, bots }: Props) {
68+ const t = useT();
5269 // Edited locally and committed on 完成, so a half-typed name never reaches
5370 // the table and 取消 really does mean cancel.
5471 const [draft, setDraft] = useState<Prefs>(prefs);
⋯ 9 unchanged lines
6481 ) => (
6582 <label className="field">
6683 <span className="f-label">
67- {zh}
68- <em>{en}</em>
84+ <BiEm zh={zh} en={en} />
6985 </span>
7086 <input
7187 type="number"
⋯ 11 unchanged lines
8399 <div className="settings-scrim" onClick={onClose}>
84100 <div className="settings" onClick={(e) => e.stopPropagation()}>
85101 <div className="settings-head">
86- <h3>設定 · Settings</h3>
102+ <h3>{t('設定', 'Settings', ' · ')}</h3>
87103 <div className="settings-head-btns">
88104 <button className="btn ghost" onClick={onClose}>
89- 取消 Cancel
105+ {t('取消', 'Cancel')}
90106 </button>
91107 <button className="btn" onClick={() => onSave(draft)}>
92- 完成 Done
108+ {t('完成', 'Done')}
93109 </button>
94110 </div>
95111 </div>
⋯ 1 unchanged line
97113 <div className="settings-body">
98114 <section>
99115 <h4>
100- 玩家 <em>Players</em>
116+ <BiEm zh="語言" en="Language" />
101117 </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>
102133 <div className="names">
103134 {draft.names.map((n, i) => (
104135 <label className="field" key={i}>
105136 <span className="f-label">{i + 1}</span>
106137 <input
107- value={bots[i] ? '電腦 Computer' : n}
138+ value={bots[i] ? t('電腦', 'Computer') : n}
108139 disabled={bots[i]}
109140 maxLength={12}
110141 placeholder={DEFAULT_NAMES[i]}
⋯ 10 unchanged lines
121152
122153 <section>
123154 <h4>
124- 籌碼 <em>Stakes</em>
155+ <BiEm zh="籌碼" en="Stakes" />
125156 </h4>
126157 {num('base', '底', 'Base', 0, 999)}
127158 {num('taiValue', '台', 'Per tai', 0, 999)}
128159 {num('startingScore', '起始籌碼', 'Starting chips', 0, 99999)}
129160 {stakesLocked && (
130161 <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+ />
134166 </p>
135167 )}
136168 </section>
137169
138170 <section>
139171 <h4>
140- 規則 <em>House rules</em>
172+ <BiEm zh="規則" en="House rules" />
141173 </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;
144176 // 過水 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;
146178 return (
147- <label className={`switch ${dead ? 'dead' : ''}`} key={t.key}>
179+ <label className={`switch ${dead ? 'dead' : ''}`} key={rule.key}>
148180 <input
149181 type="checkbox"
150182 checked={on}
151183 disabled={dead}
152- onChange={(e) => setRules({ [t.key]: e.target.checked })}
184+ onChange={(e) => setRules({ [rule.key]: e.target.checked })}
153185 />
154186 <span className="sw-name">
155- {t.zh}
156- <em>{t.en}</em>
187+ <BiEm zh={rule.zh} en={rule.en} />
157188 </span>
158- <span className="note">{t.note}</span>
189+ <span className="note">
190+ <BiText zh={rule.note[0]} en={rule.note[1]} />
191+ </span>
159192 </label>
160193 );
161194 })}
⋯ 1 unchanged line
163196
164197 <section>
165198 <h4>
166- 音效 <em>Sound</em>
199+ <BiEm zh="音效" en="Sound" />
167200 </h4>
168201 <label className="switch">
169202 <input
⋯ 7 unchanged lines
177210 }}
178211 />
179212 <span className="sw-name">
180- 開啟音效
181- <em>Sound on</em>
213+ <BiEm zh="開啟音效" en="Sound on" />
182214 </span>
183215 <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+ />
185220 </span>
186221 </label>
187222 <label className={`switch ${draft.sound ? '' : 'dead'}`}>
⋯ 9 unchanged lines
197232 }}
198233 />
199234 <span className="sw-name">
200- 報牌
201- <em>Call the tiles</em>
235+ <BiEm zh="報牌" en="Call the tiles" />
202236 </span>
237+ {/* The calls themselves stay Chinese in every mode: that is the
238+ sound of the table, not a label on it. */}
203239 <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+ />
206244 </span>
207245 </label>
208246 <label className="field">
209247 <span className="f-label">
210- 音量
211- <em>Volume</em>
248+ <BiEm zh="音量" en="Volume" />
212249 </span>
213250 <input
214251 type="range"
⋯ 19 unchanged lines
modifiedsrc/ui/SoundToggle.tsx+3 −1
11 import type { Prefs } from '../game/prefs';
2+import { useT } from './lang';
23 import { play, setSoundEnabled } from '../game/sound';
34
45 interface Props {
⋯ 9 unchanged lines
1415 * only start the audio hardware from inside a click like this one.
1516 */
1617 export function SoundToggle({ prefs, onPrefs, compact }: Props) {
18+ const t = useT();
1719 const on = prefs.sound;
1820 return (
1921 <button
⋯ 10 unchanged lines
3032 <span className="spk" aria-hidden>
3133 {on ? '🔊' : '🔇'}
3234 </span>
33- {!compact && <span>{on ? '音效開 Sound on' : '靜音 Muted'}</span>}
35+ {!compact && <span>{on ? t('音效開', 'Sound on') : t('靜音', 'Muted')}</span>}
3436 </button>
3537 );
3638 }
modifiedsrc/ui/WallRing.tsx+3 −1
⋯ 7 unchanged lines
88 type RingCapacity,
99 } from '../game/wall';
1010 import type { GameState } from '../game/engine';
11+import { useT } from './lang';
1112
1213 /**
1314 * What is left of the wall, built as a square in the middle of the table.
⋯ 11 unchanged lines
2526 * that sizes them.
2627 */
2728 export function WallRing({ state }: { state: GameState }) {
29+ const t = useT();
2830 const ring = useRef<HTMLDivElement>(null);
2931 const { cap, opening } = useSquare(ring);
3032 const sides = ringLayout(wallStacks(state), cap);
⋯ 2 unchanged lines
3335 <div
3436 className="wall-ring"
3537 ref={ring}
36- aria-label="牌牆"
38+ aria-label={t('牌牆', 'The wall')}
3739 style={opening ? { width: opening.w, height: opening.h } : undefined}
3840 >
3941 {/* 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+}