babylon.quarks-editor 0.17.7

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 (91) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +84 -0
  4. package/dist/core/binding.d.ts +43 -0
  5. package/dist/core/binding.js +82 -0
  6. package/dist/core/collision.d.ts +20 -0
  7. package/dist/core/collision.js +39 -0
  8. package/dist/core/colors.d.ts +31 -0
  9. package/dist/core/colors.js +60 -0
  10. package/dist/core/effectTree.d.ts +25 -0
  11. package/dist/core/effectTree.js +115 -0
  12. package/dist/core/groups.d.ts +12 -0
  13. package/dist/core/groups.js +27 -0
  14. package/dist/core/history.d.ts +25 -0
  15. package/dist/core/history.js +69 -0
  16. package/dist/core/loadEffect.d.ts +8 -0
  17. package/dist/core/loadEffect.js +87 -0
  18. package/dist/core/scrub.d.ts +11 -0
  19. package/dist/core/scrub.js +24 -0
  20. package/dist/core/shapes.d.ts +31 -0
  21. package/dist/core/shapes.js +98 -0
  22. package/dist/core/systems.d.ts +25 -0
  23. package/dist/core/systems.js +76 -0
  24. package/dist/core/textureExport.d.ts +9 -0
  25. package/dist/core/textureExport.js +58 -0
  26. package/dist/core/timeline.d.ts +35 -0
  27. package/dist/core/timeline.js +69 -0
  28. package/dist/core/values.d.ts +31 -0
  29. package/dist/core/values.js +81 -0
  30. package/dist/index.d.ts +15 -0
  31. package/dist/index.js +9 -0
  32. package/dist/react/CurveEditor.d.ts +12 -0
  33. package/dist/react/CurveEditor.js +106 -0
  34. package/dist/react/EffectEditor.d.ts +22 -0
  35. package/dist/react/EffectEditor.js +30 -0
  36. package/dist/react/EffectEditorHost.d.ts +42 -0
  37. package/dist/react/EffectEditorHost.js +247 -0
  38. package/dist/react/GradientEditor.d.ts +7 -0
  39. package/dist/react/GradientEditor.js +72 -0
  40. package/dist/react/ModuleSection.d.ts +9 -0
  41. package/dist/react/ModuleSection.js +24 -0
  42. package/dist/react/PromptDialog.d.ts +14 -0
  43. package/dist/react/PromptDialog.js +45 -0
  44. package/dist/react/SubEmittersModule.d.ts +10 -0
  45. package/dist/react/SubEmittersModule.js +39 -0
  46. package/dist/react/TimelinePanel.d.ts +32 -0
  47. package/dist/react/TimelinePanel.js +225 -0
  48. package/dist/react/TimelineTrackRow.d.ts +23 -0
  49. package/dist/react/TimelineTrackRow.js +107 -0
  50. package/dist/react/ValueField.d.ts +13 -0
  51. package/dist/react/ValueField.js +19 -0
  52. package/dist/react/behaviorModules.d.ts +23 -0
  53. package/dist/react/behaviorModules.js +139 -0
  54. package/dist/react/fields.d.ts +24 -0
  55. package/dist/react/fields.js +30 -0
  56. package/dist/react/index.d.ts +20 -0
  57. package/dist/react/index.js +14 -0
  58. package/dist/react/modules.d.ts +35 -0
  59. package/dist/react/modules.js +439 -0
  60. package/dist/react/theme.d.ts +26 -0
  61. package/dist/react/theme.js +78 -0
  62. package/package.json +63 -0
  63. package/src/core/binding.ts +105 -0
  64. package/src/core/collision.ts +40 -0
  65. package/src/core/colors.ts +84 -0
  66. package/src/core/effectTree.ts +143 -0
  67. package/src/core/groups.ts +29 -0
  68. package/src/core/history.ts +77 -0
  69. package/src/core/loadEffect.ts +102 -0
  70. package/src/core/scrub.ts +28 -0
  71. package/src/core/shapes.ts +133 -0
  72. package/src/core/systems.ts +98 -0
  73. package/src/core/textureExport.ts +63 -0
  74. package/src/core/timeline.ts +94 -0
  75. package/src/core/values.ts +113 -0
  76. package/src/index.ts +32 -0
  77. package/src/react/CurveEditor.tsx +168 -0
  78. package/src/react/EffectEditor.tsx +111 -0
  79. package/src/react/EffectEditorHost.tsx +377 -0
  80. package/src/react/GradientEditor.tsx +127 -0
  81. package/src/react/ModuleSection.tsx +54 -0
  82. package/src/react/PromptDialog.tsx +85 -0
  83. package/src/react/SubEmittersModule.tsx +80 -0
  84. package/src/react/TimelinePanel.tsx +354 -0
  85. package/src/react/TimelineTrackRow.tsx +267 -0
  86. package/src/react/ValueField.tsx +60 -0
  87. package/src/react/behaviorModules.tsx +658 -0
  88. package/src/react/fields.tsx +76 -0
  89. package/src/react/index.ts +43 -0
  90. package/src/react/modules.tsx +1034 -0
  91. package/src/react/theme.ts +85 -0
