anvilsign in

collin/mahjong

1import { useLayoutEffect, useRef, useState } from 'react';
2import {
3 ringFor,
4 ringLayout,
5 ringOpening,
6 WALL_SIDES,
7 wallStacks,
8 type RingCapacity,
9} from '../game/wall';
10import type { GameState } from '../game/engine';
11
12/**
13 * What is left of the wall, built as a square in the middle of the table.
14 *
15 * Four staggered sides like a #, with the corners open — but a square the size
16 * of the wall *as it is dealt*, not of 144 tiles. Eighteen full-size stacks a
17 * side is longer than the middle of any ordinary window is tall, and by the
18 * time anyone is looking there is nothing like that much wall left, so it is
19 * rebuilt smaller and the tiles stay the size of the ones in your hand. What
20 * goes where is `ringFor` and `ringLayout` in `game/wall.ts`; this measures the
21 * middle for them and puts the answer on screen.
22 *
23 * Every stack carries its index, and that is load-bearing — `table/geometry.ts`
24 * measures these elements to build the colliders rather than restating the CSS
25 * that sizes them.
26 */
27export function WallRing({ state }: { state: GameState }) {
28 const ring = useRef<HTMLDivElement>(null);
29 const { cap, opening } = useSquare(ring);
30 const sides = ringLayout(wallStacks(state), cap);
31
32 return (
33 <div
34 className="wall-ring"
35 ref={ring}
36 aria-label="牌牆"
37 style={opening ? { width: opening.w, height: opening.h } : undefined}
38 >
39 {/* One cell, never drawn: the ring is measured in stacks, so something has
40 to be the size of a stack before any of them have been placed. */}
41 <i className="wall-probe" />
42 {WALL_SIDES.map((cls, side) => (
43 <div className={`wall-side ${cls}`} key={cls}>
44 {sides[side].map(({ index, stack }) => (
45 <div
46 key={index}
47 data-stack={index}
48 className={`wall-stack s${stack.count}${stack.dead ? ' dead' : ''}${
49 stack.next ? ' next' : ''
50 }`}
51 >
52 <i />
53 </div>
54 ))}
55 </div>
56 ))}
57 </div>
58 );
59}
60
61interface Square {
62 cap: RingCapacity;
63 /** The opening the square leaves, once there is a middle to measure. */
64 opening: { w: number; h: number } | null;
65}
66
67/**
68 * How big to build the square, measured rather than worked out twice: how big a
69 * tile is is CSS's business, so one cell is rendered and asked how it came out,
70 * and the middle is whatever the four strips have left. Nothing here depends on
71 * the state of the hand — the square is built once for the window it is in and
72 * then eaten, so it does not close in on the discards lying inside it.
73 */
74function useSquare(ring: React.RefObject<HTMLDivElement | null>): Square {
75 const [square, setSquare] = useState<Square>({ cap: { h: 1, v: 1 }, opening: null });
76
77 useLayoutEffect(() => {
78 const el = ring.current;
79 const middle = el?.parentElement;
80 const probe = el?.querySelector<HTMLElement>('.wall-probe');
81 if (!el || !middle || !probe) return;
82
83 const read = () => {
84 const box = probe.getBoundingClientRect();
85 const cell = { s: box.width, d: box.height };
86 if (cell.s <= 0) return;
87 const cap = ringFor({ w: middle.clientWidth, h: middle.clientHeight }, cell);
88 const opening = ringOpening(cap, cell);
89 setSquare((was) =>
90 was.cap.h === cap.h &&
91 was.cap.v === cap.v &&
92 was.opening?.w === opening.w &&
93 was.opening?.h === opening.h
94 ? was
95 : { cap, opening },
96 );
97 };
98
99 read();
100 // The middle, not the square: the square's own size is this effect's doing,
101 // and watching it would be watching itself.
102 const observer = new ResizeObserver(read);
103 observer.observe(middle);
104 observer.observe(probe);
105 return () => observer.disconnect();
106 }, [ring]);
107
108 return square;
109}