bevy-ts 0.1.0-alpha.2 → 0.1.0-alpha.3

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/bevy-ts.js CHANGED
@@ -1,10 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  // Runs the precompiled engine binary from the platform package npm installed alongside this one
3
3
  // (as esbuild and Biome ship), so `npx bevy-ts dev src/game.ts` needs no Rust toolchain.
4
- // BEVY_TS_BINARY overrides the binary, for engine development.
4
+ // BEVY_TS_BINARY overrides the binary, for engine development. `new` and `examples` run here, in
5
+ // Node, with no engine.
5
6
  import { spawn } from "node:child_process";
7
+ import { create, examples } from "./examples.js";
6
8
  import { binary } from "./resolve.js";
7
9
 
10
+ // exitCode, not exit(): stdout to a pipe is written asynchronously and must drain first.
11
+ const [command, ...rest] = process.argv.slice(2);
12
+ if (command === "new") process.exitCode = create(rest);
13
+ else if (command === "examples") process.exitCode = examples(rest);
14
+ else runEngine();
15
+
8
16
  function binaryOrExit() {
9
17
  try {
10
18
  return binary();
@@ -14,11 +22,13 @@ function binaryOrExit() {
14
22
  }
15
23
  }
16
24
 
17
- // Forward signals so stopping the launcher stops the engine too.
18
- const child = spawn(binaryOrExit(), process.argv.slice(2), { stdio: "inherit" });
19
- for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(sig, () => child.kill(sig));
20
- child.on("error", (e) => {
21
- console.error(`bevy-ts: ${e.message}`);
22
- process.exit(1);
23
- });
24
- child.on("exit", (code, signal) => process.exit(code ?? (signal ? 1 : 0)));
25
+ function runEngine() {
26
+ // Forward signals so stopping the launcher stops the engine too.
27
+ const child = spawn(binaryOrExit(), process.argv.slice(2), { stdio: "inherit" });
28
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(sig, () => child.kill(sig));
29
+ child.on("error", (e) => {
30
+ console.error(`bevy-ts: ${e.message}`);
31
+ process.exit(1);
32
+ });
33
+ child.on("exit", (code, signal) => process.exit(code ?? (signal ? 1 : 0)));
34
+ }
@@ -0,0 +1,141 @@
1
+ // `bevy-ts examples` and `bevy-ts new`: the launcher's own commands. They read the examples this
2
+ // package ships (copied in at pack time by scripts/sync-examples.mjs) and never start the engine.
3
+ import { spawnSync } from "node:child_process";
4
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
5
+ import { basename, resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const root = fileURLToPath(new URL("../examples/", import.meta.url));
9
+ const version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
10
+ // What `new` leaves out of the copy: the metadata, not the game.
11
+ const META = ["example.json", "thumbnail.png"];
12
+
13
+ /** The shipped examples, in the order to show them: `cube` first, then by id. */
14
+ export function list() {
15
+ if (!existsSync(root)) return [];
16
+ const out = [];
17
+ for (const name of readdirSync(root)) {
18
+ const file = `${root}${name}/example.json`;
19
+ if (!existsSync(file)) continue;
20
+ const { id, title, description, entry } = JSON.parse(readFileSync(file, "utf8"));
21
+ const e = { id, title, description, entry };
22
+ if (existsSync(`${root}${name}/thumbnail.png`)) e.thumbnail = `${root}${name}/thumbnail.png`;
23
+ e.dir = `${root}${name}`;
24
+ out.push(e);
25
+ }
26
+ return out.sort((a, b) => (a.id === "cube" ? -1 : b.id === "cube" ? 1 : a.id < b.id ? -1 : 1));
27
+ }
28
+
29
+ function refuse(msg, code = 1) {
30
+ console.error(`bevy-ts: ${msg}`);
31
+ return code;
32
+ }
33
+
34
+ export function examples(args) {
35
+ const all = list().map(({ dir, ...e }) => e);
36
+ if (args.includes("--json")) {
37
+ process.stdout.write(`${JSON.stringify({ version, examples: all })}\n`);
38
+ } else {
39
+ for (const e of all) console.log(`${e.id.padEnd(10)} ${e.title}: ${e.description}`);
40
+ }
41
+ return 0;
42
+ }
43
+
44
+ const NEW_USAGE = "usage: bevy-ts new <dir> [--example <id>] [--no-install] [--json]";
45
+
46
+ export function create(args) {
47
+ let dir;
48
+ let id = "cube";
49
+ let install = true;
50
+ let json = false;
51
+ for (let i = 0; i < args.length; i++) {
52
+ const a = args[i];
53
+ if (a === "--example") {
54
+ id = args[++i];
55
+ if (!id || id.startsWith("--")) return refuse(`--example takes an id\n${NEW_USAGE}`, 2);
56
+ } else if (a === "--no-install") install = false;
57
+ else if (a === "--json") json = true;
58
+ else if (a.startsWith("--")) return refuse(`unknown option ${a}\n${NEW_USAGE}`, 2);
59
+ else if (dir === undefined) dir = a;
60
+ else return refuse(`unexpected argument ${a}\n${NEW_USAGE}`, 2);
61
+ }
62
+ if (dir === undefined) return refuse(`missing <dir>\n${NEW_USAGE}`, 2);
63
+
64
+ // Everything is checked before the first write, so a refusal writes nothing.
65
+ const all = list();
66
+ const example = all.find((e) => e.id === id);
67
+ if (!example) return refuse(`no example "${id}"; there are: ${all.map((e) => e.id).join(", ")}`);
68
+ const target = resolve(dir);
69
+ if (existsSync(target)) {
70
+ if (!statSync(target).isDirectory()) return refuse(`${target} is a file`);
71
+ if (readdirSync(target).length) return refuse(`${target} is not empty; bevy-ts new makes a project only in an empty or missing directory`);
72
+ }
73
+
74
+ try {
75
+ mkdirSync(target, { recursive: true });
76
+ for (const f of readdirSync(example.dir)) {
77
+ if (!META.includes(f)) cpSync(`${example.dir}/${f}`, `${target}/${f}`, { recursive: true });
78
+ }
79
+ const name = basename(target).toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[._-]+/, "") || "game";
80
+ const pkg = {
81
+ name,
82
+ private: true,
83
+ type: "module",
84
+ scripts: { dev: `bevy-ts dev ${example.entry}` },
85
+ devDependencies: { "bevy-ts": version },
86
+ };
87
+ // The examples' compiler options, resolving bevy-ts from node_modules instead of the checkout.
88
+ const tsconfig = {
89
+ compilerOptions: {
90
+ target: "es2023",
91
+ module: "esnext",
92
+ moduleResolution: "bundler",
93
+ strict: true,
94
+ noEmit: true,
95
+ verbatimModuleSyntax: true,
96
+ jsx: "react-jsx",
97
+ jsxImportSource: "bevy-ts",
98
+ allowImportingTsExtensions: true,
99
+ },
100
+ include: ["src", ".bevy-ts/types"],
101
+ };
102
+ writeFileSync(`${target}/package.json`, `${JSON.stringify(pkg, null, 2)}\n`);
103
+ writeFileSync(`${target}/tsconfig.json`, `${JSON.stringify(tsconfig, null, 2)}\n`);
104
+ writeFileSync(`${target}/.gitignore`, "node_modules/\n.bevy-ts/\n");
105
+ } catch (e) {
106
+ return refuse(e.message);
107
+ }
108
+
109
+ const pm = packageManager();
110
+ const installed = install ? runInstall(pm, target) : false;
111
+ if (json) {
112
+ process.stdout.write(`${JSON.stringify({ dir: target, entry: example.entry, example: id, installed })}\n`);
113
+ } else {
114
+ console.log(`Made ${target} from ${id}${install && !installed ? "; the install failed" : ""}.`);
115
+ const steps = [`cd ${shellQuote(dir)}`, ...(installed ? [] : [`${pm} install`]), `npx bevy-ts dev ${example.entry}`];
116
+ console.log(` ${steps.join(" && ")}`);
117
+ }
118
+ return install && !installed ? 1 : 0;
119
+ }
120
+
121
+ /** The package manager that ran bevy-ts (npm, pnpm, yarn or bun), npm otherwise. */
122
+ function packageManager() {
123
+ const agent = (process.env.npm_config_user_agent ?? "").split("/")[0];
124
+ return ["npm", "pnpm", "yarn", "bun"].includes(agent) ? agent : "npm";
125
+ }
126
+
127
+ /** The path as it pastes into a POSIX shell: single-quoted when it holds anything but safe characters. */
128
+ function shellQuote(p) {
129
+ return /^[\w@%+=:,./-]+$/.test(p) ? p : `'${p.replaceAll("'", "'\\''")}'`;
130
+ }
131
+
132
+ /** Installs with `pm` in `dir`, its output on stderr. */
133
+ function runInstall(pm, dir) {
134
+ console.error(`bevy-ts: ${pm} install in ${dir}`);
135
+ const r = spawnSync(pm, ["install"], { cwd: dir, stdio: ["ignore", 2, 2], shell: process.platform === "win32" });
136
+ if (r.error || r.status !== 0) {
137
+ console.error(`bevy-ts: ${pm} install failed${r.error ? `: ${r.error.message}` : ""}; the project is in place, run it again there`);
138
+ return false;
139
+ }
140
+ return true;
141
+ }
@@ -0,0 +1,18 @@
1
+ // The arena: where things are. What an enemy is lives in src/enemy.tsx.
2
+
3
+ #EnemyA
4
+ @enemy::Enemy
5
+ Transform { translation: Vec3 { x: -3.0, z: -4.0 } }
6
+ --
7
+ #EnemyB
8
+ @enemy::Enemy { @hp: 250.0 }
9
+ Transform { translation: Vec3 { x: 3.0, z: -4.0 } }
10
+ --
11
+ // Two copies of one room, each with its guard.
12
+ #RoomA
13
+ :"levels/room.bsn"
14
+ Transform { translation: Vec3 { x: -12.0, z: -12.0 } }
15
+ --
16
+ #RoomB
17
+ :"levels/room.bsn"
18
+ Transform { translation: Vec3 { x: 12.0, z: -12.0 } }
@@ -0,0 +1,8 @@
1
+ // A room, placed twice by arena.bsn: its guard is an enemy too.
2
+ #Room
3
+ Transform
4
+ Children [
5
+ #Guard
6
+ @enemy::Enemy { @hp: 50.0 }
7
+ Transform { translation: Vec3 { z: -16.0 } }
8
+ ]
@@ -0,0 +1,103 @@
1
+ {
2
+ "accessors": [
3
+ {
4
+ "bufferView": 0,
5
+ "componentType": 5126,
6
+ "count": 24,
7
+ "max": [
8
+ 0.5,
9
+ 0.5,
10
+ 0.5
11
+ ],
12
+ "min": [
13
+ -0.5,
14
+ -0.5,
15
+ -0.5
16
+ ],
17
+ "type": "VEC3"
18
+ },
19
+ {
20
+ "bufferView": 1,
21
+ "componentType": 5126,
22
+ "count": 24,
23
+ "type": "VEC3"
24
+ },
25
+ {
26
+ "bufferView": 2,
27
+ "componentType": 5123,
28
+ "count": 36,
29
+ "type": "SCALAR"
30
+ }
31
+ ],
32
+ "asset": {
33
+ "generator": "bevy-ts-viewport tests/assets.rs",
34
+ "version": "2.0"
35
+ },
36
+ "bufferViews": [
37
+ {
38
+ "buffer": 0,
39
+ "byteLength": 288,
40
+ "byteOffset": 0
41
+ },
42
+ {
43
+ "buffer": 0,
44
+ "byteLength": 288,
45
+ "byteOffset": 288
46
+ },
47
+ {
48
+ "buffer": 0,
49
+ "byteLength": 72,
50
+ "byteOffset": 576
51
+ }
52
+ ],
53
+ "buffers": [
54
+ {
55
+ "byteLength": 648,
56
+ "uri": "data:application/octet-stream;base64,AAAAPwAAAL8AAAC/AAAAPwAAAD8AAAC/AAAAPwAAAD8AAAA/AAAAPwAAAL8AAAA/AAAAvwAAAL8AAAA/AAAAvwAAAD8AAAA/AAAAvwAAAD8AAAC/AAAAvwAAAL8AAAC/AAAAvwAAAD8AAAC/AAAAvwAAAD8AAAA/AAAAPwAAAD8AAAA/AAAAPwAAAD8AAAC/AAAAvwAAAL8AAAA/AAAAvwAAAL8AAAC/AAAAPwAAAL8AAAC/AAAAPwAAAL8AAAA/AAAAvwAAAL8AAAA/AAAAPwAAAL8AAAA/AAAAPwAAAD8AAAA/AAAAvwAAAD8AAAA/AAAAPwAAAL8AAAC/AAAAvwAAAL8AAAC/AAAAvwAAAD8AAAC/AAAAPwAAAD8AAAC/AACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAABAAIAAAACAAMABAAFAAYABAAGAAcACAAJAAoACAAKAAsADAANAA4ADAAOAA8AEAARABIAEAASABMAFAAVABYAFAAWABcA"
57
+ }
58
+ ],
59
+ "materials": [
60
+ {
61
+ "name": "wood",
62
+ "pbrMetallicRoughness": {
63
+ "baseColorFactor": [
64
+ 0.55,
65
+ 0.36,
66
+ 0.18,
67
+ 1.0
68
+ ],
69
+ "metallicFactor": 0.0,
70
+ "roughnessFactor": 0.8
71
+ }
72
+ }
73
+ ],
74
+ "meshes": [
75
+ {
76
+ "name": "crate",
77
+ "primitives": [
78
+ {
79
+ "attributes": {
80
+ "NORMAL": 1,
81
+ "POSITION": 0
82
+ },
83
+ "indices": 2,
84
+ "material": 0
85
+ }
86
+ ]
87
+ }
88
+ ],
89
+ "nodes": [
90
+ {
91
+ "mesh": 0,
92
+ "name": "crate"
93
+ }
94
+ ],
95
+ "scene": 0,
96
+ "scenes": [
97
+ {
98
+ "nodes": [
99
+ 0
100
+ ]
101
+ }
102
+ ]
103
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "id": "arena",
3
+ "title": "Arena",
4
+ "description": "A player and a HUD in TSX, enemies placed by a level file, and systems that bob, hurt and cull them.",
5
+ "entry": "src/game.tsx"
6
+ }
@@ -0,0 +1,28 @@
1
+ // What an enemy is: a scene component. Levels place it (assets/levels/arena.bsn writes
2
+ // `@enemy::Enemy { @hp: 250.0 }`), and TSX can mount it too (`<Enemy at={[0, 0, -6]} />`).
3
+ import { Component, SceneComponent, type f32, type Vec3Like } from "bevy-ts";
4
+ import { Transform } from "bevy-ts/bevy";
5
+
6
+ export class Health extends Component {
7
+ hp: f32 = 100;
8
+ max: f32 = 100;
9
+ }
10
+ /** Marks enemies, for the systems that move and cull them. */
11
+ export class Foe extends Component {}
12
+
13
+ export class Enemy extends SceneComponent {
14
+ static scene({ hp = 100, at = [0, 0, 0] }: { hp?: f32; at?: Vec3Like }) {
15
+ return (
16
+ <entity name="enemy">
17
+ <Transform translation={at} />
18
+ <Health initialHp={hp} max={hp} />
19
+ <Foe />
20
+ <mesh shape="cuboid" size={[1, 1, 1]} color="#cc3333" />
21
+ <entity key="eye">
22
+ <Transform translation={[0, 0.6, 0.5]} />
23
+ <mesh shape="sphere" radius={0.15} color="#ffffff" />
24
+ </entity>
25
+ </entity>
26
+ );
27
+ }
28
+ }
@@ -0,0 +1,52 @@
1
+ // The arena: a player and a HUD in TSX, the enemies placed by assets/levels/arena.bsn, a camera, and
2
+ // three systems that play. `bevy-ts dev src/game.tsx`.
3
+ import { app, type Commands, type Entity, type Local, type Mut, type Query, ref, type Res, Startup, Time, Transform, type u32, Update, type With, type Without } from "bevy-ts";
4
+ import { Foe, Health } from "./enemy.tsx";
5
+ import { Hud } from "./hud.tsx";
6
+ import { Player, PlayerUnit } from "./player.tsx";
7
+
8
+ /** The game's camera and light. The camera sits where the editor's first view does, so edit mode
9
+ * opens on the same view. */
10
+ export function stage(commands: Commands) {
11
+ commands.spawnNative("camera3d", { position: [-6, 5, 10], lookAt: [0, 0, 0] });
12
+ commands.spawnNative("directionalLight", { position: [3, 6, 2], lookAt: [0, 0, 0] });
13
+ }
14
+
15
+ /** Every 30 frames, each enemy within 10 units hurts the player by 5. */
16
+ export function damage(
17
+ clock: Local<{ frames: u32 }>,
18
+ enemies: Query<[Transform], With<Foe>>,
19
+ players: Query<[Transform, Mut<Health>], With<Player>>,
20
+ ) {
21
+ clock.frames += 1;
22
+ if (clock.frames % 30 !== 0) return;
23
+ players.each((p, h) => {
24
+ enemies.each((e) => {
25
+ if (e.translation.distance(p.translation) < 10) h.hp = Math.max(0, h.hp - 5);
26
+ });
27
+ });
28
+ }
29
+
30
+ /** Enemies bob up and down. */
31
+ export function bob(time: Res<Time>, enemies: Query<[Mut<Transform>], [With<Foe>, Without<Player>]>) {
32
+ enemies.each((t) => {
33
+ t.translation.y = Math.sin(time.elapsed_secs * 2) * 0.5;
34
+ });
35
+ }
36
+
37
+ /** Enemies at no hp are despawned: a reload does not bring them back. */
38
+ export function cull(commands: Commands, enemies: Query<[Entity, Health], With<Foe>>) {
39
+ enemies.each((e, h) => {
40
+ if (h.hp <= 0) commands.entity(e).despawn();
41
+ });
42
+ }
43
+
44
+ const player = ref();
45
+ app.scene("arena", () => (
46
+ <>
47
+ <PlayerUnit handle={player} />
48
+ <Hud player={player} />
49
+ </>
50
+ ));
51
+ app.level("levels/arena.bsn");
52
+ app.addSystems(Startup, stage).addSystems(Update, damage, bob, cull);
@@ -0,0 +1,12 @@
1
+ // The HUD: no hooks. The text binds the player's Health; the button mounts an enemy.
2
+ import { type NodeRef, px } from "bevy-ts";
3
+ import { Enemy, Health } from "./enemy.tsx";
4
+
5
+ export const Hud = ({ player }: { player: NodeRef }) => (
6
+ <node style={{ position: "absolute", top: px(12), left: px(12), padding: px(8), flexDirection: "column", rowGap: px(6) }} background="#00000088">
7
+ <text key="hp" size={20}>{() => `HP ${Math.ceil(player.get(Health).hp)} / ${player.get(Health).max}`}</text>
8
+ <button key="spawn" style={{ padding: px(4) }} background="#3366cc" onPointerClick={({ commands, frame }) => commands.mount(<Enemy key={`e${frame}`} at={[0, 0, -6]} />)}>
9
+ <text>Spawn</text>
10
+ </button>
11
+ </node>
12
+ );
@@ -0,0 +1,14 @@
1
+ import { Component, type NodeRef } from "bevy-ts";
2
+ import { Transform } from "bevy-ts/bevy";
3
+ import { Health } from "./enemy.tsx";
4
+
5
+ export class Player extends Component {}
6
+
7
+ export const PlayerUnit = ({ handle }: { handle: NodeRef }) => (
8
+ <entity name="player" ref={handle}>
9
+ <Transform translation={[0, 0, 0]} />
10
+ <Health initialHp={100} max={100} />
11
+ <Player />
12
+ <mesh shape="sphere" radius={0.5} color="#3366cc" />
13
+ </entity>
14
+ );
@@ -0,0 +1,6 @@
1
+ {
2
+ "id": "cube",
3
+ "title": "Cube",
4
+ "description": "One cube bobbing on a sine wave: a component, a startup system and an update system.",
5
+ "entry": "src/game.ts"
6
+ }
@@ -0,0 +1,22 @@
1
+ import { app, Component, type f32, Startup, Time, Transform, Update } from "bevy-ts";
2
+ import type { Commands, Mut, Query, Res } from "bevy-ts";
3
+
4
+ export class Mover extends Component {
5
+ speed: f32 = 1;
6
+ amplitude: f32 = 2;
7
+ }
8
+
9
+ export function scene(commands: Commands) {
10
+ commands.spawnNative("camera3d", { position: [-3, 3, 6], lookAt: [0, 0, 0] });
11
+ commands.spawnNative("directionalLight", { position: [3, 6, 2], lookAt: [0, 0, 0] });
12
+ const cube = commands.spawnNative("cuboid", { size: [1, 1, 1], color: "#f80" });
13
+ commands.entity(cube).insert(Mover.of());
14
+ }
15
+
16
+ export function bob(time: Res<Time>, q: Query<[Mover, Mut<Transform>]>) {
17
+ q.each((mover, t) => {
18
+ t.translation.y = Math.sin(time.elapsed_secs * mover.speed) * mover.amplitude;
19
+ });
20
+ }
21
+
22
+ app.addSystems(Startup, scene).addSystems(Update, bob);
@@ -0,0 +1,18 @@
1
+ // The cube example with a script material (bevy-ts/material): its params in TS, its shader in
2
+ // glow.wgsl, no Rust. Run this file as the entry: `bevy-ts dev examples/cube/src/glow.tsx`.
3
+ // Save glow.wgsl and the cube redraws with no reload; change the params and it re-encodes.
4
+ import "./game.ts";
5
+ import { app } from "bevy-ts";
6
+ import { Transform } from "bevy-ts/bevy";
7
+ import { color, f32, material } from "bevy-ts/material";
8
+ import glow from "./glow.wgsl";
9
+
10
+ export const Glow = material("game::Glow", { params: { color: color, pulse: f32 }, fragment: glow });
11
+
12
+ app.scene("glow", () => (
13
+ <entity name="glow">
14
+ <Transform translation={[2.5, 0, 0]} />
15
+ <mesh shape="cuboid" size={[1, 1, 1]} />
16
+ <material kind={Glow} color="#ff8800" pulse={2} />
17
+ </entity>
18
+ ));
@@ -0,0 +1,13 @@
1
+ // game::Glow's fragment shader. bevy-ts appends `params` (a GlowParams with color and pulse)
2
+ // from the schema in glow.tsx, so this file does not declare it.
3
+ import bevy_pbr::render::forward_io::VertexOutput;
4
+ import bevy_pbr::render::mesh_view_bindings::globals;
5
+
6
+ const FLOOR: f32 = 0.25;
7
+
8
+ @fragment
9
+ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
10
+ let wave = 0.5 + 0.5 * sin(globals.time * params.pulse);
11
+ let glow = FLOOR + (1.0 - FLOOR) * wave;
12
+ return vec4<f32>(params.color.rgb * glow, params.color.a);
13
+ }
package/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ /// <reference path="./wgsl.d.ts" />
1
2
  // Types for `bevy-ts`: the ruled surface (operator, 2026-10-20; docs/research/dx.md). The runtime
