@guinetik/gcanvas 1.0.2 → 1.0.4

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 (217) hide show
  1. package/dist/gcanvas.es.js +25656 -0
  2. package/dist/gcanvas.es.min.js +1 -0
  3. package/dist/gcanvas.umd.js +1 -0
  4. package/dist/gcanvas.umd.min.js +1 -0
  5. package/package.json +23 -6
  6. package/src/game/objects/index.js +1 -0
  7. package/src/game/objects/spritesheet.js +260 -0
  8. package/src/game/ui/theme.js +6 -0
  9. package/src/io/keys.js +9 -1
  10. package/src/math/boolean.js +481 -0
  11. package/src/math/index.js +1 -0
  12. package/.github/workflows/release.yaml +0 -70
  13. package/.jshintrc +0 -4
  14. package/.vscode/settings.json +0 -22
  15. package/CLAUDE.md +0 -310
  16. package/blackhole.jpg +0 -0
  17. package/demo.png +0 -0
  18. package/demos/CNAME +0 -1
  19. package/demos/animations.html +0 -31
  20. package/demos/basic.html +0 -38
  21. package/demos/baskara.html +0 -31
  22. package/demos/bezier.html +0 -35
  23. package/demos/beziersignature.html +0 -29
  24. package/demos/blackhole.html +0 -28
  25. package/demos/blob.html +0 -35
  26. package/demos/coordinates.html +0 -698
  27. package/demos/cube3d.html +0 -23
  28. package/demos/demos.css +0 -303
  29. package/demos/dino.html +0 -42
  30. package/demos/easing.html +0 -28
  31. package/demos/events.html +0 -195
  32. package/demos/fluent.html +0 -647
  33. package/demos/fluid-simple.html +0 -22
  34. package/demos/fluid.html +0 -37
  35. package/demos/fractals.html +0 -36
  36. package/demos/gameobjects.html +0 -626
  37. package/demos/genart.html +0 -26
  38. package/demos/gendream.html +0 -26
  39. package/demos/group.html +0 -36
  40. package/demos/home.html +0 -587
  41. package/demos/index.html +0 -376
  42. package/demos/isometric.html +0 -34
  43. package/demos/js/animations.js +0 -452
  44. package/demos/js/basic.js +0 -204
  45. package/demos/js/baskara.js +0 -751
  46. package/demos/js/bezier.js +0 -692
  47. package/demos/js/beziersignature.js +0 -241
  48. package/demos/js/blackhole/accretiondisk.obj.js +0 -379
  49. package/demos/js/blackhole/blackhole.obj.js +0 -318
  50. package/demos/js/blackhole/index.js +0 -409
  51. package/demos/js/blackhole/particle.js +0 -56
  52. package/demos/js/blackhole/starfield.obj.js +0 -218
  53. package/demos/js/blob.js +0 -2276
  54. package/demos/js/coordinates.js +0 -840
  55. package/demos/js/cube3d.js +0 -789
  56. package/demos/js/dino.js +0 -1420
  57. package/demos/js/easing.js +0 -477
  58. package/demos/js/fluent.js +0 -183
  59. package/demos/js/fluid-simple.js +0 -253
  60. package/demos/js/fluid.js +0 -527
  61. package/demos/js/fractals.js +0 -931
  62. package/demos/js/fractalworker.js +0 -93
  63. package/demos/js/gameobjects.js +0 -176
  64. package/demos/js/genart.js +0 -268
  65. package/demos/js/gendream.js +0 -209
  66. package/demos/js/group.js +0 -140
  67. package/demos/js/info-toggle.js +0 -25
  68. package/demos/js/isometric.js +0 -863
  69. package/demos/js/kerr.js +0 -1556
  70. package/demos/js/lavalamp.js +0 -590
  71. package/demos/js/layout.js +0 -354
  72. package/demos/js/mondrian.js +0 -285
  73. package/demos/js/opacity.js +0 -275
  74. package/demos/js/painter.js +0 -484
  75. package/demos/js/particles-showcase.js +0 -514
  76. package/demos/js/particles.js +0 -299
  77. package/demos/js/patterns.js +0 -397
  78. package/demos/js/penrose/artifact.js +0 -69
  79. package/demos/js/penrose/blackhole.js +0 -121
  80. package/demos/js/penrose/constants.js +0 -73
  81. package/demos/js/penrose/game.js +0 -943
  82. package/demos/js/penrose/lore.js +0 -278
  83. package/demos/js/penrose/penrosescene.js +0 -892
  84. package/demos/js/penrose/ship.js +0 -216
  85. package/demos/js/penrose/sounds.js +0 -211
  86. package/demos/js/penrose/voidparticle.js +0 -55
  87. package/demos/js/penrose/voidscene.js +0 -258
  88. package/demos/js/penrose/voidship.js +0 -144
  89. package/demos/js/penrose/wormhole.js +0 -46
  90. package/demos/js/pipeline.js +0 -555
  91. package/demos/js/plane3d.js +0 -256
  92. package/demos/js/platformer.js +0 -1579
  93. package/demos/js/scene.js +0 -304
  94. package/demos/js/scenes.js +0 -320
  95. package/demos/js/schrodinger.js +0 -410
  96. package/demos/js/schwarzschild.js +0 -1023
  97. package/demos/js/shapes.js +0 -628
  98. package/demos/js/space/alien.js +0 -171
  99. package/demos/js/space/boom.js +0 -98
  100. package/demos/js/space/boss.js +0 -353
  101. package/demos/js/space/buff.js +0 -73
  102. package/demos/js/space/bullet.js +0 -102
  103. package/demos/js/space/constants.js +0 -85
  104. package/demos/js/space/game.js +0 -1884
  105. package/demos/js/space/hud.js +0 -112
  106. package/demos/js/space/laserbeam.js +0 -179
  107. package/demos/js/space/lightning.js +0 -277
  108. package/demos/js/space/minion.js +0 -192
  109. package/demos/js/space/missile.js +0 -212
  110. package/demos/js/space/player.js +0 -430
  111. package/demos/js/space/powerup.js +0 -90
  112. package/demos/js/space/starfield.js +0 -58
  113. package/demos/js/space/starpower.js +0 -90
  114. package/demos/js/spacetime.js +0 -559
  115. package/demos/js/sphere3d.js +0 -229
  116. package/demos/js/sprite.js +0 -473
  117. package/demos/js/svgtween.js +0 -204
  118. package/demos/js/tde/accretiondisk.js +0 -471
  119. package/demos/js/tde/blackhole.js +0 -219
  120. package/demos/js/tde/blackholescene.js +0 -209
  121. package/demos/js/tde/config.js +0 -59
  122. package/demos/js/tde/index.js +0 -820
  123. package/demos/js/tde/jets.js +0 -290
  124. package/demos/js/tde/lensedstarfield.js +0 -154
  125. package/demos/js/tde/tdestar.js +0 -297
  126. package/demos/js/tde/tidalstream.js +0 -372
  127. package/demos/js/tde_old/blackhole.obj.js +0 -354
  128. package/demos/js/tde_old/debris.obj.js +0 -791
  129. package/demos/js/tde_old/flare.obj.js +0 -239
  130. package/demos/js/tde_old/index.js +0 -448
  131. package/demos/js/tde_old/star.obj.js +0 -812
  132. package/demos/js/tiles.js +0 -312
  133. package/demos/js/tweendemo.js +0 -79
  134. package/demos/js/visibility.js +0 -102
  135. package/demos/kerr.html +0 -28
  136. package/demos/lavalamp.html +0 -27
  137. package/demos/layouts.html +0 -37
  138. package/demos/logo.svg +0 -4
  139. package/demos/loop.html +0 -84
  140. package/demos/mondrian.html +0 -32
  141. package/demos/og_image.png +0 -0
  142. package/demos/opacity.html +0 -36
  143. package/demos/painter.html +0 -39
  144. package/demos/particles-showcase.html +0 -28
  145. package/demos/particles.html +0 -24
  146. package/demos/patterns.html +0 -33
  147. package/demos/penrose-game.html +0 -31
  148. package/demos/pipeline.html +0 -737
  149. package/demos/plane3d.html +0 -24
  150. package/demos/platformer.html +0 -43
  151. package/demos/scene.html +0 -33
  152. package/demos/scenes.html +0 -96
  153. package/demos/schrodinger.html +0 -27
  154. package/demos/schwarzschild.html +0 -27
  155. package/demos/shapes.html +0 -16
  156. package/demos/space.html +0 -85
  157. package/demos/spacetime.html +0 -27
  158. package/demos/sphere3d.html +0 -24
  159. package/demos/sprite.html +0 -18
  160. package/demos/svgtween.html +0 -29
  161. package/demos/tde.html +0 -28
  162. package/demos/tiles.html +0 -28
  163. package/demos/transforms.html +0 -400
  164. package/demos/tween.html +0 -45
  165. package/demos/visibility.html +0 -33
  166. package/docs/README.md +0 -230
  167. package/docs/api/FluidSystem.md +0 -173
  168. package/docs/concepts/architecture-overview.md +0 -204
  169. package/docs/concepts/coordinate-system.md +0 -384
  170. package/docs/concepts/lifecycle.md +0 -255
  171. package/docs/concepts/rendering-pipeline.md +0 -279
  172. package/docs/concepts/shapes-vs-gameobjects.md +0 -187
  173. package/docs/concepts/tde-zorder.md +0 -106
  174. package/docs/concepts/two-layer-architecture.md +0 -229
  175. package/docs/fluid-dynamics.md +0 -99
  176. package/docs/getting-started/first-game.md +0 -354
  177. package/docs/getting-started/hello-world.md +0 -269
  178. package/docs/getting-started/installation.md +0 -175
  179. package/docs/modules/collision/README.md +0 -453
  180. package/docs/modules/fluent/README.md +0 -1075
  181. package/docs/modules/game/README.md +0 -303
  182. package/docs/modules/isometric-camera.md +0 -210
  183. package/docs/modules/isometric.md +0 -275
  184. package/docs/modules/painter/README.md +0 -328
  185. package/docs/modules/particle/README.md +0 -559
  186. package/docs/modules/shapes/README.md +0 -221
  187. package/docs/modules/shapes/base/euclidian.md +0 -123
  188. package/docs/modules/shapes/base/geometry2d.md +0 -204
  189. package/docs/modules/shapes/base/renderable.md +0 -215
  190. package/docs/modules/shapes/base/shape.md +0 -262
  191. package/docs/modules/shapes/base/transformable.md +0 -243
  192. package/docs/modules/shapes/hierarchy.md +0 -218
  193. package/docs/modules/state/README.md +0 -577
  194. package/docs/modules/util/README.md +0 -99
  195. package/docs/modules/util/camera3d.md +0 -412
  196. package/docs/modules/util/scene3d.md +0 -395
  197. package/index.html +0 -17
  198. package/jsdoc.json +0 -50
  199. package/scripts/build-demo.js +0 -69
  200. package/scripts/bundle4llm.js +0 -276
  201. package/scripts/clearconsole.js +0 -48
  202. package/test/math/orbital.test.js +0 -61
  203. package/test/math/tensor.test.js +0 -114
  204. package/test/particle/emitter.test.js +0 -204
  205. package/test/particle/particle-system.test.js +0 -310
  206. package/test/particle/particle.test.js +0 -116
  207. package/test/particle/updaters.test.js +0 -386
  208. package/test/setup.js +0 -120
  209. package/test/shapes/euclidian.test.js +0 -44
  210. package/test/shapes/geometry.test.js +0 -86
  211. package/test/shapes/group.test.js +0 -86
  212. package/test/shapes/rectangle.test.js +0 -64
  213. package/test/shapes/transform.test.js +0 -379
  214. package/test/util/camera3d.test.js +0 -428
  215. package/test/util/scene3d.test.js +0 -352
  216. package/vite.config.js +0 -50
  217. package/vitest.config.js +0 -13
