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,1096 @@
|
|
|
1
|
+
// SETTINGS THE VIEWER KEEPS (operator, 2026-09-12: "We need to add a configuration panel, allow
|
|
2
|
+
// persistent settings for our app ... tweaking the visual effects of the Block space. eg: remove
|
|
3
|
+
// shadows, simple cubes, lower level of detail etc... anything to make it run faster ... settings
|
|
4
|
+
// that affect the star field").
|
|
5
|
+
//
|
|
6
|
+
// Every switch here maps to an option the renderer already honours, or to one added for it
|
|
7
|
+
// (`shadows`, `starDensity`, `starBrightness`). Nothing in this file draws: it holds the values,
|
|
8
|
+
// clamps them, persists them on the server, and hands the renderers their options. A setting that
|
|
9
|
+
// did not change what is drawn would be a lie told in a checkbox.
|
|
10
|
+
//
|
|
11
|
+
// Stored on the server in config/blockyard.json (GET/POST /api/settings), so a phone and a desktop
|
|
12
|
+
// pointed at the same monitor agree; the browser keeps a copy under `blockyard.settings` so a board
|
|
13
|
+
// still draws when the server cannot be reached. (They were per browser until 2026-09-13.)
|
|
14
|
+
//
|
|
15
|
+
// THE STORE IS VERSIONED (operator, 2026-09-12: "Scope an improved durable settings menu", and
|
|
16
|
+
// the choice to build the foundation first). Regrouping a key used to be unaffordable: normalise
|
|
17
|
+
// dropped what it did not recognise and setSetting ignored what it could not place, so any move
|
|
18
|
+
// silently discarded the operator's stored choice. A version and a migration chain make a move
|
|
19
|
+
// survivable, and `sky` below is the first one to take it.
|
|
20
|
+
|
|
21
|
+
export const SETTINGS_KEY = 'blockyard.settings';
|
|
22
|
+
export const SCHEMA_VERSION = 4;
|
|
23
|
+
|
|
24
|
+
export const DEFAULTS = Object.freeze({
|
|
25
|
+
// APPEARANCE (operator, 2026-09-16: "add an appearance section in preferences to change the colors
|
|
26
|
+
// of our layout. Our current scheme should be the default appearance ... as well as options for
|
|
27
|
+
// configure custom schemes by allowing users to pick colors and save to settings so it sticks").
|
|
28
|
+
// The faces themselves live in theme.js; this holds the choice and the nine custom colours, whose
|
|
29
|
+
// defaults are the shipped dark look so that Custom starts where the page starts.
|
|
30
|
+
appearance: Object.freeze({
|
|
31
|
+
mode: 'dark', // 'light' | 'dark' | 'system' -- which face of the theme
|
|
32
|
+
theme: 'blockyard', // a preset in theme.js PRESETS, or 'custom'
|
|
33
|
+
customBg: '#0b0d10', // the page
|
|
34
|
+
customPanel: '#12151a', // cards, the header, the settings sheet
|
|
35
|
+
customText: '#dfe5ee',
|
|
36
|
+
customMuted: '#96a0b0',
|
|
37
|
+
customAccent: '#f7931a',
|
|
38
|
+
customLine: '#262c36',
|
|
39
|
+
customOk: '#2ecc8f',
|
|
40
|
+
customWarn: '#f0b429',
|
|
41
|
+
customBad: '#ef5a5a',
|
|
42
|
+
}),
|
|
43
|
+
space: Object.freeze({
|
|
44
|
+
// OFF by default (operator, 2026-09-12: "make simple cubes the default, disable shadows by
|
|
45
|
+
// default"). Shadows are the costliest single thing the board draws -- one per resting stone
|
|
46
|
+
// and more in flight -- and the board is the first thing most people open.
|
|
47
|
+
shadows: false, // cube-on-cube and resting shadows (blockscene3d shadowOps)
|
|
48
|
+
idleFx: true, // the effects at rest: ripples, light cycles, the lightning ball
|
|
49
|
+
edges: true, // the dark seam around each stone
|
|
50
|
+
grid: true, // the neon grid on the board
|
|
51
|
+
// ITS COLOUR AND ITS BRIGHTNESS (operator, 2026-09-12: "Also add a color selector and
|
|
52
|
+
// brightness setting for the grid lighting for blockspace"). One hex drives the core, the
|
|
53
|
+
// halo, the glow and the bright edge line together -- see gridColours() for why it cannot be
|
|
54
|
+
// a single value. The shipped pair reproduces the hand-tuned green exactly, so the board does
|
|
55
|
+
// not change appearance until someone changes it.
|
|
56
|
+
gridColour: '#32be7d',
|
|
57
|
+
gridBrightness: 1,
|
|
58
|
+
// THE FINISH (operator, 2026-09-12: "consider neon-izing each of teh blocks, and adding an
|
|
59
|
+
// optional specular metallic sheen to the blocks. Have it toggle. I want to be able to apply
|
|
60
|
+
// the sheen onto simple cube mode if I want. think maximum configuration options"). Both
|
|
61
|
+
// opt-in: the shipped look is the deliberate one, these are finishes laid over it, and they
|
|
62
|
+
// work at every level of detail -- a Simple cube takes the sheen as well as a full one.
|
|
63
|
+
neon: false, // the edges of every block stroked in its own colour, lit
|
|
64
|
+
// THE NEON TUBES TUNED (operator, 2026-09-12: "preferences for tuning the neon outline colors
|
|
65
|
+
// ... Slider for brightness, color selection, realtime preview ... optionally have the neon
|
|
66
|
+
// color tied to the block temperature"): the tube takes the block's feerate colour, or one
|
|
67
|
+
// colour of the operator's choosing, at a brightness of their choosing
|
|
68
|
+
neonSource: 'temperature', // 'temperature' (the block's own colour) | 'colour' (neonColour)
|
|
69
|
+
neonColour: '#3d8bff', // the one colour, when chosen
|
|
70
|
+
neonBrightness: 1, // multiplies the tubes' alpha and width (0.2 .. 2)
|
|
71
|
+
sheen: false, // a metallic highlight along the lit edge of each top face
|
|
72
|
+
// WHICH METAL (operator, 2026-09-14: "give it a chrome or faux reflective effect ... really improve
|
|
73
|
+
// the metallic look"). 'chrome' mirrors a horizon in every face that slides as cubes move;
|
|
74
|
+
// 'satin' is the edge highlight the sheen always was. Chrome is the default because it is the
|
|
75
|
+
// answer to that ask; satin stays for anyone who preferred the quieter finish.
|
|
76
|
+
sheenStyle: 'chrome', // 'chrome' | 'satin'
|
|
77
|
+
// ON (operator, 2026-09-13: "the current settings I have saved out should be the shipping
|
|
78
|
+
// defaults"). It was opt-in because a twinkling field means the board never stops repainting.
|
|
79
|
+
// That cost is now paid deliberately rather than avoided: it is the look that was chosen.
|
|
80
|
+
stars: true,
|
|
81
|
+
dome: 5, // how far the board bows toward the viewer, 0 = flat
|
|
82
|
+
// HEIGHT THAT FORESHORTENS (operator, 2026-09-13: "I'm not seeing the bottom or the front face
|
|
83
|
+
// changing at all during movement ... fix camera stuff"). The oblique camera is parallel: height
|
|
84
|
+
// moves a cube up the screen and nothing scales, so a face is the same size at z=0 and z=80 --
|
|
85
|
+
// measured at 11.0px either way. This scales a point about the vanishing point by 1 + gz*rise,
|
|
86
|
+
// so a cube's top is larger than its base and a flying cube grows as it comes toward you.
|
|
87
|
+
//
|
|
88
|
+
// Off by default: a size that changes with height is exactly what the oblique camera was chosen
|
|
89
|
+
// to avoid ("nothing ever changes size -- so there is no growth to police and nothing to
|
|
90
|
+
// flicker"), and this area has already regressed the paint order three times. It ships as a
|
|
91
|
+
// control to be looked at and compared, not as a new default.
|
|
92
|
+
// RANGE: 0.01, then 0.08 (operator, 2026-09-14: "allow us a much greater range on the slider to
|
|
93
|
+
// adjust Depth"), then CAPPED AT 0.03 the same day ("Lock depth at 0.03. too many issues higher than
|
|
94
|
+
// that"). Measured on the paint order across a refresh, pops rise with depth -- 6 at 0.01, 24 at
|
|
95
|
+
// 0.03, 44 at 0.08 -- and past 0.03 a rising cube swells over its neighbours enough to be seen.
|
|
96
|
+
// A stored value above the cap is clamped to it on load; the default stays 0.
|
|
97
|
+
// ...AND THEN 0.001 (operator, 2026-09-14: "The board depth slider should be a limit between 0 and
|
|
98
|
+
// 0.001. It starts looking bad any higher than 0.001"), in steps of 0.0001 so the range still has
|
|
99
|
+
// ten stops. A stored value above it clamps on load.
|
|
100
|
+
perspective: 0, // 0 = the parallel camera; 0.001 the most allowed
|
|
101
|
+
light: 'overhead', // where the lamp is (operator, 2026-09-12: "directly above the board centered")
|
|
102
|
+
detail: 'simple', // 'full' | 'simple' | 'flat' -- facet and crown thresholds below; simple by default
|
|
103
|
+
motion: 'full', // 'full' | 'quick' | 'still' -- the refresh choreography
|
|
104
|
+
// HOW CUBES LEAVE AND ARRIVE (operator, 2026-09-13: "all the left and right side blocks are
|
|
105
|
+
// arcing towards/away from the sides instead of just traveling straight up ... make it a toggle
|
|
106
|
+
// for Linear vs Arcing", then "give me 3 choices to see and toggle between", then, having seen
|
|
107
|
+
// all three on the live board: "Get rid of straight up. Make Along the board's curve the
|
|
108
|
+
// default.")
|
|
109
|
+
//
|
|
110
|
+
// Measured before any of it: the shipped path is already nearly straight (the drawn slope dx/dy
|
|
111
|
+
// moves only 0.7335 -> 0.7481 over a whole climb at the left edge). What reads as arcing is
|
|
112
|
+
// that the straight line is STEEP -- three pixels sideways for every four up at the rim,
|
|
113
|
+
// against -0.017 over the middle. A `vertical` mode that removed the fan entirely was built and
|
|
114
|
+
// shown alongside these two, and cut after the comparison: the fan is the domed board being
|
|
115
|
+
// honest about itself.
|
|
116
|
+
//
|
|
117
|
+
// A stored 'vertical' from that round is not a valid value any more, and normalise resolves an
|
|
118
|
+
// unknown choice to this default, so those boards land on 'normal' rather than on nothing.
|
|
119
|
+
departures: 'normal', // 'normal' | 'arcing'
|
|
120
|
+
}),
|
|
121
|
+
// THE SKY IS ONE SKY. density and brightness lived under `markets` and were passed only to the
|
|
122
|
+
// markets board, so the Block space star field -- the same stars, drawn by the same code -- had
|
|
123
|
+
// no density or brightness control at all. Whether each board shows stars stays per board
|
|
124
|
+
// (space.stars, markets.stars); what the stars LOOK like belongs to neither.
|
|
125
|
+
sky: Object.freeze({
|
|
126
|
+
density: 3, // multiplies the star count (0.2 .. 3) -- the shipped look sits at the top of the range
|
|
127
|
+
brightness: 1, // multiplies each star's alpha (0.2 .. 1.5)
|
|
128
|
+
galaxy: true, // the same stars laid on spiral arms, turning once a quarter hour
|
|
129
|
+
galaxyAt: 'bottom-left', // where its middle sits: behind the board, or any of the corners
|
|
130
|
+
// THE LAYERS OF THE SKY, each its own switch (operator, 2026-09-12: "We should have toggles for
|
|
131
|
+
// all these sub-options in preferences"). All on: they were asked for, and a feature shipped
|
|
132
|
+
// behind an off switch is not shipped.
|
|
133
|
+
nebulae: true, // gas clouds on the arms
|
|
134
|
+
galaxies: true, // distant galaxies in the deep field behind everything
|
|
135
|
+
dust: true, // dark lanes along the inner edge of each arm
|
|
136
|
+
clusters: true, // tight knots of stars out in the halo
|
|
137
|
+
colours: true, // stars coloured by population: warm bulge, blue-white arms
|
|
138
|
+
glints: true, // the halo and cross glint on the brightest stars
|
|
139
|
+
}),
|
|
140
|
+
// `glow` was here and is gone (operator, 2026-09-12: "on markets and price. we should never show
|
|
141
|
+
// the grid glow. that's just terrible"). Never-show makes the switch a control nobody may use,
|
|
142
|
+
// and a control that must stay off is worse than no control: the board forces it off now.
|
|
143
|
+
markets: Object.freeze({
|
|
144
|
+
// THE SWITCH (operator, 2026-09-15: "an app wide 'Enable Market Polling' checkbox"): off out
|
|
145
|
+
// of the box, so a fresh monitor makes no outbound connection but to the node. The server
|
|
146
|
+
// reads it from the shared settings file on every market request (http/api.js).
|
|
147
|
+
polling: false,
|
|
148
|
+
stars: true,
|
|
149
|
+
effects: true, // the idle effects and the flight when the candles refresh
|
|
150
|
+
// THE TOOLBAR REMEMBERS (operator, 2026-09-12: "We need to remember the user settings for the
|
|
151
|
+
// Markets page"). Which exchange and how many hours were a click that survived until the tab
|
|
152
|
+
// was closed; they are preferences, and they live here now. Strings, because a <select> hands
|
|
153
|
+
// back a string and a number that arrives as "24" must still match its own control.
|
|
154
|
+
exchange: 'coinbase',
|
|
155
|
+
range: '24',
|
|
156
|
+
// ONE VIEW OR THE OTHER, never both (operator, 2026-09-12: "For markets page, have a selector
|
|
157
|
+
// for either the 3D view or 2D view for price. Not both at the same time. Too much waste of
|
|
158
|
+
// space for that screen"). The page drew the 3D board AND the flat candlestick chart on every
|
|
159
|
+
// render, one above the other, which is two tall panels of the same hours. A string for the
|
|
160
|
+
// same reason `range` is one: a control hands back a string, and a remembered choice has to
|
|
161
|
+
// match what its own control offers.
|
|
162
|
+
// 2D by default (operator, 2026-09-12: "make 2D view the default"): the flat chart is the one
|
|
163
|
+
// that answers "what is the price doing" at a glance, with axes and a crosshair. The board is
|
|
164
|
+
// the showpiece, and it is one click away.
|
|
165
|
+
priceView: '2d', // '2d' (the flat chart) | '3d' (the candle board)
|
|
166
|
+
// THE SUMMARY LINE ON OVERVIEW (operator, 2026-09-13: "We really need to squeeze this line into
|
|
167
|
+
// the top of the Overview, between Sync status and Block Flow").
|
|
168
|
+
//
|
|
169
|
+
// ON by default (operator, 2026-09-13: "In Display and Settings, enable 'Price Line on
|
|
170
|
+
// Overview' checked as default"). It shipped OFF, and the reason it was off still stands and
|
|
171
|
+
// is worth stating plainly rather than deleting: those four figures come from the full
|
|
172
|
+
// exchange feed, which is otherwise parked unless someone is on Markets or Kiosk -- and
|
|
173
|
+
// Overview is the page the app OPENS on. So with this on, EVERY deployment contacts five
|
|
174
|
+
// exchanges the moment anyone looks at it, not only when they go looking for a price.
|
|
175
|
+
//
|
|
176
|
+
// That is the operator's call to make, and it is made. What must not happen is the code
|
|
177
|
+
// quietly disagreeing with the promise: docs/SECURITY.md's outbound table and the README row
|
|
178
|
+
// both now say Overview reaches out by default, and the switch is still here for anyone who
|
|
179
|
+
// wants the old behaviour.
|
|
180
|
+
overviewSummary: true,
|
|
181
|
+
}),
|
|
182
|
+
// EVERY EFFECT ITS OWN SWITCH (operator, 2026-09-12: "at least 25 total different effects, all
|
|
183
|
+
// toggleable"), AND EACH BOARD ITS OWN LIST (operator, 2026-09-14: "I want the markets tab to have
|
|
184
|
+
// a separate effects list ... the Block Space effects specific to that panel, and settings specific
|
|
185
|
+
// to market panel"). This group is the block board's: its keys are exactly details3d's SPACE_FX --
|
|
186
|
+
// every effect but the two drawn on a price line -- and a test asserts the lists match, so an
|
|
187
|
+
// effect cannot ship without a switch or a switch outlive its effect. All on: they were asked
|
|
188
|
+
// for, and the board picks among whatever is left on (scheduleFx). Turn them all off and the
|
|
189
|
+
// board simply rests, which `idleFx` also does in one click.
|
|
190
|
+
effects: Object.freeze({
|
|
191
|
+
ripple: true, outline: true, tide: true, cascade: true, twinkle: true, scan: true, xray: true,
|
|
192
|
+
lightcycle: true, ball: true,
|
|
193
|
+
// FIREWORKS ARE KEPT FOR OCCASIONS (operator, 2026-09-15: "make it disabled by default. I want
|
|
194
|
+
// to re-use that one for special occasions in the future, and don't want it in the rotation
|
|
195
|
+
// unless user selects it"): off on both boards until someone ticks it
|
|
196
|
+
shockwave: true, nova: true, firework: false, flare: true, wave: true, quake: true,
|
|
197
|
+
rain: true, sparkle: true, checker: true, radar: true, vortex: true, powerup: true, combo: true, aurora: true, plasma: true,
|
|
198
|
+
// the agents: something happening on the board, rather than a pattern over it
|
|
199
|
+
centipede: true, tractor: true, missile: true, boulderdash: true, stormball: true,
|
|
200
|
+
// NO REPEATS (operator, 2026-09-14: "add a config field that defaults to 12. Make sure to pick a
|
|
201
|
+
// random effect to play, but never pick one that has been played in the last 12 sequences"). A
|
|
202
|
+
// number, not a switch: enabledEffects reads only the switches.
|
|
203
|
+
noRepeat: 12,
|
|
204
|
+
// THE CADENCE (operator, 2026-09-14: "add sliders to each the market and block space effects
|
|
205
|
+
// panels so we can tune the randomized timing of the events being triggered ... maximum
|
|
206
|
+
// configurability on how often they see effects trigger for each panel, based on the current
|
|
207
|
+
// defaults ... delay effects being triggered even longer than they are now"). The scheduler
|
|
208
|
+
// waits a random span between pauseMin and pauseMax seconds after one effect before the next
|
|
209
|
+
// (details3d idleEvery, 5-9 s since 2026-09-11), and firstAfter seconds, give or take a third,
|
|
210
|
+
// for the first after the board lands (idleFirst, 0.8-1.6 s). Up to ten minutes between.
|
|
211
|
+
pauseMin: 5,
|
|
212
|
+
pauseMax: 9,
|
|
213
|
+
firstAfter: 1.2,
|
|
214
|
+
}),
|
|
215
|
+
// THE PRICE BOARD'S OWN LIST: details3d's MARKET_FX -- the twelve that translate to a candle
|
|
216
|
+
// chart (operator, 2026-09-14: "the selection I have made for the market effects is what we
|
|
217
|
+
// should ship with ... Many of the effects don't translate over to the market chart"). Its own
|
|
218
|
+
// no-repeat window too. All on, twelve.
|
|
219
|
+
marketEffects: Object.freeze({
|
|
220
|
+
ripple: true, outline: true, tide: true, cascade: true, twinkle: true, scan: true, xray: true,
|
|
221
|
+
pulse: true, // the surge that runs the price line
|
|
222
|
+
bulge: true, // a sphere rolls through the pipe and it swells round it
|
|
223
|
+
breathe: true, // the line breathes between the wire and the pulse's heat
|
|
224
|
+
saber: true, // the line ignites as a light saber
|
|
225
|
+
blackhole: true, // the chart collapses into a black hole
|
|
226
|
+
firework: false, // kept for occasions, off until ticked (see the block board's list)
|
|
227
|
+
flare: true, wave: true, stormball: true,
|
|
228
|
+
noRepeat: 12,
|
|
229
|
+
// no firstAfter here: the candle board does not land (operator, 2026-09-14: "there is no
|
|
230
|
+
// 'landing' for the markets display"); after a refresh its first effect keeps the cadence below
|
|
231
|
+
pauseMin: 5,
|
|
232
|
+
pauseMax: 9,
|
|
233
|
+
}),
|
|
234
|
+
// BLOCKOUT (operator, 2026-09-12: "take the classic Atari Breakout game, and make a clone of it,
|
|
235
|
+
// in another tab, using our engine"). The same shape as the Tetrust group: the game says whether
|
|
236
|
+
// there is a sky and a galaxy; what the sky is MADE of comes from the Sky group.
|
|
237
|
+
blockout: Object.freeze({
|
|
238
|
+
stars: true,
|
|
239
|
+
galaxy: true,
|
|
240
|
+
galaxyAt: 'center',
|
|
241
|
+
neon: false, // the bricks as dim bodies under lit tubes
|
|
242
|
+
neonSource: 'brick', // 'brick' (each row's own colour) | 'colour'
|
|
243
|
+
neonColour: '#3d8bff',
|
|
244
|
+
neonBrightness: 1,
|
|
245
|
+
// the grid under the court, its own now rather than the engine's hardcoded green
|
|
246
|
+
grid: true,
|
|
247
|
+
gridColour: '#3cc88c',
|
|
248
|
+
gridBrightness: 0.35, // well down from full: the court reads better with the lattice faint
|
|
249
|
+
sfx: true,
|
|
250
|
+
}),
|
|
251
|
+
// BLOCKANOID (operator, 2026-09-12: "Take blockout, and make rip off of Arkanoid using our
|
|
252
|
+
// engine, and make it a new Diversion called 'Blockanoid'"). Blockout's shape plus the two
|
|
253
|
+
// switches Arkanoid earns: whether capsules fall at all, and whether the minions turn up.
|
|
254
|
+
blockanoid: Object.freeze({
|
|
255
|
+
stars: true,
|
|
256
|
+
galaxy: true,
|
|
257
|
+
galaxyAt: 'center',
|
|
258
|
+
neon: false,
|
|
259
|
+
neonSource: 'brick', // 'brick' (the brick's own colour) | 'colour'
|
|
260
|
+
neonColour: '#3d8bff',
|
|
261
|
+
neonBrightness: 1,
|
|
262
|
+
capsules: true, // the falling letters
|
|
263
|
+
enemies: true, // the minions drifting down the court
|
|
264
|
+
grid: true, // the grid under the court
|
|
265
|
+
gridColour: '#332c63', // deep indigo rather than the engine green
|
|
266
|
+
gridBrightness: 1,
|
|
267
|
+
sfx: true,
|
|
268
|
+
}),
|
|
269
|
+
// TETRUST (operator, 2026-09-12: "Have this entire panel filled black and rendering the spiral
|
|
270
|
+
// galaxy for this display. Have the text floating over the spiral galaxy ... add toggles for
|
|
271
|
+
// those settings in the game and have them persistent in settings"). The sky is the panel's own
|
|
272
|
+
// canvas behind the well, so it costs the game nothing per key press.
|
|
273
|
+
tetrust: Object.freeze({
|
|
274
|
+
stars: true, // the star field across the whole panel
|
|
275
|
+
galaxy: true, // the spiral galaxy in it
|
|
276
|
+
galaxyAt: 'center', // where its centre sits: behind the title
|
|
277
|
+
// the landing marker's colour (operator: "I want to be able to set the ghost wireframe
|
|
278
|
+
// color"). Its own setting, not the neon tubes': the wireframe is drawn instead of a block,
|
|
279
|
+
// so the neon finish never touches it.
|
|
280
|
+
ghostColour: '#2f2c44',
|
|
281
|
+
ghostWidth: 0.5, // multiplies the marker's line thickness (operator: "thinner lines")
|
|
282
|
+
music: true, // the tune
|
|
283
|
+
sfx: true, // the effects: move, rotate, drop, clear, game over
|
|
284
|
+
neon: false, // neon tubes on the pieces and the stack
|
|
285
|
+
neonSource: 'piece', // 'piece' (each piece's colour) | 'colour' (neonColour)
|
|
286
|
+
neonColour: '#3d8bff',
|
|
287
|
+
neonBrightness: 1,
|
|
288
|
+
grid: true, // the grid under the well
|
|
289
|
+
gridColour: '#1844bf', // blue rather than the engine green
|
|
290
|
+
gridBrightness: 1.55, // and above full, which the blue needs to read at all
|
|
291
|
+
}),
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const DETAIL = {
|
|
295
|
+
// facetPx/crownPx are "draw this extra detail only when a stone is at least this many device
|
|
296
|
+
// pixels across". Raising them is the cheap way to simplify: the same scene, fewer polygons.
|
|
297
|
+
full: { facetPx: 9, crownPx: 18 },
|
|
298
|
+
simple: { facetPx: 26, crownPx: Infinity }, // the divot IS the crown: never, at any size
|
|
299
|
+
flat: { facetPx: Infinity, crownPx: Infinity }, // facets and crown only: the seam is the operator's call
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const MOTION = {
|
|
303
|
+
full: null, // the shipped 20 s choreography
|
|
304
|
+
quick: { rise: 900, travel: 3200, drop: 1800 },
|
|
305
|
+
still: { rise: 0, travel: 1, drop: 0 }, // lands immediately; no flight
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
// What the panel draws. Kept beside the values so a new setting cannot be added without a
|
|
309
|
+
// control, or a control without a value -- and, since normalise reads its bounds from here, so
|
|
310
|
+
// that a slider and the clamp behind it cannot disagree. They used to be written out twice.
|
|
311
|
+
// THE EFFECT ROWS, one table for both boards' groups: the label and the hint of each effect are
|
|
312
|
+
// written once, and each group below lists the keys it offers.
|
|
313
|
+
const FX_ROW = Object.freeze({
|
|
314
|
+
ripple: Object.freeze({ label: 'Ripple', hint: 'A ring spreading from a point on the board' }),
|
|
315
|
+
outline: Object.freeze({ label: 'Outline sweep', hint: 'A front that traces each block\u2019s edges as it passes' }),
|
|
316
|
+
tide: Object.freeze({ label: 'Tide', hint: 'A swell that lifts the blocks it passes under' }),
|
|
317
|
+
cascade: Object.freeze({ label: 'Cascade', hint: 'The blocks light in feerate order, richest first' }),
|
|
318
|
+
twinkle: Object.freeze({ label: 'Twinkle', hint: 'Scattered blocks flash white, each on its own beat' }),
|
|
319
|
+
scan: Object.freeze({ label: 'Scan line', hint: 'A curtain of light standing on the board, floor to top, sweeping across: a white core, soft cyan faces, raster rippling down it, a bar it hangs from, a glowing foot with a phosphor tail, motes in the beam' }),
|
|
320
|
+
xray: Object.freeze({ label: 'X-ray', hint: 'A front sweeps the board and everything behind it goes x-ray -- bodies to glass, edges and a raster lit -- then develops back to solid' }),
|
|
321
|
+
lightcycle: Object.freeze({ label: 'Light cycles', hint: 'Two riders from opposite edges, leaving light walls, until one crashes' }),
|
|
322
|
+
ball: Object.freeze({ label: 'Lightning ball', hint: 'A plasma ball tracing the grid, throwing bolts and a dust trail' }),
|
|
323
|
+
pulse: Object.freeze({ label: 'Energy pulse', hint: 'The surge that runs the price line on Markets, blue behind the head' }),
|
|
324
|
+
bulge: Object.freeze({ label: 'Pipe bulge', hint: 'On Markets: a glowing sphere rolls through the price line left to right, and the pipe swells around it as it passes' }),
|
|
325
|
+
breathe: Object.freeze({ label: 'Breathe', hint: 'The price line breathes: three slow swells from the plain wire to the pulse\u2019s white heat and back' }),
|
|
326
|
+
saber: Object.freeze({ label: 'Light saber', hint: 'The price line ignites from its left end as a light saber -- blue, green, red or purple -- hums, spits sparks, and retracts' }),
|
|
327
|
+
blackhole: Object.freeze({ label: 'Black hole', hint: 'A point of darkness opens on the price line and grows, then drifts slowly across the chart from one side to the other: the line bends round it, the candles nearest are swallowed into orbit, and an accretion disk of the chart\u2019s own colours spirals in round the shadow -- brighter on the side coming toward you, its far side arched over the top -- until it lets everything go' }),
|
|
328
|
+
shockwave: Object.freeze({ label: 'Shockwave', hint: 'A hard ring that throws the blocks it passes into the air' }),
|
|
329
|
+
nova: Object.freeze({ label: 'Nova', hint: 'An implosion to the middle, then a brighter blast back out' }),
|
|
330
|
+
firework: Object.freeze({ label: 'Fireworks', hint: 'A display of up to ten shells, each at its own moment and place, with its smoke. Off by default -- kept for special occasions; tick it to put it in the rotation' }),
|
|
331
|
+
flare: Object.freeze({ label: 'Supernova', hint: 'One block goes supernova: a star swells and blows out in a white-out, a debris cloud of gas expands and cools white to blue to violet, a shockwave throws what it crosses, and a pulsar is left beating at the centre' }),
|
|
332
|
+
wave: Object.freeze({ label: 'Wave', hint: 'Several crests rolling across the board, the blocks riding them' }),
|
|
333
|
+
quake: Object.freeze({ label: 'Quake', hint: 'The board shakes, hardest at the start, and settles' }),
|
|
334
|
+
rain: Object.freeze({ label: 'Code rain', hint: 'A drop falls down every column with a white head and a green tail' }),
|
|
335
|
+
sparkle: Object.freeze({ label: 'Sparkle', hint: 'A constellation lighting a few blocks at a time, each its own colour' }),
|
|
336
|
+
checker: Object.freeze({ label: 'Checkerboard', hint: 'The board flips like a chessboard, dark squares against light' }),
|
|
337
|
+
radar: Object.freeze({ label: 'Radar', hint: 'A sweep hand turning once, the blocks behind it fading like phosphor' }),
|
|
338
|
+
vortex: Object.freeze({ label: 'Vortex', hint: 'Spiral arms turning, draining the board inward' }),
|
|
339
|
+
powerup: Object.freeze({ label: 'Power-up', hint: 'The board charges from the floor up, gold, with a bright lip' }),
|
|
340
|
+
combo: Object.freeze({ label: 'Combo chain', hint: 'A chain reaction running the diagonal, each link popping in turn' }),
|
|
341
|
+
aurora: Object.freeze({ label: 'Aurora', hint: 'Slow curtains of colour drifting over the board' }),
|
|
342
|
+
plasma: Object.freeze({ label: 'Plasma', hint: 'The demoscene plasma: sines over the board, the colour cycling' }),
|
|
343
|
+
centipede: Object.freeze({ label: 'Centipede', hint: 'A column weaving down the board that splits in two partway, each half carrying on' }),
|
|
344
|
+
tractor: Object.freeze({ label: 'Tractor beam', hint: 'A saucer draws the tallest transaction up in a beam, and puts it back' }),
|
|
345
|
+
missile: Object.freeze({ label: 'Interception', hint: 'Arcs rain toward the board while interceptors rise to meet them, each catch a ring of light' }),
|
|
346
|
+
boulderdash: Object.freeze({ label: 'Collapse', hint: 'The board gives way from a point and the blocks fall in, cascading outward' }),
|
|
347
|
+
stormball: Object.freeze({ label: 'Ball lightning', hint: 'An electric blue sphere in a nebula drifts across the view, crackling, throwing arcs that electrify the blocks they strike; a struck block often throws a green arc on to another, and that one sometimes on to a third' }),
|
|
348
|
+
});
|
|
349
|
+
// where an effect reads differently on the price board, the price board's hint
|
|
350
|
+
const MARKET_HINT = Object.freeze({
|
|
351
|
+
blackhole: 'A point of darkness opens on the price line and grows, then drifts slowly along the hours from one side to the other: the line bends round it, the candles nearest are swallowed into orbit, and an accretion disk of the chart\u2019s own colours spirals in round the shadow -- brighter on the side coming toward you, its far side arched over the top -- until it lets everything go',
|
|
352
|
+
twinkle: 'Scattered candles flash white, each on its own beat',
|
|
353
|
+
pulse: 'The surge that runs the price line: the tube swells round the head and goes white-hot behind it',
|
|
354
|
+
bulge: 'A glowing sphere rolls through the price line left to right, and the pipe swells around it as it passes',
|
|
355
|
+
stormball: 'An electric blue sphere in a nebula flies through the chart, striking candles and charging the price line where it passes; a struck candle often throws a green arc on to another, and that one sometimes on to a third',
|
|
356
|
+
cascade: 'The candles light in order, tallest first',
|
|
357
|
+
flare: 'One candle goes supernova: it swells white-hot, blows out in a flash and a lens flare, a shockwave rings out across the chart, plasma is flung on every side, and a ring nebula expands and cools for the rest of the run',
|
|
358
|
+
});
|
|
359
|
+
// THE WINDOW'S TOP IS THE LIST'S LENGTH (operator, 2026-09-14: "for the markets page, all we have
|
|
360
|
+
// is 12 effects, so max the slider out at max effects"): a window wider than the list is the same
|
|
361
|
+
// as one the list's length (chooseIdleFx clamps it), so the slider stops where the meaning does.
|
|
362
|
+
const noRepeatRow = (max) => Object.freeze({ key: 'noRepeat', label: 'No repeats within', kind: 'range', min: 0, max, step: 1, hint: `An effect is never played again until this many other effects have played since (at ${max}, every effect on the list plays before any comes round again). Where fewer effects are switched on, the one that has waited longest plays next` });
|
|
363
|
+
// THE CADENCE SLIDERS, on both tabs: how long the board rests between effects, as a random span
|
|
364
|
+
// between a floor and a ceiling, and how soon the first one comes after the board lands. Ten
|
|
365
|
+
// minutes is the top: an operator who wants a still board has the switches and all off.
|
|
366
|
+
// `top`: the sliders' ceiling in seconds -- ten minutes on the block board, five on the candle board
|
|
367
|
+
// (operator, 2026-09-14: "600 seconds is too long for the market effects time sliders. Have the
|
|
368
|
+
// range on the bars be between 1 and 300 seconds")
|
|
369
|
+
const cadenceRows = (top) => Object.freeze([
|
|
370
|
+
Object.freeze({ key: 'pauseMin', label: 'Between effects, at least', kind: 'range', min: 1, max: top, step: 1, hint: 'Seconds the board rests after one effect before the next may start. The wait is a random span between this and the ceiling below; if this is set above the ceiling, the two swap' }),
|
|
371
|
+
Object.freeze({ key: 'pauseMax', label: 'Between effects, at most', kind: 'range', min: 1, max: top, step: 1, hint: 'The ceiling on that wait, in seconds. Set both high for an effect only now and then; set both low for a busy board' }),
|
|
372
|
+
]);
|
|
373
|
+
// only the block board lands (the flight when the pool changes); the candle board has no landing
|
|
374
|
+
// to time from, so it gets the two sliders and its first effect after a refresh keeps the cadence
|
|
375
|
+
const LANDING_ROW = Object.freeze({ key: 'firstAfter', label: 'First effect after landing', kind: 'range', min: 0, max: 120, step: 0.1, hint: 'Seconds after the blocks land before the first effect, give or take a third. The board re-lays on every refresh, so this is also how soon one follows each refresh' });
|
|
376
|
+
const fxRows = (keys, hintFor = {}, { landing = true, top = 600 } = {}) => Object.freeze([noRepeatRow(keys.length), ...cadenceRows(top), ...(landing ? [LANDING_ROW] : []), ...keys.map((key) => Object.freeze({ key, label: FX_ROW[key].label, kind: 'toggle', hint: hintFor[key] ?? FX_ROW[key].hint }))]);
|
|
377
|
+
|
|
378
|
+
/** The scheduler's timers from a group's sliders, in ms: [floor, ceiling] between effects, and the first after landing (the same span where the board has no landing). */
|
|
379
|
+
export function fxCadence(g) {
|
|
380
|
+
const lo = Math.min(g.pauseMin, g.pauseMax), hi = Math.max(g.pauseMin, g.pauseMax);
|
|
381
|
+
const idleEvery = [lo * 1000, hi * 1000];
|
|
382
|
+
const idleFirst = Number.isFinite(g.firstAfter) ? [Math.round(g.firstAfter * 1000 * (2 / 3)), Math.round(g.firstAfter * 1000 * (4 / 3))] : idleEvery;
|
|
383
|
+
return { idleEvery, idleFirst };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// THE TABS, IN ORDER AND IN ROWS (operator, 2026-09-16: "we really need to group these better.
|
|
387
|
+
// Diversions go at the very end"). The groups are written below in the order they were added; the
|
|
388
|
+
// sheet shows them in this order, each row of the strip labelled: the boards, their effects, the
|
|
389
|
+
// games last.
|
|
390
|
+
export const TAB_ROWS = Object.freeze([
|
|
391
|
+
Object.freeze({ label: 'Boards', groups: Object.freeze(['appearance', 'space', 'sky', 'markets']) }),
|
|
392
|
+
Object.freeze({ label: 'Effects', groups: Object.freeze(['effects', 'marketEffects']) }),
|
|
393
|
+
Object.freeze({ label: 'Diversions', groups: Object.freeze(['tetrust', 'blockout', 'blockanoid']) }),
|
|
394
|
+
]);
|
|
395
|
+
const PANEL_GROUPS = Object.freeze([
|
|
396
|
+
Object.freeze({
|
|
397
|
+
group: 'appearance',
|
|
398
|
+
title: 'Appearance',
|
|
399
|
+
note: 'The colours of the layout: the page, its cards, text, lines, accent and charts. The 3D boards are space whatever is chosen here, and the Explorer\u2019s block and transaction pages keep their own dark cards.',
|
|
400
|
+
rows: Object.freeze([
|
|
401
|
+
Object.freeze({
|
|
402
|
+
key: 'mode', label: 'Theme mode', kind: 'segment', hint: 'Light or dark, or whichever the operating system asks for (System follows it as it changes)',
|
|
403
|
+
options: Object.freeze([['light', 'Light'], ['dark', 'Dark'], ['system', 'System']]),
|
|
404
|
+
}),
|
|
405
|
+
Object.freeze({
|
|
406
|
+
key: 'theme', label: 'Theme', kind: 'cards', hint: 'Each has a light and a dark face; the mode above picks which. Custom is the nine colours below',
|
|
407
|
+
options: Object.freeze([['blockyard', 'BlockYard'], ['mono', 'Mono'], ['nous', 'Nous'], ['github', 'GitHub'], ['catppuccin', 'Catppuccin'], ['custom', 'Custom']]),
|
|
408
|
+
}),
|
|
409
|
+
Object.freeze({ key: 'customBg', label: 'Page', kind: 'colour', hint: 'The colour behind everything. Whether Custom is a light or a dark theme follows from this one: the derived shades go the other way', custom: true }),
|
|
410
|
+
Object.freeze({ key: 'customPanel', label: 'Panels', kind: 'colour', hint: 'Cards, the header, tables and the settings sheet. Raised panels and hovers are this nudged toward the text colour', custom: true }),
|
|
411
|
+
Object.freeze({ key: 'customText', label: 'Text', kind: 'colour', hint: 'Body text and figures', custom: true }),
|
|
412
|
+
Object.freeze({ key: 'customMuted', label: 'Muted text', kind: 'colour', hint: 'Labels, hints and notes. The fainter text is this halfway to the page colour', custom: true }),
|
|
413
|
+
Object.freeze({ key: 'customAccent', label: 'Accent', kind: 'colour', hint: 'Titles, the active tab, links, buttons and the primary lines of charts', custom: true }),
|
|
414
|
+
Object.freeze({ key: 'customLine', label: 'Lines', kind: 'colour', hint: 'Card borders and table rules. The softer rules are this halfway to the page colour', custom: true }),
|
|
415
|
+
Object.freeze({ key: 'customOk', label: 'Good', kind: 'colour', hint: 'Synced, fresh, a block on time', custom: true }),
|
|
416
|
+
Object.freeze({ key: 'customWarn', label: 'Warning', kind: 'colour', hint: 'Late, stale, a caveat', custom: true }),
|
|
417
|
+
Object.freeze({ key: 'customBad', label: 'Bad', kind: 'colour', hint: 'Overdue, failed, offline', custom: true }),
|
|
418
|
+
]),
|
|
419
|
+
}),
|
|
420
|
+
Object.freeze({
|
|
421
|
+
group: 'space',
|
|
422
|
+
title: 'Block space',
|
|
423
|
+
note: 'The 3D board on Overview, Block space, Mempool and Kiosk. Turn things off here if the board is heavy on this machine.',
|
|
424
|
+
rows: Object.freeze([
|
|
425
|
+
Object.freeze({ key: 'shadows', label: 'Shadows', kind: 'toggle', hint: 'Cubes casting shadows on the board and on each other' }),
|
|
426
|
+
Object.freeze({ key: 'idleFx', label: 'Idle effects', kind: 'toggle', hint: 'The effects while the board rests: which of them is the Space effects tab' }),
|
|
427
|
+
Object.freeze({ key: 'edges', label: 'Stone edges', kind: 'toggle', hint: 'The dark seam around each stone' }),
|
|
428
|
+
Object.freeze({ key: 'grid', label: 'Neon grid', kind: 'toggle', hint: 'The glowing grid on the board' }),
|
|
429
|
+
Object.freeze({ key: 'gridColour', label: 'Grid colour', kind: 'colour', hint: 'The grid’s colour: its lit core, and the halo and glow around it, all take it together' }),
|
|
430
|
+
Object.freeze({ key: 'gridBrightness', label: 'Grid brightness', kind: 'range', min: 0, max: 2, step: 0.05, hint: 'How hard the grid burns; 1 is the shipped grid, 0 leaves the lines unlit' }),
|
|
431
|
+
Object.freeze({ key: 'neon', label: 'Neon blocks', kind: 'toggle', hint: 'Every block a dim solid body under lit tubes on its edges. Works at any level of detail' }),
|
|
432
|
+
Object.freeze({
|
|
433
|
+
key: 'neonSource', label: 'Neon colour from', kind: 'choice', hint: 'The tubes in each block’s own feerate colour, or all in one colour',
|
|
434
|
+
options: Object.freeze([['temperature', 'The block’s feerate colour'], ['colour', 'One colour']]),
|
|
435
|
+
}),
|
|
436
|
+
Object.freeze({ key: 'neonColour', label: 'Neon colour', kind: 'colour', hint: 'The one colour, when chosen above' }),
|
|
437
|
+
Object.freeze({ key: 'neonBrightness', label: 'Neon brightness', kind: 'range', min: 0.2, max: 2, step: 0.1, hint: 'How hard the tubes glow; 1 is the shipped glow' }),
|
|
438
|
+
Object.freeze({ key: 'sheen', label: 'Metallic sheen', kind: 'toggle', hint: 'A specular highlight along the lit edge of each block’s top face. Works on Simple cubes too' }),
|
|
439
|
+
Object.freeze({
|
|
440
|
+
key: 'sheenStyle', label: 'Metallic finish', kind: 'choice', hint: 'Chrome mirrors a horizon in every face that slides as the blocks move; satin is a softer highlight along the lit edge. Needs Metallic sheen on',
|
|
441
|
+
options: Object.freeze([['chrome', 'Chrome'], ['satin', 'Satin']]),
|
|
442
|
+
}),
|
|
443
|
+
Object.freeze({ key: 'stars', label: 'Star field', kind: 'toggle', hint: 'Stars behind the board. They twinkle, so the board keeps repainting while they are on' }),
|
|
444
|
+
Object.freeze({
|
|
445
|
+
key: 'detail', label: 'Level of detail', kind: 'choice', hint: 'Simpler cubes draw fewer polygons at the same size',
|
|
446
|
+
options: Object.freeze([['full', 'Full'], ['simple', 'Simple cubes'], ['flat', 'Flat tiles']]),
|
|
447
|
+
}),
|
|
448
|
+
Object.freeze({
|
|
449
|
+
key: 'motion', label: 'Refresh animation', kind: 'choice', hint: 'How blocks travel when the board refreshes',
|
|
450
|
+
options: Object.freeze([['full', 'Full flight'], ['quick', 'Quick'], ['still', 'None']]),
|
|
451
|
+
}),
|
|
452
|
+
Object.freeze({
|
|
453
|
+
key: 'departures', label: 'Departures and arrivals', kind: 'choice',
|
|
454
|
+
hint: 'The path a block takes as it leaves or arrives. Both follow the board’s curve outward from the middle; along the curve is a straight line, arcing bends as the block climbs',
|
|
455
|
+
options: Object.freeze([['normal', 'Along the board’s curve'], ['arcing', 'Arcing (original)']]),
|
|
456
|
+
}),
|
|
457
|
+
Object.freeze({ key: 'dome', label: 'Board curve', kind: 'range', min: 0, max: 12, step: 1, hint: 'How far the board bows toward you; 0 is flat' }),
|
|
458
|
+
Object.freeze({ key: 'perspective', label: 'Depth', kind: 'range', min: 0, max: 0.001, step: 0.0001, hint: 'How much height foreshortens. 0 is the flat parallel camera the board shipped with: a cube is the same size however high it flies. Raise it and a cube’s top grows a little wider than its base and a flying block swells slightly as it rises' }),
|
|
459
|
+
Object.freeze({
|
|
460
|
+
key: 'light', label: 'Light', kind: 'choice', hint: 'Where the lamp hangs. Straight above lights the whole board evenly; a corner shades the far slope of the curve and the sides turned away',
|
|
461
|
+
options: Object.freeze([['overhead', 'Straight above'], ['upper-left', 'Upper left'], ['upper-right', 'Upper right'], ['front', 'From the viewer']]),
|
|
462
|
+
}),
|
|
463
|
+
]),
|
|
464
|
+
}),
|
|
465
|
+
Object.freeze({
|
|
466
|
+
group: 'sky',
|
|
467
|
+
title: 'Sky',
|
|
468
|
+
note: 'The star field itself, wherever it is drawn — behind the Block space board and behind the candles. Each board decides whether to show it; this decides what it looks like.',
|
|
469
|
+
rows: Object.freeze([
|
|
470
|
+
Object.freeze({ key: 'galaxy', label: 'Spiral galaxy', kind: 'toggle', hint: 'Lay the stars on slowly turning spiral arms instead of scattering them evenly. One turn takes about fifteen minutes' }),
|
|
471
|
+
Object.freeze({
|
|
472
|
+
key: 'galaxyAt', label: 'Galaxy centre', kind: 'choice',
|
|
473
|
+
hint: 'Where its middle sits. A corner crowds the bright centre there and sweeps the arms across; behind the board shows the whole spiral',
|
|
474
|
+
options: Object.freeze([['center', 'Behind the board'], ['top-left', 'Top left'], ['top-right', 'Top right'], ['bottom-left', 'Bottom left'], ['bottom-right', 'Bottom right']]),
|
|
475
|
+
}),
|
|
476
|
+
Object.freeze({ key: 'nebulae', label: 'Nebulae', kind: 'toggle', hint: 'Clouds of gas along the spiral arms, in the colours of star-forming lanes' }),
|
|
477
|
+
Object.freeze({ key: 'dust', label: 'Dust lanes', kind: 'toggle', hint: 'Dark ribbons along the inner edge of each arm, the way a real spiral carries them' }),
|
|
478
|
+
Object.freeze({ key: 'clusters', label: 'Star clusters', kind: 'toggle', hint: 'Tight knots of stars out in the halo, turning with the galaxy' }),
|
|
479
|
+
Object.freeze({ key: 'galaxies', label: 'Distant galaxies', kind: 'toggle', hint: 'Other galaxies, small and faint and far, behind everything else' }),
|
|
480
|
+
Object.freeze({ key: 'colours', label: 'Star colours', kind: 'toggle', hint: 'Warm old stars in the middle, blue-white young ones in the arms. Off is one colour of starlight' }),
|
|
481
|
+
Object.freeze({ key: 'glints', label: 'Star glints', kind: 'toggle', hint: 'The halo and cross glint on the brightest stars' }),
|
|
482
|
+
Object.freeze({ key: 'density', label: 'Star density', kind: 'range', min: 0.2, max: 8, step: 0.1, hint: 'How many stars, against the shipped number. High values are a lot of drawing on a big panel' }),
|
|
483
|
+
Object.freeze({ key: 'brightness', label: 'Star brightness', kind: 'range', min: 0.2, max: 1.5, step: 0.1, hint: 'How brightly they burn' }),
|
|
484
|
+
]),
|
|
485
|
+
}),
|
|
486
|
+
Object.freeze({
|
|
487
|
+
group: 'markets',
|
|
488
|
+
title: 'Markets & Price',
|
|
489
|
+
note: 'The exchange feed, and the candle board on Markets and Kiosk.',
|
|
490
|
+
rows: Object.freeze([
|
|
491
|
+
Object.freeze({
|
|
492
|
+
key: 'polling', label: 'Enable market polling', kind: 'toggle',
|
|
493
|
+
hint: 'Let the server ask five exchanges (Coinbase, Kraken, Bitstamp, Bitfinex, OKX) for prices, candles and order books. '
|
|
494
|
+
+ 'Off out of the box: this is the only thing the monitor ever says to anyone but your node, and until it is on, '
|
|
495
|
+
+ 'Markets and Kiosk say so and the explorer shows no dollar figures. On, the server asks only while someone is looking, '
|
|
496
|
+
+ 'and stops ten minutes after the last look. Shared by every screen of this monitor',
|
|
497
|
+
}),
|
|
498
|
+
// right under the polling switch it depends on (operator, 2026-09-15: "Move price line on Overview
|
|
499
|
+
// underneath Enable Market Polling")
|
|
500
|
+
Object.freeze({
|
|
501
|
+
key: 'overviewSummary', label: 'Price line on Overview', kind: 'toggle',
|
|
502
|
+
hint: 'Median, spread, 24 h volume and how many books reported, at the top of Overview. '
|
|
503
|
+
+ 'It needs market polling (above); with that on, leaving this on means this monitor contacts '
|
|
504
|
+
+ 'five exchanges whenever Overview is open, not only on Markets and Kiosk. '
|
|
505
|
+
+ 'Switch it off and the landing page talks to nothing but your node.',
|
|
506
|
+
}),
|
|
507
|
+
Object.freeze({ key: 'stars', label: 'Star field', kind: 'toggle', hint: 'The twinkling sky behind the candles' }),
|
|
508
|
+
Object.freeze({ key: 'effects', label: 'Board effects', kind: 'toggle', hint: 'The idle effects while the board rests (which of them is the Market effects tab) and the flight when the candles refresh. Off draws the board and leaves it alone' }),
|
|
509
|
+
Object.freeze({
|
|
510
|
+
key: 'exchange', label: 'Exchange', kind: 'choice', hint: 'Whose candles the chart and the 3D board draw. The others stay as overlay lines',
|
|
511
|
+
options: Object.freeze([['coinbase', 'Coinbase'], ['kraken', 'Kraken'], ['bitstamp', 'Bitstamp'], ['bitfinex', 'Bitfinex'], ['okx', 'OKX']]),
|
|
512
|
+
}),
|
|
513
|
+
Object.freeze({
|
|
514
|
+
key: 'range', label: 'Range', kind: 'choice', hint: 'How many hours the chart covers when the page opens',
|
|
515
|
+
options: Object.freeze([['24', '24 hours'], ['48', '48 hours'], ['168', '7 days']]),
|
|
516
|
+
}),
|
|
517
|
+
Object.freeze({
|
|
518
|
+
key: 'priceView', label: 'Price view', kind: 'choice', hint: 'Which one the Markets page draws. Only one at a time — they show the same hours, and two tall panels of it filled the screen',
|
|
519
|
+
options: Object.freeze([['2d', 'Flat chart'], ['3d', '3D candle board']]),
|
|
520
|
+
}),
|
|
521
|
+
]),
|
|
522
|
+
}),
|
|
523
|
+
Object.freeze({
|
|
524
|
+
group: 'blockout',
|
|
525
|
+
title: 'Blockout',
|
|
526
|
+
note: 'The Breakout court. These switches are also on the game\u2019s own panel; the sky takes its density, brightness and layers from Sky above.',
|
|
527
|
+
rows: Object.freeze([
|
|
528
|
+
Object.freeze({ key: 'stars', label: 'Star field', kind: 'toggle', hint: 'The sky across the whole panel, behind the court' }),
|
|
529
|
+
Object.freeze({ key: 'galaxy', label: 'Spiral galaxy', kind: 'toggle', hint: 'The galaxy in that sky, turning' }),
|
|
530
|
+
Object.freeze({
|
|
531
|
+
key: 'galaxyAt', label: 'Galaxy centre', kind: 'choice', hint: 'Where the galaxy\u2019s centre sits on the panel',
|
|
532
|
+
options: Object.freeze([['center', 'Behind the court'], ['top-left', 'Top left'], ['top-right', 'Top right'], ['bottom-left', 'Bottom left'], ['bottom-right', 'Bottom right']]),
|
|
533
|
+
}),
|
|
534
|
+
Object.freeze({ key: 'neon', label: 'Neon bricks', kind: 'toggle', hint: 'The wall, the bat and the ball as dim bodies under lit tubes' }),
|
|
535
|
+
Object.freeze({
|
|
536
|
+
key: 'neonSource', label: 'Neon colour from', kind: 'choice', hint: 'Each brick row\u2019s own colour, or all in one colour',
|
|
537
|
+
options: Object.freeze([['brick', 'The brick\u2019s colour'], ['colour', 'One colour']]),
|
|
538
|
+
}),
|
|
539
|
+
Object.freeze({ key: 'neonColour', label: 'Neon colour', kind: 'colour', hint: 'The one colour, when chosen above' }),
|
|
540
|
+
Object.freeze({ key: 'neonBrightness', label: 'Neon brightness', kind: 'range', min: 0.2, max: 2, step: 0.1, hint: 'How hard the tubes glow' }),
|
|
541
|
+
Object.freeze({ key: 'grid', label: 'Grid', kind: 'toggle', hint: 'The grid under the court' }),
|
|
542
|
+
Object.freeze({ key: 'gridColour', label: 'Grid colour', kind: 'colour', hint: 'The colour of the grid under the court' }),
|
|
543
|
+
Object.freeze({ key: 'gridBrightness', label: 'Grid intensity', kind: 'range', min: 0, max: 2, step: 0.05, hint: 'How strongly the grid shows; 1 is the shipped weight, 0 hides it' }),
|
|
544
|
+
Object.freeze({ key: 'sfx', label: 'Sound effects', kind: 'toggle', hint: 'The bat, the bricks, the walls and a lost ball' }),
|
|
545
|
+
]),
|
|
546
|
+
}),
|
|
547
|
+
Object.freeze({
|
|
548
|
+
group: 'blockanoid',
|
|
549
|
+
title: 'Blockanoid',
|
|
550
|
+
note: 'The Arkanoid court: silver bricks that take more than one hit, gold that takes none, and capsules that fall out of what you break. These switches are also on the game’s own panel.',
|
|
551
|
+
rows: Object.freeze([
|
|
552
|
+
Object.freeze({ key: 'stars', label: 'Star field', kind: 'toggle', hint: 'The sky across the whole panel, behind the court' }),
|
|
553
|
+
Object.freeze({ key: 'galaxy', label: 'Spiral galaxy', kind: 'toggle', hint: 'The galaxy in that sky, turning' }),
|
|
554
|
+
Object.freeze({
|
|
555
|
+
key: 'galaxyAt', label: 'Galaxy centre', kind: 'choice', hint: 'Where the galaxy’s centre sits on the panel',
|
|
556
|
+
options: Object.freeze([['center', 'Behind the court'], ['top-left', 'Top left'], ['top-right', 'Top right'], ['bottom-left', 'Bottom left'], ['bottom-right', 'Bottom right']]),
|
|
557
|
+
}),
|
|
558
|
+
Object.freeze({ key: 'neon', label: 'Neon bricks', kind: 'toggle', hint: 'The wall, Vaus and the ball as dim bodies under lit tubes' }),
|
|
559
|
+
Object.freeze({
|
|
560
|
+
key: 'neonSource', label: 'Neon colour from', kind: 'choice', hint: 'Each brick’s own colour, or all in one colour',
|
|
561
|
+
options: Object.freeze([['brick', 'The brick’s colour'], ['colour', 'One colour']]),
|
|
562
|
+
}),
|
|
563
|
+
Object.freeze({ key: 'neonColour', label: 'Neon colour', kind: 'colour', hint: 'The one colour, when chosen above' }),
|
|
564
|
+
Object.freeze({ key: 'neonBrightness', label: 'Neon brightness', kind: 'range', min: 0.2, max: 2, step: 0.1, hint: 'How hard the tubes glow' }),
|
|
565
|
+
Object.freeze({ key: 'capsules', label: 'Capsules', kind: 'toggle', hint: 'The letters that fall out of broken bricks: laser, wide, catch, slow, three balls, a life, skip' }),
|
|
566
|
+
Object.freeze({ key: 'enemies', label: 'Minions', kind: 'toggle', hint: 'The drifting shapes that spoil your aim, and pay when you hit one' }),
|
|
567
|
+
Object.freeze({ key: 'grid', label: 'Grid', kind: 'toggle', hint: 'The grid under the court' }),
|
|
568
|
+
Object.freeze({ key: 'gridColour', label: 'Grid colour', kind: 'colour', hint: 'The colour of the grid under the court' }),
|
|
569
|
+
Object.freeze({ key: 'gridBrightness', label: 'Grid intensity', kind: 'range', min: 0, max: 2, step: 0.05, hint: 'How strongly the grid shows; 1 is the shipped weight, 0 hides it' }),
|
|
570
|
+
Object.freeze({ key: 'sfx', label: 'Sound effects', kind: 'toggle', hint: 'Vaus, the bricks, the capsules, the laser and a lost ball' }),
|
|
571
|
+
]),
|
|
572
|
+
}),
|
|
573
|
+
// TWO EFFECTS TABS, one per board (operator, 2026-09-14: "I want the markets tab to have a
|
|
574
|
+
// separate effects list"). The rows come from FX_ROW above; the key lists are the boards' own and
|
|
575
|
+
// test/effects.test.js holds them to details3d's SPACE_FX and MARKET_FX.
|
|
576
|
+
Object.freeze({
|
|
577
|
+
group: 'effects',
|
|
578
|
+
title: 'Space effects',
|
|
579
|
+
note: 'What the Block space board may play while it rests. One is chosen at random every seven to thirteen seconds, never one played within the no-repeat window \u2014 so the more you leave on, the less often you see any one of them. The Markets board has a list of its own, on the next tab.',
|
|
580
|
+
bulk: true,
|
|
581
|
+
rows: fxRows([
|
|
582
|
+
'ripple', 'outline', 'tide', 'cascade', 'twinkle', 'scan', 'xray', 'lightcycle', 'ball', 'shockwave', 'nova',
|
|
583
|
+
'firework', 'flare', 'wave', 'quake', 'rain', 'sparkle', 'checker', 'radar', 'vortex', 'powerup',
|
|
584
|
+
'combo', 'aurora', 'plasma', 'centipede', 'tractor', 'missile', 'boulderdash', 'stormball',
|
|
585
|
+
]),
|
|
586
|
+
}),
|
|
587
|
+
Object.freeze({
|
|
588
|
+
group: 'marketEffects',
|
|
589
|
+
title: 'Market effects',
|
|
590
|
+
note: 'What the candle board on Markets and Kiosk may play while it rests, chosen the same way as on Block space but from the sixteen that translate to a chart: fronts along the hours, bursts from the candle row, the candles lit where they stand, and the price line\u2019s own pulse, bulge and ball lightning. The Board effects switch on the Markets & Price tab is the master.',
|
|
591
|
+
bulk: true,
|
|
592
|
+
rows: fxRows([
|
|
593
|
+
'ripple', 'outline', 'tide', 'cascade', 'twinkle', 'scan', 'xray', 'pulse', 'bulge', 'breathe', 'saber', 'blackhole', 'firework', 'flare', 'wave', 'stormball',
|
|
594
|
+
], MARKET_HINT, { landing: false, top: 300 }),
|
|
595
|
+
}),
|
|
596
|
+
Object.freeze({
|
|
597
|
+
group: 'tetrust',
|
|
598
|
+
title: 'Tetrust',
|
|
599
|
+
note: 'The game. These switches are also on the game’s own panel; the sky takes the star field’s density, brightness and layers from Sky above.',
|
|
600
|
+
rows: Object.freeze([
|
|
601
|
+
Object.freeze({ key: 'stars', label: 'Star field', kind: 'toggle', hint: 'The sky across the whole panel, behind the well' }),
|
|
602
|
+
Object.freeze({ key: 'galaxy', label: 'Spiral galaxy', kind: 'toggle', hint: 'The galaxy in that sky, turning' }),
|
|
603
|
+
Object.freeze({
|
|
604
|
+
key: 'galaxyAt', label: 'Galaxy centre', kind: 'choice', hint: 'Where the galaxy’s centre sits on the panel: behind the title, or a corner',
|
|
605
|
+
options: Object.freeze([['center', 'Behind the title'], ['top-left', 'Top left'], ['top-right', 'Top right'], ['bottom-left', 'Bottom left'], ['bottom-right', 'Bottom right']]),
|
|
606
|
+
}),
|
|
607
|
+
Object.freeze({ key: 'ghostColour', label: 'Landing marker', kind: 'colour', hint: 'The wireframe on the floor of the well showing where the falling piece will land. Its own colour: the neon finish below never touches it, because the marker is drawn instead of a block rather than over one' }),
|
|
608
|
+
Object.freeze({ key: 'ghostWidth', label: 'Landing marker thickness', kind: 'range', min: 0.3, max: 2.5, step: 0.1, hint: 'How heavy the marker\u2019s lines are. 1 is the shipped weight; below it the outline thins out of the way of the stack behind it' }),
|
|
609
|
+
Object.freeze({ key: 'music', label: 'Music', kind: 'toggle', hint: 'Korobeiniki, on oscillators' }),
|
|
610
|
+
Object.freeze({ key: 'sfx', label: 'Sound effects', kind: 'toggle', hint: 'Move, rotate, drop, clear, game over' }),
|
|
611
|
+
Object.freeze({ key: 'neon', label: 'Neon pieces', kind: 'toggle', hint: 'The pieces and the stack as dim bodies under lit tubes' }),
|
|
612
|
+
Object.freeze({
|
|
613
|
+
key: 'neonSource', label: 'Neon colour from', kind: 'choice', hint: 'Each piece’s own colour, or all in one colour',
|
|
614
|
+
options: Object.freeze([['piece', 'The piece’s colour'], ['colour', 'One colour']]),
|
|
615
|
+
}),
|
|
616
|
+
Object.freeze({ key: 'neonColour', label: 'Neon colour', kind: 'colour', hint: 'The one colour, when chosen above' }),
|
|
617
|
+
Object.freeze({ key: 'neonBrightness', label: 'Neon brightness', kind: 'range', min: 0.2, max: 2, step: 0.1, hint: 'How hard the tubes glow' }),
|
|
618
|
+
Object.freeze({ key: 'grid', label: 'Grid', kind: 'toggle', hint: 'The grid under the well' }),
|
|
619
|
+
Object.freeze({ key: 'gridColour', label: 'Grid colour', kind: 'colour', hint: 'The colour of the grid under the well' }),
|
|
620
|
+
Object.freeze({ key: 'gridBrightness', label: 'Grid intensity', kind: 'range', min: 0, max: 2, step: 0.05, hint: 'How strongly the grid shows; 1 is the shipped weight, 0 hides it' }),
|
|
621
|
+
]),
|
|
622
|
+
}),
|
|
623
|
+
]);
|
|
624
|
+
/** The groups in the sheet's order: row by row of TAB_ROWS, then anything a row forgot (a test says nothing is). */
|
|
625
|
+
export const PANEL = Object.freeze([
|
|
626
|
+
...TAB_ROWS.flatMap((row) => row.groups.map((id) => PANEL_GROUPS.find((g) => g.group === id)).filter(Boolean)),
|
|
627
|
+
...PANEL_GROUPS.filter((g) => !TAB_ROWS.some((row) => row.groups.includes(g.group))),
|
|
628
|
+
]);
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
// "group.key" -> the row that defines it. The bounds live in exactly one place now.
|
|
632
|
+
const ROWS = new Map();
|
|
633
|
+
for (const g of PANEL) for (const r of g.rows) ROWS.set(`${g.group}.${r.key}`, r);
|
|
634
|
+
|
|
635
|
+
const clamp = (v, lo, hi, fallback) => {
|
|
636
|
+
const n = Number(v);
|
|
637
|
+
return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : fallback;
|
|
638
|
+
};
|
|
639
|
+
const bool = (v, fallback) => (typeof v === 'boolean' ? v : fallback);
|
|
640
|
+
|
|
641
|
+
/** Clamp a number to the bounds its own slider advertises. */
|
|
642
|
+
function clampRow(row, v, fallback) {
|
|
643
|
+
return clamp(v, row?.min ?? -Infinity, row?.max ?? Infinity, fallback);
|
|
644
|
+
}
|
|
645
|
+
/** Accept a choice only if its own control offers it. */
|
|
646
|
+
const HEX = /^#[0-9a-f]{6}$/i;
|
|
647
|
+
function pickRow(row, v, fallback) {
|
|
648
|
+
if (row?.kind === 'colour') return typeof v === 'string' && HEX.test(v) ? v.toLowerCase() : fallback;
|
|
649
|
+
const allowed = (row?.options ?? []).map(([val]) => val);
|
|
650
|
+
return allowed.includes(v) ? v : fallback;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// THE GRID'S COLOUR (operator, 2026-09-12: "we need to break out the green grid settings per game.
|
|
654
|
+
// We should also add a grid color picker, and a transparency slider. I don't want to see the grid
|
|
655
|
+
// in bitlaga for example, and I really want to turn down the intensity on blockanoid", and "Also
|
|
656
|
+
// add a color selector and brightness setting for the grid lighting for blockspace").
|
|
657
|
+
//
|
|
658
|
+
// A picker CANNOT simply overwrite one value. The board draws its grid as a family: an OPAQUE core
|
|
659
|
+
// with a translucent halo and glow around it and a brighter edge line over it, and that
|
|
660
|
+
// relationship is deliberate -- a see-through core reads dimmer wherever the floor beneath it is
|
|
661
|
+
// shadowed, and composite modes are off the table (details3d.js, "I want that neon light cutting
|
|
662
|
+
// through darkness entirely"). So one hex recolours the whole family while each layer keeps its
|
|
663
|
+
// RELATIVE alpha and lift toward white, and brightness multiplies those alphas together.
|
|
664
|
+
//
|
|
665
|
+
// [lift toward white, alpha at brightness 1, brighten first?] -- reverse-engineered from
|
|
666
|
+
// details3d.js's own tuned defaults, and MEASURED against them rather than guessed.
|
|
667
|
+
//
|
|
668
|
+
// The first cut lifted the base hue toward WHITE for the rings and got
|
|
669
|
+
// rgba(122,213,171) where the board draws rgba(40,255,140): the whole grid came out greyer. The
|
|
670
|
+
// palette does not lighten, it SATURATES -- every ring layer has its green channel pinned at 255 --
|
|
671
|
+
// so the ring colours brighten to full first, and only then lift toward white. Checked against the
|
|
672
|
+
// originals that puts gridEdgeColor at (123,255,194) against (120,255,190), and neonLine at
|
|
673
|
+
// (170,255,216) against (170,255,210). The core alone is the base hue untouched, which is exact.
|
|
674
|
+
const GRID_LAYERS = Object.freeze({
|
|
675
|
+
neonCell: [0, 1, false], // the opaque core: the chosen colour itself
|
|
676
|
+
neonHalo: [0, 0.07, true],
|
|
677
|
+
neonGlow: [0, 0.2, true],
|
|
678
|
+
gridGlow: [0, 0.05, true],
|
|
679
|
+
gridColor: [0, 0.16, true],
|
|
680
|
+
gridEdgeColor: [0.3, 1, true],
|
|
681
|
+
neonLine: [0.55, 1, true],
|
|
682
|
+
});
|
|
683
|
+
const rgbOf = (hex, fallback) => {
|
|
684
|
+
const m = /^#([0-9a-f]{6})$/i.exec(String(hex ?? ''));
|
|
685
|
+
if (!m) return fallback;
|
|
686
|
+
const n = parseInt(m[1], 16);
|
|
687
|
+
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
688
|
+
};
|
|
689
|
+
const liftRgb = ([r, g, b], t) => [r + (255 - r) * t, g + (255 - g) * t, b + (255 - b) * t].map((v) => Math.round(v));
|
|
690
|
+
const rgbaOf = ([r, g, b], a) => `rgba(${r},${g},${b},${Math.round(Math.max(0, Math.min(1, a)) * 1000) / 1000})`;
|
|
691
|
+
|
|
692
|
+
/** The neon form of a colour: the same hue with its brightest channel pushed to full. */
|
|
693
|
+
const brighten = ([r, g, b]) => {
|
|
694
|
+
const m = Math.max(r, g, b) || 1;
|
|
695
|
+
return [r, g, b].map((v) => Math.min(255, Math.round((v * 255) / m)));
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
/** The block-space board's grid layers, from one colour and a brightness. */
|
|
699
|
+
export function gridColours(hex, brightness = 1) {
|
|
700
|
+
const base = rgbOf(hex, [50, 190, 125]);
|
|
701
|
+
const k = clamp(brightness, 0, 2, 1);
|
|
702
|
+
const lit = brighten(base);
|
|
703
|
+
const out = {};
|
|
704
|
+
for (const [key, [lift, alpha, saturate]] of Object.entries(GRID_LAYERS)) {
|
|
705
|
+
const c = saturate ? lit : base;
|
|
706
|
+
out[key] = rgbaOf(lift ? liftRgb(c, lift) : c, alpha * k);
|
|
707
|
+
}
|
|
708
|
+
return out;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* A game court's grid. The courts deliberately suppress the halo and the glow -- a playfield wants
|
|
713
|
+
* a quiet grid under the pieces, not a lit one -- so only the core is coloured and the two ring
|
|
714
|
+
* layers stay off. `alpha` is the court's own weight: Tetrust draws its grid fainter (0.06) than
|
|
715
|
+
* the brick games do (0.18), which is how they were hand-tuned before this was configurable.
|
|
716
|
+
*/
|
|
717
|
+
export function courtGridColours(hex, brightness = 1, alpha = 0.18) {
|
|
718
|
+
const base = rgbOf(hex, [60, 200, 140]);
|
|
719
|
+
const k = clamp(brightness, 0, 2, 1);
|
|
720
|
+
// THE KEYS THE FLOOR ACTUALLY DRAWS WITH. This used to set neonCell, neonHalo and gridGlow only
|
|
721
|
+
// -- and on a `space` board the visible grid is NOT neonCell. details3d strokes the floor twice:
|
|
722
|
+
// `gridGlow` wide and faint, then `gridColor` thin and bright, with `gridEdgeColor` round the
|
|
723
|
+
// board's rim. neonCell feeds boardGridLayers, a different layer. So the picker recoloured
|
|
724
|
+
// something invisible while gridColor kept the engine's default green, and the slider scaled an
|
|
725
|
+
// alpha nobody could see: measured, red-at-1 against blue-at-2 differed in neonCell and in
|
|
726
|
+
// nothing else. That is the whole of "the slider does not update the games grid".
|
|
727
|
+
//
|
|
728
|
+
// The courts stay QUIET, which is the part worth keeping: the lit ring and the wide halo are the
|
|
729
|
+
// board's treatment, not a playfield's. The line is the chosen colour, the glow under it is the
|
|
730
|
+
// same colour at a fraction of the alpha, and the rim is a touch brighter so the court still has
|
|
731
|
+
// an edge. Everything scales with `alpha` (the court's own weight) and `k` (the operator's).
|
|
732
|
+
const lit = liftRgb(base, 0.35);
|
|
733
|
+
// `neonLine` IS THE COURT'S GRID. boardGridLayers splits the lattice with
|
|
734
|
+
// `(i % gridStep ? cell : line)`: cells take `neonCell`, every gridStep-th line takes `neonLine`.
|
|
735
|
+
// The courts set gridStep: 1, so `i % 1` is always 0 -- EVERY line takes neonLine and neonCell is
|
|
736
|
+
// never used on a court at all. Omitting neonLine left the whole lattice at the engine default
|
|
737
|
+
// rgba(170,255,210,1), an opaque pale green, while the rim obediently changed colour: "grid color
|
|
738
|
+
// only seems to affect the border, not the grid itself for the games. I can't change the entire
|
|
739
|
+
// grid color from green."
|
|
740
|
+
// It is stroked at a higher alpha than the cell lines because it is the ONLY line on these
|
|
741
|
+
// boards -- at 0.18 the court would be a whisper -- and it is the colour that was picked, not a
|
|
742
|
+
// lifted one, so what is chosen is what is seen.
|
|
743
|
+
return {
|
|
744
|
+
neonLine: rgbaOf(base, Math.min(1, alpha * 3.6 * k)),
|
|
745
|
+
gridColor: rgbaOf(base, alpha * k),
|
|
746
|
+
gridGlow: rgbaOf(base, alpha * 0.35 * k),
|
|
747
|
+
gridEdgeColor: rgbaOf(lit, Math.min(1, alpha * 3.2 * k)),
|
|
748
|
+
neonCell: rgbaOf(base, alpha * k),
|
|
749
|
+
neonHalo: 'rgba(0,0,0,0)',
|
|
750
|
+
neonGlow: 'rgba(0,0,0,0)',
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* v1 -> v2: the sky keys move out of `markets` into their own group. A v1 store keeps the values
|
|
756
|
+
* the operator chose; it does not get them reset for having been written yesterday.
|
|
757
|
+
* A store with no `version` at all is v1: that is every store written before this.
|
|
758
|
+
*/
|
|
759
|
+
const MIGRATIONS = {
|
|
760
|
+
1: (raw) => {
|
|
761
|
+
const mk = raw.markets && typeof raw.markets === 'object' ? raw.markets : {};
|
|
762
|
+
const { starDensity, starBrightness, ...markets } = mk;
|
|
763
|
+
const moved = {};
|
|
764
|
+
if (starDensity !== undefined) moved.density = starDensity;
|
|
765
|
+
if (starBrightness !== undefined) moved.brightness = starBrightness;
|
|
766
|
+
// anything already under `sky` wins: it was written by a newer schema than the one being read
|
|
767
|
+
return { ...raw, markets, sky: { ...moved, ...(raw.sky && typeof raw.sky === 'object' ? raw.sky : {}) } };
|
|
768
|
+
},
|
|
769
|
+
// v2 -> v3: `markets.glow` is dropped. The markets board never draws the grid glow now, so the
|
|
770
|
+
// stored value has nothing left to mean. Written out rather than left to normalise (which would
|
|
771
|
+
// drop the key anyway) so the chain says what changed and when.
|
|
772
|
+
2: (raw) => {
|
|
773
|
+
const mk = raw.markets && typeof raw.markets === 'object' ? raw.markets : {};
|
|
774
|
+
const { glow, ...markets } = mk;
|
|
775
|
+
return { ...raw, markets };
|
|
776
|
+
},
|
|
777
|
+
// v3 -> v4: the price board gets its own effects group. Until now one list governed both boards,
|
|
778
|
+
// so the store's list is the operator's choice for both: it is copied to `marketEffects` (normalise
|
|
779
|
+
// drops the keys the price board does not offer). A store that already has the group keeps it.
|
|
780
|
+
3: (raw) => {
|
|
781
|
+
if (raw.marketEffects && typeof raw.marketEffects === 'object') return raw;
|
|
782
|
+
const fx = raw.effects && typeof raw.effects === 'object' ? raw.effects : {};
|
|
783
|
+
return { ...raw, marketEffects: { ...fx } };
|
|
784
|
+
},
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
function migrate(raw) {
|
|
788
|
+
if (!raw || typeof raw !== 'object') return raw;
|
|
789
|
+
let v = Number.isFinite(raw.version) ? raw.version : 1;
|
|
790
|
+
// Written by a build newer than this one: its shape is unknown, so the honest answer is the
|
|
791
|
+
// defaults rather than a guess at what its keys meant.
|
|
792
|
+
if (v > SCHEMA_VERSION) return null;
|
|
793
|
+
let out = raw;
|
|
794
|
+
while (v < SCHEMA_VERSION) {
|
|
795
|
+
const step = MIGRATIONS[v];
|
|
796
|
+
if (!step) return null;
|
|
797
|
+
out = step(out);
|
|
798
|
+
v++;
|
|
799
|
+
}
|
|
800
|
+
return out;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Merge anything (a parsed store, a patch) onto the defaults, clamping every value.
|
|
805
|
+
* Driven by DEFAULTS and PANEL rather than written out key by key, so adding a setting is one
|
|
806
|
+
* entry in each and a new key cannot arrive unclamped or unlisted.
|
|
807
|
+
*/
|
|
808
|
+
export function normalise(raw) {
|
|
809
|
+
const s = raw && typeof raw === 'object' ? raw : {};
|
|
810
|
+
const out = {};
|
|
811
|
+
for (const group of Object.keys(DEFAULTS)) {
|
|
812
|
+
const given = s[group] && typeof s[group] === 'object' ? s[group] : {};
|
|
813
|
+
const settled = {};
|
|
814
|
+
for (const [key, def] of Object.entries(DEFAULTS[group])) {
|
|
815
|
+
const row = ROWS.get(`${group}.${key}`);
|
|
816
|
+
const v = given[key];
|
|
817
|
+
if (typeof def === 'boolean') settled[key] = bool(v, def);
|
|
818
|
+
else if (typeof def === 'number') settled[key] = clampRow(row, v, def);
|
|
819
|
+
else settled[key] = pickRow(row, v, def);
|
|
820
|
+
}
|
|
821
|
+
out[group] = Object.freeze(settled);
|
|
822
|
+
}
|
|
823
|
+
return Object.freeze(out);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// Parsing localStorage on every board paint was a JSON.parse per frame (markets.js and mining.js
|
|
827
|
+
// both call loadSettings() as they draw). The parse is memoised on the raw string, so an
|
|
828
|
+
// unchanged store costs a getItem and a comparison.
|
|
829
|
+
let cache = { raw: null, value: null };
|
|
830
|
+
|
|
831
|
+
export function loadSettings(storage = globalThis.localStorage) {
|
|
832
|
+
try {
|
|
833
|
+
const raw = storage?.getItem(SETTINGS_KEY) ?? null;
|
|
834
|
+
if (cache.value && cache.raw === raw) return cache.value;
|
|
835
|
+
const value = normalise(migrate(raw ? JSON.parse(raw) : null));
|
|
836
|
+
cache = { raw, value };
|
|
837
|
+
return value;
|
|
838
|
+
} catch {
|
|
839
|
+
return normalise(null); // unreadable or corrupt: the defaults, never a crash
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// Consumers that want to know the moment a value changes, rather than waiting for the next paint.
|
|
844
|
+
const listeners = new Set();
|
|
845
|
+
/** Subscribe to settled settings. Returns an unsubscribe. */
|
|
846
|
+
export function onSettingsChange(fn) {
|
|
847
|
+
listeners.add(fn);
|
|
848
|
+
return () => listeners.delete(fn);
|
|
849
|
+
}
|
|
850
|
+
function emit(s) {
|
|
851
|
+
for (const fn of [...listeners]) {
|
|
852
|
+
try { fn(s); } catch { /* a broken listener must not break the save */ }
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// THE SERVER HOLDS THESE NOW (operator, 2026-09-13: "This is a server app. Should store things on
|
|
857
|
+
// a server"). localStorage stays as a LOCAL CACHE of what the server has, for one reason:
|
|
858
|
+
// loadSettings() is called on every board paint and must answer synchronously -- fetching per paint
|
|
859
|
+
// is not on the table, and waiting on a fetch before the first render would stall the page. So the
|
|
860
|
+
// boot seeds this cache from GET /api/settings (app.js), every change writes through to
|
|
861
|
+
// POST /api/settings, and a browser that cannot reach the server still draws with the last values
|
|
862
|
+
// it saw instead of snapping back to the defaults.
|
|
863
|
+
let push = null; // injected by app.js: (settled) => Promise, debounced below
|
|
864
|
+
let pushTimer = null;
|
|
865
|
+
/** app.js hands us the poster once it has a CSRF-capable api(). */
|
|
866
|
+
export function setSettingsPush(fn) { push = fn; }
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Seed the cache from the server, before the first paint.
|
|
870
|
+
* Returns the settled settings so the caller can tell whether anything was stored.
|
|
871
|
+
*/
|
|
872
|
+
export function seedSettings(stored, storage = globalThis.localStorage) {
|
|
873
|
+
const s = normalise(migrate(stored));
|
|
874
|
+
try { storage?.setItem(SETTINGS_KEY, JSON.stringify({ version: SCHEMA_VERSION, ...s })); } catch { /* fine */ }
|
|
875
|
+
cache = { raw: null, value: null };
|
|
876
|
+
emit(s);
|
|
877
|
+
return s;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// Coalesced: dragging a slider fires an input event per step, and each one must not be its own
|
|
881
|
+
// write to disk. The value is already applied locally and on screen; the file catches up.
|
|
882
|
+
function pushSoon(s) {
|
|
883
|
+
if (!push) return;
|
|
884
|
+
if (pushTimer) clearTimeout(pushTimer);
|
|
885
|
+
pushTimer = setTimeout(() => {
|
|
886
|
+
pushTimer = null;
|
|
887
|
+
try { Promise.resolve(push(s)).catch(() => {}); } catch { /* offline: the local cache stands */ }
|
|
888
|
+
}, 400);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
export function saveSettings(next, storage = globalThis.localStorage) {
|
|
892
|
+
const s = normalise(next);
|
|
893
|
+
try {
|
|
894
|
+
storage?.setItem(SETTINGS_KEY, JSON.stringify({ version: SCHEMA_VERSION, ...s }));
|
|
895
|
+
} catch { /* private mode, quota: keep it in memory */ }
|
|
896
|
+
cache = { raw: null, value: null };
|
|
897
|
+
emit(s);
|
|
898
|
+
pushSoon(s);
|
|
899
|
+
return s;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Set one value by "group.key" and persist the result. Returns the whole settled object.
|
|
904
|
+
*
|
|
905
|
+
* An unknown path THROWS. It used to be ignored, which made a typo indistinguishable from a
|
|
906
|
+
* saved setting -- the control would move and nothing would persist. Every path comes from PANEL
|
|
907
|
+
* (app.js builds the markup from it, and a test asserts every row names a real setting), so this
|
|
908
|
+
* is unreachable unless something is genuinely wrong, and then it should say so.
|
|
909
|
+
*/
|
|
910
|
+
export function setSetting(current, path, value, storage = globalThis.localStorage) {
|
|
911
|
+
const [group, key] = String(path).split('.');
|
|
912
|
+
const base = normalise(current);
|
|
913
|
+
if (!base[group] || !(key in base[group])) {
|
|
914
|
+
throw new TypeError(`unknown setting "${path}" (groups: ${Object.keys(DEFAULTS).join(', ')})`);
|
|
915
|
+
}
|
|
916
|
+
return saveSettings({ ...base, [group]: { ...base[group], [key]: value } }, storage);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
export function resetSettings(storage = globalThis.localStorage) {
|
|
920
|
+
try { storage?.removeItem(SETTINGS_KEY); } catch { /* nothing to remove */ }
|
|
921
|
+
cache = { raw: null, value: null };
|
|
922
|
+
const s = normalise(null);
|
|
923
|
+
emit(s);
|
|
924
|
+
// Reset is a change like any other: the server has to hear it, or the next browser to open the
|
|
925
|
+
// page would be handed the settings this one just discarded.
|
|
926
|
+
pushSoon(s);
|
|
927
|
+
return s;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/** True when nothing has been changed from the shipped defaults. */
|
|
931
|
+
export function isDefault(s) {
|
|
932
|
+
return JSON.stringify(normalise(s)) === JSON.stringify(normalise(null));
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* The renderer options for a block-space board (mining.js: Overview, Block space, Mempool, Kiosk).
|
|
937
|
+
* Merged OVER the caller's own options, so a mode's resolution and slab still win where they are
|
|
938
|
+
* the point of the mode; these are the user's preferences about how it is drawn.
|
|
939
|
+
*/
|
|
940
|
+
export function spaceOptions(s) {
|
|
941
|
+
const n = normalise(s);
|
|
942
|
+
const sp = n.space;
|
|
943
|
+
const d = DETAIL[sp.detail] ?? DETAIL.full;
|
|
944
|
+
const out = {
|
|
945
|
+
shadows: sp.shadows,
|
|
946
|
+
idleFx: sp.idleFx,
|
|
947
|
+
grid: sp.grid,
|
|
948
|
+
neon: sp.neon,
|
|
949
|
+
sheen: sp.sheen,
|
|
950
|
+
sheenStyle: sp.sheenStyle,
|
|
951
|
+
// `space` is the board STYLE (no deck texture, a translucent floor); `stars` is the sky. They
|
|
952
|
+
// travel together here, which is the block-space board's shipped behaviour, but they are two
|
|
953
|
+
// options now so the markets board can keep its style while turning its sky off.
|
|
954
|
+
space: sp.stars,
|
|
955
|
+
stars: sp.stars,
|
|
956
|
+
dome: sp.dome,
|
|
957
|
+
facetPx: d.facetPx,
|
|
958
|
+
crownPx: d.crownPx,
|
|
959
|
+
// the same sky the markets board draws: this board had stars and no way to thin them
|
|
960
|
+
starDensity: n.sky.density,
|
|
961
|
+
starBrightness: n.sky.brightness,
|
|
962
|
+
galaxy: n.sky.galaxy,
|
|
963
|
+
galaxyAt: n.sky.galaxyAt,
|
|
964
|
+
nebulae: n.sky.nebulae, galaxies: n.sky.galaxies, dust: n.sky.dust, clusters: n.sky.clusters,
|
|
965
|
+
starColours: n.sky.colours, starGlints: n.sky.glints,
|
|
966
|
+
};
|
|
967
|
+
// the seam belongs to the Stone edges switch at every level of detail: a control that does
|
|
968
|
+
// nothing in one mode is worse than no control (operator: "stone edges don't work in flat tile
|
|
969
|
+
// display mode"). Detail decides facets and the crown; this decides the outline.
|
|
970
|
+
out.edges = sp.edges;
|
|
971
|
+
if (!sp.edges) out.seamAlpha = 0;
|
|
972
|
+
const motion = MOTION[sp.motion];
|
|
973
|
+
if (motion) out.transition = motion;
|
|
974
|
+
out.departures = sp.departures;
|
|
975
|
+
out.light = sp.light;
|
|
976
|
+
// merged into the camera by details3d (it owns the oblique constants); 0 leaves it exactly as it
|
|
977
|
+
// has always been, so the switch costs nothing until someone moves it
|
|
978
|
+
out.obliqueRise = sp.perspective;
|
|
979
|
+
out.fxKinds = enabledEffects(n);
|
|
980
|
+
out.fxNoRepeat = n.effects.noRepeat;
|
|
981
|
+
Object.assign(out, fxCadence(n.effects));
|
|
982
|
+
out.neonSource = sp.neonSource; out.neonColour = sp.neonColour; out.neonBrightness = sp.neonBrightness;
|
|
983
|
+
// THE GRID'S OWN COLOUR. Until now these were never set here at all, so the board fell through to
|
|
984
|
+
// the hand-tuned greens in details3d.js and there was no way to change them. The shipped values
|
|
985
|
+
// reproduce those greens, so this is a new control rather than a new look.
|
|
986
|
+
Object.assign(out, gridColours(sp.gridColour, sp.gridBrightness));
|
|
987
|
+
return out;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* The game's switches, with the sky's density, brightness and layers from the Sky group: the game
|
|
992
|
+
* decides whether there is a sky and a galaxy and where the galaxy sits; what the sky is made of
|
|
993
|
+
* is one preference for every board.
|
|
994
|
+
*/
|
|
995
|
+
// A slider's value as the panel prints it: to its step's decimals, always (operator, 2026-09-14: "these
|
|
996
|
+
// sliders jump around when changing values. Many of our bars do this"). The panel row is a grid whose
|
|
997
|
+
// control column sizes to fit, and the value was printed with String(): `1`, `0.9`, `0.35`, `0.0005`
|
|
998
|
+
// -- a different width almost every notch, so the column resized and the slider moved under the
|
|
999
|
+
// pointer. Fixed decimals make every value of a row the same width, and the CSS box (.cfgrow .val,
|
|
1000
|
+
// 6.5ch) holds the widest any row can print, which test/settings.test.js checks for every slider.
|
|
1001
|
+
export function formatRangeValue(step, v) {
|
|
1002
|
+
const s = String(step);
|
|
1003
|
+
const decimals = s.includes('e-') ? Number(s.split('e-')[1]) : (s.split('.')[1] ?? '').length;
|
|
1004
|
+
return Number(v).toFixed(decimals);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
export function enabledEffects(s, group = 'effects') {
|
|
1008
|
+
const n = normalise(s);
|
|
1009
|
+
return Object.keys(n[group]).filter((k) => n[group][k] === true);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/** Blockout's switches, with the sky's make-up from the Sky group (as tetrustOptions does). */
|
|
1013
|
+
export function blockoutOptions(s) {
|
|
1014
|
+
const n = normalise(s);
|
|
1015
|
+
const sky = spaceOptions(s);
|
|
1016
|
+
return {
|
|
1017
|
+
stars: n.blockout.stars, galaxy: n.blockout.galaxy, galaxyAt: n.blockout.galaxyAt, sfx: n.blockout.sfx,
|
|
1018
|
+
neon: n.blockout.neon,
|
|
1019
|
+
// the engine calls the data-coloured source "temperature"; here that is the brick's own row
|
|
1020
|
+
neonSource: n.blockout.neonSource === 'colour' ? 'colour' : 'temperature',
|
|
1021
|
+
neonColour: n.blockout.neonColour, neonBrightness: n.blockout.neonBrightness,
|
|
1022
|
+
grid: n.blockout.grid, gridColour: n.blockout.gridColour, gridBrightness: n.blockout.gridBrightness,
|
|
1023
|
+
// composed here rather than in the screen, so the court needs no new import and the renderer
|
|
1024
|
+
// keys stay in one place. 0.18 is the weight this court was hand-tuned at.
|
|
1025
|
+
gridOpts: courtGridColours(n.blockout.gridColour, n.blockout.gridBrightness, 0.18),
|
|
1026
|
+
starDensity: sky.starDensity, starBrightness: sky.starBrightness,
|
|
1027
|
+
nebulae: sky.nebulae, galaxies: sky.galaxies, dust: sky.dust, clusters: sky.clusters,
|
|
1028
|
+
starColours: sky.starColours, starGlints: sky.starGlints,
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/** Blockanoid's switches. Blockout's shape, plus the two that are Arkanoid's own. */
|
|
1033
|
+
export function blockanoidOptions(s) {
|
|
1034
|
+
const n = normalise(s);
|
|
1035
|
+
const sky = spaceOptions(s);
|
|
1036
|
+
return {
|
|
1037
|
+
stars: n.blockanoid.stars, galaxy: n.blockanoid.galaxy, galaxyAt: n.blockanoid.galaxyAt, sfx: n.blockanoid.sfx,
|
|
1038
|
+
capsules: n.blockanoid.capsules, enemies: n.blockanoid.enemies,
|
|
1039
|
+
neon: n.blockanoid.neon,
|
|
1040
|
+
neonSource: n.blockanoid.neonSource === 'colour' ? 'colour' : 'temperature',
|
|
1041
|
+
neonColour: n.blockanoid.neonColour, neonBrightness: n.blockanoid.neonBrightness,
|
|
1042
|
+
grid: n.blockanoid.grid, gridColour: n.blockanoid.gridColour, gridBrightness: n.blockanoid.gridBrightness,
|
|
1043
|
+
gridOpts: courtGridColours(n.blockanoid.gridColour, n.blockanoid.gridBrightness, 0.18),
|
|
1044
|
+
starDensity: sky.starDensity, starBrightness: sky.starBrightness,
|
|
1045
|
+
nebulae: sky.nebulae, galaxies: sky.galaxies, dust: sky.dust, clusters: sky.clusters,
|
|
1046
|
+
starColours: sky.starColours, starGlints: sky.starGlints,
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
export function tetrustOptions(s) {
|
|
1051
|
+
const n = normalise(s);
|
|
1052
|
+
const sky = spaceOptions(s);
|
|
1053
|
+
return {
|
|
1054
|
+
stars: n.tetrust.stars, galaxy: n.tetrust.galaxy, galaxyAt: n.tetrust.galaxyAt, music: n.tetrust.music, sfx: n.tetrust.sfx,
|
|
1055
|
+
ghostColour: n.tetrust.ghostColour, ghostWidth: n.tetrust.ghostWidth,
|
|
1056
|
+
neon: n.tetrust.neon, neonSource: n.tetrust.neonSource === 'colour' ? 'colour' : 'temperature', neonColour: n.tetrust.neonColour, neonBrightness: n.tetrust.neonBrightness,
|
|
1057
|
+
grid: n.tetrust.grid, gridColour: n.tetrust.gridColour, gridBrightness: n.tetrust.gridBrightness,
|
|
1058
|
+
// 0.06, not 0.18: the well draws its grid fainter than the brick courts do, because the stack
|
|
1059
|
+
// sits on top of it. That difference was hardcoded in tetrust.js; it lives here now.
|
|
1060
|
+
gridOpts: courtGridColours(n.tetrust.gridColour, n.tetrust.gridBrightness, 0.06),
|
|
1061
|
+
starDensity: sky.starDensity, starBrightness: sky.starBrightness,
|
|
1062
|
+
nebulae: sky.nebulae, galaxies: sky.galaxies, dust: sky.dust, clusters: sky.clusters, starColours: sky.starColours, starGlints: sky.starGlints,
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/** The renderer options for the markets board (markets.js board3d). */
|
|
1067
|
+
export function marketsOptions(s) {
|
|
1068
|
+
const n = normalise(s);
|
|
1069
|
+
const mk = n.markets;
|
|
1070
|
+
return {
|
|
1071
|
+
// ALWAYS a space board: `space` carries the deck texture, the floor and the floor line as well
|
|
1072
|
+
// as the sky, so driving it from the star switch restyled the whole board when the operator
|
|
1073
|
+
// only wanted the stars gone. The sky has its own option now.
|
|
1074
|
+
space: true,
|
|
1075
|
+
stars: mk.stars,
|
|
1076
|
+
starDensity: n.sky.density,
|
|
1077
|
+
starBrightness: n.sky.brightness,
|
|
1078
|
+
galaxy: n.sky.galaxy,
|
|
1079
|
+
galaxyAt: n.sky.galaxyAt,
|
|
1080
|
+
nebulae: n.sky.nebulae, galaxies: n.sky.galaxies, dust: n.sky.dust, clusters: n.sky.clusters,
|
|
1081
|
+
starColours: n.sky.colours, starGlints: n.sky.glints,
|
|
1082
|
+
// never, at any setting: the halo under the grid lines is not wanted on this board
|
|
1083
|
+
neonHalo: 'rgba(0,0,0,0)',
|
|
1084
|
+
gridGlow: 'rgba(0,0,0,0)',
|
|
1085
|
+
// One switch for everything that MOVES on this board (operator, 2026-09-12: "we need a toggle
|
|
1086
|
+
// for disable effects in the market and price"). The candle board inherited idleFx from the
|
|
1087
|
+
// renderer's defaults and ran the refresh flight, and neither had a control of its own: the
|
|
1088
|
+
// Block space switches next to them govern a different board entirely.
|
|
1089
|
+
...(mk.effects ? {} : { idleFx: false, transition: MOTION.still }),
|
|
1090
|
+
// ITS OWN SWITCHES (operator, 2026-09-14: "settings specific to market panel"): the Market
|
|
1091
|
+
// effects group, not the block board's
|
|
1092
|
+
fxKinds: enabledEffects(n, 'marketEffects'),
|
|
1093
|
+
fxNoRepeat: n.marketEffects.noRepeat,
|
|
1094
|
+
...fxCadence(n.marketEffects),
|
|
1095
|
+
};
|
|
1096
|
+
}
|