@guinetik/gcanvas 1.0.0 → 1.0.1

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 (68) hide show
  1. package/demos/fluid-simple.html +22 -0
  2. package/demos/fluid.html +37 -0
  3. package/demos/index.html +2 -0
  4. package/demos/js/blob.js +18 -5
  5. package/demos/js/fluid-simple.js +253 -0
  6. package/demos/js/fluid.js +527 -0
  7. package/demos/js/tde/accretiondisk.js +64 -11
  8. package/demos/js/tde/blackholescene.js +2 -2
  9. package/demos/js/tde/config.js +2 -2
  10. package/demos/js/tde/index.js +152 -27
  11. package/demos/js/tde/lensedstarfield.js +32 -25
  12. package/demos/js/tde/tdestar.js +78 -98
  13. package/demos/js/tde/tidalstream.js +23 -7
  14. package/docs/README.md +230 -222
  15. package/docs/api/FluidSystem.md +173 -0
  16. package/docs/concepts/architecture-overview.md +204 -204
  17. package/docs/concepts/rendering-pipeline.md +279 -279
  18. package/docs/concepts/two-layer-architecture.md +229 -229
  19. package/docs/fluid-dynamics.md +97 -0
  20. package/docs/getting-started/first-game.md +354 -354
  21. package/docs/getting-started/installation.md +175 -157
  22. package/docs/modules/collision/README.md +2 -2
  23. package/docs/modules/fluent/README.md +6 -6
  24. package/docs/modules/game/README.md +303 -303
  25. package/docs/modules/isometric-camera.md +2 -2
  26. package/docs/modules/isometric.md +1 -1
  27. package/docs/modules/painter/README.md +328 -328
  28. package/docs/modules/particle/README.md +3 -3
  29. package/docs/modules/shapes/README.md +221 -221
  30. package/docs/modules/shapes/base/euclidian.md +123 -123
  31. package/docs/modules/shapes/base/shape.md +262 -262
  32. package/docs/modules/shapes/base/transformable.md +243 -243
  33. package/docs/modules/state/README.md +2 -2
  34. package/docs/modules/util/README.md +1 -1
  35. package/docs/modules/util/camera3d.md +3 -3
  36. package/docs/modules/util/scene3d.md +1 -1
  37. package/package.json +3 -1
  38. package/readme.md +19 -5
  39. package/src/collision/collision.js +75 -0
  40. package/src/game/index.js +2 -1
  41. package/src/game/pipeline.js +3 -3
  42. package/src/game/systems/FluidSystem.js +835 -0
  43. package/src/game/systems/index.js +11 -0
  44. package/src/game/ui/button.js +39 -18
  45. package/src/game/ui/cursor.js +14 -0
  46. package/src/game/ui/fps.js +12 -4
  47. package/src/game/ui/index.js +2 -0
  48. package/src/game/ui/stepper.js +549 -0
  49. package/src/game/ui/theme.js +121 -0
  50. package/src/game/ui/togglebutton.js +9 -3
  51. package/src/game/ui/tooltip.js +11 -4
  52. package/src/math/fluid.js +507 -0
  53. package/src/math/index.js +2 -0
  54. package/src/mixins/anchor.js +17 -7
  55. package/src/motion/tweenetik.js +16 -0
  56. package/src/shapes/index.js +1 -0
  57. package/src/util/camera3d.js +218 -12
  58. package/types/fluent.d.ts +361 -0
  59. package/types/game.d.ts +303 -0
  60. package/types/index.d.ts +144 -5
  61. package/types/math.d.ts +361 -0
  62. package/types/motion.d.ts +271 -0
  63. package/types/particle.d.ts +373 -0
  64. package/types/shapes.d.ts +107 -9
  65. package/types/util.d.ts +353 -0
  66. package/types/webgl.d.ts +109 -0
  67. package/disk_example.png +0 -0
  68. package/tde.png +0 -0
@@ -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
@@ -2,15 +2,12 @@ import { GameObject, Sphere3D } from "../../../src/index.js";
2
2
  import { polarToCartesian } from "../../../src/math/gr.js";
3
3
  import { CONFIG } from "./config.js";
4
4
 