2
3
  // is built into the engine binary.
3
4
  //
@@ -427,6 +428,8 @@ export interface App {
427
428
 
428
429
  /** Writes a manifest section for a Rust plugin. */
429
430
  export declare function section(name: string, value: Uint8Array | string | object): void;
431
+ /** The loading generation's `{ items }` list for a JSON section, for a built-in module's per-generation declarations. */
432
+ export declare function sectionItems(name: string): unknown[];
430
433
  /** Decodes UTF-8 bytes. */
431
434
  export declare function text(bytes: Uint8Array): string;
432
435
  /** A plugin: an exported function taking the app, as Bevy's `fn build(&self, app: &mut App)`. */
package/jsx-runtime.d.ts CHANGED
@@ -132,6 +132,17 @@ export interface MaterialProps {
132
132
  roughness?: number;
133
133
  emissive?: string;
134
134
  unlit?: boolean;
135
+ kind?: never;
136
+ }
137
+
138
+ /**
139
+ * `<material kind={Glow} color="#f80" pulse={2} />`: a script material from `material()` in
140
+ * `bevy-ts/material`, with its params by name and its textures as asset paths. Checked against
141
+ * the kind's schema when the generation loads.
142
+ */
143
+ export interface ScriptMaterialProps {
144
+ kind: { readonly name: string };
145
+ [param: string]: unknown;
135
146
  }
136
147
 
137
148
  type DescElement = Element;
@@ -161,7 +172,7 @@ export declare namespace JSX {
161
172
  interface IntrinsicElements {
162
173
  entity: EntityProps & KeyAttribute;
163
174
  mesh: MeshProps;
164
- material: MaterialProps;
175
+ material: MaterialProps | ScriptMaterialProps;
165
176
  node: NodeProps & KeyAttribute;
166
177
  button: NodeProps & KeyAttribute;
167
178
  text: TextProps & KeyAttribute;
package/material.d.ts ADDED
@@ -0,0 +1,61 @@
1
+ // @generated from crates/bevy-ts-material/ts/material.d.ts by scripts/sync-runtime.mjs. Do not edit.
2
+ /** The value a `.wgsl` import gives: the file, by path. Its WGSL stays in the engine. */
3
+ export interface ShaderFile {
4
+ readonly kind: "shaderFile";
5
+ readonly path: string;
6
+ }
7
+
8
+ export type Base = "f32" | "i32" | "u32" | "vec2f" | "vec3f" | "vec4f" | "mat4x4f" | "color";
9
+ export type ParamType = Base | { readonly array: Base; readonly len: number };
10
+
11
+ export declare const f32: "f32";
12
+ export declare const i32: "i32";
13
+ export declare const u32: "u32";
14
+ export declare const vec2f: "vec2f";
15
+ export declare const vec3f: "vec3f";
16
+ export declare const vec4f: "vec4f";
17
+ export declare const mat4x4f: "mat4x4f";
18
+ /** A `vec4<f32>` in linear RGBA, written as `"#f80"` or sRGB `[r, g, b, a?]`. */
19
+ export declare const color: "color";
20
+ /** A 2D texture with a filtering sampler: `name` and `name_sampler` in WGSL. */
21
+ export declare const texture2d: "texture2d";
22
+
23
+ /** A fixed array, strided to 16 bytes; scalar and `vec2f` elements read as `.x`/`.xy` of a `vec4`. */
24
+ export declare function array<B extends Base, N extends number>(of: B, len: N): { readonly array: B; readonly len: N };
25
+
26
+ export type AlphaMode = "opaque" | "blend" | "premultiplied" | "add" | "multiply" | "alphaToCoverage" | { mask: number };
27
+
28
+ /** A param's value in TS: numbers, number tuples, or a color. */
29
+ export type ParamValue<T extends ParamType> = T extends "f32" | "i32" | "u32"
30
+ ? number
31
+ : T extends "vec2f"
32
+ ? [number, number]
33
+ : T extends "vec3f"
34
+ ? [number, number, number]
35
+ : T extends "vec4f"
36
+ ? [number, number, number, number]
37
+ : T extends "mat4x4f"
38
+ ? number[]
39
+ : T extends "color"
40
+ ? string | [number, number, number] | [number, number, number, number]
41
+ : T extends { array: infer E extends Base }
42
+ ? ParamValue<E>[]
43
+ : never;
44
+
45
+ /** A material kind, what `<material kind={...} />` names. */
46
+ export interface MaterialKind<P extends Record<string, ParamType> = Record<string, ParamType>, T extends Record<string, "texture2d"> = {}> {
47
+ readonly name: string;
48
+ /** Types only: the params and textures `<material>` takes for this kind. */
49
+ readonly __props?: { [K in keyof P]?: ParamValue<P[K]> } & { [K in keyof T]?: string };
50
+ }
51
+
52
+ /**
53
+ * Declares a script material: its params, laid out by WGSL's uniform rules in Rust; its textures;
54
+ * and its shaders, `.wgsl` files imported like modules (`import glow from "./glow.wgsl"`). The WGSL
55
+ * reads the params as `params.<name>` and each texture as `<name>` with `<name>_sampler`; bevy-ts
56
+ * appends those declarations, so the file does not declare them.
57
+ */
58
+ export declare function material<P extends Record<string, ParamType>, T extends Record<string, "texture2d"> = {}>(
59
+ name: string,
60
+ decl: { params: P; textures?: T; fragment: ShaderFile; vertex?: ShaderFile; alphaMode?: AlphaMode },
61
+ ): MaterialKind<P, T>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bevy-ts",
3
- "version": "0.1.0-alpha.2",
3
+ "version": "0.1.0-alpha.3",
4
4
  "description": "TypeScript for Bevy: components, systems and hot reload without recompiling Rust",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "repository": {
@@ -30,6 +30,10 @@
30
30
  "types": "./jsx-dev-runtime.d.ts",
31
31
  "default": "./runtime/jsx-dev-runtime.js"
32
32
  },
33
+ "./material": {
34
+ "types": "./material.d.ts",
35
+ "default": "./runtime/material.js"
36
+ },
33
37
  "./protocol": {
34
38
  "types": "./protocol.d.ts"
35
39
  },
@@ -51,14 +55,14 @@
51
55
  "*.js",
52
56
  "bin",
53
57
  "runtime",
54
- "plugins"
58
+ "plugins",
59
+ "examples"
55
60
  ],
56
61
  "optionalDependencies": {
57
- "@bevy-ts/darwin-arm64": "0.1.0-alpha.2",
58
- "@bevy-ts/darwin-x64": "0.1.0-alpha.2",
59
- "@bevy-ts/linux-arm64": "0.1.0-alpha.2",
60
- "@bevy-ts/linux-x64": "0.1.0-alpha.2",
61
- "@bevy-ts/win32-x64": "0.1.0-alpha.2"
62
+ "@bevy-ts/darwin-arm64": "0.1.0-alpha.3",
63
+ "@bevy-ts/darwin-x64": "0.1.0-alpha.3",
64
+ "@bevy-ts/linux-arm64": "0.1.0-alpha.3",
65
+ "@bevy-ts/linux-x64": "0.1.0-alpha.3"
62
66
  },
63
67
  "devDependencies": {
64
68
  "json-schema-to-typescript": "^15.0.4",
@@ -67,7 +71,8 @@
67
71
  },
68
72
  "scripts": {
69
73
  "check": "node scripts/sync-runtime.mjs --check && tsc -p ../../examples/cube && tsc -p test",
70
- "test": "vitest run --config test/vitest/vitest.config.mjs"
74
+ "prepack": "node scripts/sync-examples.mjs",
75
+ "test": "node scripts/sync-examples.mjs && vitest run --config test/vitest/vitest.config.mjs"
71
76
  },
72
77
  "publishConfig": {
73
78
  "access": "public",
@@ -5,6 +5,11 @@ export default function bevyTs() {
5
5
  return {
6
6
  name: "bevy-ts",
7
7
  setup(build) {
8
+ // A `.wgsl` import is a shader-file value, as in the engine; the WGSL is not loaded.
9
+ build.onLoad({ filter: /\.wgsl$/ }, (args) => ({
10
+ contents: `export default Object.freeze({ kind: "shaderFile", path: ${JSON.stringify(args.path)} });\n`,
11
+ loader: "js",
12
+ }));
8
13
  build.onLoad({ filter: /\.(c|m)?tsx?$/ }, (args) => {
9
14
  if (args.path.includes("/node_modules/")) return undefined;
10
15
  return { contents: transform(args.path), loader: "js" };
package/plugins/vite.js CHANGED
@@ -6,6 +6,12 @@ export default function bevyTs() {
6
6
  return {
7
7
  name: "bevy-ts",
8
8
  enforce: "pre",
9
+ // A `.wgsl` import is a shader-file value, as in the engine; the WGSL is not loaded.
10
+ load(id) {
11
+ // `./x.wgsl?raw` and `?url` keep Vite's own meaning.
12
+ if (!id.endsWith(".wgsl")) return null;
13
+ return `export default Object.freeze({ kind: "shaderFile", path: ${JSON.stringify(id)} });\n`;
14
+ },
9
15
  transform(code, id) {
10
16
  const file = id.split("?")[0];
11
17
  if (!TS_FILE.test(file) || file.includes("/node_modules/")) return null;
package/protocol.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // @generated by packages/bevy-ts/scripts/gen-protocol.mjs from protocol/bevy-ts.openrpc.json. Do not edit.
2
2
 
3
3
  /** The protocol version these types describe. */
4
- export const PROTOCOL_VERSION: "1.10.0";
4
+ export const PROTOCOL_VERSION: "1.16.0";
5
5
 
6
6
  /**
7
7
  * An entity id: Entity::to_bits.
@@ -203,7 +203,10 @@ export type LevelOp =
203
203
  * A name path from the file's top; `#n` is the n-th unnamed sibling.
204
204
  */
205
205
  parent: string[];
206
- name: string;
206
+ /**
207
+ * Optional (1.12): without it the writer picks the first free sibling name.
208
+ */
209
+ name?: string;
207
210
  /**
208
211
  * add_instance: the scene component.
209
212
  */
@@ -224,6 +227,10 @@ export type LevelOp =
224
227
  [k: string]: LevelValue;
225
228
  };
226
229
  }[];
230
+ /**
231
+ * add_entity (1.12): a .glb or .gltf path, written as WorldAssetRoot("path#Scene0"), or a .bsn to include.
232
+ */
233
+ asset?: string;
227
234
  }
228
235
  | {
229
236
  op: "delete";
@@ -250,6 +257,10 @@ export type LevelOp =
250
257
  * A name path from the file's top; `#n` is the n-th unnamed sibling.
251
258
  */
252
259
  parent: string[];
260
+ /**
261
+ * (1.12) The place among the parent's written children; last when absent.
262
+ */
263
+ index?: number;
253
264
  }
254
265
  | {
255
266
  op: "restore";
@@ -259,6 +270,32 @@ export type LevelOp =
259
270
  parent: string[];
260
271
  index: number;
261
272
  text: string;
273
+ }
274
+ | {
275
+ op: "add_component";
276
+ /**
277
+ * A name path from the file's top; `#n` is the n-th unnamed sibling.
278
+ */
279
+ path: string[];
280
+ /**
281
+ * As LevelOverride.component names it, or any path a .bsn patch resolves.
282
+ */
283
+ component: string;
284
+ fields?: {
285
+ [k: string]: LevelValue;
286
+ };
287
+ /**
288
+ * The patch as written; the inverse of a remove_component carries it.
289
+ */
290
+ text?: string;
291
+ }
292
+ | {
293
+ op: "remove_component";
294
+ /**
295
+ * A name path from the file's top; `#n` is the n-th unnamed sibling.
296
+ */
297
+ path: string[];
298
+ component: string;
262
299
  };
263
300
 
264
301
  /**
@@ -503,7 +540,19 @@ export interface TsSchemaExtension {
503
540
  };
504
541
  }
505
542
  /**
506
- * A component's schema in the shape stock registry.schema uses, plus x-bevy-ts for TS components.
543
+ * How an editor treats a component: whose it is, whether the engine maintains it, whether Add component may offer it, and its icon category.
544
+ *
545
+ * This interface was referenced by `ProtocolTypes`'s JSON-Schema
546
+ * via the `definition` "EditorInfo".
547
+ */
548
+ export interface EditorInfo {
549
+ origin: "engine" | "game" | "plugin";
550
+ internal: boolean;
551
+ addable: boolean;
552
+ category: "transform" | "render" | "light" | "camera" | "physics" | "audio" | "ui" | "animation" | "game" | "other";
553
+ }
554
+ /**
555
+ * A component's schema in the shape stock registry.schema uses, plus x-bevy-ts for TS components and x-bevy-ts-editor for every component.
507
556
  *
508
557
  * This interface was referenced by `ProtocolTypes`'s JSON-Schema
509
558
  * via the `definition` "ComponentSchema".
@@ -519,6 +568,7 @@ export interface ComponentSchema {
519
568
  required?: string[];
520
569
  reflectTypes?: string[];
521
570
  "x-bevy-ts"?: TsSchemaExtension;
571
+ "x-bevy-ts-editor"?: EditorInfo;
522
572
  [k: string]: unknown;
523
573
  }
524
574
  /**
@@ -758,6 +808,28 @@ export interface TsxTreeNode {
758
808
  * Despawned by something other than tsx; not respawned until a restart or a key change.
759
809
  */
760
810
  tombstoned: boolean;
811
+ /**
812
+ * (1.12) Set when a level placed the entity; absent for a TSX-only node.
813
+ */
814
+ level?: {
815
+ /**
816
+ * The outermost level file (a sidecar's path for a scene overlay).
817
+ */
818
+ file: string;
819
+ namePath: string[];
820
+ /**
821
+ * Written in that file, rather than coming from a prefab, an included level or the scene's TSX.
822
+ */
823
+ written: boolean;
824
+ };
825
+ /**
826
+ * (1.15) The root the node is in.
827
+ */
828
+ root?: string;
829
+ /**
830
+ * (1.15) The node id of the nearest ancestor row, or null at the root's top. Slot steps (`<Show>`, `<For>`) are in the key path but are not rows.
831
+ */
832
+ parent?: TsxNodeId | null;
761
833
  }
762
834
  /**
763
835
  * This interface was referenced by `ProtocolTypes`'s JSON-Schema
@@ -767,6 +839,10 @@ export interface TsxRoot {
767
839
  name: string;
768
840
  location?: SourceLocation | null;
769
841
  nodes: TsxTreeNode[];
842
+ /**
843
+ * (1.12) An app.scene root's sidecar level path, whether or not the file exists.
844
+ */
845
+ sidecar?: string;
770
846
  }
771
847
  /**
772
848
  * This interface was referenced by `ProtocolTypes`'s JSON-Schema
@@ -867,7 +943,7 @@ export interface TsxMoved {
867
943
  source: SourceLocation | null;
868
944
  }
869
945
  /**
870
- * The first message carries `snapshot`; later ones carry one reconcile's changes: nodes added, removed, tombstoned, whose source moved, and whose values tsx wrote. A gap in `seq` means resubscribe.
946
+ * The first message carries `snapshot`; later ones carry one reconcile's changes: nodes added, removed, tombstoned, whose source moved, whose values tsx wrote, and sibling lists whose order changed. A client that applies `added` and `removed`, then replaces each `ordered` parent's children with `children`, holds the tree `tsx_tree` returns. A gap in `seq` means resubscribe.
871
947
  *
872
948
  * This interface was referenced by `ProtocolTypes`'s JSON-Schema
873
949
  * via the `definition` "TsxTreeWatchMessage".
@@ -876,10 +952,31 @@ export interface TsxTreeWatchMessage {
876
952
  seq: number;
877
953
  snapshot?: TsxRoot[];
878
954
  added?: TsxTreeNode[];
955
+ /**
956
+ * Every node gone, its whole subtree included (1.15).
957
+ */
879
958
  removed?: TsxNodeId[];
880
959
  tombstoned?: TsxNodeId[];
881
960
  moved?: TsxMoved[];
882
961
  written?: TsxNodeId[];
962
+ /**
963
+ * (1.15) Each sibling list that gained a child, lost one or changed order since the last message, with its full order as `tsx_tree` gives it. A list whose parent was removed is not sent.
964
+ */
965
+ ordered?: TsxOrdered[];
966
+ }
967
+ /**
968
+ * One sibling list's full child order.
969
+ *
970
+ * This interface was referenced by `ProtocolTypes`'s JSON-Schema
971
+ * via the `definition` "TsxOrdered".
972
+ */
973
+ export interface TsxOrdered {
974
+ root: string;
975
+ /**
976
+ * The list's parent row, or null for the root's top.
977
+ */
978
+ parent: TsxNodeId | null;
979
+ children: TsxNodeId[];
883
980
  }
884
981
  /**
885
982
  * Modifier keys held; absent means up.
@@ -1356,6 +1453,11 @@ export interface PickHit {
1356
1453
  entity: Entity;
1357
1454
  depth: number;
1358
1455
  selects: Entity;
1456
+ /**
1457
+ * @minItems 3
1458
+ * @maxItems 3
1459
+ */
1460
+ point?: [number, number, number];
1359
1461
  }
1360
1462
  /**
1361
1463
  * This interface was referenced by `ProtocolTypes`'s JSON-Schema
@@ -1363,6 +1465,11 @@ export interface PickHit {
1363
1465
  */
1364
1466
  export interface ViewportPickResult {
1365
1467
  hits: PickHit[];
1468
+ /**
1469
+ * @minItems 3
1470
+ * @maxItems 3
1471
+ */
1472
+ ground?: [number, number, number] | null;
1366
1473
  }
1367
1474
  /**
1368
1475
  * Entities, or a source location resolved as bevy-ts/tsx_find resolves it.
@@ -1571,6 +1678,7 @@ export interface AssetInfo {
1571
1678
  loadState: AssetLoadState;
1572
1679
  error?: string | null;
1573
1680
  labels?: string[];
1681
+ thumbnail?: boolean;
1574
1682
  }
1575
1683
  /**
1576
1684
  * This interface was referenced by `ProtocolTypes`'s JSON-Schema
@@ -1807,6 +1915,10 @@ export interface LevelNodeResult {
1807
1915
  * A scene component instance's @props, beside its fields; empty for anything else. (1.7)
1808
1916
  */
1809
1917
  props?: LevelProp[];
1918
+ /**
1919
+ * (1.12) Components the file adds that the entity's prefab does not give, named as LevelOverride.component names them.
1920
+ */
1921
+ patches?: string[];
1810
1922
  }
1811
1923
  /**
1812
1924
  * One @prop of a scene component instance: its type, the value it takes, and whether the level sets it.
@@ -1848,7 +1960,10 @@ export interface SourceLocation1 {
1848
1960
  export interface LevelWriteParams {
1849
1961
  id?: number;
1850
1962
  file: string;
1851
- base: LevelHash;
1963
+ /**
1964
+ * null (1.12): the file does not exist yet.
1965
+ */
1966
+ base: LevelHash | null;
1852
1967
  ops: LevelOp[];
1853
1968
  }
1854
1969
  /**
@@ -1877,6 +1992,13 @@ export interface LevelWriteResult {
1877
1992
  * The batch that undoes this one.
1878
1993
  */
1879
1994
  inverse: LevelOp[];
1995
+ /**
1996
+ * (1.12) One per entity the batch added, in order; entity is null when the level is not open.
1997
+ */
1998
+ created?: {
1999
+ namePath: string[];
2000
+ entity: Entity | null;
2001
+ }[];
1880
2002
  }
1881
2003
  /**
1882
2004
  * This interface was referenced by `ProtocolTypes`'s JSON-Schema
@@ -2031,6 +2153,43 @@ export interface AssetPreviewCloseParams {
2031
2153
  export interface AssetPreviewCloseResult {
2032
2154
  closed: boolean;
2033
2155
  }
2156
+ /**
2157
+ * This interface was referenced by `ProtocolTypes`'s JSON-Schema
2158
+ * via the `definition` "LevelCatalogResult".
2159
+ */
2160
+ export interface LevelCatalogResult {
2161
+ scenes: LevelCatalogScene[];
2162
+ }
2163
+ /**
2164
+ * This interface was referenced by `ProtocolTypes`'s JSON-Schema
2165
+ * via the `definition` "LevelCatalogScene".
2166
+ */
2167
+ export interface LevelCatalogScene {
2168
+ name: string;
2169
+ file?: string | null;
2170
+ line?: number | null;
2171
+ props: LevelCatalogProp[];
2172
+ }
2173
+ /**
2174
+ * This interface was referenced by `ProtocolTypes`'s JSON-Schema
2175
+ * via the `definition` "LevelCatalogProp".
2176
+ */
2177
+ export interface LevelCatalogProp {
2178
+ name: string;
2179
+ /**
2180
+ * The prop's type as the manifest writes it ({ "kind": "f32" }).
2181
+ */
2182
+ type: {
2183
+ [k: string]: unknown;
2184
+ };
2185
+ /**
2186
+ * The literal default, or null.
2187
+ */
2188
+ default: {
2189
+ [k: string]: unknown;
2190
+ };
2191
+ required: boolean;
2192
+ }
2034
2193
 
2035
2194
  /** Every method: params, result, owning crate, and whether it streams. */
2036
2195
  export interface Methods {
@@ -2073,6 +2232,7 @@ export interface Methods {
2073
2232
  "bevy-ts/asset_preview_close": { params: AssetPreviewCloseParams; result: AssetPreviewCloseResult; crate: "bevy-ts-viewport"; watch: false };
2074
2233
  "bevy-ts/level_list": { params: undefined; result: LevelListResult; crate: "bevy-ts-levels"; watch: false };
2075
2234
  "bevy-ts/level_node": { params: LevelNodeParams; result: LevelNodeResult; crate: "bevy-ts-levels"; watch: false };
2235
+ "bevy-ts/level_catalog": { params: undefined; result: LevelCatalogResult; crate: "bevy-ts-levels"; watch: false };
2076
2236
  "bevy-ts/level_write": { params: LevelWriteParams; result: LevelWriteResult; crate: "bevy-ts-levels"; watch: false };
2077
2237
  "bevy-ts/level_apply": { params: LevelApplyParams; result: LevelApplyResult; crate: "bevy-ts-levels"; watch: false };
2078
2238
  "bevy-ts/level+watch": { params: undefined; result: LevelWatchMessage; crate: "bevy-ts-levels"; watch: true };
package/runtime/core.js CHANGED
@@ -849,6 +849,21 @@ export function section(name, value) {
849
849
  g.sections.set(name, { bytes, location: at });
850
850
  }
851
851
 
852
+ /**
853
+ * The loading generation's list for a JSON section, `{ items: [...] }`, created on first use. For
854
+ * a built-in module, which is evaluated once, whose declarations come from each generation's code
855
+ * (`material()` in `bevy-ts/material`). The section's location is the first caller's.
856
+ */
857
+ export function sectionItems(name) {
858
+ const g = loadingOrThrow(`sectionItems(${name})`);
859
+ let s = g.sections.get(name);
860
+ if (!s || !s.items) {
861
+ s = { items: [], location: callerLocation() };
862
+ g.sections.set(name, s);
863
+ }
864
+ return s.items;
865
+ }
866
+
852
867
  /** Decodes UTF-8 bytes, for entries that take text or JSON payloads. */
853
868
  export function text(bytes) {
854
869
  return decoder.decode(bytes);
@@ -1093,7 +1108,7 @@ function end() {
1093
1108
  })),
1094
1109
  entries: g.entries,
1095
1110
  };
1096
- const sections = [...g.sections].map(([name, s]) => [name, s.bytes, s.location || null]);
1111
+ const sections = [...g.sections].map(([name, s]) => [name, s.items ? encoder.encode(JSON.stringify({ items: s.items })) : s.bytes, s.location || null]);
1097
1112
  return [JSON.stringify(manifest), sections];
1098
1113
  }
