@origingame/engine 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/dist/web/origin-engine.d.ts +44 -5
  2. package/dist/web/origin-engine.js +279 -19
  3. package/dist/web/origin-engine_bg.wasm +0 -0
  4. package/dist/web/origin-engine_bg.wasm.d.ts +18 -5
  5. package/dist/web-publish/origin-engine-lanes.js +289 -48
  6. package/dist/web-publish/origin-engine-lanes_bg.wasm +0 -0
  7. package/dist/web-publish/origin-engine.d.ts +44 -5
  8. package/dist/web-publish/origin-engine.js +279 -38
  9. package/dist/web-publish/origin-engine_bg.wasm +0 -0
  10. package/dist/web-publish/origin-engine_bg.wasm.d.ts +18 -5
  11. package/dist/web-publish-2d/origin-engine-lanes.js +289 -48
  12. package/dist/web-publish-2d/origin-engine-lanes_bg.wasm +0 -0
  13. package/dist/web-publish-2d/origin-engine.d.ts +44 -5
  14. package/dist/web-publish-2d/origin-engine.js +279 -38
  15. package/dist/web-publish-2d/origin-engine_bg.wasm +0 -0
  16. package/dist/web-publish-2d/origin-engine_bg.wasm.d.ts +18 -5
  17. package/knowledge/README.md +2 -1
  18. package/knowledge/guides/three-to-og.md +393 -0
  19. package/knowledge/knowledge.json +2 -1
  20. package/knowledge/looks/catalogue.json +123 -2
  21. package/knowledge/package.json +3 -2
  22. package/knowledge/scaffold/origin-engine.json +2 -2
  23. package/knowledge/scaffold/template/package.json +1 -1
  24. package/knowledge/scaffold/template/project.ogt +1 -1
  25. package/knowledge/scripts/validate.ts +209 -27
  26. package/knowledge/skills/og-audio/SKILL.md +33 -7
  27. package/knowledge/skills/og-build/SKILL.md +66 -7
  28. package/knowledge/skills/og-core/SKILL.md +24 -8
  29. package/knowledge/skills/og-feel/SKILL.md +11 -3
  30. package/knowledge/skills/og-game/SKILL.md +680 -51
  31. package/knowledge/skills/og-i18n/SKILL.md +7 -7
  32. package/knowledge/skills/og-model/SKILL.md +374 -0
  33. package/knowledge/skills/og-platform-sdk/SKILL.md +7 -1
  34. package/knowledge/skills/og-taste/SKILL.md +7 -0
  35. package/knowledge/skills/og-ui/SKILL.md +39 -8
  36. package/knowledge/wiki/ai.md +20 -20
  37. package/knowledge/wiki/anim.md +537 -63
  38. package/knowledge/wiki/atmosphere.md +13 -13
  39. package/knowledge/wiki/audio.md +160 -20
  40. package/knowledge/wiki/camera.md +13 -13
  41. package/knowledge/wiki/commands.md +11 -11
  42. package/knowledge/wiki/debug.md +13 -13
  43. package/knowledge/wiki/game.md +8 -8
  44. package/knowledge/wiki/i18n.md +7 -7
  45. package/knowledge/wiki/index.md +81 -3
  46. package/knowledge/wiki/input.md +47 -47
  47. package/knowledge/wiki/load.md +83 -57
  48. package/knowledge/wiki/math.md +102 -102
  49. package/knowledge/wiki/nav.md +34 -34
  50. package/knowledge/wiki/net.md +10 -10
  51. package/knowledge/wiki/path.md +14 -14
  52. package/knowledge/wiki/physics.md +111 -100
  53. package/knowledge/wiki/platform.md +44 -44
  54. package/knowledge/wiki/probe.md +86 -86
  55. package/knowledge/wiki/reflect.md +30 -18
  56. package/knowledge/wiki/render.md +1243 -176
  57. package/knowledge/wiki/rng.md +14 -14
  58. package/knowledge/wiki/shapes.md +727 -0
  59. package/knowledge/wiki/stream.md +15 -15
  60. package/knowledge/wiki/system.md +8 -8
  61. package/knowledge/wiki/systems.md +2 -2
  62. package/knowledge/wiki/terrain.md +24 -20
  63. package/knowledge/wiki/tilemap.md +13 -13
  64. package/knowledge/wiki/time.md +4 -4
  65. package/knowledge/wiki/timeline.md +14 -14
  66. package/knowledge/wiki/transform.md +8 -8
  67. package/knowledge/wiki/tween.md +17 -17
  68. package/knowledge/wiki/ui.md +245 -269
  69. package/knowledge/wiki/video.md +5 -5
  70. package/knowledge/wiki/world.md +36 -36
  71. package/package.json +4 -4
  72. package/schema/api.schema.json +2238 -81
  73. package/shell/page.ts +4 -0
  74. package/shell/platform.ts +5 -0
  75. package/shell/player.ts +8 -2
  76. package/shell/transport.ts +6 -0
  77. package/src/generated/abi.ts +40 -1
  78. package/src/geom.ts +260 -0
  79. package/src/index.ts +19 -1
  80. package/src/node/device.ts +5 -2
  81. package/src/node/playtest-run.ts +1 -0
  82. package/src/node/polish.ts +34 -15
  83. package/src/node/run.ts +23 -6
  84. package/src/platform-local.ts +2 -0
  85. package/src/playtest.ts +8 -0
  86. package/src/runtime.ts +88 -6
  87. package/src/ui-kit/settings.ts +9 -0
  88. package/src/web/gl-state.ts +907 -0
  89. package/src/web/shell.ts +1 -1
  90. package/src/web/worker.ts +8 -2
  91. package/types/bindings.json +1222 -453
  92. package/types/index.d.ts +1868 -315
@@ -44,7 +44,20 @@ export function readRules(pkg: string): Rules {
44
44
  }
45
45
 
46
46
  /** The skills this package ships. */
47
- export const SKILLS = ["og-ai", "og-audio", "og-build", "og-core", "og-feel", "og-game", "og-i18n", "og-platform-sdk", "og-taste", "og-ui"] as const;
47
+ export const SKILLS = ["og-ai", "og-audio", "og-build", "og-core", "og-feel", "og-game", "og-i18n", "og-model", "og-platform-sdk", "og-taste", "og-ui"] as const;
48
+
49
+ /**
50
+ * The guides this package ships: hand-written pages in guides/, mounted
51
+ * beside og-game's SKILL.md (knowledge.json), each named by og-game and
52
+ * held to the skills' rules (API names, section references, tools).
53
+ */
54
+ export const GUIDES = ["three-to-og"] as const;
55
+
56
+ /** Where knowledge.json mounts guides/ in the production tree. */
57
+ export const GUIDE_MOUNT = "game/skills/og-game/guides/";
58
+
59
+ /** The og-cli source that cites the guides by section, in the engine repository. */
60
+ export const GUIDE_CITERS = ["crates/og-cli/src/migrate.rs"] as const;
48
61
 
