@nano133/nanoterra 0.2.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 +37 -0
- package/README.md +114 -13
- package/ai/SPEC.md +20 -4
- package/dist/admin/panel/index.d.ts +1 -1
- package/dist/admin/panel/index.js +1 -1
- package/dist/admin/panel/shelf.d.ts +7 -0
- package/dist/admin/panel/shelf.js +24 -0
- package/dist/character/bases/bee.js +7 -5
- package/dist/character/bases/spec.d.ts +7 -0
- package/dist/character/create.d.ts +15 -0
- package/dist/character/create.js +69 -2
- package/dist/character/ctx.d.ts +12 -2
- package/dist/character/index.d.ts +2 -0
- package/dist/character/index.js +2 -0
- package/dist/character/make.js +2 -1
- package/dist/character/preview.d.ts +32 -0
- package/dist/character/preview.js +112 -0
- package/dist/character/sockets.d.ts +37 -0
- package/dist/character/sockets.js +66 -0
- package/dist/input/index.d.ts +32 -0
- package/dist/input/index.js +102 -0
- package/dist/model/check.d.ts +59 -0
- package/dist/model/check.js +681 -0
- package/dist/model/index.d.ts +2 -0
- package/dist/model/index.js +5 -0
- package/dist/model/limits.d.ts +104 -0
- package/dist/model/limits.js +133 -0
- package/package.json +4 -2
- package/src/admin/panel/index.ts +1 -1
- package/src/admin/panel/shelf.ts +24 -0
- package/src/admin/panel/style.css +9 -0
- package/src/character/bases/bee.ts +7 -5
- package/src/character/bases/spec.ts +4 -0
- package/src/character/create.ts +73 -2
- package/src/character/ctx.ts +17 -2
- package/src/character/index.ts +2 -0
- package/src/character/make.ts +2 -1
- package/src/character/preview.ts +148 -0
- package/src/character/sockets.ts +81 -0
- package/src/input/index.ts +97 -0
- package/src/model/check.ts +624 -0
- package/src/model/index.ts +5 -0
- package/src/model/limits.ts +194 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import type { LookRecord } from "./record.js";
|
|
3
|
+
/** What draws one look: the game's own character dressed in it (its hat, its models), or by default the package's character. */
|
|
4
|
+
export type PreviewBody<L> = {
|
|
5
|
+
object: THREE.Object3D;
|
|
6
|
+
/** Puts the look on. A promise waits (a model that loads). */
|
|
7
|
+
dress(look: L): void | Promise<void>;
|
|
8
|
+
/** Frees what the body made. */
|
|
9
|
+
dispose(): void;
|
|
10
|
+
};
|
|
11
|
+
export type LookPreviewOptions<L> = {
|
|
12
|
+
/** The picture's side in CSS pixels (default 112); it is drawn at twice that for a sharp screen. */
|
|
13
|
+
size?: number;
|
|
14
|
+
/** A body for the next picture. Left out: the package's character in `record` (else the island's first look), its shirt in the look's body colour. */
|
|
15
|
+
body?: () => PreviewBody<L>;
|
|
16
|
+
/** The record of the default body. */
|
|
17
|
+
record?: LookRecord;
|
|
18
|
+
/** The background (default: none, a clear picture). */
|
|
19
|
+
background?: THREE.ColorRepresentation | null;
|
|
20
|
+
/** The key of a look in the cache (default: its JSON text). */
|
|
21
|
+
key?: (look: L) => string;
|
|
22
|
+
};
|
|
23
|
+
export type LookPreview<L> = {
|
|
24
|
+
/** A PNG data URL of the look on the character, or null when the page has no WebGL or the drawing failed. */
|
|
25
|
+
render(look: L): Promise<string | null>;
|
|
26
|
+
/** How many pictures are kept. */
|
|
27
|
+
readonly cached: number;
|
|
28
|
+
/** Frees the renderer (and its WebGL context) and the cache. `render` answers null after it. */
|
|
29
|
+
dispose(): void;
|
|
30
|
+
};
|
|
31
|
+
/** A picture maker. Make one for a page and keep it: it holds one WebGL context while it lives. */
|
|
32
|
+
export declare function createLookPreview<L>(options?: LookPreviewOptions<L>): LookPreview<L>;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import { createCharacter } from "./create.js";
|
|
3
|
+
function defaultBody(record) {
|
|
4
|
+
return () => {
|
|
5
|
+
const c = createCharacter({ record, reducedMotion: true });
|
|
6
|
+
return {
|
|
7
|
+
object: c.object,
|
|
8
|
+
dress(look) {
|
|
9
|
+
const body = look.change?.body;
|
|
10
|
+
if (body && (look.priceUsd ?? 1) > 0)
|
|
11
|
+
c.setColors({ shirt: body });
|
|
12
|
+
},
|
|
13
|
+
dispose: () => c.dispose(),
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/** A picture maker. Make one for a page and keep it: it holds one WebGL context while it lives. */
|
|
18
|
+
export function createLookPreview(options = {}) {
|
|
19
|
+
const size = options.size ?? 112;
|
|
20
|
+
const makeBody = options.body ?? defaultBody(options.record);
|
|
21
|
+
const keyOf = options.key ?? ((l) => JSON.stringify(l));
|
|
22
|
+
const cache = new Map();
|
|
23
|
+
let renderer = null;
|
|
24
|
+
let failed = false;
|
|
25
|
+
let disposed = false;
|
|
26
|
+
let queue = Promise.resolve();
|
|
27
|
+
const scene = new THREE.Scene();
|
|
28
|
+
if (options.background != null)
|
|
29
|
+
scene.background = new THREE.Color(options.background);
|
|
30
|
+
scene.add(new THREE.HemisphereLight("#ffffff", "#8a8f9e", 1.6));
|
|
31
|
+
const sun = new THREE.DirectionalLight("#ffffff", 1.6);
|
|
32
|
+
sun.position.set(2, 4, 5);
|
|
33
|
+
scene.add(sun);
|
|
34
|
+
const camera = new THREE.PerspectiveCamera(30, 1, 0.05, 50);
|
|
35
|
+
function getRenderer() {
|
|
36
|
+
if (renderer || failed)
|
|
37
|
+
return renderer;
|
|
38
|
+
try {
|
|
39
|
+
// three makes its own canvas (in a page); with no page or no WebGL this throws, and the answer is null.
|
|
40
|
+
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true });
|
|
41
|
+
renderer.setPixelRatio(1);
|
|
42
|
+
renderer.setSize(size * 2, size * 2, false);
|
|
43
|
+
renderer.setClearColor(0x000000, 0);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
failed = true;
|
|
47
|
+
renderer = null;
|
|
48
|
+
}
|
|
49
|
+
return renderer;
|
|
50
|
+
}
|
|
51
|
+
async function draw(look) {
|
|
52
|
+
if (disposed)
|
|
53
|
+
return null;
|
|
54
|
+
const r = getRenderer();
|
|
55
|
+
if (!r)
|
|
56
|
+
return null;
|
|
57
|
+
const body = makeBody();
|
|
58
|
+
try {
|
|
59
|
+
await body.dress(look);
|
|
60
|
+
if (disposed)
|
|
61
|
+
return null;
|
|
62
|
+
scene.add(body.object);
|
|
63
|
+
// Frame the whole character (with what it holds) from the front, a little from its right and above.
|
|
64
|
+
body.object.updateMatrixWorld(true);
|
|
65
|
+
const box = new THREE.Box3().setFromObject(body.object);
|
|
66
|
+
const sphere = box.getBoundingSphere(new THREE.Sphere());
|
|
67
|
+
const dir = new THREE.Vector3(-0.6, 0.15, 1).normalize();
|
|
68
|
+
const dist = sphere.radius / Math.sin(THREE.MathUtils.degToRad(camera.fov / 2)) * 1.02;
|
|
69
|
+
camera.position.copy(sphere.center).addScaledVector(dir, dist);
|
|
70
|
+
camera.lookAt(sphere.center);
|
|
71
|
+
camera.near = Math.max(0.01, dist - sphere.radius * 2);
|
|
72
|
+
camera.far = dist + sphere.radius * 2;
|
|
73
|
+
camera.updateProjectionMatrix();
|
|
74
|
+
r.render(scene, camera);
|
|
75
|
+
return r.domElement.toDataURL("image/png");
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
scene.remove(body.object);
|
|
82
|
+
body.dispose();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
render(look) {
|
|
87
|
+
if (disposed)
|
|
88
|
+
return Promise.resolve(null);
|
|
89
|
+
const key = keyOf(look);
|
|
90
|
+
const hit = cache.get(key);
|
|
91
|
+
if (hit)
|
|
92
|
+
return hit;
|
|
93
|
+
// One at a time: the renderer and the scene are shared.
|
|
94
|
+
const p = queue.then(() => draw(look));
|
|
95
|
+
queue = p.catch(() => null);
|
|
96
|
+
cache.set(key, p);
|
|
97
|
+
return p;
|
|
98
|
+
},
|
|
99
|
+
get cached() {
|
|
100
|
+
return cache.size;
|
|
101
|
+
},
|
|
102
|
+
dispose() {
|
|
103
|
+
if (disposed)
|
|
104
|
+
return;
|
|
105
|
+
disposed = true;
|
|
106
|
+
cache.clear();
|
|
107
|
+
renderer?.dispose();
|
|
108
|
+
renderer?.forceContextLoss();
|
|
109
|
+
renderer = null;
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import type { BaseSpec } from "./bases/spec.js";
|
|
3
|
+
import { BUILD_FACTORS } from "./make.js";
|
|
4
|
+
import type { BoneName } from "./skeleton.js";
|
|
5
|
+
/** The names of the sockets. */
|
|
6
|
+
export declare const SOCKETS: readonly ["head", "hand", "back"];
|
|
7
|
+
export type SocketName = (typeof SOCKETS)[number];
|
|
8
|
+
/** Where a socket sits: its bone, and its place, turn and scale in that bone's space (the units of the character's own space). */
|
|
9
|
+
export type SocketFrame = {
|
|
10
|
+
bone: BoneName;
|
|
11
|
+
position: THREE.Vector3;
|
|
12
|
+
quaternion: THREE.Quaternion;
|
|
13
|
+
scale: THREE.Vector3;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* The hand's hold: a hand item is modelled standing up (+Y), its grip at the origin, its front at +Z. The hand holds it pointing forward,
|
|
17
|
+
* as a torch is held out: the item's +Y along the character's forward (+Z), its front (+Z) up, its +X to the character's right (-X).
|
|
18
|
+
*/
|
|
19
|
+
export declare const HAND_HOLD: Readonly<THREE.Quaternion>;
|
|
20
|
+
/** What `socketFrame` reads of a look: its build and its head's size. */
|
|
21
|
+
export type SocketLook = {
|
|
22
|
+
build: keyof typeof BUILD_FACTORS;
|
|
23
|
+
head: number;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* A socket's frame on a base for a look.
|
|
27
|
+
* - "head": on the head bone, at the base's hat frame (`spec.hat`), scaled by the hat frame and the head's size: a thing made for the
|
|
28
|
+
* Classic's head, round the middle of the head (the game starter's hats are made so), fits every head.
|
|
29
|
+
* - "hand": on grip_R, in the middle of the right hand (the hands of a wide body are drawn further out than their bone), turned by
|
|
30
|
+
* `HAND_HOLD`.
|
|
31
|
+
* - "back": on the chest bone, on the back of the torso (`spec.back`, else the chest's height and the torso's radius there); its axes are the
|
|
32
|
+
* character's, so a back item is modelled with the side that touches the body at the origin and its bulk towards -Z.
|
|
33
|
+
*/
|
|
34
|
+
export declare function socketFrame(spec: BaseSpec, look: SocketLook, name: SocketName): SocketFrame;
|
|
35
|
+
/** Puts a socket object on its bone (from `bones`) at its frame. The object keeps its children: a hung item moves with it. */
|
|
36
|
+
export declare function placeSocket(socket: THREE.Object3D, bones: Partial<Record<BoneName, THREE.Object3D>>, spec: BaseSpec, look: SocketLook, name: SocketName): THREE.Object3D<THREE.Object3DEventMap>;
|
|
37
|
+
export declare const isSocketName: (v: unknown) => v is SocketName;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import { BUILD_FACTORS } from "./make.js";
|
|
3
|
+
// The character's three attach points (sockets): the head, the right hand and the back. A socket is a THREE.Object3D on one bone of the
|
|
4
|
+
// island's skeleton, so a thing hung on it follows the walk, the run, the jump and the wave, and costs its own draws (a model of the game's,
|
|
5
|
+
// a .glb say). A worn thing that is merged into the body's geometry (ctx.ts `WornAdapter.add`) adds no draw and needs no socket.
|
|
6
|
+
//
|
|
7
|
+
// Each socket's place depends on the base and on the look (the build widens the body and moves the hands out; the head's size scales the
|
|
8
|
+
// head). `socketFrame` works it out; `createCharacter` keeps its sockets on the new bones after a rebuild (a new base, hair or clothes).
|
|
9
|
+
/** The names of the sockets. */
|
|
10
|
+
export const SOCKETS = ["head", "hand", "back"];
|
|
11
|
+
/** The head bone's rest height. */
|
|
12
|
+
const HEAD_BONE_Y = 1.5;
|
|
13
|
+
/** The chest bone's rest height. */
|
|
14
|
+
const CHEST_BONE_Y = 1.12;
|
|
15
|
+
/**
|
|
16
|
+
* The hand's hold: a hand item is modelled standing up (+Y), its grip at the origin, its front at +Z. The hand holds it pointing forward,
|
|
17
|
+
* as a torch is held out: the item's +Y along the character's forward (+Z), its front (+Z) up, its +X to the character's right (-X).
|
|
18
|
+
*/
|
|
19
|
+
export const HAND_HOLD = Object.freeze(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 1).normalize(), Math.PI));
|
|
20
|
+
/**
|
|
21
|
+
* A socket's frame on a base for a look.
|
|
22
|
+
* - "head": on the head bone, at the base's hat frame (`spec.hat`), scaled by the hat frame and the head's size: a thing made for the
|
|
23
|
+
* Classic's head, round the middle of the head (the game starter's hats are made so), fits every head.
|
|
24
|
+
* - "hand": on grip_R, in the middle of the right hand (the hands of a wide body are drawn further out than their bone), turned by
|
|
25
|
+
* `HAND_HOLD`.
|
|
26
|
+
* - "back": on the chest bone, on the back of the torso (`spec.back`, else the chest's height and the torso's radius there); its axes are the
|
|
27
|
+
* character's, so a back item is modelled with the side that touches the body at the origin and its bulk towards -Z.
|
|
28
|
+
*/
|
|
29
|
+
export function socketFrame(spec, look, name) {
|
|
30
|
+
const t = BUILD_FACTORS[look.build] ?? BUILD_FACTORS.regular;
|
|
31
|
+
const hs = look.head;
|
|
32
|
+
if (name === "head") {
|
|
33
|
+
const h = spec.hat;
|
|
34
|
+
return {
|
|
35
|
+
bone: "head",
|
|
36
|
+
position: new THREE.Vector3(0, (h.y - HEAD_BONE_Y) * hs, (h.z ?? 0) * hs),
|
|
37
|
+
quaternion: new THREE.Quaternion(),
|
|
38
|
+
scale: new THREE.Vector3(h.s[0] * hs, h.s[1] * hs, h.s[2] * hs),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (name === "hand") {
|
|
42
|
+
// limbs.ts draws the hand at 0.47 + 0.45 (torso - 1) + armOut from the middle; the bone stays at 0.47.
|
|
43
|
+
const out = 0.45 * (t.torso - 1) + (spec.limbs.armOut ?? 0);
|
|
44
|
+
const at = spec.handAt ?? [0, 0, 0];
|
|
45
|
+
return { bone: "grip_R", position: new THREE.Vector3(-out + at[0], at[1], at[2]), quaternion: HAND_HOLD.clone(), scale: new THREE.Vector3(1, 1, 1) };
|
|
46
|
+
}
|
|
47
|
+
if (name === "back") {
|
|
48
|
+
const y = spec.back?.y ?? CHEST_BONE_Y;
|
|
49
|
+
const z = spec.back?.z ?? -spec.torsoR(y) * t.torso;
|
|
50
|
+
return { bone: "chest", position: new THREE.Vector3(0, y - CHEST_BONE_Y, z), quaternion: new THREE.Quaternion(), scale: new THREE.Vector3(1, 1, 1) };
|
|
51
|
+
}
|
|
52
|
+
throw new TypeError(`@nano133/nanoterra: there is no socket ${JSON.stringify(name)} (the sockets are ${SOCKETS.join(", ")})`);
|
|
53
|
+
}
|
|
54
|
+
/** Puts a socket object on its bone (from `bones`) at its frame. The object keeps its children: a hung item moves with it. */
|
|
55
|
+
export function placeSocket(socket, bones, spec, look, name) {
|
|
56
|
+
const f = socketFrame(spec, look, name);
|
|
57
|
+
const bone = bones[f.bone];
|
|
58
|
+
if (!bone)
|
|
59
|
+
throw new TypeError(`@nano133/nanoterra: the skeleton has no bone ${f.bone} for the socket ${name}`);
|
|
60
|
+
socket.position.copy(f.position);
|
|
61
|
+
socket.quaternion.copy(f.quaternion);
|
|
62
|
+
socket.scale.copy(f.scale);
|
|
63
|
+
bone.add(socket);
|
|
64
|
+
return socket;
|
|
65
|
+
}
|
|
66
|
+
export const isSocketName = (v) => typeof v === "string" && SOCKETS.includes(v);
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ModelLimits } from "./limits.js";
|
|
2
|
+
export type ModelRule = "file" | "size" | "embedded" | "extensions" | "lights" | "motion" | "triangles" | "textures" | "draws" | "zone" | "markers" | "time";
|
|
3
|
+
/** One thing wrong with the model: `fix` is one plain sentence or two, for the creator. */
|
|
4
|
+
export type ModelProblem = {
|
|
5
|
+
rule: ModelRule;
|
|
6
|
+
fix: string;
|
|
7
|
+
};
|
|
8
|
+
export type Vec3 = [number, number, number];
|
|
9
|
+
export type TextureFact = {
|
|
10
|
+
type: "image/png" | "image/jpeg" | "image/webp" | "image/ktx2";
|
|
11
|
+
width: number;
|
|
12
|
+
height: number;
|
|
13
|
+
bytes: number;
|
|
14
|
+
};
|
|
15
|
+
export type ModelFacts = {
|
|
16
|
+
bytes: number;
|
|
17
|
+
/** Triangles drawn (every instance counted; the `mesh` markers, as `nano_collide`, left out). Null when not counted. */
|
|
18
|
+
triangles: number | null;
|
|
19
|
+
textures: TextureFact[];
|
|
20
|
+
/**
|
|
21
|
+
* Draws: each part (glTF primitive) of each drawn mesh instance is one draw call, the `mesh` markers left out. Null when not counted.
|
|
22
|
+
* (A game may join parts or draw an ink outline; this counts the file as it is.)
|
|
23
|
+
*/
|
|
24
|
+
draws: number | null;
|
|
25
|
+
/** The materials that the drawn parts use (a part with no material uses the default one, which counts once). */
|
|
26
|
+
materials: number;
|
|
27
|
+
/**
|
|
28
|
+
* The model's box in its own axes (metres), every drawn mesh and every `mesh` marker in it. Null when not measured. When the limits name an
|
|
29
|
+
* `anchor` and the file has it, the box, the reach and the zone are measured from the anchor's point (`anchor`), not the file's origin.
|
|
30
|
+
*/
|
|
31
|
+
bounds: {
|
|
32
|
+
min: Vec3;
|
|
33
|
+
max: Vec3;
|
|
34
|
+
} | null;
|
|
35
|
+
/** The farthest drawn point from the y axis (x and z together, metres), the `mesh` markers in it. Null when not measured. */
|
|
36
|
+
reach: number | null;
|
|
37
|
+
/** True: the box and the reach come from every vertex. False: past the work cap, from each instance's box (larger, never smaller). */
|
|
38
|
+
exactBounds: boolean;
|
|
39
|
+
/** Where each marker that the file has stands (its node's origin, in the model's axes). */
|
|
40
|
+
markers: Record<string, Vec3>;
|
|
41
|
+
/** The anchor's point in the file's axes (the limits' `anchor` marker), or null: the file's origin is the attach point. */
|
|
42
|
+
anchor: Vec3 | null;
|
|
43
|
+
nodes: number;
|
|
44
|
+
meshes: number;
|
|
45
|
+
extensions: string[];
|
|
46
|
+
};
|
|
47
|
+
/** `advice`: plain lines that would make the model lighter (never a failure: `ok` does not read them). */
|
|
48
|
+
export type ModelCheck = {
|
|
49
|
+
ok: boolean;
|
|
50
|
+
problems: ModelProblem[];
|
|
51
|
+
facts: ModelFacts;
|
|
52
|
+
advice: string[];
|
|
53
|
+
};
|
|
54
|
+
/** The picture's type and size from its bytes (never from the file's word for it), or null. */
|
|
55
|
+
export declare function sniffTexture(b: Uint8Array): Omit<TextureFact, "bytes"> | null;
|
|
56
|
+
/** Checks a .glb against `limits`. Never throws: a file that cannot be read answers a problem. */
|
|
57
|
+
export declare function checkModel(input: Uint8Array, limits: ModelLimits): ModelCheck;
|
|
58
|
+
/** One short line of what was measured: "12,000 triangles, 3 textures, 1.2 MB, 20.0 x 15.0 x 10.0 m". */
|
|
59
|
+
export declare function modelSummary(f: ModelFacts): string;
|