@nano133/nanoterra 0.12.0 → 0.13.0
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/CHANGELOG.md +11 -0
- package/README.md +26 -1
- package/dist/input/index.d.ts +2 -1
- package/dist/input/index.js +3 -0
- package/dist/input/zoom.d.ts +81 -0
- package/dist/input/zoom.js +140 -0
- package/package.json +1 -1
- package/src/input/index.ts +5 -0
- package/src/input/zoom.ts +160 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.13.0 (2026-11-01)
|
|
4
|
+
|
|
5
|
+
- **The camera's distance on one curve (`createZoom`, `listenZoom` in `@nano133/nanoterra/input`):** one number, the camera's distance from the eyes, moved by
|
|
6
|
+
the mouse wheel, a trackpad's scroll or pinch, a touch pinch and the + and − keys, with nano133.com's island feel. A notch moves `0.35 + 0.14 × distance`
|
|
7
|
+
metres (100 px of scroll, a line 16 px, a page 400 px, a trackpad pinch five times a scroll), and every bit of a scroll counts: Echo Island's dead zone and
|
|
8
|
+
speed limit dropped most of a quick spin, and its wheel needed about five times the spins. The last notch in goes to the eyes, the first notch out past the
|
|
9
|
+
switch. The touch pinch is the same curve as a ratio: twice the spread divides `distance + 2.5` by 2^1.3 (`pinchStart`, `pinch`, `pinchEnd`; the game wires
|
|
10
|
+
the fingers). `createZoom(range, { read, write })` keeps the distance in the game's own storage (`read` at first use, never on the server), eases it
|
|
11
|
+
(`step(dt, calm)`, at once with reduced motion), and has `set`, `by`, `eyes` and `subscribe`. `listenZoom(zoom, canvas, { skip })` stops the page's scroll
|
|
12
|
+
over the canvas and leaves the browser's zoom keys and a field alone. The pure parts (`wheelNotches`, `zoomedBy`, `pinchedTo`) are exported. About 1.0 kB gzip.
|
|
13
|
+
|
|
3
14
|
## 0.12.0 (2026-11-01)
|
|
4
15
|
|
|
5
16
|
- **A passkey for the owner (`createAdminSignin({ passkeys: true })`): the owner's LINKED passkeys open /admin.** A passkey is only a key to /admin; the money stays
|
package/README.md
CHANGED
|
@@ -69,7 +69,7 @@ Each path loads only what it needs. A page that reads a ticket loads no Three.js
|
|
|
69
69
|
| `@nano133/nanoterra/looks` | the looks ladder | no | 1.9 kB (all), 0.9 kB (`checkLooks` alone) |
|
|
70
70
|
| `@nano133/nanoterra/character` | the builder and `createCharacter` (it includes the record), with the three sockets | yes | 21.8 kB (all, with the sockets and `createLookPreview` of 0.3.0; 20.4 kB on 0.2.0), 17.8 kB (`createCharacter` alone, measured on 0.2.0) |
|
|
71
71
|
| `@nano133/nanoterra/model` | the 3D model check: a `.glb` against the island's limits or a cosmetic's (since 0.3.0) | no | 8.8 kB |
|
|
72
|
-
| `@nano133/nanoterra/input` | let go of held keys and sticks when the player leaves the page, and pause: `releaseWhenLost`, `pauseWhenLeft`, `swallowsKeyup` (since 0.3.1) | no | 0.5 kB |
|
|
72
|
+
| `@nano133/nanoterra/input` | let go of held keys and sticks when the player leaves the page, and pause: `releaseWhenLost`, `pauseWhenLeft`, `swallowsKeyup` (since 0.3.1); the camera's distance on one curve: `createZoom`, `listenZoom` (since 0.13.0) | no | 0.5 kB, 1.0 kB more with the zoom |
|
|
73
73
|
| `@nano133/nanoterra/admin` | the admin of a game: the guard, the handler, the store type (server code; since 0.2.0) | no | 3.7 kB |
|
|
74
74
|
| `@nano133/nanoterra/admin/d1`, `/admin/firestore` | the admin store for D1 (the starter's tables) or Firestore (a small interface, no Firebase dependency): `createD1Store`, `createFirestoreStore` (since 0.2.0) | no | 1.5 kB each |
|
|
75
75
|
| `@nano133/nanoterra/admin/panel` | the admin page, drawn in the browser into one element, and its stylesheet `@nano133/nanoterra/admin/style.css` (since 0.2.0) | no | 18.9 kB with the tabbed layout (10.6 kB on 0.3.0, 8.5 kB on 0.2.0) + 5.9 kB of CSS (minified) |
|
|
@@ -291,6 +291,31 @@ pauseWhenLeft(() => { game.paused = true; resumeCard.hidden = false; });
|
|
|
291
291
|
- It returns the function that takes its listeners away. It names no package and reads only plain browser events. The same code runs in nano133.com's island and rooms,
|
|
292
292
|
and in the [game starter](https://github.com/nano133-com/nano-game-starter).
|
|
293
293
|
|
|
294
|
+
## The camera's distance (`@nano133/nanoterra/input`; since 0.13.0)
|
|
295
|
+
|
|
296
|
+
One number, the camera's distance from the eyes, moved by the mouse wheel, a trackpad's two-finger scroll or pinch, a two-finger pinch on a touch screen
|
|
297
|
+
and the + and − keys, along ONE curve: nano133.com's island feel. Each notch moves `0.35 + 0.14 × distance` metres, so a notch feels the same near and
|
|
298
|
+
far, and every bit of a scroll counts (Echo Island's wheel had a dead zone and a speed limit, dropped most of a quick spin and needed about five times the
|
|
299
|
+
spins). The last notch in goes to the eyes and the first notch out goes past the switch, so the camera never rests half inside the head.
|
|
300
|
+
|
|
301
|
+
```ts
|
|
302
|
+
import { createZoom, listenZoom } from "@nano133/nanoterra/input";
|
|
303
|
+
|
|
304
|
+
const zoom = createZoom({ max: 10, start: 5.2, first: 0.6 }, { read: () => saved(), write: (d) => save(d) });
|
|
305
|
+
listenZoom(zoom, renderer.domElement, { skip: () => paused }); // the wheel over the canvas, + and − on the window
|
|
306
|
+
// each frame: the distance eases to the one asked for (at once with reduced motion)
|
|
307
|
+
const dist = zoom.step(dt, lessMotion);
|
|
308
|
+
const firstPerson = zoom.eyes();
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
- `read` is called the first time the distance is asked for (never on the server); `write` on every change: put a delay in it if your storage is slow.
|
|
312
|
+
- `listenZoom` stops the page's scroll and zoom over the canvas. A key with ctrl, alt or the command key (the browser's zoom) and a key typed in a field are
|
|
313
|
+
left alone; `skip(e)` keeps any other event from the zoom (a key the player is binding).
|
|
314
|
+
- The touch pinch is yours to wire, because the fingers are also your stick and your look: `pinchStart(spread)` when the second finger lands, `pinch(spread)`
|
|
315
|
+
as they move, `pinchEnd()`. Twice the spread divides `distance + 2.5` by 2^1.3, wherever the camera is: the wheel's curve.
|
|
316
|
+
- `set(d)` for a setting (a slider, a First person card), `subscribe(on)` for a menu that shows the distance. The pure parts (`wheelNotches`, `zoomedBy`,
|
|
317
|
+
`pinchedTo`) are there for a test.
|
|
318
|
+
|
|
294
319
|
## The sign-in ticket
|
|
295
320
|
|
|
296
321
|
`@nano133/nanoterra/ticket` reads and checks a sign-in ticket in the player's browser. It is for a game on a **static site**: no server, no secret.
|
package/dist/input/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export { createZoom, listenZoom, pinchedTo, wheelNotches, zoomedBy, PINCH, ZOOM_CURVE, ZOOM_EASE, ZOOM_STEP } from "./zoom.js";
|
|
2
|
+
export type { Zoom, ZoomRange } from "./zoom.js";
|
|
1
3
|
type Listens = Pick<EventTarget, "addEventListener" | "removeEventListener">;
|
|
2
4
|
/**
|
|
3
5
|
* Calls `release` each time what is held may be wrong. In it, clear every held key, thumb stick and drag, and any jump or tool that a key press queued.
|
|
@@ -29,4 +31,3 @@ export declare function pauseWhenLeft(pause: () => void, win?: Listens, doc?: Li
|
|
|
29
31
|
hidden?: boolean;
|
|
30
32
|
hasFocus?: () => boolean;
|
|
31
33
|
}): () => void;
|
|
32
|
-
export {};
|
package/dist/input/index.js
CHANGED
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
// calls your `pause` when the player really left (a free round stops its clock and shows a Resume key). No package: plain browser events.
|
|
17
17
|
//
|
|
18
18
|
// A ROUND THAT COSTS MONEY must not pause: its deadline must not move. Keep `releaseWhenLost` (the character always stops) and leave `pauseWhenLeft` out.
|
|
19
|
+
//
|
|
20
|
+
// The camera's distance (the wheel, a pinch, the + and − keys, one curve) is `createZoom` and `listenZoom`, in ./zoom.ts.
|
|
21
|
+
export { createZoom, listenZoom, pinchedTo, wheelNotches, zoomedBy, PINCH, ZOOM_CURVE, ZOOM_EASE, ZOOM_STEP } from "./zoom.js";
|
|
19
22
|
/**
|
|
20
23
|
* Calls `release` each time what is held may be wrong. In it, clear every held key, thumb stick and drag, and any jump or tool that a key press queued.
|
|
21
24
|
* `keysOnly`, when given, is called when a menu opens (a right click): a menu takes a keyup, but on a touch screen a long press also asks for a menu,
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** Where the camera may be (metres from the eyes): the farthest, the default, and the switch: nearer than `first` it is in the eyes. */
|
|
2
|
+
export type ZoomRange = {
|
|
3
|
+
max: number;
|
|
4
|
+
start: number;
|
|
5
|
+
first: number;
|
|
6
|
+
};
|
|
7
|
+
/** One notch: about one mouse-wheel click (100 px of scroll). The + and − keys move `key` notches. */
|
|
8
|
+
export declare const ZOOM_STEP: {
|
|
9
|
+
readonly perPx: 0.006;
|
|
10
|
+
readonly perPxPinch: 0.03;
|
|
11
|
+
readonly linePx: 16;
|
|
12
|
+
readonly pagePx: 400;
|
|
13
|
+
readonly key: 1.2;
|
|
14
|
+
};
|
|
15
|
+
/** The curve: a notch moves `base + scale × distance` metres. */
|
|
16
|
+
export declare const ZOOM_CURVE: {
|
|
17
|
+
readonly base: 0.35;
|
|
18
|
+
readonly scale: 0.14;
|
|
19
|
+
};
|
|
20
|
+
/** The touch pinch: twice the spread divides `distance + base/scale` by 2^power. */
|
|
21
|
+
export declare const PINCH: {
|
|
22
|
+
readonly power: 1.3;
|
|
23
|
+
};
|
|
24
|
+
/** How fast the camera eases to the distance asked for (1/s). */
|
|
25
|
+
export declare const ZOOM_EASE = 9;
|
|
26
|
+
/** One wheel event as notches (negative: in). A mouse notch arrives in lines or about 100 px, a trackpad in many small pixels, a pinch as a wheel with the ctrl key. */
|
|
27
|
+
export declare function wheelNotches(e: {
|
|
28
|
+
deltaY: number;
|
|
29
|
+
deltaMode?: number;
|
|
30
|
+
ctrlKey?: boolean;
|
|
31
|
+
}): number;
|
|
32
|
+
/** The distance after `notches` (negative: in) from `want`. */
|
|
33
|
+
export declare function zoomedBy(r: ZoomRange, want: number, notches: number): number;
|
|
34
|
+
/** The distance during a touch pinch: `from` at the start, the fingers `spread0` apart then and `spread` now (px). Apart brings it in. */
|
|
35
|
+
export declare function pinchedTo(r: ZoomRange, from: number, spread0: number, spread: number): number;
|
|
36
|
+
export type Zoom = {
|
|
37
|
+
/** The distance asked for (metres from the eyes). */
|
|
38
|
+
want(): number;
|
|
39
|
+
/** The distance now, as the last `step` eased it. */
|
|
40
|
+
now(): number;
|
|
41
|
+
/** True while the camera is in the eyes (the distance now below `first`). */
|
|
42
|
+
eyes(): boolean;
|
|
43
|
+
/** Asks for a distance (clamped), writes it and tells the listeners. */
|
|
44
|
+
set(d: number): void;
|
|
45
|
+
/** Moves the distance by notches (negative: in), along the curve. */
|
|
46
|
+
by(notches: number): void;
|
|
47
|
+
/** A wheel event (or one like it) moves the distance. */
|
|
48
|
+
wheel(e: {
|
|
49
|
+
deltaY: number;
|
|
50
|
+
deltaMode?: number;
|
|
51
|
+
ctrlKey?: boolean;
|
|
52
|
+
}): void;
|
|
53
|
+
/** A touch pinch starts with the fingers `spread` apart (px); `pinch` follows it; `pinchEnd` ends it. */
|
|
54
|
+
pinchStart(spread: number): void;
|
|
55
|
+
pinch(spread: number): void;
|
|
56
|
+
pinchEnd(): void;
|
|
57
|
+
/** One frame: the distance eases to the one asked for (at once when `calm`, reduced motion). Returns it. */
|
|
58
|
+
step(dt: number, calm: boolean): number;
|
|
59
|
+
/** Calls `on` when the distance asked for changes. Returns the function that stops it. */
|
|
60
|
+
subscribe(on: () => void): () => void;
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* The camera's distance of one game. `read` gives the kept distance (null: none, the default `start`), and is read the first time the distance is
|
|
64
|
+
* asked for, so the server never calls it; `write` keeps a new one (on every change: put a delay in it if your storage is slow).
|
|
65
|
+
*/
|
|
66
|
+
export declare function createZoom(r: ZoomRange, keep?: {
|
|
67
|
+
read?: () => number | null;
|
|
68
|
+
write?: (d: number) => void;
|
|
69
|
+
}): Zoom;
|
|
70
|
+
type Listens = Pick<EventTarget, "addEventListener" | "removeEventListener">;
|
|
71
|
+
/**
|
|
72
|
+
* The wheel over `el` (the game's canvas: the page does not scroll or zoom there) and the + and − keys on the window (also the number pad's) move
|
|
73
|
+
* `zoom`. A key with ctrl, alt or the command key is the browser's own zoom and is left alone, and so is a key typed in a field. `skip(e)` returns true
|
|
74
|
+
* for an event the game wants to keep from the zoom (a key the player is binding, a game that is paused). Returns the function that takes the listeners away.
|
|
75
|
+
* The touch pinch is yours to wire (`pinchStart`, `pinch`, `pinchEnd`), because the fingers are also your thumb stick and your look.
|
|
76
|
+
*/
|
|
77
|
+
export declare function listenZoom(zoom: Zoom, el: Listens, opts?: {
|
|
78
|
+
skip?: (e: Event) => boolean;
|
|
79
|
+
keys?: boolean;
|
|
80
|
+
}, win?: Listens): () => void;
|
|
81
|
+
export {};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// The camera's distance for a game the player walks in: the mouse wheel, a trackpad's two-finger scroll or pinch, a two-finger pinch on a touch
|
|
2
|
+
// screen, and the + and − keys move one number, the camera's distance from the eyes, along ONE curve. 0 is first person; below `first` the camera
|
|
3
|
+
// is in the eyes. It eases to the distance asked for (at once with reduced motion). No package: plain browser events.
|
|
4
|
+
//
|
|
5
|
+
// THE FEEL is nano133.com's island (app/island/zoom.ts). Each notch moves the distance in proportion to it (`0.35 + 0.14 × distance` metres a
|
|
6
|
+
// notch), so a notch feels the same near and far. Every bit of a scroll counts: a dead zone and a speed limit dropped most of a quick spin in
|
|
7
|
+
// Echo Island, and its wheel needed about five times the spins of the island's (Wes, 2026-11-01). Close in, the last notch goes all the way to
|
|
8
|
+
// the eyes; out of the eyes, the first notch goes past the switch, so the camera never rests half inside the head.
|
|
9
|
+
//
|
|
10
|
+
// THE PINCH follows the same curve: the curve is a constant ratio of `distance + 2.5`, so spreading the fingers to twice their start divides it
|
|
11
|
+
// by 2^PINCH.power, wherever the camera is.
|
|
12
|
+
/** One notch: about one mouse-wheel click (100 px of scroll). The + and − keys move `key` notches. */
|
|
13
|
+
export const ZOOM_STEP = { perPx: 0.006, perPxPinch: 0.03, linePx: 16, pagePx: 400, key: 1.2 };
|
|
14
|
+
/** The curve: a notch moves `base + scale × distance` metres. */
|
|
15
|
+
export const ZOOM_CURVE = { base: 0.35, scale: 0.14 };
|
|
16
|
+
/** The touch pinch: twice the spread divides `distance + base/scale` by 2^power. */
|
|
17
|
+
export const PINCH = { power: 1.3 };
|
|
18
|
+
/** How fast the camera eases to the distance asked for (1/s). */
|
|
19
|
+
export const ZOOM_EASE = 9;
|
|
20
|
+
const clamp = (r, d) => Math.min(r.max, Math.max(0, d));
|
|
21
|
+
/** One wheel event as notches (negative: in). A mouse notch arrives in lines or about 100 px, a trackpad in many small pixels, a pinch as a wheel with the ctrl key. */
|
|
22
|
+
export function wheelNotches(e) {
|
|
23
|
+
const px = e.deltaMode === 1 ? e.deltaY * ZOOM_STEP.linePx : e.deltaMode === 2 ? e.deltaY * ZOOM_STEP.pagePx : e.deltaY;
|
|
24
|
+
return px * (e.ctrlKey ? ZOOM_STEP.perPxPinch : ZOOM_STEP.perPx);
|
|
25
|
+
}
|
|
26
|
+
/** The snap: close in, a move into the switch goes to the eyes; out of the eyes, a move out goes past the switch. */
|
|
27
|
+
function snapped(r, from, to) {
|
|
28
|
+
if (to < from && to < r.first)
|
|
29
|
+
return 0;
|
|
30
|
+
if (to > from && from < r.first)
|
|
31
|
+
return clamp(r, Math.max(to, r.first + 1));
|
|
32
|
+
return clamp(r, to);
|
|
33
|
+
}
|
|
34
|
+
/** The distance after `notches` (negative: in) from `want`. */
|
|
35
|
+
export function zoomedBy(r, want, notches) {
|
|
36
|
+
return snapped(r, want, want + notches * (ZOOM_CURVE.base + want * ZOOM_CURVE.scale));
|
|
37
|
+
}
|
|
38
|
+
/** The distance during a touch pinch: `from` at the start, the fingers `spread0` apart then and `spread` now (px). Apart brings it in. */
|
|
39
|
+
export function pinchedTo(r, from, spread0, spread) {
|
|
40
|
+
if (!(spread0 > 0) || !(spread > 0))
|
|
41
|
+
return from;
|
|
42
|
+
const k = ZOOM_CURVE.base / ZOOM_CURVE.scale;
|
|
43
|
+
return snapped(r, from, (from + k) * (spread0 / spread) ** PINCH.power - k);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The camera's distance of one game. `read` gives the kept distance (null: none, the default `start`), and is read the first time the distance is
|
|
47
|
+
* asked for, so the server never calls it; `write` keeps a new one (on every change: put a delay in it if your storage is slow).
|
|
48
|
+
*/
|
|
49
|
+
export function createZoom(r, keep = {}) {
|
|
50
|
+
let want = r.start;
|
|
51
|
+
let now = r.start;
|
|
52
|
+
let loaded = false;
|
|
53
|
+
let pinchFrom = null;
|
|
54
|
+
const listeners = new Set();
|
|
55
|
+
const load = () => {
|
|
56
|
+
if (loaded)
|
|
57
|
+
return;
|
|
58
|
+
loaded = true;
|
|
59
|
+
const v = keep.read?.();
|
|
60
|
+
if (typeof v === "number" && Number.isFinite(v))
|
|
61
|
+
want = now = clamp(r, v);
|
|
62
|
+
};
|
|
63
|
+
const set = (d) => {
|
|
64
|
+
load();
|
|
65
|
+
want = clamp(r, d);
|
|
66
|
+
keep.write?.(want);
|
|
67
|
+
for (const on of listeners)
|
|
68
|
+
on();
|
|
69
|
+
};
|
|
70
|
+
const by = (notches) => {
|
|
71
|
+
load();
|
|
72
|
+
if (notches !== 0)
|
|
73
|
+
set(zoomedBy(r, want, notches));
|
|
74
|
+
};
|
|
75
|
+
return {
|
|
76
|
+
want: () => (load(), want),
|
|
77
|
+
now: () => (load(), now),
|
|
78
|
+
eyes: () => (load(), now < r.first),
|
|
79
|
+
set,
|
|
80
|
+
by,
|
|
81
|
+
wheel: (e) => by(wheelNotches(e)),
|
|
82
|
+
pinchStart(spread) {
|
|
83
|
+
load();
|
|
84
|
+
pinchFrom = { d: want, spread };
|
|
85
|
+
},
|
|
86
|
+
pinch(spread) {
|
|
87
|
+
if (pinchFrom)
|
|
88
|
+
set(pinchedTo(r, pinchFrom.d, pinchFrom.spread, spread));
|
|
89
|
+
},
|
|
90
|
+
pinchEnd() {
|
|
91
|
+
pinchFrom = null;
|
|
92
|
+
},
|
|
93
|
+
step(dt, calm) {
|
|
94
|
+
load();
|
|
95
|
+
now = calm ? want : now + (want - now) * (1 - Math.exp(-dt * ZOOM_EASE));
|
|
96
|
+
if (Math.abs(now - want) < 0.002)
|
|
97
|
+
now = want;
|
|
98
|
+
return now;
|
|
99
|
+
},
|
|
100
|
+
subscribe(on) {
|
|
101
|
+
listeners.add(on);
|
|
102
|
+
return () => void listeners.delete(on);
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The wheel over `el` (the game's canvas: the page does not scroll or zoom there) and the + and − keys on the window (also the number pad's) move
|
|
108
|
+
* `zoom`. A key with ctrl, alt or the command key is the browser's own zoom and is left alone, and so is a key typed in a field. `skip(e)` returns true
|
|
109
|
+
* for an event the game wants to keep from the zoom (a key the player is binding, a game that is paused). Returns the function that takes the listeners away.
|
|
110
|
+
* The touch pinch is yours to wire (`pinchStart`, `pinch`, `pinchEnd`), because the fingers are also your thumb stick and your look.
|
|
111
|
+
*/
|
|
112
|
+
export function listenZoom(zoom, el, opts = {}, win = window) {
|
|
113
|
+
const wheel = (e) => {
|
|
114
|
+
e.preventDefault();
|
|
115
|
+
if (opts.skip?.(e))
|
|
116
|
+
return;
|
|
117
|
+
zoom.wheel(e);
|
|
118
|
+
};
|
|
119
|
+
const key = (e) => {
|
|
120
|
+
const k = e;
|
|
121
|
+
if (k.metaKey || k.altKey || k.ctrlKey || opts.skip?.(e))
|
|
122
|
+
return;
|
|
123
|
+
const t = k.target;
|
|
124
|
+
if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName ?? "")))
|
|
125
|
+
return;
|
|
126
|
+
if (k.code === "Equal" || k.code === "NumpadAdd")
|
|
127
|
+
zoom.by(-ZOOM_STEP.key);
|
|
128
|
+
else if (k.code === "Minus" || k.code === "NumpadSubtract")
|
|
129
|
+
zoom.by(ZOOM_STEP.key);
|
|
130
|
+
};
|
|
131
|
+
el.addEventListener("wheel", wheel, { passive: false });
|
|
132
|
+
const keys = opts.keys !== false;
|
|
133
|
+
if (keys)
|
|
134
|
+
win.addEventListener("keydown", key);
|
|
135
|
+
return () => {
|
|
136
|
+
el.removeEventListener("wheel", wheel);
|
|
137
|
+
if (keys)
|
|
138
|
+
win.removeEventListener("keydown", key);
|
|
139
|
+
};
|
|
140
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nano133/nanoterra",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Nanoterra for a creator's game, in one package: the player's sign-in ticket, the player's character (a toon body on the island's skeleton), the looks ladder, the admin and its wallet sign-in, the friends list, an options menu, emotes, the player's sign-in (a passkey or a wallet app) and a shop that sells looks for Nano with no key on the server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
package/src/input/index.ts
CHANGED
|
@@ -16,6 +16,11 @@
|
|
|
16
16
|
// calls your `pause` when the player really left (a free round stops its clock and shows a Resume key). No package: plain browser events.
|
|
17
17
|
//
|
|
18
18
|
// A ROUND THAT COSTS MONEY must not pause: its deadline must not move. Keep `releaseWhenLost` (the character always stops) and leave `pauseWhenLeft` out.
|
|
19
|
+
//
|
|
20
|
+
// The camera's distance (the wheel, a pinch, the + and − keys, one curve) is `createZoom` and `listenZoom`, in ./zoom.ts.
|
|
21
|
+
|
|
22
|
+
export { createZoom, listenZoom, pinchedTo, wheelNotches, zoomedBy, PINCH, ZOOM_CURVE, ZOOM_EASE, ZOOM_STEP } from "./zoom.js";
|
|
23
|
+
export type { Zoom, ZoomRange } from "./zoom.js";
|
|
19
24
|
|
|
20
25
|
type Listens = Pick<EventTarget, "addEventListener" | "removeEventListener">;
|
|
21
26
|
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// The camera's distance for a game the player walks in: the mouse wheel, a trackpad's two-finger scroll or pinch, a two-finger pinch on a touch
|
|
2
|
+
// screen, and the + and − keys move one number, the camera's distance from the eyes, along ONE curve. 0 is first person; below `first` the camera
|
|
3
|
+
// is in the eyes. It eases to the distance asked for (at once with reduced motion). No package: plain browser events.
|
|
4
|
+
//
|
|
5
|
+
// THE FEEL is nano133.com's island (app/island/zoom.ts). Each notch moves the distance in proportion to it (`0.35 + 0.14 × distance` metres a
|
|
6
|
+
// notch), so a notch feels the same near and far. Every bit of a scroll counts: a dead zone and a speed limit dropped most of a quick spin in
|
|
7
|
+
// Echo Island, and its wheel needed about five times the spins of the island's (Wes, 2026-11-01). Close in, the last notch goes all the way to
|
|
8
|
+
// the eyes; out of the eyes, the first notch goes past the switch, so the camera never rests half inside the head.
|
|
9
|
+
//
|
|
10
|
+
// THE PINCH follows the same curve: the curve is a constant ratio of `distance + 2.5`, so spreading the fingers to twice their start divides it
|
|
11
|
+
// by 2^PINCH.power, wherever the camera is.
|
|
12
|
+
|
|
13
|
+
/** Where the camera may be (metres from the eyes): the farthest, the default, and the switch: nearer than `first` it is in the eyes. */
|
|
14
|
+
export type ZoomRange = { max: number; start: number; first: number };
|
|
15
|
+
|
|
16
|
+
/** One notch: about one mouse-wheel click (100 px of scroll). The + and − keys move `key` notches. */
|
|
17
|
+
export const ZOOM_STEP = { perPx: 0.006, perPxPinch: 0.03, linePx: 16, pagePx: 400, key: 1.2 } as const;
|
|
18
|
+
/** The curve: a notch moves `base + scale × distance` metres. */
|
|
19
|
+
export const ZOOM_CURVE = { base: 0.35, scale: 0.14 } as const;
|
|
20
|
+
/** The touch pinch: twice the spread divides `distance + base/scale` by 2^power. */
|
|
21
|
+
export const PINCH = { power: 1.3 } as const;
|
|
22
|
+
/** How fast the camera eases to the distance asked for (1/s). */
|
|
23
|
+
export const ZOOM_EASE = 9;
|
|
24
|
+
|
|
25
|
+
const clamp = (r: ZoomRange, d: number) => Math.min(r.max, Math.max(0, d));
|
|
26
|
+
|
|
27
|
+
/** One wheel event as notches (negative: in). A mouse notch arrives in lines or about 100 px, a trackpad in many small pixels, a pinch as a wheel with the ctrl key. */
|
|
28
|
+
export function wheelNotches(e: { deltaY: number; deltaMode?: number; ctrlKey?: boolean }): number {
|
|
29
|
+
const px = e.deltaMode === 1 ? e.deltaY * ZOOM_STEP.linePx : e.deltaMode === 2 ? e.deltaY * ZOOM_STEP.pagePx : e.deltaY;
|
|
30
|
+
return px * (e.ctrlKey ? ZOOM_STEP.perPxPinch : ZOOM_STEP.perPx);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The snap: close in, a move into the switch goes to the eyes; out of the eyes, a move out goes past the switch. */
|
|
34
|
+
function snapped(r: ZoomRange, from: number, to: number): number {
|
|
35
|
+
if (to < from && to < r.first) return 0;
|
|
36
|
+
if (to > from && from < r.first) return clamp(r, Math.max(to, r.first + 1));
|
|
37
|
+
return clamp(r, to);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The distance after `notches` (negative: in) from `want`. */
|
|
41
|
+
export function zoomedBy(r: ZoomRange, want: number, notches: number): number {
|
|
42
|
+
return snapped(r, want, want + notches * (ZOOM_CURVE.base + want * ZOOM_CURVE.scale));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The distance during a touch pinch: `from` at the start, the fingers `spread0` apart then and `spread` now (px). Apart brings it in. */
|
|
46
|
+
export function pinchedTo(r: ZoomRange, from: number, spread0: number, spread: number): number {
|
|
47
|
+
if (!(spread0 > 0) || !(spread > 0)) return from;
|
|
48
|
+
const k = ZOOM_CURVE.base / ZOOM_CURVE.scale;
|
|
49
|
+
return snapped(r, from, (from + k) * (spread0 / spread) ** PINCH.power - k);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type Zoom = {
|
|
53
|
+
/** The distance asked for (metres from the eyes). */
|
|
54
|
+
want(): number;
|
|
55
|
+
/** The distance now, as the last `step` eased it. */
|
|
56
|
+
now(): number;
|
|
57
|
+
/** True while the camera is in the eyes (the distance now below `first`). */
|
|
58
|
+
eyes(): boolean;
|
|
59
|
+
/** Asks for a distance (clamped), writes it and tells the listeners. */
|
|
60
|
+
set(d: number): void;
|
|
61
|
+
/** Moves the distance by notches (negative: in), along the curve. */
|
|
62
|
+
by(notches: number): void;
|
|
63
|
+
/** A wheel event (or one like it) moves the distance. */
|
|
64
|
+
wheel(e: { deltaY: number; deltaMode?: number; ctrlKey?: boolean }): void;
|
|
65
|
+
/** A touch pinch starts with the fingers `spread` apart (px); `pinch` follows it; `pinchEnd` ends it. */
|
|
66
|
+
pinchStart(spread: number): void;
|
|
67
|
+
pinch(spread: number): void;
|
|
68
|
+
pinchEnd(): void;
|
|
69
|
+
/** One frame: the distance eases to the one asked for (at once when `calm`, reduced motion). Returns it. */
|
|
70
|
+
step(dt: number, calm: boolean): number;
|
|
71
|
+
/** Calls `on` when the distance asked for changes. Returns the function that stops it. */
|
|
72
|
+
subscribe(on: () => void): () => void;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The camera's distance of one game. `read` gives the kept distance (null: none, the default `start`), and is read the first time the distance is
|
|
77
|
+
* asked for, so the server never calls it; `write` keeps a new one (on every change: put a delay in it if your storage is slow).
|
|
78
|
+
*/
|
|
79
|
+
export function createZoom(r: ZoomRange, keep: { read?: () => number | null; write?: (d: number) => void } = {}): Zoom {
|
|
80
|
+
let want = r.start;
|
|
81
|
+
let now = r.start;
|
|
82
|
+
let loaded = false;
|
|
83
|
+
let pinchFrom: { d: number; spread: number } | null = null;
|
|
84
|
+
const listeners = new Set<() => void>();
|
|
85
|
+
const load = () => {
|
|
86
|
+
if (loaded) return;
|
|
87
|
+
loaded = true;
|
|
88
|
+
const v = keep.read?.();
|
|
89
|
+
if (typeof v === "number" && Number.isFinite(v)) want = now = clamp(r, v);
|
|
90
|
+
};
|
|
91
|
+
const set = (d: number) => {
|
|
92
|
+
load();
|
|
93
|
+
want = clamp(r, d);
|
|
94
|
+
keep.write?.(want);
|
|
95
|
+
for (const on of listeners) on();
|
|
96
|
+
};
|
|
97
|
+
const by = (notches: number) => {
|
|
98
|
+
load();
|
|
99
|
+
if (notches !== 0) set(zoomedBy(r, want, notches));
|
|
100
|
+
};
|
|
101
|
+
return {
|
|
102
|
+
want: () => (load(), want),
|
|
103
|
+
now: () => (load(), now),
|
|
104
|
+
eyes: () => (load(), now < r.first),
|
|
105
|
+
set,
|
|
106
|
+
by,
|
|
107
|
+
wheel: (e) => by(wheelNotches(e)),
|
|
108
|
+
pinchStart(spread) {
|
|
109
|
+
load();
|
|
110
|
+
pinchFrom = { d: want, spread };
|
|
111
|
+
},
|
|
112
|
+
pinch(spread) {
|
|
113
|
+
if (pinchFrom) set(pinchedTo(r, pinchFrom.d, pinchFrom.spread, spread));
|
|
114
|
+
},
|
|
115
|
+
pinchEnd() {
|
|
116
|
+
pinchFrom = null;
|
|
117
|
+
},
|
|
118
|
+
step(dt, calm) {
|
|
119
|
+
load();
|
|
120
|
+
now = calm ? want : now + (want - now) * (1 - Math.exp(-dt * ZOOM_EASE));
|
|
121
|
+
if (Math.abs(now - want) < 0.002) now = want;
|
|
122
|
+
return now;
|
|
123
|
+
},
|
|
124
|
+
subscribe(on) {
|
|
125
|
+
listeners.add(on);
|
|
126
|
+
return () => void listeners.delete(on);
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
type Listens = Pick<EventTarget, "addEventListener" | "removeEventListener">;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The wheel over `el` (the game's canvas: the page does not scroll or zoom there) and the + and − keys on the window (also the number pad's) move
|
|
135
|
+
* `zoom`. A key with ctrl, alt or the command key is the browser's own zoom and is left alone, and so is a key typed in a field. `skip(e)` returns true
|
|
136
|
+
* for an event the game wants to keep from the zoom (a key the player is binding, a game that is paused). Returns the function that takes the listeners away.
|
|
137
|
+
* The touch pinch is yours to wire (`pinchStart`, `pinch`, `pinchEnd`), because the fingers are also your thumb stick and your look.
|
|
138
|
+
*/
|
|
139
|
+
export function listenZoom(zoom: Zoom, el: Listens, opts: { skip?: (e: Event) => boolean; keys?: boolean } = {}, win: Listens = window): () => void {
|
|
140
|
+
const wheel = (e: Event) => {
|
|
141
|
+
e.preventDefault();
|
|
142
|
+
if (opts.skip?.(e)) return;
|
|
143
|
+
zoom.wheel(e as WheelEvent);
|
|
144
|
+
};
|
|
145
|
+
const key = (e: Event) => {
|
|
146
|
+
const k = e as KeyboardEvent;
|
|
147
|
+
if (k.metaKey || k.altKey || k.ctrlKey || opts.skip?.(e)) return;
|
|
148
|
+
const t = k.target as HTMLElement | null;
|
|
149
|
+
if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName ?? ""))) return;
|
|
150
|
+
if (k.code === "Equal" || k.code === "NumpadAdd") zoom.by(-ZOOM_STEP.key);
|
|
151
|
+
else if (k.code === "Minus" || k.code === "NumpadSubtract") zoom.by(ZOOM_STEP.key);
|
|
152
|
+
};
|
|
153
|
+
el.addEventListener("wheel", wheel, { passive: false });
|
|
154
|
+
const keys = opts.keys !== false;
|
|
155
|
+
if (keys) win.addEventListener("keydown", key);
|
|
156
|
+
return () => {
|
|
157
|
+
el.removeEventListener("wheel", wheel);
|
|
158
|
+
if (keys) win.removeEventListener("keydown", key);
|
|
159
|
+
};
|
|
160
|
+
}
|