@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,527 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fluid & Gas Explorer Demo
|
|
3
|
+
*
|
|
4
|
+
* Advanced fluid simulation demo using FluidSystem with thermal physics.
|
|
5
|
+
* Demonstrates gas mode with heat zones, thermal convection, and temperature coloring.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Smooth Particle Hydrodynamics for liquid behavior
|
|
9
|
+
* - Gas mode with thermal convection (hot rises, cold sinks)
|
|
10
|
+
* - Temperature-based coloring (blue=cold, red=hot)
|
|
11
|
+
* - Visual heat zone indicators
|
|
12
|
+
* - Mouse interaction (hover to stir, click to push)
|
|
13
|
+
*/
|
|
14
|
+
import {
|
|
15
|
+
Game,
|
|
16
|
+
FluidSystem,
|
|
17
|
+
FPSCounter,
|
|
18
|
+
Painter,
|
|
19
|
+
Button,
|
|
20
|
+
ToggleButton,
|
|
21
|
+
Stepper,
|
|
22
|
+
Position,
|
|
23
|
+
Rectangle,
|
|
24
|
+
ShapeGOFactory,
|
|
25
|
+
HorizontalLayout,
|
|
26
|
+
} from "../../src/index.js";
|
|
27
|
+
import { zoneTemperature } from "../../src/math/heat.js";
|
|
28
|
+
import { Easing } from "../../src/motion/easing.js";
|
|
29
|
+
|
|
30
|
+
// Base particle size - all proportional values derive from this
|
|
31
|
+
const PARTICLE_SIZE = Math.PI * 10;
|
|
32
|
+
|
|
33
|
+
const CONFIG = {
|
|
34
|
+
particleSize: PARTICLE_SIZE,
|
|
35
|
+
|
|
36
|
+
sim: {
|
|
37
|
+
maxParticles: Math.floor(PARTICLE_SIZE * 11),
|
|
38
|
+
gravity: 200,
|
|
39
|
+
damping: 0.98,
|
|
40
|
+
bounce: 0.3,
|
|
41
|
+
maxSpeed: 400,
|
|
42
|
+
},
|
|
43
|
+
fluid: {
|
|
44
|
+
smoothingRadius: PARTICLE_SIZE * 2,
|
|
45
|
+
restDensity: 3.0,
|
|
46
|
+
pressureStiffness: 80,
|
|
47
|
+
nearPressureStiffness: 3,
|
|
48
|
+
viscosity: 0.005,
|
|
49
|
+
maxForce: 5000,
|
|
50
|
+
},
|
|
51
|
+
gas: {
|
|
52
|
+
interactionRadius: PARTICLE_SIZE * 4,
|
|
53
|
+
pressure: 150,
|
|
54
|
+
diffusion: 0.15,
|
|
55
|
+
drag: 0.02,
|
|
56
|
+
turbulence: 50,
|
|
57
|
+
buoyancy: 300,
|
|
58
|
+
sinking: 200,
|
|
59
|
+
repulsion: 300,
|
|
60
|
+
},
|
|
61
|
+
heat: {
|
|
62
|
+
enabled: true,
|
|
63
|
+
heatZone: 0.88,
|
|
64
|
+
coolZone: 0.25,
|
|
65
|
+
rate: 0.03,
|
|
66
|
+
heatMultiplier: 1.5,
|
|
67
|
+
coolMultiplier: 2.0,
|
|
68
|
+
middleMultiplier: 0.005,
|
|
69
|
+
transitionWidth: 0.08,
|
|
70
|
+
neutralTemp: 0.5,
|
|
71
|
+
deadZone: 0.15,
|
|
72
|
+
buoyancy: 300,
|
|
73
|
+
sinking: 200,
|
|
74
|
+
},
|
|
75
|
+
pointer: {
|
|
76
|
+
radius: PARTICLE_SIZE * 6,
|
|
77
|
+
push: 8000,
|
|
78
|
+
pull: 2000,
|
|
79
|
+
},
|
|
80
|
+
visuals: {
|
|
81
|
+
liquid: {
|
|
82
|
+
baseHue: 200,
|
|
83
|
+
hueRange: 40,
|
|
84
|
+
saturation: 75,
|
|
85
|
+
minLight: 50,
|
|
86
|
+
maxLight: 65,
|
|
87
|
+
},
|
|
88
|
+
gas: {
|
|
89
|
+
coldHue: 220,
|
|
90
|
+
hotHue: 0,
|
|
91
|
+
saturation: 85,
|
|
92
|
+
minLight: 45,
|
|
93
|
+
maxLight: 70,
|
|
94
|
+
},
|
|
95
|
+
alpha: 0.9,
|
|
96
|
+
},
|
|
97
|
+
ui: {
|
|
98
|
+
margin: 16,
|
|
99
|
+
width: 130,
|
|
100
|
+
height: 32,
|
|
101
|
+
spacing: 8,
|
|
102
|
+
},
|
|
103
|
+
container: {
|
|
104
|
+
marginX: 80,
|
|
105
|
+
marginY: 150,
|
|
106
|
+
strokeColor: "#22c55e",
|
|
107
|
+
strokeWidth: 2,
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* FluidGasGame - SPH-based fluid simulation demo using FluidSystem.
|
|
113
|
+
*/
|
|
114
|
+
class FluidGasGame extends Game {
|
|
115
|
+
constructor(canvas) {
|
|
116
|
+
super(canvas);
|
|
117
|
+
this.backgroundColor = "#0a0e1a";
|
|
118
|
+
this.enableFluidSize();
|
|
119
|
+
this.pointer = { x: 0, y: 0, down: false };
|
|
120
|
+
this.mode = "liquid";
|
|
121
|
+
this.gravityOn = true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
init() {
|
|
125
|
+
super.init();
|
|
126
|
+
this.pointer.x = this.width * 0.5;
|
|
127
|
+
this.pointer.y = this.height * 0.5;
|
|
128
|
+
|
|
129
|
+
// Calculate container bounds
|
|
130
|
+
this._updateContainerBounds();
|
|
131
|
+
|
|
132
|
+
// Create container outline
|
|
133
|
+
const containerShape = new Rectangle({
|
|
134
|
+
width: this.bounds.w,
|
|
135
|
+
height: this.bounds.h,
|
|
136
|
+
stroke: CONFIG.container.strokeColor,
|
|
137
|
+
lineWidth: CONFIG.container.strokeWidth,
|
|
138
|
+
color: null,
|
|
139
|
+
});
|
|
140
|
+
this.containerRect = ShapeGOFactory.create(this, containerShape, {
|
|
141
|
+
x: this.bounds.x + this.bounds.w / 2,
|
|
142
|
+
y: this.bounds.y + this.bounds.h / 2,
|
|
143
|
+
});
|
|
144
|
+
this.pipeline.add(this.containerRect);
|
|
145
|
+
|
|
146
|
+
// Create FluidSystem - handles all physics internally
|
|
147
|
+
this.fluid = new FluidSystem(this, {
|
|
148
|
+
maxParticles: CONFIG.sim.maxParticles,
|
|
149
|
+
particleSize: CONFIG.particleSize,
|
|
150
|
+
bounds: this.bounds,
|
|
151
|
+
physics: "liquid",
|
|
152
|
+
gravity: CONFIG.sim.gravity,
|
|
153
|
+
damping: CONFIG.sim.damping,
|
|
154
|
+
bounce: CONFIG.sim.bounce,
|
|
155
|
+
maxSpeed: CONFIG.sim.maxSpeed,
|
|
156
|
+
fluid: CONFIG.fluid,
|
|
157
|
+
gas: CONFIG.gas,
|
|
158
|
+
heat: CONFIG.heat,
|
|
159
|
+
blendMode: "source-over",
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Spawn particles
|
|
163
|
+
this.fluid.spawn(CONFIG.sim.maxParticles);
|
|
164
|
+
this.pipeline.add(this.fluid);
|
|
165
|
+
|
|
166
|
+
// Build UI controls
|
|
167
|
+
this._buildUI();
|
|
168
|
+
|
|
169
|
+
// Input events
|
|
170
|
+
this.events.on("inputmove", (e) => {
|
|
171
|
+
this.pointer.x = e.x;
|
|
172
|
+
this.pointer.y = e.y;
|
|
173
|
+
});
|
|
174
|
+
this.events.on("inputdown", () => (this.pointer.down = true));
|
|
175
|
+
this.events.on("inputup", () => (this.pointer.down = false));
|
|
176
|
+
window.addEventListener("keydown", (e) => this._handleKey(e));
|
|
177
|
+
|
|
178
|
+
// Handle resize
|
|
179
|
+
this.onResize = () => this._handleResize();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Handle window resize
|
|
184
|
+
*/
|
|
185
|
+
_handleResize() {
|
|
186
|
+
this._updateContainerBounds();
|
|
187
|
+
|
|
188
|
+
if (this.containerRect) {
|
|
189
|
+
this.containerRect.transform
|
|
190
|
+
.position(this.bounds.x + this.bounds.w / 2, this.bounds.y + this.bounds.h / 2)
|
|
191
|
+
.size(this.bounds.w, this.bounds.h);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (this.fluid) {
|
|
195
|
+
this.fluid.setBounds(this.bounds);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (this.buttonRow) this.buttonRow.markBoundsDirty();
|
|
199
|
+
if (this.stepperRow) this.stepperRow.markBoundsDirty();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
update(dt) {
|
|
203
|
+
dt = Math.min(dt, 0.033);
|
|
204
|
+
|
|
205
|
+
// Update physics mode on FluidSystem
|
|
206
|
+
this.fluid.setPhysicsMode(this.mode);
|
|
207
|
+
this.fluid.gravityEnabled = this.gravityOn;
|
|
208
|
+
|
|
209
|
+
// Apply pointer forces
|
|
210
|
+
this._pointerForces(this.fluid.particles);
|
|
211
|
+
|
|
212
|
+
super.update(dt);
|
|
213
|
+
|
|
214
|
+
// Apply demo-specific coloring
|
|
215
|
+
this._applyColors(this.fluid.particles);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Render with optional heat zone visualization
|
|
220
|
+
*/
|
|
221
|
+
render() {
|
|
222
|
+
super.render();
|
|
223
|
+
|
|
224
|
+
if (this.fluid.modeMix > 0.5 && this.bounds) {
|
|
225
|
+
this._drawHeatZones();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Draw visual indicators for thermal zones
|
|
231
|
+
*/
|
|
232
|
+
_drawHeatZones() {
|
|
233
|
+
const { heatZone, coolZone } = CONFIG.heat;
|
|
234
|
+
const { x, y, w, h } = this.bounds;
|
|
235
|
+
const ctx = this.ctx;
|
|
236
|
+
|
|
237
|
+
const coldZoneHeight = coolZone * h;
|
|
238
|
+
const hotZoneStart = heatZone * h;
|
|
239
|
+
const hotZoneHeight = h - hotZoneStart;
|
|
240
|
+
|
|
241
|
+
const alpha = Math.min(1, (this.fluid.modeMix - 0.5) * 4) * 0.25;
|
|
242
|
+
|
|
243
|
+
ctx.save();
|
|
244
|
+
|
|
245
|
+
// Cold zone at top
|
|
246
|
+
const coldGrad = ctx.createLinearGradient(x, y, x, y + coldZoneHeight);
|
|
247
|
+
coldGrad.addColorStop(0, `rgba(100, 150, 255, ${alpha})`);
|
|
248
|
+
coldGrad.addColorStop(1, `rgba(100, 150, 255, 0)`);
|
|
249
|
+
ctx.fillStyle = coldGrad;
|
|
250
|
+
ctx.fillRect(x, y, w, coldZoneHeight);
|
|
251
|
+
|
|
252
|
+
// Hot zone at bottom
|
|
253
|
+
const hotGrad = ctx.createLinearGradient(x, y + hotZoneStart, x, y + h);
|
|
254
|
+
hotGrad.addColorStop(0, `rgba(255, 100, 50, 0)`);
|
|
255
|
+
hotGrad.addColorStop(1, `rgba(255, 100, 50, ${alpha})`);
|
|
256
|
+
ctx.fillStyle = hotGrad;
|
|
257
|
+
ctx.fillRect(x, y + hotZoneStart, w, hotZoneHeight);
|
|
258
|
+
|
|
259
|
+
// Zone boundary lines
|
|
260
|
+
ctx.strokeStyle = `rgba(100, 150, 255, ${alpha * 0.8})`;
|
|
261
|
+
ctx.lineWidth = 1;
|
|
262
|
+
ctx.setLineDash([5, 5]);
|
|
263
|
+
ctx.beginPath();
|
|
264
|
+
ctx.moveTo(x, y + coldZoneHeight);
|
|
265
|
+
ctx.lineTo(x + w, y + coldZoneHeight);
|
|
266
|
+
ctx.stroke();
|
|
267
|
+
|
|
268
|
+
ctx.strokeStyle = `rgba(255, 100, 50, ${alpha * 0.8})`;
|
|
269
|
+
ctx.beginPath();
|
|
270
|
+
ctx.moveTo(x, y + hotZoneStart);
|
|
271
|
+
ctx.lineTo(x + w, y + hotZoneStart);
|
|
272
|
+
ctx.stroke();
|
|
273
|
+
ctx.setLineDash([]);
|
|
274
|
+
|
|
275
|
+
ctx.restore();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
_buildUI() {
|
|
279
|
+
const { margin, width, height, spacing } = CONFIG.ui;
|
|
280
|
+
const buttonRowWidth = width * 3 + spacing * 2;
|
|
281
|
+
const buttonRowHeight = height;
|
|
282
|
+
const stepperWidth = 130;
|
|
283
|
+
const stepperHeight = 46;
|
|
284
|
+
const stepperRowWidth = stepperWidth * 4 - (spacing + 32);
|
|
285
|
+
|
|
286
|
+
// Button row
|
|
287
|
+
const buttonRow = new HorizontalLayout(this, {
|
|
288
|
+
width: buttonRowWidth,
|
|
289
|
+
height: buttonRowHeight,
|
|
290
|
+
spacing,
|
|
291
|
+
padding: 0,
|
|
292
|
+
anchor: Position.BOTTOM_LEFT,
|
|
293
|
+
margin: margin,
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
this.btnMode = new ToggleButton(this, {
|
|
297
|
+
width,
|
|
298
|
+
height,
|
|
299
|
+
text: "Mode: Liquid",
|
|
300
|
+
startToggled: false,
|
|
301
|
+
onToggle: (on) => {
|
|
302
|
+
this.mode = on ? "gas" : "liquid";
|
|
303
|
+
this.btnMode.text = on ? "Mode: Gas" : "Mode: Liquid";
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
buttonRow.add(this.btnMode);
|
|
307
|
+
|
|
308
|
+
this.btnGravity = new ToggleButton(this, {
|
|
309
|
+
width,
|
|
310
|
+
height,
|
|
311
|
+
text: "Gravity: On",
|
|
312
|
+
startToggled: true,
|
|
313
|
+
onToggle: (on) => {
|
|
314
|
+
this.gravityOn = on;
|
|
315
|
+
this.btnGravity.text = on ? "Gravity: On" : "Gravity: Off";
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
buttonRow.add(this.btnGravity);
|
|
319
|
+
|
|
320
|
+
this.btnReset = new Button(this, {
|
|
321
|
+
width,
|
|
322
|
+
height,
|
|
323
|
+
text: "Reset",
|
|
324
|
+
onClick: () => this.fluid.reset(),
|
|
325
|
+
});
|
|
326
|
+
buttonRow.add(this.btnReset);
|
|
327
|
+
|
|
328
|
+
// Stepper row
|
|
329
|
+
const stepperRow = new HorizontalLayout(this, {
|
|
330
|
+
width: stepperRowWidth,
|
|
331
|
+
height: stepperHeight,
|
|
332
|
+
spacing: spacing + 8,
|
|
333
|
+
padding: 0,
|
|
334
|
+
anchor: Position.BOTTOM_LEFT,
|
|
335
|
+
margin: margin,
|
|
336
|
+
anchorOffsetY: -(buttonRowHeight + spacing),
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
this.gravityStep = new Stepper(this, {
|
|
340
|
+
value: CONFIG.sim.gravity,
|
|
341
|
+
min: 0,
|
|
342
|
+
max: 500,
|
|
343
|
+
step: 25,
|
|
344
|
+
label: "Gravity",
|
|
345
|
+
valueWidth: 48,
|
|
346
|
+
buttonSize: 26,
|
|
347
|
+
height: 26,
|
|
348
|
+
onChange: (val) => {
|
|
349
|
+
this.fluid.config.gravity = val;
|
|
350
|
+
},
|
|
351
|
+
});
|
|
352
|
+
stepperRow.add(this.gravityStep);
|
|
353
|
+
|
|
354
|
+
this.viscosityStep = new Stepper(this, {
|
|
355
|
+
value: CONFIG.fluid.viscosity * 1000,
|
|
356
|
+
min: 0,
|
|
357
|
+
max: 100,
|
|
358
|
+
step: 5,
|
|
359
|
+
label: "Viscosity",
|
|
360
|
+
valueWidth: 48,
|
|
361
|
+
buttonSize: 26,
|
|
362
|
+
height: 26,
|
|
363
|
+
formatValue: (v) => (v / 1000).toFixed(2),
|
|
364
|
+
onChange: (val) => {
|
|
365
|
+
this.fluid.config.fluid.viscosity = val / 1000;
|
|
366
|
+
},
|
|
367
|
+
});
|
|
368
|
+
stepperRow.add(this.viscosityStep);
|
|
369
|
+
|
|
370
|
+
this.pressureStep = new Stepper(this, {
|
|
371
|
+
value: CONFIG.fluid.pressureStiffness,
|
|
372
|
+
min: 10,
|
|
373
|
+
max: 500,
|
|
374
|
+
step: 20,
|
|
375
|
+
label: "Pressure",
|
|
376
|
+
valueWidth: 48,
|
|
377
|
+
buttonSize: 26,
|
|
378
|
+
height: 26,
|
|
379
|
+
onChange: (val) => {
|
|
380
|
+
this.fluid.config.fluid.pressureStiffness = val;
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
stepperRow.add(this.pressureStep);
|
|
384
|
+
|
|
385
|
+
this.bounceStep = new Stepper(this, {
|
|
386
|
+
value: Math.round(CONFIG.sim.bounce * 100),
|
|
387
|
+
min: 0,
|
|
388
|
+
max: 100,
|
|
389
|
+
step: 5,
|
|
390
|
+
label: "Bounce",
|
|
391
|
+
valueWidth: 48,
|
|
392
|
+
buttonSize: 26,
|
|
393
|
+
height: 26,
|
|
394
|
+
formatValue: (v) => `${v}%`,
|
|
395
|
+
onChange: (val) => {
|
|
396
|
+
this.fluid.config.bounce = val / 100;
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
stepperRow.add(this.bounceStep);
|
|
400
|
+
|
|
401
|
+
this.pipeline.add(buttonRow);
|
|
402
|
+
this.pipeline.add(stepperRow);
|
|
403
|
+
|
|
404
|
+
this.buttonRow = buttonRow;
|
|
405
|
+
this.stepperRow = stepperRow;
|
|
406
|
+
|
|
407
|
+
this.pipeline.add(new FPSCounter(this, { anchor: "bottom-right" }));
|
|
408
|
+
|
|
409
|
+
buttonRow.markBoundsDirty();
|
|
410
|
+
stepperRow.markBoundsDirty();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Apply visual coloring based on mode and temperature
|
|
415
|
+
*/
|
|
416
|
+
_applyColors(particles) {
|
|
417
|
+
const { liquid, gas, alpha } = CONFIG.visuals;
|
|
418
|
+
const maxSpeed = CONFIG.sim.maxSpeed;
|
|
419
|
+
const containerTop = this.bounds?.y || 0;
|
|
420
|
+
const containerHeight = this.bounds?.h || this.height || 1;
|
|
421
|
+
|
|
422
|
+
for (let i = 0; i < particles.length; i++) {
|
|
423
|
+
const p = particles[i];
|
|
424
|
+
const speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
|
|
425
|
+
const speedNorm = Math.min(1, speed / maxSpeed);
|
|
426
|
+
|
|
427
|
+
let hue, saturation, light;
|
|
428
|
+
|
|
429
|
+
if (this.fluid.modeMix < 0.5) {
|
|
430
|
+
// LIQUID MODE: Blue water
|
|
431
|
+
hue = liquid.baseHue - speedNorm * liquid.hueRange;
|
|
432
|
+
saturation = liquid.saturation;
|
|
433
|
+
light = Easing.lerp(liquid.minLight, liquid.maxLight, 0.3 + speedNorm * 0.5);
|
|
434
|
+
} else {
|
|
435
|
+
// GAS MODE: Temperature-based coloring
|
|
436
|
+
const temp = p.custom.temperature ?? CONFIG.heat.neutralTemp;
|
|
437
|
+
|
|
438
|
+
// Blue -> Purple -> Magenta -> Red
|
|
439
|
+
if (temp < 0.33) {
|
|
440
|
+
hue = gas.coldHue + (280 - gas.coldHue) * (temp / 0.33);
|
|
441
|
+
} else if (temp < 0.66) {
|
|
442
|
+
hue = 280 + 40 * ((temp - 0.33) / 0.33);
|
|
443
|
+
} else {
|
|
444
|
+
hue = 320 + 40 * ((temp - 0.66) / 0.34);
|
|
445
|
+
if (hue >= 360) hue -= 360;
|
|
446
|
+
}
|
|
447
|
+
saturation = gas.saturation;
|
|
448
|
+
light = Easing.lerp(gas.minLight, gas.maxLight, 0.4 + temp * 0.4);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const [r, g, b] = Painter.colors.hslToRgb(hue, saturation, light);
|
|
452
|
+
p.color.r = r;
|
|
453
|
+
p.color.g = g;
|
|
454
|
+
p.color.b = b;
|
|
455
|
+
p.color.a = alpha;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Apply pointer interaction forces
|
|
461
|
+
*/
|
|
462
|
+
_pointerForces(particles) {
|
|
463
|
+
const { radius, push, pull } = CONFIG.pointer;
|
|
464
|
+
const r2 = radius * radius;
|
|
465
|
+
const mx = this.pointer.x;
|
|
466
|
+
const my = this.pointer.y;
|
|
467
|
+
|
|
468
|
+
for (let i = 0; i < particles.length; i++) {
|
|
469
|
+
const p = particles[i];
|
|
470
|
+
const dx = mx - p.x;
|
|
471
|
+
const dy = my - p.y;
|
|
472
|
+
const dist2 = dx * dx + dy * dy;
|
|
473
|
+
if (dist2 >= r2 || dist2 < 1) continue;
|
|
474
|
+
|
|
475
|
+
const dist = Math.sqrt(dist2);
|
|
476
|
+
const t = 1 - dist / radius;
|
|
477
|
+
const strength = (this.pointer.down ? -push : pull) * t * t;
|
|
478
|
+
|
|
479
|
+
// Apply directly to velocity (simpler than accumulating forces)
|
|
480
|
+
const dt = 0.016; // Approximate frame time
|
|
481
|
+
p.vx += (dx / dist) * strength * dt;
|
|
482
|
+
p.vy += (dy / dist) * strength * dt;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
_updateContainerBounds() {
|
|
487
|
+
const { marginX, marginY } = CONFIG.container;
|
|
488
|
+
this.bounds = {
|
|
489
|
+
x: marginX,
|
|
490
|
+
y: marginY,
|
|
491
|
+
w: this.width - marginX * 2,
|
|
492
|
+
h: this.height - marginY * 2,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
_handleKey(e) {
|
|
497
|
+
if (e.key === "1") {
|
|
498
|
+
this.mode = "liquid";
|
|
499
|
+
this.btnMode.toggle(false);
|
|
500
|
+
this.btnMode.text = "Mode: Liquid";
|
|
501
|
+
} else if (e.key === "2") {
|
|
502
|
+
this.mode = "gas";
|
|
503
|
+
this.btnMode.toggle(true);
|
|
504
|
+
this.btnMode.text = "Mode: Gas";
|
|
505
|
+
} else if (e.key === " ") {
|
|
506
|
+
e.preventDefault();
|
|
507
|
+
const newMode = this.mode === "liquid" ? "gas" : "liquid";
|
|
508
|
+
this.mode = newMode;
|
|
509
|
+
this.btnMode.toggle(newMode === "gas");
|
|
510
|
+
this.btnMode.text = newMode === "gas" ? "Mode: Gas" : "Mode: Liquid";
|
|
511
|
+
} else if (e.key === "r" || e.key === "R") {
|
|
512
|
+
this.fluid.reset();
|
|
513
|
+
} else if (e.key === "g" || e.key === "G") {
|
|
514
|
+
this.gravityOn = !this.gravityOn;
|
|
515
|
+
this.btnGravity.toggle(this.gravityOn);
|
|
516
|
+
this.btnGravity.text = this.gravityOn ? "Gravity: On" : "Gravity: Off";
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
export { FluidGasGame };
|
|
522
|
+
|
|
523
|
+
window.addEventListener("load", () => {
|
|
524
|
+
const canvas = document.getElementById("game");
|
|
525
|
+
const game = new FluidGasGame(canvas);
|
|
526
|
+
game.start();
|
|
527
|
+
});
|
|
@@ -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:
|
|
21
|
-
particleLifetime:
|
|
22
|
-
spawnRate:
|
|
20
|
+
maxParticles: 6000,
|
|
21
|
+
particleLifetime: 1000,
|
|
22
|
+
spawnRate: 500,
|
|
23
23
|
|
|
24
24
|
// Orbital physics
|
|
25
25
|
baseOrbitalSpeed: 0.8,
|
|
@@ -41,7 +41,7 @@ const DISK_CONFIG = {
|
|
|
41
41
|
colorCool: { r: 180, g: 40, b: 40 }, // Outer (deep red)
|
|
42
42
|
|
|
43
43
|
sizeMin: 1,
|
|
44
|
-
sizeMax: 2
|
|
44
|
+
sizeMax: 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
|
|
282
|
-
//
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
294
|
-
|
|
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:
|
|
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:
|
|
50
|
+
jets: 1, // Jets: always on top
|
|
51
51
|
};
|
|
52
52
|
}
|
|
53
53
|
|
package/demos/js/tde/config.js
CHANGED
|
@@ -6,7 +6,7 @@ export const CONFIG = {
|
|
|
6
6
|
|
|
7
7
|
// Phase durations (seconds)
|
|
8
8
|
durations: {
|
|
9
|
-
approach:
|
|
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:
|
|
48
|
+
starCount: 5000,
|
|
49
49
|
},
|
|
50
50
|
|
|
51
51
|
// Accretion disk settings
|