@vibes.diy/prompts 14.3.82 → 14.3.84
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.
- package/llms/access.md +105 -15
- package/llms/blockly.d.ts +2 -0
- package/llms/blockly.js +59 -0
- package/llms/blockly.js.map +1 -0
- package/llms/blockly.md +964 -0
- package/llms/fireproof.md +4 -4
- package/llms/index.d.ts +7 -1
- package/llms/index.js +18 -0
- package/llms/index.js.map +1 -1
- package/llms/motion.d.ts +2 -0
- package/llms/motion.js +47 -0
- package/llms/motion.js.map +1 -0
- package/llms/motion.md +601 -0
- package/llms/physics.d.ts +2 -0
- package/llms/physics.js +44 -0
- package/llms/physics.js.map +1 -0
- package/llms/physics.md +635 -0
- package/llms/richtext.d.ts +2 -0
- package/llms/richtext.js +47 -0
- package/llms/richtext.js.map +1 -0
- package/llms/richtext.md +612 -0
- package/llms/strudel.d.ts +2 -0
- package/llms/strudel.js +36 -0
- package/llms/strudel.js.map +1 -0
- package/llms/strudel.md +686 -0
- package/llms/web-audio.md +2 -0
- package/llms/worlds.d.ts +2 -0
- package/llms/worlds.js +45 -0
- package/llms/worlds.js.map +1 -0
- package/llms/worlds.md +826 -0
- package/package.json +5 -4
- package/restyle.js +3 -2
- package/restyle.js.map +1 -1
- package/system-prompt-initial-oneshot.md +3 -2
- package/system-prompt-initial.md +3 -2
- package/system-prompt.md +6 -3
package/llms/physics.md
ADDED
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
# Physics — things with mass, built with Matter.js
|
|
2
|
+
|
|
3
|
+
When someone asks for physics, gravity, things that bounce, collide, tumble or
|
|
4
|
+
pile up, a marble run, a Rube Goldberg machine, plinko, a catapult or
|
|
5
|
+
slingshot, dominoes, a tower to knock down, pinball, a ragdoll, a page where
|
|
6
|
+
the words fall and can be flicked around, or any toy where objects behave
|
|
7
|
+
like objects, this is the recipe. Matter.js is a 2D rigid-body engine: the
|
|
8
|
+
app decides which shapes exist and where, the engine moves them, a canvas
|
|
9
|
+
paints them, and Fireproof keeps the pieces so a build is there tomorrow and
|
|
10
|
+
for everyone who opens it.
|
|
11
|
+
|
|
12
|
+
The sibling boundary: a sketch that draws without collisions is `p5`; a
|
|
13
|
+
scene with depth is `three-js`; a block world is `voxel`. Physics is the
|
|
14
|
+
right call when the fun is bodies with mass meeting other bodies.
|
|
15
|
+
|
|
16
|
+
The worked examples below were built and measured for this doc (Chromium,
|
|
17
|
+
2026-10-07, `matter-js@0.20.0` from esm.sh): a marble run builder whose
|
|
18
|
+
pieces are documents, and a page where typed words tumble.
|
|
19
|
+
|
|
20
|
+
## Importing Matter.js — one pinned default import
|
|
21
|
+
|
|
22
|
+
**Import the engine as `import Matter from "https://esm.sh/matter-js@0.20.0"`
|
|
23
|
+
and take the modules off it** (`const { Engine, Bodies, Composite } = Matter`).
|
|
24
|
+
The esm.sh build of the package exposes Matter as its default export and
|
|
25
|
+
nothing as named exports (measured: `import { Engine } …` reads undefined),
|
|
26
|
+
so the default import is the one shape that works. Pin the version: a
|
|
27
|
+
physics toy's feel is the engine's numbers, and a saved level assumes the
|
|
28
|
+
engine it was built on.
|
|
29
|
+
|
|
30
|
+
Concave shapes (an L, an arrow, a cup) need a decomposer the engine does not
|
|
31
|
+
ship: `Common.setDecomp(await import("https://esm.sh/poly-decomp@0.3.0"))`
|
|
32
|
+
once, then `Bodies.fromVertices(x, y, points)`. Rectangles, circles and
|
|
33
|
+
regular polygons need nothing extra, and most toys are made of those.
|
|
34
|
+
|
|
35
|
+
## Project shape
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
App.jsx the chrome: mode switch, piece picker, play controls, score, saved levels
|
|
39
|
+
lib/physics.js the engine: world creation, the fixed-step loop, painting, picking, collisions
|
|
40
|
+
access.js who may save a level
|
|
41
|
+
seed.json two or three finished levels so the first visit has something to drop a marble into
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Relative imports include the extension (`./lib/physics.js`). Everything that
|
|
45
|
+
touches Matter lives in the module; `App.jsx` reads as an interface.
|
|
46
|
+
|
|
47
|
+
## Complete starter — a marble run builder
|
|
48
|
+
|
|
49
|
+
The engine module. One world per canvas: a DPR-scaled context, three walls,
|
|
50
|
+
a mouse constraint that knows the DPR, a loop that steps the engine by a
|
|
51
|
+
fixed `STEP` and paints every body from its vertices in the app's theme
|
|
52
|
+
colours, and helpers that turn piece documents into bodies and back.
|
|
53
|
+
|
|
54
|
+
### `lib/physics.js`
|
|
55
|
+
```js
|
|
56
|
+
import Matter from "https://esm.sh/matter-js@0.20.0";
|
|
57
|
+
|
|
58
|
+
const { Engine, Bodies, Body, Composite, Mouse, MouseConstraint, Events, Common, Query } = Matter;
|
|
59
|
+
export { Matter };
|
|
60
|
+
|
|
61
|
+
/** One physics step, in milliseconds. Every world steps by exactly this, so a replay is a replay. */
|
|
62
|
+
export const STEP = 1000 / 60;
|
|
63
|
+
|
|
64
|
+
/** Resolve one of the app's theme tokens to a colour the canvas can paint with. */
|
|
65
|
+
export function cssColor(host, token, fallback) {
|
|
66
|
+
const probe = document.createElement("span");
|
|
67
|
+
probe.style.display = "none";
|
|
68
|
+
probe.style.color = `var(${token}, ${fallback})`;
|
|
69
|
+
host.appendChild(probe);
|
|
70
|
+
const resolved = window.getComputedStyle(probe).color;
|
|
71
|
+
probe.remove();
|
|
72
|
+
return resolved || fallback;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A body from a piece document; the document is the source of truth, the body is its stand-in. */
|
|
76
|
+
export function bodyFromPiece(piece) {
|
|
77
|
+
const common = { label: piece.kind, isStatic: piece.kind !== "marble", angle: piece.angle ?? 0, isSensor: piece.kind === "goal", restitution: piece.kind === "bumper" ? 1.2 : 0.4, friction: 0.05 };
|
|
78
|
+
const body =
|
|
79
|
+
piece.kind === "peg" || piece.kind === "bumper" || piece.kind === "marble"
|
|
80
|
+
? Bodies.circle(piece.x, piece.y, piece.r ?? 12, common)
|
|
81
|
+
: Bodies.rectangle(piece.x, piece.y, piece.w ?? 120, piece.h ?? 12, common);
|
|
82
|
+
body.pieceId = piece.id;
|
|
83
|
+
return body;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Create a world on a canvas: a DPR-scaled 2D context, walls, a mouse constraint that
|
|
88
|
+
* understands the DPR, and a loop that steps the engine by STEP as many times as the frame
|
|
89
|
+
* owes (a handful at most), then paints every body from its vertices.
|
|
90
|
+
*/
|
|
91
|
+
export function createWorld(canvas, { width, height, gravity = 1, seed = 1 } = {}) {
|
|
92
|
+
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
93
|
+
canvas.width = width * dpr;
|
|
94
|
+
canvas.height = height * dpr;
|
|
95
|
+
canvas.style.width = `${width}px`;
|
|
96
|
+
canvas.style.height = `${height}px`;
|
|
97
|
+
const ctx = canvas.getContext("2d");
|
|
98
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
99
|
+
|
|
100
|
+
Common._seed = seed;
|
|
101
|
+
const engine = Engine.create({ enableSleeping: true });
|
|
102
|
+
engine.gravity.y = gravity;
|
|
103
|
+
const wall = { isStatic: true, label: "wall" };
|
|
104
|
+
Composite.add(engine.world, [
|
|
105
|
+
Bodies.rectangle(width / 2, height + 20, width + 80, 40, wall),
|
|
106
|
+
Bodies.rectangle(-20, height / 2, 40, height * 2, wall),
|
|
107
|
+
Bodies.rectangle(width + 20, height / 2, 40, height * 2, wall),
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
const mouse = Mouse.create(canvas);
|
|
111
|
+
mouse.pixelRatio = dpr;
|
|
112
|
+
const mouseConstraint = MouseConstraint.create(engine, { mouse, constraint: { stiffness: 0.2 } });
|
|
113
|
+
Composite.add(engine.world, mouseConstraint);
|
|
114
|
+
|
|
115
|
+
const colours = {
|
|
116
|
+
paper: cssColor(canvas, "--background", "#ffffff"),
|
|
117
|
+
ink: cssColor(canvas, "--text-primary", "#111111"),
|
|
118
|
+
lead: cssColor(canvas, "--primary", "#4f46e5"),
|
|
119
|
+
accent: cssColor(canvas, "--accent", "#f59e0b"),
|
|
120
|
+
};
|
|
121
|
+
const fillFor = (body) => (body.label === "marble" ? colours.lead : body.label === "goal" || body.label === "bumper" ? colours.accent : colours.ink);
|
|
122
|
+
|
|
123
|
+
const world = {
|
|
124
|
+
engine,
|
|
125
|
+
mouse,
|
|
126
|
+
mouseConstraint,
|
|
127
|
+
running: false,
|
|
128
|
+
paused: false,
|
|
129
|
+
raf: 0,
|
|
130
|
+
last: 0,
|
|
131
|
+
owed: 0,
|
|
132
|
+
paint() {
|
|
133
|
+
ctx.fillStyle = colours.paper;
|
|
134
|
+
ctx.fillRect(0, 0, width, height);
|
|
135
|
+
for (const body of Composite.allBodies(engine.world)) {
|
|
136
|
+
if (body.label === "wall") continue;
|
|
137
|
+
ctx.beginPath();
|
|
138
|
+
for (const v of body.vertices) ctx.lineTo(v.x, v.y);
|
|
139
|
+
ctx.closePath();
|
|
140
|
+
ctx.globalAlpha = body.isSensor ? 0.35 : 1;
|
|
141
|
+
ctx.fillStyle = fillFor(body);
|
|
142
|
+
ctx.fill();
|
|
143
|
+
ctx.globalAlpha = 1;
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
frame(now) {
|
|
147
|
+
if (!world.running) return;
|
|
148
|
+
if (!world.paused) {
|
|
149
|
+
world.owed += Math.min(now - world.last, 4 * STEP);
|
|
150
|
+
while (world.owed >= STEP) {
|
|
151
|
+
Engine.update(engine, STEP);
|
|
152
|
+
world.owed -= STEP;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
world.last = now;
|
|
156
|
+
world.paint();
|
|
157
|
+
world.raf = requestAnimationFrame(world.frame);
|
|
158
|
+
},
|
|
159
|
+
start() {
|
|
160
|
+
if (world.running) return;
|
|
161
|
+
world.running = true;
|
|
162
|
+
world.last = performance.now();
|
|
163
|
+
world.owed = 0;
|
|
164
|
+
world.raf = requestAnimationFrame(world.frame);
|
|
165
|
+
},
|
|
166
|
+
stop() {
|
|
167
|
+
world.running = false;
|
|
168
|
+
cancelAnimationFrame(world.raf);
|
|
169
|
+
},
|
|
170
|
+
/** Every non-wall body out, the clock back to zero, the same seed: the next run replays. */
|
|
171
|
+
reset(pieces) {
|
|
172
|
+
// Bodies go; the walls and the mouse constraint stay attached across every reset.
|
|
173
|
+
for (const body of Composite.allBodies(engine.world)) if (body.label !== "wall") Composite.remove(engine.world, body);
|
|
174
|
+
Common._seed = seed;
|
|
175
|
+
engine.timing.timestamp = 0;
|
|
176
|
+
for (const piece of pieces) Composite.add(engine.world, bodyFromPiece(piece));
|
|
177
|
+
world.paint();
|
|
178
|
+
},
|
|
179
|
+
add(piece) {
|
|
180
|
+
const body = bodyFromPiece(piece);
|
|
181
|
+
Composite.add(engine.world, body);
|
|
182
|
+
return body;
|
|
183
|
+
},
|
|
184
|
+
remove(body) {
|
|
185
|
+
Composite.remove(engine.world, body);
|
|
186
|
+
},
|
|
187
|
+
bodies() {
|
|
188
|
+
return Composite.allBodies(engine.world).filter((b) => b.label !== "wall");
|
|
189
|
+
},
|
|
190
|
+
/** The body under a point in canvas coordinates, or undefined. */
|
|
191
|
+
pick(point) {
|
|
192
|
+
return Query.point(world.bodies(), point)[0];
|
|
193
|
+
},
|
|
194
|
+
/** Fire `fn(a, b)` when a body labelled `a` meets one labelled `b`. */
|
|
195
|
+
onMeet(a, b, fn) {
|
|
196
|
+
const handler = (event) => {
|
|
197
|
+
for (const pair of event.pairs) {
|
|
198
|
+
const labels = [pair.bodyA.label, pair.bodyB.label];
|
|
199
|
+
if (labels.includes(a) && labels.includes(b)) fn(pair.bodyA.label === a ? pair.bodyA : pair.bodyB, pair.bodyA.label === a ? pair.bodyB : pair.bodyA);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
Events.on(engine, "collisionStart", handler);
|
|
203
|
+
return () => Events.off(engine, "collisionStart", handler);
|
|
204
|
+
},
|
|
205
|
+
dispose() {
|
|
206
|
+
world.stop();
|
|
207
|
+
Mouse.clearSourceEvents(mouse);
|
|
208
|
+
Composite.clear(engine.world, false);
|
|
209
|
+
Engine.clear(engine);
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
world.paint();
|
|
213
|
+
return world;
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
The interface. A level is one document whose `pieces` array is the run; in
|
|
218
|
+
build mode a tap places the chosen piece and a tap on a piece removes it,
|
|
219
|
+
and the pieces autosave when this person may edit the level; in play mode a
|
|
220
|
+
button drops a marble, the goal sensor scores it, and reset rebuilds the run
|
|
221
|
+
from the pieces so the next drop replays the last one exactly.
|
|
222
|
+
|
|
223
|
+
### `App.jsx`
|
|
224
|
+
```jsx
|
|
225
|
+
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
226
|
+
import { useFireproof } from "use-fireproof";
|
|
227
|
+
import { useVibe, useViewer } from "use-vibes";
|
|
228
|
+
import { Badge, Button, Card, Field, Heading, Input, Row, Segmented, Skeleton, Spacer, Stack, Text } from "@vibes.diy/pouf";
|
|
229
|
+
import { createWorld } from "./lib/physics.js";
|
|
230
|
+
|
|
231
|
+
const WIDTH = 360;
|
|
232
|
+
const HEIGHT = 520;
|
|
233
|
+
const KINDS = [
|
|
234
|
+
{ value: "ramp", label: "Ramp" },
|
|
235
|
+
{ value: "peg", label: "Peg" },
|
|
236
|
+
{ value: "bumper", label: "Bumper" },
|
|
237
|
+
{ value: "goal", label: "Goal" },
|
|
238
|
+
];
|
|
239
|
+
const STARTER_PIECES = [
|
|
240
|
+
{ id: "p1", kind: "ramp", x: 110, y: 150, w: 170, h: 12, angle: 0.35 },
|
|
241
|
+
{ id: "p2", kind: "peg", x: 230, y: 250, r: 8 },
|
|
242
|
+
{ id: "p3", kind: "bumper", x: 120, y: 330, r: 16 },
|
|
243
|
+
{ id: "p4", kind: "goal", x: 280, y: 505, w: 110, h: 10 },
|
|
244
|
+
];
|
|
245
|
+
|
|
246
|
+
export default function App() {
|
|
247
|
+
const { database, useLiveQuery } = useFireproof("marbleRuns");
|
|
248
|
+
const { me, can, ready } = useVibe("marbleRuns");
|
|
249
|
+
const { requestLogin, ViewerTag } = useViewer();
|
|
250
|
+
const { docs } = useLiveQuery("type", { key: "level" });
|
|
251
|
+
const levels = useMemo(() => [...docs].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)), [docs]);
|
|
252
|
+
|
|
253
|
+
const [activeId, setActiveId] = useState(null);
|
|
254
|
+
const [pieces, setPieces] = useState(STARTER_PIECES);
|
|
255
|
+
const [mode, setMode] = useState("build");
|
|
256
|
+
const [kind, setKind] = useState("ramp");
|
|
257
|
+
const [score, setScore] = useState(0);
|
|
258
|
+
const [drops, setDrops] = useState(0);
|
|
259
|
+
const [name, setName] = useState("");
|
|
260
|
+
|
|
261
|
+
const canvasRef = useRef(null);
|
|
262
|
+
const worldRef = useRef(null);
|
|
263
|
+
const piecesRef = useRef(pieces);
|
|
264
|
+
const saveTimer = useRef(null);
|
|
265
|
+
piecesRef.current = pieces;
|
|
266
|
+
|
|
267
|
+
const active = levels.find((l) => l._id === activeId) ?? null;
|
|
268
|
+
const canEdit = Boolean(active && ready && can.edit(active).ok);
|
|
269
|
+
const canMake = Boolean(ready && me && can.create({ type: "level", name: "x", pieces: [], creatorHandle: me.userHandle }).ok);
|
|
270
|
+
|
|
271
|
+
// The engine lives in a ref: created once, disposed in the effect's cleanup.
|
|
272
|
+
useEffect(() => {
|
|
273
|
+
const world = createWorld(canvasRef.current, { width: WIDTH, height: HEIGHT, seed: 7 });
|
|
274
|
+
worldRef.current = world;
|
|
275
|
+
world.reset(piecesRef.current);
|
|
276
|
+
const off = world.onMeet("marble", "goal", (marble) => {
|
|
277
|
+
world.remove(marble);
|
|
278
|
+
setScore((s) => s + 1);
|
|
279
|
+
});
|
|
280
|
+
world.start();
|
|
281
|
+
return () => {
|
|
282
|
+
off();
|
|
283
|
+
world.dispose();
|
|
284
|
+
worldRef.current = null;
|
|
285
|
+
};
|
|
286
|
+
}, []);
|
|
287
|
+
|
|
288
|
+
// A level switch loads its pieces into the world.
|
|
289
|
+
useEffect(() => {
|
|
290
|
+
if (!active) return;
|
|
291
|
+
setPieces(active.pieces);
|
|
292
|
+
worldRef.current?.reset(active.pieces);
|
|
293
|
+
setScore(0);
|
|
294
|
+
setDrops(0);
|
|
295
|
+
}, [active?._id]);
|
|
296
|
+
|
|
297
|
+
function commit(next) {
|
|
298
|
+
setPieces(next);
|
|
299
|
+
worldRef.current?.reset(next);
|
|
300
|
+
setScore(0);
|
|
301
|
+
setDrops(0);
|
|
302
|
+
if (active && canEdit) {
|
|
303
|
+
clearTimeout(saveTimer.current);
|
|
304
|
+
saveTimer.current = setTimeout(() => database.put({ ...active, pieces: next, updatedAt: Date.now() }), 500);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function tap(event) {
|
|
309
|
+
const world = worldRef.current;
|
|
310
|
+
if (!world || mode !== "build") return;
|
|
311
|
+
const rect = canvasRef.current.getBoundingClientRect();
|
|
312
|
+
const point = { x: event.clientX - rect.left, y: event.clientY - rect.top };
|
|
313
|
+
const hit = world.pick(point);
|
|
314
|
+
if (hit && hit.pieceId) return commit(pieces.filter((p) => p.id !== hit.pieceId));
|
|
315
|
+
const size = kind === "peg" ? { r: 8 } : kind === "bumper" ? { r: 16 } : kind === "goal" ? { w: 110, h: 10 } : { w: 150, h: 12, angle: 0.3 };
|
|
316
|
+
commit([...pieces, { id: crypto.randomUUID(), kind, x: point.x, y: point.y, ...size }]);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function drop() {
|
|
320
|
+
const world = worldRef.current;
|
|
321
|
+
if (!world) return;
|
|
322
|
+
world.add({ id: crypto.randomUUID(), kind: "marble", x: 40 + (drops % 5) * 12, y: 16, r: 9 });
|
|
323
|
+
setDrops((d) => d + 1);
|
|
324
|
+
setMode("play");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function replay() {
|
|
328
|
+
worldRef.current?.reset(pieces);
|
|
329
|
+
setScore(0);
|
|
330
|
+
setDrops(0);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function keep() {
|
|
334
|
+
if (!canMake) return;
|
|
335
|
+
const doc = { _id: crypto.randomUUID(), type: "level", name: name || "My run", pieces, seed: 7, creatorHandle: me.userHandle, updatedAt: Date.now() };
|
|
336
|
+
await database.put(doc);
|
|
337
|
+
setName("");
|
|
338
|
+
setActiveId(doc._id);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return (
|
|
342
|
+
<main className="min-h-screen bg-background text-foreground p-4 md:p-8">
|
|
343
|
+
<Stack gap={4}>
|
|
344
|
+
<Row gap={2} align="center" wrap>
|
|
345
|
+
<Heading level={1}>Marble Run</Heading>
|
|
346
|
+
<Spacer />
|
|
347
|
+
<Badge tone="up">{score} in the goal</Badge>
|
|
348
|
+
{active && ready && !canEdit && <Badge tone="info">View only</Badge>}
|
|
349
|
+
</Row>
|
|
350
|
+
|
|
351
|
+
<Row gap={2} align="center" wrap>
|
|
352
|
+
<Segmented label="Mode" value={mode} onChange={setMode} options={[{ value: "build", label: "Build" }, { value: "play", label: "Play" }]} />
|
|
353
|
+
{mode === "build" && <Segmented label="Piece" value={kind} onChange={setKind} options={KINDS} />}
|
|
354
|
+
<Spacer />
|
|
355
|
+
<Button onClick={drop}>Drop a marble</Button>
|
|
356
|
+
<Button variant="quiet" onClick={replay}>Reset</Button>
|
|
357
|
+
</Row>
|
|
358
|
+
<Text size="sm" muted>{mode === "build" ? "Tap the stage to place a piece; tap a piece to take it away." : "Drag a marble while it rolls. Reset replays the run from the same start."}</Text>
|
|
359
|
+
|
|
360
|
+
<Card variant="flush">
|
|
361
|
+
<canvas ref={canvasRef} onPointerDown={tap} className="block touch-none mx-auto" />
|
|
362
|
+
</Card>
|
|
363
|
+
|
|
364
|
+
<Card variant="tight">
|
|
365
|
+
<Stack gap={3}>
|
|
366
|
+
<Heading level={2}>Runs</Heading>
|
|
367
|
+
{!ready && <div aria-busy="true"><Skeleton variant="row" /></div>}
|
|
368
|
+
{ready && !me && <Row gap={2} align="center" wrap><Text muted>Sign in and your runs stay with you.</Text><Button size="sm" variant="quiet" onClick={requestLogin}>Sign in to keep runs</Button></Row>}
|
|
369
|
+
{ready && me && canMake && (
|
|
370
|
+
<Row gap={2} align="top" wrap>
|
|
371
|
+
<Field label="Name this run">{(id, d) => <Input id={id} describedBy={d} value={name} onChange={setName} placeholder="the long drop" />}</Field>
|
|
372
|
+
<Button size="sm" onClick={keep}>Keep</Button>
|
|
373
|
+
</Row>
|
|
374
|
+
)}
|
|
375
|
+
{levels.map((l) => (
|
|
376
|
+
<Row key={l._id} gap={2} align="center" wrap>
|
|
377
|
+
<Button size="sm" variant={l._id === active?._id ? "solid" : "quiet"} onClick={() => setActiveId(l._id)}>{l.name}</Button>
|
|
378
|
+
{l.creatorHandle && <ViewerTag userHandle={l.creatorHandle} />}
|
|
379
|
+
<Spacer />
|
|
380
|
+
{can.delete(l).ok && <Button size="sm" variant="quiet" tone="down" onClick={() => database.del(l._id)}>Remove</Button>}
|
|
381
|
+
</Row>
|
|
382
|
+
))}
|
|
383
|
+
</Stack>
|
|
384
|
+
</Card>
|
|
385
|
+
</Stack>
|
|
386
|
+
</main>
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
### `access.js`
|
|
392
|
+
```js
|
|
393
|
+
// level: a marble run. Its creator edits it; everyone can open and play it.
|
|
394
|
+
export function marbleRuns(doc, oldDoc, user, ctx) {
|
|
395
|
+
if (!user) throw { forbidden: "sign in to keep runs" };
|
|
396
|
+
if (doc.type === "level" || (doc._deleted && oldDoc && oldDoc.type === "level")) {
|
|
397
|
+
const creator = oldDoc ? oldDoc.creatorHandle : doc.creatorHandle;
|
|
398
|
+
if (creator !== user.userHandle) throw { forbidden: "runs are kept as yourself" };
|
|
399
|
+
if (oldDoc && !doc._deleted && doc.creatorHandle !== oldDoc.creatorHandle) throw { forbidden: "a run keeps its maker" };
|
|
400
|
+
return { channels: ["levels"], grant: { public: ["levels"] } };
|
|
401
|
+
}
|
|
402
|
+
throw { forbidden: "unknown document type" };
|
|
403
|
+
}
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
## The engine lives in a ref and steps by a fixed STEP
|
|
407
|
+
|
|
408
|
+
**Create the world once inside `useEffect(() => {…}, [])`, keep it in a ref,
|
|
409
|
+
and dispose the world in the effect's cleanup** (`world.dispose()` above:
|
|
410
|
+
stop the loop with `cancelAnimationFrame`, release the mouse listeners,
|
|
411
|
+
`Composite.clear` the world and `Engine.clear(engine)`). React renders the
|
|
412
|
+
chrome; the engine owns the canvas. Per-change values the loop needs (the
|
|
413
|
+
pieces, the mode) travel through refs the handlers read.
|
|
414
|
+
|
|
415
|
+
**The loop steps the engine by a fixed `STEP` as many times as the frame
|
|
416
|
+
owes, and leaves the frame's own delta out of the physics.** `Engine.update(engine, STEP)` with
|
|
417
|
+
`STEP = 1000 / 60` makes the simulation independent of the display: a
|
|
418
|
+
60 Hz laptop and a 120 Hz phone compute the same trajectory, a dropped frame
|
|
419
|
+
is paid back as two steps rather than one long one, and the cap
|
|
420
|
+
(`Math.min(now - last, 4 * STEP)`) keeps a background tab from stepping
|
|
421
|
+
hundreds of times on return. Measured: two worlds built the same way and
|
|
422
|
+
stepped 300 times land every body on identical coordinates. `Matter.Runner`
|
|
423
|
+
does the same fixed stepping with its own `requestAnimationFrame`
|
|
424
|
+
(`Runner.run(runner, engine)`, `Runner.stop(runner)`); the loop above exists
|
|
425
|
+
so painting and stepping share one frame.
|
|
426
|
+
|
|
427
|
+
**Paint from `body.vertices` on a DPR-scaled canvas in the app's theme
|
|
428
|
+
colours.** The canvas is `width * dpr` pixels wide and `width` CSS pixels
|
|
429
|
+
wide, `ctx.setTransform(dpr, 0, 0, dpr, 0, 0)` once, and every body is a
|
|
430
|
+
closed path of its vertices filled by label. The colours come from the
|
|
431
|
+
injected tokens through `cssColor` (the p5 doc's probe trick), so the
|
|
432
|
+
owner's restyle reaches the stage. Matter's own `Render` is a fine wireframe
|
|
433
|
+
prototype (`Render.create({ element, engine, options: { wireframes: true } })`,
|
|
434
|
+
`Render.run`), with one measured catch: **create it before the first
|
|
435
|
+
`Engine.update`.** `Render.create` recursively merges its configuration,
|
|
436
|
+
the supplied engine included, and once collision pairs have put cycles in
|
|
437
|
+
the engine that merge walks pair → body → parent and overflows the stack
|
|
438
|
+
(measured: fine on a fresh engine, `RangeError` after 120 steps of a
|
|
439
|
+
colliding world). Creating it first is the safe ordering; the custom canvas
|
|
440
|
+
loop above has no such order.
|
|
441
|
+
|
|
442
|
+
**Set `mouse.pixelRatio = dpr`** right after `Mouse.create(canvas)`, so the
|
|
443
|
+
mouse constraint picks the body under the finger on a retina screen rather
|
|
444
|
+
than one half a stage away. The same `Mouse` handles touch (its touch
|
|
445
|
+
listeners are registered with `passive: false`), so dragging a marble on a
|
|
446
|
+
phone needs nothing more than `touch-none` on the canvas.
|
|
447
|
+
|
|
448
|
+
## Build mode and play mode — the document is the source of truth
|
|
449
|
+
|
|
450
|
+
**A piece is a document and its body is a stand-in**: `bodyFromPiece` makes
|
|
451
|
+
the body, `reset(pieces)` rebuilds the whole world from the array, and a
|
|
452
|
+
change to the pieces calls `reset` rather than nudging a body, so what is
|
|
453
|
+
saved and what is on the stage are the same thing by construction. Marbles
|
|
454
|
+
are runtime-only: dropped from a button, removed when they score, gone on
|
|
455
|
+
reset. Static pieces are placed by a tap (`Query.point` through `pick`
|
|
456
|
+
decides whether the tap landed on a piece to remove or on empty stage to
|
|
457
|
+
fill), and the mouse constraint drags only the dynamic bodies, which is the
|
|
458
|
+
behaviour a builder wants: a ramp stays where it was put, a marble can be
|
|
459
|
+
flicked.
|
|
460
|
+
|
|
461
|
+
Autosave the pieces on a short debounce when `can.edit(level).ok`; a
|
|
462
|
+
visitor without edit rights builds on their own screen and sees the
|
|
463
|
+
view-only badge; "Keep" writes a new level as the visitor, gated on
|
|
464
|
+
`can.create`. Seed `seed.json` with two or three finished levels so the
|
|
465
|
+
first visit has somewhere to drop a marble.
|
|
466
|
+
|
|
467
|
+
## Seeded replays
|
|
468
|
+
|
|
469
|
+
**Reset puts the clock back to zero, re-seeds `Common._seed`, and rebuilds
|
|
470
|
+
the bodies from the pieces, so the next run replays the last one exactly.**
|
|
471
|
+
Measured: drop a marble, step 600 times, reset, drop again, step 600 times,
|
|
472
|
+
and the marble lands on the same coordinates to the hundredth of a pixel
|
|
473
|
+
with the same goal count. That is what makes "share this run" honest: a
|
|
474
|
+
saved level plus its seed plus the same drops is the same show on every
|
|
475
|
+
device. Any randomness the toy wants (a bumper's kick, a spawn position)
|
|
476
|
+
goes through `Common.random()` so the seed covers it.
|
|
477
|
+
|
|
478
|
+
## Collisions and scoring
|
|
479
|
+
|
|
480
|
+
**Label bodies and listen on `collisionStart` for the pair you care about.**
|
|
481
|
+
`onMeet("marble", "goal", fn)` above wraps it; `isSensor: true` on the goal
|
|
482
|
+
makes it fire without stopping the marble. The same hook drives sounds (the
|
|
483
|
+
web-audio doc's restraint rule: a sound on the goal, none on every peg),
|
|
484
|
+
particle bursts, or a tower's "knocked down" check (count the pieces whose
|
|
485
|
+
`position.y` crossed a line). `collisionActive` and `collisionEnd` exist for
|
|
486
|
+
resting contact and release.
|
|
487
|
+
|
|
488
|
+
## A second shape — words with mass
|
|
489
|
+
|
|
490
|
+
Type a sentence; every word becomes a body sized to its text, tumbles into
|
|
491
|
+
the pile, and can be flicked. The same module, no persistence, a shake
|
|
492
|
+
button that applies a force to every body.
|
|
493
|
+
|
|
494
|
+
### `App.jsx`
|
|
495
|
+
```jsx app=tumble
|
|
496
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
497
|
+
import { Button, Field, Heading, Input, Row, Spacer, Stack, Text } from "@vibes.diy/pouf";
|
|
498
|
+
import { Matter, createWorld, cssColor } from "./lib/physics.js";
|
|
499
|
+
|
|
500
|
+
const { Bodies, Body, Composite } = Matter;
|
|
501
|
+
const WIDTH = 360;
|
|
502
|
+
const HEIGHT = 420;
|
|
503
|
+
|
|
504
|
+
export default function App() {
|
|
505
|
+
const canvasRef = useRef(null);
|
|
506
|
+
const worldRef = useRef(null);
|
|
507
|
+
const [text, setText] = useState("");
|
|
508
|
+
|
|
509
|
+
useEffect(() => {
|
|
510
|
+
const canvas = canvasRef.current;
|
|
511
|
+
const world = createWorld(canvas, { width: WIDTH, height: HEIGHT, seed: 3 });
|
|
512
|
+
worldRef.current = world;
|
|
513
|
+
// Paint each word inside its body: rotate with the body, centre the text.
|
|
514
|
+
const ctx = canvas.getContext("2d");
|
|
515
|
+
const ink = cssColor(canvas, "--background", "#ffffff");
|
|
516
|
+
const basePaint = world.paint;
|
|
517
|
+
world.paint = () => {
|
|
518
|
+
basePaint();
|
|
519
|
+
ctx.fillStyle = ink;
|
|
520
|
+
ctx.font = "600 16px system-ui, sans-serif";
|
|
521
|
+
ctx.textAlign = "center";
|
|
522
|
+
ctx.textBaseline = "middle";
|
|
523
|
+
for (const body of world.bodies()) {
|
|
524
|
+
if (!body.word) continue;
|
|
525
|
+
ctx.save();
|
|
526
|
+
ctx.translate(body.position.x, body.position.y);
|
|
527
|
+
ctx.rotate(body.angle);
|
|
528
|
+
ctx.fillText(body.word, 0, 0);
|
|
529
|
+
ctx.restore();
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
world.start();
|
|
533
|
+
return () => {
|
|
534
|
+
world.dispose();
|
|
535
|
+
worldRef.current = null;
|
|
536
|
+
};
|
|
537
|
+
}, []);
|
|
538
|
+
|
|
539
|
+
function addWords(event) {
|
|
540
|
+
event.preventDefault();
|
|
541
|
+
const world = worldRef.current;
|
|
542
|
+
if (!world || !text.trim()) return;
|
|
543
|
+
const ctx = canvasRef.current.getContext("2d");
|
|
544
|
+
ctx.font = "600 16px system-ui, sans-serif";
|
|
545
|
+
text.trim().split(/\s+/).forEach((word, i) => {
|
|
546
|
+
const w = ctx.measureText(word).width + 16;
|
|
547
|
+
const body = Bodies.rectangle(40 + ((i * 70) % (WIDTH - 80)), -20 - i * 30, w, 28, { label: "word", chamfer: { radius: 8 }, restitution: 0.3 });
|
|
548
|
+
body.word = word;
|
|
549
|
+
Composite.add(world.engine.world, body);
|
|
550
|
+
});
|
|
551
|
+
setText("");
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function shake() {
|
|
555
|
+
const world = worldRef.current;
|
|
556
|
+
if (!world) return;
|
|
557
|
+
for (const body of world.bodies()) {
|
|
558
|
+
Body.applyForce(body, body.position, { x: (Math.random() - 0.5) * 0.04 * body.mass, y: -0.05 * body.mass });
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return (
|
|
563
|
+
<main className="min-h-screen bg-background text-foreground p-4 md:p-8">
|
|
564
|
+
<Stack gap={4}>
|
|
565
|
+
<Heading level={1}>Word Pile</Heading>
|
|
566
|
+
<form onSubmit={addWords}>
|
|
567
|
+
<Row gap={2} align="top" wrap>
|
|
568
|
+
<Field label="Say something">{(id, d) => <Input id={id} describedBy={d} value={text} onChange={setText} placeholder="gravity is a habit" />}</Field>
|
|
569
|
+
<Button type="submit">Drop the words</Button>
|
|
570
|
+
<Spacer />
|
|
571
|
+
<Button type="button" variant="quiet" onClick={shake}>Shake</Button>
|
|
572
|
+
</Row>
|
|
573
|
+
</form>
|
|
574
|
+
<canvas ref={canvasRef} className="block touch-none mx-auto rounded-md" />
|
|
575
|
+
<Text size="sm" muted>Drag a word to throw it. Shake lifts the whole pile.</Text>
|
|
576
|
+
</Stack>
|
|
577
|
+
</main>
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
`Body.applyForce` takes a force in Matter's units, so scale it by `body.mass`
|
|
583
|
+
(as the engine's own examples do) and apply it once; `Body.setVelocity` sets
|
|
584
|
+
a speed outright, which is the right call for a launch. `chamfer` rounds a
|
|
585
|
+
rectangle's corners so a word pile settles like beanbags rather than bricks.
|
|
586
|
+
|
|
587
|
+
## Mobile
|
|
588
|
+
|
|
589
|
+
Touch is handled by Matter's `Mouse` with the `pixelRatio` set; `touch-none`
|
|
590
|
+
on the canvas keeps a drag from scrolling the page. Size the stage from the
|
|
591
|
+
host (`Math.min(host.clientWidth, 480)`) and rebuild the world on resize (a
|
|
592
|
+
new `createWorld`, then `reset(pieces)`), since bodies keep their
|
|
593
|
+
coordinates. Cap `dpr` at 2 and `enableSleeping: true` so a settled pile
|
|
594
|
+
costs nothing; a phone handles a few hundred bodies at 60 Hz.
|
|
595
|
+
|
|
596
|
+
## Shapes a physics vibe takes
|
|
597
|
+
|
|
598
|
+
- **A marble run or Rube Goldberg builder** — pieces as documents, build and
|
|
599
|
+
play modes, a goal sensor, seeded replays, a gallery of runs. The starter.
|
|
600
|
+
- **A slingshot or catapult** — a `Constraint` with low `stiffness` from an
|
|
601
|
+
anchor point to a projectile, released when the mouse lets go (the
|
|
602
|
+
engine's slingshot example: `Constraint.create({ pointA: anchor, bodyB: rock,
|
|
603
|
+
stiffness: 0.05, damping: 0.01 })`), a tower of `Bodies.rectangle` from
|
|
604
|
+
`Composites.pyramid`, and a score from how many blocks fell past a line.
|
|
605
|
+
- **Dominoes and towers** — tall thin rectangles placed by tap, one push
|
|
606
|
+
(`Body.applyForce`) to start the chain, a replay to watch it again.
|
|
607
|
+
- **Plinko and pachinko** — a `Composites.stack` of static pegs, marbles
|
|
608
|
+
dropped from a slider-chosen column, buckets as sensors with points.
|
|
609
|
+
- **Pinball** — flippers as static bodies rotated with `Body.setAngle` on a
|
|
610
|
+
key or tap, bumpers with `restitution` above 1, a drain sensor.
|
|
611
|
+
- **A word pile or a page with mass** — the second example; a landing page
|
|
612
|
+
whose cards fall when tapped is the same shape with cards as bodies.
|
|
613
|
+
- **Ragdolls and soft bodies** — `Composites.softBody` for jelly,
|
|
614
|
+
`Constraint`s between circles for a ragdoll; the engine's examples have
|
|
615
|
+
both.
|
|
616
|
+
|
|
617
|
+
## Common gotchas
|
|
618
|
+
|
|
619
|
+
- **`import { Engine } from "matter-js"` reads undefined** — the esm.sh
|
|
620
|
+
build has a default export only. `import Matter from …` and destructure.
|
|
621
|
+
- **The simulation runs at a different speed on another device** — the
|
|
622
|
+
update used the frame delta. Step by a fixed `STEP` and pay back owed time
|
|
623
|
+
in whole steps.
|
|
624
|
+
- **A tap or drag lands half a stage away** — the mouse has no `pixelRatio`
|
|
625
|
+
on a DPR-scaled canvas. Set `mouse.pixelRatio = dpr`.
|
|
626
|
+
- **`Render.create` overflows the stack** — it was created after the engine
|
|
627
|
+
had simulated collisions. Create it before the first update, or paint from
|
|
628
|
+
vertices as the module does.
|
|
629
|
+
- **A level reloads with pieces slightly off** — bodies were saved instead
|
|
630
|
+
of pieces. Save the piece documents and rebuild bodies from them.
|
|
631
|
+
- **Bodies freeze mid-air** — sleeping bodies stay asleep until something
|
|
632
|
+
touches them; `Sleeping.set(body, false)` or `Body.setVelocity` wakes one,
|
|
633
|
+
and a dropped marble wakes what it hits.
|
|
634
|
+
- **A big frame hitch makes bodies tunnel through walls** — the cap on owed
|
|
635
|
+
time keeps steps small; make walls thick (40 px) so a fast body meets them.
|
package/llms/richtext.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export const richtextConfig = {
|
|
2
|
+
name: "richtext",
|
|
3
|
+
label: "Rich text (Tiptap)",
|
|
4
|
+
description: "Notebooks, journals, wikis, notes apps and document editors built on Tiptap: pages with headings, " +
|
|
5
|
+
"lists, checklists, links and images, a list of documents and one editor, a shared page a team " +
|
|
6
|
+
"edits with the last editor named, meeting notes with action items, a recipe book with real pages, " +
|
|
7
|
+
"a journal with one entry per day. Covers every Tiptap package pinned to one version by its esm.sh " +
|
|
8
|
+
"URL (one ProseMirror, no second React), an editor module with createEditor, load, flush, run and " +
|
|
9
|
+
"destroy, a saved note as its JSON plus plain text and a derived title, saving after typing pauses " +
|
|
10
|
+
"with flush on blur and switch, loading a document without a save echo (setContent with emitUpdate " +
|
|
11
|
+
"false), destroy in the effect's cleanup, the page styled from the host with arbitrary variants, " +
|
|
12
|
+
"toolbar commands and active states, and images as _files. " +
|
|
13
|
+
"notebook, notes, note taking, journal, diary, wiki, document editor, rich text editor, text editor, " +
|
|
14
|
+
"wysiwyg, writing app, markdown editor, meeting notes, knowledge base, tiptap, prosemirror",
|
|
15
|
+
cues: [
|
|
16
|
+
"notebook",
|
|
17
|
+
"notes app",
|
|
18
|
+
"note taking",
|
|
19
|
+
"note-taking",
|
|
20
|
+
"take notes",
|
|
21
|
+
"journal",
|
|
22
|
+
"diary",
|
|
23
|
+
"wiki",
|
|
24
|
+
"document editor",
|
|
25
|
+
"doc editor",
|
|
26
|
+
"rich text",
|
|
27
|
+
"rich-text",
|
|
28
|
+
"text editor",
|
|
29
|
+
"wysiwyg",
|
|
30
|
+
"writing app",
|
|
31
|
+
"writing space",
|
|
32
|
+
"markdown editor",
|
|
33
|
+
"meeting notes",
|
|
34
|
+
"knowledge base",
|
|
35
|
+
"formatting toolbar",
|
|
36
|
+
"bold and italic",
|
|
37
|
+
"headings and lists",
|
|
38
|
+
"checklist in",
|
|
39
|
+
"tiptap",
|
|
40
|
+
"prosemirror",
|
|
41
|
+
"quill",
|
|
42
|
+
"lexical",
|
|
43
|
+
"slate editor",
|
|
44
|
+
"editor.js",
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
//# sourceMappingURL=richtext.js.map
|