mirror of
https://github.com/nvms/prsm.git
synced 2025-12-16 08:00:53 +00:00
relocate
This commit is contained in:
parent
e197339f30
commit
24b856b78f
9
packages/ngn/.esr.yml
Normal file
9
packages/ngn/.esr.yml
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
platform: browser
|
||||||
|
bundle: true
|
||||||
|
outdir: public
|
||||||
|
format: esm
|
||||||
|
sourcemap: true
|
||||||
|
|
||||||
|
serve:
|
||||||
|
html: public/index.html
|
||||||
|
port: 1234
|
||||||
25
packages/ngn/.github/workflows/onpush-pnpm-test.yml
vendored
Normal file
25
packages/ngn/.github/workflows/onpush-pnpm-test.yml
vendored
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
name: Run bun test on push
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: Set up Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: bun test
|
||||||
3
packages/ngn/.gitignore
vendored
Normal file
3
packages/ngn/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.aider*
|
||||||
2
packages/ngn/.npmignore
Normal file
2
packages/ngn/.npmignore
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
src
|
||||||
3
packages/ngn/.prettierrc
Normal file
3
packages/ngn/.prettierrc
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 200
|
||||||
|
}
|
||||||
766
packages/ngn/README.md
Normal file
766
packages/ngn/README.md
Normal file
@ -0,0 +1,766 @@
|
|||||||
|
# ngn
|
||||||
|
|
||||||
|
An ECS framework (and robust input system) for the web.
|
||||||
|
|
||||||
|
<!-- vim-markdown-toc GFM -->
|
||||||
|
|
||||||
|
- [Comprehensive sample](#comprehensive-sample)
|
||||||
|
- [Installation](#installation)
|
||||||
|
- [API overview](#api-overview)
|
||||||
|
- [createWorld](#createworld)
|
||||||
|
- [Entities](#entities)
|
||||||
|
- [Components](#components)
|
||||||
|
- [Extending components](#extending-components)
|
||||||
|
- [Extras](#extras)
|
||||||
|
- [Keyboard, mouse and gamepad input](#keyboard-mouse-and-gamepad-input)
|
||||||
|
- [Input system](#input-system)
|
||||||
|
- [ButtonState](#buttonstate)
|
||||||
|
- [Mouse](#mouse)
|
||||||
|
- [Keyboard](#keyboard)
|
||||||
|
- [Gamepad](#gamepad)
|
||||||
|
- [Input usage examples](#input-usage-examples)
|
||||||
|
- [Gamepad](#gamepad-1)
|
||||||
|
- [Keyboard](#keyboard-1)
|
||||||
|
- [Mouse](#mouse-1)
|
||||||
|
- [Expiring log system](#expiring-log-system)
|
||||||
|
|
||||||
|
<!-- vim-markdown-toc -->
|
||||||
|
|
||||||
|
# Comprehensive sample
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createWorld, type WorldState } from "@prsm/ngn";
|
||||||
|
import {
|
||||||
|
inputSystem,
|
||||||
|
gamepad,
|
||||||
|
GamepadMapping,
|
||||||
|
SCUFVantage2,
|
||||||
|
onGamepadConnected,
|
||||||
|
} from "@prsm/ngn/input";
|
||||||
|
|
||||||
|
// Create a mapping with unique button/key names.
|
||||||
|
const MyMapping = (): GamepadMapping => {
|
||||||
|
return Object.assign(SCUFVantage2(), {
|
||||||
|
axes: {
|
||||||
|
2: "LookHorizontal",
|
||||||
|
3: "LookVertical",
|
||||||
|
},
|
||||||
|
buttons: {
|
||||||
|
0: "Sprint", // X
|
||||||
|
2: "Jump", // ■
|
||||||
|
3: "Action", // ▲
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Assign this mapping to gamepads when they connect.
|
||||||
|
onGamepadConnected((e: GamepadEvent) => {
|
||||||
|
gamepad(e.gamepad.index).useMapping(MyMapping);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a world
|
||||||
|
const {
|
||||||
|
state,
|
||||||
|
query,
|
||||||
|
createEntity,
|
||||||
|
addSystem,
|
||||||
|
start,
|
||||||
|
step,
|
||||||
|
defineMain,
|
||||||
|
} = createWorld();
|
||||||
|
|
||||||
|
// Create components
|
||||||
|
const Position = () => ({ x: 0, y: 0 });
|
||||||
|
const Velocity = () => ({ x: 0, y: 0 });
|
||||||
|
const Alive = () => ({});
|
||||||
|
const Dead = () => ({});
|
||||||
|
|
||||||
|
// Create entities
|
||||||
|
const player =
|
||||||
|
createEntity()
|
||||||
|
.addComponent(Position)
|
||||||
|
.addComponent(Velocity)
|
||||||
|
.addComponent(Alive)
|
||||||
|
.addTag("player");
|
||||||
|
|
||||||
|
Array
|
||||||
|
.from(Array(50))
|
||||||
|
.forEach((i) =>
|
||||||
|
createEntity({ name: `monster ${i}`, hp: 100 })
|
||||||
|
.addComponent(Position)
|
||||||
|
.addComponent(Velocity)
|
||||||
|
.addComponent(Alive)
|
||||||
|
.addTag("monster");
|
||||||
|
|
||||||
|
// Create queries
|
||||||
|
const movables = query({ and: [Position, Velocity] });
|
||||||
|
const livingMonsters = query({ tag: ["monster"], and: [Alive] });
|
||||||
|
const deadOrAliveMonsters = query({ tag: ["monster"], or: [Dead, Alive] });
|
||||||
|
|
||||||
|
// Create systems
|
||||||
|
const moveSystem = (_: WorldState) => {
|
||||||
|
movables((results) => {
|
||||||
|
results.forEach(({ entity, Position, Velocity }) => {
|
||||||
|
Position.x += Velocity.x;
|
||||||
|
Position.y += Velocity.y;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const monsterDeathSystem = (_: WorldState) => {
|
||||||
|
livingMonsters((results) => {
|
||||||
|
results.forEach(({ entity }) => {
|
||||||
|
if (entity.hp <= 0) {
|
||||||
|
entity.removeComponent(Alive);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// Just for demonstration of 'or' query results:
|
||||||
|
deadOrAliveMonsters((results) => {
|
||||||
|
// Since this query uses 'or', `Dead` OR `Alive` will be
|
||||||
|
// present on the results. You will need to check for existence:
|
||||||
|
results.forEach(({ entity, Dead, Alive }) => {
|
||||||
|
if (Dead) { }
|
||||||
|
if (Alive) { }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const gravitySystem = (w: WorldState) => {
|
||||||
|
movables((results) => {
|
||||||
|
results.
|
||||||
|
forEach(({ Velocity }) => {
|
||||||
|
Velocity.y += 4.9 * w.time.delta;
|
||||||
|
})
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const playerControlSystem = (_: WorldState) => {
|
||||||
|
if (gamepad(0).getButton("Jump").justPressed) {
|
||||||
|
player.getComponent(Velocity).y = 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add or remove systems at any time
|
||||||
|
addSystem(inputSystem, moveSystem, monsterDeathSystem);
|
||||||
|
|
||||||
|
// Finally, define your main entry point with `defineMain`:
|
||||||
|
defineMain(() => {
|
||||||
|
// Once `start` is called, this will be called every frame.
|
||||||
|
|
||||||
|
// Call `step` to call each registered system, passing the state of the world to each.
|
||||||
|
//
|
||||||
|
// This is intentionally handled by *you*, because there's a good chance
|
||||||
|
// you'd prefer to dictate the order of execution here.
|
||||||
|
step();
|
||||||
|
});
|
||||||
|
|
||||||
|
start();
|
||||||
|
```
|
||||||
|
|
||||||
|
# Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @prsm/ngn
|
||||||
|
```
|
||||||
|
|
||||||
|
# API overview
|
||||||
|
|
||||||
|
## createWorld
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { state, createEntity, getEntity, onEntityCreated, query, addSystem, removeSystem, start, stop, step } = createWorld();
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`state`**
|
||||||
|
|
||||||
|
- Stores all the entities.
|
||||||
|
- Tracks relationships between entities and components for fast lookups.
|
||||||
|
- Tracks query dependencies and caches results.
|
||||||
|
- Is passed to all systems (if you use ngn's system mechanics, which is optional).
|
||||||
|
- Contains a useful `time` object that looks like:
|
||||||
|
|
||||||
|
* `state.time.delta` - time since last frame in ms, unaffected by scale.
|
||||||
|
* `state.time.loopDelta` - time since last call to main game loop, affected by sclae. useful for calculations involving time and scale.
|
||||||
|
* `state.time.scale` - time scale. (default: `1`, valid: `0.1 - 1`).
|
||||||
|
- 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.elapsed` - time since `start` was called in ms.
|
||||||
|
* `state.time.fps` - frames per second.
|
||||||
|
|
||||||
|
This table may help provide clarity to the behavior of `time.scale`.
|
||||||
|
|
||||||
|
| scale | fps | delta | loopDelta |
|
||||||
|
| ----- | --- | ----- | --------- |
|
||||||
|
| 1 | 120 | 8.33 | 8.33 |
|
||||||
|
| 0.5 | 120 | 8.33 | 16.66 |
|
||||||
|
| 0.1 | 120 | 8.33 | 83.33 |
|
||||||
|
|
||||||
|
### Entities
|
||||||
|
|
||||||
|
- **`World > createEntity`**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { id, addTag, removeTag, getTag, addComponent, hasComponent, getComponent, removeComponent, destroy } = createEntity({ optional: "default values" });
|
||||||
|
```
|
||||||
|
|
||||||
|
**Forcefully setting the entity ID**
|
||||||
|
|
||||||
|
You can forcefully set the entity ID by providing it as one of the properties of the object passed to `createEntity`. This is a feature that's probably not very useful in the context of this library alone, but this is a critical feature that `@prsm/ngn-net` relies on. An authoritative game server must be able to assign IDs to entities.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// IDs are not numbers, but this example serves to
|
||||||
|
// illustrate a behavior.
|
||||||
|
|
||||||
|
// This entity will have id 1 (not really, but go with it).
|
||||||
|
const firstEntity = createEntity();
|
||||||
|
// Now this entity has id 1, and `firstEntity` has id 2.
|
||||||
|
const secondEntity = createEntity({ id: 1 });
|
||||||
|
// This entity has id 3.
|
||||||
|
const thirdEntity = createEntity();
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`Entity > addTag`**
|
||||||
|
|
||||||
|
Adds a tag to the entity. Tags are only useful for querying entities. An entity can only have one tag.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
entity.addTag("coin");
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`Entity > removeTag`**
|
||||||
|
|
||||||
|
Removes the tag from the entity.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
entity.removeTag();
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`Entity > getTag`**
|
||||||
|
|
||||||
|
Returns the tag of the entity.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const tag = entity.getTag();
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`Entity > destroy`**
|
||||||
|
|
||||||
|
Destroys the entity. Removes it from the world.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
entity.destroy();
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`World > getEntity`**
|
||||||
|
|
||||||
|
Returns the entity with the given ID.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const entity = getEntity("ngnluxhlpj30271be3f727d31");
|
||||||
|
```
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
- **`Entity > addComponent`**
|
||||||
|
|
||||||
|
Adds a component to the entity. Components are functions that return an object.
|
||||||
|
An entity can only have one of each type of a component. Components are just stored
|
||||||
|
as an array of objects on the entity.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const Position = () => ({ x: 50, y: 50 });
|
||||||
|
const Velocity = () => ({ x: 0, y: 0 });
|
||||||
|
entity.addComponent(Position).addComponent(Velocity);
|
||||||
|
|
||||||
|
// entity:
|
||||||
|
// {
|
||||||
|
// ...,
|
||||||
|
// components: [
|
||||||
|
// { x: 50, y: 50 }, <-- Position
|
||||||
|
// { x: 0, y: 0 }, <-- Velocity
|
||||||
|
// ],
|
||||||
|
// }
|
||||||
|
```
|
||||||
|
|
||||||
|
If the object returned by the component function includes an `onAttach` function, it is called at this time.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const MeshComponent = () => ({
|
||||||
|
entityId: null,
|
||||||
|
mesh: null,
|
||||||
|
onAttach(entity: Entity) {
|
||||||
|
this.entityId = entity.id;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
You can override default values:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
entity.addComponent(Position, { y: 10 });
|
||||||
|
|
||||||
|
// entity:
|
||||||
|
// {
|
||||||
|
// ...,
|
||||||
|
// components: [
|
||||||
|
// { x: 50, y: 10 }, <-- Position
|
||||||
|
// ],
|
||||||
|
// }
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`Entity > hasComponent`**
|
||||||
|
|
||||||
|
Returns `true` if the entity has the component.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const hasPosition = entity.hasComponent(Position);
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`Entity > getComponent`**
|
||||||
|
|
||||||
|
Returns the component of the entity.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const position = entity.getComponent<typeof Position>(Position);
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`Entity > removeComponent`**
|
||||||
|
|
||||||
|
Removes the component from the entity. Provide either the component function or the string name of the component (`.name` property).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
entity.removeComponent(Position);
|
||||||
|
// is the same as:
|
||||||
|
entity.removeComponent("Position");
|
||||||
|
```
|
||||||
|
|
||||||
|
If the object returned by the component function includes an `onDetach` function, it is called at this time.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const MeshComponent = () => ({
|
||||||
|
mesh: null,
|
||||||
|
onDetach(entity: Entity) {
|
||||||
|
if (mesh) {
|
||||||
|
dispose(mesh);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Extending components
|
||||||
|
|
||||||
|
Occasionally you will want to override the component defaults when instantiating a component.
|
||||||
|
|
||||||
|
You can do something like `addComponent(Position, { y: CURRENT_Y })`, but for something more generic you can `extend` the component:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { extend } from "@prsm/ngn";
|
||||||
|
|
||||||
|
const Health = () => ({ max: 100 });
|
||||||
|
const WarriorHealth = extend(Health)({ max: 200 });
|
||||||
|
const MageHealth = extend(Health)({ max: 75 });
|
||||||
|
|
||||||
|
// Internally, `WarriorHealth` and `MageHealth` are still
|
||||||
|
// identified as a `Health` components.
|
||||||
|
// This means that queries that match against `Health` will be updated
|
||||||
|
// to include anything that has `WarriorHealth` or `MageHealth`.
|
||||||
|
|
||||||
|
warriorEntity.addComponent(WarriorHealth));
|
||||||
|
|
||||||
|
const mortals = query({ and: [Health] });
|
||||||
|
|
||||||
|
mortals((results) => {
|
||||||
|
// results includes warriorEntity
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`World > query`**
|
||||||
|
|
||||||
|
Queries the world for entities with the given tags and components.
|
||||||
|
|
||||||
|
`query` returns a function that accepts a callback. The callback is immediately called
|
||||||
|
with an array of results. Each result is an object that contains an `entity` key, and a key
|
||||||
|
for each component that is found on the entity.
|
||||||
|
|
||||||
|
`query` accepts an object with the following properties:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
and: [], // matched Entities will have all of these components
|
||||||
|
or: [], // matched Entities will have any of these components
|
||||||
|
not: [], // matched Entities will have none of these components
|
||||||
|
tags: [], // matched Entities will have any of these tags
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
createEntity().addComponent(Position).addComponent(Velocity);
|
||||||
|
createEntity().addComponent(Position).addComponent(Velocity).addComponent(Dead);
|
||||||
|
|
||||||
|
const movables = query({ and: [Position, Velocity], not: [Dead] });
|
||||||
|
|
||||||
|
movables((results) => {
|
||||||
|
results.forEach(({ Position, Velocity }) => {
|
||||||
|
Position.x += Velocity.x;
|
||||||
|
Position.y += Velocity.y;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
For optimum performance, query results are cached while entity state is clean. When an entity is created, destroyed, or has a component added or removed, the cache is invalidated.
|
||||||
|
|
||||||
|
- **`World > addSystem`**
|
||||||
|
|
||||||
|
Adds a system to the world. Systems are either:
|
||||||
|
|
||||||
|
- A function that receives the `WorldState` as its only argument.
|
||||||
|
- An object with an `update` function that receives the `WorldState` as its only argument.
|
||||||
|
- An instance of a class that has an `update` function that receives the `WorldState` as its only argument.
|
||||||
|
|
||||||
|
None of these need to return anything, and the `WorldState` they receive is mutable.
|
||||||
|
|
||||||
|
Systems are called in the order they were added.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const MovementSystem = (state: WorldState) => {};
|
||||||
|
addSystem(MovementSystem);
|
||||||
|
|
||||||
|
const MovementSystem = { update: (state: WorldState) => {} };
|
||||||
|
addSystem(MovementSystem);
|
||||||
|
|
||||||
|
class MovementSystem {
|
||||||
|
update(state: WorldState) {}
|
||||||
|
}
|
||||||
|
addSystem(new MovementSystem());
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`World > removeSystem`**
|
||||||
|
|
||||||
|
Removes a system from the world. Preserves the order of the remaining systems.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
removeSystem(movableSystem);
|
||||||
|
```
|
||||||
|
|
||||||
|
**`World > defineMain`**
|
||||||
|
|
||||||
|
Defines the main program loop. The callback will be called every frame once `start` is called.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
defineMain(() => {
|
||||||
|
// ..
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`World > start`**
|
||||||
|
|
||||||
|
Starts the main program loop. Does not do anything other than call
|
||||||
|
the callback provided to `defineMain`.
|
||||||
|
|
||||||
|
You can use your own loop instead of this one if you prefer, but the builtin loop does things like calculate fps and frame delta for you. These values are stored in `state.time`. If you create your own loop, it would be a good idea to calculate these values yourself and populate `state.time` with them.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
start();
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`World > stop`**
|
||||||
|
|
||||||
|
Stops the main program loop (which was defined by passing it to `defineMain`).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// if gameover, or something
|
||||||
|
stop();
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`World > step`**
|
||||||
|
|
||||||
|
Calls all systems once. Passes the `WorldState` to each system. You should do this in your main program loop, e.g.:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const main = () => {
|
||||||
|
step();
|
||||||
|
};
|
||||||
|
|
||||||
|
defineMain(main);
|
||||||
|
|
||||||
|
start();
|
||||||
|
|
||||||
|
// later on:
|
||||||
|
stop();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Extras
|
||||||
|
|
||||||
|
Some _completely optional_ extras are provided.
|
||||||
|
|
||||||
|
### Keyboard, mouse and gamepad input
|
||||||
|
|
||||||
|
#### Input system
|
||||||
|
|
||||||
|
This input system recognizes keyboard, mouse and gamepad input and has a simple API.
|
||||||
|
|
||||||
|
There is a provided input system that is responsible for deriving the state of devices from their inputs. Import it, and make sure it's called _before_ any systems that depend on the latest input state.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { inputSystem } from "@prsm/ngn/input";
|
||||||
|
|
||||||
|
world.addSystem(inputSystem);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ButtonState
|
||||||
|
|
||||||
|
For keyboard and mouse devices, the state of a button is represented as a `ButtonState` object:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface ButtonState {
|
||||||
|
// This is true for one frame only.
|
||||||
|
justPressed: boolean;
|
||||||
|
// This is true for as long as the button is being pressed.
|
||||||
|
pressed: boolean;
|
||||||
|
// This is true for one frame only.
|
||||||
|
justReleased: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Gamepad button state is represented as a `GamepadButtonState` object:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface GamepadButtonState extends ButtonState {
|
||||||
|
// This is true for as long as the button is being touched (e.g. the touchpad on a PS5 controller)
|
||||||
|
touched: boolean;
|
||||||
|
// This is the value of the button, between 0 and 1. For triggers, this is the amount the trigger is pressed.
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Mouse
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { mouse } from "@prsm/ngn/input";
|
||||||
|
```
|
||||||
|
|
||||||
|
- `useMapping`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
mouse.useMapping(m: MouseMapping): void
|
||||||
|
```
|
||||||
|
|
||||||
|
Defines a human-readable mapping to mouse buttons and axes.
|
||||||
|
|
||||||
|
By default, the [`StandardMouse`](./src/packages/input/devices/mappings/mouse.ts) mapping is used and you probably don't need to call this.
|
||||||
|
|
||||||
|
- `getButton`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
mouse.getButton(): ButtonState
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns the state of a mouse button, e.g.:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { pressed, justPressed, justReleased } = mouse.getButton("Mouse1");
|
||||||
|
```
|
||||||
|
|
||||||
|
- `getAxis`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
mouse.getAxis(axis: string): number
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns the value of a mouse axis.
|
||||||
|
With the `StandardMouse` mapping, the axes are: `Horizontal`, `Vertical`, and `Wheel`.
|
||||||
|
|
||||||
|
- `getPosition`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
mouse.getPosition(): { x: number, y: number }
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns the position of the mouse relative to the window.
|
||||||
|
|
||||||
|
- `getAcceleration`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
mouse.getAcceleration(): number
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns the acceleration of the mouse.
|
||||||
|
|
||||||
|
#### Keyboard
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { keyboard } from "@prsm/ngn/input";
|
||||||
|
```
|
||||||
|
|
||||||
|
- `useMapping`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
keyboard.useMapping(m: KeyboardMapping): void
|
||||||
|
```
|
||||||
|
|
||||||
|
Defines a human-readable mapping to keyboard keys.
|
||||||
|
|
||||||
|
By default, the [`StandardKeyboard`](./src/packages/input/devices/mappings/keyboard.ts) mapping is used and you probably don't need to call this, unless you want to rename some keys:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { StandardKeyboard } from "@prsm/ngn";
|
||||||
|
|
||||||
|
const MyKeyboardMapping = (): KeyboardMapping => {
|
||||||
|
return {
|
||||||
|
...StandardKeyboard(),
|
||||||
|
[KeyboardKey.Space]: "Jump",
|
||||||
|
[KeyboardKey.KeyC]: "FireLazerz",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
keyboard.useMapping(MyKeyboardMapping);
|
||||||
|
keyboard.getKey("FireLazerz");
|
||||||
|
```
|
||||||
|
|
||||||
|
- `getKey`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
keyboard.getKey(b: string): ButtonState
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns the state of a keyboard key. The key should be the human readable name value defined in the mapping used.
|
||||||
|
|
||||||
|
#### Gamepad
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { gamepad } from "@prsm/ngn/input";
|
||||||
|
```
|
||||||
|
|
||||||
|
- `useMapping`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
gamepad(index: number).useMapping(m: GamepadMapping): void
|
||||||
|
```
|
||||||
|
|
||||||
|
Defines a human-readable mapping to gamepad buttons and axes.
|
||||||
|
|
||||||
|
The default mapping is assigned by inspecting the `Gamepad.id` property.
|
||||||
|
|
||||||
|
You can see all of the built-in mappings [`here`](./src/packages/input/devices/mappings/gamepad.ts), which includes mappings for PlayStation5, Xbox, and SCUF Vantage 2 controllers.
|
||||||
|
|
||||||
|
_PRs that add additional mappings are welcome_!
|
||||||
|
|
||||||
|
- `getButton`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
gamepad(index: number).getButton(button: string): GamepadButtonState
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns the state of a gamepad button.
|
||||||
|
|
||||||
|
- `getAxis`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
gamepad(index: number).getAxis(axis: string): number
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns the value of a gamepad axis.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (gamepad(0).getAxis("Look") < 0) {
|
||||||
|
/* Left */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `device`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
gamepad(index: number).device: Gamepad
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns the Gamepad object from the navigator at the provided index.
|
||||||
|
|
||||||
|
- `rumble`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
gamepad(index: number).rumble(options: RumbleOptions): void
|
||||||
|
```
|
||||||
|
|
||||||
|
Rumble the device.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
gamepad(1).rumble({
|
||||||
|
startDelay: 0,
|
||||||
|
duration: 500,
|
||||||
|
strongMagnitude: 1.0,
|
||||||
|
weakMagnitude: 1.0,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Input usage examples
|
||||||
|
|
||||||
|
##### Gamepad
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { keyboard, mouse, gamepad } from "@prsm/ngn/input";
|
||||||
|
|
||||||
|
if (gamepad(0).getAxis("Look") < 0) {
|
||||||
|
/* Left */
|
||||||
|
}
|
||||||
|
if (gamepad(0).getAxis("Look") > 0) {
|
||||||
|
/* Right */
|
||||||
|
}
|
||||||
|
|
||||||
|
gamepad(1).rumble({
|
||||||
|
startDelay: 0,
|
||||||
|
duration: 500,
|
||||||
|
strongMagnitude: 1.0,
|
||||||
|
weakMagnitude: 1.0,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Keyboard
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { keyboard } from "@prsm/ngn/input";
|
||||||
|
|
||||||
|
if (keyboard.getKey("Space").justPressed) {
|
||||||
|
/* Jump! */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Mouse
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { mouse } from "@prsm/ngn/input";
|
||||||
|
|
||||||
|
if (mouse.getAxis("Wheel")) {
|
||||||
|
/* Scrolling */
|
||||||
|
}
|
||||||
|
if (mouse.getAcceleration() > 5) {
|
||||||
|
/* Woah, slow down */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Expiring log system
|
||||||
|
|
||||||
|
- **`logSystem`**
|
||||||
|
|
||||||
|
This log system takes advantage of `state.time.delta` to expire log entries over
|
||||||
|
time. By default, this is 10 seconds, but this is configurable.
|
||||||
|
|
||||||
|
The whole point of this system is to draw debug messages to a canvas, but have them disappear after a while.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createLogSystem, type WorldState } from "@prsm/ngn";
|
||||||
|
|
||||||
|
const logSystem = createLogSystem({ maxLifetime: 5_000 });
|
||||||
|
|
||||||
|
const logDrawSystem = (state: WorldState) => {
|
||||||
|
logSystem.expiringLogs.forEach(({ message }, index) => {
|
||||||
|
drawTextToCanvas(message, { x: 0, y: index * 20 });
|
||||||
|
});
|
||||||
|
|
||||||
|
logSystem.update(state);
|
||||||
|
};
|
||||||
|
|
||||||
|
addSystem(logDrawSystem);
|
||||||
|
|
||||||
|
logSystem.log("some useful debug message");
|
||||||
|
```
|
||||||
BIN
packages/ngn/bun.lockb
Executable file
BIN
packages/ngn/bun.lockb
Executable file
Binary file not shown.
43
packages/ngn/package.json
Normal file
43
packages/ngn/package.json
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"name": "@prsm/ngn",
|
||||||
|
"version": "1.5.5",
|
||||||
|
"description": "",
|
||||||
|
"author": "nvms <pyersjonathan@gmail.com>",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "npm run build:core && npm run build:packages:input && npm run build:packages:2d",
|
||||||
|
"build:core": "tsup src/index.ts --format cjs,esm --dts --minify --clean",
|
||||||
|
"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",
|
||||||
|
"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",
|
||||||
|
"serve": "esr --serve src/demo.ts"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"require": "./dist/index.cjs",
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts"
|
||||||
|
},
|
||||||
|
"./input": {
|
||||||
|
"require": "./dist/index.cjs",
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public",
|
||||||
|
"registry": "https://registry.npmjs.org/"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.5.1",
|
||||||
|
"@types/web": "^0.0.157",
|
||||||
|
"bumpp": "^9.1.0",
|
||||||
|
"manten": "^0.3.0",
|
||||||
|
"nodemon": "^2.0.20",
|
||||||
|
"tsup": "^6.7.0",
|
||||||
|
"typescript": "^4.8.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
906
packages/ngn/public/demo.js
Executable file
906
packages/ngn/public/demo.js
Executable file
@ -0,0 +1,906 @@
|
|||||||
|
// src/misc.ts
|
||||||
|
function pulse(time, freq = 1, min = 0, max = 1) {
|
||||||
|
const halfRange = (max - min) / 2;
|
||||||
|
return min + halfRange * (1 + Math.sin(2 * Math.PI * freq * time));
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/ids.ts
|
||||||
|
var HEX = [];
|
||||||
|
for (let i = 0; i < 256; i++) {
|
||||||
|
HEX[i] = (i + 256).toString(16).substring(1);
|
||||||
|
}
|
||||||
|
function pad(str, size) {
|
||||||
|
const s = "000000" + str;
|
||||||
|
return s.substring(s.length - size);
|
||||||
|
}
|
||||||
|
var SHARD_COUNT = 32;
|
||||||
|
function getCreateId(opts) {
|
||||||
|
const len = opts.len || 16;
|
||||||
|
let str = "";
|
||||||
|
let num = 0;
|
||||||
|
const discreteValues = 1679616;
|
||||||
|
let current = opts.init + Math.ceil(discreteValues / 2);
|
||||||
|
function counter() {
|
||||||
|
current = current <= discreteValues ? current : 0;
|
||||||
|
current++;
|
||||||
|
return (current - 1).toString(16);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (!str || num === 256) {
|
||||||
|
str = "";
|
||||||
|
num = (1 + len) / 2 | 0;
|
||||||
|
while (num--)
|
||||||
|
str += HEX[256 * Math.random() | 0];
|
||||||
|
str = str.substring(num = 0, len);
|
||||||
|
}
|
||||||
|
const date = Date.now().toString(36);
|
||||||
|
const paddedCounter = pad(counter(), 6);
|
||||||
|
const hex = HEX[num++];
|
||||||
|
const shardKey = parseInt(hex, 16) % SHARD_COUNT;
|
||||||
|
return `ngn${date}${paddedCounter}${hex}${str}${shardKey}`;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/ngn.ts
|
||||||
|
var createId = getCreateId({ init: 0, len: 4 });
|
||||||
|
var $eciMap = Symbol();
|
||||||
|
var $ceMap = Symbol();
|
||||||
|
var $eMap = Symbol();
|
||||||
|
var $queryResults = Symbol();
|
||||||
|
var $dirtyQueries = Symbol();
|
||||||
|
var $queryDependencies = Symbol();
|
||||||
|
var $systems = Symbol();
|
||||||
|
var $running = Symbol();
|
||||||
|
var $onEntityCreated = Symbol();
|
||||||
|
var $mainLoop = Symbol();
|
||||||
|
var createWorld = () => {
|
||||||
|
const state = {
|
||||||
|
[$eciMap]: {},
|
||||||
|
[$ceMap]: {},
|
||||||
|
[$eMap]: {},
|
||||||
|
[$dirtyQueries]: /* @__PURE__ */ new Set(),
|
||||||
|
[$queryDependencies]: /* @__PURE__ */ new Map(),
|
||||||
|
[$queryResults]: {},
|
||||||
|
[$systems]: [],
|
||||||
|
[$mainLoop]: null,
|
||||||
|
time: {
|
||||||
|
elapsed: 0,
|
||||||
|
delta: 0,
|
||||||
|
loopDelta: 0,
|
||||||
|
lastLoopDelta: 0,
|
||||||
|
scale: 1,
|
||||||
|
fps: 0
|
||||||
|
},
|
||||||
|
[$running]: false,
|
||||||
|
[$onEntityCreated]: []
|
||||||
|
};
|
||||||
|
const defineMain2 = (callback) => {
|
||||||
|
state[$mainLoop] = callback;
|
||||||
|
};
|
||||||
|
const start2 = () => {
|
||||||
|
let then = 0;
|
||||||
|
let accumulator = 0;
|
||||||
|
const boundLoop = handler.bind(start2);
|
||||||
|
let loopHandler = -1;
|
||||||
|
const { time } = state;
|
||||||
|
time.delta = 0;
|
||||||
|
time.elapsed = 0;
|
||||||
|
time.fps = 0;
|
||||||
|
state[$running] = true;
|
||||||
|
let raf = null;
|
||||||
|
let craf = null;
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
raf = requestAnimationFrame;
|
||||||
|
craf = cancelAnimationFrame;
|
||||||
|
} else {
|
||||||
|
let now = 0;
|
||||||
|
raf = (cb) => {
|
||||||
|
return setTimeout(() => {
|
||||||
|
now += 16.67;
|
||||||
|
cb(now);
|
||||||
|
}, 16.67);
|
||||||
|
};
|
||||||
|
craf = (id) => {
|
||||||
|
clearTimeout(id);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let xfps = 1;
|
||||||
|
const xtimes = [];
|
||||||
|
function handler(now) {
|
||||||
|
if (!state[$running])
|
||||||
|
return craf(loopHandler);
|
||||||
|
while (xtimes.length > 0 && xtimes[0] <= now - 1e3) {
|
||||||
|
xtimes.shift();
|
||||||
|
}
|
||||||
|
xtimes.push(now);
|
||||||
|
xfps = xtimes.length;
|
||||||
|
time.fps = xfps;
|
||||||
|
time.delta = now - then;
|
||||||
|
then = now;
|
||||||
|
accumulator += time.delta * time.scale;
|
||||||
|
const stepThreshold = 1e3 / (time.fps || 60);
|
||||||
|
while (accumulator >= stepThreshold) {
|
||||||
|
time.loopDelta = now - time.lastLoopDelta;
|
||||||
|
time.lastLoopDelta = now;
|
||||||
|
state[$mainLoop](state);
|
||||||
|
accumulator -= stepThreshold;
|
||||||
|
}
|
||||||
|
time.elapsed += time.delta * 1e-3;
|
||||||
|
loopHandler = raf(boundLoop);
|
||||||
|
}
|
||||||
|
loopHandler = raf(boundLoop);
|
||||||
|
return () => state[$running] = false;
|
||||||
|
};
|
||||||
|
const stop = () => {
|
||||||
|
state[$running] = false;
|
||||||
|
};
|
||||||
|
function step2() {
|
||||||
|
for (const system of state[$systems]) {
|
||||||
|
system(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function addSystem2(...systems) {
|
||||||
|
for (const system of systems) {
|
||||||
|
if (typeof system === "function") {
|
||||||
|
state[$systems].push(system);
|
||||||
|
} else if (system.update && typeof system.update === "function") {
|
||||||
|
state[$systems].push(system.update);
|
||||||
|
} else {
|
||||||
|
throw new Error(`Not a valid system: ${JSON.stringify(system)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function removeSystem(...systems) {
|
||||||
|
for (const system of systems) {
|
||||||
|
if (typeof system === "function") {
|
||||||
|
state[$systems] = state[$systems].filter((s) => s !== system);
|
||||||
|
} else if (system.update && typeof system.update === "function") {
|
||||||
|
state[$systems] = state[$systems].filter((s) => s !== system.update);
|
||||||
|
} else {
|
||||||
|
throw new TypeError("Parameter must be a function or an object with an update function.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const getQuery = (queryConfig, queryName) => {
|
||||||
|
if (!state[$dirtyQueries].has(queryName) && state[$queryResults][queryName]) {
|
||||||
|
return state[$queryResults][queryName].results;
|
||||||
|
}
|
||||||
|
const { and = [], or = [], not = [], tag = [] } = queryConfig;
|
||||||
|
const entities = Object.values(state[$eMap]).filter((entity) => {
|
||||||
|
return (!not.length || !not.some((component) => entity.hasComponent(component))) && (!and.length || and.every((component) => entity.hasComponent(component))) && (!or.length || or.some((component) => entity.hasComponent(component))) && (!tag.length || tag.some((t) => entity.tag === t));
|
||||||
|
});
|
||||||
|
state[$queryResults][queryName] = {
|
||||||
|
results: entities.map((entity) => {
|
||||||
|
const result = { entity };
|
||||||
|
entity.components.forEach((component) => {
|
||||||
|
result[component.__ngn__.name] = component;
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
state[$dirtyQueries].delete(queryName);
|
||||||
|
return state[$queryResults][queryName].results;
|
||||||
|
};
|
||||||
|
const markQueryDirty = (queryName) => {
|
||||||
|
state[$dirtyQueries].add(queryName);
|
||||||
|
};
|
||||||
|
const query = ({ and = [], or = [], not = [], tag = [] }) => {
|
||||||
|
const validQuery = (c) => Object.prototype.hasOwnProperty.call(c, "name");
|
||||||
|
if (![...and, ...or, ...not].every(validQuery))
|
||||||
|
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("");
|
||||||
|
[...and, ...or, ...not].forEach((c) => {
|
||||||
|
const dependencies = state[$queryDependencies].get(c.name) || /* @__PURE__ */ new Set();
|
||||||
|
dependencies.add(queryName);
|
||||||
|
state[$queryDependencies].set(c.name, dependencies);
|
||||||
|
});
|
||||||
|
tag.forEach((t) => {
|
||||||
|
const tagKey = `tag:${t}`;
|
||||||
|
const dependencies = state[$queryDependencies].get(tagKey) || /* @__PURE__ */ new Set();
|
||||||
|
dependencies.add(queryName);
|
||||||
|
state[$queryDependencies].set(tagKey, dependencies);
|
||||||
|
});
|
||||||
|
return (queryImpl) => queryImpl(getQuery({ and, or, not, tag }, queryName));
|
||||||
|
};
|
||||||
|
function destroyEntity(e) {
|
||||||
|
const exists = state[$eMap][e.id];
|
||||||
|
if (!exists)
|
||||||
|
return false;
|
||||||
|
const componentsToRemove = Object.keys(state[$eciMap][e.id]);
|
||||||
|
componentsToRemove.forEach((componentName) => {
|
||||||
|
state[$ceMap][componentName] = state[$ceMap][componentName].filter((id) => id !== e.id);
|
||||||
|
});
|
||||||
|
delete state[$eciMap][e.id];
|
||||||
|
delete state[$eMap][e.id];
|
||||||
|
componentsToRemove.forEach((componentName) => {
|
||||||
|
const affectedQueries = state[$queryDependencies].get(componentName);
|
||||||
|
if (affectedQueries) {
|
||||||
|
affectedQueries.forEach(markQueryDirty);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function onEntityCreated(fn) {
|
||||||
|
if (typeof fn !== "function")
|
||||||
|
return;
|
||||||
|
state[$onEntityCreated].push(fn);
|
||||||
|
return () => {
|
||||||
|
state[$onEntityCreated] = state[$onEntityCreated].filter((f) => f !== fn);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function createComponent(entity, component, defaults = {}) {
|
||||||
|
if (state[$eciMap]?.[entity.id]?.[component.name] !== void 0)
|
||||||
|
return entity;
|
||||||
|
const affectedQueries = state[$queryDependencies].get(component.name);
|
||||||
|
if (affectedQueries) {
|
||||||
|
affectedQueries.forEach(markQueryDirty);
|
||||||
|
}
|
||||||
|
const componentInstance = component();
|
||||||
|
if (componentInstance.onAttach && typeof componentInstance.onAttach === "function") {
|
||||||
|
componentInstance.onAttach(entity);
|
||||||
|
}
|
||||||
|
entity.components.push(
|
||||||
|
Object.assign(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
...componentInstance,
|
||||||
|
...defaults,
|
||||||
|
__ngn__: {
|
||||||
|
parent: entity.id,
|
||||||
|
name: component.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
state[$eciMap][entity.id] = state[$eciMap][entity.id] || {};
|
||||||
|
state[$eciMap][entity.id][component.name] = entity.components.length - 1;
|
||||||
|
state[$ceMap][component.name] = state[$ceMap][component.name] || [];
|
||||||
|
state[$ceMap][component.name].push(entity.id);
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
function createEntity(spec = {}) {
|
||||||
|
const id = spec.id ?? createId();
|
||||||
|
const components = [];
|
||||||
|
const tagKey = (t) => `tag:${t}`;
|
||||||
|
function updateTagQueries(tagKey2) {
|
||||||
|
const affectedQueries = state[$queryDependencies].get(tagKey2);
|
||||||
|
if (affectedQueries) {
|
||||||
|
affectedQueries.forEach(markQueryDirty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function addTag(t) {
|
||||||
|
const previousTagKey = tagKey(this.tag);
|
||||||
|
this.tag = t;
|
||||||
|
updateTagQueries(tagKey(t));
|
||||||
|
updateTagQueries(previousTagKey);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
function removeTag() {
|
||||||
|
const previousTagKey = tagKey(this.tag);
|
||||||
|
this.tag = "";
|
||||||
|
updateTagQueries(previousTagKey);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
function getTag() {
|
||||||
|
return this.tag;
|
||||||
|
}
|
||||||
|
function addComponent(c, defaults = {}) {
|
||||||
|
return createComponent(this, c, defaults);
|
||||||
|
}
|
||||||
|
function hasComponent(component) {
|
||||||
|
return state[$eciMap]?.[id]?.[component.name] !== void 0;
|
||||||
|
}
|
||||||
|
function getComponent(arg) {
|
||||||
|
const index = state[$eciMap][id][arg.name];
|
||||||
|
return components[index];
|
||||||
|
}
|
||||||
|
function removeComponent(component) {
|
||||||
|
const name = typeof component === "string" ? component : component.name;
|
||||||
|
const componentInstance = getComponent(typeof component === "string" ? { name } : component);
|
||||||
|
if (componentInstance && componentInstance.onDetach && typeof componentInstance.onDetach === "function") {
|
||||||
|
componentInstance.onDetach(this);
|
||||||
|
}
|
||||||
|
const affectedQueries = state[$queryDependencies].get(name);
|
||||||
|
if (affectedQueries) {
|
||||||
|
affectedQueries.forEach(markQueryDirty);
|
||||||
|
}
|
||||||
|
state[$eciMap][id][name] = void 0;
|
||||||
|
state[$ceMap][name] = state[$ceMap][name].filter((e) => e !== id);
|
||||||
|
const index = state[$eciMap][id][name];
|
||||||
|
components.splice(index, 1);
|
||||||
|
Object.keys(state[$eciMap][id]).forEach((componentName) => {
|
||||||
|
if (state[$eciMap][id][componentName] > components.findIndex((c) => c.name === componentName)) {
|
||||||
|
state[$eciMap][id][componentName]--;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
function destroy() {
|
||||||
|
return destroyEntity(this);
|
||||||
|
}
|
||||||
|
const entity = Object.assign({}, spec, {
|
||||||
|
id,
|
||||||
|
components,
|
||||||
|
addTag,
|
||||||
|
removeTag,
|
||||||
|
getTag,
|
||||||
|
addComponent,
|
||||||
|
hasComponent,
|
||||||
|
getComponent,
|
||||||
|
removeComponent,
|
||||||
|
destroy
|
||||||
|
});
|
||||||
|
if (spec.id !== void 0 && state[$eMap][spec.id]) {
|
||||||
|
migrateEntityId(spec.id, createId());
|
||||||
|
}
|
||||||
|
state[$eMap][id] = entity;
|
||||||
|
state[$eciMap][id] = {};
|
||||||
|
state[$onEntityCreated].forEach((fn) => {
|
||||||
|
fn(entity);
|
||||||
|
});
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
function migrateEntityId(oldId, newId) {
|
||||||
|
const entity = state[$eMap][oldId];
|
||||||
|
if (!entity)
|
||||||
|
return;
|
||||||
|
entity.id = newId;
|
||||||
|
state[$eMap][newId] = entity;
|
||||||
|
delete state[$eMap][oldId];
|
||||||
|
state[$eciMap][newId] = state[$eciMap][oldId];
|
||||||
|
delete state[$eciMap][oldId];
|
||||||
|
}
|
||||||
|
function getEntity(id) {
|
||||||
|
return state[$eMap][id];
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
query,
|
||||||
|
createEntity,
|
||||||
|
getEntity,
|
||||||
|
onEntityCreated,
|
||||||
|
addSystem: addSystem2,
|
||||||
|
removeSystem,
|
||||||
|
start: start2,
|
||||||
|
stop,
|
||||||
|
step: step2,
|
||||||
|
defineMain: defineMain2
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// src/packages/2d/canvas.ts
|
||||||
|
var createCanvas = (options) => {
|
||||||
|
const canvas2 = document.createElement("canvas");
|
||||||
|
const { target, fullscreen } = options;
|
||||||
|
const { body } = window.document;
|
||||||
|
if (target && fullscreen) {
|
||||||
|
options.target = null;
|
||||||
|
} else if (!target && !fullscreen) {
|
||||||
|
options.fullscreen = true;
|
||||||
|
}
|
||||||
|
if (fullscreen) {
|
||||||
|
Object.assign(canvas2.style, {
|
||||||
|
position: "absolute",
|
||||||
|
top: "0",
|
||||||
|
left: "0"
|
||||||
|
});
|
||||||
|
canvas2.width = window.innerWidth;
|
||||||
|
canvas2.height = window.innerHeight;
|
||||||
|
body.appendChild(canvas2);
|
||||||
|
Object.assign(body.style, {
|
||||||
|
margin: "0",
|
||||||
|
padding: "0",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
overflow: "hidden"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (target) {
|
||||||
|
target.appendChild(canvas2);
|
||||||
|
target.style.overflow = "hidden";
|
||||||
|
canvas2.width = canvas2.offsetWidth;
|
||||||
|
canvas2.height = canvas2.offsetHeight;
|
||||||
|
}
|
||||||
|
canvas2.width = canvas2.offsetWidth;
|
||||||
|
canvas2.height = canvas2.offsetHeight;
|
||||||
|
canvas2.style.width = "100%";
|
||||||
|
canvas2.style.height = "100%";
|
||||||
|
const existingMeta = window.document.querySelector(`meta[name="viewport"]`);
|
||||||
|
if (existingMeta) {
|
||||||
|
Object.assign(existingMeta, {
|
||||||
|
content: "width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const meta = Object.assign(window.document.createElement("meta"), {
|
||||||
|
name: "viewport",
|
||||||
|
content: "width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"
|
||||||
|
});
|
||||||
|
window.document.head.appendChild(meta);
|
||||||
|
}
|
||||||
|
return canvas2;
|
||||||
|
};
|
||||||
|
|
||||||
|
// src/packages/2d/draw.ts
|
||||||
|
var fastRound = (num) => ~~(0.5 + num);
|
||||||
|
var fastRoundVector2 = (v) => ({
|
||||||
|
x: fastRound(v.x),
|
||||||
|
y: fastRound(v.y)
|
||||||
|
});
|
||||||
|
var createDraw = (context) => {
|
||||||
|
const text = (v, text2, color = "black", size = 16) => {
|
||||||
|
v = fastRoundVector2(v);
|
||||||
|
context.save();
|
||||||
|
context.fillStyle = color;
|
||||||
|
context.font = `${size}px sans-serif`;
|
||||||
|
context.fillText(text2, v.x, v.y);
|
||||||
|
context.restore();
|
||||||
|
};
|
||||||
|
const line = (from, to, color = "black", lineWidth = 1) => {
|
||||||
|
from = fastRoundVector2(from);
|
||||||
|
to = fastRoundVector2(to);
|
||||||
|
context.save();
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(from.x, from.y);
|
||||||
|
context.lineTo(to.x, to.y);
|
||||||
|
context.strokeStyle = color;
|
||||||
|
context.lineWidth = lineWidth;
|
||||||
|
context.stroke();
|
||||||
|
context.closePath();
|
||||||
|
context.restore();
|
||||||
|
};
|
||||||
|
const rectangle = (pos, dimensions, color = "black", lineWidth = 1) => {
|
||||||
|
pos = fastRoundVector2(pos);
|
||||||
|
dimensions = fastRoundVector2(dimensions);
|
||||||
|
context.save();
|
||||||
|
context.beginPath();
|
||||||
|
context.rect(pos.x, pos.y, dimensions.x, dimensions.y);
|
||||||
|
context.lineWidth = lineWidth;
|
||||||
|
context.strokeStyle = color;
|
||||||
|
context.stroke();
|
||||||
|
context.closePath();
|
||||||
|
context.restore();
|
||||||
|
};
|
||||||
|
const circle = (pos, radius = 25, color = "black", lineWidth = 1) => {
|
||||||
|
pos = fastRoundVector2(pos);
|
||||||
|
context.save();
|
||||||
|
context.beginPath();
|
||||||
|
context.strokeStyle = color;
|
||||||
|
context.lineWidth = lineWidth;
|
||||||
|
context.arc(pos.x, pos.y, radius, 0, Math.PI * 2, true);
|
||||||
|
context.stroke();
|
||||||
|
context.closePath();
|
||||||
|
context.restore();
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
line,
|
||||||
|
rectangle,
|
||||||
|
circle
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// src/packages/2d/create2d.ts
|
||||||
|
var create2D = (options) => {
|
||||||
|
const { width = 800, height = 600, fullscreen = false, target = null } = options.canvas;
|
||||||
|
const canvas2 = createCanvas({ width, height, fullscreen, target });
|
||||||
|
const context = canvas2.getContext("2d");
|
||||||
|
const draw2 = createDraw(context);
|
||||||
|
const onWindowResize = () => {
|
||||||
|
if (fullscreen) {
|
||||||
|
canvas2.style.width = "100%";
|
||||||
|
canvas2.style.height = "100%";
|
||||||
|
canvas2.width = window.innerWidth;
|
||||||
|
canvas2.height = window.innerHeight;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
canvas2.width = canvas2.offsetWidth;
|
||||||
|
canvas2.height = canvas2.offsetHeight;
|
||||||
|
};
|
||||||
|
window.addEventListener("resize", onWindowResize);
|
||||||
|
const destroy = () => {
|
||||||
|
window.removeEventListener("resize", onWindowResize);
|
||||||
|
canvas2.parentElement.removeChild(canvas2);
|
||||||
|
};
|
||||||
|
return { canvas: canvas2, context, draw: draw2, destroy };
|
||||||
|
};
|
||||||
|
|
||||||
|
// src/packages/emitter/index.ts
|
||||||
|
var getDefaultParticleEmitterOptions = (opts) => ({
|
||||||
|
...opts,
|
||||||
|
x: opts.x ?? 0,
|
||||||
|
y: opts.y ?? 0,
|
||||||
|
maxParticles: opts.maxParticles ?? 100,
|
||||||
|
rate: opts.rate ?? 1,
|
||||||
|
lifetime: opts.lifetime ?? 1e3,
|
||||||
|
lifetimeVariation: opts.lifetimeVariation ?? 0,
|
||||||
|
size: opts.size ?? 5,
|
||||||
|
sizeVariation: opts.sizeVariation ?? 0,
|
||||||
|
colorStart: opts.colorStart ?? "#000000",
|
||||||
|
colorEnd: opts.colorEnd ?? "#000000",
|
||||||
|
colorEasing: opts.colorEasing ?? "linear" /* LINEAR */,
|
||||||
|
angle: opts.angle ?? 0,
|
||||||
|
spread: opts.spread ?? 0,
|
||||||
|
gravity: opts.gravity ?? { x: 0, y: 0 },
|
||||||
|
speed: opts.speed ?? 0.1,
|
||||||
|
speedVariation: opts.speedVariation ?? 0,
|
||||||
|
canvas: opts.canvas,
|
||||||
|
burst: opts.burst ?? false
|
||||||
|
});
|
||||||
|
var interpolateColor = (colorStart, colorEnd, factor, easing) => {
|
||||||
|
switch (easing) {
|
||||||
|
case "easeIn" /* EASE_IN */:
|
||||||
|
factor = Math.pow(factor, 2);
|
||||||
|
break;
|
||||||
|
case "easeOut" /* EASE_OUT */:
|
||||||
|
factor = 1 - Math.pow(1 - factor, 2);
|
||||||
|
break;
|
||||||
|
case "easeInOut" /* EASE_IN_OUT */:
|
||||||
|
if (factor < 0.5) {
|
||||||
|
factor = 2 * Math.pow(factor, 2);
|
||||||
|
} else {
|
||||||
|
factor = 1 - 2 * Math.pow(1 - factor, 2);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "linear" /* LINEAR */:
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const color1 = parseInt(colorStart.slice(1), 16);
|
||||||
|
const color2 = parseInt(colorEnd.slice(1), 16);
|
||||||
|
const r1 = color1 >> 16 & 255;
|
||||||
|
const g1 = color1 >> 8 & 255;
|
||||||
|
const b1 = color1 & 255;
|
||||||
|
const r2 = color2 >> 16 & 255;
|
||||||
|
const g2 = color2 >> 8 & 255;
|
||||||
|
const b2 = color2 & 255;
|
||||||
|
const r = Math.round(r1 + factor * (r2 - r1));
|
||||||
|
const g = Math.round(g1 + factor * (g2 - g1));
|
||||||
|
const b = Math.round(b1 + factor * (b2 - b1));
|
||||||
|
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
|
||||||
|
};
|
||||||
|
var hexToRgb = (hex) => {
|
||||||
|
const color = parseInt(hex.slice(1), 16);
|
||||||
|
const r = color >> 16 & 255;
|
||||||
|
const g = color >> 8 & 255;
|
||||||
|
const b = color & 255;
|
||||||
|
return `${r}, ${g}, ${b}`;
|
||||||
|
};
|
||||||
|
var createParticleEmitter = (opts) => {
|
||||||
|
opts = getDefaultParticleEmitterOptions(opts);
|
||||||
|
const particles = [];
|
||||||
|
let timeSinceLastEmission = 0;
|
||||||
|
const emissionInterval = 1 / opts.rate;
|
||||||
|
const lifetimeVariation = opts.lifetimeVariation ?? 0;
|
||||||
|
const context = opts.canvas.getContext("2d");
|
||||||
|
const angleInRadians = opts.angle * (Math.PI / 180);
|
||||||
|
let dead = false;
|
||||||
|
let paused = false;
|
||||||
|
const update = (state) => {
|
||||||
|
if (dead)
|
||||||
|
return;
|
||||||
|
context.globalCompositeOperation = opts.blendMode ?? "source-over";
|
||||||
|
const { loopDelta } = state.time;
|
||||||
|
for (let i = particles.length - 1; i >= 0; i--) {
|
||||||
|
const particle = particles[i];
|
||||||
|
const lifeFactor = particle.lifetime / opts.lifetime;
|
||||||
|
let opacity = 1;
|
||||||
|
particle.color = interpolateColor(particle.colorStart, particle.colorEnd, 1 - lifeFactor, opts.colorEasing);
|
||||||
|
if (opts.fadeOutEasing) {
|
||||||
|
switch (opts.fadeOutEasing) {
|
||||||
|
case "easeIn" /* EASE_IN */:
|
||||||
|
opacity = Math.pow(lifeFactor, 2);
|
||||||
|
break;
|
||||||
|
case "easeOut" /* EASE_OUT */:
|
||||||
|
opacity = 1 - Math.pow(1 - lifeFactor, 2);
|
||||||
|
break;
|
||||||
|
case "easeInOut" /* EASE_IN_OUT */:
|
||||||
|
if (lifeFactor < 0.5) {
|
||||||
|
opacity = 2 * Math.pow(lifeFactor, 2);
|
||||||
|
} else {
|
||||||
|
opacity = 1 - 2 * Math.pow(1 - lifeFactor, 2);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "linear" /* LINEAR */:
|
||||||
|
default:
|
||||||
|
opacity = lifeFactor;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!paused) {
|
||||||
|
particle.x += particle.speedX * loopDelta;
|
||||||
|
particle.y += particle.speedY * loopDelta;
|
||||||
|
particle.speedX += opts.gravity.x * loopDelta / 1e3;
|
||||||
|
particle.speedY += opts.gravity.y * loopDelta / 1e3;
|
||||||
|
particle.lifetime -= state.time.loopDelta;
|
||||||
|
if (opts.onUpdate) {
|
||||||
|
opts.onUpdate(particle, state);
|
||||||
|
}
|
||||||
|
if (particle.onUpdate) {
|
||||||
|
particle.onUpdate(particle, state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (particle.lifetime <= 0) {
|
||||||
|
if (opts.onRemove) {
|
||||||
|
opts.onRemove(particle, state);
|
||||||
|
}
|
||||||
|
if (particle.onRemove) {
|
||||||
|
particle.onRemove(particle, state);
|
||||||
|
}
|
||||||
|
particles.splice(i, 1);
|
||||||
|
} else {
|
||||||
|
context.fillStyle = `rgba(${hexToRgb(particle.color)}, ${opacity})`;
|
||||||
|
context.beginPath();
|
||||||
|
context.rect(particle.x * particle.scaleX, particle.y * particle.scaleY, particle.size, particle.size);
|
||||||
|
context.closePath();
|
||||||
|
context.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const emitParticle = () => {
|
||||||
|
const lifetimeVariationAmount = lifetimeVariation ? opts.lifetime * lifetimeVariation * Math.random() : 0;
|
||||||
|
const particleLifetime = opts.lifetime + lifetimeVariationAmount * (Math.random() < 0.5 ? -1 : 1);
|
||||||
|
const colorStart = Array.isArray(opts.colorStart) ? opts.colorStart[Math.floor(Math.random() * opts.colorStart.length)] : opts.colorStart;
|
||||||
|
const colorEnd = Array.isArray(opts.colorEnd) ? opts.colorEnd[Math.floor(Math.random() * opts.colorEnd.length)] : opts.colorEnd;
|
||||||
|
const particle = spawnParticle({
|
||||||
|
x: opts.x,
|
||||||
|
y: opts.y,
|
||||||
|
colorStart,
|
||||||
|
colorEnd,
|
||||||
|
color: colorStart,
|
||||||
|
lifetime: particleLifetime,
|
||||||
|
size: Math.max(0, opts.size + (Math.random() - 0.5) * opts.sizeVariation),
|
||||||
|
speedX: opts.speed * (Math.sin(angleInRadians) + (Math.random() - 0.5) * opts.spread),
|
||||||
|
speedY: -opts.speed * (Math.cos(angleInRadians) + (Math.random() - 0.5) * opts.spread),
|
||||||
|
scaleX: 1,
|
||||||
|
scaleY: 1
|
||||||
|
});
|
||||||
|
if (opts.onInit) {
|
||||||
|
opts.onInit(particle, state);
|
||||||
|
}
|
||||||
|
if (particle.onInit) {
|
||||||
|
particle.onInit(particle, state);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (!paused) {
|
||||||
|
if (opts.burst && timeSinceLastEmission === 0) {
|
||||||
|
for (let i = 0; i < opts.maxParticles; i++) {
|
||||||
|
emitParticle();
|
||||||
|
}
|
||||||
|
timeSinceLastEmission = -1;
|
||||||
|
} else if (!opts.burst) {
|
||||||
|
timeSinceLastEmission += loopDelta;
|
||||||
|
while (timeSinceLastEmission >= emissionInterval && particles.length < opts.maxParticles) {
|
||||||
|
emitParticle();
|
||||||
|
timeSinceLastEmission -= emissionInterval;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (opts.burst && particles.length === 0) {
|
||||||
|
destroy();
|
||||||
|
}
|
||||||
|
context.globalCompositeOperation = "source-over";
|
||||||
|
};
|
||||||
|
const destroy = () => {
|
||||||
|
dead = true;
|
||||||
|
particles.length = 0;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
const spawnParticle = (p) => {
|
||||||
|
particles.push(p);
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
const pause = () => {
|
||||||
|
paused = true;
|
||||||
|
};
|
||||||
|
const resume = () => {
|
||||||
|
paused = false;
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
if (document.visibilityState === "visible") {
|
||||||
|
resume();
|
||||||
|
} else {
|
||||||
|
pause();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
particles,
|
||||||
|
update,
|
||||||
|
destroy,
|
||||||
|
pause,
|
||||||
|
resume,
|
||||||
|
set x(value) {
|
||||||
|
opts.x = value;
|
||||||
|
},
|
||||||
|
get x() {
|
||||||
|
return opts.x;
|
||||||
|
},
|
||||||
|
set y(value) {
|
||||||
|
opts.y = value;
|
||||||
|
},
|
||||||
|
get y() {
|
||||||
|
return opts.y;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
var createParticleSystem = (opts) => {
|
||||||
|
let _x = opts.x;
|
||||||
|
let _y = opts.y;
|
||||||
|
const emitters = [];
|
||||||
|
const startImmediately = opts.start ?? true;
|
||||||
|
const update = (state) => {
|
||||||
|
emitters.forEach((emitter2) => {
|
||||||
|
emitter2.update(state);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const destroy = () => {
|
||||||
|
emitters.forEach((emitter2) => {
|
||||||
|
emitter2.destroy();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const createEmitter = (opts2) => {
|
||||||
|
const emitter2 = createParticleEmitter({
|
||||||
|
...opts2
|
||||||
|
/* x: _x, */
|
||||||
|
/* y: _y, */
|
||||||
|
});
|
||||||
|
emitters.push(emitter2);
|
||||||
|
if (!startImmediately) {
|
||||||
|
emitter2.pause();
|
||||||
|
}
|
||||||
|
return emitter2;
|
||||||
|
};
|
||||||
|
const pause = () => {
|
||||||
|
emitters.forEach((emitter2) => {
|
||||||
|
emitter2.pause();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const start2 = () => {
|
||||||
|
emitters.forEach((emitter2) => {
|
||||||
|
emitter2.resume();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
update,
|
||||||
|
destroy,
|
||||||
|
createEmitter,
|
||||||
|
pause,
|
||||||
|
start: start2,
|
||||||
|
set x(value) {
|
||||||
|
_x = value;
|
||||||
|
},
|
||||||
|
get x() {
|
||||||
|
return _x;
|
||||||
|
},
|
||||||
|
set y(value) {
|
||||||
|
_y = value;
|
||||||
|
},
|
||||||
|
get y() {
|
||||||
|
return _y;
|
||||||
|
},
|
||||||
|
get numParticles() {
|
||||||
|
return emitters.reduce((acc, emitter2) => acc + emitter2.particles.length, 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// src/demo.ts
|
||||||
|
var { canvas, draw } = create2D({ canvas: { fullscreen: true } });
|
||||||
|
var particleSystem = createParticleSystem({
|
||||||
|
x: canvas.width / 2,
|
||||||
|
y: canvas.height / 2,
|
||||||
|
canvas
|
||||||
|
});
|
||||||
|
var emitter = particleSystem.createEmitter({
|
||||||
|
x: canvas.width / 2,
|
||||||
|
y: canvas.height / 2,
|
||||||
|
maxParticles: 100,
|
||||||
|
rate: 0.1,
|
||||||
|
lifetime: 1e3,
|
||||||
|
lifetimeVariation: 0.2,
|
||||||
|
size: 20,
|
||||||
|
sizeVariation: 10,
|
||||||
|
colorStart: ["#FF0000", "#ff5100"],
|
||||||
|
colorEnd: "#222222",
|
||||||
|
colorEasing: "easeIn" /* EASE_IN */,
|
||||||
|
fadeOutEasing: "easeOut" /* EASE_OUT */,
|
||||||
|
speed: 0.1,
|
||||||
|
speedVariation: 1,
|
||||||
|
angle: 0,
|
||||||
|
spread: 0.75,
|
||||||
|
gravity: { x: 0, y: 0 },
|
||||||
|
canvas,
|
||||||
|
burst: false,
|
||||||
|
onInit: (particle, state) => {
|
||||||
|
particle.x += Math.random() < 0.5 ? -6 : 6;
|
||||||
|
particle.y += Math.random() < 0.5 ? -6 : 6;
|
||||||
|
if (!(Math.random() < 0.02)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
particle.size = 15;
|
||||||
|
particle.speedY = -0.3;
|
||||||
|
particle.lifetime = 1e3;
|
||||||
|
particle.colorEnd = "#ff0000";
|
||||||
|
particle.onRemove = () => {
|
||||||
|
particleSystem.createEmitter({
|
||||||
|
x: particle.x,
|
||||||
|
y: particle.y,
|
||||||
|
maxParticles: 3,
|
||||||
|
lifetimeVariation: 0.2,
|
||||||
|
size: 3,
|
||||||
|
sizeVariation: 2,
|
||||||
|
colorStart: ["#FF0000", "#ff5100"],
|
||||||
|
colorEnd: "#222222",
|
||||||
|
colorEasing: "easeIn" /* EASE_IN */,
|
||||||
|
fadeOutEasing: "easeOut" /* EASE_OUT */,
|
||||||
|
speed: 0.02,
|
||||||
|
speedVariation: 1,
|
||||||
|
spread: 6,
|
||||||
|
angle: 180,
|
||||||
|
canvas,
|
||||||
|
burst: true
|
||||||
|
});
|
||||||
|
};
|
||||||
|
particle.onUpdate = () => {
|
||||||
|
particle.size = Math.max(0, particle.size + 0.25);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onUpdate: (particle, state) => {
|
||||||
|
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) => {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
var { addSystem, start, step, defineMain } = createWorld();
|
||||||
|
var clearCanvasSystem = () => {
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
context.fillStyle = "#111";
|
||||||
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
};
|
||||||
|
var fpsDrawSystem = (state) => {
|
||||||
|
draw.text({ x: 10, y: 20 }, `FPS: ${state.time.fps.toFixed(2)}`, "white");
|
||||||
|
};
|
||||||
|
var particleCountSystem = (state) => {
|
||||||
|
draw.text({ x: 10, y: 40 }, `Particle count: ${particleSystem.numParticles}`, "white");
|
||||||
|
};
|
||||||
|
var particlePositionSystem = (state) => {
|
||||||
|
const { time } = state;
|
||||||
|
const xPos = pulse(time.elapsed, 0.25, canvas.width / 2 - 100, canvas.width / 2 + 100);
|
||||||
|
emitter.x = xPos;
|
||||||
|
};
|
||||||
|
addSystem(clearCanvasSystem, fpsDrawSystem, particleCountSystem, particlePositionSystem, particleSystem);
|
||||||
|
defineMain(() => {
|
||||||
|
step();
|
||||||
|
});
|
||||||
|
start();
|
||||||
|
var container = document.createElement("div");
|
||||||
|
container.style.position = "fixed";
|
||||||
|
container.style.bottom = "0";
|
||||||
|
container.style.left = "0";
|
||||||
|
container.style.padding = "10px";
|
||||||
|
container.style.display = "flex";
|
||||||
|
container.style.justifyContent = "space-between";
|
||||||
|
container.style.alignItems = "center";
|
||||||
|
document.body.appendChild(container);
|
||||||
|
var pauseButton = document.createElement("button");
|
||||||
|
pauseButton.innerText = "Pause";
|
||||||
|
pauseButton.onclick = () => {
|
||||||
|
particleSystem.pause();
|
||||||
|
};
|
||||||
|
container.appendChild(pauseButton);
|
||||||
|
var startButton = document.createElement("button");
|
||||||
|
startButton.innerText = "Start";
|
||||||
|
startButton.style.marginLeft = "5px";
|
||||||
|
startButton.onclick = () => {
|
||||||
|
particleSystem.start();
|
||||||
|
};
|
||||||
|
container.appendChild(startButton);
|
||||||
|
var destroyButton = document.createElement("button");
|
||||||
|
destroyButton.innerText = "Destroy";
|
||||||
|
destroyButton.style.marginLeft = "5px";
|
||||||
|
destroyButton.onclick = () => {
|
||||||
|
particleSystem.destroy();
|
||||||
|
};
|
||||||
|
container.appendChild(destroyButton);
|
||||||
|
//# sourceMappingURL=demo.js.map
|
||||||
7
packages/ngn/public/demo.js.map
Executable file
7
packages/ngn/public/demo.js.map
Executable file
File diff suppressed because one or more lines are too long
13
packages/ngn/public/index.html
Normal file
13
packages/ngn/public/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>esr</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script src="demo.js" type="module"></script>
|
||||||
|
{{ livereload }}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
146
packages/ngn/src/demo.ts
Normal file
146
packages/ngn/src/demo.ts
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
import { pulse } from "./misc";
|
||||||
|
import { createWorld, type WorldState } from "./ngn";
|
||||||
|
import { create2D } from "./packages/2d";
|
||||||
|
import { ColorEasing, createParticleSystem, Particle } from "./packages/emitter";
|
||||||
|
|
||||||
|
const { canvas, draw } = create2D({ canvas: { fullscreen: true } });
|
||||||
|
|
||||||
|
const particleSystem = createParticleSystem({
|
||||||
|
x: canvas.width / 2,
|
||||||
|
y: canvas.height / 2,
|
||||||
|
canvas,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emitter = particleSystem.createEmitter({
|
||||||
|
x: canvas.width / 2,
|
||||||
|
y: canvas.height / 2,
|
||||||
|
maxParticles: 100,
|
||||||
|
rate: 0.1,
|
||||||
|
lifetime: 1000,
|
||||||
|
lifetimeVariation: 0.2,
|
||||||
|
size: 20,
|
||||||
|
sizeVariation: 10,
|
||||||
|
colorStart: ["#FF0000", "#ff5100"],
|
||||||
|
colorEnd: "#222222",
|
||||||
|
colorEasing: ColorEasing.EASE_IN,
|
||||||
|
fadeOutEasing: ColorEasing.EASE_OUT,
|
||||||
|
speed: 0.1,
|
||||||
|
speedVariation: 1,
|
||||||
|
angle: 0,
|
||||||
|
spread: 0.75,
|
||||||
|
gravity: { x: 0, y: 0 },
|
||||||
|
canvas,
|
||||||
|
burst: false,
|
||||||
|
|
||||||
|
onInit: (particle: Particle, state: WorldState) => {
|
||||||
|
particle.x += Math.random() < 0.5 ? -6 : 6;
|
||||||
|
particle.y += Math.random() < 0.5 ? -6 : 6;
|
||||||
|
|
||||||
|
if (!(Math.random() < 0.02)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
particle.size = 15;
|
||||||
|
particle.speedY = -0.3;
|
||||||
|
particle.lifetime = 1000;
|
||||||
|
particle.colorEnd = "#ff0000";
|
||||||
|
|
||||||
|
particle.onRemove = () => {
|
||||||
|
particleSystem.createEmitter({
|
||||||
|
x: particle.x,
|
||||||
|
y: particle.y,
|
||||||
|
maxParticles: 3,
|
||||||
|
lifetimeVariation: 0.2,
|
||||||
|
size: 3,
|
||||||
|
sizeVariation: 2,
|
||||||
|
colorStart: ["#FF0000", "#ff5100"],
|
||||||
|
colorEnd: "#222222",
|
||||||
|
colorEasing: ColorEasing.EASE_IN,
|
||||||
|
fadeOutEasing: ColorEasing.EASE_OUT,
|
||||||
|
speed: 0.02,
|
||||||
|
speedVariation: 1,
|
||||||
|
spread: 6,
|
||||||
|
angle: 180,
|
||||||
|
canvas,
|
||||||
|
burst: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
particle.onUpdate = () => {
|
||||||
|
particle.size = Math.max(0, particle.size + 0.25);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onUpdate: (particle: Particle, state: WorldState) => {
|
||||||
|
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: Particle, state: WorldState) => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { addSystem, start, step, defineMain } = createWorld();
|
||||||
|
|
||||||
|
const clearCanvasSystem = () => {
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
context.fillStyle = "#111";
|
||||||
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fpsDrawSystem = (state: WorldState) => {
|
||||||
|
draw.text({ x: 10, y: 20 }, `FPS: ${state.time.fps.toFixed(2)}`, "white");
|
||||||
|
};
|
||||||
|
|
||||||
|
const particleCountSystem = (state: WorldState) => {
|
||||||
|
draw.text({ x: 10, y: 40 }, `Particle count: ${particleSystem.numParticles}`, "white");
|
||||||
|
};
|
||||||
|
|
||||||
|
const particlePositionSystem = (state: WorldState) => {
|
||||||
|
const { time } = state;
|
||||||
|
const xPos = pulse(time.elapsed, 0.25, canvas.width / 2 - 100, canvas.width / 2 + 100);
|
||||||
|
// const yPos = pulse(time.elapsed, 0.25, canvas.height / 2 - 100, canvas.height / 2 + 100);
|
||||||
|
emitter.x = xPos;
|
||||||
|
// emitter.y = yPos;
|
||||||
|
};
|
||||||
|
|
||||||
|
addSystem(clearCanvasSystem, fpsDrawSystem, particleCountSystem, particlePositionSystem, particleSystem);
|
||||||
|
|
||||||
|
defineMain(() => {
|
||||||
|
step();
|
||||||
|
});
|
||||||
|
|
||||||
|
start();
|
||||||
|
|
||||||
|
const container = document.createElement("div");
|
||||||
|
container.style.position = "fixed";
|
||||||
|
container.style.bottom = "0";
|
||||||
|
container.style.left = "0";
|
||||||
|
container.style.padding = "10px";
|
||||||
|
container.style.display = "flex";
|
||||||
|
container.style.justifyContent = "space-between";
|
||||||
|
container.style.alignItems = "center";
|
||||||
|
document.body.appendChild(container);
|
||||||
|
|
||||||
|
const pauseButton = document.createElement("button");
|
||||||
|
pauseButton.innerText = "Pause";
|
||||||
|
pauseButton.onclick = () => {
|
||||||
|
particleSystem.pause();
|
||||||
|
};
|
||||||
|
container.appendChild(pauseButton);
|
||||||
|
|
||||||
|
const startButton = document.createElement("button");
|
||||||
|
startButton.innerText = "Start";
|
||||||
|
startButton.style.marginLeft = "5px";
|
||||||
|
startButton.onclick = () => {
|
||||||
|
particleSystem.start();
|
||||||
|
};
|
||||||
|
container.appendChild(startButton);
|
||||||
|
|
||||||
|
const destroyButton = document.createElement("button");
|
||||||
|
destroyButton.innerText = "Destroy";
|
||||||
|
destroyButton.style.marginLeft = "5px";
|
||||||
|
destroyButton.onclick = () => {
|
||||||
|
particleSystem.destroy();
|
||||||
|
};
|
||||||
|
container.appendChild(destroyButton);
|
||||||
64
packages/ngn/src/ids.ts
Normal file
64
packages/ngn/src/ids.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
/*
|
||||||
|
This is a mashup of github.com/lukeed/hexoid and github.com/paralleldrive/cuid
|
||||||
|
Both are MIT licensed.
|
||||||
|
|
||||||
|
~ https://github.com/paralleldrive/cuid/blob/f507d971a70da224d3eb447ed87ddbeb1b9fd097/LICENSE
|
||||||
|
--
|
||||||
|
MIT License
|
||||||
|
Copyright (c) 2012 Eric Elliott
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|
||||||
|
~ https://github.com/lukeed/hexoid/blob/1070447cdc62d1780d2a657b0df64348fc1e5ec5/license
|
||||||
|
--
|
||||||
|
MIT License
|
||||||
|
Copyright (c) Luke Edwards <luke.edwards05@gmail.com> (lukeed.com)
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const HEX: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 256; i++) {
|
||||||
|
HEX[i] = (i + 256).toString(16).substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(str: string, size: number) {
|
||||||
|
const s = "000000" + str;
|
||||||
|
return s.substring(s.length - size);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHARD_COUNT = 32;
|
||||||
|
|
||||||
|
export function getCreateId(opts: { init: number; len: number }) {
|
||||||
|
const len = opts.len || 16;
|
||||||
|
let str = "";
|
||||||
|
let num = 0;
|
||||||
|
const discreteValues = 1_679_616; // Math.pow(36, 4)
|
||||||
|
let current = opts.init + Math.ceil(discreteValues / 2);
|
||||||
|
|
||||||
|
function counter() {
|
||||||
|
current = current <= discreteValues ? current : 0;
|
||||||
|
current++;
|
||||||
|
return (current - 1).toString(16);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (!str || num === 256) {
|
||||||
|
str = "";
|
||||||
|
num = ((1 + len) / 2) | 0;
|
||||||
|
while (num--) str += HEX[(256 * Math.random()) | 0];
|
||||||
|
str = str.substring((num = 0), len);
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = Date.now().toString(36);
|
||||||
|
const paddedCounter = pad(counter(), 6);
|
||||||
|
const hex = HEX[num++];
|
||||||
|
|
||||||
|
const shardKey = parseInt(hex, 16) % SHARD_COUNT;
|
||||||
|
|
||||||
|
return `ngn${date}${paddedCounter}${hex}${str}${shardKey}`;
|
||||||
|
};
|
||||||
|
}
|
||||||
7
packages/ngn/src/index.ts
Normal file
7
packages/ngn/src/index.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export { Component, createWorld, Entity, QueryConfig, type ComponentInstance, type WorldState } from "./ngn";
|
||||||
|
export { create2D, createCanvas, CreateCanvasOptions, createDraw, type Vector2 } from "./packages/2d";
|
||||||
|
export { onGamepadConnected, onGamepadDisconnected } from "./packages/input/devices/gamepad";
|
||||||
|
export { GamepadMapping, PlayStation4, PlayStation5, SCUFVantage2, Xbox } from "./packages/input/devices/mappings/gamepad";
|
||||||
|
export { KeyboardKey, KeyboardMapping, StandardKeyboard } from "./packages/input/devices/mappings/keyboard";
|
||||||
|
export { MouseButton, MouseMapping, StandardMouse } from "./packages/input/devices/mappings/mouse";
|
||||||
|
export { createLogSystem } from "./packages/log";
|
||||||
233
packages/ngn/src/math/mersenne-twister.ts
Normal file
233
packages/ngn/src/math/mersenne-twister.ts
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
// source: https://github.com/boo1ean/mersenne-twister
|
||||||
|
/*
|
||||||
|
https://github.com/banksean wrapped Makoto Matsumoto and Takuji Nishimura's code in a namespace
|
||||||
|
so it's better encapsulated. Now you can have multiple random number generators
|
||||||
|
and they won't stomp all over eachother's state.
|
||||||
|
If you want to use this as a substitute for Math.random(), use the random()
|
||||||
|
method like so:
|
||||||
|
var m = new MersenneTwister();
|
||||||
|
var randomNumber = m.random();
|
||||||
|
You can also call the other genrand_{foo}() methods on the instance.
|
||||||
|
If you want to use a specific seed in order to get a repeatable random
|
||||||
|
sequence, pass an integer into the constructor:
|
||||||
|
var m = new MersenneTwister(123);
|
||||||
|
and that will always produce the same random sequence.
|
||||||
|
Sean McCullough (banksean@gmail.com)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
A C-program for MT19937, with initialization improved 2002/1/26.
|
||||||
|
Coded by Takuji Nishimura and Makoto Matsumoto.
|
||||||
|
Before using, initialize the state by using init_seed(seed)
|
||||||
|
or init_by_array(init_key, key_length).
|
||||||
|
Copyright (C) 1997 - 2002, Makoto Matsumoto and Takuji Nishimura,
|
||||||
|
All rights reserved.
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions
|
||||||
|
are met:
|
||||||
|
1. Redistributions of source code must retain the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer.
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer in the
|
||||||
|
documentation and/or other materials provided with the distribution.
|
||||||
|
3. The names of its contributors may not be used to endorse or promote
|
||||||
|
products derived from this software without specific prior written
|
||||||
|
permission.
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||||
|
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||||
|
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||||
|
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
|
||||||
|
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
||||||
|
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
||||||
|
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
||||||
|
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||||
|
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||||
|
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
Any feedback is very welcome.
|
||||||
|
http://www.math.sci.hiroshima-u.ac.jp/~m-mat/MT/emt.html
|
||||||
|
email: m-mat @ math.sci.hiroshima-u.ac.jp (remove space)
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
# Usage
|
||||||
|
var MersenneTwister = require('mersenne-twister');
|
||||||
|
var generator = new MersenneTwister();
|
||||||
|
|
||||||
|
// Generates a random number on [0,1) real interval (same interval as Math.random)
|
||||||
|
generator.random();
|
||||||
|
|
||||||
|
// [0, 4294967295]
|
||||||
|
generator.random_int();
|
||||||
|
|
||||||
|
// [0,1]
|
||||||
|
generator.random_incl();
|
||||||
|
|
||||||
|
// (0,1)
|
||||||
|
generator.random_excl();
|
||||||
|
|
||||||
|
// [0,1) with 53-bit resolution
|
||||||
|
generator.random_long();
|
||||||
|
|
||||||
|
// [0, 2147483647]
|
||||||
|
generator.random_int31();
|
||||||
|
|
||||||
|
# Seeding
|
||||||
|
If you want to use a specific seed in order to get a repeatable random sequence, pass an integer into the constructor:
|
||||||
|
|
||||||
|
var generator = new MersenneTwister(123);
|
||||||
|
and that will always produce the same random sequence.
|
||||||
|
|
||||||
|
Also you can do it on existing generator instance:
|
||||||
|
|
||||||
|
generator.init_seed(123);
|
||||||
|
*/
|
||||||
|
export class MersenneTwister {
|
||||||
|
mt;
|
||||||
|
M;
|
||||||
|
N;
|
||||||
|
MATRIX_A;
|
||||||
|
UPPER_MASK;
|
||||||
|
LOWER_MASK;
|
||||||
|
mti;
|
||||||
|
|
||||||
|
constructor(seed?) {
|
||||||
|
if (seed == undefined) {
|
||||||
|
seed = new Date().getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Period parameters */
|
||||||
|
this.N = 624;
|
||||||
|
this.M = 397;
|
||||||
|
this.MATRIX_A = 0x9908b0df; /* constant vector a */
|
||||||
|
this.UPPER_MASK = 0x80000000; /* most significant w-r bits */
|
||||||
|
this.LOWER_MASK = 0x7fffffff; /* least significant r bits */
|
||||||
|
|
||||||
|
this.mt = new Array(this.N); /* the array for the state vector */
|
||||||
|
this.mti = this.N + 1; /* mti==N+1 means mt[N] is not initialized */
|
||||||
|
|
||||||
|
if (seed.constructor == Array) {
|
||||||
|
this.init_by_array(seed, seed.length);
|
||||||
|
} else {
|
||||||
|
this.init_seed(seed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a1(txt) {
|
||||||
|
if (typeof txt === "string") {
|
||||||
|
return txt
|
||||||
|
.toLowerCase()
|
||||||
|
.split("")
|
||||||
|
.map(function (c) {
|
||||||
|
return "abcdefghijklmnopqrstuvwxyz".indexOf(c) + 1 || c;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
return txt;
|
||||||
|
}
|
||||||
|
|
||||||
|
init_seed(s) {
|
||||||
|
s = this.a1(s);
|
||||||
|
this.mt[0] = s >>> 0;
|
||||||
|
for (this.mti = 1; this.mti < this.N; this.mti++) {
|
||||||
|
var s: any = this.mt[this.mti - 1] ^ (this.mt[this.mti - 1] >>> 30);
|
||||||
|
this.mt[this.mti] = ((((s & 0xffff0000) >>> 16) * 1812433253) << 16) + (s & 0x0000ffff) * 1812433253 + this.mti;
|
||||||
|
/* See Knuth TAOCP Vol2. 3rd Ed. P.106 for multiplier. */
|
||||||
|
/* In the previous versions, MSBs of the seed affect */
|
||||||
|
/* only MSBs of the array mt[]. */
|
||||||
|
/* 2002/01/09 modified by Makoto Matsumoto */
|
||||||
|
this.mt[this.mti] >>>= 0;
|
||||||
|
/* for >32 bit machines */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init_by_array(init_key, key_length) {
|
||||||
|
var i, j, k;
|
||||||
|
this.init_seed(19650218);
|
||||||
|
i = 1;
|
||||||
|
j = 0;
|
||||||
|
k = this.N > key_length ? this.N : key_length;
|
||||||
|
for (; k; k--) {
|
||||||
|
var s = this.mt[i - 1] ^ (this.mt[i - 1] >>> 30);
|
||||||
|
this.mt[i] = (this.mt[i] ^ (((((s & 0xffff0000) >>> 16) * 1664525) << 16) + (s & 0x0000ffff) * 1664525)) + init_key[j] + j; /* non linear */
|
||||||
|
this.mt[i] >>>= 0; /* for WORDSIZE > 32 machines */
|
||||||
|
i++;
|
||||||
|
j++;
|
||||||
|
if (i >= this.N) {
|
||||||
|
this.mt[0] = this.mt[this.N - 1];
|
||||||
|
i = 1;
|
||||||
|
}
|
||||||
|
if (j >= key_length) j = 0;
|
||||||
|
}
|
||||||
|
for (k = this.N - 1; k; k--) {
|
||||||
|
var s = this.mt[i - 1] ^ (this.mt[i - 1] >>> 30);
|
||||||
|
this.mt[i] = (this.mt[i] ^ (((((s & 0xffff0000) >>> 16) * 1566083941) << 16) + (s & 0x0000ffff) * 1566083941)) - i; /* non linear */
|
||||||
|
this.mt[i] >>>= 0; /* for WORDSIZE > 32 machines */
|
||||||
|
i++;
|
||||||
|
if (i >= this.N) {
|
||||||
|
this.mt[0] = this.mt[this.N - 1];
|
||||||
|
i = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.mt[0] = 0x80000000; /* MSB is 1; assuring non-zero initial array */
|
||||||
|
}
|
||||||
|
|
||||||
|
random_int() {
|
||||||
|
var y;
|
||||||
|
var mag01 = new Array(0x0, this.MATRIX_A);
|
||||||
|
/* mag01[x] = x * MATRIX_A for x=0,1 */
|
||||||
|
|
||||||
|
if (this.mti >= this.N) {
|
||||||
|
/* generate N words at one time */
|
||||||
|
var kk;
|
||||||
|
|
||||||
|
if (this.mti == this.N + 1)
|
||||||
|
/* if init_seed() has not been called, */
|
||||||
|
this.init_seed(5489); /* a default initial seed is used */
|
||||||
|
|
||||||
|
for (kk = 0; kk < this.N - this.M; kk++) {
|
||||||
|
y = (this.mt[kk] & this.UPPER_MASK) | (this.mt[kk + 1] & this.LOWER_MASK);
|
||||||
|
this.mt[kk] = this.mt[kk + this.M] ^ (y >>> 1) ^ mag01[y & 0x1];
|
||||||
|
}
|
||||||
|
for (; kk < this.N - 1; kk++) {
|
||||||
|
y = (this.mt[kk] & this.UPPER_MASK) | (this.mt[kk + 1] & this.LOWER_MASK);
|
||||||
|
this.mt[kk] = this.mt[kk + (this.M - this.N)] ^ (y >>> 1) ^ mag01[y & 0x1];
|
||||||
|
}
|
||||||
|
y = (this.mt[this.N - 1] & this.UPPER_MASK) | (this.mt[0] & this.LOWER_MASK);
|
||||||
|
this.mt[this.N - 1] = this.mt[this.M - 1] ^ (y >>> 1) ^ mag01[y & 0x1];
|
||||||
|
|
||||||
|
this.mti = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
y = this.mt[this.mti++];
|
||||||
|
|
||||||
|
/* Tempering */
|
||||||
|
y ^= y >>> 11;
|
||||||
|
y ^= (y << 7) & 0x9d2c5680;
|
||||||
|
y ^= (y << 15) & 0xefc60000;
|
||||||
|
y ^= y >>> 18;
|
||||||
|
|
||||||
|
return y >>> 0;
|
||||||
|
}
|
||||||
|
random_int31() {
|
||||||
|
return this.random_int() >>> 1;
|
||||||
|
}
|
||||||
|
random_incl() {
|
||||||
|
return this.random_int() * (1.0 / 4294967295.0);
|
||||||
|
/* divided by 2^32-1 */
|
||||||
|
}
|
||||||
|
random() {
|
||||||
|
return this.random_int() * (1.0 / 4294967296.0);
|
||||||
|
/* divided by 2^32 */
|
||||||
|
}
|
||||||
|
random_excl() {
|
||||||
|
return (this.random_int() + 0.5) * (1.0 / 4294967296.0);
|
||||||
|
/* divided by 2^32 */
|
||||||
|
}
|
||||||
|
random_long() {
|
||||||
|
var a = this.random_int() >>> 5,
|
||||||
|
b = this.random_int() >>> 6;
|
||||||
|
return (a * 67108864.0 + b) * (1.0 / 9007199254740992.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
packages/ngn/src/misc.ts
Normal file
46
packages/ngn/src/misc.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Generates a sinusoidal pulse between a minimum and maximum value at a specified frequency.
|
||||||
|
*
|
||||||
|
* @param time - The time variable, typically representing elapsed time.
|
||||||
|
* @param freq - The frequency of the pulse in cycles per unit time (default is 1).
|
||||||
|
* @param min - The minimum value of the pulse (default is 0).
|
||||||
|
* @param max - The maximum value of the pulse (default is 1).
|
||||||
|
* @returns The calculated pulse value at the given time.
|
||||||
|
*/
|
||||||
|
export function pulse(time: number, freq: number = 1, min: number = 0, max: number = 1): number {
|
||||||
|
const halfRange = (max - min) / 2;
|
||||||
|
return min + halfRange * (1 + Math.sin(2 * Math.PI * freq * time));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs a linear interpolation between two numbers.
|
||||||
|
* @param a The start value.
|
||||||
|
* @param b The end value.
|
||||||
|
* @param t The interpolation factor (0-1).
|
||||||
|
* @returns The interpolated value.
|
||||||
|
*/
|
||||||
|
export function lerp(a: number, b: number, t: number): number {
|
||||||
|
return (1 - t) * a + t * b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs spherical linear interpolation between two numbers.
|
||||||
|
* @param a The start value.
|
||||||
|
* @param b The end value.
|
||||||
|
* @param t The interpolation factor, between 0 and 1.
|
||||||
|
* @returns The interpolated value.
|
||||||
|
*/
|
||||||
|
export function slerp(a: number, b: number, t: number): number {
|
||||||
|
const theta = Math.acos(Math.min(Math.max(a / b, -1), 1)) * t;
|
||||||
|
return a * Math.cos(theta) + b * Math.sin(theta);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extend<T>(component: () => T) {
|
||||||
|
return (overrides: Partial<T>): (() => T) => {
|
||||||
|
const extendedCompponent = () => ({ ...component(), ...overrides });
|
||||||
|
Object.defineProperty(extendedCompponent, "name", {
|
||||||
|
value: component.name,
|
||||||
|
});
|
||||||
|
return extendedCompponent;
|
||||||
|
};
|
||||||
|
}
|
||||||
589
packages/ngn/src/ngn.ts
Normal file
589
packages/ngn/src/ngn.ts
Normal file
@ -0,0 +1,589 @@
|
|||||||
|
import { getCreateId } from "./ids";
|
||||||
|
|
||||||
|
const createId = getCreateId({ init: 0, len: 4 });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* entity.id -> component.name -> index of component in entity.components
|
||||||
|
*
|
||||||
|
* This map stores indices of components in the entity component array.
|
||||||
|
* The purpose of this map is to allow for fast lookups of components in the
|
||||||
|
* entity.components array (e.g. entity.getComponent()).
|
||||||
|
*/
|
||||||
|
export const $eciMap = Symbol();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* component.name -> array of entity.ids that have this component
|
||||||
|
*/
|
||||||
|
export const $ceMap = Symbol();
|
||||||
|
export const $eMap = Symbol();
|
||||||
|
export const $queryResults = Symbol();
|
||||||
|
export const $dirtyQueries = Symbol();
|
||||||
|
export const $queryDependencies = Symbol();
|
||||||
|
export const $systems = Symbol();
|
||||||
|
export const $running = Symbol();
|
||||||
|
export const $onEntityCreated = Symbol();
|
||||||
|
export const $mainLoop = Symbol();
|
||||||
|
|
||||||
|
export type Component = () => {};
|
||||||
|
export type ComponentInstance = () => {
|
||||||
|
__ngn__?: {
|
||||||
|
parent: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
} & {
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QueryConfig = Readonly<
|
||||||
|
Partial<{
|
||||||
|
/** Matches entities as long as the entity has all of the components in the provided array. */
|
||||||
|
and: Component[];
|
||||||
|
/** Matches entities as long as the entity has at least one of the components in the provided array. */
|
||||||
|
or: Component[];
|
||||||
|
/** Matches entities as long as the entity has none of the components in the provided array. */
|
||||||
|
not: Component[];
|
||||||
|
/** Matches entities that have any of these tag strings. */
|
||||||
|
tag: string[];
|
||||||
|
}>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type Entity = Readonly<{
|
||||||
|
id: string;
|
||||||
|
components: ReturnType<ComponentInstance>[];
|
||||||
|
addTag: (tag: string) => Entity;
|
||||||
|
removeTag: () => Entity;
|
||||||
|
getTag: () => string;
|
||||||
|
addComponent: (component: Component, defaults?: object) => Entity;
|
||||||
|
removeComponent: (component: Component) => Entity;
|
||||||
|
getComponent: <T extends ComponentInstance>(arg: T) => ReturnType<T>;
|
||||||
|
hasComponent: (component: Component) => boolean;
|
||||||
|
destroy: () => void;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type QueryResults = {
|
||||||
|
results: {
|
||||||
|
entity: Entity;
|
||||||
|
[componentName: string]: any;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SystemFn = (w: WorldState) => void;
|
||||||
|
export type SystemCls = { update: (w: WorldState) => void };
|
||||||
|
export type System = SystemCls | SystemFn;
|
||||||
|
|
||||||
|
export type WorldState = {
|
||||||
|
[$eciMap]: { [key: number]: { [componentName: string]: number } };
|
||||||
|
[$ceMap]: { [key: string]: string[] };
|
||||||
|
[$eMap]: { [key: number]: any };
|
||||||
|
[$dirtyQueries]: Set<string>;
|
||||||
|
[$queryDependencies]: Map<string, Set<string>>;
|
||||||
|
[$queryResults]: { [key: string]: QueryResults };
|
||||||
|
[$systems]: ((w: WorldState) => void)[];
|
||||||
|
[$mainLoop]: (w: WorldState) => void;
|
||||||
|
time: {
|
||||||
|
/** The total elapsed time in seconds since the game loop started. */
|
||||||
|
elapsed: number;
|
||||||
|
/** The time in milliseconds since the last frame. */
|
||||||
|
delta: number;
|
||||||
|
/** The time in milliseconds since the last time the main loop was called. */
|
||||||
|
loopDelta: number;
|
||||||
|
/** The time in milliseconds of the last call to the main loop. */
|
||||||
|
lastLoopDelta: number;
|
||||||
|
/** The time scale of the game loop. */
|
||||||
|
scale: number;
|
||||||
|
/** The current frames per second. */
|
||||||
|
fps: number;
|
||||||
|
};
|
||||||
|
[$running]: boolean;
|
||||||
|
[$onEntityCreated]: ((e: Entity) => void)[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createWorld = () => {
|
||||||
|
const state: WorldState = {
|
||||||
|
[$eciMap]: {},
|
||||||
|
[$ceMap]: {},
|
||||||
|
[$eMap]: {},
|
||||||
|
[$dirtyQueries]: new Set(),
|
||||||
|
[$queryDependencies]: new Map(),
|
||||||
|
[$queryResults]: {},
|
||||||
|
[$systems]: [],
|
||||||
|
[$mainLoop]: null,
|
||||||
|
time: {
|
||||||
|
elapsed: 0,
|
||||||
|
delta: 0,
|
||||||
|
loopDelta: 0,
|
||||||
|
lastLoopDelta: 0,
|
||||||
|
scale: 1,
|
||||||
|
fps: 0,
|
||||||
|
},
|
||||||
|
[$running]: false,
|
||||||
|
[$onEntityCreated]: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const defineMain = (callback: (w?: WorldState) => void) => {
|
||||||
|
state[$mainLoop] = callback;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* start - starts the game loop.
|
||||||
|
* @returns - a function to stop the loop.
|
||||||
|
*/
|
||||||
|
const start = () => {
|
||||||
|
let then = 0;
|
||||||
|
let accumulator = 0;
|
||||||
|
const boundLoop = handler.bind(start);
|
||||||
|
let loopHandler = -1;
|
||||||
|
const { time } = state;
|
||||||
|
time.delta = 0;
|
||||||
|
time.elapsed = 0;
|
||||||
|
time.fps = 0;
|
||||||
|
state[$running] = true;
|
||||||
|
|
||||||
|
let raf: ((cb: FrameRequestCallback) => number) | null = null;
|
||||||
|
let craf: ((handle: number) => void) | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fake requestAnimationFrame and cancelAnimationFrame
|
||||||
|
* so that we can run tests for this in node.
|
||||||
|
*/
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
raf = requestAnimationFrame;
|
||||||
|
craf = cancelAnimationFrame;
|
||||||
|
} else {
|
||||||
|
let now = 0;
|
||||||
|
raf = (cb: FrameRequestCallback): number => {
|
||||||
|
return setTimeout(() => {
|
||||||
|
now += 16.67;
|
||||||
|
cb(now);
|
||||||
|
}, 16.67) as unknown as number;
|
||||||
|
};
|
||||||
|
|
||||||
|
craf = (id: number) => {
|
||||||
|
clearTimeout(id);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let xfps = 1;
|
||||||
|
const xtimes = [];
|
||||||
|
|
||||||
|
function handler(now: number) {
|
||||||
|
if (!state[$running]) return craf(loopHandler);
|
||||||
|
|
||||||
|
while (xtimes.length > 0 && xtimes[0] <= now - 1000) {
|
||||||
|
xtimes.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
xtimes.push(now);
|
||||||
|
xfps = xtimes.length;
|
||||||
|
time.fps = xfps;
|
||||||
|
|
||||||
|
time.delta = now - then;
|
||||||
|
then = now;
|
||||||
|
|
||||||
|
accumulator += time.delta * time.scale;
|
||||||
|
|
||||||
|
// Calculate the threshold for stepping the world based on the current frame rate
|
||||||
|
const stepThreshold = 1000 / (time.fps || 60);
|
||||||
|
|
||||||
|
// Step the world only when the accumulated scaled time exceeds the threshold
|
||||||
|
while (accumulator >= stepThreshold) {
|
||||||
|
time.loopDelta = now - time.lastLoopDelta;
|
||||||
|
time.lastLoopDelta = now;
|
||||||
|
|
||||||
|
state[$mainLoop](state);
|
||||||
|
accumulator -= stepThreshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
time.elapsed += time.delta * 0.001;
|
||||||
|
|
||||||
|
loopHandler = raf(boundLoop);
|
||||||
|
}
|
||||||
|
|
||||||
|
loopHandler = raf(boundLoop);
|
||||||
|
|
||||||
|
return () => (state[$running] = false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
state[$running] = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
function step() {
|
||||||
|
for (const system of state[$systems]) {
|
||||||
|
system(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds one or more systems to the ECS world.
|
||||||
|
* A system can be either a @see SystemFn or a @see SystemCls.
|
||||||
|
* @param systems An array of system classes or functions.
|
||||||
|
* @throws {Error} If a system is not a valid system class or function.
|
||||||
|
*/
|
||||||
|
function addSystem(...systems: (SystemCls | SystemFn)[]) {
|
||||||
|
for (const system of systems) {
|
||||||
|
// If the system is a function, add it to the world systems array
|
||||||
|
if (typeof system === "function") {
|
||||||
|
state[$systems].push(system);
|
||||||
|
// If the system has an `update` method, add that method to the world systems array
|
||||||
|
} else if (system.update && typeof system.update === "function") {
|
||||||
|
state[$systems].push(system.update);
|
||||||
|
// If the system is not a valid system class or function, throw an error
|
||||||
|
} else {
|
||||||
|
throw new Error(`Not a valid system: ${JSON.stringify(system)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes one or more systems from the world.
|
||||||
|
*
|
||||||
|
* @param {...(SystemCls | SystemFn)[]} systems - The system or systems to remove.
|
||||||
|
* @throws {TypeError} Throws an error if the system parameter is not a function or an object with an update function.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
function removeSystem(...systems: (SystemCls | SystemFn)[]): void {
|
||||||
|
for (const system of systems) {
|
||||||
|
if (typeof system === "function") {
|
||||||
|
state[$systems] = state[$systems].filter((s) => s !== system);
|
||||||
|
} else if (system.update && typeof system.update === "function") {
|
||||||
|
state[$systems] = state[$systems].filter((s) => s !== system.update);
|
||||||
|
} else {
|
||||||
|
throw new TypeError("Parameter must be a function or an object with an update function.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves query results based on the given configuration and query name.
|
||||||
|
* If non-dirty query results exist for this queryName, returns them. Otherwise, filters entities based on the queryConfig
|
||||||
|
* and updates the state with the new query results before returning them.
|
||||||
|
*
|
||||||
|
* @param {QueryConfig} queryConfig - The configuration object containing 'and', 'or', 'not' and 'tag' arrays of component names.
|
||||||
|
* @param {string} queryName - The name of the query to retrieve or update results for.
|
||||||
|
* @returns {any[]} An array of result objects, each containing an entity and its components as properties.
|
||||||
|
*/
|
||||||
|
const getQuery = (queryConfig: QueryConfig, queryName: string) => {
|
||||||
|
// If we have non-dirty query results for this queryName, return them
|
||||||
|
if (!state[$dirtyQueries].has(queryName) && state[$queryResults][queryName]) {
|
||||||
|
return state[$queryResults][queryName].results;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { and = [], or = [], not = [], tag = [] } = queryConfig;
|
||||||
|
const entities: Entity[] = Object.values(state[$eMap]).filter((entity) => {
|
||||||
|
return (
|
||||||
|
(!not.length || !not.some((component) => entity.hasComponent(component))) &&
|
||||||
|
(!and.length || and.every((component) => entity.hasComponent(component))) &&
|
||||||
|
(!or.length || or.some((component) => entity.hasComponent(component))) &&
|
||||||
|
(!tag.length || tag.some((t) => entity.tag === t))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
state[$queryResults][queryName] = {
|
||||||
|
results: entities.map((entity) => {
|
||||||
|
const result: any = { entity };
|
||||||
|
|
||||||
|
entity.components.forEach((component) => {
|
||||||
|
result[component.__ngn__.name] = component;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
state[$dirtyQueries].delete(queryName);
|
||||||
|
|
||||||
|
return state[$queryResults][queryName].results;
|
||||||
|
};
|
||||||
|
|
||||||
|
const markQueryDirty = (queryName: string) => {
|
||||||
|
state[$dirtyQueries].add(queryName);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines a query for filtering entities based on a combination of criteria.
|
||||||
|
* @param queryConfig The configuration for the query. Contains and, or, not and tag criteria.
|
||||||
|
* @throws {Error} Invalid query if any criteria in the query config does not have a 'name' property.
|
||||||
|
* @returns A function that takes a query implementation and returns the results of the query.
|
||||||
|
*/
|
||||||
|
const query = ({ and = [], or = [], not = [], tag = [] }: QueryConfig) => {
|
||||||
|
// Checks if a criteria object has a 'name' property
|
||||||
|
const validQuery = (c: Component) => Object.prototype.hasOwnProperty.call(c, "name");
|
||||||
|
|
||||||
|
// Throws an error if any criteria object in the query config does not have a 'name' property
|
||||||
|
if (![...and, ...or, ...not].every(validQuery)) throw new Error("Invalid query");
|
||||||
|
|
||||||
|
// Constructs a string representing the query name based on the criteria in the query config
|
||||||
|
const queryName = ["and", ...and.map((c) => c.name), "or", ...or.map((c) => c.name), "not", ...not.map((c) => c.name), "tag", ...tag].join("");
|
||||||
|
|
||||||
|
// Component dependencies
|
||||||
|
[...and, ...or, ...not].forEach((c) => {
|
||||||
|
const dependencies = state[$queryDependencies].get(c.name) || new Set();
|
||||||
|
dependencies.add(queryName);
|
||||||
|
state[$queryDependencies].set(c.name, dependencies);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tag dependencies
|
||||||
|
tag.forEach((t) => {
|
||||||
|
const tagKey = `tag:${t}`;
|
||||||
|
const dependencies = state[$queryDependencies].get(tagKey) || new Set();
|
||||||
|
dependencies.add(queryName);
|
||||||
|
state[$queryDependencies].set(tagKey, dependencies);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (queryImpl: (results: { entity: Entity }[]) => void) => queryImpl(getQuery({ and, or, not, tag }, queryName));
|
||||||
|
};
|
||||||
|
|
||||||
|
function destroyEntity(e: Entity) {
|
||||||
|
const exists = state[$eMap][e.id];
|
||||||
|
|
||||||
|
if (!exists) return false;
|
||||||
|
|
||||||
|
const componentsToRemove: string[] = Object.keys(state[$eciMap][e.id]);
|
||||||
|
|
||||||
|
componentsToRemove.forEach((componentName) => {
|
||||||
|
state[$ceMap][componentName] = state[$ceMap][componentName].filter((id) => id !== e.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
delete state[$eciMap][e.id];
|
||||||
|
delete state[$eMap][e.id];
|
||||||
|
|
||||||
|
componentsToRemove.forEach((componentName) => {
|
||||||
|
const affectedQueries = state[$queryDependencies].get(componentName);
|
||||||
|
|
||||||
|
if (affectedQueries) {
|
||||||
|
affectedQueries.forEach(markQueryDirty);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEntityCreated(fn: any) {
|
||||||
|
if (typeof fn !== "function") return;
|
||||||
|
|
||||||
|
state[$onEntityCreated].push(fn);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
state[$onEntityCreated] = state[$onEntityCreated].filter((f) => f !== fn);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new component for the given entity and adds it to the world.
|
||||||
|
* @param entity The entity to add the component to.
|
||||||
|
* @param component The component function to add.
|
||||||
|
* @param defaults (optional) Default values to apply to the component.
|
||||||
|
* @returns The modified entity with the new component added.
|
||||||
|
*/
|
||||||
|
function createComponent(entity: Entity, component: Function, defaults: object = {}): Entity {
|
||||||
|
// If the entity already has this component, return the unmodified entity.
|
||||||
|
if (state[$eciMap]?.[entity.id]?.[component.name] !== undefined) return entity;
|
||||||
|
|
||||||
|
const affectedQueries = state[$queryDependencies].get(component.name);
|
||||||
|
|
||||||
|
if (affectedQueries) {
|
||||||
|
affectedQueries.forEach(markQueryDirty);
|
||||||
|
}
|
||||||
|
|
||||||
|
const componentInstance = component();
|
||||||
|
|
||||||
|
if (componentInstance.onAttach && typeof componentInstance.onAttach === "function") {
|
||||||
|
componentInstance.onAttach(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the component, assigning defaults and a reference to the parent entity.
|
||||||
|
entity.components.push(
|
||||||
|
Object.assign(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
...componentInstance,
|
||||||
|
...defaults,
|
||||||
|
__ngn__: {
|
||||||
|
parent: entity.id,
|
||||||
|
name: component.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
) as ComponentInstance,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add the component index to the entity's index map.
|
||||||
|
state[$eciMap][entity.id] = state[$eciMap][entity.id] || {};
|
||||||
|
state[$eciMap][entity.id][component.name] = entity.components.length - 1;
|
||||||
|
|
||||||
|
// Add the entity to the component's entity map.
|
||||||
|
state[$ceMap][component.name] = state[$ceMap][component.name] || [];
|
||||||
|
state[$ceMap][component.name].push(entity.id);
|
||||||
|
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an entity with the given specification object.
|
||||||
|
* @param {object} spec - Optional data to be stored on the entity.
|
||||||
|
* @returns {any} - Returns the created entity.
|
||||||
|
*/
|
||||||
|
function createEntity<T>(spec: T & { id?: string } = {} as T): T & Entity {
|
||||||
|
const id = spec.id ?? createId();
|
||||||
|
const components: any[] = [];
|
||||||
|
|
||||||
|
const tagKey = (t: string) => `tag:${t}`;
|
||||||
|
|
||||||
|
function updateTagQueries(tagKey: string) {
|
||||||
|
const affectedQueries = state[$queryDependencies].get(tagKey);
|
||||||
|
|
||||||
|
if (affectedQueries) {
|
||||||
|
affectedQueries.forEach(markQueryDirty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTag(t: string): Entity {
|
||||||
|
const previousTagKey = tagKey(this.tag);
|
||||||
|
|
||||||
|
this.tag = t;
|
||||||
|
|
||||||
|
updateTagQueries(tagKey(t));
|
||||||
|
updateTagQueries(previousTagKey);
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTag(): Entity {
|
||||||
|
const previousTagKey = tagKey(this.tag);
|
||||||
|
this.tag = "";
|
||||||
|
|
||||||
|
updateTagQueries(previousTagKey);
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTag() {
|
||||||
|
return this.tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addComponent(c: Component, defaults = {}) {
|
||||||
|
return createComponent(this, c, defaults);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasComponent(component: Component) {
|
||||||
|
return state[$eciMap]?.[id]?.[component.name] !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getComponent<T extends ComponentInstance>(arg: T): ReturnType<T> {
|
||||||
|
const index = state[$eciMap][id][arg.name];
|
||||||
|
return components[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the specified component from the entity and updates the world state accordingly.
|
||||||
|
*
|
||||||
|
* @param component The component to remove from the entity.
|
||||||
|
* @returns The modified entity.
|
||||||
|
*/
|
||||||
|
function removeComponent(component: Component | string): Entity {
|
||||||
|
const name = typeof component === "string" ? component : component.name;
|
||||||
|
|
||||||
|
const componentInstance = getComponent(typeof component === "string" ? ({ name } as any) : component);
|
||||||
|
|
||||||
|
if (componentInstance && componentInstance.onDetach && typeof componentInstance.onDetach === "function") {
|
||||||
|
componentInstance.onDetach(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
const affectedQueries = state[$queryDependencies].get(name);
|
||||||
|
|
||||||
|
if (affectedQueries) {
|
||||||
|
affectedQueries.forEach(markQueryDirty);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the entity's component index for the specified component to undefined.
|
||||||
|
state[$eciMap][id][name] = undefined;
|
||||||
|
|
||||||
|
// Remove the entity's ID from the component's entity list.
|
||||||
|
state[$ceMap][name] = state[$ceMap][name].filter((e) => e !== id);
|
||||||
|
|
||||||
|
// Remove the component from the entity's component list.
|
||||||
|
const index = state[$eciMap][id][name];
|
||||||
|
components.splice(index, 1);
|
||||||
|
|
||||||
|
// Update the entity's component indices for all components after the removed component.
|
||||||
|
Object.keys(state[$eciMap][id]).forEach((componentName) => {
|
||||||
|
if (state[$eciMap][id][componentName] > components.findIndex((c) => c.name === componentName)) {
|
||||||
|
state[$eciMap][id][componentName]--;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return the modified entity.
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroy() {
|
||||||
|
return destroyEntity(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entity = Object.assign({}, spec, {
|
||||||
|
id,
|
||||||
|
components,
|
||||||
|
addTag,
|
||||||
|
removeTag,
|
||||||
|
getTag,
|
||||||
|
addComponent,
|
||||||
|
hasComponent,
|
||||||
|
getComponent,
|
||||||
|
removeComponent,
|
||||||
|
destroy,
|
||||||
|
});
|
||||||
|
|
||||||
|
// If we are focing a specific entity id, we need to migrate any
|
||||||
|
// entity that might already occupy this space.
|
||||||
|
if (spec.id !== undefined && state[$eMap][spec.id]) {
|
||||||
|
migrateEntityId(spec.id, createId());
|
||||||
|
}
|
||||||
|
|
||||||
|
state[$eMap][id] = entity;
|
||||||
|
state[$eciMap][id] = {};
|
||||||
|
|
||||||
|
state[$onEntityCreated].forEach((fn) => {
|
||||||
|
fn(entity);
|
||||||
|
});
|
||||||
|
|
||||||
|
return entity as unknown as T & Entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* migrateEntityId updates the id of an entity in the world, and all
|
||||||
|
* associated world maps.
|
||||||
|
* @param oldId The id of the entity to migrate.
|
||||||
|
* @param newId The id to migrate the entity to.
|
||||||
|
*/
|
||||||
|
function migrateEntityId(oldId: string, newId: string) {
|
||||||
|
const entity = state[$eMap][oldId];
|
||||||
|
|
||||||
|
if (!entity) return;
|
||||||
|
|
||||||
|
entity.id = newId;
|
||||||
|
|
||||||
|
state[$eMap][newId] = entity;
|
||||||
|
delete state[$eMap][oldId];
|
||||||
|
|
||||||
|
state[$eciMap][newId] = state[$eciMap][oldId];
|
||||||
|
delete state[$eciMap][oldId];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEntity(id: string): Entity {
|
||||||
|
return state[$eMap][id];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
query,
|
||||||
|
createEntity,
|
||||||
|
getEntity,
|
||||||
|
onEntityCreated,
|
||||||
|
addSystem,
|
||||||
|
removeSystem,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
step,
|
||||||
|
defineMain,
|
||||||
|
};
|
||||||
|
};
|
||||||
63
packages/ngn/src/packages/2d/canvas.ts
Normal file
63
packages/ngn/src/packages/2d/canvas.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
export type CreateCanvasOptions = Partial<{
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
fullscreen: boolean;
|
||||||
|
target: HTMLElement;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export const createCanvas = (options: CreateCanvasOptions) => {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
const { target, fullscreen } = options;
|
||||||
|
const { body } = window.document;
|
||||||
|
|
||||||
|
if (target && fullscreen) {
|
||||||
|
options.target = null;
|
||||||
|
} else if (!target && !fullscreen) {
|
||||||
|
options.fullscreen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullscreen) {
|
||||||
|
Object.assign(canvas.style, {
|
||||||
|
position: "absolute",
|
||||||
|
top: "0",
|
||||||
|
left: "0",
|
||||||
|
});
|
||||||
|
canvas.width = window.innerWidth;
|
||||||
|
canvas.height = window.innerHeight;
|
||||||
|
body.appendChild(canvas);
|
||||||
|
Object.assign(body.style, {
|
||||||
|
margin: "0",
|
||||||
|
padding: "0",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
overflow: "hidden",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
target.appendChild(canvas);
|
||||||
|
target.style.overflow = "hidden";
|
||||||
|
canvas.width = canvas.offsetWidth;
|
||||||
|
canvas.height = canvas.offsetHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.width = canvas.offsetWidth;
|
||||||
|
canvas.height = canvas.offsetHeight;
|
||||||
|
canvas.style.width = "100%";
|
||||||
|
canvas.style.height = "100%";
|
||||||
|
|
||||||
|
const existingMeta = window.document.querySelector(`meta[name="viewport"]`);
|
||||||
|
if (existingMeta) {
|
||||||
|
Object.assign(existingMeta, {
|
||||||
|
content: "width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const meta = Object.assign(window.document.createElement("meta"), {
|
||||||
|
name: "viewport",
|
||||||
|
content: "width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0",
|
||||||
|
});
|
||||||
|
window.document.head.appendChild(meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
return canvas;
|
||||||
|
};
|
||||||
35
packages/ngn/src/packages/2d/create2d.ts
Normal file
35
packages/ngn/src/packages/2d/create2d.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { createCanvas, CreateCanvasOptions } from "./canvas";
|
||||||
|
import { createDraw } from "./draw";
|
||||||
|
|
||||||
|
type Create2DOptions = Partial<{
|
||||||
|
canvas: CreateCanvasOptions;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export const create2D = (options: Create2DOptions) => {
|
||||||
|
const { width = 800, height = 600, fullscreen = false, target = null } = options.canvas;
|
||||||
|
const canvas = createCanvas({ width, height, fullscreen, target });
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
const draw = createDraw(context);
|
||||||
|
|
||||||
|
const onWindowResize = () => {
|
||||||
|
if (fullscreen) {
|
||||||
|
canvas.style.width = "100%";
|
||||||
|
canvas.style.height = "100%";
|
||||||
|
canvas.width = window.innerWidth;
|
||||||
|
canvas.height = window.innerHeight;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.width = canvas.offsetWidth;
|
||||||
|
canvas.height = canvas.offsetHeight;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("resize", onWindowResize);
|
||||||
|
|
||||||
|
const destroy = () => {
|
||||||
|
window.removeEventListener("resize", onWindowResize);
|
||||||
|
canvas.parentElement.removeChild(canvas);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { canvas, context, draw, destroy };
|
||||||
|
};
|
||||||
68
packages/ngn/src/packages/2d/draw.ts
Normal file
68
packages/ngn/src/packages/2d/draw.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
export type Vector2 = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fastRound = (num: number): number => ~~(0.5 + num);
|
||||||
|
|
||||||
|
const fastRoundVector2 = (v: Vector2): Vector2 => ({
|
||||||
|
x: fastRound(v.x),
|
||||||
|
y: fastRound(v.y),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createDraw = (context: CanvasRenderingContext2D) => {
|
||||||
|
const text = (v: Vector2, text: string, color: string = "black", size: number = 16) => {
|
||||||
|
v = fastRoundVector2(v);
|
||||||
|
context.save();
|
||||||
|
context.fillStyle = color;
|
||||||
|
context.font = `${size}px sans-serif`;
|
||||||
|
context.fillText(text, v.x, v.y);
|
||||||
|
context.restore();
|
||||||
|
};
|
||||||
|
|
||||||
|
const line = (from: Vector2, to: Vector2, color: string = "black", lineWidth: number = 1) => {
|
||||||
|
from = fastRoundVector2(from);
|
||||||
|
to = fastRoundVector2(to);
|
||||||
|
context.save();
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(from.x, from.y);
|
||||||
|
context.lineTo(to.x, to.y);
|
||||||
|
context.strokeStyle = color; // Use the color parameter
|
||||||
|
context.lineWidth = lineWidth;
|
||||||
|
context.stroke();
|
||||||
|
context.closePath();
|
||||||
|
context.restore();
|
||||||
|
};
|
||||||
|
|
||||||
|
const rectangle = (pos: Vector2, dimensions: Vector2, color: string = "black", lineWidth: number = 1) => {
|
||||||
|
pos = fastRoundVector2(pos);
|
||||||
|
dimensions = fastRoundVector2(dimensions);
|
||||||
|
context.save();
|
||||||
|
context.beginPath();
|
||||||
|
context.rect(pos.x, pos.y, dimensions.x, dimensions.y);
|
||||||
|
context.lineWidth = lineWidth;
|
||||||
|
context.strokeStyle = color;
|
||||||
|
context.stroke();
|
||||||
|
context.closePath();
|
||||||
|
context.restore();
|
||||||
|
};
|
||||||
|
|
||||||
|
const circle = (pos: Vector2, radius: number = 25, color: string = "black", lineWidth: number = 1) => {
|
||||||
|
pos = fastRoundVector2(pos);
|
||||||
|
context.save();
|
||||||
|
context.beginPath();
|
||||||
|
context.strokeStyle = color;
|
||||||
|
context.lineWidth = lineWidth;
|
||||||
|
context.arc(pos.x, pos.y, radius, 0, Math.PI * 2, true);
|
||||||
|
context.stroke();
|
||||||
|
context.closePath();
|
||||||
|
context.restore();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
line,
|
||||||
|
rectangle,
|
||||||
|
circle,
|
||||||
|
};
|
||||||
|
};
|
||||||
3
packages/ngn/src/packages/2d/index.ts
Normal file
3
packages/ngn/src/packages/2d/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { CreateCanvasOptions, createCanvas } from "./canvas";
|
||||||
|
export { create2D } from "./create2d";
|
||||||
|
export { type Vector2, createDraw } from "./draw";
|
||||||
427
packages/ngn/src/packages/emitter/index.ts
Normal file
427
packages/ngn/src/packages/emitter/index.ts
Normal file
@ -0,0 +1,427 @@
|
|||||||
|
import { WorldState } from "../../ngn";
|
||||||
|
|
||||||
|
export type Particle = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
size: number;
|
||||||
|
color: string;
|
||||||
|
colorStart: string;
|
||||||
|
colorEnd: string;
|
||||||
|
lifetime: number;
|
||||||
|
speedX: number;
|
||||||
|
speedY: number;
|
||||||
|
scaleX: number;
|
||||||
|
scaleY: number;
|
||||||
|
onInit?: (particle: Particle, state: WorldState) => void;
|
||||||
|
onRemove?: (particle: Particle, state: WorldState) => void;
|
||||||
|
onUpdate?: (particle: Particle, state: WorldState) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum ColorEasing {
|
||||||
|
LINEAR = "linear",
|
||||||
|
EASE_IN = "easeIn",
|
||||||
|
EASE_OUT = "easeOut",
|
||||||
|
EASE_IN_OUT = "easeInOut",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FadeEasing = ColorEasing;
|
||||||
|
|
||||||
|
type BlendMode =
|
||||||
|
| "color"
|
||||||
|
| "color-burn"
|
||||||
|
| "color-dodge"
|
||||||
|
| "copy"
|
||||||
|
| "darken"
|
||||||
|
| "destination-atop"
|
||||||
|
| "destination-in"
|
||||||
|
| "destination-out"
|
||||||
|
| "destination-over"
|
||||||
|
| "difference"
|
||||||
|
| "exclusion"
|
||||||
|
| "hard-light"
|
||||||
|
| "hue"
|
||||||
|
| "lighten"
|
||||||
|
| "lighter"
|
||||||
|
| "luminosity"
|
||||||
|
| "multiply"
|
||||||
|
| "overlay"
|
||||||
|
| "saturation"
|
||||||
|
| "screen"
|
||||||
|
| "soft-light"
|
||||||
|
| "source-atop"
|
||||||
|
| "source-in"
|
||||||
|
| "source-out"
|
||||||
|
| "source-over"
|
||||||
|
| "xor";
|
||||||
|
|
||||||
|
export type ParticleEmitterOptions = {
|
||||||
|
x?: number; // X position
|
||||||
|
y?: number; // Y position
|
||||||
|
maxParticles?: number; // Max number of particles
|
||||||
|
rate?: number; // Particles per second
|
||||||
|
lifetime?: number; // Lifetime of each particle
|
||||||
|
lifetimeVariation?: number; // Variation in lifetime
|
||||||
|
size?: number; // Size of each particle
|
||||||
|
sizeVariation?: number; // Variation in size
|
||||||
|
colorStart?: string | string[]; // Start color
|
||||||
|
colorEnd?: string | string[]; // End color
|
||||||
|
colorEasing?: ColorEasing; // Easing function for color
|
||||||
|
fadeOutEasing?: FadeEasing;
|
||||||
|
speed?: number; // Speed of each particle
|
||||||
|
speedVariation?: number; // Variation in speed
|
||||||
|
angle?: number; // Angle of emission
|
||||||
|
spread?: number; // Spread of emission
|
||||||
|
gravity?: { x: number; y: number }; // Gravity affecting the particles
|
||||||
|
blendMode?: BlendMode; // Blend mode
|
||||||
|
canvas: HTMLCanvasElement; // Canvas to draw on
|
||||||
|
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
|
||||||
|
/** Per-particle update callback. */
|
||||||
|
onUpdate?: (particle: Particle, state: WorldState) => void; // Callback for particle update
|
||||||
|
/** Per-particle removal callback. */
|
||||||
|
onRemove?: (particle: Particle, state: WorldState) => void; // Callback for particle removal
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDefaultParticleEmitterOptions = (opts: Partial<ParticleEmitterOptions>): ParticleEmitterOptions => ({
|
||||||
|
...opts,
|
||||||
|
x: opts.x ?? 0,
|
||||||
|
y: opts.y ?? 0,
|
||||||
|
maxParticles: opts.maxParticles ?? 100,
|
||||||
|
rate: opts.rate ?? 1,
|
||||||
|
lifetime: opts.lifetime ?? 1000,
|
||||||
|
lifetimeVariation: opts.lifetimeVariation ?? 0,
|
||||||
|
size: opts.size ?? 5,
|
||||||
|
sizeVariation: opts.sizeVariation ?? 0,
|
||||||
|
colorStart: opts.colorStart ?? "#000000",
|
||||||
|
colorEnd: opts.colorEnd ?? "#000000",
|
||||||
|
colorEasing: opts.colorEasing ?? ColorEasing.LINEAR,
|
||||||
|
angle: opts.angle ?? 0,
|
||||||
|
spread: opts.spread ?? 0,
|
||||||
|
gravity: opts.gravity ?? { x: 0, y: 0 },
|
||||||
|
speed: opts.speed ?? 0.1,
|
||||||
|
speedVariation: opts.speedVariation ?? 0,
|
||||||
|
canvas: opts.canvas,
|
||||||
|
burst: opts.burst ?? false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ParticleEmitter = {
|
||||||
|
particles: Particle[];
|
||||||
|
update: (state: WorldState) => void;
|
||||||
|
destroy: () => void;
|
||||||
|
pause: () => void;
|
||||||
|
resume: () => void;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ParticleSystemOptions = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
canvas: HTMLCanvasElement;
|
||||||
|
// A property to determine whether or not it starts immediately
|
||||||
|
start?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const interpolateColor = (colorStart: string, colorEnd: string, factor: number, easing: ColorEasing): string => {
|
||||||
|
switch (easing) {
|
||||||
|
case ColorEasing.EASE_IN:
|
||||||
|
factor = Math.pow(factor, 2);
|
||||||
|
break;
|
||||||
|
case ColorEasing.EASE_OUT:
|
||||||
|
factor = 1 - Math.pow(1 - factor, 2);
|
||||||
|
break;
|
||||||
|
case ColorEasing.EASE_IN_OUT:
|
||||||
|
if (factor < 0.5) {
|
||||||
|
factor = 2 * Math.pow(factor, 2);
|
||||||
|
} else {
|
||||||
|
factor = 1 - 2 * Math.pow(1 - factor, 2);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ColorEasing.LINEAR:
|
||||||
|
default:
|
||||||
|
// No adjustment needed for linear
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assuming colorStart and colorEnd are in the format "#RRGGBB"
|
||||||
|
const color1 = parseInt(colorStart.slice(1), 16);
|
||||||
|
const color2 = parseInt(colorEnd.slice(1), 16);
|
||||||
|
|
||||||
|
const r1 = (color1 >> 16) & 0xff;
|
||||||
|
const g1 = (color1 >> 8) & 0xff;
|
||||||
|
const b1 = color1 & 0xff;
|
||||||
|
|
||||||
|
const r2 = (color2 >> 16) & 0xff;
|
||||||
|
const g2 = (color2 >> 8) & 0xff;
|
||||||
|
const b2 = color2 & 0xff;
|
||||||
|
|
||||||
|
const r = Math.round(r1 + factor * (r2 - r1));
|
||||||
|
const g = Math.round(g1 + factor * (g2 - g1));
|
||||||
|
const b = Math.round(b1 + factor * (b2 - b1));
|
||||||
|
|
||||||
|
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hexToRgb = (hex: string): string => {
|
||||||
|
const color = parseInt(hex.slice(1), 16);
|
||||||
|
const r = (color >> 16) & 0xff;
|
||||||
|
const g = (color >> 8) & 0xff;
|
||||||
|
const b = color & 0xff;
|
||||||
|
return `${r}, ${g}, ${b}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createParticleEmitter = (opts: ParticleEmitterOptions): ParticleEmitter => {
|
||||||
|
opts = getDefaultParticleEmitterOptions(opts);
|
||||||
|
const particles = [];
|
||||||
|
let timeSinceLastEmission = 0;
|
||||||
|
const emissionInterval = 1 / opts.rate;
|
||||||
|
const lifetimeVariation = opts.lifetimeVariation ?? 0;
|
||||||
|
const context = opts.canvas.getContext("2d");
|
||||||
|
const angleInRadians = opts.angle * (Math.PI / 180);
|
||||||
|
let dead = false;
|
||||||
|
let paused = false;
|
||||||
|
|
||||||
|
const update = (state: WorldState) => {
|
||||||
|
if (dead) return;
|
||||||
|
|
||||||
|
context.globalCompositeOperation = opts.blendMode ?? "source-over";
|
||||||
|
|
||||||
|
const { loopDelta } = state.time;
|
||||||
|
|
||||||
|
for (let i = particles.length - 1; i >= 0; i--) {
|
||||||
|
const particle = particles[i];
|
||||||
|
const lifeFactor = particle.lifetime / opts.lifetime;
|
||||||
|
let opacity = 1;
|
||||||
|
|
||||||
|
particle.color = interpolateColor(particle.colorStart, particle.colorEnd, 1 - lifeFactor, opts.colorEasing);
|
||||||
|
|
||||||
|
if (opts.fadeOutEasing) {
|
||||||
|
switch (opts.fadeOutEasing) {
|
||||||
|
case ColorEasing.EASE_IN:
|
||||||
|
opacity = Math.pow(lifeFactor, 2);
|
||||||
|
break;
|
||||||
|
case ColorEasing.EASE_OUT:
|
||||||
|
opacity = 1 - Math.pow(1 - lifeFactor, 2);
|
||||||
|
break;
|
||||||
|
case ColorEasing.EASE_IN_OUT:
|
||||||
|
if (lifeFactor < 0.5) {
|
||||||
|
opacity = 2 * Math.pow(lifeFactor, 2);
|
||||||
|
} else {
|
||||||
|
opacity = 1 - 2 * Math.pow(1 - lifeFactor, 2);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ColorEasing.LINEAR:
|
||||||
|
default:
|
||||||
|
opacity = lifeFactor;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!paused) {
|
||||||
|
particle.x += particle.speedX * loopDelta;
|
||||||
|
particle.y += particle.speedY * loopDelta;
|
||||||
|
|
||||||
|
particle.speedX += (opts.gravity.x * loopDelta) / 1000;
|
||||||
|
particle.speedY += (opts.gravity.y * loopDelta) / 1000;
|
||||||
|
|
||||||
|
particle.lifetime -= state.time.loopDelta;
|
||||||
|
|
||||||
|
if (opts.onUpdate) {
|
||||||
|
opts.onUpdate(particle, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (particle.onUpdate) {
|
||||||
|
particle.onUpdate(particle, state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (particle.lifetime <= 0) {
|
||||||
|
if (opts.onRemove) {
|
||||||
|
opts.onRemove(particle, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (particle.onRemove) {
|
||||||
|
particle.onRemove(particle, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
particles.splice(i, 1);
|
||||||
|
} else {
|
||||||
|
context.fillStyle = `rgba(${hexToRgb(particle.color)}, ${opacity})`;
|
||||||
|
context.beginPath();
|
||||||
|
// context.arc(particle.x * particle.scaleX, particle.y * particle.scaleY, particle.size, 0, Math.PI * 2);
|
||||||
|
// draw a rectangel instead:
|
||||||
|
context.rect(particle.x * particle.scaleX, particle.y * particle.scaleY, particle.size, particle.size);
|
||||||
|
context.closePath();
|
||||||
|
context.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const emitParticle = () => {
|
||||||
|
const lifetimeVariationAmount = lifetimeVariation ? opts.lifetime * lifetimeVariation * Math.random() : 0;
|
||||||
|
const particleLifetime = opts.lifetime + lifetimeVariationAmount * (Math.random() < 0.5 ? -1 : 1);
|
||||||
|
const colorStart = Array.isArray(opts.colorStart) ? opts.colorStart[Math.floor(Math.random() * opts.colorStart.length)] : opts.colorStart;
|
||||||
|
const colorEnd = Array.isArray(opts.colorEnd) ? opts.colorEnd[Math.floor(Math.random() * opts.colorEnd.length)] : opts.colorEnd;
|
||||||
|
const particle = spawnParticle({
|
||||||
|
x: opts.x,
|
||||||
|
y: opts.y,
|
||||||
|
colorStart: colorStart,
|
||||||
|
colorEnd: colorEnd,
|
||||||
|
color: colorStart,
|
||||||
|
lifetime: particleLifetime,
|
||||||
|
size: Math.max(0, opts.size + (Math.random() - 0.5) * opts.sizeVariation),
|
||||||
|
speedX: opts.speed * (Math.sin(angleInRadians) + (Math.random() - 0.5) * opts.spread),
|
||||||
|
speedY: -opts.speed * (Math.cos(angleInRadians) + (Math.random() - 0.5) * opts.spread),
|
||||||
|
scaleX: 1,
|
||||||
|
scaleY: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (opts.onInit) {
|
||||||
|
opts.onInit(particle, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (particle.onInit) {
|
||||||
|
particle.onInit(particle, state);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!paused) {
|
||||||
|
if (opts.burst && timeSinceLastEmission === 0) {
|
||||||
|
for (let i = 0; i < opts.maxParticles; i++) {
|
||||||
|
emitParticle();
|
||||||
|
}
|
||||||
|
|
||||||
|
timeSinceLastEmission = -1;
|
||||||
|
} else if (!opts.burst) {
|
||||||
|
timeSinceLastEmission += loopDelta;
|
||||||
|
|
||||||
|
while (timeSinceLastEmission >= emissionInterval && particles.length < opts.maxParticles) {
|
||||||
|
emitParticle();
|
||||||
|
timeSinceLastEmission -= emissionInterval;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.burst && particles.length === 0) {
|
||||||
|
destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
context.globalCompositeOperation = "source-over";
|
||||||
|
};
|
||||||
|
|
||||||
|
const destroy = () => {
|
||||||
|
dead = true;
|
||||||
|
particles.length = 0;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
const spawnParticle = (p: Particle): Particle => {
|
||||||
|
particles.push(p);
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pause = () => {
|
||||||
|
paused = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resume = () => {
|
||||||
|
paused = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
if (document.visibilityState === "visible") {
|
||||||
|
resume();
|
||||||
|
} else {
|
||||||
|
pause();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
particles,
|
||||||
|
update,
|
||||||
|
destroy,
|
||||||
|
pause,
|
||||||
|
resume,
|
||||||
|
set x(value: number) {
|
||||||
|
opts.x = value;
|
||||||
|
},
|
||||||
|
get x() {
|
||||||
|
return opts.x;
|
||||||
|
},
|
||||||
|
set y(value: number) {
|
||||||
|
opts.y = value;
|
||||||
|
},
|
||||||
|
get y() {
|
||||||
|
return opts.y;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createParticleSystem = (opts: ParticleSystemOptions) => {
|
||||||
|
let _x = opts.x;
|
||||||
|
let _y = opts.y;
|
||||||
|
const emitters: ParticleEmitter[] = [];
|
||||||
|
const startImmediately = opts.start ?? true;
|
||||||
|
|
||||||
|
const update = (state: WorldState) => {
|
||||||
|
emitters.forEach((emitter) => {
|
||||||
|
emitter.update(state);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const destroy = () => {
|
||||||
|
emitters.forEach((emitter) => {
|
||||||
|
emitter.destroy();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createEmitter = (opts: ParticleEmitterOptions): ParticleEmitter => {
|
||||||
|
const emitter = createParticleEmitter({
|
||||||
|
...opts,
|
||||||
|
/* x: _x, */
|
||||||
|
/* y: _y, */
|
||||||
|
});
|
||||||
|
emitters.push(emitter);
|
||||||
|
|
||||||
|
if (!startImmediately) {
|
||||||
|
emitter.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
return emitter;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pause = () => {
|
||||||
|
emitters.forEach((emitter) => {
|
||||||
|
emitter.pause();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const start = () => {
|
||||||
|
emitters.forEach((emitter) => {
|
||||||
|
emitter.resume();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
update,
|
||||||
|
destroy,
|
||||||
|
createEmitter,
|
||||||
|
pause,
|
||||||
|
start,
|
||||||
|
set x(value: number) {
|
||||||
|
_x = value;
|
||||||
|
},
|
||||||
|
get x() {
|
||||||
|
return _x;
|
||||||
|
},
|
||||||
|
set y(value: number) {
|
||||||
|
_y = value;
|
||||||
|
},
|
||||||
|
get y() {
|
||||||
|
return _y;
|
||||||
|
},
|
||||||
|
get numParticles() {
|
||||||
|
return emitters.reduce((acc, emitter) => acc + emitter.particles.length, 0);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
200
packages/ngn/src/packages/input/devices/gamepad.ts
Normal file
200
packages/ngn/src/packages/input/devices/gamepad.ts
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
import { GamepadButtonState } from "..";
|
||||||
|
import { GamepadMapping, PlayStation5, SCUFVantage2, Xbox } from "./mappings/gamepad";
|
||||||
|
|
||||||
|
interface GamepadState {
|
||||||
|
axes: {
|
||||||
|
0: number;
|
||||||
|
1: number;
|
||||||
|
2: number;
|
||||||
|
3: number;
|
||||||
|
};
|
||||||
|
buttons: {
|
||||||
|
[key: string]: GamepadButtonState;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type RumbleOptions = {
|
||||||
|
duration?: number;
|
||||||
|
startDelay?: number;
|
||||||
|
strongMagnitude?: number;
|
||||||
|
weakMagnitude?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const gamepadMapping = {};
|
||||||
|
const gamepadState: GamepadState = { axes: { 0: 0, 1: 0, 2: 0, 3: 0 }, buttons: {} };
|
||||||
|
const buttonsDownLastFrame = {};
|
||||||
|
|
||||||
|
const deadzone = 0.055;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Squashes a number to either 0 or 1 if the absolute value of the number
|
||||||
|
* is less than a specified deadzone. Otherwise, the original number is returned.
|
||||||
|
* @param number - The number to squash.
|
||||||
|
* @param deadzone - The deadzone threshold. Default is 0.5.
|
||||||
|
* @returns Either the original number, 0, or 1 depending on the value of the deadzone.
|
||||||
|
*/
|
||||||
|
const squash = (number) => (Math.abs(number) >= deadzone ? number : Math.abs(number) < deadzone ? 0 : 1);
|
||||||
|
|
||||||
|
export const gamepadUpdate = () => {
|
||||||
|
for (const pad of navigator.getGamepads()) {
|
||||||
|
gamepadState[pad.index] = {
|
||||||
|
axes: {
|
||||||
|
0: squash(pad.axes[0]),
|
||||||
|
1: squash(pad.axes[1]),
|
||||||
|
2: squash(pad.axes[2]),
|
||||||
|
3: squash(pad.axes[3]),
|
||||||
|
},
|
||||||
|
buttons: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [buttonIndex, button] of Object.entries(pad.buttons)) {
|
||||||
|
gamepadState[pad.index].buttons[buttonIndex] = {
|
||||||
|
pressed: button.pressed,
|
||||||
|
touched: button.touched,
|
||||||
|
value: button.value,
|
||||||
|
justPressed: button.pressed && !buttonsDownLastFrame?.[pad.index]?.buttons?.[buttonIndex]?.pressed && !buttonsDownLastFrame?.[pad.index]?.buttons?.[buttonIndex]?.justPressed,
|
||||||
|
justReleased: !button.pressed && buttonsDownLastFrame?.[pad.index]?.buttons?.[buttonIndex]?.pressed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [buttonIndex, button] of Object.entries(gamepadState[pad.index]?.buttons)) {
|
||||||
|
buttonsDownLastFrame[pad.index].buttons[buttonIndex] = {
|
||||||
|
...(button as any),
|
||||||
|
justPressed: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const gamepad = () => ({
|
||||||
|
gamepad(index: number) {
|
||||||
|
if (!gamepadMapping[index]) reasonablyAssignMapping(navigator.getGamepads()[index]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* The gamepad object from the navigator at the specified index.
|
||||||
|
*/
|
||||||
|
get device() {
|
||||||
|
return navigator.getGamepads()[index];
|
||||||
|
},
|
||||||
|
|
||||||
|
rumble: (options: RumbleOptions) => {
|
||||||
|
const { duration = 1000, startDelay = 0, strongMagnitude = 1.0, weakMagnitude = 1.0 } = options;
|
||||||
|
|
||||||
|
const pad = navigator.getGamepads()[index];
|
||||||
|
|
||||||
|
if ("vibrationActuator" in pad && pad.vibrationActuator) {
|
||||||
|
pad.vibrationActuator.playEffect("dual-rumble", {
|
||||||
|
startDelay,
|
||||||
|
duration,
|
||||||
|
strongMagnitude,
|
||||||
|
weakMagnitude,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the gamepad mapping at the specified index using the provided function that returns a GamepadMapping object.
|
||||||
|
*
|
||||||
|
* @param m - A function that returns a GamepadMapping object.
|
||||||
|
*/
|
||||||
|
useMapping: (m: () => GamepadMapping) => (gamepadMapping[index] = m()),
|
||||||
|
/**
|
||||||
|
* Returns the gamepad button state.
|
||||||
|
* @param {string} b - Gamepad button name.
|
||||||
|
* @returns {object} - Object containing information about the button state.
|
||||||
|
*/
|
||||||
|
getButton(b: string): GamepadButtonState {
|
||||||
|
if (!gamepadState[index])
|
||||||
|
return {
|
||||||
|
pressed: false,
|
||||||
|
touched: false,
|
||||||
|
value: 0,
|
||||||
|
justPressed: false,
|
||||||
|
justReleased: false,
|
||||||
|
};
|
||||||
|
const button = Object.keys(gamepadMapping[index].buttons)[Object.values(gamepadMapping[index].buttons).indexOf(b)];
|
||||||
|
if (gamepadState[index].buttons[button]) return gamepadState[index].buttons[button];
|
||||||
|
return {
|
||||||
|
pressed: false,
|
||||||
|
touched: false,
|
||||||
|
value: 0,
|
||||||
|
justPressed: false,
|
||||||
|
justReleased: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Returns the value of a given axis on the gamepad.
|
||||||
|
* @param a - The name of the axis to retrieve.
|
||||||
|
* @returns The value of the given axis. Returns 0 if the gamepad state is not available or the axis value is not found.
|
||||||
|
*/
|
||||||
|
getAxis(a: string): number {
|
||||||
|
if (!gamepadState[index]) return 0;
|
||||||
|
const ax = Object.keys(gamepadMapping[index].axes)[Object.values(gamepadMapping[index].axes).indexOf(a)];
|
||||||
|
if (gamepadState[index].axes[ax]) return gamepadState[index].axes[ax];
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assigns a gamepad mapping based on the gamepad type. Reasonably assigns defaults.
|
||||||
|
*
|
||||||
|
* @param g - The gamepad to assign a mapping for.
|
||||||
|
*
|
||||||
|
* @returns void.
|
||||||
|
*/
|
||||||
|
const reasonablyAssignMapping = (g: Gamepad): void => {
|
||||||
|
if (!g) return;
|
||||||
|
|
||||||
|
const id = g.id.toLowerCase();
|
||||||
|
const controllerTypes = [
|
||||||
|
{ ids: ["sony", "playstation"], mapping: PlayStation5 },
|
||||||
|
{ ids: ["xbox"], mapping: Xbox },
|
||||||
|
{ ids: ["scuf"], mapping: SCUFVantage2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const controllerType = controllerTypes.find((type) => type.ids.some((controllerId) => id.includes(controllerId)));
|
||||||
|
|
||||||
|
if (controllerType) {
|
||||||
|
gamepadMapping[g.index] = controllerType.mapping();
|
||||||
|
} else {
|
||||||
|
console.warn(`couldn't reasonably find a mapping for controller with id ${g.id} - defaulting to xbox mapping.`);
|
||||||
|
gamepadMapping[g.index] = Xbox();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the default state for a gamepad by initializing the axes and buttons objects to empty objects.
|
||||||
|
* @param index - The index of the gamepad to set the default state for.
|
||||||
|
* @returns void
|
||||||
|
*/
|
||||||
|
export const setDefaultGamepadState = (index: number): void => {
|
||||||
|
gamepadState[index] = { axes: {}, buttons: {} };
|
||||||
|
buttonsDownLastFrame[index] = { axes: {}, buttons: {} };
|
||||||
|
};
|
||||||
|
|
||||||
|
const connectedCallbacks = [];
|
||||||
|
const disconnectedCallbacks = [];
|
||||||
|
|
||||||
|
export const onGamepadConnected = (callback: (e: GamepadEvent) => void) => {
|
||||||
|
connectedCallbacks.push(callback);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const onGamepadDisconnected = (callback: (e: GamepadEvent) => void) => {
|
||||||
|
disconnectedCallbacks.push(callback);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Internal
|
||||||
|
export const onConnected = (e: GamepadEvent): void => {
|
||||||
|
connectedCallbacks.forEach((cb) => cb(e));
|
||||||
|
setDefaultGamepadState(e.gamepad.index);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Internal
|
||||||
|
export const onDisconnected = (e: GamepadEvent): void => {
|
||||||
|
disconnectedCallbacks.forEach((cb) => cb(e));
|
||||||
|
delete gamepadState[e.gamepad.index];
|
||||||
|
delete buttonsDownLastFrame[e.gamepad.index];
|
||||||
|
};
|
||||||
104
packages/ngn/src/packages/input/devices/keyboard.ts
Normal file
104
packages/ngn/src/packages/input/devices/keyboard.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { ButtonState } from "..";
|
||||||
|
import { KeyboardKey, KeyboardMapping, StandardKeyboard } from "./mappings/keyboard";
|
||||||
|
|
||||||
|
let keyboardMapping: KeyboardMapping = StandardKeyboard();
|
||||||
|
|
||||||
|
interface KeyboardState {
|
||||||
|
keys: KeyToState;
|
||||||
|
}
|
||||||
|
|
||||||
|
type KeyToState = {
|
||||||
|
[K in KeyboardKey]?: ButtonState;
|
||||||
|
};
|
||||||
|
|
||||||
|
const keyboardState: KeyboardState = { keys: {} };
|
||||||
|
const observedKeyboardState: KeyboardState = { keys: {} };
|
||||||
|
const keysDownLastFrame: KeyboardState = { keys: {} };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `keyboard` function returns an object with a `keyboard` property. This property is an object with methods to interact with the keyboard input.
|
||||||
|
*/
|
||||||
|
export const keyboard = () => ({
|
||||||
|
/**
|
||||||
|
* An object with methods to interact with the keyboard input.
|
||||||
|
*/
|
||||||
|
keyboard: {
|
||||||
|
/**
|
||||||
|
* Set the keyboard mapping.
|
||||||
|
* @param m A function that returns a `KeyboardMapping` object.
|
||||||
|
*/
|
||||||
|
useMapping: (m: () => KeyboardMapping) => {
|
||||||
|
keyboardMapping = m();
|
||||||
|
setDefaultKeyboardState();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the state of a key.
|
||||||
|
* @param b The name of the key as a string.
|
||||||
|
* @returns The state of the key as a `ButtonState` object.
|
||||||
|
* If the key is not found or not pressed, the `pressed`, `justPressed`, and `justReleased` properties will be set to `false`.
|
||||||
|
*/
|
||||||
|
getKey(b: string): ButtonState {
|
||||||
|
const key = Object.keys(keyboardMapping)[Object.values(keyboardMapping).indexOf(b)];
|
||||||
|
if (key) return keyboardState.keys[key];
|
||||||
|
if (keyboardState.keys[b]) return keyboardState.keys[b];
|
||||||
|
return { pressed: false, justPressed: false, justReleased: false };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the keyboard state by detecting changes in key presses and releases.
|
||||||
|
*
|
||||||
|
* @returns void
|
||||||
|
*/
|
||||||
|
export const keyboardUpdate = (): void => {
|
||||||
|
for (const [key, value] of Object.entries(observedKeyboardState.keys)) {
|
||||||
|
keyboardState.keys[key] = {
|
||||||
|
...value,
|
||||||
|
justReleased: !value.pressed && keysDownLastFrame.keys?.[key]?.pressed,
|
||||||
|
};
|
||||||
|
keysDownLastFrame.keys[key] = { ...value, justPressed: false };
|
||||||
|
observedKeyboardState.keys[key] = { ...value, justPressed: false };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the default state for all keyboard keys in the keyboard state object
|
||||||
|
*/
|
||||||
|
export const setDefaultKeyboardState = (): void => {
|
||||||
|
for (const key of Object.keys(keyboardMapping)) {
|
||||||
|
keyboardState.keys[key] = {
|
||||||
|
pressed: false,
|
||||||
|
justPressed: false,
|
||||||
|
justReleased: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function called on keydown event to update the observed keyboard state.
|
||||||
|
* @param e The KeyboardEvent object containing the keydown event details.
|
||||||
|
* @returns void
|
||||||
|
*/
|
||||||
|
export const onKeyDown = (e: KeyboardEvent): void => {
|
||||||
|
if (e.repeat) return;
|
||||||
|
observedKeyboardState.keys[e.code] = {
|
||||||
|
pressed: true,
|
||||||
|
justPressed: true,
|
||||||
|
justReleased: false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function called on keyup event to update the observed keyboard state.
|
||||||
|
* @param {KeyboardEvent} e - The event object containing details of the keyup event.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export const onKeyUp = (e: KeyboardEvent): void => {
|
||||||
|
observedKeyboardState.keys[e.code] = {
|
||||||
|
pressed: false,
|
||||||
|
justPressed: false,
|
||||||
|
justReleased: true,
|
||||||
|
};
|
||||||
|
};
|
||||||
93
packages/ngn/src/packages/input/devices/mappings/gamepad.ts
Normal file
93
packages/ngn/src/packages/input/devices/mappings/gamepad.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
export interface GamepadMapping {
|
||||||
|
axes: {
|
||||||
|
0?: any;
|
||||||
|
1?: any;
|
||||||
|
2?: any;
|
||||||
|
3?: any;
|
||||||
|
};
|
||||||
|
buttons: {
|
||||||
|
0?: string;
|
||||||
|
1?: string;
|
||||||
|
2?: string;
|
||||||
|
3?: string;
|
||||||
|
4?: string;
|
||||||
|
5?: string;
|
||||||
|
6?: string;
|
||||||
|
7?: string;
|
||||||
|
8?: string;
|
||||||
|
9?: string;
|
||||||
|
10?: string;
|
||||||
|
11?: string;
|
||||||
|
12?: string;
|
||||||
|
13?: string;
|
||||||
|
14?: string;
|
||||||
|
15?: string;
|
||||||
|
16?: string;
|
||||||
|
17?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SCUFVantage2 = (): GamepadMapping => {
|
||||||
|
return {
|
||||||
|
axes: {
|
||||||
|
0: "MoveHorizontal",
|
||||||
|
1: "MoveVertical",
|
||||||
|
2: "LookHorizontal",
|
||||||
|
3: "LookVertical",
|
||||||
|
},
|
||||||
|
buttons: {
|
||||||
|
0: "X",
|
||||||
|
1: "O",
|
||||||
|
2: "Square",
|
||||||
|
3: "Triangle",
|
||||||
|
4: "L1",
|
||||||
|
5: "R1",
|
||||||
|
6: "L2",
|
||||||
|
7: "R2",
|
||||||
|
8: "Select",
|
||||||
|
9: "Start",
|
||||||
|
10: "LeftStick",
|
||||||
|
11: "RightStick",
|
||||||
|
12: "AnalogUp",
|
||||||
|
13: "AnalogDown",
|
||||||
|
14: "AnalogLeft",
|
||||||
|
15: "AnalogRight",
|
||||||
|
16: "Dashboard",
|
||||||
|
17: "Touchpad",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PlayStation4 = (): GamepadMapping => SCUFVantage2();
|
||||||
|
export const PlayStation5 = (): GamepadMapping => SCUFVantage2();
|
||||||
|
|
||||||
|
export const Xbox = (): GamepadMapping => {
|
||||||
|
return {
|
||||||
|
axes: {
|
||||||
|
0: "MoveHorizontal",
|
||||||
|
1: "MoveVertical",
|
||||||
|
2: "LookHorizontal",
|
||||||
|
3: "LookVertical",
|
||||||
|
},
|
||||||
|
buttons: {
|
||||||
|
0: "A",
|
||||||
|
1: "B",
|
||||||
|
2: "X",
|
||||||
|
3: "Y",
|
||||||
|
4: "LB",
|
||||||
|
5: "RB",
|
||||||
|
6: "LT",
|
||||||
|
7: "RT",
|
||||||
|
8: "Back",
|
||||||
|
9: "Start",
|
||||||
|
10: "LeftStick",
|
||||||
|
11: "RightStick",
|
||||||
|
12: "AnalogUp",
|
||||||
|
13: "AnalogDown",
|
||||||
|
14: "AnalogLeft",
|
||||||
|
15: "AnalogRight",
|
||||||
|
16: "Guide",
|
||||||
|
17: "Touchpad",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
258
packages/ngn/src/packages/input/devices/mappings/keyboard.ts
Normal file
258
packages/ngn/src/packages/input/devices/mappings/keyboard.ts
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
export enum KeyboardKey {
|
||||||
|
KeyQ = "KeyQ",
|
||||||
|
KeyW = "KeyW",
|
||||||
|
KeyE = "KeyE",
|
||||||
|
KeyR = "KeyR",
|
||||||
|
KeyT = "KeyT",
|
||||||
|
KeyY = "KeyY",
|
||||||
|
KeyU = "KeyU",
|
||||||
|
KeyI = "KeyI",
|
||||||
|
KeyO = "KeyO",
|
||||||
|
KeyP = "KeyP",
|
||||||
|
|
||||||
|
KeyA = "KeyA",
|
||||||
|
KeyS = "KeyS",
|
||||||
|
KeyD = "KeyD",
|
||||||
|
KeyF = "KeyF",
|
||||||
|
KeyG = "KeyG",
|
||||||
|
KeyH = "KeyH",
|
||||||
|
KeyJ = "KeyJ",
|
||||||
|
KeyK = "KeyK",
|
||||||
|
KeyL = "KeyL",
|
||||||
|
|
||||||
|
KeyZ = "KeyZ",
|
||||||
|
KeyX = "KeyX",
|
||||||
|
KeyC = "KeyC",
|
||||||
|
KeyV = "KeyV",
|
||||||
|
KeyB = "KeyB",
|
||||||
|
KeyN = "KeyN",
|
||||||
|
KeyM = "KeyM",
|
||||||
|
|
||||||
|
BracketLeft = "BracketLeft",
|
||||||
|
BracketRight = "BracketRight",
|
||||||
|
Comma = "Comma",
|
||||||
|
Period = "Period",
|
||||||
|
Slash = "Slash",
|
||||||
|
Backquote = "Backquote",
|
||||||
|
Semicolon = "Semicolon",
|
||||||
|
Quote = "Quote",
|
||||||
|
Backslash = "Backslash",
|
||||||
|
IntlBackslash = "IntlBackslash",
|
||||||
|
|
||||||
|
Digit1 = "Digit1",
|
||||||
|
Digit2 = "Digit2",
|
||||||
|
Digit3 = "Digit3",
|
||||||
|
Digit4 = "Digit4",
|
||||||
|
Digit5 = "Digit5",
|
||||||
|
Digit6 = "Digit6",
|
||||||
|
Digit7 = "Digit7",
|
||||||
|
Digit8 = "Digit8",
|
||||||
|
Digit9 = "Digit9",
|
||||||
|
Digit0 = "Digit0",
|
||||||
|
|
||||||
|
Minus = "Minus",
|
||||||
|
Equal = "Equal",
|
||||||
|
Enter = "Enter",
|
||||||
|
Space = "Space",
|
||||||
|
|
||||||
|
NumpadDecimal = "NumpadDecimal",
|
||||||
|
Numpad0 = "Numpad0",
|
||||||
|
Numpad1 = "Numpad1",
|
||||||
|
Numpad2 = "Numpad2",
|
||||||
|
Numpad3 = "Numpad3",
|
||||||
|
Numpad4 = "Numpad4",
|
||||||
|
Numpad5 = "Numpad5",
|
||||||
|
Numpad6 = "Numpad6",
|
||||||
|
Numpad7 = "Numpad7",
|
||||||
|
Numpad8 = "Numpad8",
|
||||||
|
Numpad9 = "Numpad9",
|
||||||
|
NumpadDivide = "NumpadDivide",
|
||||||
|
NumpadMultiply = "NumpadMultiply",
|
||||||
|
NumpadSubtract = "NumpadSubtract",
|
||||||
|
NumpadAdd = "NumpadAdd",
|
||||||
|
NumpadEnter = "NumpadEnter",
|
||||||
|
|
||||||
|
Delete = "Delete",
|
||||||
|
End = "End",
|
||||||
|
Home = "Home",
|
||||||
|
Insert = "Insert",
|
||||||
|
PageDown = "PageDown",
|
||||||
|
PageUp = "PageUp",
|
||||||
|
|
||||||
|
ArrowDown = "ArrowDown",
|
||||||
|
ArrowLeft = "ArrowLeft",
|
||||||
|
ArrowRight = "ArrowRight",
|
||||||
|
ArrowUp = "ArrowUp",
|
||||||
|
|
||||||
|
Backspace = "Backspace",
|
||||||
|
|
||||||
|
AltLeft = "AltLeft",
|
||||||
|
AltRight = "AltRight",
|
||||||
|
CapsLock = "CapsLock",
|
||||||
|
ContextMenu = "ContextMenu",
|
||||||
|
ControlLeft = "ControlLeft",
|
||||||
|
ControlRight = "ControlRight",
|
||||||
|
|
||||||
|
ShiftLeft = "ShiftLeft",
|
||||||
|
ShiftRight = "ShiftRight",
|
||||||
|
|
||||||
|
Tab = "Tab",
|
||||||
|
|
||||||
|
Escape = "Escape",
|
||||||
|
F1 = "F1",
|
||||||
|
F2 = "F2",
|
||||||
|
F3 = "F3",
|
||||||
|
F4 = "F4",
|
||||||
|
F5 = "F5",
|
||||||
|
F6 = "F6",
|
||||||
|
F7 = "F7",
|
||||||
|
F8 = "F8",
|
||||||
|
F9 = "F9",
|
||||||
|
F10 = "F10",
|
||||||
|
F11 = "F11",
|
||||||
|
F12 = "F12",
|
||||||
|
|
||||||
|
PrintScreen = "PrintScreen",
|
||||||
|
ScrollLock = "ScrollLock",
|
||||||
|
Pause = "Pause",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyboardMapping {
|
||||||
|
[KeyboardKey.KeyQ]?: string;
|
||||||
|
[KeyboardKey.KeyW]?: string;
|
||||||
|
[KeyboardKey.KeyE]?: string;
|
||||||
|
[KeyboardKey.KeyR]?: string;
|
||||||
|
[KeyboardKey.KeyT]?: string;
|
||||||
|
[KeyboardKey.KeyY]?: string;
|
||||||
|
[KeyboardKey.KeyU]?: string;
|
||||||
|
[KeyboardKey.KeyI]?: string;
|
||||||
|
[KeyboardKey.KeyO]?: string;
|
||||||
|
[KeyboardKey.KeyP]?: string;
|
||||||
|
|
||||||
|
[KeyboardKey.KeyA]?: string;
|
||||||
|
[KeyboardKey.KeyS]?: string;
|
||||||
|
[KeyboardKey.KeyD]?: string;
|
||||||
|
[KeyboardKey.KeyF]?: string;
|
||||||
|
[KeyboardKey.KeyG]?: string;
|
||||||
|
[KeyboardKey.KeyH]?: string;
|
||||||
|
[KeyboardKey.KeyJ]?: string;
|
||||||
|
[KeyboardKey.KeyK]?: string;
|
||||||
|
[KeyboardKey.KeyL]?: string;
|
||||||
|
|
||||||
|
[KeyboardKey.KeyZ]?: string;
|
||||||
|
[KeyboardKey.KeyX]?: string;
|
||||||
|
[KeyboardKey.KeyC]?: string;
|
||||||
|
[KeyboardKey.KeyV]?: string;
|
||||||
|
[KeyboardKey.KeyB]?: string;
|
||||||
|
[KeyboardKey.KeyN]?: string;
|
||||||
|
[KeyboardKey.KeyM]?: string;
|
||||||
|
[KeyboardKey.KeyM]?: string;
|
||||||
|
|
||||||
|
[KeyboardKey.BracketLeft]?: string;
|
||||||
|
[KeyboardKey.BracketRight]?: string;
|
||||||
|
[KeyboardKey.Comma]?: string;
|
||||||
|
[KeyboardKey.Period]?: string;
|
||||||
|
[KeyboardKey.Slash]?: string;
|
||||||
|
[KeyboardKey.Backquote]?: string;
|
||||||
|
[KeyboardKey.Semicolon]?: string;
|
||||||
|
[KeyboardKey.Quote]?: string;
|
||||||
|
[KeyboardKey.Backslash]?: string;
|
||||||
|
[KeyboardKey.IntlBackslash]?: string;
|
||||||
|
|
||||||
|
[KeyboardKey.Digit1]?: string;
|
||||||
|
[KeyboardKey.Digit2]?: string;
|
||||||
|
[KeyboardKey.Digit3]?: string;
|
||||||
|
[KeyboardKey.Digit4]?: string;
|
||||||
|
[KeyboardKey.Digit5]?: string;
|
||||||
|
[KeyboardKey.Digit6]?: string;
|
||||||
|
[KeyboardKey.Digit7]?: string;
|
||||||
|
[KeyboardKey.Digit8]?: string;
|
||||||
|
[KeyboardKey.Digit9]?: string;
|
||||||
|
[KeyboardKey.Digit0]?: string;
|
||||||
|
|
||||||
|
[KeyboardKey.Minus]?: string;
|
||||||
|
[KeyboardKey.Equal]?: string;
|
||||||
|
[KeyboardKey.Enter]?: string;
|
||||||
|
[KeyboardKey.Space]?: string;
|
||||||
|
|
||||||
|
[KeyboardKey.NumpadDecimal]?: string;
|
||||||
|
[KeyboardKey.Numpad0]?: string;
|
||||||
|
[KeyboardKey.Numpad1]?: string;
|
||||||
|
[KeyboardKey.Numpad2]?: string;
|
||||||
|
[KeyboardKey.Numpad3]?: string;
|
||||||
|
[KeyboardKey.Numpad4]?: string;
|
||||||
|
[KeyboardKey.Numpad5]?: string;
|
||||||
|
[KeyboardKey.Numpad6]?: string;
|
||||||
|
[KeyboardKey.Numpad7]?: string;
|
||||||
|
[KeyboardKey.Numpad8]?: string;
|
||||||
|
[KeyboardKey.Numpad9]?: string;
|
||||||
|
[KeyboardKey.NumpadDivide]?: string;
|
||||||
|
[KeyboardKey.NumpadMultiply]?: string;
|
||||||
|
[KeyboardKey.NumpadSubtract]?: string;
|
||||||
|
[KeyboardKey.NumpadAdd]?: string;
|
||||||
|
[KeyboardKey.NumpadEnter]?: string;
|
||||||
|
|
||||||
|
[KeyboardKey.Delete]?: string;
|
||||||
|
[KeyboardKey.End]?: string;
|
||||||
|
[KeyboardKey.Home]?: string;
|
||||||
|
[KeyboardKey.Insert]?: string;
|
||||||
|
[KeyboardKey.PageDown]?: string;
|
||||||
|
[KeyboardKey.PageUp]?: string;
|
||||||
|
|
||||||
|
[KeyboardKey.ArrowDown]?: string;
|
||||||
|
[KeyboardKey.ArrowLeft]?: string;
|
||||||
|
[KeyboardKey.ArrowRight]?: string;
|
||||||
|
[KeyboardKey.ArrowUp]?: string;
|
||||||
|
|
||||||
|
[KeyboardKey.Backspace]?: string;
|
||||||
|
|
||||||
|
[KeyboardKey.AltLeft]?: string;
|
||||||
|
[KeyboardKey.AltRight]?: string;
|
||||||
|
[KeyboardKey.CapsLock]?: string;
|
||||||
|
[KeyboardKey.ContextMenu]?: string;
|
||||||
|
[KeyboardKey.ControlLeft]?: string;
|
||||||
|
[KeyboardKey.ControlRight]?: string;
|
||||||
|
|
||||||
|
[KeyboardKey.ShiftLeft]?: string;
|
||||||
|
[KeyboardKey.ShiftRight]?: string;
|
||||||
|
|
||||||
|
[KeyboardKey.Tab]?: string;
|
||||||
|
|
||||||
|
[KeyboardKey.Escape]?: string;
|
||||||
|
[KeyboardKey.F1]?: string;
|
||||||
|
[KeyboardKey.F2]?: string;
|
||||||
|
[KeyboardKey.F3]?: string;
|
||||||
|
[KeyboardKey.F4]?: string;
|
||||||
|
[KeyboardKey.F5]?: string;
|
||||||
|
[KeyboardKey.F6]?: string;
|
||||||
|
[KeyboardKey.F7]?: string;
|
||||||
|
[KeyboardKey.F8]?: string;
|
||||||
|
[KeyboardKey.F9]?: string;
|
||||||
|
[KeyboardKey.F10]?: string;
|
||||||
|
[KeyboardKey.F11]?: string;
|
||||||
|
[KeyboardKey.F12]?: string;
|
||||||
|
|
||||||
|
[KeyboardKey.PrintScreen]?: string;
|
||||||
|
[KeyboardKey.ScrollLock]?: string;
|
||||||
|
[KeyboardKey.Pause]?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StandardKeyboard = (): KeyboardMapping => {
|
||||||
|
return {
|
||||||
|
[KeyboardKey.KeyW]: "Forward",
|
||||||
|
[KeyboardKey.KeyA]: "Left",
|
||||||
|
[KeyboardKey.KeyS]: "Back",
|
||||||
|
[KeyboardKey.KeyD]: "Right",
|
||||||
|
|
||||||
|
[KeyboardKey.KeyQ]: "Quickswitch",
|
||||||
|
[KeyboardKey.KeyE]: "Use",
|
||||||
|
[KeyboardKey.KeyR]: "Reload",
|
||||||
|
[KeyboardKey.KeyY]: "ChatAll",
|
||||||
|
[KeyboardKey.KeyU]: "ChatTeam",
|
||||||
|
|
||||||
|
[KeyboardKey.Tab]: "Scoreboard",
|
||||||
|
[KeyboardKey.ControlLeft]: "Crouch",
|
||||||
|
[KeyboardKey.Space]: "Jump",
|
||||||
|
[KeyboardKey.ShiftLeft]: "Sprint",
|
||||||
|
};
|
||||||
|
};
|
||||||
39
packages/ngn/src/packages/input/devices/mappings/mouse.ts
Normal file
39
packages/ngn/src/packages/input/devices/mappings/mouse.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
export enum MouseButton {
|
||||||
|
Mouse1 = "0", // e.button is 0
|
||||||
|
Mouse2 = "2", // e.button is 2
|
||||||
|
Mouse3 = "1", // e.button is 1
|
||||||
|
Mouse4 = "3", // e.button is 3
|
||||||
|
Mouse5 = "4", // e.button is 4
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MouseMapping {
|
||||||
|
axes?: {
|
||||||
|
0?: string;
|
||||||
|
1?: string;
|
||||||
|
2?: string;
|
||||||
|
};
|
||||||
|
buttons?: {
|
||||||
|
0?: string;
|
||||||
|
1?: string;
|
||||||
|
2?: string;
|
||||||
|
3?: string;
|
||||||
|
4?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StandardMouse = (): MouseMapping => {
|
||||||
|
return {
|
||||||
|
axes: {
|
||||||
|
0: "Horizontal",
|
||||||
|
1: "Vertical",
|
||||||
|
2: "Wheel",
|
||||||
|
},
|
||||||
|
buttons: {
|
||||||
|
0: "Mouse1", // left
|
||||||
|
1: "Mouse3", // middle
|
||||||
|
2: "Mouse2", // right
|
||||||
|
3: "Mouse4", // back
|
||||||
|
4: "Mouse5", // forward
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
153
packages/ngn/src/packages/input/devices/mouse.ts
Normal file
153
packages/ngn/src/packages/input/devices/mouse.ts
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
import { ButtonState } from "..";
|
||||||
|
import { MouseButton, MouseMapping, StandardMouse } from "./mappings/mouse";
|
||||||
|
|
||||||
|
let mouseMapping: MouseMapping = StandardMouse();
|
||||||
|
|
||||||
|
interface MouseState {
|
||||||
|
axes: AxesToState;
|
||||||
|
buttons: ButtonToState;
|
||||||
|
position: { x: number; y: number };
|
||||||
|
acceleration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ObservedMouseState {
|
||||||
|
buttons: ButtonToState;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AxesToState = {
|
||||||
|
[A in "0" | "1" | "2"]?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ButtonToState = {
|
||||||
|
[B in MouseButton]?: ButtonState;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mousemoveTimeout = null;
|
||||||
|
const mouseState: MouseState = { axes: {}, buttons: {}, position: { x: 0, y: 0 }, acceleration: 0 };
|
||||||
|
const observedMouseState: ObservedMouseState = { buttons: {} };
|
||||||
|
const buttonsDownLastFrame: ObservedMouseState = { buttons: {} };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the state of the mouse buttons and their respective justReleased and justPressed properties
|
||||||
|
* @returns void
|
||||||
|
*/
|
||||||
|
export const mouseUpdate = (): void => {
|
||||||
|
for (const [button, value] of Object.entries(observedMouseState.buttons)) {
|
||||||
|
mouseState.buttons[button] = {
|
||||||
|
...value,
|
||||||
|
justReleased: !value.pressed && buttonsDownLastFrame.buttons?.[button]?.pressed,
|
||||||
|
};
|
||||||
|
buttonsDownLastFrame.buttons[button] = { ...value, justPressed: false };
|
||||||
|
observedMouseState.buttons[button] = { ...value, justPressed: false };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let buttonNameMappingCache: { [key: string]: any } = {};
|
||||||
|
let axisNameMappingCache: { [key: string]: any } = {};
|
||||||
|
|
||||||
|
export const mouse = () => ({
|
||||||
|
mouse: {
|
||||||
|
useMapping: (m: () => MouseMapping) => {
|
||||||
|
mouseMapping = m();
|
||||||
|
buttonNameMappingCache = {};
|
||||||
|
axisNameMappingCache = {};
|
||||||
|
setDefaultMouseState();
|
||||||
|
},
|
||||||
|
getButton(b: string): ButtonState {
|
||||||
|
// Get from cache.
|
||||||
|
if (buttonNameMappingCache[b]) {
|
||||||
|
return mouseState.buttons[buttonNameMappingCache[b]];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get from mapping.
|
||||||
|
const button = Object.keys(mouseMapping.buttons)[Object.values(mouseMapping.buttons).indexOf(b)];
|
||||||
|
|
||||||
|
if (button) {
|
||||||
|
buttonNameMappingCache[b] = button;
|
||||||
|
return mouseState.buttons[button];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mouseState.buttons[b]) return mouseState.buttons[b];
|
||||||
|
return { pressed: false, justPressed: false, justReleased: false };
|
||||||
|
},
|
||||||
|
getAxis(a: string): number {
|
||||||
|
if (axisNameMappingCache[a]) {
|
||||||
|
return mouseState.axes[axisNameMappingCache[a]];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ax = Object.keys(mouseMapping.axes)[Object.values(mouseMapping.axes).indexOf(a)];
|
||||||
|
|
||||||
|
if (ax) {
|
||||||
|
axisNameMappingCache[a] = ax;
|
||||||
|
return mouseState.axes[ax];
|
||||||
|
}
|
||||||
|
if (mouseState.axes[a]) return mouseState.axes[a];
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
getPosition(): { x: number; y: number } {
|
||||||
|
return mouseState.position;
|
||||||
|
},
|
||||||
|
getAcceleration(): number {
|
||||||
|
return mouseState.acceleration;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const setDefaultMouseState = () => {
|
||||||
|
mouseState.axes = { "0": 0, "1": 0, "2": 0 };
|
||||||
|
|
||||||
|
for (const key of Object.keys(mouseMapping.buttons)) {
|
||||||
|
mouseState.buttons[key] = {
|
||||||
|
pressed: false,
|
||||||
|
justPressed: false,
|
||||||
|
justReleased: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawAccel = () => {
|
||||||
|
document.getElementById("accel").innerHTML = `${mouseState.axes[0]}, ${mouseState.axes[1]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const onMouseMove = (e: MouseEvent) => {
|
||||||
|
clearTimeout(mousemoveTimeout);
|
||||||
|
mousemoveTimeout = setTimeout(() => {
|
||||||
|
mouseState.axes[0] = 0;
|
||||||
|
mouseState.axes[1] = 0;
|
||||||
|
mouseState.axes[2] = 0;
|
||||||
|
// drawAccel();
|
||||||
|
}, 30);
|
||||||
|
|
||||||
|
mouseState.axes[0] = e.movementX;
|
||||||
|
mouseState.axes[1] = e.movementY;
|
||||||
|
mouseState.acceleration = Math.sqrt(e.movementX ** 2 + e.movementY ** 2);
|
||||||
|
mouseState.position.x = e.clientX;
|
||||||
|
mouseState.position.y = e.clientY;
|
||||||
|
// drawAccel();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const onMouseDown = (e: MouseEvent) => {
|
||||||
|
observedMouseState.buttons[e.button] = {
|
||||||
|
pressed: true,
|
||||||
|
justPressed: true,
|
||||||
|
justReleased: false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const onMouseUp = (e: MouseEvent) => {
|
||||||
|
observedMouseState.buttons[e.button] = {
|
||||||
|
pressed: false,
|
||||||
|
justPressed: false,
|
||||||
|
justReleased: true,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const onMouseWheel = (e: WheelEvent) => {
|
||||||
|
mouseState.axes[2] = e.deltaY;
|
||||||
|
|
||||||
|
if (globalThis.requestAnimationFrame) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
mouseState.axes[2] = 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
95
packages/ngn/src/packages/input/index.ts
Normal file
95
packages/ngn/src/packages/input/index.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { gamepad as _gamepad, gamepadUpdate, onConnected as onGamepadConnected, onDisconnected as onGamepadDisconnected } from "./devices/gamepad";
|
||||||
|
import { keyboard as _keyboard, keyboardUpdate, onKeyDown, onKeyUp, setDefaultKeyboardState } from "./devices/keyboard";
|
||||||
|
import { GamepadMapping, PlayStation4, PlayStation5, SCUFVantage2, Xbox } from "./devices/mappings/gamepad";
|
||||||
|
import { KeyboardKey, KeyboardMapping, StandardKeyboard } from "./devices/mappings/keyboard";
|
||||||
|
import { MouseButton, MouseMapping, StandardMouse } from "./devices/mappings/mouse";
|
||||||
|
import { mouse as _mouse, mouseUpdate, onMouseDown, onMouseMove, onMouseUp, onMouseWheel, setDefaultMouseState } from "./devices/mouse";
|
||||||
|
|
||||||
|
export interface ButtonState {
|
||||||
|
justPressed: boolean;
|
||||||
|
pressed: boolean;
|
||||||
|
justReleased: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GamepadButtonState extends ButtonState {
|
||||||
|
touched: boolean;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let $mousemove = null;
|
||||||
|
let $mousedown = null;
|
||||||
|
let $mouseup = null;
|
||||||
|
let $mousewheel = null;
|
||||||
|
let $keydown = null;
|
||||||
|
let $keyup = null;
|
||||||
|
let $gamepadconnected = null;
|
||||||
|
let $gamepaddisconnected = null;
|
||||||
|
let boundEvents = false;
|
||||||
|
|
||||||
|
const setDefaultStates = () => {
|
||||||
|
setDefaultKeyboardState();
|
||||||
|
setDefaultMouseState();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const inputSystem = (through: any) => {
|
||||||
|
if (typeof window === "undefined") return through;
|
||||||
|
|
||||||
|
if (!boundEvents) {
|
||||||
|
bindEvents();
|
||||||
|
setDefaultStates();
|
||||||
|
boundEvents = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
mouseUpdate();
|
||||||
|
keyboardUpdate();
|
||||||
|
gamepadUpdate();
|
||||||
|
|
||||||
|
return through;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const destroyInput = () => {
|
||||||
|
destroyEvents();
|
||||||
|
boundEvents = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const bindEvents = () => {
|
||||||
|
$mousemove = window.addEventListener("mousemove", onMouseMove);
|
||||||
|
$mousedown = window.addEventListener("mousedown", onMouseDown);
|
||||||
|
$mouseup = window.addEventListener("mouseup", onMouseUp);
|
||||||
|
$mousewheel = window.addEventListener("mousewheel", onMouseWheel);
|
||||||
|
$keydown = window.addEventListener("keydown", onKeyDown);
|
||||||
|
$keyup = window.addEventListener("keyup", onKeyUp);
|
||||||
|
$gamepadconnected = window.addEventListener("gamepadconnected", onGamepadConnected);
|
||||||
|
$gamepaddisconnected = window.addEventListener("gamepaddisconnected", onGamepadDisconnected);
|
||||||
|
};
|
||||||
|
|
||||||
|
const destroyEvents = () => {
|
||||||
|
window.removeEventListener("mousemove", onMouseMove);
|
||||||
|
window.removeEventListener("mousedown", onMouseDown);
|
||||||
|
window.removeEventListener("mouseup", onMouseUp);
|
||||||
|
window.removeEventListener("mousewheel", onMouseWheel);
|
||||||
|
window.removeEventListener("keydown", onKeyDown);
|
||||||
|
window.removeEventListener("keyup", onKeyUp);
|
||||||
|
window.removeEventListener("gamepadconnected", onGamepadConnected);
|
||||||
|
window.removeEventListener("gamepaddisconnected", onGamepadDisconnected);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const keyboard = { ..._keyboard() };
|
||||||
|
export const mouse = { ..._mouse() };
|
||||||
|
export const gamepad = { ..._gamepad() };
|
||||||
|
|
||||||
|
export {
|
||||||
|
GamepadMapping,
|
||||||
|
SCUFVantage2,
|
||||||
|
Xbox,
|
||||||
|
PlayStation4,
|
||||||
|
PlayStation5,
|
||||||
|
KeyboardKey,
|
||||||
|
KeyboardMapping,
|
||||||
|
StandardKeyboard,
|
||||||
|
MouseButton,
|
||||||
|
MouseMapping,
|
||||||
|
StandardMouse,
|
||||||
|
onGamepadConnected,
|
||||||
|
onGamepadDisconnected,
|
||||||
|
};
|
||||||
58
packages/ngn/src/packages/log.ts
Normal file
58
packages/ngn/src/packages/log.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { WorldState } from "../ngn";
|
||||||
|
|
||||||
|
interface LogEntry {
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExpiringLogEntry extends LogEntry {
|
||||||
|
lifetime: number;
|
||||||
|
start: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_LOGS = 256;
|
||||||
|
|
||||||
|
type LogSystem = {
|
||||||
|
expiringLogs: ExpiringLogEntry[];
|
||||||
|
allLogs: LogEntry[];
|
||||||
|
update: (w: WorldState) => void;
|
||||||
|
log: (message: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createLogSystem = (options: Partial<{ maxLifetime: number }> = { maxLifetime: 10_000 }): LogSystem => {
|
||||||
|
const logSystem: LogSystem = {
|
||||||
|
expiringLogs: [],
|
||||||
|
allLogs: [],
|
||||||
|
update(world: WorldState) {
|
||||||
|
if (!this.expiringLogs.length) return;
|
||||||
|
|
||||||
|
this.expiringLogs.forEach((log, index) => {
|
||||||
|
log.lifetime -= world.time.delta;
|
||||||
|
|
||||||
|
if (log.lifetime < 0) {
|
||||||
|
this.expiringLogs.splice(index, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
log(message: string) {
|
||||||
|
this.expiringLogs.push({
|
||||||
|
message,
|
||||||
|
lifetime: options.maxLifetime,
|
||||||
|
start: new Date(),
|
||||||
|
});
|
||||||
|
this.allLogs.push({ message });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// bind so that `this` in update refers to logSystem
|
||||||
|
logSystem.update = logSystem.update.bind(logSystem);
|
||||||
|
logSystem.log = logSystem.log.bind(logSystem);
|
||||||
|
|
||||||
|
logSystem.allLogs.push = function () {
|
||||||
|
if (this.length >= MAX_LOGS) this.shift();
|
||||||
|
return Array.prototype.push.apply(this, arguments);
|
||||||
|
};
|
||||||
|
|
||||||
|
return logSystem;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { createLogSystem };
|
||||||
5
packages/ngn/src/tests/index.ts
Normal file
5
packages/ngn/src/tests/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { describe } from "manten";
|
||||||
|
|
||||||
|
await describe("ngn", async ({ runTestSuite }) => {
|
||||||
|
runTestSuite(import("./ngn"));
|
||||||
|
});
|
||||||
13
packages/ngn/src/tests/ngn/extras.test.ts
Normal file
13
packages/ngn/src/tests/ngn/extras.test.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { expect, test, testSuite } from "manten";
|
||||||
|
import { createLogSystem } from "../../packages/log";
|
||||||
|
|
||||||
|
export default testSuite(async ({ describe }) => {
|
||||||
|
test("createLogSystem", () => {
|
||||||
|
const logSystem = createLogSystem();
|
||||||
|
expect(logSystem).toBeDefined();
|
||||||
|
expect(logSystem.update).toBeDefined();
|
||||||
|
expect(logSystem.log).toBeDefined();
|
||||||
|
logSystem.log("");
|
||||||
|
expect(logSystem.expiringLogs.length).toEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
105
packages/ngn/src/tests/ngn/gamepad.test.ts
Normal file
105
packages/ngn/src/tests/ngn/gamepad.test.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import { expect, test, testSuite } from "manten";
|
||||||
|
import { gamepad, onConnected, onDisconnected } from "../../packages/input/devices/gamepad";
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
global.navigator = {
|
||||||
|
// @ts-ignore
|
||||||
|
getGamepads: () =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
connected: true,
|
||||||
|
id: "Mock Gamepad",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
axes: [0.5, -0.5, 0.0, 0.0],
|
||||||
|
buttons: [
|
||||||
|
{ pressed: false, touched: false, value: 0 },
|
||||||
|
{ pressed: true, touched: true, value: 1 },
|
||||||
|
// ... other buttons
|
||||||
|
],
|
||||||
|
mapping: "standard",
|
||||||
|
vibrationActuator: null,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
] as unknown as () => (Gamepad | null)[],
|
||||||
|
// ... other navigator properties if needed
|
||||||
|
};
|
||||||
|
|
||||||
|
export default testSuite(async ({ describe }) => {
|
||||||
|
describe("gamepad", () => {
|
||||||
|
test("should return an object with methods to interact with the gamepad input", () => {
|
||||||
|
const gp = gamepad();
|
||||||
|
expect(typeof gp.gamepad).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should allow setting a custom gamepad mapping", () => {
|
||||||
|
const customMapping = () => ({
|
||||||
|
buttons: {
|
||||||
|
"0": "X",
|
||||||
|
"1": "O",
|
||||||
|
},
|
||||||
|
axes: {
|
||||||
|
"0": "LeftStickX",
|
||||||
|
"1": "LeftStickY",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const gp = gamepad();
|
||||||
|
gp.gamepad(0).useMapping(customMapping);
|
||||||
|
expect(gp.gamepad(0).getButton("X")).toEqual({
|
||||||
|
pressed: false,
|
||||||
|
touched: false,
|
||||||
|
value: 0,
|
||||||
|
justPressed: false,
|
||||||
|
justReleased: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should get the state of a gamepad button", () => {
|
||||||
|
const gp = gamepad();
|
||||||
|
const state = gp.gamepad(0).getButton("X");
|
||||||
|
expect(state).toEqual({
|
||||||
|
pressed: false,
|
||||||
|
touched: false,
|
||||||
|
value: 0,
|
||||||
|
justPressed: false,
|
||||||
|
justReleased: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should get the value of a gamepad axis", () => {
|
||||||
|
const gp = gamepad();
|
||||||
|
const value = gp.gamepad(0).getAxis("LeftStickX");
|
||||||
|
expect(value).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should update the gamepad state", () => {
|
||||||
|
// Mocking gamepad input is complex due to the read-only nature of `navigator.getGamepads`.
|
||||||
|
// This test assumes that the gamepadUpdate function is called within an environment where
|
||||||
|
// navigator.getGamepads() returns a valid Gamepad object.
|
||||||
|
// You would need to mock navigator.getGamepads() to return a gamepad with a specific state.
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle gamepad connected event", () => {
|
||||||
|
const index = 0;
|
||||||
|
onConnected({ gamepad: { index } } as GamepadEvent);
|
||||||
|
const gp = gamepad();
|
||||||
|
expect(gp.gamepad(index)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle gamepad disconnected event", () => {
|
||||||
|
const index = 0;
|
||||||
|
onConnected({ gamepad: { index } } as GamepadEvent);
|
||||||
|
onDisconnected({ gamepad: { index } } as GamepadEvent);
|
||||||
|
const gp = gamepad();
|
||||||
|
expect(gp.gamepad(index).getButton("X")).toEqual({
|
||||||
|
pressed: false,
|
||||||
|
touched: false,
|
||||||
|
value: 0,
|
||||||
|
justPressed: false,
|
||||||
|
justReleased: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
23
packages/ngn/src/tests/ngn/index.ts
Normal file
23
packages/ngn/src/tests/ngn/index.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { testSuite } from "manten";
|
||||||
|
|
||||||
|
export default testSuite(async ({ describe }) => {
|
||||||
|
describe("world", async ({ runTestSuite }) => {
|
||||||
|
runTestSuite(import("./world.test.js"));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extras", async ({ runTestSuite }) => {
|
||||||
|
runTestSuite(import("./extras.test.js"));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("keyboard input", async ({ runTestSuite }) => {
|
||||||
|
runTestSuite(import("./keyboard.test.js"));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("gamepad input", async ({ runTestSuite }) => {
|
||||||
|
runTestSuite(import("./gamepad.test.js"));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mouse input", async ({ runTestSuite }) => {
|
||||||
|
runTestSuite(import("./mouse.test.js"));
|
||||||
|
});
|
||||||
|
});
|
||||||
118
packages/ngn/src/tests/ngn/keyboard.test.ts
Normal file
118
packages/ngn/src/tests/ngn/keyboard.test.ts
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import { expect, test, testSuite } from "manten";
|
||||||
|
import { keyboard, keyboardUpdate, onKeyDown, onKeyUp } from "../../packages/input/devices/keyboard";
|
||||||
|
import { KeyboardKey } from "../../packages/input/devices/mappings/keyboard";
|
||||||
|
|
||||||
|
export default testSuite(async ({ describe }) => {
|
||||||
|
describe("keyboard", () => {
|
||||||
|
test("should return an object with a keyboard property containing methods", () => {
|
||||||
|
const kb = keyboard();
|
||||||
|
expect(typeof kb.keyboard).toBe("object");
|
||||||
|
expect(typeof kb.keyboard.useMapping).toBe("function");
|
||||||
|
expect(typeof kb.keyboard.getKey).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should allow setting a custom keyboard mapping", () => {
|
||||||
|
const customMapping = () => ({
|
||||||
|
[KeyboardKey.KeyA]: "Left",
|
||||||
|
[KeyboardKey.KeyD]: "Right",
|
||||||
|
});
|
||||||
|
const kb = keyboard();
|
||||||
|
kb.keyboard.useMapping(customMapping);
|
||||||
|
expect(kb.keyboard.getKey("Left")).toEqual({
|
||||||
|
pressed: false,
|
||||||
|
justPressed: false,
|
||||||
|
justReleased: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should set the default state for all keys", () => {
|
||||||
|
const kb = keyboard();
|
||||||
|
const state = kb.keyboard.getKey("Right");
|
||||||
|
expect(state).toEqual({
|
||||||
|
pressed: false,
|
||||||
|
justPressed: false,
|
||||||
|
justReleased: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should get the state of a key", () => {
|
||||||
|
const kb = keyboard();
|
||||||
|
const state = kb.keyboard.getKey("Left");
|
||||||
|
expect(state).toEqual({
|
||||||
|
pressed: false,
|
||||||
|
justPressed: false,
|
||||||
|
justReleased: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should update the keyboard state", () => {
|
||||||
|
onKeyDown({ code: "KeyA", repeat: false } as KeyboardEvent);
|
||||||
|
keyboardUpdate();
|
||||||
|
const kb = keyboard();
|
||||||
|
expect(kb.keyboard.getKey("Left")).toEqual({
|
||||||
|
pressed: true,
|
||||||
|
justPressed: true,
|
||||||
|
justReleased: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should set the state of a key to pressed", () => {
|
||||||
|
onKeyUp({ code: "KeyA" } as KeyboardEvent);
|
||||||
|
keyboardUpdate();
|
||||||
|
onKeyDown({ code: "KeyA", repeat: false } as KeyboardEvent);
|
||||||
|
keyboardUpdate();
|
||||||
|
const kb = keyboard();
|
||||||
|
expect(kb.keyboard.getKey("Left")).toEqual({
|
||||||
|
pressed: true,
|
||||||
|
justPressed: true,
|
||||||
|
justReleased: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
keyboardUpdate();
|
||||||
|
expect(kb.keyboard.getKey("Left")).toEqual({
|
||||||
|
pressed: true,
|
||||||
|
justPressed: false,
|
||||||
|
justReleased: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not update the state if the event is a repeat", () => {
|
||||||
|
const kb = keyboard();
|
||||||
|
onKeyUp({ code: "KeyA" } as KeyboardEvent);
|
||||||
|
keyboardUpdate();
|
||||||
|
expect(kb.keyboard.getKey("Left")).toEqual({
|
||||||
|
pressed: false,
|
||||||
|
justPressed: false,
|
||||||
|
justReleased: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
keyboardUpdate();
|
||||||
|
expect(kb.keyboard.getKey("Left")).toEqual({
|
||||||
|
pressed: false,
|
||||||
|
justPressed: false,
|
||||||
|
justReleased: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
onKeyDown({ code: "KeyA", repeat: true } as KeyboardEvent);
|
||||||
|
keyboardUpdate();
|
||||||
|
expect(kb.keyboard.getKey("Left")).toEqual({
|
||||||
|
pressed: false,
|
||||||
|
justPressed: false,
|
||||||
|
justReleased: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should set the state of a key to not pressed", () => {
|
||||||
|
onKeyDown({ code: "KeyA", repeat: false } as KeyboardEvent);
|
||||||
|
keyboardUpdate();
|
||||||
|
onKeyUp({ code: "KeyA" } as KeyboardEvent);
|
||||||
|
keyboardUpdate();
|
||||||
|
const kb = keyboard();
|
||||||
|
expect(kb.keyboard.getKey("Left")).toEqual({
|
||||||
|
pressed: false,
|
||||||
|
justPressed: false,
|
||||||
|
justReleased: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
157
packages/ngn/src/tests/ngn/mouse.test.ts
Normal file
157
packages/ngn/src/tests/ngn/mouse.test.ts
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
import { expect, test, testSuite } from "manten";
|
||||||
|
import { MouseButton } from "../../packages/input/devices/mappings/mouse";
|
||||||
|
import { mouse, mouseUpdate, onMouseDown, onMouseMove, onMouseUp, onMouseWheel } from "../../packages/input/devices/mouse";
|
||||||
|
|
||||||
|
class MockMouseEvent {
|
||||||
|
constructor(
|
||||||
|
public type: string,
|
||||||
|
public config: {
|
||||||
|
button?: number;
|
||||||
|
movementX?: number;
|
||||||
|
movementY?: number;
|
||||||
|
clientX?: number;
|
||||||
|
clientY?: number;
|
||||||
|
},
|
||||||
|
) {}
|
||||||
|
get button() {
|
||||||
|
return this.config.button ?? 0;
|
||||||
|
}
|
||||||
|
get movementX() {
|
||||||
|
return this.config.movementX ?? 0;
|
||||||
|
}
|
||||||
|
get movementY() {
|
||||||
|
return this.config.movementY ?? 0;
|
||||||
|
}
|
||||||
|
get clientX() {
|
||||||
|
return this.config.clientX ?? 0;
|
||||||
|
}
|
||||||
|
get clientY() {
|
||||||
|
return this.config.clientY ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockWheelEvent {
|
||||||
|
constructor(
|
||||||
|
public type: string,
|
||||||
|
public config: { deltaY?: number },
|
||||||
|
) {}
|
||||||
|
get deltaY() {
|
||||||
|
return this.config.deltaY ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default testSuite(async ({ describe }) => {
|
||||||
|
describe("mouse", () => {
|
||||||
|
test("should return an object with methods to interact with the mouse input", () => {
|
||||||
|
const m = mouse();
|
||||||
|
expect(typeof m.mouse).toBe("object");
|
||||||
|
expect(typeof m.mouse.useMapping).toBe("function");
|
||||||
|
expect(typeof m.mouse.getButton).toBe("function");
|
||||||
|
expect(typeof m.mouse.getAxis).toBe("function");
|
||||||
|
expect(typeof m.mouse.getPosition).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should allow setting a custom mouse mapping", () => {
|
||||||
|
const customMapping = () => ({
|
||||||
|
buttons: {
|
||||||
|
[MouseButton.Mouse1]: "Shoot",
|
||||||
|
// [MouseButton.Mouse2]: 'Aim',
|
||||||
|
// [MouseButton.Mouse3]: '',
|
||||||
|
// [MouseButton.Mouse4]: '',
|
||||||
|
// [MouseButton.Mouse5]: '',
|
||||||
|
},
|
||||||
|
axes: {
|
||||||
|
"0": "MoveX",
|
||||||
|
"1": "MoveY",
|
||||||
|
"2": "Scroll",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const m = mouse();
|
||||||
|
m.mouse.useMapping(customMapping);
|
||||||
|
expect(m.mouse.getButton("Shoot")).toEqual({
|
||||||
|
pressed: false,
|
||||||
|
justPressed: false,
|
||||||
|
justReleased: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should get the state of a mouse button", () => {
|
||||||
|
const m = mouse();
|
||||||
|
const state = m.mouse.getButton("Shoot");
|
||||||
|
expect(state).toEqual({
|
||||||
|
pressed: false,
|
||||||
|
justPressed: false,
|
||||||
|
justReleased: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should get the state of a mouse button even if it doesnt exist", () => {
|
||||||
|
const m = mouse();
|
||||||
|
const state = m.mouse.getButton("Pancakes");
|
||||||
|
expect(state).toEqual({
|
||||||
|
pressed: false,
|
||||||
|
justPressed: false,
|
||||||
|
justReleased: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should get the value of a mouse axis", () => {
|
||||||
|
const m = mouse();
|
||||||
|
const value = m.mouse.getAxis("0");
|
||||||
|
expect(value).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should get the mouse position", () => {
|
||||||
|
const m = mouse();
|
||||||
|
const position = m.mouse.getPosition();
|
||||||
|
expect(position).toEqual({ x: 0, y: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle mouse down and up events", () => {
|
||||||
|
const m = mouse();
|
||||||
|
|
||||||
|
onMouseDown(
|
||||||
|
new MockMouseEvent("mousedown", {
|
||||||
|
button: Number(MouseButton.Mouse1),
|
||||||
|
}) as any,
|
||||||
|
);
|
||||||
|
mouseUpdate();
|
||||||
|
expect(m.mouse.getButton("Shoot")).toEqual({
|
||||||
|
pressed: true,
|
||||||
|
justPressed: true,
|
||||||
|
justReleased: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
onMouseUp(
|
||||||
|
new MockMouseEvent("mouseup", {
|
||||||
|
button: Number(MouseButton.Mouse1),
|
||||||
|
}) as any,
|
||||||
|
);
|
||||||
|
mouseUpdate();
|
||||||
|
expect(m.mouse.getButton("Shoot")).toEqual({
|
||||||
|
pressed: false,
|
||||||
|
justPressed: false,
|
||||||
|
justReleased: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle mouse move events", () => {
|
||||||
|
onMouseMove(
|
||||||
|
new MockMouseEvent("mousemove", {
|
||||||
|
movementX: 100,
|
||||||
|
movementY: 50,
|
||||||
|
}) as any,
|
||||||
|
);
|
||||||
|
const m = mouse();
|
||||||
|
expect(m.mouse.getAxis("0")).toBe(100);
|
||||||
|
expect(m.mouse.getAxis("1")).toBe(50);
|
||||||
|
expect(m.mouse.getPosition()).toEqual({ x: 0, y: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle mouse wheel events", () => {
|
||||||
|
onMouseWheel(new MockWheelEvent("wheel", { deltaY: 120 }) as any);
|
||||||
|
const m = mouse();
|
||||||
|
expect(m.mouse.getAxis("2")).toBe(120);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
564
packages/ngn/src/tests/ngn/world.test.ts
Normal file
564
packages/ngn/src/tests/ngn/world.test.ts
Normal file
@ -0,0 +1,564 @@
|
|||||||
|
import { expect, test, testSuite } from "manten";
|
||||||
|
import { extend } from "../../misc";
|
||||||
|
import { $ceMap, $eciMap, $eMap, $queryResults, $systems, createWorld, Entity, System, WorldState } from "../../ngn";
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
export default testSuite(async () => {
|
||||||
|
test("can createWorld", () => {
|
||||||
|
const { state, query, createEntity } = createWorld();
|
||||||
|
expect(state).toBeDefined();
|
||||||
|
expect(query).toBeDefined();
|
||||||
|
expect(createEntity).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can createEntity", () => {
|
||||||
|
const { createEntity } = createWorld();
|
||||||
|
const entity = createEntity();
|
||||||
|
expect(entity).toBeDefined();
|
||||||
|
expect(entity.id).toBeDefined();
|
||||||
|
expect(entity.components).toBeDefined();
|
||||||
|
expect(entity.addComponent).toBeDefined();
|
||||||
|
expect(entity.hasComponent).toBeDefined();
|
||||||
|
expect(entity.getComponent).toBeDefined();
|
||||||
|
expect(entity.removeComponent).toBeDefined();
|
||||||
|
expect(entity.destroy).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can createEntity and override id", () => {
|
||||||
|
const { createEntity } = createWorld();
|
||||||
|
const entity = createEntity({ name: "foo", id: "abc" });
|
||||||
|
expect(entity.name).toEqual("foo");
|
||||||
|
expect(entity.id).toEqual("abc");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("overriding existing id should move the old entity and update world maps", () => {
|
||||||
|
const { createEntity, state } = createWorld();
|
||||||
|
|
||||||
|
const Foo = () => ({ name: "foo" });
|
||||||
|
const Bar = () => ({ name: "bar" });
|
||||||
|
|
||||||
|
const firstEntity = createEntity({ name: "foo", id: "abc" }).addComponent(Foo);
|
||||||
|
expect(firstEntity.id).toEqual("abc");
|
||||||
|
|
||||||
|
const secondEntity = createEntity({ name: "bar", id: "abc" }).addComponent(Bar);
|
||||||
|
|
||||||
|
// the old entity now has a new id, which is the next valid id
|
||||||
|
expect(firstEntity.id).not.toEqual("abc");
|
||||||
|
|
||||||
|
// the new entity forcefully took the old id
|
||||||
|
expect(secondEntity.id).toEqual("abc");
|
||||||
|
|
||||||
|
// the world maps should be correctly updated
|
||||||
|
expect(state[$eMap]["abc"]).toEqual(secondEntity);
|
||||||
|
expect(state[$eMap][firstEntity.id]).toEqual(firstEntity);
|
||||||
|
|
||||||
|
expect(state[$eciMap]["abc"]).toEqual({ [Bar.name]: 0 });
|
||||||
|
expect(state[$eciMap][firstEntity.id]).toEqual({ [Foo.name]: 0 });
|
||||||
|
|
||||||
|
const thirdEntity = createEntity({ name: "baz", id: "abc" });
|
||||||
|
expect(thirdEntity.id).toEqual("abc");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can getEntity", () => {
|
||||||
|
const { createEntity, getEntity } = createWorld();
|
||||||
|
const entity = createEntity();
|
||||||
|
expect(getEntity(entity.id)).toEqual(entity);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("createEntity with defaults", () => {
|
||||||
|
const { createEntity } = createWorld();
|
||||||
|
const entity = createEntity({ a: 1 });
|
||||||
|
expect(entity.a).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("onEntityCreated", async () => {
|
||||||
|
const { createEntity, onEntityCreated } = createWorld();
|
||||||
|
let called = false;
|
||||||
|
|
||||||
|
onEntityCreated((ent: Entity) => {
|
||||||
|
called = true;
|
||||||
|
expect(ent.id).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
createEntity();
|
||||||
|
|
||||||
|
expect(called).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can addComponent", () => {
|
||||||
|
const { createEntity } = createWorld();
|
||||||
|
const entity = createEntity();
|
||||||
|
const thing = () => ({ hello: "world" });
|
||||||
|
entity.addComponent(thing);
|
||||||
|
expect(entity.components.length).toEqual(1);
|
||||||
|
expect(entity.components[0].hello).toEqual("world");
|
||||||
|
expect(entity.components[0].__ngn__).toEqual({ parent: entity.id, name: thing.name });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can addComponent with defaults to override", () => {
|
||||||
|
const { createEntity } = createWorld();
|
||||||
|
const entity = createEntity();
|
||||||
|
const thing = () => ({ hello: "world" });
|
||||||
|
entity.addComponent(thing, { hello: "universe" });
|
||||||
|
expect(entity.components.length).toEqual(1);
|
||||||
|
expect(entity.components[0].hello).toEqual("universe");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can hasComponent", () => {
|
||||||
|
const { createEntity } = createWorld();
|
||||||
|
const entity = createEntity();
|
||||||
|
const thing = () => ({ hello: "world" });
|
||||||
|
const otherThing = () => ({ hello: "world" });
|
||||||
|
entity.addComponent(thing);
|
||||||
|
expect(entity.hasComponent(thing)).toEqual(true);
|
||||||
|
expect(entity.hasComponent(otherThing)).toEqual(false);
|
||||||
|
|
||||||
|
entity.removeComponent(thing);
|
||||||
|
expect(entity.hasComponent(thing)).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can getComponent", () => {
|
||||||
|
const { createEntity } = createWorld();
|
||||||
|
const entity = createEntity();
|
||||||
|
const thing = () => ({ hello: "world" });
|
||||||
|
entity.addComponent(thing);
|
||||||
|
expect(entity.getComponent<typeof thing>(thing).hello).toEqual("world");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can modify a component", () => {
|
||||||
|
const { createEntity } = createWorld();
|
||||||
|
const entity = createEntity();
|
||||||
|
const thing = () => ({ hello: "world" });
|
||||||
|
entity.addComponent(thing);
|
||||||
|
const component = entity.getComponent<typeof thing>(thing);
|
||||||
|
component.hello = "universe";
|
||||||
|
expect(entity.getComponent<typeof thing>(thing).hello).toEqual("universe");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getComponent works even after adding/removing/add, etc", () => {
|
||||||
|
const { createEntity } = createWorld();
|
||||||
|
const entity = createEntity();
|
||||||
|
const thing1 = () => ({ hello: "world1" });
|
||||||
|
const thing2 = () => ({ hello: "world2" });
|
||||||
|
entity.addComponent(thing1);
|
||||||
|
expect(entity.getComponent<typeof thing1>(thing1).hello).toEqual("world1");
|
||||||
|
entity.addComponent(thing2);
|
||||||
|
expect(entity.getComponent<typeof thing2>(thing2).hello).toEqual("world2");
|
||||||
|
entity.removeComponent(thing1);
|
||||||
|
expect(entity.getComponent<typeof thing2>(thing2).hello).toEqual("world2");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can removeComponent", () => {
|
||||||
|
const { createEntity } = createWorld();
|
||||||
|
const entity = createEntity();
|
||||||
|
const thing = () => ({ hello: "world" });
|
||||||
|
entity.addComponent(thing);
|
||||||
|
expect(entity.components.length).toEqual(1);
|
||||||
|
entity.removeComponent(thing);
|
||||||
|
expect(entity.components.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("world maps behave predictably", () => {
|
||||||
|
const { state, createEntity } = createWorld();
|
||||||
|
const entity = createEntity();
|
||||||
|
expect(state[$eciMap][entity.id]).toEqual({});
|
||||||
|
|
||||||
|
const Position = () => ({ x: 0, y: 0 });
|
||||||
|
const Velocity = () => ({ x: 1, y: 1 });
|
||||||
|
|
||||||
|
entity.addComponent(Position).addComponent(Velocity);
|
||||||
|
|
||||||
|
expect(state[$eciMap][entity.id]).toEqual({ [Position.name]: 0, [Velocity.name]: 1 });
|
||||||
|
expect(state[$ceMap][Position.name]).toEqual([entity.id]);
|
||||||
|
expect(state[$ceMap][Velocity.name]).toEqual([entity.id]);
|
||||||
|
expect(entity.components).toEqual([
|
||||||
|
{
|
||||||
|
...Position(),
|
||||||
|
__ngn__: {
|
||||||
|
parent: entity.id,
|
||||||
|
name: Position.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...Velocity(),
|
||||||
|
__ngn__: {
|
||||||
|
parent: entity.id,
|
||||||
|
name: Velocity.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
entity.removeComponent(Position);
|
||||||
|
|
||||||
|
expect(state[$eciMap][entity.id]).toEqual({ [Velocity.name]: 0 });
|
||||||
|
expect(state[$ceMap][Position.name]).toEqual([]);
|
||||||
|
expect(state[$ceMap][Velocity.name]).toEqual([entity.id]);
|
||||||
|
expect(entity.components).toEqual([
|
||||||
|
{
|
||||||
|
...Velocity(),
|
||||||
|
__ngn__: {
|
||||||
|
parent: entity.id,
|
||||||
|
name: Velocity.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const velocity = entity.getComponent<typeof Velocity>(Velocity);
|
||||||
|
|
||||||
|
expect(velocity.x).toEqual(1);
|
||||||
|
expect(velocity.y).toEqual(1);
|
||||||
|
|
||||||
|
velocity.x = 2;
|
||||||
|
velocity.y = 2;
|
||||||
|
|
||||||
|
expect(entity.components).toEqual([{ x: 2, y: 2, __ngn__: { parent: entity.id, name: Velocity.name } }]);
|
||||||
|
expect(state[$eMap][entity.id]).toEqual(entity);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can destroy entity", () => {
|
||||||
|
const { state, createEntity } = createWorld();
|
||||||
|
const entity = createEntity();
|
||||||
|
const Position = () => ({ x: 0, y: 0 });
|
||||||
|
const Velocity = () => ({ x: 1, y: 1 });
|
||||||
|
entity.addComponent(Position).addComponent(Velocity);
|
||||||
|
entity.destroy();
|
||||||
|
expect(state[$eMap][entity.id]).toBeUndefined();
|
||||||
|
expect(state[$ceMap][Position.name]).toEqual([]);
|
||||||
|
expect(state[$ceMap][Velocity.name]).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can 'and' query", () => {
|
||||||
|
const { state, createEntity, query } = createWorld();
|
||||||
|
const entity1 = createEntity();
|
||||||
|
const entity2 = createEntity();
|
||||||
|
const Position = () => ({ x: 0, y: 0 });
|
||||||
|
const Velocity = () => ({ x: 1, y: 1 });
|
||||||
|
const NotMe = () => ({ x: 2, y: 2 });
|
||||||
|
entity1.addComponent(Position).addComponent(Velocity).addComponent(NotMe);
|
||||||
|
const movables = query({ and: [Position, Velocity] });
|
||||||
|
|
||||||
|
movables((results) => {
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
expect(results[0].entity.components).toHaveLength(3);
|
||||||
|
const queryKey = "andPositionVelocityornottag";
|
||||||
|
expect(state[$queryResults][queryKey].results).toHaveLength(1);
|
||||||
|
const resultEntity = state[$queryResults][queryKey].results[0] as any;
|
||||||
|
expect(resultEntity.Position).toBeDefined();
|
||||||
|
expect(resultEntity.Velocity).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
entity2.addComponent(Position).addComponent(Velocity).addComponent(NotMe);
|
||||||
|
|
||||||
|
movables((results) => {
|
||||||
|
expect(results).toHaveLength(2);
|
||||||
|
results.forEach((result) => {
|
||||||
|
expect(result.entity.components).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can 'or' query", () => {
|
||||||
|
const { state, createEntity, query } = createWorld();
|
||||||
|
const Position = () => ({ x: 0, y: 0 });
|
||||||
|
const Velocity = () => ({ x: 1, y: 1 });
|
||||||
|
const entity1 = createEntity().addComponent(Position);
|
||||||
|
const entity2 = createEntity().addComponent(Position).addComponent(Velocity);
|
||||||
|
const movables = query({ or: [Position, Velocity] });
|
||||||
|
|
||||||
|
movables((results) => {
|
||||||
|
expect(results).toHaveLength(2);
|
||||||
|
expect(results[0].entity.components).toHaveLength(1);
|
||||||
|
expect(results[0].entity.id).toEqual(entity1.id);
|
||||||
|
expect(results[1].entity.components).toHaveLength(2);
|
||||||
|
expect(results[1].entity.id).toEqual(entity2.id);
|
||||||
|
const queryResults = state[$queryResults]["andorPositionVelocitynottag"].results;
|
||||||
|
expect(queryResults).toHaveLength(2);
|
||||||
|
expect(queryResults[0].Position).toBeDefined();
|
||||||
|
expect(queryResults[1].Position).toBeDefined();
|
||||||
|
expect(queryResults[1].Velocity).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can 'not' query", () => {
|
||||||
|
const { createEntity, query } = createWorld();
|
||||||
|
const entity0 = createEntity();
|
||||||
|
const entity1 = createEntity();
|
||||||
|
const entity2 = createEntity();
|
||||||
|
const Position = () => ({ x: 0, y: 0 });
|
||||||
|
const Velocity = () => ({ x: 1, y: 1 });
|
||||||
|
const NotMe = () => ({ x: 2, y: 2 });
|
||||||
|
entity0.addComponent(Position).addComponent(Velocity).addComponent(NotMe);
|
||||||
|
entity1.addComponent(Position).addComponent(Velocity);
|
||||||
|
const movables = query({ and: [Position, Velocity], not: [NotMe] });
|
||||||
|
const other = query({ not: [NotMe] });
|
||||||
|
|
||||||
|
other((results) => {
|
||||||
|
expect(results.length).toEqual(2);
|
||||||
|
expect(results[0].entity.id).toEqual(entity1.id);
|
||||||
|
expect(results[1].entity.id).toEqual(entity2.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
entity2.addComponent(NotMe);
|
||||||
|
|
||||||
|
other((results) => {
|
||||||
|
expect(results.length).toEqual(1);
|
||||||
|
expect(results[0].entity.id).toEqual(entity1.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
movables((results) => {
|
||||||
|
expect(results.length).toEqual(1);
|
||||||
|
expect(results[0].entity.id).toEqual(entity1.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
entity0.removeComponent(NotMe);
|
||||||
|
|
||||||
|
movables((results) => {
|
||||||
|
expect(results.length).toEqual(2);
|
||||||
|
expect(results[0].entity.id).toEqual(entity0.id);
|
||||||
|
expect(results[1].entity.id).toEqual(entity1.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can 'tag' query", () => {
|
||||||
|
const { createEntity, query } = createWorld();
|
||||||
|
const entity0 = createEntity();
|
||||||
|
const entity1 = createEntity();
|
||||||
|
const Position = () => ({ x: 0, y: 0 });
|
||||||
|
const Velocity = () => ({ x: 1, y: 1 });
|
||||||
|
const NotMe = () => ({ x: 2, y: 2 });
|
||||||
|
entity0.addComponent(Position).addComponent(Velocity).addComponent(NotMe).addTag("cube");
|
||||||
|
entity1.addComponent(Position).addComponent(Velocity).addTag("cube");
|
||||||
|
const movables = query({ tag: ["cube"], not: [NotMe] });
|
||||||
|
|
||||||
|
movables((results) => {
|
||||||
|
expect(results.length).toEqual(1);
|
||||||
|
expect(results[0].entity.id).toEqual(entity1.id);
|
||||||
|
expect(results[0].entity.getTag()).toEqual("cube");
|
||||||
|
});
|
||||||
|
|
||||||
|
entity0.removeComponent(NotMe);
|
||||||
|
|
||||||
|
movables((results) => {
|
||||||
|
expect(results.length).toEqual(2);
|
||||||
|
expect(results[0].entity.id).toEqual(entity0.id);
|
||||||
|
expect(results[1].entity.id).toEqual(entity1.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
entity1.addTag("not-cube");
|
||||||
|
|
||||||
|
movables((results) => {
|
||||||
|
expect(results.length).toEqual(1);
|
||||||
|
expect(results[0].entity.id).toEqual(entity0.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
entity0.removeTag();
|
||||||
|
|
||||||
|
movables((results) => {
|
||||||
|
expect(results.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
entity1.addTag("cube");
|
||||||
|
|
||||||
|
movables((results) => {
|
||||||
|
expect(results.length).toEqual(1);
|
||||||
|
expect(results[0].entity.id).toEqual(entity1.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("queries update when a new entity is created", () => {
|
||||||
|
const { createEntity, query } = createWorld();
|
||||||
|
const entity = createEntity();
|
||||||
|
const Position = () => ({ x: 0, y: 0 });
|
||||||
|
const Velocity = () => ({ x: 1, y: 1 });
|
||||||
|
const movables = query({ and: [Position, Velocity] });
|
||||||
|
|
||||||
|
movables((results) => {
|
||||||
|
expect(results.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
entity.addComponent(Position).addComponent(Velocity);
|
||||||
|
|
||||||
|
movables((results) => {
|
||||||
|
expect(results.length).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
createEntity().addComponent(Position).addComponent(Velocity);
|
||||||
|
|
||||||
|
movables((results) => {
|
||||||
|
expect(results.length).toEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("components can be destructured from query results and directly modified", () => {
|
||||||
|
const { createEntity, query } = createWorld();
|
||||||
|
const entity = createEntity();
|
||||||
|
const Position = () => ({ x: 0, y: 0 });
|
||||||
|
const Velocity = () => ({ x: 1, y: 1 });
|
||||||
|
entity.addComponent(Position).addComponent(Velocity);
|
||||||
|
const movables = query({ and: [Position, Velocity] });
|
||||||
|
|
||||||
|
movables((results) => {
|
||||||
|
// @ts-ignore
|
||||||
|
results.forEach(({ Position }) => {
|
||||||
|
Position.x = 5;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(entity.getComponent<typeof Position>(Position).x).toEqual(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("destroying an entity removes it from query results", () => {
|
||||||
|
const { createEntity, query } = createWorld();
|
||||||
|
const entity = createEntity();
|
||||||
|
const Thing = () => ({ x: 0, y: 0 });
|
||||||
|
const things = query({ and: [Thing] });
|
||||||
|
|
||||||
|
entity.addComponent(Thing);
|
||||||
|
|
||||||
|
things((results) => {
|
||||||
|
expect(results.length).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
entity.destroy();
|
||||||
|
|
||||||
|
things((results) => {
|
||||||
|
expect(results.length).toEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can add and remove systems", () => {
|
||||||
|
const { state, addSystem, removeSystem } = createWorld();
|
||||||
|
|
||||||
|
const system1: System = { update() {} };
|
||||||
|
const system2: System = { update() {} };
|
||||||
|
const system3: System = () => {};
|
||||||
|
|
||||||
|
addSystem(system1, system2, system3);
|
||||||
|
|
||||||
|
expect(state[$systems].length).toEqual(3);
|
||||||
|
expect(state[$systems][0]).toEqual(system1.update);
|
||||||
|
expect(state[$systems][1]).toEqual(system2.update);
|
||||||
|
expect(state[$systems][2]).toEqual(system3);
|
||||||
|
|
||||||
|
removeSystem(system2);
|
||||||
|
expect(state[$systems].includes(system2.update)).toEqual(false);
|
||||||
|
|
||||||
|
expect(state[$systems].length).toEqual(2);
|
||||||
|
expect(state[$systems][0]).toEqual(system1.update);
|
||||||
|
expect(state[$systems][1]).toEqual(system3);
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
expect(() => addSystem({})).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("start", async () => {
|
||||||
|
const { state, start, stop, defineMain } = createWorld();
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
defineMain((state: WorldState) => {
|
||||||
|
// The main loop is called at the same frequency
|
||||||
|
// of the browser's requestAnimationFrame because the
|
||||||
|
// scale is 1.0.
|
||||||
|
if (i === 3) {
|
||||||
|
expect(state.time.loopDelta).toBe(16.67);
|
||||||
|
}
|
||||||
|
expect(state.time.delta).toBeGreaterThan(16.6);
|
||||||
|
expect(state.time.delta).toBeLessThan(16.7);
|
||||||
|
if (++i === 3) stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
start();
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
expect(i).toBe(3);
|
||||||
|
expect(state.time.delta).toEqual(16.670000000000016);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("start, scale at 0.5", async () => {
|
||||||
|
const { state, start, stop, defineMain } = createWorld();
|
||||||
|
let i = 0;
|
||||||
|
state.time.scale = 0.5;
|
||||||
|
|
||||||
|
defineMain((state: WorldState) => {
|
||||||
|
// The main loop is called at half the frequency
|
||||||
|
// of the browser's requestAnimationFrame because the
|
||||||
|
// scale is 0.5.
|
||||||
|
if (i === 3) {
|
||||||
|
expect(state.time.loopDelta).toBe(33.34);
|
||||||
|
}
|
||||||
|
if (++i === 3) stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
start();
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
expect(i).toBe(3);
|
||||||
|
expect(state.time.delta).toBe(16.670000000000016);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("step calls systems, passing world", async () => {
|
||||||
|
const { state, step, addSystem } = createWorld();
|
||||||
|
|
||||||
|
const sys1: System = (state: WorldState) => {
|
||||||
|
state["foo"] = "bar";
|
||||||
|
};
|
||||||
|
|
||||||
|
const sys2: System = (state: WorldState) => {
|
||||||
|
state["bar"] = "baz";
|
||||||
|
};
|
||||||
|
|
||||||
|
addSystem(sys1, sys2);
|
||||||
|
|
||||||
|
step();
|
||||||
|
|
||||||
|
expect(state["foo"]).toEqual("bar");
|
||||||
|
expect(state["bar"]).toEqual("baz");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("extend", async () => {
|
||||||
|
const { query, createEntity } = createWorld();
|
||||||
|
|
||||||
|
const Position = () => ({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
const ent = createEntity({ name: "foo" });
|
||||||
|
|
||||||
|
const extended = extend(Position)({ x: 5, y: 15 });
|
||||||
|
expect(extended.name).toEqual(Position.name);
|
||||||
|
|
||||||
|
ent.addComponent(extended);
|
||||||
|
|
||||||
|
expect(ent.components.length).toEqual(1);
|
||||||
|
expect(ent.components[0].x).toEqual(5);
|
||||||
|
|
||||||
|
// ensure query for "Position" still works:
|
||||||
|
const movables = query({ and: [Position] });
|
||||||
|
|
||||||
|
movables((results) => {
|
||||||
|
expect(results.length).toEqual(1);
|
||||||
|
expect(results[0].entity.components[0].x).toEqual(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// test("query .onEntityAdded works", async () => {
|
||||||
|
// const { query, createEntity } = createWorld();
|
||||||
|
|
||||||
|
// const Position = () => ({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
// const movables = query({ and: [Position] });
|
||||||
|
|
||||||
|
// let called = false;
|
||||||
|
|
||||||
|
// movables.onEntityAdded((entity) => {
|
||||||
|
// console.log("onentityadded", entity.id);
|
||||||
|
// called = true;
|
||||||
|
// expect(entity.components[0].x).toEqual(5);
|
||||||
|
// });
|
||||||
|
|
||||||
|
// const ent = createEntity({ name: "foo" });
|
||||||
|
|
||||||
|
// ent.addComponent(Position, { x: 5 });
|
||||||
|
|
||||||
|
// expect(called).toEqual(true);
|
||||||
|
// });
|
||||||
|
});
|
||||||
15
packages/ngn/tsconfig.json
Normal file
15
packages/ngn/tsconfig.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "lib",
|
||||||
|
"module": "ESNext",
|
||||||
|
"target": "ESNext",
|
||||||
|
"lib": ["ESNext", "DOM"],
|
||||||
|
"declaration": true,
|
||||||
|
"declarationDir": "./types",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"exclude": ["./lib"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user