anvilsign in

collin/mahjong · 6d18dd3d

Move the discards out of the strips and into the middle

Collin Richards · 2026-08-19 10:17 UTC · 6d18dd3db548618525b0ad30b8af3af23b937cc4 · parent 407cb68d · browse files

modifiedREADME.md+86 −8
⋯ 19 unchanged lines
2020
2121 ```
2222 ┌──── 對家 (rotated 180°) ────┐
23- 上家 (rotated 90°) │ wall / discards │ 下家 (rotated −90°)
23+ 上家 (rotated 90°) │ wall, and the discard pool │ 下家 (rotated −90°)
2424 └──── 自家 (upright, near you) ┘
2525 ```
2626
⋯ 2 unchanged lines
2929
3030 Each strip shows, from the player's edge inwards: nameplate (seat wind, 莊
3131 marker, 聽 badge, chip count) → concealed hand → action buttons → melds and
32-flowers → that player's discard row.
32+flowers. Discards are not in the strips: they are thrown into the middle and
33+stay there for the hand, the way they would be on a table. (A phone has no
34+middle to throw into, so the compact layout keeps a discard row per seat — see
35+[on a phone](#on-a-phone).)
3336
3437 ## Playing
3538
⋯ 7 unchanged lines
4346 used, and the dimmed tail is the 16-tile 底牌 that ends the hand — which is
4447 also the end kong and flower replacements are taken from, so the square is
4548 eaten from both directions at once.
46-- **Discard** — tap a tile to lift it, tap again (or the 打出 button) to throw it.
47- Action buttons sit on the right of your own strip, within thumb reach.
49+- **Discard** — drag a tile out of your row and it comes up out of your hand and
50+ follows your finger anywhere on the table; let go and it goes in, from where
51+ you let go and at the speed you let go at. Dragging *along* the row is still
52+ arranging your hand — which of the two you meant is decided once, from whether
53+ the movement is mostly along the row or out of it, and then it sticks. Let go
54+ back over your own hand and the tile goes back. Tapping a tile twice, or the
55+ 打出 button, still works and lobs it in for you.
56+- **The discard pool** — every tile thrown lands in the middle and stays there
57+ until the hand ends, with real weight: it skids, turns, knocks the tiles
58+ already there out of its way, and comes to rest against them. Nothing is ever
59+ stacked on anything — tiles are solved as rectangles, so two lying at an angle
60+ lean on each other rather than overlapping. The tile still to be claimed is
61+ the lit one.
62+- **Over the wall or across the table** — which of the two ways a tile goes in is
63+ not a setting, it is whatever the wall allows. At the start of a hand the
64+ square is a solid barrier and a discard has to be *lobbed* over it, so the
65+ flick's speed decides how far across the pool it lands. As the wall is eaten
66+ away, gaps open in front of one seat and then another, and from then on that
67+ seat can *slide* a tile in instead — flat across the cloth at exactly the speed
68+ it was flicked. Which it will be is a ray cast at the stacks actually standing
69+ there, so a gap off to one side counts, and a stack worn down to a single tile
70+ is low enough to go over. A seat whose wall is still up says 牆未開 on its
71+ nameplate, so a lob is never a mystery.
4872 - **Arranging** — drag any tile in your hand to reorder it; 理牌 sorts it back
4973 into suit order. Hands are never auto-sorted after the deal, so an arrangement
5074 you set up survives draws, claims and kongs.
⋯ 16 unchanged lines
6791 加槓 offers everyone else a 搶槓 chance.
6892 - **Where the buttons go** — the strip is built from its edge inwards, so the
6993 action bar holds a fixed height whether or not anything is in it. Otherwise
70- the melds and discards above it shuffled along every time you lifted a tile
94+ the melds above it shuffled along every time you lifted a tile
7195 and 打出 五萬 appeared, since a button naming a tile stands taller than a bare
7296 過. Every button on the bar is given that one height for the same reason.
7397 - **When a hand ends** — a big arrow drops onto the winner's edge of the table.
⋯ 28 unchanged lines
102126 Stakes are locked once a game is under way — they'd otherwise rewrite chips
103127 already won.
104128
129+## On a phone
130+
131+The table assumes four people sitting around a screen lying flat, and about
132+770px in both directions before the middle is worth looking at. A phone has
133+neither, so under `(max-height: 620px), (max-width: 820px)` it switches to the
134+compact layout: nothing is rotated, everything reads upright for the one person
135+holding it, the other three seats shrink to cards showing what is public about
136+them, and the depth that frees up goes to your hand, which is allowed to wrap.
137+
138+There is no centre square there — the middle collapses to a one-line bar showing
139+what the wall square was telling you anyway — so there is nowhere to throw a tile
140+*to*. The compact layout therefore keeps a discard row per seat, and discarding
141+is tap-twice or 打出, exactly as it was. The pool and the throw are a full-size
142+feature.
143+
144+The breakpoint is written down once, in `COMPACT_QUERY` in `src/ui/compact.ts`,
145+which sets a `compact` class on `<html>` for the CSS to key off.
146+
105147 ## Computer players
106148
107149 Single player seats you at the bottom and gives seats 1-3 to the computer.
108150 Their tiles go face down and their 聽 / 過水 badges come off, since both would
109-give the hand away; everything public — discards, melds, flowers, chips — stays
110-exactly as it is, and everyone turns their hand over when the hand ends.
151+give the hand away; everything public — the pool, melds, flowers, chips — stays
152+exactly as it is, and everyone turns their hand over when the hand ends. They
153+throw their tiles in like anyone else, and under the same rule: a computer seat
154+whose wall is open slides them across, and one still walled in lobs them over.
111155
112156 **The engine does not know the bots exist.** `AutoPlay` (`src/game/autoplay.ts`)
113157 watches the same state the screen does, and when the seat being waited on
⋯ 191 unchanged lines
305349 src/game/autoplay.ts when it does it, and how long it appears to think
306350 src/game/tai.ts 台 scoring
307351 src/game/engine.ts state machine: deal, turns, claim resolution, settlement
308-src/ui/ TileView, Hand (drag), Seat (rotated strip), Center, Help
352+src/game/wall.ts what is left of the square, and what still blocks a throw
353+src/table/physics.ts the discard pool: rectangles with weight, pure and testable
354+src/table/geometry.ts the table measured — colliders, launch points, throw gate
355+src/table/pool.ts game state ⇄ tiles in the middle, by diffing the discards
356+src/ui/ TileView, Hand (drag and throw), Seat, Center, Pool, Help
309357 ```
358+
359+## The tiles in the middle
360+
361+The pool is a small rigid-body solver (`src/table/physics.ts`) that knows nothing
362+about mahjong, the DOM, or the clock: fixed 1/120s steps, no randomness of its
363+own, and everything in table pixels. Given the same throws it produces the same
364+pile every time, which is what lets a refresh mid-hand come back to the pile you
365+had rather than a freshly scattered one — positions are derived from the hand
366+number and each tile's place in its thrower's discards, never saved.
367+
368+It is drawn on a canvas that spans the **whole table**, not just the centre.
369+That is deliberate: `.slot` and `.seat` both clip their own contents, which is
370+why a tile can't be animated out of a strip as an element. On the canvas there is
371+nothing to clip it, so a tile lifted out of a hand crosses the table in one
372+piece.
373+
374+Nothing about the square is calculated twice. Its size lives entirely in CSS
375+(`--ring`, `--ws`, `--wd`), so `geometry.ts` **measures** it instead of restating
376+that arithmetic: `WallRing` already renders every stack as a real element tagged
377+with its index, and the colliders are those elements' bounding rects. That one
378+mapping is what makes the wall a physical object — the thing a thrown tile
379+bounces off, and the thing the throw gate casts its ray at.
380+
381+`pool.ts` never listens for events. It **diffs** the discards against what it is
382+already drawing, keyed by `seat:index` — and those keys never shift, because
383+discards are only ever pushed and a claim only ever pops the one on top. So a
384+key that appeared is a tile to throw in and a key that went is a tile to take
385+off, which makes undo, 下一局, a claim and resuming a saved game all the same
386+code path. The engine has no idea any of it exists, and the save format did not
387+change.
modifiedsrc/styles.css+27 −9
⋯ 1 unchanged line
22
33 :root {
44 /* Depth of each player's strip. `--h-len` is floored so a window too narrow
5- to hold two strips shrinks the tiles rather than going negative. */
6- --depth: clamp(232px, 30vh, 330px);
5+ to hold two strips shrinks the tiles rather than going negative.
6+
7+ Shallower than it looks like it should be, on purpose: discards live in the
8+ middle of the table now rather than in a row inside each strip, and the
9+ depth that freed up was given to the centre instead of to the tiles. The
10+ wall square and the pile inside it are what wanted the room. */
11+ --depth: clamp(202px, 26vh, 276px);
712 --h-len: max(160px, calc(100vw - 2 * var(--depth)));
813 --v-len: 100vh;
914 /* Height the action buttons occupy, held whether or not any are showing.
⋯ 2 unchanged lines
1217 is what keeps everything above it still. Sized for the tallest button: the
1318 ones that name a tile (打出 五萬, 吃 三萬 四萬) carry a third line. */
1419 --bar-h: 52px;
15- /* Largest tile a strip can hold. Edge inwards it has to fit one row of
16- discards (0.88 tiles tall), the melds (0.86), the button bar, the hand
17- (1.71 + 6px) and the nameplate (28px), plus its own padding, gaps and
18- margins — so 3.45 tiles plus 124px.
20+ /* Largest tile a strip can hold. Edge inwards it has to fit the melds (0.86
21+ tiles tall), the button bar, the hand (1.71 + 6px) and the nameplate
22+ (28px), plus its own padding, gaps and margins — so 2.57 tiles plus 122px.
23+
24+ It used to be 3.45 tiles: the missing 0.88 is the row of discards, which is
25+ in the middle of the table now. `--depth` came down by the same amount
26+ rather than the tiles growing, so a tile is the size it always was and the
27+ centre is bigger.
1928
2029 Tile size otherwise follows the strip's *length*, which is why a window
21- with less height than F11 kept full-size tiles and left the discards a
22- sliver of a row to be cut off in. */
23- --tile-fit: calc((var(--depth) - 124px) / 3.45);
30+ with less height than F11 kept full-size tiles and left the strip's
31+ contents to be cut off. */
32+ --tile-fit: calc((var(--depth) - 122px) / 2.57);
2433
2534 --felt: #0f4534;
2635 --felt-2: #0a3126;
⋯ 263 unchanged lines
290299 padding-left: 12px;
291300 border-left: 1px dashed rgba(246, 240, 226, 0.3);
292301 }
302+/* Only the phone layout has these now — at full size a discard is out in the
303+ middle with the others. Kept as the shared base the `.compact` rules below
304+ build on, not as something the four strips render. */
293305 .discards {
294306 display: flex;
295307 flex-wrap: wrap;
⋯ 45 unchanged lines
341353 background: rgba(217, 180, 92, 0.2);
342354 border: 1px solid rgba(217, 180, 92, 0.5);
343355 }
356+/* Why a tile lobs over the square instead of sliding into it. */
357+.nameplate .tag.walled {
358+ background: rgba(246, 240, 226, 0.08);
359+ border-color: rgba(246, 240, 226, 0.28);
360+ opacity: 0.75;
361+}
344362 .nameplate .tag.ting {
345363 background: rgba(46, 170, 120, 0.2);
346364 border-color: rgba(46, 170, 120, 0.6);
⋯ 1556 unchanged lines
modifiedsrc/table/pool.ts+53 −1
⋯ 39 unchanged lines
4040 const THROW_LIFT = 0.12;
4141 /** Fastest flick we will honour, px/s. Beyond this it is a slip, not a throw. */
4242 export const MAX_THROW_SPEED = 2800;
43+/** How long a claimed tile takes to leave the table for the seat taking it. */
44+const TAKE_FLIGHT = 0.34;
45+
46+/** A tile on its way off the table, no longer part of the pile. */
47+interface Leaving {
48+ body: Body;
49+ tx: number;
50+ ty: number;
51+ /** 0 to 1. */
52+ t: number;
53+}
4354
4455 /** A tile lifted out of a hand and carried about. Drawn, but not simulated. */
4556 export interface Held {
⋯ 42 unchanged lines
8899 private hand = -1;
89100 /** The tile currently on the table to be claimed, so it can be marked. */
90101 private newestKey: string | null = null;
102+ /** How many melds each seat had last time, to spot who took a tile. */
103+ private melds = [0, 0, 0, 0];
104+ /** Tiles on their way off the table. Drawn, but out of the physics. */
105+ private leaving: Leaving[] = [];
91106
92107 // Which seats can throw, as a string, so the seats can re-render when it
93108 // changes. Read through useSyncExternalStore, the same way the game is.
⋯ 12 unchanged lines
106121 this.held = null;
107122 this.release = null;
108123 this.entries.clear();
124+ this.leaving = [];
109125 this.measuredAt = '';
110126 }
111127
⋯ 97 unchanged lines
209225 }
210226 }
211227
228+ const taken: Body[] = [];
212229 for (const [key, body] of this.entries) {
213230 if (want.has(key)) continue;
214231 const at = world.bodies.indexOf(body);
215232 if (at >= 0) world.bodies.splice(at, 1);
216233 this.entries.delete(key);
234+ taken.push(body);
235+ }
236+
237+ // Exactly one tile off the table is somebody taking it — a 碰, a 吃, a 槓,
238+ // or a discard being taken back. It goes to whoever now has a meld they did
239+ // not have a moment ago, and failing that back to the seat that threw it,
240+ // which is what an undo is. Several at once is the hand ending, and those
241+ // simply go.
242+ const claimer = SEATS.find((q) => state.players[q].melds.length > this.melds[q]);
243+ this.melds = SEATS.map((q) => state.players[q].melds.length);
244+ if (taken.length === 1 && !fresh) {
245+ const to = geo.launch[claimer ?? taken[0].seat];
246+ if (to) this.leaving.push({ body: taken[0], tx: to.x, ty: to.y, t: 0 });
217247 }
218248
219249 // One tile at a time is somebody playing, and it gets thrown. Several at
⋯ 17 unchanged lines
237267
238268 /** Run the physics on. Returns impacts worth a sound. */
239269 advance(seconds: number): Impact[] {
270+ this.carryOff(seconds);
240271 return this.world ? advanceWorld(this.world, seconds) : [];
241272 }
242273
274+ /** Tiles leaving the table, lifted and carried off to the seat taking them. */
275+ private carryOff(seconds: number) {
276+ if (this.leaving.length === 0) return;
277+ for (const l of this.leaving) {
278+ l.t = Math.min(1, l.t + seconds / TAKE_FLIGHT);
279+ // Ease out, so it leaves quickly and arrives gently.
280+ const k = 1 - (1 - l.t) * (1 - l.t);
281+ l.body.x += (l.tx - l.body.x) * k * 0.5;
282+ l.body.y += (l.ty - l.body.y) * k * 0.5;
283+ // Up off the table on the way, the way a tile picked up would be.
284+ l.body.z = Math.sin(l.t * Math.PI) * 40;
285+ l.body.angle += seconds * 2.2;
286+ }
287+ this.leaving = this.leaving.filter((l) => l.t < 1);
288+ }
289+
290+ /** What is on its way off, and how far gone, for the renderer to fade. */
291+ get departing(): { body: Body; fade: number }[] {
292+ return this.leaving.map((l) => ({ body: l.body, fade: 1 - l.t }));
293+ }
294+
243295 get bodies(): Body[] {
244296 return this.world?.bodies ?? [];
245297 }
⋯ 5 unchanged lines
251303
252304 /** Nothing is moving, so the render loop can stop until something changes. */
253305 get settled(): boolean {
254- return !this.held && (!this.world || settled(this.world));
306+ return !this.held && this.leaving.length === 0 && (!this.world || settled(this.world));
255307 }
256308
257309 // ---- measuring ---------------------------------------------------------
⋯ 163 unchanged lines
modifiedsrc/ui/Pool.tsx+12 −0
⋯ 136 unchanged lines
137137
138138 const bodies = pool.bodies;
139139 const held = pool.held;
140+ const departing = pool.departing;
140141
141142 // Shadows in one pass of their own, offset before rotation, so the light comes
142143 // from one direction across the whole pool rather than turning with each tile.
⋯ 10 unchanged lines
153154 ctx.restore();
154155 }
155156
157+ // On its way to whoever called it — lifted, turning, and fading as it goes.
158+ for (const { body: b, fade } of departing) {
159+ const s = zScale(b.z) * b.scale;
160+ ctx.save();
161+ ctx.globalAlpha = fade;
162+ ctx.translate(b.x, b.y);
163+ ctx.rotate(b.angle);
164+ face(ctx, b.tile, b.w * s, b.h * s, sheet, true);
165+ ctx.restore();
166+ }
167+
156168 // Whatever is in your fingers is above everything else on the table.
157169 if (held) {
158170 const s = zScale(HELD_Z);
⋯ 66 unchanged lines
modifiedsrc/ui/Seat.tsx+25 −10
⋯ 133 unchanged lines
134134 </div>
135135 )}
136136
137- <div className="discards" ref={discardsRef}>
138- {p.discards.map((t, i) => (
139- <TileView
140- key={i}
141- tile={t}
142- size="small"
143- highlight={s.lastDiscard?.from === seat && i === p.discards.length - 1}
144- />
145- ))}
146- </div>
137+ {/* Only on a phone. At full size a discard is thrown into the middle
138+ and stays there for the hand, so the row would be the same tiles
139+ listed twice. */}
140+ {compact && (
141+ <div className="discards" ref={discardsRef}>
142+ {p.discards.map((t, i) => (
143+ <TileView
144+ key={i}
145+ tile={t}
146+ size="small"
147+ highlight={s.lastDiscard?.from === seat && i === p.discards.length - 1}
148+ />
149+ ))}
150+ </div>
151+ )}
147152
148153 <div className="melds">
149154 {p.melds.map((m, i) => (
⋯ 103 unchanged lines
253258 {tenpai.length > 0 && (
254259 <span className="tag ting">聽 Ready {tenpai.map(labelOf).join('·')}</span>
255260 )}
261+ {/* Says why a tile lobs over the square rather than sliding into it —
262+ the wall in front of this seat has not been eaten away yet. */}
263+ {myTurn && pool && !pool.canThrow(seat) && (
264+ <span
265+ className="tag walled"
266+ title="牆未開 — the wall in front of you is still standing, so a tile has to go over it rather than across the table"
267+ >
268+ 牆未開 Wall up
269+ </span>
270+ )}
256271 {/* Without this the 胡 button just quietly fails to appear. */}
257272 {sacred.length > 0 && (
258273 <span className="tag sacred" title="過水 — passed on winning with these until your next draw">
⋯ 17 unchanged lines