mirror of
https://github.com/nvms/prsm.git
synced 2025-12-16 16:10:54 +00:00
feat(input): improve safety check of button assignment
feat(time scale): adjust time scale logic, add rawDelta fix(migrateEntityId): update $ceMap on entity id change chore(tests): update tests chore(README): update readme to reflect these new changes
This commit is contained in:
parent
061c50da90
commit
6e063101cc
@ -4,25 +4,25 @@ An ECS framework (and robust input system) for the web.
|
|||||||
|
|
||||||
<!-- vim-markdown-toc GFM -->
|
<!-- vim-markdown-toc GFM -->
|
||||||
|
|
||||||
- [Comprehensive sample](#comprehensive-sample)
|
* [Comprehensive sample](#comprehensive-sample)
|
||||||
- [Installation](#installation)
|
* [Installation](#installation)
|
||||||
- [API overview](#api-overview)
|
* [API overview](#api-overview)
|
||||||
- [createWorld](#createworld)
|
* [createWorld](#createworld)
|
||||||
- [Entities](#entities)
|
* [Entities](#entities)
|
||||||
- [Components](#components)
|
* [Components](#components)
|
||||||
- [Extending components](#extending-components)
|
* [Extending components](#extending-components)
|
||||||
- [Extras](#extras)
|
* [Extras](#extras)
|
||||||
- [Keyboard, mouse and gamepad input](#keyboard-mouse-and-gamepad-input)
|
* [Keyboard, mouse and gamepad input](#keyboard-mouse-and-gamepad-input)
|
||||||
- [Input system](#input-system)
|
* [Input system](#input-system)
|
||||||
- [ButtonState](#buttonstate)
|
* [ButtonState](#buttonstate)
|
||||||
- [Mouse](#mouse)
|
* [Mouse](#mouse)
|
||||||
- [Keyboard](#keyboard)
|
* [Keyboard](#keyboard)
|
||||||
- [Gamepad](#gamepad)
|
* [Gamepad](#gamepad)
|
||||||
- [Input usage examples](#input-usage-examples)
|
* [Input usage examples](#input-usage-examples)
|
||||||
- [Gamepad](#gamepad-1)
|
* [Gamepad](#gamepad-1)
|
||||||
- [Keyboard](#keyboard-1)
|
* [Keyboard](#keyboard-1)
|
||||||
- [Mouse](#mouse-1)
|
* [Mouse](#mouse-1)
|
||||||
- [Expiring log system](#expiring-log-system)
|
* [Expiring log system](#expiring-log-system)
|
||||||
|
|
||||||
<!-- vim-markdown-toc -->
|
<!-- vim-markdown-toc -->
|
||||||
|
|
||||||
@ -83,6 +83,7 @@ const player =
|
|||||||
.addComponent(Alive)
|
.addComponent(Alive)
|
||||||
.addTag("player");
|
.addTag("player");
|
||||||
|
|
||||||
|
// Create a bunch of monsters
|
||||||
Array
|
Array
|
||||||
.from(Array(50))
|
.from(Array(50))
|
||||||
.forEach((i) =>
|
.forEach((i) =>
|
||||||
@ -181,20 +182,31 @@ const { state, createEntity, getEntity, onEntityCreated, query, addSystem, remov
|
|||||||
- Is passed to all systems (if you use ngn's system mechanics, which is optional).
|
- Is passed to all systems (if you use ngn's system mechanics, which is optional).
|
||||||
- Contains a useful `time` object that looks like:
|
- Contains a useful `time` object that looks like:
|
||||||
|
|
||||||
* `state.time.delta` - time since last frame in ms, unaffected by scale.
|
* `state.time.delta` - time since last frame in ms, scaled by time.scale. Use this value for all physics and movement calculations to ensure they respect the time scale.
|
||||||
* `state.time.loopDelta` - time since last call to main game loop, affected by sclae. useful for calculations involving time and scale.
|
* `state.time.rawDelta` - raw, unscaled time since last frame in ms. This is the actual time between render frames and doesn't change with time scale.
|
||||||
* `state.time.scale` - time scale. (default: `1`, valid: `0.1 - 1`).
|
* `state.time.loopDelta` - time since last call to main game loop, affected by scale.
|
||||||
- Does not affect framerate at all. The scale determines how often to call the main game loop (if you use choose to use ngn's ticker). On a 60hz display, at a scale of 1, the main game loop is called every 16~ms, and every 33~ms at a scale of 0.5.
|
* `state.time.scale` - time scale. (default: `1`).
|
||||||
|
- Does not affect framerate at all. The scale affects both how often the main game loop is called and the delta time used for physics/movement calculations. At a scale of 1, the main loop is called every frame and delta equals rawDelta. At a scale of 0.5, the main loop is called approximately every other frame and delta is half of rawDelta.
|
||||||
|
|
||||||
|
> **Important:** Time scaling separates rendering framerate from simulation speed. The game will always render at the device's refresh rate (e.g., 60fps), but the simulation speed (how fast objects move, animations play, etc.) is controlled by the time scale. Always use `delta` in your movement and physics calculations to ensure they respect the time scale:
|
||||||
|
> ```typescript
|
||||||
|
> // This will move at half speed when time.scale is 0.5
|
||||||
|
> position.x += velocity.x * state.time.delta;
|
||||||
|
> ```
|
||||||
* `state.time.elapsed` - time since `start` was called in ms.
|
* `state.time.elapsed` - time since `start` was called in ms.
|
||||||
* `state.time.fps` - frames per second.
|
* `state.time.fps` - frames per second.
|
||||||
|
|
||||||
This table may help provide clarity to the behavior of `time.scale`.
|
> **Note:** The "last frame" and "last call to main game loop" are different concepts. The engine always runs at the device's refresh rate (e.g. 60fps), so `rawDelta` and `delta` update every frame. However, the main game loop (where your game logic runs) may be called less frequently based on the time scale. For example, at scale 0.5, the main game loop runs every other frame, resulting in a `loopDelta` that's approximately twice the `delta`.
|
||||||
|
|
||||||
| scale | fps | delta | loopDelta |
|
This table may help provide clarity to the behavior of `time.scale`:
|
||||||
| ----- | --- | ----- | --------- |
|
|
||||||
| 1 | 120 | 8.33 | 8.33 |
|
| scale | fps | rawDelta | delta | loopDelta | Description |
|
||||||
| 0.5 | 120 | 8.33 | 16.66 |
|
| ----- | --- | -------- | ----- | --------- | ----------- |
|
||||||
| 0.1 | 120 | 8.33 | 83.33 |
|
| 1 | 60 | 16.67 | 16.67 | 16.67 | Normal speed - main loop called exactly once per frame |
|
||||||
|
| 0.5 | 60 | 16.67 | 8.33 | 33.34 | Half speed - main loop called every ~2 frames |
|
||||||
|
| 2.0 | 60 | 16.67 | 33.34 | 8.33 | Double speed - main loop called ~twice per frame |
|
||||||
|
|
||||||
|
The engine always renders at the device's refresh rate (fps), but the frequency of main loop calls and the simulation time (delta) are affected by the time scale.
|
||||||
|
|
||||||
### Entities
|
### Entities
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@ -11,7 +11,6 @@
|
|||||||
"build:packages:input": "tsup src/packages/input/index.ts --format cjs,esm --dts --minify --clean --out-dir dist/packages/input",
|
"build:packages:input": "tsup src/packages/input/index.ts --format cjs,esm --dts --minify --clean --out-dir dist/packages/input",
|
||||||
"build:packages:2d": "tsup src/packages/2d/index.ts --format cjs,esm --dts --minify --clean --out-dir dist/packages/2d",
|
"build:packages:2d": "tsup src/packages/2d/index.ts --format cjs,esm --dts --minify --clean --out-dir dist/packages/2d",
|
||||||
"test": "bun src/tests/index.ts",
|
"test": "bun src/tests/index.ts",
|
||||||
"test:watch": "nodemon --watch src --watch tests --exec \"clear && pnpm run test\" --ext ts",
|
|
||||||
"release": "bumpp package.json --commit 'Release %s' --push --tag && pnpm publish --access public --no-git-checks",
|
"release": "bumpp package.json --commit 'Release %s' --push --tag && pnpm publish --access public --no-git-checks",
|
||||||
"serve": "esr --serve src/demo.ts"
|
"serve": "esr --serve src/demo.ts"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -29,8 +29,7 @@ function getCreateId(opts) {
|
|||||||
if (!str || num === 256) {
|
if (!str || num === 256) {
|
||||||
str = "";
|
str = "";
|
||||||
num = (1 + len) / 2 | 0;
|
num = (1 + len) / 2 | 0;
|
||||||
while (num--)
|
while (num--) str += HEX[256 * Math.random() | 0];
|
||||||
str += HEX[256 * Math.random() | 0];
|
|
||||||
str = str.substring(num = 0, len);
|
str = str.substring(num = 0, len);
|
||||||
}
|
}
|
||||||
const date = Date.now().toString(36);
|
const date = Date.now().toString(36);
|
||||||
@ -113,8 +112,7 @@ var createWorld = () => {
|
|||||||
let xfps = 1;
|
let xfps = 1;
|
||||||
const xtimes = [];
|
const xtimes = [];
|
||||||
function handler(now) {
|
function handler(now) {
|
||||||
if (!state[$running])
|
if (!state[$running]) return craf(loopHandler);
|
||||||
return craf(loopHandler);
|
|
||||||
while (xtimes.length > 0 && xtimes[0] <= now - 1e3) {
|
while (xtimes.length > 0 && xtimes[0] <= now - 1e3) {
|
||||||
xtimes.shift();
|
xtimes.shift();
|
||||||
}
|
}
|
||||||
@ -142,7 +140,9 @@ var createWorld = () => {
|
|||||||
};
|
};
|
||||||
function step2() {
|
function step2() {
|
||||||
for (const system of state[$systems]) {
|
for (const system of state[$systems]) {
|
||||||
system(state);
|
if (system(state) === null) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function addSystem2(...systems) {
|
function addSystem2(...systems) {
|
||||||
@ -192,8 +192,7 @@ var createWorld = () => {
|
|||||||
};
|
};
|
||||||
const query = ({ and = [], or = [], not = [], tag = [] }) => {
|
const query = ({ and = [], or = [], not = [], tag = [] }) => {
|
||||||
const validQuery = (c) => Object.prototype.hasOwnProperty.call(c, "name");
|
const validQuery = (c) => Object.prototype.hasOwnProperty.call(c, "name");
|
||||||
if (![...and, ...or, ...not].every(validQuery))
|
if (![...and, ...or, ...not].every(validQuery)) throw new Error("Invalid query");
|
||||||
throw new Error("Invalid query");
|
|
||||||
const queryName = ["and", ...and.map((c) => c.name), "or", ...or.map((c) => c.name), "not", ...not.map((c) => c.name), "tag", ...tag].join("");
|
const queryName = ["and", ...and.map((c) => c.name), "or", ...or.map((c) => c.name), "not", ...not.map((c) => c.name), "tag", ...tag].join("");
|
||||||
[...and, ...or, ...not].forEach((c) => {
|
[...and, ...or, ...not].forEach((c) => {
|
||||||
const dependencies = state[$queryDependencies].get(c.name) || /* @__PURE__ */ new Set();
|
const dependencies = state[$queryDependencies].get(c.name) || /* @__PURE__ */ new Set();
|
||||||
@ -210,8 +209,7 @@ var createWorld = () => {
|
|||||||
};
|
};
|
||||||
function destroyEntity(e) {
|
function destroyEntity(e) {
|
||||||
const exists = state[$eMap][e.id];
|
const exists = state[$eMap][e.id];
|
||||||
if (!exists)
|
if (!exists) return false;
|
||||||
return false;
|
|
||||||
const componentsToRemove = Object.keys(state[$eciMap][e.id]);
|
const componentsToRemove = Object.keys(state[$eciMap][e.id]);
|
||||||
componentsToRemove.forEach((componentName) => {
|
componentsToRemove.forEach((componentName) => {
|
||||||
state[$ceMap][componentName] = state[$ceMap][componentName].filter((id) => id !== e.id);
|
state[$ceMap][componentName] = state[$ceMap][componentName].filter((id) => id !== e.id);
|
||||||
@ -227,16 +225,14 @@ var createWorld = () => {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
function onEntityCreated(fn) {
|
function onEntityCreated(fn) {
|
||||||
if (typeof fn !== "function")
|
if (typeof fn !== "function") return;
|
||||||
return;
|
|
||||||
state[$onEntityCreated].push(fn);
|
state[$onEntityCreated].push(fn);
|
||||||
return () => {
|
return () => {
|
||||||
state[$onEntityCreated] = state[$onEntityCreated].filter((f) => f !== fn);
|
state[$onEntityCreated] = state[$onEntityCreated].filter((f) => f !== fn);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function createComponent(entity, component, defaults = {}) {
|
function createComponent(entity, component, defaults = {}) {
|
||||||
if (state[$eciMap]?.[entity.id]?.[component.name] !== void 0)
|
if (state[$eciMap]?.[entity.id]?.[component.name] !== void 0) return entity;
|
||||||
return entity;
|
|
||||||
const affectedQueries = state[$queryDependencies].get(component.name);
|
const affectedQueries = state[$queryDependencies].get(component.name);
|
||||||
if (affectedQueries) {
|
if (affectedQueries) {
|
||||||
affectedQueries.forEach(markQueryDirty);
|
affectedQueries.forEach(markQueryDirty);
|
||||||
@ -348,8 +344,7 @@ var createWorld = () => {
|
|||||||
}
|
}
|
||||||
function migrateEntityId(oldId, newId) {
|
function migrateEntityId(oldId, newId) {
|
||||||
const entity = state[$eMap][oldId];
|
const entity = state[$eMap][oldId];
|
||||||
if (!entity)
|
if (!entity) return;
|
||||||
return;
|
|
||||||
entity.id = newId;
|
entity.id = newId;
|
||||||
state[$eMap][newId] = entity;
|
state[$eMap][newId] = entity;
|
||||||
delete state[$eMap][oldId];
|
delete state[$eMap][oldId];
|
||||||
@ -582,8 +577,7 @@ var createParticleEmitter = (opts) => {
|
|||||||
let dead = false;
|
let dead = false;
|
||||||
let paused = false;
|
let paused = false;
|
||||||
const update = (state) => {
|
const update = (state) => {
|
||||||
if (dead)
|
if (dead) return;
|
||||||
return;
|
|
||||||
context.globalCompositeOperation = opts.blendMode ?? "source-over";
|
context.globalCompositeOperation = opts.blendMode ?? "source-over";
|
||||||
const { loopDelta } = state.time;
|
const { loopDelta } = state.time;
|
||||||
for (let i = particles.length - 1; i >= 0; i--) {
|
for (let i = particles.length - 1; i >= 0; i--) {
|
||||||
@ -683,7 +677,6 @@ var createParticleEmitter = (opts) => {
|
|||||||
if (opts.burst && particles.length === 0) {
|
if (opts.burst && particles.length === 0) {
|
||||||
destroy();
|
destroy();
|
||||||
}
|
}
|
||||||
context.globalCompositeOperation = "source-over";
|
|
||||||
};
|
};
|
||||||
const destroy = () => {
|
const destroy = () => {
|
||||||
dead = true;
|
dead = true;
|
||||||
@ -798,7 +791,7 @@ var particleSystem = createParticleSystem({
|
|||||||
var emitter = particleSystem.createEmitter({
|
var emitter = particleSystem.createEmitter({
|
||||||
x: canvas.width / 2,
|
x: canvas.width / 2,
|
||||||
y: canvas.height / 2,
|
y: canvas.height / 2,
|
||||||
maxParticles: 100,
|
maxParticles: 120,
|
||||||
rate: 0.1,
|
rate: 0.1,
|
||||||
lifetime: 1e3,
|
lifetime: 1e3,
|
||||||
lifetimeVariation: 0.2,
|
lifetimeVariation: 0.2,
|
||||||
@ -829,8 +822,8 @@ var emitter = particleSystem.createEmitter({
|
|||||||
particleSystem.createEmitter({
|
particleSystem.createEmitter({
|
||||||
x: particle.x,
|
x: particle.x,
|
||||||
y: particle.y,
|
y: particle.y,
|
||||||
maxParticles: 3,
|
maxParticles: 4,
|
||||||
lifetimeVariation: 0.2,
|
lifetimeVariation: 0.5,
|
||||||
size: 3,
|
size: 3,
|
||||||
sizeVariation: 2,
|
sizeVariation: 2,
|
||||||
colorStart: ["#FF0000", "#ff5100"],
|
colorStart: ["#FF0000", "#ff5100"],
|
||||||
@ -851,8 +844,6 @@ var emitter = particleSystem.createEmitter({
|
|||||||
},
|
},
|
||||||
onUpdate: (particle, state) => {
|
onUpdate: (particle, state) => {
|
||||||
particle.size = Math.max(0, particle.size - 0.35);
|
particle.size = Math.max(0, particle.size - 0.35);
|
||||||
const v = pulse(state.time.elapsed, 0.25, -1, 1);
|
|
||||||
particle.x += v * 1;
|
|
||||||
},
|
},
|
||||||
onRemove: (particle, state) => {
|
onRemove: (particle, state) => {
|
||||||
}
|
}
|
||||||
@ -868,14 +859,20 @@ var fpsDrawSystem = (state) => {
|
|||||||
draw.text({ x: 10, y: 20 }, `FPS: ${state.time.fps.toFixed(2)}`, "white");
|
draw.text({ x: 10, y: 20 }, `FPS: ${state.time.fps.toFixed(2)}`, "white");
|
||||||
};
|
};
|
||||||
var particleCountSystem = (state) => {
|
var particleCountSystem = (state) => {
|
||||||
draw.text({ x: 10, y: 40 }, `Particle count: ${particleSystem.numParticles}`, "white");
|
draw.text({ x: 10, y: 40 }, `Particle count: ${particleSystem.numParticles}. Emitter count: ${emitter.particles.length}`, "white");
|
||||||
};
|
};
|
||||||
var particlePositionSystem = (state) => {
|
var particlePositionSystem = (state) => {
|
||||||
const { time } = state;
|
const { time } = state;
|
||||||
const xPos = pulse(time.elapsed, 0.25, canvas.width / 2 - 100, canvas.width / 2 + 100);
|
const xPos = pulse(time.elapsed, 0.25, canvas.width / 2 - 100, canvas.width / 2 + 100);
|
||||||
emitter.x = xPos;
|
emitter.x = xPos;
|
||||||
};
|
};
|
||||||
addSystem(clearCanvasSystem, fpsDrawSystem, particleCountSystem, particlePositionSystem, particleSystem);
|
addSystem(
|
||||||
|
clearCanvasSystem,
|
||||||
|
fpsDrawSystem,
|
||||||
|
particleCountSystem,
|
||||||
|
particlePositionSystem,
|
||||||
|
particleSystem
|
||||||
|
);
|
||||||
defineMain(() => {
|
defineMain(() => {
|
||||||
step();
|
step();
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -4,10 +4,21 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>esr</title>
|
<title>esr</title>
|
||||||
|
<style>
|
||||||
|
#accel {
|
||||||
|
position: absolute;
|
||||||
|
top: 200px;
|
||||||
|
left: 0;
|
||||||
|
z-index: 100;
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
<div id="accel"></div>
|
||||||
<script src="demo.js" type="module"></script>
|
<script src="demo.js" type="module"></script>
|
||||||
{{ livereload }}
|
{{ livereload }}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
@ -14,7 +14,7 @@ const particleSystem = createParticleSystem({
|
|||||||
const emitter = particleSystem.createEmitter({
|
const emitter = particleSystem.createEmitter({
|
||||||
x: canvas.width / 2,
|
x: canvas.width / 2,
|
||||||
y: canvas.height / 2,
|
y: canvas.height / 2,
|
||||||
maxParticles: 100,
|
maxParticles: 120,
|
||||||
rate: 0.1,
|
rate: 0.1,
|
||||||
lifetime: 1000,
|
lifetime: 1000,
|
||||||
lifetimeVariation: 0.2,
|
lifetimeVariation: 0.2,
|
||||||
@ -49,8 +49,8 @@ const emitter = particleSystem.createEmitter({
|
|||||||
particleSystem.createEmitter({
|
particleSystem.createEmitter({
|
||||||
x: particle.x,
|
x: particle.x,
|
||||||
y: particle.y,
|
y: particle.y,
|
||||||
maxParticles: 3,
|
maxParticles: 4,
|
||||||
lifetimeVariation: 0.2,
|
lifetimeVariation: 0.5,
|
||||||
size: 3,
|
size: 3,
|
||||||
sizeVariation: 2,
|
sizeVariation: 2,
|
||||||
colorStart: ["#FF0000", "#ff5100"],
|
colorStart: ["#FF0000", "#ff5100"],
|
||||||
@ -72,8 +72,8 @@ const emitter = particleSystem.createEmitter({
|
|||||||
},
|
},
|
||||||
onUpdate: (particle: Particle, state: WorldState) => {
|
onUpdate: (particle: Particle, state: WorldState) => {
|
||||||
particle.size = Math.max(0, particle.size - 0.35);
|
particle.size = Math.max(0, particle.size - 0.35);
|
||||||
const v = pulse(state.time.elapsed, 0.25, -1, 1);
|
// const v = pulse(state.time.elapsed, 0.25, -1, 1);
|
||||||
particle.x += v * 1;
|
// particle.x += v * 1;
|
||||||
},
|
},
|
||||||
onRemove: (particle: Particle, state: WorldState) => {},
|
onRemove: (particle: Particle, state: WorldState) => {},
|
||||||
});
|
});
|
||||||
@ -93,7 +93,7 @@ const fpsDrawSystem = (state: WorldState) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const particleCountSystem = (state: WorldState) => {
|
const particleCountSystem = (state: WorldState) => {
|
||||||
draw.text({ x: 10, y: 40 }, `Particle count: ${particleSystem.numParticles}`, "white");
|
draw.text({ x: 10, y: 40 }, `Particle count: ${particleSystem.numParticles}. Emitter count: ${emitter.particles.length}`, "white");
|
||||||
};
|
};
|
||||||
|
|
||||||
const particlePositionSystem = (state: WorldState) => {
|
const particlePositionSystem = (state: WorldState) => {
|
||||||
|
|||||||
@ -36,7 +36,7 @@ export type ComponentInstance = () => {
|
|||||||
|
|
||||||
export type QueryConfig = Readonly<
|
export type QueryConfig = Readonly<
|
||||||
Partial<{
|
Partial<{
|
||||||
/** Matches entities as long as the entity has all of the components in the provided array. */
|
/** Matches entities as long as the entity has all the components in the provided array. */
|
||||||
and: Component[];
|
and: Component[];
|
||||||
/** Matches entities as long as the entity has at least one of the components in the provided array. */
|
/** Matches entities as long as the entity has at least one of the components in the provided array. */
|
||||||
or: Component[];
|
or: Component[];
|
||||||
@ -83,8 +83,10 @@ export type WorldState = {
|
|||||||
time: {
|
time: {
|
||||||
/** The total elapsed time in seconds since the game loop started. */
|
/** The total elapsed time in seconds since the game loop started. */
|
||||||
elapsed: number;
|
elapsed: number;
|
||||||
/** The time in milliseconds since the last frame. */
|
/** The time in milliseconds since the last frame, scaled by time.scale. */
|
||||||
delta: number;
|
delta: number;
|
||||||
|
/** The raw, unscaled time in milliseconds since the last frame. */
|
||||||
|
rawDelta: number;
|
||||||
/** The time in milliseconds since the last time the main loop was called. */
|
/** The time in milliseconds since the last time the main loop was called. */
|
||||||
loopDelta: number;
|
loopDelta: number;
|
||||||
/** The time in milliseconds of the last call to the main loop. */
|
/** The time in milliseconds of the last call to the main loop. */
|
||||||
@ -111,6 +113,7 @@ export const createWorld = () => {
|
|||||||
time: {
|
time: {
|
||||||
elapsed: 0,
|
elapsed: 0,
|
||||||
delta: 0,
|
delta: 0,
|
||||||
|
rawDelta: 0,
|
||||||
loopDelta: 0,
|
loopDelta: 0,
|
||||||
lastLoopDelta: 0,
|
lastLoopDelta: 0,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
@ -135,6 +138,7 @@ export const createWorld = () => {
|
|||||||
let loopHandler = -1;
|
let loopHandler = -1;
|
||||||
const { time } = state;
|
const { time } = state;
|
||||||
time.delta = 0;
|
time.delta = 0;
|
||||||
|
time.rawDelta = 0;
|
||||||
time.elapsed = 0;
|
time.elapsed = 0;
|
||||||
time.fps = 0;
|
time.fps = 0;
|
||||||
state[$running] = true;
|
state[$running] = true;
|
||||||
@ -183,23 +187,39 @@ export const createWorld = () => {
|
|||||||
xfps = xtimes.length;
|
xfps = xtimes.length;
|
||||||
time.fps = xfps;
|
time.fps = xfps;
|
||||||
|
|
||||||
time.delta = now - then;
|
// Store the raw, unscaled delta time
|
||||||
|
time.rawDelta = now - then;
|
||||||
then = now;
|
then = now;
|
||||||
|
|
||||||
accumulator += time.delta * time.scale;
|
// Apply time scale to delta - this represents the simulation time that has passed
|
||||||
|
time.delta = time.rawDelta * time.scale;
|
||||||
|
|
||||||
|
// Use the raw delta for accumulation (behavior remains the same)
|
||||||
|
accumulator += time.rawDelta * time.scale;
|
||||||
|
|
||||||
// Calculate the threshold for stepping the world based on the current frame rate
|
// Calculate the threshold for stepping the world based on the current frame rate
|
||||||
const stepThreshold = 1000 / (time.fps || 60);
|
const stepThreshold = 1000 / (time.fps || 60);
|
||||||
|
|
||||||
|
// Add a maximum number of iterations to prevent spiral of death
|
||||||
|
const maxSteps = 5; // Limit the catch-up to prevent freezing
|
||||||
|
let steps = 0;
|
||||||
|
|
||||||
// Step the world only when the accumulated scaled time exceeds the threshold
|
// Step the world only when the accumulated scaled time exceeds the threshold
|
||||||
while (accumulator >= stepThreshold) {
|
while (accumulator >= stepThreshold && steps < maxSteps) {
|
||||||
time.loopDelta = now - time.lastLoopDelta;
|
time.loopDelta = now - time.lastLoopDelta;
|
||||||
time.lastLoopDelta = now;
|
time.lastLoopDelta = now;
|
||||||
|
|
||||||
state[$mainLoop](state);
|
state[$mainLoop](state);
|
||||||
accumulator -= stepThreshold;
|
accumulator -= stepThreshold;
|
||||||
|
steps++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we hit the max steps, discard remaining accumulator time
|
||||||
|
if (steps >= maxSteps) {
|
||||||
|
accumulator = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the scaled delta for elapsed time calculation
|
||||||
time.elapsed += time.delta * 0.001;
|
time.elapsed += time.delta * 0.001;
|
||||||
|
|
||||||
loopHandler = raf(boundLoop);
|
loopHandler = raf(boundLoop);
|
||||||
@ -541,7 +561,7 @@ export const createWorld = () => {
|
|||||||
destroy,
|
destroy,
|
||||||
});
|
});
|
||||||
|
|
||||||
// If we are focing a specific entity id, we need to migrate any
|
// If we are forcing a specific entity id, we need to migrate any
|
||||||
// entity that might already occupy this space.
|
// entity that might already occupy this space.
|
||||||
if (spec.id !== undefined && state[$eMap][spec.id]) {
|
if (spec.id !== undefined && state[$eMap][spec.id]) {
|
||||||
migrateEntityId(spec.id, createId());
|
migrateEntityId(spec.id, createId());
|
||||||
@ -575,6 +595,16 @@ export const createWorld = () => {
|
|||||||
|
|
||||||
state[$eciMap][newId] = state[$eciMap][oldId];
|
state[$eciMap][newId] = state[$eciMap][oldId];
|
||||||
delete state[$eciMap][oldId];
|
delete state[$eciMap][oldId];
|
||||||
|
|
||||||
|
// Update component-to-entity mappings, because otherwise queries that
|
||||||
|
// rely on state[$ceMap] would still reference the old entity ID,
|
||||||
|
// causing inconsistencies when trying to find entities with specific
|
||||||
|
// components after ID migration.
|
||||||
|
Object.keys(state[$ceMap]).forEach((componentName) => {
|
||||||
|
if (state[$ceMap][componentName].includes(oldId)) {
|
||||||
|
state[$ceMap][componentName] = state[$ceMap][componentName].map((id) => (id === oldId ? newId : id));
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEntity(id: string): Entity {
|
function getEntity(id: string): Entity {
|
||||||
|
|||||||
@ -55,32 +55,169 @@ type BlendMode =
|
|||||||
| "xor";
|
| "xor";
|
||||||
|
|
||||||
export type ParticleEmitterOptions = {
|
export type ParticleEmitterOptions = {
|
||||||
x?: number; // X position
|
/**
|
||||||
y?: number; // Y position
|
* The x coordinate for new particles.
|
||||||
maxParticles?: number; // Max number of particles
|
* Default is 0.
|
||||||
rate?: number; // Particles per second
|
* Determines the horizontal start position of particle emission. Can be changed at any time.
|
||||||
lifetime?: number; // Lifetime of each particle
|
*/
|
||||||
lifetimeVariation?: number; // Variation in lifetime
|
x?: number;
|
||||||
size?: number; // Size of each particle
|
|
||||||
sizeVariation?: number; // Variation in size
|
/**
|
||||||
colorStart?: string | string[]; // Start color
|
* The y coordinate for new particles.
|
||||||
colorEnd?: string | string[]; // End color
|
* Default is 0.
|
||||||
colorEasing?: ColorEasing; // Easing function for color
|
* Determines the vertical start position of particle emission. Can be changed at any time.
|
||||||
|
*/
|
||||||
|
y?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum number of particles that can exist at one time.
|
||||||
|
* Default is 100.
|
||||||
|
* Helps manage performance by capping particle count.
|
||||||
|
*/
|
||||||
|
maxParticles?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of particles emitted per millisecond interval.
|
||||||
|
* Default is 1.
|
||||||
|
* Controls the frequency of particle emission in relation to time.
|
||||||
|
*/
|
||||||
|
rate?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifetime of each particle in milliseconds.
|
||||||
|
* Default is 1000 (1 second).
|
||||||
|
* Determines how long a particle will exist before disappearing.
|
||||||
|
*/
|
||||||
|
lifetime?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variation in particle lifetime as a fraction of `lifetime`.
|
||||||
|
* Provide a value between 0 and 1.
|
||||||
|
* Default is 0.
|
||||||
|
* Allows particles to have different lifetimes, adding randomness.
|
||||||
|
*/
|
||||||
|
lifetimeVariation?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base size of each particle.
|
||||||
|
* Default is 5.
|
||||||
|
* Represents the default size/scale factor for particles.
|
||||||
|
*/
|
||||||
|
size?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variation in size as a fraction of `size`.
|
||||||
|
* Provide a value between 0 and 1.
|
||||||
|
* Default is 0.
|
||||||
|
* Introduces variability to particle sizes.
|
||||||
|
*/
|
||||||
|
sizeVariation?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initial color or array of possible initial colors for particles in hexadecimal format.
|
||||||
|
* Default is "#000000".
|
||||||
|
* Specifies the starting color of particles.
|
||||||
|
*/
|
||||||
|
colorStart?: string | string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Final color or array of possible end colors for particles in hexadecimal format.
|
||||||
|
* Default is "#000000".
|
||||||
|
* Specifies the color particles will transition to over their lifetime.
|
||||||
|
*/
|
||||||
|
colorEnd?: string | string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Easing function to interpolate between `colorStart` and `colorEnd`.
|
||||||
|
* Default is `ColorEasing.LINEAR`.
|
||||||
|
* Determines how the color changes over the particle's lifetime.
|
||||||
|
*/
|
||||||
|
colorEasing?: ColorEasing;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Easing function for fade out effect.
|
||||||
|
* Default is `ColorEasing.LINEAR`.
|
||||||
|
* Controls opacity transition as particles disappear.
|
||||||
|
*/
|
||||||
fadeOutEasing?: FadeEasing;
|
fadeOutEasing?: FadeEasing;
|
||||||
speed?: number; // Speed of each particle
|
|
||||||
speedVariation?: number; // Variation in speed
|
/**
|
||||||
angle?: number; // Angle of emission
|
* Base speed of particle movement in pixels per millisecond.
|
||||||
spread?: number; // Spread of emission
|
* Default is 0.1.
|
||||||
gravity?: { x: number; y: number }; // Gravity affecting the particles
|
* Determines how fast particles move from their origin.
|
||||||
blendMode?: BlendMode; // Blend mode
|
*/
|
||||||
canvas: HTMLCanvasElement; // Canvas to draw on
|
speed?: number;
|
||||||
burst?: boolean; // If true, emit all particles at once and then stop
|
|
||||||
/** Per-particle initialization callback. */
|
/**
|
||||||
onInit?: (particle: Particle, state: WorldState) => void; // Callback for particle initialization
|
* Variation in speed as a fraction of `speed`.
|
||||||
/** Per-particle update callback. */
|
* Provide a value between 0 and 1.
|
||||||
onUpdate?: (particle: Particle, state: WorldState) => void; // Callback for particle update
|
* Default is 0.
|
||||||
/** Per-particle removal callback. */
|
* Introduces speed variability amongst particles.
|
||||||
onRemove?: (particle: Particle, state: WorldState) => void; // Callback for particle removal
|
*/
|
||||||
|
speedVariation?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emission angle in degrees.
|
||||||
|
* Default is 0.
|
||||||
|
* Sets the direction of initial particle movement.
|
||||||
|
*/
|
||||||
|
angle?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spread angle in degrees around the emission angle for particle dispersion.
|
||||||
|
* Default is 0.
|
||||||
|
* Widens the field of initial particle directions.
|
||||||
|
*/
|
||||||
|
spread?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gravity effect on particles as x and y components.
|
||||||
|
* Default is {x: 0, y: 0}.
|
||||||
|
* It simulates gravitational forces affecting particle trajectories.
|
||||||
|
*/
|
||||||
|
gravity?: { x: number; y: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blend mode used for particle rendering.
|
||||||
|
* Default is the canvas context’s "source-over".
|
||||||
|
* Determines how particles blend with the background/canvas.
|
||||||
|
*/
|
||||||
|
blendMode?: BlendMode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTMLCanvasElement on which particles are drawn.
|
||||||
|
* Required parameter.
|
||||||
|
* Represents the rendering surface for the particle system.
|
||||||
|
*/
|
||||||
|
canvas: HTMLCanvasElement;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If true, emit all particles at once and then stop.
|
||||||
|
* Default is false.
|
||||||
|
* Changes emitter behavior from continuous to singular burst.
|
||||||
|
*/
|
||||||
|
burst?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback invoked on each particle initialization.
|
||||||
|
* Default is undefined.
|
||||||
|
* Useful for setting initial particle properties dynamically.
|
||||||
|
*/
|
||||||
|
onInit?: (particle: Particle, state: WorldState) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback for code execution every frame as each particle updates.
|
||||||
|
* Default is undefined.
|
||||||
|
* Allows interaction or modification of particles per update loop.
|
||||||
|
*/
|
||||||
|
onUpdate?: (particle: Particle, state: WorldState) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback invoked when a particle is removed.
|
||||||
|
* Default is undefined.
|
||||||
|
* Useful for cleanup or concluding actions when particles disappear.
|
||||||
|
*/
|
||||||
|
onRemove?: (particle: Particle, state: WorldState) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDefaultParticleEmitterOptions = (opts: Partial<ParticleEmitterOptions>): ParticleEmitterOptions => ({
|
const getDefaultParticleEmitterOptions = (opts: Partial<ParticleEmitterOptions>): ParticleEmitterOptions => ({
|
||||||
@ -306,7 +443,7 @@ export const createParticleEmitter = (opts: ParticleEmitterOptions): ParticleEmi
|
|||||||
destroy();
|
destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
context.globalCompositeOperation = "source-over";
|
// context.globalCompositeOperation = "source-over";
|
||||||
};
|
};
|
||||||
|
|
||||||
const destroy = () => {
|
const destroy = () => {
|
||||||
|
|||||||
@ -39,7 +39,7 @@ export const keyboard = () => ({
|
|||||||
* If the key is not found or not pressed, the `pressed`, `justPressed`, and `justReleased` properties will be set to `false`.
|
* If the key is not found or not pressed, the `pressed`, `justPressed`, and `justReleased` properties will be set to `false`.
|
||||||
*/
|
*/
|
||||||
getKey(b: string): ButtonState {
|
getKey(b: string): ButtonState {
|
||||||
const key = Object.keys(keyboardMapping)[Object.values(keyboardMapping).indexOf(b)];
|
const key = Object.keys(keyboardMapping)[Object.values(keyboardMapping).indexOf(b)] || b;
|
||||||
if (key) return keyboardState.keys[key];
|
if (key) return keyboardState.keys[key];
|
||||||
if (keyboardState.keys[b]) return keyboardState.keys[b];
|
if (keyboardState.keys[b]) return keyboardState.keys[b];
|
||||||
return { pressed: false, justPressed: false, justReleased: false };
|
return { pressed: false, justPressed: false, justReleased: false };
|
||||||
@ -54,12 +54,13 @@ export const keyboard = () => ({
|
|||||||
*/
|
*/
|
||||||
export const keyboardUpdate = (): void => {
|
export const keyboardUpdate = (): void => {
|
||||||
for (const [key, value] of Object.entries(observedKeyboardState.keys)) {
|
for (const [key, value] of Object.entries(observedKeyboardState.keys)) {
|
||||||
keyboardState.keys[key] = {
|
const actualKey = Object.keys(keyboardMapping)[Object.values(keyboardMapping).indexOf(key)] || key;
|
||||||
|
keyboardState.keys[actualKey] = {
|
||||||
...value,
|
...value,
|
||||||
justReleased: !value.pressed && keysDownLastFrame.keys?.[key]?.pressed,
|
justReleased: !value.pressed && keysDownLastFrame.keys?.[actualKey]?.pressed,
|
||||||
};
|
};
|
||||||
keysDownLastFrame.keys[key] = { ...value, justPressed: false };
|
keysDownLastFrame.keys[actualKey] = { ...value, justPressed: false };
|
||||||
observedKeyboardState.keys[key] = { ...value, justPressed: false };
|
observedKeyboardState.keys[actualKey] = { ...value, justPressed: false };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -33,12 +33,13 @@ const buttonsDownLastFrame: ObservedMouseState = { buttons: {} };
|
|||||||
*/
|
*/
|
||||||
export const mouseUpdate = (): void => {
|
export const mouseUpdate = (): void => {
|
||||||
for (const [button, value] of Object.entries(observedMouseState.buttons)) {
|
for (const [button, value] of Object.entries(observedMouseState.buttons)) {
|
||||||
mouseState.buttons[button] = {
|
const actualButton = Object.keys(mouseMapping)[Object.values(mouseMapping).indexOf(button)] || button;
|
||||||
|
mouseState.buttons[actualButton] = {
|
||||||
...value,
|
...value,
|
||||||
justReleased: !value.pressed && buttonsDownLastFrame.buttons?.[button]?.pressed,
|
justReleased: !value.pressed && buttonsDownLastFrame.buttons?.[button]?.pressed,
|
||||||
};
|
};
|
||||||
buttonsDownLastFrame.buttons[button] = { ...value, justPressed: false };
|
buttonsDownLastFrame.buttons[actualButton] = { ...value, justPressed: false };
|
||||||
observedMouseState.buttons[button] = { ...value, justPressed: false };
|
observedMouseState.buttons[actualButton] = { ...value, justPressed: false };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -20,4 +20,8 @@ export default testSuite(async ({ describe }) => {
|
|||||||
describe("mouse input", async ({ runTestSuite }) => {
|
describe("mouse input", async ({ runTestSuite }) => {
|
||||||
runTestSuite(import("./mouse.test.js"));
|
runTestSuite(import("./mouse.test.js"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("time scaling", async ({ runTestSuite }) => {
|
||||||
|
runTestSuite(import("./time.test.js"));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,6 +4,31 @@ import { KeyboardKey } from "../../packages/input/devices/mappings/keyboard";
|
|||||||
|
|
||||||
export default testSuite(async ({ describe }) => {
|
export default testSuite(async ({ describe }) => {
|
||||||
describe("keyboard", () => {
|
describe("keyboard", () => {
|
||||||
|
test("accepts a custom mapping", () => {
|
||||||
|
const customMapping = () => ({
|
||||||
|
[KeyboardKey.KeyA]: "RotateLeft",
|
||||||
|
[KeyboardKey.KeyD]: "Right",
|
||||||
|
});
|
||||||
|
|
||||||
|
const kb = keyboard();
|
||||||
|
kb.keyboard.useMapping(customMapping);
|
||||||
|
|
||||||
|
expect(kb.keyboard.getKey("RotateLeft")).toEqual({
|
||||||
|
pressed: false,
|
||||||
|
justPressed: false,
|
||||||
|
justReleased: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
onKeyDown({ code: "RotateLeft", repeat: false } as KeyboardEvent);
|
||||||
|
keyboardUpdate();
|
||||||
|
|
||||||
|
expect(kb.keyboard.getKey("RotateLeft")).toEqual({
|
||||||
|
pressed: true,
|
||||||
|
justPressed: true,
|
||||||
|
justReleased: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("should return an object with a keyboard property containing methods", () => {
|
test("should return an object with a keyboard property containing methods", () => {
|
||||||
const kb = keyboard();
|
const kb = keyboard();
|
||||||
expect(typeof kb.keyboard).toBe("object");
|
expect(typeof kb.keyboard).toBe("object");
|
||||||
|
|||||||
58
packages/ngn/src/tests/ngn/time.test.ts
Normal file
58
packages/ngn/src/tests/ngn/time.test.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { expect, test, testSuite } from "manten";
|
||||||
|
import { createWorld, WorldState } from "../../ngn";
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
export default testSuite(async () => {
|
||||||
|
test("time.delta should be scaled by time.scale", async () => {
|
||||||
|
const { state, start, stop, defineMain } = createWorld();
|
||||||
|
let i = 0;
|
||||||
|
state.time.scale = 0.5;
|
||||||
|
|
||||||
|
defineMain((state: WorldState) => {
|
||||||
|
if (i > 0) {
|
||||||
|
// Check that delta is scaled (approximately half of rawDelta)
|
||||||
|
expect(state.time.delta).toBeCloseTo(state.time.rawDelta * 0.5, 1);
|
||||||
|
|
||||||
|
// The raw delta should be around 16.67ms (60fps)
|
||||||
|
expect(state.time.rawDelta).toBeGreaterThan(15);
|
||||||
|
expect(state.time.rawDelta).toBeLessThan(20);
|
||||||
|
|
||||||
|
// The scaled delta should be around 8.33ms (at scale 0.5)
|
||||||
|
expect(state.time.delta).toBeGreaterThan(7);
|
||||||
|
expect(state.time.delta).toBeLessThan(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (++i === 3) stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
start();
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
expect(i).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("time.delta should be doubled when time.scale is 2.0", async () => {
|
||||||
|
const { state, start, stop, defineMain } = createWorld();
|
||||||
|
let i = 0;
|
||||||
|
state.time.scale = 2.0;
|
||||||
|
|
||||||
|
defineMain((state: WorldState) => {
|
||||||
|
if (i > 0) {
|
||||||
|
// Check that delta is scaled (approximately double of rawDelta)
|
||||||
|
expect(state.time.delta).toBeCloseTo(state.time.rawDelta * 2.0, 1);
|
||||||
|
|
||||||
|
// The scaled delta should be around 33.34ms (at scale 2.0)
|
||||||
|
expect(state.time.delta).toBeGreaterThan(30);
|
||||||
|
expect(state.time.delta).toBeLessThan(40);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (++i === 3) stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
start();
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
expect(i).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -494,7 +494,9 @@ export default testSuite(async () => {
|
|||||||
await sleep(500);
|
await sleep(500);
|
||||||
|
|
||||||
expect(i).toBe(3);
|
expect(i).toBe(3);
|
||||||
expect(state.time.delta).toBe(16.670000000000016);
|
// delta is scaled, so it should be half of rawDelta
|
||||||
|
expect(state.time.delta).toBeCloseTo(state.time.rawDelta * 0.5, 1);
|
||||||
|
expect(state.time.rawDelta).toBeCloseTo(16.67, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("step calls systems, passing world", async () => {
|
test("step calls systems, passing world", async () => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user