@aethervtt/shard-project 0.0.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/LICENSE +21 -0
- package/README.md +3 -0
- package/dist/define.d.ts +55 -0
- package/dist/define.js +56 -0
- package/dist/docs.d.ts +26 -0
- package/dist/docs.js +1336 -0
- package/dist/host.d.ts +47 -0
- package/dist/host.js +118 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +61 -0
- package/dist/manifest.d.ts +49 -0
- package/dist/manifest.js +147 -0
- package/dist/reload.d.ts +53 -0
- package/dist/reload.js +247 -0
- package/dist/session.d.ts +73 -0
- package/dist/session.js +144 -0
- package/dist/sourcemap.d.ts +31 -0
- package/dist/sourcemap.js +91 -0
- package/dist/templates.d.ts +9 -0
- package/dist/templates.js +283 -0
- package/package.json +55 -0
package/dist/docs.js
ADDED
|
@@ -0,0 +1,1336 @@
|
|
|
1
|
+
import {
|
|
2
|
+
allAssetSchemas,
|
|
3
|
+
allDataTypes,
|
|
4
|
+
allImporters
|
|
5
|
+
} from "@aethervtt/shard-assets";
|
|
6
|
+
import { allComponents } from "@aethervtt/shard-core";
|
|
7
|
+
import { allGenerators } from "@aethervtt/shard-procgen";
|
|
8
|
+
import { saveJsonSchema } from "@aethervtt/shard-save";
|
|
9
|
+
import { prefabJsonSchema, sceneJsonSchema } from "@aethervtt/shard-scene";
|
|
10
|
+
import { BUILTIN_PLUGINS, manifestJsonSchema } from "./manifest.js";
|
|
11
|
+
const GENERATED_START = "<!-- shard:generated -->";
|
|
12
|
+
const GENERATED_END = "<!-- /shard:generated -->";
|
|
13
|
+
function fieldRow(name, schema) {
|
|
14
|
+
const type = describeType(schema);
|
|
15
|
+
const def = schema.default === void 0 ? "" : `\`${JSON.stringify(schema.default)}\``;
|
|
16
|
+
const range = [
|
|
17
|
+
schema.minimum !== void 0 ? `\u2265 ${schema.minimum}` : "",
|
|
18
|
+
schema.maximum !== void 0 ? `\u2264 ${schema.maximum}` : "",
|
|
19
|
+
schema["x-unit"] ? `${schema["x-unit"]}` : ""
|
|
20
|
+
].filter(Boolean).join(", ");
|
|
21
|
+
const presets = schema["x-presets"] ? ` Presets: ${Object.keys(schema["x-presets"]).join(", ")}.` : "";
|
|
22
|
+
const description = `${schema.description ?? ""}${presets}`.replace(
|
|
23
|
+
/\|/g,
|
|
24
|
+
"\\|"
|
|
25
|
+
);
|
|
26
|
+
return `| \`${name}\` | ${type} | ${def} | ${range} | ${description} |`;
|
|
27
|
+
}
|
|
28
|
+
function describeType(schema) {
|
|
29
|
+
if (schema.anyOf) return schema.anyOf.map(describeType).join(" or ");
|
|
30
|
+
if (schema.enum) return schema.enum.map((v) => `\`"${v}"\``).join(" \\| ");
|
|
31
|
+
if (schema.type === "array") {
|
|
32
|
+
const n = schema.minItems === schema.maxItems && schema.minItems !== void 0 ? `${schema.minItems}` : "";
|
|
33
|
+
return `${describeType(schema.items ?? {})}[${n}]`;
|
|
34
|
+
}
|
|
35
|
+
if (schema.type === "object" && schema.properties && schema.properties.guid)
|
|
36
|
+
return schema["x-asset-type"] ? `${schema["x-asset-type"]} ref` : "asset ref";
|
|
37
|
+
return schema.type ?? "any";
|
|
38
|
+
}
|
|
39
|
+
function renderComponentCatalog(defs = allComponents()) {
|
|
40
|
+
const visible = defs.filter((d) => !d.name.startsWith("test/"));
|
|
41
|
+
const lines = [
|
|
42
|
+
"# Components",
|
|
43
|
+
"",
|
|
44
|
+
"Generated by `shard docs` from the registry. Use these names as keys under `components` in scene files.",
|
|
45
|
+
""
|
|
46
|
+
];
|
|
47
|
+
for (const def of visible) {
|
|
48
|
+
lines.push(`## \`${def.name}\``, "");
|
|
49
|
+
if (def.description) lines.push(def.description, "");
|
|
50
|
+
if (!def.serializable) lines.push("_Computed by the engine; never written in scene files._", "");
|
|
51
|
+
if (def.requires.length > 0)
|
|
52
|
+
lines.push(`Brings along: ${def.requires.map((r) => `\`${r.name}\``).join(", ")}.`, "");
|
|
53
|
+
const props = def.jsonSchema().properties ?? {};
|
|
54
|
+
if (Object.keys(props).length === 0) {
|
|
55
|
+
lines.push("Tag (no fields).", "");
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
lines.push("| Field | Type | Default | Range | Description |", "|---|---|---|---|---|");
|
|
59
|
+
for (const [name, schema] of Object.entries(props)) lines.push(fieldRow(name, schema));
|
|
60
|
+
if (def.name === "core/Transform") {
|
|
61
|
+
lines.push(
|
|
62
|
+
"| `rotationEuler` | number[3] | | degrees | Scene files only: rotation as X, then Y, then Z degrees. |"
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
lines.push("");
|
|
66
|
+
}
|
|
67
|
+
return lines.join("\n");
|
|
68
|
+
}
|
|
69
|
+
function renderErrorCatalog(codes) {
|
|
70
|
+
const lines = [
|
|
71
|
+
"# Error codes",
|
|
72
|
+
"",
|
|
73
|
+
"Every engine error is a `ShardError` with one of these codes.",
|
|
74
|
+
""
|
|
75
|
+
];
|
|
76
|
+
lines.push("| Code | Source | Hint |", "|---|---|---|");
|
|
77
|
+
for (const c of [...codes].sort((a, b) => a.code.localeCompare(b.code))) {
|
|
78
|
+
lines.push(`| \`${c.code}\` | ${c.source} | ${(c.hint ?? "").replace(/\|/g, "\\|")} |`);
|
|
79
|
+
}
|
|
80
|
+
return `${lines.join("\n")}
|
|
81
|
+
`;
|
|
82
|
+
}
|
|
83
|
+
function renderAssetCatalog(importers = allImporters(), dataTypes = []) {
|
|
84
|
+
const projectImporters = new Set(dataTypes.map((d) => d.importer.name));
|
|
85
|
+
const visible = importers.filter(
|
|
86
|
+
(i) => !i.name.startsWith("test") && !projectImporters.has(i.name)
|
|
87
|
+
);
|
|
88
|
+
const lines = [
|
|
89
|
+
"# Assets",
|
|
90
|
+
"",
|
|
91
|
+
"Generated by `shard docs`. Files under the asset roots (`assetRoots` in shard.json) are imported",
|
|
92
|
+
"by extension. Each gets a `.meta` next to it with its guid and import settings; commit both.",
|
|
93
|
+
'Reference assets in scenes by path: `{ "path": "materials/hull.material.json" }`, or a',
|
|
94
|
+
'sub-asset with `#`: `{ "path": "assets/ship.glb#Mesh/Hull" }`.',
|
|
95
|
+
""
|
|
96
|
+
];
|
|
97
|
+
for (const importer of visible) {
|
|
98
|
+
lines.push(`## \`${importer.name}\``, "");
|
|
99
|
+
lines.push(`Files: ${importer.extensions.map((e) => `\`*${e}\``).join(", ")}`, "");
|
|
100
|
+
const props = importer.settings.jsonSchema().properties ?? {};
|
|
101
|
+
if (Object.keys(props).length === 0) {
|
|
102
|
+
lines.push("No import settings.", "");
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
lines.push("| Setting | Type | Default | Range | Description |", "|---|---|---|---|---|");
|
|
106
|
+
for (const [name, schema] of Object.entries(props)) lines.push(fieldRow(name, schema));
|
|
107
|
+
lines.push("");
|
|
108
|
+
}
|
|
109
|
+
if (dataTypes.length > 0) {
|
|
110
|
+
lines.push(
|
|
111
|
+
"# Project data types",
|
|
112
|
+
"",
|
|
113
|
+
"Defined in `scripts/` with `project.dataAsset`. Write one JSON file per value, anywhere under an",
|
|
114
|
+
"asset root (by convention `data/<kind>/`). A file can start from another of the same type with",
|
|
115
|
+
'`"$extends": { "path": "..." }` and override fields. Reference them from components and other',
|
|
116
|
+
"data types with `t.handle('<type>')`; read them with `world.resource(Type.store).get(ref)`.",
|
|
117
|
+
""
|
|
118
|
+
);
|
|
119
|
+
for (const type of dataTypes) {
|
|
120
|
+
lines.push(`## \`${type.name}\``, "");
|
|
121
|
+
if (type.description) lines.push(type.description, "");
|
|
122
|
+
lines.push(
|
|
123
|
+
`Files: \`*.${type.extension}.json\` (e.g. \`data/${type.extension}s/<name>.${type.extension}.json\`). Schema: \`.shard/schemas/${type.extension}.schema.json\`.`,
|
|
124
|
+
""
|
|
125
|
+
);
|
|
126
|
+
const props = type.jsonSchema().properties ?? {};
|
|
127
|
+
if (Object.keys(props).length === 0) {
|
|
128
|
+
lines.push("No fields.", "");
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
lines.push("| Field | Type | Default | Range | Description |", "|---|---|---|---|---|");
|
|
132
|
+
for (const [name, schema] of Object.entries(props)) lines.push(fieldRow(name, schema));
|
|
133
|
+
lines.push("");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return `${lines.join("\n")}
|
|
137
|
+
`;
|
|
138
|
+
}
|
|
139
|
+
function renderGeneratorCatalog(gens = allGenerators()) {
|
|
140
|
+
const visible = gens.filter((g) => !g.name.startsWith("test/"));
|
|
141
|
+
const lines = [
|
|
142
|
+
"# Generators",
|
|
143
|
+
"",
|
|
144
|
+
"Generated by `shard docs`. A generator is `(seed, params) \u2192 output`, cached by the hash of its",
|
|
145
|
+
"inputs (name, version, code, params, seed, loaded assets). Use an output as:",
|
|
146
|
+
"",
|
|
147
|
+
"- a file, `generators/<name>.gen.json` (schema `.shard/schemas/gen.schema.json`), usable anywhere a",
|
|
148
|
+
' handle of the output type is: `{ "path": "generators/boulder.gen.json" }`',
|
|
149
|
+
'- a ref, `{ "path": "procedural:<generator>?radius=2&seed=3" }` (numbers, bools, asset paths)',
|
|
150
|
+
'- entities: `procgen/GeneratorInstance { "generator": { "path": "<generator>" }, "seed": 3, "params": {} }`',
|
|
151
|
+
"- at runtime: `await generate(world, Gen, params, seed)`",
|
|
152
|
+
"",
|
|
153
|
+
'Tune one with MCP `preview_generator { "generator": "<name>", "seeds": "1-9" }` (a contact sheet)',
|
|
154
|
+
"and `run_generator` (counts, bounds, cache key). See `.agents/skills/make-a-generator.md`.",
|
|
155
|
+
""
|
|
156
|
+
];
|
|
157
|
+
if (visible.length === 0) lines.push("No generators yet.", "");
|
|
158
|
+
for (const gen of visible) {
|
|
159
|
+
lines.push(`## \`${gen.name}\``, "");
|
|
160
|
+
if (gen.description) lines.push(gen.description, "");
|
|
161
|
+
lines.push(
|
|
162
|
+
`Output: \`${gen.outputType}\`${gen.output === "mesh" ? " (sub-assets `#LOD1`, \u2026 when it makes levels of detail)" : ""}. Version ${gen.version}.`,
|
|
163
|
+
""
|
|
164
|
+
);
|
|
165
|
+
const props = gen.params.jsonSchema().properties ?? {};
|
|
166
|
+
if (Object.keys(props).length === 0) lines.push("No params.", "");
|
|
167
|
+
else {
|
|
168
|
+
lines.push("| Param | Type | Default | Range | Description |", "|---|---|---|---|---|");
|
|
169
|
+
for (const [name, schema] of Object.entries(props)) lines.push(fieldRow(name, schema));
|
|
170
|
+
lines.push("");
|
|
171
|
+
}
|
|
172
|
+
const example = {
|
|
173
|
+
$schema: "../.shard/schemas/gen.schema.json",
|
|
174
|
+
generator: gen.name,
|
|
175
|
+
seed: 1,
|
|
176
|
+
params: {}
|
|
177
|
+
};
|
|
178
|
+
lines.push("```json", JSON.stringify(example), "```", "");
|
|
179
|
+
}
|
|
180
|
+
return `${lines.join("\n")}
|
|
181
|
+
`;
|
|
182
|
+
}
|
|
183
|
+
const SKILLS = {
|
|
184
|
+
"build-a-scene.md": `# Build or change a scene
|
|
185
|
+
|
|
186
|
+
1. Read \`.agents/components.md\` for component names and fields.
|
|
187
|
+
2. Edit \`scenes/*.scene.json\`. Name every entity; nest with \`children\`. Materials go in \`assets\`
|
|
188
|
+
(\`{ "type": "Material", "value": {...} }\`), meshes can be procedural
|
|
189
|
+
(\`{ "path": "procedural:sphere?radius=2" }\`).
|
|
190
|
+
3. Validate: \`shard validate --json\` (or the MCP \`validate_scene\` tool). Fix every error by its \`path\`.
|
|
191
|
+
4. Look at it: \`shard screenshot scenes/main.scene.json --out shot.png\` (or MCP \`screenshot\`).
|
|
192
|
+
`,
|
|
193
|
+
"add-a-component.md": `# Add a component
|
|
194
|
+
|
|
195
|
+
In \`scripts/\`, define it on the project so it gets the project namespace:
|
|
196
|
+
|
|
197
|
+
\`\`\`ts
|
|
198
|
+
export const Fuel = project.component('Fuel', {
|
|
199
|
+
liters: t.f32({ default: 100, min: 0, unit: 'L', description: 'Fuel left.' }),
|
|
200
|
+
})
|
|
201
|
+
\`\`\`
|
|
202
|
+
|
|
203
|
+
Write a description for every field: it ends up in \`.agents/components.md\`. Run \`shard docs\` after.
|
|
204
|
+
`,
|
|
205
|
+
"write-a-system.md": `# Write a system
|
|
206
|
+
|
|
207
|
+
\`\`\`ts
|
|
208
|
+
const burn = defineSystem({
|
|
209
|
+
name: '<project>/burn',
|
|
210
|
+
setup: (world) => ({ q: world.query({ with: [Fuel] }) }),
|
|
211
|
+
run: ({ q }, world) => {
|
|
212
|
+
const dt = world.resource(FixedTime).step
|
|
213
|
+
for (const table of q.tables) {
|
|
214
|
+
const liters = table.column(Fuel, 'liters')
|
|
215
|
+
for (let i = 0; i < table.count; i++) liters[i] = Math.max(0, liters[i]! - dt)
|
|
216
|
+
table.markChanged(Fuel)
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
})
|
|
220
|
+
\`\`\`
|
|
221
|
+
|
|
222
|
+
Register it in the project's \`build(app)\`: \`app.addSystems(FixedUpdate, burn)\`. Hot loops must not
|
|
223
|
+
allocate: walk \`q.tables\` and use columns.
|
|
224
|
+
`,
|
|
225
|
+
"write-a-gameplay-test.md": `# Write a gameplay test
|
|
226
|
+
|
|
227
|
+
\`\`\`ts
|
|
228
|
+
import { expect, test } from '@aethervtt/shard-testing'
|
|
229
|
+
|
|
230
|
+
test('thrust moves the ship', async ({ game }) => {
|
|
231
|
+
await game.load('scenes/main.scene.json')
|
|
232
|
+
game.input.hold('<project>/Controls.thrust')
|
|
233
|
+
await game.step(120)
|
|
234
|
+
expect(game.get('ship', 'core/Transform').translation[2]).toBeLessThan(0)
|
|
235
|
+
})
|
|
236
|
+
\`\`\`
|
|
237
|
+
|
|
238
|
+
Run with \`shard test --json\`. Tests are headless, seeded, and fixed-timestep: they pass or fail the
|
|
239
|
+
same way every run.
|
|
240
|
+
`,
|
|
241
|
+
"iterate-on-a-system.md": `# Change game code and see it run
|
|
242
|
+
|
|
243
|
+
1. Edit a system or component in \`scripts/\`. The running game (MCP server, \`shard dev\` page)
|
|
244
|
+
rebuilds and hot reloads on save, keeping every entity and resource.
|
|
245
|
+
2. Check it took: MCP \`project_status\` (or the note in \`step\`'s result). A failed build or reload
|
|
246
|
+
reports \`source\` as \`scripts/<file>.ts:line:col\`, and the previous code keeps running.
|
|
247
|
+
3. \`step\` some frames and \`screenshot\` or \`get_entity\` to see the effect.
|
|
248
|
+
4. Run \`typecheck\` (\`shard check --json\`): reloads don't wait for type errors.
|
|
249
|
+
5. Changing a component's fields migrates live data: new fields take their defaults, removed ones
|
|
250
|
+
are dropped. To rename a field, bump the component's \`version\` and move the value in \`migrate\`.
|
|
251
|
+
`,
|
|
252
|
+
"use-assets.md": `# Use asset files
|
|
253
|
+
|
|
254
|
+
1. Put files under an asset root (\`assets/\`, \`materials/\`, \`data/\`). \`.agents/assets.md\` lists
|
|
255
|
+
what each extension imports as and its settings.
|
|
256
|
+
2. Import: \`shard import --json\` (MCP \`reimport_asset\` with no "asset"). Fix failures by their \`path\`.
|
|
257
|
+
3. Reference by path in scenes: \`"material": { "path": "materials/hull.material.json" }\`.
|
|
258
|
+
4. Inspect with MCP \`get_asset\` (sub-assets, bounds, dependents, errors); change import settings
|
|
259
|
+
with \`reimport_asset\` and \`{ "settings": {...} }\`.
|
|
260
|
+
5. Move or rename with \`shard mv <from> <to>\` (MCP \`move_asset\`), never by hand: it keeps the
|
|
261
|
+
\`.meta\` with the file and rewrites references.
|
|
262
|
+
6. Look before you place: MCP \`preview_asset\` renders a texture, a material, or a model.
|
|
263
|
+
|
|
264
|
+
Models: place a .glb/.gltf with \`"scene/SceneInstance": { "scene": { "path": "assets/ship.glb#Scene" } }\`;
|
|
265
|
+
its nodes become children you can address by path (\`ship/Hull\`), changed per placement with
|
|
266
|
+
\`overrides\` (see make-a-prefab.md). Reusable entity trees are prefabs (\`prefabs/*.prefab.json\`).
|
|
267
|
+
|
|
268
|
+
Textures: a texture's \`usage\` setting says what it holds: \`color\` (albedo, emissive; sRGB),
|
|
269
|
+
\`data\` (roughness, metallic, occlusion; linear), \`normal\` (tangent-space normal map), \`hdr\`.
|
|
270
|
+
New files get a usage from their name (\`*_normal*\`, \`*rough*\`, \`*_orm*\`, \`*.hdr\`). Materials
|
|
271
|
+
use textures through slots: \`"baseColorTexture": { "texture": { "path": "assets/rock.png" } }\`
|
|
272
|
+
(also metallicRoughnessTexture, normalTexture, occlusionTexture, emissiveTexture).
|
|
273
|
+
`,
|
|
274
|
+
"write-a-material.md": `# Write a material
|
|
275
|
+
|
|
276
|
+
A material type is a schema plus a shader that overrides hooks. Lighting, shadows, and post effects
|
|
277
|
+
keep working because the shader only changes the surface.
|
|
278
|
+
|
|
279
|
+
1. Define it in \`scripts/\`:
|
|
280
|
+
|
|
281
|
+
\`\`\`ts
|
|
282
|
+
export const Lava = project.material('Lava', {
|
|
283
|
+
fields: {
|
|
284
|
+
crackColor: t.color({ default: [1, 0.3, 0.05, 1] }),
|
|
285
|
+
crackLuminance: t.f32({ default: 20000, unit: 'cd/m\xB2' }),
|
|
286
|
+
flow: t.vec2({ default: [0.02, 0], description: 'UV scroll per second.' }),
|
|
287
|
+
cracks: t.handle('Texture', { description: 'Crack mask (R).' }),
|
|
288
|
+
},
|
|
289
|
+
shader: 'project::lava',
|
|
290
|
+
})
|
|
291
|
+
\`\`\`
|
|
292
|
+
|
|
293
|
+
2. Write \`shaders/lava.wesl\`. The generated module \`material::lava\` has the uniform (\`Lava\`)
|
|
294
|
+
and one texture and sampler per texture field (\`Lava_cracks\`, \`Lava_cracks_sampler\`):
|
|
295
|
+
|
|
296
|
+
\`\`\`wesl
|
|
297
|
+
import shard::pbr::types::{ VertexOutput, PbrInput };
|
|
298
|
+
import shard::pbr::standard::standard_input;
|
|
299
|
+
import shard::globals::globals;
|
|
300
|
+
import material::lava::{ Lava, Lava_cracks, Lava_cracks_sampler };
|
|
301
|
+
|
|
302
|
+
override fn pbr_input(in: VertexOutput) -> PbrInput {
|
|
303
|
+
var p = standard_input(in);
|
|
304
|
+
let uv = in.uv + Lava.flow * globals.time;
|
|
305
|
+
let crack = textureSample(Lava_cracks, Lava_cracks_sampler, uv).r;
|
|
306
|
+
p.emissive += Lava.crackColor.rgb * Lava.crackLuminance * crack;
|
|
307
|
+
return p;
|
|
308
|
+
}
|
|
309
|
+
\`\`\`
|
|
310
|
+
|
|
311
|
+
Hooks: \`vertex_position(position, normal, uv) -> vec3f\` (object-space displacement, applied in
|
|
312
|
+
every pass, shadows included), \`pbr_input(in) -> PbrInput\` (the surface), and
|
|
313
|
+
\`fragment_output(color) -> vec4f\` (pre-exposed HDR, before tonemapping). With
|
|
314
|
+
\`extends: 'none'\`, override \`shade(in) -> vec4f\` instead: it returns radiance in cd/m\xB2 and
|
|
315
|
+
alpha, and no lighting runs. \`noise: { detail: 'assets/noise/rock.noise.json' }\` in the
|
|
316
|
+
definition adds \`noise_detail(p: vec3f, seed: u32) -> f32\` to \`material::lava\` (see
|
|
317
|
+
make-a-noise-graph.md). In \`vertex_position\`, \`shard::mesh\` also gives the vertex's
|
|
318
|
+
\`vertex_uv1()\` and \`vertex_tangent()\`, \`vertex_world(p)\` (object to world), and
|
|
319
|
+
\`vertex_instance_data()\`: the two numbers of the entity's \`render/InstanceData\` (a fade,
|
|
320
|
+
flags). \`arrays: ['layers']\` binds a texture field as \`texture_2d_array\` (a
|
|
321
|
+
\`*.texarray.json\`).
|
|
322
|
+
|
|
323
|
+
3. Use it in a material file: \`{ "type": "<project>/Lava", "crackLuminance": 30000,
|
|
324
|
+
"cracks": { "path": "assets/cracks.png" } }\`. Leaving out \`"type"\` means the standard material.
|
|
325
|
+
4. Check it: \`shard validate --json\` (bad fields are reported by path), then MCP \`preview_asset\`
|
|
326
|
+
on the material file. Shader errors appear in \`recent_errors\` as \`shaders/lava.wesl:line:col\`,
|
|
327
|
+
and the last shader that compiled keeps running.
|
|
328
|
+
`,
|
|
329
|
+
"make-2d.md": `# Make 2D: sprites, animation, tilemaps
|
|
330
|
+
|
|
331
|
+
Add \`"sprite"\` to \`plugins\` in \`shard.json\`. 2D uses the same renderer as 3D (HDR, post effects).
|
|
332
|
+
|
|
333
|
+
1. An atlas. Either drop images in a folder and add \`assets/sprites/hero.atlas-pack.json\`
|
|
334
|
+
(\`{}\` packs \`assets/sprites/hero/*.png\`; regions are the file names), or write
|
|
335
|
+
\`hero.atlas.json\`: \`{ "texture": { "path": "assets/sheet.png" }, "grid": { "columns": 8,
|
|
336
|
+
"rows": 4, "cellWidth": 16, "cellHeight": 16 } }\` (regions \`cell0\`, \`cell1\`, \u2026) and/or
|
|
337
|
+
\`"regions": [{ "name": "idle_0", "rect": [x, y, w, h] }]\`.
|
|
338
|
+
2. A sprite: \`"sprite/Sprite": { "atlas": { "path": "assets/sprites/hero.atlas-pack.json" },
|
|
339
|
+
"region": "idle_0", "layer": 1 }\`. No \`size\`: pixels \xF7 \`Sprite2dSettings.pixelsPerUnit\` (100).
|
|
340
|
+
Higher \`layer\` draws on top; within a layer, higher z does. \`"space": "screen"\` makes an
|
|
341
|
+
overlay positioned in pixels.
|
|
342
|
+
3. Animation: \`walk.clip.json\` = \`{ "atlas": {...}, "frames": [{ "region": "walk_0",
|
|
343
|
+
"duration": 0.1 }, ...], "loop": "loop", "events": [{ "frame": 2, "name": "step" }] }\`, then
|
|
344
|
+
\`"sprite/SpriteAnimation": { "clip": { "path": "assets/walk.clip.json" } }\`. Read events with
|
|
345
|
+
\`world.reader(SpriteAnimationEvent)\`.
|
|
346
|
+
4. Tilemaps: \`level.tilemap.json\` layers are base64 u16 tiles (atlas region + 1, 0 empty), and
|
|
347
|
+
\`"sprite/Tilemap": { "atlas": ..., "data": ..., "tileSize": [1, 1] }\`. Edit at runtime with
|
|
348
|
+
\`setTile(world, map, x, y, tile)\`; only that chunk re-uploads.
|
|
349
|
+
5. Pixel art: \`"render/PixelPerfect": { "pixelsPerUnit": 16 }\` on an orthographic camera.
|
|
350
|
+
6. Check: \`shard validate\`, MCP \`preview_asset\` on the atlas (regions outlined and numbered) or
|
|
351
|
+
the clip, and \`render.describe\` \u2192 \`sprites\` (draw calls, sprites per layer, tilemap chunks).
|
|
352
|
+
7. Lights and shadows: see \`light-2d.md\`.
|
|
353
|
+
`,
|
|
354
|
+
"light-2d.md": `# Light 2D: lights, normal maps, shadows
|
|
355
|
+
|
|
356
|
+
Sprites are unlit until their camera has \`"sprite/Lighting2d": { "ambient": [0.08, 0.08, 0.1, 1] }\`.
|
|
357
|
+
Then each lit sprite shows \`albedo \xD7 (ambient + \u03A3 lights) + albedo \xD7 emissive\`: a light of
|
|
358
|
+
intensity 1 at its center shows the sprite at its texture color. For a bright scene where lights
|
|
359
|
+
only add, use \`"ambient": [1, 1, 1, 1]\`.
|
|
360
|
+
|
|
361
|
+
1. Lights: \`"sprite/PointLight2d": { "color": [1, 0.7, 0.4, 1], "intensity": 1.5, "radius": 6 }\`.
|
|
362
|
+
\`"sprite/SpotLight2d"\` adds \`innerAngle\` / \`outerAngle\` (degrees) and points along the
|
|
363
|
+
entity's +X: rotate the entity (or parent it to a hand) to aim it. \`falloff\` tightens the
|
|
364
|
+
edge; \`height\` is how high above the sprites it sits (low grazes normal maps).
|
|
365
|
+
2. Shadows: \`"shadows": true\` on the light (at most \`Lighting2d.maxShadowed\`, 64, per view; the
|
|
366
|
+
nearest win) and \`"sprite/LightOccluder2d"\` on what blocks it: \`"shape": "box"\` with \`size\`,
|
|
367
|
+
\`"circle"\` (\`size[0]\` is the radius), \`"polygon"\` with \`points\`, \`"sprite"\` (the sprite's
|
|
368
|
+
outline), or \`"collider"\` (its physics Collider). \`softness\` on the light widens penumbras
|
|
369
|
+
(0: hard). Tile walls: set \`"occludes": true\` on the layer in \`*.tilemap.json\`.
|
|
370
|
+
3. Normal maps: name them \`hero_n.png\` next to \`hero.png\` in an atlas-pack folder and they pack
|
|
371
|
+
into a matching page (\`"normalMap": "directx"\` in the pack file flips green). Plain texture
|
|
372
|
+
sprites: \`"sprite/SpriteLighting": { "normal": { "path": "assets/rock_n.png" } }\`. Add
|
|
373
|
+
\`"outlines": true\` to the pack file for \`shape: 'sprite'\` occluders that follow the alpha.
|
|
374
|
+
4. Glow and exceptions: \`SpriteLighting.emissive\` for eyes and screens; \`"lit": false\` on a Sprite
|
|
375
|
+
(flames, UI, particles that shine on their own). \`layers\` on lights and occluders masks which
|
|
376
|
+
layer bands they touch (bit k covers layers k\xD764 \u2212 1024 \u2026).
|
|
377
|
+
5. Check: \`render.describe\` \u2192 \`sprites.lighting\` (lights lit, culled, dropped over budget,
|
|
378
|
+
shadowed and demoted, occluder segments, tiles at the 64-light cap, GPU ms), the \`lights2d\`
|
|
379
|
+
overlay in a screenshot (radii, cones, each shadow row as a ring, occluder segments), and the log
|
|
380
|
+
for \`sprite/invalid-occluder\`, \`sprite/too-many-lights\`, \`sprite/too-many-shadowed-lights\`.
|
|
381
|
+
`,
|
|
382
|
+
"show-text.md": `# Show text
|
|
383
|
+
|
|
384
|
+
Add \`"text"\` to \`plugins\` in \`shard.json\` and put a \`.ttf\` or \`.otf\` under \`assets/\` (import
|
|
385
|
+
settings: \`charset\`, \`size\`, \`range\`, \`fallback\` fonts for missing characters).
|
|
386
|
+
|
|
387
|
+
- In the world: \`"text/Text": { "value": "Scanner 7", "font": { "path": "assets/fonts/Inter.ttf" },
|
|
388
|
+
"size": 0.5, "billboard": true }\`. \`size\` is the em height in meters.
|
|
389
|
+
- On screen: \`"text/ScreenText": { "value": "Fuel 82%", "font": ..., "size": 24, "corner":
|
|
390
|
+
"top-right", "position": [-16, 16] }\` (pixels from the corner, y down).
|
|
391
|
+
- Style: \`outline: { width: 0.03, color }\`, \`shadow: { offset: [0.05, -0.05], softness: 0.3, color }\`
|
|
392
|
+
(offset 0 with softness is a glow), \`weight\`. Wrap with \`maxWidth\`; \`align\` and \`anchor\` place it.
|
|
393
|
+
- Size before placing: MCP \`measure_text\` returns width, height, and lines. \`preview_asset\` on the
|
|
394
|
+
font shows a specimen. Missing characters: \`render.describe\` \u2192 \`text.missing\`.
|
|
395
|
+
`,
|
|
396
|
+
"add-physics.md": `# Add physics
|
|
397
|
+
|
|
398
|
+
Add \`"physics3d"\` (or \`"physics2d"\` for a 2D game, never both) to \`plugins\` in \`shard.json\`.
|
|
399
|
+
Bodies and colliders are components, so scenes, prefabs, and \`patch_entity\` create and change them:
|
|
400
|
+
|
|
401
|
+
\`\`\`json
|
|
402
|
+
{ "name": "crate", "components": {
|
|
403
|
+
"core/Transform": { "translation": [0, 3, 0] },
|
|
404
|
+
"physics/RigidBody": { "kind": "dynamic" },
|
|
405
|
+
"physics/Collider": { "shape": "cuboid", "halfExtents": [0.5, 0.5, 0.5], "restitution": 0.2 } } }
|
|
406
|
+
\`\`\`
|
|
407
|
+
|
|
408
|
+
- Kinds: \`dynamic\` (forces and contacts move it), \`fixed\`, \`kinematic-position\` (follows its
|
|
409
|
+
Transform), \`kinematic-velocity\` (moves by \`physics/Velocity\`). A \`Collider\` without a body is fixed.
|
|
410
|
+
- Shapes: ball (\`radius\`), cuboid (\`halfExtents\`), capsule, cylinder, cone (\`radius\`, \`halfHeight\`
|
|
411
|
+
along Y), convex and trimesh (\`mesh\`: a Mesh asset, or \`points\`), heightfield, segment, polyline.
|
|
412
|
+
Scale in the Transform scales the shape. Colliders on children without their own body attach to
|
|
413
|
+
the nearest ancestor body (one compound body).
|
|
414
|
+
- Move bodies with \`physics/Velocity\`, \`physics/ExternalForce\` (every step), or
|
|
415
|
+
\`physics/ExternalImpulse\` (once). Writing a dynamic body's Transform teleports it.
|
|
416
|
+
- Layers: \`layers\` and \`mask\` are 16-bit masks; two colliders touch when each one's mask has the
|
|
417
|
+
other's layer. Planets: \`physics/GravitySource\` on the planet, and \`physics/Config\` gravity \`[0, 0, 0]\`.
|
|
418
|
+
- Events: set \`events: true\` on a collider (sensors too) and read \`physics/CollisionEvent\`
|
|
419
|
+
(\`started\` / \`stopped\`, the two collider entities and their bodies).
|
|
420
|
+
- Queries in code: \`world.resource(Physics).raycast(origin, dir, { mask }, hit)\`. From tools:
|
|
421
|
+
\`physics_raycast\`, \`physics_overlap\`, \`physics_describe\`. See shapes with
|
|
422
|
+
\`screenshot\` \`"overlays": ["colliders"]\`.
|
|
423
|
+
|
|
424
|
+
Check a fall with a gameplay test:
|
|
425
|
+
|
|
426
|
+
\`\`\`ts
|
|
427
|
+
test('the crate lands', async ({ game }) => {
|
|
428
|
+
await game.load('scenes/main.scene.json')
|
|
429
|
+
await game.step(120)
|
|
430
|
+
expect(game.get('crate', 'core/Transform').translation[1]).toBeCloseTo(0.5, 1)
|
|
431
|
+
})
|
|
432
|
+
\`\`\`
|
|
433
|
+
|
|
434
|
+
## Characters
|
|
435
|
+
|
|
436
|
+
A walking player or NPC is \`physics/CharacterController\` with no RigidBody or Collider: it makes
|
|
437
|
+
its own kinematic capsule (centered on the entity), climbs steps and slopes, snaps to the ground,
|
|
438
|
+
pushes dynamic bodies, and rides kinematic platforms.
|
|
439
|
+
|
|
440
|
+
\`\`\`json
|
|
441
|
+
{ "name": "player", "components": {
|
|
442
|
+
"core/Transform": { "translation": [0, 1, 0] },
|
|
443
|
+
"physics/CharacterController": { "height": 1.8, "radius": 0.35, "up": "gravity" } } }
|
|
444
|
+
\`\`\`
|
|
445
|
+
|
|
446
|
+
- Write \`physics/CharacterIntent\`: \`move\` in m/s in the character's frame (x right, -z forward;
|
|
447
|
+
2D: x), and \`jump: true\` to jump once at the next grounded step.
|
|
448
|
+
- Read \`physics/CharacterState\` (\`grounded\`, \`groundEntity\`, \`velocity\`, \`up\`, \`airTime\`) and
|
|
449
|
+
\`physics/CharacterGroundEvent\` (landed or left the ground). \`physics_describe\` lists characters.
|
|
450
|
+
- \`up: "gravity"\` follows the strongest \`physics/GravitySource\` (walk around planets) and turns the
|
|
451
|
+
entity to match; \`"fixed"\` uses \`fixedUp\` and \`gravity\`. Turn it by writing its rotation;
|
|
452
|
+
writing its translation teleports it.
|
|
453
|
+
|
|
454
|
+
\`\`\`ts
|
|
455
|
+
test('the player walks and lands', async ({ game }) => {
|
|
456
|
+
await game.load('scenes/main.scene.json')
|
|
457
|
+
await game.step(30)
|
|
458
|
+
await game.patch('player', { 'physics/CharacterIntent': { move: [0, 0, -3] } })
|
|
459
|
+
await game.step(60)
|
|
460
|
+
expect(game.get('player', 'physics/CharacterState').grounded).toBe(true)
|
|
461
|
+
expect(game.get('player', 'core/Transform').translation[2]).toBeLessThan(-2.5)
|
|
462
|
+
})
|
|
463
|
+
\`\`\`
|
|
464
|
+
`,
|
|
465
|
+
"animate.md": `# Animate a model or a property
|
|
466
|
+
|
|
467
|
+
Add \`"animation"\` to \`plugins\` in \`shard.json\`. A \`.glb\` with animations imports each as an
|
|
468
|
+
\`AnimationClip\` sub-asset (\`assets/ships/walker.glb#Animation/Walk\`; MCP \`get_asset\` lists its
|
|
469
|
+
channels and duration). Skinned meshes and morph targets import tagged (\`render/SkinnedMesh\`,
|
|
470
|
+
\`render/MorphWeights\`) and deform on their own; the player moves the joints.
|
|
471
|
+
|
|
472
|
+
\`\`\`json
|
|
473
|
+
{ "name": "walker", "components": {
|
|
474
|
+
"core/Transform": {},
|
|
475
|
+
"scene/SceneInstance": { "scene": { "path": "assets/walker.glb#Scene" } },
|
|
476
|
+
"animation/AnimationPlayer": { "layers": [
|
|
477
|
+
{ "clip": { "path": "assets/walker.glb#Animation/Walk" } },
|
|
478
|
+
{ "clip": { "path": "assets/walker.glb#Animation/Wave" }, "weight": 1,
|
|
479
|
+
"mask": { "path": "assets/masks/upper-body.mask.json" } } ] } } }
|
|
480
|
+
\`\`\`
|
|
481
|
+
|
|
482
|
+
- Put the player on the model root (the SceneInstance entity). Channels bind joints by path under
|
|
483
|
+
it; \`animation_describe\` lists channels that found no target.
|
|
484
|
+
- Layers blend in order: \`override\` lerps toward its pose by \`weight\`; \`additive\` adds its change
|
|
485
|
+
since the clip's first frame. \`loop\`: \`loop\`, \`once\` (holds the end, sends
|
|
486
|
+
\`animation/AnimationFinished\`), \`ping-pong\`. Seek with \`time\`, pause with \`playing: false\`.
|
|
487
|
+
- A mask is \`{ "joints": { "Armature/Hips/Spine": 1, "Armature/Hips/Spine/Neck": 0 } }\` in a
|
|
488
|
+
\`*.mask.json\`: a path covers its subtree, the longest match wins, uncovered joints get 0.
|
|
489
|
+
- In code: \`crossfade(world, entity, clipRef, 0.3, { loop: 'once' })\` fades in a layer and fades
|
|
490
|
+
the others out; \`animationLayer(clipRef, { weight: 0.5 })\` builds a full layer.
|
|
491
|
+
- To pick clips from state (idle, walk, run, jump, land), use a graph: see
|
|
492
|
+
\`animate-a-character.md\`.
|
|
493
|
+
- Root motion: \`"rootMotion": "character"\` on an entity with \`physics/CharacterController\` turns
|
|
494
|
+
the clip's root travel into \`CharacterIntent.move\` (walking collides); \`"transform"\` moves the
|
|
495
|
+
entity directly. The root joint stays in place; \`animation/RootMotion\` has this frame's delta.
|
|
496
|
+
- See joints with \`screenshot\` \`"overlays": ["skeleton"]\`; \`preview_asset\` on a glTF clip shows
|
|
497
|
+
its model at 5 times.
|
|
498
|
+
- Feet on uneven ground, heads that look at things, weapons in hands, or clips from another rig:
|
|
499
|
+
see \`rig-a-character.md\`.
|
|
500
|
+
|
|
501
|
+
Property clips animate any numeric field by path (\`""\` is the player's entity):
|
|
502
|
+
|
|
503
|
+
\`\`\`json
|
|
504
|
+
{ "duration": 2,
|
|
505
|
+
"tracks": [
|
|
506
|
+
{ "path": "radar/dish", "component": "core/Transform", "field": "rotation",
|
|
507
|
+
"keys": [[0, [0, 0, 0, 1]], [2, [0, 1, 0, 0]]] },
|
|
508
|
+
{ "path": "", "component": "render/PointLight", "field": "intensity",
|
|
509
|
+
"keys": [[0, 800], [0.1, 0], [0.2, 800]], "interpolation": "step" } ],
|
|
510
|
+
"events": [{ "time": 1, "name": "ping" }] }
|
|
511
|
+
\`\`\`
|
|
512
|
+
|
|
513
|
+
Save it as \`assets/anims/radar.anim.json\`. Events arrive as \`animation/AnimationEvent\`
|
|
514
|
+
(\`name\`, \`layer\`, \`time\`). Unknown components or fields fail import with a pointer.
|
|
515
|
+
`,
|
|
516
|
+
"animate-a-character.md": `# Animate a character with a graph
|
|
517
|
+
|
|
518
|
+
A graph (\`*.animgraph.json\`) picks the character's clips from parameters: states, transitions
|
|
519
|
+
with conditions, blend spaces, and masked layers. It writes the \`animation/AnimationPlayer\`
|
|
520
|
+
layers each frame, so everything the player does (masks, root motion, events) still works.
|
|
521
|
+
|
|
522
|
+
1. Import the model (\`assets/hero.glb\`); \`get_asset\` lists its \`#Animation/...\` clips.
|
|
523
|
+
2. Write \`data/hero.animgraph.json\` (schema \`.shard/schemas/animgraph.schema.json\`):
|
|
524
|
+
|
|
525
|
+
\`\`\`json
|
|
526
|
+
{ "parameters": {
|
|
527
|
+
"speed": { "type": "float", "bind": { "component": "physics/CharacterState", "field": "velocity", "op": "horizontal" } },
|
|
528
|
+
"grounded": { "type": "bool", "bind": { "component": "physics/CharacterState", "field": "grounded" } },
|
|
529
|
+
"attack": { "type": "trigger" } },
|
|
530
|
+
"layers": [
|
|
531
|
+
{ "name": "base", "entry": "locomotion",
|
|
532
|
+
"states": {
|
|
533
|
+
"locomotion": { "blend1d": { "parameter": "speed", "clips": [[0, "#idle"], [1.5, "#walk"], [5, "#run"]] } },
|
|
534
|
+
"fall": { "clip": "#fall" },
|
|
535
|
+
"land": { "clip": "#land", "loop": "once" } },
|
|
536
|
+
"transitions": [
|
|
537
|
+
{ "from": "locomotion", "to": "fall", "when": "!grounded", "duration": 0.15 },
|
|
538
|
+
{ "from": "fall", "to": "land", "when": "grounded", "duration": 0.05 },
|
|
539
|
+
{ "from": "land", "to": "locomotion", "exitTime": 0.8, "duration": 0.2 } ] },
|
|
540
|
+
{ "name": "upper", "mask": { "path": "data/masks/upper-body.mask.json" },
|
|
541
|
+
"states": { "none": {}, "swing": { "clip": "#swing", "loop": "once" } },
|
|
542
|
+
"transitions": [
|
|
543
|
+
{ "from": "none", "to": "swing", "when": "attack", "duration": 0.1 },
|
|
544
|
+
{ "from": "swing", "to": "none", "exitTime": 1, "duration": 0.2 } ] } ],
|
|
545
|
+
"clips": {
|
|
546
|
+
"idle": { "path": "assets/hero.glb#Animation/Idle" }, "walk": { "path": "assets/hero.glb#Animation/Walk" },
|
|
547
|
+
"run": { "path": "assets/hero.glb#Animation/Run" }, "fall": { "path": "assets/hero.glb#Animation/Fall" },
|
|
548
|
+
"land": { "path": "assets/hero.glb#Animation/Land" }, "swing": { "path": "assets/hero.glb#Animation/Swing" } } }
|
|
549
|
+
\`\`\`
|
|
550
|
+
|
|
551
|
+
3. Put \`"animation/Animator": { "graph": { "path": "data/hero.animgraph.json" } }\` on the model
|
|
552
|
+
root (next to its AnimationPlayer). Bound parameters read the component on that entity or its
|
|
553
|
+
nearest ancestor, so a model under a \`physics/CharacterController\` entity finds its state.
|
|
554
|
+
4. \`shard validate --json\`: unknown states, clips, or parameters and conditions that don't parse
|
|
555
|
+
are errors with a pointer (conditions give the column); unreachable states are warnings.
|
|
556
|
+
|
|
557
|
+
- Conditions: parameter names, numbers, \`true\`/\`false\`, \`!\`, \`&&\`, \`||\`, \`< <= > >= == !=\`,
|
|
558
|
+
parentheses. Transitions are checked in order, first match wins; \`"from": "*"\` is any state.
|
|
559
|
+
\`exitTime\` waits for the source state's normalized time (1 = its end); \`duration\` crossfades.
|
|
560
|
+
- Triggers stay true until a transition that reads them is taken. Set parameters in code with
|
|
561
|
+
\`setAnimParam(world, entity, 'attack', true)\`, or patch \`animation/AnimatorParams.values\`.
|
|
562
|
+
- \`{}\` states play nothing: the layers below show through. \`blend1d\` syncs its clips' normalized
|
|
563
|
+
times; \`blend2d\` (\`"x"\`, \`"y"\`, \`"clips": [[x, y, "#clip"], ...]\`) weights the three nearest
|
|
564
|
+
samples. Bind ops: \`value\`, \`length\`, \`horizontal\` (xz), \`x\`, \`y\`, \`z\`, \`not\`.
|
|
565
|
+
- \`animation/AnimatorStateEntered\` (\`entity\`, \`layer\`, \`state\`, \`from\`) fires on each entry, for
|
|
566
|
+
"landed" or "swing hit". \`animation_describe\` shows each layer's state, transition progress,
|
|
567
|
+
time in state, blend weights, and the parameters.
|
|
568
|
+
|
|
569
|
+
\`\`\`ts
|
|
570
|
+
test('the hero falls, lands, and walks', async ({ game }) => {
|
|
571
|
+
await game.load('scenes/main.scene.json')
|
|
572
|
+
const layer = async () =>
|
|
573
|
+
(await game.call<any>('animation.describe', { entity: 'hero/model' })).animator.layers[0]
|
|
574
|
+
await game.step(30)
|
|
575
|
+
expect((await layer()).state).toBe('locomotion')
|
|
576
|
+
await game.patch('hero', { 'core/Transform': { translation: [0, 5, 0] } })
|
|
577
|
+
await game.step(10)
|
|
578
|
+
expect((await layer()).state).toBe('fall')
|
|
579
|
+
await game.patch('hero', { 'physics/CharacterIntent': { move: [0, 0, -3] } })
|
|
580
|
+
await game.step(120) // lands, then walks at 3 m/s: between walk (1.5) and run (5)
|
|
581
|
+
const base = await layer()
|
|
582
|
+
expect(base.state).toBe('locomotion')
|
|
583
|
+
expect(base.active.at(-1).motions.find((m: any) => m.name === 'Walk').weight).toBeGreaterThan(0.5)
|
|
584
|
+
})
|
|
585
|
+
\`\`\`
|
|
586
|
+
`,
|
|
587
|
+
"rig-a-character.md": `# IK, sockets, and retargeting
|
|
588
|
+
|
|
589
|
+
IK runs on the animated pose each frame (after the clips, before rendering). Joint fields are
|
|
590
|
+
**paths** under the IK entity or its nearest ancestor that has them, like animation channels, so
|
|
591
|
+
put IK on the model root (or on an entity under it, one per leg). Targets and poles are entities.
|
|
592
|
+
|
|
593
|
+
\`\`\`json
|
|
594
|
+
{ "name": "hero", "components": {
|
|
595
|
+
"scene/SceneInstance": { "scene": { "path": "assets/hero.glb#Scene" },
|
|
596
|
+
"overrides": { "Armature/Hips/Spine/Chest/Shoulder_R/UpperArm_R/LowerArm_R/Hand_R": {
|
|
597
|
+
"animation/BoneSocket": { "name": "hand_r", "offset": [0, -0.07, 0] } } } },
|
|
598
|
+
"animation/AnimationPlayer": { "layers": [{ "clip": { "path": "assets/hero.glb#Animation/Walk" } }] },
|
|
599
|
+
"animation/FootPlacement": { "hips": "Armature/Hips", "maxStep": 0.4, "feet": [
|
|
600
|
+
{ "ik": "hero/leg-l", "footJoint": "Armature/Hips/UpLeg_L/Leg_L/Foot_L", "offset": 0.08 },
|
|
601
|
+
{ "ik": "hero/leg-r", "footJoint": "Armature/Hips/UpLeg_R/Leg_R/Foot_R", "offset": 0.08 } ] },
|
|
602
|
+
"animation/LookAtIk": { "joint": "Armature/Hips/Spine/Chest/Neck/Head", "target": "player",
|
|
603
|
+
"maxAngle": 70, "chain": [{ "joint": "Armature/Hips/Spine/Chest", "share": 0.2 }] } },
|
|
604
|
+
"children": [
|
|
605
|
+
{ "name": "leg-l", "components": { "animation/TwoBoneIk": {
|
|
606
|
+
"root": "Armature/Hips/UpLeg_L", "mid": "Armature/Hips/UpLeg_L/Leg_L",
|
|
607
|
+
"tip": "Armature/Hips/UpLeg_L/Leg_L/Foot_L", "target": "hero/foot-l", "pole": "hero/knee-l",
|
|
608
|
+
"tipRotation": 1 } } },
|
|
609
|
+
{ "name": "foot-l", "components": { "core/Transform": {} } },
|
|
610
|
+
{ "name": "knee-l", "components": { "core/Transform": { "translation": [-0.1, 0.5, -1] } } } ] }
|
|
611
|
+
\`\`\`
|
|
612
|
+
|
|
613
|
+
(and the same for the right leg). Add a sword with
|
|
614
|
+
\`"animation/Attach": { "owner": "hero", "socket": "hand_r" }\`: it rides the hand, and comes back
|
|
615
|
+
to it when the model reloads.
|
|
616
|
+
|
|
617
|
+
- \`TwoBoneIk\`: legs and arms; bends toward \`pole\`. \`ChainIk\`: FABRIK for tails and tentacles.
|
|
618
|
+
\`LookAtIk\`: \`axis\` is the joint's own forward (-Z by default), clamped to \`maxAngle\` degrees.
|
|
619
|
+
- \`FootPlacement\` needs \`"physics"\` in plugins. It casts down from each foot along the entity's
|
|
620
|
+
up, lowers the hips (never raises), and moves each leg's target onto the ground, turned to its
|
|
621
|
+
normal. Put the entity level with the soles; \`offset\` is the ankle height.
|
|
622
|
+
- \`weight\` (0 to 1) blends every solver with the animation: fade it for cutscenes or ragdolls.
|
|
623
|
+
- Retarget clips made for another skeleton: \`"animation/Retarget": { "source": { "path":
|
|
624
|
+
"assets/mixamo.glb#Skin/Skin0" }, "mode": "rotation-and-root" }\` on the player. Names match after
|
|
625
|
+
normalizing (\`mixamorig:LeftUpLeg\` = \`UpLeg_L\`); others go in a \`*.jointmap.json\`
|
|
626
|
+
(\`{ "joints": { "Spine1": "Chest" } }\`, the \`map\` field).
|
|
627
|
+
- \`animation_describe\` on the model: \`ik\` (distance left to each target, foot hits, hips drop,
|
|
628
|
+
problems like \`ik/unknown-joint\`) and \`retarget\` (hip ratio, unmapped joints). Screenshot with
|
|
629
|
+
\`"overlays": ["ik", "skeleton"]\` to see targets, poles, and foot rays.
|
|
630
|
+
`,
|
|
631
|
+
"make-a-data-asset.md": `# Make a data asset
|
|
632
|
+
|
|
633
|
+
Tuning data that isn't an entity (weapon stats, items, loot tables, wave schedules) is a project
|
|
634
|
+
data type: fields in \`scripts/\`, one JSON file per value.
|
|
635
|
+
|
|
636
|
+
1. **Define the type** with component-schema fields:
|
|
637
|
+
|
|
638
|
+
\`\`\`ts
|
|
639
|
+
export const Weapon = project.dataAsset('Weapon', {
|
|
640
|
+
damage: t.f32({ default: 10, min: 0, unit: 'hp', description: 'Damage per hit.' }),
|
|
641
|
+
fireRate: t.f32({ default: 4, unit: 'shots/s' }),
|
|
642
|
+
projectile: t.handle('Prefab', { description: 'Spawned per shot.' }),
|
|
643
|
+
upgradesTo: t.handle('<project>/Weapon'),
|
|
644
|
+
}, { extension: 'weapon', description: 'A ship weapon.' })
|
|
645
|
+
\`\`\`
|
|
646
|
+
|
|
647
|
+
Run \`shard docs\`: the type shows up in \`.agents/assets.md\` and its schema is written to
|
|
648
|
+
\`.shard/schemas/weapon.schema.json\`.
|
|
649
|
+
2. **Write files**: \`data/weapons/laser.weapon.json\`. Missing fields take their defaults.
|
|
650
|
+
|
|
651
|
+
\`\`\`json
|
|
652
|
+
{ "$schema": "../../.shard/schemas/weapon.schema.json", "damage": 12,
|
|
653
|
+
"projectile": { "path": "prefabs/bolt.prefab.json" } }
|
|
654
|
+
\`\`\`
|
|
655
|
+
3. **Make a variant** that starts from another file and overrides fields (struct fields merge field
|
|
656
|
+
by field, lists and handles replace):
|
|
657
|
+
|
|
658
|
+
\`\`\`json
|
|
659
|
+
{ "$extends": { "path": "data/weapons/laser.weapon.json" }, "damage": 18 }
|
|
660
|
+
\`\`\`
|
|
661
|
+
|
|
662
|
+
Editing the base re-imports its variants. MCP \`get_asset\` on a variant shows its value, its
|
|
663
|
+
\`extends\` chain, and which file set each field (\`info.setBy\`).
|
|
664
|
+
4. **Reference it** from a component: \`project.component('Armed', { weapon: t.handle('<project>/Weapon') })\`,
|
|
665
|
+
and in a scene \`"<project>/Armed": { "weapon": { "path": "data/weapons/laser.weapon.json" } }\`.
|
|
666
|
+
The weapon loads with the scene.
|
|
667
|
+
5. **Read it** in a system: \`world.resource(Weapon.store).get(armed.weapon)\` is one map lookup,
|
|
668
|
+
no allocation. Every weapon at once: \`await loadAll(world, Weapon)\` (from \`@aethervtt/shard-assets\`).
|
|
669
|
+
6. **Check**: \`shard import --json\` then \`shard validate --json\`. Errors point into the file;
|
|
670
|
+
a handle to the wrong type is \`schema/asset-type-mismatch\`, a loop of variants \`data/extends-cycle\`.
|
|
671
|
+
|
|
672
|
+
Saving a data file or changing the type's fields while the game runs reloads the values in place.
|
|
673
|
+
`,
|
|
674
|
+
"make-a-prefab.md": `# Make a prefab
|
|
675
|
+
|
|
676
|
+
A prefab is a reusable entity tree: \`prefabs/<name>.prefab.json\`, validated against
|
|
677
|
+
\`.shard/schemas/prefab.schema.json\`. It has one \`root\` entity, written like a scene entity:
|
|
678
|
+
|
|
679
|
+
\`\`\`json
|
|
680
|
+
{ "$schema": "../.shard/schemas/prefab.schema.json", "version": 1,
|
|
681
|
+
"assets": { "hull": { "type": "Material", "value": { "baseColor": "#8a93a6" } } },
|
|
682
|
+
"root": { "name": "ship",
|
|
683
|
+
"components": { "core/Transform": {}, "<project>/Health": { "max": 100 } },
|
|
684
|
+
"children": [
|
|
685
|
+
{ "name": "Hull", "components": { "scene/SceneInstance": { "scene": { "path": "assets/ship.glb#Scene" } } } },
|
|
686
|
+
{ "name": "Exhaust", "components": { "particles/ParticleSystem": { "effect": { "path": "assets/fx/exhaust.particles.json" } } } }
|
|
687
|
+
] } }
|
|
688
|
+
\`\`\`
|
|
689
|
+
|
|
690
|
+
1. **Extract**: move an entity and its children out of a scene into \`root\`. Entity fields inside
|
|
691
|
+
the prefab use paths from the root (\`"Hull"\`, \`"Hull/Cockpit"\`; \`"."\` is the root).
|
|
692
|
+
\`shard import\` then \`shard validate --json\`: errors point into the file.
|
|
693
|
+
2. **Place** it in a scene. The instance entity *is* the root: the prefab's root components merge
|
|
694
|
+
into it, and fields the entity sets win. Its children are generated, at \`player-ship/Exhaust\`:
|
|
695
|
+
|
|
696
|
+
\`\`\`json
|
|
697
|
+
{ "name": "player-ship", "components": {
|
|
698
|
+
"core/Transform": { "translation": [0, 5, 0] },
|
|
699
|
+
"scene/PrefabInstance": { "prefab": { "path": "prefabs/ship.prefab.json" },
|
|
700
|
+
"overrides": {
|
|
701
|
+
"Exhaust": { "particles/ParticleSystem": { "timeScale": 2 } },
|
|
702
|
+
"Hull/Cockpit": { "render/MeshMaterial": { "material": { "path": "materials/red.material.json" } } },
|
|
703
|
+
"Hull/Antenna": null,
|
|
704
|
+
"Exhaust/particles/ParticleEmitterOverrides": null } } } }
|
|
705
|
+
\`\`\`
|
|
706
|
+
|
|
707
|
+
Override keys are paths from the instance and reach into models inside it (\`Hull/Cockpit\`).
|
|
708
|
+
A patch sets fields (adding the component if missing), \`null\` removes the entity, and a path
|
|
709
|
+
ending in a component name set to \`null\` removes that component. Authored \`children\` of the
|
|
710
|
+
instance sit next to the generated ones (names can't clash). \`scene/SceneInstance\` takes the same
|
|
711
|
+
\`overrides\` for a model placed directly.
|
|
712
|
+
3. **Override live**: \`patch_entity\` on \`player-ship/Exhaust\`, check \`prefab_overrides\`, then
|
|
713
|
+
\`save_scene\` with \`"write": true\`: the change is written into \`overrides\`, nothing else.
|
|
714
|
+
\`prefab.apply\` (protocol) moves an instance's overrides into the prefab file instead.
|
|
715
|
+
4. **Variant**: a prefab that extends another. Editing the base updates every variant.
|
|
716
|
+
|
|
717
|
+
\`\`\`json
|
|
718
|
+
{ "version": 1, "extends": { "path": "prefabs/ship.prefab.json" },
|
|
719
|
+
"rootComponents": { "<project>/Health": { "max": 250 } },
|
|
720
|
+
"overrides": { "Hull/Antenna": null },
|
|
721
|
+
"children": [{ "name": "Turret", "components": { "core/Transform": {} } }] }
|
|
722
|
+
\`\`\`
|
|
723
|
+
5. **Spawn from code**: load once, then spawning copies a compiled template (cheap enough for
|
|
724
|
+
bullets). In a system, pass \`ctx.commands\` to spawn when the commands apply:
|
|
725
|
+
|
|
726
|
+
\`\`\`ts
|
|
727
|
+
import { loadPrefab, spawnPrefab } from '@aethervtt/shard-scene'
|
|
728
|
+
await loadPrefab(world, 'prefabs/ship.prefab.json') // e.g. in the plugin's setup
|
|
729
|
+
const ship = spawnPrefab(ctx.commands, 'prefabs/ship.prefab.json', {
|
|
730
|
+
transform: { translation: [0, 5, 0] },
|
|
731
|
+
overrides: { Exhaust: { 'particles/ParticleSystem': { timeScale: 2 } } },
|
|
732
|
+
})
|
|
733
|
+
\`\`\`
|
|
734
|
+
|
|
735
|
+
Unloaded prefabs throw \`prefab/not-loaded\`. MCP \`spawn_prefab\` does the same from tools.
|
|
736
|
+
|
|
737
|
+
Saving a prefab file updates every running instance and keeps each one's overrides; an override
|
|
738
|
+
whose path no longer exists is kept in the file and logged as \`prefab/stale-override\`.
|
|
739
|
+
\`get_asset\` on a prefab shows its entity tree, and on a variant its base chain.
|
|
740
|
+
`,
|
|
741
|
+
"add-sound.md": `# Add sound
|
|
742
|
+
|
|
743
|
+
Add \`"audio"\` to \`plugins\` in \`shard.json\`. Drop \`.ogg\`, \`.mp3\`, \`.wav\`, or \`.flac\` files under
|
|
744
|
+
\`assets/\` and run \`shard import\`: each is an \`AudioClip\` (MCP \`get_asset\` shows duration, channels,
|
|
745
|
+
sample rate, codec). Files stay compressed. Clips under \`music/\` stream; set \`"mode": "stream"\` in
|
|
746
|
+
the \`.meta\` for other long files, and \`loopStart\`/\`loopEnd\` (seconds) for loop points.
|
|
747
|
+
|
|
748
|
+
\`\`\`json
|
|
749
|
+
{ "name": "camera", "components": { "core/Transform": {}, "render/Camera3d": {},
|
|
750
|
+
"audio/AudioListener": {} } },
|
|
751
|
+
{ "name": "reactor", "components": { "core/Transform": { "translation": [4, 0, -10] },
|
|
752
|
+
"audio/AudioSource": { "clip": { "path": "assets/sfx/hum.ogg" }, "loop": true,
|
|
753
|
+
"rolloff": "linear", "maxDistance": 30 } } },
|
|
754
|
+
{ "name": "music", "components": { "core/Transform": {},
|
|
755
|
+
"audio/AudioSource": { "clip": { "path": "assets/music/theme.ogg" }, "bus": "music",
|
|
756
|
+
"loop": true, "spatial": false, "volume": 0.6 } } }
|
|
757
|
+
\`\`\`
|
|
758
|
+
|
|
759
|
+
- Sources autoplay; set \`playing\` to start or stop one. A clip that doesn't loop clears
|
|
760
|
+
\`playing\` at its end and sends \`audio/AudioFinished\`. Spatial sources follow their transform;
|
|
761
|
+
the listener is the first \`audio/AudioListener\` (put it on the camera).
|
|
762
|
+
- \`rolloff\`: \`inverse\` (default, natural), \`linear\` (silent at \`maxDistance\`; past it the source
|
|
763
|
+
goes virtual and holds no voice), \`exponential\`. \`panning\`: \`equal-power\` or \`hrtf\`.
|
|
764
|
+
- One-shots from code: \`playSound(world, weapon.sound, { position: muzzle, bus: 'sfx' })\` from
|
|
765
|
+
\`@aethervtt/shard-audio\` returns a voice id for \`stopSound\`. A handle field
|
|
766
|
+
(\`t.handle('AudioClip')\`) on a data asset keeps sounds in data.
|
|
767
|
+
- Buses are data (\`audio/Buses\`: master, music, sfx, ui, voice). Add one in a scene's
|
|
768
|
+
\`"resources": { "audio/Buses": { "engines": { "volume": 0.7, "parent": "sfx" } } }\`; change them
|
|
769
|
+
with \`setBus(world, 'sfx', { muted: true })\`. \`duck(world, 'music', { by: 0.7, release: 0.5 })\`
|
|
770
|
+
lowers music while a voice-bus line plays.
|
|
771
|
+
- Over 64 voices (8 per clip) the lowest \`priority\`, then the quietest, lose theirs.
|
|
772
|
+
- Check it: \`audio_describe\` lists every voice with gain and pan (-1 left, +1 right); gameplay tests
|
|
773
|
+
read \`await game.audioLog()\`:
|
|
774
|
+
|
|
775
|
+
\`\`\`ts
|
|
776
|
+
expect(await game.audioLog()).toContainEqual(
|
|
777
|
+
expect.objectContaining({ event: 'start', clip: 'assets/sfx/laser.ogg', position: [0, 0, 0] }),
|
|
778
|
+
)
|
|
779
|
+
\`\`\`
|
|
780
|
+
|
|
781
|
+
Browsers start audio after the first click or key press; until then \`audio_describe\` says
|
|
782
|
+
\`"context": "suspended"\` and voices wait. Headless runs record voices without sound.
|
|
783
|
+
`,
|
|
784
|
+
"make-a-hud.md": `# Make a HUD
|
|
785
|
+
|
|
786
|
+
Add \`"ui"\` to \`plugins\` in \`shard.json\`. UI is entities: a \`ui/UiRoot\` fills the screen, and
|
|
787
|
+
\`ui/UiNode\` children lay out by flexbox (\`direction\`, \`justify\`, \`alignItems\`, \`grow\`, \`gap\`,
|
|
788
|
+
\`padding\` and \`margin\` as top, right, bottom, left). Sizes are pixels, \`"50%"\`, or \`"auto"\`.
|
|
789
|
+
Make the HUD a prefab, \`prefabs/hud.prefab.json\`, and place it in the scene with
|
|
790
|
+
\`scene/PrefabInstance\`:
|
|
791
|
+
|
|
792
|
+
\`\`\`json
|
|
793
|
+
{ "version": 1, "root": { "name": "hud", "components": {
|
|
794
|
+
"ui/UiRoot": { "scale": "fit-height", "referenceSize": [1920, 1080],
|
|
795
|
+
"theme": { "path": "ui/hud.theme.json" } },
|
|
796
|
+
"ui/UiNode": { "padding": [24, 24, 24, 24], "justify": "space-between" } },
|
|
797
|
+
"children": [
|
|
798
|
+
{ "name": "fuel", "components": { "ui/UiNode": { "style": "panel", "width": 260 } },
|
|
799
|
+
"children": [{ "name": "label", "components": { "ui/UiText": { "text": "Fuel 100%" } } }] },
|
|
800
|
+
{ "name": "scan", "components": { "ui/UiNode": { "style": "button", "padding": [8, 16, 8, 16] },
|
|
801
|
+
"ui/UiButton": {} },
|
|
802
|
+
"children": [{ "name": "label", "components": { "ui/UiText": { "text": "Scan" } } }] }
|
|
803
|
+
] } }
|
|
804
|
+
\`\`\`
|
|
805
|
+
|
|
806
|
+
- Looks: \`ui/UiStyle\` (background, borderColor, borderWidth, radius, opacity), \`ui/UiText\`
|
|
807
|
+
(text, size, color, font, align, wrap), \`ui/UiImage\` (texture or atlas region, \`slice\` for
|
|
808
|
+
nine-slice, \`fit\`). A \`*.theme.json\` names styles; nodes pick one with \`UiNode.style\`, and
|
|
809
|
+
\`"button:hovered"\` (on, focused, hovered, pressed, disabled) overrides a state. Fields a node sets
|
|
810
|
+
win over its theme. Saving the theme restyles the running game.
|
|
811
|
+
- Text needs a font: \`UiText.font\`, the theme's \`font\`, or the \`ui/UiDefaults\` resource.
|
|
812
|
+
- Widgets: \`ui/UiButton\`, \`ui/UiToggle\` (\`on\`), \`ui/UiSlider\` (\`value\`, \`min\`, \`max\`, \`step\`),
|
|
813
|
+
\`ui/UiTextInput\` (\`value\`, shown through its UiText). Systems read \`ui/UiClick\` and
|
|
814
|
+
\`ui/UiChanged\` events. A click on UI never reaches gameplay actions or world picking.
|
|
815
|
+
- Bind text from a system: \`world.set(label, UiText, { text: \\\`Fuel \\\${pct}%\\\` })\`. Setting the
|
|
816
|
+
same text again costs nothing.
|
|
817
|
+
- Markers over planets: a node with \`ui/UiAnchor\` (\`target\` entity path, \`clamp\`, \`hideBehind\`,
|
|
818
|
+
\`scaleDistance\`) and an optional \`ui/UiAnchorArrow\` child that points at an off-screen target.
|
|
819
|
+
- Keyboard and gamepad: Tab moves focus; arrows and the D-pad move it; Enter, Space, or south
|
|
820
|
+
presses. Focus a menu's first button with \`ui_focus\` or \`focusUi(world, entity)\`.
|
|
821
|
+
|
|
822
|
+
Check it as data, not pixels: \`ui_describe\` gives each node's rect, text, and state, and
|
|
823
|
+
\`ui_click\` presses a button by path. In gameplay tests:
|
|
824
|
+
|
|
825
|
+
\`\`\`ts
|
|
826
|
+
await game.ui.click('hud/scan')
|
|
827
|
+
await game.step(1)
|
|
828
|
+
expect(await game.ui.node('fuel/label')).toMatchObject({ text: 'Fuel 100%', visible: true })
|
|
829
|
+
\`\`\`
|
|
830
|
+
|
|
831
|
+
Turn on the \`ui-layout\` overlay (\`debug_overlays\`) to see node rects, padding, and margins.
|
|
832
|
+
`,
|
|
833
|
+
"save-and-settings.md": `# Save the game, and remember settings
|
|
834
|
+
|
|
835
|
+
Saves are on in every project (no plugin to add). A save holds only what changed: per loaded
|
|
836
|
+
scene, the fields that differ from the scene file and the entities despawned; entities spawned at
|
|
837
|
+
runtime (prefab instances as the prefab plus overrides); resources defined with \`persist\`; named
|
|
838
|
+
RNG streams; and time. Loading reloads each scene from its current file, so scene edits made after
|
|
839
|
+
a save show up, and the player's changes stay.
|
|
840
|
+
|
|
841
|
+
\`\`\`ts
|
|
842
|
+
import { loadGame, saveGame } from '@aethervtt/shard-save'
|
|
843
|
+
|
|
844
|
+
await saveGame(world, 'slot1', { meta: { label: 'Crash site' } })
|
|
845
|
+
await loadGame(world, 'slot1') // between frames: from a UI click handler, ready(), or a tool
|
|
846
|
+
\`\`\`
|
|
847
|
+
|
|
848
|
+
- What's saved: components that serialize, unless defined with \`save: false\`. Tag an entity
|
|
849
|
+
\`save/NoSave\` to leave it out (and keep it as it is through a load).
|
|
850
|
+
- Resources: \`project.resource('Inventory', { schema, persist: true, init })\`, where \`schema\` is a
|
|
851
|
+
\`defineSchema\` of its fields. Only resources with \`persist\` go in saves.
|
|
852
|
+
- Loot that matches after a load: draw from \`world.resource(GlobalRng).stream('<project>/loot')\`
|
|
853
|
+
(a named stream); saves record every stream's state.
|
|
854
|
+
- A component whose fields change gets \`version\` and \`migrate\`; old saves migrate on load.
|
|
855
|
+
Migrations see only the saved fields, so handle missing ones.
|
|
856
|
+
- Saves go to platform storage: \`.shard/user/saves/<slot>.json\` for \`shard serve\` and
|
|
857
|
+
\`shard mcp\` (tests and \`shard run\` keep them in memory), IndexedDB in the browser.
|
|
858
|
+
|
|
859
|
+
Test fixtures: \`save_game\`, then \`save.read\` gives the JSON (schema
|
|
860
|
+
\`.shard/schemas/save.schema.json\`). Edit a value and load it with \`load_game { json }\`:
|
|
861
|
+
|
|
862
|
+
\`\`\`ts
|
|
863
|
+
const save = await game.saves.read()
|
|
864
|
+
save.scenes['scenes/main.scene.json'].changed.ship = { '<project>/Ship': { fuel: 10 } }
|
|
865
|
+
await game.saves.load(save)
|
|
866
|
+
\`\`\`
|
|
867
|
+
|
|
868
|
+
\`load_game\` returns warnings (\`save/stale-entity\`) for saved changes whose entity is no longer in
|
|
869
|
+
the scene file.
|
|
870
|
+
|
|
871
|
+
## Settings
|
|
872
|
+
|
|
873
|
+
\`\`\`ts
|
|
874
|
+
export const Settings = project.settings({
|
|
875
|
+
invertY: t.bool(),
|
|
876
|
+
difficulty: t.enum(['normal', 'easy', 'hard']),
|
|
877
|
+
})
|
|
878
|
+
\`\`\`
|
|
879
|
+
|
|
880
|
+
Settings load before Startup: project defaults from \`settings/*.json\`
|
|
881
|
+
(\`{ "<project>/Settings": { "difficulty": "easy" } }\`), then the player's \`settings.json\`. Read them
|
|
882
|
+
like any resource; plain writes persist within half a second. \`setSettings(world, Settings, {...})\`
|
|
883
|
+
validates and applies at once. \`engine/Settings\` is built in: \`volumes\` (bus \u2192 volume), \`quality\`
|
|
884
|
+
(shadow map sizes), \`locale\`, and \`bindings\`, which \`rebindAction(world, '<map>.<action>',
|
|
885
|
+
['Key:KeyJ'])\` fills in. Agents use \`settings.get\` and \`settings.set\`.
|
|
886
|
+
`,
|
|
887
|
+
"localize.md": `# Localize text
|
|
888
|
+
|
|
889
|
+
String tables are \`locales/<locale>.strings.json\` (schema \`.shard/schemas/strings.schema.json\`),
|
|
890
|
+
one per locale, named by BCP 47 tag:
|
|
891
|
+
|
|
892
|
+
\`\`\`json
|
|
893
|
+
{ "hud.fuel": "Fuel: {amount}%", "items.count": { "one": "{n} item", "other": "{n} items" } }
|
|
894
|
+
\`\`\`
|
|
895
|
+
|
|
896
|
+
- Give text a key instead of (or besides) its text: \`"ui/UiText": { "key": "hud.fuel", "params":
|
|
897
|
+
{ "amount": 100 } }\`. \`text/Text\` and \`text/ScreenText\` take \`key\` and \`params\` too. The
|
|
898
|
+
translated string shows, and follows locale switches on the next frame. \`text\` shows until the
|
|
899
|
+
key resolves.
|
|
900
|
+
- Update a parameter from a system: \`world.set(label, UiText, { params: { amount: pct } })\`.
|
|
901
|
+
- Code: \`tr(world, 'items.count', { n: 3 })\`. Plural forms (zero, one, two, few, many, other) are
|
|
902
|
+
picked by \`n\` (or \`count\`) with \`Intl.PluralRules\`; numbers format with \`Intl.NumberFormat\`.
|
|
903
|
+
- Switch: \`setLocale(world, 'pt-BR')\`, or \`locale.set\` from a tool. \`pt-BR\` falls back to \`pt\`,
|
|
904
|
+
then \`text/Locale\`'s \`fallback\` (\`en\`). The choice persists in \`engine/Settings\`.
|
|
905
|
+
|
|
906
|
+
Check it: \`shard validate\` reports keys a locale lacks, \`{params}\` that differ between locales,
|
|
907
|
+
and keys scenes and prefabs use that no table defines. At runtime, \`locale.missing\` lists what
|
|
908
|
+
didn't resolve, and \`ui_describe\` shows each node's \`key\` and translated \`text\`.
|
|
909
|
+
`,
|
|
910
|
+
"make-it-navigate.md": `# Make it navigate
|
|
911
|
+
|
|
912
|
+
Add \`"nav"\` to \`plugins\` in \`shard.json\` (\`"nav/grid"\` for grid-only 2D games: no WASM).
|
|
913
|
+
|
|
914
|
+
**3D: a navmesh.** Tag level geometry with \`nav/NavSource\`: its \`physics/Collider\` (or, without one,
|
|
915
|
+
its \`render/Mesh3d\`) and those of children bake. One \`nav/NavMesh\` entity holds the settings:
|
|
916
|
+
|
|
917
|
+
\`\`\`json
|
|
918
|
+
{ "name": "navmesh", "components": { "nav/NavMesh": { "agentRadius": 0.4, "agentHeight": 1.8,
|
|
919
|
+
"maxClimb": 0.3, "maxSlope": 45 } } },
|
|
920
|
+
{ "name": "ground", "components": { "core/Transform": {},
|
|
921
|
+
"physics/Collider": { "shape": "cuboid", "halfExtents": [20, 0.5, 20] }, "nav/NavSource": {} } },
|
|
922
|
+
{ "name": "swamp", "components": { "core/Transform": { "translation": [6, 0, 0] },
|
|
923
|
+
"physics/Collider": { "shape": "cuboid", "halfExtents": [3, 0.5, 3] },
|
|
924
|
+
"nav/NavSource": { "area": 1 } } }
|
|
925
|
+
\`\`\`
|
|
926
|
+
|
|
927
|
+
- Area costs are the \`nav/Areas\` resource: \`"resources": { "nav/Areas": { "1": 4 } }\` makes area 1
|
|
928
|
+
four times as costly (0 excludes it). Slopes steeper than \`maxSlope\` and gaps narrower than the
|
|
929
|
+
agent don't bake.
|
|
930
|
+
- Jumps and ladders: \`nav/OffMeshLink\` on an entity at one end, \`to\` the entity at the other.
|
|
931
|
+
- Bake: \`shard bake nav\` (or the \`nav_bake\` tool) writes the tiles to \`.shard/cache/nav\`; loading
|
|
932
|
+
then skips Recast. Moving a source at runtime rebuilds only the tiles it touches.
|
|
933
|
+
|
|
934
|
+
**2D: a grid.** A \`nav/NavGrid\` with \`source: "tilemap"\` on a \`sprite/Tilemap\` entity makes a cell
|
|
935
|
+
per tile, blocked where the \`layer\` has a tile (or only \`blockingTiles\`). \`source: "colliders"\`
|
|
936
|
+
rasterizes fixed 2D colliders; \`source: "data"\` reads a \`*.navgrid.json\`. \`diagonal\`:
|
|
937
|
+
\`no-corners\`, \`never\`, or \`always\`. Editing tiles with \`setTile\` updates the grid.
|
|
938
|
+
|
|
939
|
+
**Agents.** \`nav/NavAgent\` walks to \`destination\` or follows \`target\` (an entity path), repathing when
|
|
940
|
+
it moves. \`drive: "character"\` writes \`physics/CharacterIntent\` (slopes, steps, collisions), \`"velocity"\`
|
|
941
|
+
the body's \`physics/Velocity\`, \`"transform"\` moves it directly. \`nav/NavAgentState\` has \`status\`
|
|
942
|
+
(idle, moving, arrived, unreachable) and \`remaining\`; systems read \`nav/NavArrived\` and
|
|
943
|
+
\`nav/NavUnreachable\` events. From code: \`findPath(world, from, to, { out })\`, \`nearestPoint\`,
|
|
944
|
+
\`navRaycast\` from \`@aethervtt/shard-nav\`.
|
|
945
|
+
|
|
946
|
+
Check it as data: \`nav_path\` says whether two points connect, \`nav_describe\` lists tiles, skipped
|
|
947
|
+
sources, and each agent's route. In gameplay tests:
|
|
948
|
+
|
|
949
|
+
\`\`\`ts
|
|
950
|
+
expect((await game.nav.path([0, 0, 10], [6, 3, -12])).status).toBe('complete')
|
|
951
|
+
await game.step(600)
|
|
952
|
+
expect(await game.nav.agent('enemy')).toMatchObject({ status: 'arrived' })
|
|
953
|
+
\`\`\`
|
|
954
|
+
|
|
955
|
+
Turn on the \`navmesh\` overlay (\`debug_overlays\`) to see polygons by area, links, and paths.
|
|
956
|
+
`,
|
|
957
|
+
"make-a-noise-graph.md": `# Make a noise graph
|
|
958
|
+
|
|
959
|
+
Terrain heights, biome masks, rock displacement, and cloud textures all start as a noise graph:
|
|
960
|
+
\`assets/noise/<name>.noise.json\`, validated against \`.shard/schemas/noise.schema.json\` (errors point
|
|
961
|
+
into the file). One file gives the CPU (gameplay, headless tests) and the GPU (materials, compute)
|
|
962
|
+
the same function.
|
|
963
|
+
|
|
964
|
+
\`\`\`json
|
|
965
|
+
{ "output": "height",
|
|
966
|
+
"nodes": {
|
|
967
|
+
"continents": { "fbm": { "source": "simplex", "octaves": 5, "frequency": 0.8, "seed": 1 } },
|
|
968
|
+
"mountains": { "ridged": { "source": "simplex", "octaves": 6, "frequency": 3.2, "seed": 2 } },
|
|
969
|
+
"mask": { "remap": { "input": "continents", "from": [0.1, 0.4], "to": [0, 1], "clamp": true } },
|
|
970
|
+
"warped": { "warp": { "input": "mountains", "by": "continents", "amount": 0.15 } },
|
|
971
|
+
"height": { "add": ["continents", { "multiply": ["warped", "mask"] }] } } }
|
|
972
|
+
\`\`\`
|
|
973
|
+
|
|
974
|
+
- An input is a node name, an inline node, or a number. Sources (value, perlin, simplex, cellular)
|
|
975
|
+
and fractals (fbm, ridged, billow) take \`seed\` and \`frequency\` (features per unit). Operators:
|
|
976
|
+
add, multiply, min, max, lerp, select, remap, clamp, curve, terrace, abs, power, constant; domain:
|
|
977
|
+
warp, scale, translate. The schema lists every parameter with its range.
|
|
978
|
+
- Layer, don't hand-tune one node: a low-frequency fbm for continents, ridged for mountains, a
|
|
979
|
+
\`remap\` of the continents (clamped to [0, 1]) as a mask so mountains only rise on land, and a
|
|
980
|
+
small \`warp\` so ridges bend. Change one layer's \`seed\` to reroll only that layer.
|
|
981
|
+
- Frequencies are per unit of the domain. For a planet of radius R, continents are around 1/R to 4/R.
|
|
982
|
+
Set \`"extent"\` to the radius and the importer rejects octaves too fine to address that far out.
|
|
983
|
+
- Check it numerically, not by eye: MCP \`noise_stats\` with \`{ "graph": "assets/noise/planet.noise.json",
|
|
984
|
+
"domain": "sphere", "thresholds": [0] }\` says what fraction of the surface is below 0 (under water).
|
|
985
|
+
\`sample_noise\` gives exact values at points; pass \`"graph"\` as JSON to try an edit before saving.
|
|
986
|
+
- Look at it: \`preview_noise\` (or \`preview_asset\` with \`options\`) renders it in grayscale, on a plane or a
|
|
987
|
+
sphere; \`"node": "mask"\` shows one intermediate node on its own.
|
|
988
|
+
- In code: \`sampleNoise\` (near the origin), \`sampleOffset\` (any distance: an f64 origin plus small
|
|
989
|
+
offsets), \`sampleSpherePatch\` / \`sampleGrid2d\`, and \`...Async\` versions on the worker pool.
|
|
990
|
+
- In a material: \`project.material('Rock', { shader: 'project::rock', noise: { detail:
|
|
991
|
+
'assets/noise/rock.noise.json' } })\` makes \`noise_detail(p: vec3f, seed: u32) -> f32\` available
|
|
992
|
+
to \`import material::rock::noise_detail;\`. Saving the graph re-renders the material.
|
|
993
|
+
`,
|
|
994
|
+
"make-a-generator.md": `# Make a generator
|
|
995
|
+
|
|
996
|
+
Don't place 10 000 rocks: write the rule for one rock and let seeds make the rest. A generator is a
|
|
997
|
+
pure function \`(seed, params) \u2192 output\` (a mesh, a texture, a data value, or entities), cached by the
|
|
998
|
+
hash of its inputs, so the same seed gives the same bytes on every host.
|
|
999
|
+
|
|
1000
|
+
1. Define it in \`scripts/\` (any module the entry imports):
|
|
1001
|
+
|
|
1002
|
+
\`\`\`ts
|
|
1003
|
+
export const Rock = project.generator('Rock', {
|
|
1004
|
+
params: {
|
|
1005
|
+
radius: t.f32({ default: 1, min: 0.05, unit: 'm' }),
|
|
1006
|
+
roughness: t.f32({ default: 0.3, min: 0, max: 1 }),
|
|
1007
|
+
detail: t.u8({ default: 3, min: 0, max: 6 }),
|
|
1008
|
+
shape: t.handle('NoiseGraph', { description: 'Displacement noise.' }),
|
|
1009
|
+
},
|
|
1010
|
+
output: 'mesh',
|
|
1011
|
+
run(ctx, p) {
|
|
1012
|
+
const mesh = ctx.mesh.icosphere(p.detail)
|
|
1013
|
+
const shape = ctx.load<NoiseGraph>(p.shape) // only handles in params; editing it regenerates
|
|
1014
|
+
const n = new Float32Array(mesh.positions.length / 3)
|
|
1015
|
+
ctx.noise.sample(shape, ctx.seed, mesh.positions, n)
|
|
1016
|
+
for (let i = 0; i < n.length; i++) {
|
|
1017
|
+
const s = p.radius * (1 + p.roughness * n[i]!)
|
|
1018
|
+
for (let k = 0; k < 3; k++) mesh.positions[i * 3 + k]! *= s
|
|
1019
|
+
}
|
|
1020
|
+
return ctx.mesh.finish(mesh, { normals: true, tangents: true, lods: [0.5, 0.2] })
|
|
1021
|
+
},
|
|
1022
|
+
})
|
|
1023
|
+
\`\`\`
|
|
1024
|
+
|
|
1025
|
+
\`output\` is \`'mesh'\`, \`'texture'\` (\`{ width, height, mips: [rgba8] }\`), \`'data'\` or a data type
|
|
1026
|
+
(\`project.dataAsset\`), or \`'entities'\` (a prefab-shaped entity \`{ name, components, children }\`,
|
|
1027
|
+
or a list of them). A generator can use another: \`await ctx.generate(Planet, params, ctx.childSeed(i))\`
|
|
1028
|
+
returns a ref to put in its output; each is cached on its own.
|
|
1029
|
+
2. Keep it pure. Randomness is \`ctx.rng\` (seeded from the seed and the generator's name), per-part
|
|
1030
|
+
seeds \`ctx.childSeed(label)\`. \`Math.random\`, \`Date.now\`, and \`performance.now\` throw
|
|
1031
|
+
\`procgen/nondeterministic\` (and \`shard check\` flags them). Prefer \`+ * sqrt\` over trig for
|
|
1032
|
+
geometry that must match across browsers.
|
|
1033
|
+
3. Look at it: MCP \`preview_generator { "generator": "<project>/Rock", "seeds": "1-9", "params": { "roughness": 0.6 } }\`
|
|
1034
|
+
(or \`shard gen scripts:Rock --seeds 1-9 --out sheet.png\`) renders a labelled contact sheet. Change
|
|
1035
|
+
one param, preview again, compare. \`run_generator\` reports vertex and triangle counts, bounds,
|
|
1036
|
+
the cache key, and whether it was a cache hit.
|
|
1037
|
+
4. Place it:
|
|
1038
|
+
- a file \`generators/boulder.gen.json\` (schema \`.shard/schemas/gen.schema.json\`):
|
|
1039
|
+
\`{ "generator": "<project>/Rock", "seed": 3, "params": { "radius": 2, "shape": { "path": "assets/noise/rock.noise.json" } } }\`,
|
|
1040
|
+
then \`"mesh": { "path": "generators/boulder.gen.json" }\` in a prefab (\`#LOD1\` for a level of detail)
|
|
1041
|
+
- a ref: \`"mesh": { "path": "procedural:<project>/Rock?seed=3&radius=2" }\`
|
|
1042
|
+
- entities: \`"procgen/GeneratorInstance": { "generator": { "path": "<project>/StarSystem" }, "seed": 7 }\`
|
|
1043
|
+
spawns the fragment as children; patching \`seed\` regenerates and keeps the ids of unchanged paths
|
|
1044
|
+
- at runtime: \`const mesh = await generate(world, Rock, { radius: 2 }, seed)\`
|
|
1045
|
+
5. \`shard validate\` checks every \`*.gen.json\` against its generator (\`procgen/bad-params\` with a pointer).
|
|
1046
|
+
Editing the generator's code (or a helper it imports) regenerates its outputs on hot reload; the
|
|
1047
|
+
reload report lists them under \`procgen.regenerated\`.
|
|
1048
|
+
`,
|
|
1049
|
+
"make-particles.md": `# Make a particle effect
|
|
1050
|
+
|
|
1051
|
+
Add \`"particles"\` to \`plugins\` in \`shard.json\`. An effect is \`assets/fx/<name>.particles.json\`,
|
|
1052
|
+
validated against \`.shard/schemas/particle-effect.schema.json\` (errors point into the file):
|
|
1053
|
+
|
|
1054
|
+
\`\`\`json
|
|
1055
|
+
{ "emitters": [{ "name": "exhaust", "capacity": 20000, "spawn": { "rate": 4000 },
|
|
1056
|
+
"shape": { "type": "cone", "angle": 8, "radius": 0.2 },
|
|
1057
|
+
"init": { "lifetime": [0.3, 0.6], "speed": [18, 25], "size": [0.2, 0.35], "color": "#9ad4ff" },
|
|
1058
|
+
"update": [{ "module": "drag", "coefficient": 1.5 }, { "module": "curl-noise", "strength": 2 },
|
|
1059
|
+
{ "module": "color-over-life", "gradient": [[0, "#e9f6ff", 1], [1, "#1a3a7a", 0]] }],
|
|
1060
|
+
"render": { "blend": "additive", "emissive": 4000 } }] }
|
|
1061
|
+
\`\`\`
|
|
1062
|
+
|
|
1063
|
+
- Values: a number or \`[min, max]\`; curves \`[[t, v], \u2026]\`; gradients \`[[t, color, alpha], \u2026]\`.
|
|
1064
|
+
- Modules: gravity, drag, velocity-over-life, curl-noise, attractor, rotation, color-over-life,
|
|
1065
|
+
size-over-life, collision (bounces off what the camera sees).
|
|
1066
|
+
- Use it: \`"particles/ParticleSystem": { "effect": { "path": "assets/fx/exhaust.particles.json" } }\`;
|
|
1067
|
+
\`space: "local"\` keeps particles attached. Drive it from gameplay with
|
|
1068
|
+
\`particles/ParticleEmitterOverrides\` (\`spawnScale\`). Saving the file updates the running effect.
|
|
1069
|
+
- Look: MCP \`preview_asset\` on the file shows it after one second; \`render.describe\` \u2192
|
|
1070
|
+
\`particles\` gives alive counts.
|
|
1071
|
+
`,
|
|
1072
|
+
"large-worlds.md": `# Build a large world: grids and the floating origin
|
|
1073
|
+
|
|
1074
|
+
Positions are f32. Past about 10 km from the origin they lose millimetres, and past 1 000 km they
|
|
1075
|
+
visibly jitter. For anything bigger (a star system, a planet you can land on), put entities in a
|
|
1076
|
+
\`transform/Grid\` and mark the camera \`transform/FloatingOrigin\`. A small level doesn't need any of this.
|
|
1077
|
+
|
|
1078
|
+
A position in a grid is \`GridCell.cell \xD7 Grid.cellSize + Transform.translation\`. The cell is an exact
|
|
1079
|
+
integer and the translation stays under half a cell, so everything near the camera is precise.
|
|
1080
|
+
\`GlobalTransform\` is relative to the origin's cell, so it stays small too.
|
|
1081
|
+
|
|
1082
|
+
\`\`\`json
|
|
1083
|
+
{ "name": "system", "components": { "transform/Grid": { "cellSize": 2000 } }, "children": [
|
|
1084
|
+
{ "name": "planet", "components": {
|
|
1085
|
+
"transform/Grid": { "cellSize": 2000 },
|
|
1086
|
+
"transform/GridCell": { "cell": [75000000, 0, 0] },
|
|
1087
|
+
"core/Transform": { "rotationEuler": [0, 20, 0] } } },
|
|
1088
|
+
{ "name": "ship", "components": {
|
|
1089
|
+
"transform/GridCell": { "cell": [74996000, 0, 0] },
|
|
1090
|
+
"core/Transform": { "translation": [120, 0, -40] } },
|
|
1091
|
+
"children": [ { "name": "camera", "components": {
|
|
1092
|
+
"render/Camera3d": {}, "transform/FloatingOrigin": {} } } ] } ] }
|
|
1093
|
+
\`\`\`
|
|
1094
|
+
|
|
1095
|
+
| Grid | Suggested cell | Reach (\xB12\xB3\xB9 cells) | f32 offset precision |
|
|
1096
|
+
|---|---|---|---|
|
|
1097
|
+
| Galaxy | 10\xB9\xB2 m | 2\xD710\xB2\xB9 m | ~60 km, fine for stars |
|
|
1098
|
+
| Star system | 2 000 m | 4\xD710\xB9\xB2 m (~30 AU) | ~0.1 mm |
|
|
1099
|
+
| Planet | 2 000 m | far beyond any planet | ~0.1 mm |
|
|
1100
|
+
|
|
1101
|
+
- Only direct children of a grid carry \`GridCell\`. Deeper descendants use their parent's transform as
|
|
1102
|
+
usual. A nested grid (a planet) is a \`Grid\` with a \`GridCell\` in its parent grid; spin it with its
|
|
1103
|
+
own \`Transform\` rotation and everything on its surface turns with it.
|
|
1104
|
+
- Moving things: write the translation as usual. \`transform/recenter\` moves an entity into the next
|
|
1105
|
+
cell once it goes \`cellSize / 2 + hysteresis\` past the centre. Don't write a far position into
|
|
1106
|
+
\`translation\`: \`shard validate\` reports \`transform/translation-outside-cell\`.
|
|
1107
|
+
- One \`FloatingOrigin\` per world (\`transform/multiple-origins\`), usually on the camera. When it
|
|
1108
|
+
changes cells, \`transform/OriginShift\` fires with the offset: code that caches \`GlobalTransform\`
|
|
1109
|
+
positions across frames adds \`offset\` to them.
|
|
1110
|
+
- From tools: \`patch_entity\` with \`{ "position64": [3.8e8, 0, 0], "grid": "system" }\` places an entity
|
|
1111
|
+
exactly and splits the cell for you. \`get_entity\` returns \`worldPosition64\` (relative to the origin
|
|
1112
|
+
cell), and \`app.describe\` lists the grids and where the origin is.
|
|
1113
|
+
- In code (cold paths, f64): \`worldPosition64(world, e, out, frame?)\`, \`distance64(world, a, b)\`,
|
|
1114
|
+
\`placeInGrid(world, e, grid, position)\`, and \`reparentToGrid(world, ship, planetGrid)\` to move a
|
|
1115
|
+
ship into a rotating planet grid without a jump.
|
|
1116
|
+
- Physics runs in the origin's frame, so bodies near the camera are precise; saves store cells, so a
|
|
1117
|
+
save 10\xB9\xB2 m out loads to the millimetre.
|
|
1118
|
+
`,
|
|
1119
|
+
"make-a-planet.md": `# Make a planet you can land on
|
|
1120
|
+
|
|
1121
|
+
A planet is an entity with \`terrain/Planet\` (it brings \`transform/Grid\`) and the \`terrain\` plugin in
|
|
1122
|
+
\`shard.json\` (with \`render/forward\` to draw it and \`physics3d\` to stand on it). Heights come from a
|
|
1123
|
+
noise graph (make-a-noise-graph.md); chunks are generated on the GPU as the camera moves, from six at
|
|
1124
|
+
orbit down to 0.4 m vertex spacing on the ground. Put the camera in the planet's grid with
|
|
1125
|
+
\`transform/FloatingOrigin\` (large-worlds.md).
|
|
1126
|
+
|
|
1127
|
+
\`\`\`json
|
|
1128
|
+
{ "name": "planet", "components": {
|
|
1129
|
+
"transform/Grid": { "cellSize": 2000 },
|
|
1130
|
+
"terrain/Planet": { "radius": 600000, "heightScale": 3000, "seed": 7,
|
|
1131
|
+
"height": { "path": "assets/noise/planet.noise.json" },
|
|
1132
|
+
"climate": { "path": "assets/noise/climate.noise.json" },
|
|
1133
|
+
"biomes": { "path": "assets/biomes/planet.biomes.json" }, "ocean": true, "seaLevel": 0 },
|
|
1134
|
+
"physics/GravitySource": { "strength": 9.81, "radius": 600000 } } }
|
|
1135
|
+
\`\`\`
|
|
1136
|
+
|
|
1137
|
+
- Height: the graph's output (about \u22121 to 1) \xD7 \`heightScale\` metres above \`radius\`. The graph is
|
|
1138
|
+
sampled at points on the sphere in metres, so frequency 1e-5 is 100 km features. Keep \`radius\` under
|
|
1139
|
+
50 000 km (\`terrain/radius-too-large\`).
|
|
1140
|
+
- Climate: a graph with \`temperature\` and \`moisture\` nodes (\u22121 to 1). Latitude and altitude cool it
|
|
1141
|
+
(\`latitudeBias\`, \`snowLine\` in the BiomeSet).
|
|
1142
|
+
- Biomes: \`*.biome.json\` files (ranges of temperature, moisture, height, slope; a tint; up to four
|
|
1143
|
+
texture layers by slope) listed in a \`*.biomes.json\` with \`albedo\`, \`normal\`, and \`orm\`
|
|
1144
|
+
\`*.texarray.json\` texture arrays (\`{ "layers": ["grass.png", "rock.png"], "size": 1024 }\`).
|
|
1145
|
+
- Standing on it: characters, dynamic bodies, and \`terrain/TerrainAnchor\` entities get collider chunks
|
|
1146
|
+
within \`colliderRadius\`, built from the same CPU noise as \`planetHeightAt(world, planet, dir)\`, and
|
|
1147
|
+
those chunks render their own vertices. Use \`CharacterController\` with \`up: "gravity"\`.
|
|
1148
|
+
- Walking agents: add \`terrain/PlanetNav\` to the planet; a navmesh follows the \`NavAgent\`s.
|
|
1149
|
+
- Check it: MCP \`terrain_map\` (\`{ "mode": "biomes" }\`) shows continents, sea, and poles in one image;
|
|
1150
|
+
\`sample_terrain\` gives height, water depth, slope, and biome at \`[lat, lon]\` points;
|
|
1151
|
+
\`describe_terrain\` lists chunks per depth, generation, and colliders. Overlays \`terrain-lod\`,
|
|
1152
|
+
\`terrain-biomes\`, and \`terrain-colliders\` show them in a screenshot.
|
|
1153
|
+
- Edit the height graph while \`shard dev\` runs: visible chunks regenerate together, colliders too.
|
|
1154
|
+
\`terrain/Budget\` caps chunks per frame and GPU time.
|
|
1155
|
+
- Frame time: \`vertexPixels\` (4) stops chunks splitting once vertices are that many pixels apart
|
|
1156
|
+
(normal tiles keep the finer relief in the shading), and \`terrain/Budget.triangles\` (2M) coarsens
|
|
1157
|
+
a planet a little at a time when it draws more, so frame time holds at any resolution.
|
|
1158
|
+
\`describe_terrain\` shows \`detail.lodBias\` above 1 while it's coarsening.
|
|
1159
|
+
- Give it air: \`"render/Atmosphere": {}\` on the planet entity takes the planet's radius, draws
|
|
1160
|
+
the sky from the ground to orbit, and hazes distant terrain (tune-an-atmosphere.md).
|
|
1161
|
+
`,
|
|
1162
|
+
"tune-an-atmosphere.md": `# Tune an alien sky
|
|
1163
|
+
|
|
1164
|
+
\`render/Atmosphere\` on a planet entity (the \`terrain/Planet\`, or any entity: it's centered on
|
|
1165
|
+
its origin) gives it a sky you can see from the surface, from a ship, and from orbit as a glowing
|
|
1166
|
+
limb. It also hazes distant terrain and dims and reddens the sun near the horizon, for lighting too.
|
|
1167
|
+
\`bottomRadius: 0\` takes the Planet's radius. Units are physical: metres and 1/m.
|
|
1168
|
+
|
|
1169
|
+
\`\`\`json
|
|
1170
|
+
"render/Atmosphere": { "thickness": 60000, "rayleighScattering": [5.8e-6, 13.6e-6, 33.1e-6] }
|
|
1171
|
+
\`\`\`
|
|
1172
|
+
|
|
1173
|
+
Presets (\`AtmospherePresets\` in code; copy the values into a scene): \`earth\` (the defaults),
|
|
1174
|
+
\`mars\`, \`thin\`, \`thick-haze\`, \`alien-violet\`, \`gas-giant\`.
|
|
1175
|
+
|
|
1176
|
+
Which knob does what:
|
|
1177
|
+
|
|
1178
|
+
- **Sky color and sunset color: \`rayleighScattering\`.** The sky takes the color that scatters most;
|
|
1179
|
+
sunsets get what's left after the long path. Blue above red gives a blue sky with orange sunsets.
|
|
1180
|
+
Reverse it for a red sky with blue sunsets; raise red and blue for violet. Scale all three
|
|
1181
|
+
together to thicken or thin the air.
|
|
1182
|
+
- **Haze and the glow around the sun: \`mieScattering\`** (4e-6 very clear, 1e-4 hazy) with
|
|
1183
|
+
\`mieScale\` (how high the haze reaches) and \`mieG\` (how tight the halo is). \`mieAbsorption\` makes
|
|
1184
|
+
the haze dirtier and darker. \`mieGOffset\` gives different colors different halos: Mars' blue
|
|
1185
|
+
sunset is blue scattered more forward, \`[-0.15, 0, 0.2]\`.
|
|
1186
|
+
- **Twilight and tinted air: \`absorption\`**, a layer at \`absorptionCenter\` \`absorptionWidth\` wide.
|
|
1187
|
+
Earth's ozone keeps twilight blue; at center 0 and a wide layer it's dust (Mars' butterscotch) or
|
|
1188
|
+
methane (Neptune's blue: absorb red).
|
|
1189
|
+
- **How far you see: all of the above.** Distant terrain turns the sky's color; 20 km on Earth is
|
|
1190
|
+
noticeably blue, a few km in \`thick-haze\`.
|
|
1191
|
+
- **Ground below the horizon:** \`groundAlbedo\`, where no terrain is drawn (from high up, or before
|
|
1192
|
+
chunks load).
|
|
1193
|
+
- **Gas giants:** \`bottomRadius\` is the cloud top and \`deckDepth\` the haze below it that thickens to
|
|
1194
|
+
opaque cloud as a ship dives.
|
|
1195
|
+
|
|
1196
|
+
Check it without screenshots: MCP \`sample_atmosphere\` (\`{ "direction": [0, 1, 0] }\`) gives sky
|
|
1197
|
+
radiance and transmittance from the camera; with \`position\` 40 km up the sky should be near black.
|
|
1198
|
+
\`render.describe\`'s \`atmosphere\` section lists each camera's primary atmosphere, the sunlight
|
|
1199
|
+
reaching the camera (\`illuminanceAtCamera\`), and GPU time. In code, \`sunTransmittanceAt(world,
|
|
1200
|
+
planet, position)\` is how much sunlight gets through, for gameplay (solar panels, heat).
|
|
1201
|
+
|
|
1202
|
+
- The sun's \`DirectionalLight.illuminance\` is above the air (direct-sun 100 000). Its
|
|
1203
|
+
\`angularDiameter\` (0.53\xB0) sizes the disk; the two brightest lights both draw one.
|
|
1204
|
+
- \`render/AtmosphereSettings\` on a camera: \`aerialPerspective: false\` for only the sky; the froxel
|
|
1205
|
+
range \`maxDistance\` (32 km) beyond which haze is marched per pixel.
|
|
1206
|
+
- \`render/Fog\` is for flat scenes: inside an atmosphere it's ignored (\`render/fog-with-atmosphere\`)
|
|
1207
|
+
unless \`mode: "add"\`, for ground fog on top.
|
|
1208
|
+
- \`render/ProceduralSky\` is an Earth atmosphere pinned 10 m under the camera: fine for a level, but
|
|
1209
|
+
you can't fly out of it.
|
|
1210
|
+
`,
|
|
1211
|
+
"inspect-a-scene.md": `# Find what's wrong in a scene
|
|
1212
|
+
|
|
1213
|
+
When a screenshot looks wrong, connect the pixels to entities:
|
|
1214
|
+
|
|
1215
|
+
1. \`screenshot\` with \`"overlays": ["bounds", "labels"]\`: every mesh gets its outline and its scene
|
|
1216
|
+
path. Add \`"filter": "ship/"\` to label one subtree. Other overlays: lights, cameras, cascades,
|
|
1217
|
+
normals, axes.
|
|
1218
|
+
2. \`pick\` a pixel that looks wrong (same coordinates as the screenshot): its path, position, and
|
|
1219
|
+
normal. Or \`raycast\` from a point, e.g. straight down to find the ground.
|
|
1220
|
+
3. \`get_entity\` on that path, \`patch_entity\` the fix, and screenshot again with the same overlays.
|
|
1221
|
+
|
|
1222
|
+
In your own systems, draw with gizmos (they last one frame, or \`duration\` seconds):
|
|
1223
|
+
|
|
1224
|
+
\`\`\`ts
|
|
1225
|
+
const g = world.resource(Gizmos)
|
|
1226
|
+
g.line(from, to, [1, 0.2, 0.2, 1])
|
|
1227
|
+
g.arrow(position, target, [1, 1, 0, 1], { width: 2 })
|
|
1228
|
+
g.sphere(center, radius, [0, 1, 1, 1], { depthTest: false })
|
|
1229
|
+
g.label(position, 'target', [1, 1, 1, 1])
|
|
1230
|
+
\`\`\`
|
|
1231
|
+
|
|
1232
|
+
\`list_gizmos\` shows what was drawn as data. \`pick(world, camera, x, y)\` and
|
|
1233
|
+
\`raycast(world, origin, direction)\` from \`@aethervtt/shard-render\` do the same in code.
|
|
1234
|
+
`,
|
|
1235
|
+
"debug-with-screenshots.md": `# Look at the game
|
|
1236
|
+
|
|
1237
|
+
- \`shard screenshot <scene> --out shot.png --frames 60\` renders headless (no window needed).
|
|
1238
|
+
- With the MCP server: \`screenshot\` returns the image; \`step\` advances frames; \`press\`/\`hold\`
|
|
1239
|
+
drive input; \`query_entities\` / \`get_entity\` read state; \`recent_errors\` shows what failed.
|
|
1240
|
+
`
|
|
1241
|
+
};
|
|
1242
|
+
function agentsBlock(manifest) {
|
|
1243
|
+
return `${GENERATED_START}
|
|
1244
|
+
# ${manifest.name}
|
|
1245
|
+
|
|
1246
|
+
A [Shard](https://github.com/) project. The engine is data-first: scenes are JSON validated against
|
|
1247
|
+
component schemas, game code is ECS (components, systems) in \`scripts/\`.
|
|
1248
|
+
|
|
1249
|
+
## Commands
|
|
1250
|
+
|
|
1251
|
+
\`\`\`sh
|
|
1252
|
+
shard validate --json # manifest, assets, scenes: every error with a path
|
|
1253
|
+
shard import --json # import new and changed asset files
|
|
1254
|
+
shard mv <from> <to> # move an asset and rewrite references to it
|
|
1255
|
+
shard run --frames 600 # headless run
|
|
1256
|
+
shard screenshot ${manifest.startScene} --out shot.png
|
|
1257
|
+
shard test --json # gameplay tests in tests/
|
|
1258
|
+
shard check --json # type-check scripts: file, line, column
|
|
1259
|
+
shard gen <generator> --seeds 1-9 --out sheet.png # preview a generator's outputs
|
|
1260
|
+
shard dev # play it in a browser; saves hot reload in place
|
|
1261
|
+
shard docs # regenerate this block, .agents/, and .shard/schemas/
|
|
1262
|
+
shard mcp # MCP server for this project (see .mcp.json)
|
|
1263
|
+
\`\`\`
|
|
1264
|
+
|
|
1265
|
+
## Where things are
|
|
1266
|
+
|
|
1267
|
+
- \`shard.json\`: manifest (start scene: \`${manifest.startScene}\`, seed ${manifest.seed}, plugins: ${manifest.plugins.join(", ")})
|
|
1268
|
+
- \`scripts/main.ts\`: the project plugin; project types are named \`${manifest.name}/<Name>\`
|
|
1269
|
+
- \`scenes/\`: scene files; \`tests/\`: gameplay tests; \`shaders/\`: \`project::\` shader modules
|
|
1270
|
+
- \`generators/\`: \`*.gen.json\` generator outputs; \`.agents/generators.md\`: every generator and its params
|
|
1271
|
+
- \`${manifest.assetRoots.join("/`, `")}/\`: asset files, each with a \`.meta\` (guid, import settings)
|
|
1272
|
+
- \`.agents/components.md\`: every component and field; \`.agents/assets.md\`: importers and
|
|
1273
|
+
project data types;
|
|
1274
|
+
\`.agents/errors.md\`: error codes
|
|
1275
|
+
- \`.agents/skills/\`: recipes for common tasks
|
|
1276
|
+
|
|
1277
|
+
Engine plugins available: ${Object.entries(BUILTIN_PLUGINS).map(([n, d]) => `\`${n}\` (${d})`).join("; ")}.
|
|
1278
|
+
${GENERATED_END}`;
|
|
1279
|
+
}
|
|
1280
|
+
function mergeAgentsMd(existing, manifest) {
|
|
1281
|
+
const block = agentsBlock(manifest);
|
|
1282
|
+
if (!existing)
|
|
1283
|
+
return `${block}
|
|
1284
|
+
|
|
1285
|
+
## Notes
|
|
1286
|
+
|
|
1287
|
+
Project-specific notes for agents go here; \`shard docs\` keeps them.
|
|
1288
|
+
`;
|
|
1289
|
+
const start = existing.indexOf(GENERATED_START);
|
|
1290
|
+
const end = existing.indexOf(GENERATED_END);
|
|
1291
|
+
if (start === -1 || end === -1) return `${block}
|
|
1292
|
+
|
|
1293
|
+
${existing}`;
|
|
1294
|
+
return `${existing.slice(0, start)}${block}${existing.slice(end + GENERATED_END.length)}`;
|
|
1295
|
+
}
|
|
1296
|
+
function generateDocs(manifest, errors) {
|
|
1297
|
+
const files = {
|
|
1298
|
+
".agents/components.md": renderComponentCatalog(),
|
|
1299
|
+
".agents/errors.md": renderErrorCatalog(errors),
|
|
1300
|
+
".agents/generators.md": renderGeneratorCatalog(),
|
|
1301
|
+
".agents/assets.md": renderAssetCatalog(
|
|
1302
|
+
allImporters(),
|
|
1303
|
+
allDataTypes().filter((d) => d.name.startsWith(`${manifest.name}/`))
|
|
1304
|
+
),
|
|
1305
|
+
".shard/schemas/shard.schema.json": `${JSON.stringify(manifestJsonSchema(), null, 2)}
|
|
1306
|
+
`,
|
|
1307
|
+
".shard/schemas/scene.schema.json": `${JSON.stringify(sceneJsonSchema(), null, 2)}
|
|
1308
|
+
`,
|
|
1309
|
+
".shard/schemas/prefab.schema.json": `${JSON.stringify(prefabJsonSchema(), null, 2)}
|
|
1310
|
+
`,
|
|
1311
|
+
".shard/schemas/save.schema.json": `${JSON.stringify(saveJsonSchema(), null, 2)}
|
|
1312
|
+
`
|
|
1313
|
+
};
|
|
1314
|
+
for (const [name, content] of Object.entries(SKILLS))
|
|
1315
|
+
files[`.agents/skills/${name}`] = content.replaceAll("<project>", manifest.name);
|
|
1316
|
+
for (const [file, schema] of allAssetSchemas()) {
|
|
1317
|
+
files[`.shard/schemas/${file}`] = `${JSON.stringify(schema(), null, 2)}
|
|
1318
|
+
`;
|
|
1319
|
+
}
|
|
1320
|
+
for (const def of allComponents()) {
|
|
1321
|
+
if (def.name.startsWith("test/")) continue;
|
|
1322
|
+
files[`.shard/schemas/components/${def.name.replace("/", ".")}.json`] = `${JSON.stringify(def.jsonSchema(), null, 2)}
|
|
1323
|
+
`;
|
|
1324
|
+
}
|
|
1325
|
+
return files;
|
|
1326
|
+
}
|
|
1327
|
+
export {
|
|
1328
|
+
GENERATED_END,
|
|
1329
|
+
GENERATED_START,
|
|
1330
|
+
generateDocs,
|
|
1331
|
+
mergeAgentsMd,
|
|
1332
|
+
renderAssetCatalog,
|
|
1333
|
+
renderComponentCatalog,
|
|
1334
|
+
renderErrorCatalog,
|
|
1335
|
+
renderGeneratorCatalog
|
|
1336
|
+
};
|