@guinetik/gcanvas 1.0.4 → 1.0.5

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 (193) hide show
  1. package/dist/CNAME +1 -0
  2. package/dist/animations.html +31 -0
  3. package/dist/basic.html +38 -0
  4. package/dist/baskara.html +31 -0
  5. package/dist/bezier.html +35 -0
  6. package/dist/beziersignature.html +29 -0
  7. package/dist/blackhole.html +28 -0
  8. package/dist/blob.html +35 -0
  9. package/dist/coordinates.html +698 -0
  10. package/dist/cube3d.html +23 -0
  11. package/dist/demos.css +303 -0
  12. package/dist/dino.html +42 -0
  13. package/dist/easing.html +28 -0
  14. package/dist/events.html +195 -0
  15. package/dist/fluent.html +647 -0
  16. package/dist/fluid-simple.html +22 -0
  17. package/dist/fluid.html +37 -0
  18. package/dist/fractals.html +36 -0
  19. package/dist/gameobjects.html +626 -0
  20. package/dist/gcanvas.es.js +517 -0
  21. package/dist/gcanvas.es.min.js +1 -1
  22. package/dist/gcanvas.umd.js +1 -1
  23. package/dist/gcanvas.umd.min.js +1 -1
  24. package/dist/genart.html +26 -0
  25. package/dist/gendream.html +26 -0
  26. package/dist/group.html +36 -0
  27. package/dist/home.html +587 -0
  28. package/dist/hyperbolic001.html +23 -0
  29. package/dist/hyperbolic002.html +23 -0
  30. package/dist/hyperbolic003.html +23 -0
  31. package/dist/hyperbolic004.html +23 -0
  32. package/dist/hyperbolic005.html +22 -0
  33. package/dist/index.html +398 -0
  34. package/dist/isometric.html +34 -0
  35. package/dist/js/animations.js +452 -0
  36. package/dist/js/basic.js +204 -0
  37. package/dist/js/baskara.js +751 -0
  38. package/dist/js/bezier.js +692 -0
  39. package/dist/js/beziersignature.js +241 -0
  40. package/dist/js/blackhole/accretiondisk.obj.js +379 -0
  41. package/dist/js/blackhole/blackhole.obj.js +318 -0
  42. package/dist/js/blackhole/index.js +409 -0
  43. package/dist/js/blackhole/particle.js +56 -0
  44. package/dist/js/blackhole/starfield.obj.js +218 -0
  45. package/dist/js/blob.js +2276 -0
  46. package/dist/js/coordinates.js +840 -0
  47. package/dist/js/cube3d.js +789 -0
  48. package/dist/js/dino.js +1420 -0
  49. package/dist/js/easing.js +477 -0
  50. package/dist/js/fluent.js +183 -0
  51. package/dist/js/fluid-simple.js +253 -0
  52. package/dist/js/fluid.js +527 -0
  53. package/dist/js/fractals.js +932 -0
  54. package/dist/js/fractalworker.js +93 -0
  55. package/dist/js/gameobjects.js +176 -0
  56. package/dist/js/genart.js +268 -0
  57. package/dist/js/gendream.js +209 -0
  58. package/dist/js/group.js +140 -0
  59. package/dist/js/hyperbolic001.js +310 -0
  60. package/dist/js/hyperbolic002.js +388 -0
  61. package/dist/js/hyperbolic003.js +319 -0
  62. package/dist/js/hyperbolic004.js +345 -0
  63. package/dist/js/hyperbolic005.js +340 -0
  64. package/dist/js/info-toggle.js +25 -0
  65. package/dist/js/isometric.js +863 -0
  66. package/dist/js/kerr.js +1547 -0
  67. package/dist/js/lavalamp.js +590 -0
  68. package/dist/js/layout.js +354 -0
  69. package/dist/js/mondrian.js +285 -0
  70. package/dist/js/opacity.js +275 -0
  71. package/dist/js/painter.js +484 -0
  72. package/dist/js/particles-showcase.js +514 -0
  73. package/dist/js/particles.js +299 -0
  74. package/dist/js/patterns.js +397 -0
  75. package/dist/js/penrose/artifact.js +69 -0
  76. package/dist/js/penrose/blackhole.js +121 -0
  77. package/dist/js/penrose/constants.js +73 -0
  78. package/dist/js/penrose/game.js +943 -0
  79. package/dist/js/penrose/lore.js +278 -0
  80. package/dist/js/penrose/penrosescene.js +892 -0
  81. package/dist/js/penrose/ship.js +216 -0
  82. package/dist/js/penrose/sounds.js +211 -0
  83. package/dist/js/penrose/voidparticle.js +55 -0
  84. package/dist/js/penrose/voidscene.js +258 -0
  85. package/dist/js/penrose/voidship.js +144 -0
  86. package/dist/js/penrose/wormhole.js +46 -0
  87. package/dist/js/pipeline.js +555 -0
  88. package/dist/js/plane3d.js +256 -0
  89. package/dist/js/platformer.js +1579 -0
  90. package/dist/js/scene.js +304 -0
  91. package/dist/js/scenes.js +320 -0
  92. package/dist/js/schrodinger.js +410 -0
  93. package/dist/js/schwarzschild.js +1015 -0
  94. package/dist/js/shapes.js +628 -0
  95. package/dist/js/space/alien.js +171 -0
  96. package/dist/js/space/boom.js +98 -0
  97. package/dist/js/space/boss.js +353 -0
  98. package/dist/js/space/buff.js +73 -0
  99. package/dist/js/space/bullet.js +102 -0
  100. package/dist/js/space/constants.js +85 -0
  101. package/dist/js/space/game.js +1884 -0
  102. package/dist/js/space/hud.js +112 -0
  103. package/dist/js/space/laserbeam.js +179 -0
  104. package/dist/js/space/lightning.js +277 -0
  105. package/dist/js/space/minion.js +192 -0
  106. package/dist/js/space/missile.js +212 -0
  107. package/dist/js/space/player.js +430 -0
  108. package/dist/js/space/powerup.js +90 -0
  109. package/dist/js/space/starfield.js +58 -0
  110. package/dist/js/space/starpower.js +90 -0
  111. package/dist/js/spacetime.js +559 -0
  112. package/dist/js/sphere3d.js +229 -0
  113. package/dist/js/sprite.js +473 -0
  114. package/dist/js/starfaux/config.js +118 -0
  115. package/dist/js/starfaux/enemy.js +353 -0
  116. package/dist/js/starfaux/hud.js +78 -0
  117. package/dist/js/starfaux/index.js +482 -0
  118. package/dist/js/starfaux/laser.js +182 -0
  119. package/dist/js/starfaux/player.js +468 -0
  120. package/dist/js/starfaux/terrain.js +560 -0
  121. package/dist/js/study001.js +275 -0
  122. package/dist/js/study002.js +366 -0
  123. package/dist/js/study003.js +331 -0
  124. package/dist/js/study004.js +389 -0
  125. package/dist/js/study005.js +209 -0
  126. package/dist/js/study006.js +194 -0
  127. package/dist/js/study007.js +192 -0
  128. package/dist/js/study008.js +413 -0
  129. package/dist/js/svgtween.js +204 -0
  130. package/dist/js/tde/accretiondisk.js +471 -0
  131. package/dist/js/tde/blackhole.js +219 -0
  132. package/dist/js/tde/blackholescene.js +209 -0
  133. package/dist/js/tde/config.js +59 -0
  134. package/dist/js/tde/index.js +820 -0
  135. package/dist/js/tde/jets.js +290 -0
  136. package/dist/js/tde/lensedstarfield.js +154 -0
  137. package/dist/js/tde/tdestar.js +297 -0
  138. package/dist/js/tde/tidalstream.js +372 -0
  139. package/dist/js/tde_old/blackhole.obj.js +354 -0
  140. package/dist/js/tde_old/debris.obj.js +791 -0
  141. package/dist/js/tde_old/flare.obj.js +239 -0
  142. package/dist/js/tde_old/index.js +448 -0
  143. package/dist/js/tde_old/star.obj.js +812 -0
  144. package/dist/js/tetris/config.js +157 -0
  145. package/dist/js/tetris/grid.js +286 -0
  146. package/dist/js/tetris/index.js +1195 -0
  147. package/dist/js/tetris/renderer.js +634 -0
  148. package/dist/js/tetris/tetrominos.js +280 -0
  149. package/dist/js/tiles.js +312 -0
  150. package/dist/js/tweendemo.js +79 -0
  151. package/dist/js/visibility.js +102 -0
  152. package/dist/kerr.html +28 -0
  153. package/dist/lavalamp.html +27 -0
  154. package/dist/layouts.html +37 -0
  155. package/dist/logo.svg +4 -0
  156. package/dist/loop.html +84 -0
  157. package/dist/mondrian.html +32 -0
  158. package/dist/og_image.png +0 -0
  159. package/dist/opacity.html +36 -0
  160. package/dist/painter.html +39 -0
  161. package/dist/particles-showcase.html +28 -0
  162. package/dist/particles.html +24 -0
  163. package/dist/patterns.html +33 -0
  164. package/dist/penrose-game.html +31 -0
  165. package/dist/pipeline.html +737 -0
  166. package/dist/plane3d.html +24 -0
  167. package/dist/platformer.html +43 -0
  168. package/dist/scene.html +33 -0
  169. package/dist/scenes.html +96 -0
  170. package/dist/schrodinger.html +27 -0
  171. package/dist/schwarzschild.html +27 -0
  172. package/dist/shapes.html +16 -0
  173. package/dist/space.html +85 -0
  174. package/dist/spacetime.html +27 -0
  175. package/dist/sphere3d.html +24 -0
  176. package/dist/sprite.html +18 -0
  177. package/dist/starfaux.html +22 -0
  178. package/dist/study001.html +23 -0
  179. package/dist/study002.html +23 -0
  180. package/dist/study003.html +23 -0
  181. package/dist/study004.html +23 -0
  182. package/dist/study005.html +22 -0
  183. package/dist/study006.html +24 -0
  184. package/dist/study007.html +24 -0
  185. package/dist/study008.html +22 -0
  186. package/dist/svgtween.html +29 -0
  187. package/dist/tde.html +28 -0
  188. package/dist/tetris3d.html +25 -0
  189. package/dist/tiles.html +28 -0
  190. package/dist/transforms.html +400 -0
  191. package/dist/tween.html +45 -0
  192. package/dist/visibility.html +33 -0
  193. package/package.json +1 -1
