@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,402 @@
1
+ // Deterministic game-feel event recipes. Built-in events are examples, not a
2
+ // ceiling: registries can add arbitrary event ids and effect graphs, while a
3
+ // recipe embeds the complete result for portable design/runtime parity.
4
+
5
+ import {
6
+ cloneSerializable,
7
+ createGeneratorRecipeDocument,
8
+ deepMerge,
9
+ parseGeneratorRecipeDocument,
10
+ resolveGeneratorRecipe,
11
+ serializeGeneratorRecipeDocument,
12
+ validateGeneratorRecipeDocument,
13
+ } from '../core/generation.js';
14
+ import { parsePresetDocument } from '../core/presetDocuments.js';
15
+
16
+ export const GAME_FEEL_GENERATOR_DOMAIN = 'game-feel';
17
+ export const GAME_FEEL_PRESET_DOCUMENT_TYPE = 'toonlab/game-feel-preset';
18
+ export const GAME_FEEL_PRESET_SCHEMA_VERSION = 1;
19
+
20
+ const range = (min, max, step, extra = {}) => ({ $type: 'range', min, max, step, ...extra });
21
+ const bool = (probability) => ({ $type: 'boolean', probability });
22
+ const choice = (options) => ({ $type: 'choice', options });
23
+ const color = (from, to) => ({ $type: 'color', from, linked: true, to });
24
+
25
+ const EVENT_DEFINITIONS = new Map();
26
+ const FAMILY_DEFINITIONS = new Map();
27
+
28
+ const BASE_EFFECTS = Object.freeze({
29
+ audioCue: Object.freeze({ enabled: true, gain: 0.5, pitch: 1 }),
30
+ cameraImpulse: Object.freeze({ decay: 8, duration: 0.28, enabled: true, frequency: 14, power: 0.75 }),
31
+ haptics: Object.freeze({ duration: 0.12, enabled: true, highFrequency: 0.28, lowFrequency: 0.48 }),
32
+ scalePunch: Object.freeze({ amount: 0.14, duration: 0.2, enabled: true }),
33
+ screenFlash: Object.freeze({ color: [1, 0.9, 0.72], duration: 0.12, enabled: true, opacity: 0.18 }),
34
+ timeWarp: Object.freeze({ duration: 0.1, enabled: true, hold: 0.38, scale: 0.12 }),
35
+ });
36
+
37
+ function eventDefaults(overrides = {}) {
38
+ return deepMerge({
39
+ cooldown: 0.035,
40
+ enabled: true,
41
+ intensity: 1,
42
+ effects: BASE_EFFECTS,
43
+ }, overrides);
44
+ }
45
+
46
+ function eventDomains(overrides = {}) {
47
+ return deepMerge({
48
+ cooldown: range(0.01, 0.14, 0.005, { distribution: 'log' }),
49
+ enabled: bool(0.96),
50
+ intensity: range(0.55, 1.5, 0.01, { distribution: 'normal', mean: 1, deviation: 0.22 }),
51
+ effects: {
52
+ audioCue: {
53
+ enabled: bool(0.9),
54
+ gain: range(0.18, 0.95, 0.01),
55
+ pitch: range(0.72, 1.42, 0.01),
56
+ },
57
+ cameraImpulse: {
58
+ decay: range(3, 16, 0.1),
59
+ duration: range(0.08, 0.62, 0.01),
60
+ enabled: bool(0.96),
61
+ frequency: range(4, 28, 0.5),
62
+ power: range(0.18, 1.8, 0.01, { distribution: 'normal', mean: 0.75, deviation: 0.38 }),
63
+ },
64
+ haptics: {
65
+ duration: range(0.035, 0.38, 0.005),
66
+ enabled: bool(0.82),
67
+ highFrequency: range(0.04, 0.9, 0.01),
68
+ lowFrequency: range(0.08, 1, 0.01),
69
+ },
70
+ scalePunch: {
71
+ amount: range(0.03, 0.34, 0.005),
72
+ duration: range(0.08, 0.45, 0.01),
73
+ enabled: bool(0.9),
74
+ },
75
+ screenFlash: {
76
+ color: color([0.35, 0.65, 1], [1, 0.42, 0.2]),
77
+ duration: range(0.045, 0.35, 0.005),
78
+ enabled: bool(0.84),
79
+ opacity: range(0.04, 0.42, 0.01),
80
+ },
81
+ timeWarp: {
82
+ duration: range(0.035, 0.28, 0.005),
83
+ enabled: bool(0.86),
84
+ hold: range(0.08, 0.72, 0.01),
85
+ scale: range(0.02, 0.7, 0.01, { distribution: 'normal', mean: 0.18, deviation: 0.18 }),
86
+ },
87
+ },
88
+ }, overrides);
89
+ }
90
+
91
+ /** Adds an event channel with arbitrary defaults and generator domains. */
92
+ export function registerGameFeelEventType(id, definition = {}, {
93
+ inheritDefaultDomains = false,
94
+ overwrite = false,
95
+ } = {}) {
96
+ const key = String(id ?? '').trim();
97
+ if (!key) throw new Error('Game-feel event id is required.');
98
+ if (!overwrite && EVENT_DEFINITIONS.has(key)) throw new Error(`Game-feel event "${key}" already exists.`);
99
+ EVENT_DEFINITIONS.set(key, {
100
+ defaults: eventDefaults(definition.defaults),
101
+ description: String(definition.description ?? ''),
102
+ // Third-party event defaults remain exact unless the author explicitly
103
+ // supplies domains. Built-ins opt into the complete continuous domain
104
+ // tree below; extensions can opt in too without a closed event enum.
105
+ domains: inheritDefaultDomains
106
+ ? eventDomains(definition.domains)
107
+ : deepMerge(definition.domains),
108
+ label: String(definition.label || key),
109
+ });
110
+ return key;
111
+ }
112
+
113
+ registerGameFeelEventType('impact', {
114
+ label: 'Impact',
115
+ defaults: {},
116
+ }, { inheritDefaultDomains: true });
117
+ registerGameFeelEventType('damage', {
118
+ label: 'Damage',
119
+ defaults: {
120
+ effects: {
121
+ audioCue: { gain: 0.7, pitch: 0.82 },
122
+ cameraImpulse: { duration: 0.42, power: 1.15 },
123
+ haptics: { duration: 0.22, highFrequency: 0.35, lowFrequency: 0.72 },
124
+ screenFlash: { color: [1, 0.18, 0.12], opacity: 0.3 },
125
+ timeWarp: { duration: 0.16, scale: 0.08 },
126
+ },
127
+ },
128
+ }, { inheritDefaultDomains: true });
129
+ registerGameFeelEventType('movement', {
130
+ label: 'Movement accent',
131
+ defaults: {
132
+ cooldown: 0.08,
133
+ effects: {
134
+ cameraImpulse: { duration: 0.18, power: 0.32 },
135
+ haptics: { duration: 0.06, highFrequency: 0.1, lowFrequency: 0.12 },
136
+ screenFlash: { enabled: false },
137
+ timeWarp: { enabled: false },
138
+ },
139
+ },
140
+ }, { inheritDefaultDomains: true });
141
+ registerGameFeelEventType('reward', {
142
+ label: 'Reward',
143
+ defaults: {
144
+ effects: {
145
+ audioCue: { gain: 0.72, pitch: 1.24 },
146
+ cameraImpulse: { duration: 0.35, frequency: 7, power: 0.42 },
147
+ haptics: { duration: 0.26, highFrequency: 0.55, lowFrequency: 0.25 },
148
+ screenFlash: { color: [1, 0.84, 0.18], duration: 0.28, opacity: 0.22 },
149
+ timeWarp: { duration: 0.08, hold: 0.1, scale: 0.7 },
150
+ },
151
+ },
152
+ }, { inheritDefaultDomains: true });
153
+
154
+ function currentSettingsDefaults() {
155
+ return {
156
+ master: {
157
+ intensity: 1,
158
+ maxConcurrentEffects: 18,
159
+ maxEffectsPerTrigger: 8,
160
+ },
161
+ events: Object.fromEntries(
162
+ [...EVENT_DEFINITIONS.entries()].map(([id, definition]) => [id, definition.defaults]),
163
+ ),
164
+ };
165
+ }
166
+
167
+ export const DEFAULT_GAME_FEEL_SETTINGS = Object.freeze(currentSettingsDefaults());
168
+
169
+ function currentDomains() {
170
+ return {
171
+ master: {
172
+ intensity: range(0.45, 1.6, 0.01, { distribution: 'normal', mean: 1, deviation: 0.24 }),
173
+ maxConcurrentEffects: range(8, 32, 1, { integer: true }),
174
+ maxEffectsPerTrigger: range(4, 12, 1, { integer: true }),
175
+ },
176
+ events: Object.fromEntries(
177
+ [...EVENT_DEFINITIONS.entries()].map(([id, definition]) => [id, definition.domains]),
178
+ ),
179
+ };
180
+ }
181
+
182
+ export const DEFAULT_GAME_FEEL_GENERATOR_DOMAINS = Object.freeze(currentDomains());
183
+
184
+ FAMILY_DEFINITIONS.set('responsive', {
185
+ description: 'Readable impact feedback with short freezes and layered sensory accents.',
186
+ label: 'Responsive',
187
+ });
188
+ FAMILY_DEFINITIONS.set('arcade', {
189
+ configuration: {
190
+ master: { intensity: 1.25 },
191
+ events: {
192
+ impact: { effects: { scalePunch: { amount: 0.24 }, timeWarp: { scale: 0.07 } } },
193
+ },
194
+ },
195
+ domains: {
196
+ events: {
197
+ impact: {
198
+ effects: {
199
+ cameraImpulse: { power: range(0.8, 2.4, 0.01) },
200
+ screenFlash: { opacity: range(0.16, 0.5, 0.01) },
201
+ },
202
+ },
203
+ },
204
+ },
205
+ label: 'Arcade',
206
+ });
207
+
208
+ const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
209
+ const finite = (value, fallback, min = -Infinity, max = Infinity) => (
210
+ clamp(Number.isFinite(Number(value)) ? Number(value) : fallback, min, max)
211
+ );
212
+
213
+ function cleanEffect(type, source = {}) {
214
+ const effect = cloneSerializable(source && typeof source === 'object' ? source : {});
215
+ effect.enabled = source.enabled === undefined ? true : Boolean(source.enabled);
216
+ if (type === 'cameraImpulse') {
217
+ effect.decay = finite(source.decay, 8, 0.01, 100);
218
+ effect.duration = finite(source.duration, 0.28, 0.001, 10);
219
+ effect.frequency = finite(source.frequency, 14, 0, 200);
220
+ effect.power = finite(source.power, 0.75, 0, 20);
221
+ } else if (type === 'timeWarp') {
222
+ effect.duration = finite(source.duration, 0.1, 0.001, 10);
223
+ effect.hold = finite(source.hold, 0.38, 0, 1);
224
+ effect.scale = finite(source.scale, 0.12, 0, 1);
225
+ } else if (type === 'haptics') {
226
+ effect.duration = finite(source.duration, 0.12, 0.001, 10);
227
+ effect.highFrequency = finite(source.highFrequency, 0.28, 0, 1);
228
+ effect.lowFrequency = finite(source.lowFrequency, 0.48, 0, 1);
229
+ } else if (type === 'screenFlash') {
230
+ effect.duration = finite(source.duration, 0.12, 0.001, 10);
231
+ effect.opacity = finite(source.opacity, 0.18, 0, 1);
232
+ effect.color = Array.isArray(source.color)
233
+ ? source.color.slice(0, 3).map((channel) => finite(channel, 1, 0, 1))
234
+ : [1, 0.9, 0.72];
235
+ } else if (type === 'scalePunch') {
236
+ effect.amount = finite(source.amount, 0.14, 0, 2);
237
+ effect.duration = finite(source.duration, 0.2, 0.001, 10);
238
+ } else if (type === 'audioCue') {
239
+ effect.gain = finite(source.gain, 0.5, 0, 4);
240
+ effect.pitch = finite(source.pitch, 1, 0.05, 8);
241
+ }
242
+ return effect;
243
+ }
244
+
245
+ /** Preserves custom event/effect ids while normalizing known runtime fields. */
246
+ export function sanitizeGameFeelSettings(input = {}) {
247
+ const source = deepMerge(currentSettingsDefaults(), input);
248
+ const events = {};
249
+ for (const [id, value] of Object.entries(source.events ?? {})) {
250
+ const event = value && typeof value === 'object' ? value : {};
251
+ events[id] = {
252
+ cooldown: finite(event.cooldown, 0.035, 0, 60),
253
+ enabled: event.enabled === undefined ? true : Boolean(event.enabled),
254
+ intensity: finite(event.intensity, 1, 0, 10),
255
+ effects: Object.fromEntries(Object.entries(event.effects ?? {}).map(
256
+ ([type, effect]) => [type, cleanEffect(type, effect)],
257
+ )),
258
+ };
259
+ }
260
+ return {
261
+ master: {
262
+ intensity: finite(source.master?.intensity, 1, 0, 10),
263
+ maxConcurrentEffects: Math.round(finite(source.master?.maxConcurrentEffects, 18, 1, 256)),
264
+ maxEffectsPerTrigger: Math.round(finite(source.master?.maxEffectsPerTrigger, 8, 1, 64)),
265
+ },
266
+ events,
267
+ };
268
+ }
269
+
270
+ export function registerGameFeelGeneratorFamily(id, definition = {}, { overwrite = false } = {}) {
271
+ const key = String(id ?? '').trim();
272
+ if (!key) throw new Error('Game-feel family id is required.');
273
+ if (!overwrite && FAMILY_DEFINITIONS.has(key)) throw new Error(`Game-feel family "${key}" already exists.`);
274
+ FAMILY_DEFINITIONS.set(key, deepMerge(definition));
275
+ return key;
276
+ }
277
+
278
+ export function getGameFeelGeneratorFamilyOptions() {
279
+ return [...FAMILY_DEFINITIONS.entries()].map(([id, definition]) => ({
280
+ description: definition.description ?? '', id, label: definition.label ?? id,
281
+ }));
282
+ }
283
+
284
+ export function getGameFeelEventOptions() {
285
+ return [...EVENT_DEFINITIONS.entries()].map(([id, definition]) => ({
286
+ description: definition.description, id, label: definition.label,
287
+ }));
288
+ }
289
+
290
+ export function createGameFeelGeneratorRecipe(id = 'generated-game-feel', definition = {}) {
291
+ const family = FAMILY_DEFINITIONS.get(definition.family ?? 'responsive') ?? {};
292
+ return createGeneratorRecipeDocument(GAME_FEEL_GENERATOR_DOMAIN, id, {
293
+ // Capture current registry defaults in the portable recipe. This is
294
+ // essential for event types registered after module initialization,
295
+ // especially custom effects that have no generator domain of their own.
296
+ configuration: deepMerge(currentSettingsDefaults(), family.configuration, definition.configuration),
297
+ description: definition.description ?? '',
298
+ domains: deepMerge(currentDomains(), family.domains, definition.domains),
299
+ label: definition.label ?? id,
300
+ locks: definition.locks ?? [],
301
+ seed: definition.seed ?? 1,
302
+ });
303
+ }
304
+
305
+ export function validateGameFeelGeneratorRecipe(input) {
306
+ return validateGeneratorRecipeDocument(input, { domain: GAME_FEEL_GENERATOR_DOMAIN });
307
+ }
308
+
309
+ export function parseGameFeelGeneratorRecipe(input) {
310
+ return parseGeneratorRecipeDocument(input, { domain: GAME_FEEL_GENERATOR_DOMAIN });
311
+ }
312
+
313
+ export function serializeGameFeelGeneratorRecipe(idOrDocument, definition = {}, { pretty = true } = {}) {
314
+ return serializeGeneratorRecipeDocument(GAME_FEEL_GENERATOR_DOMAIN, idOrDocument, definition, { pretty });
315
+ }
316
+
317
+ const QUALITY_BUDGETS = Object.freeze({
318
+ mobile: { concurrent: 8, perTrigger: 5 },
319
+ balanced: { concurrent: 18, perTrigger: 8 },
320
+ cinematic: { concurrent: 32, perTrigger: 12 },
321
+ });
322
+
323
+ export function applyGameFeelQualityBudget(input, quality = 'balanced') {
324
+ const settings = sanitizeGameFeelSettings(input);
325
+ const budget = QUALITY_BUDGETS[quality] ?? QUALITY_BUDGETS.balanced;
326
+ settings.master.maxConcurrentEffects = Math.min(settings.master.maxConcurrentEffects, budget.concurrent);
327
+ settings.master.maxEffectsPerTrigger = Math.min(settings.master.maxEffectsPerTrigger, budget.perTrigger);
328
+ settings.runtime = { quality: QUALITY_BUDGETS[quality] ? quality : 'balanced' };
329
+ return settings;
330
+ }
331
+
332
+ export function resolveGameFeelGeneratorRecipe(recipeInput, { quality = 'balanced' } = {}) {
333
+ const validation = recipeInput?.type
334
+ ? validateGameFeelGeneratorRecipe(recipeInput)
335
+ : { ok: true, value: createGameFeelGeneratorRecipe('generated-game-feel', recipeInput) };
336
+ if (!validation.ok) throw new Error(validation.errors.join(' '));
337
+ const settings = resolveGeneratorRecipe(validation.value, {
338
+ baseSettings: currentSettingsDefaults(),
339
+ sanitizeSettings: sanitizeGameFeelSettings,
340
+ });
341
+ return applyGameFeelQualityBudget(settings, quality);
342
+ }
343
+
344
+ export function createGeneratedGameFeelPresetDocument(recipeInput, options = {}) {
345
+ const document = {
346
+ description: `Resolved from generator seed ${recipeInput?.seed ?? 1}.`,
347
+ id: options.id ?? `${recipeInput?.id ?? 'generated-game-feel'}-resolved`,
348
+ label: options.label ?? `${recipeInput?.label ?? 'Generated Game Feel'} · resolved`,
349
+ settings: resolveGameFeelGeneratorRecipe(recipeInput, { quality: options.quality }),
350
+ type: GAME_FEEL_PRESET_DOCUMENT_TYPE,
351
+ version: GAME_FEEL_PRESET_SCHEMA_VERSION,
352
+ };
353
+ const result = validateGameFeelPresetDocument(document);
354
+ if (!result.ok) throw new Error(result.errors.join(' '));
355
+ return result.value;
356
+ }
357
+
358
+ function normalizeDocumentId(value) {
359
+ return String(value ?? '').trim().replace(/[^a-zA-Z0-9._/-]+/g, '_');
360
+ }
361
+
362
+ /** Validates the flat, runtime-ready counterpart to a generator recipe. */
363
+ export function validateGameFeelPresetDocument(input) {
364
+ const errors = [];
365
+ const warnings = [];
366
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
367
+ return { errors: ['Game-feel preset must be a JSON object.'], ok: false, value: null, warnings };
368
+ }
369
+ if (input.type !== GAME_FEEL_PRESET_DOCUMENT_TYPE) {
370
+ errors.push(`Game-feel preset type must be "${GAME_FEEL_PRESET_DOCUMENT_TYPE}".`);
371
+ }
372
+ const version = Number(input.version ?? 1);
373
+ if (!Number.isInteger(version) || version < 1) errors.push('Game-feel preset version must be a positive integer.');
374
+ if (version > GAME_FEEL_PRESET_SCHEMA_VERSION) {
375
+ errors.push(`Game-feel preset version ${version} is newer than supported version ${GAME_FEEL_PRESET_SCHEMA_VERSION}.`);
376
+ }
377
+ const id = normalizeDocumentId(input.id);
378
+ if (!id) errors.push('Game-feel preset id is required.');
379
+ return {
380
+ errors,
381
+ ok: errors.length === 0,
382
+ value: errors.length > 0 ? null : {
383
+ description: String(input.description ?? ''),
384
+ id,
385
+ label: String(input.label || id),
386
+ settings: sanitizeGameFeelSettings(input.settings),
387
+ type: GAME_FEEL_PRESET_DOCUMENT_TYPE,
388
+ version: GAME_FEEL_PRESET_SCHEMA_VERSION,
389
+ },
390
+ warnings,
391
+ };
392
+ }
393
+
394
+ export function parseGameFeelPresetDocument(input) {
395
+ return parsePresetDocument(input, validateGameFeelPresetDocument, { invalidJsonLabel: 'game-feel preset' });
396
+ }
397
+
398
+ export function serializeGameFeelPresetDocument(input, { pretty = true } = {}) {
399
+ const result = validateGameFeelPresetDocument(input);
400
+ if (!result.ok) throw new Error(result.errors.join(' '));
401
+ return JSON.stringify(result.value, null, pretty ? 2 : 0);
402
+ }