@guinetik/gcanvas 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 (349) hide show
  1. package/.github/workflows/release.yaml +70 -0
  2. package/.jshintrc +4 -0
  3. package/.vscode/settings.json +22 -0
  4. package/CLAUDE.md +310 -0
  5. package/blackhole.jpg +0 -0
  6. package/demo.png +0 -0
  7. package/demos/CNAME +1 -0
  8. package/demos/animations.html +31 -0
  9. package/demos/basic.html +38 -0
  10. package/demos/baskara.html +31 -0
  11. package/demos/bezier.html +35 -0
  12. package/demos/beziersignature.html +29 -0
  13. package/demos/blackhole.html +28 -0
  14. package/demos/blob.html +35 -0
  15. package/demos/demos.css +289 -0
  16. package/demos/easing.html +28 -0
  17. package/demos/events.html +195 -0
  18. package/demos/fluent.html +647 -0
  19. package/demos/fractals.html +36 -0
  20. package/demos/genart.html +26 -0
  21. package/demos/gendream.html +26 -0
  22. package/demos/group.html +36 -0
  23. package/demos/home.html +587 -0
  24. package/demos/index.html +364 -0
  25. package/demos/isometric.html +34 -0
  26. package/demos/js/animations.js +452 -0
  27. package/demos/js/basic.js +204 -0
  28. package/demos/js/baskara.js +751 -0
  29. package/demos/js/bezier.js +692 -0
  30. package/demos/js/beziersignature.js +241 -0
  31. package/demos/js/blackhole/accretiondisk.obj.js +379 -0
  32. package/demos/js/blackhole/blackhole.obj.js +318 -0
  33. package/demos/js/blackhole/index.js +409 -0
  34. package/demos/js/blackhole/particle.js +56 -0
  35. package/demos/js/blackhole/starfield.obj.js +218 -0
  36. package/demos/js/blob.js +2263 -0
  37. package/demos/js/easing.js +477 -0
  38. package/demos/js/fluent.js +183 -0
  39. package/demos/js/fractals.js +931 -0
  40. package/demos/js/fractalworker.js +93 -0
  41. package/demos/js/genart.js +268 -0
  42. package/demos/js/gendream.js +209 -0
  43. package/demos/js/group.js +140 -0
  44. package/demos/js/info-toggle.js +25 -0
  45. package/demos/js/isometric.js +863 -0
  46. package/demos/js/kerr.js +1556 -0
  47. package/demos/js/lavalamp.js +590 -0
  48. package/demos/js/layout.js +354 -0
  49. package/demos/js/mondrian.js +285 -0
  50. package/demos/js/opacity.js +275 -0
  51. package/demos/js/painter.js +484 -0
  52. package/demos/js/particles-showcase.js +514 -0
  53. package/demos/js/particles.js +299 -0
  54. package/demos/js/patterns.js +397 -0
  55. package/demos/js/penrose/artifact.js +69 -0
  56. package/demos/js/penrose/blackhole.js +121 -0
  57. package/demos/js/penrose/constants.js +73 -0
  58. package/demos/js/penrose/game.js +943 -0
  59. package/demos/js/penrose/lore.js +278 -0
  60. package/demos/js/penrose/penrosescene.js +892 -0
  61. package/demos/js/penrose/ship.js +216 -0
  62. package/demos/js/penrose/sounds.js +211 -0
  63. package/demos/js/penrose/voidparticle.js +55 -0
  64. package/demos/js/penrose/voidscene.js +258 -0
  65. package/demos/js/penrose/voidship.js +144 -0
  66. package/demos/js/penrose/wormhole.js +46 -0
  67. package/demos/js/pipeline.js +555 -0
  68. package/demos/js/scene.js +304 -0
  69. package/demos/js/scenes.js +320 -0
  70. package/demos/js/schrodinger.js +410 -0
  71. package/demos/js/schwarzschild.js +1023 -0
  72. package/demos/js/shapes.js +628 -0
  73. package/demos/js/space/alien.js +171 -0
  74. package/demos/js/space/boom.js +98 -0
  75. package/demos/js/space/boss.js +353 -0
  76. package/demos/js/space/buff.js +73 -0
  77. package/demos/js/space/bullet.js +102 -0
  78. package/demos/js/space/constants.js +85 -0
  79. package/demos/js/space/game.js +1884 -0
  80. package/demos/js/space/hud.js +112 -0
  81. package/demos/js/space/laserbeam.js +179 -0
  82. package/demos/js/space/lightning.js +277 -0
  83. package/demos/js/space/minion.js +192 -0
  84. package/demos/js/space/missile.js +212 -0
  85. package/demos/js/space/player.js +430 -0
  86. package/demos/js/space/powerup.js +90 -0
  87. package/demos/js/space/starfield.js +58 -0
  88. package/demos/js/space/starpower.js +90 -0
  89. package/demos/js/spacetime.js +559 -0
  90. package/demos/js/svgtween.js +204 -0
  91. package/demos/js/tde/accretiondisk.js +418 -0
  92. package/demos/js/tde/blackhole.js +219 -0
  93. package/demos/js/tde/blackholescene.js +209 -0
  94. package/demos/js/tde/config.js +59 -0
  95. package/demos/js/tde/index.js +695 -0
  96. package/demos/js/tde/jets.js +290 -0
  97. package/demos/js/tde/lensedstarfield.js +147 -0
  98. package/demos/js/tde/tdestar.js +317 -0
  99. package/demos/js/tde/tidalstream.js +356 -0
  100. package/demos/js/tde_old/blackhole.obj.js +354 -0
  101. package/demos/js/tde_old/debris.obj.js +791 -0
  102. package/demos/js/tde_old/flare.obj.js +239 -0
  103. package/demos/js/tde_old/index.js +448 -0
  104. package/demos/js/tde_old/star.obj.js +812 -0
  105. package/demos/js/tiles.js +312 -0
  106. package/demos/js/tweendemo.js +79 -0
  107. package/demos/js/visibility.js +102 -0
  108. package/demos/kerr.html +28 -0
  109. package/demos/lavalamp.html +27 -0
  110. package/demos/layouts.html +37 -0
  111. package/demos/logo.svg +4 -0
  112. package/demos/loop.html +84 -0
  113. package/demos/mondrian.html +32 -0
  114. package/demos/og_image.png +0 -0
  115. package/demos/opacity.html +36 -0
  116. package/demos/painter.html +39 -0
  117. package/demos/particles-showcase.html +28 -0
  118. package/demos/particles.html +24 -0
  119. package/demos/patterns.html +33 -0
  120. package/demos/penrose-game.html +31 -0
  121. package/demos/pipeline.html +737 -0
  122. package/demos/scene.html +33 -0
  123. package/demos/scenes.html +96 -0
  124. package/demos/schrodinger.html +27 -0
  125. package/demos/schwarzschild.html +27 -0
  126. package/demos/shapes.html +16 -0
  127. package/demos/space.html +85 -0
  128. package/demos/spacetime.html +27 -0
  129. package/demos/svgtween.html +29 -0
  130. package/demos/tde.html +28 -0
  131. package/demos/tiles.html +28 -0
  132. package/demos/transforms.html +400 -0
  133. package/demos/tween.html +45 -0
  134. package/demos/visibility.html +33 -0
  135. package/disk_example.png +0 -0
  136. package/docs/README.md +222 -0
  137. package/docs/concepts/architecture-overview.md +204 -0
  138. package/docs/concepts/lifecycle.md +255 -0
  139. package/docs/concepts/rendering-pipeline.md +279 -0
  140. package/docs/concepts/tde-zorder.md +106 -0
  141. package/docs/concepts/two-layer-architecture.md +229 -0
  142. package/docs/getting-started/first-game.md +354 -0
  143. package/docs/getting-started/hello-world.md +269 -0
  144. package/docs/getting-started/installation.md +157 -0
  145. package/docs/modules/collision/README.md +453 -0
  146. package/docs/modules/fluent/README.md +1075 -0
  147. package/docs/modules/game/README.md +303 -0
  148. package/docs/modules/isometric-camera.md +210 -0
  149. package/docs/modules/isometric.md +275 -0
  150. package/docs/modules/painter/README.md +328 -0
  151. package/docs/modules/particle/README.md +559 -0
  152. package/docs/modules/shapes/README.md +221 -0
  153. package/docs/modules/shapes/base/euclidian.md +123 -0
  154. package/docs/modules/shapes/base/geometry2d.md +204 -0
  155. package/docs/modules/shapes/base/renderable.md +215 -0
  156. package/docs/modules/shapes/base/shape.md +262 -0
  157. package/docs/modules/shapes/base/transformable.md +243 -0
  158. package/docs/modules/shapes/hierarchy.md +218 -0
  159. package/docs/modules/state/README.md +577 -0
  160. package/docs/modules/util/README.md +99 -0
  161. package/docs/modules/util/camera3d.md +412 -0
  162. package/docs/modules/util/scene3d.md +395 -0
  163. package/index.html +17 -0
  164. package/jsdoc.json +50 -0
  165. package/package.json +55 -0
  166. package/readme.md +599 -0
  167. package/scripts/build-demo.js +69 -0
  168. package/scripts/bundle4llm.js +276 -0
  169. package/scripts/clearconsole.js +48 -0
  170. package/src/collision/collision-system.js +332 -0
  171. package/src/collision/collision.js +303 -0
  172. package/src/collision/index.js +10 -0
  173. package/src/fluent/fluent-game.js +430 -0
  174. package/src/fluent/fluent-go.js +1060 -0
  175. package/src/fluent/fluent-layer.js +152 -0
  176. package/src/fluent/fluent-scene.js +291 -0
  177. package/src/fluent/index.js +98 -0
  178. package/src/fluent/sketch.js +380 -0
  179. package/src/game/game.js +467 -0
  180. package/src/game/index.js +49 -0
  181. package/src/game/objects/go.js +220 -0
  182. package/src/game/objects/imagego.js +30 -0
  183. package/src/game/objects/index.js +54 -0
  184. package/src/game/objects/isometric-scene.js +260 -0
  185. package/src/game/objects/layoutscene.js +549 -0
  186. package/src/game/objects/scene.js +175 -0
  187. package/src/game/objects/scene3d.js +118 -0
  188. package/src/game/objects/text.js +221 -0
  189. package/src/game/objects/wrapper.js +232 -0
  190. package/src/game/pipeline.js +243 -0
  191. package/src/game/ui/button.js +396 -0
  192. package/src/game/ui/cursor.js +93 -0
  193. package/src/game/ui/fps.js +91 -0
  194. package/src/game/ui/index.js +5 -0
  195. package/src/game/ui/togglebutton.js +93 -0
  196. package/src/game/ui/tooltip.js +249 -0
  197. package/src/index.js +25 -0
  198. package/src/io/events.js +20 -0
  199. package/src/io/index.js +86 -0
  200. package/src/io/input.js +70 -0
  201. package/src/io/keys.js +152 -0
  202. package/src/io/mouse.js +61 -0
  203. package/src/io/touch.js +39 -0
  204. package/src/logger/debugtab.js +138 -0
  205. package/src/logger/index.js +3 -0
  206. package/src/logger/loggable.js +47 -0
  207. package/src/logger/logger.js +113 -0
  208. package/src/math/complex.js +37 -0
  209. package/src/math/constants.js +1 -0
  210. package/src/math/fractal.js +1271 -0
  211. package/src/math/gr.js +201 -0
  212. package/src/math/heat.js +202 -0
  213. package/src/math/index.js +12 -0
  214. package/src/math/noise.js +433 -0
  215. package/src/math/orbital.js +191 -0
  216. package/src/math/patterns.js +1339 -0
  217. package/src/math/penrose.js +259 -0
  218. package/src/math/quantum.js +115 -0
  219. package/src/math/random.js +195 -0
  220. package/src/math/tensor.js +1009 -0
  221. package/src/mixins/anchor.js +131 -0
  222. package/src/mixins/draggable.js +72 -0
  223. package/src/mixins/index.js +2 -0
  224. package/src/motion/bezier.js +132 -0
  225. package/src/motion/bounce.js +58 -0
  226. package/src/motion/easing.js +349 -0
  227. package/src/motion/float.js +130 -0
  228. package/src/motion/follow.js +125 -0
  229. package/src/motion/hop.js +52 -0
  230. package/src/motion/index.js +82 -0
  231. package/src/motion/motion.js +1124 -0
  232. package/src/motion/orbit.js +49 -0
  233. package/src/motion/oscillate.js +39 -0
  234. package/src/motion/parabolic.js +141 -0
  235. package/src/motion/patrol.js +147 -0
  236. package/src/motion/pendulum.js +48 -0
  237. package/src/motion/pulse.js +88 -0
  238. package/src/motion/shake.js +83 -0
  239. package/src/motion/spiral.js +144 -0
  240. package/src/motion/spring.js +150 -0
  241. package/src/motion/swing.js +47 -0
  242. package/src/motion/tween.js +92 -0
  243. package/src/motion/tweenetik.js +139 -0
  244. package/src/motion/waypoint.js +210 -0
  245. package/src/painter/index.js +8 -0
  246. package/src/painter/painter.colors.js +331 -0
  247. package/src/painter/painter.effects.js +230 -0
  248. package/src/painter/painter.img.js +229 -0
  249. package/src/painter/painter.js +295 -0
  250. package/src/painter/painter.lines.js +189 -0
  251. package/src/painter/painter.opacity.js +41 -0
  252. package/src/painter/painter.shapes.js +277 -0
  253. package/src/painter/painter.text.js +273 -0
  254. package/src/particle/emitter.js +124 -0
  255. package/src/particle/index.js +11 -0
  256. package/src/particle/particle-system.js +322 -0
  257. package/src/particle/particle.js +71 -0
  258. package/src/particle/updaters.js +170 -0
  259. package/src/shapes/arc.js +43 -0
  260. package/src/shapes/arrow.js +33 -0
  261. package/src/shapes/bezier.js +42 -0
  262. package/src/shapes/circle.js +62 -0
  263. package/src/shapes/clouds.js +56 -0
  264. package/src/shapes/cone.js +219 -0
  265. package/src/shapes/cross.js +70 -0
  266. package/src/shapes/cube.js +244 -0
  267. package/src/shapes/cylinder.js +254 -0
  268. package/src/shapes/diamond.js +48 -0
  269. package/src/shapes/euclidian.js +111 -0
  270. package/src/shapes/figure.js +115 -0
  271. package/src/shapes/geometry.js +220 -0
  272. package/src/shapes/group.js +375 -0
  273. package/src/shapes/heart.js +42 -0
  274. package/src/shapes/hexagon.js +26 -0
  275. package/src/shapes/image.js +192 -0
  276. package/src/shapes/index.js +111 -0
  277. package/src/shapes/line.js +29 -0
  278. package/src/shapes/pattern.js +90 -0
  279. package/src/shapes/pin.js +44 -0
  280. package/src/shapes/poly.js +31 -0
  281. package/src/shapes/prism.js +226 -0
  282. package/src/shapes/rect.js +35 -0
  283. package/src/shapes/renderable.js +333 -0
  284. package/src/shapes/ring.js +26 -0
  285. package/src/shapes/roundrect.js +95 -0
  286. package/src/shapes/shape.js +117 -0
  287. package/src/shapes/slice.js +26 -0
  288. package/src/shapes/sphere.js +314 -0
  289. package/src/shapes/sphere3d.js +537 -0
  290. package/src/shapes/square.js +15 -0
  291. package/src/shapes/star.js +99 -0
  292. package/src/shapes/svg.js +408 -0
  293. package/src/shapes/text.js +553 -0
  294. package/src/shapes/traceable.js +83 -0
  295. package/src/shapes/transform.js +357 -0
  296. package/src/shapes/transformable.js +172 -0
  297. package/src/shapes/triangle.js +26 -0
  298. package/src/sound/index.js +17 -0
  299. package/src/sound/sound.js +473 -0
  300. package/src/sound/synth.analyzer.js +149 -0
  301. package/src/sound/synth.effects.js +207 -0
  302. package/src/sound/synth.envelope.js +59 -0
  303. package/src/sound/synth.js +229 -0
  304. package/src/sound/synth.musical.js +160 -0
  305. package/src/sound/synth.noise.js +85 -0
  306. package/src/sound/synth.oscillators.js +293 -0
  307. package/src/state/index.js +10 -0
  308. package/src/state/state-machine.js +371 -0
  309. package/src/util/camera3d.js +438 -0
  310. package/src/util/index.js +6 -0
  311. package/src/util/isometric-camera.js +235 -0
  312. package/src/util/layout.js +317 -0
  313. package/src/util/position.js +147 -0
  314. package/src/util/tasks.js +47 -0
  315. package/src/util/zindex.js +287 -0
  316. package/src/webgl/index.js +9 -0
  317. package/src/webgl/shaders/sphere-shaders.js +994 -0
  318. package/src/webgl/webgl-renderer.js +388 -0
  319. package/tde.png +0 -0
  320. package/test/math/orbital.test.js +61 -0
  321. package/test/math/tensor.test.js +114 -0
  322. package/test/particle/emitter.test.js +204 -0
  323. package/test/particle/particle-system.test.js +310 -0
  324. package/test/particle/particle.test.js +116 -0
  325. package/test/particle/updaters.test.js +386 -0
  326. package/test/setup.js +120 -0
  327. package/test/shapes/euclidian.test.js +44 -0
  328. package/test/shapes/geometry.test.js +86 -0
  329. package/test/shapes/group.test.js +86 -0
  330. package/test/shapes/rectangle.test.js +64 -0
  331. package/test/shapes/transform.test.js +379 -0
  332. package/test/util/camera3d.test.js +428 -0
  333. package/test/util/scene3d.test.js +352 -0
  334. package/types/collision.d.ts +249 -0
  335. package/types/common.d.ts +155 -0
  336. package/types/game.d.ts +497 -0
  337. package/types/index.d.ts +309 -0
  338. package/types/io.d.ts +188 -0
  339. package/types/logger.d.ts +127 -0
  340. package/types/math.d.ts +268 -0
  341. package/types/mixins.d.ts +92 -0
  342. package/types/motion.d.ts +678 -0
  343. package/types/painter.d.ts +378 -0
  344. package/types/shapes.d.ts +864 -0
  345. package/types/sound.d.ts +672 -0
  346. package/types/state.d.ts +251 -0
  347. package/types/util.d.ts +253 -0
  348. package/vite.config.js +50 -0
  349. package/vitest.config.js +13 -0