@@ -0,0 +1,658 @@
1
+ import React from 'react';
2
+ import {
3
+ ApplyCollision,
4
+ ApplyForce,
5
+ ChangeEmitDirection,
6
+ ColorBySpeed,
7
+ ConstantValue,
8
+ ForceOverLife,
9
+ FrameOverLife,
10
+ Gradient,
11
+ InheritVelocity,
12
+ IntervalValue,
13
+ LimitSpeedOverLife,
14
+ Noise,
15
+ OrbitOverLife,
16
+ RotationBySpeed,
17
+ RotationOverLife,
18
+ SizeBySpeed,
19
+ SpeedOverLife,
20
+ TurbulenceField,
21
+ Vector3,
22
+ VelocityOverLife,
23
+ WidthOverLength,
24
+ } from 'babylon.quarks';
25
+ import type {Behavior} from 'babylon.quarks';
26
+ import {EffectBinding} from '../core/binding';
27
+ import {ensureGroundResolver} from '../core/collision';
28
+ import {DEFAULT_GRADIENT_STOPS, buildGradient, findBehavior, readGradientStops} from '../core/colors';
29
+ import {buildCurve, buildScalar, readPieces, readScalar} from '../core/values';
30
+ import {GradientEditor} from './GradientEditor';
31
+ import {CurveEditor} from './CurveEditor';
32
+ import {ModuleSection} from './ModuleSection';
33
+ import {NumberField, Row, SelectField} from './fields';
34
+ import {ValueField} from './ValueField';
35
+
36
+ interface ModuleProps {
37
+ binding: EffectBinding;
38
+ }
39
+
40
+ function removeBehaviorOfType(behaviors: Behavior[], type: string): void {
41
+ for (let i = behaviors.length - 1; i >= 0; i--) {
42
+ if ((behaviors[i] as {type?: string}).type === type) {
43
+ behaviors.splice(i, 1);
44
+ }
45
+ }
46
+ }
47
+
48
+ /** Shared shell for modules backed by a single toggleable quarks behavior. */
49
+ function BehaviorModule<T extends Behavior>(props: {
50
+ binding: EffectBinding;
51
+ title: string;
52
+ type: string;
53
+ create: () => T;
54
+ children: (behavior: T) => React.ReactNode;
55
+ }) {
56
+ const behavior = findBehavior<T>(props.binding.system.behaviors, props.type);
57
+ return (
58
+ <ModuleSection
59
+ title={props.title}
60
+ enabled={!!behavior}
61
+ defaultOpen={false}
62
+ onToggle={(enabled) =>
63
+ props.binding.apply((s) => {
64
+ removeBehaviorOfType(s.behaviors, props.type);
65
+ if (enabled) {
66
+ s.addBehavior(props.create());
67
+ }
68
+ })
69
+ }
70
+ >
71
+ {behavior ? props.children(behavior) : null}
72
+ </ModuleSection>
73
+ );
74
+ }
75
+
76
+ export function SpeedOverLifeModule({binding}: ModuleProps) {
77
+ return (
78
+ <BehaviorModule<SpeedOverLife>
79
+ binding={binding}
80
+ title="Speed over Lifetime"
81
+ type="SpeedOverLife"
82
+ create={() => new SpeedOverLife(buildScalar({mode: 'curve', value: 1, min: 0, max: 1, curve: [1, 0.75, 0.5, 0]}) as never)}
83
+ >
84
+ {(behavior) => (
85
+ <div style={{marginTop: 6}}>
86
+ <CurveEditor
87
+ pieces={readPieces(behavior.speed)}
88
+ maxValue={2}
89
+ onChange={(pieces) => binding.apply(() => (behavior.speed = buildCurve(pieces) as never))}
90
+ />
91
+ </div>
92
+ )}
93
+ </BehaviorModule>
94
+ );
95
+ }
96
+
97
+ export function LimitSpeedOverLifeModule({binding}: ModuleProps) {
98
+ return (
99
+ <BehaviorModule<LimitSpeedOverLife>
100
+ binding={binding}
101
+ title="Limit Speed over Lifetime"
102
+ type="LimitSpeedOverLife"
103
+ create={() => new LimitSpeedOverLife(buildScalar({mode: 'curve', value: 1, min: 0, max: 1, curve: [4, 2, 1, 1]}) as never, 0.5)}
104
+ >
105
+ {(behavior) => (
106
+ <>
107
+ <div style={{marginTop: 6}}>
108
+ <CurveEditor
109
+ pieces={readPieces(behavior.speed)}
110
+ maxValue={8}
111
+ onChange={(pieces) => binding.apply(() => (behavior.speed = buildCurve(pieces) as never))}
112
+ />
113
+ </div>
114
+ <Row label="Dampen">
115
+ <NumberField
116
+ value={behavior.dampen}
117
+ min={0}
118
+ step={0.05}
119
+ onChange={(v) => binding.apply(() => (behavior.dampen = Math.min(1, v)))}
120
+ />
121
+ </Row>
122
+ </>
123
+ )}
124
+ </BehaviorModule>
125
+ );
126
+ }
127
+
128
+ export function ForceOverLifeModule({binding}: ModuleProps) {
129
+ return (
130
+ <BehaviorModule<ForceOverLife>
131
+ binding={binding}
132
+ title="Force over Lifetime"
133
+ type="ForceOverLife"
134
+ create={() => new ForceOverLife(new ConstantValue(0), new ConstantValue(2), new ConstantValue(0))}
135
+ >
136
+ {(behavior) => (
137
+ <>
138
+ <ValueField label="X" generator={behavior.x} curveMax={10} onChange={(g) => binding.apply(() => (behavior.x = g))} />
139
+ <ValueField label="Y" generator={behavior.y} curveMax={10} onChange={(g) => binding.apply(() => (behavior.y = g))} />
140
+ <ValueField label="Z" generator={behavior.z} curveMax={10} onChange={(g) => binding.apply(() => (behavior.z = g))} />
141
+ </>
142
+ )}
143
+ </BehaviorModule>
144
+ );
145
+ }
146
+
147
+ export function VelocityOverLifeModule({binding}: ModuleProps) {
148
+ return (
149
+ <BehaviorModule<VelocityOverLife>
150
+ binding={binding}
151
+ title="Velocity over Lifetime"
152
+ type="VelocityOverLife"
153
+ create={() => new VelocityOverLife(new ConstantValue(0), new ConstantValue(1), new ConstantValue(0))}
154
+ >
155
+ {(behavior) => (
156
+ <>
157
+ <Row label="Space">
158
+ <SelectField
159
+ value={behavior.space}
160
+ options={[
161
+ {value: 'local', label: 'Local'},
162
+ {value: 'world', label: 'World'},
163
+ ]}
164
+ onChange={(v) => binding.apply(() => (behavior.space = v))}
165
+ />
166
+ </Row>
167
+ <ValueField label="Linear X" generator={behavior.linearX} curveMax={10} onChange={(g) => binding.apply(() => (behavior.linearX = g))} />
168
+ <ValueField label="Linear Y" generator={behavior.linearY} curveMax={10} onChange={(g) => binding.apply(() => (behavior.linearY = g))} />
169
+ <ValueField label="Linear Z" generator={behavior.linearZ} curveMax={10} onChange={(g) => binding.apply(() => (behavior.linearZ = g))} />
170
+ <ValueField label="Orbital X" generator={behavior.orbitalX} curveMax={Math.PI * 4} onChange={(g) => binding.apply(() => (behavior.orbitalX = g))} />
171
+ <ValueField label="Orbital Y" generator={behavior.orbitalY} curveMax={Math.PI * 4} onChange={(g) => binding.apply(() => (behavior.orbitalY = g))} />
172
+ <ValueField label="Orbital Z" generator={behavior.orbitalZ} curveMax={Math.PI * 4} onChange={(g) => binding.apply(() => (behavior.orbitalZ = g))} />
173
+ </>
174
+ )}
175
+ </BehaviorModule>
176
+ );
177
+ }
178
+
179
+ export function InheritVelocityModule({binding}: ModuleProps) {
180
+ return (
181
+ <BehaviorModule<InheritVelocity>
182
+ binding={binding}
183
+ title="Inherit Velocity"
184
+ type="InheritVelocity"
185
+ create={() => new InheritVelocity(new ConstantValue(1), 'initial')}
186
+ >
187
+ {(behavior) => (
188
+ <>
189
+ <Row label="Mode">
190
+ <SelectField
191
+ value={behavior.mode}
192
+ options={[
193
+ {value: 'initial', label: 'Initial'},
194
+ {value: 'current', label: 'Current'},
195
+ ]}
196
+ onChange={(v) => binding.apply(() => (behavior.mode = v))}
197
+ />
198
+ </Row>
199
+ <ValueField
200
+ label="Multiplier"
201
+ generator={behavior.multiplier}
202
+ curveMax={2}
203
+ onChange={(g) => binding.apply(() => (behavior.multiplier = g))}
204
+ />
205
+ </>
206
+ )}
207
+ </BehaviorModule>
208
+ );
209
+ }
210
+
211
+ export function GravityModule({binding}: ModuleProps) {
212
+ return (
213
+ <BehaviorModule<ApplyForce>
214
+ binding={binding}
215
+ title="Gravity"
216
+ type="ApplyForce"
217
+ create={() => new ApplyForce(new Vector3(0, -1, 0), new ConstantValue(9.81))}
218
+ >
219
+ {(behavior) => (
220
+ <Row label="Strength">
221
+ <NumberField
222
+ value={readScalar(behavior.magnitude).value}
223
+ step={0.5}
224
+ onChange={(v) => binding.apply(() => (behavior.magnitude = new ConstantValue(v)))}
225
+ />
226
+ </Row>
227
+ )}
228
+ </BehaviorModule>
229
+ );
230
+ }
231
+
232
+ export function RotationOverLifeModule({binding}: ModuleProps) {
233
+ return (
234
+ <BehaviorModule<RotationOverLife>
235
+ binding={binding}
236
+ title="Rotation over Lifetime"
237
+ type="RotationOverLife"
238
+ create={() => new RotationOverLife(new ConstantValue(Math.PI))}
239
+ >
240
+ {(behavior) => (
241
+ <ValueField
242
+ label="Velocity (rad/s)"
243
+ generator={behavior.angularVelocity}
244
+ curveMax={Math.PI * 4}
245
+ onChange={(g) => binding.apply(() => (behavior.angularVelocity = g))}
246
+ />
247
+ )}
248
+ </BehaviorModule>
249
+ );
250
+ }
251
+
252
+ export function NoiseModule({binding}: ModuleProps) {
253
+ return (
254
+ <BehaviorModule<Noise>
255
+ binding={binding}
256
+ title="Noise"
257
+ type="Noise"
258
+ create={() => new Noise(new ConstantValue(1), new ConstantValue(1), new ConstantValue(1), new ConstantValue(0))}
259
+ >
260
+ {(behavior) => (
261
+ <>
262
+ <ValueField
263
+ label="Frequency"
264
+ generator={behavior.frequency}
265
+ min={0}
266
+ curveMax={10}
267
+ onChange={(g) => binding.apply(() => (behavior.frequency = g))}
268
+ />
269
+ <ValueField
270
+ label="Strength"
271
+ generator={behavior.power}
272
+ min={0}
273
+ curveMax={10}
274
+ onChange={(g) => binding.apply(() => (behavior.power = g))}
275
+ />
276
+ <ValueField
277
+ label="Position amount"
278
+ generator={behavior.positionAmount}
279
+ min={0}
280
+ curveMax={2}
281
+ onChange={(g) => binding.apply(() => (behavior.positionAmount = g))}
282
+ />
283
+ <ValueField
284
+ label="Rotation amount"
285
+ generator={behavior.rotationAmount}
286
+ min={0}
287
+ curveMax={Math.PI}
288
+ onChange={(g) => binding.apply(() => (behavior.rotationAmount = g))}
289
+ />
290
+ </>
291
+ )}
292
+ </BehaviorModule>
293
+ );
294
+ }
295
+
296
+ /** Three numeric inputs for a Vector3 field, replacing the whole vector on any edit. */
297
+ function Vector3Row(props: {label: string; value: Vector3; min?: number; step?: number; onChange: (v: Vector3) => void}) {
298
+ const set = (axis: 'x' | 'y' | 'z', n: number) => {
299
+ const next = props.value.clone();
300
+ next[axis] = n;
301
+ props.onChange(next);
302
+ };
303
+ return (
304
+ <Row label={props.label}>
305
+ <div style={{display: 'flex', gap: 6}}>
306
+ <NumberField value={props.value.x} min={props.min} step={props.step} onChange={(n) => set('x', n)} />
307
+ <NumberField value={props.value.y} min={props.min} step={props.step} onChange={(n) => set('y', n)} />
308
+ <NumberField value={props.value.z} min={props.min} step={props.step} onChange={(n) => set('z', n)} />
309
+ </div>
310
+ </Row>
311
+ );
312
+ }
313
+
314
+ export function TurbulenceModule({binding}: ModuleProps) {
315
+ return (
316
+ <BehaviorModule<TurbulenceField>
317
+ binding={binding}
318
+ title="Turbulence"
319
+ type="TurbulenceField"
320
+ create={() => new TurbulenceField(new Vector3(1, 1, 1), 3, new Vector3(1, 1, 1), new Vector3(1, 1, 1))}
321
+ >
322
+ {(behavior) => (
323
+ <>
324
+ {/* scale divides particle position, so keep each axis strictly positive. */}
325
+ <Vector3Row label="Scale" value={behavior.scale} min={0.01} step={0.1} onChange={(v) => binding.apply(() => (behavior.scale = v))} />
326
+ <Row label="Octaves">
327
+ <NumberField
328
+ value={behavior.octaves}
329
+ min={1}
330
+ step={1}
331
+ onChange={(v) => binding.apply(() => (behavior.octaves = Math.max(1, Math.round(v))))}
332
+ />
333
+ </Row>
334
+ <Vector3Row
335
+ label="Strength"
336
+ value={behavior.velocityMultiplier}
337
+ step={0.5}
338
+ onChange={(v) => binding.apply(() => (behavior.velocityMultiplier = v))}
339
+ />
340
+ <Vector3Row
341
+ label="Time scale"
342
+ value={behavior.timeScale}
343
+ step={0.1}
344
+ onChange={(v) => binding.apply(() => (behavior.timeScale = v))}
345
+ />
346
+ </>
347
+ )}
348
+ </BehaviorModule>
349
+ );
350
+ }
351
+
352
+ export function EmitDirectionModule({binding}: ModuleProps) {
353
+ return (
354
+ <BehaviorModule<ChangeEmitDirection>
355
+ binding={binding}
356
+ title="Randomize Direction"
357
+ type="ChangeEmitDirection"
358
+ create={() => new ChangeEmitDirection(new IntervalValue(0, Math.PI / 6))}
359
+ >
360
+ {(behavior) => (
361
+ <ValueField
362
+ label="Angle (rad)"
363
+ generator={behavior.angle as never}
364
+ min={0}
365
+ curveMax={Math.PI}
366
+ onChange={(g) => binding.apply(() => (behavior.angle = g as never))}
367
+ />
368
+ )}
369
+ </BehaviorModule>
370
+ );
371
+ }
372
+
373
+ export function CollisionModule({binding}: ModuleProps) {
374
+ // Ensure the shared ground-plane resolver exists before any ApplyCollision is created,
375
+ // otherwise its update() would dereference an undefined resolver.
376
+ const resolver = ensureGroundResolver();
377
+ return (
378
+ <BehaviorModule<ApplyCollision>
379
+ binding={binding}
380
+ title="Collision"
381
+ type="ApplyCollision"
382
+ create={() => new ApplyCollision(ensureGroundResolver(), 0.5)}
383
+ >
384
+ {(behavior) => (
385
+ <>
386
+ <Row label="Bounce">
387
+ <NumberField
388
+ value={behavior.bounce}
389
+ min={0}
390
+ step={0.05}
391
+ onChange={(v) => binding.apply(() => (behavior.bounce = v))}
392
+ />
393
+ </Row>
394
+ <Row label="Floor Y">
395
+ <NumberField
396
+ value={resolver.y}
397
+ step={0.1}
398
+ onChange={(v) => binding.apply(() => (resolver.y = v))}
399
+ />
400
+ </Row>
401
+ <div style={{fontSize: 11, opacity: 0.6, marginTop: 4}}>
402
+ Collides against a ground plane. Bounce is saved with the effect; Floor Y is an
403
+ editor preview aid (a host can register its own colliders).
404
+ </div>
405
+ </>
406
+ )}
407
+ </BehaviorModule>
408
+ );
409
+ }
410
+
411
+ /** Shared speed-range rows for the "by Speed" trio. */
412
+ function SpeedRangeRows({binding, behavior}: {binding: EffectBinding; behavior: {speedRange: IntervalValue}}) {
413
+ return (
414
+ <Row label="Speed range">
415
+ <div style={{display: 'flex', gap: 6}}>
416
+ <NumberField
417
+ value={behavior.speedRange.a}
418
+ min={0}
419
+ onChange={(v) => binding.apply(() => (behavior.speedRange = new IntervalValue(v, behavior.speedRange.b)))}
420
+ />
421
+ <NumberField
422
+ value={behavior.speedRange.b}
423
+ min={0}
424
+ onChange={(v) => binding.apply(() => (behavior.speedRange = new IntervalValue(behavior.speedRange.a, v)))}
425
+ />
426
+ </div>
427
+ </Row>
428
+ );
429
+ }
430
+
431
+ export function ColorBySpeedModule({binding}: ModuleProps) {
432
+ return (
433
+ <BehaviorModule<ColorBySpeed>
434
+ binding={binding}
435
+ title="Color by Speed"
436
+ type="ColorBySpeed"
437
+ create={() => new ColorBySpeed(buildGradient(DEFAULT_GRADIENT_STOPS), new IntervalValue(0, 5))}
438
+ >
439
+ {(behavior) => (
440
+ <>
441
+ <div style={{marginTop: 6}}>
442
+ <GradientEditor
443
+ stops={behavior.color instanceof Gradient ? readGradientStops(behavior.color) : DEFAULT_GRADIENT_STOPS}
444
+ onChange={(stops) => binding.apply(() => (behavior.color = buildGradient(stops)))}
445
+ />
446
+ </div>
447
+ <SpeedRangeRows binding={binding} behavior={behavior} />
448
+ </>
449
+ )}
450
+ </BehaviorModule>
451
+ );
452
+ }
453
+
454
+ export function SizeBySpeedModule({binding}: ModuleProps) {
455
+ return (
456
+ <BehaviorModule<SizeBySpeed>
457
+ binding={binding}
458
+ title="Size by Speed"
459
+ type="SizeBySpeed"
460
+ create={() => new SizeBySpeed(buildScalar({mode: 'curve', value: 1, min: 0, max: 1, curve: [0.5, 0.8, 1.2, 1.5]}) as never, new IntervalValue(0, 5))}
461
+ >
462
+ {(behavior) => (
463
+ <>
464
+ <div style={{marginTop: 6}}>
465
+ <CurveEditor
466
+ pieces={readPieces(behavior.size as never)}
467
+ maxValue={2}
468
+ onChange={(pieces) => binding.apply(() => (behavior.size = buildCurve(pieces) as never))}
469
+ />
470
+ </div>
471
+ <SpeedRangeRows binding={binding} behavior={behavior} />
472
+ </>
473
+ )}
474
+ </BehaviorModule>
475
+ );
476
+ }
477
+
478
+ export function RotationBySpeedModule({binding}: ModuleProps) {
479
+ return (
480
+ <BehaviorModule<RotationBySpeed>
481
+ binding={binding}
482
+ title="Rotation by Speed"
483
+ type="RotationBySpeed"
484
+ create={() => new RotationBySpeed(new ConstantValue(Math.PI), new IntervalValue(0, 5))}
485
+ >
486
+ {(behavior) => (
487
+ <>
488
+ <ValueField
489
+ label="Velocity (rad/s)"
490
+ generator={behavior.angularVelocity}
491
+ curveMax={Math.PI * 4}
492
+ onChange={(g) => binding.apply(() => (behavior.angularVelocity = g))}
493
+ />
494
+ <SpeedRangeRows binding={binding} behavior={behavior} />
495
+ </>
496
+ )}
497
+ </BehaviorModule>
498
+ );
499
+ }
500
+
501
+ export function OrbitOverLifeModule({binding}: ModuleProps) {
502
+ return (
503
+ <BehaviorModule<OrbitOverLife>
504
+ binding={binding}
505
+ title="Orbit over Lifetime"
506
+ type="OrbitOverLife"
507
+ create={() => new OrbitOverLife(new ConstantValue(Math.PI), new Vector3(0, 1, 0))}
508
+ >
509
+ {(behavior) => (
510
+ <ValueField
511
+ label="Orbit speed"
512
+ generator={behavior.orbitSpeed}
513
+ curveMax={Math.PI * 4}
514
+ onChange={(g) => binding.apply(() => (behavior.orbitSpeed = g))}
515
+ />
516
+ )}
517
+ </BehaviorModule>
518
+ );
519
+ }
520
+
521
+ export function WidthOverTrailModule({binding}: ModuleProps) {
522
+ return (
523
+ <BehaviorModule<WidthOverLength>
524
+ binding={binding}
525
+ title="Width over Trail"
526
+ type="WidthOverLength"
527
+ create={() => new WidthOverLength(buildScalar({mode: 'curve', value: 1, min: 0, max: 1, curve: [1, 0.75, 0.4, 0]}) as never)}
528
+ >
529
+ {(behavior) => (
530
+ <div style={{marginTop: 6}}>
531
+ <CurveEditor
532
+ pieces={readPieces(behavior.width as never)}
533
+ maxValue={2}
534
+ onChange={(pieces) => binding.apply(() => (behavior.width = buildCurve(pieces) as never))}
535
+ />
536
+ </div>
537
+ )}
538
+ </BehaviorModule>
539
+ );
540
+ }
541
+
542
+ export function TextureSheetModule({binding}: ModuleProps) {
543
+ const system = binding.system;
544
+ const frameBehavior = findBehavior<FrameOverLife>(system.behaviors, 'FrameOverLife');
545
+ const tiles = Math.max(1, system.uTileCount * system.vTileCount);
546
+ const textureUrl = (system.texture as {url?: string} | null)?.url;
547
+ const u = Math.max(1, system.uTileCount);
548
+ const v = Math.max(1, system.vTileCount);
549
+ // Start tile → cell (row-major, top-left origin — matches the shader's tile UV mapping).
550
+ const startIdx = Math.min(Math.max(0, Math.round(readScalar(system.startTileIndex as never).value)), tiles - 1);
551
+ const startCol = startIdx % u;
552
+ const startRow = Math.floor(startIdx / u);
553
+ return (
554
+ <ModuleSection title="Texture Sheet Animation" defaultOpen={false}>
555
+ {textureUrl && (
556
+ <div style={{marginTop: 6}}>
557
+ <div style={{position: 'relative', width: 218, border: '1px solid #2b3761', borderRadius: 6, overflow: 'hidden'}}>
558
+ <img src={textureUrl} alt="texture atlas" style={{width: '100%', display: 'block'}} />
559
+ <div
560
+ style={{
561
+ position: 'absolute',
562
+ inset: 0,
563
+ pointerEvents: 'none',
564
+ backgroundImage:
565
+ `repeating-linear-gradient(to right, rgba(158,185,255,0.55) 0 1px, transparent 1px calc(100% / ${u})),` +
566
+ `repeating-linear-gradient(to bottom, rgba(158,185,255,0.55) 0 1px, transparent 1px calc(100% / ${v}))`,
567
+ }}
568
+ />
569
+ {/* Highlight the start tile so it's obvious which sprite playback begins on. */}
570
+ <div
571
+ style={{
572
+ position: 'absolute',
573
+ pointerEvents: 'none',
574
+ left: `${(startCol * 100) / u}%`,
575
+ top: `${(startRow * 100) / v}%`,
576
+ width: `${100 / u}%`,
577
+ height: `${100 / v}%`,
578
+ boxSizing: 'border-box',
579
+ border: '2px solid #ffce6b',
580
+ background: 'rgba(255, 206, 107, 0.22)',
581
+ }}
582
+ >
583
+ <span
584
+ style={{
585
+ position: 'absolute',
586
+ top: 1,
587
+ left: 2,
588
+ fontSize: 9,
589
+ fontWeight: 700,
590
+ color: '#ffce6b',
591
+ textShadow: '0 0 3px #000',
592
+ }}
593
+ >
594
+ {startIdx}
595
+ </span>
596
+ </div>
597
+ </div>
598
+ <div style={{fontSize: 11, color: '#b7c6ea', marginTop: 3}}>
599
+ {u} × {v} = {tiles} tiles; frame indices 0–{tiles - 1}; start tile{' '}
600
+ <span style={{color: '#ffce6b', fontWeight: 600}}>{startIdx}</span>
601
+ </div>
602
+ </div>
603
+ )}
604
+ <Row label="Tiles U">
605
+ <NumberField value={system.uTileCount} min={1} step={1} onChange={(v) => binding.apply((s) => (s.uTileCount = Math.round(v)))} />
606
+ </Row>
607
+ <Row label="Tiles V">
608
+ <NumberField value={system.vTileCount} min={1} step={1} onChange={(v) => binding.apply((s) => (s.vTileCount = Math.round(v)))} />
609
+ </Row>
610
+ <Row label="Blend tiles">
611
+ <input
612
+ type="checkbox"
613
+ className="qe-hover"
614
+ checked={system.blendTiles}
615
+ style={{justifySelf: 'start', width: 15, height: 15, accentColor: '#78a5ff', cursor: 'pointer'}}
616
+ onChange={(e) => binding.apply((s) => (s.blendTiles = e.target.checked))}
617
+ />
618
+ </Row>
619
+ <Row label="Start tile">
620
+ <NumberField
621
+ value={readScalar(system.startTileIndex as never).value}
622
+ min={0}
623
+ step={1}
624
+ onChange={(v) => binding.apply((s) => (s.startTileIndex = new ConstantValue(Math.round(v))))}
625
+ />
626
+ </Row>
627
+ <Row label="Animate frames">
628
+ <input
629
+ type="checkbox"
630
+ className="qe-hover"
631
+ checked={!!frameBehavior}
632
+ style={{justifySelf: 'start', width: 15, height: 15, accentColor: '#78a5ff', cursor: 'pointer'}}
633
+ onChange={(e) =>
634
+ binding.apply((s) => {
635
+ removeBehaviorOfType(s.behaviors, 'FrameOverLife');
636
+ if (e.target.checked) {
637
+ s.addBehavior(
638
+ new FrameOverLife(
639
+ buildScalar({mode: 'curve', value: 0, min: 0, max: tiles - 1, curve: [0, (tiles - 1) / 3, (2 * (tiles - 1)) / 3, tiles - 1]}) as never
640
+ )
641
+ );
642
+ }
643
+ })
644
+ }
645
+ />
646
+ </Row>
647
+ {frameBehavior && (
648
+ <div style={{marginTop: 6}}>
649
+ <CurveEditor
650
+ pieces={readPieces(frameBehavior.frame)}
651
+ maxValue={Math.max(1, tiles - 1)}
652
+ onChange={(pieces) => binding.apply(() => (frameBehavior.frame = buildCurve(pieces) as never))}
653
+ />
654
+ </div>
655
+ )}
656
+ </ModuleSection>
657
+ );
658
+ }