@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.
- package/demos/fluid-simple.html +22 -0
- package/demos/fluid.html +37 -0
- package/demos/index.html +2 -0
- package/demos/js/blob.js +18 -5
- package/demos/js/fluid-simple.js +253 -0
- package/demos/js/fluid.js +527 -0
- package/demos/js/tde/accretiondisk.js +64 -11
- package/demos/js/tde/blackholescene.js +2 -2
- package/demos/js/tde/config.js +2 -2
- package/demos/js/tde/index.js +152 -27
- package/demos/js/tde/lensedstarfield.js +32 -25
- package/demos/js/tde/tdestar.js +78 -98
- package/demos/js/tde/tidalstream.js +23 -7
- package/docs/README.md +230 -222
- package/docs/api/FluidSystem.md +173 -0
- package/docs/concepts/architecture-overview.md +204 -204
- package/docs/concepts/rendering-pipeline.md +279 -279
- package/docs/concepts/two-layer-architecture.md +229 -229
- package/docs/fluid-dynamics.md +97 -0
- package/docs/getting-started/first-game.md +354 -354
- package/docs/getting-started/installation.md +175 -157
- package/docs/modules/collision/README.md +2 -2
- package/docs/modules/fluent/README.md +6 -6
- package/docs/modules/game/README.md +303 -303
- package/docs/modules/isometric-camera.md +2 -2
- package/docs/modules/isometric.md +1 -1
- package/docs/modules/painter/README.md +328 -328
- package/docs/modules/particle/README.md +3 -3
- package/docs/modules/shapes/README.md +221 -221
- package/docs/modules/shapes/base/euclidian.md +123 -123
- package/docs/modules/shapes/base/shape.md +262 -262
- package/docs/modules/shapes/base/transformable.md +243 -243
- package/docs/modules/state/README.md +2 -2
- package/docs/modules/util/README.md +1 -1
- package/docs/modules/util/camera3d.md +3 -3
- package/docs/modules/util/scene3d.md +1 -1
- package/package.json +3 -1
- package/readme.md +19 -5
- package/src/collision/collision.js +75 -0
- package/src/game/index.js +2 -1
- package/src/game/pipeline.js +3 -3
- package/src/game/systems/FluidSystem.js +835 -0
- package/src/game/systems/index.js +11 -0
- package/src/game/ui/button.js +39 -18
- package/src/game/ui/cursor.js +14 -0
- package/src/game/ui/fps.js +12 -4
- package/src/game/ui/index.js +2 -0
- package/src/game/ui/stepper.js +549 -0
- package/src/game/ui/theme.js +121 -0
- package/src/game/ui/togglebutton.js +9 -3
- package/src/game/ui/tooltip.js +11 -4
- package/src/math/fluid.js +507 -0
- package/src/math/index.js +2 -0
- package/src/mixins/anchor.js +17 -7
- package/src/motion/tweenetik.js +16 -0
- package/src/shapes/index.js +1 -0
- package/src/util/camera3d.js +218 -12
- package/types/fluent.d.ts +361 -0
- package/types/game.d.ts +303 -0
- package/types/index.d.ts +144 -5
- package/types/math.d.ts +361 -0
- package/types/motion.d.ts +271 -0
- package/types/particle.d.ts +373 -0
- package/types/shapes.d.ts +107 -9
- package/types/util.d.ts +353 -0
- package/types/webgl.d.ts +109 -0
- package/disk_example.png +0 -0
- 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
|
+
|