@vibes.diy/prompts 14.3.168 → 14.3.173

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.
@@ -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
- 1. Open the Stripe dashboard and sign in (or create a Stripe account). Turn on
34
- **Test mode** until the app is working.
35
- 2. Go to Developers, then API keys, and reveal the **Secret key**. Set it on
36
- the vibe as `STRIPE_SECRET_KEY` — the vibe's settings page, or
37
- `vibes-diy secrets set STRIPE_SECRET_KEY <the key>`.
38
- 3. Go to Settings, then Billing, then **Customer portal**, and save it with
39
- **Cancel at end of billing period** on. The app sends members there to
40
- cancel or change a card, and the portal does not exist until it is saved
41
- once.
42
- 4. To go live, repeat steps 2 and 3 with Test mode off.
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 the client renders the numbered
51
- steps, naming the Stripe dashboard, `STRIPE_SECRET_KEY` and the Customer Portal
52
- in so many words. A visitor who is not the owner sees a calmer sentence —
53
- memberships are not open yet — never the steps. A portal that was never saved
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
- return json({ ok: true, configured: Boolean(ctx.secrets.STRIPE_SECRET_KEY), owner: me === ctx.appInfo.ownerHandle, graceDays: GRACE_DAYS });
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
- const SETUP_STEPS = [
512
- "Open the Stripe dashboard, turn on Test mode, and reveal the Secret key under Developers → API keys.",
513
- "Set it on this vibe as STRIPE_SECRET_KEY: the Settings page, or `vibes-diy secrets set STRIPE_SECRET_KEY`.",
514
- "Under Settings → Billing → Customer portal, save the portal with Cancel at end of billing period on.",
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
- <ol>{SETUP_STEPS.map((s) => <li key={s}>{s}</li>)}</ol>
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
- 1. Open the Stripe dashboard and sign in (or create a Stripe account).
44
- 2. Turn on **Test mode** (the toggle at the top) until the app is working.
45
- 3. Go to Developers, then API keys, and reveal the **Secret key** (`sk_test_…`
46
- in test mode, `sk_live_…` later).
47
- 4. Set it on the vibe as `STRIPE_SECRET_KEY` — the vibe's settings page, or
48
- `vibes-diy secrets set STRIPE_SECRET_KEY <the key>`.
49
- 5. To go live, repeat step 3 with Test mode off and set the live key the same way.
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 the client renders the numbered
58
- steps, naming the Stripe dashboard and `STRIPE_SECRET_KEY` in so many words so
59
- the owner knows where to go and what to call it. A visitor who is not the owner
60
- sees a calmer sentence — payments are not set up yet — never the steps.
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
- return json({ ok: true, configured: Boolean(ctx.secrets.STRIPE_SECRET_KEY), owner });
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
- const SETUP_STEPS = [
410
- "Open the Stripe dashboard and turn on Test mode.",
411
- "Go to Developers → API keys and reveal the Secret key (sk_test_…).",
412
- "Set it on this vibe as STRIPE_SECRET_KEY: the Settings page, or `vibes-diy secrets set STRIPE_SECRET_KEY`.",
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
- <ol>{SETUP_STEPS.map((s) => <li key={s}>{s}</li>)}</ol>
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 and every press of an on-screen
119
- button is a touch the browser would otherwise claim for itself: it selects
120
- the nearest text, pops the copy / look-up callout, scrolls the page, or zooms.
121
- Hand those touches to the scene:
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
- For a game's control scheme — swipes that fire mid-move, a ghost stick under the thumb,
172
- hold-to-boost, pull-and-release — load the `touch-games` skill beside this one.
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
- keys: {},
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
- const gameLoop = () => {
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
- updateGame();
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.camera || !state.renderer) return;
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 { keys, glider } = state;
1561
+ const { stick, held, glider } = state;
1562
+ state.tick(dt);
1599
1563
 
1600
- // Controls
1601
- if (keys["ArrowLeft"] || keys["KeyA"]) state.heading += 0.03;
1602
- if (keys["ArrowRight"] || keys["KeyD"]) state.heading -= 0.03;
1603
- if (keys["ArrowUp"] || keys["KeyW"]) state.pitch += 0.01;
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, handleKeyDown, handleKeyUp, handleResize]);
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 className="relative h-dvh w-full overflow-hidden bg-background text-foreground font-sans select-none touch-none">
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/Arrows: Fly, Space: Thrust
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)
@@ -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
@@ -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,+BAA+B;IAKjC,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;KACb;CACF,CAAC"}
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"}
@@ -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-lock look with a click-to-play overlay and touch drag, endless chunk " +
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,yFAAyF;QACzF,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"}
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. On
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
- ### Looking: pointer lock with a click-to-play overlay (+ touch drag)
405
+ ### Moving and looking: `useWorldControls`
408
406
 
409
- Pointer lock is the number-one confusion in browser voxel games: players click
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
- Drive the overlay off the real lock state so it reappears when the player hits
432
- Esc:
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
- ```js
435
- React.useEffect(() => {
436
- const onLock = () => setLocked(!!document.pointerLockElement);
437
- document.addEventListener("pointerlockchange", onLock);
438
- return () => document.removeEventListener("pointerlockchange", onLock);
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
- In the engine, read `movementX/Y` when `document.pointerLockElement === canvas`
443
- (clamp per-event deltas — browsers spike them on lock acquire), and fall back to
444
- **drag-to-look on touch** so phones work without pointer lock. Clamp pitch to
445
- ±1.5 rad. Provide on-screen move/jump/place/break buttons for touch.
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
- Provide a labeled HUD button that works on touch and a keyboard shortcut
451
- (e.g. `V`, shown in the control legend); toggle once per press, ignoring key
452
- repeat. Render a simple blocky local-player avatar in third-person, following
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 hotbar and the touch controls on screen;
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, and bind touch as well as mouse
464
+ ### No text selection on the game surface
482
465
 
483
- The phone control schemes themselves (swipes, a ghost stick under the thumb, hold-to-move)
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, and `preventDefault()` on every
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 mouse binding needs a touch twin, or the game is dead on a phone.**
505
- Add `touchstart` / `touchmove` / `touchend` next to your mouse handlers, and —
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
- Left-click breaks the targeted block; right-click (`contextmenu`, prevent
546
- default) places `selected` on the entered face. **Guard placement** so a player
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.168",
3
+ "version": "14.3.173",
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.168",
43
- "@vibes.diy/call-ai-v2": "14.3.168",
44
- "@vibes.diy/identity": "14.3.168",
45
- "@vibes.diy/use-vibes-types": "14.3.168",
42
+ "@vibes.diy/api-types": "14.3.173",
43
+ "@vibes.diy/call-ai-v2": "14.3.173",
44
+ "@vibes.diy/identity": "14.3.173",
45
+ "@vibes.diy/use-vibes-types": "14.3.173",
46
46
  "arktype": "~2.2.6",
47
47
  "json-schema-faker": "~0.6.3"
48
48
  },