anvilsign in

collin/mahjong

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