@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,2 @@
1
+ export { checkModel, modelSummary, sniffTexture, type ModelCheck, type ModelFacts, type ModelProblem, type ModelRule, type TextureFact, type Vec3 } from "./check.js";
2
+ export { BACK_ITEM, COSMETIC_LIMITS, HAND_ITEM, HEAD_ITEM, ISLAND_MODEL, PLAIN_EXTENSIONS, type MarkerRule, type ModelLimits, type ZoneBox, type ZoneDisc } from "./limits.js";
@@ -0,0 +1,5 @@
1
+ // "@nano133/nanoterra/model": the 3D model check. Pure: no Three.js, no network, no DOM. It reads a .glb from its bytes and says what is
2
+ // wrong with it (with a fix for each), what it measured and how to make it lighter, against a preset of limits: an island model, or a
3
+ // cosmetic for one of the character's three sockets (head, hand, back).
4
+ export { checkModel, modelSummary, sniffTexture } from "./check.js";
5
+ export { BACK_ITEM, COSMETIC_LIMITS, HAND_ITEM, HEAD_ITEM, ISLAND_MODEL, PLAIN_EXTENSIONS } from "./limits.js";
@@ -0,0 +1,103 @@
1
+ /** A named node the model must (or may) have. `empty`: a node with no mesh (a point, as `nano_door`); `mesh`: a node with a mesh that is never drawn (a collider). */
2
+ export type MarkerRule = {
3
+ name: string;
4
+ required: boolean;
5
+ kind: "empty" | "mesh"; /** A `mesh` marker's own triangle limit (it is not counted in `maxTriangles`). */
6
+ maxTriangles?: number; /** What the marker is for, in plain words, for a fix line. */
7
+ purpose: string;
8
+ };
9
+ export type ZoneBox = {
10
+ width: number;
11
+ depth: number;
12
+ height: number;
13
+ below: number;
14
+ };
15
+ export type ZoneDisc = {
16
+ radius: number;
17
+ height: number;
18
+ below: number;
19
+ };
20
+ export type ModelLimits = {
21
+ /** What the model is, for a fix line ("island model"). */
22
+ what: string;
23
+ /** The whole .glb, in bytes. */
24
+ maxBytes: number;
25
+ /** The JSON chunk, in bytes (the model's description; the data is in the BIN chunk). */
26
+ maxJsonBytes: number;
27
+ /** Triangles drawn, every mesh instance counted, the `mesh` markers left out. */
28
+ maxTriangles: number;
29
+ /** Draw calls: each part (glTF primitive) of each drawn mesh instance. Left out: no limit. */
30
+ maxDraws?: number;
31
+ /** Images in the file. */
32
+ maxTextures: number;
33
+ /** The longest side of each image, in pixels. */
34
+ maxTextureSide: number;
35
+ /**
36
+ * The build zone, in metres, in the model's own axes (glTF: +Y up). A box: x from -width/2 to width/2, z from -depth/2
37
+ * to depth/2. A disc: every point at most `radius` from the y axis (x and z together). Both: y from -below to height.
38
+ * The model's origin is the middle of the plot, on the ground.
39
+ */
40
+ zone: ZoneBox | ZoneDisc;
41
+ markers: readonly MarkerRule[];
42
+ /**
43
+ * A marker whose origin is the attach point (a hand item's `grip_R`): when the file has it, the zone is measured from it, not from the
44
+ * file's origin. It should be one of `markers` too (so a misspelt name gets a fix line).
45
+ */
46
+ anchor?: string;
47
+ /** The glTF extensions the file may use (plain data only). Every other one is refused. */
48
+ extensions: readonly string[];
49
+ /** Animations, skins and morph targets: refused unless allowed. When allowed, the zone is checked in the rest pose only. */
50
+ allowAnimations: boolean;
51
+ allowSkins: boolean;
52
+ allowMorphTargets: boolean;
53
+ /** Caps on the description, so a hostile file costs little to read. */
54
+ maxNodes: number;
55
+ maxMeshes: number;
56
+ maxPrimitives: number;
57
+ maxAccessors: number;
58
+ maxBufferViews: number;
59
+ maxMaterials: number;
60
+ /** The node tree's depth. */
61
+ maxDepth: number;
62
+ /** Vertex reads for the exact zone check (every rotated instance reads its vertices); past it, the box of each instance is used (larger, never smaller). */
63
+ maxVertexWork: number;
64
+ /** The whole check ends by then (ms). */
65
+ maxMs: number;
66
+ };
67
+ /** The texture compression and material extensions three.js reads as plain data. */
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
+ /**
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)
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
74
+ * optional simple `nano_collide` mesh. No lights, no scripts, nothing outside the file. A still model for now.
75
+ */
76
+ export declare const ISLAND_MODEL: ModelLimits;
77
+ /**
78
+ * A worn thing on the character's head socket (@nano133/nanoterra/character, `socket("head")`; Wes approved the cosmetic limits on
79
+ * 2026-10-28). The origin is the socket's point, the middle of the hat frame of the Classic's head, in metres for a character
80
+ * 2.29 m tall (the socket scales it to every other head). The zone is 0.9 x 0.9 m and 0.8 m high, from 0.1 m below the origin.
81
+ * 3,000 triangles, 1 texture of at most 512 px, 2 draws, 300 KB. Rigid: no skin, no animation (it rides its bone).
82
+ */
83
+ export declare const HEAD_ITEM: ModelLimits;
84
+ /**
85
+ * A worn thing in the character's right hand (`socket("hand")`). Its attach point is its `grip_R` marker (an empty node where the right
86
+ * hand holds it), else the file's origin; a `grip_L` marker (where a left hand would hold it) is allowed. The zone is 0.96 x 0.68 m and
87
+ * 1.6 m long, from 1.17 m below the grip to 0.43 m above it: a tool held near its top, as a shovel by its T-handle (Wes chose this zone on
88
+ * 2026-10-28 over the first 0.4 x 0.4 m from 0.3 m below, which the example shovel did not fit). 3,000 triangles, 1 texture of at most
89
+ * 512 px, 2 draws, 300 KB.
90
+ */
91
+ export declare const HAND_ITEM: ModelLimits;
92
+ /**
93
+ * A worn thing on the character's back (`socket("back")`). The origin is the socket's point, on the back of the torso at the chest's
94
+ * height; the side that touches the body is at the origin and the bulk goes back (-Z). The zone is 1.0 m wide, 0.6 m deep (its middle at
95
+ * the origin) and 1.2 m high, from 0.6 m below. 4,000 triangles, 1 texture of at most 512 px, 2 draws, 400 KB.
96
+ */
97
+ export declare const BACK_ITEM: ModelLimits;
98
+ /** The cosmetic preset of each socket. */
99
+ export declare const COSMETIC_LIMITS: {
100
+ readonly head: ModelLimits;
101
+ readonly hand: ModelLimits;
102
+ readonly back: ModelLimits;
103
+ };
@@ -0,0 +1,132 @@
1
+ // The limits a 3D model is checked against (@nano133/nanoterra/model; the README's "Model check"; on nano133.com
2
+ // docs/agents/launch.md, "The island model"). The checker (check.ts) is general: it takes one of these objects, so each
3
+ // kind of model (an island, a cosmetic on each socket) is a preset here with the same code. Pure data: no network, no DOM.
4
+ /** The texture compression and material extensions three.js reads as plain data. */
5
+ export const PLAIN_EXTENSIONS = [
6
+ "KHR_texture_transform",
7
+ "KHR_texture_basisu",
8
+ "EXT_texture_webp",
9
+ "KHR_mesh_quantization",
10
+ "KHR_materials_emissive_strength",
11
+ "KHR_materials_unlit",
12
+ "KHR_materials_ior",
13
+ "KHR_materials_specular",
14
+ "KHR_materials_clearcoat",
15
+ "KHR_materials_sheen",
16
+ "KHR_materials_transmission",
17
+ "KHR_materials_volume",
18
+ "KHR_materials_iridescence",
19
+ "KHR_materials_anisotropy",
20
+ "KHR_materials_dispersion",
21
+ "KHR_xmp_json_ld",
22
+ ];
23
+ /**
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)
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
28
+ * optional simple `nano_collide` mesh. No lights, no scripts, nothing outside the file. A still model for now.
29
+ */
30
+ export const ISLAND_MODEL = {
31
+ what: "island model",
32
+ maxBytes: 5 * 1024 * 1024,
33
+ maxJsonBytes: 1024 * 1024,
34
+ maxTriangles: 50_000,
35
+ maxTextures: 8,
36
+ maxTextureSide: 1024,
37
+ zone: { radius: 10, height: 18, below: 0 },
38
+ markers: [
39
+ { name: "nano_door", required: true, kind: "empty", purpose: "where Play stands" },
40
+ { name: "nano_spawn", required: false, kind: "empty", purpose: "where players arrive" },
41
+ { name: "nano_collide", required: false, kind: "mesh", maxTriangles: 5_000, purpose: "the simple shape players walk on" },
42
+ ],
43
+ extensions: PLAIN_EXTENSIONS,
44
+ allowAnimations: false,
45
+ allowSkins: false,
46
+ allowMorphTargets: false,
47
+ maxNodes: 4096,
48
+ maxMeshes: 1024,
49
+ maxPrimitives: 4096,
50
+ maxAccessors: 16_384,
51
+ maxBufferViews: 16_384,
52
+ maxMaterials: 256,
53
+ maxDepth: 64,
54
+ maxVertexWork: 4_000_000,
55
+ maxMs: 2_000,
56
+ };
57
+ /**
58
+ * The caps on the description of a cosmetic: a worn thing is small, so a file far past these is not one (and costs little to refuse).
59
+ */
60
+ const COSMETIC_CAPS = {
61
+ maxJsonBytes: 256 * 1024,
62
+ extensions: PLAIN_EXTENSIONS,
63
+ allowAnimations: false,
64
+ allowSkins: false,
65
+ allowMorphTargets: false,
66
+ maxNodes: 256,
67
+ maxMeshes: 32,
68
+ maxPrimitives: 64,
69
+ maxAccessors: 1024,
70
+ maxBufferViews: 1024,
71
+ maxMaterials: 16,
72
+ maxDepth: 32,
73
+ maxVertexWork: 500_000,
74
+ maxMs: 1_000,
75
+ };
76
+ /**
77
+ * A worn thing on the character's head socket (@nano133/nanoterra/character, `socket("head")`; Wes approved the cosmetic limits on
78
+ * 2026-10-28). The origin is the socket's point, the middle of the hat frame of the Classic's head, in metres for a character
79
+ * 2.29 m tall (the socket scales it to every other head). The zone is 0.9 x 0.9 m and 0.8 m high, from 0.1 m below the origin.
80
+ * 3,000 triangles, 1 texture of at most 512 px, 2 draws, 300 KB. Rigid: no skin, no animation (it rides its bone).
81
+ */
82
+ export const HEAD_ITEM = {
83
+ ...COSMETIC_CAPS,
84
+ what: "head item",
85
+ maxBytes: 300 * 1024,
86
+ maxTriangles: 3_000,
87
+ maxDraws: 2,
88
+ maxTextures: 1,
89
+ maxTextureSide: 512,
90
+ zone: { width: 0.9, depth: 0.9, height: 0.7, below: 0.1 },
91
+ markers: [],
92
+ };
93
+ /**
94
+ * A worn thing in the character's right hand (`socket("hand")`). Its attach point is its `grip_R` marker (an empty node where the right
95
+ * hand holds it), else the file's origin; a `grip_L` marker (where a left hand would hold it) is allowed. The zone is 0.96 x 0.68 m and
96
+ * 1.6 m long, from 1.17 m below the grip to 0.43 m above it: a tool held near its top, as a shovel by its T-handle (Wes chose this zone on
97
+ * 2026-10-28 over the first 0.4 x 0.4 m from 0.3 m below, which the example shovel did not fit). 3,000 triangles, 1 texture of at most
98
+ * 512 px, 2 draws, 300 KB.
99
+ */
100
+ export const HAND_ITEM = {
101
+ ...COSMETIC_CAPS,
102
+ what: "hand item",
103
+ maxBytes: 300 * 1024,
104
+ maxTriangles: 3_000,
105
+ maxDraws: 2,
106
+ maxTextures: 1,
107
+ maxTextureSide: 512,
108
+ zone: { width: 0.96, depth: 0.68, height: 0.43, below: 1.17 },
109
+ markers: [
110
+ { name: "grip_R", required: false, kind: "empty", purpose: "where the right hand holds it" },
111
+ { name: "grip_L", required: false, kind: "empty", purpose: "where a left hand would hold it" },
112
+ ],
113
+ anchor: "grip_R",
114
+ };
115
+ /**
116
+ * A worn thing on the character's back (`socket("back")`). The origin is the socket's point, on the back of the torso at the chest's
117
+ * height; the side that touches the body is at the origin and the bulk goes back (-Z). The zone is 1.0 m wide, 0.6 m deep (its middle at
118
+ * the origin) and 1.2 m high, from 0.6 m below. 4,000 triangles, 1 texture of at most 512 px, 2 draws, 400 KB.
119
+ */
120
+ export const BACK_ITEM = {
121
+ ...COSMETIC_CAPS,
122
+ what: "back item",
123
+ maxBytes: 400 * 1024,
124
+ maxTriangles: 4_000,
125
+ maxDraws: 2,
126
+ maxTextures: 1,
127
+ maxTextureSide: 512,
128
+ zone: { width: 1.0, depth: 0.6, height: 0.6, below: 0.6 },
129
+ markers: [],
130
+ };
131
+ /** The cosmetic preset of each socket. */
132
+ export const COSMETIC_LIMITS = { head: HEAD_ITEM, hand: HAND_ITEM, back: BACK_ITEM };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nano133/nanoterra",
3
- "version": "0.2.0",
3
+ "version": "0.3.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) and the looks ladder.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -11,6 +11,7 @@
11
11
  "./character/record": { "types": "./dist/character/pure.d.ts", "import": "./dist/character/pure.js" },
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
+ "./model": { "types": "./dist/model/index.d.ts", "import": "./dist/model/index.js" },
14
15
  "./admin": { "types": "./dist/admin/index.d.ts", "import": "./dist/admin/index.js" },