@@ -0,0 +1,471 @@
1
+ import { GameObject, Painter, Tweenetik, Easing } from "/gcanvas.es.min.js";
2
+ import { keplerianOmega } from "/gcanvas.es.min.js";
3
+ import { CONFIG } from "./config.js";
4
+
5
+ /**
6
+ * AccretionDisk - Keplerian particle disk with gravitational lensing
7
+ *
8
+ * Uses the same proven lensing formula as demos/js/blackhole.js:
9
+ * - Single-pass lensing that pushes particles outward
10
+ * - Einstein ring forms naturally from disk geometry
11
+ * - Doppler beaming for brightness variation
12
+ */
13
+
14
+ const DISK_CONFIG = {
15
+ // Orbital bounds (multiplier of BH radius)
16
+ innerRadiusMultiplier: 1.5,
17
+ outerRadiusMultiplier: 9.0, // Wide disk with margin from screen edges
18
+
19
+ // Particle properties
20
+ maxParticles: 10000,
21
+ particleLifetime: 1000,
22
+ spawnRate: 500,
23
+
24
+ // Orbital physics
25
+ baseOrbitalSpeed: 0.8,
26
+
27
+ // Decay mechanics
28
+ decayChanceBase: 0.0002,
29
+ decaySpeedFactor: 0.995,
30
+
31
+ // Disk geometry - thin disk with some spread
32
+ diskThickness: 0.006,
33
+
34
+ // Lensing - pushes particles outward to form Einstein ring
35
+ ringRadiusFactor: 1.8, // Higher = more margin between BH and ring
36
+ lensingFalloff: 1.8, // Slightly wider falloff
37
+
38
+ // Visual - heat gradient (white-hot inner to deep red outer)
39
+ colorHot: { r: 255, g: 250, b: 220 }, // Inner (white-hot)
40
+ colorMid: { r: 255, g: 160, b: 50 }, // Mid (orange)
41
+ colorCool: { r: 180, g: 40, b: 40 }, // Outer (deep red)
42
+
43
+ sizeMin: .8,
44
+ sizeMax: 1.2,
45
+ };
46
+
47
+ export class AccretionDisk extends GameObject {
48
+ constructor(game, options = {}) {
49
+ super(game, options);
50
+
51
+ this.camera = options.camera;
52
+ this.bhRadius = options.bhRadius ?? 50;
53
+ this.bhMass = options.bhMass ?? CONFIG.blackHole.initialMass;
54
+
55
+ // Disk bounds scale with BH radius
56
+ this.innerRadius = this.bhRadius * DISK_CONFIG.innerRadiusMultiplier;
57
+ this.outerRadius = this.bhRadius * DISK_CONFIG.outerRadiusMultiplier;
58
+
59
+ // State
60
+ this.active = false;
61
+ this.lensingStrength = 0; // Ramps up during activation
62
+ this.scale = 0; // For expand-from-BH animation
63
+
64
+ // Callback when particle falls into BH
65
+ this.onParticleConsumed = options.onParticleConsumed ?? null;
66
+
67
+ // Particle array
68
+ this.particles = [];
69
+ }
70
+
71
+ /**
72
+ * Activate disk with expand-from-center animation
73
+ */
74
+ activate() {
75
+ if (this.active) return;
76
+ this.active = true;
77
+ this.scale = 0.3; // Start partially expanded so it's visible immediately
78
+ this.lensingStrength = 0;
79
+ // Expansion from BH center - 2 seconds (was 4, felt too slow)
80
+ Tweenetik.to(this, { scale: 1 }, 2.0, Easing.easeOutQuart);
81
+ // Lensing ramps up alongside scale
82
+ Tweenetik.to(this, { lensingStrength: 1 }, 2.5, Easing.easeOutQuad);
83
+ }
84
+
85
+ init() {
86
+ this.particles = [];
87
+ }
88
+
89
+ /**
90
+ * Get heat-based color for particle at given radius
91
+ */
92
+ getHeatColor(distance) {
93
+ const t = (distance - this.innerRadius) / (this.outerRadius - this.innerRadius);
94
+
95
+ let r, g, b;
96
+ if (t < 0.5) {
97
+ // Inner half: hot -> mid
98
+ const t2 = t * 2;
99
+ r = DISK_CONFIG.colorHot.r + (DISK_CONFIG.colorMid.r - DISK_CONFIG.colorHot.r) * t2;
100
+ g = DISK_CONFIG.colorHot.g + (DISK_CONFIG.colorMid.g - DISK_CONFIG.colorHot.g) * t2;
101
+ b = DISK_CONFIG.colorHot.b + (DISK_CONFIG.colorMid.b - DISK_CONFIG.colorHot.b) * t2;
102
+ } else {
103
+ // Outer half: mid -> cool
104
+ const t2 = (t - 0.5) * 2;
105
+ r = DISK_CONFIG.colorMid.r + (DISK_CONFIG.colorCool.r - DISK_CONFIG.colorMid.r) * t2;
106
+ g = DISK_CONFIG.colorMid.g + (DISK_CONFIG.colorCool.g - DISK_CONFIG.colorMid.g) * t2;
107
+ b = DISK_CONFIG.colorMid.b + (DISK_CONFIG.colorCool.b - DISK_CONFIG.colorMid.b) * t2;
108
+ }
109
+
110
+ return { r: Math.round(r), g: Math.round(g), b: Math.round(b) };
111
+ }
112
+
113
+ /**
114
+ * Spawn a new particle at random position in disk
115
+ */
116
+ spawnParticle() {
117
+ if (this.particles.length >= DISK_CONFIG.maxParticles) return;
118
+
119
+ // Balanced distribution with slight inner bias for lensing visibility
120
+ // Lower power = more particles near inner edge (where lensing is strongest)
121
+ const t = Math.pow(Math.random(), 0.6);
122
+ const distance = this.innerRadius + (this.outerRadius - this.innerRadius) * t;
123
+
124
+ const angle = Math.random() * Math.PI * 2;
125
+
126
+ // Keplerian orbital speed
127
+ const speed = keplerianOmega(distance, this.bhMass, DISK_CONFIG.baseOrbitalSpeed, this.outerRadius);
128
+
129
+ // Small vertical offset for thin disk
130
+ const baseScale = this.game.baseScale ?? Math.min(this.game.width, this.game.height);
131
+ const yOffset = (Math.random() - 0.5) * baseScale * DISK_CONFIG.diskThickness;
132
+
133
+ this.particles.push({
134
+ angle,
135
+ distance,
136
+ yOffset,
137
+ speed,
138
+ // Small random initial age prevents batch death
139
+ age: Math.random() * DISK_CONFIG.particleLifetime * 0.1, // Only 10% spread
140
+ isFalling: false,
141
+ size: DISK_CONFIG.sizeMin + Math.random() * (DISK_CONFIG.sizeMax - DISK_CONFIG.sizeMin),
142
+ baseColor: this.getHeatColor(distance),
143
+ });
144
+ }
145
+
146
+ /**
147
+ * Capture a particle from the tidal stream
148
+ * Converts Cartesian stream particle to polar disk orbit
149
+ */
150
+ captureParticle(streamParticle) {
151
+ if (this.particles.length >= DISK_CONFIG.maxParticles) return;
152
+
153
+ const x = streamParticle.x;
154
+ const z = streamParticle.z;
155
+ const dist = Math.sqrt(x * x + z * z);
156
+
157
+ // Skip if outside disk bounds
158
+ if (dist < this.innerRadius || dist > this.outerRadius) return;
159
+
160
+ const angle = Math.atan2(z, x);
161
+
162
+ // Calculate tangential velocity from stream particle
163
+ const vx = streamParticle.vx ?? 0;
164
+ const vz = streamParticle.vz ?? 0;
165
+ const tangentVx = -z / dist;
166
+ const tangentVz = x / dist;
167
+ const tangentSpeed = vx * tangentVx + vz * tangentVz;
168
+
169
+ // Convert to angular velocity
170
+ const angularVelocity = Math.abs(tangentSpeed) / dist;
171
+
172
+ // Target Keplerian speed
173
+ const keplerianSpeed = keplerianOmega(dist, this.bhMass, DISK_CONFIG.baseOrbitalSpeed, this.outerRadius);
174
+
175
+ // Blend toward Keplerian (captured particles circularize)
176
+ const blendedSpeed = (angularVelocity + keplerianSpeed) / 2;
177
+
178
+ this.particles.push({
179
+ angle,
180
+ distance: dist,
181
+ yOffset: streamParticle.y ?? 0,
182
+ speed: blendedSpeed,
183
+ age: 0,
184
+ isFalling: false,
185
+ size: streamParticle.size ?? (DISK_CONFIG.sizeMin + Math.random() * (DISK_CONFIG.sizeMax - DISK_CONFIG.sizeMin)),
186
+ baseColor: this.getHeatColor(dist),
187
+ });
188
+ }
189
+
190
+ /**
191
+ * Check if particle should begin decay spiral
192
+ */
193
+ checkDecay(p) {
194
+ // Higher decay chance near ISCO (innermost stable circular orbit)
195
+ const iscoProximity = (p.distance - this.innerRadius) / (this.outerRadius - this.innerRadius);
196
+ const ageDecayFactor = Math.min(1, p.age / DISK_CONFIG.particleLifetime);
197
+
198
+ // Particles near inner edge or old ones are more likely to fall
199
+ const decayChance = DISK_CONFIG.decayChanceBase *
200
+ (1 + 3 * (1 - iscoProximity)) *
201
+ (1 + ageDecayFactor);
202
+
203
+ if (Math.random() < decayChance) {
204
+ p.isFalling = true;
205
+ }
206
+ }
207
+
208
+ update(dt) {
209
+ super.update(dt);
210
+
211
+ // Spawn new particles when active
212
+ if (this.active && this.particles.length < DISK_CONFIG.maxParticles) {
213
+ for (let i = 0; i < DISK_CONFIG.spawnRate; i++) {
214
+ this.spawnParticle();
215
+ }
216
+ }
217
+
218
+ // Update particles
219
+ for (let i = this.particles.length - 1; i >= 0; i--) {
220
+ const p = this.particles[i];
221
+ p.age += dt;
222
+
223
+ // Remove old particles
224
+ if (p.age > DISK_CONFIG.particleLifetime) {
225
+ this.particles.splice(i, 1);
226
+ continue;
227
+ }
228
+
229
+ if (p.isFalling) {
230
+ // Spiral inward - exponential decay
231
+ p.distance *= DISK_CONFIG.decaySpeedFactor;
232
+ p.angle += p.speed * dt * 2; // Accelerate as falls
233
+ p.yOffset *= 0.95; // Flatten toward equator
234
+
235
+ // Consumed by black hole
236
+ if (p.distance < this.bhRadius * 0.5) {
237
+ this.particles.splice(i, 1);
238
+ if (this.onParticleConsumed) {
239
+ this.onParticleConsumed();
240
+ }
241
+ continue;
242
+ }
243
+ } else {
244
+ // Normal Keplerian orbit
245
+ p.angle += p.speed * dt;
246
+
247
+ // Check for decay
248
+ this.checkDecay(p);
249
+ }
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Build render list with camera-space lensing
255
+ * Uses the proven formula from demos/js/blackhole.js
256
+ */
257
+ buildRenderList() {
258
+ const renderList = [];
259
+ if (!this.camera || this.particles.length === 0) return renderList;
260
+
261
+ const lensingStrength = this.lensingStrength;
262
+
263
+ for (const p of this.particles) {
264
+ // World coordinates (flat disk in x-z plane)
265
+ const scaledDist = p.distance * this.scale;
266
+ let x = Math.cos(p.angle) * scaledDist;
267
+ let y = p.yOffset * this.scale;
268
+ let z = Math.sin(p.angle) * scaledDist;
269
+
270
+ // Transform to camera space
271
+ const cosY = Math.cos(this.camera.rotationY);
272
+ const sinY = Math.sin(this.camera.rotationY);
273
+ let xCam = x * cosY - z * sinY;
274
+ let zCam = x * sinY + z * cosY;
275
+
276
+ const cosX = Math.cos(this.camera.rotationX);
277
+ const sinX = Math.sin(this.camera.rotationX);
278
+ let yCam = y * cosX - zCam * sinX;
279
+ zCam = y * sinX + zCam * cosX;
280
+
281
+ // === GRAVITATIONAL LENSING ===
282
+ // Creates the Interstellar effect: disk curves around BH
283
+
284
+ // Camera tilt: 0 when edge-on, 1 when top-down
285
+ const cameraTilt = Math.abs(Math.sin(this.camera.rotationX));
286
+ const isBehind = zCam > 0;
287
+ const currentR = Math.sqrt(xCam * xCam + yCam * yCam);
288
+
289
+ if (lensingStrength > 0 && currentR < this.bhRadius * 6) {
290
+ const ringRadius = this.bhRadius * DISK_CONFIG.ringRadiusFactor;
291
+ const lensFactor = Math.exp(-currentR / (this.bhRadius * DISK_CONFIG.lensingFalloff));
292
+ const warp = lensFactor * 1.2 * lensingStrength;
293
+
294
+ // Determine upper/lower half for asymmetric effects
295
+ const angleRelativeToCamera = p.angle + this.camera.rotationY;
296
+ const isUpperHalf = Math.sin(angleRelativeToCamera) > 0;
297
+
298
+ // === RADIAL PUSH: Curves particles around BH silhouette ===
299
+ // Bottom ring should have TIGHTER radius (less expansion) at edge-on views
300
+ // But stay symmetric at top-down views
301
+ if (currentR > 0) {
302
+ let radialWarp = warp;
303
+
304
+ // Edge-on factor: 1 at edge-on, 0 at top-down
305
+ const edgeOnFactor = 1 - cameraTilt;
306
+
307
+ // Reduce radial expansion for bottom half, but only at edge-on angles
308
+ // This creates the tighter bottom ring radius seen in Interstellar
309
+ if (!isUpperHalf && isBehind) {
310
+ // At edge-on: bottom gets 40% of radial push (tight ring)
311
+ // At top-down: bottom gets 100% (symmetric circle)
312
+ radialWarp *= 1.0 - edgeOnFactor * 0.6;
313
+ }
314
+
315
+ const ratio = (currentR + ringRadius * radialWarp) / currentR;
316
+ xCam *= ratio;
317
+ yCam *= ratio;
318
+ }
319
+
320
+ // === VERTICAL CURVES: Only when camera is tilted ===
321
+ if (cameraTilt > 0.05) {
322
+ // Arc shape - smooth curve
323
+ const arcWidth = this.bhRadius * 5.0;
324
+ const normalizedX = xCam / arcWidth;
325
+ const arcCurve = Math.max(0, Math.cos(normalizedX * Math.PI * 0.5));
326
+
327
+ // Depth factor - different for front vs back
328
+ const depthFactor = isBehind
329
+ ? Math.min(1.0, zCam / (this.bhRadius * 3))
330
+ : Math.min(1.0, Math.abs(zCam) / (this.bhRadius * 3));
331
+
332
+ // Ring height - scales with tilt
333
+ const ringHeight = this.bhRadius * 2.0 * lensFactor * depthFactor * cameraTilt * lensingStrength;
334
+
335
+ // Apply vertical displacement
336
+ if (isBehind) {
337
+ // Back particles: upper half UP, lower half DOWN
338
+ if (isUpperHalf) {
339
+ yCam -= ringHeight * arcCurve;
340
+ } else {
341
+ // Bottom ring: less vertical displacement too
342
+ yCam += ringHeight * arcCurve * 0.5;
343
+ }
344
+ } else {
345
+ // Front particles: curve DOWN slightly
346
+ yCam += ringHeight * arcCurve * 0.4;
347
+ }
348
+ }
349
+ }
350
+
351
+ // Perspective projection
352
+ const perspectiveScale = this.camera.perspective / (this.camera.perspective + zCam);
353
+ const screenX = xCam * perspectiveScale;
354
+ const screenY = yCam * perspectiveScale;
355
+
356
+ // Skip particles behind camera
357
+ if (zCam < -this.camera.perspective + 10) continue;
358
+
359
+ // Doppler beaming - approaching side brighter
360
+ const velocityDir = Math.cos(p.angle + this.camera.rotationY);
361
+ const doppler = 1 + velocityDir * 0.4;
362
+
363
+ // Age-based fade
364
+ const ageRatio = p.age / DISK_CONFIG.particleLifetime;
365
+ const alpha = Math.max(0.3, 1 - Math.pow(ageRatio, 2.5));
366
+
367
+ // Color (redshift for falling particles)
368
+ let color = p.baseColor;
369
+ if (p.isFalling) {
370
+ const fallProgress = 1 - (p.distance / this.innerRadius);
371
+ color = {
372
+ r: Math.round(p.baseColor.r * (1 - fallProgress * 0.5)),
373
+ g: Math.round(p.baseColor.g * (1 - fallProgress * 0.7)),
374
+ b: Math.round(p.baseColor.b * (1 - fallProgress * 0.3)),
375
+ };
376
+ }
377
+
378
+ renderList.push({
379
+ x: screenX,
380
+ y: screenY,
381
+ z: zCam,
382
+ scale: perspectiveScale,
383
+ color,
384
+ doppler,
385
+ alpha,
386
+ size: p.size,
387
+ });
388
+ }
389
+
390
+ // Sort back to front for proper blending
391
+ renderList.sort((a, b) => b.z - a.z);
392
+ return renderList;
393
+ }
394
+
395
+ /**
396
+ * Clear all particles
397
+ */
398
+ clear() {
399
+ this.particles = [];
400
+ }
401
+
402
+ /**
403
+ * Update BH radius - also updates disk bounds since they scale with BH
404
+ * Particles inside the event horizon are consumed; others remain in place
405
+ * and will naturally be replaced by new spawns at correct radii
406
+ */
407
+ updateBHRadius(radius) {
408
+ this.bhRadius = radius;
409
+ // Disk bounds scale with BH radius
410
+ this.innerRadius = this.bhRadius * DISK_CONFIG.innerRadiusMultiplier;
411
+ this.outerRadius = this.bhRadius * DISK_CONFIG.outerRadiusMultiplier;
412
+
413
+ // Consume particles swallowed by event horizon (same threshold as update loop)
414
+ const consumeRadius = this.bhRadius * 0.5;
415
+ for (let i = this.particles.length - 1; i >= 0; i--) {
416
+ const p = this.particles[i];
417
+
418
+ if (p.distance < consumeRadius) {
419
+ this.particles.splice(i, 1);
420
+ if (this.onParticleConsumed) {
421
+ this.onParticleConsumed();
422
+ }
423
+ }
424
+ }
425
+ }
426
+
427
+ render() {
428
+ super.render();
429
+
430
+ if (!this.active || !this.camera || this.particles.length === 0) return;
431
+
432
+ const cx = this.game.width / 2;
433
+ const cy = this.game.height / 2;
434
+ const baseScale = this.game.baseScale ?? Math.min(this.game.width, this.game.height);
435
+ const renderList = this.buildRenderList();
436
+
437
+ Painter.useCtx((ctx) => {
438
+ // Reset transform (bypass Scene3D transforms)
439
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
440
+
441
+ for (const item of renderList) {
442
+ const { r, g, b } = item.color;
443
+ const size = baseScale * 0.003 * item.scale;
444
+ if (size < 0.1) continue;
445
+
446
+ // Apply Doppler brightness
447
+ const dr = Math.min(255, Math.round(r * item.doppler));
448
+ const dg = Math.min(255, Math.round(g * item.doppler * 0.95));
449
+ const db = Math.min(255, Math.round(b * item.doppler * 0.9));
450
+
451
+ const finalAlpha = Math.max(0, Math.min(1, item.alpha * item.doppler));
452
+
453
+ // Core particle
454
+ ctx.fillStyle = `rgba(${dr}, ${dg}, ${db}, ${finalAlpha})`;
455
+ ctx.beginPath();
456
+ ctx.arc(cx + item.x, cy + item.y, size / 2, 0, Math.PI * 2);
457
+ ctx.fill();
458
+
459
+ // Additive glow for bright/close particles (from blackhole.js)
460
+ if (item.doppler > 1.1 && item.alpha > 0.5) {
461
+ ctx.globalCompositeOperation = "screen";
462
+ ctx.fillStyle = `rgba(${dr}, ${dg}, ${db}, ${finalAlpha * 0.4})`;
463
+ ctx.beginPath();
464
+ ctx.arc(cx + item.x, cy + item.y, size, 0, Math.PI * 2);
465
+ ctx.fill();
466
+ ctx.globalCompositeOperation = "source-over";
467
+ }
468
+ }
469
+ });
470
+ }
471
+ }
@@ -0,0 +1,219 @@
1
+ import { GameObject, Sphere3D, Painter, Easing } from "/gcanvas.es.min.js";
2
+ import { CONFIG } from "./config.js";
3
+
4
+ export class BlackHole extends GameObject {
5
+ constructor(game, options = {}) {
6
+ super(game, options);
7
+ this.mass = options.initialMass ?? CONFIG.blackHole.initialMass;
8
+ this.baseRadius = game.baseScale ? game.baseScale * CONFIG.bhRadiusRatio : 50;
9
+ this.currentRadius = this.baseRadius;
10
+
11
+ // Awakening state - BH starts dormant, wakes up as it feeds
12
+ this.awakeningLevel = 0; // 0 = dormant (pure black), 1 = fully awake
13
+ this.feedingPulse = 0; // Temporary glow boost when consuming
14
+ this.totalConsumed = 0; // Track total mass consumed
15
+
16
+ // Dynamic growth animation
17
+ this.growthSpurt = 0; // Overshoot when consuming (decays to 0)
18
+ this.breathPhase = 0; // Oscillation phase for breathing effect
19
+ this.targetRadius = this.baseRadius; // Smooth radius target
20
+
21
+ // Rotation - black holes spin!
22
+ this.rotation = 0;
23
+ this.rotationSpeed = options.rotationSpeed ?? 2.9; // Slow, ominous spin
24
+
25
+ // Use WebGL shaders for rendering
26
+ this.useShader = options.useShader ?? true;
27
+ }
28
+
29
+ init() {
30
+ this.updateVisual();
31
+ }
32
+
33
+ /**
34
+ * Add mass from consumed particles - triggers awakening and pulse
35
+ *
36
+ * @param {number} amount - Amount of mass to add
37
+ *
38
+ * Note: Feeding pulse is only triggered before the stable phase.
39
+ * Once stabilizing, particles can still be consumed but won't
40
+ * cause the visual pulse effect.
41
+ */
42
+ addConsumedMass(amount) {
43
+ this.totalConsumed += amount;
44
+
45
+ // Skip pulse effects if we're in the stable phase
46
+ if (this.isStabilizing) {
47
+ return;
48
+ }
49
+
50
+ // Awakening increases as BH feeds (slow ramp up)
51
+ const awakeningProgress = Math.min(1, this.totalConsumed * 0.1);
52
+ this.awakeningLevel = Math.max(this.awakeningLevel, awakeningProgress);
53
+
54
+ // Feeding pulse - temporary glow boost
55
+ this.feedingPulse = Math.min(1, this.feedingPulse + amount * 0.2);
56
+
57
+ // Growth spurt - overshoot effect when consuming
58
+ // More dramatic spurts as awakening increases
59
+ const spurtIntensity = 0.03 + this.awakeningLevel * 0.05;
60
+ this.growthSpurt = Math.min(0.15, this.growthSpurt + amount * spurtIntensity);
61
+ }
62
+
63
+ /**
64
+ * Reset to dormant state
65
+ */
66
+ resetAwakening() {
67
+ this.awakeningLevel = 0;
68
+ this.feedingPulse = 0;
69
+ this.totalConsumed = 0;
70
+ this.rotation = 0;
71
+ this.growthSpurt = 0;
72
+ this.breathPhase = 0;
73
+ this.isStabilizing = false; // Reset stabilization state
74
+ }
75
+
76
+ updateVisual() {
77
+ // Calculate how much mass has been absorbed (0 = none, 1 = full star)
78
+ const massAbsorbed = Math.max(0, this.mass - CONFIG.blackHole.initialMass);
79
+ const absorptionProgress = massAbsorbed / CONFIG.star.initialMass;
80
+
81
+ // Apply easing to make growth feel more organic
82
+ // easeOutCubic: rapid initial growth that slows as it fills
83
+ const easedProgress = Easing.easeOutCubic(absorptionProgress);
84
+
85
+ // Base radius from absorption with easing
86
+ const baseScale = this.baseRadius / CONFIG.bhRadiusRatio;
87
+ const radiusFraction = CONFIG.bhRadiusRatio +
88
+ easedProgress * (CONFIG.bhFinalRadiusRatio - CONFIG.bhRadiusRatio);
89
+ this.targetRadius = baseScale * radiusFraction;
90
+
91
+ // Breathing oscillation - subtle when dormant, stronger when awake
92
+ const breathAmplitude = 0.01 + this.awakeningLevel * 0.02 + this.feedingPulse * 0.03;
93
+ const breathSpeed = 1.5 + this.awakeningLevel * 0.5; // Faster when active
94
+ const breathOffset = Math.sin(this.breathPhase * breathSpeed) * breathAmplitude;
95
+
96
+ // Growth spurt overshoot effect (elastic rebound)
97
+ const spurtOffset = this.growthSpurt * (1 + Math.sin(this.breathPhase * 8) * 0.3);
98
+
99
+ // Combine all effects
100
+ this.currentRadius = this.targetRadius * (1 + breathOffset + spurtOffset);
101
+
102
+ if (this.currentRadius <= 0) {
103
+ this.currentRadius = this.baseRadius;
104
+ }
105
+
106
+ // Edge brightness increases with awakening
107
+ const awakeFactor = this.awakeningLevel;
108
+ const pulseFactor = this.feedingPulse;
109
+
110
+ // For Canvas 2D fallback - gradient rendering
111
+ // Dormant: pure black edges (#101010)
112
+ // Awake: warmer edges with hint of orange/red glow
113
+ const edgeBase = 16 + Math.round(awakeFactor * 24 + pulseFactor * 16); // 16-56
114
+ const edgeR = Math.min(255, edgeBase + Math.round(awakeFactor * 40 + pulseFactor * 60));
115
+ const edgeG = Math.min(255, edgeBase + Math.round(awakeFactor * 20 + pulseFactor * 30));
116
+ const edgeB = edgeBase;
117
+
118
+ const midBase = 8 + Math.round(awakeFactor * 12 + pulseFactor * 8);
119
+ const midR = Math.min(255, midBase + Math.round(awakeFactor * 20 + pulseFactor * 30));
120
+ const midG = Math.min(255, midBase + Math.round(awakeFactor * 10 + pulseFactor * 15));
121
+ const midB = midBase;
122
+
123
+ const gradient = Painter.colors.radialGradient(
124
+ 0, 0, 0.01 * this.currentRadius,
125
+ 0, 0, this.currentRadius,
126
+ [
127
+ { offset: 0, color: "#000" },
128
+ { offset: 0.5, color: "#000" },
129
+ { offset: 0.85, color: `rgb(${midR}, ${midG}, ${midB})` },
130
+ { offset: 1, color: `rgb(${edgeR}, ${edgeG}, ${edgeB})` },
131
+ ]
132
+ );
133
+
134
+ if (!this.core) {
135
+ this.core = new Sphere3D(this.currentRadius, {
136
+ color: gradient,
137
+ camera: this.game.camera,
138
+ stroke: null, // No wireframe
139
+ debug: false,
140
+ segments: 32, // Smoother sphere
141
+ // WebGL shader options
142
+ useShader: this.useShader,
143
+ shaderType: "blackHole",
144
+ shaderUniforms: {
145
+ uAwakeningLevel: awakeFactor,
146
+ uFeedingPulse: pulseFactor,
147
+ uRotation: this.rotation,
148
+ },
149
+ });
150
+ } else {
151
+ this.core.radius = this.currentRadius;
152
+ this.core.color = gradient; // Keep gradient for Canvas 2D fallback
153
+ // Update shader uniforms
154
+ if (this.core.useShader) {
155
+ this.core.setShaderUniforms({
156
+ uAwakeningLevel: awakeFactor,
157
+ uFeedingPulse: pulseFactor,
158
+ uRotation: this.rotation,
159
+ });
160
+ }
161
+ this.core._generateGeometry();
162
+ }
163
+ }
164
+
165
+ update(dt) {
166
+ super.update(dt);
167
+
168
+ // Animate breathing phase
169
+ this.breathPhase += dt * Math.PI * 2; // Full cycle per second
170
+
171
+ // Spin the black hole - rotation speeds up when feeding
172
+ const spinMultiplier = 1 + this.feedingPulse * 2 + this.awakeningLevel * 0.5;
173
+ this.rotation += this.rotationSpeed * spinMultiplier * dt;
174
+
175
+ // Decay feeding pulse over time
176
+ if (this.feedingPulse > 0) {
177
+ this.feedingPulse = Math.max(0, this.feedingPulse - dt * 1.5);
178
+ }
179
+
180
+ // Decay growth spurt with elastic damping
181
+ if (this.growthSpurt > 0) {
182
+ // Fast initial decay, slows down (feels like elastic settling)
183
+ const decayRate = 3 + this.growthSpurt * 5; // Faster when larger
184
+ this.growthSpurt = Math.max(0, this.growthSpurt - dt * decayRate);
185
+ }
186
+
187
+ // Decay awakening level when stabilizing (slow cool-down)
188
+ // Minimum level is 0.3 - never goes fully dormant after feeding
189
+ if (this.isStabilizing && this.awakeningLevel > 0.3) {
190
+ this.awakeningLevel = Math.max(0.3, this.awakeningLevel - dt * 0.15);
191
+ }
192
+
193
+ this.updateVisual();
194
+ }
195
+
196
+ /**
197
+ * Start the stabilization phase - black hole calms down
198
+ */
199
+ startStabilizing() {
200
+ this.isStabilizing = true;
201
+ }
202
+
203
+ /**
204
+ * Reset stabilization state
205
+ */
206
+ resetStabilizing() {
207
+ this.isStabilizing = false;
208
+ }
209
+
210
+ onResize(baseRadius) {
211
+ this.baseRadius = baseRadius;
212
+ this.updateVisual();
213
+ }
214
+
215
+ render() {
216
+ super.render();
217
+ this.core.render();
218
+ }
219
+ }