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,320 @@
1
+ :root {
2
+ color-scheme: dark;
3
+ font-family:
4
+ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
5
+ --page: #121211;
6
+ --page-glow: #302c26;
7
+ --paper: #efe7d5;
8
+ --ink: #20201d;
9
+ --muted: #716c62;
10
+ --accent: #ee6540;
11
+ --line: #3b3832;
12
+ }
13
+
14
+ * {
15
+ box-sizing: border-box;
16
+ }
17
+
18
+ html,
19
+ body {
20
+ margin: 0;
21
+ min-height: 100%;
22
+ }
23
+
24
+ body {
25
+ background:
26
+ radial-gradient(circle at 50% 16%, var(--page-glow), transparent 42rem),
27
+ var(--page);
28
+ color: #f4f0e8;
29
+ }
30
+
31
+ button,
32
+ input {
33
+ font: inherit;
34
+ }
35
+
36
+ main {
37
+ width: min(1180px, calc(100vw - 40px));
38
+ margin: 0 auto;
39
+ padding: 26px 0 32px;
40
+ }
41
+
42
+ header,
43
+ footer {
44
+ display: flex;
45
+ align-items: center;
46
+ justify-content: space-between;
47
+ }
48
+
49
+ header {
50
+ min-height: 42px;
51
+ margin-bottom: 20px;
52
+ color: #9b968c;
53
+ font-size: 11px;
54
+ letter-spacing: 0.12em;
55
+ text-transform: uppercase;
56
+ }
57
+
58
+ header nav {
59
+ display: flex;
60
+ gap: 20px;
61
+ }
62
+
63
+ header a {
64
+ color: inherit;
65
+ text-decoration: none;
66
+ }
67
+
68
+ header a:hover {
69
+ color: #fff;
70
+ }
71
+
72
+ .wordmark {
73
+ display: inline-flex;
74
+ gap: 10px;
75
+ align-items: center;
76
+ color: #f4f0e8;
77
+ font-size: 14px;
78
+ font-weight: 750;
79
+ letter-spacing: -0.02em;
80
+ text-transform: none;
81
+ }
82
+
83
+ .mark {
84
+ width: 13px;
85
+ height: 13px;
86
+ border: 2px solid var(--accent);
87
+ border-radius: 50%;
88
+ box-shadow: inset 4px 0 0 var(--accent);
89
+ }
90
+
91
+ .stage {
92
+ overflow: hidden;
93
+ border: 1px solid var(--line);
94
+ border-radius: 4px;
95
+ background: #1b1a18;
96
+ box-shadow: 0 30px 90px rgb(0 0 0 / 44%);
97
+ }
98
+
99
+ .poster {
100
+ position: relative;
101
+ aspect-ratio: 16 / 9;
102
+ overflow: hidden;
103
+ background: var(--paper);
104
+ color: var(--ink);
105
+ }
106
+
107
+ .poster::after {
108
+ position: absolute;
109
+ inset: 0;
110
+ z-index: 8;
111
+ pointer-events: none;
112
+ content: "";
113
+ opacity: 0.12;
114
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.15'/%3E%3C/svg%3E");
115
+ mix-blend-mode: multiply;
116
+ }
117
+
118
+ .poster-copy {
119
+ position: absolute;
120
+ top: 36px;
121
+ left: 40px;
122
+ z-index: 5;
123
+ width: 290px;
124
+ pointer-events: none;
125
+ }
126
+
127
+ .eyebrow {
128
+ margin: 0 0 9px;
129
+ color: var(--muted);
130
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
131
+ font-size: 8px;
132
+ font-weight: 700;
133
+ letter-spacing: 0.15em;
134
+ }
135
+
136
+ h1 {
137
+ margin: 0;
138
+ font-size: clamp(34px, 4vw, 60px);
139
+ font-weight: 900;
140
+ letter-spacing: -0.075em;
141
+ line-height: 0.84;
142
+ }
143
+
144
+ h1 em {
145
+ color: var(--accent);
146
+ font-style: normal;
147
+ }
148
+
149
+ .description {
150
+ width: 235px;
151
+ margin: 14px 0 0;
152
+ color: var(--muted);
153
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
154
+ font-size: clamp(7px, 0.76vw, 10px);
155
+ line-height: 1.55;
156
+ }
157
+
158
+ .corner-note {
159
+ position: absolute;
160
+ right: 38px;
161
+ top: 34px;
162
+ z-index: 5;
163
+ margin: 0;
164
+ color: var(--muted);
165
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
166
+ font-size: 8px;
167
+ line-height: 1.5;
168
+ text-align: right;
169
+ }
170
+
171
+ .motion {
172
+ position: absolute;
173
+ inset: 0;
174
+ z-index: 3;
175
+ width: 100%;
176
+ height: 100%;
177
+ }
178
+
179
+ .folio {
180
+ position: absolute;
181
+ right: 38px;
182
+ bottom: 26px;
183
+ z-index: 5;
184
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
185
+ font-size: 8px;
186
+ font-weight: 700;
187
+ letter-spacing: 0.18em;
188
+ }
189
+
190
+ .loading {
191
+ position: absolute;
192
+ right: 38px;
193
+ bottom: 46px;
194
+ z-index: 10;
195
+ padding: 5px 7px;
196
+ background: var(--ink);
197
+ color: var(--paper);
198
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
199
+ font-size: 8px;
200
+ letter-spacing: 0.12em;
201
+ }
202
+
203
+ footer {
204
+ gap: 30px;
205
+ padding-top: 17px;
206
+ }
207
+
208
+ .controls {
209
+ display: flex;
210
+ flex: 1;
211
+ gap: 12px;
212
+ align-items: center;
213
+ }
214
+
215
+ button {
216
+ min-width: 96px;
217
+ padding: 8px 10px;
218
+ border: 1px solid #4a463f;
219
+ border-radius: 3px;
220
+ background: transparent;
221
+ color: #d8d3c9;
222
+ cursor: pointer;
223
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
224
+ font-size: 9px;
225
+ letter-spacing: 0.09em;
226
+ }
227
+
228
+ button:hover {
229
+ border-color: var(--accent);
230
+ color: #fff;
231
+ }
232
+
233
+ input[type="range"] {
234
+ width: min(430px, 40vw);
235
+ accent-color: var(--accent);
236
+ }
237
+
238
+ .metrics {
239
+ display: grid;
240
+ grid-template-columns: repeat(4, auto);
241
+ gap: 22px;
242
+ margin: 0;
243
+ }
244
+
245
+ .metrics div {
246
+ display: grid;
247
+ gap: 3px;
248
+ }
249
+
250
+ .metrics dt {
251
+ color: #777269;
252
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
253
+ font-size: 8px;
254
+ letter-spacing: 0.12em;
255
+ text-transform: uppercase;
256
+ }
257
+
258
+ .metrics dd {
259
+ margin: 0;
260
+ color: #d8d3c9;
261
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
262
+ font-size: 10px;
263
+ }
264
+
265
+ .theme-crystal {
266
+ --paper: #f4eedc;
267
+ --accent: #cc5f4a;
268
+ --page-glow: #293337;
269
+ }
270
+
271
+ .theme-ball {
272
+ --paper: #e9e1cf;
273
+ --accent: #df672e;
274
+ --page-glow: #342c24;
275
+ }
276
+
277
+ .theme-kick {
278
+ --paper: #dbe5c9;
279
+ --accent: #f05d3d;
280
+ --page-glow: #263121;
281
+ }
282
+
283
+ .theme-wave {
284
+ --paper: #e7efe9;
285
+ --accent: #f06a43;
286
+ --page-glow: #20333a;
287
+ }
288
+
289
+ @media (max-width: 760px) {
290
+ main {
291
+ width: min(100% - 20px, 1180px);
292
+ padding-top: 12px;
293
+ }
294
+
295
+ header nav,
296
+ .corner-note,
297
+ .description,
298
+ .metrics {
299
+ display: none;
300
+ }
301
+
302
+ .poster-copy {
303
+ top: 20px;
304
+ left: 22px;
305
+ width: 210px;
306
+ }
307
+
308
+ h1 {
309
+ font-size: clamp(26px, 7vw, 42px);
310
+ }
311
+
312
+ footer {
313
+ padding-top: 11px;
314
+ }
315
+
316
+ .controls,
317
+ input[type="range"] {
318
+ width: 100%;
319
+ }
320
+ }
@@ -0,0 +1,85 @@
1
+ export function mountPlayer({
2
+ scene,
3
+ simulation,
4
+ renderer,
5
+ metricLabel = "signal",
6
+ metricValue = "—"
7
+ }) {
8
+ const loading = document.querySelector("#loading");
9
+ const toggle = document.querySelector("#toggle");
10
+ const scrubber = document.querySelector("#scrubber");
11
+ const fingerprint = document.querySelector("#fingerprint");
12
+ const metricName = document.querySelector("#metric-name");
13
+ const metric = document.querySelector("#metric");
14
+ const lastFrame = simulation.frames.length - 1;
15
+ const durationMs = scene.timing.duration * 1000;
16
+
17
+ scrubber.max = String(lastFrame);
18
+ fingerprint.textContent = simulation.fingerprint;
19
+ metricName.textContent = metricLabel;
20
+ metric.textContent = metricValue;
21
+ loading?.remove();
22
+
23
+ let playing = true;
24
+ let origin = performance.now();
25
+ let pausedFrame = 0;
26
+
27
+ function paint(frame) {
28
+ const safeFrame = Math.max(0, Math.min(lastFrame, Math.round(frame)));
29
+ pausedFrame = safeFrame;
30
+ renderer.renderFrame(safeFrame);
31
+ scrubber.value = String(safeFrame);
32
+ }
33
+
34
+ function play() {
35
+ if (playing) return;
36
+ playing = true;
37
+ toggle.textContent = "PAUSE";
38
+ origin =
39
+ performance.now() -
40
+ (pausedFrame / scene.timing.fps) * 1000;
41
+ }
42
+
43
+ function pause() {
44
+ playing = false;
45
+ toggle.textContent = "PLAY";
46
+ }
47
+
48
+ function seekFrame(frame) {
49
+ pause();
50
+ paint(frame);
51
+ }
52
+
53
+ function seekTime(time) {
54
+ seekFrame(time * scene.timing.fps);
55
+ }
56
+
57
+ function animate(now) {
58
+ if (playing) {
59
+ const elapsed = (now - origin) % durationMs;
60
+ paint((elapsed / 1000) * scene.timing.fps);
61
+ }
62
+ requestAnimationFrame(animate);
63
+ }
64
+
65
+ toggle.addEventListener("click", () => {
66
+ if (playing) pause();
67
+ else play();
68
+ });
69
+ scrubber.addEventListener("input", () => {
70
+ seekFrame(Number(scrubber.value));
71
+ });
72
+
73
+ window.__oddlyAlivePlayer = {
74
+ scene,
75
+ simulation,
76
+ paint,
77
+ pause,
78
+ play,
79
+ seekFrame,
80
+ seekTime
81
+ };
82
+
83
+ paint(0);
84
+ requestAnimationFrame(animate);
85
+ }
@@ -0,0 +1,4 @@
1
+ # Provenance
2
+
3
+ The shoe and water are original procedural SVG. No external image assets are
4
+ used. This is a damped one-dimensional surface model, not a fluid simulator.
@@ -0,0 +1,26 @@
1
+ import {
2
+ createSurfaceWaveRenderer,
3
+ simulateScene,
4
+ validateScene
5
+ } from "../../src/index.js";
6
+ import { mountPlayer } from "../shared/player.js";
7
+
8
+ await new Promise((resolve) => requestAnimationFrame(resolve));
9
+
10
+ const response = await fetch("./scene.json");
11
+ if (!response.ok) throw new Error(`Could not load scene: ${response.status}`);
12
+ const scene = validateScene(await response.json());
13
+ const simulation = simulateScene(scene);
14
+ const renderer = createSurfaceWaveRenderer(
15
+ document.querySelector("#motion"),
16
+ simulation,
17
+ { shoeHref: "../../assets/photoreal/sneaker.png" }
18
+ );
19
+
20
+ mountPlayer({
21
+ scene,
22
+ simulation,
23
+ renderer,
24
+ metricLabel: "wave peak",
25
+ metricValue: `${simulation.diagnostics.maxWaveHeight.toFixed(1)} px`
26
+ });
@@ -0,0 +1,56 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta name="description" content="OddlyAlive shoe and water-wave example" />
7
+ <title>Shoe Splash — OddlyAlive</title>
8
+ <link rel="stylesheet" href="../shared/example.css" />
9
+ </head>
10
+ <body class="theme-wave">
11
+ <main>
12
+ <header>
13
+ <a class="wordmark" href="../"><span class="mark"></span>OddlyAlive</a>
14
+ <nav>
15
+ <a href="../football-kick/">← Football Kick</a>
16
+ <a href="../">All examples →</a>
17
+ </nav>
18
+ </header>
19
+ <section class="stage">
20
+ <div class="poster">
21
+ <div class="poster-copy">
22
+ <p class="eyebrow">05 / COUPLED SYSTEM</p>
23
+ <h1>THROW<br /><em>THE SHOE.</em></h1>
24
+ <p class="description">
25
+ A rigid prop disturbs a damped wave surface. The water answers,
26
+ then buoyancy and drag make the shoe answer back.
27
+ </p>
28
+ </div>
29
+ <p class="corner-note">RIGID PROP<br />1D WAVE SURFACE<br />TWO-WAY COUPLING</p>
30
+ <svg
31
+ id="motion"
32
+ class="motion"
33
+ viewBox="0 0 960 540"
34
+ role="img"
35
+ aria-label="A shoe falling into water and making waves"
36
+ ></svg>
37
+ <div class="folio">OA—005</div>
38
+ <div id="loading" class="loading">SOLVING WORLD…</div>
39
+ </div>
40
+ </section>
41
+ <footer>
42
+ <div class="controls">
43
+ <button id="toggle" type="button">PAUSE</button>
44
+ <input id="scrubber" type="range" min="0" value="0" aria-label="Timeline" />
45
+ </div>
46
+ <dl class="metrics">
47
+ <div><dt>solver</dt><dd>coupled wave</dd></div>
48
+ <div><dt>step</dt><dd>240 Hz</dd></div>
49
+ <div><dt id="metric-name">wave peak</dt><dd id="metric">—</dd></div>
50
+ <div><dt>fingerprint</dt><dd id="fingerprint">—</dd></div>
51
+ </dl>
52
+ </footer>
53
+ </main>
54
+ <script type="module" src="./app.js"></script>
55
+ </body>
56
+ </html>
@@ -0,0 +1,38 @@
1
+ {
2
+ "$schema": "../../schemas/scene.schema.json",
3
+ "version": 1,
4
+ "type": "surface-wave",
5
+ "name": "OddlyAlive — Shoe Splash",
6
+ "seed": 31,
7
+ "canvas": { "width": 960, "height": 540 },
8
+ "timing": { "duration": 6.4, "fps": 60, "substeps": 4, "preRoll": 0 },
9
+ "surface": {
10
+ "x": 70,
11
+ "width": 820,
12
+ "restY": 330,
13
+ "samples": 161,
14
+ "waveSpeed": 172,
15
+ "damping": 2.4,
16
+ "coupling": 0.42
17
+ },
18
+ "world": {
19
+ "gravity": 720,
20
+ "airDrag": 0.06,
21
+ "waterDrag": 4.8,
22
+ "buoyancy": 29
23
+ },
24
+ "body": {
25
+ "id": "shoe",
26
+ "kind": "shoe",
27
+ "x": 500,
28
+ "y": 78,
29
+ "width": 174,
30
+ "height": 66,
31
+ "mass": 0.72,
32
+ "vx": -11,
33
+ "vy": 20,
34
+ "angle": -13,
35
+ "angularVelocity": 0.9,
36
+ "restitution": 0.18
37
+ }
38
+ }
@@ -0,0 +1,11 @@
1
+ # Provenance
2
+
3
+ The interaction model is an original extraction from TheVibeFounder’s 2026
4
+ city-postcard experiments. The public alpha preserves their fixed-step XPBD
5
+ constraints, pressure-bearing contact capsule, per-strand static/kinetic friction,
6
+ load-based peel, and deterministic material variation while removing all
7
+ city-specific artwork and layout assumptions.
8
+
9
+ The demo typography, SVG hardware, interface, and scene are original work for
10
+ OddlyAlive. No third-party image, video, font file, or embedded raster contributes
11
+ pixels.
@@ -0,0 +1,79 @@
1
+ import {
2
+ createSvgRenderer,
3
+ sampleGesture,
4
+ simulateScene,
5
+ validateScene
6
+ } from "../../src/index.js";
7
+
8
+ const loading = document.querySelector("#loading");
9
+ const svg = document.querySelector("#motion");
10
+ const toggle = document.querySelector("#toggle");
11
+ const scrubber = document.querySelector("#scrubber");
12
+ const debugButton = document.querySelector("#debug");
13
+ const grip = document.querySelector("#grip");
14
+ const fingerprint = document.querySelector("#fingerprint");
15
+
16
+ await new Promise((resolve) => requestAnimationFrame(resolve));
17
+ const rawScene = await fetch("./scene.json").then((response) => response.json());
18
+ const scene = validateScene(rawScene);
19
+ const simulation = simulateScene(scene);
20
+ const renderer = createSvgRenderer(svg, simulation, {
21
+ glyphCharmHref: "../../assets/photoreal/letter-charm-square.png"
22
+ });
23
+ const durationMs = scene.timing.duration * 1000;
24
+ const lastFrame = simulation.frames.length - 1;
25
+
26
+ scrubber.max = String(lastFrame);
27
+ grip.textContent = `${simulation.diagnostics.observedMaxStaticGrip}/${scene.contact.maxStaticGrip}`;
28
+ fingerprint.textContent = simulation.fingerprint;
29
+ loading.remove();
30
+
31
+ let playing = true;
32
+ let debug = false;
33
+ let origin = performance.now();
34
+ let pausedFrame = 0;
35
+
36
+ function paint(frame) {
37
+ const time = frame / scene.timing.fps;
38
+ renderer.renderFrame(frame, sampleGesture(scene.gesture.points, time));
39
+ scrubber.value = String(frame);
40
+ }
41
+
42
+ function animate(now) {
43
+ if (playing) {
44
+ const elapsed = (now - origin) % durationMs;
45
+ const frame = Math.min(
46
+ lastFrame,
47
+ Math.round((elapsed / 1000) * scene.timing.fps)
48
+ );
49
+ pausedFrame = frame;
50
+ paint(frame);
51
+ }
52
+ requestAnimationFrame(animate);
53
+ }
54
+
55
+ toggle.addEventListener("click", () => {
56
+ playing = !playing;
57
+ toggle.textContent = playing ? "PAUSE" : "PLAY";
58
+ if (playing) {
59
+ origin =
60
+ performance.now() -
61
+ (pausedFrame / scene.timing.fps) * 1000;
62
+ }
63
+ });
64
+
65
+ scrubber.addEventListener("input", () => {
66
+ playing = false;
67
+ toggle.textContent = "PLAY";
68
+ pausedFrame = Number(scrubber.value);
69
+ paint(pausedFrame);
70
+ });
71
+
72
+ debugButton.addEventListener("click", () => {
73
+ debug = !debug;
74
+ renderer.setDebug(debug);
75
+ debugButton.textContent = debug ? "HIDE TOUCH" : "SHOW TOUCH";
76
+ });
77
+
78
+ paint(0);
79
+ requestAnimationFrame(animate);
@@ -0,0 +1,70 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta
7
+ name="description"
8
+ content="OddlyAlive deterministic string-touch physics demo"
9
+ />
10
+ <title>OddlyAlive — Make anything feel alive</title>
11
+ <link rel="stylesheet" href="./styles.css" />
12
+ </head>
13
+ <body>
14
+ <main>
15
+ <header>
16
+ <a class="wordmark" href="../" aria-label="OddlyAlive home">
17
+ <span class="mark"></span>
18
+ OddlyAlive
19
+ </a>
20
+ <p>Deterministic object motion</p>
21
+ </header>
22
+
23
+ <section class="stage" aria-label="String touch physics demo">
24
+ <div class="poster">
25
+ <div class="poster-copy">
26
+ <div>
27
+ <p class="eyebrow">OPEN SOURCE PHYSICS · ALPHA 01</p>
28
+ <h1>MAKE<br />ANYTHING<br /><em>FEEL ALIVE.</em></h1>
29
+ </div>
30
+ <p class="caption">
31
+ No hand. No magnetic keyframes.<br />
32
+ Just pressure, friction, load and release.
33
+ </p>
34
+ </div>
35
+ <svg
36
+ id="motion"
37
+ viewBox="0 0 960 540"
38
+ role="img"
39
+ aria-label="Letters hanging from flexible strings move under an invisible touch"
40
+ ></svg>
41
+ <div class="folio">OA—001</div>
42
+ <div id="loading" class="loading">SIMULATING 240HZ PHYSICS…</div>
43
+ </div>
44
+ </section>
45
+
46
+ <footer>
47
+ <div class="controls">
48
+ <button id="toggle" type="button">PAUSE</button>
49
+ <input
50
+ id="scrubber"
51
+ aria-label="Animation playhead"
52
+ type="range"
53
+ min="0"
54
+ max="384"
55
+ value="0"
56
+ />
57
+ <button id="debug" type="button">SHOW TOUCH</button>
58
+ </div>
59
+ <dl class="metrics">
60
+ <div><dt>solver</dt><dd>XPBD</dd></div>
61
+ <div><dt>step</dt><dd>240Hz</dd></div>
62
+ <div><dt>grip</dt><dd id="grip">—</dd></div>
63
+ <div><dt>fingerprint</dt><dd id="fingerprint">—</dd></div>
64
+ </dl>
65
+ </footer>
66
+ </main>
67
+
68
+ <script type="module" src="./app.js"></script>
69
+ </body>
70
+ </html>