@nano133/nanoterra 0.2.0 → 0.3.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +82 -12
  3. package/ai/SPEC.md +5 -1
  4. package/dist/admin/panel/index.d.ts +1 -1
  5. package/dist/admin/panel/index.js +1 -1
  6. package/dist/admin/panel/shelf.d.ts +7 -0
  7. package/dist/admin/panel/shelf.js +24 -0
  8. package/dist/character/bases/bee.js +7 -5
  9. package/dist/character/bases/spec.d.ts +7 -0
  10. package/dist/character/create.d.ts +15 -0
  11. package/dist/character/create.js +69 -2
  12. package/dist/character/ctx.d.ts +12 -2
  13. package/dist/character/index.d.ts +2 -0
  14. package/dist/character/index.js +2 -0
  15. package/dist/character/make.js +2 -1
  16. package/dist/character/preview.d.ts +32 -0
  17. package/dist/character/preview.js +112 -0
  18. package/dist/character/sockets.d.ts +37 -0
  19. package/dist/character/sockets.js +66 -0
  20. package/dist/model/check.d.ts +59 -0
  21. package/dist/model/check.js +681 -0
  22. package/dist/model/index.d.ts +2 -0
  23. package/dist/model/index.js +5 -0
  24. package/dist/model/limits.d.ts +103 -0
  25. package/dist/model/limits.js +132 -0
  26. package/package.json +3 -2
  27. package/src/admin/panel/index.ts +1 -1
  28. package/src/admin/panel/shelf.ts +24 -0
  29. package/src/admin/panel/style.css +9 -0
  30. package/src/character/bases/bee.ts +7 -5
  31. package/src/character/bases/spec.ts +4 -0
  32. package/src/character/create.ts +73 -2
  33. package/src/character/ctx.ts +17 -2
  34. package/src/character/index.ts +2 -0
  35. package/src/character/make.ts +2 -1
  36. package/src/character/preview.ts +148 -0
  37. package/src/character/sockets.ts +81 -0
  38. package/src/model/check.ts +624 -0
  39. package/src/model/index.ts +5 -0
  40. package/src/model/limits.ts +193 -0
