anvilsign in

collin/mahjong

1import { useLayoutEffect, useRef, useState } from 'react';
2import { STACKS_PER_SIDE } from '../game/tiles';
3import {
4 rhombus,
5 ringLayout,
6 RING,
7 WALL_SIDES,
8 wallStacks,
9 type Rhombus,
10 type WallProgress,
11} from '../game/wall';
12
13import { useT } from './lang';
14
15/**
16 * What is left of the wall, built as a square in the middle of the table.
17 *
18 * Four staggered sides like a #, with the corners open — but a square the size
19 * of the wall *as it is dealt*, not of 144 tiles. Eighteen full-size stacks a
20 * side is longer than the middle of any ordinary window is tall, and by the
21 * time anyone is looking there is nothing like that much wall left, so it is
22 * rebuilt smaller and the tiles stay the size of the ones in your hand. What
23 * goes where is `RING` and `ringLayout` in `game/wall.ts`; this measures the
24 * middle for them and puts the answer on screen.
25 *
26 * Every stack carries its index, and that is load-bearing — `table/geometry.ts`
27 * measures these elements to build the colliders rather than restating the CSS
28 * that sizes them.
29 */
30export function WallRing({ state }: { state: WallProgress }) {
31 const t = useT();
32 const ring = useRef<HTMLDivElement>(null);
33 const { opening, cell, shape } = useSquare(ring);
34 const sides = ringLayout(wallStacks(state), RING);
35
36 return (
37 <div
38 className="wall-ring"
39 ref={ring}
40 aria-label={t('牌牆', 'The wall')}
41 style={
42 opening
43 ? // `--ws` is set from here rather than from the tile, because the
44 // square is the fixed thing now and the tile is what gives.
45 ({
46 width: opening.w,
47 height: opening.h,
48 '--ws': `${cell}px`,
49 // A side is its stacks and nothing else, so there is no sum for
50 // the corners to come out of by a pixel.
51 '--wall-len': `${RING.h * cell}px`,
52 } as React.CSSProperties)
53 : undefined
54 }
55 >
56 {/* One cell, never drawn: the ring is measured in stacks, so something has
57 to be the size of a stack before any of them have been placed. */}
58 <i className="wall-probe" />
59 {WALL_SIDES.map((cls, side) => (
60 <div
61 className={`wall-side ${cls}`}
62 key={cls}
63 style={
64 shape
65 ? ({
66 '--vx': `${shape.places[side].x}px`,
67 '--vy': `${shape.places[side].y}px`,
68 '--dir': `${shape.places[side].dir}deg`,
69 } as React.CSSProperties)
70 : undefined
71 }
72 >
73 {sides[side].map(({ index, stack }) => (
74 <div
75 key={index}
76 data-stack={index}
77 // The 16-tile 底牌 tail is not marked out. It is still the tail —
78 // `Stack.dead` says so and the engine ends the hand on it — but a
79 // wall on a table is one wall, not a wall with the end greyed off.
80 className={`wall-stack s${stack.count}${stack.next ? ' next' : ''}`}
81 // The layout already knows which way this stack is lying, so the
82 // physics is told rather than left to work it out off a box that
83 // has been turned. See table/geometry.ts.
84 data-angle={shape ? shape.places[side].dir : 0}
85 >
86 <i />
87 </div>
88 ))}
89 </div>
90 ))}
91 </div>
92 );
93}
94
95interface Square {
96 /** The opening the square leaves, once there is a middle to measure. */
97 opening: { w: number; h: number } | null;
98 /** How big a stack came out — the wall's own tile size, in px. */
99 cell: number;
100 /** Where the four walls stand, from `rhombus`. */
101 shape: Rhombus | null;
102}
103
104/**
105 * How big to build the square, measured rather than worked out twice: how big a
106 * tile is is CSS's business, so one cell is rendered and asked how it came out,
107 * and the middle is whatever the four strips have left. Nothing here depends on
108 * the state of the hand — the square is built once for the window it is in and
109 * then eaten, so it does not close in on the discards lying inside it.
110 */
111function useSquare(ring: React.RefObject<HTMLDivElement | null>): Square {
112 const [square, setSquare] = useState<Square>({ opening: null, cell: 0, shape: null });
113
114 useLayoutEffect(() => {
115 const el = ring.current;
116 const middle = el?.parentElement;
117 const probe = el?.querySelector<HTMLElement>('.wall-probe');
118 if (!el || !middle || !probe) return;
119
120 // Everything the square is worked out *from*. Both observers below can be
121 // set off by this effect's own output — the probe is sized by the `--ws` it
122 // writes, and the ring's style attribute is where it writes it — so the
123 // guard is not a nicety: without it, one recompute schedules the next and
124 // the tab stops answering. Same inputs, nothing to do.
125 let last = '';
126
127 const read = () => {
128 const box = probe.getBoundingClientRect();
129 if (box.width <= 0) return;
130 // What shape a stack is, is CSS's business — read it, don't restate it.
131 // Scale-free, so this is the same number however big the square came out.
132 const ratio = box.height / box.width;
133 const style = getComputedStyle(el);
134 // The corner it turns, how far round it is set, and how much daylight is
135 // left where two walls meet — in tiles, and under one of them.
136 const angle = parseFloat(style.getPropertyValue('--wall-angle')) || 90;
137 const turn = parseFloat(style.getPropertyValue('--wall-turn')) || 0;
138 const gap = parseFloat(style.getPropertyValue('--wall-gap')) || 0;
139
140 const from = `${middle.clientWidth}x${middle.clientHeight}/${ratio}/${angle}/${turn}/${gap}`;
141 if (from === last) return;
142 last = from;
143
144 // Everything about the shape is linear in the stack, so it is asked for
145 // once at a stack of one and the answer scaled to the middle there is.
146 const unit = rhombus(STACKS_PER_SIDE, ratio, angle, turn, gap);
147 const s = Math.max(
148 1,
149 Math.min(middle.clientWidth / unit.box.w, middle.clientHeight / unit.box.h),
150 );
151 const shape = rhombus(STACKS_PER_SIDE * s, ratio * s, angle, turn, gap);
152 setSquare({ opening: { w: shape.open.w, h: shape.open.h }, cell: s, shape });
153 };
154
155 read();
156 // The middle, not the square: the square's own size is this effect's doing,
157 // and watching it would be watching itself. The probe is not watched for
158 // the same reason — it is `--ws` wearing a box, and `--ws` is the answer.
159 const observer = new ResizeObserver(read);
160 observer.observe(middle);
161 // The shape is three numbers on the element and none of them changes
162 // anything that has a size, so the observer above would never hear about
163 // one. `/wall.html` sets them on the ring itself, so that is what is
164 // watched — and this effect writes to that same attribute, which is what
165 // `last` is there to absorb.
166 const styled = new MutationObserver(read);
167 styled.observe(el, { attributes: true, attributeFilter: ['style'] });
168 return () => {
169 observer.disconnect();
170 styled.disconnect();
171 };
172 }, [ring]);
173
174 return square;
175}