@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,695 @@
1
+ import {
2
+ Game,
3
+ Camera3D,
4
+ StateMachine,
5
+ Painter,
6
+ Text,
7
+ Button,
8
+ Tweenetik,
9
+ Easing,
10
+ } from "../../../src/index.js";
11
+ import { FPSCounter } from "../../../src/game/ui/fps.js";
12
+ import { LensedStarfield } from "./lensedstarfield.js";
13
+ import { polarToCartesian } from "../../../src/math/gr.js";
14
+ import { keplerianOmega, decayingOrbitalRadius, orbitalRadius } from "../../../src/math/orbital.js";
15
+ import { applyAnchor } from "../../../src/mixins/anchor.js";
16
+ import { Position } from "../../../src/util/position.js";
17
+
18
+ import { CONFIG } from "./config.js";
19
+ import { BlackHoleScene } from "./blackholescene.js";
20
+
21
+ export class TDEDemo extends Game {
22
+ constructor(canvas) {
23
+ super(canvas);
24
+ this.enableFluidSize();
25
+ this.backgroundColor = "#020202";
26
+ }
27
+
28
+ updateScaledSizes() {
29
+ this.baseScale = Math.min(this.width, this.height);
30
+ }
31
+
32
+ init() {
33
+ super.init();
34
+ this.updateScaledSizes();
35
+
36
+ this.camera = new Camera3D({
37
+ rotationX: 0.3,
38
+ rotationY: 0,
39
+ rotationZ: 0,
40
+ perspective: this.baseScale * 1.8, // Zoomed out for wider view
41
+ autoRotate: true,
42
+ autoRotateSpeed: 0.08,
43
+ // Inertia for smooth camera drag
44
+ inertia: true,
45
+ friction: 0.94, // 0.9 = fast stop, 0.98 = slow drift
46
+ velocityScale: 1.2, // Throw strength multiplier
47
+ });
48
+ this.camera.enableMouseControl(this.canvas);
49
+
50
+ // Apply orbit center offset to shift the scene (and thus the orbit) on screen
51
+ const orbitOffsetX = (CONFIG.star.orbitCenterX || 0) * this.width;
52
+ const orbitOffsetY = (CONFIG.star.orbitCenterY || 0) * this.height;
53
+
54
+ this.scene = new BlackHoleScene(this, {
55
+ camera: this.camera,
56
+ x: this.width / 2 + orbitOffsetX,
57
+ y: this.height / 2 + orbitOffsetY,
58
+ });
59
+ this.pipeline.add(this.scene);
60
+
61
+ // Initialize scene sizes and positions before first frame
62
+ this.scene.onResize();
63
+
64
+ // Create lensed starfield AFTER scene so we can pass the black hole reference
65
+ this.starField = new LensedStarfield(this, {
66
+ camera: this.camera,
67
+ starCount: CONFIG.sceneOptions.starCount,
68
+ blackHole: this.scene.bh,
69
+ lensingStrength: 0, // Starts at 0, ramps up during disruption
70
+ });
71
+ this.pipeline.add(this.starField);
72
+ this.pipeline.sendToBack(this.starField);
73
+
74
+ // Flash overlay for dramatic collision moment
75
+ this.flashIntensity = 0;
76
+
77
+ // Phase Info Label (bottom left)
78
+ this.infoLabel = new Text(this, "", {
79
+ color: "#888",
80
+ font: "14px monospace",
81
+ });
82
+ applyAnchor(this.infoLabel, {
83
+ anchor: Position.BOTTOM_LEFT,
84
+ anchorOffsetX: -60,
85
+ anchorMargin: 20,
86
+ });
87
+ this.infoLabel.zIndex = 100;
88
+ this.pipeline.add(this.infoLabel);
89
+
90
+ // Replay button (above phase text)
91
+ this.replayButton = new Button(this, {
92
+ width: 120,
93
+ height: 32,
94
+ text: "▶ Replay",
95
+ font: "14px monospace",
96
+ colorDefaultBg: "rgba(0, 0, 0, 0.6)",
97
+ colorDefaultStroke: "#666",
98
+ colorDefaultText: "#aaa",
99
+ colorHoverBg: "rgba(30, 30, 30, 0.8)",
100
+ colorHoverStroke: "#ff8844",
101
+ colorHoverText: "#ff8844",
102
+ colorPressedBg: "rgba(50, 50, 50, 0.9)",
103
+ colorPressedStroke: "#ffaa66",
104
+ colorPressedText: "#ffaa66",
105
+ onClick: () => this.restart(),
106
+ });
107
+ applyAnchor(this.replayButton, {
108
+ anchor: Position.BOTTOM_LEFT,
109
+ anchorMargin: 20,
110
+ anchorOffsetY: -30, // Above the phase text
111
+ });
112
+ this.replayButton.zIndex = 100;
113
+ this.pipeline.add(this.replayButton);
114
+
115
+ // FPS Counter (bottom right)
116
+ this.fpsCounter = new FPSCounter(this, {
117
+ font: "12px monospace",
118
+ color: "#666",
119
+ });
120
+ applyAnchor(this.fpsCounter, {
121
+ anchor: Position.BOTTOM_RIGHT,
122
+ anchorMargin: 20,
123
+ });
124
+ this.fpsCounter.zIndex = 100;
125
+ this.pipeline.add(this.fpsCounter);
126
+
127
+ this.initStateMachine();
128
+
129
+ // Initialize state properly (same as restart)
130
+ this.restart();
131
+ }
132
+
133
+ initStateMachine() {
134
+ this.fsm = new StateMachine({
135
+ initial: "approach",
136
+ context: this,
137
+ states: {
138
+ approach: {
139
+ // Star in stable wide orbit
140
+ duration: CONFIG.durations.approach,
141
+ next: "stretch",
142
+ },
143
+ stretch: {
144
+ // Orbit begins to decay, tidal forces start
145
+ duration: CONFIG.durations.stretch,
146
+ next: "disrupt",
147
+ enter: () => {
148
+ // TIDAL TRAUMA - dramatic visual feedback when disruption begins
149
+ const star = this.scene?.star;
150
+ if (star) {
151
+ // === VIOLENT BRIGHTNESS FLARE ===
152
+ star.tidalFlare = 2.0;
153
+ // Fade over 5 seconds
154
+ Tweenetik.to(star, { tidalFlare: 0 }, 5.0, Easing.easeOutQuad);
155
+
156
+ // === GEOMETRY WOBBLE - comet-like trauma ===
157
+ // Spike the wobble high - violent shaking
158
+ star.tidalWobble = 1.2;
159
+ // Fade back to stable over 6 seconds (star tries to recover)
160
+ Tweenetik.to(star, { tidalWobble: 0.1 }, 6.0, Easing.easeOutElastic);
161
+
162
+ // === SUDDEN STRETCH SPIKE - comet shape ===
163
+ // Force immediate elongation like panels 2-3 in reference
164
+ star.tidalStretch = 0.8;
165
+ // Ease back toward spherical (but not fully - it can't recover)
166
+ Tweenetik.to(star, { tidalStretch: 0.2 }, 4.0, Easing.easeOutQuad);
167
+
168
+ // === STRESS SPIKE ===
169
+ star.stressLevel = 0.6;
170
+ // Slowly calm down but not fully
171
+ Tweenetik.to(star, { stressLevel: 0.25 }, 5.0, Easing.easeOutQuad);
172
+
173
+ // === PARTICLE BURST ===
174
+ if (this.scene.stream) {
175
+ for (let i = 0; i < 80; i++) {
176
+ this.emitStreamParticles(0.016, 1);
177
+ }
178
+ }
179
+ }
180
+ },
181
+ },
182
+ disrupt: {
183
+ // No duration - transitions via trigger when star mass depletes
184
+ on: {
185
+ starConsumed: "accrete",
186
+ },
187
+ },
188
+ accrete: {
189
+ // Debris accretion phase - triggers flash
190
+ duration: CONFIG.durations.accrete,
191
+ next: "flare",
192
+ enter: () => {
193
+ // White flash with dramatic falloff
194
+ this.flashIntensity = 1.0;
195
+ Tweenetik.to(this, { flashIntensity: 0 }, 5.0, Easing.easeOutExpo);
196
+ },
197
+ },
198
+ flare: {
199
+ // Luminous flare after consumption - JETS FIRE!
200
+ duration: CONFIG.durations.flare,
201
+ next: "stable",
202
+ enter: () => {
203
+ // Second flash for jet ignition
204
+ this.flashIntensity = 0.8;
205
+ Tweenetik.to(this, { flashIntensity: 0 }, 5, Easing.easeOutExpo);
206
+ },
207
+ },
208
+ stable: {
209
+ // Terminal state - button to restart
210
+ duration: CONFIG.durations.stable,
211
+ enter: () => {
212
+ // Black hole calms down after feeding
213
+ this.scene.bh.startStabilizing();
214
+ // Show replay button
215
+ if (this.replayButton) this.replayButton.visible = true;
216
+ },
217
+ },
218
+ },
219
+ });
220
+ }
221
+
222
+ restart() {
223
+ // Reset masses
224
+ this.scene.bh.mass = CONFIG.blackHole.initialMass;
225
+ this.scene.star.mass = CONFIG.star.initialMass;
226
+ this.scene.star.initialMass = CONFIG.star.initialMass;
227
+
228
+ // Store eccentricity
229
+ const eccentricity = CONFIG.star.eccentricity || 0;
230
+ this.scene.star.eccentricity = eccentricity;
231
+
232
+ // Calculate semi-major axis based on baseScale (smaller of width/height)
233
+ // This ensures the orbit fits on any aspect ratio screen
234
+ // initialOrbitRadius is a fraction of baseScale (e.g., 0.5 = half of smaller dimension)
235
+ let semiMajorAxis = (this.baseScale / 2) * CONFIG.star.initialOrbitRadius;
236
+
237
+ // Option to bypass constraints for full control over orbit size
238
+ if (!CONFIG.star.bypassConstraints) {
239
+ // Apply constraints to keep orbit somewhat on-screen
240
+ const starRadius = this.baseScale * CONFIG.starRadiusRatio;
241
+ const allowOffscreen = CONFIG.star.allowOffscreen ?? 0;
242
+ const effectiveMargin = starRadius * (1 - allowOffscreen);
243
+
244
+ const orbitOffsetX = (CONFIG.star.orbitCenterX || 0) * this.width;
245
+ const orbitOffsetY = (CONFIG.star.orbitCenterY || 0) * this.height;
246
+
247
+ const leftEdgeDist = (this.width / 2) + orbitOffsetX;
248
+ const rightEdgeDist = (this.width / 2) - orbitOffsetX;
249
+ const maxHorizontalExtent = Math.max(leftEdgeDist, rightEdgeDist) - effectiveMargin;
250
+ const maxSafeSemiMajorH = maxHorizontalExtent / (1 + eccentricity);
251
+
252
+ const practicalTilt = Math.min(Math.abs(this.camera._initialRotationX || 0.3) * 2, 0.6);
253
+ const topEdgeDist = (this.height / 2) + orbitOffsetY;
254
+ const bottomEdgeDist = (this.height / 2) - orbitOffsetY;
255
+ const maxVerticalDisplacement = Math.max(topEdgeDist, bottomEdgeDist) - effectiveMargin;
256
+ const tiltFactor = Math.abs(Math.sin(practicalTilt));
257
+ const maxSafeSemiMajorV = tiltFactor > 0.01 ? maxVerticalDisplacement / tiltFactor : Infinity;
258
+
259
+ semiMajorAxis = Math.min(semiMajorAxis, maxSafeSemiMajorH, maxSafeSemiMajorV);
260
+ }
261
+
262
+ this.scene.star.semiMajorAxis = semiMajorAxis;
263
+ this.scene.star.initialSemiMajorAxis = semiMajorAxis;
264
+ // Keep orbitalRadius for compatibility (will be updated each frame)
265
+ this.scene.star.orbitalRadius = semiMajorAxis;
266
+ this.scene.star.initialOrbitalRadius = semiMajorAxis;
267
+
268
+ // Start angle: 0 = periapsis (right side), π = apoapsis (left side)
269
+ const startAngle = CONFIG.star.startAngle ?? Math.PI;
270
+ this.scene.star.phi = startAngle;
271
+
272
+ // Reset position to starting angle
273
+ const r = orbitalRadius(semiMajorAxis, eccentricity, startAngle);
274
+ const pos = polarToCartesian(r, startAngle);
275
+ this.scene.star.x = pos.x;
276
+ this.scene.star.z = pos.z;
277
+
278
+ // Reset velocity tracking to avoid spike after position reset
279
+ this.scene.star.resetVelocity();
280
+
281
+ // Clear tidal stream particles
282
+ if (this.scene.stream) {
283
+ this.scene.stream.clear();
284
+ }
285
+
286
+ // Reset accretion disk
287
+ if (this.scene.disk) {
288
+ this.scene.disk.clear();
289
+ this.scene.disk.active = false;
290
+ this.scene.disk.lensingStrength = 0;
291
+ this.scene.disk.scale = 0;
292
+ }
293
+
294
+ // Reset relativistic jets
295
+ if (this.scene.jets) {
296
+ this.scene.jets.clear();
297
+ }
298
+ this._lastJetPulse = -1;
299
+
300
+ // Reset black hole to dormant state
301
+ if (this.scene.bh) {
302
+ this.scene.bh.resetAwakening();
303
+ }
304
+
305
+ // Reset flash
306
+ this.flashIntensity = 0;
307
+
308
+ // Reset starfield lensing to subtle base level
309
+ if (this.starField) {
310
+ this.starField.lensingStrength = 0.15;
311
+ }
312
+
313
+ // Hide replay button
314
+ if (this.replayButton) this.replayButton.visible = false;
315
+
316
+ this.fsm.setState("approach");
317
+ }
318
+
319
+ /**
320
+ * Emit particles from the star's center position
321
+ *
322
+ * The trailing effect happens NATURALLY because:
323
+ * - Each frame, particles are emitted at star's CURRENT position
324
+ * - As star moves, older particles stay at their emission positions
325
+ * - This creates a trail without needing artificial offsets
326
+ *
327
+ * No velocity-based offset is applied because that only looks correct
328
+ * from specific camera angles. Emitting at center works from any angle.
329
+ */
330
+ emitStreamParticles(dt, rate) {
331
+ const star = this.scene.star;
332
+ const stream = this.scene.stream;
333
+
334
+ if (!stream || star.mass <= 0) return;
335
+
336
+ // Use star's ACTUAL current radius (shrinks during disruption)
337
+ const currentRadius = star.currentRadius;
338
+
339
+ // Use star's ACTUAL tracked velocity for particle inheritance
340
+ const vx = star.velocityX || 0;
341
+ const vz = star.velocityZ || 0;
342
+
343
+ // Add radial offset to compensate for visual projection mismatch
344
+ // Calculate in full 3D space including Y
345
+ const starY = star.y || 0;
346
+ const dist = Math.sqrt(star.x * star.x + starY * starY + star.z * star.z) || 1;
347
+ const radialX = star.x / dist; // Unit vector pointing away from BH (3D)
348
+ const radialY = starY / dist;
349
+ const radialZ = star.z / dist;
350
+
351
+ // Make offset proportional to current orbital distance
352
+ // As orbit shrinks, offset shrinks proportionally
353
+ const orbitOffsetRatio = 1.09; // 80% of current orbital radius as offset
354
+ const orbitOffset = dist * orbitOffsetRatio;
355
+
356
+ // Also add tangential offset (along velocity direction) to compensate for arc lag
357
+ const speed = Math.sqrt(vx * vx + vz * vz) || 1;
358
+ const velDirX = vx / speed;
359
+ const velDirZ = vz / speed;
360
+ const tangentOffset = 15; // pixels ahead along orbit path
361
+
362
+ const emitX = star.x + radialX * orbitOffset + velDirX * tangentOffset;
363
+ const emitY = starY + radialY * orbitOffset;
364
+ const emitZ = star.z + radialZ * orbitOffset + velDirZ * tangentOffset;
365
+
366
+ for (let i = 0; i < rate; i++) {
367
+ stream.emit(
368
+ emitX, emitY, emitZ,
369
+ vx, 0, vz,
370
+ currentRadius,
371
+ star.rotation || 0,
372
+ star.currentColor // Pass star's current color for particle emission
373
+ );
374
+ }
375
+ }
376
+
377
+ update(dt) {
378
+ super.update(dt);
379
+ if (this.camera) this.camera.update(dt);
380
+ Tweenetik.updateAll(dt);
381
+ if (!this.fsm) return;
382
+ this.fsm.update(dt);
383
+
384
+ const state = this.fsm.state;
385
+ const progress = this.fsm.progress;
386
+ const star = this.scene.star;
387
+ const bh = this.scene.bh;
388
+
389
+ // Flash is now handled by Tweenetik in FSM enter callbacks
390
+
391
+ // Update info label
392
+ if (this.infoLabel) {
393
+ // Calculate display progress (disrupt uses stateTime, not duration-based progress)
394
+ let displayProgress;
395
+ if (state === "disrupt") {
396
+ displayProgress = Math.min(1, this.fsm.stateTime / CONFIG.durations.disrupt);
397
+ } else if (state === "stable") {
398
+ displayProgress = 1;
399
+ } else {
400
+ displayProgress = progress;
401
+ }
402
+ const pPercent = Math.round(displayProgress * 100);
403
+
404
+ const stateLabels = {
405
+ approach: "Star Approaching",
406
+ stretch: "Tidal Stretching",
407
+ disrupt: "Stellar Disruption",
408
+ accrete: "Debris Accretion",
409
+ flare: "Luminous Flare",
410
+ stable: "Stable Disk",
411
+ };
412
+
413
+ const stateColors = {
414
+ approach: "#88f",
415
+ stretch: "#fa8",
416
+ disrupt: "#f88",
417
+ accrete: "#ff8",
418
+ flare: "#fff",
419
+ stable: "#8a8",
420
+ };
421
+
422
+ this.infoLabel.color = stateColors[state] || "#888";
423
+
424
+ if (state === "stable") {
425
+ this.infoLabel.text = `${stateLabels[state]}`;
426
+ } else {
427
+ this.infoLabel.text = `${stateLabels[state]}: ${pPercent}%`;
428
+ }
429
+ }
430
+
431
+ // === GRAVITATIONAL LENSING RAMP ===
432
+ // Black holes always warp spacetime - subtle base, INTENSE when feeding
433
+ if (this.starField) {
434
+ const baseLensing = 0.15; // Subtle base - dormant BH
435
+ if (state === "approach") {
436
+ this.starField.lensingStrength = baseLensing;
437
+ } else if (state === "stretch") {
438
+ // Gentle ramp: 0.15 -> 0.4
439
+ this.starField.lensingStrength = baseLensing + progress * 0.25;
440
+ } else if (state === "disrupt") {
441
+ // AGGRESSIVE ramp: 0.4 -> 2.0 (goes past 1.0 for dramatic effect)
442
+ const disruptProgress = Math.min(1, this.fsm.stateTime / CONFIG.durations.disrupt);
443
+ this.starField.lensingStrength = 0.4 + disruptProgress * 1.6;
444
+ } else {
445
+ // Max lensing during accrete/flare/stable
446
+ this.starField.lensingStrength = 2.0;
447
+ }
448
+ }
449
+
450
+ // Store star's position before updates for accurate velocity computation
451
+ const oldStarX = star.x;
452
+ const oldStarZ = star.z;
453
+
454
+ // Approach phase: Elliptical orbit - star swings from apoapsis toward periapsis
455
+ if (state === "approach") {
456
+ star.tidalProgress = 0; // No distortion yet
457
+
458
+ const e = star.eccentricity || 0;
459
+ const a = star.semiMajorAxis;
460
+
461
+ // Current radius from ellipse equation
462
+ const r = orbitalRadius(a, e, star.phi);
463
+ star.orbitalRadius = r;
464
+
465
+ // Angular velocity varies with r² (Kepler's 2nd law: r²·dθ/dt = const)
466
+ // Faster at periapsis, slower at apoapsis
467
+ const baseOmega = keplerianOmega(a, bh.mass, 1.0, star.initialSemiMajorAxis);
468
+ const omega = baseOmega * (a * a) / (r * r);
469
+
470
+ star.phi += omega * dt * CONFIG.star.orbitSpeed;
471
+
472
+ // Store effective orbit speed for particle emission
473
+ star.effectiveOrbitSpeed = omega * r * CONFIG.star.orbitSpeed;
474
+
475
+ const pos = polarToCartesian(r, star.phi);
476
+ star.x = pos.x;
477
+ star.z = pos.z;
478
+ }
479
+ // Stretch phase: Elliptical orbit decays and circularizes
480
+ else if (state === "stretch") {
481
+ const stretchProgress = this.fsm.progress;
482
+
483
+ // Drive tidal distortion - starts subtle, builds through phase
484
+ // Use easeIn curve so distortion starts slowly then accelerates
485
+ star.tidalProgress = stretchProgress * stretchProgress;
486
+
487
+ // Semi-major axis decays
488
+ const a = decayingOrbitalRadius(
489
+ star.initialSemiMajorAxis,
490
+ CONFIG.star.decayRate * 0.3,
491
+ stretchProgress * 2
492
+ );
493
+ star.semiMajorAxis = a;
494
+
495
+ // Eccentricity decreases as orbit circularizes (tidal forces)
496
+ const e = star.eccentricity * (1 - stretchProgress * 0.5);
497
+
498
+ // Current radius from ellipse
499
+ const r = orbitalRadius(a, e, star.phi);
500
+ star.orbitalRadius = r;
501
+
502
+ // Angular velocity with Kepler's 2nd law
503
+ const baseOmega = keplerianOmega(a, bh.mass, 1.0, star.initialSemiMajorAxis);
504
+ const omega = baseOmega * (a * a) / (r * r);
505
+ const speedMultiplier = 1.1;
506
+ const phiStep = omega * dt * CONFIG.star.orbitSpeed * speedMultiplier;
507
+ star.phi += phiStep;
508
+
509
+ // Store effective orbit speed for particle emission
510
+ star.effectiveOrbitSpeed = omega * r * CONFIG.star.orbitSpeed * speedMultiplier;
511
+
512
+ const pos = polarToCartesian(r, star.phi);
513
+ star.x = pos.x;
514
+ star.z = pos.z;
515
+
516
+ // Start emitting particles during stretch (slowly at first)
517
+ if (this.scene.stream && stretchProgress > 0.1) {
518
+ // Compute current-frame velocity for accurate particle emission
519
+ if (dt > 0) {
520
+ star.velocityX = (star.x - oldStarX) / dt;
521
+ star.velocityZ = (star.z - oldStarZ) / dt;
522
+ }
523
+ const emitRate = 2 + Math.floor(stretchProgress * 15);
524
+ this.emitStreamParticles(dt, emitRate);
525
+ }
526
+ }
527
+ // Disrupt phase: Rapid decay with mass transfer
528
+ else if (state === "disrupt") {
529
+ star.tidalProgress = 1; // Max external distortion
530
+
531
+ // Use stateTime to calculate decay progress (event-based exit)
532
+ const decayTime = this.fsm.stateTime;
533
+ const disruptProgress = Math.min(1, decayTime / CONFIG.durations.disrupt);
534
+
535
+ // Continue decay from where stretch left off
536
+ const stretchEndAxis = decayingOrbitalRadius(
537
+ star.initialSemiMajorAxis,
538
+ CONFIG.star.decayRate * 0.3,
539
+ 2 // stretch ended at progress=1, factor=2
540
+ );
541
+
542
+ // Semi-major axis decays with easing - slow start, accelerates later
543
+ const decayEase = disruptProgress * disruptProgress; // easeIn quadratic
544
+ // Blend from stretch rate (0.3x) to full rate over first 50% of disrupt
545
+ const decayRateBlend = 0.3 + Math.min(disruptProgress * 2, 1) * 0.7; // 0.3 -> 1.0
546
+ const a = decayingOrbitalRadius(
547
+ stretchEndAxis,
548
+ CONFIG.star.decayRate * decayRateBlend,
549
+ decayEase * 5
550
+ );
551
+
552
+ // Eccentricity continues from stretch end (0.5 * initial) and decays to 0
553
+ const e = star.eccentricity * 0.5 * (1 - disruptProgress);
554
+
555
+ // Current radius from ellipse equation
556
+ const baseRadius = orbitalRadius(a, e, star.phi);
557
+
558
+ // === ORBITAL CHAOS ===
559
+ // Starts at 0, builds with easeIn curve for gradual onset
560
+ const chaosProgress = disruptProgress * disruptProgress; // easeIn
561
+ const chaos = chaosProgress * 0.6; // 0% -> 60%
562
+ const time = this.fsm.stateTime;
563
+
564
+ // Radial wobble - only after chaos builds
565
+ const radialWobble = chaos > 0.01
566
+ ? Math.sin(time * 2.5) * chaos * baseRadius * 0.15
567
+ + Math.sin(time * 5.8) * chaos * baseRadius * 0.08
568
+ : 0;
569
+ star.orbitalRadius = baseRadius + radialWobble;
570
+
571
+ // Angular velocity with Kepler's 2nd law (same as stretch phase)
572
+ const baseOmega = keplerianOmega(a, bh.mass, 1.0, star.initialSemiMajorAxis);
573
+ const omega = baseOmega * (a * a) / (star.orbitalRadius * star.orbitalRadius);
574
+
575
+ // Speed ramp: starts at 1.1x (matching stretch end), accelerates toward end
576
+ const speedRamp = Math.pow(disruptProgress, 4); // easeIn quartic
577
+ const speedMultiplier = 1.1 + speedRamp * 1.4; // 1.1x -> 2.5x
578
+
579
+ // Angular jitter only kicks in as chaos builds
580
+ const angularJitter = chaos > 0.05 ? Math.sin(time * 3.7) * chaos * 0.15 : 0;
581
+ const phiStep = omega * dt * CONFIG.star.orbitSpeed * (speedMultiplier + angularJitter);
582
+ star.phi += phiStep;
583
+
584
+ // Store effective orbit speed for particle emission
585
+ star.effectiveOrbitSpeed = omega * star.orbitalRadius * CONFIG.star.orbitSpeed * (speedMultiplier);
586
+
587
+ const pos = polarToCartesian(star.orbitalRadius, star.phi);
588
+
589
+ // Vertical wobble - only after chaos builds
590
+ const verticalWobble = chaos > 0.01
591
+ ? Math.sin(time * 1.7) * chaos * baseRadius * 0.12
592
+ + Math.cos(time * 3.9) * chaos * baseRadius * 0.06
593
+ : 0;
594
+
595
+ star.x = pos.x;
596
+ star.y = verticalWobble;
597
+ star.z = pos.z;
598
+
599
+ // Emit particles throughout disrupt phase (more than stretch)
600
+ if (this.scene.stream && star.mass > 0) {
601
+ // Compute current-frame velocity for accurate particle emission
602
+ if (dt > 0) {
603
+ star.velocityX = (star.x - oldStarX) / dt;
604
+ star.velocityZ = (star.z - oldStarZ) / dt;
605
+ }
606
+ const emitRate = 3 + Math.floor(disruptProgress * 20);
607
+ this.emitStreamParticles(dt, emitRate);
608
+ }
609
+
610
+ // Mass transfer starts at configured percentage of disrupt phase
611
+ if (disruptProgress >= CONFIG.star.massTransferStart) {
612
+ const transferRate = (CONFIG.star.initialMass / (CONFIG.durations.disrupt * 0.5)) * dt;
613
+
614
+ star.mass = Math.max(0, star.mass - transferRate);
615
+ bh.mass += transferRate;
616
+
617
+ // AWAKEN the black hole as it feeds! This triggers the glow
618
+ bh.addConsumedMass(transferRate * 0.5);
619
+
620
+ // Force visual updates to reflect mass changes
621
+ star.updateVisual();
622
+ bh.updateVisual();
623
+
624
+ // Trigger accrete state when star mass is depleted
625
+ if (star.mass <= 0) {
626
+ this.fsm.trigger("starConsumed");
627
+ }
628
+ }
629
+
630
+ // Activate accretion disk at 80% progress (handles its own tweens)
631
+ if (disruptProgress >= 0.8 && this.scene.disk) {
632
+ this.scene.disk.activate();
633
+ }
634
+ }
635
+ // Accrete phase: ensure disk active
636
+ else if (state === "accrete") {
637
+ if (this.scene.disk && !this.scene.disk.active) {
638
+ this.scene.disk.activate();
639
+ }
640
+ }
641
+ // Flare phase: jets fire continuously
642
+ else if (state === "flare") {
643
+ if (this.scene.jets) {
644
+ this.scene.jets.active = true;
645
+ this.scene.jets.intensity = 1; // Keep at full blast
646
+ }
647
+ }
648
+ // Stable phase: jets fade out
649
+ else if (state === "stable") {
650
+ if (this.scene.jets && this.scene.jets.active) {
651
+ this.scene.jets.deactivate();
652
+ }
653
+ }
654
+
655
+ // Update star z-order AFTER position is set (must be last in update)
656
+ // This ensures correct depth sorting relative to black hole
657
+ if (this.scene) {
658
+ this.scene.updateStarZOrder();
659
+ }
660
+ }
661
+
662
+ onResize() {
663
+ this.updateScaledSizes();
664
+ if (this.camera) {
665
+ this.camera.perspective = this.baseScale * 1.8;
666
+ }
667
+ if (this.scene) {
668
+ // Apply orbit center offset
669
+ const orbitOffsetX = (CONFIG.star.orbitCenterX || 0) * this.width;
670
+ const orbitOffsetY = (CONFIG.star.orbitCenterY || 0) * this.height;
671
+ this.scene.x = this.width / 2 + orbitOffsetX;
672
+ this.scene.y = this.height / 2 + orbitOffsetY;
673
+ this.scene.onResize();
674
+ }
675
+ }
676
+
677
+ render() {
678
+ super.render();
679
+
680
+ // Draw flash overlay on top of everything
681
+ if (this.flashIntensity > 0) {
682
+ Painter.useCtx((ctx) => {
683
+ ctx.fillStyle = `rgba(255, 255, 255, ${this.flashIntensity})`;
684
+ ctx.fillRect(0, 0, this.width, this.height);
685
+ });
686
+ }
687
+ }
688
+ }
689
+
690
+ window.addEventListener("load", () => {
691
+ const canvas = document.getElementById("game");
692
+ const demo = new TDEDemo(canvas);
693
+ demo.enableFluidSize();
694
+ demo.start();
695
+ });