@guinetik/gcanvas 1.0.0 → 1.0.2

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 (102) hide show
  1. package/demos/coordinates.html +698 -0
  2. package/demos/cube3d.html +23 -0
  3. package/demos/demos.css +17 -3
  4. package/demos/dino.html +42 -0
  5. package/demos/fluid-simple.html +22 -0
  6. package/demos/fluid.html +37 -0
  7. package/demos/gameobjects.html +626 -0
  8. package/demos/index.html +19 -7
  9. package/demos/js/blob.js +18 -5
  10. package/demos/js/coordinates.js +840 -0
  11. package/demos/js/cube3d.js +789 -0
  12. package/demos/js/dino.js +1420 -0
  13. package/demos/js/fluid-simple.js +253 -0
  14. package/demos/js/fluid.js +527 -0
  15. package/demos/js/gameobjects.js +176 -0
  16. package/demos/js/plane3d.js +256 -0
  17. package/demos/js/platformer.js +1579 -0
  18. package/demos/js/sphere3d.js +229 -0
  19. package/demos/js/sprite.js +473 -0
  20. package/demos/js/tde/accretiondisk.js +65 -12
  21. package/demos/js/tde/blackholescene.js +2 -2
  22. package/demos/js/tde/config.js +2 -2
  23. package/demos/js/tde/index.js +152 -27
  24. package/demos/js/tde/lensedstarfield.js +32 -25
  25. package/demos/js/tde/tdestar.js +78 -98
  26. package/demos/js/tde/tidalstream.js +24 -8
  27. package/demos/plane3d.html +24 -0
  28. package/demos/platformer.html +43 -0
  29. package/demos/sphere3d.html +24 -0
  30. package/demos/sprite.html +18 -0
  31. package/docs/README.md +230 -222
  32. package/docs/api/FluidSystem.md +173 -0
  33. package/docs/concepts/architecture-overview.md +204 -204
  34. package/docs/concepts/coordinate-system.md +384 -0
  35. package/docs/concepts/rendering-pipeline.md +279 -279
  36. package/docs/concepts/shapes-vs-gameobjects.md +187 -0
  37. package/docs/concepts/two-layer-architecture.md +229 -229
  38. package/docs/fluid-dynamics.md +99 -0
  39. package/docs/getting-started/first-game.md +354 -354
  40. package/docs/getting-started/installation.md +175 -157
  41. package/docs/modules/collision/README.md +2 -2
  42. package/docs/modules/fluent/README.md +6 -6
  43. package/docs/modules/game/README.md +303 -303
  44. package/docs/modules/isometric-camera.md +2 -2
  45. package/docs/modules/isometric.md +1 -1
  46. package/docs/modules/painter/README.md +328 -328
  47. package/docs/modules/particle/README.md +3 -3
  48. package/docs/modules/shapes/README.md +221 -221
  49. package/docs/modules/shapes/base/euclidian.md +123 -123
  50. package/docs/modules/shapes/base/shape.md +262 -262
  51. package/docs/modules/shapes/base/transformable.md +243 -243
  52. package/docs/modules/state/README.md +2 -2
  53. package/docs/modules/util/README.md +1 -1
  54. package/docs/modules/util/camera3d.md +3 -3
  55. package/docs/modules/util/scene3d.md +1 -1
  56. package/package.json +3 -1
  57. package/readme.md +19 -5
  58. package/src/collision/collision.js +75 -0
  59. package/src/game/game.js +11 -5
  60. package/src/game/index.js +2 -1
  61. package/src/game/objects/index.js +3 -0
  62. package/src/game/objects/platformer-scene.js +411 -0
  63. package/src/game/objects/scene.js +14 -0
  64. package/src/game/objects/sprite.js +529 -0
  65. package/src/game/pipeline.js +20 -16
  66. package/src/game/systems/FluidSystem.js +835 -0
  67. package/src/game/systems/index.js +11 -0
  68. package/src/game/ui/button.js +39 -18
  69. package/src/game/ui/cursor.js +14 -0
  70. package/src/game/ui/fps.js +12 -4
  71. package/src/game/ui/index.js +2 -0
  72. package/src/game/ui/stepper.js +549 -0
  73. package/src/game/ui/theme.js +123 -0
  74. package/src/game/ui/togglebutton.js +9 -3
  75. package/src/game/ui/tooltip.js +11 -4
  76. package/src/io/input.js +75 -45
  77. package/src/io/mouse.js +44 -19
  78. package/src/io/touch.js +35 -12
  79. package/src/math/fluid.js +507 -0
  80. package/src/math/index.js +2 -0
  81. package/src/mixins/anchor.js +17 -7
  82. package/src/motion/tweenetik.js +16 -0
  83. package/src/shapes/cube3d.js +599 -0
  84. package/src/shapes/index.js +3 -0
  85. package/src/shapes/plane3d.js +687 -0
  86. package/src/shapes/sphere3d.js +75 -6
  87. package/src/util/camera2d.js +315 -0
  88. package/src/util/camera3d.js +218 -12
  89. package/src/util/index.js +1 -0
  90. package/src/webgl/shaders/plane-shaders.js +332 -0
  91. package/src/webgl/shaders/sphere-shaders.js +4 -2
  92. package/types/fluent.d.ts +361 -0
  93. package/types/game.d.ts +303 -0
  94. package/types/index.d.ts +144 -5
  95. package/types/math.d.ts +361 -0
  96. package/types/motion.d.ts +271 -0
  97. package/types/particle.d.ts +373 -0
  98. package/types/shapes.d.ts +107 -9
  99. package/types/util.d.ts +353 -0
  100. package/types/webgl.d.ts +109 -0
  101. package/disk_example.png +0 -0
  102. package/tde.png +0 -0
