@vibes.diy/prompts 14.3.168 → 14.3.172
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/stripe-subscriptions.md +77 -26
- package/llms/stripe.md +69 -24
- package/llms/three-js.md +49 -88
- package/llms/touch-games.js +8 -2
- package/llms/touch-games.js.map +1 -1
- package/llms/touch-games.md +207 -0
- package/llms/voxel.js +1 -1
- package/llms/voxel.js.map +1 -1
- package/llms/voxel.md +34 -74
- package/package.json +5 -5
|
@@ -28,29 +28,49 @@ one-merchant half.
|
|
|
28
28
|
|
|
29
29
|
## 1. Bring your own Stripe key, turn on the portal, and explain how
|
|
30
30
|
|
|
31
|
-
The owner sets up Stripe once
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
31
|
+
The owner sets up Stripe once. The steps are written for someone who has never
|
|
32
|
+
opened Stripe, so each one is a link to the exact page and uses the words that
|
|
33
|
+
page uses:
|
|
34
|
+
|
|
35
|
+
1. Open Stripe's **API keys** page in test mode:
|
|
36
|
+
<https://dashboard.stripe.com/test/apikeys> (sign in, or create a Stripe
|
|
37
|
+
account when it asks). Under **Standard keys**, press **Reveal test key**
|
|
38
|
+
beside **Secret key** and copy it. It starts with `sk_test_`.
|
|
39
|
+
2. Open this vibe's **Settings** tab on vibes.diy:
|
|
40
|
+
`https://vibes.diy/vibes/mine/<ownerHandle>/<appSlug>/settings` (that is
|
|
41
|
+
**My Vibes** → this vibe → **Settings**). Under **Secrets**, enter the key
|
|
42
|
+
`STRIPE_SECRET_KEY` and paste the value, then save. From a terminal the
|
|
43
|
+
same thing is `npx vibes-diy secrets set STRIPE_SECRET_KEY --vibe <ownerHandle>/<appSlug>`.
|
|
44
|
+
3. Open Stripe's **Customer portal** settings in test mode:
|
|
45
|
+
<https://dashboard.stripe.com/test/settings/billing/portal>. Under
|
|
46
|
+
**Cancellations**, turn on **Cancel subscriptions** and choose **At end of
|
|
47
|
+
billing period**, then press **Save**. The app sends members there to cancel
|
|
48
|
+
or change a card, and the portal does not exist until it is saved once.
|
|
49
|
+
4. To go live, take the **live** secret key from
|
|
50
|
+
<https://dashboard.stripe.com/apikeys> (**Reveal live key**, `sk_live_`),
|
|
51
|
+
set it the same way, and save the portal once more at
|
|
52
|
+
<https://dashboard.stripe.com/settings/billing/portal>.
|
|
43
53
|
|
|
44
54
|
The app carries those steps in its own UI, because the person reading them is
|
|
45
|
-
the person who has to do them
|
|
55
|
+
the person who has to do them — and it fills in the vibe's real handle and
|
|
56
|
+
slug, which the backend knows as `ctx.appInfo`, so the Settings link and the
|
|
57
|
+
terminal command are copy-ready rather than templates. The Settings tab opens
|
|
58
|
+
in another tab, so the app re-reads `/status` whenever it regains focus or
|
|
59
|
+
becomes visible again: the owner saves the key, comes back, and the card gives
|
|
60
|
+
way to the Join buttons without a reload.
|
|
46
61
|
|
|
47
|
-
When the key is missing, the app shows the owner the setup steps for a Stripe secret key and the Customer Portal instead of an error.
|
|
62
|
+
When the key is missing, the app shows the owner the setup steps for a Stripe secret key and the Customer Portal instead of an error, and the steps link to Stripe's API keys page, Stripe's Customer portal settings and this vibe's own Settings tab by URL.
|
|
48
63
|
|
|
49
64
|
The backend answers `{ ok: false, reason: "missing-key" }` when
|
|
50
|
-
`ctx.secrets.STRIPE_SECRET_KEY` is absent, and
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
65
|
+
`ctx.secrets.STRIPE_SECRET_KEY` is absent, and `/status` carries
|
|
66
|
+
`ownerHandle` and `appSlug` so the client can render the numbered steps
|
|
67
|
+
with real links: `https://dashboard.stripe.com/test/apikeys` for the key,
|
|
68
|
+
`https://vibes.diy/vibes/mine/<ownerHandle>/<appSlug>/settings` for where
|
|
69
|
+
it goes, `https://dashboard.stripe.com/test/settings/billing/portal` for the
|
|
70
|
+
portal, and `STRIPE_SECRET_KEY` in so many words. "Open the Stripe dashboard"
|
|
71
|
+
and "the Settings page" are not instructions; a person who has never seen
|
|
72
|
+
either would have to search for both. A visitor who is not the owner sees a
|
|
73
|
+
calmer sentence — memberships are not open yet — never the steps. A portal that was never saved
|
|
54
74
|
answers a Stripe error whose message says so; keep that sentence (§ 7) and the
|
|
55
75
|
owner reads step 3 off it.
|
|
56
76
|
|
|
@@ -345,7 +365,8 @@ export async function fetch(request, ctx) {
|
|
|
345
365
|
const me = ctx.userInfo?.userHandle;
|
|
346
366
|
|
|
347
367
|
if (url.pathname === "/status" && request.method === "GET") {
|
|
348
|
-
|
|
368
|
+
// ownerHandle + appSlug let the setup card link this vibe's own Settings tab.
|
|
369
|
+
return json({ ok: true, configured: Boolean(ctx.secrets.STRIPE_SECRET_KEY), owner: me === ctx.appInfo.ownerHandle, graceDays: GRACE_DAYS, ownerHandle: ctx.appInfo.ownerHandle, appSlug: ctx.appInfo.appSlug });
|
|
349
370
|
}
|
|
350
371
|
|
|
351
372
|
if (!me) return json({ ok: false, reason: "sign-in" }, 401);
|
|
@@ -508,11 +529,34 @@ import { useFireproof } from "use-fireproof";
|
|
|
508
529
|
import { apiFetch, useViewer } from "use-vibes";
|
|
509
530
|
import { Badge, Button, Heading, Stack, Text } from "@vibes.diy/pouf";
|
|
510
531
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
532
|
+
// The owner's setup card: every step is a link to the exact page, in that page's own words.
|
|
533
|
+
function SetupSteps({ status }) {
|
|
534
|
+
const vibe = `${status.ownerHandle}/${status.appSlug}`;
|
|
535
|
+
const settingsUrl = `https://vibes.diy/vibes/mine/${vibe}/settings`;
|
|
536
|
+
return (
|
|
537
|
+
<ol>
|
|
538
|
+
<li>
|
|
539
|
+
Open Stripe's <a href="https://dashboard.stripe.com/test/apikeys" target="_blank" rel="noreferrer">API keys page (test mode)</a>,
|
|
540
|
+
use your Stripe account (the page offers to create one), and under <b>Standard keys</b> press <b>Reveal test key</b> beside <b>Secret key</b>.
|
|
541
|
+
Copy it; it starts with <code>sk_test_</code>.
|
|
542
|
+
</li>
|
|
543
|
+
<li>
|
|
544
|
+
Open <a href={settingsUrl} target="_blank" rel="noreferrer">this vibe's Settings tab</a> (My Vibes → this vibe → Settings).
|
|
545
|
+
Under <b>Secrets</b>, enter the key <code>STRIPE_SECRET_KEY</code>, paste the value, and save.
|
|
546
|
+
From a terminal instead: <code>npx vibes-diy secrets set STRIPE_SECRET_KEY --vibe {vibe}</code>.
|
|
547
|
+
</li>
|
|
548
|
+
<li>
|
|
549
|
+
Open Stripe's <a href="https://dashboard.stripe.com/test/settings/billing/portal" target="_blank" rel="noreferrer">Customer portal settings</a>:
|
|
550
|
+
under <b>Cancellations</b> turn on <b>Cancel subscriptions</b>, choose <b>At end of billing period</b>, and press <b>Save</b>.
|
|
551
|
+
Members use that page to cancel or change a card.
|
|
552
|
+
</li>
|
|
553
|
+
<li>
|
|
554
|
+
Come back here: the Join buttons work as soon as the key is saved. In test mode the card 4242 4242 4242 4242 pays.
|
|
555
|
+
To go live, do the same with the <a href="https://dashboard.stripe.com/apikeys" target="_blank" rel="noreferrer">live key</a> and the live portal.
|
|
556
|
+
</li>
|
|
557
|
+
</ol>
|
|
558
|
+
);
|
|
559
|
+
}
|
|
516
560
|
|
|
517
561
|
export default function App() {
|
|
518
562
|
const { viewer, requestLogin } = useViewer();
|
|
@@ -523,8 +567,15 @@ export default function App() {
|
|
|
523
567
|
const [busy, setBusy] = useState(false);
|
|
524
568
|
const [error, setError] = useState(null);
|
|
525
569
|
|
|
570
|
+
// Read status on mount, and again whenever the app comes back into view: the owner sets the key
|
|
571
|
+
// in another tab and returns, and the setup card has to give way to the buttons without a reload.
|
|
526
572
|
useEffect(() => {
|
|
527
|
-
apiFetch("/status").then((r) => r.json()).then(setStatus).catch(() => setStatus({ configured: false, owner: false }));
|
|
573
|
+
const load = () => apiFetch("/status").then((r) => r.json()).then(setStatus).catch(() => setStatus({ configured: false, owner: false }));
|
|
574
|
+
load();
|
|
575
|
+
const onVisible = () => { if (document.visibilityState === "visible") load(); };
|
|
576
|
+
document.addEventListener("visibilitychange", onVisible);
|
|
577
|
+
window.addEventListener("focus", load);
|
|
578
|
+
return () => { document.removeEventListener("visibilitychange", onVisible); window.removeEventListener("focus", load); };
|
|
528
579
|
}, []);
|
|
529
580
|
|
|
530
581
|
// Back from Stripe: the fragment arrives just after first render and on hashchange, so read both.
|
|
@@ -570,7 +621,7 @@ export default function App() {
|
|
|
570
621
|
<Stack>
|
|
571
622
|
<Heading>Set up memberships</Heading>
|
|
572
623
|
<Text>Three steps in Stripe, once, and the Join buttons go live for everyone.</Text>
|
|
573
|
-
<
|
|
624
|
+
<SetupSteps status={status} />
|
|
574
625
|
</Stack>
|
|
575
626
|
);
|
|
576
627
|
}
|
package/llms/stripe.md
CHANGED
|
@@ -38,26 +38,45 @@ does not build — say so plainly and build the one-merchant half.
|
|
|
38
38
|
|
|
39
39
|
## 1. Bring your own Stripe key, and explain how
|
|
40
40
|
|
|
41
|
-
The owner gets a key from Stripe, once
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
41
|
+
The owner gets a key from Stripe, once. The steps are written for someone
|
|
42
|
+
who has never opened Stripe, so each one is a link to the exact page and uses
|
|
43
|
+
the words that page uses:
|
|
44
|
+
|
|
45
|
+
1. Open Stripe's **API keys** page in test mode:
|
|
46
|
+
<https://dashboard.stripe.com/test/apikeys> (sign in, or create a Stripe
|
|
47
|
+
account when it asks). Under **Standard keys**, press **Reveal test key**
|
|
48
|
+
beside **Secret key** and copy it. It starts with `sk_test_`.
|
|
49
|
+
2. Open this vibe's **Settings** tab on vibes.diy:
|
|
50
|
+
`https://vibes.diy/vibes/mine/<ownerHandle>/<appSlug>/settings` (that is
|
|
51
|
+
**My Vibes** → this vibe → **Settings**). Under **Secrets**, enter the key
|
|
52
|
+
`STRIPE_SECRET_KEY` and paste the value, then save. From a terminal the
|
|
53
|
+
same thing is `npx vibes-diy secrets set STRIPE_SECRET_KEY --vibe <ownerHandle>/<appSlug>`.
|
|
54
|
+
3. Come back to the app. The Pay buttons work the moment the key is saved; no
|
|
55
|
+
redeploy. In test mode the card `4242 4242 4242 4242` with any future date
|
|
56
|
+
pays.
|
|
57
|
+
4. To go live, take the **live** secret key from
|
|
58
|
+
<https://dashboard.stripe.com/apikeys> (**Reveal live key**, `sk_live_`)
|
|
59
|
+
and set it the same way; setting an existing key replaces it.
|
|
50
60
|
|
|
51
61
|
The app carries those steps in its own UI, because the person reading them is
|
|
52
|
-
the person who has to do them
|
|
62
|
+
the person who has to do them — and it fills in the vibe's real handle and
|
|
63
|
+
slug, which the backend knows as `ctx.appInfo`, so the Settings link and the
|
|
64
|
+
terminal command are copy-ready rather than templates. The Settings tab opens
|
|
65
|
+
in another tab, so the app re-reads `/status` whenever it regains focus or
|
|
66
|
+
becomes visible again: the owner saves the key, comes back, and the card gives
|
|
67
|
+
way to the Pay buttons without a reload.
|
|
53
68
|
|
|
54
|
-
When the key is missing, the app shows the owner the setup steps for a Stripe secret key instead of an error.
|
|
69
|
+
When the key is missing, the app shows the owner the setup steps for a Stripe secret key instead of an error, and the steps link to Stripe's API keys page and to this vibe's own Settings tab by URL.
|
|
55
70
|
|
|
56
71
|
The backend answers `{ ok: false, reason: "missing-key" }` when
|
|
57
|
-
`ctx.secrets.STRIPE_SECRET_KEY` is absent, and
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
72
|
+
`ctx.secrets.STRIPE_SECRET_KEY` is absent, and `/status` carries
|
|
73
|
+
`ownerHandle` and `appSlug` so the client can render the numbered steps
|
|
74
|
+
with real links: `https://dashboard.stripe.com/test/apikeys` for the key,
|
|
75
|
+
`https://vibes.diy/vibes/mine/<ownerHandle>/<appSlug>/settings` for where
|
|
76
|
+
it goes, and `STRIPE_SECRET_KEY` in so many words. "Open the Stripe
|
|
77
|
+
dashboard" and "the Settings page" are not instructions; a person who has
|
|
78
|
+
never seen either would have to search for both. A visitor who is not the
|
|
79
|
+
owner sees a calmer sentence — payments are not set up yet — never the steps.
|
|
61
80
|
|
|
62
81
|
Only the owner can set or rotate a secret, and handlers see the current value on
|
|
63
82
|
every invocation — so the pay button starts working the moment the key lands,
|
|
@@ -292,7 +311,8 @@ export async function fetch(request, ctx) {
|
|
|
292
311
|
// Is Stripe set up? The owner gets the steps; everyone else gets a sentence.
|
|
293
312
|
if (url.pathname === "/status" && request.method === "GET") {
|
|
294
313
|
const owner = ctx.userInfo?.userHandle === ctx.appInfo.ownerHandle;
|
|
295
|
-
|
|
314
|
+
// ownerHandle + appSlug let the setup card link this vibe's own Settings tab.
|
|
315
|
+
return json({ ok: true, configured: Boolean(ctx.secrets.STRIPE_SECRET_KEY), owner, ownerHandle: ctx.appInfo.ownerHandle, appSlug: ctx.appInfo.appSlug });
|
|
296
316
|
}
|
|
297
317
|
|
|
298
318
|
// Start a purchase: the order first, then a Checkout Session for exactly that order.
|
|
@@ -406,21 +426,46 @@ import { useFireproof } from "use-fireproof";
|
|
|
406
426
|
import { apiFetch, useVibe } from "use-vibes";
|
|
407
427
|
import { Badge, Button, Heading, Stack, Text } from "@vibes.diy/pouf";
|
|
408
428
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
429
|
+
// The owner's setup card: every step is a link to the exact page, in that page's own words.
|
|
430
|
+
function SetupSteps({ status }) {
|
|
431
|
+
const vibe = `${status.ownerHandle}/${status.appSlug}`;
|
|
432
|
+
const settingsUrl = `https://vibes.diy/vibes/mine/${vibe}/settings`;
|
|
433
|
+
return (
|
|
434
|
+
<ol>
|
|
435
|
+
<li>
|
|
436
|
+
Open Stripe's <a href="https://dashboard.stripe.com/test/apikeys" target="_blank" rel="noreferrer">API keys page (test mode)</a>,
|
|
437
|
+
use your Stripe account (the page offers to create one), and under <b>Standard keys</b> press <b>Reveal test key</b> beside <b>Secret key</b>.
|
|
438
|
+
Copy it; it starts with <code>sk_test_</code>.
|
|
439
|
+
</li>
|
|
440
|
+
<li>
|
|
441
|
+
Open <a href={settingsUrl} target="_blank" rel="noreferrer">this vibe's Settings tab</a> (My Vibes → this vibe → Settings).
|
|
442
|
+
Under <b>Secrets</b>, enter the key <code>STRIPE_SECRET_KEY</code>, paste the value, and save.
|
|
443
|
+
From a terminal instead: <code>npx vibes-diy secrets set STRIPE_SECRET_KEY --vibe {vibe}</code>.
|
|
444
|
+
</li>
|
|
445
|
+
<li>
|
|
446
|
+
Come back here: the Pay buttons work as soon as the key is saved. In test mode the card 4242 4242 4242 4242 pays.
|
|
447
|
+
To go live, do the same with the <a href="https://dashboard.stripe.com/apikeys" target="_blank" rel="noreferrer">live key</a>.
|
|
448
|
+
</li>
|
|
449
|
+
</ol>
|
|
450
|
+
);
|
|
451
|
+
}
|
|
414
452
|
|
|
415
453
|
export default function App() {
|
|
416
454
|
const { me } = useVibe("orders");
|
|
417
455
|
const { useLiveQuery } = useFireproof("orders");
|
|
418
456
|
const { docs: orders } = useLiveQuery("type", { key: "order" });
|
|
419
|
-
const [status, setStatus] = useState(null); // { configured, owner } from the backend
|
|
457
|
+
const [status, setStatus] = useState(null); // { configured, owner, ownerHandle, appSlug } from the backend
|
|
420
458
|
const [error, setError] = useState(null);
|
|
421
459
|
|
|
460
|
+
// Read status on mount, and again whenever the app comes back into view: the owner sets the key
|
|
461
|
+
// in another tab and returns, and the setup card has to give way to the buttons without a reload.
|
|
422
462
|
useEffect(() => {
|
|
423
|
-
apiFetch("/status").then((r) => r.json()).then(setStatus).catch(() => setStatus({ configured: false, owner: false }));
|
|
463
|
+
const load = () => apiFetch("/status").then((r) => r.json()).then(setStatus).catch(() => setStatus({ configured: false, owner: false }));
|
|
464
|
+
load();
|
|
465
|
+
const onVisible = () => { if (document.visibilityState === "visible") load(); };
|
|
466
|
+
document.addEventListener("visibilitychange", onVisible);
|
|
467
|
+
window.addEventListener("focus", load);
|
|
468
|
+
return () => { document.removeEventListener("visibilitychange", onVisible); window.removeEventListener("focus", load); };
|
|
424
469
|
}, []);
|
|
425
470
|
|
|
426
471
|
// Back from Stripe: ask the backend to read the session and settle the order.
|
|
@@ -467,7 +512,7 @@ export default function App() {
|
|
|
467
512
|
<Stack>
|
|
468
513
|
<Heading>Set up payments</Heading>
|
|
469
514
|
<Text>Add your Stripe secret key once and the Pay buttons go live for everyone.</Text>
|
|
470
|
-
<
|
|
515
|
+
<SetupSteps status={status} />
|
|
471
516
|
</Stack>
|
|
472
517
|
);
|
|
473
518
|
}
|
package/llms/three-js.md
CHANGED
|
@@ -95,6 +95,10 @@ title, settings, scores, sharing and every other panel live behind **one** menu
|
|
|
95
95
|
top-right corner (`IconButton`, never the bottom-right, which the platform owns) that toggles an
|
|
96
96
|
overlay — see the Sky Glider example below. The game stays in view the moment the overlay closes.
|
|
97
97
|
|
|
98
|
+
The lens follows the screen: pass the camera to `useWorldControls` and it widens on a phone held upright.
|
|
99
|
+
An overview camera that orbits the scene keeps its own distance: scale the radius by
|
|
100
|
+
`0.36 / (tan(fov / 2) · aspect)`, clamped to 1–2.2×, so the whole scene stays in frame upright.
|
|
101
|
+
|
|
98
102
|
When the person asks for a smaller view — a model on a product page, a scene inside a card, a
|
|
99
103
|
viewer beside a form — give the viewport a box instead and keep the page around it:
|
|
100
104
|
|
|
@@ -115,61 +119,14 @@ Size the renderer from the box (`mount.clientWidth`, `mount.clientHeight`) and w
|
|
|
115
119
|
|
|
116
120
|
## Touch controls on a phone
|
|
117
121
|
|
|
118
|
-
On a phone, every drag across the canvas
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
- Put `select-none touch-none` on the mount `<div>` / `<canvas>` **and** on the
|
|
124
|
-
container that holds the HUD, labels and on-screen controls — a long press
|
|
125
|
-
on a joystick selects the label next to it if only the canvas is covered.
|
|
126
|
-
- Drive input with Pointer Events (`pointerdown` / `pointermove` / `pointerup`
|
|
127
|
-
/ `pointercancel`): one path for mouse, pen and finger. `touch-none` is what
|
|
128
|
-
lets the move events keep flowing during a drag.
|
|
129
|
-
- A button someone **holds** (thrust, fire, accelerate, a D-pad arrow) tracks
|
|
130
|
-
its own pressed state from the pointer events, captures the pointer so a
|
|
131
|
-
finger sliding off still releases cleanly, and cancels the long-press menu:
|
|
132
|
-
|
|
133
|
-
```jsx
|
|
134
|
-
import { IconButton } from "@vibes.diy/pouf";
|
|
135
|
-
|
|
136
|
-
function HoldButton({ icon, label, onChange }) {
|
|
137
|
-
const set = (on) => (e) => {
|
|
138
|
-
e.preventDefault();
|
|
139
|
-
if (on) e.currentTarget.setPointerCapture(e.pointerId);
|
|
140
|
-
onChange(on);
|
|
141
|
-
};
|
|
142
|
-
return (
|
|
143
|
-
<IconButton
|
|
144
|
-
icon={icon}
|
|
145
|
-
label={label}
|
|
146
|
-
size="lg"
|
|
147
|
-
variant="solid"
|
|
148
|
-
onPointerDown={set(true)}
|
|
149
|
-
onPointerUp={set(false)}
|
|
150
|
-
onPointerCancel={set(false)}
|
|
151
|
-
onContextMenu={(e) => e.preventDefault()}
|
|
152
|
-
/>
|
|
153
|
-
);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// The wrapper carries select-none touch-none, and its buttons inherit both —
|
|
157
|
-
// the whole control area belongs to the finger:
|
|
158
|
-
<div className="relative h-dvh w-full select-none touch-none">
|
|
159
|
-
<div ref={mountRef} className="absolute inset-0" />
|
|
160
|
-
<div className="absolute bottom-6 left-6 flex gap-3">
|
|
161
|
-
<HoldButton icon="◀" label="Steer left" onChange={(on) => (input.current.left = on)} />
|
|
162
|
-
<HoldButton icon="▶" label="Steer right" onChange={(on) => (input.current.right = on)} />
|
|
163
|
-
</div>
|
|
164
|
-
</div>
|
|
165
|
-
```
|
|
166
|
-
|
|
167
|
-
Read the held state (`input.current`) inside the animation loop, so a press
|
|
168
|
-
moves the scene every frame it's down. `OrbitControls` already speaks pointer
|
|
169
|
-
events; it still needs `touch-none` on its element to receive the drag.
|
|
122
|
+
On a phone, every drag across the canvas is a touch the browser would otherwise claim for itself:
|
|
123
|
+
it selects the nearest text, pops the copy / look-up callout, scrolls the page, or zooms. Hand
|
|
124
|
+
those touches to the scene with `select-none touch-none` on the mount `<div>` / `<canvas>` **and**
|
|
125
|
+
on the container that holds the HUD and labels. `OrbitControls` already speaks pointer events and
|
|
126
|
+
receives the drag once its element is `touch-none`.
|
|
170
127
|
|
|
171
|
-
|
|
172
|
-
|
|
128
|
+
- A world you move through — walked, explored, flown around — calls `useWorldControls` from `use-vibes`, taught in the `touch-games` skill (Walk + look): left thumb walks, right thumb looks, tap acts, and a click-to-play overlay carries the desktop.
|
|
129
|
+
- A flyer or a car that charges forward calls for hold + steer from the `touch-games` skill: thumb down is the throttle and its offset steers.
|
|
173
130
|
|
|
174
131
|
## Core Setup
|
|
175
132
|
|
|
@@ -1300,11 +1257,14 @@ Structure your component in this order:
|
|
|
1300
1257
|
import React, { useState, useEffect, useRef, useCallback } from "react";
|
|
1301
1258
|
import { useFireproof } from "use-fireproof";
|
|
1302
1259
|
import * as THREE from "three";
|
|
1260
|
+
import { useWorldControls } from "use-vibes";
|
|
1303
1261
|
import { Card, Heading, IconButton, Stack, Text } from "@vibes.diy/pouf";
|
|
1304
1262
|
|
|
1305
1263
|
export default function SkyGlider() {
|
|
1306
1264
|
const { database, useLiveQuery } = useFireproof("skyGliderScores");
|
|
1265
|
+
const rootRef = useRef(null);
|
|
1307
1266
|
const canvasRef = useRef(null);
|
|
1267
|
+
const [camera] = useState(() => new THREE.PerspectiveCamera(60, 1, 0.1, 1000));
|
|
1308
1268
|
const gameStateRef = useRef({
|
|
1309
1269
|
scene: null,
|
|
1310
1270
|
camera: null,
|
|
@@ -1317,7 +1277,7 @@ export default function SkyGlider() {
|
|
|
1317
1277
|
lastSmokeTime: 0,
|
|
1318
1278
|
score: 0,
|
|
1319
1279
|
gameRunning: false,
|
|
1320
|
-
|
|
1280
|
+
stick: { x: 0, y: 0 },
|
|
1321
1281
|
velocity: { x: 0, y: 0, z: 0 },
|
|
1322
1282
|
heading: 0,
|
|
1323
1283
|
forwardSpeed: 0,
|
|
@@ -1327,6 +1287,18 @@ export default function SkyGlider() {
|
|
|
1327
1287
|
|
|
1328
1288
|
const [currentScore, setCurrentScore] = useState(0);
|
|
1329
1289
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
1290
|
+
|
|
1291
|
+
// The left thumb (or WASD) steers through useWorldControls, a flick up thrusts, and the hook
|
|
1292
|
+
// keeps the lens fitted to the screen because it holds the camera.
|
|
1293
|
+
const { keys, tick } = useWorldControls(rootRef, {
|
|
1294
|
+
mode: "player",
|
|
1295
|
+
flying: true,
|
|
1296
|
+
camera,
|
|
1297
|
+
onMove: (x, y) => Object.assign(gameStateRef.current.stick, { x, y }),
|
|
1298
|
+
onFlick: (dir) => dir === "up" && (gameStateRef.current.forwardSpeed = 0.3),
|
|
1299
|
+
});
|
|
1300
|
+
gameStateRef.current.tick = tick;
|
|
1301
|
+
gameStateRef.current.held = keys;
|
|
1330
1302
|
const { docs: scoreData } = useLiveQuery("type", { key: "score" }) || {
|
|
1331
1303
|
docs: [],
|
|
1332
1304
|
};
|
|
@@ -1465,7 +1437,6 @@ export default function SkyGlider() {
|
|
|
1465
1437
|
scene.background = new THREE.Color(0x70d6ff);
|
|
1466
1438
|
scene.fog = new THREE.Fog(0x70d6ff, 50, 300);
|
|
1467
1439
|
|
|
1468
|
-
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
|
|
1469
1440
|
camera.position.set(0, 10, 20);
|
|
1470
1441
|
|
|
1471
1442
|
const renderer = new THREE.WebGLRenderer({
|
|
@@ -1538,15 +1509,19 @@ export default function SkyGlider() {
|
|
|
1538
1509
|
roll: 0,
|
|
1539
1510
|
};
|
|
1540
1511
|
|
|
1541
|
-
|
|
1512
|
+
// dt is the real frame time in seconds, clamped so a backgrounded tab resumes gently.
|
|
1513
|
+
let last = performance.now();
|
|
1514
|
+
const gameLoop = (now) => {
|
|
1542
1515
|
if (gameStateRef.current.gameRunning) {
|
|
1543
|
-
|
|
1516
|
+
const dt = Math.min((now - last) / 1000, 0.1);
|
|
1517
|
+
last = now;
|
|
1518
|
+
updateGame(dt);
|
|
1544
1519
|
requestAnimationFrame(gameLoop);
|
|
1545
1520
|
}
|
|
1546
1521
|
};
|
|
1547
1522
|
gameStateRef.current.gameRunning = true;
|
|
1548
|
-
gameLoop
|
|
1549
|
-
}, [createTexturedCoin]);
|
|
1523
|
+
requestAnimationFrame(gameLoop);
|
|
1524
|
+
}, [createTexturedCoin, camera]);
|
|
1550
1525
|
|
|
1551
1526
|
const checkCoinCollisions = useCallback(() => {
|
|
1552
1527
|
const state = gameStateRef.current;
|
|
@@ -1573,36 +1548,23 @@ export default function SkyGlider() {
|
|
|
1573
1548
|
});
|
|
1574
1549
|
}, [createGlowEffect]);
|
|
1575
1550
|
|
|
1576
|
-
const handleKeyDown = useCallback((event) => {
|
|
1577
|
-
if (event.code === "Space") event.preventDefault();
|
|
1578
|
-
gameStateRef.current.keys[event.code] = true;
|
|
1579
|
-
}, []);
|
|
1580
|
-
|
|
1581
|
-
const handleKeyUp = useCallback((event) => {
|
|
1582
|
-
if (event.code === "Space") event.preventDefault();
|
|
1583
|
-
gameStateRef.current.keys[event.code] = false;
|
|
1584
|
-
}, []);
|
|
1585
|
-
|
|
1586
1551
|
const handleResize = useCallback(() => {
|
|
1587
1552
|
const state = gameStateRef.current;
|
|
1588
|
-
if (!state.
|
|
1589
|
-
state.camera.aspect = window.innerWidth / window.innerHeight;
|
|
1590
|
-
state.camera.updateProjectionMatrix();
|
|
1553
|
+
if (!state.renderer) return;
|
|
1591
1554
|
state.renderer.setSize(window.innerWidth, window.innerHeight);
|
|
1592
1555
|
}, []);
|
|
1593
1556
|
|
|
1594
|
-
const updateGame = useCallback(() => {
|
|
1557
|
+
const updateGame = useCallback((dt) => {
|
|
1595
1558
|
const state = gameStateRef.current;
|
|
1596
1559
|
if (!state.gameRunning || !state.glider) return;
|
|
1597
1560
|
|
|
1598
|
-
const {
|
|
1561
|
+
const { stick, held, glider } = state;
|
|
1562
|
+
state.tick(dt);
|
|
1599
1563
|
|
|
1600
|
-
// Controls
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
if (
|
|
1604
|
-
if (keys["ArrowDown"] || keys["KeyS"]) state.pitch -= 0.01;
|
|
1605
|
-
if (keys["Space"]) state.forwardSpeed = Math.min(0.3, state.forwardSpeed + 0.005);
|
|
1564
|
+
// Controls: the stick (thumb or WASD) banks and pitches; Space thrusts
|
|
1565
|
+
state.heading -= stick.x * 0.03;
|
|
1566
|
+
state.pitch -= stick.y * 0.01; // forward on the stick (y < 0) climbs
|
|
1567
|
+
if (held.has("Space")) state.forwardSpeed = Math.min(0.3, state.forwardSpeed + 0.005);
|
|
1606
1568
|
|
|
1607
1569
|
// Physics
|
|
1608
1570
|
state.forwardSpeed = Math.max(0.05, state.forwardSpeed * 0.995);
|
|
@@ -1672,12 +1634,8 @@ export default function SkyGlider() {
|
|
|
1672
1634
|
|
|
1673
1635
|
useEffect(() => {
|
|
1674
1636
|
initThreeJS();
|
|
1675
|
-
window.addEventListener("keydown", handleKeyDown);
|
|
1676
|
-
window.addEventListener("keyup", handleKeyUp);
|
|
1677
1637
|
window.addEventListener("resize", handleResize);
|
|
1678
1638
|
return () => {
|
|
1679
|
-
window.removeEventListener("keydown", handleKeyDown);
|
|
1680
|
-
window.removeEventListener("keyup", handleKeyUp);
|
|
1681
1639
|
window.removeEventListener("resize", handleResize);
|
|
1682
1640
|
const state = gameStateRef.current;
|
|
1683
1641
|
state.gameRunning = false;
|
|
@@ -1696,12 +1654,15 @@ export default function SkyGlider() {
|
|
|
1696
1654
|
}
|
|
1697
1655
|
if (state.renderer) state.renderer.dispose();
|
|
1698
1656
|
};
|
|
1699
|
-
}, [initThreeJS,
|
|
1657
|
+
}, [initThreeJS, handleResize]);
|
|
1700
1658
|
|
|
1701
1659
|
return (
|
|
1702
1660
|
// Full screen by default: the game is the whole view. The score stays as a quiet HUD; the title,
|
|
1703
1661
|
// the controls legend and the high scores live behind ONE menu button, so nothing covers play.
|
|
1704
|
-
<div
|
|
1662
|
+
<div
|
|
1663
|
+
ref={rootRef}
|
|
1664
|
+
className="relative h-dvh w-full overflow-hidden bg-background text-foreground font-sans select-none touch-none"
|
|
1665
|
+
>
|
|
1705
1666
|
<canvas ref={canvasRef} className="absolute inset-0 select-none touch-none" />
|
|
1706
1667
|
<div className="absolute top-3 left-3 pointer-events-none">
|
|
1707
1668
|
<Text num>Score: {currentScore}</Text>
|
|
@@ -1715,7 +1676,7 @@ export default function SkyGlider() {
|
|
|
1715
1676
|
<Stack gap={2}>
|
|
1716
1677
|
<Heading level={2}>Sky Glider</Heading>
|
|
1717
1678
|
<Text size="sm" muted>
|
|
1718
|
-
WASD
|
|
1679
|
+
Left thumb or WASD: steer · flick up or Space: thrust
|
|
1719
1680
|
</Text>
|
|
1720
1681
|
{scoreData
|
|
1721
1682
|
.sort((a, b) => b.value - a.value)
|
package/llms/touch-games.js
CHANGED
|
@@ -5,9 +5,11 @@ export const touchGamesConfig = {
|
|
|
5
5
|
"physics, p5, worlds or pouf-game for every game, 3D scene you move through, or canvas toy: the play area " +
|
|
6
6
|
"is the controller (select-none touch-none surface, Pointer Events with pointer capture), swipes that fire " +
|
|
7
7
|
"mid-move with chaining, tap vs swipe on one surface, a floating ghost stick under the thumb, hold-to-boost " +
|
|
8
|
-
"with drag steering, pull-back-and-release aiming, gesture trails and feedback, keyboard twins for desktop
|
|
8
|
+
"with drag steering, pull-back-and-release aiming, gesture trails and feedback, keyboard twins for desktop, " +
|
|
9
|
+
"and walk + look for a 3D world through the useWorldControls hook from use-vibes (left thumb walks, right thumb " +
|
|
10
|
+
"looks, flick up to jump, desktop pointer lock and WASD). " +
|
|
9
11
|
"game, mobile game, touch controls, swipe, joystick, runner, racer, flyer, shooter, snake, slingshot, " +
|
|
10
|
-
"platformer, arcade, one thumb",
|
|
12
|
+
"platformer, arcade, one thumb, first-person, walk around, explore",
|
|
11
13
|
cues: [
|
|
12
14
|
"swipe",
|
|
13
15
|
"touch controls",
|
|
@@ -36,6 +38,10 @@ export const touchGamesConfig = {
|
|
|
36
38
|
"catapult",
|
|
37
39
|
"flappy",
|
|
38
40
|
"snake game",
|
|
41
|
+
"first-person",
|
|
42
|
+
"first person",
|
|
43
|
+
"walk around",
|
|
44
|
+
"explore",
|
|
39
45
|
],
|
|
40
46
|
};
|
|
41
47
|
//# sourceMappingURL=touch-games.js.map
|
package/llms/touch-games.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"touch-games.js","sourceRoot":"","sources":["../../jsr/llms/touch-games.ts"],"names":[],"mappings":"AAEA,MAAM,CAAC,MAAM,gBAAgB,GAAc;IACzC,IAAI,EAAE,aAAa;IACnB,KAAK,EAAE,aAAa;IACpB,WAAW,EACT,qGAAqG;QACrG,2GAA2G;QAC3G,4GAA4G;QAC5G,6GAA6G;QAC7G,6GAA6G;QAC7G,uGAAuG;QACvG
|
|
1
|
+
{"version":3,"file":"touch-games.js","sourceRoot":"","sources":["../../jsr/llms/touch-games.ts"],"names":[],"mappings":"AAEA,MAAM,CAAC,MAAM,gBAAgB,GAAc;IACzC,IAAI,EAAE,aAAa;IACnB,KAAK,EAAE,aAAa;IACpB,WAAW,EACT,qGAAqG;QACrG,2GAA2G;QAC3G,4GAA4G;QAC5G,6GAA6G;QAC7G,6GAA6G;QAC7G,iHAAiH;QACjH,2DAA2D;QAC3D,uGAAuG;QACvG,mEAAmE;IAKrE,IAAI,EAAE;QACJ,OAAO;QACP,gBAAgB;QAChB,aAAa;QACb,YAAY;QACZ,YAAY;QACZ,aAAa;QACb,cAAc;QACd,aAAa;QACb,UAAU;QACV,QAAQ;QACR,OAAO;QACP,QAAQ;QACR,OAAO;QACP,SAAS;QACT,YAAY;QACZ,gBAAgB;QAChB,SAAS;QACT,aAAa;QACb,aAAa;QACb,OAAO;QACP,SAAS;QACT,WAAW;QACX,SAAS;QACT,WAAW;QACX,UAAU;QACV,QAAQ;QACR,YAAY;QACZ,cAAc;QACd,cAAc;QACd,aAAa;QACb,SAAS;KACV;CACF,CAAC"}
|
package/llms/touch-games.md
CHANGED
|
@@ -18,6 +18,7 @@ Three things make that true, and each has one home:
|
|
|
18
18
|
steers the game while nearby text stays put — no selection highlight, no copy callout, no
|
|
19
19
|
page scroll, no pinch zoom, no pull-to-refresh.
|
|
20
20
|
2. **One input path for every pointer.** Input runs on Pointer Events with `setPointerCapture` on one tracked `pointerId`, so mouse, pen and finger share one path.
|
|
21
|
+
A 3D world you walk through gets that path from `useWorldControls` (see Walk + look below).
|
|
21
22
|
3. **The game says what it understood.** A short fading trail under the finger, plus an arrow,
|
|
22
23
|
ghost stick or aim line, so the player sees which gesture the game read.
|
|
23
24
|
|
|
@@ -30,6 +31,7 @@ Three things make that true, and each has one home:
|
|
|
30
31
|
| Flies or drives forward: a glider, a ship, a car | **Hold + steer** — thumb down = throttle, offset = steer | canyon flyer |
|
|
31
32
|
| Aims then commits: a launcher, golf, pool, basketball | **Pull back + release** | slingshot |
|
|
32
33
|
| Moves in steps AND has one action | **Flick + tap** on the same surface | snake with a dash |
|
|
34
|
+
| Walks or flies through a 3D world: a block world, a garden, a ruin to explore | **Walk + look** — `useWorldControls` | first-person explorer |
|
|
33
35
|
|
|
34
36
|
A desktop visitor gets the same game: the mouse drives the same pointer path, and arrow keys /
|
|
35
37
|
WASD / Space call the same action functions the gestures call.
|
|
@@ -159,6 +161,211 @@ the start point (~24px) cancels, and the ring says so. The thumb usually starts
|
|
|
159
161
|
third, so put the ground at about two-thirds of the screen height and leave room below it to
|
|
160
162
|
pull. Preview only part of the arc (about 0.85s of flight) so aiming stays a skill.
|
|
161
163
|
|
|
164
|
+
## Walk + look: move through a 3D world
|
|
165
|
+
|
|
166
|
+
A 3D world you walk, fly or look around in gets its controls from `useWorldControls` (from `use-vibes`), on phone and desktop alike. Hand it the root element and what each act means; the hook owns the gestures, keeps the root `touch-none`, and sizes the lens when you pass it the camera.
|
|
167
|
+
|
|
168
|
+
Left half walks: a floating stick appears under the thumb. Right half looks: drag to turn, and a finger held near the screen's edge keeps turning.
|
|
169
|
+
|
|
170
|
+
Tap acts on what is under the finger; press and hold is the second action; a quick upward flick on the look side jumps.
|
|
171
|
+
|
|
172
|
+
- **The jump is judged as the finger lifts**, and the flick's up/down look is held back, so the
|
|
173
|
+
camera stays level through every jump.
|
|
174
|
+
- **`mode: "player"`** walks, looks and flicks; **`mode: "free"`** orbits a camera that zooms
|
|
175
|
+
with a pinch or the mouse wheel (`onPinch(factor)`, under 1 zooms in). With `flying: true`, a
|
|
176
|
+
flick down (`onFlick("down")`) sinks.
|
|
177
|
+
- **The callbacks speak screen sense.** `onMove(x, y)` is the walk vector with forward at `y < 0`;
|
|
178
|
+
`onLook(dx, dy)` and `onTurn(yaw, pitch)` arrive in radians, applied the same way; `onTap`,
|
|
179
|
+
`onHold` and `onAim` carry client coordinates.
|
|
180
|
+
- **Call `tick(dt)` once per frame** from the animation loop; the edge turn, the hold timer and
|
|
181
|
+
the finger trail advance on that clock, and `keys` holds every key code down right now.
|
|
182
|
+
- **The lens follows the screen.** Pass the `THREE.PerspectiveCamera` as `camera` and the hook
|
|
183
|
+
keeps its aspect current and widens the view on a phone held upright.
|
|
184
|
+
- **HUD controls live inside the root.** A press on a button, input or link there is that
|
|
185
|
+
control's own and never a world tap; mark any other piece that keeps its own touches (a slider,
|
|
186
|
+
a chip row) with `data-world-controls-ignore`.
|
|
187
|
+
|
|
188
|
+
A 🎥 button outside the menu cycles the views; on a phone the world opens in a free-cam overview that frames the whole scene. The views are **Free cam → First person → Shoulder view**; a desktop visitor opens in first person.
|
|
189
|
+
The overview's distance is the app's: scale the orbit radius by
|
|
190
|
+
`0.36 / (tan(fov / 2) · aspect)`, clamped to 1–2.2×, each frame, so a phone held upright still
|
|
191
|
+
frames the whole scene.
|
|
192
|
+
|
|
193
|
+
The one-line hint follows the current view.
|
|
194
|
+
|
|
195
|
+
The ☰ menu holds everything else, in this order: the views by name, fullscreen, how to play,
|
|
196
|
+
who else is here and what they did lately (players and activity), then invite a friend and all
|
|
197
|
+
worlds.
|
|
198
|
+
|
|
199
|
+
**The desktop twin is the same world.** In the player views a click-to-play overlay calls
|
|
200
|
+
`requestLock()`, so the mouse looks under pointer lock; WASD or the arrows walk and Space jumps.
|
|
201
|
+
`locked` reports the lock, so the overlay comes back when the player presses Esc, and `isTouch`
|
|
202
|
+
keeps the overlay off phones. While locked, a click under the crosshair is the tap and a
|
|
203
|
+
right-click is the hold (`onTap` / `onHold` at the screen centre). Free cam needs no lock: drag
|
|
204
|
+
orbits, a click is `onTap`, a right-click is `onHold`, and the wheel zooms.
|
|
205
|
+
|
|
206
|
+
### `App.jsx`
|
|
207
|
+
```jsx
|
|
208
|
+
import React from "react";
|
|
209
|
+
import * as THREE from "three";
|
|
210
|
+
import { useWorldControls } from "use-vibes";
|
|
211
|
+
import { Button, Card, Heading, IconButton, Stack, Text } from "@vibes.diy/pouf";
|
|
212
|
+
|
|
213
|
+
const VIEWS = ["Free cam", "First person", "Shoulder view"];
|
|
214
|
+
const HINT = {
|
|
215
|
+
"Free cam": "drag to look around · pinch to zoom · 🎥 to step in",
|
|
216
|
+
"First person": "left thumb walks · right thumb looks · flick up to jump",
|
|
217
|
+
"Shoulder view": "tap a hedge to bloom it · hold to trim it back",
|
|
218
|
+
};
|
|
219
|
+
const stop = (e) => e.stopPropagation();
|
|
220
|
+
|
|
221
|
+
export default function App() {
|
|
222
|
+
const rootRef = React.useRef(null);
|
|
223
|
+
const canvasRef = React.useRef(null);
|
|
224
|
+
const [camera] = React.useState(() => new THREE.PerspectiveCamera(70, 1, 0.1, 500));
|
|
225
|
+
const [view, setView] = React.useState(() => (matchMedia("(pointer: coarse)").matches ? "Free cam" : "First person"));
|
|
226
|
+
const [menuOpen, setMenuOpen] = React.useState(false);
|
|
227
|
+
const me = React.useRef({ pos: new THREE.Vector3(0, 0, 12), yaw: 0, pitch: 0, move: [0, 0], vy: 0, zoom: 34 });
|
|
228
|
+
const world = React.useRef({ hedges: [] });
|
|
229
|
+
|
|
230
|
+
const turn = (yaw, pitch) => {
|
|
231
|
+
me.current.yaw += yaw;
|
|
232
|
+
me.current.pitch = Math.max(-1.5, Math.min(1.5, me.current.pitch + pitch));
|
|
233
|
+
};
|
|
234
|
+
const look = (dx, dy) => turn(-dx, -dy); // radians, screen sense: a drag right turns right
|
|
235
|
+
const hedgeAt = (x, y) => {
|
|
236
|
+
const r = rootRef.current.getBoundingClientRect(); // taps arrive in client coordinates
|
|
237
|
+
const ray = new THREE.Raycaster();
|
|
238
|
+
ray.setFromCamera(new THREE.Vector2(((x - r.left) / r.width) * 2 - 1, 1 - ((y - r.top) / r.height) * 2), camera);
|
|
239
|
+
return ray.intersectObjects(world.current.hedges)[0]?.object;
|
|
240
|
+
};
|
|
241
|
+
const { isTouch, locked, requestLock, tick } = useWorldControls(rootRef, {
|
|
242
|
+
mode: view === "Free cam" ? "free" : "player",
|
|
243
|
+
camera,
|
|
244
|
+
onMove: (x, y) => (me.current.move = [x, y]),
|
|
245
|
+
onLook: look,
|
|
246
|
+
onTurn: look,
|
|
247
|
+
onPinch: (factor) => (me.current.zoom = Math.max(8, Math.min(60, me.current.zoom * factor))),
|
|
248
|
+
onTap: (x, y) => hedgeAt(x, y)?.material.color.set(0xff6fb1),
|
|
249
|
+
onHold: (x, y) => hedgeAt(x, y)?.material.color.set(0x2f7a3a),
|
|
250
|
+
onFlick: (dir) => dir === "up" && me.current.pos.y === 0 && (me.current.vy = 7),
|
|
251
|
+
});
|
|
252
|
+
const live = React.useRef({});
|
|
253
|
+
live.current = { tick, view };
|
|
254
|
+
|
|
255
|
+
React.useEffect(() => {
|
|
256
|
+
const root = rootRef.current;
|
|
257
|
+
const renderer = new THREE.WebGLRenderer({ canvas: canvasRef.current, antialias: true });
|
|
258
|
+
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
259
|
+
const size = new ResizeObserver(() => renderer.setSize(root.clientWidth, root.clientHeight, false));
|
|
260
|
+
size.observe(root);
|
|
261
|
+
const scene = new THREE.Scene();
|
|
262
|
+
scene.background = new THREE.Color(0x9fe3ff);
|
|
263
|
+
scene.add(new THREE.HemisphereLight(0xffffff, 0x3b5d2a, 1.4));
|
|
264
|
+
const groundGeo = new THREE.PlaneGeometry(60, 60);
|
|
265
|
+
const ground = new THREE.Mesh(groundGeo, new THREE.MeshLambertMaterial({ color: 0x6fcf5b }));
|
|
266
|
+
ground.rotation.x = -Math.PI / 2;
|
|
267
|
+
scene.add(ground);
|
|
268
|
+
const hedgeGeo = new THREE.BoxGeometry(5, 2, 1);
|
|
269
|
+
for (let i = 0; i < 10; i++) {
|
|
270
|
+
const hedge = new THREE.Mesh(hedgeGeo, new THREE.MeshLambertMaterial({ color: 0x2f7a3a }));
|
|
271
|
+
hedge.position.set(Math.cos(i * 0.63) * 18, 1, Math.sin(i * 0.63) * 18);
|
|
272
|
+
hedge.lookAt(0, 1, 0);
|
|
273
|
+
scene.add(hedge);
|
|
274
|
+
world.current.hedges.push(hedge);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
let raf = 0;
|
|
278
|
+
let last = performance.now();
|
|
279
|
+
const frame = (now) => {
|
|
280
|
+
const dt = Math.min((now - last) / 1000, 1 / 30);
|
|
281
|
+
last = now;
|
|
282
|
+
live.current.tick(dt);
|
|
283
|
+
const m = me.current;
|
|
284
|
+
const fwd = new THREE.Vector3(-Math.sin(m.yaw), 0, -Math.cos(m.yaw));
|
|
285
|
+
const side = new THREE.Vector3(-fwd.z, 0, fwd.x);
|
|
286
|
+
m.pos.addScaledVector(fwd, -m.move[1] * 5 * dt).addScaledVector(side, m.move[0] * 5 * dt); // forward is y < 0
|
|
287
|
+
m.vy -= 20 * dt;
|
|
288
|
+
m.pos.y = Math.max(0, m.pos.y + m.vy * dt);
|
|
289
|
+
if (m.pos.y === 0) m.vy = 0;
|
|
290
|
+
if (live.current.view === "Free cam") {
|
|
291
|
+
// Back the overview off on a narrow screen so the whole garden stays in frame.
|
|
292
|
+
const fit = Math.min(2.2, Math.max(1, 0.36 / (Math.tan((camera.fov * Math.PI) / 360) * camera.aspect)));
|
|
293
|
+
const d = m.zoom * fit;
|
|
294
|
+
camera.position.set(m.pos.x + Math.sin(m.yaw) * d, d * 0.7, m.pos.z + Math.cos(m.yaw) * d);
|
|
295
|
+
camera.lookAt(m.pos);
|
|
296
|
+
} else {
|
|
297
|
+
camera.position.copy(m.pos).y += 1.6;
|
|
298
|
+
if (live.current.view === "Shoulder view") camera.position.addScaledVector(fwd, -4).y += 1;
|
|
299
|
+
camera.rotation.set(m.pitch, m.yaw, 0, "YXZ");
|
|
300
|
+
}
|
|
301
|
+
renderer.render(scene, camera);
|
|
302
|
+
raf = requestAnimationFrame(frame);
|
|
303
|
+
};
|
|
304
|
+
raf = requestAnimationFrame(frame);
|
|
305
|
+
|
|
306
|
+
return () => {
|
|
307
|
+
cancelAnimationFrame(raf);
|
|
308
|
+
size.disconnect();
|
|
309
|
+
world.current.hedges.forEach((hedge) => hedge.material.dispose());
|
|
310
|
+
world.current.hedges = [];
|
|
311
|
+
hedgeGeo.dispose();
|
|
312
|
+
groundGeo.dispose();
|
|
313
|
+
ground.material.dispose();
|
|
314
|
+
renderer.dispose();
|
|
315
|
+
};
|
|
316
|
+
}, [camera]);
|
|
317
|
+
|
|
318
|
+
const cycle = () => setView(VIEWS[(VIEWS.indexOf(view) + 1) % VIEWS.length]);
|
|
319
|
+
return (
|
|
320
|
+
<div ref={rootRef} className="relative h-dvh w-full overflow-hidden bg-background text-foreground select-none touch-none">
|
|
321
|
+
<canvas ref={canvasRef} className="absolute inset-0 block w-full h-full select-none touch-none" />
|
|
322
|
+
<div className="absolute top-3 left-3 pointer-events-none">
|
|
323
|
+
<Text size="sm">{HINT[view]}</Text>
|
|
324
|
+
</div>
|
|
325
|
+
<div className="absolute top-3 right-3 flex gap-2" onPointerDown={stop}>
|
|
326
|
+
<IconButton icon="🎥" label={`View: ${view}`} onClick={cycle} />
|
|
327
|
+
<IconButton icon="☰" label={menuOpen ? "Hide menu" : "Show menu"} onClick={() => setMenuOpen(!menuOpen)} />
|
|
328
|
+
</div>
|
|
329
|
+
{menuOpen && (
|
|
330
|
+
<div className="absolute top-16 right-3 w-72" onPointerDown={stop}>
|
|
331
|
+
<Card>
|
|
332
|
+
<Stack gap={2}>
|
|
333
|
+
<Heading level={2}>Hedge Garden</Heading>
|
|
334
|
+
{VIEWS.map((v) => (
|
|
335
|
+
<Button key={v} variant={v === view ? "solid" : "quiet"} onClick={() => setView(v)}>
|
|
336
|
+
{v}
|
|
337
|
+
</Button>
|
|
338
|
+
))}
|
|
339
|
+
<Text size="sm" muted>
|
|
340
|
+
Left thumb walks, right thumb looks. Tap a hedge to bloom it, hold to trim it, flick up to jump.
|
|
341
|
+
</Text>
|
|
342
|
+
</Stack>
|
|
343
|
+
</Card>
|
|
344
|
+
</div>
|
|
345
|
+
)}
|
|
346
|
+
{!isTouch && !locked && view !== "Free cam" && (
|
|
347
|
+
<div className="absolute inset-0 z-20 flex items-center justify-center bg-background/80" onPointerDown={stop}>
|
|
348
|
+
<Card>
|
|
349
|
+
<Stack gap={3}>
|
|
350
|
+
<Heading level={2}>Hedge Garden</Heading>
|
|
351
|
+
<Button size="lg" onClick={requestLock}>
|
|
352
|
+
Click to play
|
|
353
|
+
</Button>
|
|
354
|
+
<Text size="sm" muted>
|
|
355
|
+
WASD walk · mouse look · Space jump · click bloom · Esc frees the mouse
|
|
356
|
+
</Text>
|
|
357
|
+
</Stack>
|
|
358
|
+
</Card>
|
|
359
|
+
</div>
|
|
360
|
+
)}
|
|
361
|
+
</div>
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
Grow it by changing what the acts mean: `voxel` makes the tap build and the hold break, a ruin
|
|
367
|
+
makes the tap open a door. The gesture numbers live in the hook; pass `tune` to adjust them.
|
|
368
|
+
|
|
162
369
|
## Teach the gesture once, then get out of the way
|
|
163
370
|
|
|
164
371
|
- **A ghost thumb on first run.** Before the first touch, a translucent thumb with a short motion
|
package/llms/voxel.js
CHANGED
|
@@ -5,7 +5,7 @@ export const voxelConfig = {
|
|
|
5
5
|
"Build a Minecraft-style first-person voxel sandbox with Three.js: chunked block world, " +
|
|
6
6
|
"procedural terrain (noise heightmaps, biomes, caves, ore veins, trees), place/break with a " +
|
|
7
7
|
"DDA voxel raycast, an AABB player controller with gravity, jump, sprint, auto step-up and " +
|
|
8
|
-
"un-stick, pointer
|
|
8
|
+
"un-stick, thumb walk/look and desktop pointer lock through useWorldControls with a click-to-play overlay, endless chunk " +
|
|
9
9
|
"streaming, a shared voxel palette with material-colored controls, a day/night cycle, " +
|
|
10
10
|
"ambient mobs, and live multiplayer block edits with temporary player presence over Fireproof. " +
|
|
11
11
|
"Covers the survival layer (table-driven crafting tiers, tools with durability, smelting, " +
|
package/llms/voxel.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"voxel.js","sourceRoot":"","sources":["../../jsr/llms/voxel.ts"],"names":[],"mappings":"AAEA,MAAM,CAAC,MAAM,WAAW,GAAc;IACpC,IAAI,EAAE,OAAO;IACb,KAAK,EAAE,OAAO;IACd,WAAW,EACT,wGAAwG;QACtG,yFAAyF;QAC3F,6FAA6F;QAC7F,4FAA4F;QAC5F,
|
|
1
|
+
{"version":3,"file":"voxel.js","sourceRoot":"","sources":["../../jsr/llms/voxel.ts"],"names":[],"mappings":"AAEA,MAAM,CAAC,MAAM,WAAW,GAAc;IACpC,IAAI,EAAE,OAAO;IACb,KAAK,EAAE,OAAO;IACd,WAAW,EACT,wGAAwG;QACtG,yFAAyF;QAC3F,6FAA6F;QAC7F,4FAA4F;QAC5F,0HAA0H;QAC1H,uFAAuF;QACvF,gGAAgG;QAChG,2FAA2F;QAC3F,yFAAyF;QACzF,6CAA6C;QAC7C,2FAA2F;QAC3F,+JAA+J;IACjK,IAAI,EAAE,CAAC,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAE,aAAa,EAAE,QAAQ,EAAE,cAAc,CAAC;IAC/E,YAAY,EAAE,OAAO;IACrB,UAAU,EAAE,OAAO;IACnB,UAAU,EAAE,WAAW;CACxB,CAAC"}
|
package/llms/voxel.md
CHANGED
|
@@ -190,9 +190,7 @@ luminance and contrast ratio ([normal text: at least 4.5:1](https://www.w3.org/W
|
|
|
190
190
|
and erase take `ink`; recompute for additional or customized colors. A simple
|
|
191
191
|
brightness threshold can choose white on wood, water, or lava even when dark
|
|
192
192
|
text is more readable. Keep labels/icons and `aria-pressed` or `aria-checked`:
|
|
193
|
-
the accent reinforces the selected mode rather than being its only signal.
|
|
194
|
-
touch, keep movement/look separate from Place/Break/Jump controls, and clear
|
|
195
|
-
held input on pointer cancellation or lost capture.
|
|
193
|
+
the accent reinforces the selected mode rather than being its only signal.
|
|
196
194
|
|
|
197
195
|
## Rendering: instance the EXPOSED blocks (this is the whole performance game)
|
|
198
196
|
|
|
@@ -404,52 +402,36 @@ handlers. Clear held keys on blur, pointer-lock loss, pause, and tab hiding so
|
|
|
404
402
|
movement stops even when the matching keyup never arrives. Remove listeners
|
|
405
403
|
when the engine unmounts.
|
|
406
404
|
|
|
407
|
-
###
|
|
405
|
+
### Moving and looking: `useWorldControls`
|
|
408
406
|
|
|
409
|
-
|
|
410
|
-
and nothing happens, or they don't know the mouse controls the camera at all.
|
|
411
|
-
**Always** show an explicit "Click to play" overlay with the control legend,
|
|
412
|
-
and only request lock from that click:
|
|
413
|
-
|
|
414
|
-
```jsx
|
|
415
|
-
{worldReady && !isTouch && !locked && (
|
|
416
|
-
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-background/80 backdrop-blur-sm">
|
|
417
|
-
<Card>
|
|
418
|
-
<Stack gap={3}>
|
|
419
|
-
<Heading level={2}>Voxel World</Heading>
|
|
420
|
-
<Button type="button" size="lg" onClick={() => engineRef.current?.requestLock()}>
|
|
421
|
-
Click to play
|
|
422
|
-
</Button>
|
|
423
|
-
{/* WASD move · Mouse look · Shift sprint · Space jump · Left-click break · Right-click place · F fly · 1–9 blocks */}
|
|
424
|
-
<Text size="sm" muted>Press Esc to release the mouse</Text>
|
|
425
|
-
</Stack>
|
|
426
|
-
</Card>
|
|
427
|
-
</div>
|
|
428
|
-
)}
|
|
429
|
-
```
|
|
407
|
+
Controls come from `useWorldControls`; voxel adds what the acts mean: a tap builds on the targeted block, a hold breaks it, the block chip and hotbar pick the material, G toggles flying, and a flick down sinks while flying.
|
|
430
408
|
|
|
431
|
-
|
|
432
|
-
|
|
409
|
+
The hook, its usage example and its desktop twin live in the `touch-games` skill (Walk + look);
|
|
410
|
+
load it beside this one. In a block world the hook's callbacks become:
|
|
433
411
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
```
|
|
412
|
+
- `onTap(x, y)` builds and `onHold(x, y)` breaks, through the DDA raycast below — from that point
|
|
413
|
+
(client coordinates) on a phone, from screen centre under the crosshair while the mouse is locked.
|
|
414
|
+
- `onAim(x, y)` moves the block highlight under a resting finger, and `onAim(null, null)` clears it.
|
|
415
|
+
- `onFlick("up")` jumps. G toggles `flying: true`; while flying, `onFlick("down")` sinks.
|
|
416
|
+
- `onLook(dx, dy)` and `onTurn(yaw, pitch)` turn the player, in radians. Clamp pitch to ±1.5 rad.
|
|
417
|
+
- `camera` is the player's `THREE.PerspectiveCamera`, so the lens widens on a phone held upright.
|
|
441
418
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
**
|
|
445
|
-
|
|
419
|
+
Pointer lock is the number-one confusion in browser voxel games: players click
|
|
420
|
+
and nothing happens, or they don't know the mouse controls the camera at all.
|
|
421
|
+
**Always** show an explicit "Click to play" overlay with the control legend,
|
|
422
|
+
and only request lock from that click: its button calls the hook's `requestLock()`, and the hook's
|
|
423
|
+
`locked` brings the overlay back when the player presses Esc. The overlay JSX is in `touch-games`;
|
|
424
|
+
the voxel legend reads "WASD move · Mouse look · Shift sprint · Space jump · Click build ·
|
|
425
|
+
Right-click break · G fly · 1–9 blocks".
|
|
446
426
|
|
|
447
427
|
### View switcher: see your own character
|
|
448
428
|
|
|
449
429
|
Include a view switcher between first-person and third-person so players can see their own avatar.
|
|
450
|
-
|
|
451
|
-
(e.g. `V`, shown in the control legend); toggle once per press,
|
|
452
|
-
repeat.
|
|
430
|
+
The 🎥 camera button outside the menu cycles **Free cam → First person → Shoulder view**, and a
|
|
431
|
+
keyboard shortcut (e.g. `V`, shown in the control legend) does the same; toggle once per press,
|
|
432
|
+
ignoring key repeat. On a phone the world opens in Free cam, an overview that frames the island;
|
|
433
|
+
back its radius off on a narrow screen as `touch-games` shows.
|
|
434
|
+
Render a simple blocky local-player avatar in third-person, following
|
|
453
435
|
the player's position and heading, and hide it in first-person so it does not
|
|
454
436
|
cover the camera. Keep the same player physics and position when switching:
|
|
455
437
|
offset the third-person camera behind the player and shorten that offset when
|
|
@@ -473,15 +455,15 @@ when needed. Remove the fullscreen listener on unmount.
|
|
|
473
455
|
### Full screen, with one menu button
|
|
474
456
|
|
|
475
457
|
The world fills the screen (the `CanvasStage` above is `absolute inset-0` inside a
|
|
476
|
-
`relative h-dvh w-full` root). Keep the crosshair, the
|
|
458
|
+
`relative h-dvh w-full` root). Keep the crosshair, the block chip (the material in hand; a tap
|
|
459
|
+
opens the palette), the hotbar (1–9 and 0 on a keyboard), undo and the 🎥 camera button on screen;
|
|
477
460
|
put the title, settings, crafting recipes, the world-reset control and the player list behind
|
|
478
461
|
**one** top-right menu button (`IconButton`) that toggles an overlay. The bottom-right corner
|
|
479
462
|
belongs to the platform. A smaller boxed world is only for when the person asks for one.
|
|
480
463
|
|
|
481
|
-
### No text selection
|
|
464
|
+
### No text selection on the game surface
|
|
482
465
|
|
|
483
|
-
The phone
|
|
484
|
-
live in the `touch-games` skill; load it beside this one.
|
|
466
|
+
The phone controls themselves come from `useWorldControls`, taught in the `touch-games` skill.
|
|
485
467
|
|
|
486
468
|
Two things separate "works on my laptop" from "works", and both are easy to
|
|
487
469
|
forget because the desktop build hides them:
|
|
@@ -490,8 +472,7 @@ forget because the desktop build hides them:
|
|
|
490
472
|
otherwise highlights the HUD text, fires iOS's selection callout, or scrolls
|
|
491
473
|
the page under the player. Put `select-none touch-none` (Tailwind for
|
|
492
474
|
`user-select: none` + `touch-action: none`) on the `<canvas>` **and** its
|
|
493
|
-
container, cover the WebKit-only bits
|
|
494
|
-
`mousedown` / `touchstart` you handle so a drag can never *begin* a selection:
|
|
475
|
+
container, and cover the WebKit-only bits:
|
|
495
476
|
|
|
496
477
|
```css
|
|
497
478
|
#world, #world * {
|
|
@@ -501,31 +482,8 @@ forget because the desktop build hides them:
|
|
|
501
482
|
}
|
|
502
483
|
```
|
|
503
484
|
|
|
504
|
-
- **Every
|
|
505
|
-
|
|
506
|
-
this is the part that bites — register them with `{ passive: false }`, or the
|
|
507
|
-
browser treats the listener as passive and your `preventDefault()` is ignored
|
|
508
|
-
(scroll/zoom win). A drag looks; a tap mines/places (raycast from the touch
|
|
509
|
-
point, or from screen-center under the crosshair). On-screen move/jump buttons
|
|
510
|
-
stand in for the keyboard.
|
|
511
|
-
|
|
512
|
-
```js
|
|
513
|
-
const opts = { passive: false }; // REQUIRED — a passive listener CANNOT preventDefault
|
|
514
|
-
// ALWAYS read `changedTouches[0]`, never `touches[0]`: at touchend/touchcancel
|
|
515
|
-
// `touches` is EMPTY (the finger lifted), so `touches[0]` is undefined and you
|
|
516
|
-
// lose the final tap coordinate. A TouchEvent also has no mouse `movementX`, so
|
|
517
|
-
// compute look deltas yourself from the last point rather than reusing the
|
|
518
|
-
// pointer-lock path. Handle touchcancel too (a call/notification interrupts).
|
|
519
|
-
let lx = 0, ly = 0;
|
|
520
|
-
const pt = (e) => e.changedTouches[0];
|
|
521
|
-
canvas.addEventListener("touchstart", (e) => { e.preventDefault(); const t = pt(e); lx = t.clientX; ly = t.clientY; onDown(t); }, opts);
|
|
522
|
-
canvas.addEventListener("touchmove", (e) => { e.preventDefault(); const t = pt(e); onLook(t.clientX - lx, t.clientY - ly); lx = t.clientX; ly = t.clientY; }, opts);
|
|
523
|
-
canvas.addEventListener("touchend", (e) => { e.preventDefault(); onUp(pt(e)); }, opts); // pt(e) = the finger that lifted; a tap here = mine/place
|
|
524
|
-
canvas.addEventListener("touchcancel",(e) => { e.preventDefault(); onCancel(); }, opts); // gesture interrupted — reset drag state
|
|
525
|
-
// Pointer Events (pointerdown/move/up + pointercancel) unify mouse + touch in
|
|
526
|
-
// one path and give you `movementX` under lock — often cleaner — but you STILL
|
|
527
|
-
// need `touch-action: none` above or the browser eats the drag.
|
|
528
|
-
```
|
|
485
|
+
- **Every act reaches a phone through the hook.** The tap, the hold, the stick and the look drag
|
|
486
|
+
arrive as `useWorldControls` callbacks, so one handler serves the mouse and the finger alike.
|
|
529
487
|
|
|
530
488
|
### Block targeting: a DDA voxel raycast, not `Raycaster`
|
|
531
489
|
|
|
@@ -542,8 +500,10 @@ function castTarget() {
|
|
|
542
500
|
}
|
|
543
501
|
```
|
|
544
502
|
|
|
545
|
-
|
|
546
|
-
|
|
503
|
+
On a desktop, a click under the crosshair is the tap and a right-click is the hold: the hook
|
|
504
|
+
reports both at screen centre while the mouse is locked (and cancels the context menu), so
|
|
505
|
+
`onTap` places `selected` on the entered face and `onHold` breaks the targeted block, the same
|
|
506
|
+
handlers a phone uses. **Guard placement** so a player
|
|
547
507
|
can never place a block inside their own AABB. Show a wireframe highlight box on
|
|
548
508
|
the current target each frame, and spawn a quick burst of small colored cubes
|
|
549
509
|
when a block breaks — cheap, and it makes mining feel real.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vibes.diy/prompts",
|
|
3
|
-
"version": "14.3.
|
|
3
|
+
"version": "14.3.172",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./index.js",
|
|
6
6
|
"exports": {
|
|
@@ -39,10 +39,10 @@
|
|
|
39
39
|
"license": "Apache-2.0",
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@adviser/cement": "~0.5.34",
|
|
42
|
-
"@vibes.diy/api-types": "14.3.
|
|
43
|
-
"@vibes.diy/call-ai-v2": "14.3.
|
|
44
|
-
"@vibes.diy/identity": "14.3.
|
|
45
|
-
"@vibes.diy/use-vibes-types": "14.3.
|
|
42
|
+
"@vibes.diy/api-types": "14.3.172",
|
|
43
|
+
"@vibes.diy/call-ai-v2": "14.3.172",
|
|
44
|
+
"@vibes.diy/identity": "14.3.172",
|
|
45
|
+
"@vibes.diy/use-vibes-types": "14.3.172",
|
|
46
46
|
"arktype": "~2.2.6",
|
|
47
47
|
"json-schema-faker": "~0.6.3"
|
|
48
48
|
},
|