1099
1114
 
@@ -0,0 +1,101 @@
1
+ // @generated from crates/bevy-ts-material/ts/material.js by scripts/sync-runtime.mjs. Do not edit.
2
+ // `bevy-ts/material`: script materials. A material is a name, a params schema in WGSL types and
3
+ // `.wgsl` files imported like modules; Rust lays out the params, generates their declarations and
4
+ // draws it with one erased material. Served by the engine; evaluated once, so each declaration
5
+ // goes into the loading generation's `materials` section.
6
+ import { sectionItems } from "bevy-ts";
7
+
8
+ export const f32 = "f32";
9
+ export const i32 = "i32";
10
+ export const u32 = "u32";
11
+ export const vec2f = "vec2f";
12
+ export const vec3f = "vec3f";
13
+ export const vec4f = "vec4f";
14
+ export const mat4x4f = "mat4x4f";
15
+ /** A `vec4<f32>` in linear RGBA, written as `"#f80"` or sRGB `[r, g, b, a?]`. */
16
+ export const color = "color";
17
+ /** A 2D texture with a filtering sampler, `<name>` and `<name>_sampler` in WGSL. */
18
+ export const texture2d = "texture2d";
19
+
20
+ const BASES = new Set([f32, i32, u32, vec2f, vec3f, vec4f, mat4x4f, color]);
21
+ const ALPHA = new Set(["opaque", "blend", "premultiplied", "add", "multiply", "alphaToCoverage"]);
22
+
23
+ /** A fixed array, strided to 16 bytes; scalar and `vec2f` elements read as `.x`/`.xy` of a `vec4`. */
24
+ export function array(of, len) {
25
+ if (!BASES.has(of)) throw new Error(`array(${JSON.stringify(of)}, ${len}): elements are f32, i32, u32, vec2f, vec3f, vec4f, mat4x4f or color`);
26
+ if (!Number.isInteger(len) || len < 1) throw new Error(`array(${of}, ${len}): the length is a positive integer`);
27
+ return Object.freeze({ array: of, len });
28
+ }
29
+
30
+ const FRAME = /\(?((?:file|bevy-ts):[^\s()]+?):(\d+):(\d+)\)?\s*$/;
31
+
32
+ function callerLocation() {
33
+ for (const line of String(new Error().stack).split("\n").slice(1)) {
34
+ if (/bevy-ts:/.test(line)) continue;
35
+ const m = FRAME.exec(line);
36
+ if (m) return { file: m[1].replace(/\?gen=\d+$/, ""), line: +m[2], column: +m[3] };
37
+ }
38
+ return undefined;
39
+ }
40
+
41
+ function located(message, location) {
42
+ const e = new Error(message);
43
+ e.bevyTsLocation = location;
44
+ return e;
45
+ }
46
+
47
+ function isShaderFile(v) {
48
+ return v !== null && typeof v === "object" && v.kind === "shaderFile" && typeof v.path === "string";
49
+ }
50
+
51
+ /** A material kind: what `<material kind={...} />` names. Only `name` crosses into a scene. */
52
+ class MaterialKind {
53
+ constructor(name) {
54
+ this.name = name;
55
+ Object.freeze(this);
56
+ }
57
+ }
58
+
59
+ /**
60
+ * `material("game::Glow", { params: { color: color, pulse: f32 }, fragment: glow })`, where
61
+ * `glow` is `import glow from "./glow.wgsl"`.
62
+ */
63
+ export function material(name, decl) {
64
+ const at = callerLocation();
65
+ const fail = (m) => {
66
+ throw located(`material ${name}: ${m}`, at);
67
+ };
68
+ if (typeof name !== "string" || !name) throw located("material(name, ...): the name is a string (\"game::Glow\")", at);
69
+ if (!decl || typeof decl !== "object") fail("takes { params, fragment }");
70
+ for (const k of Object.keys(decl)) {
71
+ if (!["params", "textures", "fragment", "vertex", "alphaMode"].includes(k)) fail(`unknown option ${k}`);
72
+ }
73
+ const shader = (k) => {
74
+ const v = decl[k];
75
+ if (v === undefined) return null;
76
+ if (typeof v === "string") fail(`${k} is a WGSL file, import it: import shader from "./${k}.wgsl"; then ${k}: shader`);
77
+ if (!isShaderFile(v)) fail(`${k} is a shader file from a .wgsl import`);
78
+ return v.path;
79
+ };
80
+ const fragment = shader("fragment");
81
+ if (fragment === null) fail("needs a fragment shader");
82
+ const params = [];
83
+ for (const [p, type] of Object.entries(decl.params || {})) {
84
+ const ok = BASES.has(type) || (type && typeof type === "object" && BASES.has(type.array) && Number.isInteger(type.len));
85
+ if (!ok) fail(`param ${p} has type ${JSON.stringify(type)}; use f32, i32, u32, vec2f, vec3f, vec4f, mat4x4f, color or array(...)`);
86
+ params.push({ name: p, type });
87
+ }
88
+ const textures = [];
89
+ for (const [t, type] of Object.entries(decl.textures || {})) {
90
+ if (type !== texture2d) fail(`texture ${t} has type ${JSON.stringify(type)}; use texture2d`);
91
+ if (params.some((p) => p.name === t)) fail(`${t} is both a param and a texture`);
92
+ textures.push(t);
93
+ }
94
+ let alphaMode = decl.alphaMode === undefined ? "opaque" : decl.alphaMode;
95
+ if (typeof alphaMode === "object" && alphaMode !== null && typeof alphaMode.mask === "number") alphaMode = { mask: alphaMode.mask };
96
+ else if (!ALPHA.has(alphaMode)) fail(`alphaMode ${JSON.stringify(alphaMode)}; use ${[...ALPHA].join(", ")} or { mask: 0.5 }`);
97
+ const items = sectionItems("materials");
98
+ if (items.some((m) => m.name === name)) fail("is declared twice");
99
+ items.push({ name, params, textures, fragment, vertex: shader("vertex"), alphaMode, location: at || null });
100
+ return new MaterialKind(name);
101
+ }
package/wgsl.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ // A `.wgsl` import is a shader file, for `material()` in `bevy-ts/material`. The engine resolves
2
+ // it relative to the importing file and watches it; its WGSL never runs as JS.
3
+ declare module "*.wgsl" {
4
+ const shader: { readonly kind: "shaderFile"; readonly path: string };
5
+ export default shader;
6
+ }