15
16
  "./admin/conformance": { "types": "./dist/admin/conformance.d.ts", "import": "./dist/admin/conformance.js" },
16
17
  "./admin/d1": { "types": "./dist/admin/adapters/d1.d.ts", "import": "./dist/admin/adapters/d1.js" },
@@ -44,5 +45,5 @@
44
45
  },
45
46
  "homepage": "https://nano133.com/build/nanoterra",
46
47
  "license": "MIT",
47
- "keywords": ["nano", "xno", "nanoterra", "sign in", "ticket", "character", "avatar", "three", "three.js", "toon", "looks", "cosmetics"]
48
+ "keywords": ["nano", "gltf", "glb", "model check", "xno", "nanoterra", "sign in", "ticket", "character", "avatar", "three", "three.js", "toon", "looks", "cosmetics"]
48
49
  }
@@ -11,7 +11,7 @@ export type { MountedAdmin, MountOptions } from "./mount.js";
11
11
  export { ApiError, createApi } from "./api.js";
12
12
  export type { Api } from "./api.js";
13
13
  export { gameCard } from "./gameCard.js";
14
- export { lookPicture, shelfView } from "./shelf.js";
14
+ export { lookPicture, previewPicture, shelfView } from "./shelf.js";
15
15
  export { DEFAULT_LOGINS_NOTE, signinsCard } from "./signins.js";
