@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.
- package/CHANGELOG.md +24 -0
- package/README.md +82 -12
- package/ai/SPEC.md +5 -1
- 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/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 +103 -0
- package/dist/model/limits.js +132 -0
- package/package.json +3 -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/model/check.ts +624 -0
- package/src/model/index.ts +5 -0
- 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.
|
|
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
|
}
|
package/src/admin/panel/index.ts
CHANGED
|
@@ -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";
|
package/src/admin/panel/shelf.ts
CHANGED
|
@@ -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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
};
|
package/src/character/create.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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();
|
package/src/character/ctx.ts
CHANGED
|
@@ -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 = {
|
|
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). */
|
package/src/character/index.ts
CHANGED
|
@@ -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";
|
package/src/character/make.ts
CHANGED
|
@@ -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>;
|