@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,549 @@
1
+ // Adapter-driven game-feel runtime. It owns scheduling, deterministic effect
2
+ // selection, overlap composition and budgets, while hosts retain ownership of
3
+ // cameras, gameplay clocks, audio contexts, DOM and renderable objects.
4
+
5
+ import { deriveSeed, hashSeed } from '../core/generation.js';
6
+ import {
7
+ applyGameFeelQualityBudget,
8
+ GAME_FEEL_GENERATOR_DOMAIN,
9
+ GAME_FEEL_PRESET_DOCUMENT_TYPE,
10
+ resolveGameFeelGeneratorRecipe,
11
+ sanitizeGameFeelSettings,
12
+ validateGameFeelPresetDocument,
13
+ } from './gameFeelGenerator.js';
14
+
15
+ const effectFactories = new Map();
16
+ const DEFAULT_EFFECT_PRIORITIES = Object.freeze({
17
+ timeWarp: 100,
18
+ cameraImpulse: 90,
19
+ scalePunch: 80,
20
+ screenFlash: 70,
21
+ haptics: 60,
22
+ audioCue: 50,
23
+ });
24
+
25
+ const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
26
+ const finite = (value, fallback = 0) => Number.isFinite(Number(value)) ? Number(value) : fallback;
27
+
28
+ function callSafely(callback, argument, counters) {
29
+ if (typeof callback !== 'function') return false;
30
+ try {
31
+ const result = callback(argument);
32
+ if (result?.catch) result.catch(() => { counters.adapterErrors += 1; });
33
+ // Adapters may explicitly decline a capability so the runtime can use a
34
+ // browser fallback and report unsupported output honestly.
35
+ return result !== false;
36
+ } catch {
37
+ counters.adapterErrors += 1;
38
+ return false;
39
+ }
40
+ }
41
+
42
+ function effectDuration(effect, fallback = 1 / 60) {
43
+ return Math.max(1 / 1000, finite(effect?.duration, fallback));
44
+ }
45
+
46
+ /** Registers a serialized effect type without introducing a finite enum. */
47
+ export function registerGameFeelEffectType(type, factory, { overwrite = false } = {}) {
48
+ const id = String(type ?? '').trim();
49
+ if (!id) throw new Error('Game-feel effect type is required.');
50
+ if (typeof factory !== 'function') throw new Error(`Game-feel effect "${id}" factory must be a function.`);
51
+ if (!overwrite && effectFactories.has(id)) throw new Error(`Game-feel effect "${id}" already exists.`);
52
+ effectFactories.set(id, factory);
53
+ return id;
54
+ }
55
+
56
+ export function getGameFeelEffectTypeOptions() {
57
+ return [...effectFactories.keys()];
58
+ }
59
+
60
+ function cameraImpulseFactory(context) {
61
+ const cameraRig = context.adapters.cameraRig;
62
+ const handle = cameraRig?.addImpulse?.({
63
+ decay: context.effect.decay,
64
+ duration: context.effect.duration,
65
+ frequency: context.effect.frequency,
66
+ power: context.effect.power * context.intensity,
67
+ seed: context.seed,
68
+ }) ?? null;
69
+ return {
70
+ duration: effectDuration(context.effect, 0.28),
71
+ dispose() { handle?.stop?.(); },
72
+ };
73
+ }
74
+
75
+ function timeWarpFactory(context) {
76
+ const duration = effectDuration(context.effect, 0.1);
77
+ const hold = clamp(finite(context.effect.hold, 0.38), 0, 1);
78
+ const scale = clamp(1 - ((1 - finite(context.effect.scale, 0.12)) * context.intensity), 0, 1);
79
+ return {
80
+ duration,
81
+ timeScaleAt(age) {
82
+ if (age < 0 || age > duration) return 1;
83
+ const holdEnd = duration * hold;
84
+ if (age <= holdEnd || holdEnd >= duration) return scale;
85
+ return scale + (1 - scale) * ((age - holdEnd) / (duration - holdEnd));
86
+ },
87
+ timeScaleBreaks: [duration * hold, duration],
88
+ };
89
+ }
90
+
91
+ function hapticsFactory(context) {
92
+ const argument = {
93
+ duration: context.effect.duration,
94
+ eventId: context.eventId,
95
+ highFrequency: clamp(context.effect.highFrequency * context.intensity, 0, 1),
96
+ intensity: context.intensity,
97
+ lowFrequency: clamp(context.effect.lowFrequency * context.intensity, 0, 1),
98
+ payload: context.payload,
99
+ };
100
+ let handled = callSafely(context.adapters.haptics, argument, context.counters);
101
+ if (!handled) {
102
+ const navigatorObject = globalThis.navigator;
103
+ try {
104
+ const gamepads = navigatorObject?.getGamepads?.() ?? [];
105
+ const gamepad = [...gamepads].find((entry) => entry?.vibrationActuator?.playEffect);
106
+ if (gamepad) {
107
+ const result = gamepad.vibrationActuator.playEffect('dual-rumble', {
108
+ duration: Math.round(argument.duration * 1000),
109
+ strongMagnitude: argument.lowFrequency,
110
+ weakMagnitude: argument.highFrequency,
111
+ });
112
+ result?.catch?.(() => { context.counters.adapterErrors += 1; });
113
+ handled = true;
114
+ } else if (typeof navigatorObject?.vibrate === 'function') {
115
+ handled = navigatorObject.vibrate(Math.round(argument.duration * 1000)) !== false;
116
+ }
117
+ } catch {
118
+ context.counters.adapterErrors += 1;
119
+ }
120
+ }
121
+ if (handled) context.counters.hapticDispatches += 1;
122
+ else context.counters.unsupportedHaptics += 1;
123
+ return { duration: effectDuration(context.effect, 0.12) };
124
+ }
125
+
126
+ function screenFlashFactory(context) {
127
+ const duration = effectDuration(context.effect, 0.12);
128
+ const color = Array.isArray(context.effect.color) ? context.effect.color.slice(0, 3) : [1, 1, 1];
129
+ const opacity = clamp(finite(context.effect.opacity, 0.18) * context.intensity, 0, 1);
130
+ return {
131
+ duration,
132
+ sample(frame, progress) {
133
+ frame.flashes.push({ color, opacity: opacity * (1 - progress) ** 2 });
134
+ },
135
+ };
136
+ }
137
+
138
+ function scalePunchFactory(context) {
139
+ const duration = effectDuration(context.effect, 0.2);
140
+ const target = context.payload.scaleTarget ?? context.payload.target ?? context.adapters.scaleTarget ?? null;
141
+ const amount = Math.max(0, finite(context.effect.amount, 0.14) * context.intensity);
142
+ return {
143
+ duration,
144
+ sample(frame, progress) {
145
+ if (!target) return;
146
+ // Fast expansion and a single soft undershoot. The curve is evaluated
147
+ // from absolute age, so its pose does not depend on frame rate.
148
+ const punch = Math.sin(progress * Math.PI) * amount;
149
+ const settle = Math.sin(progress * Math.PI * 2) * amount * 0.14 * progress;
150
+ frame.punches.push({ amount: punch - settle, target });
151
+ },
152
+ };
153
+ }
154
+
155
+ function audioCueFactory(context) {
156
+ const handled = callSafely(context.adapters.audioCue, {
157
+ effect: context.effect,
158
+ eventId: context.eventId,
159
+ gain: context.effect.gain * context.intensity,
160
+ intensity: context.intensity,
161
+ payload: context.payload,
162
+ pitch: context.effect.pitch,
163
+ seed: context.seed,
164
+ }, context.counters);
165
+ if (handled) context.counters.audioDispatches += 1;
166
+ else context.counters.unsupportedAudio += 1;
167
+ return { duration: 1 / 60 };
168
+ }
169
+
170
+ registerGameFeelEffectType('cameraImpulse', cameraImpulseFactory);
171
+ registerGameFeelEffectType('timeWarp', timeWarpFactory);
172
+ registerGameFeelEffectType('haptics', hapticsFactory);
173
+ registerGameFeelEffectType('screenFlash', screenFlashFactory);
174
+ registerGameFeelEffectType('scalePunch', scalePunchFactory);
175
+ registerGameFeelEffectType('audioCue', audioCueFactory);
176
+
177
+ function normalizeRuntimeSettings(input, quality = 'balanced') {
178
+ let normalized;
179
+ if (input?.type === `toonlab/${GAME_FEEL_GENERATOR_DOMAIN}-generator`) {
180
+ normalized = resolveGameFeelGeneratorRecipe(input, { quality });
181
+ } else if (input?.type === GAME_FEEL_PRESET_DOCUMENT_TYPE) {
182
+ const result = validateGameFeelPresetDocument(input);
183
+ if (!result.ok) throw new Error(result.errors.join(' '));
184
+ normalized = result.value.settings;
185
+ } else {
186
+ normalized = sanitizeGameFeelSettings(input);
187
+ }
188
+ // Quality is a runtime concern, not just a generator concern. Applying the
189
+ // cap here lets mobile safely tighten raw settings and imported presets too.
190
+ return applyGameFeelQualityBudget(normalized, quality);
191
+ }
192
+
193
+ function scaleAt(entry, offset) {
194
+ return clamp(finite(entry.instance.timeScaleAt?.(entry.age + offset), 1), 0, 1);
195
+ }
196
+
197
+ // The built-in time-warp curve is piecewise linear. Splitting at every phase
198
+ // boundary and pairwise crossing lets us integrate the minimum of overlapping
199
+ // warps exactly, so scaled time is invariant across 30/60/120 Hz update rates.
200
+ function integrateTimeScale(entries, delta) {
201
+ const warps = entries.filter((entry) => typeof entry.instance.timeScaleAt === 'function');
202
+ if (warps.length === 0 || delta <= 0) return delta;
203
+ let points = [0, delta];
204
+ for (const entry of warps) {
205
+ for (const phase of entry.instance.timeScaleBreaks ?? [entry.duration]) {
206
+ const offset = phase - entry.age;
207
+ if (offset > 0 && offset < delta) points.push(offset);
208
+ }
209
+ }
210
+ points = [...new Set(points)].sort((a, b) => a - b);
211
+ const crossings = [];
212
+ for (let index = 0; index < points.length - 1; index += 1) {
213
+ const from = points[index];
214
+ const to = points[index + 1];
215
+ const inset = (to - from) * 1e-9;
216
+ const sampleFrom = from + inset;
217
+ const sampleTo = to - inset;
218
+ for (let a = 0; a < warps.length; a += 1) {
219
+ for (let b = a + 1; b < warps.length; b += 1) {
220
+ const differenceFrom = scaleAt(warps[a], sampleFrom) - scaleAt(warps[b], sampleFrom);
221
+ const differenceTo = scaleAt(warps[a], sampleTo) - scaleAt(warps[b], sampleTo);
222
+ if (differenceFrom * differenceTo < 0) {
223
+ crossings.push(sampleFrom + (sampleTo - sampleFrom) * (differenceFrom / (differenceFrom - differenceTo)));
224
+ }
225
+ }
226
+ }
227
+ }
228
+ points = [...new Set([...points, ...crossings])].sort((a, b) => a - b);
229
+ const minimumAt = (offset) => warps.reduce((minimum, entry) => Math.min(minimum, scaleAt(entry, offset)), 1);
230
+ let integral = 0;
231
+ for (let index = 0; index < points.length - 1; index += 1) {
232
+ const from = points[index];
233
+ const to = points[index + 1];
234
+ const inset = (to - from) * 1e-9;
235
+ integral += (to - from) * (minimumAt(from + inset) + minimumAt(to - inset)) * 0.5;
236
+ }
237
+ return integral;
238
+ }
239
+
240
+ function writeTargetScale(target, multiplier, record, adapter, counters) {
241
+ if (typeof adapter === 'function') {
242
+ callSafely(adapter, { multiplier, target }, counters);
243
+ record.lastMultiplier = multiplier;
244
+ return;
245
+ }
246
+ const scale = target?.scale;
247
+ if (!scale || !Number.isFinite(scale.x) || !Number.isFinite(scale.y) || !Number.isFinite(scale.z)) return;
248
+ const previous = Math.max(record.lastMultiplier, 1e-6);
249
+ const baseX = scale.x / previous;
250
+ const baseY = scale.y / previous;
251
+ const baseZ = scale.z / previous;
252
+ if (typeof scale.set === 'function') scale.set(baseX * multiplier, baseY * multiplier, baseZ * multiplier);
253
+ else Object.assign(scale, { x: baseX * multiplier, y: baseY * multiplier, z: baseZ * multiplier });
254
+ record.lastMultiplier = multiplier;
255
+ }
256
+
257
+ function applyPunchFrame(frame, scaleRecords, adapters, counters) {
258
+ const combined = new Map();
259
+ for (const punch of frame.punches) {
260
+ combined.set(punch.target, (combined.get(punch.target) ?? 0) + punch.amount);
261
+ }
262
+ const targets = new Set([...scaleRecords.keys(), ...combined.keys()]);
263
+ for (const target of targets) {
264
+ const record = scaleRecords.get(target) ?? { lastMultiplier: 1 };
265
+ const multiplier = Math.max(0.05, 1 + (combined.get(target) ?? 0));
266
+ writeTargetScale(target, multiplier, record, adapters.applyScalePunch, counters);
267
+ if (combined.has(target)) scaleRecords.set(target, record);
268
+ else scaleRecords.delete(target);
269
+ }
270
+ }
271
+
272
+ function applyFlashFrame(frame, adapters, counters) {
273
+ let opacity = 0;
274
+ let weight = 0;
275
+ const color = [0, 0, 0];
276
+ for (const flash of frame.flashes) {
277
+ const alpha = clamp(finite(flash.opacity, 0), 0, 1);
278
+ opacity = 1 - ((1 - opacity) * (1 - alpha));
279
+ weight += alpha;
280
+ for (let channel = 0; channel < 3; channel += 1) color[channel] += finite(flash.color[channel], 1) * alpha;
281
+ }
282
+ if (weight > 0) for (let channel = 0; channel < 3; channel += 1) color[channel] /= weight;
283
+ else color.fill(1);
284
+ callSafely(adapters.setScreenFlash ?? adapters.screenFlash, { color, opacity }, counters);
285
+ return { color, opacity };
286
+ }
287
+
288
+ /**
289
+ * Creates a scheduler. `update(realDelta)` returns the gameplay delta after
290
+ * hit-stop/time-warp; camera, flash and haptics continue on unscaled time.
291
+ */
292
+ export function createGameFeelRuntime(options = {}) {
293
+ if (options?.events || options?.master || options?.type) options = { settings: options };
294
+ const adapters = { ...(options.adapters ?? {}) };
295
+ if (options.cameraRig) adapters.cameraRig = options.cameraRig;
296
+ const localFactories = new Map(effectFactories);
297
+ for (const [type, factory] of Object.entries(options.effectFactories ?? {})) localFactories.set(type, factory);
298
+ let settings = normalizeRuntimeSettings(options.settings ?? {}, options.quality);
299
+ let disposed = false;
300
+ let active = [];
301
+ let elapsedReal = 0;
302
+ let elapsedScaled = 0;
303
+ let serial = 0;
304
+ let lastTimeScale = 1;
305
+ let lastFlash = { color: [1, 1, 1], opacity: 0 };
306
+ const lastTriggers = new Map();
307
+ const scaleRecords = new Map();
308
+ const counters = {
309
+ adapterErrors: 0,
310
+ audioDispatches: 0,
311
+ cooldownRejections: 0,
312
+ effectsCompleted: 0,
313
+ effectsDropped: 0,
314
+ effectsStarted: 0,
315
+ hapticDispatches: 0,
316
+ triggerAttempts: 0,
317
+ triggersAccepted: 0,
318
+ unknownEffects: 0,
319
+ unknownEvents: 0,
320
+ unsupportedAudio: 0,
321
+ unsupportedHaptics: 0,
322
+ updates: 0,
323
+ };
324
+
325
+ function stopEntry(entry, completed = false) {
326
+ try { entry.instance.dispose?.(); } catch { counters.adapterErrors += 1; }
327
+ if (completed) counters.effectsCompleted += 1;
328
+ }
329
+
330
+ function enforceBudget() {
331
+ const maximum = settings.master.maxConcurrentEffects;
332
+ if (active.length <= maximum) return;
333
+ active.sort((a, b) => b.priority - a.priority || b.serial - a.serial);
334
+ const dropped = active.splice(maximum);
335
+ for (const entry of dropped) stopEntry(entry);
336
+ counters.effectsDropped += dropped.length;
337
+ }
338
+
339
+ const runtime = {
340
+ configure(input, { quality = options.quality ?? 'balanced' } = {}) {
341
+ if (disposed) return runtime;
342
+ settings = normalizeRuntimeSettings(input, quality);
343
+ enforceBudget();
344
+ return runtime;
345
+ },
346
+ dispose() {
347
+ if (disposed) return;
348
+ disposed = true;
349
+ for (const entry of active) stopEntry(entry);
350
+ active = [];
351
+ const emptyFrame = { flashes: [], punches: [] };
352
+ applyPunchFrame(emptyFrame, scaleRecords, adapters, counters);
353
+ applyFlashFrame(emptyFrame, adapters, counters);
354
+ callSafely(adapters.setTimeScale ?? adapters.timeScale, 1, counters);
355
+ adapters.dispose?.();
356
+ lastTimeScale = 1;
357
+ lastFlash = { color: [1, 1, 1], opacity: 0 };
358
+ },
359
+ get scaledDelta() { return runtime._scaledDelta ?? 0; },
360
+ get settings() { return settings; },
361
+ get timeScale() { return lastTimeScale; },
362
+ getStats() { return runtime.stats(); },
363
+ stats() {
364
+ const byType = {};
365
+ for (const entry of active) byType[entry.type] = (byType[entry.type] ?? 0) + 1;
366
+ return {
367
+ ...counters,
368
+ activeByType: byType,
369
+ activeEffects: active.length,
370
+ disposed,
371
+ elapsedReal,
372
+ elapsedScaled,
373
+ flashOpacity: lastFlash.opacity,
374
+ maxConcurrentEffects: settings.master.maxConcurrentEffects,
375
+ maxEffectsPerTrigger: settings.master.maxEffectsPerTrigger,
376
+ timeScale: lastTimeScale,
377
+ };
378
+ },
379
+ trigger(eventId, payload = {}, triggerOptions = {}) {
380
+ counters.triggerAttempts += 1;
381
+ if (disposed) return { accepted: false, effectCount: 0, eventId, reason: 'disposed' };
382
+ const id = String(eventId ?? '');
383
+ const event = settings.events[id];
384
+ if (!event) {
385
+ counters.unknownEvents += 1;
386
+ return { accepted: false, effectCount: 0, eventId: id, reason: 'unknown-event' };
387
+ }
388
+ if (!event.enabled) return { accepted: false, effectCount: 0, eventId: id, reason: 'disabled' };
389
+ const previous = lastTriggers.get(id);
390
+ if (!triggerOptions.ignoreCooldown && previous !== undefined && elapsedReal - previous < event.cooldown) {
391
+ counters.cooldownRejections += 1;
392
+ return { accepted: false, effectCount: 0, eventId: id, reason: 'cooldown' };
393
+ }
394
+ lastTriggers.set(id, elapsedReal);
395
+ counters.triggersAccepted += 1;
396
+ const triggerSeed = hashSeed(triggerOptions.seed ?? deriveSeed(options.seed ?? 1, `${id}:${++serial}`));
397
+ const intensity = Math.max(0, finite(triggerOptions.intensity, 1)
398
+ * event.intensity * settings.master.intensity * finite(payload.intensity, 1));
399
+ const candidates = Object.entries(event.effects ?? {})
400
+ .filter(([, effect]) => effect?.enabled !== false)
401
+ .map(([type, effect]) => ({
402
+ effect,
403
+ factory: localFactories.get(type) ?? effectFactories.get(type),
404
+ priority: finite(effect.priority, DEFAULT_EFFECT_PRIORITIES[type] ?? 40),
405
+ type,
406
+ }))
407
+ .sort((a, b) => b.priority - a.priority || a.type.localeCompare(b.type));
408
+ const supported = [];
409
+ for (const candidate of candidates) {
410
+ if (candidate.factory) supported.push(candidate);
411
+ else {
412
+ counters.unknownEffects += 1;
413
+ counters.effectsDropped += 1;
414
+ }
415
+ }
416
+ const maximum = Math.min(
417
+ settings.master.maxEffectsPerTrigger,
418
+ Math.max(0, settings.master.maxConcurrentEffects - active.length),
419
+ );
420
+ const selected = supported.slice(0, maximum);
421
+ counters.effectsDropped += supported.length - selected.length;
422
+ const handles = [];
423
+ for (const candidate of selected) {
424
+ const factory = candidate.factory;
425
+ const effectSeed = deriveSeed(triggerSeed, candidate.type);
426
+ try {
427
+ const instance = factory({
428
+ adapters,
429
+ counters,
430
+ effect: candidate.effect,
431
+ event,
432
+ eventId: id,
433
+ intensity,
434
+ payload: payload ?? {},
435
+ runtime,
436
+ seed: effectSeed,
437
+ settings,
438
+ }) ?? {};
439
+ const entry = {
440
+ age: 0,
441
+ duration: Math.max(1 / 1000, finite(instance.duration, effectDuration(candidate.effect))),
442
+ instance,
443
+ priority: candidate.priority,
444
+ serial: ++serial,
445
+ type: candidate.type,
446
+ };
447
+ active.push(entry);
448
+ counters.effectsStarted += 1;
449
+ handles.push({
450
+ type: candidate.type,
451
+ stop() {
452
+ const index = active.indexOf(entry);
453
+ if (index < 0) return;
454
+ active.splice(index, 1);
455
+ stopEntry(entry);
456
+ },
457
+ });
458
+ } catch {
459
+ counters.adapterErrors += 1;
460
+ counters.effectsDropped += 1;
461
+ }
462
+ }
463
+ return { accepted: true, effectCount: handles.length, eventId: id, handles, intensity, seed: triggerSeed };
464
+ },
465
+ update(delta) {
466
+ if (disposed) return { delta: 0, flash: lastFlash, realDelta: 0, timeScale: 1 };
467
+ const realDelta = clamp(finite(delta, 0), 0, finite(options.maxDelta, 0.25));
468
+ const scaledDelta = integrateTimeScale(active, realDelta);
469
+ elapsedReal += realDelta;
470
+ elapsedScaled += scaledDelta;
471
+ runtime._scaledDelta = scaledDelta;
472
+ counters.updates += 1;
473
+ const frame = { flashes: [], punches: [] };
474
+ const survivors = [];
475
+ for (const entry of active) {
476
+ entry.age += realDelta;
477
+ const progress = clamp(entry.age / entry.duration, 0, 1);
478
+ try {
479
+ entry.instance.sample?.(frame, progress);
480
+ entry.instance.update?.({
481
+ delta: realDelta,
482
+ elapsed: entry.age,
483
+ frame,
484
+ progress,
485
+ scaledDelta,
486
+ });
487
+ } catch {
488
+ counters.adapterErrors += 1;
489
+ }
490
+ if (entry.age + 1e-12 < entry.duration) survivors.push(entry);
491
+ else stopEntry(entry, true);
492
+ }
493
+ active = survivors;
494
+ lastTimeScale = active.reduce((minimum, entry) => (
495
+ Math.min(minimum, typeof entry.instance.timeScaleAt === 'function' ? scaleAt(entry, 0) : 1)
496
+ ), 1);
497
+ callSafely(adapters.setTimeScale ?? adapters.timeScale, lastTimeScale, counters);
498
+ applyPunchFrame(frame, scaleRecords, adapters, counters);
499
+ lastFlash = applyFlashFrame(frame, adapters, counters);
500
+ return { delta: scaledDelta, flash: lastFlash, realDelta, timeScale: lastTimeScale };
501
+ },
502
+ };
503
+ return runtime;
504
+ }
505
+
506
+ /** Minimal browser adapters for lab/prototype use; all are optional. */
507
+ export function createGameFeelDomAdapters({
508
+ audioContext = null,
509
+ audioDestination = null,
510
+ flashElement = null,
511
+ } = {}) {
512
+ let context = audioContext;
513
+ let ownsContext = false;
514
+ const adapters = {
515
+ audioCue({ eventId, gain, pitch }) {
516
+ const Context = globalThis.AudioContext ?? globalThis.webkitAudioContext;
517
+ if (!context && Context) {
518
+ context = new Context();
519
+ ownsContext = true;
520
+ }
521
+ if (!context) return false;
522
+ context.resume?.().catch?.(() => {});
523
+ const oscillator = context.createOscillator();
524
+ const envelope = context.createGain();
525
+ const base = eventId === 'reward' ? 620 : eventId === 'damage' ? 120 : eventId === 'movement' ? 280 : 190;
526
+ oscillator.type = eventId === 'reward' ? 'sine' : 'triangle';
527
+ oscillator.frequency.setValueAtTime(base * finite(pitch, 1), context.currentTime);
528
+ oscillator.frequency.exponentialRampToValueAtTime(Math.max(40, base * 0.62 * finite(pitch, 1)), context.currentTime + 0.09);
529
+ envelope.gain.setValueAtTime(0.0001, context.currentTime);
530
+ envelope.gain.exponentialRampToValueAtTime(Math.max(0.0002, finite(gain, 0.4) * 0.16), context.currentTime + 0.008);
531
+ envelope.gain.exponentialRampToValueAtTime(0.0001, context.currentTime + 0.11);
532
+ oscillator.connect(envelope).connect(audioDestination ?? context.destination);
533
+ oscillator.start();
534
+ oscillator.stop(context.currentTime + 0.12);
535
+ return true;
536
+ },
537
+ dispose() {
538
+ if (ownsContext) context?.close?.().catch?.(() => {});
539
+ context = null;
540
+ },
541
+ setScreenFlash({ color, opacity }) {
542
+ if (!flashElement) return;
543
+ const channels = color.map((channel) => Math.round(clamp(channel, 0, 1) * 255));
544
+ flashElement.style.backgroundColor = `rgb(${channels.join(' ')})`;
545
+ flashElement.style.opacity = String(opacity);
546
+ },
547
+ };
548
+ return adapters;
549
+ }
@@ -0,0 +1,2 @@
1
+ export * from './gameFeelGenerator.js';
2
+ export * from './gameFeelRuntime.js';
package/src/index.js CHANGED
@@ -8,21 +8,32 @@ export * from './environment/environmentPlanarReflection.js';
8
8
  export * from './environment/environmentPresets.js';
