@game-vir/entity
Version:
Entity system for game development.
293 lines (239 loc) • 9.35 kB
Markdown
# -vir/entity
An entity system for Pixi.js graphics that lends itself well to game use. Also included are some helpful entity maths.
Reference docs: https://electrovir.github.io/game-vir/entity/
## Install
```sh
npm i -vir/entity
```
## Usage
Use [`defineEntitySuite`](https://electrovir.github.io/game-vir/entity/functions/defineEntitySuite.html) to get a `defineEntity` method for defining entities and an `entityStore` instance for storing entity instances.
- [`defineEntity`](https://electrovir.github.io/game-vir/entity/types/EntitySuite.html#defineentity): use this as the super class of a new entity class definition.
<!-- example-link: src/readme-examples/define-entity.example.ts -->
```TypeScript
import {Graphics} from 'pixi.js';
import {defineEntitySuite, entityPositionParamsShape} from '-vir/entity';
const {defineEntity} = defineEntitySuite<{
/** Optionally provide a Context type. */
movementSpeed: number;
}>();
export class Block extends defineEntity({
key: 'Block',
paramsShape: entityPositionParamsShape,
paramsMap: {
hitbox: {
x: true,
y: true,
},
view: {
x: true,
y: true,
},
},
}) {
public override update(): void {
this.params.x += this.context.movementSpeed;
this.params.y += this.context.movementSpeed;
}
public override createView() {
/**
* View and hitbox position don't need to be manually set their counterparts from params;
* they will be updated to match them because of the above `paramsMap` definition.
*/
return {
view: new Graphics().rect(0, 0, 100, 100).fill('red'),
/** Hitboxes are optional. */
hitbox: this.hitboxSystem.createBox({}, 100, 100),
};
}
}
```
- `entityStore` is primarily interacted with via the [`addEntity`](https://electrovir.github.io/game-vir/entity/classes/EntityStore.html#addentity) and [`updateAllEntities`](https://electrovir.github.io/game-vir/entity/classes/EntityStore.html#updateallentities) methods.
- [`addEntity`](https://electrovir.github.io/game-vir/entity/classes/EntityStore.html#addentity): construct a new instance of the given entity class and adds it to the entity store. This is the easies way to construct new entities.
<!-- example-link: src/readme-examples/add-entity.example.ts -->
```TypeScript
import {createPixi, defineEntitySuite} from '-vir/entity';
import {Block} from './define-entity.example.js';
const {EntityStore} = defineEntitySuite<{movementSpeed: number}>();
const entityStore = new EntityStore({
pixi: await createPixi(),
context: {movementSpeed: 6},
registeredEntities: [Block],
});
entityStore.addEntity(Block, {x: 15, y: 20});
```
- [`updateAllEntities`](https://electrovir.github.io/game-vir/entity/classes/EntityStore.html#updateallentities): update all entities. This calls the `update()` method on every entity instance currently within the entity store. This should be called on every game tick or animation frame.
<!-- example-link: src/readme-examples/update-entities.example.ts -->
```TypeScript
import {createPixi, defineEntitySuite} from '-vir/entity';
const {EntityStore} = defineEntitySuite<{movementSpeed: number}>();
const entityStore = new EntityStore({
pixi: await createPixi(),
context: {movementSpeed: 6},
registeredEntities: [],
});
entityStore.pixi.ticker.add(() => {
entityStore.updateAllEntities();
});
```
## Example
Here's a full usage example. This can be seen in action through the following steps:
1. Clone the repo.
2. Run `npm ci`..
3. Run `cd packages/entity`.
4. Run `npm start`.
5. Open the browser link logged to your console.
<!-- example-link: src/readme-examples/entity.example.ts -->
```TypeScript
import {assertWrap} from '-vir/assert';
import {and, defineShape} from 'object-shape-tester';
import {Graphics, GraphicsContext} from 'pixi.js';
import {Angle, createPixi, defineEntitySuite, entityPositionParamsShape, Vector} from '-vir/entity';
/** Create an entity suite. */
const {defineEntity, defineLogicEntity, EntityStore} = defineEntitySuite<{movementSpeed: number}>();
/** Define entities. */
/** Define a standard entity (with a view) that bounces back and forth. */
class Block extends defineEntity({
key: 'Block',
paramsShape: defineShape(
and(entityPositionParamsShape, {
direction: 1,
}),
),
paramsMap: {
view: {
x: true,
y: true,
},
},
}) {
public static readonly bonkCount = 20;
public static readonly graphicContext = new GraphicsContext()
.rect(0, 0, 10, 10)
.fill('magenta');
public override update(): void {
this.params.x += this.context.movementSpeed * this.params.direction;
this.params.y += this.context.movementSpeed * this.params.direction;
if (!this.isInBounds({entirely: true})) {
this.params.direction = -1 * this.params.direction;
this.createBonk();
}
}
protected createBonk() {
const degreesPerBonk = 360 / Block.bonkCount;
for (let i = 0; i < Block.bonkCount; i++) {
const move = new Vector(
BlockBonk.moveSpeed,
new Angle({degrees: degreesPerBonk * i}, {digits: 4}),
{digits: 4},
).toComponents();
/** Entities can easily create more entities. */
this.addEntity(BlockBonk, {
move,
ticksSinceCreation: 0,
x: this.view.x,
y: this.view.y,
});
}
}
public override createView() {
const graphic = new Graphics(Block.graphicContext);
graphic.x = this.params.x;
graphic.y = this.params.y;
return {
view: graphic,
};
}
}
/** Define a standard entity (with a view) that emits from Block when it bounces. */
class BlockBonk extends defineEntity({
key: 'BlockBonk',
paramsShape: defineShape(
and(entityPositionParamsShape, {
move: {
x: -1,
y: -1,
},
ticksSinceCreation: -1,
}),
),
paramsMap: {
view: {
x: true,
y: true,
},
},
}) {
public static readonly moveSpeed = 4;
public static readonly maxLife = 20;
public static readonly graphicContext = new GraphicsContext().rect(0, 0, 4, 4).fill('yellow');
public override update(): void {
this.params.ticksSinceCreation++;
if (this.params.ticksSinceCreation > BlockBonk.maxLife) {
/** Automatically clean up the bounce particles when they reach their end of life. */
this.destroy();
return;
}
this.view.alpha = Math.min(
1,
(BlockBonk.maxLife + 3 - this.params.ticksSinceCreation) / BlockBonk.maxLife,
);
this.params.x += this.params.move.x;
this.params.y += this.params.move.y;
}
public override createView() {
const graphic = new Graphics(BlockBonk.graphicContext);
graphic.x = this.params.x;
graphic.y = this.params.y;
return {
view: graphic,
};
}
}
/** Define a logic entity which doesn't have a Pixi.js view. */
class Fps extends defineLogicEntity({
key: 'Fps',
paramsShape: undefined,
}) {
protected fpsCounts: number[] = [];
public override update(): void {
this.fpsCounts.push(this.pixi.ticker.FPS);
if (this.fpsCounts.length > 100) {
const averageFps = Math.round(
this.fpsCounts.reduce((a, b) => a + b) / this.fpsCounts.length,
);
this.fpsCounts = [];
assertWrap.instanceOf(document.body.querySelector('.fps'), HTMLElement).innerText =
String(averageFps);
}
}
}
/** Create the view */
const entityStore = new EntityStore({
pixi: await createPixi({
background: 'black',
height: 500,
width: 500,
}),
context: {
movementSpeed: 6,
},
registeredEntities: [
Block,
Fps,
BlockBonk,
],
});
document.body.append(entityStore.pixi.canvas);
/** Add entities to the view. */
entityStore.addEntity(Block, {direction: 1, x: 0, y: 0});
entityStore.addEntity(Block, {direction: -1, x: 490, y: 240});
entityStore.addEntity(Block, {direction: 1, x: 2, y: 252});
entityStore.addEntity(Fps);
/** Start updates. */
entityStore.pixi.ticker.add(() => {
entityStore.updateAllEntities();
});
```
## Common footguns
1. When updating an entity's position, update it by modifying `this.view.x` or `this.view.y`. Do not update the position `this.hitbox` (unless you really know what you're doing and you set `preventAutomaticHitboxUpdates` to `true`).
2. Do not set