5
- // Star shader configuration
6
- const STAR_SHADER_CONFIG = {
7
- useShader: true,
8
- shaderType: "star",
9
- shaderUniforms: {
10
- uStarColor: [1.0, 0.85, 0.3], // Golden yellow
11
- uTemperature: 5500, // K (slightly cooler than Sun)
12
- uActivityLevel: 0.75, // Moderate surface activity
13
- },
5
+ // Performance tuning: reduce update frequency for expensive operations
6
+ const PERF_CONFIG = {
7
+ geometryUpdateThreshold: 0.02, // Only regenerate geometry if radius changes by 2%
8
+ uniformUpdateInterval: 2, // Update shader uniforms every N frames
9
+ breathingEnabled: true, // Toggle breathing effect
10
+ stressColorEnabled: true, // Toggle dynamic color shifts
14
11
  };
15
12
 
16
13
  export class Star extends GameObject {
@@ -48,6 +45,11 @@ export class Star extends GameObject {
48
45
  this.tidalProgress = 0; // External tidal progress from FSM (0-1)
49
46
  this.tidalFlare = 0; // 0-1, sudden brightness burst at disruption start
50
47
  this.tidalWobble = 0; // 0-1, violent geometry wobble during trauma
48
+
49
+ // Performance optimization state
50
+ this._frameCount = 0;
51
+ this._lastGeometryRadius = 0;
52
+ this._cachedUniforms = null;
51
53
  }
52
54
 
53
55
  init() {
@@ -93,109 +95,73 @@ export class Star extends GameObject {
93
95
  const massRatio = this.mass / this.initialMass;
94
96
 
95
97
  // === NON-LINEAR SIZE COLLAPSE ===
96
- // Star resists at first (internal pressure), then collapses rapidly
97
- // Use a power curve: slow start, rapid end
98
- const collapseProgress = 1 - massRatio; // 0 = full, 1 = gone
99
- const resistanceCurve = Math.pow(collapseProgress, 0.5); // sqrt = resists early
100
- const effectiveMassRatio = 1 - resistanceCurve;
98
+ // Use sqrt for resistance curve (star resists early, then collapses)
99
+ const collapseProgress = 1 - massRatio;
100
+ const effectiveMassRatio = 1 - Math.sqrt(collapseProgress);
101
101
 
102
102
  // Base radius with non-linear collapse
103
103
  this.currentRadius = this.baseRadius * Math.max(0.05, effectiveMassRatio);
104
104
 
105
- // Don't update geometry if star is consumed
105
+ // Don't update if star is consumed
106
106
  if (this.currentRadius <= 0 || this.mass <= 0) {
107
107
  return;
108
108
  }
109
109
 
110
- // === TIDAL STRETCH (Spaghettification) ===
111
- // Create comet/teardrop shape pointed toward black hole
112
- const dist = Math.sqrt(this.x * this.x + (this.z || 0) * (this.z || 0)) || 1;
110
+ // === TIDAL STRETCH (Simplified) ===
111
+ const zVal = this.z || 0;
112
+ const distSq = this.x * this.x + zVal * zVal;
113
+ const dist = Math.sqrt(distSq) || 1;
114
+ const invDist = 1 / dist;
113
115
 
114
116
  // Direction toward black hole (unit vector)
115
- let dirX = -this.x / dist;
116
- let dirZ = -(this.z || 0) / dist;
117
+ const dirX = -this.x * invDist;
118
+ const dirZ = -zVal * invDist;
117
119
 
118
120
  // Proximity factor: closer to BH = more stretch
119
- let proximityFactor = Math.max(0, 1 - dist / this.initialOrbitalRadius);
121
+ const proximityFactor = Math.max(0, 1 - dist / this.initialOrbitalRadius);
120
122
 
121
- // Calculate stretch amount based on phase and proximity
123
+ // Simplified stretch calculation
122
124
  if (collapseProgress > 0.8) {
123
- // Very late stage - reduce stretch as star becomes tiny
124
125
  this.tidalStretch = (1 - collapseProgress) * 2;
125
126
  } else {
126
- // Main deformation: builds with tidalProgress and proximity
127
- // tidalProgress is driven by FSM state (0 in approach, ramps in stretch/disrupt)
128
- const baseStretch = this.tidalProgress * 1.2; // Up to 1.2 stretch
129
- const proximityBoost = proximityFactor * 0.5; // Extra stretch when close
130
-
131
- this.tidalStretch = baseStretch + proximityBoost;
132
- this.tidalStretch = Math.min(1.8, this.tidalStretch); // Cap at 1.8
127
+ this.tidalStretch = Math.min(1.8, this.tidalProgress * 1.2 + proximityFactor * 0.5);
133
128
  }
134
129
 
135
- // === BREATHING (Slow, ominous expansion/contraction) ===
136
- // Very slow rhythm - like a dying star's final gasps
137
- // No rapid bouncing - this should feel cosmic, not cartoonish
138
- const breathingAmp = 0.03 * (1 - collapseProgress * 0.5); // Subtle, weakens as disrupted
139
- const breathing = Math.sin(this.pulsationPhase) * breathingAmp;
140
-
141
- // Apply breathing to radius (very subtle)
142
- this.currentRadius *= (1 + breathing);
130
+ // === BREATHING (Optional, can be disabled for performance) ===
131
+ if (PERF_CONFIG.breathingEnabled) {
132
+ const breathingAmp = 0.03 * (1 - collapseProgress * 0.5);
133
+ this.currentRadius *= (1 + Math.sin(this.pulsationPhase) * breathingAmp);
134
+ }
143
135
 
144
- // === STRESS LEVEL ===
145
- // Combines proximity and mass loss - drives surface chaos
146
- // Use power curve so stress stays LOW for most of disruption, then ramps up sharply
147
- // This gives more time to see the red-orange star with surface chaos
148
- const rawStress = proximityFactor * 0.4 + collapseProgress * 0.6; // Reduced weights
149
- // Power of 3 = stays low longer, ramps up sharply at the end
150
- this.stressLevel = Math.min(1, Math.pow(rawStress, 2.5));
136
+ // === STRESS LEVEL (Simplified power curve) ===
137
+ const rawStress = proximityFactor * 0.4 + collapseProgress * 0.6;
138
+ this.stressLevel = Math.min(1, rawStress * rawStress * rawStress); // Cubic approximation
151
139
 
152
140
  // === ACTIVITY & ROTATION ===
153
- const activityLevel = 0.3 + this.stressLevel * 0.7; // 0.3 -> 1.0
154
-
155
- // Angular momentum conservation: shrinking = faster spin
141
+ const activityLevel = 0.3 + this.stressLevel * 0.7;
156
142
  const baseRotationSpeed = CONFIG.star.rotationSpeed ?? 0.5;
157
- const spinUpFactor = 1 / Math.max(0.2, effectiveMassRatio); // Inverse of size
158
- const rotationSpeed = Math.min(10, baseRotationSpeed * spinUpFactor);
159
-
160
- // === COLOR SHIFT ===
161
- // Start deep red-orange, transition through orange → yellow → white
162
- // This lets us see the tidal chaos on a colorful surface before brightening
163
- //
164
- // Phase 1 (stress 0-0.5): Deep red-orange, surface chaos building
165
- // Phase 2 (stress 0.5-0.8): Shift to orange-yellow, intense activity
166
- // Phase 3 (stress 0.8-1.0): Rapid shift to white-hot, death throes
167
-
168
- // Temperature increases with stress (tidal heating is real physics!)
169
- const tempShift = this.stressLevel * this.stressLevel * 2500; // Up to +2500K at max stress
170
- const temperature = (CONFIG.star.temperature ?? 3800) + tempShift;
171
-
172
- // Color transition: red-orange → orange → yellow → white
173
- // R stays high, G increases with stress, B only increases late
174
- let r = 1.0;
175
- let g, b;
176
-
177
- if (this.stressLevel < 0.5) {
178
- // Phase 1: Deep red-orange → orange (stress 0-0.5)
179
- const t = this.stressLevel * 2; // 0 to 1 over this phase
180
- g = 0.35 + t * 0.25; // 0.35 → 0.6
181
- b = 0.15 + t * 0.1; // 0.15 → 0.25
182
- } else if (this.stressLevel < 0.8) {
183
- // Phase 2: Orange → yellow-orange (stress 0.5-0.8)
184
- const t = (this.stressLevel - 0.5) / 0.3; // 0 to 1
185
- g = 0.6 + t * 0.2; // 0.6 → 0.8
186
- b = 0.25 + t * 0.1; // 0.25 → 0.35
143
+ const rotationSpeed = Math.min(10, baseRotationSpeed / Math.max(0.2, effectiveMassRatio));
144
+
145
+ // === COLOR SHIFT (Simplified linear interpolation) ===
146
+ let r = 1.0, g, b;
147
+ const stress = this.stressLevel;
148
+
149
+ if (PERF_CONFIG.stressColorEnabled) {
150
+ // Simplified color: lerp from red-orange to white based on stress
151
+ g = 0.35 + stress * 0.6; // 0.35 0.95
152
+ b = 0.15 + stress * 0.7; // 0.15 0.85
187
153
  } else {
188
- // Phase 3: Yellow-orange → white-hot (stress 0.8-1.0)
189
- const t = (this.stressLevel - 0.8) / 0.2; // 0 to 1
190
- g = 0.8 + t * 0.15; // 0.8 → 0.95
191
- b = 0.35 + t * 0.5; // 0.35 → 0.85 (rapid blue increase = white)
154
+ g = 0.5;
155
+ b = 0.2;
192
156
  }
193
157
 
194
158
  const stressColor = [r, g, b];
195
-
196
- // Expose current color for particle emission
197
159
  this.currentColor = stressColor;
198
160
 
161
+ // Temperature calculation
162
+ const temperature = (CONFIG.star.temperature ?? 3800) + stress * stress * 2500;
163
+
164
+ // === VISUAL UPDATE ===
199
165
  if (!this.visual) {
200
166
  this.visual = new Sphere3D(this.currentRadius, {
201
167
  color: CONFIG.star.color,
@@ -215,23 +181,37 @@ export class Star extends GameObject {
215
181
  uTidalWobble: this.tidalWobble,
216
182
  },
217
183
  });
184
+ this._lastGeometryRadius = this.currentRadius;
218
185
  } else {
219
186
  this.visual.radius = this.currentRadius;
220
- if (this.visual.useShader) {
221
- this.visual.setShaderUniforms({
222
- uStarColor: stressColor,
223
- uTemperature: temperature,
224
- uActivityLevel: activityLevel,
225
- uRotationSpeed: rotationSpeed,
226
- uTidalStretch: this.tidalStretch,
227
- uStretchDirX: dirX,
228
- uStretchDirZ: dirZ,
229
- uStressLevel: this.stressLevel,
230
- uTidalFlare: this.tidalFlare,
231
- uTidalWobble: this.tidalWobble,
232
- });
187
+
188
+ // Only update shader uniforms every N frames
189
+ this._frameCount++;
190
+ if (this._frameCount >= PERF_CONFIG.uniformUpdateInterval) {
191
+ this._frameCount = 0;
192
+
193
+ if (this.visual.useShader) {
194
+ this.visual.setShaderUniforms({
195
+ uStarColor: stressColor,
196
+ uTemperature: temperature,
197
+ uActivityLevel: activityLevel,
198
+ uRotationSpeed: rotationSpeed,
199
+ uTidalStretch: this.tidalStretch,
200
+ uStretchDirX: dirX,
201
+ uStretchDirZ: dirZ,
202
+ uStressLevel: this.stressLevel,
203
+ uTidalFlare: this.tidalFlare,
204
+ uTidalWobble: this.tidalWobble,
205
+ });
206
+ }
207
+ }
208
+
209
+ // Only regenerate geometry if radius changed significantly
210
+ const radiusChange = Math.abs(this.currentRadius - this._lastGeometryRadius) / this._lastGeometryRadius;
211
+ if (radiusChange > PERF_CONFIG.geometryUpdateThreshold) {
212
+ this.visual._generateGeometry();
213
+ this._lastGeometryRadius = this.currentRadius;
233
214
  }
234
- this.visual._generateGeometry();
235
215
  }
236
216
  }
237
217