blockyard 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +929 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +191 -4
- package/SECURITY.md +38 -0
- package/bin/blockyard.js +41 -0
- package/config/pool-map.json +2620 -0
- package/docs/API.md +1577 -0
- package/docs/ARCHITECTURE.md +1394 -0
- package/docs/AUTO-UPDATE.md +269 -0
- package/docs/CONFIGURATION.md +847 -0
- package/docs/DEFECTS.md +813 -0
- package/docs/EFFECTS-AGENTS.md +448 -0
- package/docs/GETTING-STARTED.md +205 -0
- package/docs/INSTALL.md +547 -0
- package/docs/MEASUREMENTS.md +1401 -0
- package/docs/RULES.md +681 -0
- package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
- package/docs/SECURITY-AUDIT.md +258 -0
- package/docs/SECURITY.md +212 -0
- package/docs/TROUBLESHOOTING.md +332 -0
- package/docs/USER-GUIDE.md +1262 -0
- package/package.json +53 -5
- package/public/404.html +9 -0
- package/public/css/app.css +2009 -0
- package/public/donate-qr.png +0 -0
- package/public/index.html +1085 -0
- package/public/js/about.js +112 -0
- package/public/js/agents.js +1141 -0
- package/public/js/app.js +1386 -0
- package/public/js/arkanoid.js +806 -0
- package/public/js/blockanoid.js +347 -0
- package/public/js/blockout.js +347 -0
- package/public/js/blockpack.js +428 -0
- package/public/js/blockscene3d.js +2830 -0
- package/public/js/breakout.js +224 -0
- package/public/js/charts.js +635 -0
- package/public/js/depthchart.js +315 -0
- package/public/js/details3d.js +4342 -0
- package/public/js/doom.js +31 -0
- package/public/js/dosaudio.js +48 -0
- package/public/js/dosgame.js +389 -0
- package/public/js/dosio.js +186 -0
- package/public/js/dospc.js +1353 -0
- package/public/js/dosworker.js +196 -0
- package/public/js/explorer.js +405 -0
- package/public/js/feepalette.js +149 -0
- package/public/js/fmt.js +162 -0
- package/public/js/goggles.js +886 -0
- package/public/js/kiosk.js +41 -0
- package/public/js/login.js +88 -0
- package/public/js/markets.js +395 -0
- package/public/js/mining.js +1416 -0
- package/public/js/panels.js +970 -0
- package/public/js/pricechart.js +189 -0
- package/public/js/quake.js +20 -0
- package/public/js/settings.js +1096 -0
- package/public/js/soundcard.js +459 -0
- package/public/js/tetris.js +226 -0
- package/public/js/tetrust.js +356 -0
- package/public/js/tetsound.js +175 -0
- package/public/js/theme.js +235 -0
- package/public/js/wolf3d.js +22 -0
- package/public/js/x86.js +1978 -0
- package/public/login.html +33 -0
- package/scripts/blockfile-measure.js +156 -0
- package/scripts/browser-check.mjs +286 -0
- package/scripts/check.js +173 -0
- package/scripts/decode-check.js +81 -0
- package/scripts/doc-counts.js +109 -0
- package/scripts/donate-qr.py +23 -0
- package/scripts/dos-bench.js +56 -0
- package/scripts/fake-node.js +534 -0
- package/scripts/index-bench.js +216 -0
- package/scripts/index-benchmark.js +117 -0
- package/scripts/index-build.js +40 -0
- package/scripts/live-render-check.mjs +89 -0
- package/scripts/manage-users.js +132 -0
- package/scripts/motion-check.mjs +138 -0
- package/scripts/pool-map.js +157 -0
- package/scripts/setup.js +432 -0
- package/scripts/shots.mjs +278 -0
- package/scripts/smoke.sh +327 -0
- package/scripts/tls.js +31 -0
- package/scripts/ui.js +174 -0
- package/server/auth/sessions.js +221 -0
- package/server/auth/users.js +243 -0
- package/server/chain/blockfile.js +234 -0
- package/server/chain/index/build.js +210 -0
- package/server/chain/index/heights.js +36 -0
- package/server/chain/index/live.js +276 -0
- package/server/chain/index/rows.js +145 -0
- package/server/chain/index/store.js +154 -0
- package/server/chain/index/worker.js +109 -0
- package/server/chain/tx.js +310 -0
- package/server/collect/gbt.js +229 -0
- package/server/collect/logparse.js +765 -0
- package/server/collect/logtail.js +189 -0
- package/server/collect/markets.js +333 -0
- package/server/collect/mining.js +333 -0
- package/server/collect/monitor.js +2545 -0
- package/server/collect/network.js +295 -0
- package/server/collect/nextblock.js +275 -0
- package/server/collect/sync.js +386 -0
- package/server/config.js +644 -0
- package/server/http/api.js +1319 -0
- package/server/http/explorer.js +418 -0
- package/server/http/games.js +77 -0
- package/server/http/server.js +420 -0
- package/server/http/sse.js +176 -0
- package/server/http/static.js +212 -0
- package/server/main.js +673 -0
- package/server/netinfo.js +253 -0
- package/server/rpc/allowlist.js +130 -0
- package/server/rpc/client.js +414 -0
- package/server/store/audit.js +148 -0
- package/server/store/history.js +220 -0
- package/server/store/ledger.js +290 -0
- package/server/store/ring.js +173 -0
- package/server/tls/selfsigned.js +160 -0
- package/server/util/fmt.js +29 -0
- package/systemd/blockyard.service +102 -0
|
@@ -0,0 +1,2830 @@
|
|
|
1
|
+
// blockscene3d.js -- the block as a field of uniform slabs on a plane, and a
|
|
2
|
+
// Tetris-inspired, collision-free choreography that moves them between layouts.
|
|
3
|
+
//
|
|
4
|
+
// THE DESIGN, as specified by the operator 2026-09-10:
|
|
5
|
+
// * Every tile on the plane has the SAME height. Feerate is carried by
|
|
6
|
+
// COLOUR alone (the feerate bands of feepalette.js). An earlier cut varied height
|
|
7
|
+
// by feerate, which made the resting plane a skyline; that is not this.
|
|
8
|
+
// Height is reserved entirely for motion.
|
|
9
|
+
// * On a refresh a tile lifts OFF the plane, travels, and descends into its
|
|
10
|
+
// new slot, never passing through another tile on the way.
|
|
11
|
+
// * The movement is TETRIS: lateral travel happens in whole grid steps, and
|
|
12
|
+
// the arrival is a gravity drop that accelerates and locks. Nothing
|
|
13
|
+
// drifts smoothly on a diagonal, because that is not how a piece moves.
|
|
14
|
+
// * Slow enough to read: >= 5 s against a 10 s refresh.
|
|
15
|
+
//
|
|
16
|
+
// HOW THE NO-COLLISION GUARANTEE WORKS. Every mover shares the same three
|
|
17
|
+
// phase boundaries, and that is the whole trick:
|
|
18
|
+
//
|
|
19
|
+
// phase 1 RISE each tile lifts straight up out of its OWN slot. The
|
|
20
|
+
// packer guarantees resting slots never overlap, so two
|
|
21
|
+
// tiles cannot meet while going straight up.
|
|
22
|
+
// phase 2 TRAVEL each tile shifts at its OWN ALTITUDE, in grid steps.
|
|
23
|
+
// Altitudes are distinct and separated by more than a
|
|
24
|
+
// slab is thick, so no two tiles can share space whatever
|
|
25
|
+
// routes they take -- no path planning, no pairwise
|
|
26
|
+
// checks.
|
|
27
|
+
// phase 3 DROP each tile falls straight down into its NEW slot. New
|
|
28
|
+
// slots are non-overlapping too, so again no meeting.
|
|
29
|
+
//
|
|
30
|
+
// The alternative -- staggered departures with tiles routing around each
|
|
31
|
+
// other -- needs pairwise path checks and still promises little. Phase
|
|
32
|
+
// separation is provable in three sentences, and is tested by sampling the
|
|
33
|
+
// whole flight and intersecting every pair of boxes.
|
|
34
|
+
//
|
|
35
|
+
// CONSTRAINTS (test/never-clip.test.js and the incident behind it): no
|
|
36
|
+
// ctx.clip(), no ctx.globalAlpha, zero dependencies. Pure geometry and time,
|
|
37
|
+
// no canvas, so all of it is testable headless.
|
|
38
|
+
|
|
39
|
+
// EVERY TILE IS THE SAME HEIGHT (operator, 2026-09-10): a block's volume must
|
|
40
|
+
// track its transaction's vbytes, and vbytes is an AREA on this grid. Briefly
|
|
41
|
+
// these were true cubes with height = side; that cubed the volume, so a tile
|
|
42
|
+
// twice as wide looked eight times the transaction instead of four, and the
|
|
43
|
+
// small ones vanished. Uniform height restores the proportion: footprint
|
|
44
|
+
// carries the size, and the height is a constant that only gives the tile
|
|
45
|
+
// enough body to catch the light.
|
|
46
|
+
export const TILE_H = 1.0; // the unit slab: the clearance lanes keep, and a fallback height
|
|
47
|
+
// ACTUAL CUBES (operator, 2026-09-11: "They need to be actual cubes"). This
|
|
48
|
+
// reverses the 2026-09-10 decision above on the operator's call. The honest
|
|
49
|
+
// part is unchanged -- the FOOTPRINT is the vbytes, exactly as on
|
|
50
|
+
// mempool.space -- but a block now stands as tall as it is wide, so a large
|
|
51
|
+
// transaction is a tower and a small one a pebble, and seen through the camera
|
|
52
|
+
// a cube shows the sides that face it. Everything that assumed one slab height
|
|
53
|
+
// now asks cubeHeight(): the lanes (so flight stays collision-free), the paint
|
|
54
|
+
// order, the growth policing and the edge hold.
|
|
55
|
+
// A tile laid out by a caller (board3d -- the market towers) may stand taller than its
|
|
56
|
+
// footprint: `tall` is its height. Transactions never set it and stay cubes.
|
|
57
|
+
export function cubeHeight(tile) {
|
|
58
|
+
if (tile && Number.isFinite(tile.tall)) return tile.tall;
|
|
59
|
+
return tile && Number.isFinite(tile.s) ? tile.s : TILE_H;
|
|
60
|
+
}
|
|
61
|
+
export const SLAB_H = TILE_H;
|
|
62
|
+
|
|
63
|
+
// --- projection --------------------------------------------------------
|
|
64
|
+
// A SQUARE GRID, straight on. Not isometric (operator, 2026-09-10: "Don't
|
|
65
|
+
// make it a diamond any more. Make it a square grid up and down. we should
|
|
66
|
+
// get rid of rotation if it helps the animation").
|
|
67
|
+
//
|
|
68
|
+
// Screen x is the grid column, screen y is the grid row MINUS the height, so
|
|
69
|
+
// a block that rises moves straight up the screen and nothing skews. The grid
|
|
70
|
+
// is therefore a true square, every cell is a square, and a tile's footprint
|
|
71
|
+
// lands exactly on cell boundaries -- which is what "grid aligned" can
|
|
72
|
+
// actually mean once the diamond is gone.
|
|
73
|
+
//
|
|
74
|
+
// Rotation is gone with it. Orbiting a square grid only turns it back into a
|
|
75
|
+
// diamond, and dropping it removes a whole axis from the depth sort, the face
|
|
76
|
+
// selection and the hit testing.
|
|
77
|
+
// flipY puts row 0 at the BOTTOM of the screen. The packer lays the richest
|
|
78
|
+
// transactions down first, at row 0, so without this the expensive end sits
|
|
79
|
+
// at the top; the operator wants it bottom-left, where a miner's "what fits"
|
|
80
|
+
// line reads naturally upward.
|
|
81
|
+
// THE SPHERE (operator, 2026-09-11: "A dome is the right analog for this. In
|
|
82
|
+
// fact, I want the entire sphere to be textured as it is, and I want our grid
|
|
83
|
+
// centered within the view-space" / "grid surface fills the panel" / "sphere
|
|
84
|
+
// continues rendering off the screen, textured ... viewport only obviously").
|
|
85
|
+
// The board is a patch of a real sphere: its centre raised by o.dome, its four
|
|
86
|
+
// corners on the plane, and the same sphere carrying on past the board to
|
|
87
|
+
// every edge of the panel. The radius follows from those two facts. No dome
|
|
88
|
+
// (or no grid), no sphere: the plane.
|
|
89
|
+
export function sphereOf(o = {}) {
|
|
90
|
+
const { dome = 0, gridW = 0, gridH = 0 } = o;
|
|
91
|
+
if (!(dome > 0) || !gridW || !gridH) return null;
|
|
92
|
+
const R = ((gridW / 2) ** 2 + (gridH / 2) ** 2 + dome * dome) / (2 * dome);
|
|
93
|
+
return { cx: gridW / 2, cy: gridH / 2, R, sink: R - dome };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// the sphere's height above the plane at a grid point
|
|
97
|
+
export function capZ(gx, gy, o = {}) {
|
|
98
|
+
const s = sphereOf(o);
|
|
99
|
+
if (!s) return 0;
|
|
100
|
+
const dx = gx - s.cx, dy = gy - s.cy;
|
|
101
|
+
return Math.sqrt(Math.max(0, s.R * s.R - dx * dx - dy * dy)) - s.sink;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// "straight up relative to their position on the sphere" (operator,
|
|
105
|
+
// 2026-09-11): the outward normal there, in grid units -- radial from the
|
|
106
|
+
// sphere's centre, so blocks leaving the board fan outward from its middle.
|
|
107
|
+
// The direction a block travels, as drawn. Under the oblique camera project() adds ox per unit
|
|
108
|
+
// of height, which is a constant rightward push on every block whatever its place on the board;
|
|
109
|
+
// taking it out of the path leaves motion that is symmetric about the middle -- what the sphere's
|
|
110
|
+
// normal was meant to give. Off the oblique camera there is no such term and this is the normal.
|
|
111
|
+
export function flightDir(gx, gy, o = {}) {
|
|
112
|
+
// The oblique lean is radial now (obliqueLean), so the camera's own push already fans from the
|
|
113
|
+
// middle: the flight is the sphere's normal again, and subtracting the lean here as well would
|
|
114
|
+
// count that fan twice and tip every flight to the left.
|
|
115
|
+
return surfaceNormal(gx, gy, o);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// HOW A CUBE LEAVES AND ARRIVES: 'normal' | 'arcing'.
|
|
119
|
+
//
|
|
120
|
+
// MEASURED FIRST, because the obvious story was wrong. The complaint (operator, 2026-09-13: "all
|
|
121
|
+
// the left and right side blocks are arcing towards/away from the sides instead of just traveling
|
|
122
|
+
// straight up") was that the path CURVES, and the settle loop below does bend it -- but sampling
|
|
123
|
+
// the drawn path up a flight showed the bend is about 2%: dx/dy runs 0.7335 -> 0.7481 at the left
|
|
124
|
+
// edge over a whole climb. The path was already very nearly straight. What it was not is VERTICAL:
|
|
125
|
+
// that slope means an edge cube moves three pixels sideways for every four up, while the middle
|
|
126
|
+
// column sits at -0.017. So what reads as arcing is mostly a steep straight FAN, not a curve, and
|
|
127
|
+
// removing the curve alone would have looked like nothing happened.
|
|
128
|
+
//
|
|
129
|
+
// normal the lean is held at its resting value: a straight line along the sphere's normal
|
|
130
|
+
// arcing the lean is re-settled as the cube climbs, so the fan bends slightly
|
|
131
|
+
//
|
|
132
|
+
// A third mode, `vertical`, removed the fan entirely -- no sideways travel at all. It was built,
|
|
133
|
+
// measured (exactly 0.00px drift at both edges) and shown side by side with these two, and the
|
|
134
|
+
// operator cut it: "Get rid of straight up. Make Along the board's curve the default." The fan is
|
|
135
|
+
// the board's curve being honest about itself, and a cube rising dead vertical off a domed board
|
|
136
|
+
// reads as flatter than the board is. Recorded so it is not proposed again as an obvious fix.
|
|
137
|
+
//
|
|
138
|
+
// The default here is `arcing` so that every caller that has never heard of this -- and every test
|
|
139
|
+
// written before it -- draws exactly what it always drew. settings.js passes a mode explicitly.
|
|
140
|
+
export const departMode = (o = {}) => o.departures ?? 'arcing';
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Where a flying cube really is, and how it leans, settled once per cube.
|
|
144
|
+
*
|
|
145
|
+
* THE ONE PLACE BOTH CALLERS AGREE. liftProjector draws the cube here and obliqueOrder sorts it,
|
|
146
|
+
* and both used to re-derive this with the same six lines copied. The comment on the second copy
|
|
147
|
+
* warned that the order and the geometry must be measured at the same point -- so with a mode to
|
|
148
|
+
* honour, the copies become one function rather than two that can drift apart. Paint order that
|
|
149
|
+
* disagrees with geometry by even a little is the flicker class this board has had before.
|
|
150
|
+
*/
|
|
151
|
+
export function flightGeom(tile, zv, o = {}) {
|
|
152
|
+
const cxRest = tile.x + tile.s / 2, cyRest = tile.y + tile.s / 2;
|
|
153
|
+
const mode = departMode(o);
|
|
154
|
+
const drift = flightOffset(cxRest, cyRest, zv, o).x; // along the sphere's normal, once clear
|
|
155
|
+
// THE LEAN IS ALSO THE CUBE'S SHAPE. Height enters the projection as `x += z * lean * unit`, so
|
|
156
|
+
// the same term that pushes a flight sideways is what makes a cube's side faces lean. A mode
|
|
157
|
+
// cannot simply zero it to change a path: that would flatten an airborne cube and then SNAP it at
|
|
158
|
+
// touchdown, because a resting cube leans by its column. So both modes keep a real lean and
|
|
159
|
+
// differ only in WHEN it is settled -- which is also why the removed `vertical` mode had to
|
|
160
|
+
// subtract the flight's share of it rather than drop the term.
|
|
161
|
+
// THE SETTLED LEAN at a given flight height. A cube's lean is a fixed point: it leans by where it
|
|
162
|
+
// is, and leaning moves where it is, so this iterates twice -- the shipped rule, unchanged.
|
|
163
|
+
const settleAt = (h) => {
|
|
164
|
+
const d = flightOffset(cxRest, cyRest, h, o).x;
|
|
165
|
+
const height = h + capZ(cxRest + d, cyRest, o) + (tile.floor ?? 0) + cubeHeight(tile) / 2;
|
|
166
|
+
let lean = obliqueLean(cxRest + d, o);
|
|
167
|
+
for (let i = 0; i < 2; i++) lean = obliqueLean(cxRest + d + height * lean, o);
|
|
168
|
+
return lean;
|
|
169
|
+
};
|
|
170
|
+
// A CUBE AT REST IS DRAWN THE SAME IN EVERY MODE, AND EACH MODE AGREES WITH ITSELF AS IT LANDS.
|
|
171
|
+
//
|
|
172
|
+
// Both halves of that were got wrong in turn, and only measurement caught either. First cut: the
|
|
173
|
+
// mode applied at every height, so a RESTING rim cube was drawn at 23.945640 under arcing and
|
|
174
|
+
// 23.976709 under the others -- a flight setting changing how the standing board looks. Second
|
|
175
|
+
// cut guarded on `zv > 0`, which fixed rest and broke the boundary instead: flight used the plain
|
|
176
|
+
// resting lean while rest used the settled one, so each mode now disagreed with ITSELF across
|
|
177
|
+
// touchdown -- a 0.031px snap on the last step down, and a `vertical` that still drifted 0.03px.
|
|
178
|
+
//
|
|
179
|
+
// The constant that satisfies both is the settled lean evaluated AT ZERO HEIGHT: it is exactly
|
|
180
|
+
// what a resting cube uses (so rest is identical in both modes and identical to shipped),
|
|
181
|
+
// and it does not change as the cube climbs (so the path stays perfectly straight and nothing
|
|
182
|
+
// moves at touchdown). `arcing` keeps re-settling as it climbs, which is the curve it is named
|
|
183
|
+
// for. Every number in this comment came from a probe, not from reading the code.
|
|
184
|
+
// THE SHAPE IS RE-SETTLED EVERY FRAME; ONLY THE PATH IS PINNED (operator, 2026-09-13: "that looks
|
|
185
|
+
// terrible when the blocks fall into place (non-changing perspective looks strange visually) ...
|
|
186
|
+
// I'm not seeing the bottom or the front face changing at all during movement. We are
|
|
187
|
+
// regressing"). Correct, and it was my regression: before this setting existed the renderer always
|
|
188
|
+
// re-settled the lean with height -- what `arcing` still does -- and making `normal` the default
|
|
189
|
+
// froze it for the whole flight. Measured falling from z=40 at gx=6: arcing sweeps -0.1291 ->
|
|
190
|
+
// -0.0885 and converges on the resting lean as it lands; `normal` held -0.0885 at EVERY height.
|
|
191
|
+
// Frozen exactly where the perspective should move most.
|
|
192
|
+
//
|
|
193
|
+
// So the two jobs are separated rather than traded. `lean` is the cube's SHAPE and is re-settled
|
|
194
|
+
// at the cube's real height in both modes, so a falling cube's faces change frame by frame as
|
|
195
|
+
// they always did. `leanPath` is what the departure mode decides -- pinned for `normal`, giving
|
|
196
|
+
// the straight line along the sphere's normal that was chosen, and re-settled for `arcing`, which
|
|
197
|
+
// is the curve it is named for. liftProjector draws with `lean` and then shifts the cube rigidly
|
|
198
|
+
// so its centre sits on `leanPath`; at rest the two are equal, so the standing board and the
|
|
199
|
+
// touchdown are untouched.
|
|
200
|
+
const lean = settleAt(zv);
|
|
201
|
+
return { drift, lean, leanPath: mode === 'arcing' ? lean : settleAt(0), cx: cxRest + drift };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// WHERE A FLIGHT HAS GOT TO at drawn height zv: straight up until it clears its neighbours, then out
|
|
205
|
+
// along the sphere's normal (operator, 2026-09-14: "The small blocks that remain on the board always
|
|
206
|
+
// sort over the cubes flying above it"). Flights used to fan from the floor, so a cube a unit up had
|
|
207
|
+
// already moved a fraction of a unit sideways -- into the space of the cube beside it. Two boxes that
|
|
208
|
+
// overlap on every axis have no correct paint order, and every rule for them flips as the cube
|
|
209
|
+
// bounces: measured on a live refresh, ordering those pairs by drawn height took the resting-over-
|
|
210
|
+
// flyer errors from 115 to 5 and the pops from 0 to 518; by the drifted footprint, 1 error and 1,100
|
|
211
|
+
// pops. The path was the fault, not the order. Rising straight up through FLIGHT_CLEAR first means
|
|
212
|
+
// nothing moves sideways until it is above the cubes around it, so a low cube never shares space with
|
|
213
|
+
// a neighbour; above that it fans exactly as before, and at rest nothing has moved at all. The
|
|
214
|
+
// projector, the lean and the paint order all read this one function.
|
|
215
|
+
export const FLIGHT_CLEAR = 3;
|
|
216
|
+
export function flightOffset(gx, gy, zv, o = {}) {
|
|
217
|
+
const c = o.oblique?.clear ?? FLIGHT_CLEAR;
|
|
218
|
+
if (!(zv > c)) return { x: 0, y: 0, z: Math.max(0, zv) };
|
|
219
|
+
const n = flightDir(gx, gy, o), t = zv - c;
|
|
220
|
+
return { x: n.x * t, y: n.y * t, z: c + n.z * t };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function surfaceNormal(gx, gy, o = {}) {
|
|
224
|
+
const s = sphereOf(o);
|
|
225
|
+
if (!s) return { x: 0, y: 0, z: 1 };
|
|
226
|
+
const dx = gx - s.cx, dy = gy - s.cy;
|
|
227
|
+
return { x: dx / s.R, y: dy / s.R, z: Math.sqrt(Math.max(0, s.R * s.R - dx * dx - dy * dy)) / s.R };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// How far a unit of height pushes a cube sideways, at gx. Zero over the middle of the board and
|
|
231
|
+
// +/- oblique.ox at its edges: the fan of a camera standing over the centre. Without a board width
|
|
232
|
+
// (a bare projection in a test) it falls back to the old constant, so nothing that does not know
|
|
233
|
+
// about a board changes shape.
|
|
234
|
+
export function obliqueLean(gx, o = {}) {
|
|
235
|
+
const ox = o.oblique?.ox ?? 0.15;
|
|
236
|
+
const W = o.gridW || 0;
|
|
237
|
+
if (!(W > 0)) return ox;
|
|
238
|
+
const half = W / 2;
|
|
239
|
+
return ox * Math.max(-1, Math.min(1, (gx - half) / half));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function project(gx, gy, gz, o = {}) {
|
|
243
|
+
const {
|
|
244
|
+
unit = 12, zUnit = 10, originX = 0, originY = 0, flipY = true,
|
|
245
|
+
persp = 0.55, vanishX = 0, vanishY = 0, risePerUnit = 0.075,
|
|
246
|
+
} = o;
|
|
247
|
+
// gz is HEIGHT TOWARD THE VIEWER (operator, 2026-09-10: "receding is not
|
|
248
|
+
// working well. Make them rise toward the viewer at different heights").
|
|
249
|
+
//
|
|
250
|
+
// A rising block grows and moves OUTWARD from the vanishing point, which is
|
|
251
|
+
// the opposite of the receding version and reads as coming at you. It can
|
|
252
|
+
// therefore push past the board's edge, so the renderer reserves a constant
|
|
253
|
+
// margin for the tallest lane -- constant, so the zoom still does not
|
|
254
|
+
// breathe during a transition.
|
|
255
|
+
//
|
|
256
|
+
// It remains a real spatial axis, so the no-collision proof is unchanged:
|
|
257
|
+
// two blocks at different heights cannot occupy the same space.
|
|
258
|
+
const x0 = originX + gx * unit;
|
|
259
|
+
// A LOWER CAMERA (operator, 2026-09-11: "Adjust the view even lower than currently, so that we
|
|
260
|
+
// see more of the side view, than from above"): oblique.dy draws the board's depth shorter than
|
|
261
|
+
// its width while height keeps its full oy, so the rows recede and the front faces of the cubes
|
|
262
|
+
// take the screen. 1 (the default) is the straight-down board the block viewer uses.
|
|
263
|
+
const dS = o.oblique?.dy ?? 1;
|
|
264
|
+
const y0 = originY + (flipY ? -gy : gy) * unit * dS;
|
|
265
|
+
// THE CURVED BOARD (operator, 2026-09-11: "adjusting the grid surface to be
|
|
266
|
+
// a partial spherical segment would give us an entirely new dimension to
|
|
267
|
+
// work with"): a patch of a real sphere, capZ above. It was a "pillow",
|
|
268
|
+
// pinned to zero on every edge, while the board had to sit flush with its
|
|
269
|
+
// frame; the board is centred on a sphere that fills the panel now, so the
|
|
270
|
+
// true cap is back. Off (flat) without o.dome.
|
|
271
|
+
const z = (gz || 0) + capZ(gx, gy, o);
|
|
272
|
+
if (!z) return { x: x0, y: y0 };
|
|
273
|
+
// THE OBLIQUE CAMERA (2026-09-11). Height is a FIXED screen offset -- up
|
|
274
|
+
// and a little right, a camera off the lower-left of the screen -- instead
|
|
275
|
+
// of the overhead pinhole's swell. Operator, in order: "offset something
|
|
276
|
+
// outside the screen bounds so things aren't dropping directly down from
|
|
277
|
+
// the viewer's perspective"; "Are they even proper cubes like I asked for?"
|
|
278
|
+
// (overhead, a two-unit cube showed a sliver of side and read as a tile);
|
|
279
|
+
// "Blocks bouncing around during movements and z-fighting in a weird way"
|
|
280
|
+
// (the pinhole grew blocks with height and a growth rule re-limited them
|
|
281
|
+
// every frame). Here every cube shows its top and its west and south faces,
|
|
282
|
+
// a fall is visible motion up and down the screen, and nothing ever changes
|
|
283
|
+
// size -- so there is no growth to police and nothing to flicker.
|
|
284
|
+
if (o.oblique) {
|
|
285
|
+
const px = x0 + z * (o.leanFixed ?? obliqueLean(gx, o)) * unit;
|
|
286
|
+
const py = y0 - (flipY ? 1 : -1) * z * o.oblique.oy * unit;
|
|
287
|
+
// HEIGHT WITH A PERSPECTIVE, NOT JUST AN OFFSET (operator, 2026-09-13: "I'm not seeing the
|
|
288
|
+
// bottom or the front face changing at all during movement ... fix camera stuff").
|
|
289
|
+
//
|
|
290
|
+
// The two lines above are the whole oblique camera: height moves a point up the screen and a
|
|
291
|
+
// little sideways, and NOTHING scales. Measured, that is why a cube's front face is 11.0px tall
|
|
292
|
+
// at z=0 and 11.0px at z=80 -- there is no term that could foreshorten it, so no lean policy
|
|
293
|
+
// could ever make faces change with altitude. It is an oblique projection in the strict sense:
|
|
294
|
+
// parallel, sizeless, and flat in depth.
|
|
295
|
+
//
|
|
296
|
+
// `rise` adds the missing term: a point is scaled about the vanishing point by 1 + gz*rise, so
|
|
297
|
+
// its distance from the camera finally does something. A resting cube's top is then slightly
|
|
298
|
+
// larger than its base (a real cube seen from above, rather than a parallelogram), and a cube
|
|
299
|
+
// in flight visibly grows as it comes toward the viewer.
|
|
300
|
+
//
|
|
301
|
+
// IT SCALES BY gz, NOT BY z, and the difference is the whole board. `z` above is
|
|
302
|
+
// `gz + capZ(...)`: on a domed board EVERY floor point carries the dome's own height, so
|
|
303
|
+
// scaling by `z` moved the floor too -- measured, the grid point (10,10,0) slid 97.490 ->
|
|
304
|
+
// 95.756 -> 93.155 as rise went 0 -> 0.004 -> 0.01, and a first version of this comment claimed
|
|
305
|
+
// the floor was untouched. It was not. Height ABOVE the board is what should foreshorten; the
|
|
306
|
+
// board's own curvature is the board. With gz the factor at a footprint is exactly 1 and the
|
|
307
|
+
// grid, the deck and every resting footprint are genuinely unchanged.
|
|
308
|
+
//
|
|
309
|
+
// Default 0, so nothing changes until it is switched on. That is deliberate: size changing with
|
|
310
|
+
// height is precisely what the oblique camera was chosen to avoid ("nothing ever changes size
|
|
311
|
+
// -- so there is no growth to police and nothing to flicker"), and three attempts at this area
|
|
312
|
+
// have already regressed the paint order. It ships as a control to be compared, and the flicker
|
|
313
|
+
// test is the gate on making it the default.
|
|
314
|
+
const rise = o.oblique.rise ?? 0;
|
|
315
|
+
if (!rise) return { x: px, y: py };
|
|
316
|
+
const k = 1 + Math.max(0, gz || 0) * rise;
|
|
317
|
+
const vx = o.vanishX ?? 0, vy = o.vanishY ?? 0;
|
|
318
|
+
return { x: vx + (px - vx) * k, y: vy + (py - vy) * k };
|
|
319
|
+
}
|
|
320
|
+
const k = 1 + z * risePerUnit * persp; // > 1: nearer, and larger
|
|
321
|
+
return { x: vanishX + (x0 - vanishX) * k, y: vanishY + (y0 - vanishY) * k };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// OUTWARD, THROUGH A REAL CAMERA (operator, 2026-09-11, the latest of several
|
|
325
|
+
// rounds: "I think we need to have blocks moving outwards instead of directly
|
|
326
|
+
// up. Having issues really selling the 3D-ness of it"). An airborne block is
|
|
327
|
+
// projected by a true pinhole about the vanishing point: position AND size
|
|
328
|
+
// scale together by liftBoost(z), so a rising block travels out along the ray
|
|
329
|
+
// from the board's centre as it grows, and falls back in along that ray onto
|
|
330
|
+
// its slot. Two blocks at the same height scale APART together, so they cannot
|
|
331
|
+
// run into each other (which the own-centre growth below the next heading did,
|
|
332
|
+
// and which settleGrowth had to police). The swell is 7% per unit, capped at
|
|
333
|
+
// 2x, so the outward travel mostly stays inside the board; the edge hold in
|
|
334
|
+
// airTop catches the rest. History of the rounds, oldest first:
|
|
335
|
+
//
|
|
336
|
+
// STRAIGHT UP, TOWARD THE VIEWER. The asks, in order:
|
|
337
|
+
// "consider having the 3d blocks moving more towards the center, not away
|
|
338
|
+
// from the center"; then "rise to and come from a centerpoint"; then, of the
|
|
339
|
+
// centre pull that produced, "I don't mean for shit to get pulled directly to
|
|
340
|
+
// the center, or spawn from the center. I mean just pull or spawn it towards
|
|
341
|
+
// the viewer directly up until it fades out."
|
|
342
|
+
//
|
|
343
|
+
// What that means geometrically: the pinhole above scales every point about
|
|
344
|
+
// the vanishing point, so a risen block both GROWS and SLIDES OUTWARD -- the
|
|
345
|
+
// slide is what carried rim blocks off the panel and read as "away from the
|
|
346
|
+
// center". An airborne block is therefore drawn about its OWN centre instead:
|
|
347
|
+
// the centre stays exactly where a resting block's would be, and only the
|
|
348
|
+
// size changes with height.
|
|
349
|
+
//
|
|
350
|
+
// And the size has to change VISIBLY. The pinhole alone swells a block by
|
|
351
|
+
// r * persp per unit -- about 1.5% for a one-unit bounce -- so a bounce did
|
|
352
|
+
// not come toward the viewer at all, while its shadow spread into a dark ring
|
|
353
|
+
// round it, and that read as sinking into a hole (operator: "It looks like the
|
|
354
|
+
// cubes are bouncing from below, not from above. They need to obviously bounce
|
|
355
|
+
// towards the viewer"). liftBoost swells it by 12% per unit of height, capped
|
|
356
|
+
// at 3x. A first cut saturated at 30% and the operator still saw "they always
|
|
357
|
+
// stay the same size despite height off the grid" -- most hops are under a
|
|
358
|
+
// unit, so a saturating swell was worth 1-2% where it mattered.
|
|
359
|
+
//
|
|
360
|
+
// At z -> 0 both terms go to 1 and this is exactly the resting projection, so
|
|
361
|
+
// nothing jumps at touchdown. A centre-gather pull was built and removed the
|
|
362
|
+
// same day; recorded so it is not re-added. Pure rendering: world positions --
|
|
363
|
+
// and so the no-collision proof -- are untouched.
|
|
364
|
+
// capped at 1.5x (was 2x): a doubled block covered four of its neighbours.
|
|
365
|
+
// No swell at all below the height settleGrowth counts as airborne (0.02): a
|
|
366
|
+
// block in the last instant of its landing is policed as RESTING, so it must
|
|
367
|
+
// also be DRAWN at resting size -- measured, one at z = 0.006 overlapped its
|
|
368
|
+
// neighbour by a hundredth of a pixel because the two disagreed.
|
|
369
|
+
export function liftBoost(z) { return z > 0.02 ? Math.min(1.5, 1 + 0.07 * z) : 1; }
|
|
370
|
+
|
|
371
|
+
// HELD INSIDE THE BOARD (operator: "I want the grid bounds flush with the
|
|
372
|
+
// viewport"). A swollen block near the rim would cross the edge, so its whole
|
|
373
|
+
// drawing is nudged inward by exactly as much as its swollen top would
|
|
374
|
+
// overhang, and no more: an interior block still goes straight up over its
|
|
375
|
+
// own slot. The board spans x in [0, 2 vanishX] and y between 0 and 2 vanishY
|
|
376
|
+
// (the vanishing point is its centre); with no vanishing point given there is
|
|
377
|
+
// no board to hold it in.
|
|
378
|
+
// The screen box of an airborne block's top face at growth g, after the edge
|
|
379
|
+
// hold: centre and half-size. The hold's bounds are the board WIDENED to
|
|
380
|
+
// wherever the block's resting top already reaches, so the hold only ever
|
|
381
|
+
// answers for the growth -- at g = 1 nothing moves, and nothing jumps at
|
|
382
|
+
// touchdown.
|
|
383
|
+
export function airTop(tile, g, o = {}) {
|
|
384
|
+
const { risePerUnit = 0.075, persp = 0.55, unit = 12, vanishX = 0, vanishY = 0 } = o;
|
|
385
|
+
const cTop = project(tile.x + tile.s / 2, tile.y + tile.s / 2, cubeHeight(tile), o);
|
|
386
|
+
// true perspective: the centre goes out along the ray from the vanishing point
|
|
387
|
+
const px = vanishX + (cTop.x - vanishX) * g, py = vanishY + (cTop.y - vanishY) * g;
|
|
388
|
+
const rest = (tile.s * unit / 2) * (1 + cubeHeight(tile) * risePerUnit * persp);
|
|
389
|
+
const half = rest * g;
|
|
390
|
+
const hold = (v, c, a, b) => {
|
|
391
|
+
const lo = Math.min(a, c - rest), hi = Math.max(b, c + rest);
|
|
392
|
+
return hi - lo < 2 * half ? (lo + hi) / 2 : Math.max(lo + half, Math.min(hi - half, v));
|
|
393
|
+
};
|
|
394
|
+
// The board's own bounds, given explicitly now that the vanishing point can
|
|
395
|
+
// sit off the board (camera options, 2026-09-11); without them the board is
|
|
396
|
+
// assumed centred on the vanishing point, as it always was.
|
|
397
|
+
const W = o.boardW ?? Math.abs(2 * vanishX);
|
|
398
|
+
const H = o.boardH ?? Math.abs(2 * vanishY);
|
|
399
|
+
const yLo = o.flipY === false ? 0 : -H, yHi = o.flipY === false ? H : 0;
|
|
400
|
+
const x = W ? hold(px, cTop.x, 0, W) : px;
|
|
401
|
+
const y = H ? hold(py, cTop.y, yLo, yHi) : py;
|
|
402
|
+
return { x, y, half, dx: x - px, dy: y - py };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// With the oblique camera a flight altitude is drawn COMPRESSED into the
|
|
406
|
+
// headroom above the board, so however high the lanes stack (cube lanes are
|
|
407
|
+
// intervals and can climb) a block in the air never leaves the frame; the
|
|
408
|
+
// cube's own height is drawn true. Near the plane the compression is the
|
|
409
|
+
// identity, so touchdown is seamless.
|
|
410
|
+
//
|
|
411
|
+
// The cube's own height counts against that headroom (2026-09-11). Only the
|
|
412
|
+
// BASE used to be compressed, into 8 units, and a 12-unit cube drawn true on
|
|
413
|
+
// top of it reached 20 units into a 13-unit strip -- the big ones flew off
|
|
414
|
+
// the top of the canvas. Each cube now gets what is left above its own
|
|
415
|
+
// height (headroom + dome - side), so its top never leaves the frame.
|
|
416
|
+
export function altitudeView(z, o = {}, h = 0) {
|
|
417
|
+
const H = Math.max(1.5, (o.oblique?.headroom ?? 10) + (o.dome || 0) - h);
|
|
418
|
+
return z > 0 ? H * (1 - Math.exp(-z / H)) : 0;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Where a cube's base is DRAWN under the oblique camera: its flight altitude
|
|
422
|
+
// compressed into the headroom, plus `entry` -- the share of the height that
|
|
423
|
+
// puts it wholly outside the canvas (1), for an arrival still falling in or a
|
|
424
|
+
// departure flying off -- see offscreenLift. Used by the projector and by the paint order alike.
|
|
425
|
+
export function visualBase(tile, o = {}) {
|
|
426
|
+
const z = tile.z ?? 0;
|
|
427
|
+
let zv = 0;
|
|
428
|
+
if (z > 0) {
|
|
429
|
+
if (o.oblique && o.viewRect) {
|
|
430
|
+
// HIGHER (operator, 2026-09-11: "the blocks can rise higher than they do
|
|
431
|
+
// during reshuffling ... it will help sell the depth and shadows more").
|
|
432
|
+
// Flights were squeezed into one fixed band (headroom + dome - side) so
|
|
433
|
+
// that the tallest cube at the back edge stayed in frame. With the board
|
|
434
|
+
// centred on the sphere most cubes have far more room than that: each
|
|
435
|
+
// flight now gets the room at ITS spot -- the height at which its own
|
|
436
|
+
// top would reach the panel's edge along its flight direction
|
|
437
|
+
// (flightRoom) -- up to oblique.flight. Front and centre climb high,
|
|
438
|
+
// the back rows stay in frame, and nothing is ever cut off.
|
|
439
|
+
// The room is measured for the SPOT (a point at the cube's centre), not
|
|
440
|
+
// the cube: a size-dependent room drew a big cube that was really above a
|
|
441
|
+
// small one LOWER than it, and the pair flickered (see obliqueOrder). The
|
|
442
|
+
// cube's own room then only clips the biggest at the ceiling, so none
|
|
443
|
+
// leaves the frame.
|
|
444
|
+
//
|
|
445
|
+
// ...AND THAT IS NOW REVERSED: FLIGHTS LEAVE THE FRAME (operator, 2026-09-14: "the blocks are
|
|
446
|
+
// wrapping at the viewport extents. They need to move off-screen instead of bunching up at the
|
|
447
|
+
// extents"). The room was the panel's, so it shrank with the panel: on the square Overview
|
|
448
|
+
// board a rim cube climbed only until it touched the edge and then slid along it, following
|
|
449
|
+
// the board's curve, while every lane above it was drawn at the same place -- the pile along
|
|
450
|
+
// the edges. Departures and arrivals then burst out of that pile in their last frames, which
|
|
451
|
+
// is the popping "worse the narrower the display". Height is now one curve for every spot
|
|
452
|
+
// and every panel -- oblique.flight's soft ceiling, nothing measured from the canvas -- so a
|
|
453
|
+
// flight near an edge simply carries on past it and comes back when it lands. Being the same
|
|
454
|
+
// everywhere it also cannot draw a higher cube lower than a neighbour, which is what the
|
|
455
|
+
// per-spot room had to be careful about.
|
|
456
|
+
const F = Math.max(1, o.oblique.flight ?? 120);
|
|
457
|
+
const view = (zz) => F * (1 - Math.exp(-zz / F));
|
|
458
|
+
// A LANDING RUNS IN DRAWN SPACE (operator, 2026-09-11: "The items slowly start
|
|
459
|
+
// dropping. They need to drop like they were just let go, immediately succumb
|
|
460
|
+
// to gravity, and bounce to a stop, following real physics"). Mapped point by
|
|
461
|
+
// point through the compression above, a fall's real parabola was drawn slow
|
|
462
|
+
// at the top -- where the compression is strongest -- and plunging at the
|
|
463
|
+
// bottom: a hover, then a lurch. Now the DRAWN height is the gravity curve
|
|
464
|
+
// itself: the release height as drawn, times bounceDrop's profile (1 - u^2 to
|
|
465
|
+
// the floor, then rebounds of e^2 of it, each e as long). The real height --
|
|
466
|
+
// the collision proof, the paint order -- keeps the same profile.
|
|
467
|
+
zv = tile.landV != null && tile.fallFrom > 0 ? view(tile.fallFrom) * Math.max(0, tile.landV) : view(z);
|
|
468
|
+
} else zv = altitudeView(z, o, cubeHeight(tile));
|
|
469
|
+
}
|
|
470
|
+
if (o.oblique && tile.entry > 0) zv += tile.entry * offscreenLift(tile, o);
|
|
471
|
+
return zv;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// OFF SCREEN (operator, 2026-09-11: "I can see the new blocks spawning on
|
|
475
|
+
// screen. Have the spawning happen off-screen for new blocks being dropped
|
|
476
|
+
// in"). Under the oblique camera height goes up AND right, so a cube lifted
|
|
477
|
+
// high enough leaves over the top edge or the right one, whichever comes
|
|
478
|
+
// first; this is the height that takes its nearest corner past that edge,
|
|
479
|
+
// with two units to spare. viewUp / viewRight are the canvas's extent in grid
|
|
480
|
+
// units, from the renderer's constant fit; without them (tests, no panel) the
|
|
481
|
+
// board plus its reserved strip stands in.
|
|
482
|
+
//
|
|
483
|
+
// Since flight follows the sphere's normal (2026-09-11) a block's path can
|
|
484
|
+
// leave through ANY edge -- up-right at the middle of the board, out to the
|
|
485
|
+
// left near its left side -- so this finds the height at which the whole cube
|
|
486
|
+
// has passed whichever edge its path meets first, one unit beyond it.
|
|
487
|
+
// o.viewRect is the panel's extent in grid units, from the renderer's
|
|
488
|
+
// constant fit; without it (tests, no panel) the board plus the reserved
|
|
489
|
+
// margin on every side stands in.
|
|
490
|
+
//
|
|
491
|
+
// THE ESTIMATE IS ONLY WHERE THE SEARCH STARTS (operator, 2026-09-14: "blocks still disappear in
|
|
492
|
+
// mid-air instead of flying off the screen"). The straight-line figure below models the flight
|
|
493
|
+
// with a worst-case lean and a flat step per unit of height, and the real projector does not move
|
|
494
|
+
// a cube that way. Measured on a 500x900 panel: 9 of 90 departures were still on screen on their
|
|
495
|
+
// last frame -- at entry 0.995, i.e. at this height -- and were then deleted there, which is the
|
|
496
|
+
// mid-air vanish; the same shortfall had 1x1 arrivals appearing 49px inside the panel. So the
|
|
497
|
+
// height is now checked against the projector itself, the one that draws the cube, and raised
|
|
498
|
+
// until every corner is past one edge of the panel. Whatever the estimate gets wrong, the answer
|
|
499
|
+
// is measured on the picture.
|
|
500
|
+
const liftMemo = new Map();
|
|
501
|
+
export function offscreenLift(tile, o = {}) {
|
|
502
|
+
const { v, dX, dY, x0, x1, up0, up1 } = flightFrame(tile, o);
|
|
503
|
+
let h = Infinity;
|
|
504
|
+
if (dX > 1e-6) h = Math.min(h, (v.x1 + 1 - x0) / dX);
|
|
505
|
+
if (dX < -1e-6) h = Math.min(h, (x1 - (v.x0 - 1)) / -dX);
|
|
506
|
+
if (dY > 1e-6) h = Math.min(h, (v.y1 + 1 - up0) / dY);
|
|
507
|
+
if (dY < -1e-6) h = Math.min(h, (up1 - (v.y0 - 1)) / -dY);
|
|
508
|
+
h = Number.isFinite(h) ? Math.max(0, h) : 0;
|
|
509
|
+
if (!o.oblique) return h;
|
|
510
|
+
// per frame this runs for every cube entering or leaving, several times each; the answer depends
|
|
511
|
+
// only on the cube's footprint and the camera, so it is kept
|
|
512
|
+
const ob = o.oblique;
|
|
513
|
+
const key = [tile.x, tile.y, tile.s, tile.tall, tile.floor, tile.fxz, v.x0, v.x1, v.y0, v.y1,
|
|
514
|
+
ob.ox, ob.oy, ob.dy, ob.rise, ob.headroom, o.dome, o.gridW, o.gridH, o.flipY, o.departures].join();
|
|
515
|
+
const hit = liftMemo.get(key);
|
|
516
|
+
if (hit !== undefined) return hit;
|
|
517
|
+
const out = (zv) => {
|
|
518
|
+
const P = obliqueProjector(tile, zv, o);
|
|
519
|
+
const base = (tile.z ?? 0) + (tile.floor ?? 0) + (tile.fxz ?? 0), top = base + cubeHeight(tile);
|
|
520
|
+
const unit = o.unit ?? 1, flip = o.flipY === false ? -1 : 1;
|
|
521
|
+
let left = true, right = true, above = true, below = true;
|
|
522
|
+
for (const gz of [base, top]) for (const [gx, gy] of [[tile.x, tile.y], [tile.x + tile.s, tile.y], [tile.x, tile.y + tile.s], [tile.x + tile.s, tile.y + tile.s]]) {
|
|
523
|
+
const p = P(gx, gy, gz);
|
|
524
|
+
const cx = p.x / unit, row = -flip * p.y / unit; // back into the panel's grid units
|
|
525
|
+
if (cx > v.x0 - 1) left = false;
|
|
526
|
+
if (cx < v.x1 + 1) right = false;
|
|
527
|
+
if (row < v.y1 + 1) above = false;
|
|
528
|
+
if (row > v.y0 - 1) below = false;
|
|
529
|
+
}
|
|
530
|
+
return left || right || above || below;
|
|
531
|
+
};
|
|
532
|
+
for (let i = 0; i < 80 && !out(h); i++) h += Math.max(1, h * 0.1);
|
|
533
|
+
if (liftMemo.size > 20000) liftMemo.clear();
|
|
534
|
+
liftMemo.set(key, h);
|
|
535
|
+
return h;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// A cube's drawn extent at rest, the panel, and how far the cube moves across
|
|
539
|
+
// (dX) and up (dY) the panel per unit of flight along the sphere's normal.
|
|
540
|
+
function flightFrame(tile, o = {}) {
|
|
541
|
+
const { ox = 0.15, oy = 0.36, headroom = 10 } = o.oblique || {};
|
|
542
|
+
const head = headroom + (o.dome || 0);
|
|
543
|
+
const W = o.gridW || 0, H = o.gridH || 0;
|
|
544
|
+
const v = o.viewRect ?? { x0: -head * ox, x1: W + head * ox, y0: -head * oy, y1: H + head * oy };
|
|
545
|
+
const cx = tile.x + tile.s / 2, cy = tile.y + tile.s / 2;
|
|
546
|
+
const n = flightDir(cx, cy, o);
|
|
547
|
+
const flip = o.flipY === false ? -1 : 1;
|
|
548
|
+
// the lean where it stands, and the most it can have by the end of the flight (obliqueLean is
|
|
549
|
+
// bounded by +/-ox), taken in the direction it is travelling: the conservative case
|
|
550
|
+
const leanRest = obliqueLean(cx, o);
|
|
551
|
+
const lean = n.x + leanRest * n.z >= 0 ? ox : -ox;
|
|
552
|
+
const dX = n.x + lean * n.z, dY = flip * n.y + oy * n.z;
|
|
553
|
+
const c = capZ(cx, cy, o), s = tile.s, reach = cubeHeight(tile);
|
|
554
|
+
const x0 = tile.x + Math.min(c * lean, (c + reach) * lean);
|
|
555
|
+
const x1 = tile.x + s + Math.max(c * lean, (c + reach) * lean);
|
|
556
|
+
const up0 = (flip > 0 ? tile.y : -(tile.y + s)) + c * oy, up1 = up0 + s + reach * oy;
|
|
557
|
+
return { v, dX, dY, x0, x1, up0, up1 };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// How high a cube can fly at its spot before any part of it reaches the
|
|
561
|
+
// panel's edge (half a unit short of it): the ceiling visualBase compresses
|
|
562
|
+
// its flight into, so a flight is as high as the panel allows and no higher.
|
|
563
|
+
export function flightRoom(tile, o = {}) {
|
|
564
|
+
const { v, dX, dY, x0, x1, up0, up1 } = flightFrame(tile, o);
|
|
565
|
+
const M = 0.5;
|
|
566
|
+
let h = Infinity;
|
|
567
|
+
if (dX > 1e-6) h = Math.min(h, (v.x1 - M - x1) / dX);
|
|
568
|
+
if (dX < -1e-6) h = Math.min(h, (x0 - (v.x0 + M)) / -dX);
|
|
569
|
+
if (dY > 1e-6) h = Math.min(h, (v.y1 - M - up1) / dY);
|
|
570
|
+
if (dY < -1e-6) h = Math.min(h, (up0 - (v.y0 + M)) / -dY);
|
|
571
|
+
return h;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
export function liftProjector(tile, o = {}) {
|
|
575
|
+
const z = tile.z ?? 0;
|
|
576
|
+
// the camera's lean belongs to the block, not to each corner of it: settle it once per cube so
|
|
577
|
+
// it stays rigid however wide it is, and take it where the cube IS -- a block in flight has
|
|
578
|
+
// moved along the sphere's normal, and a lean from the slot it left is the wrong lean (see
|
|
579
|
+
// obliqueLean)
|
|
580
|
+
if (o.oblique) return obliqueProjector(tile, z > 0 ? visualBase(tile, o) : 0, o);
|
|
581
|
+
return liftProjectorAt(tile, o, z);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// The oblique projector for a cube whose base is DRAWN at `zv` -- split out of liftProjector so
|
|
585
|
+
// offscreenLift can ask where a cube would be drawn at a trial height without going back through
|
|
586
|
+
// visualBase, which is what calls it.
|
|
587
|
+
function obliqueProjector(tile, zv, o) {
|
|
588
|
+
const z = tile.z ?? 0;
|
|
589
|
+
const g = flightGeom(tile, zv, o);
|
|
590
|
+
// draw with the SHAPE lean (re-settled each frame), then put the cube's centre back on the
|
|
591
|
+
// departure mode's PATH. The lean enters project()'s x term only, so a single x offset moves
|
|
592
|
+
// the whole cube rigidly -- its faces keep the shape the camera gives them at this height.
|
|
593
|
+
// At rest the two leans are equal and this is exactly the shipped resting projection.
|
|
594
|
+
o = { ...o, leanFixed: g.lean };
|
|
595
|
+
if (g.leanPath !== g.lean) {
|
|
596
|
+
const cx = tile.x + tile.s / 2, cy = tile.y + tile.s / 2;
|
|
597
|
+
const inner = liftProjectorAt(tile, o, z, zv);
|
|
598
|
+
const onPath = liftProjectorAt(tile, { ...o, leanFixed: g.leanPath }, z, zv);
|
|
599
|
+
const dx = onPath(cx, cy, z).x - inner(cx, cy, z).x;
|
|
600
|
+
if (dx) return (gx, gy, gz) => { const p = inner(gx, gy, gz); return { x: p.x + dx, y: p.y }; };
|
|
601
|
+
return inner;
|
|
602
|
+
}
|
|
603
|
+
return liftProjectorAt(tile, o, z, zv);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// the projection with the lean already settled onto `o` -- split out so liftProjector can evaluate
|
|
607
|
+
// it twice, once for the cube's shape and once for the path its centre must follow. `zv` is the
|
|
608
|
+
// drawn base height when the caller already has it (obliqueProjector); otherwise visualBase.
|
|
609
|
+
function liftProjectorAt(tile, o, z, zvGiven) {
|
|
610
|
+
if (!(z > 0)) return (gx, gy, gz) => project(gx, gy, gz, o);
|
|
611
|
+
if (o.oblique) {
|
|
612
|
+
// flight goes along the sphere's normal at the block's centre, so the
|
|
613
|
+
// board's blocks leave and arrive fanned out from its middle; the cube
|
|
614
|
+
// itself stays upright. project() adds the sphere's height where a point
|
|
615
|
+
// is DRAWN, so the difference to where it stands is put back: the flight
|
|
616
|
+
// is measured from the block's own spot on the sphere.
|
|
617
|
+
const zv = zvGiven ?? visualBase(tile, o);
|
|
618
|
+
const off = flightOffset(tile.x + tile.s / 2, tile.y + tile.s / 2, zv, o);
|
|
619
|
+
const sx = off.x, sy = off.y, sz = off.z;
|
|
620
|
+
if (!sx && !sy) return (gx, gy, gz) => project(gx, gy, sz + (gz - z), o);
|
|
621
|
+
return (gx, gy, gz) => project(gx + sx, gy + sy, sz + (gz - z) + capZ(gx, gy, o) - capZ(gx + sx, gy + sy, o), o);
|
|
622
|
+
}
|
|
623
|
+
const { risePerUnit = 0.075, persp = 0.55 } = o;
|
|
624
|
+
const { vanishX = 0, vanishY = 0 } = o;
|
|
625
|
+
const boost = Number.isFinite(tile.boost) ? tile.boost : liftBoost(z);
|
|
626
|
+
const { dx, dy } = airTop(tile, boost, o);
|
|
627
|
+
return (gx, gy, gz) => {
|
|
628
|
+
const h = gz - z; // height within the block: 0 at its base, its side on top
|
|
629
|
+
const f = project(gx, gy, 0, o);
|
|
630
|
+
// a real pinhole: position AND size scale about the vanishing point; at
|
|
631
|
+
// boost 1 this is exactly the resting projection, so touchdown is seamless
|
|
632
|
+
const k = (1 + h * risePerUnit * persp) * boost;
|
|
633
|
+
return { x: vanishX + (f.x - vanishX) * k + dx, y: vanishY + (f.y - vanishY) * k + dy };
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// AIRBORNE BLOCKS DO NOT GROW INTO EACH OTHER (operator: "the large bouncing
|
|
638
|
+
// blocks intersect each other when growing in size. We need to find a
|
|
639
|
+
// mitigation for that"). Growing each block about its own centre is what
|
|
640
|
+
// keeps it straight over its slot, but it is not a real projection: under a
|
|
641
|
+
// true pinhole two blocks at the same height scale APART together and never
|
|
642
|
+
// meet, while two neighbours swelling in place run into each other. So each
|
|
643
|
+
// frame the airborne blocks are placed highest first, and each one grows only
|
|
644
|
+
// as far as it can without touching a block already placed -- never below its
|
|
645
|
+
// true size, which is always clear, because in the drop every block is in its
|
|
646
|
+
// own column. A pair whose FOOTPRINTS overlap (a high lane crossing a low one
|
|
647
|
+
// in travel) is exempt: there one really is above the other, and the paint
|
|
648
|
+
// order already says so. Growing over RESTING blocks is left alone: that is
|
|
649
|
+
// what something above them looks like, and the offset shadow says so.
|
|
650
|
+
export function settleGrowth(tiles, o = {}) {
|
|
651
|
+
const air = tiles.filter((t) => (t.z ?? 0) > 0.02).sort((p, q) => (q.z - p.z) || String(p.txid).localeCompare(String(q.txid)));
|
|
652
|
+
const out = new Map();
|
|
653
|
+
if (air.length < 2) { for (const t of air) out.set(t, liftBoost(t.z)); return out; }
|
|
654
|
+
const feet = (p, q) => p.x < q.x + q.s && q.x < p.x + p.s && p.y < q.y + q.s && q.y < p.y + p.s;
|
|
655
|
+
const hits = (A, B) => Math.abs(A.x - B.x) < A.half + B.half - 1e-9 && Math.abs(A.y - B.y) < A.half + B.half - 1e-9;
|
|
656
|
+
// Everyone starts at TRUE size, which is always clear: at g = 1 an airborne
|
|
657
|
+
// top is exactly a resting top, and resting tops of disjoint footprints do
|
|
658
|
+
// not overlap. Each block, highest first, then grows as far as it can
|
|
659
|
+
// without touching any other -- the ones already placed at their grown
|
|
660
|
+
// size, the rest at true size -- so every later block still has its true
|
|
661
|
+
// size to fall back on. (A first cut only looked at blocks already placed;
|
|
662
|
+
// the highest then grew freely over a lower one that could not shrink.)
|
|
663
|
+
// resting blocks are obstacles too, at their true size, for blocks near the plane
|
|
664
|
+
const ground = tiles.filter((t) => !((t.z ?? 0) > 0.02));
|
|
665
|
+
const box = new Map([...air, ...ground].map((t) => [t, airTop(t, 1, o)]));
|
|
666
|
+
// With CUBES, true size is not always clear: a taller cube's top is nearer
|
|
667
|
+
// the camera and drawn larger, so it covers the edge of a shorter neighbour.
|
|
668
|
+
// That is correct occlusion, painted in the right order by top height. What
|
|
669
|
+
// is policed is overlap that GROWTH adds, so a pair already overlapping at
|
|
670
|
+
// true size is exempt, like a crossing.
|
|
671
|
+
const rest1 = new Map(box);
|
|
672
|
+
for (const t of air) {
|
|
673
|
+
const want = liftBoost(t.z);
|
|
674
|
+
const A1 = box.get(t);
|
|
675
|
+
const Aw = airTop(t, want, o);
|
|
676
|
+
// Through a real camera a growing block also TRAVELS, out along its ray,
|
|
677
|
+
// so a neighbour can be touched at true size, passed through on the way,
|
|
678
|
+
// and clear again at full growth. The first cut only checked full growth,
|
|
679
|
+
// missed exactly that neighbour, and was measured overlapping it at
|
|
680
|
+
// g = 1.07. So the watch list is everything touching the whole SWEPT box
|
|
681
|
+
// (the centre moves monotonically and the size grows, so the boxes in
|
|
682
|
+
// between stay inside the bounds of the two ends, edge hold included).
|
|
683
|
+
const sw = {
|
|
684
|
+
x0: Math.min(A1.x - A1.half, Aw.x - Aw.half), x1: Math.max(A1.x + A1.half, Aw.x + Aw.half),
|
|
685
|
+
y0: Math.min(A1.y - A1.half, Aw.y - Aw.half), y1: Math.max(A1.y + A1.half, Aw.y + Aw.half),
|
|
686
|
+
};
|
|
687
|
+
const inSweep = (B) => B.x + B.half > sw.x0 + 1e-9 && B.x - B.half < sw.x1 - 1e-9 && B.y + B.half > sw.y0 + 1e-9 && B.y - B.half < sw.y1 - 1e-9;
|
|
688
|
+
// EVERY airborne pair is policed, whatever their heights. Letting a block
|
|
689
|
+
// more than a slab higher overlap a lower one (true occlusion, in
|
|
690
|
+
// principle) was measured at ~750 overlapping pairs per frame on a
|
|
691
|
+
// realistic refresh, and read as a heap (operator: "There is way too much
|
|
692
|
+
// shit overlapping in 3D space"). And near the plane it is policed against
|
|
693
|
+
// RESTING blocks too: a low bounce swelling over its neighbours reads as
|
|
694
|
+
// sinking into them. Only a pair whose footprints genuinely overlap -- one
|
|
695
|
+
// crossing over the other in travel -- is exempt; nothing can separate those.
|
|
696
|
+
const obstacles = t.z < 2 ? [...air, ...ground] : air;
|
|
697
|
+
const near = obstacles.filter((q) => q !== t && !feet(t, q) && !hits(A1, rest1.get(q)) && inSweep(box.get(q)));
|
|
698
|
+
let g = want;
|
|
699
|
+
if (near.length) {
|
|
700
|
+
// Grow only up to the FIRST contact, never beyond it even where it would
|
|
701
|
+
// be clear again: otherwise a block could pop over its neighbour from one
|
|
702
|
+
// frame to the next as its height changes. Stepped, then refined.
|
|
703
|
+
const clear = (k) => { const B = airTop(t, k, o); return near.every((q) => !hits(B, box.get(q))); };
|
|
704
|
+
const STEPS = 32;
|
|
705
|
+
let lo = 1, hi = null;
|
|
706
|
+
for (let i = 1; i <= STEPS; i++) {
|
|
707
|
+
const k = 1 + ((want - 1) * i) / STEPS;
|
|
708
|
+
if (clear(k)) lo = k; else { hi = k; break; }
|
|
709
|
+
}
|
|
710
|
+
if (hi !== null) for (let i = 0; i < 14; i++) { const mid = (lo + hi) / 2; if (clear(mid)) lo = mid; else hi = mid; }
|
|
711
|
+
g = lo;
|
|
712
|
+
}
|
|
713
|
+
box.set(t, airTop(t, g, o));
|
|
714
|
+
out.set(t, g);
|
|
715
|
+
}
|
|
716
|
+
return out;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// Painter's depth: rows draw back to front, so a nearer block's lip overlaps
|
|
720
|
+
// the one behind it. One axis, because there is only one now.
|
|
721
|
+
export function depthOf(tile, o = {}) {
|
|
722
|
+
// nearer the viewer means lower on screen: with the rows flipped that is
|
|
723
|
+
// the SMALLER row index, so the order reverses with the axis
|
|
724
|
+
return (o.flipY === false) ? (tile.y + tile.s) : -tile.y;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
export function depthSort(tiles, o = {}) {
|
|
728
|
+
return [...tiles]
|
|
729
|
+
.map((t) => ({ t, d: depthOf(t, o), z: t.z ?? 0 }))
|
|
730
|
+
// lowest paints first: a block risen toward the viewer is in front of
|
|
731
|
+
// everything still on the plane
|
|
732
|
+
.sort((a, b) => (a.z - b.z) || (a.d - b.d) || String(a.t.txid).localeCompare(String(b.t.txid)))
|
|
733
|
+
.map((e) => e.t);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// --- shading -----------------------------------------------------------
|
|
737
|
+
// Between two '#rrggbb' colours, k of the way from a to b. Anything else (a missing colour, a
|
|
738
|
+
// named one) cannot be blended, so it switches at the midpoint rather than at either end.
|
|
739
|
+
export function mixColor(a, b, k) {
|
|
740
|
+
if (a === b || k >= 1) return b;
|
|
741
|
+
if (k <= 0) return a;
|
|
742
|
+
const hex = /^#([0-9a-f]{6})$/i;
|
|
743
|
+
const ma = hex.exec(String(a)), mb = hex.exec(String(b));
|
|
744
|
+
if (!ma || !mb) return k < 0.5 ? a : b;
|
|
745
|
+
const pa = parseInt(ma[1], 16), pb = parseInt(mb[1], 16);
|
|
746
|
+
const ch = (sh) => Math.round(((pa >> sh) & 255) + ((((pb >> sh) & 255) - ((pa >> sh) & 255)) * k));
|
|
747
|
+
return `#${((ch(16) << 16) | (ch(8) << 8) | ch(0)).toString(16).padStart(6, '0')}`;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Alpha rides inside rgba(), never through globalAlpha.
|
|
751
|
+
export function shade(hex, k, alpha = 1) {
|
|
752
|
+
const h = String(hex).replace('#', '');
|
|
753
|
+
const cl = (v) => Math.max(0, Math.min(255, Math.round(v)));
|
|
754
|
+
const r = cl(parseInt(h.slice(0, 2), 16) * k);
|
|
755
|
+
const g = cl(parseInt(h.slice(2, 4), 16) * k);
|
|
756
|
+
const b = cl(parseInt(h.slice(4, 6), 16) * k);
|
|
757
|
+
return `rgba(${r},${g},${b},${alpha})`;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// --- one slab ----------------------------------------------------------
|
|
761
|
+
// With a free yaw we cannot know in advance which sides face the camera, so
|
|
762
|
+
// all four are emitted and sorted back-to-front within the tile. The
|
|
763
|
+
// overdraw is two quads and it removes a whole class of bug.
|
|
764
|
+
// --- one block ---------------------------------------------------------
|
|
765
|
+
//
|
|
766
|
+
// THE BLOCK IS A TETRIS CELL WITH A CYBER FINISH (operator, 2026-09-11: "That
|
|
767
|
+
// bejewelled shit with the sparkles isn't working out for me. Consider a more
|
|
768
|
+
// tetris look for our blocks, but think cyber/futuristic. maybe even metallic
|
|
769
|
+
// sheens"). The gem cut tried earlier the same day -- chamfered octagons,
|
|
770
|
+
// crown facets, glints and stars -- is gone; recorded so it is not re-added
|
|
771
|
+
// as a flourish. All of this is solid polygons in paint order:
|
|
772
|
+
//
|
|
773
|
+
// lip the front face along the near edge, in shadow
|
|
774
|
+
// top the square, with the dark seam stroked round it
|
|
775
|
+
// bevel the raised frame of a Tetris cell, lit from the upper left:
|
|
776
|
+
// far edge brightest, left lit, right dim, near edge darkest
|
|
777
|
+
// face the cell's face, in its feerate colour
|
|
778
|
+
// well the hollow in the middle of the face: four walls lit the OTHER
|
|
779
|
+
// way round (the far wall in its own shadow, the near wall
|
|
780
|
+
// catching the light) around a darker floor -- the inner square
|
|
781
|
+
// of the Tetris reference (operator: "The blocks don't have that
|
|
782
|
+
// hollow inner area")
|
|
783
|
+
// rim a thin line round the well's lip in a lifted tint: the neon edge
|
|
784
|
+
// Diagonal sheen streaks and a gloss band were tried the same day and removed
|
|
785
|
+
// ("I don't like the diagonal lines in our large block design").
|
|
786
|
+
//
|
|
787
|
+
// A mid-sized block has the frame and face; a tiny one is a plain slab. The
|
|
788
|
+
// feerate colour is untouched -- every layer is derived from it, or is white
|
|
789
|
+
// at low alpha laid over it.
|
|
790
|
+
export function blockBevel(s) { return Math.min(0.7, Math.max(0.12, s * 0.15)); }
|
|
791
|
+
|
|
792
|
+
export function tileFaces(tile, o = {}, cut = false) {
|
|
793
|
+
const { x, y, s } = tile;
|
|
794
|
+
// the plane or the flight altitude, plus a resting altitude (`floor`: a market candle floats at
|
|
795
|
+
// its price) and any idle-effect lift
|
|
796
|
+
const base = (tile.z ?? 0) + (tile.floor ?? 0) + (tile.fxz ?? 0);
|
|
797
|
+
const top = base + cubeHeight(tile);
|
|
798
|
+
const P = liftProjector(tile, o);
|
|
799
|
+
|
|
800
|
+
// A CUBE SHOWS THE SIDES THAT FACE THE CAMERA. The camera is a pinhole over
|
|
801
|
+
// the vanishing point, so a cube's top is pushed outward from it and the
|
|
802
|
+
// side faces turned TOWARD the vanishing point come into view -- up to two
|
|
803
|
+
// of them, none for a cube dead under the camera. A side is drawn when its
|
|
804
|
+
// outward normal on screen points at the vanishing point.
|
|
805
|
+
const topQuad = [P(x, y, top), P(x + s, y, top), P(x + s, y + s, top), P(x, y + s, top)];
|
|
806
|
+
const foot = [P(x, y, base), P(x + s, y, base), P(x + s, y + s, base), P(x, y + s, base)];
|
|
807
|
+
const vx = o.vanishX ?? 0, vy = o.vanishY ?? 0;
|
|
808
|
+
const fcx = (foot[0].x + foot[2].x) / 2, fcy = (foot[0].y + foot[2].y) / 2;
|
|
809
|
+
const sides = [];
|
|
810
|
+
for (const [i, j, key] of [[0, 1, 'near'], [1, 2, 'right'], [2, 3, 'far'], [3, 0, 'left']]) {
|
|
811
|
+
const A = foot[i], B = foot[j];
|
|
812
|
+
const mx = (A.x + B.x) / 2, my = (A.y + B.y) / 2;
|
|
813
|
+
let nx = B.y - A.y, ny = A.x - B.x;
|
|
814
|
+
if ((mx - fcx) * nx + (my - fcy) * ny < 0) { nx = -nx; ny = -ny; } // make it point outward
|
|
815
|
+
const len = Math.hypot(nx, ny) || 1;
|
|
816
|
+
// A side is seen when the top edge has been displaced AGAINST the side's
|
|
817
|
+
// outward normal -- the face then spans the gap between footprint and top.
|
|
818
|
+
// Under the pinhole that is exactly "faces the vanishing point"; under the
|
|
819
|
+
// oblique camera it is the west and south faces. One rule for both.
|
|
820
|
+
const tmx = (topQuad[i].x + topQuad[j].x) / 2, tmy = (topQuad[i].y + topQuad[j].y) / 2;
|
|
821
|
+
if (nx * (tmx - mx) + ny * (tmy - my) < -1e-9) {
|
|
822
|
+
sides.push({ key, nx: nx / len, ny: ny / len, points: [topQuad[i], topQuad[j], B, A] });
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
const out = { top: topQuad, sides, base, topZ: top, P };
|
|
826
|
+
if (!cut) return out;
|
|
827
|
+
const b = blockBevel(s);
|
|
828
|
+
out.bevel = b;
|
|
829
|
+
out.innerG = [[x + b, y + b], [x + s - b, y + b], [x + s - b, y + s - b], [x + b, y + s - b]];
|
|
830
|
+
out.inset = out.innerG.map(([gx, gy]) => P(gx, gy, top));
|
|
831
|
+
return out;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// Blend a hex colour toward white by t, alpha riding inside the rgba() --
|
|
835
|
+
// never through globalAlpha.
|
|
836
|
+
export function lift(hex, t, alpha = 1) {
|
|
837
|
+
const h = String(hex).replace('#', '');
|
|
838
|
+
const ch = (i) => parseInt(h.slice(i, i + 2), 16);
|
|
839
|
+
const m = (v) => Math.max(0, Math.min(255, Math.round(v + (255 - v) * t)));
|
|
840
|
+
return `rgba(${m(ch(0))},${m(ch(2))},${m(ch(4))},${alpha})`;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const round3 = (v) => Math.round(v * 1000) / 1000;
|
|
844
|
+
const flipOf = (o) => (o.flipY === false ? -1 : 1);
|
|
845
|
+
|
|
846
|
+
// On the curved board each block is lit by the SLOPE of the surface under it
|
|
847
|
+
// -- light from the upper left and above -- which is what makes a curve read
|
|
848
|
+
// as a curve. 1 on a flat board, so nothing changes when the dome is off.
|
|
849
|
+
// WHERE THE LAMP IS (operator, 2026-09-12: "The bottom of the tetrust board is too [dark]. We
|
|
850
|
+
// need direct overhead lighting in teh 3d scene for the game" ... "Move the block space light to
|
|
851
|
+
// be directly above the board centered ... we should add configurable light locations in
|
|
852
|
+
// settings"). Each lamp is a direction over the board for the dome's slope shading (grid x,
|
|
853
|
+
// screen-up rows, up) and a direction across the screen for the side faces (x right, y down);
|
|
854
|
+
// `overhead` is straight above, so no slope is in shade and every side takes the same light.
|
|
855
|
+
export const LIGHTS = Object.freeze({
|
|
856
|
+
'overhead': { L: [0, 0, 1], side: [0, 0] },
|
|
857
|
+
'upper-left': { L: [-0.55, 0.55, 0.63], side: [-0.7071, -0.7071] },
|
|
858
|
+
'upper-right': { L: [0.55, 0.55, 0.63], side: [0.7071, -0.7071] },
|
|
859
|
+
'front': { L: [0, -0.55, 0.63], side: [0, 0.9] },
|
|
860
|
+
});
|
|
861
|
+
export const LIGHT_DEFAULT = 'upper-left';
|
|
862
|
+
const NEON_HEX = /^#[0-9a-f]{6}$/i;
|
|
863
|
+
export function lightOf(o = {}) {
|
|
864
|
+
return LIGHTS[o.light] ? o.light : o.overheadLight === true ? 'overhead' : LIGHT_DEFAULT;
|
|
865
|
+
}
|
|
866
|
+
export function domeLight(t, o = {}) {
|
|
867
|
+
const { dome = 0, gridW = 0, gridH = 0 } = o;
|
|
868
|
+
if (!dome || !gridW || !gridH) return 1;
|
|
869
|
+
const lamp = LIGHTS[lightOf(o)];
|
|
870
|
+
// straight above: the slope shades nothing -- the bottom rows, which lean away from a corner
|
|
871
|
+
// lamp and sat at the 0.6 floor, read as bright as the middle
|
|
872
|
+
if (lamp.L[0] === 0 && lamp.L[1] === 0) return 1;
|
|
873
|
+
const u = (2 * (t.x + t.s / 2)) / gridW - 1, v = (2 * (t.y + t.s / 2)) / gridH - 1;
|
|
874
|
+
const gx = (dome * -2 * u * (1 - v * v) * 2) / gridW; // dz/dx in grid units
|
|
875
|
+
const gy = (dome * (1 - u * u) * -2 * v * 2) / gridH; // dz/dy
|
|
876
|
+
const up = o.flipY === false ? -1 : 1; // screen-up in grid rows
|
|
877
|
+
const L = [lamp.L[0], lamp.L[1] * up, lamp.L[2]];
|
|
878
|
+
const d = (-gx * L[0] + -gy * L[1] + L[2]) / Math.hypot(gx, gy, 1);
|
|
879
|
+
return Math.max(0.6, Math.min(1.35, d / L[2]));
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// THE BOARD AT REST (operator, 2026-09-11: "There needs to be an occasional
|
|
883
|
+
// energy ripple or other effect that sweeps the grid when the blocks are at
|
|
884
|
+
// rest", then "add varied effects. More than a pulse. Maybe add an energy pulse
|
|
885
|
+
// that travels along from one side of the board to another via block outlines.
|
|
886
|
+
// Be creative and varied"). One effect at a time, chosen by the renderer:
|
|
887
|
+
//
|
|
888
|
+
// ripple a ring of light spreading from a point
|
|
889
|
+
// outline an energy front crossing the board, lighting each cube's OUTLINE
|
|
890
|
+
// as it passes and leaving a fading trail
|
|
891
|
+
// tide a wave that LIFTS the cubes as it passes
|
|
892
|
+
// cascade a gold flash running from the richest transaction to the cheapest
|
|
893
|
+
// -- the effect is also a reading of the fee structure
|
|
894
|
+
// twinkle scattered cubes glint, each at its own moment
|
|
895
|
+
// scan a bright line sweeping across the board
|
|
896
|
+
//
|
|
897
|
+
// fx = { kind, u (progress 0..1), amp (fade in/out), gridW, gridH, dx, dy,
|
|
898
|
+
// rank (cascade), seed (twinkle), x, y, r, w (ripple) }.
|
|
899
|
+
// Returns { glow 0..1, outline 0..1, lift (units), color [r,g,b] }. Pure, so it
|
|
900
|
+
// is tested directly; only resting blocks are touched.
|
|
901
|
+
// `hide` and `scale` are how an effect may ALTER THE BOARD and have it snap back (operator,
|
|
902
|
+
// 2026-09-13, asked whether effects may eat cubes: "Allow it, it snaps back"). They are applied
|
|
903
|
+
// per frame onto a COPY of the tile, exactly as `lift` already is -- nothing here ever mutates
|
|
904
|
+
// st.restTiles -- so when the effect ends, is interrupted by a transition, or the tab is hidden,
|
|
905
|
+
// the override simply stops being computed and the board is correct again by construction. That
|
|
906
|
+
// is the only version of "snaps back" that cannot leak.
|
|
907
|
+
// hide 1 = the cube is not drawn at all this frame (absorbed, dug through, collapsed away)
|
|
908
|
+
// scale 1 = full height; below 1 the cube is drawn shorter, for a collapse or a dig
|
|
909
|
+
export const FX_NONE = Object.freeze({ glow: 0, outline: 0, lift: 0, color: null, hide: 0, scale: 1 });
|
|
910
|
+
|
|
911
|
+
// where a sweeping front is, along (dx, dy), in grid units -- entering from
|
|
912
|
+
// just outside one side of the board and leaving past the other
|
|
913
|
+
export function fxFront(fx) {
|
|
914
|
+
const qs = [0, fx.gridW * fx.dx, fx.gridH * fx.dy, fx.gridW * fx.dx + fx.gridH * fx.dy];
|
|
915
|
+
// THE MARGIN IS THE PANEL'S, NOT A CONSTANT (operator, 2026-09-15: "It needs to cleanly travel on
|
|
916
|
+
// and off screen"). Four units clears the BOARD, and the panel reaches much further than that --
|
|
917
|
+
// measured 21.8 units beyond a 96-wide board -- so a front that started four units out began its
|
|
918
|
+
// sweep in plain view and ended it there. render3d measures the real overhang from the viewRect
|
|
919
|
+
// and puts it on `fx.margin`; four is the fallback for a caller with no panel (tests).
|
|
920
|
+
const m = fx.margin ?? 4;
|
|
921
|
+
const lo = Math.min(...qs) - m, hi = Math.max(...qs) + m;
|
|
922
|
+
// A SEARCHLIGHT PANS (operator, 2026-09-15: "the scanner needs to be more like a search light,
|
|
923
|
+
// slowly panning back and forth"). A single linear pass is a scanner; sweeping out and back is a
|
|
924
|
+
// light being AIMED, which is what reads as something looking for things. `pan` is set for the
|
|
925
|
+
// top-down board only -- on the price chart the camera is low and one pass along the hours is
|
|
926
|
+
// the right motion.
|
|
927
|
+
const t = fx.pan ? 1 - Math.abs(((fx.u * 2) % 2) - 1) : fx.u;
|
|
928
|
+
return lo + t * (hi - lo);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// a deterministic 0..1 from an integer: where a firework bursts, which cube flares. Hashed, not
|
|
932
|
+
// random, so an effect draws the same picture every time it replays -- which is what lets the
|
|
933
|
+
// tests above assert on it at all.
|
|
934
|
+
export function fxHash(n) {
|
|
935
|
+
const x = Math.sin((n | 0) * 12.9898 + 78.233) * 43758.5453;
|
|
936
|
+
return x - Math.floor(x);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
export function fxAt(t, fx) {
|
|
940
|
+
if (!fx) return FX_NONE;
|
|
941
|
+
const A = fx.amp ?? 1;
|
|
942
|
+
const cx = t.x + t.s / 2, cy = t.y + t.s / 2;
|
|
943
|
+
const g = (k) => Math.exp(-k * k);
|
|
944
|
+
const along = () => cx * fx.dx + cy * fx.dy;
|
|
945
|
+
switch (fx.kind) {
|
|
946
|
+
case 'ripple': {
|
|
947
|
+
const k = (Math.hypot(cx - fx.x, cy - fx.y) - fx.r) / Math.max(0.5, fx.w);
|
|
948
|
+
return { glow: A * g(k), outline: 0.6 * A * g(k), lift: 0, color: [120, 255, 210] };
|
|
949
|
+
}
|
|
950
|
+
case 'outline': {
|
|
951
|
+
const q = along(), p = fxFront(fx);
|
|
952
|
+
const lead = g((q - p) / 3);
|
|
953
|
+
const trail = q < p ? 0.6 * Math.exp(-(p - q) / 9) : 0;
|
|
954
|
+
return { glow: 0.45 * A * lead, outline: A * Math.min(1, lead + trail), lift: 0, color: [90, 230, 255] };
|
|
955
|
+
}
|
|
956
|
+
case 'tide': {
|
|
957
|
+
const w = g((along() - fxFront(fx)) / 3.2);
|
|
958
|
+
return { glow: 0.6 * A * w, outline: 0.35 * A * w, lift: 2.2 * A * w, color: [140, 255, 180] };
|
|
959
|
+
}
|
|
960
|
+
case 'cascade': {
|
|
961
|
+
const r = fx.rank?.get(t.txid);
|
|
962
|
+
if (r == null) return FX_NONE;
|
|
963
|
+
const w = g((r - (fx.u * 1.25 - 0.1)) / 0.12);
|
|
964
|
+
return { glow: A * w, outline: 0.9 * A * w, lift: 0.6 * A * w, color: [255, 214, 120] };
|
|
965
|
+
}
|
|
966
|
+
case 'twinkle': {
|
|
967
|
+
if (jitterOf(t.txid, 'tw' + fx.seed) > 0.45) return FX_NONE;
|
|
968
|
+
const w = g((fx.u - 0.05 - jitterOf(t.txid, 'tt' + fx.seed) * 0.85) / 0.05);
|
|
969
|
+
return { glow: A * w, outline: 0.8 * A * w, lift: 0, color: [255, 255, 255] };
|
|
970
|
+
}
|
|
971
|
+
case 'scan': {
|
|
972
|
+
// the beam's own width -- ten units, so a whole swath of blocks is lit as it passes
|
|
973
|
+
// (2026-09-15: "Reach more blocks with the scanner effect")
|
|
974
|
+
// THE LIGHT IS THE POINT (operator, 2026-09-15: "We really need to make it much more obvious
|
|
975
|
+
// somehow that the blocks are being illuminated"). The swath tracks the beam's own half-width
|
|
976
|
+
// (fx.beamR, set with the cone) so light and geometry cannot disagree, and it hits harder:
|
|
977
|
+
// a near-white glow, a bright outline and real lift, so a struck cube stands up out of the
|
|
978
|
+
// board rather than merely brightening.
|
|
979
|
+
const w = g((along() - fxFront(fx)) / (fx.beamR ?? 5.5));
|
|
980
|
+
return { glow: Math.min(1, 1.6 * A * w), outline: Math.min(1, 1.2 * A * w), lift: 1.4 * A * w, color: [225, 248, 255] };
|
|
981
|
+
}
|
|
982
|
+
case 'xray': {
|
|
983
|
+
// X-RAY (2026-09-15, operator: "Add xray as additional choosable effect"): a front sweeps the
|
|
984
|
+
// board and behind it every tile goes x-ray -- its body dims to glass and its edges and a
|
|
985
|
+
// raster across its faces light up (buildScene's xray treatment) -- then develops back to
|
|
986
|
+
// solid over a few units, the front itself a bright edge
|
|
987
|
+
const d = fxFront(fx) - along(); // > 0 once the front has passed
|
|
988
|
+
const edge = g(d / 1.2);
|
|
989
|
+
const back = d > 0 && d < 9 ? (d < 5 ? 1 : 1 - (d - 5) / 4) : 0;
|
|
990
|
+
const xray = Math.max(edge, back) * A;
|
|
991
|
+
return xray > 0.02 ? { glow: 0.5 * A * edge, outline: A * Math.max(edge, 0.8 * back), lift: 0, color: [190, 240, 255], xray } : FX_NONE;
|
|
992
|
+
}
|
|
993
|
+
case 'ball': {
|
|
994
|
+
// "illuminating everything it comes near": brightest right under the ball, gone ~5 units off
|
|
995
|
+
const b = fx.ball;
|
|
996
|
+
if (!b) return FX_NONE;
|
|
997
|
+
const ddx = Math.max(t.x - b.x, 0, b.x - (t.x + t.s)), ddy = Math.max(t.y - b.y, 0, b.y - (t.y + t.s));
|
|
998
|
+
const w = g(Math.hypot(ddx, ddy) / 2.6);
|
|
999
|
+
return w > 0.02 ? { glow: 0.95 * w, outline: 0.9 * w, lift: 0, color: [170, 225, 255] } : FX_NONE;
|
|
1000
|
+
}
|
|
1001
|
+
// ---------------------------------------------------------------------------------------
|
|
1002
|
+
// THE ARCADE (operator, 2026-09-12: "Think of many more other video-game inspired effects ...
|
|
1003
|
+
// at least 25 total different effects, all toggleable"). Every one below is a PURE function of
|
|
1004
|
+
// the tile and the effect's clock -- no state is kept, nothing is allocated per frame, and the
|
|
1005
|
+
// board-level choices (where a firework bursts, which tile flares) are hashed out of fx.seed,
|
|
1006
|
+
// so an effect replays identically and can be asserted here rather than watched.
|
|
1007
|
+
//
|
|
1008
|
+
// `reach` is the distance from the effect's origin to the farthest corner: a radial effect
|
|
1009
|
+
// that covers the board in the time it is given, whatever the board's shape.
|
|
1010
|
+
case 'shockwave': {
|
|
1011
|
+
// Smash-style: a hard ring that throws the cubes it passes UP, not a soft glow
|
|
1012
|
+
const reach = Math.hypot(Math.max(fx.x, fx.gridW - fx.x), Math.max(fx.y, fx.gridH - fx.y));
|
|
1013
|
+
const r = reach * (1 - Math.pow(1 - fx.u, 1.7));
|
|
1014
|
+
const k = (Math.hypot(cx - fx.x, cy - fx.y) - r) / 1.7;
|
|
1015
|
+
const w = g(k);
|
|
1016
|
+
return { glow: 0.85 * A * w, outline: A * w, lift: 4.5 * A * w, color: [255, 240, 190] };
|
|
1017
|
+
}
|
|
1018
|
+
case 'nova': {
|
|
1019
|
+
// an implosion that snaps back out: the ring races IN to the middle over the first half,
|
|
1020
|
+
// then out again, brighter and whiter, over the second
|
|
1021
|
+
const reach = Math.hypot(Math.max(fx.x, fx.gridW - fx.x), Math.max(fx.y, fx.gridH - fx.y));
|
|
1022
|
+
const inward = fx.u < 0.5;
|
|
1023
|
+
const v = inward ? fx.u / 0.5 : (fx.u - 0.5) / 0.5;
|
|
1024
|
+
const r = inward ? reach * (1 - Math.pow(v, 0.7)) : reach * Math.pow(v, 0.6);
|
|
1025
|
+
const w = g((Math.hypot(cx - fx.x, cy - fx.y) - r) / (inward ? 2.2 : 1.5));
|
|
1026
|
+
const hot = inward ? 0.55 : 1;
|
|
1027
|
+
return { glow: hot * A * w, outline: 0.8 * hot * A * w, lift: (inward ? 0.4 : 3.2) * A * w,
|
|
1028
|
+
color: inward ? [150, 190, 255] : [255, 255, 245] };
|
|
1029
|
+
}
|
|
1030
|
+
// 'firework' lights through fxNow's heads now (details3d fireworkShells): one head per live
|
|
1031
|
+
// shell, in the shell's colour -- see the heads branch below
|
|
1032
|
+
case 'flare': {
|
|
1033
|
+
// one cube goes supernova and lights its neighbourhood -- the same hashed cube every replay
|
|
1034
|
+
const fxp = fxHash(fx.seed + 7) * fx.gridW, fyp = fxHash(fx.seed + 8) * fx.gridH;
|
|
1035
|
+
const bell = Math.sin(Math.PI * fx.u);
|
|
1036
|
+
const d = Math.hypot(cx - fxp, cy - fyp);
|
|
1037
|
+
// A SUPERNOVA'S LIGHT (2026-09-15, drawn in details3d drawSupernova): on a thin board -- the
|
|
1038
|
+
// price board, eight deep -- the light reaches three times as far, so the chart is lit, and
|
|
1039
|
+
// it cools with the remnant, gold to red to violet, after the white of the blast
|
|
1040
|
+
const thin = fx.gridH <= 12;
|
|
1041
|
+
const w = g(d / (1.2 + (thin ? 22 : 7) * bell)) * bell;
|
|
1042
|
+
const cool = Math.max(0, Math.min(1, (fx.u - 0.2) / 0.8));
|
|
1043
|
+
// white at the blast, then blue, then violet, as the cloud goes (details3d drawSupernova)
|
|
1044
|
+
const col = cool < 0.3 ? [Math.round(255 - 105 * cool / 0.3), Math.round(255 - 60 * cool / 0.3), 255] : [Math.round(150 + 25 * (cool - 0.3) / 0.7), Math.round(195 - 85 * (cool - 0.3) / 0.7), 255];
|
|
1045
|
+
// THE SHOCKWAVE THROWS WHAT IT CROSSES (operator, 2026-09-15: "Consider perturbing all the
|
|
1046
|
+
// candles that are affected by the shockwave"): the ring drawn in drawSupernova (4.5 shell
|
|
1047
|
+
// radii, out over u 0.14-0.6) runs through the tiles here too -- a tile it reaches is thrown
|
|
1048
|
+
// up hard and lit white, and shakes itself out for a while after the front has passed
|
|
1049
|
+
let lift = 1.4 * A * w, glow = A * w, outline = 0.8 * A * w, colour = col;
|
|
1050
|
+
if (fx.u >= 0.14) {
|
|
1051
|
+
const fr = Math.min(1, (fx.u - 0.14) / 0.46), ring = (thin ? 27 : 31) * (1 - Math.pow(1 - fr, 2.2));
|
|
1052
|
+
const front = g((d - ring) / 1.8) * (1 - fr * 0.6);
|
|
1053
|
+
const passed = ring - d; // > 0 once the front has gone by
|
|
1054
|
+
// the shaking starts as the front APPROACHES, five units out, not once it has passed
|
|
1055
|
+
// (operator: "trigger their shaking sooner in the blast radius")
|
|
1056
|
+
const shake = passed > -5 && fx.u < 0.75 ? Math.max(0, Math.sin(fx.u * 70 + jitterOf(t.txid, 'sn' + fx.seed) * 6.28)) * Math.exp(-Math.max(0, passed) / 12) * (1 - (fx.u - 0.14) / 0.61) : 0;
|
|
1057
|
+
if (front > 0.02 || shake > 0.02) {
|
|
1058
|
+
lift = Math.max(lift, 0.5 * A * front + 0.12 * A * shake); // a tenth of the first cut (operator: "affects the candles too much", "half as much still", "we really need to turn down the block shaking")
|
|
1059
|
+
glow = Math.max(glow, A * front + 0.4 * A * shake);
|
|
1060
|
+
outline = Math.max(outline, A * front);
|
|
1061
|
+
if (front > w) colour = [255, 255, 255];
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
return glow > 0.02 || lift > 0.05 ? { glow: Math.min(1, glow), outline: Math.min(1, outline), lift, color: colour } : FX_NONE;
|
|
1065
|
+
}
|
|
1066
|
+
case 'wave': {
|
|
1067
|
+
// a swell rolling across the board: the cubes rise and fall with it, several crests at once
|
|
1068
|
+
const d = along() - fxFront(fx);
|
|
1069
|
+
const env = g(d / 7);
|
|
1070
|
+
const phase = Math.sin(d * 0.75);
|
|
1071
|
+
const up = Math.max(0, phase);
|
|
1072
|
+
return { glow: 0.45 * A * env * up, outline: 0.3 * A * env * up, lift: 3 * A * env * up, color: [120, 200, 255] };
|
|
1073
|
+
}
|
|
1074
|
+
case 'quake': {
|
|
1075
|
+
// the board shakes: every cube jumps on its own beat, hardest at the start, dying out
|
|
1076
|
+
const decay = Math.pow(1 - fx.u, 2);
|
|
1077
|
+
const j = jitterOf(t.txid, 'qk' + fx.seed);
|
|
1078
|
+
const shake = Math.sin(fx.u * 60 + j * 6.28);
|
|
1079
|
+
const up = Math.max(0, shake) * decay;
|
|
1080
|
+
return { glow: 0.3 * A * up, outline: 0.5 * A * up, lift: 2.4 * A * up, color: [255, 180, 140] };
|
|
1081
|
+
}
|
|
1082
|
+
case 'rain': {
|
|
1083
|
+
// code rain: each column has its own drop, falling from the top of the board to the floor,
|
|
1084
|
+
// with a white head and a fading green tail behind it
|
|
1085
|
+
const col = Math.floor(cx);
|
|
1086
|
+
const ph = fxHash(fx.seed + col * 17);
|
|
1087
|
+
const head = fx.gridH + 3 - (fx.u * 1.35 - ph * 0.35) * (fx.gridH + 8);
|
|
1088
|
+
const d = cy - head; // above the head: the tail
|
|
1089
|
+
if (d < -1.2) return FX_NONE;
|
|
1090
|
+
const w = d < 1.2 ? 1 : Math.exp(-(d - 1.2) / 3.5);
|
|
1091
|
+
const white = d < 1.2 ? 1 : 0;
|
|
1092
|
+
return { glow: A * w, outline: 0.7 * A * w, lift: 0,
|
|
1093
|
+
color: white ? [225, 255, 235] : [60, 235, 140] };
|
|
1094
|
+
}
|
|
1095
|
+
case 'sparkle': {
|
|
1096
|
+
// a constellation lighting up a few cubes at a time, each in its own colour
|
|
1097
|
+
const pick = jitterOf(t.txid, 'sp' + fx.seed);
|
|
1098
|
+
if (pick > 0.3) return FX_NONE;
|
|
1099
|
+
const when = jitterOf(t.txid, 'st' + fx.seed);
|
|
1100
|
+
const w = g((fx.u - 0.08 - when * 0.8) / 0.07);
|
|
1101
|
+
const hue = jitterOf(t.txid, 'sc' + fx.seed);
|
|
1102
|
+
const col = hue < 0.34 ? [255, 230, 160] : hue < 0.67 ? [170, 220, 255] : [235, 175, 255];
|
|
1103
|
+
return { glow: A * w, outline: 0.85 * A * w, lift: 0.5 * A * w, color: col };
|
|
1104
|
+
}
|
|
1105
|
+
case 'checker': {
|
|
1106
|
+
// the board flips like a chessboard: black squares up while white squares are down
|
|
1107
|
+
const dark = (Math.floor(cx) + Math.floor(cy)) & 1;
|
|
1108
|
+
const beat = Math.sin(fx.u * Math.PI * 4 + (dark ? Math.PI : 0));
|
|
1109
|
+
const up = Math.max(0, beat) * Math.sin(Math.PI * fx.u);
|
|
1110
|
+
// (the first cut peaked at 0.26 of full brightness -- a flip nobody would notice across a
|
|
1111
|
+
// board of small cubes; the squares have to actually read as lifting against each other)
|
|
1112
|
+
return { glow: 0.85 * A * up, outline: 0.75 * A * up, lift: 2.8 * A * up,
|
|
1113
|
+
color: dark ? [140, 255, 210] : [255, 210, 130] };
|
|
1114
|
+
}
|
|
1115
|
+
case 'radar': {
|
|
1116
|
+
// a sweep hand turning once round the board, the cubes behind it fading like phosphor
|
|
1117
|
+
const ang = Math.atan2(cy - fx.y, cx - fx.x);
|
|
1118
|
+
const hand = -Math.PI + fx.u * Math.PI * 2;
|
|
1119
|
+
let d = ang - hand;
|
|
1120
|
+
while (d < 0) d += Math.PI * 2; // 0 at the hand, 2pi just before it
|
|
1121
|
+
const w = d < 0.18 ? 1 : Math.exp(-(d - 0.18) * 1.6);
|
|
1122
|
+
return { glow: 0.8 * A * w, outline: 0.5 * A * w, lift: 0, color: [110, 255, 170] };
|
|
1123
|
+
}
|
|
1124
|
+
case 'vortex': {
|
|
1125
|
+
// the radar's hand, wound into a spiral: the arms turn and the whole board drains inward
|
|
1126
|
+
const ang = Math.atan2(cy - fx.y, cx - fx.x);
|
|
1127
|
+
const rad = Math.hypot(cy - fx.y, cx - fx.x);
|
|
1128
|
+
const arm = ang + rad * 0.42 - fx.u * Math.PI * 4;
|
|
1129
|
+
const d = Math.abs(((arm % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2) - Math.PI);
|
|
1130
|
+
const w = g((Math.PI - d) / 0.55) * Math.sin(Math.PI * fx.u);
|
|
1131
|
+
return { glow: 0.9 * A * w, outline: 0.6 * A * w, lift: 1.2 * A * w, color: [165, 150, 255] };
|
|
1132
|
+
}
|
|
1133
|
+
case 'powerup': {
|
|
1134
|
+
// the board charges from the floor up, gold, with a bright lip at the top of the fill
|
|
1135
|
+
const fill = fx.u * (fx.gridH + 4) - 2;
|
|
1136
|
+
const below = fill - cy;
|
|
1137
|
+
if (below < -1) return FX_NONE;
|
|
1138
|
+
const lip = g(below / 1.1);
|
|
1139
|
+
const held = below > 0 ? Math.min(0.55, 0.55 * Math.pow(1 - fx.u, 0.6)) : 0;
|
|
1140
|
+
const w = Math.min(1, lip + held);
|
|
1141
|
+
return { glow: A * w, outline: A * lip, lift: 1.6 * A * lip,
|
|
1142
|
+
color: lip > 0.4 ? [255, 250, 220] : [255, 200, 90] };
|
|
1143
|
+
}
|
|
1144
|
+
case 'combo': {
|
|
1145
|
+
// a chain reaction running the diagonal, each link popping as it is reached
|
|
1146
|
+
const q = (cx + cy) / (fx.gridW + fx.gridH);
|
|
1147
|
+
const v = fx.u * 1.2 - 0.1;
|
|
1148
|
+
const w = g((q - v) / 0.05);
|
|
1149
|
+
const step = Math.floor(q * 12);
|
|
1150
|
+
const col = [[255, 120, 90], [255, 190, 80], [120, 255, 160], [110, 200, 255]][step & 3];
|
|
1151
|
+
return { glow: A * w, outline: A * w, lift: 2.2 * A * w, color: col };
|
|
1152
|
+
}
|
|
1153
|
+
case 'aurora': {
|
|
1154
|
+
// slow curtains of colour drifting over the board, brightest where they fold
|
|
1155
|
+
const band = Math.sin(cx * 0.24 + fx.u * 5) + Math.sin(cy * 0.17 - fx.u * 3.4);
|
|
1156
|
+
const w = Math.max(0, band) * 0.5 * Math.sin(Math.PI * fx.u);
|
|
1157
|
+
const mix = (band + 2) / 4;
|
|
1158
|
+
const col = [Math.round(80 + 60 * mix), Math.round(200 + 40 * mix), Math.round(255 - 70 * mix)];
|
|
1159
|
+
return { glow: 0.8 * A * w, outline: 0.25 * A * w, lift: 0.8 * A * w, color: col };
|
|
1160
|
+
}
|
|
1161
|
+
case 'plasma': {
|
|
1162
|
+
// the demoscene plasma: three sines over the board, the colour cycling with the clock
|
|
1163
|
+
const v = Math.sin(cx * 0.32) + Math.sin(cy * 0.29) + Math.sin((cx + cy) * 0.19 + fx.u * 7);
|
|
1164
|
+
const w = Math.max(0, v / 3) * Math.sin(Math.PI * fx.u);
|
|
1165
|
+
const ph = (v + 3) / 6;
|
|
1166
|
+
const col = [Math.round(140 + 115 * ph), Math.round(90 + 140 * (1 - ph)), Math.round(190 + 60 * ph)];
|
|
1167
|
+
return { glow: 0.85 * A * w, outline: 0.2 * A * w, lift: 0, color: col };
|
|
1168
|
+
}
|
|
1169
|
+
case 'pulse': // the price line's surge lights the candles under its head (details3d fxNow)
|
|
1170
|
+
case 'firework': // each shell lights its surroundings in its colour (details3d fxNow)
|
|
1171
|
+
case 'blackhole': // the hole's head shrinks the candles it reaches toward nothing (scale) and lights the rest orange
|
|
1172
|
+
case 'lightcycle':
|
|
1173
|
+
case 'packets':
|
|
1174
|
+
case 'centipede':
|
|
1175
|
+
case 'tractor':
|
|
1176
|
+
case 'missile':
|
|
1177
|
+
case 'boulderdash':
|
|
1178
|
+
case 'stormball': {
|
|
1179
|
+
let best = FX_NONE, bw = 0;
|
|
1180
|
+
for (const hd of fx.heads ?? []) {
|
|
1181
|
+
const ddx = Math.max(t.x - hd.x, 0, hd.x - (t.x + t.s)), ddy = Math.max(t.y - hd.y, 0, hd.y - (t.y + t.s));
|
|
1182
|
+
const w = (hd.alpha ?? 1) * g(Math.hypot(ddx, ddy) / Math.max(0.2, hd.r ?? 0.8));
|
|
1183
|
+
if (w > bw && w > 0.02) {
|
|
1184
|
+
bw = w;
|
|
1185
|
+
// HIDE AND SCALE TRAVEL WITH THE HEAD, or the machinery for them is unreachable. These
|
|
1186
|
+
// were hardcoded to "visible, full size" when the branch was written, which meant an
|
|
1187
|
+
// agent could ask to absorb or collapse a cube and nothing whatever would happen --
|
|
1188
|
+
// katamari, boulder dash and lemmings would all have drawn a glow and called it eating.
|
|
1189
|
+
// `hide` is a threshold rather than a blend: a cube is drawn or it is not, and a head
|
|
1190
|
+
// only hides what it has genuinely reached (w near 1), never what it is merely near.
|
|
1191
|
+
// ALTERATION IS GATED ON REACH, NOT ON ALPHA. `w` folds the head's alpha into its
|
|
1192
|
+
// distance falloff, which is right for LIGHTING -- a dim head lights dimly -- and wrong
|
|
1193
|
+
// for hiding: boulder dash fades its ring out as it collapses (alpha 0.8 * shrink), so
|
|
1194
|
+
// by the time a cube should vanish the alpha is near zero and the hide was cancelled by
|
|
1195
|
+
// the very fade that meant it was working. Measured: 0 cubes hidden over a whole run.
|
|
1196
|
+
// So the physical effect uses PROXIMITY alone and the visual effect keeps using w.
|
|
1197
|
+
const reach = g(Math.hypot(ddx, ddy) / Math.max(0.2, hd.r ?? 0.8));
|
|
1198
|
+
// `release` (1 unless the head says otherwise): the head letting go of everything it
|
|
1199
|
+
// holds, evenly -- the pull and the scale fade by it, so a tile under the head glides
|
|
1200
|
+
// home over the head's whole release rather than when its reach finally passes it
|
|
1201
|
+
const rel = hd.release ?? 1;
|
|
1202
|
+
best = {
|
|
1203
|
+
glow: 0.85 * w, outline: 0.8 * w, lift: (hd.lift ?? 0) * w, color: hd.color,
|
|
1204
|
+
hide: (hd.hide ?? 0) > 0 && reach > 0.45 ? 1 : 0,
|
|
1205
|
+
scale: hd.scale == null ? 1 : 1 - (1 - hd.scale) * reach * rel,
|
|
1206
|
+
// `pull`: how hard the head drags this tile toward itself (the black hole; buildScene
|
|
1207
|
+
// shears the faces toward the head's screen point by it)
|
|
1208
|
+
pull: (hd.pull ?? 0) * reach * rel, pullAt: hd.pull ? { x: hd.x, y: hd.y, z: hd.z ?? 0 } : null,
|
|
1209
|
+
// the pull at the head's full size (rPeak): the orbit is laid out against this, so it
|
|
1210
|
+
// does not unwind as the head shrinks; the current pull only says how far along the
|
|
1211
|
+
// line from home to that orbit the tile sits
|
|
1212
|
+
pullPeak: (hd.pull ?? 0) * (hd.rPeak ? g(Math.hypot(ddx, ddy) / Math.max(0.2, hd.rPeak)) : reach),
|
|
1213
|
+
// `lean`: how much the corners lean and stretch toward the head on top of the glide
|
|
1214
|
+
// (1 on the price board; 0 on the block board, where the cubes are lifted out whole)
|
|
1215
|
+
lean: hd.lean ?? 1,
|
|
1216
|
+
// `shrink`: scale the footprint with the height, so the cube stays a cube as it goes
|
|
1217
|
+
shrink: hd.shrink ? 1 : 0,
|
|
1218
|
+
// `orbitR`: the head's reach in grid units -- buildScene lays the block board's orbits
|
|
1219
|
+
// out at a fixed distance round the hole from it, rather than part-way home
|
|
1220
|
+
orbitR: hd.r ?? 0,
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
return best;
|
|
1225
|
+
}
|
|
1226
|
+
default: return FX_NONE;
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// --- the scene ---------------------------------------------------------
|
|
1231
|
+
//
|
|
1232
|
+
// Level of detail: `facetMinUnits` (frame + face) and `crownMinUnits` (gloss,
|
|
1233
|
+
// sheen, rim) are grid-unit sides below which a block is drawn plainer. The
|
|
1234
|
+
// renderer derives both from the constant board transform, so a block's detail
|
|
1235
|
+
// cannot change between frames.
|
|
1236
|
+
//
|
|
1237
|
+
// SHADOWS FROM ABOVE (operator: "consider casting shadows from above on the
|
|
1238
|
+
// movements"). Every airborne block casts a soft shadow straight down onto
|
|
1239
|
+
// the plane at its TRUE footprint -- not where the gathered, swollen block is
|
|
1240
|
+
// drawn -- so the shadow says which slot a block is over. The umbra is dark
|
|
1241
|
+
// and tight just above the plane and spreads and pales as the block climbs;
|
|
1242
|
+
// an arrival's shadow therefore sharpens on the very slot it is about to hit.
|
|
1243
|
+
// Shadows paint after every resting block and before every airborne one:
|
|
1244
|
+
// they fall ON the board, and under what casts them.
|
|
1245
|
+
//
|
|
1246
|
+
// Fades carry into the outline. The seam used to be stroked in one fixed
|
|
1247
|
+
// colour whatever the tile's alpha, so a departing block's fill faded while
|
|
1248
|
+
// its black frame stayed at full strength until the tile was dropped (operator:
|
|
1249
|
+
// "the black outlines for disappearing blocks do not fade out"). The stroke
|
|
1250
|
+
// now travels on the op as rgba scaled by the same alpha.
|
|
1251
|
+
// THE DENSE BOARD'S ORDER (Viewer Mode 2): thousands of low slabs whose footprints never overlap.
|
|
1252
|
+
// Under the oblique camera a slab's height leans STRAIGHT UP -- measured 2026-09-13: +1 of height
|
|
1253
|
+
// moves screen x by 0.00 and y by -3.06, and +1 of grid x moves screen x by 9.00 and y by 0.00.
|
|
1254
|
+
// So depth is grid Y alone; x contributes nothing to what can occlude what. (This comment used to
|
|
1255
|
+
// say "up and to the right", which is a leftover from an earlier camera and sent an investigation
|
|
1256
|
+
// of a paint-order report down the wrong path for an hour.) Resting slabs paint far row first, the
|
|
1257
|
+
// airborne
|
|
1258
|
+
// after them, lowest first. Linear-log where obliqueOrder's pairwise tests are quadratic -- at
|
|
1259
|
+
// 4,000 transactions that is the difference between a frame and a stall.
|
|
1260
|
+
export function diagonalOrder(tiles) {
|
|
1261
|
+
return tiles.map((t) => ({ t, air: (t.z ?? 0) > 0.02 ? 1 : 0, z: t.z ?? 0, d: t.x + t.y + t.s }))
|
|
1262
|
+
.sort((p, q) => (p.air - q.air) || (p.air ? p.z - q.z : 0) || (q.d - p.d) || String(p.t.txid).localeCompare(String(q.t.txid)))
|
|
1263
|
+
.map((e) => e.t);
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
export function buildScene(tiles, o = {}) {
|
|
1267
|
+
// the oblique camera never swells a block, so there is no growth to police
|
|
1268
|
+
const growth = o.oblique ? new Map() : settleGrowth(tiles, o);
|
|
1269
|
+
// PAINT ORDER FOR CUBES: seen from above, a higher surface is nearer the
|
|
1270
|
+
// camera, so blocks paint by the height of their TOP, lowest first, and
|
|
1271
|
+
// among equals the one further from the vanishing point first (its sides
|
|
1272
|
+
// lean outward, away from its nearer neighbours).
|
|
1273
|
+
const vx0 = o.vanishX ?? 0, vy0 = o.vanishY ?? 0;
|
|
1274
|
+
const ordered = o.oblique && o.order === 'diagonal' ? diagonalOrder(tiles) : o.oblique ? obliqueOrder(tiles, o) : tiles.map((t) => (growth.has(t) ? { ...t, boost: growth.get(t) } : t))
|
|
1275
|
+
.map((t) => { const c = project(t.x + t.s / 2, t.y + t.s / 2, 0, o); return { t, top: (t.z ?? 0) + (t.floor ?? 0) + cubeHeight(t), d: Math.hypot(c.x - vx0, c.y - vy0) }; })
|
|
1276
|
+
// Under the oblique camera a cube reaches STRAIGHT UP from its footprint (measured: height
|
|
1277
|
+
// moves screen y only, grid x moves screen x only), so it can only cover blocks at smaller
|
|
1278
|
+
// grid y: paint from the far row in, the airborne after the resting and lowest
|
|
1279
|
+
// first. A fixed order -- nothing flips as blocks rise and fall past each
|
|
1280
|
+
// other at nearly the same height, which was the "z-fighting".
|
|
1281
|
+
.sort((p, q) => (p.top - q.top) || (q.d - p.d) || String(p.t.txid).localeCompare(String(q.t.txid)))
|
|
1282
|
+
.map((e) => e.t);
|
|
1283
|
+
const ground = [];
|
|
1284
|
+
const air = [];
|
|
1285
|
+
const shadows = [];
|
|
1286
|
+
// IN THE AIR, PAINTED LAST (operator, 2026-09-15, of the black hole on the block board: "There
|
|
1287
|
+
// are too many collision errors happening"): a cube pulled off the board toward the hole was
|
|
1288
|
+
// still painted in the board's own order, so it cut through the neighbours it was passing over.
|
|
1289
|
+
// Its faces are moved to the end of the frame instead, the least-pulled first, so the ones
|
|
1290
|
+
// nearest the hole are on top of everything
|
|
1291
|
+
const pulled = new Map();
|
|
1292
|
+
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
|
1293
|
+
const note = (p) => {
|
|
1294
|
+
if (p.x < minX) minX = p.x;
|
|
1295
|
+
if (p.x > maxX) maxX = p.x;
|
|
1296
|
+
if (p.y < minY) minY = p.y;
|
|
1297
|
+
if (p.y > maxY) maxY = p.y;
|
|
1298
|
+
};
|
|
1299
|
+
const facetMin = o.facetMinUnits ?? 0;
|
|
1300
|
+
const crownMin = o.crownMinUnits ?? 0;
|
|
1301
|
+
const seam = o.seamAlpha ?? 0.35;
|
|
1302
|
+
// LIT FROM THE VIEWER (the Markets board; operator, 2026-09-11: "move the lighting to the viewer
|
|
1303
|
+
// position for the market view. I want everything bright and clear on the faces"). The light
|
|
1304
|
+
// sits at the camera: the faces turned toward it are the brightest, the edges are drawn light
|
|
1305
|
+
// instead of dark, the cell bevel (made for a light from the upper left) is left off, and
|
|
1306
|
+
// nothing casts a shadow -- a shadow from a light at the eye falls behind what casts it.
|
|
1307
|
+
const viewerLit = o.light === 'viewer';
|
|
1308
|
+
// ...then "offset the light at 45 degrees to the right so older stuff gets dimmer": the lamp
|
|
1309
|
+
// stands to the front-right, so a face turned left falls toward shadow, and the light reaching
|
|
1310
|
+
// a cube falls off leftward across the board -- the newest hours (on the right) brightest,
|
|
1311
|
+
// the oldest at half
|
|
1312
|
+
const reachOf = (t) => (viewerLit && o.gridW ? 0.5 + 0.5 * Math.min(1, Math.max(0, (t.x + t.s / 2) / o.gridW)) : 1);
|
|
1313
|
+
const flip = flipOf(o);
|
|
1314
|
+
// CUBES SHADOW CUBES (operator, 2026-09-11: "can we get the cubes casting shadows
|
|
1315
|
+
// against other cubes during movement?"). A cube in flight throws its shadow
|
|
1316
|
+
// straight down -- the same light as its floor shadow -- onto the TOP of every cube
|
|
1317
|
+
// beneath it: the overlap of its footprint (a penumbra and a core) with that cube's
|
|
1318
|
+
// top square, a rectangle in grid space, so a plain rgba quad and no clip. It is
|
|
1319
|
+
// drawn straight after the lower cube's own faces, so everything nearer -- the flyer
|
|
1320
|
+
// included -- paints over it. Darker the nearer the flyer, and fading exactly as
|
|
1321
|
+
// its floor shadow does: across the last 1.5 units of a landing, and with it as it
|
|
1322
|
+
// flies off (or falls in from) off screen.
|
|
1323
|
+
const casters = o.shadows !== false && o.oblique && !viewerLit && o.order !== 'diagonal' ? tiles.filter((c) => (c.z ?? 0) > 0.02).map((c) => ({
|
|
1324
|
+
c, x0: c.x, y0: c.y, x1: c.x + c.s, y1: c.y + c.s, z0: c.z,
|
|
1325
|
+
k: (c.alpha ?? 1) * Math.min(1, c.z / 1.5) * (1 - (c.entry ?? 0)),
|
|
1326
|
+
})).filter((e) => e.k > 0.01) : [];
|
|
1327
|
+
// the lamp's direction across the screen, for the side faces (LIGHTS); under the overhead
|
|
1328
|
+
// lamp every side takes the same light, a shade under the top
|
|
1329
|
+
const lampSide = LIGHTS[lightOf(o)].side;
|
|
1330
|
+
for (const t of ordered) {
|
|
1331
|
+
const fxv = (t.z ?? 0) > 0.02 ? FX_NONE : fxAt(t, o.fx);
|
|
1332
|
+
// GONE FOR THE DURATION, not deleted: a hidden cube is skipped this frame and drawn again the
|
|
1333
|
+
// moment the effect stops asking for it to be hidden.
|
|
1334
|
+
if (fxv.hide > 0.5) continue;
|
|
1335
|
+
// A SHORTER CUBE, the same way: `tall` on a copy. A collapse or a dig is a height override,
|
|
1336
|
+
// never an edit to the tile the board was built from.
|
|
1337
|
+
const sc = fxv.scale == null ? 1 : fxv.scale;
|
|
1338
|
+
let shaped = sc < 0.999 ? { ...t, tall: Math.max(0.04, cubeHeight(t) * sc) } : t;
|
|
1339
|
+
// a WHOLE smaller cube, not a plate, where the head asks for it (the black hole over the block
|
|
1340
|
+
// board): the footprint shrinks about its centre with the height
|
|
1341
|
+
if (sc < 0.999 && fxv.shrink) { const s2 = Math.max(0.08, t.s * Math.max(0.08, sc)); shaped = { ...shaped, x: t.x + (t.s - s2) / 2, y: t.y + (t.s - s2) / 2, s: s2 }; }
|
|
1342
|
+
// NEON is a flat cube: no facets, no crown -- solid faces and the tubes on their edges
|
|
1343
|
+
const f = tileFaces(fxv.lift > 0.001 ? { ...shaped, fxz: fxv.lift } : shaped, o, t.s >= facetMin && o.neon !== true);
|
|
1344
|
+
// SPAGHETTIFIED (operator, 2026-09-15: "the candles need to stretch and bend toward the hole
|
|
1345
|
+
// before they shrink"): a tile within a pulling head's reach has its faces sheared toward the
|
|
1346
|
+
// head's point on screen -- the top corners dragged hard, the base corners a little -- so it
|
|
1347
|
+
// leans and lengthens toward the hole before the scale takes it. Each corner object is moved
|
|
1348
|
+
// once, however many faces share it.
|
|
1349
|
+
if (fxv.pull > 0.001 && fxv.pullAt) {
|
|
1350
|
+
pulled.set(String(t.txid), fxv.pull);
|
|
1351
|
+
// ...AND ORBITS (operator, 2026-09-15: "The chart bars need to cleanly orbit the disc"): the
|
|
1352
|
+
// tile is swept round the hole along the disk's flow as it is drawn in -- its centre swings
|
|
1353
|
+
// about the hole by an angle that grows with the pull and the clock, on a radius that
|
|
1354
|
+
// shrinks with the pull, flattened toward the disk's plane -- and its corners lean and
|
|
1355
|
+
// stretch toward the hole on top of that
|
|
1356
|
+
const hp = project(fxv.pullAt.x, fxv.pullAt.y, fxv.pullAt.z, o);
|
|
1357
|
+
// EVERY CORNER THE CUBE HAS, the bevel's inner quad included -- it was left at home while
|
|
1358
|
+
// the top and sides moved, so a bevelled cube on the block board drew as a slab stretched
|
|
1359
|
+
// from its old place to its new one (operator, 2026-09-15: "too much bad shearing"); the
|
|
1360
|
+
// crown's points are projected later through f.P, which is shifted the same way below
|
|
1361
|
+
const pts = [...f.top, ...f.sides.flatMap((sd) => sd.points), ...(f.inset ?? [])];
|
|
1362
|
+
const uniq = [...new Set(pts)];
|
|
1363
|
+
const cx0 = uniq.reduce((a, p) => a + p.x, 0) / uniq.length, cy0 = uniq.reduce((a, p) => a + p.y, 0) / uniq.length;
|
|
1364
|
+
// THE ORBIT IS LAID OUT AGAINST THE HOLE'S FULL SIZE, and the tile sits somewhere on the
|
|
1365
|
+
// straight line between its home and that orbit by the CURRENT pull (operator, 2026-09-15:
|
|
1366
|
+
// "Instead of the bars reversing into place, can they just reorient themselves into their
|
|
1367
|
+
// original positions when the blackhole starts to disappear?"). While the hole grows the
|
|
1368
|
+
// tile glides out to its orbit and turns with it; while it shrinks the pull falls and the
|
|
1369
|
+
// tile glides straight home -- the orbit never unwinds.
|
|
1370
|
+
// STEADILY (operator, 2026-09-15: "The bars don't cleanly leave or return ... They sorta get
|
|
1371
|
+
// ripped off, and snapped back into place"): the capture and the release are eased with a
|
|
1372
|
+
// smoothstep, and the orbit is KEPLERIAN -- its angular speed goes with the square of the
|
|
1373
|
+
// pull, so a bar far out barely turns and one close in whips round -- where before every
|
|
1374
|
+
// bar's target swept a wide circle at one speed and a bar just being caught was yanked after
|
|
1375
|
+
// it. The angle's starting offset is small for the same reason.
|
|
1376
|
+
const peak = Math.max(fxv.pullPeak ?? fxv.pull, 0.001), kl = Math.min(1, fxv.pull / peak), k0 = kl * kl * (3 - 2 * kl);
|
|
1377
|
+
const dx = cx0 - hp.x, dy = cy0 - hp.y, r0 = Math.hypot(dx, dy) || 1, a0 = Math.atan2(dy, dx);
|
|
1378
|
+
// HALF THE SPEED (operator, 2026-09-15: "The bars sping around much much too quickly. Try to
|
|
1379
|
+
// calm it down by at least half to start" -- then "slow the spin ... by another half. Want it
|
|
1380
|
+
// smoother and calmer"): 0.00035 a millisecond at full pull, a quarter of the 0.0014 it was
|
|
1381
|
+
const swing = peak * peak * (o.now ?? 0) * 0.00035 + 0.5 * peak; // clockwise on screen, Keplerian
|
|
1382
|
+
// ROUND THE HOLE, on the block board (operator, 2026-09-15: "the blocks don't look right"):
|
|
1383
|
+
// part-way home from a hole twenty units up left the cubes hanging in the middle distance;
|
|
1384
|
+
// each is carried instead to its own ring round the hole, one and a half to three reaches
|
|
1385
|
+
// out in screen pixels, and glides straight back from it as the pull falls
|
|
1386
|
+
const ring = fxv.lean === 0 && fxv.orbitR > 0 && o.unit ? fxv.orbitR * o.unit * (1.5 + 1.5 * fxHash(Math.round(t.x * 31 + t.y * 7 + 3))) : null;
|
|
1387
|
+
const r1 = ring != null ? r0 + (ring - r0) * peak : r0 * (1 - 0.55 * peak), a1 = a0 + swing;
|
|
1388
|
+
const cx1 = hp.x + Math.cos(a1) * r1, cy1 = hp.y + Math.sin(a1) * r1 * (1 - 0.6 * peak); // flattened toward the disk
|
|
1389
|
+
const sx = (cx1 - cx0) * k0, sy = (cy1 - cy0) * k0;
|
|
1390
|
+
const tops = new Set([...f.top, ...f.sides.flatMap((sd) => [sd.points[0], sd.points[1]])]);
|
|
1391
|
+
for (const p of uniq) {
|
|
1392
|
+
p.x += sx; p.y += sy; // carried toward, and round, the orbit
|
|
1393
|
+
const k = (tops.has(p) ? 0.55 : 0.2) * peak * k0 * (fxv.lean ?? 1); // and leaning in (not on the block board: lean 0)
|
|
1394
|
+
p.x += (hp.x - p.x) * k; p.y += (hp.y - p.y) * k;
|
|
1395
|
+
}
|
|
1396
|
+
const P0 = f.P, kc = 0.55 * peak * k0 * (fxv.lean ?? 1);
|
|
1397
|
+
f.P = (x, y, z) => { const p = P0(x, y, z); const q = { x: p.x + sx, y: p.y + sy }; q.x += (hp.x - q.x) * kc; q.y += (hp.y - q.y) * kc; return q; };
|
|
1398
|
+
}
|
|
1399
|
+
// an x-rayed tile's body is glass: most of its alpha goes, and its edges come back below
|
|
1400
|
+
const xray = fxv.xray ?? 0;
|
|
1401
|
+
const a = (t.alpha ?? 1) * (1 - 0.72 * xray);
|
|
1402
|
+
const airborne = (t.z ?? 0) > 0.02;
|
|
1403
|
+
const out = airborne && !o.oblique ? air : ground;
|
|
1404
|
+
// A tile that just locked flashes brighter for a moment -- the Tetris
|
|
1405
|
+
// lock, and the only cue that says "this one just arrived".
|
|
1406
|
+
const lock = t.lock ?? 0;
|
|
1407
|
+
// an idle effect (see fxAt) lights the cubes it passes
|
|
1408
|
+
const pulse = fxv.glow;
|
|
1409
|
+
// the pointer's glow (details3d setHover): lit up, outlined, fading when released
|
|
1410
|
+
const hover = o.hoverGlow?.get(t.txid) ?? 0;
|
|
1411
|
+
// NEON (o.neon, below) is a dim SOLID body in the block's own colour under bright tubes: the
|
|
1412
|
+
// faces keep their hue -- the feerate -- at a constant half light, no dome or lamp shading
|
|
1413
|
+
// (2026-09-12: the first cut dimmed the lit shading and the faces went near black, "don't
|
|
1414
|
+
// have any suitable color fill for their temperature. Need solid dim neon colored faces")
|
|
1415
|
+
const lit = o.neon === true
|
|
1416
|
+
? 0.55 * (1 + 0.55 * lock + 0.45 * pulse + 0.6 * hover)
|
|
1417
|
+
: (1 + 0.55 * lock + 0.45 * pulse + 0.6 * hover) * domeLight(t, o) * reachOf(t);
|
|
1418
|
+
const c = t.color;
|
|
1419
|
+
// A WIREFRAME (Tetrust's ghost, operator 2026-09-12: "wireframes on teh bottom of the tetrust
|
|
1420
|
+
// playfield ... The solid dark colored stuff is too difficult to see"): the cube's
|
|
1421
|
+
// outline in `t.wire` and nothing else -- no fill, so what is behind shows through, and a wide
|
|
1422
|
+
// faint halo under a bright line so it reads as a lit tube. `always`: the seam switch (Stone
|
|
1423
|
+
// edges) does not govern it, the outline IS the tile.
|
|
1424
|
+
if (t.wire) {
|
|
1425
|
+
const halo = lift(t.wire, 0.2, round3(0.3 * a)), tube = lift(t.wire, 0.1, round3(0.98 * a));
|
|
1426
|
+
// how heavy the outline is (`wireWidth`, from settings; operator, 2026-09-12: "a way of
|
|
1427
|
+
// making the ghost marker have thinner lines"). Both passes scale together so the halo stays
|
|
1428
|
+
// a halo around the line rather than swallowing it.
|
|
1429
|
+
const ww = Math.max(0.2, Math.min(3, Number(o.wireWidth) || 1));
|
|
1430
|
+
for (const poly of [...f.sides.map((sd) => sd.points), f.top]) {
|
|
1431
|
+
out.push({ txid: t.txid, face: 'wire', points: poly, fill: 'rgba(0,0,0,0)', stroke: halo, lw: round3(9 * ww), always: true });
|
|
1432
|
+
out.push({ txid: t.txid, face: 'wire', points: poly, fill: 'rgba(0,0,0,0)', stroke: tube, lw: round3(3 * ww), always: true });
|
|
1433
|
+
poly.forEach(note);
|
|
1434
|
+
}
|
|
1435
|
+
continue;
|
|
1436
|
+
}
|
|
1437
|
+
// A BALL, NOT A BLOCK (operator, 2026-09-12: "Can we have a ball for blockout instead of a
|
|
1438
|
+
// block for the bouncing dot?"). An op is a filled polygon -- there are no arcs in the format,
|
|
1439
|
+
// and gradients and shadows are forbidden here (see the rules at the head of details3d.js) --
|
|
1440
|
+
// so a sphere is three nested many-sided discs: a dark rim, the body, and a highlight offset
|
|
1441
|
+
// toward the light. At the size a ball is actually drawn, 24 sides is a circle.
|
|
1442
|
+
if (t.sphere) {
|
|
1443
|
+
const bcx = t.x + t.s / 2, bcy = t.y + t.s / 2;
|
|
1444
|
+
const zc = (t.z ?? 0) + (t.floor ?? 0) + cubeHeight(t) / 2;
|
|
1445
|
+
const mid = f.P(bcx, bcy, zc);
|
|
1446
|
+
const rim = f.P(bcx + t.s / 2, bcy, zc);
|
|
1447
|
+
const R = Math.hypot(rim.x - mid.x, rim.y - mid.y) || 1;
|
|
1448
|
+
const disc = (r, dx, dy, fill) => {
|
|
1449
|
+
const pts = [];
|
|
1450
|
+
for (let i = 0; i < 24; i++) {
|
|
1451
|
+
const ang = (i / 24) * Math.PI * 2;
|
|
1452
|
+
pts.push({ x: mid.x + dx + Math.cos(ang) * r, y: mid.y + dy + Math.sin(ang) * r });
|
|
1453
|
+
}
|
|
1454
|
+
out.push({ txid: t.txid, face: 'ball', points: pts, fill });
|
|
1455
|
+
return pts;
|
|
1456
|
+
};
|
|
1457
|
+
disc(R, 0, 0, shade(c, 0.5 * lit, a)).forEach(note);
|
|
1458
|
+
disc(R * 0.84, -R * 0.08, -R * 0.1, shade(c, 1.0 * lit, a));
|
|
1459
|
+
disc(R * 0.4, -R * 0.24, -R * 0.28, lift(c, 0.7, a));
|
|
1460
|
+
continue;
|
|
1461
|
+
}
|
|
1462
|
+
// A SHAPE WITH AN ORIENTATION (operator, 2026-09-12: "proper varied Minion graphics ...
|
|
1463
|
+
// powerups to look like elongated pills rotating and dropping ... morphing the player paddle
|
|
1464
|
+
// into something visually different when it's firing lasers").
|
|
1465
|
+
//
|
|
1466
|
+
// One primitive for all three, not three branches: a pill, a minion body and a laser cannon are
|
|
1467
|
+
// each a closed outline that can be turned. `t.poly` is that outline in UNIT SPACE -- points in
|
|
1468
|
+
// -0.5..0.5, so a shape is written once and scales with `s` -- and `t.rot` turns it. Built the
|
|
1469
|
+
// same way the sphere is (nested filled polygons, no arcs in the op format, no gradients under
|
|
1470
|
+
// the canvas rules): the outline, the body inset toward the light, and a highlight.
|
|
1471
|
+
//
|
|
1472
|
+
// Its own face name, because test/finish.test.js holds a sphere to emitting `ball` ops and
|
|
1473
|
+
// NOTHING else; a new kind that borrowed that name would quietly break that guarantee.
|
|
1474
|
+
if (t.poly && t.poly.length >= 3) {
|
|
1475
|
+
const pcx = t.x + t.s / 2, pcy = t.y + t.s / 2;
|
|
1476
|
+
const zc = (t.z ?? 0) + (t.floor ?? 0) + cubeHeight(t) / 2;
|
|
1477
|
+
const mid = f.P(pcx, pcy, zc);
|
|
1478
|
+
const rim = f.P(pcx + t.s / 2, pcy, zc);
|
|
1479
|
+
// the screen radius of half a tile, so the outline scales with the camera like everything else
|
|
1480
|
+
const R = Math.hypot(rim.x - mid.x, rim.y - mid.y) || 1;
|
|
1481
|
+
const rot = Number(t.rot) || 0;
|
|
1482
|
+
const ca = Math.cos(rot), sa = Math.sin(rot);
|
|
1483
|
+
// shrink toward the shape's own centroid, which keeps a long thin pill from pinching
|
|
1484
|
+
const gx = t.poly.reduce((n, q) => n + q[0], 0) / t.poly.length;
|
|
1485
|
+
const gy = t.poly.reduce((n, q) => n + q[1], 0) / t.poly.length;
|
|
1486
|
+
const ring = (k, dx, dy, fill) => {
|
|
1487
|
+
const pts = t.poly.map(([ux, uy]) => {
|
|
1488
|
+
const sx = gx + (ux - gx) * k, sy = gy + (uy - gy) * k;
|
|
1489
|
+
return { x: mid.x + dx + (sx * ca - sy * sa) * 2 * R, y: mid.y + dy + (sx * sa + sy * ca) * 2 * R };
|
|
1490
|
+
});
|
|
1491
|
+
out.push({ txid: t.txid, face: 'poly', points: pts, fill });
|
|
1492
|
+
return pts;
|
|
1493
|
+
};
|
|
1494
|
+
ring(1, 0, 0, shade(c, 0.45 * lit, a)).forEach(note);
|
|
1495
|
+
ring(0.78, -R * 0.06, -R * 0.08, shade(c, 1.0 * lit, a));
|
|
1496
|
+
ring(0.34, -R * 0.2, -R * 0.24, lift(c, 0.65, a));
|
|
1497
|
+
// eyes: a minion reads as alive because something looks back. Drawn as their own small
|
|
1498
|
+
// discs rather than baked into the outline, so one body can blink or look about later.
|
|
1499
|
+
for (const [ex, ey, er] of t.eyes ?? []) {
|
|
1500
|
+
const px = mid.x + (ex * ca - ey * sa) * 2 * R, py = mid.y + (ex * sa + ey * ca) * 2 * R;
|
|
1501
|
+
const disc = (rr, fill) => {
|
|
1502
|
+
const pts = [];
|
|
1503
|
+
for (let i = 0; i < 12; i++) {
|
|
1504
|
+
const ang = (i / 12) * Math.PI * 2;
|
|
1505
|
+
pts.push({ x: px + Math.cos(ang) * rr, y: py + Math.sin(ang) * rr });
|
|
1506
|
+
}
|
|
1507
|
+
out.push({ txid: t.txid, face: 'poly', points: pts, fill });
|
|
1508
|
+
};
|
|
1509
|
+
disc(er * 2 * R, 'rgba(12,16,22,0.92)');
|
|
1510
|
+
disc(er * 2 * R * 0.45, 'rgba(235,245,255,0.95)');
|
|
1511
|
+
}
|
|
1512
|
+
continue;
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
for (const side of f.sides) {
|
|
1516
|
+
// lit from the upper left of the screen: a side turned that way is
|
|
1517
|
+
// brighter, one turned away falls into shadow
|
|
1518
|
+
const d = side.nx * lampSide[0] + side.ny * lampSide[1];
|
|
1519
|
+
// from the viewer: a face pointing down the screen (toward the camera) takes the most light
|
|
1520
|
+
const k = viewerLit ? 0.6 + 0.42 * Math.max(0, side.ny) + 0.3 * Math.max(0, side.nx) - 0.16 * Math.max(0, -side.nx) : 0.34 + 0.26 * (d + 1);
|
|
1521
|
+
out.push({ txid: t.txid, face: 'side', key: side.key, points: side.points, fill: shade(c, k * lit, a), ...(viewerLit ? { stroke: lift(c, 0.45, round3(0.5 * a)) } : {}) });
|
|
1522
|
+
side.points.forEach(note);
|
|
1523
|
+
}
|
|
1524
|
+
out.push({ txid: t.txid, face: 'top', points: f.top, fill: shade(c, (viewerLit ? 1.02 : 0.8) * lit, a), stroke: viewerLit ? lift(c, 0.55, round3(0.6 * a)) : `rgba(0,0,0,${round3(seam * a)})` });
|
|
1525
|
+
f.top.forEach(note);
|
|
1526
|
+
|
|
1527
|
+
if (f.innerG && !viewerLit) {
|
|
1528
|
+
const [BL, BR, TR, TL] = f.top;
|
|
1529
|
+
const [bl, br, tr, tl] = f.inset;
|
|
1530
|
+
// the screen-top edge is row y+s with the rows flipped, row y without
|
|
1531
|
+
const farE = flip > 0 ? [TL, TR, tr, tl] : [BL, BR, br, bl];
|
|
1532
|
+
const nearE = flip > 0 ? [BR, BL, bl, br] : [TR, TL, tl, tr];
|
|
1533
|
+
out.push({ txid: t.txid, face: 'bevel', points: farE, fill: lift(c, 0.5 + 0.3 * lock, a) });
|
|
1534
|
+
out.push({ txid: t.txid, face: 'bevel', points: [BL, TL, tl, bl], fill: lift(c, 0.2 + 0.3 * lock, a) });
|
|
1535
|
+
out.push({ txid: t.txid, face: 'bevel', points: [TR, BR, br, tr], fill: shade(c, 0.62 * lit, a) });
|
|
1536
|
+
out.push({ txid: t.txid, face: 'bevel', points: nearE, fill: shade(c, 0.4 * lit, a) });
|
|
1537
|
+
out.push({ txid: t.txid, face: 'face', points: f.inset, fill: shade(c, lit, a) });
|
|
1538
|
+
if (t.s >= crownMin) {
|
|
1539
|
+
const w = t.s - 2 * f.bevel;
|
|
1540
|
+
const x0 = t.x + f.bevel;
|
|
1541
|
+
const at = ([p, q]) => f.P(x0 + p * w, flip > 0 ? (t.y + t.s - f.bevel - q * w) : (t.y + f.bevel + q * w), f.topZ);
|
|
1542
|
+
// the well: its lip at 20% in from the face's edge, its floor at 30%,
|
|
1543
|
+
// in (p, q) face coordinates with q running DOWN the screen
|
|
1544
|
+
const E = [[0.2, 0.2], [0.8, 0.2], [0.8, 0.8], [0.2, 0.8]].map(at); // lip: TL, TR, BR, BL on screen
|
|
1545
|
+
const F = [[0.3, 0.3], [0.7, 0.3], [0.7, 0.7], [0.3, 0.7]].map(at); // floor
|
|
1546
|
+
out.push({ txid: t.txid, face: 'wall', points: [E[0], E[1], F[1], F[0]], fill: shade(c, 0.5 * lit, a) });
|
|
1547
|
+
out.push({ txid: t.txid, face: 'wall', points: [E[3], E[0], F[0], F[3]], fill: shade(c, 0.66 * lit, a) });
|
|
1548
|
+
out.push({ txid: t.txid, face: 'wall', points: [E[1], E[2], F[2], F[1]], fill: lift(c, 0.16 + 0.3 * lock, a) });
|
|
1549
|
+
out.push({ txid: t.txid, face: 'wall', points: [E[2], E[3], F[3], F[2]], fill: lift(c, 0.34 + 0.3 * lock, a) });
|
|
1550
|
+
out.push({ txid: t.txid, face: 'floor', points: F, fill: shade(c, 0.84 * lit, a) });
|
|
1551
|
+
out.push({ txid: t.txid, face: 'rim', points: E, fill: 'rgba(0,0,0,0)', stroke: lift(c, 0.7, round3(0.7 * a)) });
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
// THE FINISH (operator, 2026-09-12: "consider neon-izing each of teh blocks, and adding an
|
|
1555
|
+
// optional specular metallic sheen to the blocks. Have it toggle. I want to be able to apply
|
|
1556
|
+
// the sheen onto simple cube mode if I want"). Both are laid over the top face every tile
|
|
1557
|
+
// has, so they work at every level of detail. What was tried and rejected before is recorded
|
|
1558
|
+
// at the head of this section -- diagonal streaks -- and neither of these is a streak.
|
|
1559
|
+
if (o.sheen === true && o.sheenStyle === 'chrome') {
|
|
1560
|
+
// CHROME (operator, 2026-09-14: "make the specular metallic effect more prominent. Maybe give
|
|
1561
|
+
// it a chrome or faux reflective effect ... really improve the metallic look"). The satin
|
|
1562
|
+
// sheen below is a highlight along one edge -- a lit surface. Chrome is not lit, it REFLECTS:
|
|
1563
|
+
// what reads as chrome is a hard horizon mirrored in the face -- bright sky above a white line,
|
|
1564
|
+
// near-black ground below it, lightening again toward the near edge -- and a reflection that
|
|
1565
|
+
// SLIDES as the object moves. So the horizon's place on each face comes from where the cube
|
|
1566
|
+
// is on the board and how high it flies: cubes side by side show it at different heights,
|
|
1567
|
+
// and a cube in flight has it travel across its faces. Hue is kept in the sky and the bounce
|
|
1568
|
+
// so the fee colour still reads; the colour is the data, the chrome is the finish.
|
|
1569
|
+
const [BL, BR, TR, TL] = f.top;
|
|
1570
|
+
const L = (p, q, k) => ({ x: p.x + (q.x - p.x) * k, y: p.y + (q.y - p.y) * k });
|
|
1571
|
+
const gw = o.gridW || 44, gh = o.gridH || gw;
|
|
1572
|
+
// the horizon changes from cube to cube (a board of equal slabs is not one flat mirror) and
|
|
1573
|
+
// travels with height, so a flight carries its reflection across its faces
|
|
1574
|
+
const env = ((t.x + t.s / 2) / gw) * 2.3 + ((t.y + t.s / 2) / gh) * 1.1 + (t.z ?? 0) * 0.02;
|
|
1575
|
+
const wave = (ph) => Math.sin(2 * Math.PI * (env + ph));
|
|
1576
|
+
// the far edge of the top on screen is TL-TR, the near edge BL-BR; q runs far (0) to near (1)
|
|
1577
|
+
const strip = (q0, q1) => [L(TL, BL, Math.max(0, q0)), L(TR, BR, Math.max(0, q0)), L(TR, BR, Math.min(1, q1)), L(TL, BL, Math.min(1, q1))];
|
|
1578
|
+
const k = 0.45 + 0.25 * wave(0);
|
|
1579
|
+
// RAMPS ARE NESTED BANDS, as the satin sheen's are: n translucent quads anchored on one line
|
|
1580
|
+
// accumulate into an n-step gradient, where adjacent strips of differing alpha read as stripes.
|
|
1581
|
+
out.push({ txid: t.txid, face: 'sheen', points: f.top, fill: `rgba(160,170,182,${round3(0.14 * a)})` }); // a light steel wash
|
|
1582
|
+
const RAMP = 6;
|
|
1583
|
+
for (let i = 0; i < RAMP; i++) {
|
|
1584
|
+
const u = 1 - i / RAMP; // 1 (widest) toward the horizon
|
|
1585
|
+
// the sky: brightest at the horizon, still carrying the cube's hue
|
|
1586
|
+
out.push({ txid: t.txid, face: 'sheen', points: strip(k - k * Math.pow(u, 1.3), k), fill: lift(c, 0.72, round3(0.1 * a)) });
|
|
1587
|
+
// the ground: darkest just under the horizon, in the cube's own colour darkened, not black
|
|
1588
|
+
out.push({ txid: t.txid, face: 'sheen', points: strip(k, k + (1 - k) * Math.pow(u, 1.6)), fill: shade(c, 0.18, round3(0.125 * a)) });
|
|
1589
|
+
// the bounce: light again toward the near edge
|
|
1590
|
+
out.push({ txid: t.txid, face: 'sheen', points: strip(1 - 0.28 * Math.pow(u, 1.5), 1), fill: lift(c, 0.5, round3(0.065 * a)) });
|
|
1591
|
+
}
|
|
1592
|
+
// the horizon itself: a soft glow over a hard white line
|
|
1593
|
+
out.push({ txid: t.txid, face: 'sheen', points: strip(k - 0.1, k), fill: `rgba(240,248,255,${round3(0.3 * a)})` });
|
|
1594
|
+
out.push({ txid: t.txid, face: 'sheen', points: strip(k - 0.03, k + 0.01), fill: `rgba(255,255,255,${round3(0.92 * a)})` });
|
|
1595
|
+
// the sides mirror their own horizon, offset so it never lines up with the top's
|
|
1596
|
+
for (const side of f.sides) {
|
|
1597
|
+
const lamp = viewerLit ? Math.max(0, side.ny) : Math.max(0, side.nx * lampSide[0] + side.ny * lampSide[1]);
|
|
1598
|
+
const [p0, p1, p2, p3] = side.points; // top edge p0-p1, bottom edge p3-p2
|
|
1599
|
+
const band = (v0, v1) => [L(p0, p3, Math.max(0, v0)), L(p1, p2, Math.max(0, v0)), L(p1, p2, Math.min(1, v1)), L(p0, p3, Math.min(1, v1))];
|
|
1600
|
+
const kv = 0.36 + 0.18 * wave(0.37 + (side.key === 'left' || side.key === 'right' ? 0.21 : 0));
|
|
1601
|
+
for (let i = 0; i < 3; i++) {
|
|
1602
|
+
const u = 1 - i / 3;
|
|
1603
|
+
out.push({ txid: t.txid, face: 'sheen', points: band(kv - kv * u, kv), fill: lift(c, 0.65, round3((0.1 + 0.1 * lamp) * a)) });
|
|
1604
|
+
out.push({ txid: t.txid, face: 'sheen', points: band(kv, kv + (1 - kv) * Math.pow(u, 1.4)), fill: shade(c, 0.15, round3(0.18 * a)) });
|
|
1605
|
+
}
|
|
1606
|
+
out.push({ txid: t.txid, face: 'sheen', points: band(0.88, 1), fill: lift(c, 0.4, round3(0.22 * a)) });
|
|
1607
|
+
out.push({ txid: t.txid, face: 'sheen', points: band(kv - 0.05, kv + 0.02), fill: `rgba(255,255,255,${round3((0.5 + 0.4 * lamp) * a)})` });
|
|
1608
|
+
}
|
|
1609
|
+
// crisp polished edges, drawn whether or not the dark seam is on
|
|
1610
|
+
out.push({ txid: t.txid, face: 'sheen', points: f.top, fill: 'rgba(0,0,0,0)', stroke: `rgba(255,255,255,${round3(0.55 * a)})`, lw: 0.9, always: true });
|
|
1611
|
+
// a star glint on some cubes, at the corner facing the lamp; it moves on when the cube does
|
|
1612
|
+
const g = env * 7.3 - Math.floor(env * 7.3);
|
|
1613
|
+
if (g > 0.82) {
|
|
1614
|
+
const corner = viewerLit ? BL : (flip > 0 ? TL : BL);
|
|
1615
|
+
const across = viewerLit ? BR : (flip > 0 ? TR : BR), down = viewerLit ? TL : (flip > 0 ? BL : TL);
|
|
1616
|
+
const cx = corner.x + (across.x - corner.x) * 0.16 + (down.x - corner.x) * 0.16;
|
|
1617
|
+
const cy = corner.y + (across.y - corner.y) * 0.16 + (down.y - corner.y) * 0.16;
|
|
1618
|
+
const r = Math.hypot(across.x - corner.x, across.y - corner.y) * (0.1 + 0.12 * (g - 0.82) / 0.18), w2 = r * 0.12;
|
|
1619
|
+
const glint = (alpha, rr, ww) => {
|
|
1620
|
+
out.push({ txid: t.txid, face: 'sheen', points: [{ x: cx - rr, y: cy }, { x: cx, y: cy - ww }, { x: cx + rr, y: cy }, { x: cx, y: cy + ww }], fill: `rgba(255,255,255,${round3(alpha * a)})` });
|
|
1621
|
+
out.push({ txid: t.txid, face: 'sheen', points: [{ x: cx, y: cy - rr }, { x: cx + ww, y: cy }, { x: cx, y: cy + rr }, { x: cx - ww, y: cy }], fill: `rgba(255,255,255,${round3(alpha * a)})` });
|
|
1622
|
+
};
|
|
1623
|
+
glint(0.35, r * 1.5, w2 * 2.2);
|
|
1624
|
+
glint(0.95, r, w2);
|
|
1625
|
+
}
|
|
1626
|
+
} else if (o.sheen === true) {
|
|
1627
|
+
// a specular band hugging the LIT edge of the top: the far edge under the upper-left lamp,
|
|
1628
|
+
// the near edge when the light sits at the viewer. Two nested bands, the inner one hotter:
|
|
1629
|
+
// a metallic gleam along an edge, not a gloss stripe across the face.
|
|
1630
|
+
const [BL, BR, TR, TL] = f.top;
|
|
1631
|
+
const litFar = !viewerLit;
|
|
1632
|
+
const e0 = litFar ? (flip > 0 ? TL : BL) : (flip > 0 ? BL : TL);
|
|
1633
|
+
const e1 = litFar ? (flip > 0 ? TR : BR) : (flip > 0 ? BR : TR);
|
|
1634
|
+
const o0 = litFar ? (flip > 0 ? BL : TL) : (flip > 0 ? TL : BL);
|
|
1635
|
+
const o1 = litFar ? (flip > 0 ? BR : TR) : (flip > 0 ? TR : BR);
|
|
1636
|
+
const L = (p, q, k) => ({ x: p.x + (q.x - p.x) * k, y: p.y + (q.y - p.y) * k });
|
|
1637
|
+
const band = (k) => [e0, e1, L(e1, o1, k), L(e0, o0, k)];
|
|
1638
|
+
// A RAMP, NOT STEPS (2026-09-12, operator: "we need more specular on the metallic sheen, and
|
|
1639
|
+
// have the gradient be less coarse"). The first cut was two bands, the second three, and three
|
|
1640
|
+
// wide bands read as three stripes because that is what they are.
|
|
1641
|
+
//
|
|
1642
|
+
// The bands are NESTED and all anchored on the lit edge, so drawing them widest-first lets
|
|
1643
|
+
// each narrower one lay over the last and the alpha ACCUMULATE toward the edge: n translucent
|
|
1644
|
+
// quads are an n-step ramp, and the fineness of the gradient is just n. Each step is kept
|
|
1645
|
+
// under a tenth of full opacity so no single one of them can be seen as an edge. (A real
|
|
1646
|
+
// canvas gradient is not forbidden here -- the rules ban clip, globalAlpha, composite modes
|
|
1647
|
+
// and shadowBlur, not gradients -- but an op's `fill` is a plain rgba STRING that paintFrame
|
|
1648
|
+
// assigns straight to fillStyle, and which the recording-canvas tests read; a CanvasGradient
|
|
1649
|
+
// would need a new op shape and would blind them. Layered fills are the house idiom.)
|
|
1650
|
+
const SHEEN_STEPS = 14;
|
|
1651
|
+
for (let k = 0; k < SHEEN_STEPS; k++) {
|
|
1652
|
+
const t2 = 1 - k / SHEEN_STEPS; // 1 (widest) down to one step's width
|
|
1653
|
+
const w = 0.5 * Math.pow(t2, 1.5); // bunched toward the lit edge
|
|
1654
|
+
const hot = 1 - t2; // 0 at the sheen's inner edge, 1 at the lit one
|
|
1655
|
+
out.push({ txid: t.txid, face: 'sheen', points: band(w),
|
|
1656
|
+
fill: lift(c, 0.4 + 0.55 * hot, round3((0.03 + 0.075 * Math.pow(hot, 1.8)) * a)) });
|
|
1657
|
+
}
|
|
1658
|
+
// THE SPECULAR ITSELF: a tight near-white sliver right on the edge, which is the part that
|
|
1659
|
+
// reads as polished metal rather than as a lit surface
|
|
1660
|
+
out.push({ txid: t.txid, face: 'sheen', points: band(0.055), fill: `rgba(255,255,255,${round3(0.9 * a)})` });
|
|
1661
|
+
out.push({ txid: t.txid, face: 'sheen', points: band(0.022), fill: `rgba(255,255,255,${round3(0.96 * a)})` });
|
|
1662
|
+
// and the roll-off into shadow on the far edge, graded the same way
|
|
1663
|
+
const dark = (k) => [o0, o1, L(o1, e1, k), L(o0, e0, k)];
|
|
1664
|
+
const DARK_STEPS = 8;
|
|
1665
|
+
for (let k = 0; k < DARK_STEPS; k++) {
|
|
1666
|
+
const t2 = 1 - k / DARK_STEPS;
|
|
1667
|
+
const w = 0.34 * Math.pow(t2, 1.4);
|
|
1668
|
+
const deep = 1 - t2;
|
|
1669
|
+
out.push({ txid: t.txid, face: 'sheen', points: dark(w), fill: `rgba(0,0,0,${round3((0.035 + 0.06 * deep) * a)})` });
|
|
1670
|
+
}
|
|
1671
|
+
for (const side of f.sides) {
|
|
1672
|
+
// the side turned to the lamp carries the same ramp up its outer edge
|
|
1673
|
+
const d = viewerLit ? Math.max(0, side.ny) : Math.max(0, side.nx * lampSide[0] + side.ny * lampSide[1]);
|
|
1674
|
+
if (d < 0.3) continue;
|
|
1675
|
+
const [p0, p1, p2, p3] = side.points;
|
|
1676
|
+
const SIDE_STEPS = 6;
|
|
1677
|
+
for (let k = 0; k < SIDE_STEPS; k++) {
|
|
1678
|
+
const t2 = 1 - k / SIDE_STEPS;
|
|
1679
|
+
const w = 0.32 * Math.pow(t2, 1.4);
|
|
1680
|
+
const hot = 1 - t2;
|
|
1681
|
+
out.push({ txid: t.txid, face: 'sheen', points: [p0, L(p0, p1, w), L(p3, p2, w), p3],
|
|
1682
|
+
fill: lift(c, 0.55 + 0.4 * hot, round3((0.05 + 0.12 * hot) * d * a)) });
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
if (o.neon === true) {
|
|
1687
|
+
// every edge the camera sees, stroked in the block's own colour lit up: a wide faint halo
|
|
1688
|
+
// under a thin bright line, the way a neon tube reads. `always`: drawn even with the dark
|
|
1689
|
+
// seam (Stone edges) switched off, because it is the seam's replacement, not its companion.
|
|
1690
|
+
// (2026-09-12, second cut: the first stroked a 1.4 x tube over a 0.6-pixel base line --
|
|
1691
|
+
// under a pixel, and half of it under the next cube's fill -- "I don't see neon blocks
|
|
1692
|
+
// working". `lw` multiplies paintFrame's base width, so these are device pixels x 1.7.)
|
|
1693
|
+
// tuned (settings.js neonSource / neonColour / neonBrightness): the tube in the block's own
|
|
1694
|
+
// colour or one chosen colour, glowing as hard as asked -- brightness into alpha and width
|
|
1695
|
+
const nc = o.neonSource === 'colour' && NEON_HEX.test(o.neonColour || '') ? o.neonColour : c;
|
|
1696
|
+
const nb = Math.max(0.2, Math.min(2, Number(o.neonBrightness) || 1));
|
|
1697
|
+
const halo = lift(nc, 0.25, round3(Math.min(1, 0.3 * nb) * a)), tube = lift(nc, 0.3, round3(Math.min(1, nb) * a)), core = lift(nc, 0.75, round3(Math.min(1, 0.7 * nb) * a)); // the tube keeps the hue; only the thin core goes toward white
|
|
1698
|
+
for (const poly of [f.top, ...f.sides.map((sd) => sd.points)]) {
|
|
1699
|
+
out.push({ txid: t.txid, face: 'neon', points: poly, fill: 'rgba(0,0,0,0)', stroke: halo, lw: round3(11 * (0.6 + 0.4 * nb)), always: true });
|
|
1700
|
+
out.push({ txid: t.txid, face: 'neon', points: poly, fill: 'rgba(0,0,0,0)', stroke: tube, lw: round3(4 * (0.7 + 0.3 * nb)), always: true });
|
|
1701
|
+
out.push({ txid: t.txid, face: 'neon', points: poly, fill: 'rgba(0,0,0,0)', stroke: core, lw: 1.6, always: true });
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
// the lock: the whole cell flashes white for a moment, then settles
|
|
1705
|
+
if (pulse > 0.03) out.push({ txid: t.txid, face: 'glow', points: f.top, fill: `rgba(${fxv.color.join(',')},${round3(0.5 * pulse * a)})` });
|
|
1706
|
+
// ON THE PRICE BOARD THE SIDES LIGHT TOO. The camera there is low (Markets' CAMERA_3D: the
|
|
1707
|
+
// depth drawn at 0.3), so a candle's top is a sliver and a glow painted on it alone is
|
|
1708
|
+
// invisible: a scan crossed the board in 2026-09-14's captures and lit nothing anyone could
|
|
1709
|
+
// see. The faces the camera sees carry the glow, at a little under the top's weight.
|
|
1710
|
+
// The faces the camera sees carry the glow, and heavily: a candle is already a bright body, so
|
|
1711
|
+
// a tint at the top's weight (0.5) was there in the pixels and nowhere to the eye (measured:
|
|
1712
|
+
// the scan's peak moved a candle's face by a 0.26 cyan wash). At 0.85 it reads as struck.
|
|
1713
|
+
if (pulse > 0.03 && o.axes) for (const side of f.sides) out.push({ txid: t.txid, face: 'glow', points: side.points, fill: `rgba(${fxv.color.join(',')},${round3(0.85 * pulse * a)})` });
|
|
1714
|
+
// THE X-RAY TREATMENT: bright edges round every face, and a raster of thin lines across the top
|
|
1715
|
+
// and the sides, so the tile reads as a wireframe scan of itself
|
|
1716
|
+
if (xray > 0.02) {
|
|
1717
|
+
const xc = `rgba(200,245,255,${round3(0.95 * xray)})`;
|
|
1718
|
+
out.push({ txid: t.txid, face: 'outline', points: f.top, fill: 'rgba(0,0,0,0)', stroke: xc, lw: 1.2, always: true });
|
|
1719
|
+
for (const side of f.sides) out.push({ txid: t.txid, face: 'outline', points: side.points, fill: 'rgba(0,0,0,0)', stroke: xc, lw: 1.2, always: true });
|
|
1720
|
+
const raster = (poly) => {
|
|
1721
|
+
const [p0, p1, p2, p3] = poly; // p0->p1 and p3->p2 are the edges the lines run between
|
|
1722
|
+
for (let i = 1; i <= 3; i++) {
|
|
1723
|
+
const u = i / 4;
|
|
1724
|
+
const a0 = { x: p0.x + (p3.x - p0.x) * u, y: p0.y + (p3.y - p0.y) * u }, b0 = { x: p1.x + (p2.x - p1.x) * u, y: p1.y + (p2.y - p1.y) * u };
|
|
1725
|
+
out.push({ txid: t.txid, face: 'outline', points: [a0, b0], fill: 'rgba(0,0,0,0)', stroke: `rgba(160,230,255,${round3(0.45 * xray)})`, lw: 0.8, always: true });
|
|
1726
|
+
}
|
|
1727
|
+
};
|
|
1728
|
+
raster(f.top);
|
|
1729
|
+
for (const side of f.sides) raster(side.points);
|
|
1730
|
+
}
|
|
1731
|
+
if (fxv.outline > 0.03) {
|
|
1732
|
+
const col = fxv.color.join(',');
|
|
1733
|
+
out.push({ txid: t.txid, face: 'outline', points: f.top, fill: 'rgba(0,0,0,0)', stroke: `rgba(${col},${round3(0.95 * fxv.outline * a)})`, lw: 1 + 4 * fxv.outline });
|
|
1734
|
+
// and round the sides the camera sees, so the whole cube is traced
|
|
1735
|
+
if (fxv.outline > (o.axes ? 0.05 : 0.15)) for (const side of f.sides) {
|
|
1736
|
+
out.push({ txid: t.txid, face: 'outline', points: side.points, fill: 'rgba(0,0,0,0)', stroke: `rgba(${col},${round3(0.7 * fxv.outline * a)})`, lw: 1 + 2 * fxv.outline });
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
if (hover > 0.01) {
|
|
1740
|
+
out.push({ txid: t.txid, face: 'glow', points: f.top, fill: `rgba(215,255,235,${round3(0.38 * hover * a)})` });
|
|
1741
|
+
out.push({ txid: t.txid, face: 'outline', points: f.top, fill: 'rgba(0,0,0,0)', stroke: `rgba(175,255,225,${round3(0.95 * hover * a)})`, lw: 1 + 3 * hover });
|
|
1742
|
+
for (const side of f.sides) out.push({ txid: t.txid, face: 'outline', points: side.points, fill: 'rgba(0,0,0,0)', stroke: `rgba(175,255,225,${round3(0.7 * hover * a)})`, lw: 1 + 2 * hover });
|
|
1743
|
+
}
|
|
1744
|
+
if (lock > 0.02) out.push({ txid: t.txid, face: 'flash', points: f.top, fill: `rgba(255,255,255,${round3(0.18 * lock * a)})` });
|
|
1745
|
+
if (casters.length) {
|
|
1746
|
+
const bTop = (t.z ?? 0) + (t.floor ?? 0) + cubeHeight(t) + (fxv.lift > 0.001 ? fxv.lift : 0);
|
|
1747
|
+
for (const e of casters) {
|
|
1748
|
+
if (e.c === t) continue;
|
|
1749
|
+
const gap = e.z0 - bTop;
|
|
1750
|
+
if (gap < -1e-6) continue; // only onto cubes whose top it is above
|
|
1751
|
+
for (const [grow, share] of [[Math.min(1.2, 0.1 + gap * 0.06), 0.16], [0, 0.34]]) {
|
|
1752
|
+
const x0 = Math.max(t.x, e.x0 - grow), x1 = Math.min(t.x + t.s, e.x1 + grow);
|
|
1753
|
+
const y0 = Math.max(t.y, e.y0 - grow), y1 = Math.min(t.y + t.s, e.y1 + grow);
|
|
1754
|
+
if (x1 - x0 < 1e-3 || y1 - y0 < 1e-3) continue;
|
|
1755
|
+
const dk = share * e.k * Math.min(1, 1.6 / (1 + gap * 0.05));
|
|
1756
|
+
if (dk < 0.01) continue;
|
|
1757
|
+
out.push({ txid: t.txid, face: 'cast', points: [f.P(x0, y0, f.topZ), f.P(x1, y0, f.topZ), f.P(x1, y1, f.topZ), f.P(x0, y1, f.topZ)], fill: `rgba(0,0,0,${round3(dk)})` });
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
// SHADOWS FADE, THEY DO NOT SWITCH (operator, 2026-09-11: "the shadows just
|
|
1762
|
+
// disappear. they need to fade out"). A departure kept a full shadow on its
|
|
1763
|
+
// slot until the frame it was gone, and lift-off and touchdown swapped one
|
|
1764
|
+
// shadow for another. Under the oblique camera the resting shadow now
|
|
1765
|
+
// fades out over the first 1.5 units of a lift (and in over the last 1.5
|
|
1766
|
+
// of a landing), the flight shadow fades in and out across the same band,
|
|
1767
|
+
// and a block flying off screen -- or falling in from it -- takes its
|
|
1768
|
+
// shadow with it as it goes: (1 - entry).
|
|
1769
|
+
const zt = t.z ?? 0;
|
|
1770
|
+
// SHADOWS ARE OPTIONAL (operator, 2026-09-12: "remove shadows ... anything to make it run
|
|
1771
|
+
// faster"). They are the most expensive thing on a full board -- one quad per stone at rest,
|
|
1772
|
+
// more in flight -- so a machine that struggles can have the scene without them. Paint order
|
|
1773
|
+
// is unchanged: an empty shadow list still comes first.
|
|
1774
|
+
if (o.shadows !== false) {
|
|
1775
|
+
if (airborne && !viewerLit) shadows.push(...shadowOps(t, a * (o.oblique ? Math.min(1, zt / 1.5) * (1 - (t.entry ?? 0)) : 1), o));
|
|
1776
|
+
if (o.oblique && !viewerLit && zt < 1.5) shadows.push(...restingShadowOps(t, o, 1 - zt / 1.5));
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
let ops = o.oblique ? [...shadows, ...ground] : [...ground, ...shadows, ...air];
|
|
1780
|
+
if (pulled.size) {
|
|
1781
|
+
const rest = [], lifted = [];
|
|
1782
|
+
ops.forEach((op, i) => (pulled.has(String(op.txid)) ? lifted : rest).push({ op, i }));
|
|
1783
|
+
lifted.sort((p, q) => (pulled.get(String(p.op.txid)) - pulled.get(String(q.op.txid))) || (p.i - q.i));
|
|
1784
|
+
ops = [...rest.map((e) => e.op), ...lifted.map((e) => e.op)];
|
|
1785
|
+
}
|
|
1786
|
+
return { ops, bounds: ops.length ? { minX, maxX, minY, maxY } : null, count: ordered.length };
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
// PAINT ORDER UNDER THE OBLIQUE CAMERA (operator, 2026-09-11: "Blocks
|
|
1790
|
+
// bouncing around during movements and z-fighting in a weird way" / "new
|
|
1791
|
+
// blocks landing and bouncing is totally broken visually"). The fixed rule
|
|
1792
|
+
// was "the far corner first, everything airborne last", and with cubes of
|
|
1793
|
+
// real height that is wrong: a small cube bouncing a unit off the floor
|
|
1794
|
+
// BEHIND a tall resting one was painted over it, so landings looked like
|
|
1795
|
+
// cubes sliding through each other.
|
|
1796
|
+
//
|
|
1797
|
+
// The real rule, for two cubes whose pictures overlap: find an axis that
|
|
1798
|
+
// separates them and paint the one on the viewer's side of it later. Height
|
|
1799
|
+
// first -- the planner keeps any two flights that pass over each other in
|
|
1800
|
+
// disjoint altitude intervals, so a pair one above the other always splits
|
|
1801
|
+
// here, and the higher paints later -- then the row (the camera sits past
|
|
1802
|
+
// row 0 when rows are flipped), then the column (it sits past column 0).
|
|
1803
|
+
// Those pairwise facts are sorted topologically; the old diagonal key only
|
|
1804
|
+
// breaks ties, so a still board paints exactly as before.
|
|
1805
|
+
export function obliqueOrder(tiles, o = {}) {
|
|
1806
|
+
const n = tiles.length;
|
|
1807
|
+
const flipped = flipOf(o) > 0;
|
|
1808
|
+
// A cube's outline on screen: the convex hull of its eight corners as drawn.
|
|
1809
|
+
const hullOf = (pts) => {
|
|
1810
|
+
const s = pts.slice().sort((p, q) => p.x - q.x || p.y - q.y);
|
|
1811
|
+
const cross = (a, p, q) => (p.x - a.x) * (q.y - a.y) - (p.y - a.y) * (q.x - a.x);
|
|
1812
|
+
const lo = [], hi = [];
|
|
1813
|
+
for (const p of s) { while (lo.length >= 2 && cross(lo[lo.length - 2], lo[lo.length - 1], p) <= 0) lo.pop(); lo.push(p); }
|
|
1814
|
+
for (let i = s.length - 1; i >= 0; i--) { const p = s[i]; while (hi.length >= 2 && cross(hi[hi.length - 2], hi[hi.length - 1], p) <= 0) hi.pop(); hi.push(p); }
|
|
1815
|
+
return lo.slice(0, -1).concat(hi.slice(0, -1));
|
|
1816
|
+
};
|
|
1817
|
+
// Do two outlines overlap by more than `tol` on every axis? (separating axis
|
|
1818
|
+
// theorem for convex polygons)
|
|
1819
|
+
const overlaps = (A, B, tol) => {
|
|
1820
|
+
for (const poly of [A, B]) {
|
|
1821
|
+
for (let i = 0; i < poly.length; i++) {
|
|
1822
|
+
const p = poly[i], q = poly[(i + 1) % poly.length];
|
|
1823
|
+
let nx = q.y - p.y, ny = p.x - q.x;
|
|
1824
|
+
const L = Math.hypot(nx, ny);
|
|
1825
|
+
if (!L) continue;
|
|
1826
|
+
nx /= L; ny /= L;
|
|
1827
|
+
let a0 = Infinity, a1 = -Infinity, b0 = Infinity, b1 = -Infinity;
|
|
1828
|
+
for (const v of A) { const d = v.x * nx + v.y * ny; if (d < a0) a0 = d; if (d > a1) a1 = d; }
|
|
1829
|
+
for (const v of B) { const d = v.x * nx + v.y * ny; if (d < b0) b0 = d; if (d > b1) b1 = d; }
|
|
1830
|
+
if (Math.min(a1, b1) - Math.max(a0, b0) < tol) return false;
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
return true;
|
|
1834
|
+
};
|
|
1835
|
+
const info = tiles.map((t, i) => {
|
|
1836
|
+
const z0 = (t.z ?? 0) + (t.floor ?? 0), h = cubeHeight(t);
|
|
1837
|
+
const P = liftProjector(t, o);
|
|
1838
|
+
const pts = [];
|
|
1839
|
+
for (const gx of [t.x, t.x + t.s]) for (const gy of [t.y, t.y + t.s]) for (const gz of [z0, z0 + h]) pts.push(P(gx, gy, gz));
|
|
1840
|
+
let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
|
|
1841
|
+
for (const p of pts) { if (p.x < x0) x0 = p.x; if (p.x > x1) x1 = p.x; if (p.y < y0) y0 = p.y; if (p.y > y1) y1 = p.y; }
|
|
1842
|
+
// Footprints are the SLOT, deliberately, even in flight. Judging a flight by where it had drifted
|
|
1843
|
+
// to was tried (2026-09-14) and made pairs flip on every bounce; what fixed the resting-over-flyer
|
|
1844
|
+
// errors was the path instead -- flightOffset rises straight up until clear of the neighbours.
|
|
1845
|
+
const dx = 0, dy = 0;
|
|
1846
|
+
// the drawn base and top, for the one decision that must follow the picture (nearer, below)
|
|
1847
|
+
const air = (t.z ?? 0) > 0 || (t.entry ?? 0) > 0;
|
|
1848
|
+
const ccx = t.x + t.s / 2, ccy = t.y + t.s / 2;
|
|
1849
|
+
const dBottom = (air ? flightOffset(ccx, ccy, visualBase(t, o), o).z : 0) + (t.floor ?? 0) + capZ(ccx, ccy, o);
|
|
1850
|
+
return { t, i, air, dBottom, dTop: dBottom + h, zv0: z0, zv1: z0 + h, hull: hullOf(pts), bx0: x0, bx1: x1, by0: y0, by1: y1,
|
|
1851
|
+
fx0: t.x + dx, fx1: t.x + t.s + dx, fy0: t.y + dy, fy1: t.y + t.s + dy, key: String(t.txid), diag: t.x + t.y + t.s };
|
|
1852
|
+
});
|
|
1853
|
+
// The priority among cubes nothing constrains, and where a cycle is cut: the
|
|
1854
|
+
// old diagonal order, by FOOTPRINT only. It used to include the current height
|
|
1855
|
+
// and whether a cube was airborne, which change every frame, so a cycle was cut
|
|
1856
|
+
// in a different place each frame and the picture flickered (2026-09-11).
|
|
1857
|
+
const rank = new Array(n);
|
|
1858
|
+
info.slice().sort((p, q) => (q.diag - p.diag) || (p.key < q.key ? -1 : p.key > q.key ? 1 : 0))
|
|
1859
|
+
.forEach((e, k) => { rank[e.i] = k; });
|
|
1860
|
+
// 1e-3, not 1e-6: an eased flight lands its footprint on a whole cell only to ~1e-5 (measured: x
|
|
1861
|
+
// 23.9999929 against a neighbour at 25.9999858), so two abutting cubes kept crossing a 1e-6
|
|
1862
|
+
// tolerance on rounding noise and swapped between the column rule and the height rule -- 60px of
|
|
1863
|
+
// faces changing hands in one frame at the travel/drop boundary. No real overlap is that thin.
|
|
1864
|
+
const EPS = 1e-3;
|
|
1865
|
+
// IN REAL SPACE, footprint first, height only when the footprints overlap: in
|
|
1866
|
+
// real space the planner guarantees no two cubes intersect, so some axis always
|
|
1867
|
+
// separates a pair, and a bouncing cube (which keeps crossing its neighbour's
|
|
1868
|
+
// top) stays decided by the footprints.
|
|
1869
|
+
const memo = o.orderMemo instanceof Map ? o.orderMemo : null;
|
|
1870
|
+
const ABOVE_BAND = 0.75;
|
|
1871
|
+
const nearer = (a, b) => {
|
|
1872
|
+
// A CUBE DRAWN WHOLLY ABOVE A RESTING ONE IS IN FRONT OF IT (operator, 2026-09-14, twice: "The small
|
|
1873
|
+
// blocks that remain on the board always sort over the cubes flying above it", then, on Depth
|
|
1874
|
+
// 0.023 with Arcing: "items that remain on the board during transitions ... still sort above
|
|
1875
|
+
// everything else" and "blocks moving through other tall blocks"). Depth scales a point by its
|
|
1876
|
+
// height, so a rising cube's picture swells over the cube BESIDE it, and the footprint rules
|
|
1877
|
+
// below -- right for a parallel camera -- let the resting neighbour paint over it: 362 such pairs
|
|
1878
|
+
// on one live refresh at the operator's settings. The camera looks down, so a surface drawn above
|
|
1879
|
+
// another's top is nearer. First tried when flights still drifted sideways from the floor, this
|
|
1880
|
+
// rule made a bouncing cube swap with its neighbour on every hop; flights now rise straight up
|
|
1881
|
+
// until clear (flightOffset), and it is measured again here rather than assumed.
|
|
1882
|
+
// WITH A DEAD BAND, because the plain comparison was measured and failed: correct for the pair
|
|
1883
|
+
// (362 wrong -> 5) but a landing cube's bottom crosses its neighbour's top on every hop, and each
|
|
1884
|
+
// crossing swapped the pair over pictures Depth had already made overlap -- 844 px changing hands.
|
|
1885
|
+
// Clearly above: the flyer in front. Clearly below: the footprint rules. In between, whatever the
|
|
1886
|
+
// pair was last frame, so a hop that only grazes the neighbour's top changes nothing.
|
|
1887
|
+
// ONLY WITH DEPTH ON. At Depth 0 nothing swells, the footprint rules alone measured 0 wrong pairs
|
|
1888
|
+
// and 0 pops, and this rule added 82 px of pops there for nothing. With Depth it is a trade, and
|
|
1889
|
+
// the numbers chose it (Depth 0.023, Arcing, one refresh): wrong pairs 362 -> 72, visible pops
|
|
1890
|
+
// 0 -> 356 px, mostly small landings. Wider bands gave fewer pops and more wrong pairs (1.5: 164 /
|
|
1891
|
+
// 269 px; 3: 337 / 257 px), so 0.75 is where most of the errors go for the fewest pops.
|
|
1892
|
+
if (a.air !== b.air && (o.oblique?.rise ?? 0) > 0) {
|
|
1893
|
+
const fly = a.air ? a : b, rest = a.air ? b : a;
|
|
1894
|
+
const gap = fly.dBottom - rest.dTop;
|
|
1895
|
+
if (gap >= ABOVE_BAND) return a.air ? 1 : -1;
|
|
1896
|
+
if (gap > -ABOVE_BAND && memo) {
|
|
1897
|
+
const pa = memo.get(a.key), pb = memo.get(b.key);
|
|
1898
|
+
if (pa !== undefined && pb !== undefined && pa !== pb) return pa > pb ? 1 : -1;
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
if (a.fy1 <= b.fy0 + EPS) return flipped ? 1 : -1;
|
|
1902
|
+
if (b.fy1 <= a.fy0 + EPS) return flipped ? -1 : 1;
|
|
1903
|
+
// ACROSS COLUMNS THE LEAN DECIDES, not "left after right" (operator, 2026-09-14: "shit popping
|
|
1904
|
+
// over other shit at end of movements"). Height pushes a cube's top sideways by the lean, which
|
|
1905
|
+
// is radial: rightward on the right half, LEFTWARD on the left. A cube covers the neighbour its
|
|
1906
|
+
// top leans over, so on the left half the RIGHT cube paints after. The column rule was right
|
|
1907
|
+
// only on the right half, and leanEdge below corrected the left half on a settled board only --
|
|
1908
|
+
// so the order was wrong the whole time anything flew and flipped the frame the last cube
|
|
1909
|
+
// landed. Measured on a 90-cube transition: 18px of faces changing owner on that frame. The
|
|
1910
|
+
// lean is taken at the boundary between the two footprints, a function of the slots alone, so
|
|
1911
|
+
// it cannot change from frame to frame.
|
|
1912
|
+
if (a.fx1 <= b.fx0 + EPS) return obliqueLean((a.fx1 + b.fx0) / 2, o) < 0 ? -1 : 1;
|
|
1913
|
+
if (b.fx1 <= a.fx0 + EPS) return obliqueLean((b.fx1 + a.fx0) / 2, o) < 0 ? 1 : -1;
|
|
1914
|
+
if (a.zv0 >= b.zv1 - EPS) return 1;
|
|
1915
|
+
if (b.zv0 >= a.zv1 - EPS) return -1;
|
|
1916
|
+
// overlapping even in real space (not in a planned flight): the axis of least overlap
|
|
1917
|
+
const oz = Math.min(a.zv1, b.zv1) - Math.max(a.zv0, b.zv0);
|
|
1918
|
+
const oy = Math.min(a.fy1, b.fy1) - Math.max(a.fy0, b.fy0);
|
|
1919
|
+
const ox = Math.min(a.fx1, b.fx1) - Math.max(a.fx0, b.fx0);
|
|
1920
|
+
if (oz <= oy && oz <= ox) return a.zv0 + a.zv1 > b.zv0 + b.zv1 ? 1 : -1;
|
|
1921
|
+
if (oy <= ox) return (a.fy0 + a.fy1 < b.fy0 + b.fy1) === flipped ? 1 : -1;
|
|
1922
|
+
return a.fx0 + a.fx1 < b.fx0 + b.fx1 ? 1 : -1;
|
|
1923
|
+
};
|
|
1924
|
+
// CONSTRAINTS ONLY WHERE THE PICTURES TRULY OVERLAP. A padded bounding box
|
|
1925
|
+
// added one for every pair within ~11 px of each other; between cubes whose
|
|
1926
|
+
// outlines do not actually overlap those constraints are free to contradict
|
|
1927
|
+
// one another, and the cycles they formed were cut arbitrarily -- replayed, the
|
|
1928
|
+
// final order contradicted a pairwise decision in 1267 of 1452 frames while no
|
|
1929
|
+
// decision itself ever changed. Now: outlines overlapping at all.
|
|
1930
|
+
// 0, not 0.5 (2026-09-14): with the lean deciding columns (nearer) the pair rules agree, so a
|
|
1931
|
+
// touching pair no longer needs to be kept out -- and keeping it out is what made it pop. A cube
|
|
1932
|
+
// bouncing beside a neighbour crossed the half-pixel threshold on every hop, the edge came and
|
|
1933
|
+
// went, and without it the pair fell back to the diagonal rank and swapped: measured, 212px of
|
|
1934
|
+
// faces changing hands during the landings of a 90-cube transition at 0.5, 127px at 0.
|
|
1935
|
+
const TOL = 0;
|
|
1936
|
+
const after = Array.from({ length: n }, () => []);
|
|
1937
|
+
const indeg = new Array(n).fill(0);
|
|
1938
|
+
// A LEANING FACE OVER A SHORTER NEIGHBOUR (operator, 2026-09-12: "Height sorting issue on bottom
|
|
1939
|
+
// left larger blocks next to smaller blocks"). Measured on the live Simple board: 6 of 33
|
|
1940
|
+
// same-row tall/short pairs painted the short one AFTER the tall one, clipping the tall cube's
|
|
1941
|
+
// side face -- every one of them on the LEFT half. Two things together: the pair rule below
|
|
1942
|
+
// orders an x-overlap by column alone (left after right), which is right where faces lean
|
|
1943
|
+
// right and backwards where the radial lean points them left; and abutting neighbours never
|
|
1944
|
+
// reach that rule anyway, because the bounding-box check skips any pair closer than TOL while
|
|
1945
|
+
// a 2-tall face leans over its neighbour by ~0.18 units. So: for same-row neighbours of
|
|
1946
|
+
// unequal height, the taller paints after the one its face leans over, the side taken from the
|
|
1947
|
+
// lean at its own column. +1: i after j. -1: j after i. 0: not this case.
|
|
1948
|
+
// ON A SETTLED BOARD ONLY. A clipped face is a resting-board artefact -- a cube standing beside
|
|
1949
|
+
// a shorter one. Applied during a transition this rule made pairs flicker (the guard caught 21
|
|
1950
|
+
// of 785 overlapping pairs swapping mid-flight), and gating it per pair was not enough: an
|
|
1951
|
+
// extra edge changes the SHAPE of the constraint graph, so as third cubes fly past and their
|
|
1952
|
+
// hull edges come and go, the group a resting pair belongs to is re-cut differently from frame
|
|
1953
|
+
// to frame and the pair swaps without either of them moving. So while anything on the board is
|
|
1954
|
+
// in flight, no leaning-face edges at all: the graph is exactly what it was before this rule
|
|
1955
|
+
// existed, and the resting order takes over once, at settle -- a single change, not a swap.
|
|
1956
|
+
const flying = (t) => (t.z ?? 0) > 0 || (t.entry ?? 0) > 0;
|
|
1957
|
+
const settledBoard = !tiles.some(flying);
|
|
1958
|
+
const leanEdge = (p, q) => {
|
|
1959
|
+
if (!settledBoard) return 0;
|
|
1960
|
+
const hp = cubeHeight(p.t), hq = cubeHeight(q.t);
|
|
1961
|
+
if (hp === hq) return 0;
|
|
1962
|
+
const [tall, short, sign] = hp > hq ? [p.t, q.t, 1] : [q.t, p.t, -1];
|
|
1963
|
+
if (!(short.y < tall.y + tall.s && tall.y < short.y + short.s)) return 0; // same rows
|
|
1964
|
+
const lean = obliqueLean(tall.x + tall.s / 2, o);
|
|
1965
|
+
const onLeanSide = (lean > 0 && short.x === tall.x + tall.s) || (lean < 0 && short.x + short.s === tall.x);
|
|
1966
|
+
return onLeanSide ? sign : 0;
|
|
1967
|
+
};
|
|
1968
|
+
for (let i = 0; i < n; i++) {
|
|
1969
|
+
const a = info[i];
|
|
1970
|
+
for (let j = i + 1; j < n; j++) {
|
|
1971
|
+
const c2 = info[j];
|
|
1972
|
+
const le = leanEdge(a, c2);
|
|
1973
|
+
if (le > 0) { after[j].push(i); indeg[i]++; continue; }
|
|
1974
|
+
if (le < 0) { after[i].push(j); indeg[j]++; continue; }
|
|
1975
|
+
if (a.bx1 - TOL < c2.bx0 || c2.bx1 - TOL < a.bx0 || a.by1 - TOL < c2.by0 || c2.by1 - TOL < a.by0) continue;
|
|
1976
|
+
if (!overlaps(a.hull, c2.hull, TOL)) continue;
|
|
1977
|
+
const c = nearer(a, c2);
|
|
1978
|
+
if (c > 0) { after[j].push(i); indeg[i]++; } else if (c < 0) { after[i].push(j); indeg[j]++; }
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
// CYCLES ARE RESOLVED AS A GROUP, NOT CUT (2026-09-11). Even with constraints
|
|
1982
|
+
// only between truly overlapping outlines, a cube in flight can be tangled
|
|
1983
|
+
// with several others: the pair decisions are made in real space and the
|
|
1984
|
+
// outlines are drawn on the sphere, so a few groups contradict each other. The
|
|
1985
|
+
// old loop broke a cycle by forcing the lowest-ranked cube LEFT ANYWHERE --
|
|
1986
|
+
// often one outside the cycle -- and which cubes were left changed as others
|
|
1987
|
+
// moved, so the same tangle was cut differently from frame to frame (replayed:
|
|
1988
|
+
// 21 of the last 23 flickers, with the pair decision and the overlap both
|
|
1989
|
+
// unchanged). Now the tangles are found (strongly connected components), the
|
|
1990
|
+
// groups are ordered among themselves -- that order cannot cycle -- and inside
|
|
1991
|
+
// a group the cubes go by how near their drawn centre is to the camera, a
|
|
1992
|
+
// smooth measure that cannot be cut two ways.
|
|
1993
|
+
// the camera looks along (-ox, -oy/dy, 1): a squeezed depth weighs a row further back more
|
|
1994
|
+
const oby = (o.oblique?.oy ?? 0.36) / (o.oblique?.dy ?? 1);
|
|
1995
|
+
const depth = info.map((e2) => {
|
|
1996
|
+
const t = e2.t, h = cubeHeight(t), cy = t.y + t.s / 2;
|
|
1997
|
+
const zb = (t.z ?? 0) > 0 || t.entry > 0 ? visualBase(t, o) : 0;
|
|
1998
|
+
// where this cube is actually DRAWN: its slot, plus the flight along the sphere's normal,
|
|
1999
|
+
// plus the camera's own sideways push -- settled by the SAME function liftProjector uses, so
|
|
2000
|
+
// the order and the geometry cannot be measured at different points (obliqueLean is radial, so
|
|
2001
|
+
// a block that has travelled leans by where it has got to, not by the slot it left). This was
|
|
2002
|
+
// six lines copied from there; with a departure mode to honour, a copy is a latent flicker.
|
|
2003
|
+
const { lean, cx } = flightGeom(t, zb, o);
|
|
2004
|
+
return -lean * cx - (flipped ? 1 : -1) * oby * cy + capZ(cx, cy, o) + zb + (t.floor ?? 0) + h / 2;
|
|
2005
|
+
});
|
|
2006
|
+
const index = new Array(n).fill(-1), low = new Array(n).fill(0), onStack = new Array(n).fill(false), comp = new Array(n).fill(-1);
|
|
2007
|
+
const stack = [];
|
|
2008
|
+
let idx = 0, nComp = 0;
|
|
2009
|
+
for (let s0 = 0; s0 < n; s0++) {
|
|
2010
|
+
if (index[s0] >= 0) continue;
|
|
2011
|
+
const work = [[s0, 0]];
|
|
2012
|
+
index[s0] = low[s0] = idx++; stack.push(s0); onStack[s0] = true;
|
|
2013
|
+
while (work.length) {
|
|
2014
|
+
const top = work[work.length - 1], v = top[0];
|
|
2015
|
+
if (top[1] < after[v].length) {
|
|
2016
|
+
const w = after[v][top[1]++];
|
|
2017
|
+
if (index[w] < 0) { index[w] = low[w] = idx++; stack.push(w); onStack[w] = true; work.push([w, 0]); }
|
|
2018
|
+
else if (onStack[w]) low[v] = Math.min(low[v], index[w]);
|
|
2019
|
+
} else {
|
|
2020
|
+
work.pop();
|
|
2021
|
+
if (work.length) { const u = work[work.length - 1][0]; low[u] = Math.min(low[u], low[v]); }
|
|
2022
|
+
if (low[v] === index[v]) { let w; do { w = stack.pop(); onStack[w] = false; comp[w] = nComp; } while (w !== v); nComp++; }
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
const members = Array.from({ length: nComp }, () => []);
|
|
2027
|
+
for (let v = 0; v < n; v++) members[comp[v]].push(v);
|
|
2028
|
+
const cRank = members.map((m) => Math.min(...m.map((v) => rank[v])));
|
|
2029
|
+
const cAfter = Array.from({ length: nComp }, () => new Set());
|
|
2030
|
+
const cIn = new Array(nComp).fill(0);
|
|
2031
|
+
for (let v = 0; v < n; v++) for (const w of after[v]) {
|
|
2032
|
+
const p = comp[v], q = comp[w];
|
|
2033
|
+
if (p !== q && !cAfter[p].has(q)) { cAfter[p].add(q); cIn[q]++; }
|
|
2034
|
+
}
|
|
2035
|
+
// the groups, lowest-ranked ready group first (a DAG: nothing to cut)
|
|
2036
|
+
const heap = [];
|
|
2037
|
+
const push = (c) => { heap.push(c); let k = heap.length - 1; while (k > 0) { const p = (k - 1) >> 1; if (cRank[heap[p]] <= cRank[c]) break; heap[k] = heap[p]; k = p; } heap[k] = c; };
|
|
2038
|
+
const pop = () => { const top = heap[0], last = heap.pop(); if (heap.length) { let k = 0; for (;;) { const l = 2 * k + 1, r = l + 1; let m = k; const vm = () => (m === k ? last : heap[m]); if (l < heap.length && cRank[heap[l]] < cRank[vm()]) m = l; if (r < heap.length && cRank[heap[r]] < cRank[vm()]) m = r; if (m === k) break; heap[k] = heap[m]; k = m; } heap[k] = last; } return top; };
|
|
2039
|
+
for (let c = 0; c < nComp; c++) if (cIn[c] === 0) push(c);
|
|
2040
|
+
// A TANGLE REMEMBERS ITS ORDER (2026-09-12, operator: "Still showing block z-fighting during
|
|
2041
|
+
// transitions"). Depth alone re-ordered a tangle every frame it was re-cut, so a pair whose
|
|
2042
|
+
// decision never changed still swapped whenever a bystander flying past pulled it into a
|
|
2043
|
+
// tangle or let it out (replayed: 18 of 19 flickers had the edge and the decision unchanged
|
|
2044
|
+
// and the final order flipped). With `orderMemo` (render3d keeps one per canvas) the cubes of
|
|
2045
|
+
// a tangle keep the relative order they had LAST frame -- the order the edges gave them before
|
|
2046
|
+
// the tangle formed -- and only cubes with no last frame fall in by depth. In and out of a
|
|
2047
|
+
// tangle the pair's order is then the same, so nothing can flicker; a cube genuinely passing
|
|
2048
|
+
// another is decided by the edges again the moment the tangle dissolves.
|
|
2049
|
+
const prev = memo ? (v) => memo.get(String(tiles[v].txid)) : () => undefined;
|
|
2050
|
+
const out = [];
|
|
2051
|
+
while (heap.length) {
|
|
2052
|
+
const c = pop();
|
|
2053
|
+
const m = members[c];
|
|
2054
|
+
if (m.length > 1) m.sort((p, q) => {
|
|
2055
|
+
const a = prev(p), b = prev(q);
|
|
2056
|
+
if (a !== undefined && b !== undefined && a !== b) return a - b;
|
|
2057
|
+
if (a !== undefined && b === undefined) return -1;
|
|
2058
|
+
if (a === undefined && b !== undefined) return 1;
|
|
2059
|
+
return (depth[p] - depth[q]) || (rank[p] - rank[q]);
|
|
2060
|
+
});
|
|
2061
|
+
for (const v of m) out.push(tiles[v]);
|
|
2062
|
+
for (const d of cAfter[c]) if (--cIn[d] === 0) push(d);
|
|
2063
|
+
}
|
|
2064
|
+
if (memo) { memo.clear(); out.forEach((t, k) => memo.set(String(t.txid), k)); }
|
|
2065
|
+
return out;
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
// RESTING CUBES CAST SHADOWS TOO (operator, 2026-09-11: "a more interesting
|
|
2069
|
+
// ground texture ... shows the shadows well"). Only flights cast one before,
|
|
2070
|
+
// so at rest -- most of the time -- nothing on the ground showed a shadow. A
|
|
2071
|
+
// standing cube throws a short one away from the light (upper left, above):
|
|
2072
|
+
// down and to the right of its footprint, longer the taller the cube -- the
|
|
2073
|
+
// hull of the footprint and its shifted copy -- with a fainter penumbra round
|
|
2074
|
+
// it. It lies on the floor, so every cube paints over it (buildScene paints
|
|
2075
|
+
// shadows first).
|
|
2076
|
+
export function restingShadowOps(t, o = {}, k = 1) {
|
|
2077
|
+
if (!(k > 0.005)) return [];
|
|
2078
|
+
const flip = o.flipY === false ? -1 : 1;
|
|
2079
|
+
const d = Math.min(2.5, 0.2 + 0.28 * (cubeHeight(t) + (t.floor ?? 0)));
|
|
2080
|
+
const hull = (e, dd) => {
|
|
2081
|
+
const x0 = t.x - e, y0 = t.y - e, x1 = t.x + t.s + e, y1 = t.y + t.s + e, dy = -flip * dd; // screen-down is -row
|
|
2082
|
+
return [[x0, y1], [x1, y1], [x1 + dd, y1 + dy], [x1 + dd, y0 + dy], [x0 + dd, y0 + dy], [x0, y0]].map(([gx, gy]) => project(gx, gy, 0, o));
|
|
2083
|
+
};
|
|
2084
|
+
return [
|
|
2085
|
+
{ txid: t.txid, face: 'shadow', points: hull(0.15, d * 1.35), fill: `rgba(0,0,0,${round3(0.12 * k)})` },
|
|
2086
|
+
{ txid: t.txid, face: 'shadow', points: hull(0, d), fill: `rgba(0,0,0,${round3(0.26 * k)})` },
|
|
2087
|
+
];
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
// The shadow an airborne tile casts on the plane, as up to three nested
|
|
2091
|
+
// rgba squares: a soft penumbra and a darker umbra, both at the tile's TRUE
|
|
2092
|
+
// footprint on the tops of the resting stones.
|
|
2093
|
+
export function shadowOps(t, a = 1, o = {}) {
|
|
2094
|
+
const z = t.z ?? 0;
|
|
2095
|
+
if (!(z > 0.02) || !(a > 0)) return [];
|
|
2096
|
+
const blur = Math.min(3, 0.15 + z * 0.13); // spreads as it climbs (gentler since flights climb higher)
|
|
2097
|
+
const dark = Math.min(0.78, 0.78 / (1 + z * 0.05)) * a; // and pales -- slowly, so a high flight still marks its slot
|
|
2098
|
+
// Cast by a light above and to the upper left: the shadow slides down and to
|
|
2099
|
+
// the right of the slot as the block climbs, and that growing gap is what
|
|
2100
|
+
// says "lifted toward the viewer" (directly underneath, a spreading dark
|
|
2101
|
+
// ring read as the block sinking).
|
|
2102
|
+
const off = o.oblique ? 0 : Math.min(2, z * 0.3); // oblique: the block itself is displaced, the shadow stays on its slot
|
|
2103
|
+
const ox = off, oy = (o.flipY === false ? off : -off); // screen-down is -row when rows are flipped
|
|
2104
|
+
const P = (gx, gy) => project(gx + ox, gy + oy, 0, o); // on the floor: resting cubes are of every height now
|
|
2105
|
+
const sq = (e) => [P(t.x - e, t.y - e), P(t.x + t.s + e, t.y - e), P(t.x + t.s + e, t.y + t.s + e), P(t.x - e, t.y + t.s + e)];
|
|
2106
|
+
const core = Math.min(t.s * 0.3, z * 0.04);
|
|
2107
|
+
return [
|
|
2108
|
+
{ txid: t.txid, face: 'shadow', points: sq(blur), fill: `rgba(0,0,0,${round3(dark * 0.22)})` },
|
|
2109
|
+
{ txid: t.txid, face: 'shadow', points: sq(blur * 0.45), fill: `rgba(0,0,0,${round3(dark * 0.3)})` },
|
|
2110
|
+
{ txid: t.txid, face: 'shadow', points: sq(-core), fill: `rgba(0,0,0,${round3(dark * 0.5)})` },
|
|
2111
|
+
];
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
// FILL the box, not fit inside it. The packing is rarely exactly square --
|
|
2115
|
+
// sides round to whole units, so a block packs a few rows taller or shorter
|
|
2116
|
+
// than it is wide (blockpack.js) -- and an aspect-preserving fit into a
|
|
2117
|
+
// square panel left black bars down both sides -- "too much black ...
|
|
2118
|
+
// wasted space".
|
|
2119
|
+
//
|
|
2120
|
+
// The cost, stated rather than hidden: scaling the axes separately makes a
|
|
2121
|
+
// block up to about 10% off square. That is below the threshold where anyone reads
|
|
2122
|
+
// it as a rectangle, and it buys the whole panel. Areas still track vbytes
|
|
2123
|
+
// exactly, because both axes scale every block identically.
|
|
2124
|
+
export function fitToBox(bounds, boxW, boxH, pad = 8) {
|
|
2125
|
+
if (!bounds) return { scaleX: 1, scaleY: 1, scale: 1, dx: 0, dy: 0 };
|
|
2126
|
+
const w = bounds.maxX - bounds.minX || 1;
|
|
2127
|
+
const h = bounds.maxY - bounds.minY || 1;
|
|
2128
|
+
const scaleX = (boxW - pad * 2) / w;
|
|
2129
|
+
const scaleY = (boxH - pad * 2) / h;
|
|
2130
|
+
return {
|
|
2131
|
+
scaleX, scaleY,
|
|
2132
|
+
scale: Math.min(scaleX, scaleY), // for callers that want the uniform one
|
|
2133
|
+
dx: pad - bounds.minX * scaleX,
|
|
2134
|
+
dy: pad - bounds.minY * scaleY,
|
|
2135
|
+
};
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
// --- easing ------------------------------------------------------------
|
|
2139
|
+
export function easeInOutCubic(t) {
|
|
2140
|
+
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
|
2141
|
+
}
|
|
2142
|
+
export function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
|
|
2143
|
+
// Gravity: a piece does not ease into the floor, it accelerates into it.
|
|
2144
|
+
export function gravity(t) { const c = Math.max(0, Math.min(1, t)); return c * c; }
|
|
2145
|
+
|
|
2146
|
+
// The landing, with real gravity and a decaying series of bounces. Returns
|
|
2147
|
+
// the REMAINING height as a fraction of the drop: 1 at release, 0 at rest.
|
|
2148
|
+
//
|
|
2149
|
+
// The fall is h = 1 - u^2, which is gravity: the block covers three quarters
|
|
2150
|
+
// of the distance in the second half of the fall, so it visibly accelerates
|
|
2151
|
+
// into the floor. Each bounce afterwards is a parabola of decaying height
|
|
2152
|
+
// (restitution 0.55) and correspondingly shorter duration, exactly as a
|
|
2153
|
+
// dropped object behaves -- quick little hops at the end rather than one
|
|
2154
|
+
// polite rebound.
|
|
2155
|
+
//
|
|
2156
|
+
// `bounces` is 1..5 per block, from its own hash, so no two settle the same
|
|
2157
|
+
// way (operator: "Make every block bounce at least 1-5 times randomly before
|
|
2158
|
+
// settling into place").
|
|
2159
|
+
export function bounceDrop(t, bounce = 0.12, bounces = 3, rest = 0.55) {
|
|
2160
|
+
const c = Math.max(0, Math.min(1, t));
|
|
2161
|
+
const n = Math.max(1, Math.min(12, Math.round(bounces)));
|
|
2162
|
+
const REST = rest;
|
|
2163
|
+
|
|
2164
|
+
const heights = [];
|
|
2165
|
+
let h = Math.max(bounce, 1e-6);
|
|
2166
|
+
for (let k = 0; k < n; k++) { heights.push(h); h *= REST; }
|
|
2167
|
+
|
|
2168
|
+
// PHYSICS FOR THE TIMING (operator, 2026-09-11: "The bouncing needs to be
|
|
2169
|
+
// sinusoidal and apply physics so each bounce isn't the same length of
|
|
2170
|
+
// time"). Under constant gravity a fall from height 1 takes time T, and a
|
|
2171
|
+
// hop up to height h and back takes 2 T sqrt(h). The old weights were
|
|
2172
|
+
// 1.25 * sqrt(h / h0): relative to the FIRST hop, not to the fall, so the
|
|
2173
|
+
// first hop always lasted 1.25 falls whatever its height -- a 3% rebound
|
|
2174
|
+
// floated as long as a 22% one. With heights kept at REST = 0.55 per bounce,
|
|
2175
|
+
// each hop is now sqrt(0.55) = 0.74 as long as the one before it.
|
|
2176
|
+
const segs = [1, ...heights.map((v) => 2 * Math.sqrt(v))];
|
|
2177
|
+
const total = segs.reduce((a, b) => a + b, 0);
|
|
2178
|
+
|
|
2179
|
+
let acc = 0;
|
|
2180
|
+
for (let i = 0; i < segs.length; i++) {
|
|
2181
|
+
const w = segs[i] / total;
|
|
2182
|
+
const last = i === segs.length - 1;
|
|
2183
|
+
if (c < acc + w || last) {
|
|
2184
|
+
const u = w > 0 ? Math.min(1, Math.max(0, (c - acc) / w)) : 1;
|
|
2185
|
+
if (i === 0) return 1 - u * u; // the fall: accelerating
|
|
2186
|
+
if (last && c >= 1) return 0; // and it does come to rest
|
|
2187
|
+
return heights[i - 1] * Math.sin(Math.PI * u); // a hop: a sine arc, lower and shorter each time
|
|
2188
|
+
}
|
|
2189
|
+
acc += w;
|
|
2190
|
+
}
|
|
2191
|
+
return 0;
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
// GRAVITY TIMES EVERY LANDING (operator, 2026-09-11: "I don't get any sense
|
|
2195
|
+
// of blocks falling and bouncing ... Need much more randomness and
|
|
2196
|
+
// variability"). Every block's landing used to be squeezed into the same
|
|
2197
|
+
// fixed drop window whatever it fell from, so a block dropping one unit took
|
|
2198
|
+
// as long as one dropping twelve and floated down. Now a fall from h takes
|
|
2199
|
+
// sqrt(2h / g) -- a fall from 12 units takes 2.4 s -- and the whole landing
|
|
2200
|
+
// is that fall plus its own hops (bounceDrop's segments are in the same
|
|
2201
|
+
// proportions, so the curve and the clock agree). Low blocks land fast, high
|
|
2202
|
+
// ones take longer, and no two finish together.
|
|
2203
|
+
// 2026-09-11: a 12-unit fall took 1.6 s, which on this board read as slow
|
|
2204
|
+
// motion (operator: "They bounce too slowly right now"); 0.7 s was then far
|
|
2205
|
+
// too fast ("Holy shit the bouncing is too fast now. cut speed in half at
|
|
2206
|
+
// least"). 1.4 s: half the speed of 0.7 s, every hop with it.
|
|
2207
|
+
// ...and 1.4 s was STILL "way too fast. Calm that shit down": 2.4 s now, with
|
|
2208
|
+
// deader rebounds (restitutionOf below).
|
|
2209
|
+
// 12 units in 0.6 s since 2026-09-11 -- 2.4 s was "The items slowly start dropping.
|
|
2210
|
+
// They need to drop like they were just let go, immediately succumb to gravity", and
|
|
2211
|
+
// 1.2 s "gravity still doesn't seem strong enough"
|
|
2212
|
+
export const GRAVITY = (2 * 12) / (600 * 600); // grid units per ms^2
|
|
2213
|
+
export function fallMs(h) { return Math.sqrt((2 * Math.max(0, h)) / GRAVITY); }
|
|
2214
|
+
export function landingMs(h, bounce, bounces, rest = 0.55) {
|
|
2215
|
+
const n = Math.max(1, Math.min(12, Math.round(bounces)));
|
|
2216
|
+
let b = Math.max(bounce, 1e-6), hops = 0;
|
|
2217
|
+
for (let k = 0; k < n; k++) { hops += 2 * Math.sqrt(b); b *= rest; }
|
|
2218
|
+
return fallMs(h) * (1 + hops);
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
// One block's landing: where it falls from, how it bounces, and when it lets
|
|
2222
|
+
// go and comes to rest. Deterministic from the block's own hashes, so it has
|
|
2223
|
+
// the same character on every repaint.
|
|
2224
|
+
export function landingOf(tw, plan) {
|
|
2225
|
+
const j = tw.jitter ?? 0;
|
|
2226
|
+
const jH = tw.jH ?? j;
|
|
2227
|
+
const spread = plan.cfg.bounceSpread;
|
|
2228
|
+
// MASS (operator, 2026-09-11: "Have the blocks obey the natural laws of
|
|
2229
|
+
// gravity with their bouncing ... Give the blocks actual weight/mass for
|
|
2230
|
+
// physics to simulate"). Mass does not change how fast a block falls
|
|
2231
|
+
// (Galileo); it changes how it BOUNCES, through its coefficient of
|
|
2232
|
+
// restitution e: a rebound reaches e^2 of the height it fell from, and each
|
|
2233
|
+
// hop lasts e as long as the one before. A heavy block (large area, i.e. many
|
|
2234
|
+
// vbytes) thuds and settles; a light one rebounds and keeps hopping.
|
|
2235
|
+
const e = restitutionOf(tw.to?.s ?? 1, tw.jR ?? jitterOf(tw.txid, 'r'));
|
|
2236
|
+
const rest = e * e;
|
|
2237
|
+
const bounce = rest; // the first rebound is e^2 of the drop, like every one after it
|
|
2238
|
+
const nBounce = bouncesUntil(bounce, rest);
|
|
2239
|
+
// an arrival lets go from its own height, always above the flight stack
|
|
2240
|
+
const from = tw.kind === 'enter' ? plan.cfg.enterFrom * (1 + 0.9 * (tw.jA ?? 0)) : (tw.lane ?? 0);
|
|
2241
|
+
// Cube lanes can stack high over a busy board; gravity from forty units
|
|
2242
|
+
// would take seconds. The fall is TIMED as if from at most 24 units (~3.4 s;
|
|
2243
|
+
// it was forty for the 40 s transition, twelve and sixteen before that),
|
|
2244
|
+
// which the eye reads as the same drop -- the heights themselves are kept,
|
|
2245
|
+
// because the no-collision proof lives in them.
|
|
2246
|
+
const t0 = plan.phases.travel + j * (plan.cfg.dropStagger || 0);
|
|
2247
|
+
return { bounce, nBounce, rest, from, t0, t1: t0 + landingMs(Math.min(from, 24), bounce, nBounce, rest) };
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
// COMING TO REST THE WAY A DROPPED THING DOES (operator, 2026-09-11: "The
|
|
2251
|
+
// blocks bouncing and coming to rest seems too abrupt. It needs to really look
|
|
2252
|
+
// like physics is making it come to rest naturally"). A fixed 1-5 bounces
|
|
2253
|
+
// stopped a block while its hops were still plainly visible. It now bounces
|
|
2254
|
+
// until a hop would be under 0.4% of the drop -- each lower by e^2 and shorter
|
|
2255
|
+
// by e, where e is the block's coefficient of restitution (from its mass, see
|
|
2256
|
+
// restitutionOf) -- a converging series of ever quicker, ever smaller hops,
|
|
2257
|
+
// which is how a real object comes to rest in finite time. Variety comes from
|
|
2258
|
+
// mass and a small per-block share rather than a dice roll on the count.
|
|
2259
|
+
// (2026-09-11, "come to rest sooner": the floor 0.4% -> 1% and one hop allowed,
|
|
2260
|
+
// so a light block gets a small second hop and a heavy one settles after one)
|
|
2261
|
+
export function bouncesUntil(bounce, rest, floor = 0.01) {
|
|
2262
|
+
const n = Math.ceil(Math.log(floor / Math.max(bounce, 1e-6)) / Math.log(rest));
|
|
2263
|
+
return Math.max(1, Math.min(8, n));
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
// Coefficient of restitution from mass. Mass goes as a block's AREA (its
|
|
2267
|
+
// vbytes), and a heavier block is a deader bounce: e falls with log2 of the
|
|
2268
|
+
// side, from ~0.78 for a one-unit block to ~0.4 for the largest, plus a +-0.05
|
|
2269
|
+
// share from the block's own hash so two blocks of one size do not settle in
|
|
2270
|
+
// lockstep.
|
|
2271
|
+
export function restitutionOf(side, jitter = 0.5) {
|
|
2272
|
+
// calmer (2026-09-11): e in 0.28-0.6; then heavier still ("less bouncing.
|
|
2273
|
+
// Make them come to rest sooner. More weight to each block"): e in 0.15-0.36,
|
|
2274
|
+
// so a one-unit block keeps at most 13% of its fall and a big one ~3%
|
|
2275
|
+
return Math.max(0.15, Math.min(0.36, 0.36 - 0.05 * Math.log2(Math.max(1, side)) + (jitter - 0.5) * 0.06));
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
// how many times a given block bounces: 1..5, stable from its id (the old
|
|
2279
|
+
// policy, kept for anything that still asks)
|
|
2280
|
+
export function bouncesFor(jitter) { return 1 + Math.floor(Math.max(0, Math.min(0.999, jitter)) * 5); }
|
|
2281
|
+
|
|
2282
|
+
// Heavier (bigger) blocks barely bounce; small ones do. Bounded so nothing
|
|
2283
|
+
// bounces absurdly and nothing is perfectly dead.
|
|
2284
|
+
// 2026-09-11: raised from [0.03, 0.22] -- with a drop of a unit or two a 3%
|
|
2285
|
+
// rebound is a few hundredths of a unit, and nobody saw a bounce at all.
|
|
2286
|
+
export function bounceFor(side) { return Math.max(0.1, Math.min(0.4, 0.9 / Math.max(1, side))); }
|
|
2287
|
+
|
|
2288
|
+
// A stable per-block number in [0,1), from its id. DETERMINISTIC on purpose:
|
|
2289
|
+
// a random draw per frame would make every block jitter its own landing, and
|
|
2290
|
+
// the same block must get the same character on every repaint or the picture
|
|
2291
|
+
// shimmers. FNV-1a, which is four lines and needs no dependency.
|
|
2292
|
+
export function jitterOf(txid, salt = '') {
|
|
2293
|
+
let h = 2166136261;
|
|
2294
|
+
const s = salt + String(txid);
|
|
2295
|
+
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
|
|
2296
|
+
return ((h >>> 0) % 10007) / 10007;
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
|
|
2300
|
+
// --- the choreography --------------------------------------------------
|
|
2301
|
+
// rise + travel + drop = 5.4 s by default: inside a 10 s refresh, and slow
|
|
2302
|
+
// enough to follow one tile across the board.
|
|
2303
|
+
// Thirty seconds end to end (operator, 2026-09-10), against a 20 s data
|
|
2304
|
+
// refresh: a transition therefore overlaps the next poll, and the replan
|
|
2305
|
+
// guard in the renderer is what keeps that from restarting it -- an unchanged
|
|
2306
|
+
// layout leaves the running choreography alone.
|
|
2307
|
+
export const TRANSITION = {
|
|
2308
|
+
// 40 s nominal (operator, 2026-09-11: "increase the animation time to at
|
|
2309
|
+
// least 40 seconds if it's refreshed every 60 seconds"); was 5 + 18.5 + 6.5
|
|
2310
|
+
// 20 s since the second 2026-09-11 change ("Faster refresh": the viewer
|
|
2311
|
+
// refreshes every 30 s now, so a 40 s transition would never finish)
|
|
2312
|
+
rise: 3000, travel: 11000, drop: 6000,
|
|
2313
|
+
// > a tile is thick, which IS the separation proof, and no higher: the fit
|
|
2314
|
+
// has to include the highest tile in flight, so a tall choreography zooms
|
|
2315
|
+
// the whole board out (operator: "it zooms out too much").
|
|
2316
|
+
laneGap: TILE_H * 1.35,
|
|
2317
|
+
// every mover lifts at least this far (2026-09-11: "the blocks can rise
|
|
2318
|
+
// higher ... it will help sell the depth and shadows"): a block with nothing
|
|
2319
|
+
// under its path used to skim a lane-gap above the board. Higher is always
|
|
2320
|
+
// safe -- lanes still stack as disjoint intervals above it.
|
|
2321
|
+
// 8 -> 24 (2026-09-11: "the blocks can rise even higher than they doing for a
|
|
2322
|
+
// reshuffle. Really make use of verticality within the viewport"): the lift
|
|
2323
|
+
// itself is higher, rather than height being stretched near the floor, which
|
|
2324
|
+
// would have inflated every bounce as well
|
|
2325
|
+
liftMin: 24,
|
|
2326
|
+
lockMs: 260,
|
|
2327
|
+
// Landings were all on the same frame, which read as machinery rather than
|
|
2328
|
+
// as objects (operator: "Everything is too uniform on landing"). Each block
|
|
2329
|
+
// now starts its drop somewhere inside this window and bounces to its own
|
|
2330
|
+
// height. Staggering only the DROP is safe: every block descends into its
|
|
2331
|
+
// OWN slot, and slots never overlap, so the no-collision proof is untouched.
|
|
2332
|
+
entryMs: 1300, // (2.4 s until gravity went to 12 units in 0.6 s: an arrival's fall from off screen must keep pace)
|
|
2333
|
+
// an arrival's fall from off screen starts this long before its drop slot (2026-09-11)
|
|
2334
|
+
dropStagger: 3500, // 1400 -> 4500 -> 6000 (2026-09-11: "much more randomness", then "at least 40 seconds")
|
|
2335
|
+
// Lift-offs stagger too, so the board does not heave all at once (operator:
|
|
2336
|
+
// "don't have to all move at once"). Bounded INSIDE the rise phase: every
|
|
2337
|
+
// block must be airborne before any block descends, or one could drop into
|
|
2338
|
+
// a slot its previous occupant has not vacated. That is exactly why rise and
|
|
2339
|
+
// travel keep shared boundaries while the drop does not.
|
|
2340
|
+
riseStagger: 1300,
|
|
2341
|
+
bounceSpread: 0.85,
|
|
2342
|
+
heightCap: 0.22, // the flight stack may reach this fraction of the board
|
|
2343
|
+
// HOW FAR A BLOCK IN FLIGHT MAY SWELL, and it is the real lever for "moving
|
|
2344
|
+
// too high". In this projection height does NOT move a block up the screen
|
|
2345
|
+
// -- it only scales it outward from the vanishing point -- so a 42% swell
|
|
2346
|
+
// threw edge blocks well past the board while the raw lane number looked
|
|
2347
|
+
// irrelevant. 15% is a clear lift that stays near the grid.
|
|
2348
|
+
maxGrowth: 0.15, // how much the nearest lane may swell; the fit reserves it
|
|
2349
|
+
};
|
|
2350
|
+
|
|
2351
|
+
export function planTransition(prev, next, opts = {}) {
|
|
2352
|
+
const cfg = { ...TRANSITION, ...opts };
|
|
2353
|
+
const now = opts.now ?? 0;
|
|
2354
|
+
const byId = new Map((prev || []).map((t) => [t.txid, t]));
|
|
2355
|
+
const nextIds = new Set((next || []).map((t) => t.txid));
|
|
2356
|
+
|
|
2357
|
+
const movers = [];
|
|
2358
|
+
const tweens = [];
|
|
2359
|
+
for (const t of next || []) {
|
|
2360
|
+
const from = byId.get(t.txid);
|
|
2361
|
+
if (!from) tweens.push({ kind: 'enter', txid: t.txid, to: t, from: t });
|
|
2362
|
+
else if (from.x !== t.x || from.y !== t.y || from.s !== t.s) movers.push({ from, to: t });
|
|
2363
|
+
// `from` is the tile as it WAS: a hold keeps its slot but not always its colour (below)
|
|
2364
|
+
else tweens.push({ kind: 'hold', txid: t.txid, from, to: t });
|
|
2365
|
+
}
|
|
2366
|
+
for (const t of prev || []) if (!nextIds.has(t.txid)) tweens.push({ kind: 'exit', txid: t.txid, from: t, to: t });
|
|
2367
|
+
for (const tw of tweens) if (tw.kind === 'enter') {
|
|
2368
|
+
tw.jitter = jitterOf(tw.txid);
|
|
2369
|
+
tw.jN = jitterOf(tw.txid, 'n');
|
|
2370
|
+
tw.jH = jitterOf(tw.txid, 'h');
|
|
2371
|
+
tw.jA = jitterOf(tw.txid, 'a');
|
|
2372
|
+
tw.jR = jitterOf(tw.txid, 'r');
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
// FLIGHT LANES. One lane per mover would be correct but absurd now that
|
|
2376
|
+
// tiles are cubes: a cube of side 10 is 10 units tall, so 100 movers would
|
|
2377
|
+
// stack a thousand units into the air and the fit would shrink the board to
|
|
2378
|
+
// a smudge. Two movers can share an altitude whenever their SWEPT
|
|
2379
|
+
// footprints are disjoint -- neither can ever be where the other is -- so
|
|
2380
|
+
// lanes are assigned greedily by overlap. A mostly-local rearrangement then
|
|
2381
|
+
// needs a handful of lanes instead of hundreds, and the guarantee is
|
|
2382
|
+
// unchanged: same lane implies disjoint sweeps, different lane implies
|
|
2383
|
+
// vertical separation.
|
|
2384
|
+
// BIGGEST FIRST, so the big blocks take the LOWEST lanes. Lanes are handed
|
|
2385
|
+
// out in order, and a large block on a high lane is exactly what leaves the
|
|
2386
|
+
// top of the panel (operator: "The big green squares are moving too high off
|
|
2387
|
+
// the display"). Distance breaks ties, so among equals short hops stay low.
|
|
2388
|
+
movers.sort((a, b) => {
|
|
2389
|
+
const sa = Math.max(a.from.s, a.to.s), sb = Math.max(b.from.s, b.to.s);
|
|
2390
|
+
if (sa !== sb) return sb - sa;
|
|
2391
|
+
const da = Math.max(Math.abs(a.to.x - a.from.x), Math.abs(a.to.y - a.from.y));
|
|
2392
|
+
const db = Math.max(Math.abs(b.to.x - b.from.x), Math.abs(b.to.y - b.from.y));
|
|
2393
|
+
return da - db;
|
|
2394
|
+
});
|
|
2395
|
+
// The swept region must contain the whole ROUTE. Now that travel is
|
|
2396
|
+
// L-shaped the block also passes through a corner -- (to.x, from.y) or
|
|
2397
|
+
// (from.x, to.y) -- and it may be at its LARGER size when it does. Taking
|
|
2398
|
+
// the endpoints only left that corner outside the box, two blocks were
|
|
2399
|
+
// given the same lane on the strength of it, and the pairwise collision
|
|
2400
|
+
// test failed on the next run.
|
|
2401
|
+
// ONE BOX PER LEG, AND LEGS ONLY MEET THEIR OWN HALF (operator, 2026-09-14: "a lot of popping at
|
|
2402
|
+
// the edges"). sampleTween runs every first leg in the first half of travel and every second leg
|
|
2403
|
+
// in the second, each ending exactly at the half, so a first leg can only ever be where another
|
|
2404
|
+
// mover's FIRST leg is, never its second. One box round the whole L claimed the whole rectangle
|
|
2405
|
+
// between the two corners -- replayed on the live pool, 201 movers stacked 110 units up, and the
|
|
2406
|
+
// squeeze into the panel's room drew neighbouring lanes on top of each other, which is where the
|
|
2407
|
+
// rim pops were. Still conservative: each leg's box is the largest the cube can be on it.
|
|
2408
|
+
const box = (x0, y0, x1, y1, s) => ({ x0: Math.min(x0, x1), y0: Math.min(y0, y1), x1: Math.max(x0, x1) + s, y1: Math.max(y0, y1) + s });
|
|
2409
|
+
const swept = (m) => {
|
|
2410
|
+
const s = Math.max(m.from.s, m.to.s);
|
|
2411
|
+
const xFirst = jitterOf(m.to.txid) < 0.5; // the same draw sampleTween reads
|
|
2412
|
+
const cx = xFirst ? m.to.x : m.from.x, cy = xFirst ? m.from.y : m.to.y; // the corner
|
|
2413
|
+
const legs = [box(m.from.x, m.from.y, cx, cy, s), box(cx, cy, m.to.x, m.to.y, s)];
|
|
2414
|
+
return { legs, ...box(m.from.x, m.from.y, m.to.x, m.to.y, s) };
|
|
2415
|
+
};
|
|
2416
|
+
const rectHit = (a, b) => a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
|
|
2417
|
+
const hits = (a, b) => (a.legs && b.legs
|
|
2418
|
+
? rectHit(a.legs[0], b.legs[0]) || rectHit(a.legs[1], b.legs[1])
|
|
2419
|
+
: (a.legs ?? [a]).some((p) => (b.legs ?? [b]).some((q) => rectHit(p, q))));
|
|
2420
|
+
// CUBES STACK BY THEIR OWN HEIGHT. A mover at base altitude L occupies
|
|
2421
|
+
// [L, L + s], not a slab, so an altitude is an INTERVAL: movers whose swept
|
|
2422
|
+
// footprints overlap get disjoint intervals, first fit, biggest first; and
|
|
2423
|
+
// every mover travels clear of the tallest RESTING cube under its sweep
|
|
2424
|
+
// (the holds are the only cubes standing during travel). Same proof as
|
|
2425
|
+
// before, with the real heights in it -- and the pairwise collision test
|
|
2426
|
+
// uses cubeHeight, so it checks exactly this.
|
|
2427
|
+
const resting = tweens.filter((t) => t.kind === 'hold').map((t) => t.to);
|
|
2428
|
+
const flying = [];
|
|
2429
|
+
const CLEAR = 0.25 * cfg.laneGap;
|
|
2430
|
+
for (const m of movers) {
|
|
2431
|
+
const sw = swept(m);
|
|
2432
|
+
const h = Math.max(m.from.s, m.to.s); // the tallest it can be in flight
|
|
2433
|
+
// each block its own lift, liftMin x 0.55..1.45: one shared minimum made
|
|
2434
|
+
// every drop 8-12 units, so every landing took nearly the same time
|
|
2435
|
+
let base = Math.max(cfg.laneGap, (cfg.liftMin ?? 0) * (0.55 + 0.9 * jitterOf(m.to.txid, 'lift')));
|
|
2436
|
+
for (const r of resting) {
|
|
2437
|
+
if (hits(sw, { x0: r.x, y0: r.y, x1: r.x + r.s, y1: r.y + r.s })) base = Math.max(base, cubeHeight(r) + CLEAR);
|
|
2438
|
+
}
|
|
2439
|
+
const busy = flying.filter((f2) => hits(sw, f2.sw)).sort((p, q) => p.lo - q.lo);
|
|
2440
|
+
for (const f2 of busy) {
|
|
2441
|
+
if (base + h + CLEAR <= f2.lo) break; // fits in the gap below this one
|
|
2442
|
+
if (base < f2.hi + CLEAR) base = f2.hi + CLEAR; // otherwise go above it
|
|
2443
|
+
}
|
|
2444
|
+
flying.push({ sw, lo: base, hi: base + h });
|
|
2445
|
+
tweens.push({
|
|
2446
|
+
kind: 'move', txid: m.to.txid, from: m.from, to: m.to,
|
|
2447
|
+
lane: base,
|
|
2448
|
+
laneIndex: flying.length - 1,
|
|
2449
|
+
jitter: jitterOf(m.to.txid),
|
|
2450
|
+
// INDEPENDENT draws: one hash per property, each stable for its block.
|
|
2451
|
+
jN: jitterOf(m.to.txid, 'n'),
|
|
2452
|
+
jH: jitterOf(m.to.txid, 'h'),
|
|
2453
|
+
jR: jitterOf(m.to.txid, 'r'),
|
|
2454
|
+
cells: Math.max(Math.abs(m.to.x - m.from.x), Math.abs(m.to.y - m.from.y)),
|
|
2455
|
+
});
|
|
2456
|
+
}
|
|
2457
|
+
const gridN = Math.max(8, opts.gridN || 0);
|
|
2458
|
+
const ceiling = gridN * cfg.heightCap;
|
|
2459
|
+
cfg.maxLane = flying.length ? Math.max(...flying.map((f2) => f2.hi)) : cfg.laneGap;
|
|
2460
|
+
// THE CAMERA IS NOT ALLOWED TO CHANGE BETWEEN ROUNDS.
|
|
2461
|
+
//
|
|
2462
|
+
// This used to be derived from cfg.maxLane -- how tall THIS round's lane
|
|
2463
|
+
// stack happened to be. A quiet round with no movers has one lane and got
|
|
2464
|
+
// 0.075; a busy round with seven lanes got 0.028. Same board, two cameras
|
|
2465
|
+
// nearly 3x apart in depth scaling, swapped at the moment a plan was made.
|
|
2466
|
+
// The visible symptom was the first paint (operator: "the initial view on
|
|
2467
|
+
// load is buggy, it has dark triangles, but things return to normal on the
|
|
2468
|
+
// first animation"): the first plan is a no-op self-transition, so it took
|
|
2469
|
+
// the shallow-stack camera, drew every cube's dark front lip almost three
|
|
2470
|
+
// times too tall, and those lips read as dark wedges -- worst either side
|
|
2471
|
+
// of the vanishing line, where the lip flips from pointing up to pointing
|
|
2472
|
+
// down and passes through a degenerate triangle.
|
|
2473
|
+
//
|
|
2474
|
+
// Derived from the GRID and the height cap instead: both are constants, so
|
|
2475
|
+
// every plan on a given board produces the same camera, and the first
|
|
2476
|
+
// paint already looks like the steady state. The bound is still honest --
|
|
2477
|
+
// lanes are compressed to fit under `ceiling` just above, so the tallest
|
|
2478
|
+
// lane any round can reach is the value used here.
|
|
2479
|
+
// The bound is `ceiling`, the altitude lane compression aims for. While the
|
|
2480
|
+
// stack fits under it -- which is every round whose lane count leaves the
|
|
2481
|
+
// gap above its floor -- growth stays inside the reserved margin exactly as
|
|
2482
|
+
// before. A round needing more lanes than the floor gap allows under the
|
|
2483
|
+
// ceiling stacks past it and those blocks fly beyond the panel edge, which
|
|
2484
|
+
// is the licence blocks in flight already have and is far cheaper than a
|
|
2485
|
+
// camera that changes under the operator between rounds.
|
|
2486
|
+
cfg.risePerUnit = Math.min(0.075, cfg.maxGrowth / Math.max(1e-6, ceiling * 0.55));
|
|
2487
|
+
cfg.camCeiling = ceiling;
|
|
2488
|
+
|
|
2489
|
+
|
|
2490
|
+
// Parking altitudes for arrivals and departures must clear every travel
|
|
2491
|
+
// lane AND each other, or an invisible waiting tile would occupy the same
|
|
2492
|
+
// space as a departing one. Derived from the lane stack, not hard-coded.
|
|
2493
|
+
const maxLane = cfg.maxLane;
|
|
2494
|
+
cfg.exitTo = maxLane + cfg.laneGap;
|
|
2495
|
+
// ON SCREEN (operator, 2026-09-11: "keep as much movement on screen as
|
|
2496
|
+
// possible"). This used to be far enough that the projected block started
|
|
2497
|
+
// OUTSIDE the panel (2026-09-10: "drops in from off screen"); the newer ask
|
|
2498
|
+
// supersedes it. An arrival now lets go just above the flight stack, where
|
|
2499
|
+
// the gather pull holds it over the board, fades up as it falls, and its
|
|
2500
|
+
// shadow sharpens on the slot it is about to land in.
|
|
2501
|
+
cfg.enterFrom = maxLane + 2 * cfg.laneGap;
|
|
2502
|
+
|
|
2503
|
+
const t0 = now;
|
|
2504
|
+
const phases = {
|
|
2505
|
+
t0,
|
|
2506
|
+
rise: t0 + cfg.rise,
|
|
2507
|
+
travel: t0 + cfg.rise + cfg.travel,
|
|
2508
|
+
end: t0 + cfg.rise + cfg.travel + cfg.drop,
|
|
2509
|
+
};
|
|
2510
|
+
// a recolour is motion too: a board whose only change is colour still needs frames to blend in
|
|
2511
|
+
const anyMotion = tweens.some((t) => t.kind !== 'hold' || t.from.color !== t.to.color);
|
|
2512
|
+
const plan = { tweens, phases, cfg, settleAt: now, duration: cfg.rise + cfg.travel + cfg.drop };
|
|
2513
|
+
if (anyMotion) {
|
|
2514
|
+
// settled when the LAST landing is done: landings are timed per block now
|
|
2515
|
+
let last = phases.end;
|
|
2516
|
+
for (const tw of tweens) if (tw.kind === 'move' || tw.kind === 'enter') last = Math.max(last, landingOf(tw, plan).t1);
|
|
2517
|
+
plan.settleAt = last + cfg.lockMs;
|
|
2518
|
+
}
|
|
2519
|
+
return plan;
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
// A tween at a moment. `z` is the slab's BASE above the plane; every resting
|
|
2523
|
+
// tile has z = 0, which is what keeps the plane a plane.
|
|
2524
|
+
export function sampleTween(tw, now, plan) {
|
|
2525
|
+
const p = plan.phases;
|
|
2526
|
+
// this block's own landing: when it lets go, and how hard it bounces
|
|
2527
|
+
const j = tw.jitter ?? 0;
|
|
2528
|
+
const L = (tw.kind === 'move' || tw.kind === 'enter') ? landingOf(tw, plan) : null;
|
|
2529
|
+
const dropT0 = L?.t0 ?? p.travel;
|
|
2530
|
+
const dropT1 = L?.t1 ?? p.end;
|
|
2531
|
+
const bounce = L?.bounce ?? 0;
|
|
2532
|
+
const nBounce = L?.nBounce ?? 1;
|
|
2533
|
+
const rest = L?.rest ?? 0.55;
|
|
2534
|
+
const lockOf = () => {
|
|
2535
|
+
const dt = now - dropT1;
|
|
2536
|
+
return dt >= 0 && dt < plan.cfg.lockMs ? 1 - dt / plan.cfg.lockMs : 0;
|
|
2537
|
+
};
|
|
2538
|
+
// COLOURS BLEND, THEY DO NOT SWITCH (operator, 2026-09-14: "when blocks are ready to leave the
|
|
2539
|
+
// display, they change tints before doing so ... It looks bad with colors popping in"). The
|
|
2540
|
+
// pool's tail is drawn as equal pieces named by their SLOT (aggregate@x,y) and coloured by the
|
|
2541
|
+
// fee bands, so a refresh that shifts the bands recolours a piece that has not moved. The hold
|
|
2542
|
+
// was sampled as `to` from its first frame -- measured on the live pool, 151 pieces changed tint
|
|
2543
|
+
// on frame one, while the departures around them were only starting to lift. A hold now fades
|
|
2544
|
+
// from its old colour to its new one across the rise; a mover across its travel (it used to
|
|
2545
|
+
// switch the instant it finished rising). Exits keep their colour and arrivals arrive in theirs.
|
|
2546
|
+
if (tw.kind === 'hold') {
|
|
2547
|
+
const k = tw.from.color === tw.to.color ? 1 : easeInOutCubic(Math.min(1, Math.max(0, (now - p.t0) / Math.max(1, p.rise - p.t0))));
|
|
2548
|
+
return { ...tw.to, color: mixColor(tw.from.color, tw.to.color, k), z: 0, alpha: 1, lock: 0 };
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
if (tw.kind === 'enter') {
|
|
2552
|
+
// A NEW block drops in from OFF SCREEN and fades up as it comes (operator,
|
|
2553
|
+
// 2026-09-10). Not drawn at all before it starts: it has not arrived yet.
|
|
2554
|
+
// enterFrom is chosen so the projected block really is outside the panel
|
|
2555
|
+
// when it lets go, not merely high.
|
|
2556
|
+
// ...and since 2026-09-11 ("Have the spawning happen off-screen for new
|
|
2557
|
+
// blocks being dropped in") it FALLS IN from outside the canvas. One fall:
|
|
2558
|
+
// the off-screen height (entry, drawn by visualBase) comes off on a single
|
|
2559
|
+
// gravity parabola that starts entryMs before the drop slot and reaches
|
|
2560
|
+
// zero exactly at impact, so the block accelerates all the way down, hits
|
|
2561
|
+
// and goes straight into its weighted bounce. The first cut eased to a
|
|
2562
|
+
// stop at the top of the ordinary fall and then dropped from rest
|
|
2563
|
+
// (operator: "should hit the ground and start bouncing. Not pause and
|
|
2564
|
+
// then drop again").
|
|
2565
|
+
// Its real altitude is the top of the fall throughout -- above every
|
|
2566
|
+
// travel lane -- so the collision proof is untouched; only the drawing
|
|
2567
|
+
// adds the off-screen height (visualBase).
|
|
2568
|
+
const entryMs = plan.cfg.entryMs ?? 0;
|
|
2569
|
+
if (now < dropT0 - entryMs) return null;
|
|
2570
|
+
const impact = dropT0 + fallShare(bounce, nBounce, rest) * (dropT1 - dropT0);
|
|
2571
|
+
const since = (now - (dropT0 - entryMs)) / Math.max(1, impact - (dropT0 - entryMs));
|
|
2572
|
+
const entry = since < 1 ? 1 - since * since : 0;
|
|
2573
|
+
// its REAL altitude stays at the top of the fall until the drop slot --
|
|
2574
|
+
// above every travel lane, so the collision proof is untouched
|
|
2575
|
+
if (now < dropT0) return { ...tw.to, z: L.from, entry, alpha: 1, lock: 0 };
|
|
2576
|
+
const t = Math.max(0, Math.min(1, (now - dropT0) / Math.max(1, dropT1 - dropT0)));
|
|
2577
|
+
// SOLID (2026-09-11): it used to fade up, and a see-through cube over
|
|
2578
|
+
// solid ones read as a ghost sliding through them ("new blocks landing
|
|
2579
|
+
// and bouncing is totally broken visually"). Briefly it grew in from a
|
|
2580
|
+
// point instead; now it arrives whole, from off screen.
|
|
2581
|
+
const dv = bounceDrop(t, bounce, nBounce, rest);
|
|
2582
|
+
return { ...tw.to, z: L.from * dv, landV: dv, fallFrom: L.from, entry, alpha: 1, lock: lockOf() };
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
if (tw.kind === 'exit') {
|
|
2586
|
+
// A block that has left the pool rises away and fades as it goes. Its
|
|
2587
|
+
// slot is empty long before anyone lands, which is what the shared rise
|
|
2588
|
+
// phase buys. (Briefly this stayed opaque on the way up; the operator
|
|
2589
|
+
// asked for that and then recognised why the fade is there -- a block
|
|
2590
|
+
// that simply vanished at the top read worse than one that departs.)
|
|
2591
|
+
if (now >= p.rise) return null;
|
|
2592
|
+
// NOT IN UNISON (operator, 2026-09-11: "All the blocks are flying up and
|
|
2593
|
+
// away at a uniform rate. It looks strange. Mix it up a bit with speed").
|
|
2594
|
+
// Each departure leaves on its own beat -- a start somewhere in the first
|
|
2595
|
+
// 55% of the rise, the big ones tending earlier and so climbing slower
|
|
2596
|
+
// (more mass) -- and with its own acceleration: an exponent of 1.3-2.6 on
|
|
2597
|
+
// the off-screen share, so some drift and then bolt while others pull away
|
|
2598
|
+
// steadily. Every one is still gone by the end of the rise, before any
|
|
2599
|
+
// mover travels, so the collision proof is untouched.
|
|
2600
|
+
const R = p.rise - p.t0;
|
|
2601
|
+
const heavy = Math.min(1, (tw.from.s || 1) / 12);
|
|
2602
|
+
const go = p.t0 + 0.55 * R * jitterOf(tw.txid, 'xgo') * (1 - 0.4 * heavy);
|
|
2603
|
+
if (now <= go) return { ...tw.from, z: 0, entry: 0, alpha: 1, lock: 0 };
|
|
2604
|
+
const t = Math.min(1, (now - go) / Math.max(1, p.rise - go));
|
|
2605
|
+
// FROM REST (operator, 2026-09-14: "still seeing a lot of popping at the edges"). This was
|
|
2606
|
+
// easeOutCubic, which starts at its FULL speed: replayed on the live pool, where exits park
|
|
2607
|
+
// 100+ units up, a departing cube went from standing to ~6px a frame on its first frame -- 36
|
|
2608
|
+
// such jumps in one refresh, 30 of them on the rim where the flight tips sideways. Ease in and
|
|
2609
|
+
// out instead: it leaves the ground at rest and still arrives by the end of the rise.
|
|
2610
|
+
const e = easeInOutCubic(t);
|
|
2611
|
+
const acc = 1.3 + 1.3 * jitterOf(tw.txid, 'xacc');
|
|
2612
|
+
// UP AND OFF THE SCREEN (operator, 2026-09-11: "I want to see old blocks
|
|
2613
|
+
// flying up and off the screen instead of disappearing"). Whole and solid
|
|
2614
|
+
// -- a fading block was see-through over everything it passed, and the
|
|
2615
|
+
// shrink that replaced the fade read as vanishing. The off-screen height
|
|
2616
|
+
// arrivals fall from (entry, drawn by visualBase) is taken on as t^2, so
|
|
2617
|
+
// it lifts off from rest and accelerates away, wholly outside the canvas
|
|
2618
|
+
// by the end of the rise. Its REAL altitude still eases up to exitTo,
|
|
2619
|
+
// clear of every lane, so the collision proof is untouched.
|
|
2620
|
+
return { ...tw.from, z: plan.cfg.exitTo * e, entry: Math.pow(t, acc), alpha: 1, lock: 0 };
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
// move: lift out, shift in whole cells at the lane, then drop under gravity
|
|
2624
|
+
const liftAt = p.t0 + (1 - j) * (plan.cfg.riseStagger || 0);
|
|
2625
|
+
if (now <= liftAt) return { ...tw.from, z: 0, alpha: 1, lock: 0 };
|
|
2626
|
+
if (now < p.rise) {
|
|
2627
|
+
// from rest, for the same reason as a departure above: a lane is 100 units up on a busy board
|
|
2628
|
+
const e = easeInOutCubic((now - liftAt) / Math.max(1, p.rise - liftAt));
|
|
2629
|
+
return { ...tw.from, z: tw.lane * e, alpha: 1, lock: 0 };
|
|
2630
|
+
}
|
|
2631
|
+
if (now < p.travel) {
|
|
2632
|
+
// TWO MOVEMENTS, not one diagonal glide. A tetromino shifts along one axis
|
|
2633
|
+
// and then the other; a straight diagonal is the one motion it never
|
|
2634
|
+
// makes, and a board full of them reads as linear drift (operator: "break
|
|
2635
|
+
// things up so they aren't so linear ... Can translate in two movements").
|
|
2636
|
+
// Which axis leads is per-block, from its own hash, so the board does not
|
|
2637
|
+
// all pivot together, and each leg eases separately so there is a real
|
|
2638
|
+
// beat where the block changes direction.
|
|
2639
|
+
const raw = Math.min(1, (now - p.rise) / Math.max(1, p.travel - p.rise));
|
|
2640
|
+
const xFirst = (tw.jitter ?? 0) < 0.5;
|
|
2641
|
+
const leg1 = easeInOutCubic(Math.min(1, raw / 0.5));
|
|
2642
|
+
const leg2 = easeInOutCubic(Math.max(0, (raw - 0.5) / 0.5));
|
|
2643
|
+
const ex = xFirst ? leg1 : leg2;
|
|
2644
|
+
const ey = xFirst ? leg2 : leg1;
|
|
2645
|
+
return {
|
|
2646
|
+
...tw.to,
|
|
2647
|
+
color: mixColor(tw.from.color, tw.to.color, easeInOutCubic(raw)),
|
|
2648
|
+
x: tw.from.x + (tw.to.x - tw.from.x) * ex,
|
|
2649
|
+
y: tw.from.y + (tw.to.y - tw.from.y) * ey,
|
|
2650
|
+
s: tw.from.s + (tw.to.s - tw.from.s) * Math.max(ex, ey),
|
|
2651
|
+
z: tw.lane, // constant: this is the guarantee
|
|
2652
|
+
alpha: 1, lock: 0,
|
|
2653
|
+
};
|
|
2654
|
+
}
|
|
2655
|
+
if (now < dropT1) {
|
|
2656
|
+
const t = (now - dropT0) / Math.max(1, dropT1 - dropT0);
|
|
2657
|
+
// settles with its own weighted bounce, on its own beat
|
|
2658
|
+
const dv = bounceDrop(t, bounce, nBounce, rest);
|
|
2659
|
+
return { ...tw.to, z: tw.lane * dv, landV: dv, fallFrom: tw.lane, alpha: 1, lock: 0 };
|
|
2660
|
+
}
|
|
2661
|
+
return { ...tw.to, z: 0, alpha: 1, lock: lockOf() }; // landed flat, flashing
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2664
|
+
// The share of a bounceDrop() spent in the first fall, before the first
|
|
2665
|
+
// impact -- the same segment weights bounceDrop uses.
|
|
2666
|
+
export function fallShare(bounce = 0.12, bounces = 3, rest = 0.55) {
|
|
2667
|
+
const n = Math.max(1, Math.min(12, Math.round(bounces)));
|
|
2668
|
+
let h = Math.max(bounce, 1e-6), total = 1;
|
|
2669
|
+
for (let k = 0; k < n; k++) { total += 2 * Math.sqrt(h); h *= rest; }
|
|
2670
|
+
return 1 / total;
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
export function frameAt(plan, now, o = {}) {
|
|
2674
|
+
const live = [];
|
|
2675
|
+
for (const tw of plan.tweens) {
|
|
2676
|
+
const s = sampleTween(tw, now, plan);
|
|
2677
|
+
if (s) live.push(s);
|
|
2678
|
+
}
|
|
2679
|
+
const scene = buildScene(live, o);
|
|
2680
|
+
return { ...scene, settled: now >= plan.settleAt, tiles: live };
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
// LIGHT CYCLES (operator, 2026-09-11: "I was thinking like a TRON Light cycle or something
|
|
2684
|
+
// navigating the block grid from one end of the board to another, and then the line begins
|
|
2685
|
+
// to fade out quickly? ... moving at 90 degree turns along the blocks from one side to
|
|
2686
|
+
// another"). A route is a walk along the grid LINES -- the cube edges -- from one edge of the
|
|
2687
|
+
// board to the opposite one, in unit steps: a run of 2-7 along the way across, then a jink of
|
|
2688
|
+
// 1-5 to one side, never doubling back and never off the board. Deterministic by seed.
|
|
2689
|
+
function lcg(seed) {
|
|
2690
|
+
let s = (seed >>> 0) || 1;
|
|
2691
|
+
return () => ((s = (Math.imul(s, 1103515245) + 12345) >>> 0) / 4294967296);
|
|
2692
|
+
}
|
|
2693
|
+
// `lanes` [lo, hi]: the side-to-side room the route may use, in grid lines; the whole board by
|
|
2694
|
+
// default. The price board hands the candles' own rows, so a rider's wall runs along the candle
|
|
2695
|
+
// tops rather than out to the empty front (operator, 2026-09-14: "Always interacting with either
|
|
2696
|
+
// the grid price line or candles").
|
|
2697
|
+
export function cyclePath(seed, W, H, from = 'left', lanes = null) {
|
|
2698
|
+
const rnd = lcg(seed);
|
|
2699
|
+
const horiz = from === 'left' || from === 'right';
|
|
2700
|
+
const sign = from === 'left' || from === 'bottom' ? 1 : -1;
|
|
2701
|
+
const lenMain = horiz ? W : H, lenSide = horiz ? H : W;
|
|
2702
|
+
const qLo = lanes ? Math.max(1, Math.floor(lanes[0])) : 1, qHi = lanes ? Math.min(lenSide - 1, Math.ceil(lanes[1])) : lenSide - 1;
|
|
2703
|
+
let m = sign > 0 ? 0 : lenMain;
|
|
2704
|
+
let q = lanes ? qLo + Math.floor(rnd() * Math.max(1, qHi - qLo + 1)) : 2 + Math.floor(rnd() * Math.max(1, lenSide - 4));
|
|
2705
|
+
q = Math.max(qLo, Math.min(qHi, q));
|
|
2706
|
+
const pts = [];
|
|
2707
|
+
const push = () => pts.push(horiz ? { x: m, y: q } : { x: q, y: m });
|
|
2708
|
+
push();
|
|
2709
|
+
const done = () => (sign > 0 ? m >= lenMain : m <= 0);
|
|
2710
|
+
while (!done()) {
|
|
2711
|
+
const run = Math.min(2 + Math.floor(rnd() * 6), sign > 0 ? lenMain - m : m);
|
|
2712
|
+
for (let i = 0; i < run; i++) { m += sign; push(); }
|
|
2713
|
+
if (done()) break;
|
|
2714
|
+
let dir = rnd() < 0.5 ? -1 : 1;
|
|
2715
|
+
const jink = 1 + Math.floor(rnd() * 5);
|
|
2716
|
+
if (q + dir * jink < qLo || q + dir * jink > qHi) dir = -dir;
|
|
2717
|
+
const steps = Math.max(0, Math.min(jink, dir > 0 ? qHi - q : q - qLo));
|
|
2718
|
+
for (let i = 0; i < steps; i++) { q += dir; push(); }
|
|
2719
|
+
}
|
|
2720
|
+
return pts;
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
// LIGHT CYCLES CRASH (operator, 2026-09-11: "if one light cycle crashes into the tail of another
|
|
2724
|
+
// light cycle, it should de-res (die with visual effects) and the winner keeps going"). Worked out
|
|
2725
|
+
// once, when the race starts, from both routes and their timing: the race is stepped in small
|
|
2726
|
+
// slices of the effect; a head that arrives on a grid node the other cycle's live wall covers dies
|
|
2727
|
+
// there, and two heads on one node at once both die. A dead cycle's wall de-reses and is no
|
|
2728
|
+
// longer solid, so the other rides on through it. Pure: paths [{ pts, lag }] in, per path
|
|
2729
|
+
// { u, d, at } or null out.
|
|
2730
|
+
// `trail`: how much of a cycle's wall is solid, counted back from its head. It is the whole wall
|
|
2731
|
+
// now (operator, 2026-09-12: the tails last the entire board), so a rider dies on any part of a
|
|
2732
|
+
// route another cycle has already laid -- which is what the walls look like.
|
|
2733
|
+
export function cycleCrashes(paths, { trail = Infinity, step = 0.002, runShare = 0.8 } = {}) {
|
|
2734
|
+
const n = paths.length;
|
|
2735
|
+
const out = paths.map(() => null);
|
|
2736
|
+
const len = paths.map((p) => p.pts.length - 1);
|
|
2737
|
+
const headAt = (i, u) => { const v = Math.max(0, (u - paths[i].lag) / (1 - paths[i].lag)); return Math.min(1, v / runShare) * len[i]; };
|
|
2738
|
+
const key = (p) => `${p.x},${p.y}`;
|
|
2739
|
+
for (let s = 0; s * step <= 1 + 1e-9; s++) {
|
|
2740
|
+
const u = s * step;
|
|
2741
|
+
const d = paths.map((_, i) => (out[i] ? null : headAt(i, u)));
|
|
2742
|
+
const hits = [];
|
|
2743
|
+
for (let i = 0; i < n; i++) {
|
|
2744
|
+
if (d[i] == null || d[i] <= 0) continue;
|
|
2745
|
+
const hk = key(paths[i].pts[Math.floor(d[i] + 1e-9)]);
|
|
2746
|
+
let hit = false;
|
|
2747
|
+
for (let j = 0; j < n && !hit; j++) {
|
|
2748
|
+
if (j === i || d[j] == null || d[j] <= 0) continue;
|
|
2749
|
+
for (let k = Math.ceil(Math.max(0, d[j] - trail)); k <= Math.floor(d[j] + 1e-9); k++) if (key(paths[j].pts[k]) === hk) { hit = true; break; }
|
|
2750
|
+
}
|
|
2751
|
+
if (hit) hits.push(i);
|
|
2752
|
+
}
|
|
2753
|
+
for (const i of hits) { const k = Math.floor(d[i] + 1e-9); out[i] = { u, d: k, at: { ...paths[i].pts[k] } }; }
|
|
2754
|
+
if (out.every(Boolean)) break;
|
|
2755
|
+
}
|
|
2756
|
+
return out;
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
// THE LIGHTNING BALL's route (operator, 2026-09-11, of the data packets: "It comes off more as
|
|
2760
|
+
// wandering lights. I was hoping for something that comes from off-screen along a grid line, and
|
|
2761
|
+
// then starts tracing through the grid to the opposite side"). A light-cycle route from one edge
|
|
2762
|
+
// to the opposite one, run in straight along its first grid line from E units OFF the board and
|
|
2763
|
+
// out along its last one E units past the far edge, in unit steps throughout.
|
|
2764
|
+
export function ballPath(seed, W, H, from = 'left', E = 16) {
|
|
2765
|
+
const core = cyclePath(seed, W, H, from);
|
|
2766
|
+
const a = core[0], b = core[core.length - 1];
|
|
2767
|
+
const dx = from === 'left' ? -1 : from === 'right' ? 1 : 0;
|
|
2768
|
+
const dy = from === 'bottom' ? -1 : from === 'top' ? 1 : 0;
|
|
2769
|
+
const lead = [];
|
|
2770
|
+
for (let k = E; k >= 1; k--) lead.push({ x: a.x + dx * k, y: a.y + dy * k });
|
|
2771
|
+
const tail = [];
|
|
2772
|
+
for (let k = 1; k <= E; k++) tail.push({ x: b.x - dx * k, y: b.y - dy * k });
|
|
2773
|
+
return [...lead, ...core, ...tail];
|
|
2774
|
+
}
|
|
2775
|
+
|
|
2776
|
+
// DATA PACKETS: short walks along the grid lines from random points, turning now and then,
|
|
2777
|
+
// each starting at its own moment (s0, a share of the effect).
|
|
2778
|
+
export function packetPaths(seed, W, H, n = 8) {
|
|
2779
|
+
const rnd = lcg(seed ^ 0x5bd1e995);
|
|
2780
|
+
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
|
|
2781
|
+
const out = [];
|
|
2782
|
+
for (let k = 0; k < n; k++) {
|
|
2783
|
+
let x = 1 + Math.floor(rnd() * Math.max(1, W - 2)), y = 1 + Math.floor(rnd() * Math.max(1, H - 2));
|
|
2784
|
+
let d = dirs[Math.floor(rnd() * 4)];
|
|
2785
|
+
const pts = [{ x, y }];
|
|
2786
|
+
const len = 6 + Math.floor(rnd() * 9);
|
|
2787
|
+
const inside = (a, b) => a >= 0 && a <= W && b >= 0 && b <= H;
|
|
2788
|
+
for (let i = 0; i < len; i++) {
|
|
2789
|
+
if (i > 0 && rnd() < 0.3) d = rnd() < 0.5 ? [d[1], -d[0]] : [-d[1], d[0]];
|
|
2790
|
+
if (!inside(x + d[0], y + d[1])) { d = [-d[1], d[0]]; if (!inside(x + d[0], y + d[1])) break; }
|
|
2791
|
+
x += d[0]; y += d[1];
|
|
2792
|
+
pts.push({ x, y });
|
|
2793
|
+
}
|
|
2794
|
+
out.push({ pts, s0: rnd() * 0.55 });
|
|
2795
|
+
}
|
|
2796
|
+
return out;
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2799
|
+
// The top of the resting cube over each grid cell (0 for the floor): what a route rides on.
|
|
2800
|
+
export function cellTops(tiles, W, H) {
|
|
2801
|
+
const tops = new Float32Array(Math.max(0, W * H));
|
|
2802
|
+
for (const t of tiles ?? []) {
|
|
2803
|
+
const top = (t.z ?? 0) + (t.floor ?? 0) + cubeHeight(t);
|
|
2804
|
+
// EVERY CELL THE TILE TOUCHES. The loops began at t.x and t.y themselves, which on the block
|
|
2805
|
+
// board are whole numbers; the candles sit between grid lines (x = 2i + 0.3, y = 3, s = 1.4),
|
|
2806
|
+
// and a fractional index into a typed array stores nothing -- so the price board's tops were
|
|
2807
|
+
// all zero, no route rode a candle and ball lightning had nothing to strike (operator,
|
|
2808
|
+
// 2026-09-14: "the ball lightning was not arcing out to any of the candles").
|
|
2809
|
+
for (let cy = Math.max(0, Math.floor(t.y)); cy < Math.min(H, Math.ceil(t.y + t.s)); cy++) {
|
|
2810
|
+
for (let cx = Math.max(0, Math.floor(t.x)); cx < Math.min(W, Math.ceil(t.x + t.s)); cx++) if (top > tops[cy * W + cx]) tops[cy * W + cx] = top;
|
|
2811
|
+
}
|
|
2812
|
+
}
|
|
2813
|
+
return tops;
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
// Each unit stretch of a route rides the TALLER of the two cells either side of the grid line
|
|
2817
|
+
// it runs along; where two stretches meet at different heights the wall steps straight up or
|
|
2818
|
+
// down, so every change -- of direction or of level -- is a right angle.
|
|
2819
|
+
export function pathHeights(pts, tops, W, H) {
|
|
2820
|
+
const top = (cx, cy) => (cx >= 0 && cx < W && cy >= 0 && cy < H ? tops[cy * W + cx] : 0);
|
|
2821
|
+
const hs = [];
|
|
2822
|
+
for (let k = 0; k + 1 < pts.length; k++) {
|
|
2823
|
+
const a = pts[k], b = pts[k + 1];
|
|
2824
|
+
const h = a.y === b.y
|
|
2825
|
+
? Math.max(top(Math.min(a.x, b.x), a.y - 1), top(Math.min(a.x, b.x), a.y))
|
|
2826
|
+
: Math.max(top(a.x - 1, Math.min(a.y, b.y)), top(a.x, Math.min(a.y, b.y)));
|
|
2827
|
+
hs.push(h + 0.04);
|
|
2828
|
+
}
|
|
2829
|
+
return hs;
|
|
2830
|
+
}
|