@origingame/engine 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/og.mjs +1 -1
- package/dist/web/origin-engine.d.ts +219 -5
- package/dist/web/origin-engine.js +1344 -125
- package/dist/web/origin-engine_bg.wasm +0 -0
- package/dist/web/origin-engine_bg.wasm.d.ts +68 -3
- package/dist/web-publish/origin-engine-lanes.js +1007 -22
- package/dist/web-publish/origin-engine-lanes_bg.wasm +0 -0
- package/dist/web-publish/origin-engine.d.ts +162 -5
- package/dist/web-publish/origin-engine.js +1004 -19
- package/dist/web-publish/origin-engine_bg.wasm +0 -0
- package/dist/web-publish/origin-engine_bg.wasm.d.ts +52 -3
- package/dist/web-publish-2d/origin-engine-lanes.js +1007 -22
- package/dist/web-publish-2d/origin-engine-lanes_bg.wasm +0 -0
- package/dist/web-publish-2d/origin-engine.d.ts +162 -5
- package/dist/web-publish-2d/origin-engine.js +1004 -19
- package/dist/web-publish-2d/origin-engine_bg.wasm +0 -0
- package/dist/web-publish-2d/origin-engine_bg.wasm.d.ts +52 -3
- package/knowledge/looks/catalogue.json +40 -40
- package/knowledge/package.json +1 -1
- package/knowledge/scaffold/distribution.json +14 -0
- package/knowledge/scaffold/origin-engine.json +2 -2
- package/knowledge/scaffold/template/README.md +2 -1
- package/knowledge/scaffold/template/assets/audio/sting.ogt +10 -0
- package/knowledge/scaffold/template/assets/timelines/intro.ogt +101 -0
- package/knowledge/scaffold/template/assets/timelines/narration.srt +11 -0
- package/knowledge/scaffold/template/host.d.ts +46 -0
- package/knowledge/scaffold/template/package.json +1 -1
- package/knowledge/scaffold/template/project.ogt +1 -1
- package/knowledge/scaffold/template/src/main.ts +1 -1
- package/knowledge/scaffold/template/tests/frames.ts +134 -0
- package/knowledge/scaffold/template/tests/intro.og.ts +65 -0
- package/knowledge/scaffold/template/tests/smoke.og.ts +30 -36
- package/knowledge/scaffold/template/tsconfig.json +1 -1
- package/knowledge/scripts/mount.ts +2 -1
- package/knowledge/scripts/validate.ts +19 -10
- package/knowledge/skills/og-build/SKILL.md +8 -5
- package/knowledge/skills/og-core/SKILL.md +11 -0
- package/knowledge/skills/og-game/SKILL.md +877 -8
- package/knowledge/skills/og-platform-sdk/SKILL.md +1 -0
- package/knowledge/skills/og-taste/SKILL.md +82 -73
- package/knowledge/skills/og-ui/SKILL.md +65 -50
- package/knowledge/wiki/ai.md +20 -20
- package/knowledge/wiki/anim.md +384 -41
- package/knowledge/wiki/atmosphere.md +36 -13
- package/knowledge/wiki/audio.md +18 -18
- package/knowledge/wiki/camera.md +19 -13
- package/knowledge/wiki/commands.md +19 -13
- package/knowledge/wiki/debug.md +414 -0
- package/knowledge/wiki/game.md +8 -8
- package/knowledge/wiki/i18n.md +7 -7
- package/knowledge/wiki/index.md +262 -7
- package/knowledge/wiki/input.md +47 -47
- package/knowledge/wiki/load.md +236 -68
- package/knowledge/wiki/math.md +2607 -0
- package/knowledge/wiki/nav.md +34 -34
- package/knowledge/wiki/net.md +340 -0
- package/knowledge/wiki/path.md +402 -0
- package/knowledge/wiki/physics.md +95 -95
- package/knowledge/wiki/platform.md +463 -29
- package/knowledge/wiki/probe.md +681 -67
- package/knowledge/wiki/reflect.md +15 -15
- package/knowledge/wiki/render.md +3893 -1896
- package/knowledge/wiki/rng.md +14 -14
- package/knowledge/wiki/stream.md +15 -15
- package/knowledge/wiki/system.md +8 -8
- package/knowledge/wiki/systems.md +2 -2
- package/knowledge/wiki/terrain.md +19 -19
- package/knowledge/wiki/tilemap.md +13 -13
- package/knowledge/wiki/time.md +4 -4
- package/knowledge/wiki/timeline.md +396 -0
- package/knowledge/wiki/transform.md +8 -8
- package/knowledge/wiki/tween.md +17 -17
- package/knowledge/wiki/ui.md +559 -629
- package/knowledge/wiki/video.md +157 -0
- package/knowledge/wiki/world.md +36 -36
- package/package.json +4 -3
- package/schema/api.schema.json +21707 -14725
- package/shell/page.ts +26 -2
- package/shell/platform.ts +46 -0
- package/shell/player.ts +7 -0
- package/src/depth.ts +71 -0
- package/src/e6.ts +32 -1
- package/src/env.d.ts +5 -1
- package/src/generated/abi.ts +189 -3
- package/src/index.ts +25 -4
- package/src/light.ts +18 -0
- package/src/math.ts +622 -0
- package/src/node/index.ts +17 -0
- package/src/node/playtest-run.ts +114 -18
- package/src/node/polish.ts +13 -3
- package/src/node/render.ts +291 -0
- package/src/node/run.ts +86 -5
- package/src/p2.ts +19 -1
- package/src/playtest.ts +57 -4
- package/src/runtime.ts +13 -3
- package/src/targets.ts +28 -0
- package/src/trace.ts +169 -0
- package/src/ui-kit/huds.ts +34 -34
- package/src/ui-kit/menus.ts +12 -12
- package/src/ui-kit/settings.ts +2 -2
- package/src/ui-kit/store.ts +18 -18
- package/src/ui-kit/story.ts +6 -4
- package/src/ui-kit/util.ts +8 -8
- package/src/web/shell.ts +58 -0
- package/src/web/worker.ts +103 -2
- package/types/bindings.json +9101 -6647
- package/types/index.d.ts +15503 -11564
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
type timeline
|
|
2
|
+
version 1
|
|
3
|
+
---
|
|
4
|
+
name "Intro"
|
|
5
|
+
duration 10
|
|
6
|
+
# The camera's rail: a slow arc in front of the yard, from the left to the right.
|
|
7
|
+
path "flyby" {
|
|
8
|
+
kind catmullRom
|
|
9
|
+
point -5 3.2 9
|
|
10
|
+
point -1.5 2.4 7.5
|
|
11
|
+
point 2 2 7
|
|
12
|
+
point 5 2.8 8.5
|
|
13
|
+
}
|
|
14
|
+
track camera {
|
|
15
|
+
rail "flyby"
|
|
16
|
+
key 0 {
|
|
17
|
+
at 0
|
|
18
|
+
target 0 1 0
|
|
19
|
+
fov 50
|
|
20
|
+
}
|
|
21
|
+
key 9.5 {
|
|
22
|
+
at 1
|
|
23
|
+
target 1.5 1 0
|
|
24
|
+
fov 42
|
|
25
|
+
ease cubicInOut
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
track transform "trickster" {
|
|
29
|
+
key 2 {
|
|
30
|
+
position 2 0 0
|
|
31
|
+
}
|
|
32
|
+
key 5 {
|
|
33
|
+
position 0.5 0 1.5
|
|
34
|
+
ease cubicInOut
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
track animation "trickster" {
|
|
38
|
+
key 0 {
|
|
39
|
+
clip "idle"
|
|
40
|
+
loop true
|
|
41
|
+
}
|
|
42
|
+
key 2 {
|
|
43
|
+
clip "walk"
|
|
44
|
+
loop true
|
|
45
|
+
blendIn 0.3
|
|
46
|
+
}
|
|
47
|
+
key 5 {
|
|
48
|
+
clip "wave"
|
|
49
|
+
blendIn 0.25
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
track caption {
|
|
53
|
+
captions "narration.srt"
|
|
54
|
+
style lg
|
|
55
|
+
anchor bottom
|
|
56
|
+
}
|
|
57
|
+
track audio {
|
|
58
|
+
key 1 {
|
|
59
|
+
sound "audio/blip.wav"
|
|
60
|
+
volume 0.6
|
|
61
|
+
}
|
|
62
|
+
key 5 {
|
|
63
|
+
sound "audio/click.ogt"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
track music {
|
|
67
|
+
key 6 {
|
|
68
|
+
sound "audio/sting.ogt"
|
|
69
|
+
volume 0.7
|
|
70
|
+
fadeIn 0.3
|
|
71
|
+
fadeOut 1
|
|
72
|
+
end 9.5
|
|
73
|
+
loop true
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
track event {
|
|
77
|
+
key 5.5 {
|
|
78
|
+
do "spawn_wave:2"
|
|
79
|
+
retroactive true
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
track transition {
|
|
83
|
+
key 0 {
|
|
84
|
+
kind fade
|
|
85
|
+
mode in
|
|
86
|
+
duration 1
|
|
87
|
+
}
|
|
88
|
+
key 9 {
|
|
89
|
+
kind fade
|
|
90
|
+
mode out
|
|
91
|
+
duration 1
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
track marker {
|
|
95
|
+
key 2 {
|
|
96
|
+
name "walk"
|
|
97
|
+
}
|
|
98
|
+
key 5.5 {
|
|
99
|
+
name "drop"
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// The host this project's playtest helpers (tests/*.ts) touch under bun:
|
|
2
|
+
// `node:` modules, `process`, `Buffer`, `TextEncoder`, `import.meta.url`.
|
|
3
|
+
// Declared here because the project type-checks with `types: []` (no
|
|
4
|
+
// @types/node, no bun-types): `og check` reads them through tsconfig.json's
|
|
5
|
+
// include. Game code (src/) uses none of them.
|
|
6
|
+
|
|
7
|
+
declare module "node:fs" {
|
|
8
|
+
export function existsSync(path: string): boolean;
|
|
9
|
+
export function readFileSync(path: string): Uint8Array;
|
|
10
|
+
export function readFileSync(path: string, encoding: string): string;
|
|
11
|
+
export function writeFileSync(path: string, data: string | Uint8Array): void;
|
|
12
|
+
export function mkdirSync(path: string, options?: { recursive?: boolean }): void;
|
|
13
|
+
export function copyFileSync(from: string, to: string): void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
declare module "node:path" {
|
|
17
|
+
export function dirname(path: string): string;
|
|
18
|
+
export function join(...parts: string[]): string;
|
|
19
|
+
export function resolve(...parts: string[]): string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
declare module "node:url" {
|
|
23
|
+
export function fileURLToPath(url: string | URL): string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
declare module "node:zlib" {
|
|
27
|
+
export function inflateSync(data: Uint8Array): Uint8Array;
|
|
28
|
+
export function deflateSync(data: Uint8Array, options?: { level?: number }): Uint8Array;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
declare const process: {
|
|
32
|
+
readonly env: Record<string, string | undefined>;
|
|
33
|
+
cwd(): string;
|
|
34
|
+
readonly stdout: { write(text: string): boolean };
|
|
35
|
+
};
|
|
36
|
+
declare const Buffer: { concat(chunks: readonly Uint8Array[]): Uint8Array };
|
|
37
|
+
declare class TextEncoder {
|
|
38
|
+
encode(text: string): Uint8Array;
|
|
39
|
+
}
|
|
40
|
+
declare class URL {
|
|
41
|
+
constructor(url: string, base?: string | URL);
|
|
42
|
+
readonly href: string;
|
|
43
|
+
}
|
|
44
|
+
interface ImportMeta {
|
|
45
|
+
readonly url: string;
|
|
46
|
+
}
|
|
@@ -66,7 +66,7 @@ export const Menu = defineSystem("Menu", { reads: [Spin, og.Input], writes: [Run
|
|
|
66
66
|
const started = run.value.started;
|
|
67
67
|
og.ui.column("Hud", { padding: { left: 24, top: 24, right: 24, bottom: 24 } }, () => og.ui.panel("Menu", { padding: { left: 20, top: 16, right: 20, bottom: 16 } }, () => {
|
|
68
68
|
og.ui.label("Title", og.i18n.t(world, "menu.title"), { step: "displaySm" });
|
|
69
|
-
og.ui.label("Status", started ? og.i18n.t(world, "menu.spinning", { speed: speed.toFixed(1) }) : og.i18n.t(world, "menu.prompt"), { color: { token: started ? "
|
|
69
|
+
og.ui.label("Status", started ? og.i18n.t(world, "menu.spinning", { speed: speed.toFixed(1) }) : og.i18n.t(world, "menu.prompt"), { color: { token: started ? "highlight" : "inkMuted" } });
|
|
70
70
|
const start = og.ui.button("Start", og.i18n.t(world, started ? "menu.started" : "menu.start"), { variant: "primary", size: "large", disabled: started });
|
|
71
71
|
if (!started && (start.clicked || input.value.pressed("KeyS") || input.value.pressed("Enter"))) run.value.started = true;
|
|
72
72
|
}));
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// The reference-frame check: decode the PNGs a run wrote and compare them
|
|
2
|
+
// with references/<tier>/ under og-tests' tolerance (a channel off by more
|
|
3
|
+
// than 32/255 is an outlier; fail above 0.5 % outliers or an RGB RMSE above
|
|
4
|
+
// 0.01). A tier without its own references compares with `metal` and says
|
|
5
|
+
// so. `OG_FX_CAPTURE=1 og test .` records this tier's references instead.
|
|
6
|
+
// (Copied from templates/fx-showcase/tests/frames.ts, so the template stands alone.)
|
|
7
|
+
|
|
8
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
9
|
+
import { dirname, join, resolve } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { inflateSync } from "node:zlib";
|
|
12
|
+
import type { PlaytestChecks, PlaytestRun } from "@origingame/engine/playtest";
|
|
13
|
+
|
|
14
|
+
export interface Image {
|
|
15
|
+
width: number;
|
|
16
|
+
height: number;
|
|
17
|
+
/** RGBA8 rows. */
|
|
18
|
+
rgba: Uint8Array;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** An 8-bit RGB or RGBA, non-interlaced PNG, unfiltered. */
|
|
22
|
+
export function decodePng(bytes: Uint8Array): Image {
|
|
23
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
24
|
+
let at = 8;
|
|
25
|
+
let width = 0;
|
|
26
|
+
let height = 0;
|
|
27
|
+
let channels = 4;
|
|
28
|
+
const idat: Uint8Array[] = [];
|
|
29
|
+
while (at < bytes.length) {
|
|
30
|
+
const len = view.getUint32(at);
|
|
31
|
+
const type = String.fromCharCode(...bytes.subarray(at + 4, at + 8));
|
|
32
|
+
const data = bytes.subarray(at + 8, at + 8 + len);
|
|
33
|
+
if (type === "IHDR") {
|
|
34
|
+
width = view.getUint32(at + 8);
|
|
35
|
+
height = view.getUint32(at + 12);
|
|
36
|
+
const depth = data[8]!;
|
|
37
|
+
const color = data[9]!;
|
|
38
|
+
if (depth !== 8 || (color !== 6 && color !== 2) || data[12] !== 0) throw new Error(`unsupported PNG (depth ${depth}, colour ${color}, interlace ${data[12]})`);
|
|
39
|
+
channels = color === 6 ? 4 : 3;
|
|
40
|
+
} else if (type === "IDAT") idat.push(data);
|
|
41
|
+
else if (type === "IEND") break;
|
|
42
|
+
at += 12 + len;
|
|
43
|
+
}
|
|
44
|
+
const raw = inflateSync(Buffer.concat(idat));
|
|
45
|
+
const stride = width * channels;
|
|
46
|
+
const out = new Uint8Array(width * height * 4);
|
|
47
|
+
const prev = new Uint8Array(stride);
|
|
48
|
+
const cur = new Uint8Array(stride);
|
|
49
|
+
for (let y = 0; y < height; y++) {
|
|
50
|
+
const filter = raw[y * (stride + 1)]!;
|
|
51
|
+
const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
|
|
52
|
+
for (let i = 0; i < stride; i++) {
|
|
53
|
+
const a = i >= channels ? cur[i - channels]! : 0;
|
|
54
|
+
const b = prev[i]!;
|
|
55
|
+
const c = i >= channels ? prev[i - channels]! : 0;
|
|
56
|
+
let v = line[i]!;
|
|
57
|
+
if (filter === 1) v += a;
|
|
58
|
+
else if (filter === 2) v += b;
|
|
59
|
+
else if (filter === 3) v += (a + b) >> 1;
|
|
60
|
+
else if (filter === 4) {
|
|
61
|
+
const p = a + b - c;
|
|
62
|
+
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
|
|
63
|
+
v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
64
|
+
}
|
|
65
|
+
cur[i] = v & 255;
|
|
66
|
+
}
|
|
67
|
+
for (let x = 0; x < width; x++) {
|
|
68
|
+
for (let k = 0; k < 4; k++) out[(y * width + x) * 4 + k] = k < channels ? cur[x * channels + k]! : 255;
|
|
69
|
+
}
|
|
70
|
+
prev.set(cur);
|
|
71
|
+
}
|
|
72
|
+
return { width, height, rgba: out };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** og-tests' rule: outlier fraction and RGB RMSE between two equal-size frames. */
|
|
76
|
+
export function difference(a: Image, b: Image): { outliers: number; rmse: number } {
|
|
77
|
+
if (a.width !== b.width || a.height !== b.height) return { outliers: 1, rmse: 1 };
|
|
78
|
+
let outliers = 0;
|
|
79
|
+
let sum = 0;
|
|
80
|
+
const n = a.width * a.height;
|
|
81
|
+
for (let p = 0; p < n; p++) {
|
|
82
|
+
let out = false;
|
|
83
|
+
for (let k = 0; k < 3; k++) {
|
|
84
|
+
const d = Math.abs(a.rgba[p * 4 + k]! - b.rgba[p * 4 + k]!);
|
|
85
|
+
if (d > 32) out = true;
|
|
86
|
+
const f = d / 255;
|
|
87
|
+
sum += f * f;
|
|
88
|
+
}
|
|
89
|
+
if (out) outliers++;
|
|
90
|
+
}
|
|
91
|
+
return { outliers: outliers / n, rmse: Math.sqrt(sum / (n * 3)) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The playtest's output directory (the bundled playtest runs from it). */
|
|
95
|
+
export function outDir(): string {
|
|
96
|
+
return dirname(fileURLToPath(import.meta.url));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The project's references/, from its og-out/test/<name>/. */
|
|
100
|
+
export function referencesDir(): string {
|
|
101
|
+
return resolve(outDir(), "..", "..", "..", "references");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Compares each screenshot with its reference (`<prefix><file>`), or records it. */
|
|
105
|
+
export function checkFrames(run: PlaytestRun, t: PlaytestChecks, files: readonly string[], prefix: string): void {
|
|
106
|
+
// Read only where there is a renderer: without one (tier `none`) each frame is
|
|
107
|
+
// reported "no renderer, frame unchecked" and every other check still runs.
|
|
108
|
+
t.frame(files, () => {
|
|
109
|
+
const refs = referencesDir();
|
|
110
|
+
t.ok(existsSync(refs) || process.env.OG_FX_CAPTURE !== undefined, `the references directory exists at ${refs}`);
|
|
111
|
+
const own = join(refs, run.tier);
|
|
112
|
+
const capture = process.env.OG_FX_CAPTURE !== undefined;
|
|
113
|
+
for (const f of files) {
|
|
114
|
+
const actual = join(outDir(), f);
|
|
115
|
+
// Each file on its own: this tier's reference where it has one, metal's otherwise.
|
|
116
|
+
const mine = join(own, prefix + f);
|
|
117
|
+
const ref = capture || existsSync(mine) ? mine : join(refs, "metal", prefix + f);
|
|
118
|
+
if (ref !== mine) process.stdout.write(` note: no ${run.tier} reference for ${prefix}${f}; comparing with metal\n`);
|
|
119
|
+
if (capture) {
|
|
120
|
+
mkdirSync(own, { recursive: true });
|
|
121
|
+
copyFileSync(actual, ref);
|
|
122
|
+
t.ok(true, `recorded ${ref}`);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (!existsSync(ref)) {
|
|
126
|
+
t.ok(false, `${ref} is missing: record it with OG_FX_CAPTURE=1 og test .`);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const d = difference(decodePng(readFileSync(actual)), decodePng(readFileSync(ref)));
|
|
130
|
+
process.stdout.write(` ${f}: ${(d.outliers * 100).toFixed(3)} % outliers, RMSE ${d.rmse.toFixed(5)} against ${ref}\n`);
|
|
131
|
+
t.ok(d.outliers <= 0.005 && d.rmse <= 0.01, `${f} matches ${prefix}${f}: ${(d.outliers * 100).toFixed(3)} % outliers (≤ 0.5 %), RMSE ${d.rmse.toFixed(5)} (≤ 0.01)`);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// The intro cutscene (assets/timelines/intro.ogt) played from the command
|
|
2
|
+
// channel: `timeline:play:intro` makes the entity `timeline-intro`, and the
|
|
3
|
+
// timeline drives the camera along the "flyby" rail, walks the trickster
|
|
4
|
+
// with its clips, shows the narration from narration.srt, plays two sounds,
|
|
5
|
+
// runs `spawn_wave:2` at 5.5 s and fades in and out. At 4 s and 8 s the
|
|
6
|
+
// caption on screen is the .srt's cue for that time and the frames are
|
|
7
|
+
// compared with references/<tier>/intro-4.png and intro-8.png
|
|
8
|
+
// (tests/frames.ts; `OG_FX_CAPTURE=1 og test .` records them). After the end
|
|
9
|
+
// the camera is the game's again.
|
|
10
|
+
import { playtest } from "@origingame/engine/playtest";
|
|
11
|
+
import { checkFrames } from "./frames.ts";
|
|
12
|
+
|
|
13
|
+
interface EntityDump {
|
|
14
|
+
components: Record<string, Record<string, number | boolean | string | number[]>>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface UiDump {
|
|
18
|
+
elements: Array<{ id: string; text: string; x: number; y: number; width: number; height: number }>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const FILES = ["intro-4.png", "intro-8.png"] as const;
|
|
22
|
+
|
|
23
|
+
export default playtest({
|
|
24
|
+
name: "the intro plays its rail camera, captions, clips, event and fades from timeline:play:intro",
|
|
25
|
+
size: "1280x720",
|
|
26
|
+
commands: [
|
|
27
|
+
"0:timeline:play:intro",
|
|
28
|
+
"4:screenshot:intro-4.png",
|
|
29
|
+
"4:dump_ui:ui-4.json",
|
|
30
|
+
"4:dump_entity:timeline-intro:timeline-4.json",
|
|
31
|
+
"4:dump_entity:trickster:trickster-4.json",
|
|
32
|
+
"8:screenshot:intro-8.png",
|
|
33
|
+
"8:dump_ui:ui-8.json",
|
|
34
|
+
"8:dump_world:world-8.json",
|
|
35
|
+
"10.5:dump_entity:timeline-intro:timeline-end.json",
|
|
36
|
+
"10.5:dump_ui:ui-end.json",
|
|
37
|
+
"10.6:quit",
|
|
38
|
+
].join(";"),
|
|
39
|
+
check(run, t) {
|
|
40
|
+
t.equal(run.warnings.join("; "), "", "the script ran without warnings");
|
|
41
|
+
t.ok(run.quit, "the script reached quit");
|
|
42
|
+
const caption = (file: string) =>
|
|
43
|
+
run.json<UiDump>(file).elements.find((e) => e.id.startsWith("timeline-caption-") && e.id.endsWith("-text"));
|
|
44
|
+
const at4 = caption("ui-4.json");
|
|
45
|
+
t.equal(at4?.text, "The trickster walks out to meet it.", "at 4 s the caption is the .srt's second cue");
|
|
46
|
+
t.ok(at4 !== undefined && at4.y > 600, `the caption sits at the bottom (y ${at4?.y})`);
|
|
47
|
+
t.equal(caption("ui-8.json")?.text, "Here they come.", "at 8 s the caption is the third cue");
|
|
48
|
+
const tl = run.json<EntityDump>("timeline-4.json").components["og.Timeline"]!;
|
|
49
|
+
t.equal(tl["playing"], true, "the timeline plays at 4 s");
|
|
50
|
+
t.near(Number(tl["time"]), 4, 0.02, "its time is the run's (it started at tick 0)");
|
|
51
|
+
// The transform track walks the trickster from (2 0 0) at 2 s to (0.5 0 1.5) at 5 s.
|
|
52
|
+
const p = run.json<EntityDump>("trickster-4.json").components["og.Transform"]!["position"] as number[];
|
|
53
|
+
t.ok(p[0]! < 2 && p[0]! > 0.5 && p[2]! > 0 && p[2]! < 1.5, `the trickster is on its way at 4 s (${p.join(", ")})`);
|
|
54
|
+
const world = run.json<{ entities: number }>("world-8.json");
|
|
55
|
+
t.ok(world.entities >= 10, `the event key at 5.5 s ran spawn_wave:2 (${world.entities} entities)`);
|
|
56
|
+
const end = run.json<EntityDump>("timeline-end.json").components["og.Timeline"]!;
|
|
57
|
+
t.equal(end["playing"], false, "the timeline ended");
|
|
58
|
+
t.equal(Number(end["time"]), 10, "at its duration");
|
|
59
|
+
t.equal(caption("ui-end.json"), undefined, "no caption after the end");
|
|
60
|
+
t.frame(FILES, () => {
|
|
61
|
+
for (const f of FILES) t.equal(run.png(f).width, 1280, `${f} is 1280 wide`);
|
|
62
|
+
});
|
|
63
|
+
checkFrames(run, t, FILES, "");
|
|
64
|
+
},
|
|
65
|
+
});
|
|
@@ -1,43 +1,37 @@
|
|
|
1
1
|
// The template's playtest, run by `og test` through `og run --headless
|
|
2
2
|
// --commands`: a tap on the menu's Start button (found by its UI id, no key
|
|
3
3
|
// fallback) starts the trickster spinning and drops the crate onto the
|
|
4
|
-
// ground
|
|
4
|
+
// ground. Every check is an `expect:` step evaluated in the run at its tick
|
|
5
|
+
// (docs/design.md §3.4): a failed one fails the run with the tick and what
|
|
6
|
+
// it observed, and a frame one without a renderer is reported unchecked.
|
|
7
|
+
// The frame, the scene described as data, the dumps and the UI tree are
|
|
8
|
+
// written beside them.
|
|
5
9
|
import { playtest } from "@origingame/engine/playtest";
|
|
6
10
|
|
|
7
|
-
interface EntityDump {
|
|
8
|
-
components: Record<string, Record<string, number | boolean | string | number[]>>;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
interface UiDump {
|
|
12
|
-
elements: Array<{ id: string; parent: string; text: string; x: number; y: number; width: number; height: number }>;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
11
|
export default playtest({
|
|
16
|
-
name: "A tap on Start spins the trickster and drops the crate; the run writes its frame, dumps and UI tree",
|
|
17
|
-
commands:
|
|
18
|
-
"0.5:touch_move_to:Start
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
t.ok(y > 0.4 && y < 0.6, `the crate fell from 4 m and rests on the ground (y ${y.toFixed(3)})`);
|
|
42
|
-
},
|
|
12
|
+
name: "A tap on Start spins the trickster and drops the crate; the run writes its frame, description, dumps and UI tree",
|
|
13
|
+
commands: [
|
|
14
|
+
"0.5:touch_move_to:Start",
|
|
15
|
+
"0.6:touch_start",
|
|
16
|
+
"0.7:touch_end",
|
|
17
|
+
// The tap was one press; the run started; the button says so.
|
|
18
|
+
"0.8:expect:event:og.PointerDown:1",
|
|
19
|
+
"0.8:expect:state:started==true",
|
|
20
|
+
"0.9:expect:ui:text:=Started",
|
|
21
|
+
// The trickster spins; the crate fell from 4 m and rests on the ground.
|
|
22
|
+
"2.0:expect:state:spin>0",
|
|
23
|
+
"2.0:expect:entity:crate:within:-3,0.4,-1,1,0.6,2",
|
|
24
|
+
"2.0:expect:event:og.CollisionStarted",
|
|
25
|
+
// The frame (1280x720): the sky at the top, the crate and the trickster where they stand.
|
|
26
|
+
"2.0:screenshot:smoke.png",
|
|
27
|
+
"2.0:expect:region:560,16,64,24:color:#9da5b1:24",
|
|
28
|
+
"2.0:expect:entity:crate:within:380,360,280,240",
|
|
29
|
+
"2.0:expect:entity:trickster:within:640,300,240,280",
|
|
30
|
+
"2.1:describe:describe.json",
|
|
31
|
+
"2.1:dump_entity:trickster:trickster.json",
|
|
32
|
+
"2.15:dump_entity:crate:crate.json",
|
|
33
|
+
"2.2:dump_world:world.json",
|
|
34
|
+
"2.3:dump_ui:ui.json",
|
|
35
|
+
"2.5:quit",
|
|
36
|
+
].join(";"),
|
|
43
37
|
});
|
|
@@ -90,7 +90,8 @@ export function mount(roots: Roots, production: string, write: boolean): MountRe
|
|
|
90
90
|
if (exists && lstatSync(target).isDirectory()) throw new Error(`${f.path}: a directory in the platform tree where the package has a file`);
|
|
91
91
|
const same = exists && Buffer.compare(readFileSync(target), Buffer.from(f.bytes)) === 0;
|
|
92
92
|
const mode = executable(f.source) ? 0o755 : 0o644;
|
|
93
|
-
|
|
93
|
+
// Windows chmod cannot represent POSIX group/other or executable bits.
|
|
94
|
+
const modeSame = exists && (process.platform === "win32" || (statSync(target).mode & 0o777) === mode);
|
|
94
95
|
if (same && modeSame) continue;
|
|
95
96
|
changes.push({ kind: exists ? "changed" : "added", path: f.path });
|
|
96
97
|
if (write) {
|
|
@@ -399,7 +399,7 @@ interface Style {
|
|
|
399
399
|
checks: string[];
|
|
400
400
|
premise: Record<string, string>;
|
|
401
401
|
look: { preset: string; presetReady: boolean; support: string; primitives: string[]; missing: string[] };
|
|
402
|
-
|
|
402
|
+
ui: string;
|
|
403
403
|
palette: { size: string; rules: string };
|
|
404
404
|
assets: Record<string, string>;
|
|
405
405
|
artBible: { direction: string; avoid: string[] };
|
|
@@ -408,7 +408,7 @@ interface Style {
|
|
|
408
408
|
/** 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
409
|
export const SUPPORT = ["full", "partial", "planned", "content"] as const;
|
|
410
410
|
/** The placeholders the art-bible template may use. */
|
|
411
|
-
export const BIBLE_FIELDS = ["title", "premise", "camera", "platform", "name", "descriptor", "direction", "palette", "assets", "
|
|
411
|
+
export const BIBLE_FIELDS = ["title", "premise", "camera", "platform", "name", "descriptor", "direction", "palette", "assets", "ui", "avoid", "game", "slug", "checks"] as const;
|
|
412
412
|
const PREMISE = ["genre", "mood", "era", "audience"];
|
|
413
413
|
const ASSET_SPECS = ["logicalResolution", "spriteScale", "faceBudget", "outlinePx", "textureDensity"];
|
|
414
414
|
|
|
@@ -428,7 +428,7 @@ function looks(roots: Roots, files: readonly PayloadFile[], problems: string[]):
|
|
|
428
428
|
problems.push(`${at}: missing; og-taste §12 reads the style catalogue from it`);
|
|
429
429
|
return;
|
|
430
430
|
}
|
|
431
|
-
let cat: { schemaVersion?: number; categories?: Record<string, string>;
|
|
431
|
+
let cat: { schemaVersion?: number; categories?: Record<string, string>; ui?: string[]; artBibleTemplate?: string; styles?: Style[] };
|
|
432
432
|
try {
|
|
433
433
|
cat = JSON.parse(readFileSync(path, "utf8"));
|
|
434
434
|
} catch (e) {
|
|
@@ -437,7 +437,10 @@ function looks(roots: Roots, files: readonly PayloadFile[], problems: string[]):
|
|
|
437
437
|
}
|
|
438
438
|
if (cat.schemaVersion !== 1) problems.push(`${at}: schemaVersion ${cat.schemaVersion}, expected 1`);
|
|
439
439
|
const categories = Object.keys(cat.categories ?? {});
|
|
440
|
-
const
|
|
440
|
+
const packs = cat.ui ?? [];
|
|
441
|
+
for (const p of packs) {
|
|
442
|
+
if (existsSync(join(roots.pkg, "..", "..", "templates")) && !existsSync(join(roots.pkg, "..", "..", "templates", "styles", p, "style.ogt"))) problems.push(`${at}: ui names ${p}, which is not a built-in style (templates/styles/${p}/style.ogt)`);
|
|
443
|
+
}
|
|
441
444
|
for (const m of (cat.artBibleTemplate ?? "").matchAll(/\{(\w+)\}/g)) {
|
|
442
445
|
if (!(BIBLE_FIELDS as readonly string[]).includes(m[1]!)) problems.push(`${at}: artBibleTemplate uses {${m[1]}}, which is none of ${BIBLE_FIELDS.join(", ")}`);
|
|
443
446
|
}
|
|
@@ -454,9 +457,9 @@ function looks(roots: Roots, files: readonly PayloadFile[], problems: string[]):
|
|
|
454
457
|
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(s.slug ?? "")) problems.push(`${id}: slug is not lowercase-hyphen`);
|
|
455
458
|
if (seen.has(s.slug)) problems.push(`${id}: a second style with this slug`);
|
|
456
459
|
seen.add(s.slug);
|
|
457
|
-
for (const k of ["name", "descriptor", "
|
|
460
|
+
for (const k of ["name", "descriptor", "ui"] as const) if (!text(s[k])) problems.push(`${id}: no ${k}`);
|
|
458
461
|
if (!categories.includes(s.category)) problems.push(`${id}: category ${s.category} is none of ${categories.join(", ")}`);
|
|
459
|
-
if (!
|
|
462
|
+
if (!packs.includes(s.ui)) problems.push(`${id}: ui ${s.ui} is none of the built-in styles (${packs.join(", ")})`);
|
|
460
463
|
if (!Array.isArray(s.checks) || s.checks.length !== 3 || !s.checks.every(text)) problems.push(`${id}: needs exactly three checks a critic can judge on a frame`);
|
|
461
464
|
for (const k of PREMISE) if (!text(s.premise?.[k])) problems.push(`${id}: premise has no ${k}`);
|
|
462
465
|
for (const k of ASSET_SPECS) if (!text(s.assets?.[k])) problems.push(`${id}: assets has no ${k}`);
|
|
@@ -482,15 +485,21 @@ function looks(roots: Roots, files: readonly PayloadFile[], problems: string[]):
|
|
|
482
485
|
const preset = join(repo, look.preset);
|
|
483
486
|
if (look.presetReady) {
|
|
484
487
|
if (existsSync(join(repo, "templates")) && !existsSync(preset)) problems.push(`${id}: presetReady, but ${look.preset} does not exist`);
|
|
485
|
-
else if (existsSync(preset)
|
|
488
|
+
else if (existsSync(preset)) {
|
|
489
|
+
const body = readFileSync(preset, "utf8");
|
|
490
|
+
if (!/^type look\s*$/m.test(body)) problems.push(`${id}: ${look.preset} is not an .ogt of type look`);
|
|
491
|
+
// The preset dresses the UI in the style the catalogue gives it.
|
|
492
|
+
const ui = /^ui[ \t]+"([^"]+)"/m.exec(body)?.[1];
|
|
493
|
+
if (ui !== s.ui) problems.push(`${id}: ${look.preset} has ${ui ? `ui "${ui}"` : "no ui line"}; the catalogue says ui "${s.ui}"`);
|
|
494
|
+
}
|
|
486
495
|
}
|
|
487
496
|
if (tasteText && !tasteText.includes(`\`${s.slug}\``)) problems.push(`skills/og-taste/SKILL.md: the style catalogue (§12) does not list \`${s.slug}\``);
|
|
488
497
|
else if (tasteText) {
|
|
489
|
-
// §12's row for the style says what the catalogue says: family, name, support,
|
|
498
|
+
// §12's row for the style says what the catalogue says: family, name, support, ui style.
|
|
490
499
|
const row = new RegExp(`^\\| \`${s.slug.replace(/[-]/g, "\\-")}\` \\| ([^|]+) \\| ([^|]+) \\| ([^|]+) \\| \`([^\`]+)\` \\|$`, "m").exec(tasteText);
|
|
491
|
-
const want = [cat.categories?.[s.category] ?? s.category, s.name, look.support, s.
|
|
500
|
+
const want = [cat.categories?.[s.category] ?? s.category, s.name, look.support, s.ui];
|
|
492
501
|
const have = row?.slice(1, 5).map((x) => x.trim());
|
|
493
|
-
if (!have) problems.push(`skills/og-taste/SKILL.md: §12's table has no row for \`${s.slug}\` (| slug | family | style | support |
|
|
502
|
+
if (!have) problems.push(`skills/og-taste/SKILL.md: §12's table has no row for \`${s.slug}\` (| slug | family | style | support | ui |)`);
|
|
494
503
|
else if (have.join("|") !== want.join("|")) problems.push(`skills/og-taste/SKILL.md: §12's row for \`${s.slug}\` reads ${have.join(", ")}; the catalogue says ${want.join(", ")}`);
|
|
495
504
|
}
|
|
496
505
|
const all = JSON.stringify(s);
|
|
@@ -14,8 +14,8 @@ 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 skin
|
|
18
|
-
without its CJK `fallback` named the line and og-taste §1;
|
|
17
|
+
warning, and every refusal named the line and the fix (a copied UI skin, as
|
|
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
|
|
21
21
|
replays worked the first time they were asked for, and `og polish` scored
|
|
@@ -201,7 +201,10 @@ every screen that matters.
|
|
|
201
201
|
| feel | Sound on input | inputs answered by a sound within 6 ticks | every action | og-audio §1 |
|
|
202
202
|
| 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 |
|
|
203
203
|
|
|
204
|
-
The report also lists clipped samples in the mix (og-audio §4)
|
|
204
|
+
The report also lists clipped samples in the mix (og-audio §4), and a UI
|
|
205
|
+
style finding (no score) when a shot's `dump_ui` reports `style` empty: the
|
|
206
|
+
UI draws in prototype, the engine's placeholder, not a style of the game's
|
|
207
|
+
own (og-taste §1). Contrast and
|
|
205
208
|
text size read text only: icons and images (their "text" is a picture's
|
|
206
209
|
name), disabled controls and what they hold (WCAG exempts them), the
|
|
207
210
|
slivers a scroll container cuts, what a dialog's or a tutorial's scrim
|
|
@@ -218,7 +221,7 @@ so it depends on the glyphs' coverage: thin strokes cover few pixels
|
|
|
218
221
|
whole, and a regular-weight CJK face at body sizes measures lower than
|
|
219
222
|
Latin in the same colours (a 15 px "设置" 3.1:1 where "Settings" reads
|
|
220
223
|
4.6:1 on one button), as it reads lower to the eye. Darken the ink in the
|
|
221
|
-
|
|
224
|
+
style's palette, give the CJK face a heavier weight or the label a larger
|
|
222
225
|
step; the measure is not wrong there. Facts are the game's `facts`
|
|
223
226
|
(og-game §3): expose `hp`, `hits` or `kills` there or the hit measures stay
|
|
224
227
|
n/a. A baseline line says how often things change with no input at all;
|
|
@@ -226,7 +229,7 @@ when it is high (a runner that always moves), read latency together with
|
|
|
226
229
|
the frames.
|
|
227
230
|
|
|
228
231
|
The numbers cannot judge a style. Read the frames before changing anything,
|
|
229
|
-
fix the cause in the
|
|
232
|
+
fix the cause in the style, the template or the system, never per call, and
|
|
230
233
|
run it again after the fix: the report is the evidence.
|
|
231
234
|
|
|
232
235
|
## 6. Shipping
|
|
@@ -270,6 +270,17 @@ it is declared.
|
|
|
270
270
|
probe and the command channel address entities by them.
|
|
271
271
|
- Vector, quaternion, matrix, colour, transform and material vocabulary is
|
|
272
272
|
three.js's (`position`, `quaternion`, `scale`, `metalness`, `roughness`).
|
|
273
|
+
- Math on those values is three.js's methods as pure functions, imported
|
|
274
|
+
from `@origingame/engine`: `vec3`, `vec2`, `quat`, `mat4`, `box3`,
|
|
275
|
+
`sphere`, `plane`, `frustum`, `ray`, `color` (`vec3.add(a, b)`,
|
|
276
|
+
`quat.slerp(a, b, t)`, `mat4.transformPoint(m, p)`,
|
|
277
|
+
`frustum.fromCamera(...)` then `frustum.intersectsBox(f, box)`,
|
|
278
|
+
`ray.intersectTriangle(r, a, b, c)` answering a point or null,
|
|
279
|
+
`color.fromHex(0x14161b)` answering linear RGB). They never change their
|
|
280
|
+
arguments and answer new plain objects, so there are no classes to
|
|
281
|
+
construct and a result is written into a column as any other value;
|
|
282
|
+
they use only arithmetic and `sqrt`, so a system using them stays
|
|
283
|
+
deterministic. Signatures: `reference/math.md`.
|
|
273
284
|
|
|
274
285
|
## 7. Where the API is described
|
|
275
286
|
|