16
16
  export type { ShelfData } from "./shelf.js";
17
17
  export { aiPrompt, centsOf, changeText, ownedIds, shelfStats, soldText } from "./shelfData.js";
@@ -45,6 +45,30 @@ export function lookPicture(l: Look): Element {
45
45
  );
46
46
  }
47
47
 
48
+ /**
49
+ * A Shelf picture that shows the look on the real character. `load` gives the drawing function (a look to a PNG address, or null), and is
50
+ * called once, the first time a picture is asked for: let it import your preview file dynamically, so Three.js loads only when the Shelf
51
+ * opens (this file never imports it; the game starter's src/client/admin.ts shows how). Until the picture is drawn, and when it cannot be (no WebGL, a failure), the flat picture stays.
52
+ * `@nano133/nanoterra/character` has `createLookPreview` to make the drawing function.
53
+ */
54
+ export function previewPicture(load: () => Promise<(l: Look) => Promise<string | null>>, fallback: (l: Look) => Element = lookPicture, size = 64): (l: Look) => Element {
55
+ let ready: Promise<((l: Look) => Promise<string | null>) | null> | null = null;
56
+ return (l) => {
57
+ const box = h("span", { class: "shelf-preview" }, fallback(l));
58
+ ready ??= load().catch(() => null);
59
+ const look: Look = { ...l, change: { ...l.change }, ...(l.slots ? { slots: { ...l.slots } } : {}) };
60
+ void ready
61
+ .then((render) => (render ? render(look) : null))
62
+ .then((src) => {
63
+ if (!src) return;
64
+ const img = h("img", { src, width: size, height: size, alt: `${l.name || "A look"} on the character` }) as HTMLImageElement;
65
+ box.replaceChildren(img);
66
+ })
67
+ .catch(() => undefined);
68
+ return box;
69
+ };
70
+ }
71
+
48
72
  const clone = (ls: readonly Look[]): Look[] => ls.map((l) => ({ id: l.id, name: l.name, priceUsd: l.priceUsd, change: { ...l.change }, ...(l.slots ? { slots: { ...l.slots } } : {}) }));
