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