@call-me-sensei/toonlab 0.2.0 → 0.3.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 (205) hide show
  1. package/AGENTS.md +127 -4
  2. package/ATTRIBUTION.md +41 -3
  3. package/README.md +231 -34
  4. package/docs/characters.md +144 -0
  5. package/docs/debug-panel.md +126 -0
  6. package/docs/docs.css +589 -0
  7. package/docs/environment.md +186 -0
  8. package/docs/getting-started.md +180 -0
  9. package/docs/index.html +14 -0
  10. package/docs/lab-architecture.md +100 -0
  11. package/docs/lighting.md +825 -0
  12. package/docs/main.jsx +752 -0
  13. package/docs/mcp.md +97 -0
  14. package/docs/post-processing.md +98 -0
  15. package/docs/settings-reference.md +1878 -0
  16. package/docs/shader-constants.md +77 -0
  17. package/docs/sky.md +182 -0
  18. package/docs/style-labs.md +309 -0
  19. package/docs/texture-lab.md +135 -0
  20. package/docs/toon-shading.md +183 -0
  21. package/docs/tsl-conventions.md +167 -0
  22. package/docs/vegetation-sky.md +275 -0
  23. package/docs/water.md +430 -0
  24. package/docs/weather.md +200 -0
  25. package/docs/world-scale.md +111 -0
  26. package/mcp/server.mjs +610 -0
  27. package/mcp/style-lab-tools.mjs +371 -0
  28. package/mcp/vite-plugin.mjs +174 -0
  29. package/mcp/workspace.mjs +397 -0
  30. package/package.json +64 -5
  31. package/src/ambientfx/INTEGRATION.md +164 -0
  32. package/src/ambientfx/ambientFxPresets.js +83 -0
  33. package/src/ambientfx/ambientFxSettings.js +368 -0
  34. package/src/ambientfx/emitters.js +169 -0
  35. package/src/ambientfx/index.js +5 -0
  36. package/src/ambientfx/particleBackbone.js +493 -0
  37. package/src/ambientfx/stylizedAmbientFx.js +450 -0
  38. package/src/assetlib/ambientcg.js +117 -0
  39. package/src/assetlib/assetRef.js +91 -0
  40. package/src/assetlib/importedEntry.js +40 -0
  41. package/src/assetlib/index.js +23 -0
  42. package/src/assetlib/kaykit.js +192 -0
  43. package/src/assetlib/kaykitStaticIndex.js +700 -0
  44. package/src/assetlib/loadImported.js +143 -0
  45. package/src/assetlib/opensource3d.js +113 -0
  46. package/src/assetlib/polyhaven.js +182 -0
  47. package/src/assetlib/polypizza.js +115 -0
  48. package/src/assetlib/smithsonian.js +214 -0
  49. package/src/assetlib/sources.js +279 -0
  50. package/src/assetlib/zip.js +58 -0
  51. package/src/biome/biomeGenerator.js +385 -0
  52. package/src/biome/biomeRuntime.js +299 -0
  53. package/src/biome/index.js +2 -0
  54. package/src/buildinggen/buildingAsset.js +44 -0
  55. package/src/buildinggen/buildingGrammar.js +311 -0
  56. package/src/buildinggen/buildingMesh.js +451 -0
  57. package/src/buildinggen/buildingPresets.js +46 -0
  58. package/src/buildinggen/buildingRecipe.js +100 -0
  59. package/src/buildinggen/buildingSettings.js +238 -0
  60. package/src/buildinggen/index.js +6 -0
  61. package/src/camera/cameraDirector.js +157 -0
  62. package/src/camera/cameraGenerator.js +367 -0
  63. package/src/camera/cameraRig.js +570 -0
  64. package/src/camera/cameraSettings.js +236 -0
  65. package/src/camera/index.js +7 -0
  66. package/src/catalog/builtinEntries.js +245 -0
  67. package/src/catalog/catalog.js +210 -0
  68. package/src/catalog/index.js +3 -0
  69. package/src/catalog/manifest.js +84 -0
  70. package/src/core/generation.js +529 -0
  71. package/src/environment/environmentRigs.js +21 -1
  72. package/src/environment/environmentSettings.js +4 -0
  73. package/src/environment/environmentSunShadowPass.js +8 -0
  74. package/src/fauna/INTEGRATION.md +174 -0
  75. package/src/fauna/boids.js +861 -0
  76. package/src/fauna/faunaBodies.js +492 -0
  77. package/src/fauna/faunaPresets.js +52 -0
  78. package/src/fauna/faunaSettings.js +525 -0
  79. package/src/fauna/index.js +5 -0
  80. package/src/fauna/stylizedFauna.js +395 -0
  81. package/src/game-feel/gameFeelGenerator.js +402 -0
  82. package/src/game-feel/gameFeelRuntime.js +549 -0
  83. package/src/game-feel/index.js +2 -0
  84. package/src/index.js +17 -6
  85. package/src/lighting/colorIntensity.js +177 -0
  86. package/src/lighting/index.js +161 -0
  87. package/src/lighting/lightDescriptors.js +249 -0
  88. package/src/lighting/lightingCapabilities.js +79 -0
  89. package/src/lighting/lightingDocuments.js +247 -0
  90. package/src/lighting/lightingFixtures.js +446 -0
  91. package/src/lighting/lightingGenerator.js +449 -0
  92. package/src/lighting/lightingPresets.js +319 -0
  93. package/src/lighting/lightingRuntime.js +723 -0
  94. package/src/lighting/lightingStyle.js +386 -0
  95. package/src/lighting/lightingSystem.js +774 -0
  96. package/src/lighting/unrealExport.js +186 -0
  97. package/src/lighting/utils.js +87 -0
  98. package/src/motion/index.js +5 -0
  99. package/src/motion/motionClip.js +441 -0
  100. package/src/motion/motionController.js +628 -0
  101. package/src/motion/motionDocuments.js +225 -0
  102. package/src/motion/motionGraph.js +307 -0
  103. package/src/motion/motionSettings.js +222 -0
  104. package/src/pathgen/index.js +7 -0
  105. package/src/pathgen/pathBridge.js +232 -0
  106. package/src/pathgen/pathPresets.js +35 -0
  107. package/src/pathgen/pathRibbon.js +410 -0
  108. package/src/pathgen/pathRouter.js +380 -0
  109. package/src/pathgen/pathSettings.js +335 -0
  110. package/src/pathgen/pathTextures.js +123 -0
  111. package/src/pathgen/stylizedPaths.js +453 -0
  112. package/src/post/index.js +1 -0
  113. package/src/post/postGenerator.js +177 -0
  114. package/src/post/postProcessing.js +41 -0
  115. package/src/propgen/generatorsWave1.js +379 -0
  116. package/src/propgen/generatorsWave2.js +462 -0
  117. package/src/propgen/index.js +5 -0
  118. package/src/propgen/propAsset.js +323 -0
  119. package/src/propgen/propParts.js +170 -0
  120. package/src/propgen/propPlacement.js +459 -0
  121. package/src/propgen/propPresets.js +82 -0
  122. package/src/propgen/propSettings.js +395 -0
  123. package/src/shaders-tsl/chunks/projected-water-caustics.js +242 -0
  124. package/src/shaders-tsl/chunks/vegetation-style.js +360 -0
  125. package/src/shaders-tsl/chunks/water-shore-state.js +31 -0
  126. package/src/shaders-tsl/chunks/water-waves.js +90 -11
  127. package/src/shaders-tsl/environment.js +15 -1
  128. package/src/shaders-tsl/flower.js +279 -30
  129. package/src/shaders-tsl/grass.js +60 -33
  130. package/src/shaders-tsl/sky.js +125 -31
  131. package/src/shaders-tsl/tree-leaf.js +61 -25
  132. package/src/shaders-tsl/water-breaker.js +7 -4
  133. package/src/shaders-tsl/water-shore-state-simulation.js +523 -0
  134. package/src/shaders-tsl/water.js +439 -49
  135. package/src/shaders-tsl/woody-surface.js +154 -0
  136. package/src/sky/sceneOverrideLayers.js +10 -0
  137. package/src/sky/skyQuality.js +26 -0
  138. package/src/sky/stylizedSky.js +753 -45
  139. package/src/soundscape/index.js +4 -0
  140. package/src/soundscape/soundscapeGenerator.js +179 -0
  141. package/src/soundscape/soundscapeRuntime.js +806 -0
  142. package/src/soundscape/soundscapeSettings.js +292 -0
  143. package/src/styles/index.js +13 -0
  144. package/src/styles/styleBundle.js +325 -0
  145. package/src/stylizedTerrain.js +32 -2
  146. package/src/stylizedWorld.js +423 -20
  147. package/src/texgen/evaluateTexture.js +675 -0
  148. package/src/texgen/index.js +60 -0
  149. package/src/texgen/noise2.js +210 -0
  150. package/src/texgen/textureAi.js +436 -0
  151. package/src/texgen/textureGenerators.js +516 -0
  152. package/src/texgen/texturePresets.js +490 -0
  153. package/src/texgen/textureSettings.js +342 -0
  154. package/src/texgen/textureThree.js +59 -0
  155. package/src/vegetation/flowerSpecies.js +15 -3
  156. package/src/vegetation/grassPalettes.js +153 -0
  157. package/src/vegetation/index.js +6 -0
  158. package/src/vegetation/stylizedBush.js +2 -0
  159. package/src/vegetation/stylizedFlower.js +82 -0
  160. package/src/vegetation/stylizedFlowers.js +48 -7
  161. package/src/vegetation/stylizedForest.js +29 -1
  162. package/src/vegetation/stylizedGrass.js +291 -56
  163. package/src/vegetation/stylizedTree.js +38 -2
  164. package/src/vegetation/stylizedTreeFoliage.js +2 -1
  165. package/src/vegetation/vegetationShaders.js +1110 -0
  166. package/src/vfxgen/INTEGRATION.md +145 -0
  167. package/src/vfxgen/core/burstBackbone.js +380 -0
  168. package/src/vfxgen/core/projectileCore.js +92 -0
  169. package/src/vfxgen/core/spriteShapes.js +98 -0
  170. package/src/vfxgen/core/trailRibbon.js +272 -0
  171. package/src/vfxgen/core/vfxRandom.js +29 -0
  172. package/src/vfxgen/effects/emitHelpers.js +37 -0
  173. package/src/vfxgen/effects/magicEffects.js +162 -0
  174. package/src/vfxgen/effects/movementEffects.js +87 -0
  175. package/src/vfxgen/effects/weaponEffects.js +118 -0
  176. package/src/vfxgen/index.js +18 -0
  177. package/src/vfxgen/moves/moveController.js +146 -0
  178. package/src/vfxgen/moves/moveLibrary.js +335 -0
  179. package/src/vfxgen/vfxPresets.js +98 -0
  180. package/src/vfxgen/vfxSettings.js +384 -0
  181. package/src/vfxgen/vfxSystem.js +449 -0
  182. package/src/vfxgen/weapons/stylizedWeapons.js +137 -0
  183. package/src/villagegen/index.js +4 -0
  184. package/src/villagegen/stylizedVillage.js +490 -0
  185. package/src/villagegen/villageArchetypes.js +160 -0
  186. package/src/villagegen/villageNames.js +40 -0
  187. package/src/villagegen/villageSites.js +105 -0
  188. package/src/water/sceneOverrideLayers.js +23 -0
  189. package/src/water/water.js +5 -0
  190. package/src/water/waterBreakerSystem.js +15 -1
  191. package/src/water/waterCurrentField.js +447 -0
  192. package/src/water/waterMaterial.js +12 -0
  193. package/src/water/waterNearshorePhase.js +320 -0
  194. package/src/water/waterScenePasses.js +83 -30
  195. package/src/water/waterSettings.js +325 -13
  196. package/src/water/waterShoreMaterial.js +322 -0
  197. package/src/water/waterShoreStateField.js +605 -0
  198. package/src/water/waterSurface.js +797 -28
  199. package/src/weather/index.js +6 -0
  200. package/src/weather/weatherPrecipitation.js +221 -0
  201. package/src/weather/weatherPresets.js +258 -0
  202. package/src/weather/weatherSettings.js +269 -0
  203. package/src/weather/weatherSystem.js +871 -0
  204. package/src/worldMinimap.js +62 -0
  205. package/src/worldPresets.js +4 -1
