@ludra/core 0.0.0-stage → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +17 -2
- package/dist/ascii.d.ts +11 -0
- package/dist/ascii.js +62 -0
- package/dist/assertions.d.ts +57 -0
- package/dist/assertions.js +147 -0
- package/dist/audio.d.ts +16 -0
- package/dist/audio.js +106 -0
- package/dist/browser.d.ts +12 -0
- package/dist/browser.js +283 -0
- package/dist/components.d.ts +116 -0
- package/dist/components.js +159 -0
- package/dist/effects.d.ts +19 -0
- package/dist/effects.js +31 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/input.d.ts +22 -0
- package/dist/input.js +67 -0
- package/dist/particles.d.ts +17 -0
- package/dist/particles.js +65 -0
- package/dist/simulate.d.ts +9 -0
- package/dist/simulate.js +16 -0
- package/dist/time.d.ts +11 -0
- package/dist/time.js +27 -0
- package/dist/types.d.ts +40 -0
- package/dist/types.js +2 -0
- package/dist/validate.d.ts +12 -0
- package/dist/validate.js +267 -0
- package/dist/world.d.ts +121 -0
- package/dist/world.js +317 -0
- package/package.json +48 -4
- package/src/ascii.ts +72 -0
- package/src/assertions.ts +200 -0
- package/src/audio.ts +125 -0
- package/src/browser.ts +304 -0
- package/src/components.ts +265 -0
- package/src/effects.ts +47 -0
- package/src/index.ts +10 -0
- package/src/input.ts +75 -0
- package/src/particles.ts +85 -0
- package/src/simulate.ts +20 -0
- package/src/time.ts +29 -0
- package/src/types.ts +47 -0
- package/src/validate.ts +353 -0
- package/src/world.ts +397 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ludra contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @ludra/core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The runtime of [Ludra](https://github.com/ersingvn/ludra), the game engine built for AI agents. Most people want the [`@ludra/cli`](https://www.npmjs.com/package/@ludra/cli) command instead; use this package to embed the engine.
|
|
4
|
+
|
|
5
|
+
- **Scene format and validation:** `validateScene(json)` returns diagnostics with a JSON path and a concrete fix.
|
|
6
|
+
- **Deterministic simulation:** `new World(scene)` advances only through `world.step(heldActions)` at a fixed 60 Hz.
|
|
7
|
+
- **Headless testing:** `InputScript.parse("right:1s, jump:0.4s")`, `simulate(...)`, `runTest(scene, { inputs, assert: ["won == true"] })` and `renderAscii(world)`.
|
|
8
|
+
- **Browser runtime** (`@ludra/core/browser`): `startGame(canvas, scene, { assetBase })` adds sprites, animations, particles, sound and keyboard input. It never changes what the simulation does.
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { InputScript, simulate, World } from "@ludra/core";
|
|
12
|
+
|
|
13
|
+
const world = World.fromJSON(sceneJson); // throws with diagnostics if invalid
|
|
14
|
+
simulate(world, InputScript.parse("right:0.5s, right+jump:0.45s"), 120);
|
|
15
|
+
console.log(world.snapshot());
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Zero dependencies. Requires Node.js 22.18 or newer, or any modern browser. MIT licensed.
|
package/dist/ascii.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { World } from "./world.ts";
|
|
2
|
+
export interface AsciiOptions {
|
|
3
|
+
/** Width of the map in characters. Defaults to 80. */
|
|
4
|
+
columns?: number;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Draws the world as text so an agent can "see" a frame without a browser.
|
|
8
|
+
* Characters are about twice as tall as they are wide, so each row covers
|
|
9
|
+
* two columns' worth of pixels.
|
|
10
|
+
*/
|
|
11
|
+
export declare function renderAscii(world: World, options?: AsciiOptions): string;
|
package/dist/ascii.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
const LEGEND = "@ player # solid = moving solid o collectible ^ hazard E enemy G goal + sensor D dynamic";
|
|
2
|
+
/**
|
|
3
|
+
* Draws the world as text so an agent can "see" a frame without a browser.
|
|
4
|
+
* Characters are about twice as tall as they are wide, so each row covers
|
|
5
|
+
* two columns' worth of pixels.
|
|
6
|
+
*/
|
|
7
|
+
export function renderAscii(world, options = {}) {
|
|
8
|
+
const columns = options.columns ?? 80;
|
|
9
|
+
const cellWidth = world.width / columns;
|
|
10
|
+
const cellHeight = cellWidth * 2;
|
|
11
|
+
const rows = Math.max(1, Math.ceil(world.height / cellHeight));
|
|
12
|
+
const grid = Array.from({ length: rows }, () => Array(columns).fill(" "));
|
|
13
|
+
const outside = [];
|
|
14
|
+
const paint = (e, char) => {
|
|
15
|
+
const b = e.body;
|
|
16
|
+
const left = Math.floor((e.transform.x - b.width / 2) / cellWidth);
|
|
17
|
+
const right = Math.max(left, Math.ceil((e.transform.x + b.width / 2) / cellWidth) - 1);
|
|
18
|
+
const top = Math.floor((e.transform.y - b.height / 2) / cellHeight);
|
|
19
|
+
const bottom = Math.max(top, Math.ceil((e.transform.y + b.height / 2) / cellHeight) - 1);
|
|
20
|
+
if (right < 0 || left >= columns || bottom < 0 || top >= rows) {
|
|
21
|
+
// Off-screen static bodies (boundary walls) are expected; moving ones usually mean a bug.
|
|
22
|
+
if (b.type !== "static")
|
|
23
|
+
outside.push(`${char} ${e.id} is outside the world at (${Math.round(e.transform.x)}, ${Math.round(e.transform.y)})`);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
for (let row = Math.max(0, top); row <= Math.min(rows - 1, bottom); row++) {
|
|
27
|
+
for (let col = Math.max(0, left); col <= Math.min(columns - 1, right); col++) {
|
|
28
|
+
grid[row][col] = char;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
// Later passes draw on top of earlier ones.
|
|
33
|
+
const withBody = world.entities.filter((e) => e.body);
|
|
34
|
+
const passes = [
|
|
35
|
+
["#", (e) => e.body.type === "static" && !e.body.sensor],
|
|
36
|
+
["=", (e) => e.body.type === "kinematic" && !e.body.sensor],
|
|
37
|
+
["+", (e) => e.body.sensor && !e.collectible && !e.hazard && !e.goal],
|
|
38
|
+
["o", (e) => !!e.collectible],
|
|
39
|
+
["G", (e) => !!e.goal],
|
|
40
|
+
["^", (e) => !!e.hazard && e.body.type !== "kinematic"],
|
|
41
|
+
["E", (e) => !!e.hazard && e.body.type === "kinematic"],
|
|
42
|
+
["D", (e) => e.body.type === "dynamic" && !e.platformer],
|
|
43
|
+
["@", (e) => !!e.platformer],
|
|
44
|
+
];
|
|
45
|
+
for (const [char, test] of passes) {
|
|
46
|
+
for (const e of withBody.filter(test))
|
|
47
|
+
paint(e, char);
|
|
48
|
+
}
|
|
49
|
+
const border = `+${"-".repeat(columns)}+`;
|
|
50
|
+
const lines = [
|
|
51
|
+
`t=${world.time.toFixed(2)}s frame ${world.frame} score ${world.score} deaths ${world.deaths}${world.won ? " WON" : ""} (1 char = ${round(cellWidth)}x${round(cellHeight)}px)`,
|
|
52
|
+
border,
|
|
53
|
+
...grid.map((row) => `|${row.join("")}|`),
|
|
54
|
+
border,
|
|
55
|
+
LEGEND,
|
|
56
|
+
...outside,
|
|
57
|
+
];
|
|
58
|
+
return lines.join("\n");
|
|
59
|
+
}
|
|
60
|
+
function round(n) {
|
|
61
|
+
return Math.round(n * 10) / 10;
|
|
62
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { type GameEventType, type SceneDef } from "./types.ts";
|
|
2
|
+
import { type GameEvent, type WorldSnapshot } from "./world.ts";
|
|
3
|
+
declare const OPERATORS: readonly ["<=", ">=", "==", "!=", "<", ">"];
|
|
4
|
+
type Operator = (typeof OPERATORS)[number];
|
|
5
|
+
declare const ENTITY_FIELDS: readonly ["x", "y", "vx", "vy", "grounded", "exists"];
|
|
6
|
+
type EntityField = (typeof ENTITY_FIELDS)[number];
|
|
7
|
+
declare const WORLD_FIELDS: readonly ["score", "deaths", "won"];
|
|
8
|
+
type WorldField = (typeof WORLD_FIELDS)[number];
|
|
9
|
+
type Subject = {
|
|
10
|
+
kind: "world";
|
|
11
|
+
field: WorldField;
|
|
12
|
+
} | {
|
|
13
|
+
kind: "events";
|
|
14
|
+
type: GameEventType;
|
|
15
|
+
} | {
|
|
16
|
+
kind: "entity";
|
|
17
|
+
id: string;
|
|
18
|
+
field: EntityField;
|
|
19
|
+
};
|
|
20
|
+
export interface Assertion {
|
|
21
|
+
source: string;
|
|
22
|
+
subject: Subject;
|
|
23
|
+
operator: Operator;
|
|
24
|
+
expected: number | boolean;
|
|
25
|
+
/** Frame to check at, or "end" for the last frame of the run. */
|
|
26
|
+
at: number | "end";
|
|
27
|
+
}
|
|
28
|
+
export declare class AssertionSyntaxError extends Error {
|
|
29
|
+
}
|
|
30
|
+
/** Parses `<subject> <op> <value> [at <time|end>]`. */
|
|
31
|
+
export declare function parseAssertion(source: string): Assertion;
|
|
32
|
+
export interface AssertionResult {
|
|
33
|
+
source: string;
|
|
34
|
+
pass: boolean;
|
|
35
|
+
actual: number | boolean | undefined;
|
|
36
|
+
time: number;
|
|
37
|
+
message: string;
|
|
38
|
+
}
|
|
39
|
+
export declare function evaluateAssertion(assertion: Assertion, snapshot: WorldSnapshot, events: readonly GameEvent[]): AssertionResult;
|
|
40
|
+
/** One case in a `tests/*.test.json` file. */
|
|
41
|
+
export interface TestCase {
|
|
42
|
+
name: string;
|
|
43
|
+
/** Input script, see `InputScript`. */
|
|
44
|
+
inputs?: string;
|
|
45
|
+
/** Run length, e.g. "3s". Defaults to the inputs plus one second, or the latest `at`. */
|
|
46
|
+
duration?: string | number;
|
|
47
|
+
assert: string[];
|
|
48
|
+
}
|
|
49
|
+
export interface TestResult {
|
|
50
|
+
name: string;
|
|
51
|
+
pass: boolean;
|
|
52
|
+
duration: number;
|
|
53
|
+
results: AssertionResult[];
|
|
54
|
+
error?: string;
|
|
55
|
+
}
|
|
56
|
+
export declare function runTest(scene: SceneDef, test: TestCase): TestResult;
|
|
57
|
+
export {};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { InputScript } from "./input.js";
|
|
2
|
+
import { defaultDuration, simulate } from "./simulate.js";
|
|
3
|
+
import { FIXED_DT, parseDuration, secondsToFrames } from "./time.js";
|
|
4
|
+
import { EVENT_TYPES } from "./types.js";
|
|
5
|
+
import { World } from "./world.js";
|
|
6
|
+
const OPERATORS = ["<=", ">=", "==", "!=", "<", ">"];
|
|
7
|
+
const ENTITY_FIELDS = ["x", "y", "vx", "vy", "grounded", "exists"];
|
|
8
|
+
const WORLD_FIELDS = ["score", "deaths", "won"];
|
|
9
|
+
export class AssertionSyntaxError extends Error {
|
|
10
|
+
}
|
|
11
|
+
const EXAMPLES = 'Examples: "player.x > 400", "player.grounded == true at 1s", "score >= 3", "won == true", "deaths == 0", "events.jump == 1 at end", "coin1.exists == false".';
|
|
12
|
+
/** Parses `<subject> <op> <value> [at <time|end>]`. */
|
|
13
|
+
export function parseAssertion(source) {
|
|
14
|
+
const match = /^\s*([\w.-]+)\s*(<=|>=|==|!=|<|>)\s*(\S+?)(?:\s+at\s+(\S+))?\s*$/.exec(source);
|
|
15
|
+
if (!match)
|
|
16
|
+
throw new AssertionSyntaxError(`Cannot parse assertion "${source}". ${EXAMPLES}`);
|
|
17
|
+
const [, lhs = "", operator, rhs = "", at] = match;
|
|
18
|
+
let subject;
|
|
19
|
+
if (WORLD_FIELDS.includes(lhs)) {
|
|
20
|
+
subject = { kind: "world", field: lhs };
|
|
21
|
+
}
|
|
22
|
+
else if (lhs.startsWith("events.")) {
|
|
23
|
+
const type = lhs.slice("events.".length);
|
|
24
|
+
if (!EVENT_TYPES.includes(type)) {
|
|
25
|
+
throw new AssertionSyntaxError(`Unknown event "${type}" in "${source}". Events: ${EVENT_TYPES.join(", ")}.`);
|
|
26
|
+
}
|
|
27
|
+
subject = { kind: "events", type: type };
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
const dot = lhs.lastIndexOf(".");
|
|
31
|
+
const field = lhs.slice(dot + 1);
|
|
32
|
+
if (dot <= 0 || !ENTITY_FIELDS.includes(field)) {
|
|
33
|
+
throw new AssertionSyntaxError(`"${lhs}" is not something Ludra can check. Use ${WORLD_FIELDS.join(", ")}, events.<${EVENT_TYPES.join("|")}> or <entityId>.<${ENTITY_FIELDS.join("|")}>.`);
|
|
34
|
+
}
|
|
35
|
+
subject = { kind: "entity", id: lhs.slice(0, dot), field: field };
|
|
36
|
+
}
|
|
37
|
+
let expected;
|
|
38
|
+
if (rhs === "true" || rhs === "false")
|
|
39
|
+
expected = rhs === "true";
|
|
40
|
+
else if (Number.isFinite(Number(rhs)))
|
|
41
|
+
expected = Number(rhs);
|
|
42
|
+
else
|
|
43
|
+
throw new AssertionSyntaxError(`Expected a number or true/false after "${operator}" in "${source}", got "${rhs}".`);
|
|
44
|
+
let frame = "end";
|
|
45
|
+
if (at !== undefined && at !== "end") {
|
|
46
|
+
try {
|
|
47
|
+
frame = secondsToFrames(parseDuration(at));
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
throw new AssertionSyntaxError(`${err.message} (in "${source}")`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { source, subject, operator: operator, expected, at: frame };
|
|
54
|
+
}
|
|
55
|
+
export function evaluateAssertion(assertion, snapshot, events) {
|
|
56
|
+
const { subject, operator, expected, source } = assertion;
|
|
57
|
+
const time = snapshot.time;
|
|
58
|
+
let actual;
|
|
59
|
+
let label;
|
|
60
|
+
switch (subject.kind) {
|
|
61
|
+
case "world":
|
|
62
|
+
actual = snapshot[subject.field];
|
|
63
|
+
label = subject.field;
|
|
64
|
+
break;
|
|
65
|
+
case "events":
|
|
66
|
+
actual = events.filter((e) => e.type === subject.type && e.frame <= snapshot.frame).length;
|
|
67
|
+
label = `events.${subject.type}`;
|
|
68
|
+
break;
|
|
69
|
+
case "entity": {
|
|
70
|
+
const entity = snapshot.entities[subject.id];
|
|
71
|
+
label = `${subject.id}.${subject.field}`;
|
|
72
|
+
if (subject.field === "exists")
|
|
73
|
+
actual = entity !== undefined;
|
|
74
|
+
else if (entity)
|
|
75
|
+
actual = entity[subject.field];
|
|
76
|
+
else {
|
|
77
|
+
return {
|
|
78
|
+
source,
|
|
79
|
+
pass: false,
|
|
80
|
+
actual: undefined,
|
|
81
|
+
time,
|
|
82
|
+
message: `entity "${subject.id}" does not exist at ${time.toFixed(2)}s (wrong id, or it was collected/removed).`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (typeof actual !== typeof expected) {
|
|
88
|
+
return { source, pass: false, actual, time, message: `${label} is a ${typeof actual}, but it is compared with ${JSON.stringify(expected)}.` };
|
|
89
|
+
}
|
|
90
|
+
if (typeof actual === "boolean" && operator !== "==" && operator !== "!=") {
|
|
91
|
+
return { source, pass: false, actual, time, message: `${label} is true/false, so only == and != can be used.` };
|
|
92
|
+
}
|
|
93
|
+
const pass = compare(actual, operator, expected);
|
|
94
|
+
const shown = typeof actual === "number" ? round(actual) : actual;
|
|
95
|
+
return { source, pass, actual, time, message: `${label} = ${shown} at ${time.toFixed(2)}s` };
|
|
96
|
+
}
|
|
97
|
+
function compare(a, operator, b) {
|
|
98
|
+
switch (operator) {
|
|
99
|
+
case "<":
|
|
100
|
+
return a < b;
|
|
101
|
+
case "<=":
|
|
102
|
+
return a <= b;
|
|
103
|
+
case ">":
|
|
104
|
+
return a > b;
|
|
105
|
+
case ">=":
|
|
106
|
+
return a >= b;
|
|
107
|
+
case "==":
|
|
108
|
+
return a === b;
|
|
109
|
+
case "!=":
|
|
110
|
+
return a !== b;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function round(n) {
|
|
114
|
+
return Math.round(n * 100) / 100;
|
|
115
|
+
}
|
|
116
|
+
export function runTest(scene, test) {
|
|
117
|
+
let script;
|
|
118
|
+
let assertions;
|
|
119
|
+
let frames;
|
|
120
|
+
try {
|
|
121
|
+
if (!Array.isArray(test.assert) || test.assert.length === 0) {
|
|
122
|
+
throw new AssertionSyntaxError(`Test "${test.name}" has no "assert" list. ${EXAMPLES}`);
|
|
123
|
+
}
|
|
124
|
+
script = InputScript.parse(test.inputs ?? "");
|
|
125
|
+
assertions = test.assert.map(parseAssertion);
|
|
126
|
+
const latestAt = Math.max(0, ...assertions.map((a) => (a.at === "end" ? 0 : a.at)));
|
|
127
|
+
frames =
|
|
128
|
+
test.duration !== undefined
|
|
129
|
+
? secondsToFrames(typeof test.duration === "number" ? test.duration : parseDuration(test.duration))
|
|
130
|
+
: Math.max(secondsToFrames(defaultDuration(script)), latestAt);
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
return { name: test.name, pass: false, duration: 0, results: [], error: err.message };
|
|
134
|
+
}
|
|
135
|
+
const wanted = new Set(assertions.map((a) => (a.at === "end" ? frames : Math.min(a.at, frames))));
|
|
136
|
+
const snapshots = new Map();
|
|
137
|
+
const world = new World(scene);
|
|
138
|
+
simulate(world, script, frames, (w) => {
|
|
139
|
+
if (wanted.has(w.frame))
|
|
140
|
+
snapshots.set(w.frame, w.snapshot());
|
|
141
|
+
});
|
|
142
|
+
const results = assertions.map((a) => {
|
|
143
|
+
const frame = a.at === "end" ? frames : Math.min(a.at, frames);
|
|
144
|
+
return evaluateAssertion(a, snapshots.get(frame), world.events);
|
|
145
|
+
});
|
|
146
|
+
return { name: test.name, pass: results.every((r) => r.pass), duration: frames * FIXED_DT, results };
|
|
147
|
+
}
|
package/dist/audio.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plays presets and audio files. Browsers only allow audio after a user
|
|
3
|
+
* gesture, so the context is created by `unlock()` on the first key press.
|
|
4
|
+
*/
|
|
5
|
+
export declare class SoundPlayer {
|
|
6
|
+
muted: boolean;
|
|
7
|
+
private context;
|
|
8
|
+
private output;
|
|
9
|
+
private readonly buffers;
|
|
10
|
+
private readonly assetBase;
|
|
11
|
+
constructor(assetBase: string);
|
|
12
|
+
unlock(): void;
|
|
13
|
+
play(sound: string): void;
|
|
14
|
+
private playPreset;
|
|
15
|
+
private playFile;
|
|
16
|
+
}
|
package/dist/audio.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { isSoundPreset } from "./effects.js";
|
|
2
|
+
const MASTER_VOLUME = 0.5;
|
|
3
|
+
/** Each preset is a few short oscillator or noise layers, in the spirit of sfxr. */
|
|
4
|
+
const PRESETS = {
|
|
5
|
+
jump: { tones: [{ wave: "square", from: 280, to: 560, duration: 0.14, volume: 0.12 }] },
|
|
6
|
+
coin: {
|
|
7
|
+
tones: [
|
|
8
|
+
{ wave: "square", from: 988, duration: 0.07, volume: 0.1 },
|
|
9
|
+
{ wave: "square", from: 1319, start: 0.07, duration: 0.2, volume: 0.1 },
|
|
10
|
+
],
|
|
11
|
+
},
|
|
12
|
+
hit: {
|
|
13
|
+
tones: [{ wave: "sawtooth", from: 220, to: 60, duration: 0.14, volume: 0.15 }],
|
|
14
|
+
noise: { duration: 0.12, volume: 0.25, from: 3000, to: 300 },
|
|
15
|
+
},
|
|
16
|
+
explosion: { noise: { duration: 0.55, volume: 0.45, from: 1600, to: 60 } },
|
|
17
|
+
powerup: {
|
|
18
|
+
tones: [523, 659, 784, 1047].map((from, i) => ({ wave: "triangle", from, start: i * 0.08, duration: 0.14, volume: 0.16 })),
|
|
19
|
+
},
|
|
20
|
+
blip: { tones: [{ wave: "sine", from: 880, duration: 0.06, volume: 0.15 }] },
|
|
21
|
+
thud: { tones: [{ wave: "sine", from: 150, to: 50, duration: 0.1, volume: 0.3 }] },
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Plays presets and audio files. Browsers only allow audio after a user
|
|
25
|
+
* gesture, so the context is created by `unlock()` on the first key press.
|
|
26
|
+
*/
|
|
27
|
+
export class SoundPlayer {
|
|
28
|
+
muted = false;
|
|
29
|
+
context;
|
|
30
|
+
output;
|
|
31
|
+
buffers = new Map();
|
|
32
|
+
assetBase;
|
|
33
|
+
constructor(assetBase) {
|
|
34
|
+
this.assetBase = assetBase;
|
|
35
|
+
}
|
|
36
|
+
unlock() {
|
|
37
|
+
if (this.context)
|
|
38
|
+
return;
|
|
39
|
+
this.context = new AudioContext();
|
|
40
|
+
this.output = this.context.createGain();
|
|
41
|
+
this.output.gain.value = MASTER_VOLUME;
|
|
42
|
+
this.output.connect(this.context.destination);
|
|
43
|
+
}
|
|
44
|
+
play(sound) {
|
|
45
|
+
if (this.muted || !this.context || !this.output || sound === "")
|
|
46
|
+
return;
|
|
47
|
+
if (isSoundPreset(sound))
|
|
48
|
+
this.playPreset(this.context, this.output, sound);
|
|
49
|
+
else
|
|
50
|
+
void this.playFile(this.context, this.output, sound);
|
|
51
|
+
}
|
|
52
|
+
playPreset(ac, out, preset) {
|
|
53
|
+
const { tones = [], noise } = PRESETS[preset];
|
|
54
|
+
for (const tone of tones) {
|
|
55
|
+
const t = ac.currentTime + (tone.start ?? 0);
|
|
56
|
+
const osc = ac.createOscillator();
|
|
57
|
+
const gain = ac.createGain();
|
|
58
|
+
osc.type = tone.wave;
|
|
59
|
+
osc.frequency.setValueAtTime(tone.from, t);
|
|
60
|
+
osc.frequency.exponentialRampToValueAtTime(tone.to ?? tone.from, t + tone.duration);
|
|
61
|
+
gain.gain.setValueAtTime(tone.volume, t);
|
|
62
|
+
gain.gain.exponentialRampToValueAtTime(0.001, t + tone.duration);
|
|
63
|
+
osc.connect(gain).connect(out);
|
|
64
|
+
osc.start(t);
|
|
65
|
+
osc.stop(t + tone.duration + 0.02);
|
|
66
|
+
}
|
|
67
|
+
if (noise) {
|
|
68
|
+
const t = ac.currentTime;
|
|
69
|
+
const buffer = ac.createBuffer(1, Math.ceil(ac.sampleRate * noise.duration), ac.sampleRate);
|
|
70
|
+
const data = buffer.getChannelData(0);
|
|
71
|
+
for (let i = 0; i < data.length; i++)
|
|
72
|
+
data[i] = Math.random() * 2 - 1;
|
|
73
|
+
const source = ac.createBufferSource();
|
|
74
|
+
const filter = ac.createBiquadFilter();
|
|
75
|
+
const gain = ac.createGain();
|
|
76
|
+
source.buffer = buffer;
|
|
77
|
+
filter.type = "lowpass";
|
|
78
|
+
filter.frequency.setValueAtTime(noise.from, t);
|
|
79
|
+
filter.frequency.exponentialRampToValueAtTime(noise.to, t + noise.duration);
|
|
80
|
+
gain.gain.setValueAtTime(noise.volume, t);
|
|
81
|
+
gain.gain.exponentialRampToValueAtTime(0.001, t + noise.duration);
|
|
82
|
+
source.connect(filter).connect(gain).connect(out);
|
|
83
|
+
source.start(t);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async playFile(ac, out, path) {
|
|
87
|
+
let pending = this.buffers.get(path);
|
|
88
|
+
if (!pending) {
|
|
89
|
+
pending = fetch(this.assetBase + path)
|
|
90
|
+
.then((res) => (res.ok ? res.arrayBuffer() : Promise.reject(new Error(`${res.status}`))))
|
|
91
|
+
.then((data) => ac.decodeAudioData(data))
|
|
92
|
+
.catch((err) => {
|
|
93
|
+
console.warn(`[ludra] could not load sound "${path}":`, err);
|
|
94
|
+
return undefined;
|
|
95
|
+
});
|
|
96
|
+
this.buffers.set(path, pending);
|
|
97
|
+
}
|
|
98
|
+
const buffer = await pending;
|
|
99
|
+
if (!buffer)
|
|
100
|
+
return;
|
|
101
|
+
const source = ac.createBufferSource();
|
|
102
|
+
source.buffer = buffer;
|
|
103
|
+
source.connect(out);
|
|
104
|
+
source.start();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { SceneDef } from "./types.ts";
|
|
2
|
+
export interface GameOptions {
|
|
3
|
+
/** URL prefix for image and audio paths in the scene. Defaults to "". */
|
|
4
|
+
assetBase?: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Runs a scene in a canvas with keyboard controls, images, particles and
|
|
8
|
+
* sound. The simulation uses the same fixed step as headless runs, so what
|
|
9
|
+
* tests verify is what players get; everything added here is presentation.
|
|
10
|
+
* Resolves (after images load) to a function that stops the game.
|
|
11
|
+
*/
|
|
12
|
+
export declare function startGame(canvas: HTMLCanvasElement, scene: SceneDef, options?: GameOptions): Promise<() => void>;
|