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 not set from here: the tile is the table's, one size for
70 // every tile on it, and the square is whatever that comes to.
71 ({
72 width: square.open,
73 height: square.open,
74 // A wall is its stacks and nothing else, so there is no sum for
75 // the corners to come out of by a pixel.
76 '--wall-len': `${square.len}px`,
77 } as React.CSSProperties)
78 : undefined
79 }
80 >
81 {/* One cell, never drawn: the ring is measured in stacks, so something has
82 to be the size of a stack before any of them have been placed. */}
83 <i className="wall-probe" />
84 {WALL_SIDES.map((cls, side) => (
85 <div
86 className={`wall-side ${cls}`}
87 key={cls}
88 onPointerDown={take(side)}
89 style={
90 {
91 '--dx': `${slide[side].x * (square?.cell ?? 0)}px`,
92 '--dy': `${slide[side].y * (square?.cell ?? 0)}px`,
93 } as React.CSSProperties
94 }
95 >
96 {sides[side].map(({ index, stack }) => (
97 <div
98 key={index}
99 data-stack={index}
100 // The 16-tile 底牌 tail is not marked out. It is still the tail —
101 // `Stack.dead` says so and the engine ends the hand on it — but a
102 // wall on a table is one wall, not a wall with the end greyed off.
103 className={`wall-stack s${stack.count}${stack.next ? ' next' : ''}`}
104 >
105 {/* A spent place keeps its room in the row and shows nothing:
106 that hole is where the drawing has got to. */}
107 {stack.count > 0 && <i />}
108 </div>
109 ))}
110 </div>
111 ))}
112 </div>
113 );
114}
115
116/**
117 * Pushing a wall about.
118 *
119 * What everybody does to a wall as it is eaten: the stacks are drawn from one
120 * end, a gap opens where the drawing is happening, and somebody shoves what is
121 * left along to close it up and keep it in reach. It moves the tiles and
122 * nothing else — the wall is the same wall in the same order and the next draw
123 * is still the next draw, exactly as washing the discards about moves no tile
124 * that anybody is counting.
125 *
126 * The finger's own direction, since the square is square: a wall goes where it
127 * is pushed. The point of being able to push one is finding where it should
128 * have been standing, and a wall held to its own line can only answer half of
129 * that.
130 *
131 * The pool is told, because a wall that has moved is a wall a thrown tile
132 * bounces off somewhere else, and the pool only re-measures when it has reason
133 * to — `invalidate` in table/pool.ts, and the listener in ui/Pool.tsx.
134 */
135function useSliding(ring: React.RefObject<HTMLDivElement | null>, square: WallSquare | null) {
136 // Starting from where the last person to push them about left them, so a
137 // push carries on from the placement rather than from the built square, and
138 // what comes back is the number to write down.
139 const [slide, setSlide] = useState<WallNudge[]>(() => WALL_PLACED.map((n) => ({ ...n })));
140 // Where the walls are, as the drag knows it. React state is what draws them,
141 // and it commits when it commits — which is one push behind if the pointer
142 // has already gone up. This is the same thing, now.
143 const live = useRef<WallNudge[]>(slide);
144 const drag = useRef<{ side: number; fromX: number; fromY: number; at: WallNudge } | null>(null);
145 // What the drag needs to know, kept current without the listeners below
146 // having to be torn down and put back on every render to see it.
147 const now = useRef(square);
148 now.current = square;
149
150 const moved = () => ring.current?.dispatchEvent(new CustomEvent('wallmoved', { bubbles: true }));
151
152 const take = (side: number) => (e: React.PointerEvent<HTMLDivElement>) => {
153 if (!now.current || e.button !== 0) return;
154 // A wall being pushed, not the table being reached into.
155 e.stopPropagation();
156 e.preventDefault();
157 try {
158 e.currentTarget.setPointerCapture?.(e.pointerId);
159 } catch {
160 // Uncaptured still works while the pointer stays over the wall.
161 }
162 drag.current = { side, fromX: e.clientX, fromY: e.clientY, at: live.current[side] };
163 };
164
165 // Put on once and left alone. Registering these per render was three
166 // listeners torn down and rebuilt on every state change, for nothing.
167 useLayoutEffect(() => {
168 const move = (e: PointerEvent) => {
169 const d = drag.current;
170 const cell = now.current?.cell;
171 if (!d || !cell) return;
172 // In tiles, which is what the placement is written in: the pointer moves
173 // in pixels and a tile is however many of them the window came out at.
174 const bound = STACKS_PER_SIDE * 2;
175 const clamp = (v: number) => Math.min(bound, Math.max(-bound, v));
176 const to: WallNudge = {
177 x: clamp(d.at.x + (e.clientX - d.fromX) / cell),
178 y: clamp(d.at.y + (e.clientY - d.fromY) / cell),
179 };
180 const had = live.current[d.side];
181 if (had.x === to.x && had.y === to.y) return;
182 live.current = live.current.map((v, i) => (i === d.side ? to : v));
183 setSlide(live.current);
184 moved();
185 };
186 const up = () => {
187 const d = drag.current;
188 if (!d) return;
189 drag.current = null;
190 moved();
191 // Said out loud at the end of the push, not during it: what the fingers
192 // found, in tiles, which is the form `WALL_PLACED` is written in.
193 const at = live.current;
194 console.log(
195 `[wall] ${WALL_SIDES[d.side]} pushed to ${say(at[d.side])} — ` +
196 WALL_SIDES.map((cls, i) => `${cls} ${say(at[i])}`).join(' · '),
197 );
198 };
199 window.addEventListener('pointermove', move);
200 window.addEventListener('pointerup', up);
201 window.addEventListener('pointercancel', up);
202 return () => {
203 window.removeEventListener('pointermove', move);
204 window.removeEventListener('pointerup', up);
205 window.removeEventListener('pointercancel', up);
206 };
207 // eslint-disable-next-line react-hooks/exhaustive-deps
208 }, []);
209
210 return { slide, take };
211}
212
213/** A push, in the two decimal places `WALL_PLACED` is written to. */
214const say = (n: WallNudge) => `${round(n.x)},${round(n.y)}`;
215const round = (v: number) => Math.round(v * 100) / 100;
216
217/**
218 * How big the square came out, measured rather than worked out twice.
219 *
220 * How big a tile is is CSS's business — `--tile-w` in styles.css, one size for
221 * every tile on the table — so one cell is rendered and asked how it came out,
222 * and `wallSquare` says what a square of eighteen of them measures. Nothing
223 * here decides the tile any more, which is the whole point: a wall tile is a
224 * hand tile, and a square built from them is however big it is.
225 *
226 * Nothing here depends on the state of the hand either — the square is built
227 * once for the window it is in and then eaten, so it does not close in on the
228 * discards lying inside it.
229 */
230function useSquare(ring: React.RefObject<HTMLDivElement | null>): WallSquare | null {
231 const [square, setSquare] = useState<WallSquare | null>(null);
232
233 useLayoutEffect(() => {
234 const el = ring.current;
235 const middle = el?.parentElement;
236 const probe = el?.querySelector<HTMLElement>('.wall-probe');
237 if (!el || !middle || !probe) return;
238
239 // Same answer, nothing to do. Cheap, and it keeps a resize that did not
240 // change the tile from putting a fresh object into state every frame.
241 let last = '';
242
243 const read = () => {
244 const box = probe.getBoundingClientRect();
245 if (box.width <= 0) return;
246 // What size and shape a stack is, is CSS's business — read it, don't
247 // restate it.
248 const from = `${box.width.toFixed(2)}/${box.height.toFixed(2)}`;
249 if (from === last) return;
250 last = from;
251 setSquare(wallSquare(box.width, box.height / box.width));
252 };
253
254 read();
255 // The probe *is* the tile, so watching it is watching the one input. Safe
256 // to watch now: this hook no longer writes `--ws`, so nothing it does can
257 // change the probe's size and set itself off again.
258 const observer = new ResizeObserver(read);
259 observer.observe(probe);
260 const table = el.closest('.table') as HTMLElement | null;
261 if (table) observer.observe(table);
262 return () => observer.disconnect();
263 }, [ring]);
264
265 return square;
266}