| ⋯ 5 unchanged lines |
| 6 | 6 | | RING, |
| 7 | 7 | | WALL_SIDES, |
| 8 | 8 | | wallStacks, |
| 9 | + | type Placed, |
| 9 | 10 | | type Rhombus, |
| 10 | 11 | | type WallProgress, |
| 11 | 12 | | } from '../game/wall'; |
| ⋯ 20 unchanged lines |
| 32 | 33 | | const ring = useRef<HTMLDivElement>(null); |
| 33 | 34 | | const { opening, cell, shape } = useSquare(ring); |
| 34 | 35 | | const sides = ringLayout(wallStacks(state), RING); |
| 36 | + | const { slide, take } = useSliding(ring, shape, cell, sides); |
| 35 | 37 | | |
| 36 | 38 | | return ( |
| 37 | 39 | | <div |
| ⋯ 22 unchanged lines |
| 60 | 62 | | <div |
| 61 | 63 | | className={`wall-side ${cls}`} |
| 62 | 64 | | key={cls} |
| 65 | + | onPointerDown={take(side)} |
| 63 | 66 | | style={ |
| 64 | 67 | | shape |
| 65 | 68 | | ? ({ |
| 66 | 69 | | '--vx': `${shape.places[side].x}px`, |
| 67 | 70 | | '--vy': `${shape.places[side].y}px`, |
| 68 | 71 | | '--dir': `${shape.places[side].dir}deg`, |
| 72 | + | '--slide': `${slide[side]}px`, |
| 69 | 73 | | } as React.CSSProperties) |
| 70 | 74 | | : undefined |
| 71 | 75 | | } |
| ⋯ 20 unchanged lines |
| 92 | 96 | | ); |
| 93 | 97 | | } |
| 94 | 98 | | |
| 99 | + | /** |
| 100 | + | * Pushing a wall along its own line. |
| 101 | + | * |
| 102 | + | * What everybody does to a wall as it is eaten: the stacks are drawn from one |
| 103 | + | * end, a gap opens where the drawing is happening, and somebody shoves what is |
| 104 | + | * left along to close it up and keep it in reach. It moves the tiles and |
| 105 | + | * nothing else — the wall is the same wall in the same order and the next draw |
| 106 | + | * is still the next draw, exactly as washing the discards about moves no |
| 107 | + | * tile that anybody is counting. |
| 108 | + | * |
| 109 | + | * A wall slides only into the room it has: from where it was built to where its |
| 110 | + | * own stacks run out. It cannot leave its line, and it cannot climb over the |
| 111 | + | * one beside it. |
| 112 | + | * |
| 113 | + | * The pool is told, because a wall that has moved is a wall a thrown tile |
| 114 | + | * bounces off somewhere else, and the pool only re-measures when it has reason |
| 115 | + | * to — `invalidate` in table/pool.ts, and the listener in ui/Pool.tsx. |
| 116 | + | */ |
| 117 | + | function useSliding( |
| 118 | + | ring: React.RefObject<HTMLDivElement | null>, |
| 119 | + | shape: Rhombus | null, |
| 120 | + | cell: number, |
| 121 | + | sides: Placed[][], |
| 122 | + | ) { |
| 123 | + | const [slide, setSlide] = useState<number[]>([0, 0, 0, 0]); |
| 124 | + | const drag = useRef<{ side: number; ax: number; ay: number; from: number; at: number } | null>( |
| 125 | + | null, |
| 126 | + | ); |
| 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({ shape, cell, sides, slide }); |
| 130 | + | now.current = { shape, cell, sides, slide }; |
| 131 | + | |
| 132 | + | // A wall that has lost stacks has exactly that much line to be pushed along. |
| 133 | + | const roomOn = (side: number) => |
| 134 | + | Math.max(0, (STACKS_PER_SIDE - now.current.sides[side].length) * now.current.cell); |
| 135 | + | |
| 136 | + | const moved = () => ring.current?.dispatchEvent(new CustomEvent('wallmoved', { bubbles: true })); |
| 137 | + | |
| 138 | + | const take = (side: number) => (e: React.PointerEvent<HTMLDivElement>) => { |
| 139 | + | const place = now.current.shape?.places[side]; |
| 140 | + | if (!place || e.button !== 0 || roomOn(side) <= 0) return; |
| 141 | + | // A wall being pushed, not the table being reached into. |
| 142 | + | e.stopPropagation(); |
| 143 | + | e.preventDefault(); |
| 144 | + | const rad = (place.dir * Math.PI) / 180; |
| 145 | + | const ax = Math.cos(rad); |
| 146 | + | const ay = Math.sin(rad); |
| 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 = { |
| 153 | + | side, |
| 154 | + | ax, |
| 155 | + | ay, |
| 156 | + | from: e.clientX * ax + e.clientY * ay, |
| 157 | + | at: now.current.slide[side], |
| 158 | + | }; |
| 159 | + | }; |
| 160 | + | |
| 161 | + | // Put on once and left alone. Registering these per render was three |
| 162 | + | // listeners torn down and rebuilt on every state change, for nothing. |
| 163 | + | useLayoutEffect(() => { |
| 164 | + | const move = (e: PointerEvent) => { |
| 165 | + | const d = drag.current; |
| 166 | + | if (!d) return; |
| 167 | + | // Only what the finger did *along* the wall counts; across it, nothing. |
| 168 | + | const along = e.clientX * d.ax + e.clientY * d.ay; |
| 169 | + | const to = Math.min(0, Math.max(-roomOn(d.side), d.at + (along - d.from))); |
| 170 | + | let changed = false; |
| 171 | + | setSlide((was) => { |
| 172 | + | if (was[d.side] === to) return was; |
| 173 | + | changed = true; |
| 174 | + | return was.map((v, i) => (i === d.side ? to : v)); |
| 175 | + | }); |
| 176 | + | if (changed) moved(); |
| 177 | + | }; |
| 178 | + | const up = () => { |
| 179 | + | if (!drag.current) return; |
| 180 | + | drag.current = null; |
| 181 | + | moved(); |
| 182 | + | }; |
| 183 | + | window.addEventListener('pointermove', move); |
| 184 | + | window.addEventListener('pointerup', up); |
| 185 | + | window.addEventListener('pointercancel', up); |
| 186 | + | return () => { |
| 187 | + | window.removeEventListener('pointermove', move); |
| 188 | + | window.removeEventListener('pointerup', up); |
| 189 | + | window.removeEventListener('pointercancel', up); |
| 190 | + | }; |
| 191 | + | // eslint-disable-next-line react-hooks/exhaustive-deps |
| 192 | + | }, []); |
| 193 | + | |
| 194 | + | return { slide, take }; |
| 195 | + | } |
| 196 | + | |
| 95 | 197 | | interface Square { |
| 96 | 198 | | /** The opening the square leaves, once there is a middle to measure. */ |
| 97 | 199 | | opening: { w: number; h: number } | null; |
| ⋯ 78 unchanged lines |