@jossuealcala/madre 0.7.2 → 1.0.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.
Files changed (163) hide show
  1. package/CHANGELOG.md +773 -1
  2. package/README.md +92 -28
  3. package/bin/madre.mjs +30 -10
  4. package/docs/REFERENCE.md +157 -81
  5. package/docs/SDK.md +23 -19
  6. package/package.json +1 -1
  7. package/public/app.js +11207 -4196
  8. package/public/assets/brand/Geist-latin-ext.woff2 +0 -0
  9. package/public/assets/brand/Geist-latin.woff2 +0 -0
  10. package/public/assets/brand/GeistMono-latin-ext.woff2 +0 -0
  11. package/public/assets/brand/GeistMono-latin.woff2 +0 -0
  12. package/public/assets/brand/JetBrainsMono-latin-ext.woff2 +0 -0
  13. package/public/assets/brand/JetBrainsMono-latin.woff2 +0 -0
  14. package/public/assets/brand/OFL-Geist.txt +93 -0
  15. package/public/assets/brand/OFL-GeistMono.txt +93 -0
  16. package/public/assets/brand/OFL-JetBrainsMono.txt +93 -0
  17. package/public/assets/brand/OFL-Sora.txt +93 -0
  18. package/public/assets/brand/Sora-latin-ext.woff2 +0 -0
  19. package/public/assets/brand/Sora-latin.woff2 +0 -0
  20. package/public/assets/brand/madre-app-180.png +0 -0
  21. package/public/assets/brand/madre-app-192.png +0 -0
  22. package/public/assets/brand/madre-app-512.png +0 -0
  23. package/public/assets/brand/madre-app.svg +1 -0
  24. package/public/assets/brand/madre-favicon.svg +1 -0
  25. package/public/assets/news/1.0/alerts.webp +0 -0
  26. package/public/assets/news/1.0/bar.webp +0 -0
  27. package/public/assets/news/1.0/finder.webp +0 -0
  28. package/public/assets/news/1.0/github.webp +0 -0
  29. package/public/assets/news/1.0/intents.webp +0 -0
  30. package/public/assets/news/1.0/looks.webp +0 -0
  31. package/public/assets/news/1.0/modules.webp +0 -0
  32. package/public/assets/news/1.0/mother.webp +0 -0
  33. package/public/assets/news/1.0/nostromo.webp +0 -0
  34. package/public/assets/news/1.0/permissions.webp +0 -0
  35. package/public/assets/news/1.0/room.webp +0 -0
  36. package/public/assets/news/1.0/settings.webp +0 -0
  37. package/public/assets/wallpapers/biznaga.webp +0 -0
  38. package/public/assets/wallpapers/canela.webp +0 -0
  39. package/public/assets/wallpapers/jarrito.webp +0 -0
  40. package/public/assets/wallpapers/magna.webp +0 -0
  41. package/public/assets/wallpapers/oceano.webp +0 -0
  42. package/public/assets/wallpapers/paraiso.webp +0 -0
  43. package/public/assets/wallpapers/quila.webp +0 -0
  44. package/public/assets/wallpapers/tradicion.webp +0 -0
  45. package/public/assets/wallpapers/tropical.webp +0 -0
  46. package/public/assets/wallpapers/urbania.webp +0 -0
  47. package/public/assets/wallpapers/ventura.webp +0 -0
  48. package/public/assets/wallpapers/xico.webp +0 -0
  49. package/public/brands.js +28 -2
  50. package/public/editor.js +744 -0
  51. package/public/es.js +2784 -1026
  52. package/public/i18n.js +1 -1
  53. package/public/index.html +201 -175
  54. package/public/inquiry.js +11 -11
  55. package/public/loader.js +79 -0
  56. package/public/macroverso.js +1497 -0
  57. package/public/manifest.webmanifest +14 -0
  58. package/public/nostromo-engine.js +2222 -0
  59. package/public/resay.js +24 -0
  60. package/public/review-ask.js +85 -0
  61. package/public/salas-mac.css +787 -0
  62. package/public/salas-mac.html +154 -0
  63. package/public/salas-mac.js +3264 -0
  64. package/public/salas.css +85 -0
  65. package/public/salas.html +47 -0
  66. package/public/salas.js +200 -0
  67. package/public/styles.css +6207 -425
  68. package/public/touch.js +83 -0
  69. package/public/tour-chapters.js +342 -0
  70. package/public/tour.js +510 -0
  71. package/public/troubleshooting.js +159 -140
  72. package/src/adapters/codex.mjs +46 -6
  73. package/src/adapters/gemini.mjs +30 -10
  74. package/src/adapters/madre.mjs +110 -18
  75. package/src/adapters/process.mjs +42 -2
  76. package/src/auth-probe.mjs +2 -2
  77. package/src/bridge/devices.mjs +202 -0
  78. package/src/bridge/handoff.mjs +45 -0
  79. package/src/bridge/host.mjs +82 -0
  80. package/src/bridge/network.mjs +133 -0
  81. package/src/bridge/policy.mjs +89 -0
  82. package/src/bridge/qr.mjs +287 -0
  83. package/src/bridge/server.mjs +269 -0
  84. package/src/capabilities.mjs +5 -9
  85. package/src/chats.mjs +59 -22
  86. package/src/checkpoint.mjs +137 -35
  87. package/src/choices.mjs +18 -3
  88. package/src/cli-args.mjs +12 -1
  89. package/src/commands.mjs +15 -15
  90. package/src/config.mjs +73 -9
  91. package/src/conversation-context.mjs +5 -2
  92. package/src/dataset.mjs +3 -1
  93. package/src/directives.mjs +0 -5
  94. package/src/enlaces.mjs +451 -0
  95. package/src/event-store.mjs +55 -10
  96. package/src/exam.mjs +5 -5
  97. package/src/extensions.mjs +0 -2
  98. package/src/file-ops.mjs +23 -0
  99. package/src/files.mjs +52 -2
  100. package/src/github.mjs +407 -0
  101. package/src/json-file.mjs +63 -0
  102. package/src/lease.mjs +2 -2
  103. package/src/lobby-work.mjs +103 -0
  104. package/src/looks.mjs +55 -0
  105. package/src/maturity.mjs +3 -3
  106. package/src/mcp/github-server.mjs +164 -0
  107. package/src/mcp/image-server.mjs +1 -1
  108. package/src/mcp/memory-server.mjs +11 -3
  109. package/src/memory.mjs +38 -12
  110. package/src/models.mjs +89 -5
  111. package/src/modules/ahp.mjs +4 -4
  112. package/src/modules/ash.mjs +1 -1
  113. package/src/modules/bishop-plan.mjs +207 -0
  114. package/src/modules/bishop.mjs +645 -0
  115. package/src/modules/choices.mjs +2 -2
  116. package/src/modules/correo.mjs +1 -1
  117. package/src/modules/enlaces.mjs +255 -0
  118. package/src/modules/git-pulse.mjs +2 -2
  119. package/src/modules/github.mjs +322 -0
  120. package/src/modules/image-studio.mjs +3 -3
  121. package/src/modules/index.mjs +18 -4
  122. package/src/modules/intents-calibration.mjs +338 -0
  123. package/src/modules/intents-desk.mjs +481 -0
  124. package/src/modules/intents-observe.mjs +266 -0
  125. package/src/modules/intents-policy.mjs +414 -0
  126. package/src/modules/intents-try.mjs +91 -0
  127. package/src/modules/intents.mjs +145 -0
  128. package/src/modules/ollama.mjs +4 -4
  129. package/src/modules/playwright.mjs +121 -11
  130. package/src/modules/ripley.mjs +1 -1
  131. package/src/modules/runs.mjs +2 -2
  132. package/src/modules/sdk.mjs +10 -4
  133. package/src/ollama.mjs +6 -1
  134. package/src/outbound.mjs +19 -17
  135. package/src/override.mjs +100 -0
  136. package/src/ports.mjs +23 -1
  137. package/src/prices.mjs +72 -0
  138. package/src/privacy.mjs +30 -11
  139. package/src/quota-sources.mjs +0 -8
  140. package/src/request-guard.mjs +1 -1
  141. package/src/review-ask.mjs +111 -0
  142. package/src/room/archivist.mjs +55 -37
  143. package/src/room/budget.mjs +17 -5
  144. package/src/room/control.mjs +70 -7
  145. package/src/room/economy.mjs +35 -7
  146. package/src/room/escalation.mjs +2 -0
  147. package/src/room/guard.mjs +26 -2
  148. package/src/room/prompt.mjs +22 -15
  149. package/src/room/runs.mjs +84 -21
  150. package/src/room/vectors.mjs +19 -4
  151. package/src/room.mjs +351 -65
  152. package/src/router.mjs +3 -1
  153. package/src/runs.mjs +23 -5
  154. package/src/salas.mjs +313 -0
  155. package/src/sentinel-errors.mjs +1 -1
  156. package/src/server-sentinel.mjs +124 -0
  157. package/src/server.mjs +1526 -163
  158. package/src/setup.mjs +1 -0
  159. package/src/tour.mjs +159 -0
  160. package/src/updates.mjs +48 -8
  161. package/src/usage-sentinel.mjs +1 -1
  162. package/src/verdict.mjs +7 -7
  163. package/src/whats-new.mjs +160 -0
