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/
npm i @game-vir/entity
Use defineEntitySuite
to get a defineEntity
method for defining entities and an entityStore
instance for storing entity instances.
defineEntity
: use this as the super class of a new entity class definition.
import {Graphics} from 'pixi.js';
import {defineEntitySuite, entityPositionParamsShape} from '@game-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
and updateAllEntities
methods.
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.
import {createPixi, defineEntitySuite} from '@game-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
: 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.
import {createPixi, defineEntitySuite} from '@game-vir/entity';
const {EntityStore} = defineEntitySuite<{movementSpeed: number}>();
const entityStore = new EntityStore({
pixi: await createPixi(),
context: {movementSpeed: 6},
registeredEntities: [],
});
entityStore.pixi.ticker.add(() => {
entityStore.updateAllEntities();
});
Here's a full usage example. This can be seen in action through the following steps:
npm ci
..cd packages/entity
.npm start
.import {assertWrap} from '@augment-vir/assert';
import {and, defineShape} from 'object-shape-tester';
import {Graphics, GraphicsContext} from 'pixi.js';
import {Angle, createPixi, defineEntitySuite, entityPositionParamsShape, Vector} from '@game-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();
});
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
).