49
73
 
50
74
  /** "wings ember, trail #ffcf5a" */
@@ -264,6 +264,15 @@
264
264
  background: #e8f3fb;
265
265
  }
266
266
 
267
+ .n133-admin .shelf-preview {
268
+ display: grid;
269
+ place-items: center;
270
+ }
271
+
272
+ .n133-admin .shelf-preview img {
273
+ display: block;
274
+ }
275
+
267
276
  .n133-admin .shelf-text {
268
277
  display: grid;
269
278
  min-width: 0;
@@ -30,11 +30,13 @@ export const bee: BaseSpec = {
30
30
  addFace(c);
31
31
  if (!c.headOnly) {
32
32
  c.addBody(G.cone8, mtx(0, 0.72, -0.5, -Math.PI / 2 + 0.25, 0, 0, 0.1, 0.32, 0.1), "stripe", "hips");
33
- // The wings: a bone at each shoulder blade; a long wing and a short one on it.
34
- for (const [s, name] of [[1, "wing_L"], [-1, "wing_R"]] as const) {
35
- c.b.bone(name, "chest", [s * 0.12, 1.4, -0.3]);
36
- c.add(SPH_S, mtx(s * 0.3, 1.78, -0.4, 0.2, 0, s * 1.15, 0.4, 0.04, 0.22), "wing", name);
37
- c.add(SPH_S, mtx(s * 0.36, 1.52, -0.42, 0.15, 0, s * 0.8, 0.28, 0.035, 0.15), "wing", name);
33
+ // The wings: a bone at each shoulder blade; a long wing and a short one on it. A back item covers them (`hideWings`): no wing, no bone.
34
+ if (!c.hideWings) {
35
+ for (const [s, name] of [[1, "wing_L"], [-1, "wing_R"]] as const) {
36
+ c.b.bone(name, "chest", [s * 0.12, 1.4, -0.3]);
37
+ c.add(SPH_S, mtx(s * 0.3, 1.78, -0.4, 0.2, 0, s * 1.15, 0.4, 0.04, 0.22), "wing", name);
38
+ c.add(SPH_S, mtx(s * 0.36, 1.52, -0.42, 0.15, 0, s * 0.8, 0.28, 0.035, 0.15), "wing", name);
39
+ }
38
40
  }
39
41
  }
40
42
  limbs(c, bee.limbs);
@@ -14,5 +14,9 @@ export type BaseSpec = {
14
14
  limbs: Limbs;
15
15
  /** Where a hat sits: the hat frame's centre height (a Classic hat is built for 1.83) and its scale. */
16
16
  hat: { y: number; z?: number; s: readonly [number, number, number] };
17
+ /** The hand socket's place from the middle of the drawn right hand, when it is not that middle (sockets.ts). */
18
+ handAt?: readonly [number, number, number];
19
+ /** The back socket: its height (left out: the chest bone's, 1.12) and its depth (left out: the torso's radius there, behind). */
20
+ back?: { y: number; z?: number };
17
21
  build(c: Ctx): void;
18
22
  };
@@ -7,11 +7,14 @@ import { buildWith, paintCharacter, type Made } from "./make.js";
7
7
  import { makePose, MAX_DT, type Motion } from "./pose.js";
8
8
  import { clean, DEFAULT_RECORD, resolve, toLegacy, withLegacy, type CharColors, type LookRecord } from "./record.js";
9
9
  import type { BoneName } from "./skeleton.js";
10
+ import type { WornAdapter } from "./ctx.js";
11
+ import { isSocketName, placeSocket, SOCKETS, type SocketName } from "./sockets.js";
10
12
  import { followCanvas, inkMaterial, makeRamp, toonMaterial } from "./toon.js";
11
13
 
12
14
  // createCharacter: the character as ONE object for a plain Three.js game. It builds the player's record (record.ts) with the kit (make.ts), binds it
13
15
  // to the island's skeleton (assemble.ts), gives it the toon material and the ink line (toon.ts) and plays the island's idle, walk, run, jump and wave
14
- // on it (pose.ts, solver.ts) from the numbers the game gives each frame. The body only: a worn item is the host's (ctx.ts `WornAdapter`).
16
+ // on it (pose.ts, solver.ts) from the numbers the game gives each frame. A worn item is the host's (ctx.ts `WornAdapter`): its shapes merge into the
17
+ // body, or its own model hangs on one of the three sockets (sockets.ts), which stay on the new bones after a rebuild.
15
18
  // The blob shadow is a part of the body (a disc on the skeleton's shadow bone): it lies on the ground under the character in a jump and shrinks.
16
19
 
17
20
  /** The character's height in metres at the record's normal size: from the soles to the top of the hair (the Classic base). Another base, hair or size is not this tall: measure it with a Box3. */
@@ -33,6 +36,8 @@ export type CharacterOptions = {
33
36
  quality?: Quality;
34
37
  /** Starts with the walk's swing and bob off, as the island does for a player who asks for less motion. */
35
38
  reducedMotion?: boolean;
39
+ /** Worn things (ctx.ts `WornAdapter`): shapes merged into the body, or a model on a socket. Change them with `setWorn`. */
40
+ worn?: readonly WornAdapter[];
36
41
  };
37
42
 
38
43
  export interface Character {
@@ -63,6 +68,17 @@ export interface Character {
63
68
  setLook(next: LookRecord | Look): void;
64
69
  /** Paints these colours over the record's, or none (`null`): the record's own come back. It builds no geometry. */
65
70
  setColors(colors: Partial<CharColors> | null): void;
71
+ /**
72
+ * An attach point: "head" (at the base's hat frame, scaled with the head), "hand" (in the right hand, on grip_R) or "back" (on the chest,
73
+ * behind). Add your own object to it and it follows the bone. It is the same object for the character's whole life: a new base, hair or
74
+ * clothes (a rebuild) moves it, with what hangs on it, to the new bones and to the new base's frame. An unknown name throws.
75
+ */
76
+ socket(name: SocketName): THREE.Object3D;
77
+ /**
78
+ * Changes the worn things. Shapes that merge (`add`) and the hair's fit build new geometry; a thing that only hangs a model on a socket
79
+ * builds none. The objects of the things that are no longer worn come off their socket (the game frees them: the character never does).
80
+ */
81
+ setWorn(worn: readonly WornAdapter[]): void;
66
82
  /**
67
83
  * Frees the geometries, materials and textures that the character made, and takes `object` out of its parent. Later calls to the other methods do
68
84
  * nothing.
@@ -85,6 +101,22 @@ function checkColors(colors: unknown): Partial<CharColors> {
85
101
  return out;
86
102
  }
87
103
 
104
+ /** A worn thing that changes the body's geometry: its own shapes, or what it does to the hair, the badge, the tail or the wings. */
105
+ const merges = (w: WornAdapter) => !!(w.add || w.fit || w.hidesBadge || w.hideTail || w.hideWings);
106
+
107
+ function checkWorn(worn: unknown): readonly WornAdapter[] {
108
+ if (!Array.isArray(worn)) throw new TypeError("@nano133/nanoterra: worn must be a list of worn things");
109
+ worn.forEach((w: unknown, i) => {
110
+ const v = w as WornAdapter | null;
111
+ if (typeof v !== "object" || v === null) throw new TypeError(`@nano133/nanoterra: worn[${i}] is not an object`);
112
+ if (v.add !== undefined && typeof v.add !== "function") throw new TypeError(`@nano133/nanoterra: worn[${i}].add must be a function`);
113
+ if (v.socket !== undefined && !isSocketName(v.socket)) throw new TypeError(`@nano133/nanoterra: worn[${i}].socket must be one of ${SOCKETS.join(", ")}, not ${JSON.stringify(v.socket)}`);
114
+ if ((v.object === undefined) !== (v.socket === undefined)) throw new TypeError(`@nano133/nanoterra: worn[${i}] needs both a socket and an object, or neither`);
115
+ if (v.object !== undefined && !(v.object as THREE.Object3D)?.isObject3D) throw new TypeError(`@nano133/nanoterra: worn[${i}].object must be a THREE.Object3D`);
116
+ });
117
+ return [...worn];
118
+ }
119
+
88
120
  const frozen = (rec: LookRecord): Readonly<LookRecord> => Object.freeze({ ...clean(rec) });
89
121
 
90
122
  /** A record from a record or five colours; `from`: the record the five colours go into. Throws a TypeError that says what is wrong. */
@@ -110,6 +142,15 @@ export function createCharacter(options: CharacterOptions = {}): Character {
110
142
  }
111
143
  let record: LookRecord = opts.record !== undefined ? recordOf(opts.record, null) : opts.look !== undefined ? recordOf(opts.look, null) : { ...DEFAULT_RECORD };
112
144
  let over: Partial<CharColors> | null = opts.colors === undefined ? null : checkColors(opts.colors);
145
+ let worn: readonly WornAdapter[] = checkWorn(opts.worn ?? []);
146
+ /** The three sockets: made once, moved to the new bones on every rebuild. */
147
+ const sockets = Object.fromEntries(
148
+ SOCKETS.map((n) => {
149
+ const g = new THREE.Group();
150
+ g.name = `socket-${n}`;
151
+ return [n, g];
152
+ }),
153
+ ) as Record<SocketName, THREE.Group>;
113
154
 
114
155
  const ramp = makeRamp();
115
156
  const toon = toonMaterial(ramp);
@@ -125,7 +166,10 @@ export function createCharacter(options: CharacterOptions = {}): Character {
125
166
  type Rig = { made: Made; a: Assembled; pose: ReturnType<typeof makePose> };
126
167
  const colorsNow = (): CharColors => ({ ...resolve(record).colors, ...over });
127
168
  function build(): Rig {
128
- const made = buildWith(BASE_SPECS[resolve(record).base], resolve(record));
169
+ const look = resolve(record);
170
+ const spec = BASE_SPECS[look.base];
171
+ const merged = worn.filter(merges);
172
+ const made = buildWith(spec, look, merged.length ? { worn: merged } : {});
129
173
  paintCharacter(made, colorsNow());
130
174
  const a = assemble(made, { toon, ink: ink ?? toon });
131
175
  a.mesh.name = "character-toon";
@@ -139,10 +183,16 @@ export function createCharacter(options: CharacterOptions = {}): Character {
139
183
  a.group.scale.setScalar(HEIGHTS[record.height] ?? 1);
140
184
  object.add(a.group);
141
185
  const pose = makePose(a.list, a.bones as Record<BoneName, THREE.Bone>, unit);
186
+ for (const n of SOCKETS) placeSocket(sockets[n], a.bones, spec, look, n);
142
187
  return { made, a, pose };
143
188
  }
144
189
  let rig = build();
145
190
  rig.pose.rest(reducedMotion);
191
+ /** Hangs each worn model on its socket (in the order of the list). */
192
+ const hang = () => {
193
+ for (const w of worn) if (w.object && w.socket) sockets[w.socket].add(w.object);
194
+ };
195
+ hang();
146
196
 
147
197
  /** Frees a rig's geometry and skeleton. A rig that is replaced also leaves the object; a character that is disposed keeps its (dead) meshes, as it always did. */
148
198
  const free = (r: Rig, replaced: boolean) => {
@@ -201,10 +251,31 @@ export function createCharacter(options: CharacterOptions = {}): Character {
201
251
  over = colors === null ? null : checkColors(colors);
202
252
  paintCharacter(rig.made, colorsNow());
203
253
  },
254
+ socket(name) {
255
+ if (!isSocketName(name)) throw new TypeError(`@nano133/nanoterra: there is no socket ${JSON.stringify(name)} (the sockets are ${SOCKETS.join(", ")})`);
256
+ return sockets[name];
257
+ },
258
+ setWorn(next) {
259
+ if (disposed) return;
260
+ const to = checkWorn(next);
261
+ const rebuild = worn.some(merges) || to.some(merges);
262
+ for (const w of worn) if (w.object && !to.some((x) => x.object === w.object)) w.object.removeFromParent();
263
+ worn = to;
264
+ if (rebuild) {
265
+ const was = rig;
266
+ rig = build();
267
+ Object.assign(rig.pose.state, was.pose.state);
268
+ rig.pose.rest(reducedMotion);
269
+ free(was, true);
270
+ }
271
+ hang();
272
+ },
204
273
  dispose() {
205
274
  if (disposed) return;
206
275
  disposed = true;
207
276
  object.removeFromParent();
277
+ // The sockets leave the dead bones; what hangs on them is the game's.
278
+ for (const n of SOCKETS) sockets[n].removeFromParent();
208
279
  free(rig, false);
209
280
  toon.dispose();
210
281
  ink?.dispose();
@@ -2,6 +2,7 @@ import * as THREE from "three";
2
2
  import type { Builder, Paint, Weights } from "./build.js";
3
3
  import type { Limbs } from "./limbs.js";
4
4
  import type { CharLook } from "./record.js";
5
+ import type { SocketName } from "./sockets.js";
5
6
 
6
7
  // What a part gets to build with: the builder, the look, the size factors and the paint of each body segment (clothes.ts).
7
8
 
@@ -57,9 +58,21 @@ export type HatPart = { fit: HairFit; add(c: Ctx): void };
57
58
  * island's adapter (the shop's hats, scarves, packs, tops, buddies and blades, each fitted to every base) builds these; a game may bring its own.
58
59
  * `add` puts the thing's shapes and bones into the build after the body, in order, through the same context as the body's (`c.add`, `c.addHead`,
59
60
  * `c.b.bone`). A head item also says what it does to the hair (`fit`); the first that does decides. `hidesBadge`: the chest badge would poke through it.
60
- * `hideTail`: a back item (a cape, a pack, a plush) or a pinafore covers a base's tail.
61
+ * `hideTail`: a back item (a cape, a pack, a plush) or a pinafore covers a base's tail. `hideWings`: a back item covers a base's wings (the Bee's).
62
+ *
63
+ * A thing with its own model (a .glb, say) names a `socket` ("head", "hand" or "back", sockets.ts) and gives its `object`: `createCharacter`
64
+ * hangs the object on that socket (its own draws: 1 or 2 more for each thing) and still reads `fit`, `hidesBadge`, `hideTail` and `hideWings`. The game
65
+ * owns the object: the character never frees it. `add` is then optional (a thing can have both: merged shapes and a model).
61
66
  */
62
- export type WornAdapter = { fit?: HairFit; hidesBadge?: boolean; hideTail?: boolean; add(c: Ctx): void };
67
+ export type WornAdapter = {
68
+ fit?: HairFit;
69
+ hidesBadge?: boolean;
70
+ hideTail?: boolean;
71
+ hideWings?: boolean;
72
+ add?(c: Ctx): void;
73
+ socket?: SocketName;
74
+ object?: THREE.Object3D;
75
+ };
63
76
  /** A worn thing, by its older name (the same type as `WornAdapter`). */
64
77
  export type WornPart = WornAdapter;
65
78
 
@@ -79,6 +92,8 @@ export type Ctx = {
79
92
  hidesBadge: boolean;
80
93
  /** A worn back item covers the base's tail (a Critter's). */
81
94
  hideTail: boolean;
95
+ /** A worn back item covers the base's wings (a Bee's). */
96
+ hideWings: boolean;
82
97
  /** The base's arms and legs (a worn cuff follows the arm). */
83
98
  limbs: Limbs;
84
99
  /** Only the head and what sits on it (the pictures of faces and hats). */
@@ -27,3 +27,5 @@ export * from "./edit.js";
27
27
  export * from "./look.js";
28
28
  export { createCharacter, HEIGHT, RADIUS, type Character, type CharacterOptions, type Quality } from "./create.js";
29
29
  export { WAVE_SECONDS, MAX_DT, type Motion } from "./pose.js";
30
+ export { createLookPreview, type LookPreview, type LookPreviewOptions, type PreviewBody } from "./preview.js";
31
+ export { SOCKETS, HAND_HOLD, socketFrame, placeSocket, isSocketName, type SocketName, type SocketFrame, type SocketLook } from "./sockets.js";
@@ -47,6 +47,7 @@ export function makeContext(spec: BaseSpec, look: CharLook, b: Builder, opts: Ma
47
47
  fit: worn.find((w) => w.fit)?.fit ?? NO_FIT,
48
48
  hidesBadge: worn.some((w) => w.hidesBadge),
49
49
  hideTail: worn.some((w) => w.hideTail),
50
+ hideWings: worn.some((w) => w.hideWings),
50
51
  limbs: spec.limbs,
51
52
  headOnly,
52
53
  add,
@@ -69,7 +70,7 @@ export function buildWith(spec: BaseSpec, look: CharLook, opts: MakeOptions = {}
69
70
  // The shadow: a flat dark disc on its own bone, which stays on the floor under a hop.
70
71
  c.addBody(new THREE.CircleGeometry(0.62, 20), mtx(0, 0.02, 0, -Math.PI / 2), "shadow", "shadow", false);
71
72
  }
72
- for (const w of wornOf(opts)) w.add(c);
73
+ for (const w of wornOf(opts)) w.add?.(c);
73
74
  return { ...b.finish(), toonTris: b.p.length / 9, inkTris: b.ip.length / 9, look };
74
75
  }
75
76
  export type Made = ReturnType<typeof buildWith>;