@@ -1,204 +0,0 @@
1
- import {
2
- Circle,
3
- Easing,
4
- FPSCounter,
5
- Game,
6
- GameObject,
7
- Motion,
8
- Painter,
9
- Scene,
10
- SVGShape,
11
- Tween,
12
- } from "../../src/index";
13
- class MyGame extends Game {
14
- constructor(canvas) {
15
- super(canvas);
16
- this.enableFluidSize();
17
- this.backgroundColor = "black";
18
- }
19
-
20
- init() {
21
- super.init();
22
- // Set up scenes
23
- console.groupCollapsed("init");
24
- this.scene = new Scene(this, { debug: true, debugColor: "#0f0", anchor: "center" });
25
- this.ui = new Scene(this, { debug: true, debugColor: "#0f0", anchor: "center" });
26
- this.pipeline.add(this.scene); // game layer
27
- this.pipeline.add(this.ui); // UI layer
28
- console.groupEnd();
29
- // Add SVG path animation
30
- console.groupCollapsed("add SVGPathAnimation");
31
- const svg = new SVGPathAnimation(this, {
32
- width: 210,
33
- height: 250,
34
- offsetX: -70,
35
- offsetY: -35,
36
- path: "M 0 30.276 L 0 9.358 L 0 0.845 L 17.139 0.845 L 17.139 -5.247 L 5.189 -5.247 L 5.189 -19.273 L 0 -19.273 L 0 -4.975 L 0 0.845 L -8.618 0.845 L -25.071 0.845 L -25.071 9.757 L -7.593 9.757 L -7.593 30.276 L 0 30.276 Z",
37
- });
38
- this.scene.add(svg);
39
- console.groupEnd();
40
- setTimeout(() => {
41
- console.groupCollapsed("add SVGPathAnimation");
42
- this.scene.add(
43
- new SVGPathAnimation(this, {
44
- width: 210,
45
- height: 250,
46
- offsetX: 70,
47
- offsetY: 35,
48
- path: "M -0.003 20.33 L -0.003 6.031 L -0.003 0.211 L 25.068 0.211 L 25.068 -8.702 L 7.59 -8.702 L 7.59 -29.22 L -0.003 -29.22 L -0.003 -8.303 L -0.003 0.211 L -17.141 0.211 L -17.141 6.304 L -5.194 6.304 L -5.194 20.33 L -0.003 20.33 Z",
49
- })
50
- );
51
- console.groupEnd();
52
- }, 200);
53
- // Add FPS counter in the UI scene
54
- console.groupCollapsed("add FPSCounter");
55
- this.ui.add(new FPSCounter(this, { anchor: "bottom-right" }));
56
- console.groupEnd();
57
- this.glow = Painter.effects.createGlow('rgba(0, 255, 0, 1)', 100, {
58
- pulseSpeed: 1,
59
- pulseMin: 0,
60
- pulseMax: 50,
61
- colorShift: 0.5
62
- });
63
- }
64
-
65
- update(dt) {
66
- this.scene.width = this.width - 20;
67
- this.scene.height = this.height - 20;
68
- this.glow.update({ pulseSpeed: 1 });
69
- super.update(dt);
70
- }
71
-
72
- render() {
73
- super.render();
74
- // Instructions text
75
- Painter.text.setFont("18px monospace");
76
- Painter.text.setTextAlign("center");
77
- Painter.text.setTextBaseline("bottom");
78
- Painter.text.fillText(
79
- "Click anywhere to restart the SVG path animation",
80
- this.width / 2,
81
- this.height - 100,
82
- "#0f0"
83
- );
84
- }
85
- }
86
-
87
- // SVG Path Animation - An animated SVG path drawing
88
- class SVGPathAnimation extends GameObject {
89
- constructor(game, options = {}) {
90
- super(game, options);
91
- // My Logo as an SVG
92
- //
93
- //
94
- this.offsetX = options.offsetX ?? 0;
95
- this.offsetY = options.offsetY ?? 0;
96
- this.animTime = 0;
97
- const path =
98
- options.path ??
99
- "M 50,10 L 50,40 L 20,40 L 20,60 L 50,60 L 50,90 L 70,90 L 70,60 L 100,60 L 100,40 L 70,40 L 70,10 Z";
100
- // Initialize state
101
- this.progress = 0;
102
- this.speed = 0.6; // Speed of animation
103
- this.complete = false;
104
- // Create SVG shape with initial 0 progress
105
- this.svgShape = new SVGShape(path, {
106
- stroke: "#0f0", // Green color
107
- lineWidth: 3,
108
- color: "rgba(0, 255, 0, 0.1)",
109
- scale: 5,
110
- animationProgress: 1,
111
- // debug:true,
112
- //debugColor:"yellow",
113
- x: options.offsetX ?? 0,
114
- y: options.offsetY ?? 0,
115
- width: 210,
116
- height: 250,
117
-
118
- });
119
- // Create a circle to represent the drawing point
120
- this.drawingPoint = new Circle(6, {
121
- x: 0,
122
- y: 0,
123
- color: "#fff",
124
- shadowColor: "rgba(0, 255, 0, 1)",
125
- shadowBlur: 15,
126
- shadowOffsetX: 0,
127
- shadowOffsetY: 0,
128
- });
129
- // Canvas click handler to restart animation
130
- game.canvas.addEventListener("click", () => this.restart());
131
- console.log("SVGPathAnimation", this.x, this.y);
132
- this.jittery = Math.random() * 0.2 + 0.2;
133
- }
134
-
135
- // Restart the animation
136
- restart() {
137
- this.progress = 0;
138
- this.complete = false;
139
- this.x = 0;
140
- this.y = 0;
141
- this.animTime = 0;
142
- this.jittery = Math.random() * 0.2 + 0.2;
143
- }
144
-
145
- update(dt) {
146
- //console.log(this.x, this.y);
147
- // Update progress if animation not complete
148
- if (!this.complete) {
149
- this.progress += dt * this.speed;
150
- if (this.progress >= 1) {
151
- this.progress = 1;
152
- this.complete = true;
153
- this.floatState = null;
154
- }
155
- // Apply easing for more natural movement
156
- const easedProgress = Easing.easeInOutQuad(this.progress);
157
- // Update SVG shape animation progress
158
- this.svgShape.setAnimationProgress(easedProgress);
159
- }
160
- let x = 0;
161
- let y = 0;
162
- // Add gentle bouncing motion when complete
163
- if (this.complete) {
164
- this.animTime = this.complete ? (this.animTime ?? 0) + (dt) : 0;
165
- const floatResult = Motion.float(
166
- {x:-5,y:-55},
167
- this.animTime, // elapsed time
168
- 1, // duration (seconds per full loop)
169
- 1, // speed multiplier
170
- this.jittery,
171
- 50, // radius
172
- true, // loop
173
- Easing.easeInOutSine, // optional easing
174
- {},
175
- this.floatState // persistent state
176
- );
177
-
178
- this.floatState = floatResult.state;
179
- x = floatResult.x;
180
- y = floatResult.y;
181
- this.drawingPoint.visible = false;
182
- } else {
183
- // Show the drawing point during animation
184
- this.drawingPoint.visible = true;
185
- // Update drawing point position to follow the current path position
186
- const currentPoint = this.svgShape.getCurrentPoint();
187
- this.drawingPoint.x = currentPoint.x + this.offsetX;
188
- this.drawingPoint.y = currentPoint.y + this.offsetY;
189
- }
190
- this.x = x;
191
- this.y = y;
192
- super.update(dt);
193
- }
194
-
195
- draw() {
196
- super.draw();
197
- // Draw SVG path
198
- this.svgShape.render();
199
- // Draw drawing point
200
- this.drawingPoint.render();
201
- }
202
- }
203
-
204
- export { MyGame };
@@ -1,471 +0,0 @@
1
- import { GameObject, Painter, Tweenetik, Easing } from "../../../src/index.js";
2
- import { keplerianOmega } from "../../../src/math/orbital.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
- }