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 | |||
| 46 | 46 | hand once you discard. 補花 replacements and kong replacements are marked the | |
| 47 | 47 | same way. | |
| 48 | 48 | - **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). | |
| 57 | 58 | - **Discard** — drag a tile out of your row and it comes up out of your hand and | |
| 58 | 59 | follows your finger anywhere on the table; let go and it goes in, from where | |
| 59 | 60 | you let go and at the speed you let go at. Dragging *along* the row is still | |
| ⋯ 44 unchanged lines | |||
| 104 | 105 | what not pressing anything already does. | |
| 105 | 106 | - **On your turn** — 自摸, 暗槓 and 加槓 appear automatically when legal. | |
| 106 | 107 | 加槓 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. | |
| 112 | 119 | - **When a hand ends** — a big arrow drops onto the winner's edge of the table. | |
| 113 | 120 | It lives inside their strip, so it turns with the seat and lands on the right | |
| 114 | 121 | person wherever they are sitting, and 一炮多響 lights up every seat that | |
| 115 | 122 | called. A 流局 has no winner to point at, so it turns the hands over instead: | |
| 116 | 123 | who was 聽牌 and the tiles they were waiting on, the same reveal as at a real | |
| 117 | 124 | 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. | |
| 122 | 135 | Snapshots live outside the saved state, so an undo does not survive a | |
| 123 | 136 | refresh: what you can take back is what happened while everyone was still | |
| 124 | 137 | 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 | |
| 127 | 140 | two-note chime when a claim window opens, which is how a slow player notices | |
| 128 | 141 | their 碰 is available before the next player draws it shut. Everything is | |
| 129 | 142 | synthesised with a few oscillators (`src/game/sound.ts`) rather than sampled, | |
| ⋯ 431 unchanged lines | |||
| 561 | 574 | stacks a side wants about 560px and no ordinary window has that between the top | |
| 562 | 575 | and bottom strips. That bought a square at the price of it not being the wall: | |
| 563 | 576 | 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. | |
| 569 | 578 | ||
| 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. | |
| 574 | 588 | ||
| 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. | |
| 583 | 595 | ||
| 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. | |
| 588 | 627 | ||
| 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. | |
| 596 | 632 | ||
| 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. | |
| 605 | 639 | ||
| 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. | |
| 606 | 645 | Nothing about the square is calculated twice. Its size lives entirely in CSS | |
| 607 | 646 | (`--ws`, `--wd`), so `geometry.ts` **measures** it instead of restating that | |
| 608 | 647 | arithmetic: `WallRing` already renders every stack as a real element tagged with | |
| ⋯ 71 unchanged lines | |||
modifiedsrc/App.tsx+112 −4
| 1 | 1 | import { useEffect, useRef, useState, useSyncExternalStore } from 'react'; | |
| 2 | 2 | import { AutoPlay } from './game/autoplay'; | |
| 3 | 3 | import type { TableCtl } from './game/ctl'; | |
| 4 | - | import { Game } from './game/engine'; | |
| 4 | + | import { Game, type SoundCue } from './game/engine'; | |
| 5 | 5 | import { loadPrefs, savePrefs, type Lang, type Prefs } from './game/prefs'; | |
| 6 | 6 | 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'; | |
| 8 | 19 | import { WIND_EN, WIND_NAMES } from './game/tiles'; | |
| 9 | 20 | import type { SeatId } from './game/types'; | |
| 10 | 21 | import { roomFromUrl, roomLink, seatFromUrl } from './net/protocol'; | |
| ⋯ 31 unchanged lines | |||
| 42 | 53 | const subNothing = () => () => {}; | |
| 43 | 54 | const zero = () => 0; | |
| 44 | 55 | ||
| 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 | + | ||
| 45 | 101 | export default function App() { | |
| 46 | 102 | const [prefs, setPrefs] = useState<Prefs>(loadPrefs); | |
| 47 | 103 | const t = useT(); | |
| ⋯ 71 unchanged lines | |||
| 119 | 175 | session.setBusy(() => false); | |
| 120 | 176 | }; | |
| 121 | 177 | }, [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 | + | ); | |
| 123 | 192 | ||
| 124 | 193 | // Walking back into a running online game unseated — a reload, or a friend | |
| 125 | 194 | // arriving late — takes the first empty chair, deserted or computer-run. | |
| ⋯ 25 unchanged lines | |||
| 151 | 220 | // Online the table is turned so your own seat is at the bottom edge. | |
| 152 | 221 | const viewSeat: SeatId = netMode === 'online' ? mySeat ?? 0 : 0; | |
| 153 | 222 | 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 | + | }; | |
| 154 | 232 | ||
| 155 | 233 | // Names and stakes live in the game state as well as in the preferences — | |
| 156 | 234 | // saved games keep the settings they were played under. | |
| ⋯ 8 unchanged lines | |||
| 165 | 243 | setSoundEnabled(prefs.sound); | |
| 166 | 244 | setVoiceEnabled(prefs.voice); | |
| 167 | 245 | setVolume(prefs.volume); | |
| 246 | + | setSpatial(prefs.spatial); | |
| 168 | 247 | setSettingsOpen(false); | |
| 169 | 248 | }; | |
| 170 | 249 | const settingsPanel = settingsOpen && ( | |
| ⋯ 60 unchanged lines | |||
| 231 | 310 | setSoundEnabled(prefs.sound); | |
| 232 | 311 | setVoiceEnabled(prefs.voice); | |
| 233 | 312 | setVolume(prefs.volume); | |
| 313 | + | setSpatial(prefs.spatial); | |
| 234 | 314 | }, [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]); | |
| 236 | 341 | // Watches the table for the whole session and only stirs when a seat the | |
| 237 | 342 | // computer holds is the one being waited on. Idle in a networked game, where | |
| 238 | 343 | // whichever device is the room's host runs its own AutoPlay. | |
| ⋯ 164 unchanged lines | |||
| 403 | 508 | ? `${slot.name || t('手機', 'phone')}${held > 1 ? ` ×${held}` : ''}` | |
| 404 | 509 | : null, | |
| 405 | 510 | 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, | |
| 406 | 514 | }, | |
| 407 | 515 | }; | |
| 408 | 516 | } | |
| ⋯ 85 unchanged lines | |||
modifiedsrc/game/engine.test.ts+10 −4
| ⋯ 224 unchanged lines | |||
| 225 | 225 | expect(s.turn).toBe(victim); | |
| 226 | 226 | }); | |
| 227 | 227 | ||
| 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', () => { | |
| 229 | 229 | const game = new Game(); | |
| 230 | - | const cues: { kind: string; tile?: number }[] = []; | |
| 230 | + | const cues: { kind: string; tile?: number; seat?: SeatId }[] = []; | |
| 231 | 231 | game.onSound((c) => cues.push(c)); | |
| 232 | 232 | game.startHand(7); | |
| 233 | 233 | const s = game.state; | |
| ⋯ 2 unchanged lines | |||
| 236 | 236 | s.players[victim].hand = [tile, tile, ...s.players[victim].hand.slice(2)]; | |
| 237 | 237 | ||
| 238 | 238 | game.discard(s.dealer, tile); | |
| 239 | - | expect(cues).toContainEqual({ kind: 'discard', tile }); | |
| 239 | + | expect(cues).toContainEqual({ kind: 'discard', tile, seat: s.dealer }); | |
| 240 | 240 | ||
| 241 | 241 | game.respond(victim, { type: 'pung' }); | |
| 242 | 242 | 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(); | |
| 244 | 250 | }); | |
| 245 | 251 | ||
| 246 | 252 | it('takes back a mis-tapped discard', () => { | |
| ⋯ 292 unchanged lines | |||
modifiedsrc/game/engine.ts+29 −12
| ⋯ 50 unchanged lines | |||
| 51 | 51 | export interface SoundCue { | |
| 52 | 52 | kind: SoundEvent; | |
| 53 | 53 | 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; | |
| 54 | 63 | } | |
| 55 | 64 | ||
| 56 | 65 | export type ClaimType = 'hu' | 'kong' | 'pung' | 'chow'; | |
| ⋯ 232 unchanged lines | |||
| 289 | 298 | this.soundListeners.delete(fn); | |
| 290 | 299 | }; | |
| 291 | 300 | }; | |
| 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 }); | |
| 294 | 303 | } | |
| 295 | 304 | ||
| 296 | 305 | /** | |
| ⋯ 194 unchanged lines | |||
| 491 | 500 | if (isFlower(t)) { | |
| 492 | 501 | drawn = this.replaceFlowers(seat).last; | |
| 493 | 502 | 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 | |
| 495 | 504 | } | |
| 496 | 505 | s.turn = seat; | |
| 497 | 506 | this.setDrawn(seat, drawn); | |
| ⋯ 63 unchanged lines | |||
| 561 | 570 | s.lastDiscard = { tile, from: seat }; | |
| 562 | 571 | s.responses = {}; | |
| 563 | 572 | s.options = this.claimOptionsFor(tile, seat); | |
| 564 | - | this.sfx('discard', tile); | |
| 573 | + | this.sfx('discard', tile, seat); | |
| 565 | 574 | ||
| 566 | 575 | if (Object.keys(s.options).length === 0) { | |
| 567 | 576 | this.afterDiscardResolved(); | |
| 568 | 577 | } else { | |
| 569 | 578 | s.phase = 'claims'; | |
| 570 | 579 | // 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); | |
| 573 | 584 | } | |
| 574 | 585 | this.emit(); | |
| 575 | 586 | } | |
| ⋯ 161 unchanged lines | |||
| 737 | 748 | // stricter ones make you wait for your own draw. | |
| 738 | 749 | if (s.rules.sacredClearedByClaim) s.sacred[seat] = []; | |
| 739 | 750 | this.say(`${p.name} ${claimName(claim.type)} ${labelOf(tile)}`); | |
| 740 | - | this.sfx(claim.type, tile); | |
| 751 | + | this.sfx(claim.type, tile, seat); | |
| 741 | 752 | ||
| 742 | 753 | s.lastDiscard = null; | |
| 743 | 754 | s.options = {}; | |
| ⋯ 35 unchanged lines | |||
| 779 | 790 | for (let k = 0; k < 4; k++) p.hand.splice(p.hand.indexOf(tile), 1); | |
| 780 | 791 | p.melds.push({ kind: 'ankong', tiles: [tile, tile, tile, tile] }); | |
| 781 | 792 | this.say(`${p.name} 暗槓 concealed kong`); | |
| 782 | - | this.sfx('kong', tile); | |
| 793 | + | this.sfx('kong', tile, seat); | |
| 783 | 794 | this.kongReplacement(seat); | |
| 784 | 795 | this.emit(); | |
| 785 | 796 | } | |
| ⋯ 24 unchanged lines | |||
| 810 | 821 | s.responses = {}; | |
| 811 | 822 | s.phase = 'robkong'; | |
| 812 | 823 | 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); | |
| 815 | 826 | } else { | |
| 816 | 827 | this.say(`${p.name} 加槓 added kong`); | |
| 817 | - | this.sfx('kong', tile); | |
| 828 | + | this.sfx('kong', tile, seat); | |
| 818 | 829 | this.finishAddKong(); | |
| 819 | 830 | } | |
| 820 | 831 | this.emit(); | |
| ⋯ 139 unchanged lines | |||
| 960 | 971 | this.say(`${s.players[r.seat].name} 胡牌 wins ${r.tai} 台`); | |
| 961 | 972 | if (r.liable !== null) this.say(`${s.players[r.liable].name} 包牌 pays it all`); | |
| 962 | 973 | } | |
| 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 | + | ); | |
| 964 | 981 | this.emit(); | |
| 965 | 982 | } | |
| 966 | 983 | ||
| ⋯ 94 unchanged lines | |||
modifiedsrc/game/prefs.ts+7 −0
| ⋯ 35 unchanged lines | |||
| 36 | 36 | /** 報牌 — say the calls and the discarded tiles out loud. Needs `sound`. */ | |
| 37 | 37 | voice: boolean; | |
| 38 | 38 | /** | |
| 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 | + | /** | |
| 39 | 44 | * Whether the player asked for the whole screen. Fullscreen itself never | |
| 40 | 45 | * survives a reload, so this is what re-arms it on the next tap — see | |
| 41 | 46 | * ui/fullscreen.ts. Not an audio setting: it is a choice, and it carries. | |
| ⋯ 13 unchanged lines | |||
| 55 | 60 | sound: true, | |
| 56 | 61 | volume: 0.7, | |
| 57 | 62 | voice: true, | |
| 63 | + | spatial: true, | |
| 58 | 64 | fullscreen: false, | |
| 59 | 65 | lang: 'bi', | |
| 60 | 66 | names: DEFAULT_NAMES, | |
| ⋯ 11 unchanged lines | |||
| 72 | 78 | sound: !stale && typeof p.sound === 'boolean' ? p.sound : DEFAULT_PREFS.sound, | |
| 73 | 79 | volume: clamp(typeof p.volume === 'number' ? p.volume : DEFAULT_PREFS.volume, 0, 1), | |
| 74 | 80 | voice: !stale && typeof p.voice === 'boolean' ? p.voice : DEFAULT_PREFS.voice, | |
| 81 | + | spatial: !stale && typeof p.spatial === 'boolean' ? p.spatial : DEFAULT_PREFS.spatial, | |
| 75 | 82 | fullscreen: typeof p.fullscreen === 'boolean' ? p.fullscreen : DEFAULT_PREFS.fullscreen, | |
| 76 | 83 | lang: LANGS.includes(p.lang as Lang) ? (p.lang as Lang) : DEFAULT_PREFS.lang, | |
| 77 | 84 | names: | |
| ⋯ 19 unchanged lines | |||
modifiedsrc/game/sound.ts+214 −60
| ⋯ 28 unchanged lines | |||
| 29 | 29 | master = ctx.createGain(); | |
| 30 | 30 | master.gain.value = volume; | |
| 31 | 31 | 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(); | |
| 32 | 35 | } | |
| 33 | 36 | // Chrome parks the context until a gesture, and again when the tab sleeps. | |
| 34 | 37 | if (ctx.state === 'suspended') void ctx.resume(); | |
| ⋯ 10 unchanged lines | |||
| 45 | 48 | return noise; | |
| 46 | 49 | } | |
| 47 | 50 | ||
| 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 | + | ||
| 48 | 147 | // ---- voice --------------------------------------------------------------- | |
| 49 | 148 | /** | |
| 50 | 149 | * 報牌 — the calls said out loud, the way they are shouted at a table: 碰, 吃, | |
| ⋯ 23 unchanged lines | |||
| 74 | 173 | let voiceOn = false; | |
| 75 | 174 | let fetching: Promise<void> | null = null; | |
| 76 | 175 | let decoding = false; | |
| 77 | - | let voiceGain: GainNode | null = null; | |
| 78 | 176 | let speaking: AudioBufferSourceNode[] = []; | |
| 79 | 177 | ||
| 80 | 178 | /** | |
| ⋯ 44 unchanged lines | |||
| 125 | 223 | decoding = true; | |
| 126 | 224 | for (const [name, bytes] of encoded) { | |
| 127 | 225 | c.decodeAudioData(bytes) | |
| 128 | - | .then((buf) => clips.set(name, buf)) | |
| 226 | + | .then((buf) => { | |
| 227 | + | clips.set(name, buf); | |
| 228 | + | told(); | |
| 229 | + | }) | |
| 129 | 230 | .catch(() => {}); | |
| 130 | 231 | } | |
| 131 | 232 | encoded.clear(); // decodeAudioData takes ownership of the buffers | |
| ⋯ 2 unchanged lines | |||
| 134 | 235 | export function setVoiceEnabled(on: boolean) { | |
| 135 | 236 | voiceOn = on; | |
| 136 | 237 | if (on) void fetchVoicePack(); | |
| 238 | + | told(); | |
| 137 | 239 | } | |
| 138 | 240 | ||
| 139 | 241 | /** | |
| ⋯ 5 unchanged lines | |||
| 145 | 247 | * going to say anything at all. That is what lets the table hold still while | |
| 146 | 248 | * somebody is speaking, and only for as long as they are. | |
| 147 | 249 | */ | |
| 148 | - | function say(...names: string[]): number { | |
| 250 | + | function say(place: Place | null, ...names: string[]): number { | |
| 149 | 251 | const c = audio(); | |
| 150 | 252 | if (!voiceOn || !enabled || !c || !master) return 0; | |
| 151 | 253 | if (!clips.size) { | |
| ⋯ 13 unchanged lines | |||
| 165 | 267 | } | |
| 166 | 268 | speaking = []; | |
| 167 | 269 | ||
| 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; | |
| 175 | 271 | const from = c.currentTime + 0.02; | |
| 176 | 272 | let at = from; | |
| 177 | 273 | for (const name of names) { | |
| ⋯ 1 unchanged line | |||
| 179 | 275 | if (!buf) continue; | |
| 180 | 276 | const src = c.createBufferSource(); | |
| 181 | 277 | src.buffer = buf; | |
| 182 | - | src.connect(voiceGain); | |
| 278 | + | src.connect(out); | |
| 183 | 279 | src.start(at); | |
| 184 | 280 | speaking.push(src); | |
| 185 | 281 | at += buf.duration + 0.05; | |
| ⋯ 6 unchanged lines | |||
| 192 | 288 | // Reach for the hardware only once the page has been interacted with — | |
| 193 | 289 | // sound is on from the start, and a context built before that is refused | |
| 194 | 290 | // 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(); | |
| 199 | 293 | } | |
| 200 | 294 | ||
| 201 | 295 | export function setVolume(v: number) { | |
| ⋯ 1 unchanged line | |||
| 203 | 297 | if (master && ctx) master.gain.setTargetAtTime(v, ctx.currentTime, 0.01); | |
| 204 | 298 | } | |
| 205 | 299 | ||
| 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 | + | ||
| 206 | 336 | /** Envelope helper: a tone that starts at `gain` and decays away over `dur`. */ | |
| 207 | 337 | function tone( | |
| 208 | 338 | c: AudioContext, | |
| ⋯ 2 unchanged lines | |||
| 211 | 341 | at: number; | |
| 212 | 342 | dur: number; | |
| 213 | 343 | gain: number; | |
| 344 | + | out: AudioNode; | |
| 214 | 345 | type?: OscillatorType; | |
| 215 | 346 | /** Slide to this frequency over the life of the note. */ | |
| 216 | 347 | to?: number; | |
| ⋯ 8 unchanged lines | |||
| 225 | 356 | g.gain.setValueAtTime(0.0001, t); | |
| 226 | 357 | g.gain.exponentialRampToValueAtTime(opts.gain, t + 0.004); | |
| 227 | 358 | g.gain.exponentialRampToValueAtTime(0.0001, t + opts.dur); | |
| 228 | - | o.connect(g).connect(master!); | |
| 359 | + | o.connect(g).connect(opts.out); | |
| 229 | 360 | o.start(t); | |
| 230 | 361 | o.stop(t + opts.dur + 0.02); | |
| 231 | 362 | } | |
| 232 | 363 | ||
| 233 | 364 | /** 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) { | |
| 235 | 366 | const t = c.currentTime + at; | |
| 236 | 367 | const src = c.createBufferSource(); | |
| 237 | 368 | src.buffer = noiseBuffer(c); | |
| ⋯ 5 unchanged lines | |||
| 243 | 374 | const g = c.createGain(); | |
| 244 | 375 | g.gain.setValueAtTime(0.55 * gain, t); | |
| 245 | 376 | g.gain.exponentialRampToValueAtTime(0.0001, t + 0.075); | |
| 246 | - | src.connect(bp).connect(g).connect(master!); | |
| 377 | + | src.connect(bp).connect(g).connect(out); | |
| 247 | 378 | src.start(t, Math.random() * 0.5); | |
| 248 | 379 | 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 }); | |
| 250 | 381 | } | |
| 251 | 382 | ||
| 252 | 383 | /** 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 }); | |
| 257 | 388 | } | |
| 258 | 389 | ||
| 259 | 390 | /** | |
| ⋯ 2 unchanged lines | |||
| 262 | 393 | * thrown tile landing among the others, and only the physics knows when. | |
| 263 | 394 | * | |
| 264 | 395 | * `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. | |
| 266 | 399 | */ | |
| 267 | 400 | let lastKnock = 0; | |
| 268 | - | export function knock(strength: number) { | |
| 401 | + | export function knock(strength: number, where?: Place) { | |
| 269 | 402 | if (!enabled) return; | |
| 270 | 403 | const c = audio(); | |
| 271 | 404 | if (!c || !master) return; | |
| ⋯ 1 unchanged line | |||
| 273 | 406 | // tenth of a second, and hearing all of them is a rattle, not a table. | |
| 274 | 407 | if (c.currentTime - lastKnock < 0.045) return; | |
| 275 | 408 | 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); | |
| 277 | 410 | } | |
| 278 | 411 | ||
| 279 | 412 | /** | |
| ⋯ 2 unchanged lines | |||
| 282 | 415 | * The middle stops where the four players' hands start, so a tile that reaches | |
| 283 | 416 | * the edge with any speed left has been thrown at whoever is sitting there — | |
| 284 | 417 | * 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. | |
| 286 | 420 | * | |
| 287 | 421 | * Only the physics knows this happened, so like `knock` it is not a | |
| 288 | 422 | * `SoundEvent`: the engine neither knows nor cares where a discard ended up. | |
| ⋯ 3 unchanged lines | |||
| 292 | 426 | * about it is the whole thing landing on nobody. | |
| 293 | 427 | */ | |
| 294 | 428 | let lastBarge = 0; | |
| 295 | - | export function barge(strength: number): number { | |
| 429 | + | export function barge(strength: number, where?: Place): number { | |
| 296 | 430 | if (!enabled) return 0; | |
| 297 | 431 | const c = audio(); | |
| 298 | 432 | if (!c || !master) return 0; | |
| ⋯ 1 unchanged line | |||
| 300 | 434 | if (c.currentTime - lastBarge < 1.6) return 0; | |
| 301 | 435 | lastBarge = c.currentTime; | |
| 302 | 436 | ||
| 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); | |
| 304 | 439 | // 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 }); | |
| 307 | 442 | // Never the same line twice running: the repeat is what makes a stock phrase | |
| 308 | 443 | // sound like a machine, and this one fires often enough for that to show. | |
| 309 | 444 | let pick = Math.floor(Math.random() * WATCH_IT.length); | |
| 310 | 445 | if (WATCH_IT[pick] === lastWords) pick = (pick + 1) % WATCH_IT.length; | |
| 311 | 446 | lastWords = WATCH_IT[pick]; | |
| 312 | - | return say(lastWords); | |
| 447 | + | return say(where ?? null, lastWords); | |
| 313 | 448 | } | |
| 314 | 449 | ||
| 315 | 450 | /** | |
| ⋯ 14 unchanged lines | |||
| 330 | 465 | let lastWords = ''; | |
| 331 | 466 | ||
| 332 | 467 | /** | |
| 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 | + | /** | |
| 333 | 483 | * Each cue has its own shape so it is identifiable without looking up — the | |
| 334 | 484 | * whole reason for the feature is a player noticing across the table. | |
| 335 | 485 | */ | |
| 336 | - | export function play(cue: SoundCue | SoundEvent) { | |
| 486 | + | export function play(cue: SoundCue | SoundEvent, cast: Cast = {}) { | |
| 337 | 487 | const { kind, tile } = typeof cue === 'string' ? { kind: cue, tile: undefined } : cue; | |
| 338 | 488 | if (!enabled) return; | |
| 339 | 489 | ||
| 340 | 490 | // A discard is announced by name; a claim by what was called; a flower by | |
| 341 | 491 | // 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 | + | } | |
| 348 | 500 | } | |
| 501 | + | if (cast.noise === false) return; | |
| 349 | 502 | ||
| 350 | 503 | const c = audio(); | |
| 351 | 504 | if (!c || !master) return; | |
| 505 | + | const out = busFor(c, cast).in; | |
| 352 | 506 | ||
| 353 | 507 | switch (kind) { | |
| 354 | 508 | // A tile laid down. Dry and short so it never masks the chime after it. | |
| 355 | 509 | case 'discard': | |
| 356 | - | clack(c, 0); | |
| 510 | + | clack(c, out, 0); | |
| 357 | 511 | break; | |
| 358 | 512 | // Somebody's claim window is open. Rising two-note bell, easy to hear over | |
| 359 | 513 | // talk, and deliberately unlike the clack that precedes it. | |
| 360 | 514 | 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 | |
| 363 | 517 | break; | |
| 364 | 518 | // 碰 / 吃 — the tile going down alongside the pair it joins. | |
| 365 | 519 | case 'pung': | |
| 366 | 520 | 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); | |
| 370 | 524 | break; | |
| 371 | 525 | // Four tiles going down, plus a replacement off the back of the wall. | |
| 372 | 526 | 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); | |
| 378 | 532 | break; | |
| 379 | 533 | case 'flower': | |
| 380 | - | bell(c, 1319, 0, 0.45, 0.35); | |
| 534 | + | bell(c, out, 1319, 0, 0.45, 0.35); | |
| 381 | 535 | break; | |
| 382 | 536 | // 胡牌 — a rising figure, the only cue that takes its time. | |
| 383 | 537 | case 'hu': | |
| 384 | 538 | 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); | |
| 389 | 543 | break; | |
| 390 | 544 | // 流局 — the same figure, falling and dulled. | |
| 391 | 545 | 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); | |
| 395 | 549 | break; | |
| 396 | 550 | // Sixteen tiles apiece, so a scatter rather than a beat. | |
| 397 | 551 | 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); | |
| 399 | 553 | break; | |
| 400 | 554 | 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 }); | |
| 402 | 556 | break; | |
| 403 | 557 | } | |
| 404 | 558 | } | |
modifiedsrc/game/wall.ts+11 −5
| ⋯ 123 unchanged lines | |||
| 124 | 124 | } | |
| 125 | 125 | ||
| 126 | 126 | /** | |
| 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. | |
| 128 | 128 | * | |
| 129 | 129 | * Four walls of eighteen stacks, laid down square and nothing turned. Each is | |
| 130 | 130 | * pinned at the corner it is built from and runs its whole length from there, | |
| ⋯ 2 unchanged lines | |||
| 133 | 133 | * is why the corners are covered rather than mitred. | |
| 134 | 134 | * | |
| 135 | 135 | * 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. | |
| 138 | 145 | */ | |
| 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 { | |
| 141 | 147 | const len = cell * STACKS_PER_SIDE; | |
| 142 | 148 | const thick = cell * ratio; | |
| 143 | 149 | return { cell, len, thick, open: len - thick, outer: len + thick }; | |
| ⋯ 123 unchanged lines | |||
modifiedsrc/net/protocol.ts+21 −1
| ⋯ 37 unchanged lines | |||
| 38 | 38 | seats: SeatSlot[]; | |
| 39 | 39 | /** The whole engine state. null while the room is still in its lobby. */ | |
| 40 | 40 | 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[]; | |
| 41 | 51 | } | |
| 42 | 52 | ||
| 43 | 53 | /** Something a player asks the host to play for their seat. */ | |
| ⋯ 23 unchanged lines | |||
| 67 | 77 | * tile flying in from that player's edge. | |
| 68 | 78 | */ | |
| 69 | 79 | export type FxMsg = | |
| 70 | - | | { fx: 'sfx'; kind: string; tile?: Tile } | |
| 80 | + | | { fx: 'sfx'; kind: string; tile?: Tile; seat?: SeatId } | |
| 71 | 81 | | { fx: 'throw'; seat: SeatId; t: NetThrow }; | |
| 72 | 82 | ||
| 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 | + | ||
| 73 | 92 | export const EV_ACT = 'act'; | |
| 74 | 93 | export const EV_SEAT = 'seat'; | |
| 75 | 94 | export const EV_FX = 'fx'; | |
| 95 | + | export const EV_VOICE = 'voice'; | |
| 76 | 96 | ||
| 77 | 97 | export const EMPTY_SEATS: SeatSlot[] = [ | |
| 78 | 98 | { ids: [], name: '' }, | |
| ⋯ 54 unchanged lines | |||
modifiedsrc/net/session.ts+84 −3
| ⋯ 7 unchanged lines | |||
| 8 | 8 | EV_ACT, | |
| 9 | 9 | EV_FX, | |
| 10 | 10 | EV_SEAT, | |
| 11 | + | EV_VOICE, | |
| 11 | 12 | type ActMsg, | |
| 12 | 13 | type FxMsg, | |
| 13 | 14 | type NetMode, | |
| 14 | 15 | type SeatMsg, | |
| 15 | 16 | roomFromUrl, | |
| 16 | 17 | type SeatSlot, | |
| 18 | + | type VoiceMsg, | |
| 17 | 19 | } from './protocol'; | |
| 18 | 20 | import { joinRoom, type Room } from './room'; | |
| 19 | 21 | import { NetTable } from './table'; | |
| ⋯ 29 unchanged lines | |||
| 49 | 51 | ||
| 50 | 52 | private autoplay: AutoPlay | null = null; | |
| 51 | 53 | 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; | |
| 52 | 60 | private busy: () => boolean = () => false; | |
| 53 | 61 | private listeners = new Set<() => void>(); | |
| 54 | 62 | private version = 0; | |
| ⋯ 19 unchanged lines | |||
| 74 | 82 | room.on(EV_ACT, (p, from) => this.handleAct(p as unknown as ActMsg, from)), | |
| 75 | 83 | room.on(EV_SEAT, (p, from) => this.handleSeat(p as unknown as SeatMsg, from)), | |
| 76 | 84 | 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)), | |
| 77 | 86 | room.onJoin(() => this.bump()), | |
| 78 | 87 | 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 | + | } | |
| 80 | 93 | this.bump(); | |
| 81 | 94 | }), | |
| 82 | 95 | room.onHostChange(() => { | |
| ⋯ 1 unchanged line | |||
| 84 | 97 | // start acting like the host, which `syncAutoplay` and the guards on | |
| 85 | 98 | // every handler do by asking `isHost` fresh each time. | |
| 86 | 99 | 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); | |
| 87 | 104 | this.bump(); | |
| 88 | 105 | }), | |
| 89 | 106 | room.onError((e) => { | |
| ⋯ 8 unchanged lines | |||
| 98 | 115 | this.table.engine.onSound((cue) => { | |
| 99 | 116 | if (!this.isHost) return; | |
| 100 | 117 | 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); | |
| 102 | 124 | }), | |
| 103 | 125 | ); | |
| 104 | 126 | // Mirror whatever the room already holds — a late joiner walks in on the | |
| ⋯ 94 unchanged lines | |||
| 199 | 221 | mode: 'online', | |
| 200 | 222 | seats: structuredClone(EMPTY_SEATS) as never, | |
| 201 | 223 | homes: ['', '', '', ''] as never, | |
| 224 | + | voices: [false, false, false, false] as never, | |
| 202 | 225 | }); | |
| 203 | 226 | this.claimSeat(0); | |
| 204 | 227 | } | |
| ⋯ 25 unchanged lines | |||
| 230 | 253 | mode: 'party', | |
| 231 | 254 | seats: seats as never, | |
| 232 | 255 | homes: seats.map((s) => s.name) as never, | |
| 256 | + | voices: [false, false, false, false] as never, | |
| 233 | 257 | }); | |
| 234 | 258 | const eng = this.table.engine; | |
| 235 | 259 | eng.applySettings(names, rules); | |
| ⋯ 78 unchanged lines | |||
| 314 | 338 | } | |
| 315 | 339 | } | |
| 316 | 340 | this.room.setState({ seats: seats as never }); | |
| 341 | + | // A hand that changed hands changed who speaks for it. | |
| 342 | + | this.publishVoices(); | |
| 317 | 343 | } | |
| 318 | 344 | ||
| 319 | 345 | private handleAct(msg: ActMsg, from: string) { | |
| ⋯ 43 unchanged lines | |||
| 363 | 389 | private handleFx(msg: FxMsg) { | |
| 364 | 390 | if (this.isHost || !msg) return; | |
| 365 | 391 | 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 }); | |
| 367 | 393 | } else if (msg.fx === 'throw') { | |
| 368 | 394 | this.onThrowFx(msg.seat, msg.t); | |
| 369 | 395 | } | |
| 370 | 396 | } | |
| 371 | 397 | ||
| 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 | + | ||
| 372 | 453 | private publishGame() { | |
| 373 | 454 | // The engine mutates its state in place; the room keeps what it is handed, | |
| 374 | 455 | // so it gets a copy that will hold still. | |
| ⋯ 16 unchanged lines | |||
modifiedsrc/styles.css+22 −5
| ⋯ 684 unchanged lines | |||
| 685 | 685 | `.wall-ring` is the opening, not the outside: the walls hang off its edges, | |
| 686 | 686 | so the square encloses the pool the way it does on a table. */ | |
| 687 | 687 | .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. */ | |
| 693 | 693 | --ws: var(--tile-w); | |
| 694 | 694 | --wd: calc(var(--ws) * 1.375); | |
| 695 | 695 | /* A wall, end to end: eighteen stacks, set from ui/WallRing. It is longer | |
| ⋯ 1455 unchanged lines | |||
| 2151 | 2151 | user-select: none; | |
| 2152 | 2152 | touch-action: none; | |
| 2153 | 2153 | } | |
| 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 | + | } | |
| 2154 | 2161 | ||
| 2155 | 2162 | /* ---------- the phone controller ---------- */ | |
| 2156 | 2163 | /* A hand held in landscape: status, buttons, tiles, in that order down the | |
| ⋯ 180 unchanged lines | |||
| 2337 | 2344 | opacity: 0.65; | |
| 2338 | 2345 | margin-top: 2px; | |
| 2339 | 2346 | } | |
| 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 | + | } | |
| 2340 | 2357 | ||
| 2341 | 2358 | /* The dice tool. Dev builds only — `import.meta.env.DEV` in ui/Center — so it | |
| 2342 | 2359 | 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 | |||
| 617 | 617 | expect(2 * (RING.h + RING.v) * 2).toBe(144); | |
| 618 | 618 | }); | |
| 619 | 619 | ||
| 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); | |
| 625 | 628 | }); | |
| 626 | 629 | ||
| 627 | 630 | 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); | |
| 629 | 632 | // A wall runs its whole length from the corner it is built from, which | |
| 630 | 633 | // carries it one depth past the far one and over the end of the next. | |
| 631 | 634 | expect(s.outer - s.open).toBeCloseTo(2 * s.thick, 6); | |
| ⋯ 3 unchanged lines | |||
| 635 | 638 | expect(s.outer * s.outer - s.open * s.open).toBeCloseTo(4 * s.len * s.thick, 6); | |
| 636 | 639 | }); | |
| 637 | 640 | ||
| 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); | |
| 642 | 645 | expect(ten.open).toBeCloseTo(one.open * 10, 6); | |
| 646 | + | expect(ten.outer).toBeCloseTo(one.outer * 10, 6); | |
| 643 | 647 | }); | |
| 644 | 648 | }); | |
| 645 | 649 | ||
| ⋯ 130 unchanged lines | |||
modifiedsrc/ui/Controller.tsx+20 −6
| ⋯ 8 unchanged lines | |||
| 9 | 9 | import { useLang, useT } from './lang'; | |
| 10 | 10 | import { FullscreenToggle } from './FullscreenToggle'; | |
| 11 | 11 | import { Hand, type Throw } from './Hand'; | |
| 12 | + | import { SoundToggle } from './SoundToggle'; | |
| 12 | 13 | import { TileView } from './TileView'; | |
| 13 | 14 | ||
| 14 | 15 | const SEATS: SeatId[] = [0, 1, 2, 3]; | |
| ⋯ 8 unchanged lines | |||
| 23 | 24 | ||
| 24 | 25 | /** | |
| 25 | 26 | * 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. | |
| 31 | 37 | */ | |
| 32 | 38 | export function Controller({ session, prefs, onPrefs }: CtlProps) { | |
| 33 | 39 | const seat = session.mySeat; | |
| ⋯ 45 unchanged lines | |||
| 79 | 85 | ); | |
| 80 | 86 | })} | |
| 81 | 87 | </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> | |
| 83 | 94 | </div> | |
| 84 | 95 | ); | |
| 85 | 96 | } | |
| ⋯ 98 unchanged lines | |||
| 184 | 195 | <button className="btn ghost sort" onClick={() => table.sortHand(seat)}> | |
| 185 | 196 | {t('理牌', 'Sort')} | |
| 186 | 197 | </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 /> | |
| 187 | 201 | <FullscreenToggle prefs={prefs} onPrefs={onPrefs} compact /> | |
| 188 | 202 | </div> | |
| 189 | 203 | ||
| ⋯ 116 unchanged lines | |||
modifiedsrc/ui/Pool.tsx+41 −9
| 1 | 1 | import { useEffect, useRef, useState } from 'react'; | |
| 2 | 2 | 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'; | |
| 5 | 5 | import type { TablePool } from '../table/pool'; | |
| 6 | 6 | import type { SeatId } from '../game/types'; | |
| 7 | 7 | import { | |
| ⋯ 97 unchanged lines | |||
| 105 | 105 | noteImpacts(impacts); | |
| 106 | 106 | if (pausingOnHit() && !isHeld() && impacts.length) holdOn(impacts); | |
| 107 | 107 | let loudest = 0; | |
| 108 | + | let loudAt: Impact | null = null; | |
| 108 | 109 | for (const i of impacts) { | |
| 109 | 110 | // A contact quiet enough not to be worth hearing still counts as a tile | |
| 110 | 111 | // 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 | + | } | |
| 112 | 116 | // Straight into somebody's tiles — the wall square is nobody's, so this | |
| 113 | 117 | // 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); | |
| 118 | 126 | } | |
| 119 | 127 | } | |
| 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!)); | |
| 121 | 131 | paint(ctx, canvas, pool, live.current.art); | |
| 122 | 132 | ||
| 123 | 133 | // Keep going while anything is moving; otherwise wait to be poked. A held | |
| ⋯ 140 unchanged lines | |||
| 264 | 274 | * which is the other half of it: the table waits at least this long. */ | |
| 265 | 275 | const SHAKE = 0.34; | |
| 266 | 276 | ||
| 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 | + | ||
| 267 | 280 | /** | |
| 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 | + | /** | |
| 268 | 297 | * A tile has ended up in this seat's tiles — knocked into them, or simply come | |
| 269 | 298 | * to a stop off the table on their side. Either way it is theirs to complain | |
| 270 | 299 | * about, and how hard it arrived only decides how loudly. | |
| ⋯ 10 unchanged lines | |||
| 281 | 310 | seat: SeatId, | |
| 282 | 311 | thrower: SeatId | undefined, | |
| 283 | 312 | strength: number, | |
| 313 | + | pos: SeatId, | |
| 284 | 314 | ) { | |
| 285 | 315 | if (seat === thrower) return; | |
| 286 | 316 | // Hold the table while it is being said and while the row is still rocking. | |
| 287 | 317 | // The computer plays on a timer of its own, and a 碰 called over the top of | |
| 288 | 318 | // 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 }))); | |
| 290 | 322 | const hand = table.querySelector<HTMLElement>(`[data-seat="${seat}"] .hand`); | |
| 291 | 323 | if (!hand || hand.classList.contains('shoved')) return; | |
| 292 | 324 | hand.classList.add('shoved'); | |
| ⋯ 197 unchanged lines | |||
modifiedsrc/ui/Seat.tsx+15 −0
| ⋯ 22 unchanged lines | |||
| 23 | 23 | onPeek?: (show: boolean) => void; | |
| 24 | 24 | /** Online host: the seat's player is gone; offer it to the computer. */ | |
| 25 | 25 | 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; | |
| 26 | 33 | } | |
| 27 | 34 | ||
| 28 | 35 | interface Props { | |
| ⋯ 312 unchanged lines | |||
| 341 | 348 | 📱 {remote.holder} | |
| 342 | 349 | </span> | |
| 343 | 350 | )} | |
| 351 | + | {remote?.speaks && ( | |
| 352 | + | <span | |
| 353 | + | className="tag voice" | |
| 354 | + | title={t('喊牌從這支手機出來', 'this hand calls from its own phone', ' · ')} | |
| 355 | + | > | |
| 356 | + | 🔊 | |
| 357 | + | </span> | |
| 358 | + | )} | |
| 344 | 359 | {deserted && <span className="tag sacred">{t('已離線', 'offline')}</span>} | |
| 345 | 360 | {isDealer && ( | |
| 346 | 361 | <span className="tag"> | |
| ⋯ 224 unchanged lines | |||
modifiedsrc/ui/Settings.tsx+24 −1
| 1 | 1 | import { useState } from 'react'; | |
| 2 | 2 | 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'; | |
| 4 | 4 | import { DEFAULT_NAMES, type Rules } from '../game/types'; | |
| 5 | 5 | import { BiEm, BiText } from './Bi'; | |
| 6 | 6 | import { useT } from './lang'; | |
| ⋯ 236 unchanged lines | |||
| 243 | 243 | /> | |
| 244 | 244 | </span> | |
| 245 | 245 | </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> | |
| 246 | 269 | <label className="field"> | |
| 247 | 270 | <span className="f-label"> | |
| 248 | 271 | <BiEm zh="音量" en="Volume" /> | |
| ⋯ 22 unchanged lines | |||
modifiedsrc/ui/WallRing.tsx+25 −30
| ⋯ 65 unchanged lines | |||
| 66 | 66 | aria-label={t('牌牆', 'The wall')} | |
| 67 | 67 | style={ | |
| 68 | 68 | 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. | |
| 71 | 71 | ({ | |
| 72 | 72 | width: square.open, | |
| 73 | 73 | height: square.open, | |
| 74 | - | '--ws': `${square.cell}px`, | |
| 75 | 74 | // A wall is its stacks and nothing else, so there is no sum for | |
| 76 | 75 | // the corners to come out of by a pixel. | |
| 77 | 76 | '--wall-len': `${square.len}px`, | |
| ⋯ 138 unchanged lines | |||
| 216 | 215 | const round = (v: number) => Math.round(v * 100) / 100; | |
| 217 | 216 | ||
| 218 | 217 | /** | |
| 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. | |
| 224 | 229 | */ | |
| 225 | 230 | function useSquare(ring: React.RefObject<HTMLDivElement | null>): WallSquare | null { | |
| 226 | 231 | const [square, setSquare] = useState<WallSquare | null>(null); | |
| ⋯ 4 unchanged lines | |||
| 231 | 236 | const probe = el?.querySelector<HTMLElement>('.wall-probe'); | |
| 232 | 237 | if (!el || !middle || !probe) return; | |
| 233 | 238 | ||
| 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. | |
| 238 | 241 | let last = ''; | |
| 239 | 242 | ||
| 240 | 243 | const read = () => { | |
| 241 | 244 | const box = probe.getBoundingClientRect(); | |
| 242 | 245 | 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)}`; | |
| 255 | 249 | if (from === last) return; | |
| 256 | 250 | last = from; | |
| 257 | - | setSquare(wallSquare(room, ratio)); | |
| 251 | + | setSquare(wallSquare(box.width, box.height / box.width)); | |
| 258 | 252 | }; | |
| 259 | 253 | ||
| 260 | 254 | 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. | |
| 265 | 258 | 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); | |
| 267 | 262 | return () => observer.disconnect(); | |
| 268 | 263 | }, [ring]); | |
| 269 | 264 | ||
| ⋯ 2 unchanged lines | |||