anvilsign in

collin/mahjong · 2f7fb415

Push a wall along its own line

Collin Richards · 2026-08-21 21:56 UTC · 2f7fb41547e756a59490a62a5a1e09a8b320bc17 · parent 8bf33e61 · browse files

modifiedsrc/styles.css+8 −1
⋯ 726 unchanged lines
727727 display: flex;
728728 justify-content: flex-end;
729729 transform-origin: 0 0;
730- transform: translate(var(--vx), var(--vy)) rotate(var(--dir));
730+ /* `--slide` last, so it runs along the wall's own line rather than the
731+ table's — pushing a wall is pushing it *along itself*. */
732+ transform: translate(var(--vx), var(--vy)) rotate(var(--dir))
733+ translateX(var(--slide, 0px));
734+ /* The ring lets everything through; a wall is the one thing you can take
735+ hold of. */
736+ pointer-events: auto;
737+ cursor: grab;
731738 }
732739 .wall-stack {
733740 position: relative;
⋯ 1564 unchanged lines
modifiedsrc/tuneWall.tsx+6 −0
⋯ 134 unchanged lines
135135 const id = requestAnimationFrame(paint);
136136 const observer = new ResizeObserver(paint);
137137 observer.observe(el);
138+ // A wall has been pushed along its line. Nothing it is measured against has
139+ // changed size, so this is the only way to hear about it — the same event
140+ // the pool listens for, which is what keeps the colliders on the tiles.
141+ const moved = () => requestAnimationFrame(paint);
142+ el.addEventListener('wallmoved', moved);
138143 return () => {
139144 cancelAnimationFrame(id);
145+ el.removeEventListener('wallmoved', moved);
140146 observer.disconnect();
141147 };
142148 }, [place, drawn, showColliders, state]);
⋯ 89 unchanged lines
modifiedsrc/ui/Pool.tsx+8 −0
⋯ 61 unchanged lines
6262 pool.attach(table);
6363 exposePool(pool);
6464 const stopDragging = dragging(table as HTMLElement, pool, () => wake.current());
65+ // Somebody has pushed a wall along. Where a thrown tile bounces has moved
66+ // with it, and the pool re-measures only when it is given a reason to.
67+ const wallMoved = () => {
68+ pool.invalidate();
69+ wake.current();
70+ };
71+ table.addEventListener('wallmoved', wallMoved);
6572
6673 let frame = 0;
6774 let previous = 0;
⋯ 67 unchanged lines
135142 resize();
136143
137144 return () => {
145+ table.removeEventListener('wallmoved', wallMoved);
138146 stopDragging();
139147 window.removeEventListener('keydown', onKey);
140148 stopColliders();
⋯ 352 unchanged lines
modifiedsrc/ui/WallRing.tsx+102 −0
⋯ 5 unchanged lines
66 RING,
77 WALL_SIDES,
88 wallStacks,
9+ type Placed,
910 type Rhombus,
1011 type WallProgress,
1112 } from '../game/wall';
⋯ 20 unchanged lines
3233 const ring = useRef<HTMLDivElement>(null);
3334 const { opening, cell, shape } = useSquare(ring);
3435 const sides = ringLayout(wallStacks(state), RING);
36+ const { slide, take } = useSliding(ring, shape, cell, sides);
3537
3638 return (
3739 <div
⋯ 22 unchanged lines
6062 <div
6163 className={`wall-side ${cls}`}
6264 key={cls}
65+ onPointerDown={take(side)}
6366 style={
6467 shape
6568 ? ({
6669 '--vx': `${shape.places[side].x}px`,
6770 '--vy': `${shape.places[side].y}px`,
6871 '--dir': `${shape.places[side].dir}deg`,
72+ '--slide': `${slide[side]}px`,
6973 } as React.CSSProperties)
7074 : undefined
7175 }
⋯ 20 unchanged lines
9296 );
9397 }
9498
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+
95197 interface Square {
96198 /** The opening the square leaves, once there is a middle to measure. */
97199 opening: { w: number; h: number } | null;
⋯ 78 unchanged lines