@nano133/nanoterra 0.3.0 → 0.3.1

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 CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.1 (2026-10-29)
4
+
5
+ - **`@nano133/nanoterra/input`: a character that does not walk on by itself.** A key that is down when the player leaves the page (another tab, another window, a pop-up,
6
+ Cmd+Tab, another app on a phone) never sends its keyup to your game, so a "held keys" set kept it and the character walked on by itself on return (nano133.com's own
7
+ island had it, and the starter: Wes, 2026-10-29). `releaseWhenLost(release)` calls your `release` at a blur, a focus, a page hide, a hidden tab, the Cmd key and a
8
+ menu (keys only), and, new, drops every key REPEAT after a leave until a key goes down again (one more repeat could arrive after the release and put the key back: the
9
+ player now presses the key again to walk on). `pauseWhenLeft(pause)` calls your `pause` when the tab is hidden or the window loses the focus to another window.
10
+ `swallowsKeyup(event)` is for a keydown with Cmd, which is never held. No package, plain browser events. A round that costs money must not pause: keep the release
11
+ and leave `pauseWhenLeft` out.
12
+ - **`ISLAND_MODEL`'s zone is a disc of 9 m radius** (18 m across), not 10 m (20 m across). nano133.com made its creator plots 30 m across instead of 36 m
13
+ (Wes, 2026-10-28; the gates' standing places moved in, so the plot's free middle is 9 m). A model that reaches 9.0 to 10.0 m from its middle passed 0.3.0
14
+ and fails now: the fix line names the zone as before (18.0 m across).
15
+
3
16
  ## 0.3.0 (2026-10-28)
4
17
 
5
18
  - **Sockets** (`@nano133/nanoterra/character`): three attach points on the character, `c.socket("head" | "hand" | "back")`, each a `THREE.Object3D` on a bone
package/README.md CHANGED
@@ -4,11 +4,12 @@ Stage: Pre-alpha. What this means: https://nano133.com/stages
4
4
 