9
9
  export * from './environment/environmentRigs.js';
10
10
  export * from './environment/environmentTimeOfDay.js';
11
+ export * from './lighting/index.js';
11
12
  export * from './post/postProcessing.js';
13
+ export * from './post/postGenerator.js';
14
+ export * from './camera/index.js';
15
+ export * from './game-feel/index.js';
12
16
  export * from './water/water.js';
13
17
  export * from './sky/stylizedSky.js';
14
- export * from './vegetation/stylizedGrass.js';
15
- export * from './vegetation/stylizedTreeFoliage.js';
16
- export * from './vegetation/stylizedTree.js';
17
- export * from './vegetation/stylizedFlowers.js';
18
+ export * from './styles/index.js';
19
+ export * from './weather/index.js';
20
+ export * from './vegetation/index.js';
18
21
  export * from './character/characterRig.js';
19
22
  export * from './character/freestyleSwimClip.js';
20
23
  export * from './rockgen/index.js';
21
24
  export * from './debrisgen/index.js';
22
- export * from './vegetation/scatter.js';
25
+ export * from './pathgen/index.js';
26
+ export * from './propgen/index.js';
27
+ export * from './buildinggen/index.js';
28
+ export * from './villagegen/index.js';
29
+ export * from './catalog/manifest.js';
30
+ // hashCombine is exported by both clusters below; the ambiguous name drops
31
+ // out of the root surface (import it from a subpath instead).
32
+ export * from './fauna/index.js';
33
+ export * from './ambientfx/index.js';
34
+ export * from './vfxgen/index.js';
23
35
  export * from './worldPresets.js';
24
36
  export * from './stylizedWorld.js';
25
- export * from './vegetation/stylizedForest.js';
26
37
  export * from './worldCollision.js';
27
38
  export * from './worldMinimap.js';
28
39
  export * from './stylizedTerrain.js';