anvilsign in

collin/mahjong · 0ab65f87

A wall tile is a hand tile, and the square is what that comes to

Collin Richards · 2026-08-25 07:58 UTC · 0ab65f87c2895780d606c237f0b3b3a255897fd0 · parent af3ae648 · browse files

modifiedREADME.md+94 −55
⋯ 45 unchanged lines
4646 hand once you discard. 補花 replacements and kong replacements are marked the
4747 same way.
4848 - **The wall** is drawn in the centre as a square of four staggered sides, laid
49- out like a `#` with the corners open, the way it's built on a real table. The
50- gold stack is the break point you draw from, stacks shrink to a single tile and
51- then vanish as they're used, and the dimmed tail is the 16-tile 底牌 that ends
52- the hand — which is also the end kong and flower replacements are taken from,
53- so the square is eaten from both directions at once. Only what is still
54- standing is drawn, and it is evened up over the four sides as it goes, which
55- is what lets every tile left in it be the size of a tile in your hand — see
56- [what is left of the wall](#what-is-left-of-the-wall).
49+ out like a `#` the way it is built on a real table — every corner covered by
50+ one wall carrying past the end of the next. All 144 tiles, at the size of the
51+ tiles in your hand, because they are the same tiles. The gold stack is the
52+ break point you draw from, and where that is depends on what the dealer threw:
53+ stacks shrink to a single tile and then vanish as they are used, so the gap
54+ opens wherever the dice opened the wall and grows from there. The 16-tile 底牌
55+ tail at the other end is where kong and flower replacements come from, so the
56+ square is eaten from both directions at once. See
57+ [擲骰](#擲骰--where-the-wall-is-broken).
5758 - **Discard** — drag a tile out of your row and it comes up out of your hand and
5859 follows your finger anywhere on the table; let go and it goes in, from where
5960 you let go and at the speed you let go at. Dragging *along* the row is still
⋯ 44 unchanged lines
104105 what not pressing anything already does.
105106 - **On your turn** — 自摸, 暗槓 and 加槓 appear automatically when legal.
106107 加槓 offers everyone else a 搶槓 chance.
107-- **Where the buttons go** — the strip is built from its edge inwards, so the
108- action bar holds a fixed height whether or not anything is in it. Otherwise
109- the melds above it shuffled along every time you lifted a tile
110- and 打出 五萬 appeared, since a button naming a tile stands taller than a bare
111- 過. Every button on the bar is given that one height for the same reason.
108+- **Where the buttons go** — on the same row as your tiles, at the right-hand end
109+ of it, where the tile you are about to throw already is. The bar is out of the
110+ strip's flow, so a button arriving moves nothing: 打出 五萬 appearing when you
111+ lift a tile used to shove the melds and flowers above it up the strip. Every
112+ button on the bar is given one height for the same reason.
113+- **On the keys** — ← and → walk the selection along your hand and space throws
114+ the tile that is lifted. How long you *hold* space is how hard it goes: the
115+ tile draws back out of the hand while the key is down and leaves when you let
116+ go, so a tap slides it in and a long hold sends it across the square. Escape
117+ puts it back. The wall still decides whether it can be slid or has to be
118+ lobbed, exactly as with a finger.
112119 - **When a hand ends** — a big arrow drops onto the winner's edge of the table.
113120 It lives inside their strip, so it turns with the seat and lands on the right
114121 person wherever they are sitting, and 一炮多響 lights up every seat that
115122 called. A 流局 has no winner to point at, so it turns the hands over instead:
116123 who was 聽牌 and the tiles they were waiting on, the same reveal as at a real
117124 table.
118-- **Undo** — a 復原 button in the middle of the table takes back the last
119- action, naming what it will undo (`復原 玩家 2 打 五萬`). It sits in the
120- centre rather than on a seat because whoever *spots* the mis-tap should be
121- able to reach it. Twenty actions deep, cleared when the next hand is dealt.
125+- **The gear** — beside the `?` on every nameplate, and it opens that player's
126+ own copy of the table's controls: undo, sound, full screen, 設定. Anchored in
127+ their strip and turned to face their edge, like the rules panel, so what you
128+ open reads the right way up to you and to nobody else. These used to be a bar
129+ in the middle of the table, which is the one place that belongs to everybody
130+ — the pile lands there and it is upside down to two of the four.
131+- **Undo** — 復原 in that panel takes back the last action, naming what it will
132+ undo (`復原 玩家 2 打 五萬`). It is in all four panels rather than only the
133+ seat that made the mistake, because whoever *spots* the mis-tap should be able
134+ to reach it. Twenty actions deep, cleared when the next hand is dealt.
122135 Snapshots live outside the saved state, so an undo does not survive a
123136 refresh: what you can take back is what happened while everyone was still
124137 watching it happen.
125-- **Sound** — on by default, muted from the 🔊 button in the middle of the
126- table or from settings. A dry clack as a tile goes down, and a distinct
138+- **Sound** — on by default, muted from the 🔊 button behind any seat's gear, or
139+ from settings. A dry clack as a tile goes down, and a distinct
127140 two-note chime when a claim window opens, which is how a slow player notices
128141 their 碰 is available before the next player draws it shut. Everything is
129142 synthesised with a few oscillators (`src/game/sound.ts`) rather than sampled,
⋯ 431 unchanged lines
561574 stacks a side wants about 560px and no ordinary window has that between the top
562575 and bottom strips. That bought a square at the price of it not being the wall:
563576 a side ran out of stacks before it reached its corner, so the four of them never
564-met. What gives instead is the tile. A wall tile is not a hand tile — on a table
565-it is the same tile, but on a screen the wall has to fit the middle and the hand
566-has to be readable, and those are two different jobs, so `ringCell` solves for
567-how big a stack may be and `WallRing` hands the answer back to the CSS as
568-`--ws`.
577+met.
569578
570-The four walls are placed by `rhombus()` in `game/wall.ts`, which is where the
571-whole shape lives: a rhombus of any corner angle, at any rotation, with the
572-walls lapping over each other by however much you ask for. At ninety degrees it
573-is the square everyone knows.
579+Nothing gives now, and in particular the tile does not. Every tile on the table
580+is one size — `--tile-w` in styles.css, in a hand, in the wall, and lying in the
581+middle — because they are the same tiles, and a tile that changed size between
582+the wall and your hand read as a different, smaller set sitting in the middle.
583+The square is built from that tile and is however big that comes to:
584+`wallSquare()` in `game/wall.ts` takes the tile and hands back the lengths, and
585+`WallRing` measures one rendered cell to find out what CSS made it. The wall
586+being bigger than the room between the strips is a fact about a 144-tile wall,
587+not a thing to solve by shrinking it.
574588
575-Each wall is pinned at the corner it is built from and runs its whole length
576-from there, which carries it past the far corner and over the outside of the
577-next wall along — that overhang is the pinwheel. How far it has to overrun to
578-*close* the corner depends on the corner: two bands of thickness `t` meeting at
579-an interior angle ψ overlap by `t / tan(ψ/2)`, which at ninety degrees is
580-exactly one wall-depth. A rhombus has two different corner angles and its four
581-walls are all the same eighteen stacks long, so they cannot each have their own
582-overrun — all four take the sharper corner's.
589+The four walls are placed by CSS alone: each is pinned to its own edge of the
590+opening with `left`/`top`, runs its whole length from the corner it is built
591+from, and so carries one wall-depth past the far corner and over the end of the
592+next wall along. Every corner of the square is covered by exactly one wall and
593+no two overlap — that is the pinwheel. Nothing is rotated; a square wants no
594+rotation to describe it.
583595
584-A real corner is not closed, though. Walls get pushed up against each other by
585-hand and there is always a little daylight where they meet, so `--wall-gap`
586-takes some of that overrun back, measured in tiles and held under one of them —
587-a whole tile of daylight is a wall that has stopped meeting the next.
596+The overhang is exactly one wall-depth, which is what it takes to close a right
597+angle: two bands of thickness `t` meeting at an interior angle ψ overlap by
598+`t / tan(ψ/2)`, and at ninety degrees that is `t`. So the whole square measures a
599+wall's length plus one depth across, and leaves that length *less* one depth in
600+the middle — which is the opening, and which is what `.wall-ring` itself is.
601+
602+The CSS only places what it is told: `.wall-side` is a row of stacks, laid across
603+on the two flat walls and down on the two upright ones, and `--wall-len` from
604+`WallRing` is the only length it is given. Every place in the wall is rendered
605+whether or not there is still a stack standing on it, because with the break
606+anywhere but a corner a wall is eaten from its *middle* — a row that closed the
607+gap up would drag the whole tail of the wall along the table behind it.
608+
609+Where the four walls **stand** is found at the table itself rather than on a page
610+of sliders: take hold of a wall in a real game and push it. It goes where the
611+finger goes, and lets go saying where it landed — `[wall] top pushed to 0.5,-4.9
612+— top 0.5,-4.9 · right 0,0 · …` in the console, in tiles, which is the form
613+`WALL_PLACED` in `game/wall.ts` is written in, so what a push finds pastes
614+straight back in. Nothing about the hand moves: the wall is the same wall in the
615+same order and the next draw is still the next draw. There used to be a
616+`/wall.html` for this; the table is the honest place to do it, so that page is
617+gone.
618+
619+## 擲骰 — where the wall is broken
620+
621+The dealer throws three dice to open a hand, and the total does two jobs. It
622+counts round the seats — the dealer being one, and the count going the way the
623+turn goes — to pick whose wall is opened. Then the same number counts stacks in
624+from the right-hand end of *that* wall, and the break is behind them: the first
625+tile drawn is the next one along, and the drawing runs away to the left from
626+there, on round the square.
588627
589-The CSS only places what it is told: `.wall-side` is one shape — a row of stacks
590-going right, standing `--wd` deep — and a transform per side. There are no
591-`left`/`top` special cases, because a rhombus at any angle but ninety cannot be
592-described with them. Each stack carries its own `data-angle`, so `geometry.ts`
593-hands the physics a turned box rather than trying to recover the angle from a
594-bounding rect, which cannot say. `--wall-angle`, `--wall-turn` and `--wall-lap`
595-are the three numbers.
628+The square makes that cheap to say. Every wall is laid down from the corner its
629+own player's right hand falls on — that is what the pinwheel is — so the run of
630+72 places already starts at each seat's right, and the break is simply so many
631+places into the side the dice picked. `breakAt()` is those two lines.
596632
597-Where the four of them **stand** is found at the table itself rather than on a
598-page of sliders: take hold of a wall in a real game and push it. It moves along
599-its own line and off it, both walls' worth of freedom, and lets go saying where
600-it landed — `[wall] top pushed to 12,-40 — top 12,-40 · right 0,0 · …` in the
601-console, and the same numbers on `.wall-ring[data-nudged]` for reading back off
602-the DOM. Nothing about the hand moves: the wall is the same wall in the same
603-order and the next draw is still the next draw. There used to be a `/wall.html`
604-for this; the table is the honest place to do it, so that page is gone.
633+Nothing about the *tiles* turns on it: the wall was shuffled before it was built,
634+so which stack is drawn first is decided either way. What it moves is where in
635+the middle of the table the gap opens and who has to reach furthest for the next
636+draw — which is the whole of what it does at a table too. The throw is made from
637+the same seeded rng that shuffled, so a seed replays a hand's gap as well as its
638+tiles.
605639
640+In a dev build a panel in the corner of the table steps the total through every
641+one three dice can make, 3 to 18, and redraws the square on each; **整牆 144**
642+puts every tile back, which is what the square is built to and what it only ever
643+is for the moment before the deal. `game.reroll(6, 6, 6)` does the same from the
644+console.
606645 Nothing about the square is calculated twice. Its size lives entirely in CSS
607646 (`--ws`, `--wd`), so `geometry.ts` **measures** it instead of restating that
608647 arithmetic: `WallRing` already renders every stack as a real element tagged with
⋯ 71 unchanged lines
modifiedsrc/App.tsx+112 −4
11 import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
22 import { AutoPlay } from './game/autoplay';
33 import type { TableCtl } from './game/ctl';
4-import { Game } from './game/engine';
4+import { Game, type SoundCue } from './game/engine';
55 import { loadPrefs, savePrefs, type Lang, type Prefs } from './game/prefs';
66 import { loadSave } from './game/save';
7-import { play, setSoundEnabled, setVoiceEnabled, setVolume } from './game/sound';
7+import {
8+ onAudioChange,
9+ play,
10+ setSoundEnabled,
11+ unlock,
12+ setSpatial,
13+ setVoiceEnabled,
14+ setVolume,
15+ voiceLive,
16+ type Cast,
17+ type Pos,
18+} from './game/sound';
819 import { WIND_EN, WIND_NAMES } from './game/tiles';
920 import type { SeatId } from './game/types';
1021 import { roomFromUrl, roomLink, seatFromUrl } from './net/protocol';
⋯ 31 unchanged lines
4253 const subNothing = () => () => {};
4354 const zero = () => 0;
4455
56+/**
57+ * Where a cue is heard, and by which device — everything the sound needs to
58+ * know that only this screen can tell it. Handed to `sound.play` per cue; see
59+ * `castFor` for the reasoning and `game/sound.ts` for what it does with it.
60+ */
61+interface Earshot {
62+ /** Seat number → which edge of *this* screen it is sitting at. */
63+ posOf: (seat: SeatId) => SeatId;
64+ mode: 'online' | 'party' | null;
65+ mySeat: SeatId | null;
66+ /** Whether this device is the party table rather than a hand at it. */
67+ isScreen: boolean;
68+ /** Seats whose own phone is saying their calls for them. */
69+ voices: boolean[];
70+}
71+
72+/**
73+ * Which parts of a cue this device plays, and where from.
74+ *
75+ * A single screen — solo, hotseat, or an online table — plays all of it, panned
76+ * to the edge the seat is sitting at, so 碰 from 上家 comes from your left.
77+ *
78+ * A party table is a room with five devices in it, and the sound is split the
79+ * way the room already splits: the table makes the noise, because the tiles
80+ * are on the table, and the phone in a player's hand says the words, because
81+ * the words are theirs. So the table drops the voice for any seat whose phone
82+ * has said it can talk, and a phone plays nothing but its own seat's calls —
83+ * plus the chime for a claim window somebody else opened, which is a nudge
84+ * meant for the person holding the phone and nobody else in the room.
85+ */
86+function castFor(cue: SoundCue, room: Earshot): Cast {
87+ const seat = cue.seat;
88+ if (room.mode === 'party' && !room.isScreen) {
89+ // A phone still on the seat picker holds nothing and speaks for nobody.
90+ if (seat === undefined || room.mySeat === null) return { voice: false, noise: false };
91+ const mine = seat === room.mySeat;
92+ // No panning: a phone is not a table, it is one player, and everything it
93+ // says comes from where it is — in their hand.
94+ return { pos: null, voice: mine, noise: mine ? false : cue.kind === 'claimWindow' };
95+ }
96+ const pos = seat === undefined ? null : (room.posOf(seat) as Pos);
97+ const spoken = room.mode === 'party' && seat !== undefined && room.voices[seat] === true;
98+ return { pos, voice: !spoken, noise: true };
99+}
100+
45101 export default function App() {
46102 const [prefs, setPrefs] = useState<Prefs>(loadPrefs);
47103 const t = useT();
⋯ 71 unchanged lines
119175 session.setBusy(() => false);
120176 };
121177 }, [session, pool]);
122- useEffect(() => session?.table.onSound(play), [session]);
178+ // Where every cue is heard from, kept in a ref rather than in the closures
179+ // below: the two subscriptions outlive any particular arrangement of the
180+ // table, and re-subscribing on every rotation would drop cues mid-hand.
181+ const earshot = useRef<Earshot>({
182+ posOf: (s) => s,
183+ mode: null,
184+ mySeat: null,
185+ isScreen: false,
186+ voices: [false, false, false, false],
187+ });
188+ useEffect(
189+ () => session?.table.onSound((cue) => play(cue, castFor(cue, earshot.current))),
190+ [session],
191+ );
123192
124193 // Walking back into a running online game unseated — a reload, or a friend
125194 // arriving late — takes the first empty chair, deserted or computer-run.
⋯ 25 unchanged lines
151220 // Online the table is turned so your own seat is at the bottom edge.
152221 const viewSeat: SeatId = netMode === 'online' ? mySeat ?? 0 : 0;
153222 const posOf = (seat: SeatId): SeatId => (((seat - viewSeat + 4) % 4) as SeatId);
223+ // Everything the sound needs to know about where this screen is sitting, and
224+ // who else in the room is going to be talking. See castFor, above.
225+ earshot.current = {
226+ posOf,
227+ mode: netMode,
228+ mySeat,
229+ isScreen,
230+ voices: session?.voiceSeats ?? [false, false, false, false],
231+ };
154232
155233 // Names and stakes live in the game state as well as in the preferences —
156234 // saved games keep the settings they were played under.
⋯ 8 unchanged lines
165243 setSoundEnabled(prefs.sound);
166244 setVoiceEnabled(prefs.voice);
167245 setVolume(prefs.volume);
246+ setSpatial(prefs.spatial);
168247 setSettingsOpen(false);
169248 };
170249 const settingsPanel = settingsOpen && (
⋯ 60 unchanged lines
231310 setSoundEnabled(prefs.sound);
232311 setVoiceEnabled(prefs.voice);
233312 setVolume(prefs.volume);
313+ setSpatial(prefs.spatial);
234314 }, [prefs]);
235- useEffect(() => game.onSound(play), [game]);
315+
316+ // A browser holds the audio hardware shut until the page has been touched,
317+ // and a phone that arrived by QR may never touch anything that asks for it
318+ // by name. So the first touch anywhere opens it — which on a phone is also
319+ // what lets it tell the table it can do the talking.
320+ useEffect(() => {
321+ const wake = () => unlock();
322+ window.addEventListener('pointerdown', wake, true);
323+ window.addEventListener('keydown', wake, true);
324+ return () => {
325+ window.removeEventListener('pointerdown', wake, true);
326+ window.removeEventListener('keydown', wake, true);
327+ };
328+ }, []);
329+
330+ // Tell the room whether this device is going to say anything, and say so
331+ // again whenever that changes — a mute, a 報牌 toggle, the tap that wakes a
332+ // phone's audio, the tab going to sleep. A party table only stops calling a
333+ // seat's tiles once the phone on that seat has claimed the job.
334+ useEffect(() => {
335+ if (!session) return;
336+ const tell = () => session.reportVoice(voiceLive());
337+ tell();
338+ return onAudioChange(tell);
339+ }, [session, prefs.sound, prefs.voice]);
340+ useEffect(() => game.onSound((cue) => play(cue, castFor(cue, earshot.current))), [game]);
236341 // Watches the table for the whole session and only stirs when a seat the
237342 // computer holds is the one being waited on. Idle in a networked game, where
238343 // whichever device is the room's host runs its own AutoPlay.
⋯ 164 unchanged lines
403508 ? `${slot.name || t('手機', 'phone')}${held > 1 ? ` ×${held}` : ''}`
404509 : null,
405510 onPeek: setQrPeek,
511+ // The table has gone quiet for this seat on purpose: the phone on it
512+ // is doing the calling.
513+ speaks: session!.voiceSeats[seat] === true,
406514 },
407515 };
408516 }
⋯ 85 unchanged lines
modifiedsrc/game/engine.test.ts+10 −4
⋯ 224 unchanged lines
225225 expect(s.turn).toBe(victim);
226226 });
227227
228- it('names the tile on the cues that are spoken aloud', () => {
228+ it('names the tile, and whose it was, on the cues that are spoken aloud', () => {
229229 const game = new Game();
230- const cues: { kind: string; tile?: number }[] = [];
230+ const cues: { kind: string; tile?: number; seat?: SeatId }[] = [];
231231 game.onSound((c) => cues.push(c));
232232 game.startHand(7);
233233 const s = game.state;
⋯ 2 unchanged lines
236236 s.players[victim].hand = [tile, tile, ...s.players[victim].hand.slice(2)];
237237
238238 game.discard(s.dealer, tile);
239- expect(cues).toContainEqual({ kind: 'discard', tile });
239+ expect(cues).toContainEqual({ kind: 'discard', tile, seat: s.dealer });
240240
241241 game.respond(victim, { type: 'pung' });
242242 game.resolveNow();
243- expect(cues).toContainEqual({ kind: 'pung', tile });
243+ // The 碰 belongs to the seat that called it, not to the one that threw:
244+ // that is what puts the shout at the right edge of the table, and hands it
245+ // to the right phone at a party one.
246+ expect(cues).toContainEqual({ kind: 'pung', tile, seat: victim });
247+
248+ // The table's own noises belong to nobody, and say so.
249+ expect(cues.find((c) => c.kind === 'deal')?.seat).toBeUndefined();
244250 });
245251
246252 it('takes back a mis-tapped discard', () => {
⋯ 292 unchanged lines
modifiedsrc/game/engine.ts+29 −12
⋯ 50 unchanged lines
5151 export interface SoundCue {
5252 kind: SoundEvent;
5353 tile?: Tile;
54+ /**
55+ * Whose it is — the seat that threw, called, or won. Absent for the things
56+ * that belong to the table rather than to anybody: the deal, a 流局, an undo.
57+ *
58+ * The engine says whose only because a sound has to come from somewhere: the
59+ * UI pans it to that seat's edge, and at a party table it can hand the words
60+ * to the phone that seat is holding. See ui/sound.ts.
61+ */
62+ seat?: SeatId;
5463 }
5564
5665 export type ClaimType = 'hu' | 'kong' | 'pung' | 'chow';
⋯ 232 unchanged lines
289298 this.soundListeners.delete(fn);
290299 };
291300 };
292- private sfx(kind: SoundEvent, tile?: Tile) {
293- for (const l of this.soundListeners) l({ kind, tile });
301+ private sfx(kind: SoundEvent, tile?: Tile, seat?: SeatId) {
302+ for (const l of this.soundListeners) l({ kind, tile, seat });
294303 }
295304
296305 /**
⋯ 194 unchanged lines
491500 if (isFlower(t)) {
492501 drawn = this.replaceFlowers(seat).last;
493502 this.say(`${s.players[seat].name} 補花 flower`);
494- this.sfx('flower', t); // the flower itself, not the tile that replaced it
503+ this.sfx('flower', t, seat); // the flower itself, not the tile that replaced it
495504 }
496505 s.turn = seat;
497506 this.setDrawn(seat, drawn);
⋯ 63 unchanged lines
561570 s.lastDiscard = { tile, from: seat };
562571 s.responses = {};
563572 s.options = this.claimOptionsFor(tile, seat);
564- this.sfx('discard', tile);
573+ this.sfx('discard', tile, seat);
565574
566575 if (Object.keys(s.options).length === 0) {
567576 this.afterDiscardResolved();
568577 } else {
569578 s.phase = 'claims';
570579 // The point of the chime: claims don't block, so somebody has to be told
571- // their window is open before the next player draws it shut.
572- this.sfx('claimWindow');
580+ // their window is open before the next player draws it shut. It comes
581+ // from the tile — that is, from the seat that threw it — because that is
582+ // what everybody else is being asked to look at.
583+ this.sfx('claimWindow', undefined, seat);
573584 }
574585 this.emit();
575586 }
⋯ 161 unchanged lines
737748 // stricter ones make you wait for your own draw.
738749 if (s.rules.sacredClearedByClaim) s.sacred[seat] = [];
739750 this.say(`${p.name} ${claimName(claim.type)} ${labelOf(tile)}`);
740- this.sfx(claim.type, tile);
751+ this.sfx(claim.type, tile, seat);
741752
742753 s.lastDiscard = null;
743754 s.options = {};
⋯ 35 unchanged lines
779790 for (let k = 0; k < 4; k++) p.hand.splice(p.hand.indexOf(tile), 1);
780791 p.melds.push({ kind: 'ankong', tiles: [tile, tile, tile, tile] });
781792 this.say(`${p.name} 暗槓 concealed kong`);
782- this.sfx('kong', tile);
793+ this.sfx('kong', tile, seat);
783794 this.kongReplacement(seat);
784795 this.emit();
785796 }
⋯ 24 unchanged lines
810821 s.responses = {};
811822 s.phase = 'robkong';
812823 this.say(`${p.name} 加槓 ${labelOf(tile)} — 可搶槓 robbable`);
813- this.sfx('kong', tile);
814- this.sfx('claimWindow');
824+ this.sfx('kong', tile, seat);
825+ this.sfx('claimWindow', undefined, seat);
815826 } else {
816827 this.say(`${p.name} 加槓 added kong`);
817- this.sfx('kong', tile);
828+ this.sfx('kong', tile, seat);
818829 this.finishAddKong();
819830 }
820831 this.emit();
⋯ 139 unchanged lines
960971 this.say(`${s.players[r.seat].name} 胡牌 wins ${r.tai} 台`);
961972 if (r.liable !== null) this.say(`${s.players[r.liable].name} 包牌 pays it all`);
962973 }
963- this.sfx(results.some((r) => r.selfDraw) ? 'selfDraw' : 'hu', results[0].winTile);
974+ // 一炮多響 puts more than one name to it; the shout comes from the first,
975+ // which is the one the table is turned towards anyway.
976+ this.sfx(
977+ results.some((r) => r.selfDraw) ? 'selfDraw' : 'hu',
978+ results[0].winTile,
979+ results[0].seat,
980+ );
964981 this.emit();
965982 }
966983
⋯ 94 unchanged lines
modifiedsrc/game/prefs.ts+7 −0
⋯ 35 unchanged lines
3636 /** 報牌 — say the calls and the discarded tiles out loud. Needs `sound`. */
3737 voice: boolean;
3838 /**
39+ * 方位音 — put each seat's noise at that seat's edge of the table, so what
40+ * 上家 does comes from the left. Needs `sound`.
41+ */
42+ spatial: boolean;
43+ /**
3944 * Whether the player asked for the whole screen. Fullscreen itself never
4045 * survives a reload, so this is what re-arms it on the next tap — see
4146 * ui/fullscreen.ts. Not an audio setting: it is a choice, and it carries.
⋯ 13 unchanged lines
5560 sound: true,
5661 volume: 0.7,
5762 voice: true,
63+ spatial: true,
5864 fullscreen: false,
5965 lang: 'bi',
6066 names: DEFAULT_NAMES,
⋯ 11 unchanged lines
7278 sound: !stale && typeof p.sound === 'boolean' ? p.sound : DEFAULT_PREFS.sound,
7379 volume: clamp(typeof p.volume === 'number' ? p.volume : DEFAULT_PREFS.volume, 0, 1),
7480 voice: !stale && typeof p.voice === 'boolean' ? p.voice : DEFAULT_PREFS.voice,
81+ spatial: !stale && typeof p.spatial === 'boolean' ? p.spatial : DEFAULT_PREFS.spatial,
7582 fullscreen: typeof p.fullscreen === 'boolean' ? p.fullscreen : DEFAULT_PREFS.fullscreen,
7683 lang: LANGS.includes(p.lang as Lang) ? (p.lang as Lang) : DEFAULT_PREFS.lang,
7784 names:
⋯ 19 unchanged lines
modifiedsrc/game/sound.ts+214 −60
⋯ 28 unchanged lines
2929 master = ctx.createGain();
3030 master.gain.value = volume;
3131 master.connect(ctx.destination);
32+ // Whether this device is actually going to make a noise is something the
33+ // room wants to know (see `voiceLive`), and waking is when it changes.
34+ ctx.onstatechange = () => told();
3235 }
3336 // Chrome parks the context until a gesture, and again when the tab sleeps.
3437 if (ctx.state === 'suspended') void ctx.resume();
⋯ 10 unchanged lines
4548 return noise;
4649 }
4750
51+const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
52+
53+// ---- where it came from --------------------------------------------------
54+/**
55+ * 方位音. At a real table you know who called without looking up, because the
56+ * shout came from your left. A screen can do the same: everything a seat does
57+ * is panned to that seat's edge and dulled by how far across the felt it is,
58+ * so 碰 from 上家 and 碰 from 下家 are not the same sound.
59+ *
60+ * `pos` is a position *round this screen*, not a seat number — 0 the near edge
61+ * (yours), 1 the right, 2 across, 3 the left. Turning seats into positions is
62+ * the caller's job, because only it knows which way the table is facing: an
63+ * online table is rotated so your own seat is at the bottom, and the sound has
64+ * to turn with it.
65+ */
66+export type Pos = 0 | 1 | 2 | 3;
67+
68+export interface Place {
69+ /** Which edge it came from, as this screen has them laid out. */
70+ pos?: Pos | null;
71+ /** Straight left/right, −1..1, for a tile at a known spot on the felt. */
72+ pan?: number;
73+ /** 0 right under your nose … 1 the far edge. Quieter and duller with it. */
74+ far?: number;
75+}
76+
77+/** Pan and distance for each edge, heard from the near one. */
78+const EDGE: Record<Pos, { pan: number; far: number }> = {
79+ 0: { pan: 0, far: 0 },
80+ 1: { pan: 0.72, far: 0.5 },
81+ 2: { pan: 0, far: 1 },
82+ 3: { pan: -0.72, far: 0.5 },
83+};
84+
85+let spatial = true;
86+
87+export function setSpatial(on: boolean) {
88+ spatial = on;
89+}
90+
91+/** A little above the chimes: the chime is a nudge, the call is the message. */
92+const VOICE_GAIN = 1.4;
93+
94+/** One spot on the table: everything from there goes through the same nodes. */
95+interface Bus {
96+ /** Where chimes and clacks from this spot go in. */
97+ in: AudioNode;
98+ /** Where the voice does — the same place, a touch louder. */
99+ voice: GainNode;
100+}
101+const buses = new Map<string, Bus>();
102+
103+function busAt(c: AudioContext, pan: number, far: number): Bus {
104+ const key = `${pan.toFixed(2)}:${far.toFixed(2)}`;
105+ const had = buses.get(key);
106+ if (had) return had;
107+ // Distance is two things at once: quieter, and with the top taken off it.
108+ // Between you and the far edge there is a metre of air, a wall of tiles, and
109+ // three people's arms, and none of that carries 12 kHz.
110+ const g = c.createGain();
111+ g.gain.value = 1 - 0.32 * far;
112+ let head: AudioNode = g;
113+ if (far > 0.01) {
114+ const lp = c.createBiquadFilter();
115+ lp.type = 'lowpass';
116+ lp.frequency.value = 18000 - 13000 * far;
117+ lp.Q.value = 0.5;
118+ g.connect(lp);
119+ head = lp;
120+ }
121+ // Old Safari has no stereo panner; there it simply plays where it always did.
122+ const p = c.createStereoPanner?.();
123+ if (p) {
124+ p.pan.value = pan;
125+ head.connect(p).connect(master!);
126+ } else {
127+ head.connect(master!);
128+ }
129+ const voice = c.createGain();
130+ voice.gain.value = VOICE_GAIN;
131+ voice.connect(g);
132+ const bus = { in: g, voice };
133+ buses.set(key, bus);
134+ return bus;
135+}
136+
137+function busFor(c: AudioContext, place?: Place | null): Bus {
138+ if (!spatial || !place) return busAt(c, 0, 0);
139+ const e = place.pos === null || place.pos === undefined ? null : EDGE[place.pos];
140+ const pan = clamp(place.pan ?? e?.pan ?? 0, -1, 1);
141+ const far = clamp(place.far ?? e?.far ?? 0, 0, 1);
142+ // Quantised, so a pool full of skidding tiles reuses a handful of buses
143+ // rather than building a new one for every contact.
144+ return busAt(c, Math.round(pan * 10) / 10, Math.round(far * 4) / 4);
145+}
146+
48147 // ---- voice ---------------------------------------------------------------
49148 /**
50149 * 報牌 — the calls said out loud, the way they are shouted at a table: 碰, 吃,
⋯ 23 unchanged lines
74173 let voiceOn = false;
75174 let fetching: Promise<void> | null = null;
76175 let decoding = false;
77-let voiceGain: GainNode | null = null;
78176 let speaking: AudioBufferSourceNode[] = [];
79177
80178 /**
⋯ 44 unchanged lines
125223 decoding = true;
126224 for (const [name, bytes] of encoded) {
127225 c.decodeAudioData(bytes)
128- .then((buf) => clips.set(name, buf))
226+ .then((buf) => {
227+ clips.set(name, buf);
228+ told();
229+ })
129230 .catch(() => {});
130231 }
131232 encoded.clear(); // decodeAudioData takes ownership of the buffers
⋯ 2 unchanged lines
134235 export function setVoiceEnabled(on: boolean) {
135236 voiceOn = on;
136237 if (on) void fetchVoicePack();
238+ told();
137239 }
138240
139241 /**
⋯ 5 unchanged lines
145247 * going to say anything at all. That is what lets the table hold still while
146248 * somebody is speaking, and only for as long as they are.
147249 */
148-function say(...names: string[]): number {
250+function say(place: Place | null, ...names: string[]): number {
149251 const c = audio();
150252 if (!voiceOn || !enabled || !c || !master) return 0;
151253 if (!clips.size) {
⋯ 13 unchanged lines
165267 }
166268 speaking = [];
167269
168- if (!voiceGain) {
169- // A little above the chimes: the chime is a nudge, the call is the message.
170- voiceGain = c.createGain();
171- voiceGain.gain.value = 1.4;
172- voiceGain.connect(master);
173- }
174-
270+ const out = busFor(c, place).voice;
175271 const from = c.currentTime + 0.02;
176272 let at = from;
177273 for (const name of names) {
⋯ 1 unchanged line
179275 if (!buf) continue;
180276 const src = c.createBufferSource();
181277 src.buffer = buf;
182- src.connect(voiceGain);
278+ src.connect(out);
183279 src.start(at);
184280 speaking.push(src);
185281 at += buf.duration + 0.05;
⋯ 6 unchanged lines
192288 // Reach for the hardware only once the page has been interacted with —
193289 // sound is on from the start, and a context built before that is refused
194290 // anyway. When this runs from the click that toggled it, it unlocks there.
195- if (on && navigator.userActivation?.hasBeenActive !== false) {
196- const c = audio();
197- if (c) decodeVoicePack(c);
198- }
291+ if (on && navigator.userActivation?.hasBeenActive !== false) unlock();
292+ told();
199293 }
200294
201295 export function setVolume(v: number) {
⋯ 1 unchanged line
203297 if (master && ctx) master.gain.setTargetAtTime(v, ctx.currentTime, 0.01);
204298 }
205299
300+/**
301+ * Wake the hardware and decode the voice, from inside a gesture. Worth calling
302+ * on any tap on a device whose whole job is to talk — a party phone — because
303+ * until this has happened it cannot, and it is the room's business whether it
304+ * can. Cheap and idempotent after the first time.
305+ */
306+export function unlock() {
307+ if (!enabled) return;
308+ const c = audio();
309+ if (c) decodeVoicePack(c);
310+}
311+
312+/**
313+ * Whether this device would actually say something if it were asked to right
314+ * now: sound on, 報牌 on, the hardware awake, and the words decoded and ready.
315+ *
316+ * A party phone reports this to the table, which is what lets the table stop
317+ * saying that seat's calls and leave them to the person sitting there — and
318+ * start again the moment they mute their phone or it goes to sleep.
319+ */
320+export function voiceLive(): boolean {
321+ return enabled && voiceOn && !!ctx && ctx.state === 'running' && clips.size > 0;
322+}
323+
324+const watchers = new Set<() => void>();
325+/** Told when the answer to `voiceLive` might have changed. */
326+export function onAudioChange(fn: () => void): () => void {
327+ watchers.add(fn);
328+ return () => {
329+ watchers.delete(fn);
330+ };
331+}
332+function told() {
333+ for (const w of watchers) w();
334+}
335+
206336 /** Envelope helper: a tone that starts at `gain` and decays away over `dur`. */
207337 function tone(
208338 c: AudioContext,
⋯ 2 unchanged lines
211341 at: number;
212342 dur: number;
213343 gain: number;
344+ out: AudioNode;
214345 type?: OscillatorType;
215346 /** Slide to this frequency over the life of the note. */
216347 to?: number;
⋯ 8 unchanged lines
225356 g.gain.setValueAtTime(0.0001, t);
226357 g.gain.exponentialRampToValueAtTime(opts.gain, t + 0.004);
227358 g.gain.exponentialRampToValueAtTime(0.0001, t + opts.dur);
228- o.connect(g).connect(master!);
359+ o.connect(g).connect(opts.out);
229360 o.start(t);
230361 o.stop(t + opts.dur + 0.02);
231362 }
232363
233364 /** Bamboo-on-bamboo: a filtered noise burst with a short woody body under it. */
234-function clack(c: AudioContext, at: number, gain = 1) {
365+function clack(c: AudioContext, out: AudioNode, at: number, gain = 1) {
235366 const t = c.currentTime + at;
236367 const src = c.createBufferSource();
237368 src.buffer = noiseBuffer(c);
⋯ 5 unchanged lines
243374 const g = c.createGain();
244375 g.gain.setValueAtTime(0.55 * gain, t);
245376 g.gain.exponentialRampToValueAtTime(0.0001, t + 0.075);
246- src.connect(bp).connect(g).connect(master!);
377+ src.connect(bp).connect(g).connect(out);
247378 src.start(t, Math.random() * 0.5);
248379 src.stop(t + 0.1);
249- tone(c, { freq: 320, to: 160, at, dur: 0.09, gain: 0.28 * gain, type: 'triangle' });
380+ tone(c, { freq: 320, to: 160, at, dur: 0.09, gain: 0.28 * gain, type: 'triangle', out });
250381 }
251382
252383 /** Struck-metal chime: a fundamental with two inharmonic partials over it. */
253-function bell(c: AudioContext, freq: number, at: number, gain = 1, dur = 0.55) {
254- tone(c, { freq, at, dur, gain: 0.3 * gain, type: 'sine' });
255- tone(c, { freq: freq * 2.02, at, dur: dur * 0.7, gain: 0.13 * gain, type: 'sine' });
256- tone(c, { freq: freq * 3.01, at, dur: dur * 0.4, gain: 0.06 * gain, type: 'sine' });
384+function bell(c: AudioContext, out: AudioNode, freq: number, at: number, gain = 1, dur = 0.55) {
385+ tone(c, { freq, at, dur, gain: 0.3 * gain, type: 'sine', out });
386+ tone(c, { freq: freq * 2.02, at, dur: dur * 0.7, gain: 0.13 * gain, type: 'sine', out });
387+ tone(c, { freq: freq * 3.01, at, dur: dur * 0.4, gain: 0.06 * gain, type: 'sine', out });
257388 }
258389
259390 /**
⋯ 2 unchanged lines
262393 * thrown tile landing among the others, and only the physics knows when.
263394 *
264395 * `strength` is 0..1, straight off the impact, so a tile dropped in is a tick
265- * and one thrown hard is a crack.
396+ * and one thrown hard is a crack. `where` is where on the felt it happened,
397+ * which the physics also knows to the pixel — the middle is the one place a
398+ * sound has a real position rather than an edge it belongs to.
266399 */
267400 let lastKnock = 0;
268-export function knock(strength: number) {
401+export function knock(strength: number, where?: Place) {
269402 if (!enabled) return;
270403 const c = audio();
271404 if (!c || !master) return;
⋯ 1 unchanged line
273406 // tenth of a second, and hearing all of them is a rattle, not a table.
274407 if (c.currentTime - lastKnock < 0.045) return;
275408 lastKnock = c.currentTime;
276- clack(c, 0, 0.15 + strength * 0.6);
409+ clack(c, busFor(c, where).in, 0, 0.15 + strength * 0.6);
277410 }
278411
279412 /**
⋯ 2 unchanged lines
282415 * The middle stops where the four players' hands start, so a tile that reaches
283416 * the edge with any speed left has been thrown at whoever is sitting there —
284417 * and at a real table that gets you told. The clack is the tile arriving; the
285- * grumble under it is the reaction, and 報牌 puts words to it.
418+ * grumble under it is the reaction, and 報牌 puts words to it. Both come from
419+ * that seat's edge: it is their tiles being knocked about, and their voice.
286420 *
287421 * Only the physics knows this happened, so like `knock` it is not a
288422 * `SoundEvent`: the engine neither knows nor cares where a discard ended up.
⋯ 3 unchanged lines
292426 * about it is the whole thing landing on nobody.
293427 */
294428 let lastBarge = 0;
295-export function barge(strength: number): number {
429+export function barge(strength: number, where?: Place): number {
296430 if (!enabled) return 0;
297431 const c = audio();
298432 if (!c || !master) return 0;
⋯ 1 unchanged line
300434 if (c.currentTime - lastBarge < 1.6) return 0;
301435 lastBarge = c.currentTime;
302436
303- clack(c, 0, 0.5 + strength * 0.5);
437+ const out = busFor(c, where).in;
438+ clack(c, out, 0, 0.5 + strength * 0.5);
304439 // Two notes falling, which is what a grumble sounds like.
305- tone(c, { freq: 300, to: 190, at: 0.05, dur: 0.14, gain: 0.16, type: 'triangle' });
306- tone(c, { freq: 240, to: 150, at: 0.2, dur: 0.22, gain: 0.13, type: 'triangle' });
440+ tone(c, { freq: 300, to: 190, at: 0.05, dur: 0.14, gain: 0.16, type: 'triangle', out });
441+ tone(c, { freq: 240, to: 150, at: 0.2, dur: 0.22, gain: 0.13, type: 'triangle', out });
307442 // Never the same line twice running: the repeat is what makes a stock phrase
308443 // sound like a machine, and this one fires often enough for that to show.
309444 let pick = Math.floor(Math.random() * WATCH_IT.length);
310445 if (WATCH_IT[pick] === lastWords) pick = (pick + 1) % WATCH_IT.length;
311446 lastWords = WATCH_IT[pick];
312- return say(lastWords);
447+ return say(where ?? null, lastWords);
313448 }
314449
315450 /**
⋯ 14 unchanged lines
330465 let lastWords = '';
331466
332467 /**
468+ * Where a cue is to be played, and which halves of it this device is to play.
469+ *
470+ * The two halves come apart in party mode, where the table and four phones are
471+ * all in the same room: the table makes the noise, because the tiles are on
472+ * the table, and the phone in a player's hand says the words, because the
473+ * words are theirs. See `App.tsx` for who decides which.
474+ */
475+export interface Cast extends Place {
476+ /** Say the words. Off where somebody else's phone is saying them for us. */
477+ voice?: boolean;
478+ /** Make the table noise. Off on a phone, which is not the table. */
479+ noise?: boolean;
480+}
481+
482+/**
333483 * Each cue has its own shape so it is identifiable without looking up — the
334484 * whole reason for the feature is a player noticing across the table.
335485 */
336-export function play(cue: SoundCue | SoundEvent) {
486+export function play(cue: SoundCue | SoundEvent, cast: Cast = {}) {
337487 const { kind, tile } = typeof cue === 'string' ? { kind: cue, tile: undefined } : cue;
338488 if (!enabled) return;
339489
340490 // A discard is announced by name; a claim by what was called; a flower by
341491 // both, since "春" on its own tells you nothing about why you heard it.
342- if (kind === 'discard') {
343- if (tile !== undefined) say(`t${tile}`);
344- } else if (kind === 'flower' && tile !== undefined) {
345- say('flower', `t${tile}`);
346- } else if (CALL_CLIP[kind]) {
347- say(CALL_CLIP[kind]);
492+ if (cast.voice !== false) {
493+ if (kind === 'discard') {
494+ if (tile !== undefined) say(cast, `t${tile}`);
495+ } else if (kind === 'flower' && tile !== undefined) {
496+ say(cast, 'flower', `t${tile}`);
497+ } else if (CALL_CLIP[kind]) {
498+ say(cast, CALL_CLIP[kind]);
499+ }
348500 }
501+ if (cast.noise === false) return;
349502
350503 const c = audio();
351504 if (!c || !master) return;
505+ const out = busFor(c, cast).in;
352506
353507 switch (kind) {
354508 // A tile laid down. Dry and short so it never masks the chime after it.
355509 case 'discard':
356- clack(c, 0);
510+ clack(c, out, 0);
357511 break;
358512 // Somebody's claim window is open. Rising two-note bell, easy to hear over
359513 // talk, and deliberately unlike the clack that precedes it.
360514 case 'claimWindow':
361- bell(c, 784, 0.06, 1, 0.4); // G5
362- bell(c, 1047, 0.19, 0.9, 0.55); // C6
515+ bell(c, out, 784, 0.06, 1, 0.4); // G5
516+ bell(c, out, 1047, 0.19, 0.9, 0.55); // C6
363517 break;
364518 // 碰 / 吃 — the tile going down alongside the pair it joins.
365519 case 'pung':
366520 case 'chow':
367- clack(c, 0, 0.8);
368- clack(c, 0.055, 0.9);
369- bell(c, kind === 'pung' ? 660 : 587, 0.02, 0.5, 0.3);
521+ clack(c, out, 0, 0.8);
522+ clack(c, out, 0.055, 0.9);
523+ bell(c, out, kind === 'pung' ? 660 : 587, 0.02, 0.5, 0.3);
370524 break;
371525 // Four tiles going down, plus a replacement off the back of the wall.
372526 case 'kong':
373- clack(c, 0, 0.7);
374- clack(c, 0.05, 0.8);
375- clack(c, 0.1, 0.9);
376- clack(c, 0.15, 1);
377- bell(c, 523, 0.16, 0.55, 0.5);
527+ clack(c, out, 0, 0.7);
528+ clack(c, out, 0.05, 0.8);
529+ clack(c, out, 0.1, 0.9);
530+ clack(c, out, 0.15, 1);
531+ bell(c, out, 523, 0.16, 0.55, 0.5);
378532 break;
379533 case 'flower':
380- bell(c, 1319, 0, 0.45, 0.35);
534+ bell(c, out, 1319, 0, 0.45, 0.35);
381535 break;
382536 // 胡牌 — a rising figure, the only cue that takes its time.
383537 case 'hu':
384538 case 'selfDraw':
385- bell(c, 523, 0, 1, 0.5);
386- bell(c, 659, 0.11, 1, 0.5);
387- bell(c, 784, 0.22, 1, 0.6);
388- bell(c, 1047, 0.33, 1.1, 1.1);
539+ bell(c, out, 523, 0, 1, 0.5);
540+ bell(c, out, 659, 0.11, 1, 0.5);
541+ bell(c, out, 784, 0.22, 1, 0.6);
542+ bell(c, out, 1047, 0.33, 1.1, 1.1);
389543 break;
390544 // 流局 — the same figure, falling and dulled.
391545 case 'drawGame':
392- bell(c, 523, 0, 0.7, 0.5);
393- bell(c, 415, 0.13, 0.7, 0.6);
394- bell(c, 330, 0.26, 0.7, 0.9);
546+ bell(c, out, 523, 0, 0.7, 0.5);
547+ bell(c, out, 415, 0.13, 0.7, 0.6);
548+ bell(c, out, 330, 0.26, 0.7, 0.9);
395549 break;
396550 // Sixteen tiles apiece, so a scatter rather than a beat.
397551 case 'deal':
398- for (let i = 0; i < 9; i++) clack(c, i * 0.045 + Math.random() * 0.02, 0.4);
552+ for (let i = 0; i < 9; i++) clack(c, out, i * 0.045 + Math.random() * 0.02, 0.4);
399553 break;
400554 case 'undo':
401- tone(c, { freq: 700, to: 330, at: 0, dur: 0.22, gain: 0.18, type: 'triangle' });
555+ tone(c, { freq: 700, to: 330, at: 0, dur: 0.22, gain: 0.18, type: 'triangle', out });
402556 break;
403557 }
404558 }
modifiedsrc/game/wall.ts+11 −5
⋯ 123 unchanged lines
124124 }
125125
126126 /**
127- * How big to build the square, given the room across the table it has to fit.
127+ * How big the square comes out, given the tile it is built from.
128128 *
129129 * Four walls of eighteen stacks, laid down square and nothing turned. Each is
130130 * pinned at the corner it is built from and runs its whole length from there,
⋯ 2 unchanged lines
133133 * is why the corners are covered rather than mitred.
134134 *
135135 * So the whole thing measures a wall's length *plus* one depth across, and
136- * leaves that length *less* one depth in the middle. Solved the other way here:
137- * the room is what there is, and the tile is what gives.
136+ * leaves that length *less* one depth in the middle.
137+ *
138+ * The tile is the given, and the square is whatever that comes to. It used to
139+ * be solved the other way about — the room across the table was the given and
140+ * the tile was what gave — which bought a square that fitted at the price of
141+ * the one thing about a wall tile that is not negotiable: it is the tile you
142+ * are playing with. A tile that changed size between the wall and your hand
143+ * read as a different, smaller set of tiles sitting in the middle. `--tile-w`
144+ * in styles.css is the one size, and this follows it.
138145 */
139-export function wallSquare(room: number, ratio: number): WallSquare {
140- const cell = Math.max(1, room / (STACKS_PER_SIDE + ratio));
146+export function wallSquare(cell: number, ratio: number): WallSquare {
141147 const len = cell * STACKS_PER_SIDE;
142148 const thick = cell * ratio;
143149 return { cell, len, thick, open: len - thick, outer: len + thick };
⋯ 123 unchanged lines
modifiedsrc/net/protocol.ts+21 −1
⋯ 37 unchanged lines
3838 seats: SeatSlot[];
3939 /** The whole engine state. null while the room is still in its lobby. */
4040 game: GameState | null;
41+ /**
42+ * Which seats have a device on them that will say their calls out loud —
43+ * a phone in somebody's hand, with sound and 報牌 on and the hardware awake.
44+ *
45+ * The table reads this and shuts up for those seats: at a party table all
46+ * five devices are in the same room, so the 碰 should come out of the hand
47+ * of the person calling it and not out of the laptop in the middle. Held by
48+ * the host, which is the only thing that hears every phone.
49+ */
50+ voices: boolean[];
4151 }
4252
4353 /** Something a player asks the host to play for their seat. */
⋯ 23 unchanged lines
6777 * tile flying in from that player's edge.
6878 */
6979 export type FxMsg =
70- | { fx: 'sfx'; kind: string; tile?: Tile }
80+ | { fx: 'sfx'; kind: string; tile?: Tile; seat?: SeatId }
7181 | { fx: 'throw'; seat: SeatId; t: NetThrow };
7282
83+/**
84+ * A device telling the host whether it can talk. Sent whenever the answer
85+ * changes — a mute, a 報牌 toggle, a phone waking its audio on the first tap,
86+ * a tab going to sleep — and once on arrival.
87+ */
88+export interface VoiceMsg {
89+ ready: boolean;
90+}
91+
7392 export const EV_ACT = 'act';
7493 export const EV_SEAT = 'seat';
7594 export const EV_FX = 'fx';
95+export const EV_VOICE = 'voice';
7696
7797 export const EMPTY_SEATS: SeatSlot[] = [
7898 { ids: [], name: '' },
⋯ 54 unchanged lines
modifiedsrc/net/session.ts+84 −3
⋯ 7 unchanged lines
88 EV_ACT,
99 EV_FX,
1010 EV_SEAT,
11+ EV_VOICE,
1112 type ActMsg,
1213 type FxMsg,
1314 type NetMode,
1415 type SeatMsg,
1516 roomFromUrl,
1617 type SeatSlot,
18+ type VoiceMsg,
1719 } from './protocol';
1820 import { joinRoom, type Room } from './room';
1921 import { NetTable } from './table';
⋯ 29 unchanged lines
4951
5052 private autoplay: AutoPlay | null = null;
5153 private stopAutoplay: (() => void) | null = null;
54+ /** Who in the room has said they can talk. Kept by the host; see `voices`. */
55+ private talkers = new Map<string, boolean>();
56+ /** The last thing we told the host about ourselves, so we only say changes. */
57+ private toldVoice: boolean | null = null;
58+ /** Whether *we* can talk, kept so a new host can be told the same thing. */
59+ private myVoice = false;
5260 private busy: () => boolean = () => false;
5361 private listeners = new Set<() => void>();
5462 private version = 0;
⋯ 19 unchanged lines
7482 room.on(EV_ACT, (p, from) => this.handleAct(p as unknown as ActMsg, from)),
7583 room.on(EV_SEAT, (p, from) => this.handleSeat(p as unknown as SeatMsg, from)),
7684 room.on(EV_FX, (p) => this.handleFx(p as unknown as FxMsg)),
85+ room.on(EV_VOICE, (p, from) => this.handleVoice(p as unknown as VoiceMsg, from)),
7786 room.onJoin(() => this.bump()),
7887 room.onLeave((p) => {
79- if (this.isHost) this.vacate(p.id);
88+ if (this.isHost) {
89+ this.talkers.delete(p.id);
90+ this.vacate(p.id);
91+ this.publishVoices();
92+ }
8093 this.bump();
8194 }),
8295 room.onHostChange(() => {
⋯ 1 unchanged line
8497 // start acting like the host, which `syncAutoplay` and the guards on
8598 // every handler do by asking `isHost` fresh each time.
8699 this.syncAutoplay();
100+ // What it does not inherit is who told the old host they could talk,
101+ // so everybody says it again.
102+ this.toldVoice = null;
103+ this.reportVoice(this.myVoice);
87104 this.bump();
88105 }),
89106 room.onError((e) => {
⋯ 8 unchanged lines
98115 this.table.engine.onSound((cue) => {
99116 if (!this.isHost) return;
100117 this.table.emitSound(cue);
101- this.room.send(EV_FX, { fx: 'sfx', kind: cue.kind, tile: cue.tile } as never);
118+ this.room.send(EV_FX, {
119+ fx: 'sfx',
120+ kind: cue.kind,
121+ tile: cue.tile,
122+ seat: cue.seat,
123+ } as never);
102124 }),
103125 );
104126 // Mirror whatever the room already holds — a late joiner walks in on the
⋯ 94 unchanged lines
199221 mode: 'online',
200222 seats: structuredClone(EMPTY_SEATS) as never,
201223 homes: ['', '', '', ''] as never,
224+ voices: [false, false, false, false] as never,
202225 });
203226 this.claimSeat(0);
204227 }
⋯ 25 unchanged lines
230253 mode: 'party',
231254 seats: seats as never,
232255 homes: seats.map((s) => s.name) as never,
256+ voices: [false, false, false, false] as never,
233257 });
234258 const eng = this.table.engine;
235259 eng.applySettings(names, rules);
⋯ 78 unchanged lines
314338 }
315339 }
316340 this.room.setState({ seats: seats as never });
341+ // A hand that changed hands changed who speaks for it.
342+ this.publishVoices();
317343 }
318344
319345 private handleAct(msg: ActMsg, from: string) {
⋯ 43 unchanged lines
363389 private handleFx(msg: FxMsg) {
364390 if (this.isHost || !msg) return;
365391 if (msg.fx === 'sfx') {
366- this.table.emitSound({ kind: msg.kind as SoundEvent, tile: msg.tile });
392+ this.table.emitSound({ kind: msg.kind as SoundEvent, tile: msg.tile, seat: msg.seat });
367393 } else if (msg.fx === 'throw') {
368394 this.onThrowFx(msg.seat, msg.t);
369395 }
370396 }
371397
398+ // ---- who is going to say it -------------------------------------------
399+ /**
400+ * Tell the host whether this device would actually say a call out loud if
401+ * one arrived. Called from wherever the answer can change — the mute button,
402+ * the settings panel, the first tap that wakes a phone's audio. Repeats are
403+ * dropped: the wire only carries changes.
404+ */
405+ reportVoice(ready: boolean) {
406+ this.myVoice = ready;
407+ if (ready === this.toldVoice) return;
408+ this.toldVoice = ready;
409+ if (this.isHost) this.handleVoice({ ready }, this.room.me.id);
410+ else this.room.send(EV_VOICE, { ready } as never);
411+ }
412+
413+ private handleVoice(msg: VoiceMsg, from: string) {
414+ if (!this.isHost || !msg) return;
415+ const ready = msg.ready === true;
416+ if (this.talkers.get(from) === ready) return;
417+ this.talkers.set(from, ready);
418+ this.publishVoices();
419+ }
420+
421+ /**
422+ * Work out, seat by seat, whether somebody's own device is going to speak
423+ * for it, and publish that for every screen in the room.
424+ *
425+ * Only a seat *held* by a phone counts: the seats the common screen is still
426+ * playing hotseat-style have no voice of their own, and the table goes on
427+ * saying those. The host's own device is excluded even when it holds a seat
428+ * on the party screen, because it is the table — if it stopped saying a call
429+ * on the grounds that it was going to say it, nobody would say it at all.
430+ */
431+ private publishVoices() {
432+ if (!this.isHost) return;
433+ const screen = this.mode === 'party' ? this.room.me.id : '';
434+ const voices = SEATS.map((i) =>
435+ this.seats[i].ids.some((id) => id !== screen && this.talkers.get(id) === true),
436+ );
437+ const was = this.room.state.voices;
438+ if (Array.isArray(was) && was.length === 4 && voices.every((v, i) => was[i] === v)) return;
439+ this.room.setState({ voices: voices as never });
440+ this.bump();
441+ }
442+
443+ /**
444+ * Which seats are speaking for themselves, as the host last worked it out.
445+ * A table that knows this stays quiet for those seats and lets the calls come
446+ * from the phones instead — see `App.tsx`.
447+ */
448+ get voiceSeats(): boolean[] {
449+ const v = this.room.state.voices;
450+ return Array.isArray(v) && v.length === 4 ? (v as boolean[]) : [false, false, false, false];
451+ }
452+
372453 private publishGame() {
373454 // The engine mutates its state in place; the room keeps what it is handed,
374455 // so it gets a copy that will hold still.
⋯ 16 unchanged lines
modifiedsrc/styles.css+22 −5
⋯ 684 unchanged lines
685685 `.wall-ring` is the opening, not the outside: the walls hang off its edges,
686686 so the square encloses the pool the way it does on a table. */
687687 .wall-ring {
688- /* A stack's short side. Solved in ui/WallRing off the tight way across the
689- *table* — its height — so that eighteen stacks fit it, rather than off the
690- room the four strips leave in the middle, which a real wall never fitted
691- and which had the tile down to under half a hand's. The fallback here is
692- only for the frame before the first measure. */
688+ /* A stack's short side: the table's tile, and nothing else. Every tile on the
689+ table is one size — in a hand, in the wall, and lying in the middle — so
690+ this is not a size the wall gets to have an opinion about. ui/WallRing
691+ measures what it came out as and says how long a wall of eighteen of them
692+ is (`--wall-len`); it does not decide either of them. */
693693 --ws: var(--tile-w);
694694 --wd: calc(var(--ws) * 1.375);
695695 /* A wall, end to end: eighteen stacks, set from ui/WallRing. It is longer
⋯ 1455 unchanged lines
21512151 user-select: none;
21522152 touch-action: none;
21532153 }
2154+/* This hand is calling for itself, out of the phone holding it. Sits next to
2155+ the 📱 tag and reads as part of it, which is what it is. */
2156+.nameplate .tag.voice {
2157+ background: rgba(29, 90, 168, 0.2);
2158+ border-color: rgba(29, 90, 168, 0.55);
2159+ padding-inline: 5px;
2160+}
21542161
21552162 /* ---------- the phone controller ---------- */
21562163 /* A hand held in landscape: status, buttons, tiles, in that order down the
⋯ 180 unchanged lines
23372344 opacity: 0.65;
23382345 margin-top: 2px;
23392346 }
2347+/* Under the seats: the phone's own mute and the fullscreen button. Turning
2348+ sound on here is also what wakes this phone's audio, which is what lets it
2349+ do the calling for whichever seat it ends up on. */
2350+.pick-extras {
2351+ display: flex;
2352+ gap: 12px;
2353+ align-items: center;
2354+ justify-content: center;
2355+ flex-wrap: wrap;
2356+}
23402357
23412358 /* The dice tool. Dev builds only — `import.meta.env.DEV` in ui/Center — so it
23422359 is styled for being useful rather than for being lived with: bottom left, out
⋯ 92 unchanged lines
modifiedsrc/table/physics.test.ts+14 −10
⋯ 616 unchanged lines
617617 expect(2 * (RING.h + RING.v) * 2).toBe(144);
618618 });
619619
620- it('fits the room it was given, walls and all', () => {
621- const s = wallSquare(765, RATIO);
622- expect(s.outer).toBeCloseTo(765, 6);
623- expect(s.len).toBeCloseTo(s.cell * SIDE, 6);
624- expect(s.thick).toBeCloseTo(s.cell * RATIO, 6);
620+ it('is built from the tile it was given, and nothing else decides it', () => {
621+ const s = wallSquare(40, RATIO);
622+ // The tile is the given. Eighteen stacks a side, a stack deep, and the
623+ // whole thing is a wall's length plus one depth across.
624+ expect(s.cell).toBe(40);
625+ expect(s.len).toBeCloseTo(40 * SIDE, 6);
626+ expect(s.thick).toBeCloseTo(40 * RATIO, 6);
627+ expect(s.outer).toBeCloseTo(40 * (SIDE + RATIO), 6);
625628 });
626629
627630 it('closes every corner with the overhang, and leaves the rest as the opening', () => {
628- const s = wallSquare(600, RATIO);
631+ const s = wallSquare(33, RATIO);
629632 // A wall runs its whole length from the corner it is built from, which
630633 // carries it one depth past the far one and over the end of the next.
631634 expect(s.outer - s.open).toBeCloseTo(2 * s.thick, 6);
⋯ 3 unchanged lines
635638 expect(s.outer * s.outer - s.open * s.open).toBeCloseTo(4 * s.len * s.thick, 6);
636639 });
637640
638- it('scales cleanly, so a bigger table is the same square drawn bigger', () => {
639- const one = wallSquare(100, RATIO);
640- const ten = wallSquare(1000, RATIO);
641- expect(ten.cell).toBeCloseTo(one.cell * 10, 6);
641+ it('scales cleanly, so a bigger tile is the same square drawn bigger', () => {
642+ const one = wallSquare(10, RATIO);
643+ const ten = wallSquare(100, RATIO);
644+ expect(ten.len).toBeCloseTo(one.len * 10, 6);
642645 expect(ten.open).toBeCloseTo(one.open * 10, 6);
646+ expect(ten.outer).toBeCloseTo(one.outer * 10, 6);
643647 });
644648 });
645649
⋯ 130 unchanged lines
modifiedsrc/ui/Controller.tsx+20 −6
⋯ 8 unchanged lines
99 import { useLang, useT } from './lang';
1010 import { FullscreenToggle } from './FullscreenToggle';
1111 import { Hand, type Throw } from './Hand';
12+import { SoundToggle } from './SoundToggle';
1213 import { TileView } from './TileView';
1314
1415 const SEATS: SeatId[] = [0, 1, 2, 3];
⋯ 8 unchanged lines
2324
2425 /**
2526 * The phone at the party table: just your own tiles, held in landscape, with
26- * the common screen doing all the talking. What it keeps of the full table is
27- * the two things that are yours — the buttons a claim window owes you, and the
28- * throw. Flick a tile up off the top of the phone and it leaves your hand
29- * here, crosses the wire, and comes flying in from your edge of the shared
30- * screen.
27+ * the common screen doing the table's share of the talking. What it keeps of
28+ * the full table is the things that are yours — the buttons a claim window
29+ * owes you, the throw, and your voice. Flick a tile up off the top of the
30+ * phone and it leaves your hand here, crosses the wire, and comes flying in
31+ * from your edge of the shared screen.
32+ *
33+ * Your voice, because the five devices at a party table are all in the same
34+ * room: with sound on, this phone is what says 碰 when you call one, and the
35+ * common screen stops saying it for you. Mute the phone and the table takes
36+ * the job back — see `net/session.ts` for how it is told.
3137 */
3238 export function Controller({ session, prefs, onPrefs }: CtlProps) {
3339 const seat = session.mySeat;
⋯ 45 unchanged lines
7985 );
8086 })}
8187 </div>
82- <FullscreenToggle prefs={prefs} onPrefs={onPrefs} />
88+ <div className="pick-extras">
89+ {/* Turning it on here is also the tap that wakes the phone's audio, so
90+ it can start doing the talking for this seat straight away. */}
91+ <SoundToggle prefs={prefs} onPrefs={onPrefs} />
92+ <FullscreenToggle prefs={prefs} onPrefs={onPrefs} />
93+ </div>
8394 </div>
8495 );
8596 }
⋯ 98 unchanged lines
184195 <button className="btn ghost sort" onClick={() => table.sortHand(seat)}>
185196 {t('理牌', 'Sort')}
186197 </button>
198+ {/* This phone's own mute — and, at a party table, whether your calls
199+ come out of your hand or out of the screen in the middle. */}
200+ <SoundToggle prefs={prefs} onPrefs={onPrefs} compact />
187201 <FullscreenToggle prefs={prefs} onPrefs={onPrefs} compact />
188202 </div>
189203
⋯ 116 unchanged lines
modifiedsrc/ui/Pool.tsx+41 −9
11 import { useEffect, useRef, useState } from 'react';
22 import type { TableCtl } from '../game/ctl';
3-import { barge, knock } from '../game/sound';
4-import { aabbOf, centreLimits, faceOn, type Rect } from '../table/physics';
3+import { barge, knock, type Place } from '../game/sound';
4+import { aabbOf, centreLimits, faceOn, type Impact, type Rect } from '../table/physics';
55 import type { TablePool } from '../table/pool';
66 import type { SeatId } from '../game/types';
77 import {
⋯ 97 unchanged lines
105105 noteImpacts(impacts);
106106 if (pausingOnHit() && !isHeld() && impacts.length) holdOn(impacts);
107107 let loudest = 0;
108+ let loudAt: Impact | null = null;
108109 for (const i of impacts) {
109110 // A contact quiet enough not to be worth hearing still counts as a tile
110111 // arriving in somebody's lap, which is why the two thresholds differ.
111- if (i.strength > AUDIBLE) loudest = Math.max(loudest, i.strength);
112+ if (i.strength > AUDIBLE && i.strength > loudest) {
113+ loudest = i.strength;
114+ loudAt = i;
115+ }
112116 // Straight into somebody's tiles — the wall square is nobody's, so this
113117 // is a hand or a set that has been laid down.
114- if (i.into !== undefined) shove(pool, table, i.into, i.seat, i.strength);
115- else if (i.edge) {
116- const at = ((EDGE_SEAT[i.edge] + live.current.viewSeat) % 4) as SeatId;
117- shove(pool, table, at, i.seat, i.strength);
118+ if (i.into !== undefined) {
119+ shove(pool, table, i.into, i.seat, i.strength, posOf(i.into, live.current.viewSeat));
120+ } else if (i.edge) {
121+ // An edge is already stated in this screen's own terms, which is what
122+ // the sound wants; the seat sitting at it is the round trip back.
123+ const pos = EDGE_SEAT[i.edge] as SeatId;
124+ const at = ((pos + live.current.viewSeat) % 4) as SeatId;
125+ shove(pool, table, at, i.seat, i.strength, pos);
118126 }
119127 }
120- if (loudest > 0) knock(loudest);
128+ // Out in the middle a sound has a real position rather than an edge it
129+ // belongs to, and the physics knows it to the pixel.
130+ if (loudest > 0) knock(loudest, spotOf(pool, loudAt!));
121131 paint(ctx, canvas, pool, live.current.art);
122132
123133 // Keep going while anything is moving; otherwise wait to be poked. A held
⋯ 140 unchanged lines
264274 * which is the other half of it: the table waits at least this long. */
265275 const SHAKE = 0.34;
266276
277+/** A seat, as an edge of the table this screen is drawing. */
278+const posOf = (seat: SeatId, viewSeat: SeatId): SeatId => (((seat - viewSeat + 4) % 4) as SeatId);
279+
267280 /**
281+ * Where on the felt a contact happened, in the terms the sound wants: how far
282+ * left or right of the middle, and how far up the table — the far half of the
283+ * pool is over by 對家 and sounds like it. The felt is drawn in screen
284+ * coordinates, so this needs no turning: it is already the way you are facing.
285+ */
286+function spotOf(pool: TablePool, at: Impact): Place {
287+ const b = pool.world?.bounds;
288+ if (!b) return {};
289+ const pan = Math.max(-1, Math.min(1, (at.x - (b.x + b.w / 2)) / (b.w / 2)));
290+ // Only the half of the table away from you counts as distance; the near half
291+ // is all within reach, and dulling it would just sound like a blanket.
292+ const far = Math.max(0, Math.min(1, (b.y + b.h / 2 - at.y) / (b.h / 2)));
293+ return { pan: pan * 0.8, far: far * 0.55 };
294+}
295+
296+/**
268297 * A tile has ended up in this seat's tiles — knocked into them, or simply come
269298 * to a stop off the table on their side. Either way it is theirs to complain
270299 * about, and how hard it arrived only decides how loudly.
⋯ 10 unchanged lines
281310 seat: SeatId,
282311 thrower: SeatId | undefined,
283312 strength: number,
313+ pos: SeatId,
284314 ) {
285315 if (seat === thrower) return;
286316 // Hold the table while it is being said and while the row is still rocking.
287317 // The computer plays on a timer of its own, and a 碰 called over the top of
288318 // somebody objecting to the tile lands the objection on nobody.
289- pool.heckle(Math.max(SHAKE, barge(strength)));
319+ // It comes from their edge: they are the one whose tiles were scattered, and
320+ // it is their voice complaining about it.
321+ pool.heckle(Math.max(SHAKE, barge(strength, { pos })));
290322 const hand = table.querySelector<HTMLElement>(`[data-seat="${seat}"] .hand`);
291323 if (!hand || hand.classList.contains('shoved')) return;
292324 hand.classList.add('shoved');
⋯ 197 unchanged lines
modifiedsrc/ui/Seat.tsx+15 −0
⋯ 22 unchanged lines
2323 onPeek?: (show: boolean) => void;
2424 /** Online host: the seat's player is gone; offer it to the computer. */
2525 onHandToBot?: () => void;
26+ /**
27+ * Party table: this seat's phone has its sound on, so the calls for this
28+ * hand are coming out of it rather than out of this screen. Worth saying on
29+ * the nameplate, because it is the difference between the table having gone
30+ * quiet for a seat and having gone wrong.
31+ */
32+ speaks?: boolean;
2633 }
2734
2835 interface Props {
⋯ 312 unchanged lines
341348 📱 {remote.holder}
342349 </span>
343350 )}
351+ {remote?.speaks && (
352+ <span
353+ className="tag voice"
354+ title={t('喊牌從這支手機出來', 'this hand calls from its own phone', ' · ')}
355+ >
356+ 🔊
357+ </span>
358+ )}
344359 {deserted && <span className="tag sacred">{t('已離線', 'offline')}</span>}
345360 {isDealer && (
346361 <span className="tag">
⋯ 224 unchanged lines
modifiedsrc/ui/Settings.tsx+24 −1
11 import { useState } from 'react';
22 import { clamp, type Prefs } from '../game/prefs';
3-import { play, setSoundEnabled, setVoiceEnabled, setVolume } from '../game/sound';
3+import { play, setSoundEnabled, setSpatial, setVoiceEnabled, setVolume } from '../game/sound';
44 import { DEFAULT_NAMES, type Rules } from '../game/types';
55 import { BiEm, BiText } from './Bi';
66 import { useT } from './lang';
⋯ 236 unchanged lines
243243 />
244244 </span>
245245 </label>
246+ <label className={`switch ${draft.sound ? '' : 'dead'}`}>
247+ <input
248+ type="checkbox"
249+ checked={draft.spatial}
250+ disabled={!draft.sound}
251+ onChange={(e) => {
252+ setSpatial(e.target.checked);
253+ setDraft({ ...draft, spatial: e.target.checked });
254+ // From 上家's edge, which is the left one — the whole point
255+ // is that you can tell whose it was without looking.
256+ if (e.target.checked) play({ kind: 'pung' }, { pos: 3 });
257+ }}
258+ />
259+ <span className="sw-name">
260+ <BiEm zh="方位音" en="Sound by seat" />
261+ </span>
262+ <span className="note">
263+ <BiText
264+ zh="每一家的聲音從那一家的方位出來 — 上家在左,下家在右,對家遠而悶。"
265+ en="Each seat's noise comes from that seat's edge — 上家 to your left, 下家 to your right, and 對家 far off across the table."
266+ />
267+ </span>
268+ </label>
246269 <label className="field">
247270 <span className="f-label">
248271 <BiEm zh="音量" en="Volume" />
⋯ 22 unchanged lines
modifiedsrc/ui/WallRing.tsx+25 −30
⋯ 65 unchanged lines
6666 aria-label={t('牌牆', 'The wall')}
6767 style={
6868 square
69- ? // `--ws` is set from here rather than from the tile, because the
70- // square is the fixed thing now and the tile is what gives.
69+ ? // `--ws` is not set from here: the tile is the table's, one size for
70+ // every tile on it, and the square is whatever that comes to.
7171 ({
7272 width: square.open,
7373 height: square.open,
74- '--ws': `${square.cell}px`,
7574 // A wall is its stacks and nothing else, so there is no sum for
7675 // the corners to come out of by a pixel.
7776 '--wall-len': `${square.len}px`,
⋯ 138 unchanged lines
216215 const round = (v: number) => Math.round(v * 100) / 100;
217216
218217 /**
219- * How big to build the square, measured rather than worked out twice: how big a
220- * tile is is CSS's business, so one cell is rendered and asked how it came out,
221- * and `wallSquare` solves for the rest. Nothing here depends on the state of the
222- * hand — the square is built once for the window it is in and then eaten, so it
223- * does not close in on the discards lying inside it.
218+ * How big the square came out, measured rather than worked out twice.
219+ *
220+ * How big a tile is is CSS's business — `--tile-w` in styles.css, one size for
221+ * every tile on the table — so one cell is rendered and asked how it came out,
222+ * and `wallSquare` says what a square of eighteen of them measures. Nothing
223+ * here decides the tile any more, which is the whole point: a wall tile is a
224+ * hand tile, and a square built from them is however big it is.
225+ *
226+ * Nothing here depends on the state of the hand either — the square is built
227+ * once for the window it is in and then eaten, so it does not close in on the
228+ * discards lying inside it.
224229 */
225230 function useSquare(ring: React.RefObject<HTMLDivElement | null>): WallSquare | null {
226231 const [square, setSquare] = useState<WallSquare | null>(null);
⋯ 4 unchanged lines
231236 const probe = el?.querySelector<HTMLElement>('.wall-probe');
232237 if (!el || !middle || !probe) return;
233238
234- // Everything the square is worked out *from*. The observer below can be set
235- // off by this effect's own output — the ring's size is what it writes — so
236- // the guard is not a nicety: without it, one recompute schedules the next
237- // and the tab stops answering. Same inputs, nothing to do.
239+ // Same answer, nothing to do. Cheap, and it keeps a resize that did not
240+ // change the tile from putting a fresh object into state every frame.
238241 let last = '';
239242
240243 const read = () => {
241244 const box = probe.getBoundingClientRect();
242245 if (box.width <= 0) return;
243- // What shape a stack is, is CSS's business — read it, don't restate it.
244- // Scale-free, so this is the same number however big the square came out.
245- const ratio = box.height / box.width;
246-
247- // Sized off the tight way across the table, not the room between the four
248- // strips. Height is what a table is short of — 765 against 1396 — so that
249- // is what the square has to fit, and the tile is whatever that comes to.
250- // It is the whole table because the square is bigger than the middle and
251- // always was; where it goes from here is a separate question.
252- const fit = (el.closest('.table') as HTMLElement | null) ?? middle;
253- const room = fit.clientHeight;
254- const from = `${Math.round(room)}/${ratio.toFixed(3)}`;
246+ // What size and shape a stack is, is CSS's business — read it, don't
247+ // restate it.
248+ const from = `${box.width.toFixed(2)}/${box.height.toFixed(2)}`;
255249 if (from === last) return;
256250 last = from;
257- setSquare(wallSquare(room, ratio));
251+ setSquare(wallSquare(box.width, box.height / box.width));
258252 };
259253
260254 read();
261- // The table, not the square: the square's own size is this effect's doing,
262- // and watching it would be watching itself. The probe is not watched for
263- // the same reason — it is `--ws` wearing a box, and `--ws` is the answer.
264- const watch = (el.closest('.table') as HTMLElement | null) ?? middle;
255+ // The probe *is* the tile, so watching it is watching the one input. Safe
256+ // to watch now: this hook no longer writes `--ws`, so nothing it does can
257+ // change the probe's size and set itself off again.
265258 const observer = new ResizeObserver(read);
266- observer.observe(watch);
259+ observer.observe(probe);
260+ const table = el.closest('.table') as HTMLElement | null;
261+ if (table) observer.observe(table);
267262 return () => observer.disconnect();
268263 }, [ring]);
269264
⋯ 2 unchanged lines