anvilsign in

collin/mahjong · e319e006

A hand played on the keys

Collin Richards · 2026-08-25 07:39 UTC · e319e00600e2daec3fc6e26ff77145909bfa3b57 · parent e2f4495f · browse files

modifiedsrc/styles.css+29 −16
⋯ 427 unchanged lines
428428 color: var(--gold);
429429 }
430430
431+/* The tiles and the buttons that act on them, on one row. The hand stays in the
432+ middle of the strip where it has always been, and the bar is taken out of the
433+ flow and hung on the right-hand end — so a button arriving moves nothing at
434+ all, neither the tiles under it nor the sets and flowers above. */
435+.hand-row {
436+ position: relative;
437+ display: flex;
438+ justify-content: center;
439+ align-items: flex-end;
440+ align-self: stretch;
441+ flex: none;
442+}
431443 .actionbar {
444+ position: absolute;
445+ right: 0;
446+ bottom: 0;
447+ height: 100%;
432448 display: flex;
433449 gap: 8px;
434450 align-items: center;
435451 justify-content: flex-end;
436452 padding-right: calc(var(--tile-w) * 0.4);
437- align-self: stretch;
438- /* Reserved even when empty — see --bar-h. */
439- min-height: var(--bar-h);
440453 flex-wrap: wrap;
441454 flex: none;
442455 }
443-/* Nothing up: no bar. The reservation is there so the row does not change
444- height as buttons come and go, and it is worth exactly that much — but a bar
445- with nothing in it at all is 52px of daylight between a hand and the sets
446- above it, for most of a hand. Since the strip packs towards its outer edge,
447- what moves when the buttons do arrive is the melds above them, and never the
448- hand or the nameplate below. */
449-.actionbar:empty {
450- min-height: 0;
456+/* A phone has no room beside a hand. There the wrapper steps out of the way
457+ entirely and the bar is a row of its own again, where the grid and the
458+ `order`s further down put it. */
459+.compact .hand-row {
460+ display: contents;
451461 }
452-/* Except on the seat whose turn it is. There a button is one tap away — lift a
453- tile and 打出 arrives — and a bar that springs into being under your hand
454- shoves the sets and flowers above it up the strip while you are still
455- deciding. The seat that is about to need the room keeps it. */
456-.seat.active .actionbar:empty {
462+.compact .actionbar {
463+ position: static;
464+ height: auto;
457465 min-height: var(--bar-h);
466+ align-self: stretch;
458467 }
459468 /* Every button on the bar is the same height, so the row is the same height
460469 whichever ones are up: without this a 打出 五萬 stands taller than a bare 過
⋯ 1919 unchanged lines
23802389 min-height: 0;
23812390 padding: 3px 9px;
23822391 }
2392+.dice-row .btn.on {
2393+ border-color: var(--gold);
2394+ color: var(--gold);
2395+}
23832396 .dice-says {
23842397 opacity: 0.6;
23852398 letter-spacing: 0.02em;
⋯ 2 unchanged lines
modifiedsrc/table/geometry.ts+8 −6
1-import { isBarrier, wallStacks, type WallProgress } from '../game/wall';
21 import type { SeatId } from '../game/types';
32 import type { Barrier, Rect } from './physics';
43
⋯ 62 unchanged lines
6766 * Read the table. Returns null when there is no wall square to measure — the
6867 * compact layout has none, and neither does a table that has not laid out yet.
6968 */
70-export function measure(table: HTMLElement, state: WallProgress): TableGeometry | null {
69+export function measure(table: HTMLElement): TableGeometry | null {
7170 const ring = table.querySelector<HTMLElement>('.wall-ring');
7271 if (!ring) return null;
7372
⋯ 6 unchanged lines
8079 h: r.height,
8180 });
8281
83- const stacks = wallStacks(state);
8482 const walls: Barrier[] = [];
8583 const barriers: Barrier[] = [];
8684 // A stack element is --ws by --wd (or the other way round on the left and
⋯ 5 unchanged lines
9290 // Nothing in the square is turned, so a stack's bounding rect *is* the stack:
9391 // the box the layout gave it, in the table's own axes. A stack on the left or
9492 // right wall is lying the other way about, and its rect says so.
93+ //
94+ // Whether it is a barrier is read off the element too, rather than worked out
95+ // again from the state: a stack of two stands as tall as the tile thrown at
96+ // it (`isBarrier`), and `s2` is `WallRing` saying it drew one. The rule that a
97+ // tile you can see is a tile a thrown one hits, kept by asking the thing on
98+ // screen instead of a second opinion that could differ from it.
9599 for (const el of ring.querySelectorAll<HTMLElement>('[data-stack]')) {
96- const i = Number(el.dataset.stack);
97100 const r = local(el.getBoundingClientRect());
98101 short = Math.max(short, Math.min(r.w, r.h));
99- const stack = stacks[i];
100- if (stack && isBarrier(stack)) {
102+ if (el.classList.contains('s2')) {
101103 walls.push({ rect: r });
102104 barriers.push({ rect: r });
103105 }
⋯ 185 unchanged lines
modifiedsrc/table/pool.ts+1 −1
⋯ 612 unchanged lines
613613 )}x${Math.round(table.clientHeight)}`;
614614 if (stamp === this.measuredAt) return;
615615
616- const geo = measure(table, state);
616+ const geo = measure(table);
617617 this.measuredAt = geo ? stamp : '';
618618 this.geo = geo;
619619 if (!geo) {
⋯ 187 unchanged lines
modifiedsrc/ui/Center.tsx+9 −2
1+import { useState } from 'react';
12 import type { TableCtl } from '../game/ctl';
23 import type { Prefs } from '../game/prefs';
34 import type { SeatId } from '../game/types';
⋯ 24 unchanged lines
2829 export function Center({ game, prefs, compact, viewSeat = 0, preferSeat = null, onPrefs, onSettings }: Props) {
2930 const t = useT();
3031 const lang = useLang();
32+ // Dev only, and a look rather than a state: the square drawn with all 144
33+ // tiles standing, which is what it is built to and what it only ever is for
34+ // the moment before the deal. See the dice panel.
35+ const [whole, setWhole] = useState(false);
3136 const s = game.state;
3237 const r = s.result;
3338 // Point the live readout at whoever can move the game along.
⋯ 21 unchanged lines
5560 <em>{t('剩牌', 'left')}</em>
5661 </div>
5762 ) : (
58- <WallRing state={s} />
63+ <WallRing state={s} whole={whole} />
5964 ))}
6065
6166 <div
⋯ 133 unchanged lines
195200 {/* Dev only, and only where there is a square to look at: the dice decide
196201 where the wall is broken, and that is a thing you want to be able to
197202 step through rather than wait for. */}
198- {import.meta.env.DEV && !compact && s.phase !== 'gameEnd' && <DiceDebug game={game} />}
203+ {import.meta.env.DEV && !compact && s.phase !== 'gameEnd' && (
204+ <DiceDebug game={game} whole={whole} onWhole={setWhole} />
205+ )}
199206
200207 <div className="table-controls">
201208 {undoable && (
⋯ 25 unchanged lines
modifiedsrc/ui/DiceDebug.tsx+22 −1
⋯ 18 unchanged lines
1919 * was built, so nobody's hand changes and the draw order is the draw order.
2020 * See `reroll` in game/engine.ts and `breakAt` in game/wall.ts.
2121 */
22-export function DiceDebug({ game }: { game: TableCtl }) {
22+export function DiceDebug({
23+ game,
24+ whole,
25+ onWhole,
26+}: {
27+ game: TableCtl;
28+ /** Drawing the wall as if nothing had been taken off it — see `WallRing`. */
29+ whole: boolean;
30+ onWhole: (whole: boolean) => void;
31+}) {
2332 const t = useT();
2433 const s = game.state;
2534 if (!game.reroll) return null;
⋯ 28 unchanged lines
5463 +
5564 </button>
5665 </div>
66+ <div className="dice-row">
67+ {/* The wall is only whole for the moment before the deal, and that is
68+ the shape the square was built to. Puts all 144 back to look at,
69+ without touching a tile anybody is holding. */}
70+ <button
71+ className={`btn ghost ${whole ? 'on' : ''}`}
72+ onClick={() => onWhole(!whole)}
73+ aria-pressed={whole}
74+ >
75+ {t('整牆 144', 'Whole wall')}
76+ </button>
77+ </div>
5778 <div className="dice-says">
5879 {s.players[whose].name} · {WALL_SIDES[SEAT_WALL_SIDE[whose]]} ·{' '}
5980 {t(`第 ${into || STACKS_PER_SIDE} 疊`, `${into || STACKS_PER_SIDE} in`)} ·{' '}
⋯ 5 unchanged lines
modifiedsrc/ui/Hand.tsx+21 −13
⋯ 28 unchanged lines
2929 * cannot be thrown from — the phone layout, where there is no middle of the
3030 * table to throw into — and the row only ever reorders.
3131 */
32- carry?: {
33- /**
34- * Lifted out of the row: from here the canvas draws it, not the DOM. `size`
35- * is the width the tile had in the hand, so it can be drawn the size it just
36- * was and shrink to its size in the pool rather than popping.
37- */
38- onPickUp: (index: number, x: number, y: number, size: number) => void;
39- onMoveTo: (x: number, y: number) => void;
40- /** Let go of over the table. */
41- onThrow: (index: number, t: Throw) => void;
42- /** Put back — the throw was never committed to. */
43- onCancel: () => void;
44- };
32+ carry?: Carry;
33+}
34+
35+/**
36+ * Taking a tile out of a hand and putting it on the table. The fingers do it
37+ * through `Hand`; the keys do it through `useKeyboardHand` in ui/Seat, and both
38+ * make the same four calls, so a tile arrives in the middle the same way
39+ * whichever it was.
40+ */
41+export interface Carry {
42+ /**
43+ * Lifted out of the row: from here the canvas draws it, not the DOM. `size`
44+ * is the width the tile had in the hand, so it can be drawn the size it just
45+ * was and shrink to its size in the pool rather than popping.
46+ */
47+ onPickUp: (index: number, x: number, y: number, size: number) => void;
48+ onMoveTo: (x: number, y: number) => void;
49+ /** Let go of over the table. */
50+ onThrow: (index: number, t: Throw) => void;
51+ /** Put back — the throw was never committed to. */
52+ onCancel: () => void;
4553 }
4654
4755 /** How far a pointer must travel before a tap becomes a drag. */
⋯ 294 unchanged lines
modifiedsrc/ui/Seat.tsx+209 −16
11 import { useEffect, useRef, useState } from 'react';
22 import type { TableCtl } from '../game/ctl';
3-import { labelOf, WIND_EN, WIND_NAMES } from '../game/tiles';
3+import { labelOf, WIND_EN, WIND_NAMES, type Tile } from '../game/tiles';
44 import type { SeatId } from '../game/types';
5-import type { TablePool } from '../table/pool';
5+import { MAX_THROW_SPEED, type TablePool } from '../table/pool';
66 import { Bi, BiEm, CLAIM_LABEL } from './Bi';
77 import { showingColliders } from './colliders';
88 import { useT } from './lang';
9-import { Hand } from './Hand';
9+import { Hand, type Carry } from './Hand';
1010 import { SEAT_ROT, SEAT_SLOT } from './rotation';
1111 import { TileView } from './TileView';
1212
⋯ 150 unchanged lines
163163 }
164164 : undefined;
165165
166+ useKeyboardHand({ myTurn, hand: p.hand, sel, setSel, drawnIndex, carry, pool, seat, doDiscard });
167+
166168 return (
167169 // `data-seat` is how table/geometry.ts finds this seat's hand to work out
168170 // where it throws from — see measure(). It carries the seat itself, not the
⋯ 45 unchanged lines
214216 )}
215217 </div>
216218
217- <div className="actionbar">
219+ {/* The tiles and the buttons that act on them, on one row: 打出 sits at
220+ the right-hand end of your own hand, where the tile you are about to
221+ throw already is. It used to be a row of its own above the hand, and
222+ a row that is only there sometimes is a row that shoves the sets and
223+ flowers up the strip every time you lift a tile. On a phone there is
224+ no room beside a hand, so the wrapper steps out of the way and the
225+ bar goes back to being its own row — `display: contents`. */}
226+ <div className="hand-row">
227+ <Hand
228+ tiles={p.hand}
229+ faceDown={concealed}
230+ drawnIndex={drawnIndex}
231+ selected={sel}
232+ canDiscard={myTurn}
233+ onSelect={setSel}
234+ onDiscard={doDiscard}
235+ onMove={(from, to) => game.reorderHand(seat, from, to)}
236+ carry={carry}
237+ />
238+
239+ <div className="actionbar">
218240 {claiming && (
219241 <>
220242 {opts!.map((o, i) => (
⋯ 61 unchanged lines
282304 </>
283305 )}
284306 {delta !== 0 && <div className={`delta ${delta > 0 ? 'up' : 'down'}`}>{delta > 0 ? `+${delta}` : delta}</div>}
307+ </div>
285308 </div>
286-
287- <Hand
288- tiles={p.hand}
289- faceDown={concealed}
290- drawnIndex={drawnIndex}
291- selected={sel}
292- canDiscard={myTurn}
293- onSelect={setSel}
294- onDiscard={doDiscard}
295- onMove={(from, to) => game.reorderHand(seat, from, to)}
296- carry={carry}
297- />
298309
299310 <div className="nameplate">
300311 <span className={`wind ${isDealer ? 'dealer' : ''}`}>
⋯ 54 unchanged lines
355366 </div>
356367 );
357368 }
369+
370+/**
371+ * A hand played without letting go of the keys.
372+ *
373+ * ← and → walk the selection along the row, and space throws the tile that is
374+ * lifted. How long space is *held* is how hard it goes: the tile draws back out
375+ * of the hand while the key is down, the way an arm does, and leaves when it is
376+ * let go. A tap of the key is a gentle slide in; a long hold is a tile that
377+ * crosses the square and barges what is lying there.
378+ *
379+ * The same two calls the fingers make — `carry.onPickUp` and `carry.onThrow` in
380+ * ui/Seat — so a thrown tile is thrown by the physics either way, and the throw
381+ * gate applies: from a seat with a standing wall in front of it there is no
382+ * line in, so space places the tile over the wall instead, exactly as a tap
383+ * does. Only ever on the seat whose turn it is, so the four of them never
384+ * argue about a key.
385+ */
386+function useKeyboardHand({
387+ myTurn,
388+ hand,
389+ sel,
390+ setSel,
391+ drawnIndex,
392+ carry,
393+ pool,
394+ seat,
395+ doDiscard,
396+}: {
397+ myTurn: boolean;
398+ hand: Tile[];
399+ sel: number | null;
400+ setSel: (to: number | null) => void;
401+ drawnIndex: number | null;
402+ carry: Carry | undefined;
403+ pool?: TablePool;
404+ seat: SeatId;
405+ doDiscard: (i: number) => void;
406+}) {
407+ // What the listeners read. Kept current without them being torn down and put
408+ // back on every render to see it.
409+ const now = useRef({ myTurn, hand, sel, setSel, drawnIndex, carry, pool, seat, doDiscard });
410+ now.current = { myTurn, hand, sel, setSel, drawnIndex, carry, pool, seat, doDiscard };
411+ const charge = useRef<Charge | null>(null);
412+
413+ useEffect(() => {
414+ /** Where the lifted tile is, and which way the middle of the table lies. */
415+ const aim = (index: number) => {
416+ const table = document.querySelector<HTMLElement>('.table');
417+ const cell = document.querySelector<HTMLElement>(
418+ `[data-seat="${now.current.seat}"] .hand-cell:nth-child(${index + 1}) .tile`,
419+ );
420+ if (!table || !cell) return null;
421+ const base = table.getBoundingClientRect();
422+ const box = cell.getBoundingClientRect();
423+ const from = {
424+ x: box.left + box.width / 2 - base.left,
425+ y: box.top + box.height / 2 - base.top,
426+ };
427+ // At the middle of the square if there is one, and at the middle of the
428+ // table if the wall has been pushed somewhere odd — either way it is the
429+ // line a tile would be slid along.
430+ const ring = table.querySelector<HTMLElement>('.wall-ring')?.getBoundingClientRect();
431+ const to = ring
432+ ? { x: ring.left + ring.width / 2 - base.left, y: ring.top + ring.height / 2 - base.top }
433+ : { x: base.width / 2, y: base.height / 2 };
434+ const dx = to.x - from.x;
435+ const dy = to.y - from.y;
436+ const len = Math.hypot(dx, dy) || 1;
437+ return { from, size: Math.min(box.width, box.height), dir: { x: dx / len, y: dy / len } };
438+ };
439+
440+ const drawBack = () => {
441+ const c = charge.current;
442+ if (!c) return;
443+ const p = power(c.at);
444+ now.current.carry?.onMoveTo(c.from.x - c.dir.x * pullOf(p), c.from.y - c.dir.y * pullOf(p));
445+ c.frame = requestAnimationFrame(drawBack);
446+ };
447+
448+ const letGo = () => {
449+ const c = charge.current;
450+ charge.current = null;
451+ if (!c) return;
452+ cancelAnimationFrame(c.frame);
453+ return c;
454+ };
455+
456+ const down = (e: KeyboardEvent) => {
457+ const { myTurn, hand, sel, setSel, drawnIndex, carry, pool } = now.current;
458+ // The colliders sandbox has the keys — see ui/colliders.
459+ if (!myTurn || showingColliders() || e.metaKey || e.ctrlKey || e.altKey) return;
460+ if (e.target instanceof HTMLElement && e.target.matches('input, textarea, select')) return;
461+ const n = hand.length;
462+ if (n === 0) return;
463+
464+ if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
465+ e.preventDefault();
466+ const step = e.key === 'ArrowRight' ? 1 : -1;
467+ // Nothing lifted yet: start at the tile just drawn, which is the one
468+ // the decision is actually about.
469+ setSel(sel === null ? drawnIndex ?? (step > 0 ? 0 : n - 1) : (sel + step + n) % n);
470+ return;
471+ }
472+ if (e.key === 'Escape') {
473+ if (letGo()) carry?.onCancel();
474+ setSel(null);
475+ return;
476+ }
477+ if (e.key !== ' ' || e.repeat) return;
478+ e.preventDefault();
479+ if (sel === null) return setSel(drawnIndex ?? n - 1);
480+ if (charge.current || !carry) return;
481+
482+ const at = aim(sel);
483+ if (!at) return;
484+ // Wound up in the fingers. Nothing has been played yet — Escape, or a
485+ // wall in the way, still puts it back in the row.
486+ carry.onPickUp(sel, at.from.x, at.from.y, at.size);
487+ charge.current = { index: sel, at: performance.now(), from: at.from, dir: at.dir, frame: 0 };
488+ // Whether there is a line in at all is the pool's question, asked from
489+ // where the tile actually is — the same gate a flick meets.
490+ charge.current.thrown = !pool || pool.canThrowFrom(at.from.x, at.from.y);
491+ drawBack();
492+ };
493+
494+ const up = (e: KeyboardEvent) => {
495+ if (e.key !== ' ') return;
496+ const c = letGo();
497+ if (!c) return;
498+ const { carry, doDiscard } = now.current;
499+ if (!carry) return;
500+ if (!c.thrown) {
501+ // No line in. It goes over the wall instead of through it, which is
502+ // what a tap has always done.
503+ carry.onCancel();
504+ doDiscard(c.index);
505+ return;
506+ }
507+ const p = power(c.at);
508+ const speed = SLIDE_IN + (MAX_THROW_SPEED - SLIDE_IN) * p;
509+ const pull = pullOf(p);
510+ carry.onThrow(c.index, {
511+ x: c.from.x - c.dir.x * pull,
512+ y: c.from.y - c.dir.y * pull,
513+ vx: c.dir.x * speed,
514+ vy: c.dir.y * speed,
515+ spin: 0,
516+ });
517+ };
518+
519+ window.addEventListener('keydown', down);
520+ window.addEventListener('keyup', up);
521+ return () => {
522+ window.removeEventListener('keydown', down);
523+ window.removeEventListener('keyup', up);
524+ const c = charge.current;
525+ charge.current = null;
526+ if (c) cancelAnimationFrame(c.frame);
527+ };
528+ }, []);
529+}
530+
531+/** A tile wound up but not yet let go of. */
532+interface Charge {
533+ index: number;
534+ /** When the key went down. */
535+ at: number;
536+ from: { x: number; y: number };
537+ dir: { x: number; y: number };
538+ frame: number;
539+ /** Whether there is a line into the middle from where it was lifted. */
540+ thrown?: boolean;
541+}
542+
543+/** How long space has to be held for a throw at full stretch. */
544+const FULL_CHARGE = 700;
545+/** A tap of the key: enough to slide the tile in and no more. */
546+const SLIDE_IN = 900;
547+
548+const power = (since: number) => Math.min(1, Math.max(0, (performance.now() - since) / FULL_CHARGE));
549+/** How far the tile is drawn back out of the row, which is the power gauge. */
550+const pullOf = (p: number) => 8 + 52 * p;
modifiedsrc/ui/WallRing.tsx+12 −2
⋯ 35 unchanged lines
3636 * measures these elements to build the colliders rather than restating the CSS
3737 * that sizes them.
3838 */
39-export function WallRing({ state }: { state: WallProgress }) {
39+export function WallRing({ state, whole }: { state: WallProgress; whole?: boolean }) {
4040 const t = useT();
4141 const ring = useRef<HTMLDivElement>(null);
4242 const square = useSquare(ring);
43- const sides = ringLayout(wallStacks(state), RING, breakAt(state.dealer, state.dice));
43+ // `whole` puts every tile back — a hundred and forty-four, the square as it
44+ // is built and before anybody has drawn from it. Dev only, from the dice
45+ // panel: the wall is only whole for the moment before the deal, which is not
46+ // long enough to look at it in. It is a *look*, not a state — the hand is
47+ // untouched, and the physics follows what is drawn (table/geometry.ts reads
48+ // the stacks off the screen), so a tile thrown at this bounces off it.
49+ const sides = ringLayout(
50+ wallStacks(whole ? { ...state, drawnFront: 0, drawnBack: 0 } : state),
51+ RING,
52+ breakAt(state.dealer, state.dice),
53+ );
4454 const { slide, take } = useSliding(ring, square);
4555 // Where they have been pushed to, in tiles — the form `WALL_PLACED` is
4656 // written in, so what a push found can be pasted straight back into it. For
⋯ 215 unchanged lines