oddlyalive 0.2.0-alpha.2

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 (86) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/CONTRIBUTING.md +62 -0
  3. package/LICENSE +21 -0
  4. package/README.md +158 -0
  5. package/SECURITY.md +29 -0
  6. package/assets/photoreal/PROVENANCE.md +19 -0
  7. package/assets/photoreal/baseball.png +0 -0
  8. package/assets/photoreal/basketball.png +0 -0
  9. package/assets/photoreal/crystal.png +0 -0
  10. package/assets/photoreal/kicking-cleat.png +0 -0
  11. package/assets/photoreal/letter-charm-square.png +0 -0
  12. package/assets/photoreal/letter-charm.png +0 -0
  13. package/assets/photoreal/sneaker.png +0 -0
  14. package/assets/photoreal/soccer-ball.png +0 -0
  15. package/bin/oddlyalive.js +283 -0
  16. package/docs/ARCHITECTURE.md +55 -0
  17. package/docs/DEMO-VIDEOS.md +122 -0
  18. package/docs/QUICKSTART.md +115 -0
  19. package/docs/ROADMAP.md +33 -0
  20. package/examples/ball-lab/PROVENANCE.md +4 -0
  21. package/examples/ball-lab/app.js +33 -0
  22. package/examples/ball-lab/index.html +56 -0
  23. package/examples/ball-lab/scene.json +68 -0
  24. package/examples/crystal-mobile/PROVENANCE.md +4 -0
  25. package/examples/crystal-mobile/app.js +26 -0
  26. package/examples/crystal-mobile/index.html +56 -0
  27. package/examples/crystal-mobile/scene.json +69 -0
  28. package/examples/football-kick/PROVENANCE.md +4 -0
  29. package/examples/football-kick/app.js +33 -0
  30. package/examples/football-kick/index.html +56 -0
  31. package/examples/football-kick/scene.json +44 -0
  32. package/examples/gallery.css +379 -0
  33. package/examples/index.html +109 -0
  34. package/examples/shared/example.css +320 -0
  35. package/examples/shared/player.js +85 -0
  36. package/examples/shoe-splash/PROVENANCE.md +4 -0
  37. package/examples/shoe-splash/app.js +26 -0
  38. package/examples/shoe-splash/index.html +56 -0
  39. package/examples/shoe-splash/scene.json +38 -0
  40. package/examples/string-touch/PROVENANCE.md +11 -0
  41. package/examples/string-touch/app.js +79 -0
  42. package/examples/string-touch/index.html +70 -0
  43. package/examples/string-touch/scene.json +73 -0
  44. package/examples/string-touch/styles.css +273 -0
  45. package/package.json +82 -0
  46. package/schemas/scene.schema.json +336 -0
  47. package/scripts/render-demo-videos.sh +77 -0
  48. package/scripts/serve.js +88 -0
  49. package/scripts/update-visual-fixtures.mjs +60 -0
  50. package/scripts/verify-demo-videos.mjs +138 -0
  51. package/skills/oddlyalive/SKILL.md +68 -0
  52. package/skills/oddlyalive/agents/openai.yaml +4 -0
  53. package/skills/oddlyalive/references/object-models.md +25 -0
  54. package/skills/oddlyalive/references/recipes.md +27 -0
  55. package/skills/oddlyalive/references/scene-schema.md +84 -0
  56. package/src/crystal-renderer.js +338 -0
  57. package/src/gesture.js +58 -0
  58. package/src/index.js +44 -0
  59. package/src/rigid-balls.js +304 -0
  60. package/src/rigid-renderer.js +760 -0
  61. package/src/rigid-scene.js +223 -0
  62. package/src/scene-registry.js +17 -0
  63. package/src/scene.js +206 -0
  64. package/src/state-hash.js +17 -0
  65. package/src/strands.js +773 -0
  66. package/src/surface-wave.js +209 -0
  67. package/src/svg-renderer.js +348 -0
  68. package/src/wave-renderer.js +463 -0
  69. package/src/wave-scene.js +163 -0
  70. package/tests/cli.test.js +101 -0
  71. package/tests/determinism.test.js +57 -0
  72. package/tests/fixtures/visual-baselines.json +27 -0
  73. package/tests/rigid-balls.test.js +77 -0
  74. package/tests/scene.test.js +78 -0
  75. package/tests/surface-wave.test.js +39 -0
  76. package/tests/visual-fixtures.test.js +43 -0
  77. package/videos/oddlyalive-demos/BRIEF.md +40 -0
  78. package/videos/oddlyalive-demos/DEMO-PLAN.md +13 -0
  79. package/videos/oddlyalive-demos/README.md +45 -0
  80. package/videos/oddlyalive-demos/app.js +212 -0
  81. package/videos/oddlyalive-demos/hyperframes.json +9 -0
  82. package/videos/oddlyalive-demos/index.html +246 -0
  83. package/videos/oddlyalive-demos/meta.json +5 -0
  84. package/videos/oddlyalive-demos/package-lock.json +506 -0
  85. package/videos/oddlyalive-demos/package.json +19 -0
  86. package/videos/oddlyalive-demos/prepare-assets.mjs +16 -0
