anvilsign in

collin/mahjong · 40a1e457

Push a wall off its line as well as along it

Collin Richards · 2026-08-21 22:45 UTC · 40a1e457a5ccadb0363924b7c47e400e4aa31b6b · parent 4d4ce219 · browse files

modifiedsrc/styles.css+3 −3
⋯ 739 unchanged lines
740740 display: flex;
741741 justify-content: flex-end;
742742 transform-origin: 0 0;
743- /* `--slide` last, so it runs along the wall's own line rather than the
744- table's — pushing a wall is pushing it *along itself*. */
743+ /* The nudge comes after the rotate, so it is in the wall's own frame: pushing
744+ a wall pushes it along itself and off itself, not along the table. */
745745 transform: translate(var(--vx), var(--vy)) rotate(var(--dir))
746- translateX(var(--slide, 0px));
746+ translate(var(--along, 0px), var(--across, 0px));
747747 /* The ring lets everything through; a wall is the one thing you can take
748748 hold of. */
749749 pointer-events: auto;
⋯ 1568 unchanged lines
modifiedsrc/tuneWall.tsx+7 −0
⋯ 54 unchanged lines
5555 // What the square actually came out as, so the number of tiles is a thing you
5656 // can read rather than count off the screen.
5757 const [built, setBuilt] = useState({ stacks: 0, gap: 0 });
58+ // Where the four walls have been pushed to, in their own frames — the numbers
59+ // to hand back when you have found where they should have been standing.
60+ const [nudged, setNudged] = useState('0,0 0,0 0,0 0,0');
5861
5962 // Stable across renders: the measuring effect keys off it, and a fresh object
6063 // every render would have it re-measuring for ever.
⋯ 31 unchanged lines
9295
9396 const geo = measure(el, state);
9497 if (!geo) return;
98+ const pushed = el.querySelector<HTMLElement>('.wall-ring')?.dataset.nudged;
99+ if (pushed) setNudged((was) => (was === pushed ? was : pushed));
95100
96101 // How many stacks are standing, and the most room any one side has left
97102 // over — which is the gap you can see at the corners, since a side packs
⋯ 116 unchanged lines
214219 </label>
215220
216221 <p className="hint built">
222+ pushed to {nudged}
223+ <br />
217224 {built.stacks} stacks standing · {built.stacks * 2} tiles ·{' '}
218225 {built.gap > 1
219226 ? `${Math.round(built.gap)}px of empty side`
⋯ 18 unchanged lines
modifiedsrc/ui/WallRing.tsx+42 −23
⋯ 5 unchanged lines
66 RING,
77 WALL_SIDES,
88 wallStacks,
9- type Placed,
109 type Rhombus,
1110 type WallProgress,
1211 } from '../game/wall';
⋯ 20 unchanged lines
3332 const ring = useRef<HTMLDivElement>(null);
3433 const { opening, cell, shape } = useSquare(ring);
3534 const sides = ringLayout(wallStacks(state), RING);
36- const { slide, take } = useSliding(ring, shape, cell, sides);
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(' ');
3739
3840 return (
3941 <div
4042 className="wall-ring"
4143 ref={ring}
44+ data-nudged={nudged}
4245 aria-label={t('牌牆', 'The wall')}
4346 style={
4447 opening
⋯ 24 unchanged lines
6972 '--vx': `${shape.places[side].x}px`,
7073 '--vy': `${shape.places[side].y}px`,
7174 '--dir': `${shape.places[side].dir}deg`,
72- '--slide': `${slide[side]}px`,
75+ '--along': `${slide[side].along}px`,
76+ '--across': `${slide[side].across}px`,
7377 } as React.CSSProperties)
7478 : undefined
7579 }
⋯ 30 unchanged lines
106110 * is still the next draw, exactly as washing the discards about moves no
107111 * tile that anybody is counting.
108112 *
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.
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.
112118 *
113119 * The pool is told, because a wall that has moved is a wall a thrown tile
114120 * bounces off somewhere else, and the pool only re-measures when it has reason
⋯ 3 unchanged lines
118124 ring: React.RefObject<HTMLDivElement | null>,
119125 shape: Rhombus | null,
120126 cell: number,
121- sides: Placed[][],
122127 ) {
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- );
128+ const [slide, setSlide] = useState<Nudge[]>(() => [0, 1, 2, 3].map(() => ({ along: 0, across: 0 })));
129+ const drag = useRef<{
130+ side: number;
131+ ax: number;
132+ ay: number;
133+ fromAlong: number;
134+ fromAcross: number;
135+ at: Nudge;
136+ } | null>(null);
127137 // What the drag needs to know, kept current without the listeners below
128138 // 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);
139+ const now = useRef({ shape, cell, slide });
140+ now.current = { shape, cell, slide };
135141
136142 const moved = () => ring.current?.dispatchEvent(new CustomEvent('wallmoved', { bubbles: true }));
137143
138144 const take = (side: number) => (e: React.PointerEvent<HTMLDivElement>) => {
139145 const place = now.current.shape?.places[side];
140- if (!place || e.button !== 0 || roomOn(side) <= 0) return;
146+ if (!place || e.button !== 0) return;
141147 // A wall being pushed, not the table being reached into.
142148 e.stopPropagation();
143149 e.preventDefault();
⋯ 9 unchanged lines
153159 side,
154160 ax,
155161 ay,
156- from: e.clientX * ax + e.clientY * ay,
162+ fromAlong: e.clientX * ax + e.clientY * ay,
163+ // Across is the wall's own normal: its direction turned a quarter on.
164+ fromAcross: -e.clientX * ay + e.clientY * ax,
157165 at: now.current.slide[side],
158166 };
159167 };
⋯ 4 unchanged lines
164172 const move = (e: PointerEvent) => {
165173 const d = drag.current;
166174 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)));
175+ // The finger, in the wall's own frame: how far along it, and how far off.
176+ const bound = STACKS_PER_SIDE * now.current.cell * 2;
177+ const clamp = (v: number) => Math.min(bound, Math.max(-bound, v));
178+ const to: Nudge = {
179+ along: clamp(d.at.along + (e.clientX * d.ax + e.clientY * d.ay - d.fromAlong)),
180+ across: clamp(d.at.across + (-e.clientX * d.ay + e.clientY * d.ax - d.fromAcross)),
181+ };
170182 let changed = false;
171183 setSlide((was) => {
172- if (was[d.side] === to) return was;
184+ const had = was[d.side];
185+ if (had.along === to.along && had.across === to.across) return was;
173186 changed = true;
174187 return was.map((v, i) => (i === d.side ? to : v));
175188 });
⋯ 18 unchanged lines
194207 return { slide, take };
195208 }
196209
210+/** How far a wall has been pushed from where it was built, in its own frame. */
211+export interface Nudge {
212+ along: number;
213+ across: number;
214+}
215+
197216 interface Square {
198217 /** The opening the square leaves, once there is a middle to measure. */
199218 opening: { w: number; h: number } | null;
⋯ 81 unchanged lines