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