@@ -0,0 +1,98 @@
1
+ // Shared TSL sprite shapes — the "texture set" of the VFX cluster, drawn as
2
+ // signed-distance/analytic masks on a unit quad instead of any image asset
3
+ // (same zero-texture policy as texgen). Every helper takes `p`, the quad UV
4
+ // remapped to [-1, 1] (`uv().mul(2).sub(1)`), and returns a 0..1 mask node.
5
+ //
6
+ // Trig-only on purpose: these run in the one-shot backbone's fragment shader
7
+ // on both WebGPU and the WebGL2 fallback, so no texture fetches, no loops,
8
+ // no derivatives.
9
+
10
+ import {
11
+ abs, atan, clamp, cos, float, floor, fract, length, max, mix, pow, sin, smoothstep, vec2,
12
+ } from 'three/tsl';
13
+
14
+ /** Soft radial dot: 1 at center → 0 at the rim; `hardness` sharpens the core. */
15
+ export function softDot(p, hardness = 1.8) {
16
+ return pow(clamp(length(p).oneMinus(), 0.0, 1.0), hardness);
17
+ }
18
+
19
+ /** Hot-core dot: soft halo plus a tight bright center (sparks, embers). */
20
+ export function emberDot(p) {
21
+ const d = length(p);
22
+ const halo = pow(clamp(d.oneMinus(), 0.0, 1.0), 1.6);
23
+ const core = pow(clamp(d.oneMinus(), 0.0, 1.0), 6.0);
24
+ return halo.mul(0.55).add(core);
25
+ }
26
+
27
+ /**
28
+ * N-point star flash (the anime hit star): a radial falloff whose rim is
29
+ * modulated by cos(spikes·θ), plus a hot center. `spikes` may be a node
30
+ * (per-instance attribute) or a number.
31
+ */
32
+ export function starburst(p, spikes, sharpness = 3.0) {
33
+ const angle = atan(p.y, p.x);
34
+ const lobe = pow(abs(cos(angle.mul(spikes).mul(0.5))), sharpness);
35
+ const radius = length(p);
36
+ const arm = clamp(radius.div(mix(0.18, 1.0, lobe)).oneMinus(), 0.0, 1.0);
37
+ const core = pow(clamp(radius.mul(3.2).oneMinus(), 0.0, 1.0), 2.0);
38
+ return clamp(arm.mul(arm).add(core), 0.0, 1.0);
39
+ }
40
+
41
+ /**
42
+ * Ring band centered at `radius01` (0..1 of the quad) with soft `thickness`
43
+ * (number or node — per-instance attributes welcome). Expanding shockwaves/
44
+ * scorch rings animate `radius01` from the vertex side by growing the QUAD
45
+ * and keeping the band fixed — this stays static.
46
+ */
47
+ export function ringBand(p, radius01 = 0.72, thickness = 0.16) {
48
+ const width = float(thickness);
49
+ const d = abs(length(p).sub(radius01));
50
+ return smoothstep(width, width.mul(0.25), d);
51
+ }
52
+
53
+ /**
54
+ * Blobby toon puff: a circle whose rim wobbles with two angular harmonics
55
+ * seeded per particle — chunky hand-drawn smoke, no noise texture. Returns
56
+ * the SIGNED coverage (>0 inside), thresholded by the caller so cutout and
57
+ * soft variants can share it.
58
+ */
59
+ export function puffBlob(p, seed, wobble = 0.18) {
60
+ const angle = atan(p.y, p.x);
61
+ const rim = sin(angle.mul(3.0).add(seed.mul(37.0))).mul(0.6)
62
+ .add(sin(angle.mul(5.0).sub(seed.mul(91.0))).mul(0.4));
63
+ const radius = rim.mul(wobble).add(0.82);
64
+ return radius.sub(length(p));
65
+ }
66
+
67
+ /**
68
+ * Rising flame lick mask for the fireball core: a circle stretched upward
69
+ * whose rim is eaten by upward-scrolling sine licks. `t` animates; `seed`
70
+ * de-syncs projectiles. Returns 0..1 coverage, ~1 in the body, feathering
71
+ * through the licks.
72
+ */
73
+ export function flameLicks(p, t, seed) {
74
+ // Flames read taller than wide; bias the field downward so the base is round
75
+ // and the licks live on top.
76
+ const q = vec2(p.x, p.y.mul(0.78).add(0.12)).toVar();
77
+ const angle = atan(q.y, q.x);
78
+ const licks = sin(angle.mul(5.0).add(t.mul(9.0)).add(seed.mul(53.0))).mul(0.5)
79
+ .add(sin(angle.mul(9.0).sub(t.mul(13.0)).add(seed.mul(17.0))).mul(0.3));
80
+ // Licks only bite where the shape points up (angle ≈ +90°).
81
+ const upBias = smoothstep(-0.2, 0.9, q.y.div(max(length(q), 1e-3)));
82
+ const rim = licks.mul(0.16).mul(upBias).add(0.8);
83
+ return smoothstep(0.12, -0.08, length(q).sub(rim));
84
+ }
85
+
86
+ /**
87
+ * Quantizes a 0..1 fade into `bands` cel steps (top step = 1, bottom = 0).
88
+ * `bands` may be a node or a number; bands ≤ 1 collapses to a hard cut.
89
+ */
90
+ export function celBands(fade, bands) {
91
+ const steps = max(bands, 1.0);
92
+ return clamp(floor(clamp(fade, 0.0, 1.0).mul(steps)).div(max(steps.sub(1.0), 1.0)), 0.0, 1.0);
93
+ }
94
+
95
+ /** Cheap per-fragment hash sparkle in 0..1 (grain for dust, glitter). */
96
+ export function hashSparkle(p, seed) {
97
+ return fract(sin(p.x.mul(127.1).add(p.y.mul(311.7)).add(seed.mul(74.7))).mul(43758.5453));
98
+ }
@@ -0,0 +1,272 @@
1
+ // Motion-trail ribbon for weapon swings — tuned to the reference-class
2
+ // action-RPG look (Genshin-style): a SOLID, unlit, two-band surface (flat
3
+ // saturated body + crisp white leading edge) whose tail ERODES with a hard
4
+ // dissolving cutoff instead of fading translucent, over a Catmull-Rom
5
+ // smoothed centerline so fast arcs stay round instead of polygonal. The
6
+ // newest segments flash white along the blade — the "charged edge" while
7
+ // the swing is live.
8
+ //
9
+ // Each frame the CPU appends ONE raw pair of world-space vertices (blade
10
+ // base and tip) and the sampler expands it into SUBDIV spline-interpolated
11
+ // pairs; the fragment shader erodes each written segment by its age. CPU
12
+ // cost per active trail per frame: ~40 floats and a small memmove.
13
+
14
+ import * as THREE from 'three';
15
+ import { NodeMaterial } from 'three/webgpu';
16
+ import {
17
+ attribute, cameraPosition, clamp, distance, exp, Fn, max, mix, smoothstep,
18
+ positionWorld, uniform, vec3, vec4,
19
+ } from 'three/tsl';
20
+
21
+ import { celBands } from './spriteShapes.js';
22
+
23
+ const baseScratch = new THREE.Vector3();
24
+ const tipScratch = new THREE.Vector3();
25
+ const crScratch = new THREE.Vector3();
26
+
27
+ /** Catmull-Rom point for control points p0..p3 at t in [0,1], into `out`. */
28
+ function catmullRom(out, p0, p1, p2, p3, t) {
29
+ const t2 = t * t;
30
+ const t3 = t2 * t;
31
+ for (const axis of ['x', 'y', 'z']) {
32
+ out[axis] = 0.5 * (
33
+ 2 * p1[axis]
34
+ + (-p0[axis] + p2[axis]) * t
35
+ + (2 * p0[axis] - 5 * p1[axis] + 4 * p2[axis] - p3[axis]) * t2
36
+ + (-p0[axis] + 3 * p1[axis] - 3 * p2[axis] + p3[axis]) * t3
37
+ );
38
+ }
39
+ return out;
40
+ }
41
+
42
+ // Spline points emitted per raw frame sample — the smoothing factor.
43
+ const SUBDIV = 3;
44
+
45
+ /**
46
+ * One pooled ribbon. `sharedUniforms` is the burst backbone's uniform set —
47
+ * the ribbon reads uTime and the fog quartet from it so every VFX layer
48
+ * shares one clock and one fog state.
49
+ */
50
+ export function createTrailRibbon({ segments = 96, sharedUniforms }) {
51
+ const capacity = Math.max(Math.round(segments), 8);
52
+ const vertexCount = capacity * 2;
53
+ const u = sharedUniforms;
54
+
55
+ const geometry = new THREE.BufferGeometry();
56
+ const positions = new Float32Array(vertexCount * 3);
57
+ // Per-vertex (birth, edge): edge 0 = blade base, 1 = tip.
58
+ const trailData = new Float32Array(vertexCount * 2);
59
+ geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3).setUsage(THREE.DynamicDrawUsage));
60
+ geometry.setAttribute('aTrail', new THREE.BufferAttribute(trailData, 2).setUsage(THREE.DynamicDrawUsage));
61
+ const indices = new Uint16Array((capacity - 1) * 6);
62
+ for (let i = 0; i < capacity - 1; i += 1) {
63
+ const v = i * 2;
64
+ indices.set([v, v + 1, v + 2, v + 2, v + 1, v + 3], i * 6);
65
+ }
66
+ geometry.setIndex(new THREE.BufferAttribute(indices, 1));
67
+ geometry.setDrawRange(0, 0);
68
+ geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 1e5);
69
+
70
+ // Per-ribbon look uniforms — pooled ribbons re-tint on acquire.
71
+ const look = {
72
+ uBands: uniform(6),
73
+ uBodyColor: uniform(new THREE.Color(1.0, 0.78, 0.2)),
74
+ uEdgeBandColor: uniform(new THREE.Color(1, 1, 1)),
75
+ uIntensity: uniform(1),
76
+ uLifetime: uniform(0.3),
77
+ };
78
+
79
+ const material = new NodeMaterial();
80
+ material.name = 'VfxTrailRibbon';
81
+ material.transparent = true;
82
+ material.depthWrite = false;
83
+ material.side = THREE.DoubleSide;
84
+ material.fog = false;
85
+ // Positions are written in world space; the default vertex path is correct
86
+ // with the mesh at identity. Only the fragment is custom.
87
+ material.fragmentNode = Fn(() => {
88
+ const trail = attribute('aTrail', 'vec2');
89
+ const age = u.uTime.sub(trail.x);
90
+ const fade = clamp(age.div(max(look.uLifetime, 1e-3)), 0.0, 1.0).oneMinus();
91
+
92
+ // EROSION, not transparency: the surface stays solid and the tail
93
+ // dissolves with a hard swept cutoff, inner (base) edge first — that
94
+ // tapering crescent is the reference read. Partially quantizing the
95
+ // sweep into cel bands gives the stepped hand-drawn tail.
96
+ const fadeStepped = mix(fade, celBands(fade, look.uBands), 0.4);
97
+ const erode = smoothstep(0.0, 0.12,
98
+ fadeStepped.mul(1.2).sub(trail.y.oneMinus().mul(0.22)));
99
+
100
+ // Two hard bands across the width: white leading edge, saturated body.
101
+ const band = smoothstep(0.7, 0.84, trail.y);
102
+ const color = mix(look.uBodyColor, look.uEdgeBandColor, band).toVar();
103
+ // Newest segments flash white along the blade — the live charged edge.
104
+ color.assign(mix(color, vec3(1.0), smoothstep(0.05, 0.0, age).mul(0.85)));
105
+ color.mulAssign(look.uIntensity);
106
+
107
+ // Solid surfaces join the fog by TINT (like the dust), not by dimming.
108
+ const heightFalloff = exp(
109
+ max(positionWorld.y.sub(u.uFogFloorY), 0.0).div(max(u.uFogFalloff, 0.001)).negate());
110
+ const depthTerm = exp(distance(positionWorld, cameraPosition).mul(u.uFogDensity).negate()).oneMinus();
111
+ const fogged = mix(color, u.uFogColor, clamp(depthTerm.mul(heightFalloff), 0.0, 1.0));
112
+
113
+ return vec4(fogged, erode.mul(0.96));
114
+ })();
115
+ material.uniforms = look;
116
+
117
+ const mesh = new THREE.Mesh(geometry, material);
118
+ mesh.name = 'VfxTrailRibbon';
119
+ mesh.frustumCulled = false;
120
+ mesh.renderOrder = 6;
121
+
122
+ let samples = 0;
123
+ let active = false;
124
+ let lastBirth = -Infinity;
125
+ let lastRawTime = -Infinity;
126
+ let follow = null;
127
+ const baseAnchor = new THREE.Vector3();
128
+ const tipAnchor = new THREE.Vector3(0, 1, 0);
129
+ const minStep = 0.015;
130
+ // Rolling raw history for the spline: [older, prev, newest].
131
+ const rawBase = [new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3()];
132
+ const rawTip = [new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3()];
133
+ let rawCount = 0;
134
+
135
+ const writeSample = (index, base, tip, birth) => {
136
+ positions.set([base.x, base.y, base.z, tip.x, tip.y, tip.z], index * 6);
137
+ trailData.set([birth, 0, birth, 1], index * 4);
138
+ };
139
+
140
+ const appendPair = (base, tip, birth) => {
141
+ if (samples === capacity) {
142
+ positions.copyWithin(0, 6);
143
+ trailData.copyWithin(0, 4);
144
+ samples -= 1;
145
+ }
146
+ writeSample(samples, base, tip, birth);
147
+ samples += 1;
148
+ };
149
+
150
+ const markDirty = () => {
151
+ geometry.setDrawRange(0, Math.max(samples - 1, 0) * 6);
152
+ geometry.attributes.position.needsUpdate = true;
153
+ geometry.attributes.aTrail.needsUpdate = true;
154
+ };
155
+
156
+ const pushRaw = (base, tip) => {
157
+ rawBase[0].copy(rawBase[1]); rawBase[1].copy(rawBase[2]); rawBase[2].copy(base);
158
+ rawTip[0].copy(rawTip[1]); rawTip[1].copy(rawTip[2]); rawTip[2].copy(tip);
159
+ rawCount = Math.min(rawCount + 1, 3);
160
+ };
161
+
162
+ const ribbon = {
163
+ mesh,
164
+
165
+ get active() { return active; },
166
+ get samples() { return samples; },
167
+
168
+ /**
169
+ * Arms the ribbon on a followed Object3D. `base`/`tip` are LOCAL anchor
170
+ * points on the followed object (blade root and blade tip).
171
+ */
172
+ begin({ follow: target, base = [0, 0, 0], tip = [0, 1, 0], color, coreColor, lifetime, bands, intensity } = {}) {
173
+ follow = target ?? null;
174
+ baseAnchor.fromArray(base);
175
+ tipAnchor.fromArray(tip);
176
+ if (Array.isArray(color)) look.uBodyColor.value.setRGB(...color, THREE.SRGBColorSpace);
177
+ if (Array.isArray(coreColor)) look.uEdgeBandColor.value.setRGB(...coreColor, THREE.SRGBColorSpace);
178
+ if (Number.isFinite(lifetime)) look.uLifetime.value = Math.max(lifetime, 0.02);
179
+ if (Number.isFinite(bands)) look.uBands.value = Math.max(Math.round(bands), 1);
180
+ if (Number.isFinite(intensity)) look.uIntensity.value = Math.max(intensity, 0);
181
+ samples = 0;
182
+ rawCount = 0;
183
+ geometry.setDrawRange(0, 0);
184
+ active = true;
185
+ mesh.visible = true;
186
+ return ribbon;
187
+ },
188
+
189
+ /**
190
+ * Per frame while active: sample the followed object's anchors in world
191
+ * space; when the tip moved, append SUBDIV spline-smoothed segment pairs
192
+ * between the previous and current raw samples. Returns the tip position
193
+ * (for the sparkle emitter) or null when idle.
194
+ */
195
+ sample(now) {
196
+ if (!active || !follow?.isObject3D) return null;
197
+ follow.updateWorldMatrix(true, false);
198
+ const base = baseScratch.copy(baseAnchor).applyMatrix4(follow.matrixWorld);
199
+ const tip = tipScratch.copy(tipAnchor).applyMatrix4(follow.matrixWorld);
200
+
201
+ if (rawCount > 0) {
202
+ const prevTip = rawTip[2];
203
+ const moved = prevTip.distanceToSquared(tip);
204
+ if (moved < minStep * minStep) {
205
+ // Blade at rest: refresh the head so the ribbon stays attached.
206
+ if (samples > 0) {
207
+ writeSample(samples - 1, base, tip, now);
208
+ markDirty();
209
+ }
210
+ lastBirth = now;
211
+ lastRawTime = now;
212
+ return tip;
213
+ }
214
+ }
215
+
216
+ if (rawCount < 2) {
217
+ // Not enough history to spline yet — lay the raw pair directly.
218
+ pushRaw(base, tip);
219
+ appendPair(base, tip, now);
220
+ } else {
221
+ const prevTime = Number.isFinite(lastRawTime) ? lastRawTime : now;
222
+ pushRaw(base, tip);
223
+ // Spline between prev (p1) and newest (p2); p3 extrapolates forward.
224
+ for (let step = 1; step <= SUBDIV; step += 1) {
225
+ const t = step / SUBDIV;
226
+ const birth = prevTime + (now - prevTime) * t;
227
+ const b = catmullRom(crScratch.clone(), rawBase[0], rawBase[1], rawBase[2],
228
+ crScratch.copy(rawBase[2]).multiplyScalar(2).sub(rawBase[1]), t);
229
+ const bx = b.x; const by = b.y; const bz = b.z;
230
+ const tpoint = catmullRom(crScratch, rawTip[0], rawTip[1], rawTip[2],
231
+ tipScratch.copy(rawTip[2]).multiplyScalar(2).sub(rawTip[1]), t);
232
+ appendPair({ x: bx, y: by, z: bz }, tpoint, birth);
233
+ }
234
+ }
235
+ lastBirth = now;
236
+ lastRawTime = now;
237
+ markDirty();
238
+ return tipScratch.copy(tipAnchor).applyMatrix4(follow.matrixWorld);
239
+ },
240
+
241
+ /** Stops appending; the written arc erodes out over its lifetime. */
242
+ stop() {
243
+ active = false;
244
+ follow = null;
245
+ return ribbon;
246
+ },
247
+
248
+ /** True once inactive AND every written segment has fully faded. */
249
+ isDead(now) {
250
+ return !active && now - lastBirth > look.uLifetime.value + 0.05;
251
+ },
252
+
253
+ /** Pool reset — hides the mesh without disposing GPU resources. */
254
+ reset() {
255
+ active = false;
256
+ follow = null;
257
+ samples = 0;
258
+ rawCount = 0;
259
+ lastBirth = -Infinity;
260
+ lastRawTime = -Infinity;
261
+ geometry.setDrawRange(0, 0);
262
+ mesh.visible = false;
263
+ },
264
+
265
+ dispose() {
266
+ geometry.dispose();
267
+ material.dispose();
268
+ },
269
+ };
270
+ mesh.visible = false;
271
+ return ribbon;
272
+ }
@@ -0,0 +1,29 @@
1
+ // Deterministic randomness for the VFX cluster. Same trio as ambientfx and
2
+ // fauna — duplicated on purpose (each cluster stays self-contained), and NOT
3
+ // re-exported from the cluster index so the root package surface doesn't
4
+ // grow another ambiguous `hashCombine`.
5
+
6
+ /** Chris Wellons' lowbias32: uint32 -> well-mixed uint32. */
7
+ export function lowbias32(x) {
8
+ let h = x >>> 0;
9
+ h = Math.imul(h ^ (h >>> 16), 0x7feb352d) >>> 0;
10
+ h = Math.imul(h ^ (h >>> 15), 0x846ca68b) >>> 0;
11
+ return (h ^ (h >>> 16)) >>> 0;
12
+ }
13
+
14
+ /** Mixes two uint32 seeds into one (order-sensitive). */
15
+ export function hashCombine(a, b) {
16
+ return lowbias32((a >>> 0) ^ (Math.imul(b >>> 0, 0x9e3779b9) >>> 0));
17
+ }
18
+
19
+ /** mulberry32 PRNG: uint32 seed -> () => [0, 1). No Math.random anywhere. */
20
+ export function mulberry32(seed) {
21
+ let state = (Math.trunc(seed) || 1) >>> 0;
22
+ return () => {
23
+ state = (state + 0x6d2b79f5) >>> 0;
24
+ let t = state;
25
+ t = Math.imul(t ^ (t >>> 15), t | 1);
26
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
27
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
28
+ };
29
+ }
@@ -0,0 +1,37 @@
1
+ // Tiny shared math for the effect builders. Every builder in effects/ is a
2
+ // PURE function of (settings, rng, spawn options, clock) → backbone records,
3
+ // so the verify script exercises them in Node with no renderer; keep it that
4
+ // way — no THREE, no TSL, no Date/Math.random in this folder.
5
+
6
+ /** Linear interpolation. */
7
+ export function lerp(a, b, t) {
8
+ return a + (b - a) * t;
9
+ }
10
+
11
+ /** Uniform point on the unit sphere from two rng draws. */
12
+ export function randUnitVector(rng) {
13
+ const z = rng() * 2 - 1;
14
+ const theta = rng() * Math.PI * 2;
15
+ const r = Math.sqrt(Math.max(1 - z * z, 0));
16
+ return [r * Math.cos(theta), z, r * Math.sin(theta)];
17
+ }
18
+
19
+ /** Per-particle color variation: scales the whole rgb by 1 ± amount. */
20
+ export function jitterColor(rgb, rng, amount = 0.08) {
21
+ const scale = 1 + (rng() * 2 - 1) * amount;
22
+ return [rgb[0] * scale, rgb[1] * scale, rgb[2] * scale];
23
+ }
24
+
25
+ /** Normalizes a [x, y, z] array; falls back to +Y for degenerate input. */
26
+ export function normalized(v, fallback = [0, 1, 0]) {
27
+ const x = Number(v?.[0]) || 0;
28
+ const y = Number(v?.[1]) || 0;
29
+ const z = Number(v?.[2]) || 0;
30
+ const len = Math.hypot(x, y, z);
31
+ return len > 1e-6 ? [x / len, y / len, z / len] : [...fallback];
32
+ }
33
+
34
+ /** Merges per-spawn overrides over a settings group (shallow — look knobs). */
35
+ export function withOverrides(group, overrides) {
36
+ return overrides && typeof overrides === 'object' ? { ...group, ...overrides } : group;
37
+ }
@@ -0,0 +1,162 @@
1
+ // Magic-category effects: the fireball's in-flight ember shed and its
2
+ // detonation (flame sparks + star flash + smoke puffs + expanding scorch
3
+ // ring). The projectile core itself is a pooled billboard mesh with the
4
+ // flame shader — see vfxSystem.js; these builders supply every particle
5
+ // around it.
6
+ //
7
+ // Every export is a pure builder: (settings, rng, options, clock) → backbone
8
+ // records grouped by blend state ({ glow: [...], puff: [...] }).
9
+
10
+ import { BURST_KIND } from '../core/burstBackbone.js';
11
+ import { jitterColor, lerp, normalized, randUnitVector, withOverrides } from './emitHelpers.js';
12
+
13
+ /**
14
+ * Embers shed along the flight path. vfxSystem accumulates `emberRate · dt`
15
+ * and calls this with the whole-ember count, positions jittered around the
16
+ * projectile, so shedding is framerate-independent and deterministic.
17
+ */
18
+ export function emitFireballEmbers({ settings, rng, at, velocity = [0, 0, 0], count, now = 0, overrides = null }) {
19
+ const e = withOverrides(settings.fireball, overrides);
20
+ if (!e.enabled || count <= 0) return { glow: [], puff: [] };
21
+ const glow = [];
22
+ for (let i = 0; i < count; i += 1) {
23
+ const scatter = randUnitVector(rng);
24
+ const color = jitterColor(
25
+ rng() < 0.35 ? e.coreColor : e.flameColor, rng, 0.1);
26
+ const size = lerp(e.emberSize[0], e.emberSize[1], rng());
27
+ glow.push({
28
+ birth: now,
29
+ extra: lerp(0.08, 0.22, rng()), // wander radius
30
+ gravity: -2.2, // buoyant — embers rise off the trail
31
+ kind: BURST_KIND.ember,
32
+ lifetime: e.emberLifetime * lerp(0.7, 1.3, rng()),
33
+ r: color[0] * e.intensity, g: color[1] * e.intensity, b: color[2] * e.intensity,
34
+ seed: rng(),
35
+ size0: size, size1: size * 0.25,
36
+ // Drop out of the slipstream: a touch of counter-velocity plus scatter.
37
+ vx: scatter[0] * 0.6 - velocity[0] * 0.08,
38
+ vy: scatter[1] * 0.6 - velocity[1] * 0.08 + 0.4,
39
+ vz: scatter[2] * 0.6 - velocity[2] * 0.08,
40
+ x: at[0] + scatter[0] * e.coreSize * 0.5,
41
+ y: at[1] + scatter[1] * e.coreSize * 0.5,
42
+ z: at[2] + scatter[2] * e.coreSize * 0.5,
43
+ });
44
+ }
45
+ return { glow, puff: [] };
46
+ }
47
+
48
+ /**
49
+ * Detonation at a point: flame sparks + an 8-ish spike flash + rising smoke
50
+ * puffs + (optionally) the ground scorch ring. `power` defaults to the
51
+ * settings' explosionPower; `normal` orients the spark spray (pass the
52
+ * surface normal of whatever was hit).
53
+ */
54
+ export function emitFireballExplosion({ settings, rng, at, normal = [0, 1, 0], power = null, now = 0, overrides = null }) {
55
+ const e = withOverrides(settings.fireball, overrides);
56
+ if (!e.enabled) return { glow: [], puff: [] };
57
+ const p = Math.max(Number(power ?? e.explosionPower) || 1, 0);
58
+ const n = normalized(normal);
59
+ const glow = [];
60
+ const puff = [];
61
+
62
+ // Flame sparks — like an impact burst but slower, larger, flame-colored.
63
+ const sparkCount = Math.round(14 * p);
64
+ for (let i = 0; i < sparkCount; i += 1) {
65
+ const scatter = randUnitVector(rng);
66
+ const dir = normalized([
67
+ n[0] + scatter[0], Math.abs(n[1] + scatter[1]) * 0.9, n[2] + scatter[2],
68
+ ]);
69
+ const speed = 4.5 * lerp(0.4, 1.2, rng()) * Math.sqrt(p);
70
+ const size = 0.09 * lerp(0.7, 1.4, rng()) * p ** 0.3;
71
+ const color = jitterColor(rng() < 0.4 ? e.coreColor : e.flameColor, rng, 0.1);
72
+ glow.push({
73
+ birth: now,
74
+ extra: 2.2,
75
+ gravity: 7,
76
+ kind: BURST_KIND.spark,
77
+ lifetime: lerp(0.3, 0.65, rng()),
78
+ r: color[0] * e.intensity, g: color[1] * e.intensity, b: color[2] * e.intensity,
79
+ seed: rng(),
80
+ size0: size, size1: size * 0.3,
81
+ vx: dir[0] * speed, vy: dir[1] * speed, vz: dir[2] * speed,
82
+ x: at[0], y: at[1], z: at[2],
83
+ });
84
+ }
85
+
86
+ glow.push({
87
+ birth: now,
88
+ extra: 8,
89
+ gravity: 0,
90
+ kind: BURST_KIND.flash,
91
+ lifetime: 0.22,
92
+ r: e.coreColor[0] * e.intensity * 1.6,
93
+ g: e.coreColor[1] * e.intensity * 1.6,
94
+ b: e.coreColor[2] * e.intensity * 1.6,
95
+ seed: rng(),
96
+ size0: 1.1 * Math.sqrt(p), size1: 0,
97
+ vx: 0, vy: 0, vz: 0,
98
+ x: at[0], y: at[1], z: at[2],
99
+ });
100
+
101
+ // Camera-facing blast circle — the same shockwave the impact burst uses,
102
+ // scaled up; it sells the detonation's concussion before the smoke rises.
103
+ glow.push({
104
+ birth: now,
105
+ extra: 0.2,
106
+ gravity: 0,
107
+ kind: BURST_KIND.shockwave,
108
+ lifetime: 0.38,
109
+ r: e.coreColor[0] * e.intensity * 0.9,
110
+ g: e.coreColor[1] * e.intensity * 0.9,
111
+ b: e.coreColor[2] * e.intensity * 0.9,
112
+ seed: rng(),
113
+ size0: 0.2, size1: 2.6 * Math.sqrt(p),
114
+ vx: 0, vy: 0, vz: 0,
115
+ x: at[0], y: at[1] + 0.4, z: at[2],
116
+ });
117
+
118
+ if (e.scorchRing) {
119
+ glow.push({
120
+ birth: now,
121
+ extra: 0.18, // starting band thickness
122
+ gravity: 0,
123
+ kind: BURST_KIND.ring,
124
+ lifetime: 0.55,
125
+ r: e.ringColor[0] * e.intensity,
126
+ g: e.ringColor[1] * e.intensity,
127
+ b: e.ringColor[2] * e.intensity,
128
+ seed: rng(),
129
+ size0: 0.3, size1: 2.4 * p,
130
+ vx: 0, vy: 0, vz: 0,
131
+ x: at[0], y: at[1] + 0.03, z: at[2],
132
+ });
133
+ }
134
+
135
+ // Smoke column: dark warm puffs rising off the blast.
136
+ const puffCount = Math.round(7 * p);
137
+ for (let i = 0; i < puffCount; i += 1) {
138
+ const scatter = randUnitVector(rng);
139
+ const shade = lerp(0.16, 0.3, rng());
140
+ const size = lerp(0.28, 0.5, rng()) * p ** 0.4;
141
+ puff.push({
142
+ birth: now,
143
+ extra: lerp(-2.5, 2.5, rng()), // spin
144
+ gravity: -1.4, // buoyant rise
145
+ kind: BURST_KIND.puff,
146
+ lifetime: lerp(0.7, 1.2, rng()),
147
+ r: shade * (1 + e.flameColor[0] * 0.4),
148
+ g: shade,
149
+ b: shade * 0.92,
150
+ seed: rng(),
151
+ size0: size * 0.5, size1: size * 1.9,
152
+ vx: scatter[0] * 1.4,
153
+ vy: Math.abs(scatter[1]) * 1.6 + 0.8,
154
+ vz: scatter[2] * 1.4,
155
+ x: at[0] + scatter[0] * 0.25,
156
+ y: at[1] + Math.abs(scatter[1]) * 0.2,
157
+ z: at[2] + scatter[2] * 0.25,
158
+ });
159
+ }
160
+
161
+ return { glow, puff };
162
+ }
@@ -0,0 +1,87 @@
1
+ // Movement-category effects: footstep dust puffs and the landing ring — the
2
+ // ground-feedback layer that makes body movement read as WEIGHT. Both are
3
+ // dust-only (puff group); movement feedback is matter, not light.
4
+ //
5
+ // Every export is a pure builder: (settings, rng, options, clock) → backbone
6
+ // records grouped by blend state ({ glow: [...], puff: [...] }).
7
+
8
+ import { BURST_KIND } from '../core/burstBackbone.js';
9
+ import { jitterColor, lerp, normalized, withOverrides } from './emitHelpers.js';
10
+
11
+ /**
12
+ * A footfall: a few small puffs kicked slightly backward from the travel
13
+ * direction (`dir`, optional XZ heading) and drifting up. Cheap enough to
14
+ * fire every step.
15
+ */
16
+ export function emitFootstep({ settings, rng, at, dir = null, now = 0, overrides = null }) {
17
+ const e = withOverrides(settings.footstep, overrides);
18
+ if (!e.enabled) return { glow: [], puff: [] };
19
+ const heading = dir ? normalized([dir[0], 0, dir[2] ?? dir[1]]) : null;
20
+ const puff = [];
21
+ for (let i = 0; i < e.puffCount; i += 1) {
22
+ const angle = rng() * Math.PI * 2;
23
+ const radius = rng() * e.spread;
24
+ const size = lerp(e.sizeRange[0], e.sizeRange[1], rng());
25
+ const color = jitterColor(e.color, rng, 0.07);
26
+ // Kick opposite the heading (dust trails the foot), plus radial scatter.
27
+ const kick = heading ? 0.5 + rng() * 0.4 : 0;
28
+ puff.push({
29
+ birth: now,
30
+ extra: lerp(-3, 3, rng()), // spin
31
+ gravity: -0.5, // gentle rise
32
+ kind: BURST_KIND.puff,
33
+ lifetime: e.lifetime * lerp(0.75, 1.25, rng()),
34
+ r: color[0], g: color[1], b: color[2],
35
+ seed: rng(),
36
+ size0: size * 0.6, size1: size * 2.1,
37
+ vx: Math.cos(angle) * radius * 2 - (heading ? heading[0] * kick : 0),
38
+ vy: e.rise * lerp(0.5, 1.0, rng()),
39
+ vz: Math.sin(angle) * radius * 2 - (heading ? heading[2] * kick : 0),
40
+ x: at[0] + Math.cos(angle) * radius,
41
+ y: at[1] + 0.03,
42
+ z: at[2] + Math.sin(angle) * radius,
43
+ });
44
+ }
45
+ return { glow: [], puff };
46
+ }
47
+
48
+ /**
49
+ * A touch-down: a ring of puffs expanding radially from the landing point —
50
+ * the classic anime landing hit. `power` scales count and radius (1 = a hop,
51
+ * 2+ = a superhero landing).
52
+ */
53
+ export function emitLanding({ settings, rng, at, power = 1, now = 0, overrides = null }) {
54
+ const e = withOverrides(settings.landing, overrides);
55
+ if (!e.enabled) return { glow: [], puff: [] };
56
+ const p = Math.max(Number(power) || 1, 0);
57
+ const count = Math.round(e.puffCount * p);
58
+ const radius = e.ringRadius * Math.sqrt(p);
59
+ const puff = [];
60
+ for (let i = 0; i < count; i += 1) {
61
+ // Even ring spacing with jitter — even spacing is what makes it a RING.
62
+ const angle = (i / Math.max(count, 1)) * Math.PI * 2 + (rng() - 0.5) * 0.5;
63
+ const size = lerp(e.sizeRange[0], e.sizeRange[1], rng()) * p ** 0.3;
64
+ const color = jitterColor(e.color, rng, 0.07);
65
+ const lifetime = e.lifetime * lerp(0.8, 1.2, rng());
66
+ // Launch speed tuned so the decelerating puff covers ~the ring radius
67
+ // over its life (backbone decel integrates to ~0.7 · v · lifetime).
68
+ const speed = radius / (0.7 * lifetime);
69
+ puff.push({
70
+ birth: now,
71
+ extra: lerp(-2.5, 2.5, rng()),
72
+ gravity: -0.3,
73
+ kind: BURST_KIND.puff,
74
+ lifetime,
75
+ r: color[0], g: color[1], b: color[2],
76
+ seed: rng(),
77
+ size0: size * 0.55, size1: size * 2.0,
78
+ vx: Math.cos(angle) * speed,
79
+ vy: 0.4 * lerp(0.6, 1.2, rng()),
80
+ vz: Math.sin(angle) * speed,
81
+ x: at[0] + Math.cos(angle) * 0.15,
82
+ y: at[1] + 0.04,
83
+ z: at[2] + Math.sin(angle) * 0.15,
84
+ });
85
+ }
86
+ return { glow: [], puff };
87
+ }