@@ -0,0 +1,148 @@
1
+ import * as THREE from "three";
2
+ import { createCharacter, type Character } from "./create.js";
3
+ import type { LookRecord } from "./record.js";
4
+
5
+ // A picture of a look on the real character, for the Shelf of /admin (or any page): the character is built and dressed once, one frame is
6
+ // drawn with ONE shared offscreen WebGL renderer, and the picture (a PNG data URL) is kept by the look's text, so the same look is drawn
7
+ // once. Pictures are drawn one at a time. With no WebGL, `render` answers null and the page keeps its flat picture.
8
+ //
9
+ // The Shelf (`@nano133/nanoterra/admin/panel`) has no Three.js: give it `previewPicture(load)`, where `load` imports your preview file
10
+ // dynamically, so this file loads only when the Shelf opens (the game starter's src/client/admin.ts does it).
11
+
12
+ /** What draws one look: the game's own character dressed in it (its hat, its models), or by default the package's character. */
13
+ export type PreviewBody<L> = {
14
+ object: THREE.Object3D;
15
+ /** Puts the look on. A promise waits (a model that loads). */
16
+ dress(look: L): void | Promise<void>;
17
+ /** Frees what the body made. */
18
+ dispose(): void;
19
+ };
20
+
21
+ export type LookPreviewOptions<L> = {
22
+ /** The picture's side in CSS pixels (default 112); it is drawn at twice that for a sharp screen. */
23
+ size?: number;
24
+ /** 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. */
25
+ body?: () => PreviewBody<L>;
26
+ /** The record of the default body. */
27
+ record?: LookRecord;
28
+ /** The background (default: none, a clear picture). */
29
+ background?: THREE.ColorRepresentation | null;
30
+ /** The key of a look in the cache (default: its JSON text). */
31
+ key?: (look: L) => string;
32
+ };
33
+
34
+ export type LookPreview<L> = {
35
+ /** A PNG data URL of the look on the character, or null when the page has no WebGL or the drawing failed. */
36
+ render(look: L): Promise<string | null>;
37
+ /** How many pictures are kept. */
38
+ readonly cached: number;
39
+ /** Frees the renderer (and its WebGL context) and the cache. `render` answers null after it. */
40
+ dispose(): void;
41
+ };
42
+
43
+ /** The look as the starter's looks.json has it: a body colour that paints the shirt (the body when there is no top). */
44
+ type ColourLook = { priceUsd?: number; change?: { body?: string } };
45
+
46
+ function defaultBody<L>(record: LookRecord | undefined): () => PreviewBody<L> {
47
+ return () => {
48
+ const c: Character = createCharacter({ record, reducedMotion: true });
49
+ return {
50
+ object: c.object,
51
+ dress(look) {
52
+ const body = (look as ColourLook).change?.body;
53
+ if (body && ((look as ColourLook).priceUsd ?? 1) > 0) c.setColors({ shirt: body });
54
+ },
55
+ dispose: () => c.dispose(),
56
+ };
57
+ };
58
+ }
59
+
60
+ /** A picture maker. Make one for a page and keep it: it holds one WebGL context while it lives. */
61
+ export function createLookPreview<L>(options: LookPreviewOptions<L> = {}): LookPreview<L> {
62
+ const size = options.size ?? 112;
63
+ const makeBody = options.body ?? defaultBody<L>(options.record);
64
+ const keyOf = options.key ?? ((l: L) => JSON.stringify(l));
65
+ const cache = new Map<string, Promise<string | null>>();
66
+ let renderer: THREE.WebGLRenderer | null = null;
67
+ let failed = false;
68
+ let disposed = false;
69
+ let queue: Promise<unknown> = Promise.resolve();
70
+
71
+ const scene = new THREE.Scene();
72
+ if (options.background != null) scene.background = new THREE.Color(options.background);
73
+ scene.add(new THREE.HemisphereLight("#ffffff", "#8a8f9e", 1.6));
74
+ const sun = new THREE.DirectionalLight("#ffffff", 1.6);
75
+ sun.position.set(2, 4, 5);
76
+ scene.add(sun);
77
+ const camera = new THREE.PerspectiveCamera(30, 1, 0.05, 50);
78
+
79
+ function getRenderer(): THREE.WebGLRenderer | null {
80
+ if (renderer || failed) return renderer;
81
+ try {
82
+ // three makes its own canvas (in a page); with no page or no WebGL this throws, and the answer is null.
83
+ renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true });
84
+ renderer.setPixelRatio(1);
85
+ renderer.setSize(size * 2, size * 2, false);
86
+ renderer.setClearColor(0x000000, 0);
87
+ } catch {
88
+ failed = true;
89
+ renderer = null;
90
+ }
91
+ return renderer;
92
+ }
93
+
94
+ async function draw(look: L): Promise<string | null> {
95
+ if (disposed) return null;
96
+ const r = getRenderer();
97
+ if (!r) return null;
98
+ const body = makeBody();
99
+ try {
100
+ await body.dress(look);
101
+ if (disposed) return null;
102
+ scene.add(body.object);
103
+ // Frame the whole character (with what it holds) from the front, a little from its right and above.
104
+ body.object.updateMatrixWorld(true);
105
+ const box = new THREE.Box3().setFromObject(body.object);
106
+ const sphere = box.getBoundingSphere(new THREE.Sphere());
107
+ const dir = new THREE.Vector3(-0.6, 0.15, 1).normalize();
108
+ const dist = sphere.radius / Math.sin(THREE.MathUtils.degToRad(camera.fov / 2)) * 1.02;
109
+ camera.position.copy(sphere.center).addScaledVector(dir, dist);
110
+ camera.lookAt(sphere.center);
111
+ camera.near = Math.max(0.01, dist - sphere.radius * 2);
112
+ camera.far = dist + sphere.radius * 2;
113
+ camera.updateProjectionMatrix();
114
+ r.render(scene, camera);
115
+ return r.domElement.toDataURL("image/png");
116
+ } catch {
117
+ return null;
118
+ } finally {
119
+ scene.remove(body.object);
120
+ body.dispose();
121
+ }
122
+ }
123
+
124
+ return {
125
+ render(look) {
126
+ if (disposed) return Promise.resolve(null);
127
+ const key = keyOf(look);
128
+ const hit = cache.get(key);
129
+ if (hit) return hit;
130
+ // One at a time: the renderer and the scene are shared.
131
+ const p = queue.then(() => draw(look));
132
+ queue = p.catch(() => null);
133
+ cache.set(key, p);
134
+ return p;
135
+ },
136
+ get cached() {
137
+ return cache.size;
138
+ },
139
+ dispose() {
140
+ if (disposed) return;
141
+ disposed = true;
142
+ cache.clear();
143
+ renderer?.dispose();
144
+ renderer?.forceContextLoss();
145
+ renderer = null;
146
+ },
147
+ };
148
+ }
@@ -0,0 +1,81 @@
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
+
6
+ // 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
7
+ // 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,
8
+ // 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.
9
+ //
10
+ // 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
11
+ // head). `socketFrame` works it out; `createCharacter` keeps its sockets on the new bones after a rebuild (a new base, hair or clothes).
12
+
13
+ /** The names of the sockets. */
14
+ export const SOCKETS = ["head", "hand", "back"] as const;
15
+ export type SocketName = (typeof SOCKETS)[number];
16
+
17
+ /** 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). */
18
+ export type SocketFrame = { bone: BoneName; position: THREE.Vector3; quaternion: THREE.Quaternion; scale: THREE.Vector3 };
19
+
20
+ /** The head bone's rest height. */
21
+ const HEAD_BONE_Y = 1.5;
22
+ /** The chest bone's rest height. */
23
+ const CHEST_BONE_Y = 1.12;
24
+
25
+ /**
26
+ * 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,
27
+ * 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).
28
+ */
29
+ export const HAND_HOLD = Object.freeze(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 1).normalize(), Math.PI));
30
+
31
+ /** What `socketFrame` reads of a look: its build and its head's size. */
32
+ export type SocketLook = { build: keyof typeof BUILD_FACTORS; head: number };
33
+
34
+ /**
35
+ * A socket's frame on a base for a look.
36
+ * - "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
37
+ * Classic's head, round the middle of the head (the game starter's hats are made so), fits every head.
38
+ * - "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
39
+ * `HAND_HOLD`.
40
+ * - "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
41
+ * character's, so a back item is modelled with the side that touches the body at the origin and its bulk towards -Z.
42
+ */
43
+ export function socketFrame(spec: BaseSpec, look: SocketLook, name: SocketName): SocketFrame {
44
+ const t = BUILD_FACTORS[look.build] ?? BUILD_FACTORS.regular;
45
+ const hs = look.head;
46
+ if (name === "head") {
47
+ const h = spec.hat;
48
+ return {
49
+ bone: "head",
50
+ position: new THREE.Vector3(0, (h.y - HEAD_BONE_Y) * hs, (h.z ?? 0) * hs),
51
+ quaternion: new THREE.Quaternion(),
52
+ scale: new THREE.Vector3(h.s[0] * hs, h.s[1] * hs, h.s[2] * hs),
53
+ };
54
+ }
55
+ if (name === "hand") {
56
+ // limbs.ts draws the hand at 0.47 + 0.45 (torso - 1) + armOut from the middle; the bone stays at 0.47.
57
+ const out = 0.45 * (t.torso - 1) + (spec.limbs.armOut ?? 0);
58
+ const at = spec.handAt ?? [0, 0, 0];
59
+ 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) };
60
+ }
61
+ if (name === "back") {
62
+ const y = spec.back?.y ?? CHEST_BONE_Y;
63
+ const z = spec.back?.z ?? -spec.torsoR(y) * t.torso;
64
+ return { bone: "chest", position: new THREE.Vector3(0, y - CHEST_BONE_Y, z), quaternion: new THREE.Quaternion(), scale: new THREE.Vector3(1, 1, 1) };
65
+ }
66
+ throw new TypeError(`@nano133/nanoterra: there is no socket ${JSON.stringify(name)} (the sockets are ${SOCKETS.join(", ")})`);
67
+ }
68
+
69
+ /** Puts a socket object on its bone (from `bones`) at its frame. The object keeps its children: a hung item moves with it. */
70
+ export function placeSocket(socket: THREE.Object3D, bones: Partial<Record<BoneName, THREE.Object3D>>, spec: BaseSpec, look: SocketLook, name: SocketName) {
71
+ const f = socketFrame(spec, look, name);
72
+ const bone = bones[f.bone];
73
+ if (!bone) throw new TypeError(`@nano133/nanoterra: the skeleton has no bone ${f.bone} for the socket ${name}`);
74
+ socket.position.copy(f.position);
75
+ socket.quaternion.copy(f.quaternion);
76
+ socket.scale.copy(f.scale);
77
+ bone.add(socket);
78
+ return socket;
79
+ }
80
+
81
+ export const isSocketName = (v: unknown): v is SocketName => typeof v === "string" && (SOCKETS as readonly string[]).includes(v);