@@ -17,9 +17,9 @@ const DISK_CONFIG = {
17
17
  outerRadiusMultiplier: 9.0, // Wide disk with margin from screen edges
18
18
 
19
19
  // Particle properties
20
- maxParticles: 4000,
21
- particleLifetime: 80,
22
- spawnRate: 50,
20
+ maxParticles: 10000,
21
+ particleLifetime: 1000,
22
+ spawnRate: 500,
23
23
 
24
24
  // Orbital physics
25
25
  baseOrbitalSpeed: 0.8,
@@ -40,8 +40,8 @@ const DISK_CONFIG = {
40
40
  colorMid: { r: 255, g: 160, b: 50 }, // Mid (orange)
41
41
  colorCool: { r: 180, g: 40, b: 40 }, // Outer (deep red)
42
42
 
43
- sizeMin: 1,
44
- sizeMax: 2.5,
43
+ sizeMin: .8,
44
+ sizeMax: 1.2,
45
45
  };
46
46
 
47
47
  export class AccretionDisk extends GameObject {
@@ -278,20 +278,73 @@ export class AccretionDisk extends GameObject {
278
278
  let yCam = y * cosX - zCam * sinX;
279
279
  zCam = y * sinX + zCam * cosX;
280
280
 
281
- // === GRAVITATIONAL LENSING (from blackhole.js) ===
282
- // Only affects particles behind the BH (zCam > 0)
283
- if (lensingStrength > 0 && zCam > 0) {
284
- const currentR = Math.sqrt(xCam * xCam + yCam * yCam);
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) {
285
290
  const ringRadius = this.bhRadius * DISK_CONFIG.ringRadiusFactor;
286
291
  const lensFactor = Math.exp(-currentR / (this.bhRadius * DISK_CONFIG.lensingFalloff));
287
292
  const warp = lensFactor * 1.2 * lensingStrength;
288
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
289
301
  if (currentR > 0) {
290
- const ratio = (currentR + ringRadius * warp) / currentR;
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;
291
316
  xCam *= ratio;
292
317
  yCam *= ratio;
293
- } else {
294
- yCam = ringRadius * lensingStrength;
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
+ }
295
348
  }
296
349
  }
297
350
 
@@ -44,10 +44,10 @@ export class BlackHoleScene extends Scene3D {
44
44
  this.Z = {
45
45
  starBack: 10, // Star when behind BH
46
46
  blackHole: 15, // BlackHole: dark shadow at back
47
- disk: 20, // AccretionDisk: over the black hole
47
+ disk: 1, // AccretionDisk: over the black hole
48
48
  starFront: 25, // Star when in front of BH
49
49
  stream: 30, // TidalStream: always on top of star and BH
50
- jets: 40, // Jets: always on top
50
+ jets: 1, // Jets: always on top
51
51
  };
52
52
  }
53
53
 
@@ -6,7 +6,7 @@ export const CONFIG = {
6
6
 
7
7
  // Phase durations (seconds)
8
8
  durations: {
9
- approach: 10.0, // Stable wide orbit
9
+ approach: 12.0, // Stable wide orbit
10
10
  stretch: 10.0, // Orbit begins to decay
11
11
  disrupt: 20.0, // Mass transfer (event-based exit)
12
12
  accrete: 1.0, // Debris accretion
@@ -45,7 +45,7 @@ export const CONFIG = {
45
45
  startAngle: Math.PI * 1.85, // Start lower-right, comes FROM right, swings up and around
46
46
  },
47
47
  sceneOptions: {
48
- starCount: 3000,
48
+ starCount: 5000,
49
49
  },
50
50
 
51
51
  // Accretion disk settings
@@ -34,9 +34,9 @@ export class TDEDemo extends Game {
34
34
  this.updateScaledSizes();
35
35
 
36
36
  this.camera = new Camera3D({
37
- rotationX: 0.3,
37
+ rotationX: 0.2,
38
38
  rotationY: 0,
39
- rotationZ: 0,
39
+ rotationZ: 0.0,
40
40
  perspective: this.baseScale * 1.8, // Zoomed out for wider view
41
41
  autoRotate: true,
42
42
  autoRotateSpeed: 0.08,
@@ -81,13 +81,37 @@ export class TDEDemo extends Game {
81
81
  });
82
82
  applyAnchor(this.infoLabel, {
83
83
  anchor: Position.BOTTOM_LEFT,
84
- anchorOffsetX: -60,
85
- anchorMargin: 20,
84
+ anchorOffsetX: -70,
86
85
  });
87
86
  this.infoLabel.zIndex = 100;
88
87
  this.pipeline.add(this.infoLabel);
89
88
 
90
- // Replay button (above phase text)
89
+ // Star View toggle button (above phase text)
90
+ this.starViewButton = new Button(this, {
91
+ width: 120,
92
+ height: 32,
93
+ text: "★ Star View",
94
+ font: "14px monospace",
95
+ colorDefaultBg: "rgba(0, 0, 0, 0.6)",
96
+ colorDefaultStroke: "#666",
97
+ colorDefaultText: "#aaa",
98
+ colorHoverBg: "rgba(30, 30, 30, 0.8)",
99
+ colorHoverStroke: "#44aaff",
100
+ colorHoverText: "#44aaff",
101
+ colorPressedBg: "rgba(50, 50, 50, 0.9)",
102
+ colorPressedStroke: "#66ccff",
103
+ colorPressedText: "#66ccff",
104
+ onClick: () => this.toggleStarView(),
105
+ });
106
+ applyAnchor(this.starViewButton, {
107
+ anchor: Position.BOTTOM_LEFT,
108
+ anchorMargin: 20,
109
+ anchorOffsetY: -30, // Above the phase text
110
+ });
111
+ this.starViewButton.zIndex = 100;
112
+ this.pipeline.add(this.starViewButton);
113
+
114
+ // Replay button (above star view button)
91
115
  this.replayButton = new Button(this, {
92
116
  width: 120,
93
117
  height: 32,
@@ -107,7 +131,7 @@ export class TDEDemo extends Game {
107
131
  applyAnchor(this.replayButton, {
108
132
  anchor: Position.BOTTOM_LEFT,
109
133
  anchorMargin: 20,
110
- anchorOffsetY: -30, // Above the phase text
134
+ anchorOffsetY: -70, // Above the star view button
111
135
  });
112
136
  this.replayButton.zIndex = 100;
113
137
  this.pipeline.add(this.replayButton);
@@ -115,15 +139,22 @@ export class TDEDemo extends Game {
115
139
  // FPS Counter (bottom right)
116
140
  this.fpsCounter = new FPSCounter(this, {
117
141
  font: "12px monospace",
118
- color: "#666",
119
- });
120
- applyAnchor(this.fpsCounter, {
121
142
  anchor: Position.BOTTOM_RIGHT,
122
- anchorMargin: 20,
143
+ anchorMargin: 0,
144
+ color: "#00FF00",
123
145
  });
124
146
  this.fpsCounter.zIndex = 100;
125
147
  this.pipeline.add(this.fpsCounter);
126
148
 
149
+ // Camera Rotation Text (bottom right)
150
+ this.cameraRotationText = new Text(this, `Camera Rotation: X:${this.camera.rotationX.toFixed(2)} Y:${this.camera.rotationY.toFixed(2)} Z:${this.camera.rotationZ.toFixed(2)}`, {
151
+ font: "12px monospace",
152
+ anchor: Position.BOTTOM_CENTER,
153
+ color: "#ACACAC",
154
+ });
155
+ this.cameraRotationText.zIndex = 100;
156
+ this.pipeline.add(this.cameraRotationText);
157
+
127
158
  this.initStateMachine();
128
159
 
129
160
  // Initialize state properly (same as restart)
@@ -150,25 +181,26 @@ export class TDEDemo extends Game {
150
181
  if (star) {
151
182
  // === VIOLENT BRIGHTNESS FLARE ===
152
183
  star.tidalFlare = 2.0;
153
- // Fade over 5 seconds
154
- Tweenetik.to(star, { tidalFlare: 0 }, 5.0, Easing.easeOutQuad);
184
+ // Fade slowly - easeInQuad keeps it bright longer before dropping
185
+ Tweenetik.to(star, { tidalFlare: 0 }, 8.0, Easing.easeInQuad);
155
186
 
156
187
  // === GEOMETRY WOBBLE - comet-like trauma ===
157
188
  // Spike the wobble high - violent shaking
158
189
  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);
190
+ // Fade back to stable over 8 seconds (star tries to recover)
191
+ Tweenetik.to(star, { tidalWobble: 0.1 }, 8.0, Easing.easeOutElastic);
161
192
 
162
193
  // === SUDDEN STRETCH SPIKE - comet shape ===
163
194
  // Force immediate elongation like panels 2-3 in reference
164
195
  star.tidalStretch = 0.8;
165
196
  // Ease back toward spherical (but not fully - it can't recover)
166
- Tweenetik.to(star, { tidalStretch: 0.2 }, 4.0, Easing.easeOutQuad);
197
+ Tweenetik.to(star, { tidalStretch: 0.3 }, 6.0, Easing.easeInQuad);
167
198
 
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);
199
+ // === STRESS SPIKE (controls white color) ===
200
+ star.stressLevel = 0.8;
201
+ // Stay stressed longer - easeInQuad keeps it white before fading
202
+ // End at 0.4 (still visibly stressed, not back to original)
203
+ Tweenetik.to(star, { stressLevel: 0.4 }, 10.0, Easing.easeInQuad);
172
204
 
173
205
  // === PARTICLE BURST ===
174
206
  if (this.scene.stream) {
@@ -219,6 +251,41 @@ export class TDEDemo extends Game {
219
251
  });
220
252
  }
221
253
 
254
+ /**
255
+ * Toggle between star-following camera view and default view
256
+ */
257
+ toggleStarView() {
258
+ const star = this.scene.star;
259
+
260
+ if (this.camera.isFollowing()) {
261
+ // Switch back to default view
262
+ this.camera.unfollow(true); // true = animate back to initial position
263
+ this.starViewButton.text = "★ Star View";
264
+
265
+ // Re-enable auto-rotate
266
+ this.camera.autoRotate = true;
267
+ } else {
268
+ // Follow the star, looking at the black hole
269
+ // Position camera just above the star's surface (north pole perspective)
270
+ // Very close offset = immersive "standing on the star" feel
271
+ const offsetHeight = star.currentRadius * 0.3; // Just above surface
272
+
273
+ this.camera.follow(star, {
274
+ offsetX: 0,
275
+ offsetY: offsetHeight, // Barely above the star's "north pole"
276
+ offsetZ: 0,
277
+ lookAt: true,
278
+ lookAtTarget: this.scene.bh, // Look at black hole
279
+ lerp: 0.12, // Slightly snappier follow for immersion
280
+ });
281
+
282
+ this.starViewButton.text = "◉ BH View";
283
+
284
+ // Disable auto-rotate while following
285
+ this.camera.autoRotate = false;
286
+ }
287
+ }
288
+
222
289
  restart() {
223
290
  // Reset masses
224
291
  this.scene.bh.mass = CONFIG.blackHole.initialMass;
@@ -278,6 +345,21 @@ export class TDEDemo extends Game {
278
345
  // Reset velocity tracking to avoid spike after position reset
279
346
  this.scene.star.resetVelocity();
280
347
 
348
+ // Kill any lingering tweens on the star (from previous run's stretch phase)
349
+ Tweenetik.killTarget(this.scene.star);
350
+
351
+ // Reset star's tidal state (color, stress, stretch, etc.)
352
+ this.scene.star.tidalStretch = 0;
353
+ this.scene.star.pulsationPhase = 0;
354
+ this.scene.star.stressLevel = 0;
355
+ this.scene.star.tidalProgress = 0;
356
+ this.scene.star.tidalFlare = 0;
357
+ this.scene.star.tidalWobble = 0;
358
+ this.scene.star.angularVelocity = CONFIG.star.rotationSpeed ?? 0.5;
359
+ this.scene.star.rotation = 0;
360
+ this.scene.star.currentColor = null; // Force recalc
361
+ this.scene.star.updateVisual(); // Apply reset immediately
362
+
281
363
  // Clear tidal stream particles
282
364
  if (this.scene.stream) {
283
365
  this.scene.stream.clear();
@@ -310,6 +392,14 @@ export class TDEDemo extends Game {
310
392
  this.starField.lensingStrength = 0.15;
311
393
  }
312
394
 
395
+ // Reset camera to default view if following
396
+ if (this.camera.isFollowing()) {
397
+ this.camera.unfollow(false); // Don't animate, just reset
398
+ this.camera.reset();
399
+ if (this.starViewButton) this.starViewButton.text = "★ Star View";
400
+ this.camera.autoRotate = true;
401
+ }
402
+
313
403
  // Hide replay button
314
404
  if (this.replayButton) this.replayButton.visible = false;
315
405
 
@@ -381,6 +471,8 @@ export class TDEDemo extends Game {
381
471
  if (!this.fsm) return;
382
472
  this.fsm.update(dt);
383
473
 
474
+ this.cameraRotationText.text = `Camera Rotation: X:${this.camera.rotationX.toFixed(2)} Y:${this.camera.rotationY.toFixed(2)}`;
475
+
384
476
  const state = this.fsm.state;
385
477
  const progress = this.fsm.progress;
386
478
  const star = this.scene.star;
@@ -586,15 +678,42 @@ export class TDEDemo extends Game {
586
678
 
587
679
  const pos = polarToCartesian(star.orbitalRadius, star.phi);
588
680
 
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;
681
+ // === ORBITAL PLANE TILT ===
682
+ // As chaos builds, the orbital plane itself starts wobbling
683
+ // This creates the effect of the orbit tilting dramatically
684
+ let tiltX = 0; // Tilt around X-axis (makes orbit bob front-to-back)
685
+ let tiltZ = 0; // Tilt around Z-axis (makes orbit bob left-to-right)
686
+
687
+ if (chaos > 0.005) {
688
+ // Max tilt in radians (~30 degrees = 0.52 rad) - very dramatic!
689
+ const maxTilt = 0.52 * (chaos / 0.6); // Scale with chaos (0.6 is max chaos)
690
+
691
+ // Multiple frequencies for organic, unstable feel
692
+ // Slower base frequencies so the tilt is visible, faster harmonics for jitter
693
+ tiltX = Math.sin(time * 0.8) * maxTilt
694
+ + Math.sin(time * 2.1) * maxTilt * 0.5
695
+ + Math.sin(time * 4.5) * maxTilt * 0.15; // High freq jitter
696
+ tiltZ = Math.cos(time * 0.6) * maxTilt * 0.8
697
+ + Math.sin(time * 1.9) * maxTilt * 0.4
698
+ + Math.cos(time * 5.2) * maxTilt * 0.1; // High freq jitter
699
+ }
594
700
 
595
- star.x = pos.x;
596
- star.y = verticalWobble;
597
- star.z = pos.z;
701
+ // Apply orbital plane tilt rotation
702
+ // Rotate around X-axis: affects Y and Z
703
+ const cosX = Math.cos(tiltX);
704
+ const sinX = Math.sin(tiltX);
705
+ let newY = -pos.z * sinX;
706
+ let newZ = pos.z * cosX;
707
+
708
+ // Rotate around Z-axis: affects X and Y
709
+ const cosZ = Math.cos(tiltZ);
710
+ const sinZ = Math.sin(tiltZ);
711
+ const finalX = pos.x * cosZ - newY * sinZ;
712
+ const finalY = pos.x * sinZ + newY * cosZ;
713
+
714
+ star.x = finalX;
715
+ star.y = finalY;
716
+ star.z = newZ;
598
717
 
599
718
  // Emit particles throughout disrupt phase (more than stretch)
600
719
  if (this.scene.stream && star.mass > 0) {
@@ -623,6 +742,12 @@ export class TDEDemo extends Game {
623
742
 
624
743
  // Trigger accrete state when star mass is depleted
625
744
  if (star.mass <= 0) {
745
+ // If camera was following the star, switch back to default view
746
+ if (this.camera.isFollowing()) {
747
+ this.camera.unfollow(true);
748
+ if (this.starViewButton) this.starViewButton.text = "★ Star View";
749
+ this.camera.autoRotate = true;
750
+ }
626
751
  this.fsm.trigger("starConsumed");
627
752
  }
628
753
  }
@@ -24,7 +24,8 @@ const LENSING_CONFIG = {
24
24
  minDistance: 5,
25
25
 
26
26
  // Occlusion radius multiplier (stars within BH radius * this are hidden)
27
- occlusionMultiplier: 1.15,
27
+ // The dark shadow region extends to ~2.6x the event horizon (photon sphere)
28
+ occlusionMultiplier: 2.6,
28
29
  };
29
30
 
30
31
  export class LensedStarfield extends StarField {
@@ -68,49 +69,55 @@ export class LensedStarfield extends StarField {
68
69
  const lensingPower = LENSING_CONFIG.baseStrength * this.lensingStrength;
69
70
  const effectRadius = LENSING_CONFIG.effectRadiusPixels;
70
71
 
72
+ // Project black hole position once for all stars
73
+ // This is crucial when camera has moved (e.g., following the star)
74
+ const bhProjected = this.camera.project(0, 0, 0);
75
+ const bhScreenX = bhProjected.x;
76
+ const bhScreenY = bhProjected.y;
77
+
71
78
  Painter.useCtx((ctx) => {
72
79
  ctx.globalCompositeOperation = "source-over";
73
80
 
74
81
  for (const star of this.stars) {
75
- // === CAMERA SPACE TRANSFORMATION ===
76
- const cosY = Math.cos(this.camera.rotationY);
77
- const sinY = Math.sin(this.camera.rotationY);
78
- let xCam = star.x * cosY - star.z * sinY;
79
- let zCam = star.x * sinY + star.z * cosY;
80
-
81
- const cosX = Math.cos(this.camera.rotationX);
82
- const sinX = Math.sin(this.camera.rotationX);
83
- let yCam = star.y * cosX - zCam * sinX;
84
- zCam = star.y * sinX + zCam * cosX;
82
+ // === USE CAMERA.PROJECT FOR PROPER POSITION HANDLING ===
83
+ // This accounts for camera position (translation) not just rotation
84
+ const projected = this.camera.project(star.x, star.y, star.z);
85
85
 
86
86
  // Skip stars behind camera
87
- if (zCam < -this.camera.perspective + 50) continue;
87
+ if (projected.scale <= 0) continue;
88
88
 
89
- // === PERSPECTIVE PROJECTION ===
90
- const perspectiveScale = this.camera.perspective / (this.camera.perspective + zCam);
91
- let screenX = xCam * perspectiveScale;
92
- let screenY = yCam * perspectiveScale;
89
+ let screenX = projected.x;
90
+ let screenY = projected.y;
91
+ const perspectiveScale = projected.scale;
92
+
93
+ // === GRAVITATIONAL LENSING (relative to BH screen position) ===
94
+ // Only apply to stars "behind" the black hole (positive z after projection)
95
+ if (lensingPower > 0 && projected.z > 0) {
96
+ // Calculate position relative to BH's screen position
97
+ const relX = screenX - bhScreenX;
98
+ const relY = screenY - bhScreenY;
93
99
 
94
- // === GRAVITATIONAL LENSING (screen space) ===
95
- // Only apply to stars "behind" the black hole (zCam > 0)
96
- if (lensingPower > 0 && zCam > 0) {
97
100
  const lensed = applyGravitationalLensing(
98
- screenX, screenY,
101
+ relX, relY,
99
102
  effectRadius,
100
103
  lensingPower,
101
104
  LENSING_CONFIG.falloff,
102
105
  LENSING_CONFIG.minDistance
103
106
  );
104
- screenX = lensed.x;
105
- screenY = lensed.y;
107
+
108
+ // Transform back to screen space
109
+ screenX = lensed.x + bhScreenX;
110
+ screenY = lensed.y + bhScreenY;
106
111
  }
107
112
 
108
- // === OCCLUSION CHECK ===
113
+ // === OCCLUSION CHECK (relative to BH screen position) ===
109
114
  // Hide stars that fall within the black hole's occlusion radius
110
115
  if (this.blackHole) {
111
116
  const bhScreenRadius = this.blackHole.currentRadius * LENSING_CONFIG.occlusionMultiplier;
112
- const distFromCenter = Math.sqrt(screenX * screenX + screenY * screenY);
113
- if (distFromCenter < bhScreenRadius) continue;
117
+ const dxBH = screenX - bhScreenX;
118
+ const dyBH = screenY - bhScreenY;
119
+ const distFromBH = Math.sqrt(dxBH * dxBH + dyBH * dyBH);
120
+ if (distFromBH < bhScreenRadius) continue;
114
121
  }
115
122
 
116
123
  // Final screen position