@@ -0,0 +1,2222 @@
1
+ // NOSTROMO's engine: the physics and the painters of the archive as a body in space, lifted out
2
+ // of the room's page so the Macroverso can draw every room the way its own NOSTROMO is drawn —
3
+ // with these very functions, not a copy of them (docs/MACROVERSO.md, phase 3a).
4
+ //
5
+ // What is shared lives at the top of the file: constants, pure maths, and the textures a star of
6
+ // a class is built from once and worn by every memory of that class in every scene. What belongs
7
+ // to one scene — the bodies, the camera, the pulses, the painted face of MOTHER, the budget of
8
+ // faces painted this frame — lives on the scene object `createNostromo` is handed, and the
9
+ // functions it returns close over it. That object keeps the name `nostromo` inside, because the
10
+ // functions were written against it and read exactly as they always did.
11
+ //
12
+ // The engine never touches the room's page. The three things the drawing needs from it — words in
13
+ // the room's language, how much of the canvas the open card covers, what to shake when CODE000
14
+ // lands — are hooks on the scene (`words`, `covered`, `onClang`), and each has a default.
15
+
16
+ // Memories burn as stars burn, and a star's colour is its class. A decision is a yellow dwarf,
17
+ // the steady kind a system is built around. A fact is a white dwarf: cold, dense, settled. A
18
+ // preference is a green dwarf, personal and very long lived. A question is a blue dwarf, the
19
+ // hottest thing in the sky and the least settled. One palette for the room, the Macroverso and
20
+ // the stylesheet (`--mem-*`): a test keeps them the same.
21
+ export const MEMORY_COLORS = { decision: '#ffdc3c', fact: '#dfeeff', preference: '#5cf07a', question: '#3dc6ff', aberration: '#b14cff' };
22
+ // What a zone changes on the map is whether the star is lit: only what still travels into turns
23
+ // burns. The rule is the archive's (`travels()` in src/memory.mjs); this is the client's copy.
24
+ export const UNDERWAY = new Set(['hold', 'bridge']);
25
+ export const travels = (memory) => UNDERWAY.has(memory?.zone ?? 'hold');
26
+
27
+ // One kind is not a star. An aberration is a claim the room established is false, and it burns
28
+ // nothing: it is a collapsed body with a ring of what fell into it, and it gives off no light
29
+ // of its own. It is drawn dark on purpose, because it is the one thing in here nobody should
30
+ // read as knowledge.
31
+ export const COLLAPSED = 'aberration';
32
+ // The field of dust and stars a void sits in. Laid out once and shared: every aberration wears
33
+ // the same field turned to its own angle, so a hundred of them cost one table.
34
+ export const VOID_DUST = Array.from({ length: 64 }, (_, i) => {
35
+ const spiral = i * 2.39996; // golden angle: no clumps, no rows
36
+ const out = Math.sqrt((i + 0.6) / 64); // even by area, so it does not crowd the middle
37
+ const warm = i % 9 === 0;
38
+ return {
39
+ angle: spiral,
40
+ out,
41
+ size: 0.5 + ((i * 7919) % 100) / 100 * 1.5,
42
+ rate: 0.6 + ((i * 6151) % 100) / 100 * 1.9,
43
+ phase: ((i * 4133) % 100) / 100 * Math.PI * 2,
44
+ colour: warm ? '255, 226, 168' : ((i % 4) ? '226, 238, 255' : '176, 204, 255'),
45
+ };
46
+ });
47
+
48
+ // How alive a memory is: how often the room has actually recalled it, how recently, and how
49
+ // woven it is into the rest.
50
+ export function rawActivity(memory, degree = 0) {
51
+ const use = 1 - Math.exp(-Number(memory.recalled ?? 0) / 4);
52
+ const since = memory.lastRecalled ? (Date.now() - Date.parse(memory.lastRecalled)) / 86400000 : null;
53
+ const fresh = since === null || Number.isNaN(since) ? 0 : Math.exp(-Math.max(0, since) / 3);
54
+ const woven = Math.min(1, degree / 4);
55
+ return 0.55 * use + 0.3 * fresh + 0.15 * woven;
56
+ }
57
+
58
+ // The constellation is read against itself: the busiest memory is the brightest and the rest
59
+ // scale beneath it, so the network has contrast in a young archive and in an old one. Before
60
+ // the room has recalled anything, how woven a memory is carries the picture; the moment turns
61
+ // start reaching for memories, use takes over. The card always reports the true counts.
62
+ export function activityOf(raw, top) {
63
+ return top > 0 ? Math.max(0.08, Math.min(1, 0.08 + 0.92 * (raw / top))) : 0.08;
64
+ }
65
+
66
+ // World space: MOTHER's core sits at (0, 0); planets orbit in world units and a
67
+ // camera maps the world to the canvas. The camera follows the whole system
68
+ // until the human takes the wheel (drag, wheel), and RECENTER hands it back.
69
+ // 360 (was 300): the memories stand further out, so each one reads on its own and the
70
+ // constellation takes the canvas it has.
71
+ export const NOSTROMO_ORBIT = 360;
72
+
73
+ // The archive is not a ball. It is the shape of the thing that holds it, seen from the side:
74
+ // a brain, facing left. One function says how far the shell is at a given bearing, and BOTH the
75
+ // first placement and the force that holds the system use it — which is the whole trick. The
76
+ // ring force used to pull every memory onto a circle, so any shape given at placement was undone
77
+ // within a second or two of physics. Shape the target and the shape is what the system settles
78
+ // into instead of what it drifts away from.
79
+ //
80
+ // Bearings are canvas bearings: 0 points right (occipital, the back of the head), PI/2 points
81
+ // DOWN (temporal lobe and cerebellum), PI points left (frontal pole), 3PI/2 points up (parietal,
82
+ // the crown).
83
+ function brainRadius(angle) {
84
+ const a = ((angle % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
85
+ const cos = Math.cos(a);
86
+ const sin = Math.sin(a);
87
+ // Wider than tall, which is what stops it reading as a circle before anything else is added.
88
+ let r = 1 / Math.hypot(cos / 1.0, sin / 0.78);
89
+ // The frontal pole is blunt and sits a little forward of a pure ellipse.
90
+ r += 0.07 * Math.max(0, -cos) ** 2;
91
+ // The base is flat: a brain rests on something. Everything below the midline comes in, most
92
+ // of all straight down.
93
+ r -= 0.13 * Math.max(0, sin) ** 1.6;
94
+ // The temporal lobe hangs at the lower front, separated from the frontal lobe above it.
95
+ r += 0.09 * Math.exp(-(((a - 2.45) / 0.42) ** 2));
96
+ // And the cerebellum is its own smaller mass at the lower back.
97
+ r += 0.12 * Math.exp(-(((a - 0.95) / 0.34) ** 2));
98
+ return r;
99
+ }
100
+
101
+ // The longitudinal fissure, the groove that makes two hemispheres out of one mass. It runs
102
+ // front to back down the middle, so it lies in depth: invisible from the side, plain the moment
103
+ // the brain is turned to face you. A density gap rather than a wall, half as wide as this, in
104
+ // orbit units: memories are nudged out of it and never across it.
105
+ const FISSURE = 0.07;
106
+
107
+ // ---- The third dimension.
108
+ //
109
+ // The archive is a body, not a drawing of one. Every memory lives at (wx, wy, wz): the outline
110
+ // above is the brain seen from the side, and BRAIN_WIDTH says how far it reaches toward each
111
+ // temple. The physics runs there. Each frame the whole body is turned by the human's hand and
112
+ // projected, and the projection is written into x and y, so everything that draws, links,
113
+ // sends pulses or answers a click keeps working on the screen it always worked on.
114
+ const BRAIN_WIDTH = 0.84; // ear to ear, against front to back
115
+ const VIEW_DISTANCE = 1500; // world units from the eye to MOTHER: enough to read depth, not to distort
116
+ export const PITCH_LIMIT = 1.1; // the head tips forward and back, never over
117
+ export const TURN_PER_PIXEL = 0.008;
118
+ const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
119
+ // How deep under the surface a memory sits. Thin, on purpose: a thick shell fills the outline in
120
+ // and the brain becomes a disc; a thin one leaves the silhouette to the memories along its edge.
121
+ const shellDepth = (node) => 1.12 + node.distance * 0.18;
122
+ const frac = (value) => value - Math.floor(value);
123
+
124
+ // ---- Forgetting.
125
+ //
126
+ // A memory that is forgotten is not switched off: the universe takes it. It swells once, a last
127
+ // breath; a horizon opens where it was, light bending round its rim; what it was made of falls
128
+ // in, spiralling faster the closer it gets and drawn out into threads; the body collapses, there
129
+ // is one point of light as it goes, and a faint ring runs out into the dark and is gone. Its
130
+ // links do not snap. They come apart into dust, from its end outward like a wave, and the dust
131
+ // drifts off into the field, glinting, and goes out. Aberrations go the same way.
132
+ const IMPLOSION = 2.4; // seconds, from the last breath to nothing
133
+ const DUST_CAP = 1400; // a ceiling, so forgetting a hub does not cost a frame
134
+ const DEBRIS = 40;
135
+
136
+ // How big the body still is, 0..1 of the way through: a swell, then a collapse that accelerates.
137
+ function implosionScale(p) {
138
+ if (p < 0.12) return 1 + 0.22 * Math.sin((p / 0.12) * (Math.PI / 2));
139
+ if (p < 0.62) return 1.22 * (1 - ((p - 0.12) / 0.5) ** 2.2);
140
+ return 0;
141
+ }
142
+
143
+ // Points along a wire, as the curve it is drawn with, with where along it each one is.
144
+ function wireDust(x0, y0, cx, cy, x1, y1, from, to, born, count) {
145
+ const out = [];
146
+ for (let i = 0; i < count; i += 1) {
147
+ const u = (i + Math.random()) / count;
148
+ const at = alongCurve(x0, y0, cx, cy, x1, y1, u);
149
+ const drift = 0.25 + Math.random() * 0.55;
150
+ const heading = Math.random() * Math.PI * 2;
151
+ out.push({
152
+ x: at.x, y: at.y, vx: Math.cos(heading) * drift, vy: Math.sin(heading) * drift,
153
+ // The wave runs from the forgotten end outward: dust is born where it has reached.
154
+ born: born + 0.15 + 0.55 * u, life: 1.4 + Math.random() * 1.8,
155
+ size: 0.6 + Math.random() * 1.3, colour: hexMix(from, to, u), phase: Math.random() * 6.3,
156
+ });
157
+ }
158
+ return out;
159
+ }
160
+
161
+ // ---- The sight.
162
+ //
163
+ // What marks a dwarf under the pointer and a dwarf that has been chosen: a gunsight, the way a
164
+ // fighter's head-up display draws one. Four corner brackets that close in on the target when it
165
+ // is chosen, a broken ring turning around it, range ticks at the four bearings and the lock
166
+ // written beside it. The pointer gets the brackets only, dimmer: a sight that is looking, not
167
+ // one that has locked. Drawn in the world but sized in screen pixels, so it reads the same at
168
+ // every zoom.
169
+ const HUD_GREEN = '57, 255, 110';
170
+
171
+ // ---- Focus.
172
+ //
173
+ // Choosing a dwarf turns the archive in the hand until that dwarf faces you, brings the camera
174
+ // to it and lets everything else fall back into the dark, so the one being read is the one that
175
+ // is seen. Dragging while it is held hands the turning back to the human; letting it go brings
176
+ // the rest of the archive back up.
177
+ const FOCUS_TURN = 0.07; // how much of the remaining turn is taken each frame
178
+ export const FOCUS_VEIL = 0.7; // how far everything else falls back
179
+
180
+ const HEART_PERIOD = 1.15; // seconds per beat: slow, deliberate, alive
181
+ const WAVE_SPEED = 260; // world units per second a beat travels outward
182
+
183
+ // The core's radius in world units: constant so the layout does not depend on the window.
184
+ // 114: ten per cent larger than the 104 it was (2026-10-09), asked for so MOTHER reads first.
185
+ export const CORE_R = 114;
186
+
187
+ // ---- The shape of MOTHER's body.
188
+ // The axis leans, so we look a little down on the planet rather than straight at its equator:
189
+ // without the lean a turning sphere reads as a turning disc. The sun is fixed in view space,
190
+ // which is what lets the body turn under it.
191
+ const CORE_TILT = 0.42;
192
+ // A star lights itself, so nothing here decides which side is day. This direction only tells
193
+ // the surface features how much of their own glare faces us.
194
+ const CORE_LIGHT = { x: -0.5, y: -0.46, z: 0.73 };
195
+
196
+ // ---- The photosphere.
197
+ // Granulation is built once into a strip that repeats left to right, then wrapped round the
198
+ // body: turning the star is moving the window along the strip, so the texture costs nothing to
199
+ // animate. The strip is drawn in rows, each row cut into pieces so longitude runs as a sphere's
200
+ // does and the cells narrow toward the limb instead of smearing along it.
201
+ const GRAIN_ROWS = 30;
202
+ const GRAIN_PIECES = 5;
203
+ let grainCanvas;
204
+ function granuleTexture() {
205
+ if (grainCanvas !== undefined) return grainCanvas;
206
+ grainCanvas = null;
207
+ try {
208
+ const width = 640;
209
+ const height = 320;
210
+ const canvas = document.createElement('canvas');
211
+ canvas.width = width * 2;
212
+ canvas.height = height;
213
+ const paint = canvas.getContext('2d');
214
+ if (!paint) return grainCanvas;
215
+ // A dark ground. What makes a star frightening is not how bright it is but how much of it
216
+ // is nearly out: the molten matter has to burn against something almost black.
217
+ paint.fillStyle = '#5e1305';
218
+ paint.fillRect(0, 0, width * 2, height);
219
+ // Boiling cells: hot ones with a bright middle, cool lanes between them. Every cell is laid
220
+ // down twice, a strip apart, so any window of one strip's width joins itself seamlessly.
221
+ const cell = (x, y, size, warm) => {
222
+ for (const at of [x - width, x, x + width]) {
223
+ const skin = paint.createRadialGradient(at, y, 0, at, y, size);
224
+ if (warm > 0.78) {
225
+ // The few that are truly alight, and they are what the eye goes to.
226
+ skin.addColorStop(0, `rgba(255, ${186 + Math.round(warm * 58)}, ${88 + Math.round(warm * 96)}, ${0.5 + warm * 0.45})`);
227
+ skin.addColorStop(0.5, `rgba(255, 110, 26, ${0.22 + warm * 0.22})`);
228
+ } else if (warm > 0.5) {
229
+ skin.addColorStop(0, `rgba(${196 + Math.round(warm * 40)}, ${58 + Math.round(warm * 40)}, 12, ${0.24 + warm * 0.2})`);
230
+ skin.addColorStop(0.5, `rgba(168, 44, 10, 0.12)`);
231
+ } else {
232
+ // Cool crust, and most of the surface is this.
233
+ skin.addColorStop(0, `rgba(${44 + Math.round(warm * 58)}, ${6 + Math.round(warm * 16)}, 4, ${0.46 + (0.5 - warm) * 0.5})`);
234
+ skin.addColorStop(0.55, `rgba(60, 12, 4, ${0.2})`);
235
+ }
236
+ skin.addColorStop(1, 'rgba(120, 30, 10, 0)');
237
+ paint.fillStyle = skin;
238
+ paint.beginPath(); paint.arc(at, y, size, 0, Math.PI * 2); paint.fill();
239
+ }
240
+ };
241
+ for (let i = 0; i < 1400; i += 1) cell(Math.random() * width, Math.random() * height, 9 + Math.random() * 16, Math.random());
242
+ for (let i = 0; i < 3600; i += 1) cell(Math.random() * width, Math.random() * height, 2.5 + Math.random() * 5.5, Math.random());
243
+ grainCanvas = canvas;
244
+ } catch {
245
+ grainCanvas = null; // no canvas to draw into: the star keeps its gradient and loses its grain
246
+ }
247
+ return grainCanvas;
248
+ }
249
+ // A great circle is walked in fixed steps, so the sine and cosine of those steps are worked
250
+ // out once and read from a table for every band, every frame.
251
+ const CIRCLE_STEPS = 44;
252
+ const CIRCLE_COS = Array.from({ length: CIRCLE_STEPS + 1 }, (_, k) => Math.cos((k / CIRCLE_STEPS) * Math.PI * 2));
253
+ const CIRCLE_SIN = Array.from({ length: CIRCLE_STEPS + 1 }, (_, k) => Math.sin((k / CIRCLE_STEPS) * Math.PI * 2));
254
+
255
+ // ---- The skin of a dwarf.
256
+ // A star of any class has the same boiling surface; what changes is the colour it boils in. One
257
+ // strip is built per class, the first time that class is asked for, and every memory of that
258
+ // class wears it. The strip repeats left to right, so a body can turn inside it without a seam.
259
+ const dwarfSkins = new Map();
260
+ // A dwarf's face is laid on in columns across its width, each as wide as a sphere is at that
261
+ // longitude: the sine of evenly spaced longitudes from one limb to the other.
262
+ const SKIN_COLUMNS = 8;
263
+ const SKIN_EDGES = Array.from({ length: SKIN_COLUMNS + 1 }, (_, k) => Math.sin(-Math.PI / 2 + (k / SKIN_COLUMNS) * Math.PI));
264
+ export function dwarfTexture(hex) {
265
+ if (dwarfSkins.has(hex)) return dwarfSkins.get(hex);
266
+ let skin = null;
267
+ try {
268
+ const base = [1, 3, 5].map((at) => Number.parseInt(String(hex).slice(at, at + 2), 16));
269
+ if (base.some((value) => !Number.isFinite(value))) throw new Error('not a colour');
270
+ const shade = (amount, alpha) => `rgba(${base.map((value) => Math.round(Math.min(255, value * amount))).join(', ')}, ${alpha})`;
271
+ const white = (amount, alpha) => `rgba(${base.map((value) => Math.round(value + (255 - value) * amount)).join(', ')}, ${alpha})`;
272
+ const width = 320;
273
+ const height = 160;
274
+ const canvas = document.createElement('canvas');
275
+ canvas.width = width * 2;
276
+ canvas.height = height;
277
+ const paint = canvas.getContext('2d');
278
+ if (!paint) throw new Error('no context');
279
+ // The ground of the face, in the star's own colour. A dwarf is bright from edge to edge, so
280
+ // this is the colour at rest rather than a dark floor: the mottling reads against it both
281
+ // ways, darker where the gas is cooler and white where it is alight.
282
+ paint.fillStyle = shade(0.46, 1);
283
+ paint.fillRect(0, 0, width * 2, height);
284
+ const cell = (x, y, size, heat) => {
285
+ for (const at of [x - width, x, x + width]) {
286
+ const glow = paint.createRadialGradient(at, y, 0, at, y, size);
287
+ if (heat > 0.88) {
288
+ // The few points that are truly alight, which is what the eye finds first.
289
+ glow.addColorStop(0, white(0.85, 0.95));
290
+ glow.addColorStop(0.4, white(0.35, 0.5));
291
+ } else if (heat > 0.52) {
292
+ glow.addColorStop(0, white(0.2 + heat * 0.3, 0.45 + heat * 0.35));
293
+ glow.addColorStop(0.5, shade(1.05, 0.24));
294
+ } else {
295
+ glow.addColorStop(0, shade(0.2 + heat * 0.5, 0.5));
296
+ glow.addColorStop(0.5, shade(0.22, 0.24));
297
+ }
298
+ glow.addColorStop(1, shade(0.5, 0));
299
+ paint.fillStyle = glow;
300
+ paint.beginPath(); paint.arc(at, y, size, 0, Math.PI * 2); paint.fill();
301
+ }
302
+ };
303
+ for (let i = 0; i < 420; i += 1) cell(Math.random() * width, Math.random() * height, 7 + Math.random() * 15, Math.random() * 0.82);
304
+ for (let i = 0; i < 1100; i += 1) cell(Math.random() * width, Math.random() * height, 1.8 + Math.random() * 4.5, Math.random());
305
+ skin = canvas;
306
+ } catch {
307
+ skin = null; // no canvas to build in: the star keeps its gradient and loses its grain
308
+ }
309
+ dwarfSkins.set(hex, skin);
310
+ return skin;
311
+ }
312
+
313
+ // ---- The legend's stars (moved here from app.js on 2026-10-10, so the Project map draws the same
314
+ // ones as the Memory map).
315
+ // A real dwarf, small enough to sit in a chip: the same ground, the same boiling face and the
316
+ // same limb the ones in the constellation wear, so the legend shows the thing and not a swatch.
317
+ export function dwarfChip(hex, px) {
318
+ const canvas = document.createElement('canvas');
319
+ const dpr = Math.min(3, window.devicePixelRatio || 1);
320
+ canvas.width = Math.round(px * dpr);
321
+ canvas.height = Math.round(px * dpr);
322
+ canvas.style.width = `${px}px`;
323
+ canvas.style.height = `${px}px`;
324
+ const paint = canvas.getContext('2d');
325
+ if (!paint) return canvas;
326
+ paint.scale(dpr, dpr);
327
+ const r = px / 2;
328
+ paint.save();
329
+ paint.beginPath(); paint.arc(r, r, r, 0, Math.PI * 2); paint.closePath();
330
+ const ground = paint.createRadialGradient(r * 0.72, r * 0.72, r * 0.04, r, r, r * 1.1);
331
+ ground.addColorStop(0, hexMix(hex, '#ffffff', 0.75));
332
+ ground.addColorStop(0.42, hex);
333
+ ground.addColorStop(1, hexMix(hex, '#000000', 0.62));
334
+ paint.fillStyle = ground;
335
+ paint.fill();
336
+ paint.clip();
337
+ const skin = dwarfTexture(hex);
338
+ if (skin) {
339
+ paint.globalAlpha = 0.72;
340
+ paint.drawImage(skin, 0, 0, skin.width * 0.28, skin.height, -r * 0.08, -r * 0.08, px * 1.08, px * 1.08);
341
+ paint.globalAlpha = 1;
342
+ }
343
+ const limb = paint.createRadialGradient(r, r, r * 0.3, r, r, r);
344
+ limb.addColorStop(0, 'rgba(0, 0, 0, 0)');
345
+ limb.addColorStop(0.78, 'rgba(0, 0, 0, .18)');
346
+ limb.addColorStop(1, 'rgba(0, 0, 0, .6)');
347
+ paint.fillStyle = limb;
348
+ paint.fillRect(0, 0, px, px);
349
+ paint.restore();
350
+ return canvas;
351
+ }
352
+
353
+ // The collapsed body, small enough for a chip: a hole with the ring of what fell into it.
354
+ export function collapsedChip(hex, px) {
355
+ const canvas = document.createElement('canvas');
356
+ const dpr = Math.min(3, window.devicePixelRatio || 1);
357
+ canvas.width = Math.round(px * dpr);
358
+ canvas.height = Math.round(px * dpr);
359
+ canvas.style.width = `${px}px`;
360
+ canvas.style.height = `${px}px`;
361
+ const paint = canvas.getContext('2d');
362
+ if (!paint) return canvas;
363
+ paint.scale(dpr, dpr);
364
+ const r = px / 2;
365
+ // The same thing the constellation draws, small: dust, a round black nothing, a cold rim.
366
+ paint.globalCompositeOperation = 'lighter';
367
+ for (let i = 0; i < 14; i += 1) {
368
+ const speck = VOID_DUST[i * 4];
369
+ const at = r * (0.78 + 0.22 * speck.out);
370
+ const x = r + Math.cos(speck.angle) * at;
371
+ const y = r + Math.sin(speck.angle) * at;
372
+ paint.fillStyle = `rgba(${speck.colour}, ${0.8 - 0.4 * speck.out})`;
373
+ paint.beginPath(); paint.arc(x, y, 0.55, 0, Math.PI * 2); paint.fill();
374
+ }
375
+ paint.globalCompositeOperation = 'source-over';
376
+ paint.fillStyle = '#000000';
377
+ paint.beginPath(); paint.arc(r, r, r * 0.66, 0, Math.PI * 2); paint.fill();
378
+ paint.globalCompositeOperation = 'lighter';
379
+ paint.strokeStyle = 'rgba(168, 204, 255, .75)';
380
+ paint.lineWidth = 0.9;
381
+ paint.beginPath(); paint.arc(r, r, r * 0.69, 0.6, 3.5); paint.stroke();
382
+ paint.strokeStyle = 'rgba(140, 180, 245, .45)';
383
+ paint.beginPath(); paint.arc(r, r, r * 0.74, 3.9, 5.9); paint.stroke();
384
+ paint.globalCompositeOperation = 'source-over';
385
+ return canvas;
386
+ }
387
+
388
+ // ---- A dwarf, drawn the way a star looks.
389
+ //
390
+ // A dwarf makes its own light, so nothing out here lights it and nothing shades it: there is
391
+ // no side turned away. What makes it round is what makes the Sun round in a white-light photo.
392
+ //
393
+ // Limb darkening. The disc is brightest in the middle and dims toward the edge, the same all
394
+ // the way round: at the limb the eye sees into higher, cooler gas. It dims more in blue than
395
+ // in red, so the edge is also warmer than the centre.
396
+ // Granulation. The surface is cells: hot plasma rising in bright cores, cooling gas sinking in
397
+ // the dark lanes between them. Seen toward the limb the cells foreshorten and the pattern fades.
398
+ // Spots. A dark umbra inside a paler penumbra, where the field holds the heat down.
399
+ // Faculae. Bright patches around the spots, plain near the limb and nearly lost in the middle.
400
+ //
401
+ // Each class wears these in its own proportion: a white dwarf is smooth and white-hot, a yellow
402
+ // one is a sun with granules and spots. The surface is a map of the whole body in latitude and
403
+ // longitude, built once per class from noise sampled on the sphere itself, so it has no seam and
404
+ // nothing stretches at the poles; turning the body is sliding along it. How hard a dwarf burns
405
+ // is how much the room has made of it, as before.
406
+ //
407
+ // The geometry of a disc of a given size is worked out once and kept. A body is painted again
408
+ // only when it has turned or changed enough to show.
409
+ const ALBEDO_W = 256;
410
+ const ALBEDO_H = 128;
411
+ // How much each channel dims at the limb, red to blue: the blue dims most.
412
+ const LIMB_DARKENING = [0.68, 0.8, 0.9];
413
+ // What each class of dwarf is made of. `granules` is how strongly the cells show, `spots` how
414
+ // many of them are marked, `faculae` how bright the patches around them are, `white` how far the
415
+ // centre runs toward white, `power` how much light it gives for its size: a white dwarf is the
416
+ // smallest and the most intense. A class not named here is drawn as a sun.
417
+ const STAR_TRAITS = {
418
+ decision: { granules: 1, spots: 1, faculae: 1, white: 0.5, power: 1 },
419
+ fact: { granules: 0.3, spots: 0, faculae: 0.35, white: 0.9, power: 1.35 },
420
+ preference: { granules: 0.9, spots: 0.7, faculae: 0.9, white: 0.45, power: 1 },
421
+ question: { granules: 0.7, spots: 0.4, faculae: 0.7, white: 0.65, power: 1.15 },
422
+ };
423
+ const SUN_TRAITS = { granules: 0.8, spots: 0.6, faculae: 0.8, white: 0.5, power: 1 };
424
+ // And what a memory's standing does to that surface. A dwarf you confirmed is clean; one nobody
425
+ // has judged carries its class's spots; one held as false is blotched, the spots spreading as
426
+ // `reach` drops; one put out of circulation is the worst of all. `faculae` scales the bright
427
+ // patches. A dwarf the archive has never once carried is cooling: its granules barely move, it
428
+ // gives less light (`glow`) and its middle is no longer white-hot (`white`).
429
+ const SURFACE_BY_ZONE = {
430
+ bridge: { spots: 0, reach: 0.77, faculae: 1.3 },
431
+ hold: { spots: 1, reach: 0.77, faculae: 1 },
432
+ medbay: { spots: 1, reach: 0.67, faculae: 0.55, sick: true },
433
+ jettisoned: { spots: 1, reach: 0.62, faculae: 0.3, sick: true },
434
+ };
435
+ const COLD_GRANULES = 0.3;
436
+ const COLD_GLOW = 0.55;
437
+ const COLD_WHITE = 0.2;
438
+ function surfaceOf(memory, traits = SUN_TRAITS, cold = false) {
439
+ const zone = SURFACE_BY_ZONE[memory?.zone ?? 'hold'] ?? SURFACE_BY_ZONE.hold;
440
+ return {
441
+ spots: zone.sick ? 1 : traits.spots * zone.spots,
442
+ reach: zone.reach,
443
+ faculae: traits.faculae * zone.faculae,
444
+ granules: traits.granules * (cold ? COLD_GRANULES : 1),
445
+ glow: cold ? COLD_GLOW : 1,
446
+ white: cold ? COLD_WHITE : 1,
447
+ };
448
+ }
449
+ const dwarfAlbedos = new Map();
450
+ const sphereTables = new Map();
451
+
452
+ // Gradient noise, the improved kind: continuous in every direction, with no grid showing
453
+ // through it the way value noise shows its cells. The permutation is fixed, so every room grows
454
+ // the same surfaces.
455
+ const NOISE_PERM = (() => {
456
+ const order = Array.from({ length: 256 }, (_, i) => i);
457
+ let seed = 1013;
458
+ for (let i = 255; i > 0; i -= 1) {
459
+ seed = (seed * 16807) % 2147483647;
460
+ const j = seed % (i + 1);
461
+ [order[i], order[j]] = [order[j], order[i]];
462
+ }
463
+ return Uint8Array.from([...order, ...order]);
464
+ })();
465
+ function gradientNoise(x, y, z) {
466
+ const P = NOISE_PERM;
467
+ const X = Math.floor(x) & 255, Y = Math.floor(y) & 255, Z = Math.floor(z) & 255;
468
+ x -= Math.floor(x); y -= Math.floor(y); z -= Math.floor(z);
469
+ const fade = (t) => t * t * t * (t * (t * 6 - 15) + 10);
470
+ const u = fade(x), v = fade(y), w = fade(z);
471
+ const grad = (hash, gx, gy, gz) => {
472
+ const h = hash & 15;
473
+ const a = h < 8 ? gx : gy;
474
+ const b = h < 4 ? gy : h === 12 || h === 14 ? gx : gz;
475
+ return ((h & 1) ? -a : a) + ((h & 2) ? -b : b);
476
+ };
477
+ const lerp = (t, a, b) => a + t * (b - a);
478
+ const A = P[X] + Y, AA = P[A] + Z, AB = P[A + 1] + Z, B = P[X + 1] + Y, BA = P[B] + Z, BB = P[B + 1] + Z;
479
+ return lerp(w,
480
+ lerp(v, lerp(u, grad(P[AA], x, y, z), grad(P[BA], x - 1, y, z)), lerp(u, grad(P[AB], x, y - 1, z), grad(P[BB], x - 1, y - 1, z))),
481
+ lerp(v, lerp(u, grad(P[AA + 1], x, y, z - 1), grad(P[BA + 1], x - 1, y, z - 1)), lerp(u, grad(P[AB + 1], x, y - 1, z - 1), grad(P[BB + 1], x - 1, y - 1, z - 1))));
482
+ }
483
+ // Octaves of it, folded into 0..1.
484
+ function fractalNoise(x, y, z, octaves) {
485
+ let sum = 0, amp = 0.5, freq = 1, norm = 0;
486
+ for (let i = 0; i < octaves; i += 1) { sum += amp * gradientNoise(x * freq, y * freq, z * freq); norm += amp; amp *= 0.5; freq *= 2.03; }
487
+ return 0.5 + 0.5 * Math.max(-1, Math.min(1, (sum / norm) * 1.6));
488
+ }
489
+ // Cellular noise: the distance to the nearest and the second nearest of a scatter of points,
490
+ // one jittered into each unit cell. Where the two are nearly equal is the border between two
491
+ // cells, which is exactly the shape of a granule's dark lane.
492
+ function cellNoise(x, y, z) {
493
+ const P = NOISE_PERM;
494
+ const xi = Math.floor(x), yi = Math.floor(y), zi = Math.floor(z);
495
+ let near = 9, next = 9;
496
+ for (let dx = -1; dx <= 1; dx += 1) for (let dy = -1; dy <= 1; dy += 1) for (let dz = -1; dz <= 1; dz += 1) {
497
+ const h = P[(P[(P[(xi + dx) & 255] + ((yi + dy) & 255)) & 255] + ((zi + dz) & 255)) & 255];
498
+ const ox = xi + dx + P[h] / 255 - x;
499
+ const oy = yi + dy + P[(h + 85) & 255] / 255 - y;
500
+ const oz = zi + dz + P[(h + 170) & 255] / 255 - z;
501
+ const d = ox * ox + oy * oy + oz * oz;
502
+ if (d < near) { next = near; near = d; } else if (d < next) next = d;
503
+ }
504
+ return [Math.sqrt(near), Math.sqrt(next)];
505
+ }
506
+
507
+ const smooth = (a, b, x) => { const t = Math.min(1, Math.max(0, (x - a) / (b - a))); return t * t * (3 - 2 * t); };
508
+
509
+ // The whole surface of one class: how bright the granulation is at each texel, and how strong
510
+ // the magnetic field is there. Spots and faculae are read from the field when a dwarf is
511
+ // painted, so how many a dwarf shows can follow the memory's standing instead of being baked in.
512
+ // Each class has a surface of its own. Built a band of rows at a time when asked to be (`rows`),
513
+ // so the work can be spread over frames; the map is handed out only once it is whole.
514
+ function dwarfAlbedo(kind, rows = ALBEDO_H) {
515
+ let build = dwarfAlbedos.get(kind);
516
+ if (build?.done) return build.map;
517
+ if (!build) {
518
+ const offset = [...String(kind)].reduce((sum, letter) => sum + letter.charCodeAt(0), 0) * 0.37;
519
+ build = { offset, map: new Float32Array(ALBEDO_W * ALBEDO_H * 2), next: 0, done: false };
520
+ dwarfAlbedos.set(kind, build);
521
+ }
522
+ const { offset, map } = build;
523
+ const until = Math.min(ALBEDO_H, build.next + rows);
524
+ for (let row = build.next; row < until; row += 1) {
525
+ const lat = ((row + 0.5) / ALBEDO_H - 0.5) * Math.PI;
526
+ const cl = Math.cos(lat), sl = Math.sin(lat);
527
+ for (let col = 0; col < ALBEDO_W; col += 1) {
528
+ const lon = ((col + 0.5) / ALBEDO_W) * Math.PI * 2;
529
+ const px = cl * Math.cos(lon) + offset, py = sl, pz = cl * Math.sin(lon);
530
+ // Granules: bright where a cell's middle is, dark along the lanes between cells, and a
531
+ // slow unevenness over the lot so no two stretches of the surface match.
532
+ const drift = fractalNoise(px * 3 + 4, py * 3, pz * 3, 3) - 0.5;
533
+ const [near, next] = cellNoise(px * 21 + drift, py * 21, pz * 21 - drift);
534
+ const lane = smooth(0, 0.32, next - near);
535
+ const middle = 1 - Math.min(1, near * 0.9);
536
+ const granule = 0.5 + 0.5 * (lane * (0.6 + 0.4 * middle) - 0.55) + 0.18 * (fractalNoise(px * 6, py * 6 + 2, pz * 6, 3) - 0.5);
537
+ // The field: where it is strongest, spots; in a ring around them, faculae.
538
+ const field = fractalNoise(px * 2.3 + 11, py * 2.3, pz * 2.3, 4) + 0.06 * (fractalNoise(px * 9, py * 9, pz * 9, 2) - 0.5);
539
+ const at = (row * ALBEDO_W + col) * 2;
540
+ map[at] = Math.min(1, Math.max(0, granule));
541
+ map[at + 1] = field;
542
+ }
543
+ }
544
+ build.next = until;
545
+ build.done = until >= ALBEDO_H;
546
+ return build.done ? map : null;
547
+ }
548
+
549
+ // Every pixel of a disc `size` pixels across: which way it faces, where on the map it reads,
550
+ // and how much of it the disc covers, so the edge is smooth without a second pass.
551
+ function sphereTable(size) {
552
+ if (sphereTables.has(size)) return sphereTables.get(size);
553
+ const half = size / 2;
554
+ const cells = [];
555
+ for (let py = 0; py < size; py += 1) {
556
+ for (let px = 0; px < size; px += 1) {
557
+ const x = (px + 0.5 - half) / half;
558
+ const y = (py + 0.5 - half) / half;
559
+ const rr = x * x + y * y;
560
+ const edge = (1 - Math.sqrt(rr)) * half + 0.5;
561
+ if (edge <= 0) continue;
562
+ const z = Math.sqrt(Math.max(0, 1 - rr));
563
+ const lon = Math.atan2(x, z);
564
+ const lat = Math.asin(Math.max(-1, Math.min(1, y)));
565
+ const row = Math.max(0, Math.min(ALBEDO_H - 1.001, (lat / Math.PI + 0.5) * ALBEDO_H - 0.5));
566
+ cells.push(py * size + px, x, y, z, (lon / (Math.PI * 2)) * ALBEDO_W, row, Math.min(1, edge));
567
+ }
568
+ }
569
+ const table = { size, cells: Float32Array.from(cells) };
570
+ sphereTables.set(size, table);
571
+ return table;
572
+ }
573
+
574
+ // The shading itself. Pure: a table, a map and the star in, RGBA out. `colour` is the class's
575
+ // colour as three numbers 0..1, `surface` what its standing does to it (surfaceOf), `turn` how
576
+ // far the body has spun, `burn` how hard it burns.
577
+ function shadeDwarf(out, table, map, { colour, traits = SUN_TRAITS, surface = surfaceOf(null, traits), turn, burn }) {
578
+ out.fill(0);
579
+ const shift = (((turn / (Math.PI * 2)) % 1) + 1) % 1 * ALBEDO_W;
580
+ const energy = (0.62 + 0.62 * burn) * (traits.power ?? 1);
581
+ const whiten = traits.white * (0.5 + 0.5 * burn);
582
+ // Toward the edge the class's colour deepens: cooler gas, the same hue, more of it.
583
+ const deep = colour.map((value) => value ** 1.6);
584
+ const cells = table.cells;
585
+ const sample = new Float32Array(2);
586
+ const { spots, reach, faculae, granules } = surface;
587
+ const energyNow = energy * (surface.glow ?? 1);
588
+ const whitenNow = whiten * (surface.white ?? 1);
589
+ for (let i = 0; i < cells.length; i += 7) {
590
+ const mu = cells[i + 3];
591
+ // Read between texels, never from one: the map is smaller than a close-up body, and a body
592
+ // read texel by texel shows them as squares.
593
+ const u = cells[i + 4] + shift;
594
+ const u0 = Math.floor(u);
595
+ const fu = u - u0;
596
+ const c0 = ((u0 % ALBEDO_W) + ALBEDO_W) % ALBEDO_W;
597
+ const c1 = (c0 + 1) % ALBEDO_W;
598
+ const v = cells[i + 5];
599
+ const r0 = Math.floor(v);
600
+ const fv = v - r0;
601
+ const r1 = Math.min(ALBEDO_H - 1, r0 + 1);
602
+ for (let k = 0; k < 2; k += 1) {
603
+ const top = map[(r0 * ALBEDO_W + c0) * 2 + k] * (1 - fu) + map[(r0 * ALBEDO_W + c1) * 2 + k] * fu;
604
+ const bottom = map[(r1 * ALBEDO_W + c0) * 2 + k] * (1 - fu) + map[(r1 * ALBEDO_W + c1) * 2 + k] * fu;
605
+ sample[k] = top * (1 - fv) + bottom * fv;
606
+ }
607
+ const [granule, field] = sample;
608
+ // Spots where the field passes `reach`, umbra deeper in; faculae in the ring just short of it.
609
+ const ring = smooth(reach, reach + 0.07, field);
610
+ const umbra = smooth(reach + 0.07, reach + 0.12, field) * spots;
611
+ const penumbra = ring * spots;
612
+ const facula = Math.max(0, smooth(reach - 0.11, reach - 0.01, field) - ring) * faculae;
613
+ // The cells foreshorten toward the limb and their contrast goes with them.
614
+ const cells_ = 1 + granules * 0.62 * (granule - 0.5) * Math.sqrt(mu);
615
+ const bright = 1 + 0.6 * facula * (1 - mu) ** 0.7;
616
+ const dark = 1 - 0.8 * umbra - 0.4 * Math.max(0, penumbra - umbra);
617
+ // White-hot in the middle, its own colour toward the edge, deepening at the limb.
618
+ const heat = whitenNow * mu * mu;
619
+ const edge = (1 - mu) ** 1.5;
620
+ const p = cells[i] * 4;
621
+ for (let k = 0; k < 3; k += 1) {
622
+ const hue = colour[k] + (deep[k] - colour[k]) * edge;
623
+ const tint = hue + (1 - hue) * heat;
624
+ const limb = 1 - LIMB_DARKENING[k] * (1 - mu);
625
+ const value = tint * limb * cells_ * bright * dark * energyNow;
626
+ out[p + k] = 255 * (1 - Math.exp(-value * 2.6));
627
+ }
628
+ out[p + 3] = 255 * cells[i + 6];
629
+ }
630
+ return out;
631
+ }
632
+
633
+ // One dwarf's painted face, kept on its node and painted again only when it has changed enough
634
+ // to be seen. Null where there is no pixel buffer to paint into, and the body is drawn the
635
+ // older way instead.
636
+ //
637
+ // What it costs is bounded per frame. A surface map is built one class a frame, so opening the
638
+ // map never stalls; until its class is ready a dwarf is drawn the older way. And only so many
639
+ // faces are painted again each frame, in the order they are drawn; the rest keep last frame's
640
+ // face for one more, which at the pace these bodies turn nobody can tell from a fresh one.
641
+ const SHADE_BUDGET = 28;
642
+ const ALBEDO_ROWS_PER_FRAME = 12;
643
+
644
+ // ---- MOTHER's body, as molten rock.
645
+ //
646
+ // A crust of cooling rock, nearly black, broken into plates; between the plates, cracks running
647
+ // white-hot; under the crust, pools where it has melted through and the lava shows. The crust
648
+ // rides the body's turn, the lava underneath flows at a pace of its own, and where the two
649
+ // disagree the surface looks like it is moving rather than painted. Every so often something
650
+ // bursts inside: a flash, a white core, a ring of fire running out from it and fading.
651
+ //
652
+ // The map is built once, the way the dwarfs' are, a band at a time; what changes frame to frame
653
+ // is only where it is read and what is bursting.
654
+ const MAGMA_W = 512;
655
+ const MAGMA_H = 256;
656
+ const MAGMA_SIZE = 220; // the largest the body is ever painted; beyond it, it is scaled
657
+ const BLAST_EVERY = 1.6; // seconds between bursts, on average
658
+ const BLAST_LIFE = 2.2;
659
+ // Rock to white heat: black crust, dark red, orange, yellow, white.
660
+ const LAVA_RAMP = [[0, [0.05, 0.012, 0.008]], [0.3, [0.42, 0.04, 0.012]], [0.55, [0.92, 0.24, 0.03]], [0.78, [1, 0.62, 0.16]], [1, [1, 0.94, 0.78]]];
661
+ const magmaBuild = { map: null, next: 0, done: false };
662
+
663
+ function lavaColour(heat, out) {
664
+ const h = Math.min(1, Math.max(0, heat));
665
+ for (let i = 1; i < LAVA_RAMP.length; i += 1) {
666
+ const [at, colour] = LAVA_RAMP[i];
667
+ if (h > at) continue;
668
+ const [from, below] = LAVA_RAMP[i - 1];
669
+ const f = (h - from) / (at - from);
670
+ for (let k = 0; k < 3; k += 1) out[k] = below[k] + (colour[k] - below[k]) * f;
671
+ return out;
672
+ }
673
+ for (let k = 0; k < 3; k += 1) out[k] = LAVA_RAMP[LAVA_RAMP.length - 1][1][k];
674
+ return out;
675
+ }
676
+
677
+ // Four numbers a texel: the plates' cracks, the finer cracks inside them, where the crust has
678
+ // melted through, and grain for the rock itself.
679
+ export function magmaMap(rows = MAGMA_H) {
680
+ if (magmaBuild.done) return magmaBuild.map;
681
+ magmaBuild.map ??= new Float32Array(MAGMA_W * MAGMA_H * 4);
682
+ const map = magmaBuild.map;
683
+ const until = Math.min(MAGMA_H, magmaBuild.next + rows);
684
+ for (let row = magmaBuild.next; row < until; row += 1) {
685
+ const lat = ((row + 0.5) / MAGMA_H - 0.5) * Math.PI;
686
+ const cl = Math.cos(lat), sl = Math.sin(lat);
687
+ for (let col = 0; col < MAGMA_W; col += 1) {
688
+ const lon = ((col + 0.5) / MAGMA_W) * Math.PI * 2;
689
+ const px = cl * Math.cos(lon), py = sl, pz = cl * Math.sin(lon);
690
+ const warp = fractalNoise(px * 2.2 + 5, py * 2.2, pz * 2.2, 3) - 0.5;
691
+ const [near, next] = cellNoise(px * 6 + warp, py * 6, pz * 6 - warp);
692
+ // Fine cracks at a frequency the map can carry: any finer and the cells alias into streaks.
693
+ const [fineNear, fineNext] = cellNoise(px * 10 + warp * 2, py * 10, pz * 10);
694
+ const pools = fractalNoise(px * 2.6 + warp * 1.5, py * 2.6 + 9, pz * 2.6, 5);
695
+ const at = (row * MAGMA_W + col) * 4;
696
+ map[at] = next - near;
697
+ map[at + 1] = fineNext - fineNear;
698
+ map[at + 2] = pools;
699
+ map[at + 3] = fractalNoise(px * 11, py * 11, pz * 11 + 3, 3);
700
+ }
701
+ }
702
+ magmaBuild.next = until;
703
+ magmaBuild.done = until >= MAGMA_H;
704
+ return magmaBuild.done ? map : null;
705
+ }
706
+
707
+ // Whether the slow textures are whole: MOTHER's crust and the surface of each class of dwarf. They
708
+ // are built a band at a time, shared by every scene; a scene painted before they are whole draws
709
+ // its bodies the older way, and the Macroverso paints its sprites once more when this turns true.
710
+ export function texturesReady() {
711
+ return Boolean(magmaBuild.done) && Object.keys(MEMORY_COLORS).every((kind) => kind === COLLAPSED || dwarfAlbedos.get(kind)?.done);
712
+ }
713
+
714
+ // The shading. Pure: a table (sphereTable), the map, and the moment in; RGBA out. `turn` is how
715
+ // far the crust has turned, `flow` how far the lava under it has, `beat` the heartbeat, `blasts`
716
+ // the bursts alive now, each a direction on the visible face in view space and an age 0..1.
717
+ // Written for speed: it runs on the largest body on screen, so every read of the map is shared
718
+ // between the channels that use the same place, and the limb is worked out once per size.
719
+ const magmaLimbs = new Map();
720
+ // `tilt` is how far the human has turned the view up or down. The sun is a body in that same
721
+ // space: dragged, it turns with everything else instead of spinning in place on its own axis, so
722
+ // each pixel reads the map where that point of the body faces after the view's turn is undone.
723
+ function shadeMagma(out, table, map, { turn, flow, beat = 0, blasts = [], tilt = 0 }) {
724
+ out.fill(0);
725
+ const cosTilt = Math.cos(tilt);
726
+ const sinTilt = Math.sin(tilt);
727
+ const crustShift = (((turn / (Math.PI * 2)) % 1) + 1) % 1 * MAGMA_W;
728
+ const lavaShift = (((flow / (Math.PI * 2)) % 1) + 1) % 1 * MAGMA_W;
729
+ const cells = table.cells;
730
+ let limbs = magmaLimbs.get(table);
731
+ if (!limbs) {
732
+ limbs = new Float32Array(cells.length / 7);
733
+ for (let i = 0, j = 0; i < cells.length; i += 7, j += 1) limbs[j] = 0.32 + 0.68 * cells[i + 3] ** 0.55;
734
+ magmaLimbs.set(table, limbs);
735
+ }
736
+ const scale = MAGMA_W / ALBEDO_W;
737
+ const rowScale = MAGMA_H / ALBEDO_H;
738
+ const colour = [0, 0, 0];
739
+ const seamWidth = 0.07 + 0.025 * beat;
740
+ for (let i = 0, j = 0; i < cells.length; i += 7, j += 1) {
741
+ const nx = cells[i + 1], ny = cells[i + 2], mu = cells[i + 3];
742
+ let base = cells[i + 4] * scale;
743
+ let v = Math.min(MAGMA_H - 1.001, cells[i + 5] * rowScale);
744
+ if (tilt) {
745
+ const wy = ny * cosTilt + mu * sinTilt;
746
+ const wz = mu * cosTilt - ny * sinTilt;
747
+ base = (Math.atan2(nx, wz) / (Math.PI * 2)) * MAGMA_W;
748
+ const lat = Math.asin(Math.max(-1, Math.min(1, wy)));
749
+ v = Math.max(0, Math.min(MAGMA_H - 1.001, ((lat / Math.PI + 0.5) * ALBEDO_H - 0.5) * rowScale));
750
+ }
751
+ const r0 = v | 0;
752
+ const fv = v - r0;
753
+ const top = r0 * MAGMA_W;
754
+ const bottom = Math.min(MAGMA_H - 1, r0 + 1) * MAGMA_W;
755
+ // The crust: one place on the map, three things read from it.
756
+ let u = base + crustShift;
757
+ let u0 = Math.floor(u);
758
+ let fu = u - u0;
759
+ let c0 = u0 % MAGMA_W; if (c0 < 0) c0 += MAGMA_W;
760
+ let c1 = c0 + 1 === MAGMA_W ? 0 : c0 + 1;
761
+ let a = (top + c0) * 4, b = (top + c1) * 4, c = (bottom + c0) * 4, d = (bottom + c1) * 4;
762
+ const w00 = (1 - fu) * (1 - fv), w10 = fu * (1 - fv), w01 = (1 - fu) * fv, w11 = fu * fv;
763
+ const crack = map[a] * w00 + map[b] * w10 + map[c] * w01 + map[d] * w11;
764
+ const fine = map[a + 1] * w00 + map[b + 1] * w10 + map[c + 1] * w01 + map[d + 1] * w11;
765
+ const grain = map[a + 3] * w00 + map[b + 3] * w10 + map[c + 3] * w01 + map[d + 3] * w11;
766
+ // The lava under it, read where it has flowed to.
767
+ u = base + lavaShift;
768
+ u0 = Math.floor(u);
769
+ fu = u - u0;
770
+ c0 = u0 % MAGMA_W; if (c0 < 0) c0 += MAGMA_W;
771
+ c1 = c0 + 1 === MAGMA_W ? 0 : c0 + 1;
772
+ a = (top + c0) * 4; b = (top + c1) * 4; c = (bottom + c0) * 4; d = (bottom + c1) * 4;
773
+ const pools = map[a + 2] * (1 - fu) * (1 - fv) + map[b + 2] * fu * (1 - fv) + map[c + 2] * (1 - fu) * fv + map[d + 2] * fu * fv;
774
+ // White-hot along the plates' edges, hotter still where the beat pushes through; a fainter
775
+ // web of finer cracks inside each plate; pools wherever the crust has melted through.
776
+ const seam = 1 - smooth(0, seamWidth, crack);
777
+ const web = (1 - smooth(0, 0.045, fine)) * 0.55;
778
+ const melt = smooth(0.52, 0.78, pools + 0.06 * beat);
779
+ let heat = Math.max(seam * 0.95, web, melt * 0.82) + 0.08 * grain;
780
+ // Bursts: a small white core where it went off, and a ring of fire running out from it.
781
+ for (let k = 0; k < blasts.length; k += 1) {
782
+ const blast = blasts[k];
783
+ const near = 1 - (nx * blast.x + ny * blast.y + mu * blast.z);
784
+ if (near > 0.2) continue;
785
+ const age = blast.age;
786
+ const flash = Math.exp(-age * 7) * Math.exp(-near / (0.0015 + 0.006 * age));
787
+ const front = 0.004 + 0.07 * age;
788
+ const ring = Math.exp(-((near - front) ** 2) / (0.00012 + 0.0006 * age)) * (1 - age);
789
+ heat += 1.1 * flash + 1.1 * ring;
790
+ }
791
+ lavaColour(heat, colour);
792
+ // The edge is seen through more of its own smoke: darker and redder toward the limb.
793
+ const limb = limbs[j];
794
+ const p = cells[i] * 4;
795
+ out[p] = 255 * Math.min(1, colour[0] * limb * (1.04 + 0.1 * beat));
796
+ out[p + 1] = 255 * Math.min(1, colour[1] * limb * limb);
797
+ out[p + 2] = 255 * Math.min(1, colour[2] * limb * limb * limb);
798
+ out[p + 3] = 255 * cells[i + 6];
799
+ }
800
+ return out;
801
+ }
802
+
803
+ // Masses of molten matter riding the surface: `size` is how much of the face one covers, `rate`
804
+ // how slowly it swells and settles, `drift` how it crawls against the turning body. None of the
805
+ // rates match, so the face is never the same face twice.
806
+ const CORE_FLOWS = [
807
+ { lat: -0.22, lon: 0.0, size: 0.46, drift: 0.0031, rate: 0.083, hot: false },
808
+ { lat: 0.42, lon: 2.1, size: 0.34, drift: -0.0047, rate: 0.117, hot: true },
809
+ { lat: -0.54, lon: 3.6, size: 0.28, drift: 0.0062, rate: 0.061, hot: true },
810
+ { lat: 0.12, lon: 4.8, size: 0.52, drift: -0.0023, rate: 0.094, hot: false },
811
+ { lat: 0.68, lon: 1.2, size: 0.24, drift: 0.0039, rate: 0.139, hot: true },
812
+ { lat: -0.34, lon: 5.6, size: 0.38, drift: -0.0055, rate: 0.073, hot: true },
813
+ { lat: 0.3, lon: 3.0, size: 0.44, drift: 0.0017, rate: 0.107, hot: false },
814
+ { lat: -0.7, lon: 0.9, size: 0.22, drift: -0.0033, rate: 0.126, hot: true },
815
+ ];
816
+
817
+ // How big a memory is drawn against the core. Everything else follows from the radius: how far
818
+ // two memories push each other apart, how wide the view opens, where the label sits, and how
819
+ // near the pointer has to be. Lower this and the whole constellation gives the core more room.
820
+ // One factor for every memory, so a small one stays in proportion to a large one: 0.64 is the 0.8
821
+ // it was, a fifth smaller, for less noise the moment NOSTROMO opens.
822
+ export const MEMORY_SCALE = 0.64;
823
+
824
+ // Life in the network: one pulse at a time, each on its own clock. The core sends more to the
825
+ // memories the room actually leans on, and two memories that share a theme talk to each other
826
+ // without the core in between. Nothing here is synchronised: that is the point.
827
+ const PULSE_SPEED = 330; // world units per second
828
+ export const PULSE_CAP = 48; // a ceiling, so a large archive stays light
829
+
830
+ // A line with current running along it. The gradient carries the colour of the wire from end to
831
+ // end, and riding on it is a narrow band of white heat that travels from one end to the other
832
+ // and comes round again. Moving the band is all it takes: one gradient a frame, not a hundred
833
+ // little strokes, and the eye reads it as charge on its way somewhere.
834
+ export function currentAlong(ctx, x0, y0, x1, y1, from, to, head, bright) {
835
+ const line = ctx.createLinearGradient(x0, y0, x1, y1);
836
+ // The wire's own colour, and then the band riding on it. Where the two want the same place
837
+ // along the wire the band wins: it is the thing in motion, and dropping it there would make
838
+ // the current blink out every time it passed the middle.
839
+ const stops = [[0, from, 0], [0.5, to.mid ?? to.end, 0], [1, to.end, 0]];
840
+ if (bright > 0.01) {
841
+ const band = [[head - 0.07, `rgba(255, 246, 238, ${0.34 * bright})`], [head, `rgba(255, 255, 255, ${0.82 * bright})`], [head + 0.07, `rgba(255, 234, 222, ${0.3 * bright})`]];
842
+ for (const [at, colour] of band) if (at > 0.001 && at < 0.999) stops.push([at, colour, 1]);
843
+ }
844
+ stops.sort((a, b) => a[0] - b[0] || b[2] - a[2]);
845
+ let last = -1;
846
+ for (const [at, colour] of stops) {
847
+ const place = Math.min(1, Math.max(0, at));
848
+ if (place <= last) continue;
849
+ line.addColorStop(place, colour);
850
+ last = place;
851
+ }
852
+ return line;
853
+ }
854
+
855
+ // How big a travelling pulse is drawn. A pulse is a thing passing between two bodies, not a
856
+ // body: at the size it had it read as another small star and competed with the memories it was
857
+ // travelling to. Lower this and the constellation keeps its order of importance.
858
+ const PULSE_SCALE = 0.7;
859
+
860
+ // Where a pulse is right now, along the same curve its filament is drawn with.
861
+ export function alongCurve(x0, y0, cx, cy, x1, y1, u) {
862
+ const v = 1 - u;
863
+ return { x: v * v * x0 + 2 * v * u * cx + u * u * x1, y: v * v * y0 + 2 * v * u * cy + u * u * y1 };
864
+ }
865
+
866
+ // ---- The nebula the room sits in.
867
+ //
868
+ // It is the archive itself, seen from far enough away to be weather. Young, it is an electrical
869
+ // storm: scattered, cold, flickering, striking often. As the room grows it settles, gathers into
870
+ // a band, and the strikes become rare and slow.
871
+ //
872
+ // Its one rule is that it is never the subject. It is painted before anything else, in colours
873
+ // nothing in the foreground uses, at an opacity that is capped no matter how grown the room is.
874
+ // A reader should notice it only on looking for it; if it ever competes with the core, the
875
+ // dwarfs or the wires between them, it is wrong however pretty it looks.
876
+ const NEBULA_CAP = 0.14; // the most of it that may ever be seen
877
+ const NEBULA_CLOUDS = [
878
+ { at: 0.00, out: 0.52, size: 0.62, drift: 0.0071, hue: '86, 64, 178' },
879
+ { at: 1.94, out: 0.74, size: 0.48, drift: -0.0053, hue: '46, 88, 166' },
880
+ { at: 3.51, out: 0.40, size: 0.70, drift: 0.0039, hue: '112, 52, 156' },
881
+ { at: 5.02, out: 0.86, size: 0.44, drift: -0.0067, hue: '38, 104, 150' },
882
+ { at: 2.71, out: 0.62, size: 0.56, drift: 0.0045, hue: '68, 58, 170' },
883
+ ];
884
+
885
+ export function hexAlpha(hex, alpha) {
886
+ const n = parseInt(hex.slice(1), 16);
887
+ return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${Math.max(0, Math.min(1, alpha))})`;
888
+ }
889
+ export function hexMix(hex, other, amount) {
890
+ const a = parseInt(hex.slice(1), 16), b = parseInt(other.slice(1), 16);
891
+ const ch = (shift) => Math.round(((a >> shift) & 255) * (1 - amount) + ((b >> shift) & 255) * amount);
892
+ return `#${[16, 8, 0].map((shift) => ch(shift).toString(16).padStart(2, '0')).join('')}`;
893
+ }
894
+
895
+ // ---- One scene.
896
+ //
897
+ // Everything below closes over `nostromo`, the scene: the room's page hands its own object, with
898
+ // its canvas and its DOM beside the state; the Macroverso hands one scene per NOSTROMO it draws
899
+ // live. Nothing here reads the page.
900
+ export function createNostromo(nostromo) {
901
+ // What belongs to this scene and no other: the budget of faces painted this frame, and MOTHER's
902
+ // painted face at its current size.
903
+ const shading = { frame: -1, painted: 0, built: false };
904
+ let magmaSprite = null;
905
+
906
+ function beginImplosion(node, t) {
907
+ node.forgetting = { at: t };
908
+ node.debris = Array.from({ length: DEBRIS }, () => ({ a: Math.random() * Math.PI * 2, d: 1.4 + Math.random() * 2.8, w: 0.5 + Math.random() * 1.5, s: 0.6 + Math.random() * 0.9 }));
909
+ const id = node.memory.id;
910
+ const byId = new Map(nostromo.nodes.map((other) => [other.memory.id, other]));
911
+ const dust = nostromo.dust ?? (nostromo.dust = []);
912
+ const fading = nostromo.fading ?? (nostromo.fading = []);
913
+ const wires = [];
914
+ for (const link of nostromo.links) {
915
+ if (link.a !== id && link.b !== id) continue;
916
+ const other = byId.get(link.a === id ? link.b : link.a);
917
+ if (!other) continue;
918
+ wires.push({ x0: node.x, y0: node.y, cx: link.cx ?? (node.x + other.x) / 2, cy: link.cy ?? (node.y + other.y) / 2, x1: other.x, y1: other.y, from: node.color, to: other.color });
919
+ }
920
+ // The wire from MOTHER, too: it comes apart from the memory's end back toward her.
921
+ if (node.rimX !== undefined) wires.push({ x0: node.x, y0: node.y, cx: node.cx ?? node.x / 2, cy: node.cy ?? node.y / 2, x1: node.rimX, y1: node.rimY, from: node.color, to: '#e22816' });
922
+ for (const wire of wires) {
923
+ const length = Math.hypot(wire.x1 - wire.x0, wire.y1 - wire.y0);
924
+ const count = Math.max(8, Math.min(60, Math.round(length / 9)));
925
+ if (dust.length < DUST_CAP) dust.push(...wireDust(wire.x0, wire.y0, wire.cx, wire.cy, wire.x1, wire.y1, wire.from, wire.to, t, count));
926
+ fading.push({ ...wire, born: t });
927
+ }
928
+ nostromo.links = nostromo.links.filter((link) => link.a !== id && link.b !== id);
929
+ nostromo.witness = node;
930
+ }
931
+
932
+ function drawImplosion(ctx, node, t, scale, reduced) {
933
+ const p = Math.min(1, (t - node.forgetting.at) / IMPLOSION);
934
+ const R0 = node.r;
935
+ const x = node.x;
936
+ const y = node.y;
937
+ const px = 1 / scale;
938
+ ctx.save();
939
+ // The horizon: a disc of nothing that opens as the body begins to go and closes after it.
940
+ const open = p < 0.38 ? smooth(0.06, 0.38, p) : 1 - smooth(0.5, 0.82, p);
941
+ if (open > 0.01) {
942
+ const hole = R0 * 0.95 * open;
943
+ const dark = ctx.createRadialGradient(x, y, 0, x, y, hole * 1.15);
944
+ dark.addColorStop(0, 'rgba(0, 0, 0, 1)');
945
+ dark.addColorStop(0.82, 'rgba(0, 0, 0, .96)');
946
+ dark.addColorStop(1, 'rgba(0, 0, 0, 0)');
947
+ ctx.fillStyle = dark;
948
+ ctx.beginPath(); ctx.arc(x, y, hole * 1.15, 0, Math.PI * 2); ctx.fill();
949
+ // Light bent round its rim, in arcs that turn and do not quite close.
950
+ ctx.globalCompositeOperation = 'lighter';
951
+ ctx.lineCap = 'round';
952
+ for (let k = 0; k < 3; k += 1) {
953
+ const from = (reduced ? 0 : t * (2.4 + k * 0.7)) + k * 2.1;
954
+ ctx.strokeStyle = k === 1 ? hexAlpha(node.color, 0.7 * open) : `rgba(214, 228, 255, ${0.55 * open})`;
955
+ ctx.lineWidth = (1.6 - 0.35 * k) * px;
956
+ ctx.beginPath(); ctx.arc(x, y, hole * (1.06 + 0.05 * k), from, from + 1.6 + 0.6 * Math.sin(t + k)); ctx.stroke();
957
+ }
958
+ }
959
+ ctx.globalCompositeOperation = 'lighter';
960
+ // What it was made of, falling in: faster the closer it gets, drawn out into threads.
961
+ if (!reduced && p < 0.66) {
962
+ const fall = Math.min(1, p / 0.62);
963
+ const where = (bit, f) => {
964
+ const reach = R0 * bit.d * (1 - f) ** 1.4;
965
+ const angle = bit.a + (bit.w * f * 3.2) / (0.12 + (1 - f));
966
+ return [x + Math.cos(angle) * reach, y + Math.sin(angle) * reach];
967
+ };
968
+ ctx.lineCap = 'round';
969
+ for (const bit of node.debris ?? []) {
970
+ const f = Math.min(1, fall * bit.s + fall * (1 - bit.s) * fall);
971
+ const alpha = Math.min(1, f * 7) * (1 - smooth(0.82, 1, f));
972
+ if (alpha <= 0.01) continue;
973
+ const [ax, ay] = where(bit, Math.max(0, f - 0.05));
974
+ const [bx, by] = where(bit, f);
975
+ ctx.strokeStyle = hexAlpha(hexMix(node.color, '#ffffff', f), 0.85 * alpha);
976
+ ctx.lineWidth = (0.8 + 1.2 * f) * px;
977
+ ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke();
978
+ }
979
+ }
980
+ // The point of light as it goes, and the ring that runs out into the dark.
981
+ const gone = p - 0.6;
982
+ if (gone > 0 && gone < 0.2) {
983
+ const flash = (1 - gone / 0.2) ** 2;
984
+ const glow = ctx.createRadialGradient(x, y, 0, x, y, R0 * (0.6 + gone * 14));
985
+ glow.addColorStop(0, `rgba(255, 255, 255, ${flash})`);
986
+ glow.addColorStop(0.25, hexAlpha(hexMix(node.color, '#ffffff', 0.6), 0.6 * flash));
987
+ glow.addColorStop(1, hexAlpha(node.color, 0));
988
+ ctx.fillStyle = glow;
989
+ ctx.beginPath(); ctx.arc(x, y, R0 * (0.6 + gone * 14), 0, Math.PI * 2); ctx.fill();
990
+ }
991
+ if (gone > 0) {
992
+ const q = gone / 0.4;
993
+ ctx.strokeStyle = `rgba(206, 222, 255, ${0.32 * (1 - q) ** 1.5})`;
994
+ ctx.lineWidth = (1.4 * (1 - q) + 0.3) * px;
995
+ ctx.beginPath(); ctx.arc(x, y, R0 * (1 + q * 16), 0, Math.PI * 2); ctx.stroke();
996
+ }
997
+ ctx.restore();
998
+ }
999
+
1000
+ // The links coming apart: what is left of each wire, beyond the wave, and the dust behind it.
1001
+ function drawDust(ctx, t, scale) {
1002
+ const px = 1 / scale;
1003
+ ctx.save();
1004
+ ctx.globalCompositeOperation = 'lighter';
1005
+ for (const wire of nostromo.fading ?? []) {
1006
+ const age = t - wire.born;
1007
+ const wave = Math.min(1, Math.max(0, (age - 0.15) / 0.55));
1008
+ const fade = 1 - smooth(0.5, 1.2, age);
1009
+ if (fade <= 0 || wave >= 1) continue;
1010
+ const line = ctx.createLinearGradient(wire.x0, wire.y0, wire.x1, wire.y1);
1011
+ line.addColorStop(0, hexAlpha(wire.from, 0));
1012
+ line.addColorStop(Math.min(0.999, wave + 0.001), hexAlpha(wire.from, 0));
1013
+ line.addColorStop(Math.min(1, wave + 0.06), hexAlpha(hexMix(wire.from, wire.to, wave), 0.55 * fade));
1014
+ line.addColorStop(1, hexAlpha(wire.to, 0.45 * fade));
1015
+ ctx.strokeStyle = line;
1016
+ ctx.lineWidth = 0.9 * px;
1017
+ ctx.beginPath(); ctx.moveTo(wire.x0, wire.y0); ctx.quadraticCurveTo(wire.cx, wire.cy, wire.x1, wire.y1); ctx.stroke();
1018
+ }
1019
+ for (const mote of nostromo.dust ?? []) {
1020
+ const age = t - mote.born;
1021
+ if (age < 0 || age >= mote.life) continue;
1022
+ const glint = 0.55 + 0.45 * Math.sin(t * 9 + mote.phase);
1023
+ const alpha = Math.min(1, age * 8) * (1 - age / mote.life) ** 1.5 * glint;
1024
+ if (alpha <= 0.01) continue;
1025
+ ctx.fillStyle = hexAlpha(mote.colour, Math.min(1, alpha));
1026
+ ctx.beginPath(); ctx.arc(mote.x, mote.y, mote.size * px, 0, Math.PI * 2); ctx.fill();
1027
+ }
1028
+ ctx.restore();
1029
+ }
1030
+
1031
+ function drawSight(ctx, x, y, radius, t, scale, { locked = false, age = 1, label = '', still = false } = {}) {
1032
+ const px = 1 / scale;
1033
+ const close = locked ? 1 - (1 - Math.min(1, age / 0.35)) ** 3 : 1;
1034
+ const R = radius + 9 * px + (1 - close) * 46 * px;
1035
+ const blink = locked && age < 0.45 ? (Math.floor(age * 14) % 2 ? 0.35 : 1) : 1;
1036
+ const alpha = (locked ? 0.95 : 0.5) * blink;
1037
+ const arm = Math.max(7 * px, R * 0.34);
1038
+ ctx.save();
1039
+ ctx.lineCap = 'square';
1040
+ ctx.shadowColor = `rgba(${HUD_GREEN}, .7)`;
1041
+ ctx.shadowBlur = locked ? 6 : 3;
1042
+ ctx.strokeStyle = `rgba(${HUD_GREEN}, ${alpha})`;
1043
+ ctx.lineWidth = (locked ? 1.6 : 1.1) * px;
1044
+ ctx.beginPath();
1045
+ for (const [sx, sy] of [[-1, -1], [1, -1], [1, 1], [-1, 1]]) {
1046
+ ctx.moveTo(x + sx * R, y + sy * (R - arm));
1047
+ ctx.lineTo(x + sx * R, y + sy * R);
1048
+ ctx.lineTo(x + sx * (R - arm), y + sy * R);
1049
+ }
1050
+ ctx.stroke();
1051
+ if (locked) {
1052
+ // The broken ring, turning; and the ticks at the four bearings.
1053
+ const ring = R * 1.22;
1054
+ const spin = still ? 0 : t * 0.6;
1055
+ ctx.lineWidth = 1.1 * px;
1056
+ ctx.strokeStyle = `rgba(${HUD_GREEN}, ${0.7 * blink})`;
1057
+ for (let k = 0; k < 4; k += 1) {
1058
+ const from = spin + k * (Math.PI / 2) + 0.22;
1059
+ ctx.beginPath(); ctx.arc(x, y, ring, from, from + Math.PI / 2 - 0.44); ctx.stroke();
1060
+ }
1061
+ ctx.beginPath();
1062
+ for (const [dx, dy] of [[0, -1], [1, 0], [0, 1], [-1, 0]]) {
1063
+ ctx.moveTo(x + dx * ring * 1.08, y + dy * ring * 1.08);
1064
+ ctx.lineTo(x + dx * (ring * 1.08 + 9 * px), y + dy * (ring * 1.08 + 9 * px));
1065
+ }
1066
+ ctx.stroke();
1067
+ if (label) {
1068
+ ctx.shadowBlur = 0;
1069
+ ctx.font = `${10 * px}px ${getComputedStyle(nostromo.canvas).getPropertyValue('--mono') || 'monospace'}`;
1070
+ ctx.fillStyle = `rgba(${HUD_GREEN}, ${0.9 * blink})`;
1071
+ ctx.fillText(label, x + R + 6 * px, y - R + 3 * px);
1072
+ }
1073
+ }
1074
+ ctx.restore();
1075
+ }
1076
+
1077
+ function focusNostromo(dt, t) {
1078
+ // A memory being forgotten keeps the stage until it is gone: the dark stays down around it.
1079
+ const witness = nostromo.witness?.forgetting && nostromo.nodes.includes(nostromo.witness) ? nostromo.witness : null;
1080
+ const held = (nostromo.selected && nostromo.nodes.includes(nostromo.selected) ? nostromo.selected : null) ?? witness;
1081
+ const lock = nostromo.lock ?? (nostromo.lock = { node: null, at: 0, fade: 0, aim: false });
1082
+ if (held && held !== lock.node) { lock.node = held; lock.at = t; lock.aim = held === nostromo.selected; }
1083
+ if (held?.forgetting) lock.aim = false;
1084
+ lock.fade += ((held ? 1 : 0) - lock.fade) * Math.min(1, 0.12 * dt);
1085
+ if (!held) { lock.aim = false; if (lock.fade < 0.01) lock.node = null; }
1086
+ if (!lock.aim || !held || held.wx === undefined) return;
1087
+ // The turn that brings this dwarf to face the eye: about the vertical axis until it sits on
1088
+ // the line of sight, then tipped until it is level with it.
1089
+ const across = Math.hypot(held.wx, held.wz) || 1;
1090
+ const yawTo = Math.atan2(-held.wx, held.wz);
1091
+ const pitchTo = Math.max(-PITCH_LIMIT, Math.min(PITCH_LIMIT, Math.atan2(held.wy, across)));
1092
+ const yaw = nostromo.yaw ?? 0;
1093
+ const short = ((yawTo - yaw + Math.PI) % (Math.PI * 2) + Math.PI * 2) % (Math.PI * 2) - Math.PI;
1094
+ const step = Math.min(1, FOCUS_TURN * dt);
1095
+ nostromo.yaw = yaw + short * step;
1096
+ nostromo.pitch = (nostromo.pitch ?? 0) + (pitchTo - (nostromo.pitch ?? 0)) * step;
1097
+ }
1098
+
1099
+ function projectNostromo() {
1100
+ const yaw = nostromo.yaw ?? 0;
1101
+ const pitch = nostromo.pitch ?? 0;
1102
+ const cy = Math.cos(yaw), sy = Math.sin(yaw), cp = Math.cos(pitch), sp = Math.sin(pitch);
1103
+ for (const node of nostromo.nodes) {
1104
+ if (node.wx === undefined) { node.wx = node.x; node.wy = node.y; node.wz = 0; }
1105
+ node.r3 ??= node.r;
1106
+ // Turned about the vertical axis, then tipped about the horizontal one: the same order the
1107
+ // core's own longitude and lean are applied in, so the star and the body turn as one thing.
1108
+ const x1 = node.wx * cy + node.wz * sy;
1109
+ const z1 = node.wz * cy - node.wx * sy;
1110
+ const y2 = node.wy * cp - z1 * sp;
1111
+ const z2 = node.wy * sp + z1 * cp;
1112
+ const near = VIEW_DISTANCE / Math.max(200, VIEW_DISTANCE - z2);
1113
+ node.x = x1 * near;
1114
+ node.y = y2 * near;
1115
+ node.depth = z2;
1116
+ node.r = node.r3 * near;
1117
+ }
1118
+ }
1119
+
1120
+ function sizeNostromo() {
1121
+ const canvas = nostromo.canvas;
1122
+ const rect = canvas.getBoundingClientRect();
1123
+ const dpr = window.devicePixelRatio || 1;
1124
+ if (!rect.width || !rect.height) return false;
1125
+ nostromo.size = { w: rect.width, h: rect.height, dpr };
1126
+ canvas.width = Math.round(rect.width * dpr);
1127
+ canvas.height = Math.round(rect.height * dpr);
1128
+ placeNostromo();
1129
+ return true;
1130
+ }
1131
+ // Every memory not yet placed is given its first place on the body. Apart from the sizing, so a
1132
+ // scene whose canvas is not on the page (the Macroverso paints its sprites off screen) can be
1133
+ // sized by hand and still placed the same way.
1134
+ function placeNostromo() {
1135
+ // Spread evenly over the whole surface, so no part of the brain is bald and none is a crowd.
1136
+ // In the plane a crowd pushed itself round the ring; in a body it escapes into depth instead
1137
+ // and stays where it started, so the start has to be even already. A golden-angle spiral runs
1138
+ // from the frontal pole to the back of the head, and the memories walk it grouped by kind:
1139
+ // each kind takes a band of its own, a lobe as wide as it has memories.
1140
+ const kinds = Object.keys(MEMORY_COLORS);
1141
+ const waiting = nostromo.nodes.filter((node) => !node.placed)
1142
+ .sort((a, b) => (kinds.indexOf(a.memory.kind) - kinds.indexOf(b.memory.kind)) || (a.angle - b.angle));
1143
+ for (const [i, node] of waiting.entries()) {
1144
+ const along = 1 - (2 * (i + 0.5)) / waiting.length;
1145
+ const ring = Math.sqrt(1 - along * along);
1146
+ const turn = i * GOLDEN_ANGLE;
1147
+ let ux = -along;
1148
+ let uy = ring * Math.cos(turn);
1149
+ let uz = ring * Math.sin(turn);
1150
+ // Never on the midline, where the fissure is.
1151
+ if (Math.abs(uz) < FISSURE) { uz = Math.sign(uz || 1) * FISSURE; const fit = Math.hypot(ux, uy, uz); ux /= fit; uy /= fit; uz /= fit; }
1152
+ const radius = NOSTROMO_ORBIT * brainRadius(Math.atan2(uy, ux)) * shellDepth(node);
1153
+ node.wx = ux * radius;
1154
+ node.wy = uy * radius;
1155
+ node.wz = uz * BRAIN_WIDTH * radius;
1156
+ node.wvx = 0; node.wvy = 0; node.wvz = 0;
1157
+ node.placed = true;
1158
+ }
1159
+ }
1160
+
1161
+ // The heartbeat: lub, then a softer dub, each decaying fast. 0..~1.5.
1162
+ function heartbeat(t) {
1163
+ const period = nostromo.cage ? 0.62 : nostromo.altered ? 0.82 : HEART_PERIOD;
1164
+ const phase = (t % period) / period;
1165
+ const pulse = (at) => (phase >= at ? Math.exp(-(phase - at) * 14) : 0);
1166
+ return { phase, beat: pulse(0) + 0.55 * pulse(0.22) };
1167
+ }
1168
+
1169
+ function dwarfSprite(node, radius, burn, pixels, frame) {
1170
+ if (typeof ImageData !== 'function') return null;
1171
+ if (shading.frame !== frame) { shading.frame = frame; shading.painted = 0; shading.built = false; }
1172
+ const size = Math.max(8, Math.min(200, 2 * Math.round(radius * pixels)));
1173
+ const kind = node.memory.kind;
1174
+ if (!dwarfAlbedos.get(kind)?.done) {
1175
+ if (shading.built) return null;
1176
+ shading.built = true;
1177
+ if (!dwarfAlbedo(kind, ALBEDO_ROWS_PER_FRAME)) return null;
1178
+ }
1179
+ const map = dwarfAlbedo(kind);
1180
+ const colour = [1, 3, 5].map((at) => Number.parseInt(String(node.color).slice(at, at + 2), 16) / 255);
1181
+ if (colour.some((value) => !Number.isFinite(value))) return null;
1182
+ let sprite = node.sprite;
1183
+ // A body that has never been painted at this size waits its turn like any other: when a whole
1184
+ // class becomes ready at once it is introduced a few dozen a frame, not all in one.
1185
+ if ((!sprite || sprite.size !== size) && shading.painted >= SHADE_BUDGET) return sprite?.canvas ?? null;
1186
+ if (!sprite || sprite.size !== size) {
1187
+ const canvas = document.createElement('canvas');
1188
+ canvas.width = size;
1189
+ canvas.height = size;
1190
+ const paint = canvas.getContext('2d');
1191
+ if (!paint?.createImageData) return null;
1192
+ sprite = node.sprite = { canvas, paint, image: paint.createImageData(size, size), size, turn: NaN, burn: -1, colour: '', standing: '' };
1193
+ }
1194
+ const turn = node.spin * 0.3 + node.seed;
1195
+ // Painted again once the middle of its face has moved half a pixel, so a small body is painted
1196
+ // rarely and a large one, close up, as often as it needs.
1197
+ const fresh = !(sprite.turn === sprite.turn);
1198
+ const traits = STAR_TRAITS[kind] ?? SUN_TRAITS;
1199
+ const standing = `${node.memory.zone ?? 'hold'}${node.cold ? ':cold' : ''}`;
1200
+ const moved = fresh
1201
+ || Math.abs(turn - sprite.turn) * size > 1
1202
+ || Math.abs(burn - sprite.burn) > 0.04
1203
+ || sprite.colour !== node.color
1204
+ || sprite.standing !== standing;
1205
+ if (moved && shading.painted < SHADE_BUDGET) {
1206
+ shading.painted += 1;
1207
+ shadeDwarf(sprite.image.data, sphereTable(size), map, { colour, traits, surface: surfaceOf(node.memory, traits, Boolean(node.cold)), turn, burn });
1208
+ sprite.paint.putImageData(sprite.image, 0, 0);
1209
+ sprite.turn = turn;
1210
+ sprite.burn = burn;
1211
+ sprite.colour = node.color;
1212
+ sprite.standing = standing;
1213
+ }
1214
+ return sprite.canvas;
1215
+ }
1216
+
1217
+ // The bursts alive now: born at random on the face, more often while MOTHER is alarmed, none for
1218
+ // someone who asked for stillness. A burst goes off in the rock, not on the glass in front of it:
1219
+ // it is kept where it went off on the body (a longitude in the crust's own frame, and a latitude)
1220
+ // and rides the turn with the crust, the way the shading reads the crust (`turn` and `tilt`, as in
1221
+ // shadeMagma). Kept as a point on the screen instead, it stood still while the body turned some
1222
+ // forty degrees under it in a burst's life.
1223
+ function magmaBlasts(t, { alarm = 0, reduced = false, turn = 0, tilt = 0 } = {}) {
1224
+ const blasts = (nostromo.blasts ??= []);
1225
+ const cosTilt = Math.cos(tilt);
1226
+ const sinTilt = Math.sin(tilt);
1227
+ if (!reduced && Math.random() < (1 / BLAST_EVERY) * (1 + 3 * alarm) / 60) {
1228
+ const z = 0.35 + 0.6 * Math.random();
1229
+ const angle = Math.random() * Math.PI * 2;
1230
+ const ring = Math.sqrt(1 - z * z);
1231
+ const x = Math.cos(angle) * ring, y = Math.sin(angle) * ring;
1232
+ // From the face in view to the body: the view's tip undone, then the crust's turn added.
1233
+ const wy = y * cosTilt + z * sinTilt;
1234
+ const wz = z * cosTilt - y * sinTilt;
1235
+ blasts.push({ born: t, lon: Math.atan2(x, wz) + turn, lat: Math.asin(Math.max(-1, Math.min(1, wy))) });
1236
+ }
1237
+ nostromo.blasts = blasts.filter((blast) => t - blast.born < BLAST_LIFE && blast.lon !== undefined);
1238
+ return nostromo.blasts.map((blast) => {
1239
+ // And back: where that point of the body faces now.
1240
+ const lon = blast.lon - turn;
1241
+ const cl = Math.cos(blast.lat);
1242
+ const bx = cl * Math.sin(lon), wy = Math.sin(blast.lat), wz = cl * Math.cos(lon);
1243
+ return { x: bx, y: wy * cosTilt - wz * sinTilt, z: wy * sinTilt + wz * cosTilt, age: (t - blast.born) / BLAST_LIFE };
1244
+ });
1245
+ }
1246
+
1247
+ // The body's face, painted into a buffer of its own and laid on the disc. Null where there is no
1248
+ // pixel buffer, or until the map is whole: the star is drawn the older way until then.
1249
+ function magmaFace(radius, pixels, t, { turn, beat, alarm, reduced, tilt = 0 }) {
1250
+ if (typeof ImageData !== 'function') return null;
1251
+ const map = magmaMap(12);
1252
+ if (!map) return null;
1253
+ const size = Math.max(32, Math.min(MAGMA_SIZE, 2 * Math.round(radius * pixels)));
1254
+ if (!magmaSprite || magmaSprite.size !== size) {
1255
+ const canvas = document.createElement('canvas');
1256
+ canvas.width = size;
1257
+ canvas.height = size;
1258
+ const paint = canvas.getContext('2d');
1259
+ if (!paint?.createImageData) return null;
1260
+ magmaSprite = { canvas, paint, image: paint.createImageData(size, size), size };
1261
+ }
1262
+ // Painted every other frame: rock this slow, and bursts this short, read the same at half the
1263
+ // rate, and the frames between are free for the dwarfs.
1264
+ magmaSprite.frame = (magmaSprite.frame ?? 0) + 1;
1265
+ if (magmaSprite.painted && magmaSprite.frame % 2) return magmaSprite.canvas;
1266
+ magmaSprite.painted = true;
1267
+ const flowTime = reduced ? 0 : t;
1268
+ shadeMagma(magmaSprite.image.data, sphereTable(size), map, {
1269
+ turn,
1270
+ flow: turn * 0.82 + flowTime * 0.018,
1271
+ beat,
1272
+ blasts: magmaBlasts(t, { alarm, reduced, turn, tilt }),
1273
+ tilt,
1274
+ });
1275
+ magmaSprite.paint.putImageData(magmaSprite.image, 0, 0);
1276
+ return magmaSprite.canvas;
1277
+ }
1278
+
1279
+ function stepNostromo(dt, t) {
1280
+ // What is being forgotten holds still while it goes.
1281
+ const nodes = nostromo.nodes.filter((node) => node.scale > 0.01 && !node.forgetting);
1282
+ const byId = new Map(nodes.map((node) => [node.memory.id, node]));
1283
+ for (const node of nodes) {
1284
+ if (node.wx === undefined) { node.wx = node.x; node.wy = node.y; node.wz = 0; }
1285
+ }
1286
+ // A far universe in the Macroverso is `frozen`: its bodies already settled, it only lives —
1287
+ // it turns, beats and sends its pulses — without paying for the forces every frame.
1288
+ if (nostromo.frozen) {
1289
+ for (const node of nodes) node.spin = (node.spin ?? node.seed) + dt * 0.012 * (1 + (node.seed % 1));
1290
+ } else {
1291
+ for (const node of nodes) {
1292
+ let fx = 0;
1293
+ let fy = 0;
1294
+ let fz = 0;
1295
+ const dist = Math.hypot(node.wx, node.wy, node.wz) || 1;
1296
+ // A soft shell around the core, in the shape of a brain: too close is pushed out, too far
1297
+ // pulled in. The outline gives the reach at this bearing seen from the side; the width
1298
+ // gives it toward the temples. `shell` is 1 exactly on the surface.
1299
+ const target = NOSTROMO_ORBIT * brainRadius(Math.atan2(node.wy, node.wx)) * shellDepth(node);
1300
+ const shell = Math.hypot(Math.hypot(node.wx, node.wy) / target, node.wz / (BRAIN_WIDTH * target)) || 1;
1301
+ const pull = (1 / shell - 1) * dist * 0.004;
1302
+ fx += (node.wx / dist) * pull;
1303
+ fy += (node.wy / dist) * pull;
1304
+ fz += (node.wz / dist) * pull;
1305
+ if (dist < CORE_R * 1.7) { const push = (CORE_R * 1.7 - dist) * 0.02; fx += (node.wx / dist) * push; fy += (node.wy / dist) * push; fz += (node.wz / dist) * push; }
1306
+ // The fissure: out of the midline, back toward the hemisphere it already leans into.
1307
+ const gap = FISSURE * NOSTROMO_ORBIT;
1308
+ if (Math.abs(node.wz) < gap) fz += Math.sign(node.wz || (frac(node.seed * 0.618) < 0.5 ? -1 : 1)) * (gap - Math.abs(node.wz)) * 0.02;
1309
+ const own = node.r3 ?? node.r;
1310
+ // Every pair, every frame: the far ones are turned away on the squared distance, before any
1311
+ // square root is taken, and the rest without Math.hypot, which costs several times as much.
1312
+ for (const other of nodes) {
1313
+ if (other === node) continue;
1314
+ const ox = node.wx - other.wx;
1315
+ const oy = node.wy - other.wy;
1316
+ const oz = node.wz - other.wz;
1317
+ const reach = (own + (other.r3 ?? other.r) + 46) * 2.2;
1318
+ const square = ox * ox + oy * oy + oz * oz;
1319
+ if (square >= reach * reach) continue;
1320
+ const d = Math.sqrt(square) || 1;
1321
+ const push = ((reach - d) / reach) * 0.9;
1322
+ fx += (ox / d) * push; fy += (oy / d) * push; fz += (oz / d) * push;
1323
+ }
1324
+ if (!nostromo.reduced) {
1325
+ fx += Math.sin(node.seed + t * 0.38) * 0.02;
1326
+ fy += Math.cos(node.seed * 1.3 + t * 0.32) * 0.02;
1327
+ fz += Math.sin(node.seed * 0.7 + t * 0.29) * 0.02;
1328
+ }
1329
+ node.fx = fx;
1330
+ node.fy = fy;
1331
+ node.fz = fz;
1332
+ }
1333
+ for (const link of nostromo.links) {
1334
+ const a = byId.get(link.a);
1335
+ const b = byId.get(link.b);
1336
+ if (!a || !b) continue;
1337
+ const dx = b.wx - a.wx;
1338
+ const dy = b.wy - a.wy;
1339
+ const dz = b.wz - a.wz;
1340
+ const d = Math.hypot(dx, dy, dz) || 1;
1341
+ const rest = (a.r3 ?? a.r) + (b.r3 ?? b.r) + 96;
1342
+ const k = (d - rest) * 0.0035 * link.weight;
1343
+ a.fx += (dx / d) * k; a.fy += (dy / d) * k; a.fz += (dz / d) * k;
1344
+ b.fx -= (dx / d) * k; b.fy -= (dy / d) * k; b.fz -= (dz / d) * k;
1345
+ }
1346
+ for (const node of nodes) {
1347
+ node.wvx = ((node.wvx ?? 0) + node.fx * dt) * 0.86;
1348
+ node.wvy = ((node.wvy ?? 0) + node.fy * dt) * 0.86;
1349
+ node.wvz = ((node.wvz ?? 0) + node.fz * dt) * 0.86;
1350
+ node.wx += node.wvx * dt;
1351
+ node.wy += node.wvy * dt;
1352
+ node.wz += node.wvz * dt;
1353
+ node.spin = (node.spin ?? node.seed) + dt * 0.012 * (1 + (node.seed % 1));
1354
+ }
1355
+ }
1356
+ // A forgotten memory goes on the clock, not by a fixed step a frame, so the implosion takes
1357
+ // the same time on any screen; the dust drifts and goes out on the same clock.
1358
+ for (const node of nostromo.nodes) if (node.forgetting) node.scale = implosionScale((t - node.forgetting.at) / IMPLOSION);
1359
+ nostromo.nodes = nostromo.nodes.filter((node) => !(node.forgetting && t - node.forgetting.at >= IMPLOSION));
1360
+ if (nostromo.dust?.length) {
1361
+ for (const mote of nostromo.dust) {
1362
+ if (t < mote.born || nostromo.reduced) continue;
1363
+ mote.x += mote.vx * dt * 0.35;
1364
+ mote.y += mote.vy * dt * 0.35;
1365
+ mote.vx *= 0.995; mote.vy *= 0.995;
1366
+ }
1367
+ nostromo.dust = nostromo.dust.filter((mote) => t - mote.born < mote.life);
1368
+ }
1369
+ if (nostromo.fading?.length) nostromo.fading = nostromo.fading.filter((wire) => t - wire.born < 1.3);
1370
+ // The core turns on its own axis, slowly, the way a body does.
1371
+ // About one turn in twenty seconds: slow enough to be a body, fast enough to be seen turning.
1372
+ nostromo.spin = (nostromo.spin ?? 0) + dt * 0.0052 * (nostromo.cage ? 2.2 : 1);
1373
+ // A new beat sends a wave out from the core.
1374
+ const { phase } = heartbeat(t);
1375
+ if (phase < nostromo.lastPhase && !nostromo.reduced) nostromo.rings.push({ born: t });
1376
+ nostromo.lastPhase = phase;
1377
+ nostromo.rings = nostromo.rings.filter((ring) => t - ring.born < 2.4);
1378
+ pulseNostromo(dt, t, nodes, byId);
1379
+ }
1380
+
1381
+ function pulseNostromo(dt, t, nodes, byId) {
1382
+ const pulses = nostromo.pulses ?? (nostromo.pulses = []);
1383
+ for (const node of nodes) {
1384
+ node.lit = Math.max(0, (node.lit ?? 0) - dt * 0.05);
1385
+ // Charge is the slow one: a memory being fed from the core and from its neighbours at once
1386
+ // builds it up, and one nobody feeds cools off over a minute or so.
1387
+ node.charge = Math.max(0, (node.charge ?? 0) - dt * 0.0035);
1388
+ }
1389
+ nostromo.coreLit = Math.max(0, (nostromo.coreLit ?? 0) - dt * 0.05);
1390
+ if (!nostromo.reduced && pulses.length < PULSE_CAP) {
1391
+ for (const node of nodes) {
1392
+ // An active memory is spoken to often; a forgotten one, rarely.
1393
+ if (Math.random() < node.activity * 0.013 * dt) {
1394
+ const outward = Math.random() > 0.22; // most travel out; some answer back
1395
+ pulses.push({ from: outward ? null : node, to: outward ? node : null, node, born: t, life: Math.max(0.5, (Math.hypot(node.x, node.y) || 1) / PULSE_SPEED), kind: 'core' });
1396
+ }
1397
+ }
1398
+ for (const link of nostromo.links) {
1399
+ const a = byId.get(link.a);
1400
+ const b = byId.get(link.b);
1401
+ if (!a || !b) continue;
1402
+ const together = Math.min(a.activity, b.activity) * link.weight;
1403
+ if (Math.random() < together * 0.009 * dt) {
1404
+ const forward = Math.random() > 0.5;
1405
+ pulses.push({ from: forward ? a : b, to: forward ? b : a, link, born: t, life: Math.max(0.45, (Math.hypot(b.x - a.x, b.y - a.y) || 1) / PULSE_SPEED), kind: 'link' });
1406
+ }
1407
+ }
1408
+ }
1409
+ for (const pulse of pulses) {
1410
+ if (t - pulse.born < pulse.life) continue;
1411
+ // It arrived: whatever it reached lights up for a moment.
1412
+ if (pulse.kind === 'link') {
1413
+ pulse.to.lit = Math.min(1.4, (pulse.to.lit ?? 0) + 0.7);
1414
+ pulse.to.charge = Math.min(1, (pulse.to.charge ?? 0) + 0.09);
1415
+ } else if (pulse.to) {
1416
+ pulse.to.lit = Math.min(1.4, (pulse.to.lit ?? 0) + 0.9);
1417
+ pulse.to.charge = Math.min(1, (pulse.to.charge ?? 0) + 0.13);
1418
+ }
1419
+ else nostromo.coreLit = Math.min(1.4, (nostromo.coreLit ?? 0) + 0.5);
1420
+ }
1421
+ nostromo.pulses = pulses.filter((pulse) => t - pulse.born < pulse.life);
1422
+ }
1423
+
1424
+ // The camera follows the whole system until the human takes over.
1425
+ function fitNostromo() {
1426
+ const cam = nostromo.cam;
1427
+ if (!cam) return;
1428
+ // A held dwarf is where the camera goes, whoever last moved it.
1429
+ const witness = nostromo.witness?.forgetting && nostromo.nodes.includes(nostromo.witness) ? nostromo.witness : null;
1430
+ const held = (nostromo.selected && nostromo.nodes.includes(nostromo.selected) ? nostromo.selected : null) ?? witness;
1431
+ if (held) {
1432
+ // Centred in the part of the map the card leaves free, not under the card. How much the
1433
+ // card covers is the room's to say; the engine only asks.
1434
+ const covered = nostromo.covered?.() ?? 0;
1435
+ const aimX = held.x + covered / 2 / cam.scale;
1436
+ cam.x += (aimX - cam.x) * 0.08;
1437
+ cam.y += (held.y - cam.y) * 0.08;
1438
+ }
1439
+ if (cam.manual) return;
1440
+ const { w, h } = nostromo.size;
1441
+ let minX = -CORE_R * 2.4, maxX = CORE_R * 2.4, minY = -CORE_R * 2.4, maxY = CORE_R * 2.4;
1442
+ // Across, the frame holds the body at every turn it can take, so turning it never makes the
1443
+ // camera breathe in and out; up and down it follows what is on screen.
1444
+ const near = VIEW_DISTANCE / (VIEW_DISTANCE - NOSTROMO_ORBIT * BRAIN_WIDTH);
1445
+ for (const node of nostromo.nodes) {
1446
+ const reach = Math.hypot(node.wx ?? node.x, node.wz ?? 0) * near + node.r * 3;
1447
+ minX = Math.min(minX, -reach); maxX = Math.max(maxX, reach);
1448
+ minY = Math.min(minY, node.y - node.r * 3); maxY = Math.max(maxY, node.y + node.r * 3);
1449
+ }
1450
+ // A narrow margin: the constellation is the content of this window, so it takes most of it.
1451
+ const pad = 36;
1452
+ const scale = Math.min(1.4, Math.max(0.3, Math.min((w - pad * 2) / (maxX - minX || 1), (h - pad * 2 - 24) / (maxY - minY || 1))));
1453
+ const cx = (minX + maxX) / 2;
1454
+ const cy = (minY + maxY) / 2 - 20 / scale;
1455
+ if (!held) { cam.x += (cx - cam.x) * 0.05; cam.y += (cy - cam.y) * 0.05; }
1456
+ cam.scale += (scale - cam.scale) * 0.05;
1457
+ }
1458
+ function toScreen(x, y) { const { w, h } = nostromo.size; const cam = nostromo.cam; return { x: w / 2 + (x - cam.x) * cam.scale, y: h / 2 + (y - cam.y) * cam.scale }; }
1459
+ function toWorld(sx, sy) { const { w, h } = nostromo.size; const cam = nostromo.cam; return { x: cam.x + (sx - w / 2) / cam.scale, y: cam.y + (sy - h / 2) / cam.scale }; }
1460
+
1461
+ // The weather of the archive. `grown` is 0 for a room that has distilled nothing and 1 for one
1462
+ // worth training on; everything here reads from it and nothing here is ever loud.
1463
+ function drawNebula(ctx, t, w, h, cam) {
1464
+ const grown = Math.min(1, Math.max(0, nostromo.maturity ?? 0));
1465
+ if (nostromo.reduced && !nostromo.nebulaSeen) nostromo.nebulaSeen = true;
1466
+ const time = nostromo.reduced ? 0 : t;
1467
+ const across = Math.hypot(w, h);
1468
+ // Young clouds are scattered wide and thin; a grown room draws them in and fills them out.
1469
+ const spread = across * (0.62 - 0.18 * grown);
1470
+ const depth = 0.06; // how much of the camera it follows: far away things barely move
1471
+ const cx = w / 2 - cam.x * depth;
1472
+ const cy = h / 2 - cam.y * depth;
1473
+
1474
+ ctx.save();
1475
+ ctx.globalCompositeOperation = 'lighter';
1476
+ for (let i = 0; i < NEBULA_CLOUDS.length; i += 1) {
1477
+ const cloud = NEBULA_CLOUDS[i];
1478
+ // As it matures the clouds settle toward one band instead of scattering round the sky.
1479
+ const angle = cloud.at + time * cloud.drift * (1.6 - grown);
1480
+ const lean = 0.42 * grown;
1481
+ const out = spread * cloud.out;
1482
+ const x = cx + Math.cos(angle) * out;
1483
+ const y = cy + Math.sin(angle) * out * (1 - lean * 0.55);
1484
+ const size = across * cloud.size * (0.34 + 0.2 * grown);
1485
+ // Breathing, quick and shallow while it storms, slow and deep once it settles.
1486
+ const breath = 0.86 + 0.14 * Math.sin(time * (0.5 - 0.34 * grown) + i * 1.7);
1487
+ const alpha = NEBULA_CAP * (0.42 + 0.58 * grown) * breath;
1488
+ const cloudy = ctx.createRadialGradient(x, y, 0, x, y, size);
1489
+ cloudy.addColorStop(0, `rgba(${cloud.hue}, ${alpha})`);
1490
+ cloudy.addColorStop(0.42, `rgba(${cloud.hue}, ${alpha * 0.38})`);
1491
+ cloudy.addColorStop(1, `rgba(${cloud.hue}, 0)`);
1492
+ ctx.fillStyle = cloudy;
1493
+ ctx.beginPath(); ctx.arc(x, y, size, 0, Math.PI * 2); ctx.fill();
1494
+ }
1495
+
1496
+ // The storm. Often and sharp while the room is young, rare and slow once it is grown: what
1497
+ // changes is not how bright a strike is but how often the sky is torn.
1498
+ if (!nostromo.reduced) {
1499
+ nostromo.bolts = (nostromo.bolts ?? []).filter((bolt) => t - bolt.born < bolt.life);
1500
+ const every = 0.9 + 7.5 * grown; // seconds between strikes, on average
1501
+ if (nostromo.bolts.length < 2 && Math.random() < 1 / (every * 60)) {
1502
+ // Inside a cloud, and inside the frame. Strikes used to be thrown onto a circle the size
1503
+ // of the diagonal, so a third of them happened where nobody could see them and the rest
1504
+ // fell through empty sky. Lightning belongs in the weather it comes from.
1505
+ const cloud = NEBULA_CLOUDS[Math.floor(Math.random() * NEBULA_CLOUDS.length)];
1506
+ const angle = cloud.at + time * cloud.drift * (1.6 - grown);
1507
+ const out = spread * cloud.out;
1508
+ const lean = 0.42 * grown;
1509
+ nostromo.bolts.push({
1510
+ born: t, life: 0.5 + Math.random() * 0.45,
1511
+ x: Math.min(w * 0.94, Math.max(w * 0.06, cx + Math.cos(angle) * out + (Math.random() - 0.5) * across * 0.16)),
1512
+ y: Math.min(h * 0.94, Math.max(h * 0.06, cy + Math.sin(angle) * out * (1 - lean * 0.55) + (Math.random() - 0.5) * across * 0.1)),
1513
+ angle: Math.random() * Math.PI * 2,
1514
+ reach: across * (0.1 + Math.random() * 0.14),
1515
+ seed: Math.random() * 100,
1516
+ });
1517
+ // The lit cloud always contains the thread inside it: a bolt poking out of its own glow
1518
+ // would read as a scratch on the canvas rather than as weather.
1519
+ const bolt = nostromo.bolts.at(-1);
1520
+ bolt.glow = bolt.reach * (1.7 + Math.random() * 0.8);
1521
+ }
1522
+ for (const bolt of nostromo.bolts) {
1523
+ const age = (t - bolt.born) / bolt.life;
1524
+ // It arrives at once and fades: a flash that eased in would read as a lamp, not lightning.
1525
+ const flash = Math.max(0, 1 - age) ** 2 * NEBULA_CAP * 2.4;
1526
+ // A storm far enough away is seen as the cloud lighting up, not as the bolt. This is what
1527
+ // makes it read at all without ever being bright: the sheet carries it, the thread only
1528
+ // gives it a shape.
1529
+ const sheet = ctx.createRadialGradient(bolt.x, bolt.y, 0, bolt.x, bolt.y, bolt.glow);
1530
+ sheet.addColorStop(0, `rgba(132, 152, 245, ${flash * 0.5})`);
1531
+ sheet.addColorStop(0.45, `rgba(104, 124, 224, ${flash * 0.16})`);
1532
+ sheet.addColorStop(1, 'rgba(90, 110, 210, 0)');
1533
+ ctx.fillStyle = sheet;
1534
+ ctx.beginPath(); ctx.arc(bolt.x, bolt.y, bolt.glow, 0, Math.PI * 2); ctx.fill();
1535
+ ctx.strokeStyle = `rgba(150, 168, 255, ${flash})`;
1536
+ ctx.lineWidth = 1.1;
1537
+ ctx.lineCap = 'round';
1538
+ for (let branch = 0; branch < 2; branch += 1) {
1539
+ ctx.beginPath();
1540
+ ctx.moveTo(bolt.x, bolt.y);
1541
+ let px = bolt.x;
1542
+ let py = bolt.y;
1543
+ let heading = bolt.angle + (branch ? 0.7 : 0);
1544
+ for (let step = 1; step <= 5; step += 1) {
1545
+ heading += Math.sin(bolt.seed + step * 2.3 + branch) * 0.6;
1546
+ px += Math.cos(heading) * (bolt.reach / 5);
1547
+ py += Math.sin(heading) * (bolt.reach / 5);
1548
+ ctx.lineTo(px, py);
1549
+ }
1550
+ ctx.stroke();
1551
+ }
1552
+ }
1553
+ }
1554
+ ctx.globalCompositeOperation = 'source-over';
1555
+ ctx.restore();
1556
+ }
1557
+
1558
+ function drawNostromo(t) {
1559
+ const ctx = nostromo.canvas.getContext('2d');
1560
+ const { w, h, dpr } = nostromo.size;
1561
+ const cam = nostromo.cam;
1562
+ const { beat } = heartbeat(t);
1563
+ const alarm = nostromo.alarm ? Math.max(0, 1 - (t - nostromo.alarm) / 3.6) : 0;
1564
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1565
+ ctx.clearRect(0, 0, w, h);
1566
+ // The nebula, before anything else and behind everything: it is the ground the room stands on.
1567
+ // A bare scene — a body painted into a sprite, or composed live over the Macroverso's own
1568
+ // space — gets no sky, no stars and no vignette: those belong to whoever holds it.
1569
+ if (!nostromo.bare) drawNebula(ctx, t, w, h, cam);
1570
+
1571
+ // Stars, with a little parallax against the camera.
1572
+ if (!nostromo.bare) {
1573
+ ctx.fillStyle = 'rgba(255, 244, 240, .35)';
1574
+ for (let i = 0; i < 120; i += 1) {
1575
+ const sx = ((i * 97.3 - cam.x * 0.08) % w + w) % w;
1576
+ const sy = ((i * 53.7 + 31 - cam.y * 0.08) % h + h) % h;
1577
+ ctx.globalAlpha = 0.12 + 0.22 * Math.abs(Math.sin(t * 0.6 + i));
1578
+ ctx.fillRect(sx, sy, 1.2, 1.2);
1579
+ }
1580
+ ctx.globalAlpha = 1;
1581
+ }
1582
+
1583
+ ctx.save();
1584
+ ctx.translate(w / 2, h / 2);
1585
+ ctx.scale(cam.scale, cam.scale);
1586
+ ctx.translate(-cam.x, -cam.y);
1587
+ // Seen from far (the Macroverso, or a room's map zoomed out), a body is a few pixels: the core
1588
+ // reads a little larger so the universe is read by its sun, every glow is softer so a crowd of
1589
+ // stars does not run together into a white blot, and a memory never shrinks below a point the
1590
+ // eye can still hold. Close up, all of it is as it always was.
1591
+ const nearness = Math.min(1, Math.max(0, (cam.scale - 0.1) / 0.3)); // 0 far · 1 near
1592
+ // In the Macroverso (a bare scene) a universe seen from far is one small thing among others:
1593
+ // its glow, its pulses and its heartbeat's rings dim and shrink with the distance, so the near
1594
+ // ones lead and the far ones do not blaze. A room's own NOSTROMO is not bare, and keeps them.
1595
+ const farLight = nostromo.bare ? 0.4 + 0.6 * nearness : 1;
1596
+ const farSize = nostromo.bare ? 0.45 + 0.55 * nearness : 1;
1597
+ const glowAt = (0.5 + 0.5 * nearness) * farLight;
1598
+ const R = CORE_R * (1 + 0.07 * beat + 0.12 * alarm) * (1 + 0.22 * (1 - nearness));
1599
+
1600
+ // Plasma between linked memories: they share a theme, so a filament runs between them. Drawn
1601
+ // as a wide breath of colour and a thin bright thread: the same glow as a blur, far cheaper.
1602
+ const byId = new Map(nostromo.nodes.map((node) => [node.memory.id, node]));
1603
+ // An open card is a question about one memory: its own wires answer a little louder, and the
1604
+ // one under the cursor in the list answers louder still. Nothing else is dimmed for it.
1605
+ const focus = nostromo.selected?.memory.id ?? null;
1606
+ for (const link of nostromo.links) {
1607
+ const a = byId.get(link.a);
1608
+ const b = byId.get(link.b);
1609
+ if (!a || !b) continue;
1610
+ const gain = nostromo.focusLink === link ? 2.4 : focus !== null && (link.a === focus || link.b === focus) ? 1.6 : 1;
1611
+ const dx = b.x - a.x, dy = b.y - a.y;
1612
+ const d = Math.hypot(dx, dy) || 1;
1613
+ const bow = Math.sin(t * 1.3 + a.seed + b.seed) * Math.min(40, d * 0.15);
1614
+ link.cx = (a.x + b.x) / 2 - (dy / d) * bow;
1615
+ link.cy = (a.y + b.y) / 2 + (dx / d) * bow;
1616
+ // Between two memories the wire takes the colour of whichever is nearer, so it leaves one
1617
+ // star as that star and arrives as the other, trading hands where they meet.
1618
+ const charged = Math.max(a.charge ?? 0, b.charge ?? 0);
1619
+ const downhill = (a.charge ?? 0) >= (b.charge ?? 0);
1620
+ const near = downhill ? a : b;
1621
+ const far = downhill ? b : a;
1622
+ // Current runs the way charge does: out of the fuller one and into the emptier.
1623
+ const spark = ((t * (0.13 + 0.2 * charged) + a.seed * 0.29 + b.seed * 0.17) % 1 + 1) % 1;
1624
+ ctx.strokeStyle = currentAlong(ctx, near.x, near.y, far.x, far.y,
1625
+ hexAlpha(near.color, Math.min(0.95, 0.6 * link.weight * gain)),
1626
+ { mid: hexAlpha(hexMix(near.color, far.color, 0.5), Math.min(0.8, 0.3 * link.weight * gain)), end: hexAlpha(far.color, Math.min(0.95, 0.6 * link.weight * gain)) },
1627
+ spark, 0.18 + 0.7 * charged);
1628
+ ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.quadraticCurveTo(link.cx, link.cy, b.x, b.y);
1629
+ ctx.globalAlpha = Math.min(0.7, 0.3 * gain) * glowAt; ctx.lineWidth = (3.4 + 1.6 * charged) * (gain > 1 ? 1.25 : 1) / cam.scale; ctx.stroke();
1630
+ ctx.globalAlpha = 1; ctx.lineWidth = (0.7 + 0.4 * charged + 0.35 * Math.abs(Math.sin(t * 4 + a.seed))) * (gain > 1 ? 1.5 : 1) / cam.scale; ctx.stroke();
1631
+ }
1632
+
1633
+ // Plasma from the core to every memory. The filament a memory is spoken to often burns a
1634
+ // little brighter than one nobody has needed.
1635
+ for (const node of nostromo.nodes) {
1636
+ if (node.forgetting) continue; // its wire is dust now
1637
+ const d = Math.hypot(node.x, node.y) || 1;
1638
+ const nx = -node.y / d, ny = node.x / d;
1639
+ const bow = Math.sin(t * 1.7 + node.seed) * Math.min(60, d * 0.18);
1640
+ node.cx = node.x / 2 + nx * bow; node.cy = node.y / 2 + ny * bow;
1641
+ node.rimX = (node.x / d) * R * 0.92; node.rimY = (node.y / d) * R * 0.92;
1642
+ const life = 0.45 + 0.55 * node.activity;
1643
+ const fed = node.charge ?? 0;
1644
+ // The wire leaves MOTHER her colour and arrives wearing the star's, changing hands along
1645
+ // the way. Current runs down it toward the memory, faster and brighter the better fed it is.
1646
+ const head = ((t * (0.16 + 0.22 * fed) + node.seed * 0.37) % 1 + 1) % 1;
1647
+ const wire = currentAlong(ctx, node.rimX, node.rimY, node.x, node.y,
1648
+ alarm ? 'rgba(255, 230, 220, .95)' : `rgba(226, 40, 22, ${0.62 + 0.3 * life})`,
1649
+ { mid: hexAlpha(hexMix('#e22816', node.color, 0.55), (0.3 + 0.3 * life) * (0.6 + 0.5 * fed)), end: hexAlpha(node.color, (0.45 + 0.45 * life) * node.scale) },
1650
+ head, 0.25 + 0.75 * fed);
1651
+ ctx.strokeStyle = wire;
1652
+ ctx.beginPath();
1653
+ ctx.moveTo(node.rimX, node.rimY);
1654
+ ctx.quadraticCurveTo(node.cx, node.cy, node.x, node.y);
1655
+ ctx.globalAlpha = 0.26 * (0.5 + life); ctx.lineWidth = (4 + 2 * fed) / cam.scale; ctx.stroke();
1656
+ ctx.globalAlpha = 1; ctx.lineWidth = (0.8 + 0.5 * life + 0.5 * fed + 0.3 * Math.abs(Math.sin(t * 5 + node.seed * 3)) + 0.5 * beat) / cam.scale; ctx.stroke();
1657
+ }
1658
+
1659
+ // The pulses themselves: each one its own errand, none in step with another. Out from the core
1660
+ // to the memories it leans on, back again, and between two memories that share a theme.
1661
+ for (const pulse of nostromo.pulses ?? []) {
1662
+ const u = Math.min(1, (t - pulse.born) / pulse.life);
1663
+ let at;
1664
+ let color;
1665
+ if (pulse.kind === 'link') {
1666
+ const { from, to, link } = pulse;
1667
+ at = alongCurve(from.x, from.y, link.cx ?? (from.x + to.x) / 2, link.cy ?? (from.y + to.y) / 2, to.x, to.y, u);
1668
+ color = to.color;
1669
+ } else {
1670
+ const node = pulse.node;
1671
+ const outward = pulse.to === node;
1672
+ const rim = { x: node.rimX ?? 0, y: node.rimY ?? 0 };
1673
+ const from = outward ? rim : node;
1674
+ const to = outward ? node : rim;
1675
+ at = alongCurve(from.x, from.y, node.cx ?? node.x / 2, node.cy ?? node.y / 2, to.x, to.y, u);
1676
+ color = outward ? '#ffd0c0' : node.color;
1677
+ }
1678
+ const size = PULSE_SCALE * (1.9 + 1.3 * Math.sin(u * Math.PI)) * farSize / cam.scale;
1679
+ const halo = ctx.createRadialGradient(at.x, at.y, 0, at.x, at.y, size * 4.5);
1680
+ halo.addColorStop(0, hexAlpha(color, 0.85 * farLight));
1681
+ halo.addColorStop(1, hexAlpha(color, 0));
1682
+ ctx.fillStyle = halo;
1683
+ ctx.beginPath(); ctx.arc(at.x, at.y, size * 4.5, 0, Math.PI * 2); ctx.fill();
1684
+ ctx.fillStyle = 'rgba(255, 248, 243, .95)';
1685
+ ctx.beginPath(); ctx.arc(at.x, at.y, size, 0, Math.PI * 2); ctx.fill();
1686
+ }
1687
+
1688
+ // Shockwaves: one ring per beat, expanding and thinning.
1689
+ for (const ring of nostromo.rings) {
1690
+ const age = t - ring.born;
1691
+ if (age < 0) continue; // a clock driven by hand can step back: a ring not yet born is not drawn
1692
+ const radius = R + age * WAVE_SPEED * farSize;
1693
+ ctx.strokeStyle = `rgba(255, 60, 40, ${Math.max(0, 0.32 - age * 0.14) * farLight})`;
1694
+ ctx.lineWidth = Math.max(0.4, 2.4 - age * 1.1) * farSize / cam.scale;
1695
+ ctx.beginPath(); ctx.arc(0, 0, radius, 0, Math.PI * 2); ctx.stroke();
1696
+ }
1697
+
1698
+ // One memory, drawn where the projection put it. Called twice over the list: the ones behind
1699
+ // MOTHER before her, so she hides them, and the ones in front after her.
1700
+ const drawMemory = (node) => {
1701
+ const micro = 1 + 0.05 * Math.sin(t * node.rate + node.seed);
1702
+ // Never smaller than a point and a half on screen, while it is there at all.
1703
+ const r = node.scale > 0.01 ? Math.max(node.r * node.scale * micro, 1.5 / cam.scale) : 0;
1704
+ if (r <= 0) return;
1705
+ const arrive = Math.min(1.2, node.lit ?? 0);
1706
+ // Two things decide how brightly a dwarf burns. Maturity is what the room has made of it
1707
+ // over time: how often it has been reached for, how lately, how woven into the rest. Feeding
1708
+ // is what it is being given right now, by MOTHER and by its neighbours at once. A young
1709
+ // memory nobody feeds is nearly out; an old one the room leans on runs white.
1710
+ const grown = node.activity ?? 0;
1711
+ const fed = Math.min(1, (node.charge ?? 0) + 0.25 * arrive);
1712
+ const burn = Math.min(1, 0.4 * grown + 0.75 * fed);
1713
+
1714
+ // A collapsed body. This one is not a star and it is not a furnace either: it is a claim the
1715
+ // room established is false, and what it looks like is absence. A round black nothing, the
1716
+ // dust and stars of the field standing around it, and the faint cold light of what passes
1717
+ // close enough to be bent round the rim instead of going straight.
1718
+ //
1719
+ // There is no disc and nothing burns. The whole of it is the dark, and the dark is the point.
1720
+ if (node.memory.kind === COLLAPSED) {
1721
+ const hole = r * 1.35;
1722
+ const field = hole * 2.4;
1723
+ const turn = node.seed + node.spin * 0.12;
1724
+ const lit = 0.45 + 0.55 * burn;
1725
+
1726
+ // Seen from far, the field of dust and the arcs are smaller than a pixel and only smear
1727
+ // into a grey stain round a black blot. A small void reads by what it is: a dark point with
1728
+ // one thin cold rim of bent light, its colour the kind's.
1729
+ if (hole * cam.scale < 9) {
1730
+ ctx.fillStyle = 'rgba(0, 0, 3, .92)';
1731
+ ctx.beginPath(); ctx.arc(node.x, node.y, hole, 0, Math.PI * 2); ctx.fill();
1732
+ ctx.strokeStyle = hexAlpha(hexMix(node.color, '#a8ccff', 0.55), (0.45 + 0.35 * lit) * glowAt);
1733
+ ctx.lineWidth = 1 / cam.scale;
1734
+ ctx.beginPath(); ctx.arc(node.x, node.y, hole + 0.6 / cam.scale, 0, Math.PI * 2); ctx.stroke();
1735
+ if (node === nostromo.hover && !nostromo.selected) drawSight(ctx, node.x, node.y, hole * 1.6, t, cam.scale);
1736
+ return;
1737
+ }
1738
+
1739
+ // The field it sits in: specks of dust and far stars, each on its own slow flicker.
1740
+ ctx.globalCompositeOperation = 'lighter';
1741
+ for (let i = 0; i < VOID_DUST.length; i += 1) {
1742
+ const speck = VOID_DUST[i];
1743
+ const at = hole * 1.06 + (field - hole * 1.06) * speck.out;
1744
+ const angle = speck.angle + turn;
1745
+ const x = node.x + Math.cos(angle) * at;
1746
+ const y = node.y + Math.sin(angle) * at;
1747
+ // Fading outward, so the field thins into the dark rather than stopping.
1748
+ const fade = (1 - speck.out * 0.72) * lit * (0.45 + 0.55 * Math.abs(Math.sin(t * speck.rate + speck.phase)));
1749
+ const size = speck.size * (1 + 0.25 * burn) / cam.scale;
1750
+ const glow = ctx.createRadialGradient(x, y, 0, x, y, size * 3.2);
1751
+ glow.addColorStop(0, `rgba(${speck.colour}, ${fade})`);
1752
+ glow.addColorStop(0.35, `rgba(${speck.colour}, ${fade * 0.3})`);
1753
+ glow.addColorStop(1, `rgba(${speck.colour}, 0)`);
1754
+ ctx.fillStyle = glow;
1755
+ ctx.beginPath(); ctx.arc(x, y, size * 3.2, 0, Math.PI * 2); ctx.fill();
1756
+ }
1757
+ ctx.globalCompositeOperation = 'source-over';
1758
+
1759
+ // The void. Black all the way through, with just enough softness at the rim that it reads
1760
+ // as depth rather than as a sticker.
1761
+ const pit = ctx.createRadialGradient(node.x, node.y, 0, node.x, node.y, hole * 1.1);
1762
+ pit.addColorStop(0, 'rgba(0, 0, 0, 1)');
1763
+ pit.addColorStop(0.86, 'rgba(0, 0, 0, 1)');
1764
+ pit.addColorStop(0.95, 'rgba(0, 0, 2, .82)');
1765
+ pit.addColorStop(1, 'rgba(0, 0, 4, 0)');
1766
+ ctx.fillStyle = pit;
1767
+ ctx.beginPath(); ctx.arc(node.x, node.y, hole * 1.1, 0, Math.PI * 2); ctx.fill();
1768
+
1769
+ // What passes close enough is bent round instead of going straight: a cold ring of
1770
+ // smeared light gripping the rim, in a few arcs that do not quite close.
1771
+ ctx.globalCompositeOperation = 'lighter';
1772
+ const halo = ctx.createRadialGradient(node.x, node.y, hole * 0.94, node.x, node.y, hole * 1.3);
1773
+ halo.addColorStop(0, 'rgba(150, 190, 255, 0)');
1774
+ halo.addColorStop(0.3, `rgba(168, 204, 255, ${0.26 * lit})`);
1775
+ halo.addColorStop(0.55, `rgba(120, 160, 230, ${0.12 * lit})`);
1776
+ halo.addColorStop(1, 'rgba(90, 130, 210, 0)');
1777
+ ctx.fillStyle = halo;
1778
+ ctx.beginPath(); ctx.arc(node.x, node.y, hole * 1.3, 0, Math.PI * 2); ctx.fill();
1779
+ ctx.lineCap = 'round';
1780
+ for (let arc = 0; arc < 4; arc += 1) {
1781
+ const at = hole * (1.005 + arc * 0.028);
1782
+ const from = turn * (1 + arc * 0.35) + arc * 1.9;
1783
+ const span = 0.9 + 0.5 * Math.sin(t * 0.21 + arc);
1784
+ ctx.strokeStyle = `rgba(${arc % 2 ? '198, 222, 255' : '146, 186, 250'}, ${(0.3 - 0.05 * arc) * lit})`;
1785
+ ctx.lineWidth = (1.4 - 0.25 * arc) / cam.scale;
1786
+ ctx.beginPath(); ctx.arc(node.x, node.y, at, from, from + span); ctx.stroke();
1787
+ }
1788
+ ctx.globalCompositeOperation = 'source-over';
1789
+
1790
+ if (node === nostromo.hover && !nostromo.selected) drawSight(ctx, node.x, node.y, field, t, cam.scale);
1791
+ return;
1792
+ }
1793
+
1794
+ // Drawn pixel by pixel wherever there is a buffer to paint into.
1795
+ const sprite = dwarfSprite(node, node.r * node.scale, burn, cam.scale * dpr, t);
1796
+ if (sprite) ctx.drawImage(sprite, node.x - r, node.y - r, r * 2, r * 2);
1797
+ else {
1798
+ ctx.save();
1799
+ ctx.beginPath(); ctx.arc(node.x, node.y, r, 0, Math.PI * 2); ctx.closePath();
1800
+ // The ground, struck from the body's own middle: a star is not lit from a corner. It runs
1801
+ // white at the centre the harder it burns and keeps its class's colour outward.
1802
+ const ground = ctx.createRadialGradient(node.x, node.y, r * 0.03, node.x, node.y, r);
1803
+ ground.addColorStop(0, hexMix(node.color, '#ffffff', 0.5 + 0.5 * burn));
1804
+ ground.addColorStop(0.45, hexMix(node.color, '#ffffff', 0.12 + 0.4 * burn));
1805
+ ground.addColorStop(0.85, hexMix(node.color, '#000000', 0.2 - 0.2 * burn));
1806
+ ground.addColorStop(1, hexMix(node.color, '#000000', 0.34 - 0.24 * burn));
1807
+ ctx.fillStyle = ground;
1808
+ ctx.fill();
1809
+ ctx.clip();
1810
+ // The face: the boiling surface a star of this class has, turning slowly, laid over the
1811
+ // whole body. This is most of what is seen, so it is laid down at close to full strength.
1812
+ const face = dwarfTexture(node.color);
1813
+ if (face) {
1814
+ // Wrapped on a sphere, half the strip across the visible face, in columns that narrow
1815
+ // toward the limb: the cells slide in from one edge, cross wide and leave thin, which is
1816
+ // what reads as a body turning rather than a picture sliding behind a hole.
1817
+ const strip = face.width / 2;
1818
+ const turn = ((node.spin * 0.3 + node.seed) / (Math.PI * 2) % 1 + 1) % 1;
1819
+ ctx.globalAlpha = 0.72 + 0.26 * burn;
1820
+ for (let column = 0; column < SKIN_COLUMNS; column += 1) {
1821
+ const x0 = SKIN_EDGES[column];
1822
+ const x1 = SKIN_EDGES[column + 1];
1823
+ ctx.drawImage(face, (turn + column / (SKIN_COLUMNS * 2)) * strip, 0, strip / (SKIN_COLUMNS * 2), face.height,
1824
+ node.x + x0 * r * 1.06, node.y - r * 1.06, Math.max(0.5, (x1 - x0) * r * 1.06), r * 2.12);
1825
+ }
1826
+ ctx.globalAlpha = 1;
1827
+ }
1828
+ // The hottest of it burns through the face, the way an active region does.
1829
+ ctx.globalCompositeOperation = 'lighter';
1830
+ const core = ctx.createRadialGradient(node.x, node.y, 0, node.x, node.y, r * 0.92);
1831
+ core.addColorStop(0, hexAlpha(hexMix(node.color, '#ffffff', 0.9), 0.2 + 0.45 * burn));
1832
+ core.addColorStop(0.5, hexAlpha(hexMix(node.color, '#ffffff', 0.5), 0.08 + 0.2 * burn));
1833
+ core.addColorStop(1, hexAlpha(node.color, 0));
1834
+ ctx.fillStyle = core;
1835
+ ctx.fillRect(node.x - r, node.y - r, r * 2, r * 2);
1836
+ ctx.globalCompositeOperation = 'source-over';
1837
+ ctx.restore();
1838
+ }
1839
+
1840
+ // The glow: the light a star gives off, clinging to it in a tight bloom the width of a finger,
1841
+ // the same all the way round because nothing else is lighting it. It carries past the body
1842
+ // and stops there, so it belongs to the star rather than hanging off it.
1843
+ ctx.globalCompositeOperation = 'lighter';
1844
+ const air = ctx.createRadialGradient(node.x, node.y, r * 0.99, node.x, node.y, r * 1.42);
1845
+ // It starts at the limb, never over it, and no brighter than the limb itself: scattered
1846
+ // light falling away from the body, with no seam and no ring. A cooling dwarf gives less of
1847
+ // it, in the same proportion its face has dimmed.
1848
+ const cooling = node.cold ? COLD_GLOW : 1;
1849
+ air.addColorStop(0, hexAlpha(hexMix(node.color, '#ffffff', 0.2 + 0.3 * burn), ((0.16 + 0.34 * burn) * cooling + 0.2 * arrive) * glowAt));
1850
+ air.addColorStop(0.25, hexAlpha(hexMix(node.color, '#ffffff', 0.1 + 0.2 * burn), ((0.07 + 0.16 * burn) * cooling + 0.1 * arrive) * glowAt));
1851
+ air.addColorStop(1, hexAlpha(node.color, 0));
1852
+ ctx.fillStyle = air;
1853
+ ctx.beginPath(); ctx.arc(node.x, node.y, r * 1.34, 0, Math.PI * 2); ctx.fill();
1854
+ ctx.globalCompositeOperation = 'source-over';
1855
+
1856
+ if (node === nostromo.hover && !nostromo.selected) drawSight(ctx, node.x, node.y, r * 1.1, t, cam.scale);
1857
+ };
1858
+ const ordered = [...nostromo.nodes].sort((a, b) => (a.depth ?? 0) - (b.depth ?? 0));
1859
+ for (const node of ordered) if ((node.depth ?? 0) < 0) drawMemory(node);
1860
+
1861
+ // ---- MOTHER's core: a body, not a disc.
1862
+ //
1863
+ // Everything on the surface lives at a latitude and a longitude and is projected before it
1864
+ // is drawn, so it narrows toward the limb and goes round the back as the planet turns.
1865
+ // Three cues do the work: features that travel on a real sphere, an edge that darkens into
1866
+ // shadow, and one fixed light that decides which side is day. The planet turns; the sun
1867
+ // stays where it is.
1868
+ // The human's hand turns the star with the body around it: the turn adds to its longitude and
1869
+ // the tip to its lean, in the same order the memories are projected in.
1870
+ const yaw = nostromo.yaw ?? 0;
1871
+ const sinT = Math.sin(CORE_TILT + (nostromo.pitch ?? 0));
1872
+ const cosT = Math.cos(CORE_TILT + (nostromo.pitch ?? 0));
1873
+ const spin = nostromo.spin ?? 0;
1874
+ // A point on the surface: where it lands on screen, how much it faces us, what light it takes.
1875
+ const surface = (lat, lon) => {
1876
+ const cl = Math.cos(lat);
1877
+ const px = cl * Math.sin(lon);
1878
+ const py = Math.sin(lat);
1879
+ const pz = cl * Math.cos(lon);
1880
+ const y = py * cosT - pz * sinT;
1881
+ const z = py * sinT + pz * cosT;
1882
+ return { x: px, y, z, light: Math.max(0, px * CORE_LIGHT.x + y * CORE_LIGHT.y + z * CORE_LIGHT.z) };
1883
+ };
1884
+
1885
+ // ---- What the star sits in.
1886
+ //
1887
+ // No wide halo. A body this size does not need a cloud around it to be felt, and a soft
1888
+ // smudge only makes it look smaller. What surrounds it is darkness: one tight, fierce skin of
1889
+ // light gripping the limb and falling away almost at once, and beyond that a deep red stain
1890
+ // so faint it reads as the dark being lit rather than as anything drawn.
1891
+ const stain = ctx.createRadialGradient(0, 0, R, 0, 0, R * 4.6);
1892
+ stain.addColorStop(0, `rgba(122, 12, 6, ${0.3 + 0.08 * beat})`);
1893
+ stain.addColorStop(0.24, `rgba(88, 6, 6, ${0.14 + 0.04 * beat})`);
1894
+ stain.addColorStop(0.62, 'rgba(46, 2, 6, .05)');
1895
+ stain.addColorStop(1, 'rgba(20, 0, 4, 0)');
1896
+ ctx.fillStyle = stain;
1897
+ ctx.beginPath(); ctx.arc(0, 0, R * 4.6, 0, Math.PI * 2); ctx.fill();
1898
+
1899
+ // ---- The body: a star, dark and molten.
1900
+ //
1901
+ // A star makes its own light, so it has no day side, no night side and no highlight struck
1902
+ // off it by something else. This one is not a bright disc either: it burns deep, almost
1903
+ // black at the limb, and what moves on it is liquid rock. The skin is a strip of boiling
1904
+ // cells built once and wrapped round the body; a second pass of the same strip, sliding at a
1905
+ // different rate, makes the two disagree, and that disagreement is what reads as flow. Over
1906
+ // both, masses of molten matter drift across the face at a walking pace.
1907
+ ctx.save();
1908
+ ctx.beginPath(); ctx.arc(0, 0, R, 0, Math.PI * 2); ctx.closePath();
1909
+ ctx.shadowColor = `rgba(220, 44, 12, ${0.55 + 0.2 * beat})`;
1910
+ ctx.shadowBlur = 40 + 26 * beat;
1911
+ const body = ctx.createRadialGradient(0, 0, R * 0.05, 0, 0, R);
1912
+ body.addColorStop(0, `hsl(${14 + 5 * beat} 100% ${28 + 8 * beat}%)`);
1913
+ body.addColorStop(0.58, 'hsl(9 100% 20%)');
1914
+ body.addColorStop(1, 'hsl(5 100% 10%)');
1915
+ ctx.fillStyle = body;
1916
+ ctx.fill();
1917
+ ctx.shadowBlur = 0;
1918
+ ctx.clip();
1919
+
1920
+ // Molten rock, painted pixel by pixel wherever there is a buffer to paint into: plates of
1921
+ // crust, cracks running white-hot, lava flowing under them, bursts going off inside.
1922
+ const magma = magmaFace(R, cam.scale * dpr, t, { turn: spin - yaw, beat, alarm, reduced: nostromo.reduced, tilt: nostromo.pitch ?? 0 });
1923
+ if (magma) ctx.drawImage(magma, -R, -R, R * 2, R * 2);
1924
+ else {
1925
+ // The skin, wrapped on. Each row of the disc takes the slice of the strip belonging to its
1926
+ // latitude, cut into pieces so longitude runs as a sphere's does and the cells narrow toward
1927
+ // the limb instead of smearing along it. Turning the star is moving the window along.
1928
+ // Someone who asked for stillness gets a star that turns and breathes but does not churn.
1929
+ const flowTime = nostromo.reduced ? 0 : t;
1930
+ const grain = granuleTexture();
1931
+ if (grain) {
1932
+ const width = grain.width / 2;
1933
+ const wrapAt = (turn, alpha, lift) => {
1934
+ ctx.globalAlpha = alpha;
1935
+ for (let row = 0; row < GRAIN_ROWS; row += 1) {
1936
+ const y0 = -R + (row / GRAIN_ROWS) * 2 * R;
1937
+ const y1 = -R + ((row + 1) / GRAIN_ROWS) * 2 * R;
1938
+ const mid = (y0 + y1) * 0.5;
1939
+ const half = Math.sqrt(Math.max(0, R * R - mid * mid));
1940
+ if (half < 0.5) continue;
1941
+ const v0 = (((y0 + R) / (2 * R) + lift) % 1 + 1) % 1 * grain.height;
1942
+ const dv = Math.max(1, ((y1 - y0) / (2 * R)) * grain.height);
1943
+ for (let piece = 0; piece < GRAIN_PIECES; piece += 1) {
1944
+ const xA = Math.sin((piece / GRAIN_PIECES - 0.5) * Math.PI) * half;
1945
+ const xB = Math.sin(((piece + 1) / GRAIN_PIECES - 0.5) * Math.PI) * half;
1946
+ const u = (turn + (piece / GRAIN_PIECES) * 0.5) * width;
1947
+ ctx.drawImage(grain, u, Math.min(v0, grain.height - dv), (width * 0.5) / GRAIN_PIECES, dv, xA, y0, Math.max(0.5, xB - xA), Math.max(1, y1 - y0));
1948
+ }
1949
+ }
1950
+ ctx.globalAlpha = 1;
1951
+ };
1952
+ // Advancing the window carries the skin from right to left, so a turn of the hand to the
1953
+ // right carries it back the other way, with the memories.
1954
+ const turn = (((spin - yaw) / (Math.PI * 2)) % 1 + 1) % 1;
1955
+ wrapAt(turn, 0.95, 0);
1956
+ // The same skin again, crawling at its own pace: where the two pull apart the surface
1957
+ // churns, and that is the slowness of lava rather than the flicker of fire.
1958
+ ctx.globalCompositeOperation = 'lighter';
1959
+ wrapAt(((turn * 0.83 + flowTime * 0.0042) % 1 + 1) % 1, 0.32, 0.37);
1960
+ ctx.globalCompositeOperation = 'source-over';
1961
+ }
1962
+
1963
+ // Masses of molten matter riding the surface. They are slow, they are large, and each keeps
1964
+ // its own drift, so the face is never the same face twice.
1965
+ for (let i = 0; i < CORE_FLOWS.length; i += 1) {
1966
+ const flow = CORE_FLOWS[i];
1967
+ // The masses ride the same way the skin turns. They used to travel against it, and the two
1968
+ // motions cancelled into a surface that churned in place instead of turning.
1969
+ const p = surface(flow.lat + 0.1 * Math.sin(flowTime * flow.rate * 0.6 + i), flow.lon - spin * 0.96 + yaw + flowTime * flow.drift);
1970
+ if (p.z <= 0.02) continue;
1971
+ const swell = 0.62 + 0.38 * Math.sin(flowTime * flow.rate + i * 1.7);
1972
+ const fade = Math.min(1, p.z * 2.3) * swell;
1973
+ ctx.save();
1974
+ ctx.translate(p.x * R, p.y * R);
1975
+ ctx.rotate(Math.atan2(p.y, p.x));
1976
+ ctx.scale(Math.max(0.05, p.z), 1);
1977
+ const molten = ctx.createRadialGradient(0, 0, 0, 0, 0, R * flow.size);
1978
+ molten.addColorStop(0, `rgba(255, ${flow.hot ? 214 : 96}, ${flow.hot ? 132 : 30}, ${(flow.hot ? 0.46 : 0.2) * fade})`);
1979
+ molten.addColorStop(0.42, `rgba(${flow.hot ? '255, 122, 36' : '176, 34, 10'}, ${(flow.hot ? 0.24 : 0.14) * fade})`);
1980
+ molten.addColorStop(1, 'rgba(120, 16, 8, 0)');
1981
+ ctx.fillStyle = molten;
1982
+ ctx.beginPath(); ctx.arc(0, 0, R * flow.size, 0, Math.PI * 2); ctx.fill();
1983
+ ctx.restore();
1984
+ }
1985
+
1986
+ // Limb darkening, hard. Looking at the edge of a star means looking through far more of its
1987
+ // own gas, and on a body this dark the edge goes almost to black. This is the whole of the
1988
+ // illusion: it is what gives the thing its weight.
1989
+ const limb = ctx.createRadialGradient(0, 0, R * 0.34, 0, 0, R);
1990
+ limb.addColorStop(0, 'rgba(0, 0, 0, 0)');
1991
+ limb.addColorStop(0.58, `rgba(96, 8, 4, ${0.3 - 0.05 * beat})`);
1992
+ limb.addColorStop(0.85, `rgba(50, 2, 4, ${0.62 - 0.08 * beat})`);
1993
+ limb.addColorStop(1, `rgba(14, 0, 2, ${0.9 - 0.1 * beat})`);
1994
+ ctx.fillStyle = limb;
1995
+ ctx.fillRect(-R, -R, R * 2, R * 2);
1996
+ }
1997
+ ctx.restore();
1998
+
1999
+ // Prominences: arches of matter torn off the limb, standing up and falling back. Few, large
2000
+ // and slow. A star this size does not flicker; it heaves.
2001
+ ctx.lineCap = 'round';
2002
+ for (let i = 0; i < 4; i += 1) {
2003
+ const base = i * 1.5708 + t * 0.05;
2004
+ const spread = 0.28 + 0.1 * Math.sin(t * 0.37 + i);
2005
+ const lift = R * (0.2 + 0.26 * Math.abs(Math.sin(t * 0.21 + i * 1.7)) + 0.3 * alarm);
2006
+ const x0 = Math.cos(base - spread) * R * 0.99, y0 = Math.sin(base - spread) * R * 0.99;
2007
+ const x1 = Math.cos(base + spread) * R * 0.99, y1 = Math.sin(base + spread) * R * 0.99;
2008
+ const cxp = Math.cos(base) * (R + lift * 2.1), cyp = Math.sin(base) * (R + lift * 2.1);
2009
+ // A wide dull body of matter with a thin hot thread running through it.
2010
+ ctx.strokeStyle = `rgba(190, 34, 14, ${0.2 + 0.14 * Math.abs(Math.sin(t * 0.6 + i))})`;
2011
+ ctx.lineWidth = (5.5 + 3 * beat) / cam.scale;
2012
+ ctx.beginPath(); ctx.moveTo(x0, y0); ctx.quadraticCurveTo(cxp, cyp, x1, y1); ctx.stroke();
2013
+ ctx.strokeStyle = `rgba(255, ${128 + 70 * beat}, ${64 + 60 * beat}, ${0.3 + 0.28 * Math.abs(Math.sin(t * 0.6 + i))})`;
2014
+ ctx.lineWidth = (1.5 + 0.9 * beat) / cam.scale;
2015
+ ctx.beginPath(); ctx.moveTo(x0, y0); ctx.quadraticCurveTo(cxp, cyp, x1, y1); ctx.stroke();
2016
+ }
2017
+
2018
+ // The limb. One fierce skin of light gripping the edge, gone within a fraction of a radius:
2019
+ // it is what separates the body from the dark, and it holds the whole shape together. It is
2020
+ // also what answers when a pulse comes home.
2021
+ const home = Math.min(1, nostromo.coreLit ?? 0);
2022
+ const fierce = 0.55 + 0.35 * beat + 0.4 * home;
2023
+ ctx.globalCompositeOperation = 'lighter';
2024
+ const edge = ctx.createRadialGradient(0, 0, R * 0.9, 0, 0, R * 1.22);
2025
+ edge.addColorStop(0, 'rgba(255, 96, 34, 0)');
2026
+ edge.addColorStop(0.42, `rgba(255, ${132 + 60 * beat}, 62, ${0.5 * fierce})`);
2027
+ edge.addColorStop(0.52, `rgba(255, ${176 + 60 * beat}, ${110 + 60 * beat}, ${0.62 * fierce})`);
2028
+ edge.addColorStop(1, 'rgba(210, 40, 16, 0)');
2029
+ ctx.fillStyle = edge;
2030
+ ctx.beginPath(); ctx.arc(0, 0, R * 1.22, 0, Math.PI * 2); ctx.fill();
2031
+ ctx.globalCompositeOperation = 'source-over';
2032
+ ctx.strokeStyle = `rgba(255, ${186 + 60 * beat}, ${128 + 80 * beat}, ${Math.min(0.92, 0.7 * fierce)})`;
2033
+ ctx.lineWidth = (1.2 + 1.2 * beat + 1.1 * home) / cam.scale;
2034
+ ctx.beginPath(); ctx.arc(0, 0, R, 0, Math.PI * 2); ctx.stroke();
2035
+
2036
+ // ---- Memories: dwarf stars.
2037
+ //
2038
+ // A dwarf makes its own light, so none of it is in shadow. Its face boils with granules, it
2039
+ // dims toward the limb the way every star does, and what light escapes clings to it in a tight
2040
+ // bloom the width of a finger. On top of that each breathes at a rate of its own: a
2041
+ // micro-pulsation, small enough that it is never a flash and never in time with its neighbours.
2042
+ for (const node of ordered) if ((node.depth ?? 0) >= 0) drawMemory(node);
2043
+ // The cold zone, when it is asked for: a thin dusty ring around every memory the archive has
2044
+ // had its chances with and never once carried. The mark is added rather than taken from the
2045
+ // rest — these are already the dimmest things out here, and dimming everything else to find
2046
+ // them would be turning the map off to look at it.
2047
+ if (nostromo.coldOnly) {
2048
+ ctx.save();
2049
+ ctx.setLineDash([3 / cam.scale, 4 / cam.scale]);
2050
+ ctx.lineWidth = 1 / cam.scale;
2051
+ for (const node of nostromo.nodes) {
2052
+ if (!node.cold || node.scale <= 0.01) continue;
2053
+ const breath = Math.sin(t * 0.8 + node.seed);
2054
+ ctx.strokeStyle = `rgba(150, 176, 198, ${0.36 + 0.14 * breath})`;
2055
+ ctx.beginPath(); ctx.arc(node.x, node.y, node.r * node.scale * 2.2 + 4 + breath, 0, Math.PI * 2); ctx.stroke();
2056
+ }
2057
+ ctx.restore();
2058
+ }
2059
+
2060
+ // The veil: everything falls back, and the held dwarf, its own wires and the sight are drawn
2061
+ // again above it.
2062
+ const lock = nostromo.lock;
2063
+ if (lock?.node && lock.fade > 0.01) {
2064
+ const held = lock.node;
2065
+ ctx.save();
2066
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
2067
+ // In a bare scene (the Macroverso's live universe) the veil darkens only what is drawn here;
2068
+ // the space around is the holder's to darken, all of it, so no square of dark shows.
2069
+ if (nostromo.bare) ctx.globalCompositeOperation = 'source-atop';
2070
+ ctx.fillStyle = `rgba(2, 1, 4, ${FOCUS_VEIL * lock.fade})`;
2071
+ ctx.fillRect(0, 0, w, h);
2072
+ ctx.restore();
2073
+ const id = held.memory.id;
2074
+ ctx.lineCap = 'round';
2075
+ for (const link of nostromo.links) {
2076
+ if (link.a !== id && link.b !== id) continue;
2077
+ const a = byId.get(link.a);
2078
+ const b = byId.get(link.b);
2079
+ if (!a || !b) continue;
2080
+ const other = a === held ? b : a;
2081
+ const line = ctx.createLinearGradient(held.x, held.y, other.x, other.y);
2082
+ line.addColorStop(0, hexAlpha(held.color, 0.85 * lock.fade));
2083
+ line.addColorStop(1, hexAlpha(other.color, 0.55 * lock.fade));
2084
+ ctx.strokeStyle = line;
2085
+ ctx.lineWidth = 1.2 / cam.scale;
2086
+ ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.quadraticCurveTo(link.cx ?? (a.x + b.x) / 2, link.cy ?? (a.y + b.y) / 2, b.x, b.y); ctx.stroke();
2087
+ }
2088
+ ctx.globalAlpha = lock.fade;
2089
+ drawMemory(held);
2090
+ ctx.globalAlpha = 1;
2091
+ if (held === nostromo.selected) {
2092
+ const reach = held.memory.kind === COLLAPSED ? held.r * held.scale * 1.35 * 2.4 : held.r * held.scale * 1.1;
2093
+ drawSight(ctx, held.x, held.y, reach, t, cam.scale, { locked: true, age: t - lock.at, label: nostromo.words?.locked?.(held.memory) ?? '', still: nostromo.reduced });
2094
+ }
2095
+ }
2096
+
2097
+ // Forgetting, above everything else: the implosions, and the dust their links became.
2098
+ for (const node of nostromo.nodes) if (node.forgetting) drawImplosion(ctx, node, t, cam.scale, nostromo.reduced);
2099
+ if (nostromo.dust?.length || nostromo.fading?.length) drawDust(ctx, t, cam.scale);
2100
+
2101
+ // CODE000: the safety box. Bars fall from above and lock around the core.
2102
+ if (nostromo.cage) {
2103
+ const age = t - nostromo.cage.at;
2104
+ const p = nostromo.cage.closed ? 1 : Math.min(1, age / 1.1);
2105
+ const ease = 1 - Math.pow(1 - p, 3);
2106
+ const B = CORE_R * 2.05;
2107
+ const drop = (1 - ease) * 900;
2108
+ ctx.fillStyle = `rgba(0, 0, 0, ${0.35 * ease})`;
2109
+ ctx.fillRect(-B - 8, -B - 8 - drop, B * 2 + 16, B * 2 + 16);
2110
+ const steel = ctx.createLinearGradient(-B, 0, B, 0);
2111
+ steel.addColorStop(0, '#2a2c30'); steel.addColorStop(0.45, '#9aa0a8'); steel.addColorStop(0.55, '#d8dde3'); steel.addColorStop(1, '#3a3d42');
2112
+ ctx.strokeStyle = steel;
2113
+ ctx.lineCap = 'butt';
2114
+ ctx.shadowColor = 'rgba(0, 0, 0, .9)'; ctx.shadowBlur = 14;
2115
+ ctx.lineWidth = 7;
2116
+ for (let i = 0; i <= 6; i += 1) {
2117
+ const x = -B + (i * 2 * B) / 6;
2118
+ ctx.beginPath(); ctx.moveTo(x, -B - drop); ctx.lineTo(x, B - drop); ctx.stroke();
2119
+ }
2120
+ ctx.lineWidth = 9;
2121
+ for (const y of [-B, 0, B]) { ctx.beginPath(); ctx.moveTo(-B - 4, y - drop); ctx.lineTo(B + 4, y - drop); ctx.stroke(); }
2122
+ ctx.shadowBlur = 0;
2123
+ // Rivets at the joints.
2124
+ ctx.fillStyle = '#e8ecf0';
2125
+ for (let i = 0; i <= 6; i += 1) for (const y of [-B, 0, B]) { ctx.beginPath(); ctx.arc(-B + (i * 2 * B) / 6, y - drop, 2.6, 0, Math.PI * 2); ctx.fill(); }
2126
+ // The lock plate.
2127
+ if (ease > 0.98) {
2128
+ ctx.fillStyle = '#1b1d20'; ctx.fillRect(-B * 0.32, B - 14, B * 0.64, 28);
2129
+ ctx.strokeStyle = '#c4c9cf'; ctx.lineWidth = 2; ctx.strokeRect(-B * 0.32, B - 14, B * 0.64, 28);
2130
+ ctx.fillStyle = '#ff2a1f'; ctx.font = `700 ${Math.round(B * 0.1)}px ${getComputedStyle(nostromo.canvas).getPropertyValue('--mono') || 'monospace'}`; ctx.textAlign = 'center'; ctx.fillText('CODE000', 0, B + 6); ctx.textAlign = 'start';
2131
+ }
2132
+ if (!nostromo.cage.closed && p >= 1 && !nostromo.cage.clanged) { nostromo.cage.clanged = true; nostromo.onClang?.(); }
2133
+ }
2134
+ ctx.restore();
2135
+
2136
+ // Vignette that tightens on the beat; red wash while MOTHER is angry.
2137
+ if (!nostromo.bare) {
2138
+ const vig = ctx.createRadialGradient(w / 2, h / 2, Math.min(w, h) * (0.35 - 0.03 * beat), w / 2, h / 2, Math.max(w, h) * 0.75);
2139
+ vig.addColorStop(0, 'rgba(0, 0, 0, 0)');
2140
+ vig.addColorStop(1, `rgba(${alarm ? 60 : 0}, 0, 0, ${0.55 + 0.1 * beat})`);
2141
+ ctx.fillStyle = vig;
2142
+ ctx.fillRect(0, 0, w, h);
2143
+ if (alarm) { ctx.fillStyle = `rgba(255, 30, 20, ${0.08 * alarm * (0.6 + 0.4 * Math.sin(t * 18))})`; ctx.fillRect(0, 0, w, h); }
2144
+ }
2145
+
2146
+ // The hovered memory, named on screen; not while another one is held, which is the one being read.
2147
+ if (nostromo.hover && nostromo.hover.scale > 0.5 && !nostromo.selected) {
2148
+ const node = nostromo.hover;
2149
+ const p = toScreen(node.x, node.y);
2150
+ const label = `${nostromo.words?.kind?.(node.memory.kind) ?? node.memory.kind} · ${node.memory.text.length > 72 ? `${node.memory.text.slice(0, 71)}…` : node.memory.text}`;
2151
+ ctx.font = '11px ' + (getComputedStyle(nostromo.canvas).getPropertyValue('--mono') || 'monospace');
2152
+ const width = ctx.measureText(label).width + 16;
2153
+ const lx = Math.min(w - width - 8, Math.max(8, p.x - width / 2));
2154
+ const ly = p.y + node.r * cam.scale + 14;
2155
+ ctx.fillStyle = 'rgba(3, 2, 3, .85)';
2156
+ ctx.fillRect(lx, ly, width, 22);
2157
+ ctx.strokeStyle = hexAlpha(node.color, 0.6);
2158
+ ctx.strokeRect(lx + 0.5, ly + 0.5, width - 1, 21);
2159
+ ctx.fillStyle = '#eef1ea';
2160
+ ctx.fillText(label, lx + 8, ly + 15);
2161
+ }
2162
+ }
2163
+
2164
+ // Whether there is a camera to answer the pointer with. The canvas takes events from the moment
2165
+ // it is on the page, and the camera only exists once the map has started: a pointer that moved
2166
+ // over it while the archive was still loading reached for a camera that was not there yet.
2167
+ function nostromoLive() { return Boolean(nostromo.cam && nostromo.size); }
2168
+
2169
+ // Pointer: what is under it, in world space; the core counts too.
2170
+ function nostromoAt(event) {
2171
+ if (!nostromoLive()) return null;
2172
+ const rect = nostromo.canvas.getBoundingClientRect();
2173
+ const p = toWorld(event.clientX - rect.left, event.clientY - rect.top);
2174
+ const slack = 8 / nostromo.cam.scale;
2175
+ let best = null;
2176
+ const core = Math.hypot(p.x, p.y) <= CORE_R * 1.15;
2177
+ for (const node of nostromo.nodes) {
2178
+ if (node.forgetting) continue;
2179
+ const d = Math.hypot(node.x - p.x, node.y - p.y);
2180
+ if (d > node.r * node.scale + slack) continue;
2181
+ // What MOTHER hides cannot be clicked through her, and of two that overlap, the nearer wins.
2182
+ if (core && (node.depth ?? 0) < 0) continue;
2183
+ if (!best || (node.depth ?? 0) > (best.node.depth ?? 0)) best = { node, d };
2184
+ }
2185
+ if (best) return best.node;
2186
+ return Math.hypot(p.x, p.y) <= CORE_R * 1.15 ? 'core' : null;
2187
+ }
2188
+
2189
+ // The bodies, from what the archive sent: every memory a node, sized by how much ledger it
2190
+ // covers, coloured by its kind and lit only if it still travels; between those that share a
2191
+ // theme, the links. Planets start in their kind's sector and drift toward the memories they
2192
+ // agree with, so topics gather on their own. What is said about them is the page's to say.
2193
+ function buildNodes(data) {
2194
+ // How grown the archive is, for the weather behind it to read.
2195
+ nostromo.maturity = data.maturity?.score ?? 0;
2196
+ nostromo.stage = data.maturity?.stage ?? null;
2197
+ const kinds = Object.keys(MEMORY_COLORS);
2198
+ const memories = data.memories ?? [];
2199
+ nostromo.links = (data.links ?? []).filter((link) => memories.some((m) => m.id === link.a) && memories.some((m) => m.id === link.b));
2200
+ const degree = new Map();
2201
+ for (const link of nostromo.links) { degree.set(link.a, (degree.get(link.a) ?? 0) + 1); degree.set(link.b, (degree.get(link.b) ?? 0) + 1); }
2202
+ nostromo.pulses = [];
2203
+ const raw = memories.map((memory) => rawActivity(memory, degree.get(memory.id) ?? 0));
2204
+ const top = raw.reduce((best, value) => (value > best ? value : best), 0);
2205
+ nostromo.rawTop = top;
2206
+ nostromo.nodes = memories.map((memory, index) => {
2207
+ const sector = kinds.indexOf(memory.kind) < 0 ? 1 : kinds.indexOf(memory.kind);
2208
+ const angle = (sector / kinds.length) * Math.PI * 2 + ((index % 7) / 7 - 0.5) * (Math.PI / 2.4) + Math.random() * 0.2;
2209
+ const distance = 0.42 + Math.random() * 0.5;
2210
+ const span = Math.max(1, (memory.throughSequence ?? 0) - (memory.fromSequence ?? 0));
2211
+ // A far room sends how many sources a memory cites, not the list of them.
2212
+ const cited = memory.sources?.length ?? memory.sourcesCount ?? 0;
2213
+ return { memory, cold: memory.cold ?? null, angle, distance, activity: activityOf(raw[index], top), lit: 0, charge: 0, x: 0, y: 0, vx: 0, vy: 0, r: MEMORY_SCALE * (7 + Math.min(11, Math.log2(span + 1) * 2.2 + cited * 0.6)), scale: 1, seed: Math.random() * Math.PI * 2, rate: 0.5 + Math.random() * 0.9, color: travels(memory) ? (MEMORY_COLORS[memory.kind] ?? MEMORY_COLORS.fact) : hexMix(MEMORY_COLORS[memory.kind] ?? MEMORY_COLORS.fact, '#0b0f16', 0.52), placed: false };
2214
+ });
2215
+ return nostromo.nodes;
2216
+ }
2217
+
2218
+ return {
2219
+ buildNodes, sizeNostromo, placeNostromo, stepNostromo, focusNostromo, projectNostromo, fitNostromo, drawNostromo, drawNebula, heartbeat,
2220
+ toScreen, toWorld, nostromoLive, nostromoAt, beginImplosion, drawImplosion, drawDust, drawSight, dwarfSprite, magmaFace,
2221
+ };
2222
+ }