49
62
  /** What a scaffolded project must contain (scaffold/README.md describes each). */
50
63
  export const TEMPLATE_FILES = ["project.ogt", "src/main.ts", "package.json", "tsconfig.json", "tests/smoke.og.ts"] as const;
@@ -79,10 +92,13 @@ export function validate(roots: Roots = defaultRoots()): string[] {
79
92
  }
80
93
 
81
94
  skills(rules, files, problems);
95
+ guideFiles(roots, rules, files, problems);
82
96
  coherence(files, problems);
97
+ skinWord(roots, files, problems);
83
98
  apiNames(roots, files, problems);
84
99
  looks(roots, files, problems);
85
100
  grammar(roots, files, problems);
101
+ problems.push(...ogtShape(skillTexts(files)));
86
102
  scaffold(roots, rules, manifest.overlay, files, byPath, problems);
87
103
  return problems;
88
104
  }
@@ -91,7 +107,7 @@ function packageJson(roots: Roots, problems: string[]): void {
91
107
  const p = JSON.parse(readFileSync(join(roots.pkg, "package.json"), "utf8")) as { name?: string; version?: string; files?: string[]; scripts?: Record<string, string> };
92
108
  if (p.name !== "@origingame/engine-knowledge") problems.push(`package.json: name is ${p.name}; the package is @origingame/engine-knowledge (design §9)`);
93
109
  if (!/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(p.version ?? "")) problems.push(`package.json: version ${p.version} is not semver`);
94
- for (const f of ["wiki", "skills", "looks", "scaffold", "rules", "knowledge.json"]) {
110
+ for (const f of ["wiki", "skills", "guides", "looks", "scaffold", "rules", "knowledge.json"]) {
95
111
  if (!p.files?.some((x) => x === f || x === `${f}/`)) problems.push(`package.json: files does not publish ${f}`);
96
112
  }
97
113
  if (!p.scripts?.test) problems.push("package.json: no test script, so scripts/check.sh bun-test would skip the package");
@@ -165,6 +181,46 @@ function skills(rules: Rules, files: readonly PayloadFile[], problems: string[])
165
181
  for (const s of SKILLS) if (!found.has(s)) problems.push(`skills/${s}/SKILL.md: missing`);
166
182
  }
167
183
 
184
+ /**
185
+ * The guides as files: guides/ holds exactly GUIDES, each reaches the
186
+ * payload at GUIDE_MOUNT, names no tool a Session lacks and no wiki page
187
+ * the IDL does not generate, and every `three-to-og §N` the og-cli source
188
+ * cites (GUIDE_CITERS: `og migrate three` points to its sections) is a
189
+ * numbered section of the guide.
190
+ */
191
+ function guideFiles(roots: Roots, rules: Rules, files: readonly PayloadFile[], problems: string[]): void {
192
+ const dir = join(roots.pkg, "guides");
193
+ const present = existsSync(dir) ? readdirSync(dir).filter((n) => n.endsWith(".md")).map((n) => n.slice(0, -3)) : [];
194
+ for (const g of present) if (!(GUIDES as readonly string[]).includes(g)) problems.push(`guides/${g}.md: not in GUIDES (scripts/validate.ts); list it there and name it from og-game, or remove it`);
195
+ const pages = new Set(files.map((f) => /^game\/skills\/og-game\/reference\/([^/]+)$/.exec(f.path)?.[1]).filter((n): n is string => n !== undefined));
196
+ const repo = join(roots.pkg, "..", "..");
197
+ for (const g of GUIDES) {
198
+ const f = files.find((x) => x.path === `${GUIDE_MOUNT}${g}.md`);
199
+ if (!f) {
200
+ problems.push(`guides/${g}.md: missing, or not mounted at ${GUIDE_MOUNT} (knowledge.json mounts guides/ there)`);
201
+ continue;
202
+ }
203
+ if (!isText(f.bytes)) continue;
204
+ const text = decoder.decode(f.bytes);
205
+ for (const t of namedTools(text, rules)) problems.push(`guides/${g}.md: names \`${t}\`, which is no tool a Session can be offered; rename it or put it in a code block if it is not a tool`);
206
+ for (const page of new Set([...text.matchAll(/\breference\/([a-z0-9-]+\.md)\b/g)].map((m) => m[1]!))) {
207
+ if (!pages.has(page)) problems.push(`guides/${g}.md: names reference/${page}, which the generated wiki does not have (a module's page is reference/<module>.md)`);
208
+ }
209
+ const have = new Set([...text.matchAll(/^## (\d+)\. /gm)].map((m) => m[1]!));
210
+ for (const citer of GUIDE_CITERS) {
211
+ const path = join(repo, citer);
212
+ if (!existsSync(join(repo, "crates"))) break; // the published package: the sources are not beside it
213
+ if (!existsSync(path)) {
214
+ problems.push(`${citer}: missing; GUIDE_CITERS in scripts/validate.ts names it`);
215
+ continue;
216
+ }
217
+ for (const m of readFileSync(path, "utf8").matchAll(new RegExp(`${g} §(\\d+)`, "g"))) {
218
+ if (!have.has(m[1]!)) problems.push(`${citer}: cites ${g} §${m[1]}, which guides/${g}.md does not have (its sections: ${[...have].join(", ")})`);
219
+ }
220
+ }
221
+ }
222
+ }
223
+
168
224
  function scaffold(roots: Roots, rules: Rules, overlay: string, files: readonly PayloadFile[], byPath: Map<string, PayloadFile>, problems: string[]): void {
169
225
  const prefix = `game-scaffold/${overlay}/`;
170
226
  if (rules.scaffold.existingOverlays.includes(overlay)) {
@@ -255,7 +311,15 @@ function coherence(files: readonly PayloadFile[], problems: string[]): void {
255
311
  const t = textOf(`game/skills/${s}/SKILL.md`);
256
312
  if (t !== undefined) texts.set(s, t);
257
313
  }
258
- const sections = new Map([...texts].map(([s, t]) => [s, new Set([...t.matchAll(/^## (\d+)\. /gm)].map((m) => m[1]!))]));
314
+ // The guides (guides/<name>.md, mounted beside og-game's SKILL.md) are
315
+ // held to the same section references; their owner names each.
316
+ const guides = new Map<string, string>();
317
+ for (const g of GUIDES) {
318
+ const t = textOf(`${GUIDE_MOUNT}${g}.md`);
319
+ if (t !== undefined) guides.set(g, t);
320
+ }
321
+ const numbered = (t: string) => new Set([...t.matchAll(/^## (\d+)\. /gm)].map((m) => m[1]!));
322
+ const sections = new Map([...texts, ...guides].map(([s, t]) => [s, numbered(t)]));
259
323
  for (const [module, skill] of home) {
260
324
  const t = texts.get(skill);
261
325
  if (t !== undefined && !t.includes(`reference/${module}.md`)) problems.push(`skills/${skill}/SKILL.md: teaches the ${module} module (wiki/index.md) but never names reference/${module}.md`);
@@ -265,22 +329,76 @@ function coherence(files: readonly PayloadFile[], problems: string[]): void {
265
329
  const owner = home.get(m);
266
330
  if (owner && owner !== skill) problems.push(`skills/${skill}/SKILL.md: names reference/${m}.md, which ${owner} teaches; point to ${owner} instead, so each module is taught in one place`);
267
331
  }
268
- const prose = t.replace(/^```[\s\S]*?^```/gm, "");
269
- for (const m of prose.matchAll(/(og-[a-z]+(?:-[a-z]+)*) §([A-Za-z][\w ]*)/g)) problems.push(`skills/${skill}/SKILL.md: refers to ${m[1]} §${m[2]!.split(" ")[0]} by name; refer to a skill's sections by their numbers (## N.)`);
270
- for (const m of prose.matchAll(/§(\d+)/g)) {
271
- // The skill this § belongs to: walk back over a list of §s ("og-feel
272
- // §9–§11", "og-taste §4, §10") to the word before it.
273
- const before = prose.slice(0, m.index).split(/\s+/);
274
- let i = before.length - 1;
275
- while (i >= 0 && (before[i] === "" || /^\(?§\d+(\.\d+)?[–,;)]*$/.test(before[i]!) || before[i] === "and" || before[i] === "or")) i--;
276
- const word = (before[i] ?? "").replace(/^[(`*]+/, "");
277
- let target: string | undefined = skill;
278
- if (/^og-[a-z]+(?:-[a-z]+)*$/.test(word)) target = word;
279
- else if (/\.md\b|\//.test(word) || /^design/.test(word)) target = undefined;
280
- if (target === undefined) continue;
281
- const have = sections.get(target);
282
- if (!have) problems.push(`skills/${skill}/SKILL.md: refers to ${target} §${m[1]}, and there is no skill ${target}`);
283
- else if (!have.has(m[1]!)) problems.push(`skills/${skill}/SKILL.md: refers to ${target === skill ? "its own" : target} §${m[1]}, which ${target === skill ? "it does not have" : "does not exist there"} (its sections: ${[...have].join(", ") || "none numbered"})`);
332
+ }
333
+ for (const [name, t] of [...texts, ...guides]) {
334
+ const at = guides.has(name) ? `guides/${name}.md` : `skills/${name}/SKILL.md`;
335
+ problems.push(...sectionRefs(at, name, t, sections));
336
+ }
337
+ // What the guides say of a page, and who names them.
338
+ const names = (t: string) => new Set([...t.matchAll(/\bguides\/([a-z0-9-]+)\.md\b/g)].map((m) => m[1]!));
339
+ for (const [name, t] of [...texts, ...guides]) {
340
+ const at = guides.has(name) ? `guides/${name}.md` : `skills/${name}/SKILL.md`;
341
+ for (const g of names(t)) if (!(GUIDES as readonly string[]).includes(g)) problems.push(`${at}: names guides/${g}.md, which this package does not ship (its guides: ${GUIDES.join(", ")})`);
342
+ }
343
+ for (const g of guides.keys()) {
344
+ if (!texts.get("og-game")?.includes(`guides/${g}.md`)) problems.push(`skills/og-game/SKILL.md: never names guides/${g}.md, so no agent finds the guide; name it where its subject is taught`);
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Every section reference of one text (`og-feel §4`, or `§4` for its own,
350
+ * `three-to-og §2` for a guide's) names a numbered `## N.` section that
351
+ * exists: `at` names the text in the messages, `own` the text itself.
352
+ */
353
+ export function sectionRefs(at: string, own: string, text: string, sections: ReadonlyMap<string, ReadonlySet<string>>): string[] {
354
+ const problems: string[] = [];
355
+ const prose = text.replace(/^```[\s\S]*?^```/gm, "");
356
+ for (const m of prose.matchAll(/(og(?:-[a-z]+)+|three-to-og) §([A-Za-z][\w ]*)/g)) problems.push(`${at}: refers to ${m[1]} §${m[2]!.split(" ")[0]} by name; refer to a skill's sections by their numbers (## N.)`);
357
+ for (const m of prose.matchAll(/§(\d+)/g)) {
358
+ // The text this § belongs to: walk back over a list of §s ("og-feel
359
+ // §9–§11", "og-taste §4, §10") to the word before it.
360
+ const before = prose.slice(0, m.index).split(/\s+/);
361
+ let i = before.length - 1;
362
+ while (i >= 0 && (before[i] === "" || /^\(?§\d+(\.\d+)?[–,;)]*$/.test(before[i]!) || before[i] === "and" || before[i] === "or")) i--;
363
+ const word = (before[i] ?? "").replace(/^[(`*]+/, "");
364
+ let target: string | undefined = own;
365
+ if (/^og-[a-z]+(?:-[a-z]+)*$/.test(word) || (GUIDES as readonly string[]).includes(word)) target = word;
366
+ else if (/\.md\b|\//.test(word) || /^design/.test(word)) target = undefined;
367
+ if (target === undefined) continue;
368
+ const have = sections.get(target);
369
+ if (!have) problems.push(`${at}: refers to ${target} §${m[1]}, and there is no skill ${target}`);
370
+ else if (!have.has(m[1]!)) problems.push(`${at}: refers to ${target === own ? "its own" : target} §${m[1]}, which ${target === own ? "it does not have" : "does not exist there"} (its sections: ${[...have].join(", ") || "none numbered"})`);
371
+ }
372
+ return problems;
373
+ }
374
+
375
+ /**
376
+ * What a sentence that says "skin" must also name for the word to be one of
377
+ * its other meanings: a glTF rig's skin (joints, morphs, a model), the
378
+ * character controllers' collision gap (the `skin` field, off a surface),
379
+ * a 2D skeleton's skin (a rig2d `skin` block swapping a slot's attachment,
380
+ * `og.Skeleton2D.skin`, an armature's setup pose), or a surface (a skin
381
+ * tone, peach skin, a crowd's or a tile set's looks).
382
+ */
383
+ export const SKIN_ELSEWHERE = /gltf|joint|skelet|morph|\brig\b|bone|mesh|model|clips?\b|anim|`skins?`|slot|attach|armature|\bswap\b|setup pose|surface|\bgap\b|collid|\btones?\b|peach|crowd|tiles/i;
384
+
385
+ /**
386
+ * A skill or wiki page never calls a UI style a "skin": og-ui's word is
387
+ * style (`type style`, `og.load.style`, `UiDesc.style`, `styles/<name>/`).
388
+ * Each sentence holding `skin` or `skins` must name one of the word's
389
+ * other meanings (SKIN_ELSEWHERE).
390
+ */
391
+ function skinWord(roots: Roots, files: readonly PayloadFile[], problems: string[]): void {
392
+ for (const f of files) {
393
+ if (!f.path.startsWith("game/skills/") || !f.path.endsWith(".md") || !isText(f.bytes)) continue;
394
+ const name = relative(roots.pkg, f.source).split("\\").join("/");
395
+ for (const paragraph of decoder.decode(f.bytes).split(/\n\s*\n/)) {
396
+ // Sentences, and a table's cells, of the paragraph's joined lines.
397
+ for (const sentence of paragraph.replace(/\s*\n\s*/g, " ").split(/(?<=[.;!?])\s+|\s\|\s/)) {
398
+ if (/\bskins?\b/i.test(sentence) && !SKIN_ELSEWHERE.test(sentence)) {
399
+ problems.push(`${name}: calls a UI style a skin ("${sentence.trim().slice(0, 120)}"); og-ui's word is style (type style, og.load.style, styles/<name>/)`);
400
+ }
401
+ }
284
402
  }
285
403
  }
286
404
  }
@@ -312,6 +430,7 @@ export const NOT_FIELDS: Record<string, string> = {
312
430
  runs: "PlaytestDesc.runs (packages/engine/src/playtest.ts)",
313
431
  then: "PlaytestDesc.then (packages/engine/src/playtest.ts)",
314
432
  store: "PlaytestRun.store (packages/engine/src/playtest.ts)",
433
+ setup: "PlaytestDesc.setup (packages/engine/src/playtest.ts)",
315
434
  };
316
435
 
317
436
  export function readApiNames(engine: string): ApiNames | undefined {
@@ -353,8 +472,9 @@ function apiNames(roots: Roots, files: readonly PayloadFile[], problems: string[
353
472
  if (!api) return;
354
473
  for (const f of files) {
355
474
  const m = /^game\/skills\/([^/]+)\/SKILL\.md$/.exec(f.path);
356
- if (!m || !isText(f.bytes)) continue;
357
- const at = `skills/${m[1]}/SKILL.md`;
475
+ const guide = f.path.startsWith(GUIDE_MOUNT) && f.path.endsWith(".md") ? f.path.slice(GUIDE_MOUNT.length) : undefined;
476
+ if ((!m && !guide) || !isText(f.bytes)) continue;
477
+ const at = guide ? `guides/${guide}` : `skills/${m![1]}/SKILL.md`;
358
478
  const text = decoder.decode(f.bytes);
359
479
  const bad = new Set<string>();
360
480
  for (const x of text.matchAll(/(?<![\w./@-])og\.([A-Za-z_]\w*)(?:\.([A-Za-z_]\w*))?/g)) {
@@ -398,7 +518,7 @@ interface Style {
398
518
  descriptor: string;
399
519
  checks: string[];
400
520
  premise: Record<string, string>;
401
- look: { preset: string; presetReady: boolean; support: string; primitives: string[]; missing: string[] };
521
+ look: { preset: string; presetReady: boolean; support: string; profiles: string[]; primitives: string[]; missing: string[] };
402
522
  ui: string;
403
523
  palette: { size: string; rules: string };
404
524
  assets: Record<string, string>;
@@ -407,6 +527,8 @@ interface Style {
407
527
 
408
528
  /** What `look.support` may say: full (every primitive draws), partial or planned (`missing` names what is still to land), content (the look is authored art). */
409
529
  export const SUPPORT = ["full", "partial", "planned", "content"] as const;
530
+ /** What `look.profiles` may name: the project profiles a style draws for (create's premise match never offers a style outside the game's). */
531
+ export const PROFILES = ["2d", "3d"] as const;
410
532
  /** The placeholders the art-bible template may use. */
411
533
  export const BIBLE_FIELDS = ["title", "premise", "camera", "platform", "name", "descriptor", "direction", "palette", "assets", "ui", "avoid", "game", "slug", "checks"] as const;
412
534
  const PREMISE = ["genre", "mood", "era", "audience"];
@@ -418,7 +540,7 @@ const ASSET_SPECS = ["logicalResolution", "spriteScale", "faceBudget", "outlineP
418
540
  * bible direction), its preset path `templates/looks/<slug>.ogt` (a
419
541
  * `type look` file when presetReady), its support honest (`missing` names
420
542
  * the primitives a style that is not `full` lacks, and is empty for one
421
- * that is), every `Resource.field` it names a member of the generated API,
543
+ * that is), the profiles it draws for (`look.profiles`, 2d and/or 3d), every `Resource.field` it names a member of the generated API,
422
544
  * og-taste naming every slug, and no text phrased as an imitation.
423
545
  */
424
546
  function looks(roots: Roots, files: readonly PayloadFile[], problems: string[]): void {
@@ -472,6 +594,10 @@ function looks(roots: Roots, files: readonly PayloadFile[], problems: string[]):
472
594
  }
473
595
  if (look.preset !== `templates/looks/${s.slug}.ogt`) problems.push(`${id}: look.preset is ${look.preset}, expected templates/looks/${s.slug}.ogt`);
474
596
  if (!(SUPPORT as readonly string[]).includes(look.support)) problems.push(`${id}: look.support ${look.support} is none of ${SUPPORT.join(", ")}`);
597
+ const profiles = Array.isArray(look.profiles) ? look.profiles : [];
598
+ if (profiles.length === 0 || new Set(profiles).size !== profiles.length || !profiles.every((p) => (PROFILES as readonly string[]).includes(p))) {
599
+ problems.push(`${id}: look.profiles must name the profiles the style draws for, one or both of ${PROFILES.join(", ")} (create matches a premise only among the game's)`);
600
+ }
475
601
  const missing = look.missing ?? [];
476
602
  if (look.support === "full" && missing.length > 0) problems.push(`${id}: support full, yet it names missing primitives`);
477
603
  if (look.support !== "full" && !missing.some(text)) problems.push(`${id}: support ${look.support} must name what is missing (the primitive or the authored art)`);
@@ -571,7 +697,7 @@ export const GRAMMARS: readonly Grammar[] = [
571
697
  source: "crates/og-anim/src/graph.rs",
572
698
  region: [/pub fn from_document/, /\/\/\/ The slot of a parameter/],
573
699
  reads: readKeys,
574
- names: { param: 1, clip: 1, blend1d: 2, blend2d: 3, child: 1, state: 2, entry: 1, transition: 2, when: 1, weight: 1 },
700
+ names: { param: 1, clip: 1, blend1d: 2, blend2d: 3, child: 1, state: 2, entry: 1, transition: 2, when: 1, weight: 1, sync: 1 },
575
701
  },
576
702
  { type: "navmesh", source: "crates/og-cook/src/cook/e6.rs", region: [/pub\(super\) fn cook_navmesh/, /og_nav::bake::bake\(/], reads: readKeys, home: "og-ai" },
577
703
  {
@@ -723,14 +849,70 @@ export function grammarDrift(skills: ReadonlyMap<string, string>, read: (path: s
723
849
  return problems;
724
850
  }
725
851
 
726
- function grammar(roots: Roots, files: readonly PayloadFile[], problems: string[]): void {
727
- const repo = join(roots.pkg, "..", "..");
728
- if (!existsSync(join(repo, "crates"))) return; // the published package: the parsers are not beside it
852
+ /** Each skill's SKILL.md text, by skill name. */
853
+ function skillTexts(files: readonly PayloadFile[]): Map<string, string> {
729
854
  const skills = new Map<string, string>();
730
855
  for (const f of files) {
731
856
  const m = /^game\/skills\/([^/]+)\/SKILL\.md$/.exec(f.path);
732
857
  if (m && isText(f.bytes)) skills.set(m[1]!, decoder.decode(f.bytes));
733
858
  }
859
+ return skills;
860
+ }
861
+
862
+ /**
863
+ * Every `.ogt` example a skill writes in a code block (```text or a bare
864
+ * fence, as a whole file or a fragment) holds to og-core's parser
865
+ * (crates/og-core/src/ogt.rs `parse`) line by line, so an author who copies
866
+ * it cooks: a `{` ends its key's line, a `}` stands alone on its own, the
867
+ * blocks close, and a file that opens with `type` has `version` and `---`
868
+ * next. A block with no brace and no `type` line is not checked (shell,
869
+ * prose). Strings and `#` comments are read as the parser reads them.
870
+ */
871
+ export function ogtShape(skills: ReadonlyMap<string, string>): string[] {
872
+ const problems: string[] = [];
873
+ for (const [skill, md] of skills) {
874
+ for (const m of md.matchAll(/^( *)```([\w-]*)\n([\s\S]*?)^ *```/gm)) {
875
+ if (m[2] !== "text" && m[2] !== "") continue;
876
+ const indent = m[1]!.length;
877
+ const lines = m[3]!.split("\n").map((l) => l.slice(Math.min(indent, l.length - l.trimStart().length)));
878
+ const words = lines.map((l) => {
879
+ const out: string[] = [];
880
+ for (const t of l.matchAll(/"(?:[^"\\]|\\.)*"|\S+/g)) {
881
+ if (t[0].startsWith("#")) break;
882
+ out.push(t[0]);
883
+ }
884
+ return out;
885
+ });
886
+ const typed = words.find((w) => w.length > 0)?.[0] === "type";
887
+ if (!typed && !words.some((w) => w.includes("{") || w.includes("}"))) continue;
888
+ const first = md.slice(0, m.index).split("\n").length + 1;
889
+ const at = (i: number) => `skills/${skill}/SKILL.md:${first + i}`;
890
+ if (typed) {
891
+ const head = words.map((w, i) => [w, i] as const).filter(([w]) => w.length > 0).slice(0, 3);
892
+ if (head[1]?.[0][0] !== "version" || head[2]?.[0].join(" ") !== "---") problems.push(`${at(head[0]![1])}: an .ogt example opens with \`type\` but not \`version <n>\` and \`---\` after it; the parser refuses a file without its header`);
893
+ }
894
+ let depth = 0;
895
+ words.forEach((w, i) => {
896
+ const open = w.indexOf("{");
897
+ if (open >= 0 && open !== w.length - 1) problems.push(`${at(i)}: \`${lines[i]!.trim()}\` opens a block mid-line; the .ogt parser refuses it ('{' must end its line): put each line of the block on its own`);
898
+ if (w.includes("}") && w.length !== 1) problems.push(`${at(i)}: \`${lines[i]!.trim()}\` closes a block beside other words; the .ogt parser refuses it ('}' must be alone on its line)`);
899
+ if (open >= 0) depth++;
900
+ if (w.includes("}")) depth--;
901
+ if (depth < 0) {
902
+ problems.push(`${at(i)}: an unmatched '}'`);
903
+ depth = 0;
904
+ }
905
+ });
906
+ if (depth > 0) problems.push(`${at(lines.length - 1)}: an .ogt example leaves ${depth} block(s) open`);
907
+ }
908
+ }
909
+ return problems;
910
+ }
911
+
912
+ function grammar(roots: Roots, files: readonly PayloadFile[], problems: string[]): void {
913
+ const repo = join(roots.pkg, "..", "..");
914
+ if (!existsSync(join(repo, "crates"))) return; // the published package: the parsers are not beside it
915
+ const skills = skillTexts(files);
734
916
  problems.push(...grammarDrift(skills, (p) => (existsSync(join(repo, p)) ? readFileSync(join(repo, p), "utf8") : undefined)));
735
917
  }
736
918
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: og-audio
3
- description: Sound on Origin Engine — one-shot voices for game events, the procedural sound presets og cook synthesises (coin, jump, hit, click and more), UI sounds, the four buses and their effects (filters, reverb send, compressor or limiter, ducking), music with stems, bar-aligned starts, crossfades and stingers on og.Music's clock, spatial sources with occlusion and distance dulling, voice priority and the cap, and proving it all headless with listen and og run --audio. Use when a game is silent, when adding any sound or music, or when the mix is muddy, clipping or wrong.
3
+ description: Sound on Origin Engine — one-shot voices for game events, the procedural sound presets og cook synthesises (coin, jump, hit, click and more), UI sounds, the four buses and their effects (filters, reverb send, compressor or limiter, ducking), music with stems, bar-aligned starts, crossfades and stingers on og.Music's clock, spatial sources with occlusion, distance dulling, Doppler, cones and HRTF for headphones, voice priority and the cap, and proving it all headless with listen and og run --audio. Use when a game is silent, when adding any sound or music, or when the mix is muddy, clipping or wrong.
4
4
  ---
5
5
 
6
6
  Audio on Origin Engine is world state: an entity with `og.AudioSource`
@@ -34,9 +34,11 @@ given and taken, pickups, a door, a death, a win and a loss, every UI press.
34
34
 
35
35
  - One voice entity per sound, spawned in `start` with `playing: false`, and
36
36
  a `Voices` system that plays each requested one: systems set bits of a
37
- game resource (`Sounds.sfx |= Sound.Jump`), Voices sets `playing` on the
38
- matching voice (a voice already playing is stopped this tick and
39
- restarted the next). Every archetype in `templates/archetypes` does
37
+ game resource (`Sounds.sfx |= Sound.Jump`), Voices sets `retrigger: true`
38
+ on the matching voice (it starts again from the top in that tick,
39
+ playing or not, and the engine clears it; archetypes written before
40
+ `retrigger` set `playing`, stopping a playing voice one tick and
41
+ starting it the next). Every archetype in `templates/archetypes` does
40
42
  this; copy a `Voices`.
41
43
  - Vary repeated sounds: `pitch` 0.92–1.08 from the world's `og.Rng` for
42
44
  footsteps, hits and coins, so a stream of them does not machine-gun.
@@ -58,14 +60,14 @@ game's style needs it; presets are a floor, not a style.
58
60
  ## 3. UI sounds
59
61
 
60
62
  `og.ui.configure(world, { sounds: { hover, press, open, close, success, error } })`
61
- (or the skin's `sound` lines) plays them on the `ui` bus: `click` for
63
+ (or the style's `sound` lines) plays them on the `ui` bus: `click` for
62
64
  press, `select` for success, `back` for close, `error` for a refused press
63
65
  (and for `og.ui.shake({ sound: true })`, og-ui §6). Each starts in the
64
66
  tick of its input. `hover` also answers keyboard and gamepad focus moving
65
67
  to a control, and `press` a press by Enter, Space or the pad's A, so a
66
68
  menu walked with a pad is heard step by step; keep `hover` short and quiet
67
69
  (volume ≤ 0.3). `configure` changes only the fields it is given: setting
68
- the sounds after a skin (or a look that brings one) keeps the skin and its
70
+ the sounds after a style (or a look that brings one) keeps the style and its
69
71
  fonts, and `sounds: null` silences them again.
70
72
 
71
73
  ## 4. Buses and the mix
@@ -173,10 +175,34 @@ at 1.0 is a mix bug, not loudness.
173
175
  the listener is. It follows the listener's world transform, so put the
174
176
  og.AudioListener where the ears are (the camera, or the player); a zone
175
177
  on a child of a moving room moves with it.
178
+ - Motion is heard: a spatial source or listener that moves shifts the
179
+ pitch (Doppler, from tick velocities down `og.Parent`, so a car on a
180
+ rig or a listener on a moving camera just works). `dopplerFactor` 1 is
181
+ physical; 0.3–0.6 on a racing game's engines keeps a fly-by readable;
182
+ 0 on a source that must keep its pitch (a voice line, a musical
183
+ instrument in the world) or on the `og.AudioListener` to turn it off
184
+ everywhere. A warp or respawn (a jump faster than sound in one tick)
185
+ shifts nothing. `og.Audio.speedOfSound` (343.3) is the scale.
186
+ - Directional sources: `coneInnerAngle`, `coneOuterAngle` (full angles,
187
+ degrees, about the entity's +Z, as three.js's `setDirectionalCone`)
188
+ and `coneOuterGain`: a loudspeaker or an NPC talking forward `90`,
189
+ `240`, `0.3`; a siren on a vehicle `120`, `300`, `0.5`. Rotate the
190
+ entity to aim it; the default 360/360 is omnidirectional.
191
+ - Headphones: `og.AudioListener.panningModel: "hrtf"` places spatial
192
+ sources by interaural delay and head shadow (a spherical-head model),
193
+ clearer left and right than the stereo panner, without front/back or
194
+ height cues; offer it as a setting ("Headphones"), keep `equalpower`
195
+ for speakers (the default). Non-spatial sources keep their `pan`.
176
196
  - `og.Audio.maxVoices` (32) caps voices; past it the lowest `priority`,
177
197
  then the quietest, then the oldest is culled (`SOUND_CULLED`). Give the
178
198
  player's own sounds and the music a high priority (200+), ambient props a
179
- low one.
199
+ low one. Ambient loops and music stems that should keep their place
200
+ when culled get `virtualize: true`: past the cap they go silent with
201
+ their playhead running and come back where they would be, not from the
202
+ top (`og.AudioPlayback.virtual` says which).
203
+ - `og.Audio.resampler` is `sinc` (clean highs at any pitch); `linear` is
204
+ the older, cheaper mix, kept for a game that compares its exact output
205
+ (a WAV hash in a test). Unpitched 48 kHz sounds play the same either way.
180
206
 
181
207
  ## 7. Prove it
182
208
 
@@ -14,7 +14,7 @@ needs a browser or a Rust toolchain. The command channel is
14
14
  The loop held for two whole games written from this package alone
15
15
  (`examples/lantern-vale`, `examples/hollowbell`): `og cook` took 47 and 69
16
16
  assets cold in 8.2 s and 3.6 s and under 100 ms after a change, without a
17
- warning, and every refusal named the line and the fix (a copied UI skin, as
17
+ warning, and every refusal named the line and the fix (a copied UI style, as
18
18
  styles were then called, without its CJK `fallback` named the line and og-taste §1;
19
19
  `PostEffects.vignette is true or false` named the line). Scripted runs gave
20
20
  the same frames every time, `store: "keep"` relaunches and two-process
@@ -53,8 +53,9 @@ a number and a sound (taste 98, feel 79).
53
53
  `get:<Resource>[.<field>]` or `get:<entity>[.<Component>[.<field>]]`
54
54
  (logs the value as game code sees it, og.Time whole and a light og.DayNight
55
55
  drives as it is drawn, and writes `get-<tick>.json`, then
56
- `get-<tick>-2.json`, ... for more on one tick; a world dump lists
57
- resources by name only, so `get:` is how to read one),
56
+ `get-<tick>-2.json`, ... for more on one tick; `get:Game:game.json`
57
+ names the file; a world dump lists resources by name only, so `get:`
58
+ is how to read one),
58
59
  `set:<entity>.<Component>.<field>=<value>` or
59
60
  `set:<Resource>.<field>=<value>` (`set:og.Camera.trauma=0.5`,
60
61
  `set:Game.coins=10`, `set:og.Stylise.ink=false`: a number, `true` or
@@ -98,7 +99,46 @@ each its argument form and one sentence in `GameDesc.commandHelp`
98
99
  (`{ spawn_wave: { usage: "<count>", doc: "Spawns a wave now." } }`)
99
100
  so `help` explains it (it lists `spawn_wave:<count>`: the name, then the
100
101
  usage). An entry naming no action is logged when the game
101
- starts.
102
+ starts. A step's arguments are its colon-delimited fields after the
103
+ action, never shell words: `0.5:records:12:34.5` hands the handler
104
+ `args[0]` `"12"` and `args[1]` `"34.5"`, and its usage is written the same
105
+ way, `usage: "<berries>:<seconds>"` (a space stays inside its field:
106
+ `records:12 34.5` is one argument). A game may not take a core action's
107
+ name; og-game §3 lists them, with `open_pause` beside the engine's `pause`.
108
+
109
+ **File names.** Every output of one script has its own file. An unnamed
110
+ output takes the action and the tick (`get-000030.json`), and a second on
111
+ the same tick is suffixed quietly (`get-000030-2.json`). A named one that
112
+ an earlier step of the script already wrote, at any time (a copy-pasted
113
+ `0.5:get:Game:met.json`), is written as `met-2.json`, then `met-3.json`,
114
+ never over the first, and logs a WARN naming both steps ("'met.json' was
115
+ already written by the 0.5s get step, so this one is 'met-2.json'"). The
116
+ WARN is in `run.warnings`, so a playtest that asserts a clean run
117
+ (`t.equal(run.warnings.length, 0, ...)`) fails on the clash, and
118
+ `run.json("met.json")` reads the first step's: give each named step its
119
+ own file.
120
+
121
+ **Values in the JSON.** A dump and a `get` answer write a vector as an
122
+ array in lane order (`Vector3` `[x, y, z]`, `Quaternion` `[x, y, z, w]`,
123
+ `Color` `[r, g, b]`), where the API's values are objects
124
+ (`{ x, y, z }`); `set:` takes the lanes comma-separated, without spaces
125
+ (`set:box.og.Transform.position=4,5,6`). Read a dump by index:
126
+
127
+ ```ts
128
+ // 0.3:get:box.og.Transform:t.json writes {"kind":"get","target":"box.og.Transform","tick":18,"value":{"position":[1,2.5,-3],...}}
129
+ const [x, y, z] = run.json<{ value: { position: [number, number, number] } }>("t.json").value.position;
130
+ t.near(y, 2.5, 1e-4, "the box stands on the ledge");
131
+ ```
132
+
133
+ An `f32` field is IEEE-754 single precision. Game code reads it as the
134
+ f32's exact value promoted to a JavaScript number, so `2.7` written to an
135
+ f32 reads back as `2.700000047683716`, and what game code sends carries
136
+ that number as JSON (the facts, an og.platform payload, so
137
+ `dump_platform`); `get` and the dumps print the f32's shortest form,
138
+ `2.7`. Compare numbers with a tolerance in a test (`t.near(a, b, 1e-4,
139
+ ...)`), round only to display one (`toFixed(1)` on the label), and keep a
140
+ value that must round-trip exactly (a payload a test compares with `===`)
141
+ in an `f64` field.
102
142
 
103
143
  ## 2. Live sessions: the probe and hot reload
104
144
 
@@ -169,6 +209,15 @@ same store, for a relaunch (og-platform-sdk §4).
169
209
  - Determinism is checkable: two runs with the same seed and script give the
170
210
  same world hash in `dump_world`. When they do not, find the wall-clock
171
211
  read or the unseeded randomness before anything else.
212
+ - A model built as code (og-model) is judged on numbers and one sheet,
213
+ not on a frame read by eye: `view:<entity>:front|back|side|top`,
214
+ `view:<entity>:orbit:<degrees>` and `turntable:<entity>:<n>` frame it
215
+ from fixed cameras, and `og compare <reference.png> <render.png>`
216
+ prints the silhouette IoU and the ΔE00 per mask region and writes the
217
+ reference beside the render as one sheet PNG (`--mask`, `--align`,
218
+ `--json`). Two runs give the same numbers and the same sheet bytes, so
219
+ quote them with the command. og-model §3 has the commands and how to
220
+ read them, og-model §4 the gate each pass must meet.
172
221
  - Report what ran and what did not. A build that compiled is not a game
173
222
  that plays.
174
223
 
@@ -196,7 +245,7 @@ every screen that matters.
196
245
  | taste | UI sounds | a press that changed the UI and started a voice on the ui bus within 8 ticks | every press | og-taste §6, og-audio §3 |
197
246
  | taste | Locale keys | `og cook`'s warnings | none | og-i18n §1 |
198
247
  | feel | Input latency | ticks from the tick an input lands to the first thing seen or heard to change (a named entity accelerating or changing clip, the UI, a sound, a tween, a floating text, shake, hit stop, a particle burst); a press answered on its release counts from the release | 0–1 ticks | og-feel §1 |
199
- | feel | Hit feedback | each damage moment (a fact named `hp`, `health`, `lives`… falling, or `hits`, `damage`, `kills`… rising): hit stop, shake, floating text and a sound within 8 ticks | three of the four | og-feel §3, §4, §7, og-audio §1 |
248
+ | feel | Hit feedback | each damage moment (a fact named `hp`, `health`, `lives`… falling, or `hits`, `damage`, `kills`… rising): hit stop, shake, floating text and a sound within 8 ticks | three of the four, per moment (a moment with three owes no fourth: a bump that holds, shakes and sounds is not asked for a number) | og-feel §3, §4, §7, og-audio §1 |
200
249
  | feel | Idle frames | ticks after the first ten where nothing moves or animates, no UI moves, no sound starts or plays, no tween, text or particle runs | at most 10 % | og-feel §9, og-taste §5 |
201
250
  | feel | Sound on input | inputs answered by a sound within 6 ticks | every action | og-audio §1 |
202
251
  | feel | Frame time | step + render, p95, on the tier it ran on (`metal` on a Mac, `vulkan` on a GPU); the probe's own screenshots (render, read back, PNG) are not the game's step and are left out. Without a renderer the row says `n/a (no renderer)`, as do contrast, text size and HUD occupancy, which read frames | 8.3 ms (half the frame) | og-game §14 |
@@ -237,14 +286,24 @@ run it again after the fix: the report is the evidence.
237
286
  `og build .` bundles `src/` to `dist/game.js`, cooks, and copies the engine
238
287
  runtime and wasm next to it. `og build . --publish` produces the published
239
288
  form: the probe keeps only its player-facing members (hello, tick, state,
240
- pick, entity, input, capture) and the command channel is gone.
289
+ pick, entity, input, capture) and the command channel is gone. A `2d`
290
+ project publishes the 2D module, which has no 3D world
291
+ (`RenderCapabilities.world3d` false): the build refuses scenes holding
292
+ `Terrain`, `TerrainScatter`, `Water`, `WaterSurface` or `Fluid3D`, naming
293
+ each by scene file and line (og-game §2).
241
294
  What ships is still checked by script, through what it keeps: real input
242
295
  on the published page and the probe's `tick`, `state` and `capture`. A
243
296
  playtest written in input steps (`key_down`, `type`, `touch_move_to` an
244
297
  id, `move_to_text`, `touch_start`) and `screenshot` steps runs there as it
245
298
  runs under `og test`, each frame compared with the same references; a
246
299
  game command (warp, give) has no channel there and is refused, so keep
247
- the steps a published check needs free of them. The engine runs this on
300
+ the steps a published check needs free of them. The page shows the
301
+ Player's Back over the canvas's top-left corner (12, 12, 40 by 40 CSS px
302
+ on a desktop page; og-ui §5), which anchored HUD groups keep out of, so
303
+ a playtest the page is held to gives its native run the same cover with
304
+ `setup: "cover:12,12,40,40"` (`PlaytestDesc.setup`: command steps run
305
+ once after `start`, before the first tick, never part of the page's
306
+ script) and its frames lay out as the page's. The engine runs this on
248
307
  the published build of its own games (its `tests/web/published.ts`: the
249
308
  boot, the title, a press, a typed name and a frame against the metal
250
309
  reference, with a clean console).
@@ -218,16 +218,32 @@ export const Move = defineSystem("Move", { reads: [Velocity], writes: [og.Transf
218
218
  module variable or as numbers of code points.
219
219
  A `str` holds 1 to 4,096 bytes: a larger capacity typechecks and is
220
220
  refused when the game starts (`a string field holds 1 to 4096 bytes`).
221
- - A field holds one value: a component has no array or list field. A few
222
- values are numbered fields (`p0`..`p7`, as og.CameraRail's rail points);
223
- a list is an entity per element with its own component, looped over by
224
- a query (a ring of skid marks, the laps of a race); a long record (a
225
- lap's taped inputs) is text in `str(4096)` chunks, an entity each.
221
+ - A field holds one value, or a fixed-capacity list of one number or
222
+ handle kind: `list(u32(), 8)`, `list(Asset(), 4)` (1 to 1,024 elements,
223
+ starting empty, never tunable). Reading it answers a frozen array, a
224
+ copy: `push`, `splice` and index writes throw, so write it whole
225
+ (`v.passes = [...v.passes, h]`, `v.passes = []`, `world.set(e, Chain,
226
+ { passes: [3, 5] })`). One over the capacity is refused with the fix
227
+ named (`declare the field list(u32(), 9) or larger`), never truncated.
228
+ Its column is the element's typed array with (capacity + 1) lanes per
229
+ entity, the count first, zeros past it, so `[]` and `[0]` are different
230
+ worlds. The probe reads it as a JSON array (`get:`, `dump_entity`),
231
+ `set:Chain.passes=[3,5]` (or `3,5`, nothing for `[]`) writes it whole, a
232
+ scene or a look gives it on one line (`passes 3 5`, the key alone for
233
+ `[]`), and snapshots and the hash carry it; a migrating restore keeps a
234
+ list that fits a new capacity and empties one that does not.
235
+ A list holds numbers only: a few vectors are numbered fields
236
+ (`p0`..`p7`, as og.CameraRail's rail points); a list of things with
237
+ fields of their own is an entity per element with its own component,
238
+ looped over by a query (a ring of skid marks, the laps of a race); a
239
+ long record (a lap's taped inputs) is text in `str(4096)` chunks, an
240
+ entity each.
226
241
  - A handle is a 64-bit number (an `og.load` answer, a texture, a sound,
227
242
  a geometry, a material, an entity): keep it in a field of its own kind,
228
- `Asset()`, `Texture()`, `Sound()`, `Geometry()`, `Material()` or
229
- `Entity()` (each handle type of the API has its factory), in a
230
- component or a resource. A `u32` field refuses one (`4294967298 is not
243
+ `Asset()`, `Texture()`, `Sound()`, `Font()`, `Style()`, `Look()`,
244
+ `Geometry()`, `Material()` or `Entity()` (each handle type of the API
245
+ has its factory, named as the type: a font is a `Font()`, never an
246
+ `Asset()` cast), in a component or a resource. A `u32` field refuses one (`4294967298 is not
231
247
  a u32`).
232
248
  - Per-entity calls (`world.get`, `world.set`) are for commands, setup and
233
249
  the few named entities a system touches (a camera, a listener, the
@@ -135,7 +135,7 @@ is what a sprite wants.
135
135
  | Take-off | (0.85, 1.15) | 0.1 s |
136
136
  | Hit taken | (1.2, 0.8) | 0.15 s, with a flash |
137
137
  | Pickup collected (on the pickup) | (1.4, 1.4) then shrink to 0 | 0.15 s |
138
- | UI button press (the skin's `press` does it for og.ui) | (0.92, 0.92) | 0.08 s, unscaled |
138
+ | UI button press (the style's `press` does it for og.ui) | (0.92, 0.92) | 0.08 s, unscaled |
139
139
 
140
140
  Do not squash rigid things (crates, walls, the camera's target in a
141
141
  cutscene) or anything more than once per 6 ticks.
@@ -161,7 +161,7 @@ motion and hit stop (UI motion). `og.tween.squash` (§5) and
161
161
  | Constant motion, timers, scrolling, colour cycles | `linear` |
162
162
  | Most small motion when unsure | `quadOut` (the default) |
163
163
 
164
- Durations: 0.1–0.2 s for feedback, 0.12–0.3 s for UI (the skin's
164
+ Durations: 0.1–0.2 s for feedback, 0.12–0.3 s for UI (the style's
165
165
  durations, og-taste §5), 0.2–0.35 s for swaps, 0.4–0.8 s for camera moves,
166
166
  reveals and screen transitions (up to 0.7 s). Longer feels sluggish unless
167
167
  it is a cutscene or a reward.
@@ -169,7 +169,10 @@ it is a cutscene or a reward.
169
169
  ## 7. Floating text
170
170
 
171
171
  `og.render.floatingText(world, { text, position, ... })` pops a text that
172
- rises, drifts and fades by itself (world state, deterministic).
172
+ rises, drifts and fades by itself (world state, deterministic). A label
173
+ that stays with a character (a name tag, a player's number) is not
174
+ floating text: it is screen text where the og.Camera resource's
175
+ `worldToScreen` puts the point, og-ui §3 (Text over a world point).
173
176
 
174
177
  - Damage and score numbers above the hit point, not on the HUD: the eye is
175
178
  already there. Pickups: what was gained (`+1`, `key`), short.
@@ -180,6 +183,11 @@ rises, drifts and fades by itself (world state, deterministic).
180
183
  biggest number on screen should be the most important.
181
184
  - Lifetime 0.6–1.0 s, rise 0.8–1.5 units, spread 0.2–0.4 so stacked hits do
182
185
  not overlap. At most 6–8 on screen: sum rapid hits into one number.
186
+ - Not every impact wants a number. An impact is held by three of four
187
+ signals (hit stop §3, shake §4, floating text, a sound): `og polish`
188
+ counts them per damage moment and asks for the missing ones only of a
189
+ moment with fewer than three, so a party game's bump that holds, shakes
190
+ and sounds owes no floating text.
183
191
 
184
192
  ## 8. Camera: look-ahead, dead zones, zones, rails
185
193