anvilsign in

collin/mahjong

1import { useLayoutEffect, useRef, useState } from 'react';
2import { STACKS_PER_SIDE } from '../game/tiles';
3import {
4 breakAt,
5 ringLayout,
6 RING,
7 wallSquare,
8 WALL_PLACED,
9 WALL_SIDES,
10 wallStacks,
11 type WallNudge,
12 type WallProgress,
13 type WallSquare,
14} from '../game/wall';
15
16import { useT } from './lang';
17
18/**
19 * What is left of the wall, built as a square in the middle of the table.
20 *
21 * Four staggered sides like a #, with the corners covered rather than mitred —
22 * but a square the size of the wall *as it is dealt*, not of 144 tiles.
23 * Eighteen full-size stacks a side is longer than the middle of any ordinary
24 * window is tall, and by the time anyone is looking there is nothing like that
25 * much wall left, so it is rebuilt smaller and the tiles stay the size of the
26 * ones in your hand. What goes where is `RING` and `ringLayout` in
27 * `game/wall.ts`; this measures the middle for them and puts the answer on
28 * screen.
29 *
30 * Nothing here is turned. A square wants no rotation to describe it: each wall
31 * is pinned to its own edge of the opening in CSS, the two flat ones run their
32 * stacks across and the two upright ones run them down, and that is the whole
33 * of it. `--wall-len` and `--ws` are the only numbers the CSS is given.
34 *
35 * Every stack carries its index, and that is load-bearing — `table/geometry.ts`
36 * measures these elements to build the colliders rather than restating the CSS
37 * that sizes them.
38 */
39export function WallRing({ state, whole }: { state: WallProgress; whole?: boolean }) {
40 const t = useT();
41 const ring = useRef<HTMLDivElement>(null);
42 const square = useSquare(ring);
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 );
54 const { slide, take } = useSliding(ring, square);
55 // Where they have been pushed to, in tiles — the form `WALL_PLACED` is
56 // written in, so what a push found can be pasted straight back into it. For
57 // the console and anything reading it off the DOM rather than have it
58 // threaded through as a prop nothing else wants.
59 const nudged = slide.map(say).join(' ');
60
61 return (
62 <div
63 className="wall-ring"
64 ref={ring}
65 data-nudged={nudged}
66 aria-label={t('牌牆', 'The wall')}
67 style={
68 square
69 ? // `--ws` is set from here rather than from the tile, because the
70 // square is the fixed thing now and the tile is what gives.
71 ({
72 width: square.open,
73 height: square.open,
74 '--ws': `${square.cell}px`,
75 // A wall is its stacks and nothing else, so there is no sum for
76 // the corners to come out of by a pixel.
77 '--wall-len': `${square.len}px`,
78 } as React.CSSProperties)
79 : undefined
80 }
81 >
82 {/* One cell, never drawn: the ring is measured in stacks, so something has
83 to be the size of a stack before any of them have been placed. */}
84 <i className="wall-probe" />
85 {WALL_SIDES.map((cls, side) => (
86 <div
87 className={`wall-side ${cls}`}
88 key={cls}
89 onPointerDown={take(side)}
90 style={
91 {
92 '--dx': `${slide[side].x * (square?.cell ?? 0)}px`,
93 '--dy': `${slide[side].y * (square?.cell ?? 0)}px`,
94 } as React.CSSProperties
95 }
96 >
97 {sides[side].map(({ index, stack }) => (
98 <div
99 key={index}
100 data-stack={index}
101 // The 16-tile 底牌 tail is not marked out. It is still the tail —
102 // `Stack.dead` says so and the engine ends the hand on it — but a
103 // wall on a table is one wall, not a wall with the end greyed off.
104 className={`wall-stack s${stack.count}${stack.next ? ' next' : ''}`}
105 >
106 {/* A spent place keeps its room in the row and shows nothing:
107 that hole is where the drawing has got to. */}
108 {stack.count > 0 && <i />}
109 </div>
110 ))}
111 </div>
112 ))}
113 </div>
114 );
115}
116
117/**
118 * Pushing a wall about.
119 *
120 * What everybody does to a wall as it is eaten: the stacks are drawn from one
121 * end, a gap opens where the drawing is happening, and somebody shoves what is
122 * left along to close it up and keep it in reach. It moves the tiles and
123 * nothing else — the wall is the same wall in the same order and the next draw
124 * is still the next draw, exactly as washing the discards about moves no tile
125 * that anybody is counting.
126 *
127 * The finger's own direction, since the square is square: a wall goes where it
128 * is pushed. The point of being able to push one is finding where it should
129 * have been standing, and a wall held to its own line can only answer half of
130 * that.
131 *
132 * The pool is told, because a wall that has moved is a wall a thrown tile
133 * bounces off somewhere else, and the pool only re-measures when it has reason
134 * to — `invalidate` in table/pool.ts, and the listener in ui/Pool.tsx.
135 */
136function useSliding(ring: React.RefObject<HTMLDivElement | null>, square: WallSquare | null) {
137 // Starting from where the last person to push them about left them, so a
138 // push carries on from the placement rather than from the built square, and
139 // what comes back is the number to write down.
140 const [slide, setSlide] = useState<WallNudge[]>(() => WALL_PLACED.map((n) => ({ ...n })));
141 // Where the walls are, as the drag knows it. React state is what draws them,
142 // and it commits when it commits — which is one push behind if the pointer
143 // has already gone up. This is the same thing, now.
144 const live = useRef<WallNudge[]>(slide);
145 const drag = useRef<{ side: number; fromX: number; fromY: number; at: WallNudge } | null>(null);
146 // What the drag needs to know, kept current without the listeners below
147 // having to be torn down and put back on every render to see it.
148 const now = useRef(square);
149 now.current = square;
150
151 const moved = () => ring.current?.dispatchEvent(new CustomEvent('wallmoved', { bubbles: true }));
152
153 const take = (side: number) => (e: React.PointerEvent<HTMLDivElement>) => {
154 if (!now.current || e.button !== 0) return;
155 // A wall being pushed, not the table being reached into.
156 e.stopPropagation();
157 e.preventDefault();
158 try {
159 e.currentTarget.setPointerCapture?.(e.pointerId);
160 } catch {
161 // Uncaptured still works while the pointer stays over the wall.
162 }
163 drag.current = { side, fromX: e.clientX, fromY: e.clientY, at: live.current[side] };
164 };
165
166 // Put on once and left alone. Registering these per render was three
167 // listeners torn down and rebuilt on every state change, for nothing.
168 useLayoutEffect(() => {
169 const move = (e: PointerEvent) => {
170 const d = drag.current;
171 const cell = now.current?.cell;
172 if (!d || !cell) return;
173 // In tiles, which is what the placement is written in: the pointer moves
174 // in pixels and a tile is however many of them the window came out at.
175 const bound = STACKS_PER_SIDE * 2;
176 const clamp = (v: number) => Math.min(bound, Math.max(-bound, v));
177 const to: WallNudge = {
178 x: clamp(d.at.x + (e.clientX - d.fromX) / cell),
179 y: clamp(d.at.y + (e.clientY - d.fromY) / cell),
180 };
181 const had = live.current[d.side];
182 if (had.x === to.x && had.y === to.y) return;
183 live.current = live.current.map((v, i) => (i === d.side ? to : v));
184 setSlide(live.current);
185 moved();
186 };
187 const up = () => {
188 const d = drag.current;
189 if (!d) return;
190 drag.current = null;
191 moved();
192 // Said out loud at the end of the push, not during it: what the fingers
193 // found, in tiles, which is the form `WALL_PLACED` is written in.
194 const at = live.current;
195 console.log(
196 `[wall] ${WALL_SIDES[d.side]} pushed to ${say(at[d.side])} — ` +
197 WALL_SIDES.map((cls, i) => `${cls} ${say(at[i])}`).join(' · '),
198 );
199 };
200 window.addEventListener('pointermove', move);
201 window.addEventListener('pointerup', up);
202 window.addEventListener('pointercancel', up);
203 return () => {
204 window.removeEventListener('pointermove', move);
205 window.removeEventListener('pointerup', up);
206 window.removeEventListener('pointercancel', up);
207 };
208 // eslint-disable-next-line react-hooks/exhaustive-deps
209 }, []);
210
211 return { slide, take };
212}
213
214/** A push, in the two decimal places `WALL_PLACED` is written to. */
215const say = (n: WallNudge) => `${round(n.x)},${round(n.y)}`;
216const round = (v: number) => Math.round(v * 100) / 100;
217
218/**
219 * How big to build the square, measured rather than worked out twice: how big a
220 * tile is is CSS's business, so one cell is rendered and asked how it came out,
221 * and `wallSquare` solves for the rest. Nothing here depends on the state of the
222 * hand — the square is built once for the window it is in and then eaten, so it
223 * does not close in on the discards lying inside it.
224 */
225function useSquare(ring: React.RefObject<HTMLDivElement | null>): WallSquare | null {
226 const [square, setSquare] = useState<WallSquare | null>(null);
227
228 useLayoutEffect(() => {
229 const el = ring.current;
230 const middle = el?.parentElement;
231 const probe = el?.querySelector<HTMLElement>('.wall-probe');
232 if (!el || !middle || !probe) return;
233
234 // Everything the square is worked out *from*. The observer below can be set
235 // off by this effect's own output — the ring's size is what it writes — so
236 // the guard is not a nicety: without it, one recompute schedules the next
237 // and the tab stops answering. Same inputs, nothing to do.
238 let last = '';
239
240 const read = () => {
241 const box = probe.getBoundingClientRect();
242 if (box.width <= 0) return;
243 // What shape a stack is, is CSS's business — read it, don't restate it.
244 // Scale-free, so this is the same number however big the square came out.
245 const ratio = box.height / box.width;
246
247 // Sized off the tight way across the table, not the room between the four
248 // strips. Height is what a table is short of — 765 against 1396 — so that
249 // is what the square has to fit, and the tile is whatever that comes to.
250 // It is the whole table because the square is bigger than the middle and
251 // always was; where it goes from here is a separate question.
252 const fit = (el.closest('.table') as HTMLElement | null) ?? middle;
253 const room = fit.clientHeight;
254 const from = `${Math.round(room)}/${ratio.toFixed(3)}`;
255 if (from === last) return;
256 last = from;
257 setSquare(wallSquare(room, ratio));
258 };
259
260 read();
261 // The table, not the square: the square's own size is this effect's doing,
262 // and watching it would be watching itself. The probe is not watched for
263 // the same reason — it is `--ws` wearing a box, and `--ws` is the answer.
264 const watch = (el.closest('.table') as HTMLElement | null) ?? middle;
265 const observer = new ResizeObserver(read);
266 observer.observe(watch);
267 return () => observer.disconnect();
268 }, [ring]);
269
270 return square;
271}