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