@@ -0,0 +1,791 @@
1
+ /**
2
+ * DebrisManager - Manages debris streams for TDE demo
3
+ *
4
+ * Uses ParticleSystem with gravitational attraction toward the black hole.
5
+ * Handles tidal shear, disk formation with lensing, and accretion tracking.
6
+ */
7
+ import { GameObject, Easing, Painter } from "../../../src/index.js";
8
+
9
+ const CONFIG = {
10
+ // Physics - VISIBLE gravity for dramatic streaming
11
+ gravityStrength: 1000, // Reduced so particles are visible longer
12
+ damping: 0.992,
13
+ tidalShearStrength: 0.6,
14
+
15
+ // Disk formation - WIDER disk to match blackhole demo
16
+ diskInnerRatio: 1.6, // Inner disk radius as multiple of bhRadius
17
+ diskOuterRatio: 6.0, // Outer disk radius - MUCH WIDER for dense disk
18
+ circularizationRate: 0.084,
19
+ diskFlattenRate: 0.063,
20
+
21
+ // Accretion - particles must reach center to be consumed
22
+ accretionRadius: 0.24453, // Smaller so particles spiral longer
23
+ fallInRate: 0.22,
24
+
25
+ // Particle lifetime - LONG for stable disk
26
+ maxLifetime: 300, // 5 minutes - disk particles should persist
27
+
28
+ // Lensing
29
+ lensingStrength: 1.4,
30
+
31
+ // CYCLONE SPIRAL
32
+ spiralTurnsMin: 1.0,
33
+ spiralTurnsMax: 8.0,
34
+ spiralSpeedBase: 400,
35
+
36
+ // Falling particle speed
37
+ fallingSpiralRate: 0.08,
38
+ diskSpiralRate: 0.015,
39
+
40
+ // Colors - temperature gradient
41
+ colors: {
42
+ inner: [255, 240, 200], // Brighter white-yellow core
43
+ mid: [255, 140, 50], // Vibrant orange
44
+ outer: [200, 40, 10], // Deep red/crimson
45
+ },
46
+
47
+ // Max debris particles
48
+ maxDebris: 15000,
49
+ };
50
+
51
+ export class DebrisManager extends GameObject {
52
+ /**
53
+ * @param {Game} game - Game instance
54
+ * @param {Object} options
55
+ * @param {Camera3D} options.camera - Camera for projection
56
+ * @param {number} options.bhRadius - Black hole radius
57
+ * @param {number} options.baseScale - Base scale
58
+ */
59
+ constructor(game, options = {}) {
60
+ super(game, options);
61
+
62
+ this.camera = options.camera;
63
+ this.bhRadius = options.bhRadius ?? 50;
64
+ this.baseScale = options.baseScale ?? 500;
65
+
66
+ // Black hole position (attraction target)
67
+ this.bhPosition = { x: 0, y: 0, z: 0 };
68
+
69
+ // Disk sizing
70
+ this.diskInner = this.bhRadius * CONFIG.diskInnerRatio;
71
+ this.diskOuter = this.bhRadius * CONFIG.diskOuterRatio;
72
+
73
+ // Particle system
74
+ this.particleSystem = null;
75
+
76
+ // Debris particles (manual tracking for lensing)
77
+ this.debris = [];
78
+
79
+ // Accretion tracking
80
+ this.particlesAccreted = 0;
81
+ this.accretionRate = 0;
82
+ this.lastAccretionCount = 0;
83
+
84
+ // Lensing strength (increases as disk forms)
85
+ this.lensingAmount = 0;
86
+ }
87
+
88
+ /**
89
+ * Initialize the debris manager.
90
+ */
91
+ init() {
92
+ // We'll manually manage debris instead of using ParticleSystem
93
+ // This gives us control over lensing projection
94
+ this.debris = [];
95
+ this.diskParticles = []; // Permanent accretion disk (like blackhole demo)
96
+ this.diskFormed = false;
97
+ }
98
+
99
+ /**
100
+ * Create a permanent accretion disk (like the blackhole demo).
101
+ * Called early to ensure there's always a visible disk.
102
+ */
103
+ createAccretionDisk(particleCount = 2000) {
104
+ if (this.diskFormed) return;
105
+ this.diskFormed = true;
106
+ this.addToDisk(particleCount);
107
+ }
108
+
109
+ /**
110
+ * Add more particles to the accretion disk.
111
+ */
112
+ addToDisk(particleCount) {
113
+ for (let i = 0; i < particleCount; i++) {
114
+ const angle = Math.random() * Math.PI * 2;
115
+ const t = Math.random();
116
+ // Bias toward inner (hotter) region like blackhole demo
117
+ const r = this.diskInner + t * t * (this.diskOuter - this.diskInner);
118
+
119
+ // Keplerian orbital speed
120
+ const speed = (1 / Math.sqrt(r / this.bhRadius)) * 2.0;
121
+ // FLAT disk - very small y offset
122
+ const yOffset = (Math.random() - 0.5) * this.bhRadius * 0.05;
123
+
124
+ this.diskParticles.push({
125
+ angle: angle,
126
+ distance: r,
127
+ yOffset: yOffset,
128
+ speed: speed,
129
+ baseColor: this.getHeatColor(r),
130
+ size: 1.5 + Math.random() * 2.5,
131
+ isFalling: false,
132
+ });
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Get temperature-based color for distance from black hole.
138
+ * Outer particles FADE TO TRANSPARENT - no hard edge.
139
+ */
140
+ getHeatColor(dist) {
141
+ const t = (dist - this.diskInner) / (this.diskOuter - this.diskInner);
142
+ const clampedT = Math.max(0, Math.min(1, t));
143
+
144
+ let c1, c2, mix;
145
+ if (clampedT < 0.3) {
146
+ c1 = CONFIG.colors.inner;
147
+ c2 = CONFIG.colors.mid;
148
+ mix = clampedT / 0.3;
149
+ } else {
150
+ c1 = CONFIG.colors.mid;
151
+ c2 = CONFIG.colors.outer;
152
+ mix = (clampedT - 0.3) / 0.7;
153
+ }
154
+
155
+ // Alpha FADES at outer edge - but stays visible longer
156
+ // Inner: fully opaque, Outer: still visible (0.2 minimum)
157
+ const alpha = 0.2 + Math.pow(1 - clampedT, 1.2) * 0.7;
158
+
159
+ return {
160
+ r: c1[0] + (c2[0] - c1[0]) * mix,
161
+ g: c1[1] + (c2[1] - c1[1]) * mix,
162
+ b: c1[2] + (c2[2] - c1[2]) * mix,
163
+ a: alpha,
164
+ };
165
+ }
166
+
167
+ /**
168
+ * Update sizing.
169
+ */
170
+ updateSizing(bhRadius, baseScale) {
171
+ this.bhRadius = bhRadius;
172
+ this.baseScale = baseScale;
173
+ this.diskInner = bhRadius * CONFIG.diskInnerRatio;
174
+ this.diskOuter = bhRadius * CONFIG.diskOuterRatio;
175
+ }
176
+
177
+ /**
178
+ * Add debris particles from the disrupted star.
179
+ * Particles flow FROM star TOWARD BH center - either into disk or consumed.
180
+ */
181
+ addDebris(debrisData) {
182
+ for (const d of debrisData) {
183
+ // Limit total debris
184
+ if (this.debris.length >= CONFIG.maxDebris) break;
185
+
186
+ // Distance and angle from BH center (origin)
187
+ const dist = Math.sqrt(d.x * d.x + d.z * d.z);
188
+ const angle = Math.atan2(d.z, d.x);
189
+
190
+ // Determine fate: accretion disk or fall into BH
191
+ // MOST particles should form the disk - only small fraction falls in
192
+ const normalizedDist = Math.min(1, dist / (this.bhRadius * 8));
193
+ // 15% of close particles fall in, 5% of far particles - keeps disk DENSE
194
+ const fallInChance = 0.15 - normalizedDist * 0.1;
195
+ const willFallIn = Math.random() < fallInChance;
196
+
197
+ // Target orbit radius - biased toward inner disk
198
+ const randFactor = Math.pow(Math.random(), 1.5); // Bias toward inner
199
+ const targetDist = willFallIn
200
+ ? 0
201
+ : this.diskInner + randFactor * (this.diskOuter - this.diskInner) * 0.6;
202
+
203
+ // Spiral toward BH - all particles spiral inward
204
+ const spiralTurns =
205
+ CONFIG.spiralTurnsMin +
206
+ Math.random() * (CONFIG.spiralTurnsMax - CONFIG.spiralTurnsMin);
207
+
208
+ // Target angle - continue in spiral direction toward BH
209
+ const targetAngle = angle + spiralTurns * Math.PI * 2;
210
+
211
+ // Initial velocities from star stream
212
+ const vx = d.vx || 0;
213
+ const vy = d.vy || 0;
214
+ const vz = d.vz || 0;
215
+
216
+ // FLAT DISK like the original blackhole demo
217
+ const yVariation = this.baseScale * 0.006;
218
+
219
+ this.debris.push({
220
+ // Current state
221
+ x: d.x,
222
+ y: d.y,
223
+ z: d.z,
224
+ vx: vx,
225
+ vy: vy,
226
+ vz: vz,
227
+
228
+ // SPIRAL trajectory state (for disk formation logic)
229
+ startAngle: angle,
230
+ startDistance: dist,
231
+ startYOffset: d.y,
232
+
233
+ // Current polar state (animated via physics)
234
+ angle: angle,
235
+ distance: dist,
236
+ yOffset: d.y,
237
+
238
+ // Target state - THICK disk with vertical variation
239
+ targetAngle: targetAngle,
240
+ targetDistance: targetDist,
241
+ targetYOffset: (Math.random() - 0.5) * yVariation,
242
+
243
+ // SPIRAL PARAMS
244
+ spiralTurns: spiralTurns,
245
+ spiralProgress: 0, // Start at zero - no jump
246
+
247
+ // Behavior flags
248
+ willFallIn: willFallIn,
249
+ // Treat ALL incoming debris as dynamic "falling" physics initially
250
+ // They will transition to disk orbit later if not consumed
251
+ isFalling: true,
252
+
253
+ // Appearance - slight size variation
254
+ size: d.size * (0.6 + Math.random() * 0.6),
255
+ baseColor: this.getHeatColor(dist),
256
+
257
+ // State
258
+ age: 0,
259
+ consumed: false,
260
+ circularized: false,
261
+ });
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Clear all debris (but keep permanent disk if formed).
267
+ */
268
+ clear() {
269
+ this.debris = [];
270
+ this.particlesAccreted = 0;
271
+ this.accretionRate = 0;
272
+ this.lastAccretionCount = 0;
273
+ this.lensingAmount = 0;
274
+ // Reset disk for new simulation
275
+ this.diskParticles = [];
276
+ this.diskFormed = false;
277
+ }
278
+
279
+ /**
280
+ * Get current accretion rate.
281
+ */
282
+ getAccretionRate() {
283
+ return this.accretionRate;
284
+ }
285
+
286
+ /**
287
+ * Set lensing amount (0-1).
288
+ */
289
+ setLensingAmount(amount) {
290
+ this.lensingAmount = Math.max(0, Math.min(1, amount));
291
+ }
292
+
293
+ update(dt) {
294
+ super.update(dt);
295
+
296
+ const accretionDist = this.bhRadius * CONFIG.accretionRadius;
297
+ let newAccretions = 0;
298
+
299
+ // Update each debris particle
300
+ for (let i = this.debris.length - 1; i >= 0; i--) {
301
+ const p = this.debris[i];
302
+ if (p.consumed) continue;
303
+
304
+ p.age += dt;
305
+
306
+ if (p.isFalling) {
307
+ // --- NEW PHYSICS: ORBITAL MECHANICS ---
308
+
309
+ // 1. Gravity (Newtonian approx)
310
+ const distSq = p.x * p.x + p.y * p.y + p.z * p.z;
311
+ const dist = Math.sqrt(distSq);
312
+
313
+ // Gravity force = G * M / r^2
314
+ // We can tune strength to match visual scale
315
+ const gravityAccel = CONFIG.gravityStrength / Math.max(distSq, 100);
316
+
317
+ // Direction to center
318
+ const dirX = -p.x / dist;
319
+ const dirY = -p.y / dist;
320
+ const dirZ = -p.z / dist;
321
+
322
+ // Apply Gravity
323
+ p.vx = (p.vx || 0) + dirX * gravityAccel * dt;
324
+ p.vy = (p.vy || 0) + dirY * gravityAccel * dt;
325
+ p.vz = (p.vz || 0) + dirZ * gravityAccel * dt;
326
+
327
+ // 2. Drag / Circularization / Accretion Force
328
+ // Gently nudge velocity towards a circular orbit to form disk
329
+ // Target velocity for circular orbit: sqrt(GM/r)
330
+ // Tangent direction: cross product of vertical axis (0,1,0) and radius
331
+
332
+ // But for "S" shape, we primarily just want gravity to do the work first.
333
+ // We add a small "drag" to prevent them from flying off forever if they are too fast.
334
+
335
+ const speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy + p.vz * p.vz);
336
+ if (speed > 200) {
337
+ // Drag if moving too fast
338
+ p.vx *= 0.99;
339
+ p.vy *= 0.99;
340
+ p.vz *= 0.99;
341
+ }
342
+
343
+ // 3. Move
344
+ p.x += p.vx * dt;
345
+ p.y += p.vy * dt;
346
+ p.z += p.vz * dt;
347
+
348
+ // Update derived polar coords for rendering logic
349
+ p.distance = Math.sqrt(p.x * p.x + p.z * p.z);
350
+ p.angle = Math.atan2(p.z, p.x);
351
+
352
+ // Check accretion
353
+ if (dist < accretionDist) {
354
+ p.consumed = true;
355
+ newAccretions++;
356
+ }
357
+
358
+ // 4. Force Capture (Prevent flying off to infinity)
359
+ // If particle gets too far, nudge it back strongly
360
+ if (dist > this.bhRadius * 15) {
361
+ const pullBack = 2.0;
362
+ p.vx += dirX * pullBack * dt;
363
+ p.vy += dirY * pullBack * dt;
364
+ p.vz += dirZ * pullBack * dt;
365
+ }
366
+
367
+ } else {
368
+ // Disk-forming particles - ALWAYS move inward first, then settle
369
+ p.spiralProgress = Math.min(1, p.spiralProgress + 0.05);
370
+
371
+ // ALWAYS apply inward pull - particles MUST move toward BH
372
+ p.distance -= p.inwardVel * dt * 0.4;
373
+
374
+ // Gravity pull toward center
375
+ const gravityAccel =
376
+ (CONFIG.gravityStrength * dt * 0.2) / Math.max(p.distance, 50);
377
+ p.inwardVel = Math.min(100, p.inwardVel + gravityAccel);
378
+
379
+ // Once close to target orbit, start settling into circular orbit
380
+ if (p.distance <= p.targetDistance * 1.3) {
381
+ const settleRate = 0.05;
382
+ p.distance = Easing.lerp(p.distance, p.targetDistance, settleRate);
383
+ p.circularized = p.distance < p.targetDistance * 1.1;
384
+ }
385
+
386
+ // Clamp minimum distance - don't go inside inner disk
387
+ p.distance = Math.max(this.diskInner * 0.5, p.distance);
388
+
389
+ // Angular motion - rotate around BH
390
+ const angularSpeed =
391
+ 1.8 / Math.sqrt(Math.max(1, p.distance / this.bhRadius));
392
+ p.angle += angularSpeed * dt;
393
+
394
+ // Flatten to disk plane
395
+ const flattenT = Math.min(1, p.age * 0.5);
396
+ p.yOffset = Easing.lerp(p.startYOffset, p.targetYOffset, flattenT);
397
+
398
+ // Once circularized, maintain stable Keplerian orbit
399
+ if (p.circularized) {
400
+ // Stable orbital motion - particles stay in orbit
401
+ const orbitalSpeed =
402
+ (1 / Math.sqrt(Math.max(1, p.distance / this.bhRadius))) * 2.0;
403
+ p.angle += orbitalSpeed * dt;
404
+
405
+ // Small wobble to keep disk flat but not perfectly static
406
+ const wobble =
407
+ Math.sin(p.angle * 3 + p.startAngle) * this.baseScale * 0.003;
408
+ p.yOffset = p.targetYOffset + wobble;
409
+
410
+ // VERY rarely, inner particles fall in (keeps disk dynamic but dense)
411
+ if (p.distance < this.diskInner * 1.1 && Math.random() < 0.00001) {
412
+ p.isFalling = true;
413
+ // Initialize velocity for falling physics
414
+ // Tangent velocity + slight inward kick
415
+ const tangentX = -Math.sin(p.angle);
416
+ const tangentZ = Math.cos(p.angle);
417
+ const orbSpeed = orbitalSpeed * p.distance; // rad/s * dist = units/s
418
+
419
+ p.vx = tangentX * orbSpeed * 0.9; // 0.9 to start spiral
420
+ p.vz = tangentZ * orbSpeed * 0.9;
421
+ p.vy = 0;
422
+
423
+ // Explicitly set positions from polar
424
+ p.x = Math.cos(p.angle) * p.distance;
425
+ p.z = Math.sin(p.angle) * p.distance;
426
+ p.y = p.yOffset;
427
+ }
428
+ }
429
+
430
+ // Convert stray particles that haven't settled
431
+ if (p.age > 5 && p.distance > this.diskOuter) {
432
+ p.isFalling = true;
433
+ p.vx = (Math.random()-0.5) * 10;
434
+ p.vz = (Math.random()-0.5) * 10;
435
+ p.vy = 0;
436
+ }
437
+ }
438
+
439
+ // Update Cartesian coordinates from polar (ONLY FOR DISK PARTICLES)
440
+ // Falling particles update Cartesian directly in physics block above
441
+ if (!p.isFalling) {
442
+ p.x = Math.cos(p.angle) * p.distance;
443
+ p.z = Math.sin(p.angle) * p.distance;
444
+ p.y = p.yOffset;
445
+ }
446
+
447
+ // Update color based on current distance
448
+ // For falling particles, use full distance (3D)
449
+ const colorDist = p.isFalling ? Math.sqrt(p.x*p.x + p.z*p.z) : p.distance;
450
+ p.baseColor = this.getHeatColor(colorDist);
451
+ }
452
+
453
+ // Remove consumed particles and expired particles
454
+ this.debris = this.debris.filter(
455
+ (p) => !p.consumed && p.age < CONFIG.maxLifetime,
456
+ );
457
+
458
+ this.particlesAccreted += newAccretions;
459
+ this.accretionRate = newAccretions / (dt || 1);
460
+
461
+ // Update permanent accretion disk particles (stable Keplerian orbits)
462
+ for (const p of this.diskParticles) {
463
+ if (p.isFalling) {
464
+ // Falling particles spiral in
465
+ p.distance *= 0.99;
466
+ p.angle += p.speed * dt * 1.5;
467
+ p.yOffset *= 0.95;
468
+
469
+ if (p.distance < accretionDist) {
470
+ p.consumed = true;
471
+ }
472
+ } else {
473
+ // Stable Keplerian orbit (like blackhole demo)
474
+ p.angle += p.speed * dt;
475
+
476
+ // Very rarely, inner particles fall in
477
+ if (p.distance < this.diskInner * 1.3 && Math.random() < 0.0001) {
478
+ p.isFalling = true;
479
+ }
480
+ }
481
+ }
482
+
483
+ // Remove consumed disk particles
484
+ this.diskParticles = this.diskParticles.filter((p) => !p.consumed);
485
+
486
+ // Increase lensing as disk forms
487
+ const circularizedCount = this.debris.filter((p) => p.circularized).length;
488
+ const targetLensing =
489
+ this.debris.length > 0
490
+ ? Math.min(1, circularizedCount / (this.debris.length * 0.5))
491
+ : 0;
492
+ this.lensingAmount = Easing.lerp(this.lensingAmount, targetLensing, 0.02);
493
+ }
494
+
495
+ /**
496
+ * Build render list with lensing applied.
497
+ * Uses same simple lensing as the blackhole demo - just warps particles outward.
498
+ */
499
+ buildRenderList() {
500
+ const renderList = [];
501
+ // Use full lensing once disk has some particles
502
+ const lensingStrength =
503
+ this.debris.length > 50 ? CONFIG.lensingStrength : 0;
504
+
505
+ for (const p of this.debris) {
506
+ if (p.consumed) continue;
507
+
508
+ // Transform to camera space
509
+ const cosY = Math.cos(this.camera.rotationY);
510
+ const sinY = Math.sin(this.camera.rotationY);
511
+ let xCam = p.x * cosY - p.z * sinY;
512
+ let zCam = p.x * sinY + p.z * cosY;
513
+
514
+ const cosX = Math.cos(this.camera.rotationX);
515
+ const sinX = Math.sin(this.camera.rotationX);
516
+ let yCam = p.y * cosX - zCam * sinX;
517
+ zCam = p.y * sinX + zCam * cosX;
518
+
519
+ // Apply gravitational lensing
520
+ if (lensingStrength > 0) {
521
+ let currentR = Math.sqrt(xCam * xCam + yCam * yCam);
522
+ const ringRadius = this.bhRadius * 1.5; // Einstein ring approx
523
+
524
+ if (zCam > 0) {
525
+ // Behind BH - The "Interstellar" Halo Effect
526
+ // Light from behind is bent around the BH.
527
+ // We see the light that traveled "up" and bent down to us.
528
+ // So we shift the image UP (negative Y) towards the Einstein ring.
529
+
530
+ // 1. Radial expansion (standard lensing)
531
+ const lensFactor = Math.exp(-currentR / (this.bhRadius * 2.0));
532
+ const warp = lensFactor * 2.0 * lensingStrength;
533
+
534
+ if (currentR > 0) {
535
+ const ratio = (currentR + ringRadius * warp) / currentR;
536
+ xCam *= ratio;
537
+ yCam *= ratio;
538
+ }
539
+
540
+ // 2. Vertical Arching (Crucial for edge-on view)
541
+ // If the particle is behind the BH, we pull it towards the ring radius vertically
542
+ // This creates the "hump" or halo over the shadow
543
+ const archStrength =
544
+ Math.exp(-(xCam * xCam) / (ringRadius * ringRadius * 4)) *
545
+ lensingStrength;
546
+
547
+ // Shift Y upwards (negative) to form the upper arc
548
+ // We blend the current Y with the ring height
549
+ // stronger shift when x is small (directly behind)
550
+ const targetY = -ringRadius * 0.9;
551
+ yCam = yCam + (targetY - yCam) * archStrength * 0.8;
552
+
553
+ } else if (currentR > 0 && currentR < this.bhRadius * 3) {
554
+ // In front of BH - bend around the black hole edge
555
+ // Particles near BH edge curve around it
556
+ const edgeProximity = currentR / this.bhRadius;
557
+
558
+ if (edgeProximity < 2.5) {
559
+ // Strong bending near the edge - pushes particles outward and around
560
+ const bendStrength =
561
+ Math.exp(-edgeProximity * 0.8) * lensingStrength;
562
+ const pushOut = 1 + bendStrength * 0.4;
563
+
564
+ // Also curve around - displace perpendicular to radius
565
+ const angle = Math.atan2(yCam, xCam);
566
+ const curvature = bendStrength * 0.3 * Math.sign(yCam || 1);
567
+
568
+ xCam =
569
+ xCam * pushOut +
570
+ Math.cos(angle + Math.PI / 2) * curvature * this.bhRadius;
571
+ yCam =
572
+ yCam * pushOut +
573
+ Math.sin(angle + Math.PI / 2) * curvature * this.bhRadius;
574
+ }
575
+ }
576
+ }
577
+
578
+ // OCCLUSION: Cull particles behind BH that project inside the shadow
579
+ const finalDist = Math.sqrt(xCam * xCam + yCam * yCam);
580
+ if (zCam > 0 && finalDist < this.bhRadius * 0.95) continue;
581
+
582
+ // Perspective projection
583
+ const perspectiveScale =
584
+ this.camera.perspective / (this.camera.perspective + zCam);
585
+ const screenX = xCam * perspectiveScale;
586
+ const screenY = yCam * perspectiveScale;
587
+
588
+ // Cull particles behind camera
589
+ if (zCam < -this.camera.perspective + 10) continue;
590
+
591
+ // Doppler effect
592
+ const velocityDir = Math.cos(p.angle + this.camera.rotationY);
593
+ const doppler = 1 + velocityDir * 0.4;
594
+
595
+ renderList.push({
596
+ z: zCam,
597
+ x: screenX,
598
+ y: screenY,
599
+ scale: perspectiveScale,
600
+ color: p.baseColor,
601
+ doppler: doppler,
602
+ size: p.size,
603
+ isFalling: p.willFallIn,
604
+ horizonProximity: p.distance / this.bhRadius,
605
+ });
606
+ }
607
+
608
+ // Add permanent accretion disk particles (if any exist)
609
+ for (const p of this.diskParticles) {
610
+ if (p.consumed) continue;
611
+
612
+ // Convert polar to Cartesian
613
+ const px = Math.cos(p.angle) * p.distance;
614
+ const pz = Math.sin(p.angle) * p.distance;
615
+ const py = p.yOffset;
616
+
617
+ // Transform to camera space
618
+ const cosY = Math.cos(this.camera.rotationY);
619
+ const sinY = Math.sin(this.camera.rotationY);
620
+ let xCam = px * cosY - pz * sinY;
621
+ let zCam = px * sinY + pz * cosY;
622
+
623
+ const cosX = Math.cos(this.camera.rotationX);
624
+ const sinX = Math.sin(this.camera.rotationX);
625
+ let yCam = py * cosX - zCam * sinX;
626
+ zCam = py * sinX + zCam * cosX;
627
+
628
+ // Apply gravitational lensing
629
+ if (lensingStrength > 0) {
630
+ const rSq = xCam * xCam + yCam * yCam;
631
+ const currentR = Math.sqrt(rSq);
632
+ const ringRadius = this.bhRadius * 1.5; // Approx Einstein ring radius
633
+
634
+ if (zCam > 0) {
635
+ // BEHIND THE BLACK HOLE (The "Halo")
636
+ // Light from the back of the disk is bent around the BH.
637
+ // We see it as a halo/ring surrounding the shadow.
638
+
639
+ // Warp factor: increases as we get closer to the center axis
640
+ // This pushes the image OUTWARD towards the Einstein ring radius
641
+ const distToRing = Math.abs(currentR - ringRadius);
642
+
643
+ // Simple geometric warp for the "Interstellar" look:
644
+ // If we are behind, we map the coordinates to the ring radius vertically.
645
+
646
+ // Strength of the warp depends on how close we are to the BH shadow
647
+ // Particles directly behind (small currentR) get pushed to the ring.
648
+ if (currentR < ringRadius * 2) {
649
+ const t = Math.max(0, 1 - currentR / (ringRadius * 2.5));
650
+
651
+ // Push Y towards the ring edge, preserving sign
652
+ // This creates the "arch" over and under the shadow
653
+ const targetY = (yCam > 0 ? 1 : -1) * Math.sqrt(Math.max(0, ringRadius*ringRadius - xCam*xCam * 0.5));
654
+
655
+ // Blend between original position and warped position
656
+ // Stronger blend near the center (high t)
657
+ const blend = t * t * lensingStrength * 0.9;
658
+ yCam = yCam * (1 - blend) + targetY * blend;
659
+
660
+ // Also slight radial expansion
661
+ const radialPush = 1 + t * 0.5 * lensingStrength;
662
+ xCam *= radialPush;
663
+ yCam *= radialPush;
664
+ }
665
+
666
+ } else if (currentR > 0 && currentR < this.bhRadius * 3) {
667
+ // FRONT OF BLACK HOLE (Accretion Disk proper)
668
+ // Light is still bent, but less dramatically.
669
+ // Main effect is slight apparent magnification and distortion near the shadow.
670
+
671
+ const edgeProximity = currentR / this.bhRadius;
672
+ if (edgeProximity < 3.0) {
673
+ // Warp space slightly near the event horizon
674
+ const warp = 1.0 + Math.exp(-edgeProximity * 2.0) * 0.2 * lensingStrength;
675
+ xCam *= warp;
676
+ yCam *= warp;
677
+ }
678
+ }
679
+ }
680
+
681
+ // OCCLUSION: Cull particles that end up inside the Event Horizon shadow
682
+ // The shadow is slightly larger than the BH radius due to light capture
683
+ const finalRSq = xCam * xCam + yCam * yCam;
684
+ const shadowRadiusSq = (this.bhRadius * 0.9) ** 2; // 0.9 to allow slight overlap/glow
685
+
686
+ // If behind (z>0) AND projected inside shadow, it's occluded
687
+ if (zCam > 0 && finalRSq < shadowRadiusSq) continue;
688
+
689
+ // If in front (z<0) AND inside, it might be blocking the view (but we usually draw on top)
690
+ // Actually, particles *in front* should be visible even if projected on the black circle,
691
+ // because they are between us and the BH.
692
+ // BUT if they physically fell in (r < radius), they are gone.
693
+ // Our physics handles physical consumption. This check is purely for visual occlusion of background.
694
+
695
+ // Perspective projection
696
+ const perspectiveScale =
697
+ this.camera.perspective / (this.camera.perspective + zCam);
698
+ const screenX = xCam * perspectiveScale;
699
+ const screenY = yCam * perspectiveScale;
700
+
701
+ if (zCam < -this.camera.perspective + 10) continue;
702
+
703
+ // Doppler effect
704
+ const velocityDir = Math.cos(p.angle + this.camera.rotationY);
705
+ const doppler = 1 + velocityDir * 0.4;
706
+
707
+ renderList.push({
708
+ z: zCam,
709
+ x: screenX,
710
+ y: screenY,
711
+ scale: perspectiveScale,
712
+ color: p.baseColor,
713
+ doppler: doppler,
714
+ size: p.size,
715
+ isFalling: p.isFalling,
716
+ horizonProximity: p.distance / this.bhRadius,
717
+ });
718
+ }
719
+
720
+ return renderList;
721
+ }
722
+
723
+ /**
724
+ * Draw debris particles with lensing.
725
+ * Uses Painter.useCtx() for direct canvas drawing.
726
+ */
727
+ draw() {
728
+ const renderList = this.buildRenderList();
729
+ if (renderList.length === 0) return;
730
+
731
+ // Sort by z for proper depth
732
+ renderList.sort((a, b) => b.z - a.z);
733
+
734
+ const cx = this.game.width / 2;
735
+ const cy = this.game.height / 2;
736
+
737
+ Painter.useCtx((ctx) => {
738
+ // Use additive blending for glowing plasma effect
739
+ ctx.globalCompositeOperation = "screen";
740
+
741
+ // Render each particle
742
+ for (const item of renderList) {
743
+ const screenX = cx + item.x;
744
+ const screenY = cy + item.y;
745
+
746
+ // Particle size based on type
747
+ // Larger base size for better blending
748
+ const baseSize = Math.max(1.5, item.size * item.scale * 1.5);
749
+ const size = item.isFalling ? baseSize * 0.4 : baseSize * 0.5;
750
+
751
+ // Apply doppler effect to color
752
+ const color = item.color;
753
+ const dopplerBoost = item.doppler; // 0.6 to 1.4
754
+
755
+ // Enhance doppler contrast - brighter approaching, dimmer receding
756
+ const boost = Math.pow(dopplerBoost, 1.5);
757
+
758
+ const r = Math.min(255, Math.round(color.r * boost));
759
+ const g = Math.min(255, Math.round(color.g * boost));
760
+ const b = Math.min(255, Math.round(color.b * boost));
761
+
762
+ // Falling particles are brighter
763
+ const alpha = item.isFalling
764
+ ? Math.min(1, color.a * 1.5)
765
+ : color.a * 0.8;
766
+
767
+ // Brighter glow for particles near the horizon or falling
768
+ const horizonGlow =
769
+ item.horizonProximity < 2 ? (2 - item.horizonProximity) * 0.5 : 0;
770
+
771
+ // Draw particle
772
+ ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${alpha})`;
773
+
774
+ // Only use shadowBlur for very bright particles to save performance
775
+ // and create "hot spots"
776
+ if (item.isFalling || horizonGlow > 0.3) {
777
+ ctx.shadowColor = `rgba(${r}, ${g}, ${b}, ${alpha})`;
778
+ ctx.shadowBlur = size * 2;
779
+ } else {
780
+ ctx.shadowBlur = 0;
781
+ }
782
+
783
+ ctx.beginPath();
784
+ ctx.arc(screenX, screenY, size, 0, Math.PI * 2);
785
+ ctx.fill();
786
+ }
787
+ // Reset composite operation
788
+ ctx.globalCompositeOperation = "source-over";
789
+ });
790
+ }
791
+ }