@call-me-sensei/toonlab 0.2.0 → 0.3.0

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 +234 -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 +739 -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,118 @@
1
+ // Weapon-category effects: the impact burst (star flash + ballistic sparks)
2
+ // and the slash trail's edge sparkle. The slash ribbon itself is geometry,
3
+ // not particles — see core/trailRibbon.js; vfxSystem owns the ribbon pool
4
+ // and calls emitSlashSparkle along the sampled tip path.
5
+ //
6
+ // Every export is a pure builder: (settings, rng, options, clock) → backbone
7
+ // records grouped by blend state ({ glow: [...], puff: [...] }).
8
+
9
+ import { BURST_KIND } from '../core/burstBackbone.js';
10
+ import { jitterColor, lerp, normalized, randUnitVector, withOverrides } from './emitHelpers.js';
11
+
12
+ /**
13
+ * Hit feedback at a point: one N-spike star flash plus `sparkCount · power`
14
+ * ballistic sparks biased along the hit normal. `power` scales count, speed,
15
+ * and flash size — 0.3 reads as a parry tick, 2 as a heavy finisher.
16
+ */
17
+ export function emitImpact({ settings, rng, at, normal = [0, 1, 0], power = 1, now = 0, overrides = null }) {
18
+ const e = withOverrides(settings.impact, overrides);
19
+ if (!e.enabled) return { glow: [], puff: [] };
20
+ const p = Math.max(Number(power) || 1, 0);
21
+ const n = normalized(normal);
22
+ const glow = [];
23
+
24
+ const count = Math.round(e.sparkCount * p);
25
+ for (let i = 0; i < count; i += 1) {
26
+ const scatter = randUnitVector(rng);
27
+ // Hemisphere bias: fold scatter toward the hit normal so sparks spray
28
+ // OFF the surface, with enough spread to read as a shower.
29
+ const dir = normalized([
30
+ n[0] + scatter[0] * 0.85,
31
+ Math.abs(n[1] + scatter[1] * 0.85),
32
+ n[2] + scatter[2] * 0.85,
33
+ ]);
34
+ const speed = e.sparkSpeed * lerp(0.4, 1.2, rng()) * Math.sqrt(p);
35
+ const size = 0.05 * lerp(0.7, 1.3, rng()) * p ** 0.3;
36
+ const color = jitterColor(e.sparkColor, rng);
37
+ glow.push({
38
+ birth: now,
39
+ extra: 3.5, // streak stretch along velocity
40
+ gravity: e.gravity,
41
+ kind: BURST_KIND.spark,
42
+ lifetime: e.lifetime * lerp(0.5, 1.0, rng()),
43
+ r: color[0] * e.intensity, g: color[1] * e.intensity, b: color[2] * e.intensity,
44
+ seed: rng(),
45
+ size0: size, size1: size * 0.35,
46
+ vx: dir[0] * speed, vy: dir[1] * speed, vz: dir[2] * speed,
47
+ x: at[0], y: at[1], z: at[2],
48
+ });
49
+ }
50
+
51
+ if (e.flashSize > 0) {
52
+ glow.push({
53
+ birth: now,
54
+ extra: e.spikes,
55
+ gravity: 0,
56
+ kind: BURST_KIND.flash,
57
+ lifetime: Math.max(e.lifetime * 0.45, 0.08),
58
+ r: e.flashColor[0] * e.intensity * 1.4,
59
+ g: e.flashColor[1] * e.intensity * 1.4,
60
+ b: e.flashColor[2] * e.intensity * 1.4,
61
+ seed: rng(),
62
+ size0: e.flashSize * Math.sqrt(p), size1: 0,
63
+ vx: 0, vy: 0, vz: 0,
64
+ x: at[0], y: at[1], z: at[2],
65
+ });
66
+ }
67
+
68
+ // The action-RPG hit circle: a camera-facing ring that pops outward and
69
+ // dies fast — reads as the hit "landing" even before the sparks resolve.
70
+ if (e.shockwave) {
71
+ glow.push({
72
+ birth: now,
73
+ extra: 0.22, // starting band thickness
74
+ gravity: 0,
75
+ kind: BURST_KIND.shockwave,
76
+ lifetime: 0.3,
77
+ r: e.flashColor[0] * e.intensity * 0.9,
78
+ g: e.flashColor[1] * e.intensity * 0.9,
79
+ b: e.flashColor[2] * e.intensity * 0.9,
80
+ seed: rng(),
81
+ size0: 0.12, size1: 1.25 * Math.sqrt(p),
82
+ vx: 0, vy: 0, vz: 0,
83
+ x: at[0], y: at[1], z: at[2],
84
+ });
85
+ }
86
+
87
+ return { glow, puff: [] };
88
+ }
89
+
90
+ /**
91
+ * Sparks shed from the blade tip while a slash trail is live. vfxSystem
92
+ * accumulates `sparkle · dt` and calls this with the whole-spark count each
93
+ * frame, so emission is framerate-independent and deterministic.
94
+ */
95
+ export function emitSlashSparkle({ settings, rng, tip, count, now = 0, overrides = null }) {
96
+ const e = withOverrides(settings.slash, overrides);
97
+ if (!e.enabled || count <= 0) return { glow: [], puff: [] };
98
+ const glow = [];
99
+ for (let i = 0; i < count; i += 1) {
100
+ const dir = randUnitVector(rng);
101
+ const speed = lerp(0.4, 1.6, rng());
102
+ const size = lerp(0.02, 0.05, rng());
103
+ const color = jitterColor(e.color, rng, 0.12);
104
+ glow.push({
105
+ birth: now,
106
+ extra: 2.0,
107
+ gravity: 4,
108
+ kind: BURST_KIND.spark,
109
+ lifetime: lerp(0.12, 0.3, rng()),
110
+ r: color[0] * e.intensity, g: color[1] * e.intensity, b: color[2] * e.intensity,
111
+ seed: rng(),
112
+ size0: size, size1: size * 0.3,
113
+ vx: dir[0] * speed, vy: dir[1] * speed, vz: dir[2] * speed,
114
+ x: tip[0], y: tip[1], z: tip[2],
115
+ });
116
+ }
117
+ return { glow, puff: [] };
118
+ }
@@ -0,0 +1,18 @@
1
+ // Gameplay-VFX cluster — event-driven combat/movement effects, organized by
2
+ // category (weapon / magic / movement). Entry point: createVfxSystem
3
+ // (vfxSystem.js). core/vfxRandom.js is deliberately NOT re-exported (its
4
+ // names would collide with ambientfx/fauna on the root package surface).
5
+ export * from './vfxSettings.js';
6
+ export * from './vfxPresets.js';
7
+ export * from './vfxSystem.js';
8
+ export { BURST_KIND, GROUP_FOR_BURST_KIND, createBurstBackbone } from './core/burstBackbone.js';
9
+ export { createTrailRibbon } from './core/trailRibbon.js';
10
+ export { createProjectileCore } from './core/projectileCore.js';
11
+ export { emitImpact, emitSlashSparkle } from './effects/weaponEffects.js';
12
+ export { emitFireballEmbers, emitFireballExplosion } from './effects/magicEffects.js';
13
+ export { emitFootstep, emitLanding } from './effects/movementEffects.js';
14
+ export { createStylizedWeapon, getWeaponOptions, WEAPON_IDS } from './weapons/stylizedWeapons.js';
15
+ export {
16
+ collectMoveEvents, getMove, getMoveOptions, MOVE_IDS, moveDuration, sampleMovePose,
17
+ } from './moves/moveLibrary.js';
18
+ export { createMoveController } from './moves/moveController.js';
@@ -0,0 +1,146 @@
1
+ // Drives a weapon through a move and translates the move's event track into
2
+ // VFX spawns — the runtime glue a game uses for held-weapon attacks:
3
+ //
4
+ // const weapon = createStylizedWeapon({ type: 'greatsword' });
5
+ // actorGroup.add(weapon.root);
6
+ // const attack = createMoveController({ weapon, vfx });
7
+ // attack.play('overhead'); // on the attack input
8
+ // attack.update(delta); // per frame, before vfx.update
9
+ //
10
+ // The controller owns the trail lifecycle (start/stop beats live in the
11
+ // move data), fires impacts at the blade tip with the swing's real travel
12
+ // direction, and grounds landing/dust beats under the actor. Bring-your-own
13
+ // animation stays possible: skip the controller and drive vfx.spawn('slash',
14
+ // { follow: bone }) from your own clips — the moves are the batteries
15
+ // included, not a cage.
16
+
17
+ import * as THREE from 'three';
18
+
19
+ import { collectMoveEvents, getMove, sampleMovePose } from './moveLibrary.js';
20
+
21
+ const tipScratch = new THREE.Vector3();
22
+ const prevTipScratch = new THREE.Vector3();
23
+ const normalScratch = new THREE.Vector3();
24
+
25
+ export function createMoveController({ weapon, vfx, onEvent = null, groundY = 0 } = {}) {
26
+ if (!weapon?.root) throw new Error('createMoveController needs a weapon from createStylizedWeapon().');
27
+ const weight = weapon.profile?.weight ?? 1;
28
+
29
+ let move = null;
30
+ let time = 0;
31
+ let trail = null;
32
+ let hasPrevTip = false;
33
+
34
+ const applyPose = (pose) => {
35
+ weapon.root.position.set(pose.p[0], pose.p[1], pose.p[2]);
36
+ weapon.root.rotation.set(pose.r[0], pose.r[1], pose.r[2], 'XYZ');
37
+ };
38
+
39
+ const tipWorld = (target) => {
40
+ weapon.root.updateWorldMatrix(true, false);
41
+ return target.fromArray(weapon.anchors.tip).applyMatrix4(weapon.root.matrixWorld);
42
+ };
43
+
44
+ const stopTrail = () => {
45
+ trail?.stop();
46
+ trail = null;
47
+ };
48
+
49
+ const controller = {
50
+ get playing() { return move !== null; },
51
+ get phase() { return move ? sampleMovePose(move, time, weight).phaseId : null; },
52
+
53
+ /** Starts a move by id (restarts if one is already playing). */
54
+ play(moveId) {
55
+ const next = getMove(moveId);
56
+ if (!next) {
57
+ console.warn(`[vfxgen] Unknown move "${moveId}".`);
58
+ return controller;
59
+ }
60
+ stopTrail();
61
+ move = next;
62
+ time = 0;
63
+ hasPrevTip = false;
64
+ applyPose(sampleMovePose(move, 0, weight));
65
+ return controller;
66
+ },
67
+
68
+ stop() {
69
+ stopTrail();
70
+ move = null;
71
+ return controller;
72
+ },
73
+
74
+ /** Per frame. Returns true while a move is playing. */
75
+ update(delta) {
76
+ if (!move) return false;
77
+ const dt = Math.min(Math.max(delta ?? 0.016, 0), 0.1);
78
+ const prevTime = time;
79
+ time += dt;
80
+
81
+ const pose = sampleMovePose(move, time, weight);
82
+ applyPose(pose);
83
+
84
+ // Track the tip across frames so impacts inherit the swing's real
85
+ // travel direction (the spark spray leaves ALONG the blow).
86
+ const tip = tipWorld(tipScratch);
87
+ const travel = hasPrevTip
88
+ ? normalScratch.copy(tip).sub(prevTipScratch)
89
+ : normalScratch.set(0, 1, 0);
90
+ if (travel.lengthSq() < 1e-8) travel.set(0, 1, 0);
91
+ travel.normalize();
92
+
93
+ for (const event of collectMoveEvents(move, prevTime, time, weight)) {
94
+ onEvent?.(event);
95
+ if (!vfx) continue;
96
+ switch (event.do) {
97
+ case 'trailStart': {
98
+ stopTrail();
99
+ // The arc reaches a touch past the physical tip — reference
100
+ // trails overshoot the blade slightly.
101
+ const { base, tip } = weapon.anchors;
102
+ trail = vfx.spawn('slash', {
103
+ base: [...base],
104
+ follow: weapon.root,
105
+ tip: base.map((b, i) => b + (tip[i] - b) * 1.15),
106
+ });
107
+ break;
108
+ }
109
+ case 'trailStop':
110
+ stopTrail();
111
+ break;
112
+ case 'impact':
113
+ vfx.spawn('impact', {
114
+ at: [tip.x, Math.max(tip.y, groundY + 0.08), tip.z],
115
+ normal: [travel.x, travel.y, travel.z],
116
+ power: event.power ?? 1,
117
+ });
118
+ break;
119
+ case 'landing':
120
+ vfx.spawn('landing', {
121
+ at: [weapon.root.position.x, groundY, weapon.root.position.z],
122
+ power: event.power ?? 1,
123
+ });
124
+ break;
125
+ case 'dust':
126
+ vfx.spawn('footstep', {
127
+ at: [weapon.root.position.x, groundY, weapon.root.position.z],
128
+ });
129
+ break;
130
+ default:
131
+ break;
132
+ }
133
+ }
134
+
135
+ prevTipScratch.copy(tip);
136
+ hasPrevTip = true;
137
+
138
+ if (pose.done) {
139
+ stopTrail();
140
+ move = null;
141
+ }
142
+ return move !== null;
143
+ },
144
+ };
145
+ return controller;
146
+ }
@@ -0,0 +1,335 @@
1
+ // The weapon-move library: authored default motions the VFX rides on, so a
2
+ // designer only tunes the LOOK — the swing itself is provided. Every move is
3
+ // PURE DATA broken into phases (windup → strike → recover …), and each phase
4
+ // carries its own VFX event track (trail on/off, impact, landing, dust).
5
+ // Elaborate moves decompose the same way — `plunge` is the Dragoon jump:
6
+ // crouch → leap → apex → dive → landfall → recover, six phases, each with
7
+ // its beats.
8
+ //
9
+ // Conventions (what weapons/stylizedWeapons.js provides):
10
+ // - actor origin at (0,0,0), facing −X; grip pose is relative to it
11
+ // - pose = { p: [x, y, z] grip position, r: [rx, ry, rz] euler 'XYZ' },
12
+ // weapon blade along local +Y
13
+ // - phase durations are seconds at weight 1; a weapon profile's `weight`
14
+ // multiplies time (dagger flicks, hammer commits) and impact power
15
+ //
16
+ // This module is pure math — no THREE — so verify-vfxgen.mjs exercises the
17
+ // full move set in Node. moves/moveController.js is the THREE-side driver.
18
+
19
+ const HALF_PI = Math.PI / 2;
20
+ const REST = { p: [0.35, 1.0, 0.25], r: [0, 0, -0.35] };
21
+
22
+ /** Grip on a circle around the actor, blade pointing outward, horizontal. */
23
+ function arcPose(theta, { height = 1.15, radius = 0.55 } = {}) {
24
+ return {
25
+ p: [Math.cos(theta) * radius, height, Math.sin(theta) * radius],
26
+ r: [0, -theta, -HALF_PI],
27
+ };
28
+ }
29
+
30
+ const key = (t, pose) => ({ t, ...pose });
31
+
32
+ /**
33
+ * Grip pose for a PLANE swing: the blade rotates about a shoulder pivot in
34
+ * the actor's X/Y plane (a real cut crosses the body in a plane — orbiting
35
+ * the actor like a cylinder folds the trail into a chevron from most
36
+ * cameras). rz is the blade angle: 0 = straight up, +ve sweeps toward the
37
+ * actor's left/down.
38
+ */
39
+ function swingPose(rz, { pivot = [0, 1.25, 0.1], reach = 0.35 } = {}) {
40
+ const dir = [-Math.sin(rz), Math.cos(rz), 0];
41
+ return {
42
+ p: [pivot[0] + dir[0] * reach, pivot[1] + dir[1] * reach, pivot[2] + dir[2] * reach],
43
+ r: [0, 0, rz],
44
+ };
45
+ }
46
+
47
+ const MOVES = {
48
+ slash: {
49
+ id: 'slash',
50
+ label: 'Slash',
51
+ description: 'A wide plane cut across the body — the bread-and-butter swing; the trail draws one continuous ~200° crescent.',
52
+ phases: [
53
+ {
54
+ id: 'windup',
55
+ duration: 0.22,
56
+ keys: [key(0, REST), key(1, swingPose(-1.7))],
57
+ events: [],
58
+ },
59
+ {
60
+ id: 'strike',
61
+ duration: 0.18,
62
+ // Four keys keep the interpolated arc round across the wide sweep.
63
+ keys: [
64
+ key(0, swingPose(-1.7)),
65
+ key(0.35, swingPose(-0.5)),
66
+ key(0.7, swingPose(0.9)),
67
+ key(1, swingPose(2.3)),
68
+ ],
69
+ events: [
70
+ { at: 0, do: 'trailStart' },
71
+ { at: 0.6, do: 'impact', power: 1.0 },
72
+ ],
73
+ },
74
+ {
75
+ id: 'recover',
76
+ duration: 0.3,
77
+ keys: [key(0, swingPose(2.3)), key(1, REST)],
78
+ events: [{ at: 0.15, do: 'trailStop' }],
79
+ },
80
+ ],
81
+ },
82
+
83
+ overhead: {
84
+ id: 'overhead',
85
+ label: 'Overhead',
86
+ description: 'Raise over the shoulder, chop down through the front.',
87
+ phases: [
88
+ {
89
+ id: 'windup',
90
+ duration: 0.28,
91
+ keys: [key(0, REST), key(1, { p: [0.15, 1.62, 0.15], r: [0, 0, -0.5] })],
92
+ events: [],
93
+ },
94
+ {
95
+ id: 'strike',
96
+ duration: 0.16,
97
+ keys: [
98
+ key(0, { p: [0.15, 1.62, 0.15], r: [0, 0, -0.5] }),
99
+ key(0.55, { p: [-0.3, 1.25, 0.12], r: [0, 0, HALF_PI] }),
100
+ key(1, { p: [-0.52, 0.72, 0.1], r: [0, 0, 2.62] }),
101
+ ],
102
+ events: [
103
+ { at: 0, do: 'trailStart' },
104
+ { at: 0.8, do: 'impact', power: 1.25 },
105
+ ],
106
+ },
107
+ {
108
+ id: 'recover',
109
+ duration: 0.32,
110
+ keys: [key(0, { p: [-0.52, 0.72, 0.1], r: [0, 0, 2.62] }), key(1, REST)],
111
+ events: [{ at: 0.15, do: 'trailStop' }],
112
+ },
113
+ ],
114
+ },
115
+
116
+ thrust: {
117
+ id: 'thrust',
118
+ label: 'Thrust',
119
+ description: 'Coil back and lunge the point straight forward — spears live here.',
120
+ phases: [
121
+ {
122
+ id: 'windup',
123
+ duration: 0.18,
124
+ keys: [key(0, REST), key(1, { p: [0.5, 1.12, 0.12], r: [0, 0, HALF_PI] })],
125
+ events: [],
126
+ },
127
+ {
128
+ id: 'strike',
129
+ duration: 0.12,
130
+ keys: [
131
+ key(0, { p: [0.5, 1.12, 0.12], r: [0, 0, HALF_PI] }),
132
+ key(1, { p: [-0.78, 1.12, 0.05], r: [0, 0, HALF_PI] }),
133
+ ],
134
+ events: [
135
+ { at: 0, do: 'trailStart' },
136
+ { at: 0.85, do: 'impact', power: 0.9 },
137
+ ],
138
+ },
139
+ {
140
+ id: 'recover',
141
+ duration: 0.26,
142
+ keys: [key(0, { p: [-0.78, 1.12, 0.05], r: [0, 0, HALF_PI] }), key(1, REST)],
143
+ events: [{ at: 0.2, do: 'trailStop' }],
144
+ },
145
+ ],
146
+ },
147
+
148
+ spin: {
149
+ id: 'spin',
150
+ label: 'Spin',
151
+ description: 'Full 360° sweep — the trail draws a complete circle.',
152
+ phases: [
153
+ {
154
+ id: 'windup',
155
+ duration: 0.2,
156
+ keys: [key(0, REST), key(1, arcPose(Math.PI + 0.3, { height: 1.18 }))],
157
+ events: [],
158
+ },
159
+ {
160
+ id: 'strike',
161
+ duration: 0.5,
162
+ // Five keys around the circle keep the interpolated arc round.
163
+ keys: [0, 0.25, 0.5, 0.75, 1].map((t) =>
164
+ key(t, arcPose(Math.PI + 0.3 - t * Math.PI * 2, { height: 1.18, radius: 0.62 }))),
165
+ events: [
166
+ { at: 0, do: 'trailStart' },
167
+ { at: 0.45, do: 'impact', power: 0.8 },
168
+ ],
169
+ },
170
+ {
171
+ id: 'recover',
172
+ duration: 0.26,
173
+ keys: [key(0, arcPose(Math.PI + 0.3 - Math.PI * 2, { height: 1.18, radius: 0.62 })), key(1, REST)],
174
+ events: [{ at: 0.1, do: 'trailStop' }],
175
+ },
176
+ ],
177
+ },
178
+
179
+ plunge: {
180
+ id: 'plunge',
181
+ label: 'Plunge',
182
+ description: 'The Dragoon jump, decomposed: crouch → leap → apex → dive → landfall → recover.',
183
+ phases: [
184
+ {
185
+ id: 'crouch',
186
+ duration: 0.28,
187
+ keys: [key(0, REST), key(1, { p: [0.3, 0.68, 0.2], r: [0, 0, -0.6] })],
188
+ events: [{ at: 0.2, do: 'dust' }],
189
+ },
190
+ {
191
+ id: 'leap',
192
+ duration: 0.4,
193
+ keys: [
194
+ key(0, { p: [0.3, 0.68, 0.2], r: [0, 0, -0.6] }),
195
+ key(1, { p: [0.15, 3.4, 0.12], r: [0, 0, -0.15] }),
196
+ ],
197
+ events: [
198
+ { at: 0.1, do: 'trailStart' },
199
+ { at: 0.85, do: 'trailStop' },
200
+ ],
201
+ },
202
+ {
203
+ id: 'apex',
204
+ duration: 0.15,
205
+ // Hang beat: flip the blade to point straight down for the dive.
206
+ keys: [
207
+ key(0, { p: [0.15, 3.4, 0.12], r: [0, 0, -0.15] }),
208
+ key(1, { p: [-0.45, 3.28, 0.08], r: [0, 0, Math.PI] }),
209
+ ],
210
+ events: [],
211
+ },
212
+ {
213
+ id: 'dive',
214
+ duration: 0.22,
215
+ keys: [
216
+ key(0, { p: [-0.45, 3.28, 0.08], r: [0, 0, Math.PI] }),
217
+ key(1, { p: [-1.7, 0.45, 0], r: [0, 0, Math.PI] }),
218
+ ],
219
+ events: [{ at: 0, do: 'trailStart' }],
220
+ },
221
+ {
222
+ id: 'landfall',
223
+ duration: 0.3,
224
+ keys: [
225
+ key(0, { p: [-1.7, 0.45, 0], r: [0, 0, Math.PI] }),
226
+ key(1, { p: [-1.6, 0.55, 0.05], r: [0, 0, 2.6] }),
227
+ ],
228
+ events: [
229
+ { at: 0, do: 'impact', power: 2.2 },
230
+ { at: 0, do: 'landing', power: 2.0 },
231
+ { at: 0.12, do: 'dust' },
232
+ { at: 0.15, do: 'trailStop' },
233
+ ],
234
+ },
235
+ {
236
+ id: 'recover',
237
+ duration: 0.35,
238
+ keys: [key(0, { p: [-1.6, 0.55, 0.05], r: [0, 0, 2.6] }), key(1, REST)],
239
+ events: [],
240
+ },
241
+ ],
242
+ },
243
+ };
244
+
245
+ export const MOVE_IDS = Object.freeze(Object.keys(MOVES));
246
+
247
+ export function getMove(id) {
248
+ return MOVES[id] ?? null;
249
+ }
250
+
251
+ /** `{ id, label, description, phases: [ids] }` list for pickers/docs. */
252
+ export function getMoveOptions() {
253
+ return MOVE_IDS.map((id) => ({
254
+ description: MOVES[id].description,
255
+ id,
256
+ label: MOVES[id].label,
257
+ phases: MOVES[id].phases.map((phase) => phase.id),
258
+ }));
259
+ }
260
+
261
+ /** Total seconds of a move for a weapon weight (heavier = slower). */
262
+ export function moveDuration(move, weight = 1) {
263
+ return move.phases.reduce((sum, phase) => sum + phase.duration, 0) * Math.max(weight, 0.05);
264
+ }
265
+
266
+ function smooth(t) {
267
+ const u = Math.min(Math.max(t, 0), 1);
268
+ return u * u * (3 - 2 * u);
269
+ }
270
+
271
+ function lerp3(a, b, t) {
272
+ return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
273
+ }
274
+
275
+ function interpolateKeys(keys, t) {
276
+ if (t <= keys[0].t) return { p: [...keys[0].p], r: [...keys[0].r] };
277
+ for (let i = 1; i < keys.length; i += 1) {
278
+ if (t <= keys[i].t) {
279
+ const a = keys[i - 1];
280
+ const b = keys[i];
281
+ const u = smooth((t - a.t) / Math.max(b.t - a.t, 1e-6));
282
+ return { p: lerp3(a.p, b.p, u), r: lerp3(a.r, b.r, u) };
283
+ }
284
+ }
285
+ const last = keys[keys.length - 1];
286
+ return { p: [...last.p], r: [...last.r] };
287
+ }
288
+
289
+ /**
290
+ * Grip pose at `time` seconds into a move for a weapon weight. Returns
291
+ * `{ p, r, phaseId, done }`; after the move ends it holds the final pose
292
+ * with `done: true` so controllers can settle and release.
293
+ */
294
+ export function sampleMovePose(move, time, weight = 1) {
295
+ const scale = Math.max(weight, 0.05);
296
+ let cursor = 0;
297
+ for (const phase of move.phases) {
298
+ const span = phase.duration * scale;
299
+ if (time < cursor + span) {
300
+ const local = (time - cursor) / span;
301
+ return { ...interpolateKeys(phase.keys, local), done: false, phaseId: phase.id };
302
+ }
303
+ cursor += span;
304
+ }
305
+ const lastPhase = move.phases[move.phases.length - 1];
306
+ return { ...interpolateKeys(lastPhase.keys, 1), done: true, phaseId: lastPhase.id };
307
+ }
308
+
309
+ /**
310
+ * Events whose scaled absolute time falls in (t0, t1] — controllers call
311
+ * this once per frame with the previous/current clock so every beat fires
312
+ * exactly once regardless of frame rate. Impact power scales with weight
313
+ * (a hammer landing carries more than a dagger flick).
314
+ */
315
+ export function collectMoveEvents(move, t0, t1, weight = 1) {
316
+ const scale = Math.max(weight, 0.05);
317
+ const fired = [];
318
+ let cursor = 0;
319
+ for (const phase of move.phases) {
320
+ const span = phase.duration * scale;
321
+ for (const event of phase.events) {
322
+ const at = cursor + event.at * span;
323
+ if (at > t0 && at <= t1) {
324
+ fired.push({
325
+ ...event,
326
+ phaseId: phase.id,
327
+ power: event.power !== undefined ? event.power * (0.6 + 0.4 * scale) : undefined,
328
+ time: at,
329
+ });
330
+ }
331
+ }
332
+ cursor += span;
333
+ }
334
+ return fired.sort((a, b) => a.time - b.time);
335
+ }