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