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 | |||
| 20 | 20 | ||
| 21 | 21 | ``` | |
| 22 | 22 | ┌──── 對家 (rotated 180°) ────┐ | |
| 23 | - | 上家 (rotated 90°) │ wall / discards │ 下家 (rotated −90°) | |
| 23 | + | 上家 (rotated 90°) │ wall, and the discard pool │ 下家 (rotated −90°) | |
| 24 | 24 | └──── 自家 (upright, near you) ┘ | |
| 25 | 25 | ``` | |
| 26 | 26 | ||
| ⋯ 2 unchanged lines | |||
| 29 | 29 | ||
| 30 | 30 | Each strip shows, from the player's edge inwards: nameplate (seat wind, 莊 | |
| 31 | 31 | 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).) | |
| 33 | 36 | ||
| 34 | 37 | ## Playing | |
| 35 | 38 | ||
| ⋯ 7 unchanged lines | |||
| 43 | 46 | used, and the dimmed tail is the 16-tile 底牌 that ends the hand — which is | |
| 44 | 47 | also the end kong and flower replacements are taken from, so the square is | |
| 45 | 48 | 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. | |
| 48 | 72 | - **Arranging** — drag any tile in your hand to reorder it; 理牌 sorts it back | |
| 49 | 73 | into suit order. Hands are never auto-sorted after the deal, so an arrangement | |
| 50 | 74 | you set up survives draws, claims and kongs. | |
| ⋯ 16 unchanged lines | |||
| 67 | 91 | 加槓 offers everyone else a 搶槓 chance. | |
| 68 | 92 | - **Where the buttons go** — the strip is built from its edge inwards, so the | |
| 69 | 93 | 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 | |
| 71 | 95 | and 打出 五萬 appeared, since a button naming a tile stands taller than a bare | |
| 72 | 96 | 過. Every button on the bar is given that one height for the same reason. | |
| 73 | 97 | - **When a hand ends** — a big arrow drops onto the winner's edge of the table. | |
| ⋯ 28 unchanged lines | |||
| 102 | 126 | Stakes are locked once a game is under way — they'd otherwise rewrite chips | |
| 103 | 127 | already won. | |
| 104 | 128 | ||
| 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 | + | ||
| 105 | 147 | ## Computer players | |
| 106 | 148 | ||
| 107 | 149 | Single player seats you at the bottom and gives seats 1-3 to the computer. | |
| 108 | 150 | 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. | |
| 111 | 155 | ||
| 112 | 156 | **The engine does not know the bots exist.** `AutoPlay` (`src/game/autoplay.ts`) | |
| 113 | 157 | watches the same state the screen does, and when the seat being waited on | |
| ⋯ 191 unchanged lines | |||
| 305 | 349 | src/game/autoplay.ts when it does it, and how long it appears to think | |
| 306 | 350 | src/game/tai.ts 台 scoring | |
| 307 | 351 | 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 | |
| 309 | 357 | ``` | |
| 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 | |||
| 2 | 2 | ||
| 3 | 3 | :root { | |
| 4 | 4 | /* 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); | |
| 7 | 12 | --h-len: max(160px, calc(100vw - 2 * var(--depth))); | |
| 8 | 13 | --v-len: 100vh; | |
| 9 | 14 | /* Height the action buttons occupy, held whether or not any are showing. | |
| ⋯ 2 unchanged lines | |||
| 12 | 17 | is what keeps everything above it still. Sized for the tallest button: the | |
| 13 | 18 | ones that name a tile (打出 五萬, 吃 三萬 四萬) carry a third line. */ | |
| 14 | 19 | --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. | |
| 19 | 28 | ||
| 20 | 29 | 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); | |
| 24 | 33 | ||
| 25 | 34 | --felt: #0f4534; | |
| 26 | 35 | --felt-2: #0a3126; | |
| ⋯ 263 unchanged lines | |||
| 290 | 299 | padding-left: 12px; | |
| 291 | 300 | border-left: 1px dashed rgba(246, 240, 226, 0.3); | |
| 292 | 301 | } | |
| 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. */ | |
| 293 | 305 | .discards { | |
| 294 | 306 | display: flex; | |
| 295 | 307 | flex-wrap: wrap; | |
| ⋯ 45 unchanged lines | |||
| 341 | 353 | background: rgba(217, 180, 92, 0.2); | |
| 342 | 354 | border: 1px solid rgba(217, 180, 92, 0.5); | |
| 343 | 355 | } | |
| 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 | + | } | |
| 344 | 362 | .nameplate .tag.ting { | |
| 345 | 363 | background: rgba(46, 170, 120, 0.2); | |
| 346 | 364 | border-color: rgba(46, 170, 120, 0.6); | |
| ⋯ 1556 unchanged lines | |||
modifiedsrc/table/pool.ts+53 −1
| ⋯ 39 unchanged lines | |||
| 40 | 40 | const THROW_LIFT = 0.12; | |
| 41 | 41 | /** Fastest flick we will honour, px/s. Beyond this it is a slip, not a throw. */ | |
| 42 | 42 | 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 | + | } | |
| 43 | 54 | ||
| 44 | 55 | /** A tile lifted out of a hand and carried about. Drawn, but not simulated. */ | |
| 45 | 56 | export interface Held { | |
| ⋯ 42 unchanged lines | |||
| 88 | 99 | private hand = -1; | |
| 89 | 100 | /** The tile currently on the table to be claimed, so it can be marked. */ | |
| 90 | 101 | 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[] = []; | |
| 91 | 106 | ||
| 92 | 107 | // Which seats can throw, as a string, so the seats can re-render when it | |
| 93 | 108 | // changes. Read through useSyncExternalStore, the same way the game is. | |
| ⋯ 12 unchanged lines | |||
| 106 | 121 | this.held = null; | |
| 107 | 122 | this.release = null; | |
| 108 | 123 | this.entries.clear(); | |
| 124 | + | this.leaving = []; | |
| 109 | 125 | this.measuredAt = ''; | |
| 110 | 126 | } | |
| 111 | 127 | ||
| ⋯ 97 unchanged lines | |||
| 209 | 225 | } | |
| 210 | 226 | } | |
| 211 | 227 | ||
| 228 | + | const taken: Body[] = []; | |
| 212 | 229 | for (const [key, body] of this.entries) { | |
| 213 | 230 | if (want.has(key)) continue; | |
| 214 | 231 | const at = world.bodies.indexOf(body); | |
| 215 | 232 | if (at >= 0) world.bodies.splice(at, 1); | |
| 216 | 233 | 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 }); | |
| 217 | 247 | } | |
| 218 | 248 | ||
| 219 | 249 | // One tile at a time is somebody playing, and it gets thrown. Several at | |
| ⋯ 17 unchanged lines | |||
| 237 | 267 | ||
| 238 | 268 | /** Run the physics on. Returns impacts worth a sound. */ | |
| 239 | 269 | advance(seconds: number): Impact[] { | |
| 270 | + | this.carryOff(seconds); | |
| 240 | 271 | return this.world ? advanceWorld(this.world, seconds) : []; | |
| 241 | 272 | } | |
| 242 | 273 | ||
| 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 | + | ||
| 243 | 295 | get bodies(): Body[] { | |
| 244 | 296 | return this.world?.bodies ?? []; | |
| 245 | 297 | } | |
| ⋯ 5 unchanged lines | |||
| 251 | 303 | ||
| 252 | 304 | /** Nothing is moving, so the render loop can stop until something changes. */ | |
| 253 | 305 | 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)); | |
| 255 | 307 | } | |
| 256 | 308 | ||
| 257 | 309 | // ---- measuring --------------------------------------------------------- | |
| ⋯ 163 unchanged lines | |||
modifiedsrc/ui/Pool.tsx+12 −0
| ⋯ 136 unchanged lines | |||
| 137 | 137 | ||
| 138 | 138 | const bodies = pool.bodies; | |
| 139 | 139 | const held = pool.held; | |
| 140 | + | const departing = pool.departing; | |
| 140 | 141 | ||
| 141 | 142 | // Shadows in one pass of their own, offset before rotation, so the light comes | |
| 142 | 143 | // from one direction across the whole pool rather than turning with each tile. | |
| ⋯ 10 unchanged lines | |||
| 153 | 154 | ctx.restore(); | |
| 154 | 155 | } | |
| 155 | 156 | ||
| 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 | + | ||
| 156 | 168 | // Whatever is in your fingers is above everything else on the table. | |
| 157 | 169 | if (held) { | |
| 158 | 170 | const s = zScale(HELD_Z); | |
| ⋯ 66 unchanged lines | |||
modifiedsrc/ui/Seat.tsx+25 −10
| ⋯ 133 unchanged lines | |||
| 134 | 134 | </div> | |
| 135 | 135 | )} | |
| 136 | 136 | ||
| 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 | + | )} | |
| 147 | 152 | ||
| 148 | 153 | <div className="melds"> | |
| 149 | 154 | {p.melds.map((m, i) => ( | |
| ⋯ 103 unchanged lines | |||
| 253 | 258 | {tenpai.length > 0 && ( | |
| 254 | 259 | <span className="tag ting">聽 Ready {tenpai.map(labelOf).join('·')}</span> | |
| 255 | 260 | )} | |
| 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 | + | )} | |
| 256 | 271 | {/* Without this the 胡 button just quietly fails to appear. */} | |
| 257 | 272 | {sacred.length > 0 && ( | |
| 258 | 273 | <span className="tag sacred" title="過水 — passed on winning with these until your next draw"> | |
| ⋯ 17 unchanged lines | |||