@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
@@ -0,0 +1,835 @@
1
+ /**
2
+ * FluidSystem - High-level fluid simulation built on ParticleSystem.
3
+ *
4
+ * Integrates SPH physics, collision detection, and boundary handling
5
+ * into a cohesive, configurable fluid simulation system.
6
+ *
7
+ * @example
8
+ * // Create a liquid simulation
9
+ * const fluid = new FluidSystem(game, {
10
+ * maxParticles: 500,
11
+ * particleSize: 20,
12
+ * bounds: { x: 50, y: 50, w: 700, h: 500 },
13
+ * physics: 'liquid',
14
+ * });
15
+ * fluid.spawn(300);
16
+ * game.pipeline.add(fluid);
17
+ *
18
+ * @example
19
+ * // Create a gas simulation with heat zones
20
+ * const gas = new FluidSystem(game, {
21
+ * maxParticles: 200,
22
+ * particleSize: 15,
23
+ * physics: 'gas',
24
+ * gravity: 50,
25
+ * enableHeat: true,
26
+ * });
27
+ *
28
+ * @module game/systems/FluidSystem
29
+ */
30
+
31
+ import { ParticleSystem } from "../../particle/index.js";
32
+ import { ParticleEmitter } from "../../particle/index.js";
33
+ import { Updaters } from "../../particle/index.js";
34
+ import { Collision } from "../../collision/index.js";
35
+ import { computeFluidForces, computeGasForces, blendForces } from "../../math/fluid.js";
36
+ import { zoneTemperature } from "../../math/heat.js";
37
+ import { Easing } from "../../motion/easing.js";
38
+
39
+ /**
40
+ * Default configuration for FluidSystem.
41
+ * @type {Object}
42
+ */
43
+ const DEFAULT_CONFIG = {
44
+ // Particle settings
45
+ maxParticles: 500,
46
+ particleSize: 20,
47
+ particleColor: { r: 100, g: 180, b: 255, a: 0.9 },
48
+
49
+ // Physics mode: 'liquid', 'gas', or 'blend'
50
+ physics: "liquid",
51
+
52
+ // Simulation parameters
53
+ gravity: 200,
54
+ damping: 0.98,
55
+ bounce: 0.3,
56
+ maxSpeed: 400,
57
+
58
+ // SPH fluid parameters
59
+ fluid: {
60
+ smoothingRadius: null, // Defaults to particleSize * 2
61
+ restDensity: 3.0,
62
+ pressureStiffness: 80,
63
+ nearPressureStiffness: 3,
64
+ viscosity: 0.005,
65
+ maxForce: 5000,
66
+ },
67
+
68
+ // Gas parameters
69
+ gas: {
70
+ interactionRadius: null, // Defaults to particleSize * 4
71
+ pressure: 150, // Strong repulsion to spread out
72
+ diffusion: 0.15,
73
+ drag: 0.02,
74
+ turbulence: 50,
75
+ buoyancy: 300, // Base buoyancy (used when heat disabled)
76
+ sinking: 200, // Cold particle sinking force
77
+ repulsion: 300, // Extra repulsion to prevent clumping
78
+ },
79
+
80
+ // Heat zone parameters (for thermal convection in gas mode)
81
+ heat: {
82
+ enabled: false, // Opt-in, auto-enabled in gas mode
83
+ heatZone: 0.88, // Bottom 12% is hot (thermal vent)
84
+ coolZone: 0.25, // Top 25% is cold (ceiling)
85
+ rate: 0.03, // Temperature change rate
86
+ heatMultiplier: 1.5, // Heating strength
87
+ coolMultiplier: 2.0, // Cooling strength
88
+ middleMultiplier: 0.005, // Almost no change in middle
89
+ transitionWidth: 0.08, // Zone boundary sharpness
90
+ neutralTemp: 0.5, // Neutral temperature
91
+ deadZone: 0.15, // No thermal force within this range of neutral
92
+ buoyancy: 300, // Thermal buoyancy force (hot rises)
93
+ sinking: 200, // Thermal sinking force (cold falls)
94
+ },
95
+
96
+ // Collision settings
97
+ collision: {
98
+ enabled: true,
99
+ strength: 5000,
100
+ },
101
+
102
+ // Boundary settings
103
+ boundary: {
104
+ enabled: true,
105
+ strength: 4000,
106
+ radius: null, // Defaults to particleSize * 0.8
107
+ },
108
+
109
+ // Window shake (bottle effect) settings
110
+ shake: {
111
+ enabled: true, // Enable window motion detection
112
+ sensitivity: 2.0, // Force multiplier (higher = more responsive)
113
+ maxForce: 2500, // Cap on shake force
114
+ damping: 0.8, // How quickly shake effect fades (lower = longer effect)
115
+ },
116
+
117
+ // Rendering
118
+ blendMode: "source-over",
119
+ };
120
+
121
+ /**
122
+ * FluidSystem class for fluid dynamics simulation.
123
+ * @extends ParticleSystem
124
+ */
125
+ export class FluidSystem extends ParticleSystem {
126
+ /**
127
+ * Create a new FluidSystem.
128
+ * @param {Game} game - The game instance
129
+ * @param {Object} [options={}] - Configuration options
130
+ * @param {number} [options.maxParticles=500] - Maximum number of particles
131
+ * @param {number} [options.particleSize=20] - Base particle size
132
+ * @param {Object} [options.bounds] - Containment bounds { x, y, w, h }
133
+ * @param {string} [options.physics='liquid'] - Physics mode: 'liquid', 'gas', 'blend'
134
+ * @param {number} [options.gravity=200] - Gravity strength
135
+ * @param {boolean} [options.gravityEnabled=true] - Whether gravity is active
136
+ */
137
+ constructor(game, options = {}) {
138
+ const config = FluidSystem._mergeConfig(options);
139
+
140
+ // Compute center position for debug rendering (Renderable draws centered)
141
+ const boundsX = options.bounds?.x ?? 0;
142
+ const boundsY = options.bounds?.y ?? 0;
143
+ const boundsW = options.width ?? options.bounds?.w ?? 0;
144
+ const boundsH = options.height ?? options.bounds?.h ?? 0;
145
+
146
+ // Initialize parent ParticleSystem with all relevant options
147
+ super(game, {
148
+ maxParticles: config.maxParticles,
149
+ blendMode: config.blendMode,
150
+ updaters: [Updaters.velocity, Updaters.lifetime],
151
+ // Position at CENTER of bounds (debug drawing is centered)
152
+ x: options.x ?? (boundsX + boundsW / 2),
153
+ y: options.y ?? (boundsY + boundsH / 2),
154
+ width: boundsW,
155
+ height: boundsH,
156
+ debug: options.debug ?? false,
157
+ debugColor: options.debugColor ?? "#0f0",
158
+ });
159
+
160
+ /** @type {Object} Merged configuration */
161
+ this.config = config;
162
+
163
+ /** @type {Object|null} Containment bounds { x, y, w, h } */
164
+ this.bounds = options.bounds || null;
165
+
166
+ /** @type {boolean} Whether gravity is active */
167
+ this.gravityEnabled = options.gravityEnabled ?? true;
168
+
169
+ /** @type {number} Current blend factor for physics modes (0=liquid, 1=gas) */
170
+ this.modeMix = config.physics === "gas" ? 1.0 : 0.0;
171
+
172
+ /** @type {number} Target mode for smooth lerping */
173
+ this._targetMode = this.modeMix;
174
+
175
+ /** @type {number} Mode transition speed */
176
+ this._modeLerpSpeed = 5;
177
+
178
+ /** @type {Array<{x: number, y: number}>} Force accumulator */
179
+ this._forces = [];
180
+
181
+ // Window shake tracking (bottle effect)
182
+ this._shake = {
183
+ lastX: window.screenX,
184
+ lastY: window.screenY,
185
+ velocityX: 0,
186
+ velocityY: 0,
187
+ forceX: 0,
188
+ forceY: 0,
189
+ };
190
+
191
+ // Create default emitter for spawning
192
+ this._createEmitter();
193
+ }
194
+
195
+ /**
196
+ * Merge user options with defaults, computing derived values.
197
+ * @private
198
+ * @param {Object} options - User options
199
+ * @returns {Object} Merged configuration
200
+ */
201
+ static _mergeConfig(options) {
202
+ const config = { ...DEFAULT_CONFIG, ...options };
203
+ const size = config.particleSize;
204
+
205
+ // Compute derived values
206
+ config.fluid = { ...DEFAULT_CONFIG.fluid, ...options.fluid };
207
+ config.gas = { ...DEFAULT_CONFIG.gas, ...options.gas };
208
+ config.heat = { ...DEFAULT_CONFIG.heat, ...options.heat };
209
+ config.collision = { ...DEFAULT_CONFIG.collision, ...options.collision };
210
+ config.boundary = { ...DEFAULT_CONFIG.boundary, ...options.boundary };
211
+ config.shake = { ...DEFAULT_CONFIG.shake, ...options.shake };
212
+
213
+ // Set defaults based on particle size
214
+ if (config.fluid.smoothingRadius === null) {
215
+ config.fluid.smoothingRadius = size * 2;
216
+ }
217
+ if (config.gas.interactionRadius === null) {
218
+ config.gas.interactionRadius = size * 4;
219
+ }
220
+ if (config.boundary.radius === null) {
221
+ config.boundary.radius = size * 0.8;
222
+ }
223
+
224
+ return config;
225
+ }
226
+
227
+ /**
228
+ * Create the default particle emitter.
229
+ * @private
230
+ */
231
+ _createEmitter() {
232
+ const { particleSize, particleColor } = this.config;
233
+
234
+ const emitter = new ParticleEmitter({
235
+ rate: 0, // Manual spawning only
236
+ position: { x: 0, y: 0 },
237
+ spread: { x: 100, y: 100 },
238
+ velocity: { x: 0, y: 0 },
239
+ velocitySpread: { x: 10, y: 10 },
240
+ size: { min: particleSize, max: particleSize + 2 },
241
+ lifetime: { min: 99999, max: 99999 },
242
+ color: particleColor,
243
+ shape: "circle",
244
+ });
245
+
246
+ this.addEmitter("fluid", emitter);
247
+ }
248
+
249
+ /**
250
+ * Spawn particles at a position or within bounds.
251
+ * @param {number} count - Number of particles to spawn
252
+ * @param {Object} [options={}] - Spawn options
253
+ * @param {number} [options.x] - Center X (defaults to bounds center)
254
+ * @param {number} [options.y] - Center Y (defaults to bounds center)
255
+ * @param {number} [options.spreadX] - Horizontal spread
256
+ * @param {number} [options.spreadY] - Vertical spread
257
+ */
258
+ spawn(count, options = {}) {
259
+ const emitter = this.emitters.get("fluid");
260
+ if (!emitter) return;
261
+
262
+ // Default to bounds center if available
263
+ let x = options.x;
264
+ let y = options.y;
265
+ let spreadX = options.spreadX ?? 100;
266
+ let spreadY = options.spreadY ?? 100;
267
+
268
+ if (this.bounds && x === undefined) {
269
+ x = this.bounds.x + this.bounds.w / 2;
270
+ y = this.bounds.y + this.bounds.h * 0.6;
271
+ spreadX = Math.min(this.bounds.w * 0.8, 400);
272
+ spreadY = Math.min(this.bounds.h * 0.5, 250);
273
+ }
274
+
275
+ // Update emitter
276
+ emitter.position.x = x ?? this.game.width / 2;
277
+ emitter.position.y = y ?? this.game.height / 2;
278
+ emitter.spread.x = spreadX;
279
+ emitter.spread.y = spreadY;
280
+
281
+ // Spawn particles
282
+ this.burst(count, "fluid");
283
+
284
+ // Initialize custom properties
285
+ for (const p of this.particles) {
286
+ if (!p.custom.initialized) {
287
+ p.custom.initialized = true;
288
+ p.custom.mass = 1;
289
+ p.custom.temperature = 0.5;
290
+ p.vx = (Math.random() - 0.5) * 20;
291
+ p.vy = (Math.random() - 0.5) * 20;
292
+ }
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Set containment bounds.
298
+ * @param {Object} bounds - Bounds { x, y, w, h }
299
+ */
300
+ setBounds(bounds) {
301
+ this.bounds = bounds;
302
+ // Update position and size for debug rendering
303
+ this.x = bounds.x + bounds.w / 2;
304
+ this.y = bounds.y + bounds.h / 2;
305
+ this.width = bounds.w;
306
+ this.height = bounds.h;
307
+ }
308
+
309
+ /**
310
+ * Update the fluid simulation.
311
+ * @param {number} dt - Delta time in seconds
312
+ */
313
+ update(dt) {
314
+ // Clamp dt to prevent physics explosion on tab switch
315
+ dt = Math.min(dt, 0.033);
316
+
317
+ // Smooth mode transition (lerp toward target)
318
+ this.modeMix = Easing.lerp(
319
+ this.modeMix,
320
+ this._targetMode,
321
+ dt * this._modeLerpSpeed
322
+ );
323
+
324
+ const particles = this.particles;
325
+ if (particles.length === 0) {
326
+ super.update(dt);
327
+ return;
328
+ }
329
+
330
+ // Ensure force array is sized correctly
331
+ this._ensureForceArray(particles.length);
332
+
333
+ // Reset forces
334
+ this._resetForces();
335
+
336
+ // Compute physics forces based on mode
337
+ this._computePhysicsForces(particles);
338
+
339
+ // In gas mode, apply additional forces
340
+ if (this.modeMix > 0.5) {
341
+ // Update temperatures based on position in heat zones
342
+ if (this.config.heat.enabled || this.modeMix > 0.95) {
343
+ this._updateTemperatures(particles);
344
+ this._applyThermalForces(particles);
345
+ }
346
+
347
+ // Apply gas repulsion to prevent clumping
348
+ if (this.modeMix > 0.95) {
349
+ this._applyGasRepulsion(particles);
350
+ }
351
+ }
352
+
353
+ // Apply collision separation
354
+ if (this.config.collision.enabled) {
355
+ Collision.applyCircleSeparation(particles, this._forces, {
356
+ strength: this.config.collision.strength,
357
+ useSizeAsRadius: true,
358
+ });
359
+ }
360
+
361
+ // Apply boundary forces
362
+ if (this.bounds && this.config.boundary.enabled) {
363
+ this._applyBoundaryForces(particles);
364
+ }
365
+
366
+ // Apply window shake forces (bottle effect)
367
+ if (this.config.shake.enabled) {
368
+ this._updateShakeForces(dt);
369
+ this._applyShakeForces();
370
+ }
371
+
372
+ // Integrate forces into velocities
373
+ this._integrateForces(particles, dt);
374
+
375
+ // Update particle system (applies velocities to positions)
376
+ super.update(dt);
377
+
378
+ // Clamp to bounds
379
+ if (this.bounds) {
380
+ this._clampBounds(particles);
381
+ }
382
+ }
383
+
384
+ /**
385
+ * Ensure force array has correct size.
386
+ * @private
387
+ * @param {number} count - Required size
388
+ */
389
+ _ensureForceArray(count) {
390
+ while (this._forces.length < count) {
391
+ this._forces.push({ x: 0, y: 0 });
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Reset all forces to zero.
397
+ * @private
398
+ */
399
+ _resetForces() {
400
+ for (let i = 0; i < this._forces.length; i++) {
401
+ this._forces[i].x = 0;
402
+ this._forces[i].y = 0;
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Compute physics forces based on current mode.
408
+ * @private
409
+ * @param {Array} particles - Particle array
410
+ */
411
+ _computePhysicsForces(particles) {
412
+ const { fluid, gas } = this.config;
413
+
414
+ if (this.modeMix < 0.01) {
415
+ // Pure liquid mode
416
+ const result = computeFluidForces(particles, {
417
+ kernel: { smoothingRadius: fluid.smoothingRadius },
418
+ fluid: {
419
+ restDensity: fluid.restDensity,
420
+ pressureStiffness: fluid.pressureStiffness,
421
+ nearPressureStiffness: fluid.nearPressureStiffness,
422
+ viscosity: fluid.viscosity,
423
+ maxForce: fluid.maxForce,
424
+ },
425
+ });
426
+ this._accumulateForces(result.forces);
427
+ } else if (this.modeMix > 0.95) {
428
+ // Pure gas mode - skip fluid forces entirely
429
+ const result = computeGasForces(particles, {
430
+ gas: {
431
+ interactionRadius: gas.interactionRadius,
432
+ pressure: gas.pressure,
433
+ diffusion: gas.diffusion,
434
+ drag: gas.drag,
435
+ turbulence: gas.turbulence,
436
+ buoyancy: gas.buoyancy,
437
+ },
438
+ });
439
+ this._accumulateForces(result.forces);
440
+ } else {
441
+ // Blended mode (transition)
442
+ const liquidResult = computeFluidForces(particles, {
443
+ kernel: { smoothingRadius: fluid.smoothingRadius },
444
+ fluid,
445
+ });
446
+ const gasResult = computeGasForces(particles, { gas });
447
+ const blended = blendForces(
448
+ liquidResult.forces,
449
+ gasResult.forces,
450
+ this.modeMix
451
+ );
452
+ this._accumulateForces(blended);
453
+ }
454
+ }
455
+
456
+ /**
457
+ * Accumulate computed forces into force array.
458
+ * @private
459
+ * @param {Array} forces - Forces to add
460
+ */
461
+ _accumulateForces(forces) {
462
+ const n = Math.min(forces.length, this._forces.length);
463
+ for (let i = 0; i < n; i++) {
464
+ this._forces[i].x += forces[i].x;
465
+ this._forces[i].y += forces[i].y;
466
+ }
467
+ }
468
+
469
+ /**
470
+ * Apply boundary repulsion forces.
471
+ * @private
472
+ * @param {Array} particles - Particle array
473
+ */
474
+ _applyBoundaryForces(particles) {
475
+ const { x, y, w, h } = this.bounds;
476
+ const { radius, strength } = this.config.boundary;
477
+
478
+ const left = x;
479
+ const right = x + w;
480
+ const top = y;
481
+ const bottom = y + h;
482
+
483
+ for (let i = 0; i < particles.length; i++) {
484
+ const p = particles[i];
485
+ const r = p.size * 0.5;
486
+
487
+ const distLeft = p.x - r - left;
488
+ const distRight = right - p.x - r;
489
+ const distTop = p.y - r - top;
490
+ const distBottom = bottom - p.y - r;
491
+
492
+ if (distLeft < radius) {
493
+ const t = Math.max(0, 1 - distLeft / radius);
494
+ this._forces[i].x += strength * t * t;
495
+ }
496
+
497
+ if (distRight < radius) {
498
+ const t = Math.max(0, 1 - distRight / radius);
499
+ this._forces[i].x -= strength * t * t;
500
+ }
501
+
502
+ if (distTop < radius) {
503
+ const t = Math.max(0, 1 - distTop / radius);
504
+ this._forces[i].y += strength * t * t;
505
+ }
506
+
507
+ if (distBottom < radius) {
508
+ const t = Math.max(0, 1 - distBottom / radius);
509
+ this._forces[i].y -= strength * t * t;
510
+ }
511
+ }
512
+ }
513
+
514
+ /**
515
+ * Update particle temperatures based on position in heat zones.
516
+ * Uses zoneTemperature from heat.js for smooth zone transitions.
517
+ * @private
518
+ * @param {Array} particles - Particle array
519
+ */
520
+ _updateTemperatures(particles) {
521
+ if (!this.bounds) return;
522
+
523
+ const { heat } = this.config;
524
+ const containerTop = this.bounds.y;
525
+ const containerHeight = this.bounds.h;
526
+
527
+ for (let i = 0; i < particles.length; i++) {
528
+ const p = particles[i];
529
+ const tCurrent = p.custom.temperature ?? heat.neutralTemp;
530
+
531
+ // Normalize position: 0 = top, 1 = bottom
532
+ const normalized = Math.min(1, Math.max(0,
533
+ (p.y - containerTop) / containerHeight
534
+ ));
535
+
536
+ // Calculate new temperature using zone-based heating/cooling
537
+ const tNext = zoneTemperature(normalized, tCurrent, heat);
538
+ p.custom.temperature = Math.min(1, Math.max(0, tNext));
539
+ }
540
+ }
541
+
542
+ /**
543
+ * Apply thermal convection forces based on particle temperature.
544
+ * Hot particles rise, cold particles sink, with a dead zone for stability.
545
+ * @private
546
+ * @param {Array} particles - Particle array
547
+ */
548
+ _applyThermalForces(particles) {
549
+ const { heat } = this.config;
550
+ const neutral = heat.neutralTemp;
551
+ const deadZone = heat.deadZone;
552
+
553
+ for (let i = 0; i < particles.length; i++) {
554
+ const p = particles[i];
555
+ const temp = p.custom.temperature ?? neutral;
556
+ const tempDelta = temp - neutral;
557
+
558
+ // Only apply force if outside dead zone
559
+ if (Math.abs(tempDelta) > deadZone) {
560
+ let thermalForce = 0;
561
+
562
+ if (tempDelta > 0) {
563
+ // Buoyancy: hot rises (negative = up)
564
+ const excess = tempDelta - deadZone;
565
+ thermalForce = -excess * heat.buoyancy * 2;
566
+ } else {
567
+ // Sinking: cold falls (positive = down)
568
+ const excess = -tempDelta - deadZone;
569
+ thermalForce = excess * heat.sinking * 2;
570
+ }
571
+
572
+ this._forces[i].y += thermalForce;
573
+ }
574
+ }
575
+ }
576
+
577
+ /**
578
+ * Apply extra repulsion between gas particles to prevent clumping.
579
+ * Uses cubic falloff for strong close-range repulsion.
580
+ * @private
581
+ * @param {Array} particles - Particle array
582
+ */
583
+ _applyGasRepulsion(particles) {
584
+ const { gas } = this.config;
585
+ const radius = gas.interactionRadius;
586
+ const r2 = radius * radius;
587
+ const strength = gas.repulsion || 200;
588
+ const n = particles.length;
589
+
590
+ for (let i = 0; i < n; i++) {
591
+ const pi = particles[i];
592
+ for (let j = i + 1; j < n; j++) {
593
+ const pj = particles[j];
594
+ const dx = pi.x - pj.x;
595
+ const dy = pi.y - pj.y;
596
+ const dist2 = dx * dx + dy * dy;
597
+
598
+ if (dist2 >= r2 || dist2 < 1) continue;
599
+
600
+ const dist = Math.sqrt(dist2);
601
+ const t = 1 - dist / radius;
602
+ const force = strength * t * t * t; // Cubic falloff
603
+
604
+ const fx = (dx / dist) * force;
605
+ const fy = (dy / dist) * force;
606
+
607
+ this._forces[i].x += fx;
608
+ this._forces[i].y += fy;
609
+ this._forces[j].x -= fx;
610
+ this._forces[j].y -= fy;
611
+ }
612
+ }
613
+ }
614
+
615
+ /**
616
+ * Track window movement and calculate shake forces.
617
+ * Creates a "bottle shaking" effect when the browser window is moved rapidly.
618
+ * @private
619
+ * @param {number} dt - Delta time
620
+ */
621
+ _updateShakeForces(dt) {
622
+ const { shake } = this.config;
623
+ if (!shake.enabled) return;
624
+
625
+ const currentX = window.screenX;
626
+ const currentY = window.screenY;
627
+
628
+ // Calculate window velocity (pixels per second)
629
+ const dx = currentX - this._shake.lastX;
630
+ const dy = currentY - this._shake.lastY;
631
+
632
+ // Only calculate velocity if dt is reasonable (avoid spikes on tab switch)
633
+ if (dt > 0 && dt < 0.1) {
634
+ this._shake.velocityX = dx / dt;
635
+ this._shake.velocityY = dy / dt;
636
+ }
637
+
638
+ // Store current position for next frame
639
+ this._shake.lastX = currentX;
640
+ this._shake.lastY = currentY;
641
+
642
+ // Apply inertia: particles resist window movement
643
+ // When window moves right, particles feel a force to the left (and vice versa)
644
+ const targetForceX = -this._shake.velocityX * shake.sensitivity;
645
+ const targetForceY = -this._shake.velocityY * shake.sensitivity;
646
+
647
+ // Smooth the force application with damping
648
+ this._shake.forceX = Easing.lerp(this._shake.forceX, targetForceX, 1 - shake.damping);
649
+ this._shake.forceY = Easing.lerp(this._shake.forceY, targetForceY, 1 - shake.damping);
650
+
651
+ // Clamp to max force
652
+ const forceMag = Math.sqrt(
653
+ this._shake.forceX * this._shake.forceX +
654
+ this._shake.forceY * this._shake.forceY
655
+ );
656
+ if (forceMag > shake.maxForce) {
657
+ const scale = shake.maxForce / forceMag;
658
+ this._shake.forceX *= scale;
659
+ this._shake.forceY *= scale;
660
+ }
661
+ }
662
+
663
+ /**
664
+ * Apply shake forces to all particles.
665
+ * @private
666
+ */
667
+ _applyShakeForces() {
668
+ const fx = this._shake.forceX;
669
+ const fy = this._shake.forceY;
670
+
671
+ // Skip if force is negligible
672
+ if (Math.abs(fx) < 1 && Math.abs(fy) < 1) return;
673
+
674
+ for (let i = 0; i < this._forces.length; i++) {
675
+ this._forces[i].x += fx;
676
+ this._forces[i].y += fy;
677
+ }
678
+ }
679
+
680
+ /**
681
+ * Integrate forces into particle velocities.
682
+ * In gas mode, temperature affects mass (hot=light, cold=heavy).
683
+ * @private
684
+ * @param {Array} particles - Particle array
685
+ * @param {number} dt - Delta time
686
+ */
687
+ _integrateForces(particles, dt) {
688
+ const { gravity, damping, maxSpeed, heat } = this.config;
689
+ const maxSpeed2 = maxSpeed * maxSpeed;
690
+
691
+ // Gas mode uses less damping (floatier)
692
+ const gasDamping = 0.995;
693
+ const effectiveDamping = Easing.lerp(damping, gasDamping, this.modeMix);
694
+
695
+ for (let i = 0; i < particles.length; i++) {
696
+ const p = particles[i];
697
+ const f = this._forces[i];
698
+ const baseMass = p.custom.mass || 1;
699
+
700
+ let mass, effectiveGravity;
701
+
702
+ if (this.modeMix > 0.5) {
703
+ // GAS MODE: Temperature affects density/mass
704
+ // Hot gas is lighter, cold gas is heavier
705
+ const temp = p.custom.temperature ?? heat.neutralTemp;
706
+
707
+ // Mass: 1.5x at cold (temp=0), 0.3x at hot (temp=1)
708
+ mass = baseMass * Easing.lerp(1.5, 0.3, temp);
709
+
710
+ // Gravity affects cold particles more
711
+ effectiveGravity = this.gravityEnabled
712
+ ? gravity * Easing.lerp(1.2, 0.6, temp)
713
+ : 0;
714
+ } else {
715
+ // LIQUID MODE: Uniform density
716
+ mass = baseMass;
717
+ effectiveGravity = this.gravityEnabled ? gravity : 0;
718
+ }
719
+
720
+ // Apply forces
721
+ p.vx += (f.x / mass) * dt;
722
+ p.vy += (f.y / mass + effectiveGravity) * dt;
723
+
724
+ // Apply damping
725
+ p.vx *= effectiveDamping;
726
+ p.vy *= effectiveDamping;
727
+
728
+ // Clamp speed
729
+ const speed2 = p.vx * p.vx + p.vy * p.vy;
730
+ if (speed2 > maxSpeed2) {
731
+ const inv = maxSpeed / Math.sqrt(speed2);
732
+ p.vx *= inv;
733
+ p.vy *= inv;
734
+ }
735
+ }
736
+ }
737
+
738
+ /**
739
+ * Clamp particles to bounds with bounce.
740
+ * @private
741
+ * @param {Array} particles - Particle array
742
+ */
743
+ _clampBounds(particles) {
744
+ const { x, y, w, h } = this.bounds;
745
+ const { bounce } = this.config;
746
+
747
+ for (let i = 0; i < particles.length; i++) {
748
+ const p = particles[i];
749
+ const r = p.size * 0.5;
750
+
751
+ const left = x + r;
752
+ const right = x + w - r;
753
+ const top = y + r;
754
+ const bottom = y + h - r;
755
+
756
+ if (p.x < left) {
757
+ p.x = left;
758
+ p.vx = Math.abs(p.vx) * bounce;
759
+ } else if (p.x > right) {
760
+ p.x = right;
761
+ p.vx = -Math.abs(p.vx) * bounce;
762
+ }
763
+
764
+ if (p.y < top) {
765
+ p.y = top;
766
+ p.vy = Math.abs(p.vy) * bounce;
767
+ } else if (p.y > bottom) {
768
+ p.y = bottom;
769
+ p.vy = -Math.abs(p.vy) * bounce;
770
+ }
771
+ }
772
+ }
773
+
774
+ /**
775
+ * Reset all particles to initial spawn state.
776
+ */
777
+ reset() {
778
+ const count = this.particles.length;
779
+ this.particles.length = 0;
780
+ this._forces.length = 0;
781
+ this.spawn(count);
782
+ }
783
+
784
+ /**
785
+ * Toggle gravity on/off.
786
+ * @returns {boolean} New gravity state
787
+ */
788
+ toggleGravity() {
789
+ this.gravityEnabled = !this.gravityEnabled;
790
+ return this.gravityEnabled;
791
+ }
792
+
793
+ /**
794
+ * Set physics mode with smooth transition.
795
+ * @param {string|number} mode - 'liquid', 'gas', or a number 0-1 for blend
796
+ * @param {boolean} [instant=false] - If true, snap immediately without lerping
797
+ */
798
+ setPhysicsMode(mode, instant = false) {
799
+ let target;
800
+ if (mode === "liquid") {
801
+ target = 0;
802
+ } else if (mode === "gas") {
803
+ target = 1;
804
+ } else if (typeof mode === "number") {
805
+ target = Math.max(0, Math.min(1, mode));
806
+ } else {
807
+ return;
808
+ }
809
+
810
+ this._targetMode = target;
811
+
812
+ if (instant) {
813
+ this.modeMix = target;
814
+ }
815
+ }
816
+
817
+ /**
818
+ * Get the current physics mode as a string.
819
+ * @returns {string} 'liquid', 'gas', or 'blending'
820
+ */
821
+ getPhysicsMode() {
822
+ if (this.modeMix < 0.01) return "liquid";
823
+ if (this.modeMix > 0.99) return "gas";
824
+ return "blending";
825
+ }
826
+
827
+ /**
828
+ * Check if heat physics is currently active.
829
+ * @returns {boolean} True if heat physics is enabled
830
+ */
831
+ isHeatEnabled() {
832
+ return this.config.heat.enabled || this.modeMix > 0.5;
833
+ }
834
+ }
835
+