| 1 | import { useLayoutEffect, useRef, useState } from 'react'; |
| 2 | import { STACKS_PER_SIDE } from '../game/tiles'; |
| 3 | import { |
| 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 | |
| 16 | import { 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 | */ |
| 39 | export 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 | */ |
| 136 | function 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. */ |
| 215 | const say = (n: WallNudge) => `${round(n.x)},${round(n.y)}`; |
| 216 | const 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 | */ |
| 225 | function 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 | } |