5
5
  Nanoterra for your game, in **one package**. [Nanoterra](https://nano133.com/build/nanoterra) is
6
6
  nano133.com's island. A player who is signed in there presses Play, and arrives at your game as
7
- themselves. This package gives your game three things:
7
+ themselves. This package gives your game four things:
8
8
 
9
9
  - **The sign-in ticket** (`/ticket`): the player's name, Nano address and character, read in the browser. No server, no secret.
10
10
  - **The player's character** (`/character`): the same toon body that the player has on the island, drawn with Three.js. Six bases, faces, hair, clothes, sizes and colours.
11
11
  - **The looks ladder** (`/looks`): the rules of `looks.json`, the price steps, and what a change does to the players who own a look.
12
+ - **Held input that does not stick** (`/input`, since 0.3.1): lets go of the keys and the stick when the player leaves the page, so the character never walks on by itself, and pauses a free round.
12
13
 
13
14
  It replaces `@nano133/character` 0.1.0, `@nano133/ticket` 0.1.0 and `@nano133/looks` 0.1.0
14
15
  (see "Moving from the three old packages"). Sign-in, checkout and late returns stay in their own packages
@@ -62,6 +63,7 @@ Each path loads only what it needs. A page that reads a ticket loads no Three.js
62
63
  | `@nano133/nanoterra/looks` | the looks ladder | no | 1.9 kB (all), 0.9 kB (`checkLooks` alone) |
63
64
  | `@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) |
64
65
  | `@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 |
66
+ | `@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 |
65
67
  | `@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 |
66
68
  | `@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 |
67
69
  | `@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 | 10.6 kB on 0.3.0 (8.5 kB measured on 0.2.0) + 2.4 kB of CSS |
@@ -226,11 +228,40 @@ tall, its origin the socket's point (a hand item's `grip_R` marker when it has o
226
228
  | `HEAD_ITEM` | 0.9 x 0.9 x 0.8, from 0.1 below the socket | 3,000 | 1 of 512 px | 2 | 300 KB |
227
229
  | `HAND_ITEM` | 0.96 x 0.68 x 1.6, from 1.17 below the grip to 0.43 above (a tool held near its top) | 3,000 | 1 of 512 px | 2 | 300 KB |
228
230
  | `BACK_ITEM` | 1.0 x 0.6 x 1.2, from 0.6 below the socket | 4,000 | 1 of 512 px | 2 | 400 KB |
229
- | `ISLAND_MODEL` | a disc of 10 m radius, 18 m high, from the ground | 50,000 | 8 of 1,024 px | no limit | 5 MB |
231
+ | `ISLAND_MODEL` | a disc of 9 m radius (18 m across), 18 m high, from the ground | 50,000 | 8 of 1,024 px | no limit | 5 MB |
230
232
 
231
233
  Every preset refuses skins, animations, morph targets, lights, external files and compressed meshes (Draco, meshopt): a worn thing is rigid on its socket.
232
234
  `COSMETIC_LIMITS` gives the preset of each socket by its name. The island preset is nano133.com's (its "Launch my game" check uses this same code).
233
235
 
236
+ ## Leaving the page (`@nano133/nanoterra/input`; since 0.3.1)
237
+
238
+ The browser sends a key's "up" only to the window that has the focus. A key that is down when the player leaves the page (another tab, another window, a pop-up,
239
+ Cmd+Tab, another app on a phone) never sends it to your game. A game that keeps a "held keys" set keeps the key, and the character **walks on by itself** when the
240
+ player comes back. nano133.com's island had this, and so did the starter. One call fixes it, and one more pauses a free round:
241
+
242
+ ```ts
243
+ import { pauseWhenLeft, releaseWhenLost, swallowsKeyup } from "@nano133/nanoterra/input";
244
+
245
+ const keys = new Set<string>();
246
+ window.addEventListener("keydown", (e) => { if (!swallowsKeyup(e)) keys.add(e.key); }); // a key with Cmd is the browser's shortcut: never held
247
+ window.addEventListener("keyup", (e) => keys.delete(e.key));
248
+ releaseWhenLost(() => { keys.clear(); stick.reset(); queuedJump = false; }); // at each leave: let go of everything
249
+ pauseWhenLeft(() => { game.paused = true; resumeCard.hidden = false; }); // a FREE round: stop the clock, show Resume
250
+ ```
251
+
252
+ - `releaseWhenLost(release, keysOnly?)` calls `release` at a window **blur**, **focus**, **pagehide**, a **hidden tab**, and the **Cmd key** (on a Mac a key that is released
253
+ while Cmd is down sends no keyup). `keysOnly` is called when a menu opens (a right click): a menu takes a keyup, but a long press on a touch screen asks for a menu too,
254
+ so a thumb on a stick must not lose its walk for it. A menu that your page stopped (`preventDefault`) lets go of nothing.
255
+ - **After a leave, a key repeat is dropped until a key goes down again.** One more repeat of the held key can arrive after the release, or the repeats go on when the
256
+ player comes back, and they put the key back (its keyup went to the other window). The helper stops such a repeat in the capture phase, so your own key handlers
257
+ never see it. The player presses the key again to walk on. A menu is no leave: its repeats still put the key back.
258
+ - `pauseWhenLeft(pause)` calls `pause` when the tab is hidden or the window loses the focus to another window (a real `blur`: not a script's, and not a focus that moves
259
+ inside your page into a dialog). It can call `pause` twice for one leave, so make it safe to call twice. Show a Resume key and start nothing until it is tapped.
260
+ - **A round that costs money must not pause**: its deadline must not move (nano133.com's arcade has the same rule). Keep `releaseWhenLost` (the character always stops)
261
+ and leave `pauseWhenLeft` out.
262
+ - 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,
263
+ and in the [game starter](https://github.com/nano133-com/nano-game-starter).
264
+
234
265
  ## The sign-in ticket
235
266
 
236
267
  `@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/ai/SPEC.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Nanoterra for a game: implementation spec
2
2
 
3
3
  Package: `@nano133/nanoterra` (0.1.0 or later). Follow these steps in order. Each step ends with a check. Ask the owner when a step says
4
- "ask". The package has three parts, on three paths: the sign-in ticket (`/ticket`), the player's character (`/character`, Three.js) and the
5
- looks ladder (`/looks`). Use only the parts the game needs. It has no network, wallet or Nano code. Read the package README first.
4
+ "ask". The package has four parts, on four paths: the sign-in ticket (`/ticket`), the player's character (`/character`, Three.js), the
5
+ looks ladder (`/looks`) and held input that does not stick (`/input`). Use only the parts the game needs. It has no network, wallet or Nano code. Read the package README first.
6
6
 
7
7
  ## 0. What the owner decides (ask)
8
8
 
@@ -104,7 +104,19 @@ export const LOOKS = checkLooks(rawLooksJson); // throws with the first mistake
104
104
 
105
105
  **Check:** `checkLooks` passes at start; a removed look that is owned is refused; a changed slot on an owned look is refused.
106
106
 
107
- ## 8. Do not
107
+ ## 8. Leaving the page (any game that a player walks in with keys or a stick)
108
+
109
+ ```ts
110
+ import { pauseWhenLeft, releaseWhenLost, swallowsKeyup } from "@nano133/nanoterra/input";
111
+ ```
112
+
113
+ - A key that is down when the player leaves the page never sends its keyup, so the character walks on by itself on return. Call `releaseWhenLost(release)` once; in `release`
114
+ clear every held key, stick, drag and queued jump. A keydown with `swallowsKeyup(e)` true (Cmd is down) is never held. After a leave the helper drops key repeats until a key goes down again.
115
+ - Call `pauseWhenLeft(pause)` for a **free** round: stop the clock, show a Resume key, start nothing until it is tapped. A round that costs money must **not** pause (its deadline must not move): keep `releaseWhenLost`, leave `pauseWhenLeft` out.
116
+
117
+ **Check:** hold a move key, hide the tab or switch windows, come back: the character stands still until the key is pressed again.
118
+
119
+ ## 9. Do not
108
120
 
109
121
  - Do not edit the geometry, the skeleton or the toon and ink materials.
110
122
  - Do not add the katana, the headband or other brands' art. A game's own worn model goes on a socket (`c.socket("head" | "hand" | "back")` or
@@ -0,0 +1,32 @@
1
+ type Listens = Pick<EventTarget, "addEventListener" | "removeEventListener">;
2
+ /**
3
+ * 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.
4
+ * `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,
5
+ * and a thumb that rests on a stick must not lose its walk for that. A menu that your page stopped (`preventDefault`) did not open: nothing is let go.
6
+ *
7
+ * After a leave (blur, focus, page hide, the tab hidden or shown), a key REPEAT does not reach your key handlers until a key goes down for the first
8
+ * time (`repeat` false). The player presses the key again to walk on. A menu is no leave: the held key that a menu took comes back by its repeat.
9
+ * The repeat is stopped in the capture phase (`stopImmediatePropagation`), so register your own key handlers as usual: they never see it.
10
+ *
11
+ * Returns the function that takes the listeners away. `win` and `doc` are given in a test.
12
+ */
13
+ export declare function releaseWhenLost(release: () => void, keysOnly?: () => void, win?: Listens, doc?: Listens & {
14
+ hidden?: boolean;
15
+ }): () => void;
16
+ /** A keydown whose keyup may never come (Cmd is down, or it is the Cmd key): it must not go into your held set. */
17
+ export declare const swallowsKeyup: (e: {
18
+ metaKey?: boolean;
19
+ key?: string;
20
+ }) => boolean;
21
+ /**
22
+ * Calls `pause` when the player leaves: the tab or the window is hidden (`visibilitychange`: another tab, a minimized window, a locked phone,
23
+ * another app on a phone), or the window loses the focus to another window (a real `blur`: not a script's, and not a focus that moves inside
24
+ * your page into a dialog or a field). It may call `pause` twice for one leave (a blur, then the tab hiding): make `pause` safe to call twice.
25
+ * Show a Resume key and stop your clock in it; start nothing again until the player taps Resume. Returns the function that takes the listeners away.
26
+ * `win` and `doc` are given in a test.
27
+ */
28
+ export declare function pauseWhenLeft(pause: () => void, win?: Listens, doc?: Listens & {
29
+ hidden?: boolean;
30
+ hasFocus?: () => boolean;
31
+ }): () => void;
32
+ export {};
@@ -0,0 +1,102 @@
1
+ // Held input that a page can no longer trust, and a pause for when the player leaves. For a game that a player walks in with keys, a thumb stick
2
+ // or a drag, in the browser.
3
+ //
4
+ // THE FAULT: the browser sends a key's "up" only to the window that has the focus. A key that is down when the player leaves the page (another
5
+ // tab, another window, a pop-up, Cmd+Tab, another app on a phone) never sends its "up" here. A game that keeps a "held keys" set keeps the key,
6
+ // and the character walks on by itself when the player comes back (nano133.com's own island and rooms had it: "my character is now moving by
7
+ // himself"). The ways that were found:
8
+ // - the tab or the window changes while a key is down (Cmd+Tab, Ctrl+Tab, Cmd+a number, a click elsewhere)
9
+ // - on a Mac, a key that is released while Cmd is down sends NO keyup (Cmd+Shift+4 for a picture, Cmd+C, ...)
10
+ // - a menu of the browser or the system opens (a right click, a long press) and takes the keyup
11
+ // - a pointer of a thumb stick ends where the page does not get it (the edge of the screen, a system gesture)
12
+ // - ONE MORE REPEAT of the held key arrives after the page was let go, or the repeats go on when the player comes back: it puts the key back
13
+ // into the held set, and its keyup went to the other window. A repeat only says that a key was down at some time.
14
+ //
15
+ // `releaseWhenLost(release)` calls your `release` at each of them, and drops key repeats after a leave until a key goes down again. `pauseWhenLeft(pause)`
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
+ //
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
+ * 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
+ * `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,
22
+ * and a thumb that rests on a stick must not lose its walk for that. A menu that your page stopped (`preventDefault`) did not open: nothing is let go.
23
+ *
24
+ * After a leave (blur, focus, page hide, the tab hidden or shown), a key REPEAT does not reach your key handlers until a key goes down for the first
25
+ * time (`repeat` false). The player presses the key again to walk on. A menu is no leave: the held key that a menu took comes back by its repeat.
26
+ * The repeat is stopped in the capture phase (`stopImmediatePropagation`), so register your own key handlers as usual: they never see it.
27
+ *
28
+ * Returns the function that takes the listeners away. `win` and `doc` are given in a test.
29
+ */
30
+ export function releaseWhenLost(release, keysOnly, win = window, doc = document) {
31
+ // Set by each leave; a key that goes down (not a repeat) ends it. While it is set, repeats do not reach the page's handlers.
32
+ let stale = false;
33
+ const lost = () => {
34
+ stale = true;
35
+ release();
36
+ };
37
+ const hidden = () => {
38
+ // Either way the page was left for a while: a repeat that comes now may belong to a key that went up elsewhere.
39
+ stale = true;
40
+ if (doc.hidden !== false)
41
+ release();
42
+ };
43
+ // Cmd (Meta) going down or up: each key that is released in between sends no keyup. The OS key on Windows is the same key.
44
+ const meta = (e) => {
45
+ const k = e;
46
+ if (k.key === "Meta" || k.key === "OS" || k.metaKey)
47
+ release();
48
+ if (e.type !== "keydown")
49
+ return;
50
+ if (!k.repeat)
51
+ stale = false;
52
+ else if (stale)
53
+ e.stopImmediatePropagation();
54
+ };
55
+ // At the window, after your own handlers: a handler that stopped the menu has done so by now.
56
+ const menu = (e) => {
57
+ if (!e.defaultPrevented)
58
+ keysOnly?.();
59
+ };
60
+ const windowEvents = ["blur", "focus", "pagehide"];
61
+ for (const type of windowEvents)
62
+ win.addEventListener(type, lost);
63
+ win.addEventListener("contextmenu", menu);
64
+ doc.addEventListener("visibilitychange", hidden);
65
+ // Capture: before your key handler, which then leaves a key with Cmd out (`swallowsKeyup`), and which a stale repeat never reaches.
66
+ win.addEventListener("keydown", meta, true);
67
+ win.addEventListener("keyup", meta, true);
68
+ return () => {
69
+ for (const type of windowEvents)
70
+ win.removeEventListener(type, lost);
71
+ win.removeEventListener("contextmenu", menu);
72
+ doc.removeEventListener("visibilitychange", hidden);
73
+ win.removeEventListener("keydown", meta, true);
74
+ win.removeEventListener("keyup", meta, true);
75
+ };
76
+ }
77
+ /** A keydown whose keyup may never come (Cmd is down, or it is the Cmd key): it must not go into your held set. */
78
+ export const swallowsKeyup = (e) => !!e.metaKey || e.key === "Meta" || e.key === "OS";
79
+ /**
80
+ * Calls `pause` when the player leaves: the tab or the window is hidden (`visibilitychange`: another tab, a minimized window, a locked phone,
81
+ * another app on a phone), or the window loses the focus to another window (a real `blur`: not a script's, and not a focus that moves inside
82
+ * your page into a dialog or a field). It may call `pause` twice for one leave (a blur, then the tab hiding): make `pause` safe to call twice.
83
+ * Show a Resume key and stop your clock in it; start nothing again until the player taps Resume. Returns the function that takes the listeners away.
84
+ * `win` and `doc` are given in a test.
85
+ */
86
+ export function pauseWhenLeft(pause, win = window, doc = document) {
87
+ const hidden = () => {
88
+ if (doc.hidden)
89
+ pause();
90
+ };
91
+ const blur = (e) => {
92
+ if (!e.isTrusted || doc.hasFocus?.())
93
+ return;
94
+ pause();
95
+ };
96
+ win.addEventListener("blur", blur);
97
+ doc.addEventListener("visibilitychange", hidden);
98
+ return () => {
99
+ win.removeEventListener("blur", blur);
100
+ doc.removeEventListener("visibilitychange", hidden);
101
+ };
102
+ }
@@ -68,9 +68,10 @@ export type ModelLimits = {
68
68
  export declare const PLAIN_EXTENSIONS: readonly ["KHR_texture_transform", "KHR_texture_basisu", "EXT_texture_webp", "KHR_mesh_quantization", "KHR_materials_emissive_strength", "KHR_materials_unlit", "KHR_materials_ior", "KHR_materials_specular", "KHR_materials_clearcoat", "KHR_materials_sheen", "KHR_materials_transmission", "KHR_materials_volume", "KHR_materials_iridescence", "KHR_materials_anisotropy", "KHR_materials_dispersion", "KHR_xmp_json_ld"];
69
69
  /**
70
70
  * A creator's island model (the creator islands gameplan, phase 2; Wes approved it on 2026-10-27): the entry of their
71
- * game on their plot. 5 MB, 50,000 triangles, 8 textures of at most 1,024 px, inside a disc of 10 m radius (20 m across)
71
+ * game on their plot. 5 MB, 50,000 triangles, 8 textures of at most 1,024 px, inside a disc of 9 m radius (18 m across)
72
72
  * and 18 m high on the plot's middle: the plot's free middle (PLOT_FREE_R in app/nanoterra/wheel/plots.ts; Wes chose the
73
- * disc over a 24 x 24 m box on 2026-10-28, as the box reached the gates' standing places); an empty `nano_door` where Play stands, an optional empty `nano_spawn` (the arrival point) and an
73
+ * disc over a 24 x 24 m box on 2026-10-28, as the box reached the gates' standing places; it was 10 m radius until the plots
74
+ * were made 30 m across instead of 36 m, 2026-10-28); an empty `nano_door` where Play stands, an optional empty `nano_spawn` (the arrival point) and an
74
75
  * optional simple `nano_collide` mesh. No lights, no scripts, nothing outside the file. A still model for now.
75
76
  */
76
77
  export declare const ISLAND_MODEL: ModelLimits;
@@ -22,9 +22,10 @@ export const PLAIN_EXTENSIONS = [
22
22
  ];
23
23
  /**
24
24
  * A creator's island model (the creator islands gameplan, phase 2; Wes approved it on 2026-10-27): the entry of their
25
- * game on their plot. 5 MB, 50,000 triangles, 8 textures of at most 1,024 px, inside a disc of 10 m radius (20 m across)
25
+ * game on their plot. 5 MB, 50,000 triangles, 8 textures of at most 1,024 px, inside a disc of 9 m radius (18 m across)
26
26
  * and 18 m high on the plot's middle: the plot's free middle (PLOT_FREE_R in app/nanoterra/wheel/plots.ts; Wes chose the
27
- * disc over a 24 x 24 m box on 2026-10-28, as the box reached the gates' standing places); an empty `nano_door` where Play stands, an optional empty `nano_spawn` (the arrival point) and an
27
+ * disc over a 24 x 24 m box on 2026-10-28, as the box reached the gates' standing places; it was 10 m radius until the plots
28
+ * were made 30 m across instead of 36 m, 2026-10-28); an empty `nano_door` where Play stands, an optional empty `nano_spawn` (the arrival point) and an
28
29
  * optional simple `nano_collide` mesh. No lights, no scripts, nothing outside the file. A still model for now.
29
30
  */
30
31
  export const ISLAND_MODEL = {
@@ -34,7 +35,7 @@ export const ISLAND_MODEL = {
34
35
  maxTriangles: 50_000,
35
36
  maxTextures: 8,
36
37
  maxTextureSide: 1024,
37
- zone: { radius: 10, height: 18, below: 0 },
38
+ zone: { radius: 9, height: 18, below: 0 },
38
39
  markers: [
39
40
  { name: "nano_door", required: true, kind: "empty", purpose: "where Play stands" },
40
41
  { name: "nano_spawn", required: false, kind: "empty", purpose: "where players arrive" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nano133/nanoterra",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
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) and the looks ladder.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -12,6 +12,7 @@
12
12
  "./ticket": { "types": "./dist/ticket/index.d.ts", "import": "./dist/ticket/index.js" },
13
13
  "./looks": { "types": "./dist/looks/index.d.ts", "import": "./dist/looks/index.js" },
14
14
  "./model": { "types": "./dist/model/index.d.ts", "import": "./dist/model/index.js" },
15
+ "./input": { "types": "./dist/input/index.d.ts", "import": "./dist/input/index.js" },
15
16
  "./admin": { "types": "./dist/admin/index.d.ts", "import": "./dist/admin/index.js" },
16
17
  "./admin/conformance": { "types": "./dist/admin/conformance.d.ts", "import": "./dist/admin/conformance.js" },
17
18
  "./admin/d1": { "types": "./dist/admin/adapters/d1.d.ts", "import": "./dist/admin/adapters/d1.js" },
@@ -0,0 +1,97 @@
1
+ // Held input that a page can no longer trust, and a pause for when the player leaves. For a game that a player walks in with keys, a thumb stick
2
+ // or a drag, in the browser.
3
+ //
4
+ // THE FAULT: the browser sends a key's "up" only to the window that has the focus. A key that is down when the player leaves the page (another
5
+ // tab, another window, a pop-up, Cmd+Tab, another app on a phone) never sends its "up" here. A game that keeps a "held keys" set keeps the key,
6
+ // and the character walks on by itself when the player comes back (nano133.com's own island and rooms had it: "my character is now moving by
7
+ // himself"). The ways that were found:
8
+ // - the tab or the window changes while a key is down (Cmd+Tab, Ctrl+Tab, Cmd+a number, a click elsewhere)
9
+ // - on a Mac, a key that is released while Cmd is down sends NO keyup (Cmd+Shift+4 for a picture, Cmd+C, ...)
10
+ // - a menu of the browser or the system opens (a right click, a long press) and takes the keyup
11
+ // - a pointer of a thumb stick ends where the page does not get it (the edge of the screen, a system gesture)
12
+ // - ONE MORE REPEAT of the held key arrives after the page was let go, or the repeats go on when the player comes back: it puts the key back
13
+ // into the held set, and its keyup went to the other window. A repeat only says that a key was down at some time.
14
+ //
15
+ // `releaseWhenLost(release)` calls your `release` at each of them, and drops key repeats after a leave until a key goes down again. `pauseWhenLeft(pause)`
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
+ //
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
+ type Listens = Pick<EventTarget, "addEventListener" | "removeEventListener">;
21
+
22
+ /**
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.
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,
25
+ * and a thumb that rests on a stick must not lose its walk for that. A menu that your page stopped (`preventDefault`) did not open: nothing is let go.
26
+ *
27
+ * After a leave (blur, focus, page hide, the tab hidden or shown), a key REPEAT does not reach your key handlers until a key goes down for the first
28
+ * time (`repeat` false). The player presses the key again to walk on. A menu is no leave: the held key that a menu took comes back by its repeat.
29
+ * The repeat is stopped in the capture phase (`stopImmediatePropagation`), so register your own key handlers as usual: they never see it.
30
+ *
31
+ * Returns the function that takes the listeners away. `win` and `doc` are given in a test.
32
+ */
33
+ export function releaseWhenLost(release: () => void, keysOnly?: () => void, win: Listens = window, doc: Listens & { hidden?: boolean } = document): () => void {
34
+ // Set by each leave; a key that goes down (not a repeat) ends it. While it is set, repeats do not reach the page's handlers.
35
+ let stale = false;
36
+ const lost = () => {
37
+ stale = true;
38
+ release();
39
+ };
40
+ const hidden = () => {
41
+ // Either way the page was left for a while: a repeat that comes now may belong to a key that went up elsewhere.
42
+ stale = true;
43
+ if (doc.hidden !== false) release();
44
+ };
45
+ // Cmd (Meta) going down or up: each key that is released in between sends no keyup. The OS key on Windows is the same key.
46
+ const meta = (e: Event) => {
47
+ const k = e as KeyboardEvent;
48
+ if (k.key === "Meta" || k.key === "OS" || k.metaKey) release();
49
+ if (e.type !== "keydown") return;
50
+ if (!k.repeat) stale = false;
51
+ else if (stale) e.stopImmediatePropagation();
52
+ };
53
+ // At the window, after your own handlers: a handler that stopped the menu has done so by now.
54
+ const menu = (e: Event) => {
55
+ if (!e.defaultPrevented) keysOnly?.();
56
+ };
57
+ const windowEvents = ["blur", "focus", "pagehide"] as const;
58
+ for (const type of windowEvents) win.addEventListener(type, lost);
59
+ win.addEventListener("contextmenu", menu);
60
+ doc.addEventListener("visibilitychange", hidden);
61
+ // Capture: before your key handler, which then leaves a key with Cmd out (`swallowsKeyup`), and which a stale repeat never reaches.
62
+ win.addEventListener("keydown", meta, true);
63
+ win.addEventListener("keyup", meta, true);
64
+ return () => {
65
+ for (const type of windowEvents) win.removeEventListener(type, lost);
66
+ win.removeEventListener("contextmenu", menu);
67
+ doc.removeEventListener("visibilitychange", hidden);
68
+ win.removeEventListener("keydown", meta, true);
69
+ win.removeEventListener("keyup", meta, true);
70
+ };
71
+ }
72
+
73
+ /** A keydown whose keyup may never come (Cmd is down, or it is the Cmd key): it must not go into your held set. */
74
+ export const swallowsKeyup = (e: { metaKey?: boolean; key?: string }) => !!e.metaKey || e.key === "Meta" || e.key === "OS";
75
+
76
+ /**
77
+ * Calls `pause` when the player leaves: the tab or the window is hidden (`visibilitychange`: another tab, a minimized window, a locked phone,
78
+ * another app on a phone), or the window loses the focus to another window (a real `blur`: not a script's, and not a focus that moves inside
79
+ * your page into a dialog or a field). It may call `pause` twice for one leave (a blur, then the tab hiding): make `pause` safe to call twice.
80
+ * Show a Resume key and stop your clock in it; start nothing again until the player taps Resume. Returns the function that takes the listeners away.
81
+ * `win` and `doc` are given in a test.
82
+ */
83
+ export function pauseWhenLeft(pause: () => void, win: Listens = window, doc: Listens & { hidden?: boolean; hasFocus?: () => boolean } = document): () => void {
84
+ const hidden = () => {
85
+ if (doc.hidden) pause();
86
+ };
87
+ const blur = (e: Event) => {
88
+ if (!e.isTrusted || doc.hasFocus?.()) return;
89
+ pause();
90
+ };
91
+ win.addEventListener("blur", blur);
92
+ doc.addEventListener("visibilitychange", hidden);
93
+ return () => {
94
+ win.removeEventListener("blur", blur);
95
+ doc.removeEventListener("visibilitychange", hidden);
96
+ };
97
+ }
@@ -78,9 +78,10 @@ export const PLAIN_EXTENSIONS = [
78
78
 
79
79
  /**
80
80
  * A creator's island model (the creator islands gameplan, phase 2; Wes approved it on 2026-10-27): the entry of their
81
- * game on their plot. 5 MB, 50,000 triangles, 8 textures of at most 1,024 px, inside a disc of 10 m radius (20 m across)
81
+ * game on their plot. 5 MB, 50,000 triangles, 8 textures of at most 1,024 px, inside a disc of 9 m radius (18 m across)
82
82
  * and 18 m high on the plot's middle: the plot's free middle (PLOT_FREE_R in app/nanoterra/wheel/plots.ts; Wes chose the
83
- * disc over a 24 x 24 m box on 2026-10-28, as the box reached the gates' standing places); an empty `nano_door` where Play stands, an optional empty `nano_spawn` (the arrival point) and an
83
+ * disc over a 24 x 24 m box on 2026-10-28, as the box reached the gates' standing places; it was 10 m radius until the plots
84
+ * were made 30 m across instead of 36 m, 2026-10-28); an empty `nano_door` where Play stands, an optional empty `nano_spawn` (the arrival point) and an
84
85
  * optional simple `nano_collide` mesh. No lights, no scripts, nothing outside the file. A still model for now.
85
86
  */
86
87
  export const ISLAND_MODEL: ModelLimits = {
@@ -90,7 +91,7 @@ export const ISLAND_MODEL: ModelLimits = {
90
91
  maxTriangles: 50_000,
91
92
  maxTextures: 8,
92
93
  maxTextureSide: 1024,
93
- zone: { radius: 10, height: 18, below: 0 },
94
+ zone: { radius: 9, height: 18, below: 0 },
94
95
  markers: [
95
96
  { name: "nano_door", required: true, kind: "empty", purpose: "where Play stands" },
96
97
  { name: "nano_spawn", required: false, kind: "empty", purpose: "where players arrive" },