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