@@ -0,0 +1,463 @@
1
+ const SVG_NS = "http://www.w3.org/2000/svg";
2
+
3
+ function svgNode(name, attributes = {}) {
4
+ const node = document.createElementNS(SVG_NS, name);
5
+ for (const [key, value] of Object.entries(attributes)) {
6
+ node.setAttribute(key, String(value));
7
+ }
8
+ return node;
9
+ }
10
+
11
+ function clamp(value, minimum, maximum) {
12
+ return Math.max(minimum, Math.min(maximum, value));
13
+ }
14
+
15
+ function createShoe(width, height, className = "oa-shoe") {
16
+ const group = svgNode("g", { class: className });
17
+ const left = -width * 0.5;
18
+ const top = -height * 0.5;
19
+ const upper = `M ${left + width * 0.03} ${top + height * 0.56}
20
+ Q ${left + width * 0.08} ${top + height * 0.25} ${left + width * 0.28} ${top + height * 0.18}
21
+ Q ${left + width * 0.43} ${top + height * 0.12} ${left + width * 0.55} ${top + height * 0.34}
22
+ L ${left + width * 0.68} ${top + height * 0.5}
23
+ Q ${left + width * 0.81} ${top + height * 0.57} ${left + width * 0.97} ${top + height * 0.58}
24
+ Q ${left + width} ${top + height * 0.64} ${left + width * 0.98} ${top + height * 0.75}
25
+ Q ${left + width * 0.66} ${top + height * 0.88} ${left + width * 0.22} ${top + height * 0.82}
26
+ L ${left + width * 0.03} ${top + height * 0.7} Z`;
27
+
28
+ group.append(
29
+ svgNode("path", {
30
+ d: upper,
31
+ fill: "url(#oa-shoe-upper)",
32
+ stroke: "#24231f",
33
+ "stroke-width": "2.35",
34
+ "stroke-linejoin": "round"
35
+ }),
36
+ svgNode("path", {
37
+ d: `M ${left + width * 0.02} ${top + height * 0.68}
38
+ Q ${left + width * 0.46} ${top + height * 0.9} ${left + width * 0.99} ${top + height * 0.7}
39
+ L ${left + width * 0.99} ${top + height * 0.87}
40
+ Q ${left + width * 0.53} ${top + height * 1.08} ${left + width * 0.02} ${top + height * 0.82} Z`,
41
+ fill: "url(#oa-shoe-sole)",
42
+ stroke: "#24231f",
43
+ "stroke-width": "1.7",
44
+ "stroke-linejoin": "round"
45
+ }),
46
+ svgNode("path", {
47
+ d: `M ${left + width * 0.03} ${top + height * 0.73}
48
+ Q ${left + width * 0.49} ${top + height * 0.94} ${left + width * 0.99} ${top + height * 0.76}`,
49
+ fill: "none",
50
+ stroke: "#fff9e9",
51
+ "stroke-width": "2.4"
52
+ }),
53
+ svgNode("path", {
54
+ d: `M ${left + width * 0.63} ${top + height * 0.49}
55
+ Q ${left + width * 0.82} ${top + height * 0.54} ${left + width * 0.97} ${top + height * 0.59}
56
+ Q ${left + width * 0.82} ${top + height * 0.66} ${left + width * 0.66} ${top + height * 0.69} Z`,
57
+ fill: "rgba(255,255,255,0.38)",
58
+ stroke: "rgba(36,35,31,0.42)",
59
+ "stroke-width": "0.9"
60
+ }),
61
+ svgNode("path", {
62
+ d: `M ${left + width * 0.1} ${top + height * 0.58}
63
+ Q ${left + width * 0.14} ${top + height * 0.3} ${left + width * 0.28} ${top + height * 0.23}
64
+ L ${left + width * 0.36} ${top + height * 0.69}
65
+ Q ${left + width * 0.22} ${top + height * 0.73} ${left + width * 0.1} ${top + height * 0.65} Z`,
66
+ fill: "rgba(196,202,198,0.48)",
67
+ stroke: "#4f514b",
68
+ "stroke-width": "1"
69
+ }),
70
+ svgNode("path", {
71
+ d: `M ${left + width * 0.25} ${top + height * 0.23}
72
+ Q ${left + width * 0.37} ${top + height * 0.15} ${left + width * 0.47} ${top + height * 0.31}
73
+ L ${left + width * 0.54} ${top + height * 0.64}
74
+ L ${left + width * 0.34} ${top + height * 0.7} Z`,
75
+ fill: "#d9d8cf",
76
+ stroke: "#42433e",
77
+ "stroke-width": "1.1"
78
+ }),
79
+ svgNode("path", {
80
+ d: `M ${left + width * 0.2} ${top + height * 0.25}
81
+ Q ${left + width * 0.33} ${top + height * 0.08} ${left + width * 0.47} ${top + height * 0.26}
82
+ Q ${left + width * 0.38} ${top + height * 0.38} ${left + width * 0.28} ${top + height * 0.31} Z`,
83
+ fill: "#343632",
84
+ stroke: "#20211e",
85
+ "stroke-width": "1"
86
+ }),
87
+ svgNode("path", {
88
+ d: `M ${left + width * 0.08} ${top + height * 0.61}
89
+ Q ${left + width * 0.28} ${top + height * 0.69} ${left + width * 0.47} ${top + height * 0.67}
90
+ M ${left + width * 0.67} ${top + height * 0.68}
91
+ Q ${left + width * 0.83} ${top + height * 0.65} ${left + width * 0.95} ${top + height * 0.61}`,
92
+ fill: "none",
93
+ stroke: "#8b8171",
94
+ "stroke-width": "0.9",
95
+ "stroke-dasharray": "2.3 2.3"
96
+ }),
97
+ svgNode("path", {
98
+ d: `M ${left + width * 0.67} ${top + height * 0.55}
99
+ q ${width * 0.07} ${-height * 0.12} ${width * 0.14} 0
100
+ q ${width * 0.05} ${height * 0.08} ${width * 0.1} ${-height * 0.02}`,
101
+ fill: "none",
102
+ stroke: "#ec6440",
103
+ "stroke-width": "2.4",
104
+ "stroke-linecap": "round"
105
+ })
106
+ );
107
+
108
+ for (let index = 0; index < 6; index += 1) {
109
+ const x = left + width * (0.29 + index * 0.054);
110
+ const y = top + height * (0.34 + index * 0.053);
111
+ group.append(
112
+ svgNode("circle", {
113
+ cx: x,
114
+ cy: y,
115
+ r: 2.15,
116
+ fill: "#d9d2c4",
117
+ stroke: "#272823",
118
+ "stroke-width": "0.85"
119
+ }),
120
+ svgNode("path", {
121
+ d: `M ${x - width * 0.035} ${y + height * 0.04} L ${x + width * 0.07} ${y - height * 0.045}`,
122
+ stroke: index % 2 === 0 ? "#ec6440" : "#3e403b",
123
+ "stroke-width": "1.55",
124
+ "stroke-linecap": "round"
125
+ })
126
+ );
127
+ }
128
+
129
+ for (let index = 0; index < 7; index += 1) {
130
+ const x = left + width * (0.13 + index * 0.105);
131
+ group.appendChild(
132
+ svgNode("path", {
133
+ d: `M ${x} ${top + height * 0.88} l ${width * 0.035} ${height * 0.075}`,
134
+ stroke: "#7b3425",
135
+ "stroke-width": "1.2",
136
+ "stroke-linecap": "round"
137
+ })
138
+ );
139
+ }
140
+
141
+ for (let index = 0; index < 8; index += 1) {
142
+ const x = left + width * (0.72 + (index % 4) * 0.055);
143
+ const y = top + height * (0.58 + Math.floor(index / 4) * 0.055);
144
+ group.appendChild(
145
+ svgNode("circle", {
146
+ cx: x,
147
+ cy: y,
148
+ r: 0.9,
149
+ fill: "#88857a",
150
+ opacity: "0.48"
151
+ })
152
+ );
153
+ }
154
+ return group;
155
+ }
156
+
157
+ function createPhotoShoe(width, height, href, className = "oa-shoe") {
158
+ const group = svgNode("g", { class: `${className} oa-shoe-photo` });
159
+ group.appendChild(
160
+ svgNode("image", {
161
+ href,
162
+ x: (-width * 0.58).toFixed(2),
163
+ y: (-height * 0.8).toFixed(2),
164
+ width: (width * 1.16).toFixed(2),
165
+ height: (height * 1.6).toFixed(2),
166
+ preserveAspectRatio: "xMidYMid meet",
167
+ decoding: "sync"
168
+ })
169
+ );
170
+ return group;
171
+ }
172
+
173
+ export function createSurfaceWaveRenderer(svg, simulation, options = {}) {
174
+ const { scene, frames, diagnostics } = simulation;
175
+ if (!frames) {
176
+ throw new TypeError("The surface renderer requires simulation frames.");
177
+ }
178
+
179
+ const root = svgNode("g", { class: "oa-wave-scene" });
180
+ const defs = svgNode("defs");
181
+ const waterGradient = svgNode("linearGradient", {
182
+ id: "oa-water-fill",
183
+ x1: "0%",
184
+ y1: "0%",
185
+ x2: "0%",
186
+ y2: "100%"
187
+ });
188
+ waterGradient.append(
189
+ svgNode("stop", { offset: "0%", "stop-color": "#75cad8" }),
190
+ svgNode("stop", { offset: "48%", "stop-color": "#3997ad" }),
191
+ svgNode("stop", { offset: "100%", "stop-color": "#155a72" })
192
+ );
193
+ const upperGradient = svgNode("linearGradient", {
194
+ id: "oa-shoe-upper",
195
+ x1: "0%",
196
+ y1: "0%",
197
+ x2: "100%",
198
+ y2: "100%"
199
+ });
200
+ upperGradient.append(
201
+ svgNode("stop", { offset: "0%", "stop-color": "#fffdf3" }),
202
+ svgNode("stop", { offset: "52%", "stop-color": "#e7e4d9" }),
203
+ svgNode("stop", { offset: "100%", "stop-color": "#aaa99f" })
204
+ );
205
+ const soleGradient = svgNode("linearGradient", {
206
+ id: "oa-shoe-sole",
207
+ x1: "0%",
208
+ y1: "0%",
209
+ x2: "0%",
210
+ y2: "100%"
211
+ });
212
+ soleGradient.append(
213
+ svgNode("stop", { offset: "0%", "stop-color": "#f1704c" }),
214
+ svgNode("stop", { offset: "100%", "stop-color": "#9d3827" })
215
+ );
216
+ const blur = svgNode("filter", {
217
+ id: "oa-water-blur",
218
+ x: "-40%",
219
+ y: "-100%",
220
+ width: "180%",
221
+ height: "300%"
222
+ });
223
+ blur.appendChild(svgNode("feGaussianBlur", { stdDeviation: "5" }));
224
+ const shoeShadow = svgNode("filter", {
225
+ id: "oa-shoe-shadow",
226
+ x: "-35%",
227
+ y: "-45%",
228
+ width: "180%",
229
+ height: "210%"
230
+ });
231
+ shoeShadow.appendChild(
232
+ svgNode("feDropShadow", {
233
+ dx: "3",
234
+ dy: "6",
235
+ stdDeviation: "3.6",
236
+ "flood-color": "#183b3f",
237
+ "flood-opacity": "0.28"
238
+ })
239
+ );
240
+ defs.append(
241
+ waterGradient,
242
+ upperGradient,
243
+ soleGradient,
244
+ blur,
245
+ shoeShadow
246
+ );
247
+ root.appendChild(defs);
248
+
249
+ const water = svgNode("path", {
250
+ fill: "url(#oa-water-fill)",
251
+ opacity: "0.82"
252
+ });
253
+ const caustics = svgNode("g", {
254
+ class: "oa-water-caustics",
255
+ opacity: "0.3"
256
+ });
257
+ for (let index = 0; index < 9; index += 1) {
258
+ const y = scene.surface.restY + 50 + index * 22;
259
+ const offset = (index % 3) * 23;
260
+ caustics.appendChild(
261
+ svgNode("path", {
262
+ d: `M ${scene.surface.x + offset} ${y} q 42 -13 84 0 t 84 0 t 84 0 t 84 0 t 84 0 t 84 0 t 84 0`,
263
+ fill: "none",
264
+ stroke: index % 2 === 0
265
+ ? "rgba(224,255,251,0.42)"
266
+ : "rgba(9,76,99,0.25)",
267
+ "stroke-width": index % 3 === 0 ? "2" : "1.1"
268
+ })
269
+ );
270
+ }
271
+
272
+ const reflection = options.shoeHref
273
+ ? createPhotoShoe(
274
+ scene.body.width,
275
+ scene.body.height,
276
+ options.shoeHref,
277
+ "oa-shoe-reflection"
278
+ )
279
+ : createShoe(
280
+ scene.body.width,
281
+ scene.body.height,
282
+ "oa-shoe-reflection"
283
+ );
284
+ reflection.setAttribute("opacity", "0");
285
+ reflection.setAttribute("filter", "url(#oa-water-blur)");
286
+ const waterShadow = svgNode("ellipse", {
287
+ fill: "rgba(11,58,71,0.38)",
288
+ filter: "url(#oa-water-blur)"
289
+ });
290
+ const shoe = options.shoeHref
291
+ ? createPhotoShoe(scene.body.width, scene.body.height, options.shoeHref)
292
+ : createShoe(scene.body.width, scene.body.height);
293
+ shoe.setAttribute("filter", "url(#oa-shoe-shadow)");
294
+ const foregroundWater = svgNode("path", {
295
+ fill: "#2a91a8",
296
+ opacity: "0.26"
297
+ });
298
+ const surface = svgNode("path", {
299
+ fill: "none",
300
+ stroke: "#145f72",
301
+ "stroke-width": "3.2",
302
+ "stroke-linecap": "round",
303
+ "stroke-linejoin": "round"
304
+ });
305
+ const surfaceGlint = svgNode("path", {
306
+ fill: "none",
307
+ stroke: "rgba(235,255,250,0.78)",
308
+ "stroke-width": "1.05",
309
+ "stroke-linecap": "round",
310
+ "stroke-linejoin": "round",
311
+ transform: "translate(0 -2)"
312
+ });
313
+
314
+ const foam = svgNode("g", { class: "oa-water-foam", opacity: "0" });
315
+ for (let index = 0; index < 18; index += 1) {
316
+ foam.appendChild(
317
+ svgNode("ellipse", {
318
+ rx: 3 + (index % 4) * 1.2,
319
+ ry: 1.3 + (index % 3) * 0.55,
320
+ fill: index % 4 === 0 ? "#f7fbef" : "rgba(226,248,240,0.84)",
321
+ stroke: "rgba(24,103,118,0.28)",
322
+ "stroke-width": "0.45"
323
+ })
324
+ );
325
+ }
326
+
327
+ const spray = svgNode("g", { class: "oa-water-spray", opacity: "0" });
328
+ for (let index = 0; index < 26; index += 1) {
329
+ spray.appendChild(
330
+ svgNode(index % 5 === 0 ? "ellipse" : "circle", index % 5 === 0
331
+ ? {
332
+ rx: 1.4 + (index % 4) * 0.45,
333
+ ry: 5.2 + (index % 3) * 1.6,
334
+ fill: index % 2 === 0 ? "#dff8f3" : "#76cfda",
335
+ opacity: "0.9"
336
+ }
337
+ : {
338
+ r: 1.4 + (index % 4) * 0.7,
339
+ fill: index % 3 === 0 ? "#f2fff9" : "#62bdd0",
340
+ stroke: "rgba(24,103,118,0.24)",
341
+ "stroke-width": "0.5"
342
+ })
343
+ );
344
+ }
345
+
346
+ root.append(
347
+ water,
348
+ caustics,
349
+ reflection,
350
+ waterShadow,
351
+ shoe,
352
+ foregroundWater,
353
+ surfaceGlint,
354
+ surface,
355
+ foam,
356
+ spray
357
+ );
358
+ svg.appendChild(root);
359
+
360
+ const impactTime = diagnostics.firstImpactTime ?? 0.82;
361
+ const impactFrameIndex = clamp(
362
+ Math.round(impactTime * scene.timing.fps),
363
+ 0,
364
+ frames.length - 1
365
+ );
366
+ const impactX = frames[impactFrameIndex].body.x;
367
+
368
+ function renderFrame(frameIndex) {
369
+ const safeIndex = Math.max(0, Math.min(frames.length - 1, frameIndex));
370
+ const frame = frames[safeIndex];
371
+ const time = safeIndex / scene.timing.fps;
372
+ const step = scene.surface.width / (frame.surface.length - 1);
373
+ let linePath = "";
374
+ for (let index = 0; index < frame.surface.length; index += 1) {
375
+ const x = scene.surface.x + index * step;
376
+ const y = scene.surface.restY + frame.surface[index];
377
+ linePath +=
378
+ index === 0
379
+ ? `M ${x.toFixed(2)} ${y.toFixed(2)}`
380
+ : ` L ${x.toFixed(2)} ${y.toFixed(2)}`;
381
+ }
382
+ const waterPath =
383
+ `${linePath} L ${(scene.surface.x + scene.surface.width).toFixed(2)} ${scene.canvas.height} L ${scene.surface.x} ${scene.canvas.height} Z`;
384
+ surface.setAttribute("d", linePath);
385
+ surfaceGlint.setAttribute("d", linePath);
386
+ water.setAttribute("d", waterPath);
387
+ foregroundWater.setAttribute("d", waterPath);
388
+
389
+ shoe.setAttribute(
390
+ "transform",
391
+ `translate(${frame.body.x.toFixed(2)} ${frame.body.y.toFixed(2)}) rotate(${frame.body.angle.toFixed(2)})`
392
+ );
393
+
394
+ const distanceToWater = Math.abs(scene.surface.restY - frame.body.y);
395
+ const reflectionOpacity = clamp(1 - distanceToWater / 250, 0, 1) * 0.18;
396
+ reflection.setAttribute("opacity", reflectionOpacity.toFixed(3));
397
+ reflection.setAttribute(
398
+ "transform",
399
+ `translate(${frame.body.x.toFixed(2)} ${(scene.surface.restY * 2 - frame.body.y + 10).toFixed(2)}) rotate(${(-frame.body.angle).toFixed(2)}) scale(1 -0.48)`
400
+ );
401
+ waterShadow.setAttribute("cx", frame.body.x.toFixed(2));
402
+ waterShadow.setAttribute("cy", (scene.surface.restY + 5).toFixed(2));
403
+ waterShadow.setAttribute(
404
+ "rx",
405
+ (scene.body.width * (0.2 + reflectionOpacity * 1.9)).toFixed(2)
406
+ );
407
+ waterShadow.setAttribute("ry", (4 + reflectionOpacity * 20).toFixed(2));
408
+ waterShadow.setAttribute(
409
+ "opacity",
410
+ clamp(1 - distanceToWater / 220, 0, 0.62).toFixed(2)
411
+ );
412
+
413
+ const age = time - impactTime;
414
+ const sprayOpacity =
415
+ age >= 0 && age <= 1.18
416
+ ? Math.sin((age / 1.18) * Math.PI)
417
+ : 0;
418
+ spray.setAttribute("opacity", sprayOpacity.toFixed(3));
419
+ const sprayAge = clamp(age, 0, 1.18);
420
+ [...spray.children].forEach((node, index) => {
421
+ const side = index % 2 === 0 ? -1 : 1;
422
+ const speedX = side * (28 + (index % 8) * 10.5);
423
+ const speedY = -(54 + (index % 7) * 15);
424
+ const x = impactX + speedX * sprayAge;
425
+ const y =
426
+ scene.surface.restY +
427
+ speedY * sprayAge +
428
+ 150 * sprayAge * sprayAge;
429
+ node.setAttribute(
430
+ "transform",
431
+ `translate(${x.toFixed(2)} ${y.toFixed(2)}) rotate(${(side * (18 + index * 7)).toFixed(2)})`
432
+ );
433
+ });
434
+
435
+ const foamOpacity =
436
+ age >= -0.05 && age <= 3.2
437
+ ? clamp(1 - Math.max(0, age - 1.2) / 2, 0, 1)
438
+ : 0;
439
+ foam.setAttribute("opacity", foamOpacity.toFixed(3));
440
+ const foamAge = clamp(age, 0, 3.2);
441
+ [...foam.children].forEach((node, index) => {
442
+ const side = index % 2 === 0 ? -1 : 1;
443
+ const drift = side * (8 + (index % 6) * 6.5) * foamAge;
444
+ const x = impactX + side * (index % 9) * 4.8 + drift;
445
+ const sampleIndex = clamp(
446
+ Math.round(((x - scene.surface.x) / scene.surface.width) * (frame.surface.length - 1)),
447
+ 0,
448
+ frame.surface.length - 1
449
+ );
450
+ const y =
451
+ scene.surface.restY +
452
+ frame.surface[sampleIndex] -
453
+ 2 -
454
+ (index % 3) * 0.8;
455
+ node.setAttribute(
456
+ "transform",
457
+ `translate(${x.toFixed(2)} ${y.toFixed(2)}) rotate(${(index * 17).toFixed(2)})`
458
+ );
459
+ });
460
+ }
461
+
462
+ return { renderFrame };
463
+ }
@@ -0,0 +1,163 @@
1
+ import { assertFiniteNumber } from "./scene.js";
2
+
3
+ const DEFAULT_WAVE_SCENE = {
4
+ version: 1,
5
+ type: "surface-wave",
6
+ name: "Shoe Splash",
7
+ seed: 31,
8
+ canvas: {
9
+ width: 960,
10
+ height: 540
11
+ },
12
+ timing: {
13
+ duration: 6.4,
14
+ fps: 60,
15
+ substeps: 4,
16
+ preRoll: 0
17
+ },
18
+ surface: {
19
+ x: 70,
20
+ width: 820,
21
+ restY: 330,
22
+ samples: 161,
23
+ waveSpeed: 172,
24
+ damping: 2.4,
25
+ coupling: 0.42
26
+ },
27
+ world: {
28
+ gravity: 720,
29
+ airDrag: 0.06,
30
+ waterDrag: 4.8,
31
+ buoyancy: 29
32
+ },
33
+ body: {
34
+ id: "shoe",
35
+ kind: "shoe",
36
+ x: 500,
37
+ y: 78,
38
+ width: 174,
39
+ height: 66,
40
+ mass: 0.72,
41
+ vx: -11,
42
+ vy: 20,
43
+ angle: -13,
44
+ angularVelocity: 0.9,
45
+ restitution: 0.18
46
+ }
47
+ };
48
+
49
+ function mergeSection(base, incoming) {
50
+ return { ...base, ...(incoming ?? {}) };
51
+ }
52
+
53
+ export function createSurfaceWaveScene(overrides = {}) {
54
+ return {
55
+ ...DEFAULT_WAVE_SCENE,
56
+ ...overrides,
57
+ canvas: mergeSection(DEFAULT_WAVE_SCENE.canvas, overrides.canvas),
58
+ timing: mergeSection(DEFAULT_WAVE_SCENE.timing, overrides.timing),
59
+ surface: mergeSection(DEFAULT_WAVE_SCENE.surface, overrides.surface),
60
+ world: mergeSection(DEFAULT_WAVE_SCENE.world, overrides.world),
61
+ body: mergeSection(DEFAULT_WAVE_SCENE.body, overrides.body)
62
+ };
63
+ }
64
+
65
+ export function validateSurfaceWaveScene(input) {
66
+ const scene = createSurfaceWaveScene(input);
67
+ if (scene.version !== 1) {
68
+ throw new RangeError(`Unsupported scene version: ${scene.version}`);
69
+ }
70
+ if (scene.type !== "surface-wave") {
71
+ throw new TypeError(`Unsupported wave scene type: ${scene.type}`);
72
+ }
73
+
74
+ assertFiniteNumber(scene.seed, "seed");
75
+ assertFiniteNumber(scene.canvas.width, "canvas.width", { minimum: 64 });
76
+ assertFiniteNumber(scene.canvas.height, "canvas.height", { minimum: 64 });
77
+ assertFiniteNumber(scene.timing.duration, "timing.duration", {
78
+ minimum: 0.1,
79
+ maximum: 60
80
+ });
81
+ assertFiniteNumber(scene.timing.fps, "timing.fps", {
82
+ minimum: 1,
83
+ maximum: 240
84
+ });
85
+ assertFiniteNumber(scene.timing.substeps, "timing.substeps", {
86
+ minimum: 1,
87
+ maximum: 16
88
+ });
89
+ assertFiniteNumber(scene.timing.preRoll, "timing.preRoll", {
90
+ minimum: 0,
91
+ maximum: 30
92
+ });
93
+ if (!Number.isInteger(scene.timing.fps) || !Number.isInteger(scene.timing.substeps)) {
94
+ throw new TypeError("timing.fps and timing.substeps must be integers.");
95
+ }
96
+
97
+ for (const key of [
98
+ "x",
99
+ "width",
100
+ "restY",
101
+ "samples",
102
+ "waveSpeed",
103
+ "damping",
104
+ "coupling"
105
+ ]) {
106
+ assertFiniteNumber(scene.surface[key], `surface.${key}`);
107
+ }
108
+ if (!Number.isInteger(scene.surface.samples) || scene.surface.samples < 16) {
109
+ throw new RangeError("surface.samples must be an integer of at least 16.");
110
+ }
111
+ if (scene.surface.samples > 1024) {
112
+ throw new RangeError("surface.samples cannot exceed 1024.");
113
+ }
114
+ if (
115
+ scene.surface.width <= 0 ||
116
+ scene.surface.waveSpeed <= 0 ||
117
+ scene.surface.damping < 0 ||
118
+ scene.surface.coupling < 0
119
+ ) {
120
+ throw new RangeError(
121
+ "surface width and speed must be positive; damping and coupling cannot be negative."
122
+ );
123
+ }
124
+ for (const key of ["gravity", "airDrag", "waterDrag", "buoyancy"]) {
125
+ assertFiniteNumber(scene.world[key], `world.${key}`);
126
+ }
127
+ if (
128
+ scene.world.airDrag < 0 ||
129
+ scene.world.waterDrag < 0 ||
130
+ scene.world.buoyancy < 0
131
+ ) {
132
+ throw new RangeError("world drag and buoyancy values cannot be negative.");
133
+ }
134
+ if (typeof scene.body.id !== "string" || scene.body.id.length === 0) {
135
+ throw new TypeError("body.id must be a non-empty string.");
136
+ }
137
+ if (typeof scene.body.kind !== "string" || scene.body.kind.length === 0) {
138
+ throw new TypeError("body.kind must be a non-empty string.");
139
+ }
140
+ for (const key of [
141
+ "x",
142
+ "y",
143
+ "width",
144
+ "height",
145
+ "mass",
146
+ "vx",
147
+ "vy",
148
+ "angle",
149
+ "angularVelocity",
150
+ "restitution"
151
+ ]) {
152
+ assertFiniteNumber(scene.body[key], `body.${key}`);
153
+ }
154
+ if (scene.body.width <= 0 || scene.body.height <= 0 || scene.body.mass <= 0) {
155
+ throw new RangeError("body width, height, and mass must be positive.");
156
+ }
157
+ if (scene.body.restitution < 0 || scene.body.restitution > 1) {
158
+ throw new RangeError("body.restitution must be between 0 and 1.");
159
+ }
160
+ return scene;
161
+ }
162
+
163
+ export const defaultSurfaceWaveScene = createSurfaceWaveScene();