Refactor typings to enforce cloning hygiene, at expense of intrusive Component type
This commit is contained in:
parent
33eb28e338
commit
264db31ac7
9 changed files with 418 additions and 219 deletions
5
plan.txt
5
plan.txt
|
@ -1,10 +1,11 @@
|
||||||
Open:
|
Open:
|
||||||
- Rework State implementation for easier cloning/deserialization
|
|
||||||
- Insecured websocket server implementation
|
|
||||||
- Refactor input messages for more than one player
|
- Refactor input messages for more than one player
|
||||||
|
- Insecured websocket server implementation
|
||||||
- Cloneable RNG that goes in state (use MurmurHash3 finalizer in counter mode?)
|
- Cloneable RNG that goes in state (use MurmurHash3 finalizer in counter mode?)
|
||||||
|
- remove all random() calls
|
||||||
|
|
||||||
Done:
|
Done:
|
||||||
|
- Rework State implementation for easier cloning/deserialization
|
||||||
- Test Lockstep/rollback
|
- Test Lockstep/rollback
|
||||||
- Smarter typings for Join/Lookup functions
|
- Smarter typings for Join/Lookup functions
|
||||||
- Parcel scripts
|
- Parcel scripts
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
|
|
||||||
import { Layer, SpriteSheet } from "../Applet/Render";
|
import { Layer, SpriteSheet } from "../Applet/Render";
|
||||||
import { copy, Data as CoreData, SparseStore, Store } from "./Data";
|
import { Component, copyDense, copySparse, EntityState, StateForSchema, Store } from "./Data";
|
||||||
|
|
||||||
export class Box {
|
export class Box {
|
||||||
constructor(
|
constructor(
|
||||||
|
@ -24,64 +24,116 @@ export function Approach(source: number, target: number, speed: number): number
|
||||||
/**
|
/**
|
||||||
* pairs of vertex coordinates in ccw winding order
|
* pairs of vertex coordinates in ccw winding order
|
||||||
*/
|
*/
|
||||||
export class Polygon {
|
export interface Polygon {
|
||||||
constructor(
|
points: number[];
|
||||||
public points: number[]
|
|
||||||
) {};
|
|
||||||
}
|
}
|
||||||
|
export class PolygonComponent extends Component<PolygonComponent> {
|
||||||
export class Location {
|
points: number[];
|
||||||
constructor(init?: Partial<Location>) {
|
constructor(from: Partial<PolygonComponent>) {
|
||||||
init && Object.assign(this, init);
|
super(from);
|
||||||
|
this.points = from.points?.slice() ?? []
|
||||||
};
|
};
|
||||||
X = 0;
|
clone() {
|
||||||
Y = 0;
|
return new PolygonComponent(this);
|
||||||
Angle = 0;
|
}
|
||||||
VX = 0;
|
|
||||||
VY = 0;
|
|
||||||
VAngle = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CollisionClass {
|
export class Location extends Component<Location> {
|
||||||
constructor(
|
X: number;
|
||||||
public name: string
|
Y: number;
|
||||||
) {};
|
Angle: number;
|
||||||
|
VX: number;
|
||||||
|
VY: number;
|
||||||
|
VAngle: number;
|
||||||
|
constructor(from: Partial<Location>) {
|
||||||
|
super(from);
|
||||||
|
this.X = from.X ?? 0;
|
||||||
|
this.Y = from.Y ?? 0;
|
||||||
|
this.Angle = from.Angle ?? 0;
|
||||||
|
this.VX = from.VX ?? 0;
|
||||||
|
this.VY = from.VY ?? 0;
|
||||||
|
this.VAngle = from.VAngle ?? 0;
|
||||||
|
};
|
||||||
|
clone() {
|
||||||
|
return new Location(this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RenderBounds {
|
export class CollisionClass extends Component<CollisionClass> {
|
||||||
constructor(
|
public name: string;
|
||||||
public color = "#f00",
|
constructor(from: Partial<CollisionClass>) {
|
||||||
public layer: number
|
super(from);
|
||||||
) {};
|
this.name = from.name ?? "unknown";
|
||||||
|
};
|
||||||
|
clone() {
|
||||||
|
return new CollisionClass(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RenderBounds extends Component<RenderBounds> {
|
||||||
|
public color: string;
|
||||||
|
public layer: number;
|
||||||
|
constructor(from: Partial<RenderBounds>) {
|
||||||
|
super(from);
|
||||||
|
this.color = from.color ?? "#f00";
|
||||||
|
this.layer = from.layer ?? 1;
|
||||||
|
};
|
||||||
|
clone() {
|
||||||
|
return new RenderBounds(this);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export class RenderSprite {
|
export class RenderSprite extends Component<RenderSprite> {
|
||||||
constructor(
|
// TODO: make this an id/handle for serializability
|
||||||
public sheet: SpriteSheet,
|
public sheet: SpriteSheet;
|
||||||
public layer: number,
|
public layer: number;
|
||||||
public index = 0,
|
public index: number;
|
||||||
public offsetX = 0,
|
public offsetX: number;
|
||||||
public offsetY = 0
|
public offsetY: number;
|
||||||
) {};
|
constructor(from: Partial<RenderSprite> & {sheet: SpriteSheet}) {
|
||||||
|
super(from);
|
||||||
|
this.sheet = from.sheet;
|
||||||
|
this.layer = from.layer ?? 1;
|
||||||
|
this.index = from.index ?? 0;
|
||||||
|
this.offsetX = from.offsetX ?? 0;
|
||||||
|
this.offsetY = from.offsetY ?? 0;
|
||||||
|
};
|
||||||
|
clone() {
|
||||||
|
return new RenderSprite(this);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export class Data extends CoreData {
|
export interface ComponentSchema {
|
||||||
location: Store<Location> = [];
|
location: Location;
|
||||||
bounds: Store<Polygon> = [];
|
bounds: PolygonComponent;
|
||||||
renderBounds: SparseStore<RenderBounds> = {};
|
renderBounds: RenderBounds;
|
||||||
renderSprite: SparseStore<RenderSprite> = {};
|
renderSprite: RenderSprite;
|
||||||
collisionSourceClass: SparseStore<CollisionClass> = {};
|
collisionSourceClass: CollisionClass;
|
||||||
collisionTargetClass: SparseStore<CollisionClass> = {};
|
collisionTargetClass: CollisionClass;
|
||||||
|
}
|
||||||
|
|
||||||
layers: Layer[] = [new Layer(0)];
|
export class Data implements StateForSchema<ComponentSchema> {
|
||||||
|
entity: EntityState[];
|
||||||
|
|
||||||
constructor(source?: Partial<Data>) {
|
location: Store<Location>;
|
||||||
super(source);
|
bounds: Store<PolygonComponent>;
|
||||||
if(source?.location) this.location = source.location.map(b => ({...b}));
|
renderBounds: Store<RenderBounds>;
|
||||||
if(source?.bounds) this.bounds = source.bounds.map(b => ({...b}));
|
renderSprite: Store<RenderSprite>;
|
||||||
if(source?.renderBounds) this.renderBounds = copy(source.renderBounds);
|
collisionSourceClass: Store<CollisionClass>;
|
||||||
if(source?.renderSprite) this.renderSprite = copy(source.renderSprite);
|
collisionTargetClass: Store<CollisionClass>;
|
||||||
if(source?.collisionSourceClass) this.collisionSourceClass = copy(source.collisionSourceClass);
|
|
||||||
if(source?.collisionTargetClass) this.collisionTargetClass = copy(source.collisionTargetClass);
|
layers: Layer[] = [new Layer(0), new Layer(1)];
|
||||||
|
|
||||||
|
constructor(from: Partial<Data>) {
|
||||||
|
this.entity = copyDense(from.entity);
|
||||||
|
this.location = copyDense(from.location);
|
||||||
|
this.bounds = copyDense(from.bounds);
|
||||||
|
this.renderBounds = copySparse(from.renderBounds);
|
||||||
|
this.renderSprite = copySparse(from.renderSprite);
|
||||||
|
this.collisionSourceClass = copySparse(from.collisionSourceClass);
|
||||||
|
this.collisionTargetClass = copySparse(from.collisionTargetClass);
|
||||||
|
}
|
||||||
|
clone() {
|
||||||
|
return new Data(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
122
src/Ecs/Data.ts
122
src/Ecs/Data.ts
|
@ -1,4 +1,33 @@
|
||||||
|
|
||||||
|
export type Store<T extends Component<T>> = Record<number, T>;
|
||||||
|
export interface Clone<T> {
|
||||||
|
clone(): T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function copyDense<T extends Component<T>, S extends Store<T>>(storage?: S): T[] {
|
||||||
|
if(Array.isArray(storage)) {
|
||||||
|
return storage.map(x => x.clone());
|
||||||
|
} else if(storage) {
|
||||||
|
const result: T[] = [];
|
||||||
|
for(const key in storage) {
|
||||||
|
result[key] = storage[key].clone();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function copySparse<T extends Component<T>, S extends Store<T>>(storage?: S): Store<T> {
|
||||||
|
if(storage) {
|
||||||
|
const result: Store<T> = {};
|
||||||
|
for(const key in storage) {
|
||||||
|
result[key] = storage[key].clone();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} else {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type Id = [number, number];
|
export type Id = [number, number];
|
||||||
|
|
||||||
|
@ -8,41 +37,36 @@ export const enum Liveness {
|
||||||
INACTIVE = 2
|
INACTIVE = 2
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HasGeneration {
|
export abstract class Component<T> implements Clone<Component<T>> {
|
||||||
generation: number;
|
public generation: number;
|
||||||
|
public constructor(from: Partial<Component<T>>) {
|
||||||
|
this.generation = from.generation ?? -1;
|
||||||
}
|
}
|
||||||
export interface EntityState {
|
abstract clone(): Component<T> & T;
|
||||||
alive: Liveness;
|
}
|
||||||
|
export class EntityState extends Component<EntityState> {
|
||||||
|
public alive: Liveness;
|
||||||
|
public constructor(from: Partial<EntityState>) {
|
||||||
|
super(from);
|
||||||
|
this.alive = from.alive ?? Liveness.ALIVE;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Store<T> = (T & HasGeneration)[];
|
clone(): EntityState {
|
||||||
export type SparseStore<T> = Record<number, T & HasGeneration>;
|
return new EntityState(this);
|
||||||
export class Data {
|
|
||||||
entity: Store<EntityState> = [];
|
|
||||||
|
|
||||||
constructor(source?: Partial<Data>) {
|
|
||||||
if(source?.entity) this.entity = source.entity.slice();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function copy<T>(source: SparseStore<T>): SparseStore<T> {
|
export type StateForSchema<T> = {
|
||||||
return JSON.parse(JSON.stringify(source));
|
[K in keyof T]: Record<number, Component<T[K]>>;
|
||||||
}
|
} & {
|
||||||
|
entity: EntityState[];
|
||||||
|
} & Clone<StateForSchema<T>>;
|
||||||
|
|
||||||
// Ergonomic Lookup typings
|
// Ergonomic Lookup typings
|
||||||
type StoreKeysOf<DATA> = {
|
type StoreKeysOf<DATA> = {
|
||||||
[K in keyof DATA]: DATA[K] extends Record<number, infer T & HasGeneration> ? K : never;
|
[K in keyof DATA]: DATA[K] extends Record<number, Component<infer T>> ? K : never;
|
||||||
};
|
}[keyof DATA];
|
||||||
type StoreKeys<DATA extends Data> = StoreKeysOf<DATA>[keyof DATA];
|
type StoreTypeOf<DATA, K> = K extends keyof DATA ? DATA[K] extends Record<number, Component<infer T>> ? T : never : never;
|
||||||
type ItemType<S> = S extends Record<number, infer T & HasGeneration> ? T : never;
|
|
||||||
type StoreType<DATA extends Data, K> = K extends "id" ? Id : K extends keyof DATA ? ItemType<DATA[K]> : never;
|
|
||||||
type StoreTypes<DATA extends Data, K extends (keyof DATA | "id")[]> = {
|
|
||||||
[I in keyof K]: StoreType<DATA, K[I]>;
|
|
||||||
};
|
|
||||||
type MaybeStoreTypes<DATA extends Data, K> = {
|
|
||||||
[I in keyof K]: StoreType<DATA, K[I]> | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an entity in the store
|
* Create an entity in the store
|
||||||
|
@ -51,10 +75,10 @@ type MaybeStoreTypes<DATA extends Data, K> = {
|
||||||
* @param state Liveness state, allows creating an inactive entity
|
* @param state Liveness state, allows creating an inactive entity
|
||||||
* @returns the new entity's ID and generation
|
* @returns the new entity's ID and generation
|
||||||
*/
|
*/
|
||||||
type Assigner<DATA extends Data> = {
|
type Assigner<DATA> = {
|
||||||
[S in StoreKeys<DATA>]?: StoreType<DATA, S>
|
[K in StoreKeysOf<DATA>]?: StoreTypeOf<DATA, K>;
|
||||||
};
|
};
|
||||||
export function Create<DATA extends Data>(data: DATA, assign: Assigner<DATA>, state = Liveness.ALIVE): Id {
|
export function Create<DATA extends StateForSchema<unknown>>(data: DATA, assign: Assigner<DATA>, state = Liveness.ALIVE): Id {
|
||||||
const entities = data.entity;
|
const entities = data.entity;
|
||||||
// find free ID
|
// find free ID
|
||||||
let freeId = -1;
|
let freeId = -1;
|
||||||
|
@ -72,14 +96,16 @@ export function Create<DATA extends Data>(data: DATA, assign: Assigner<DATA>, st
|
||||||
generation = 1;
|
generation = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
entities[freeId] = {
|
entities[freeId] = new EntityState({
|
||||||
generation,
|
generation,
|
||||||
alive: state
|
alive: state
|
||||||
};
|
});
|
||||||
|
|
||||||
for (const key in assign) {
|
for (const key in assign) {
|
||||||
const store = data[key as keyof Data] as Store<{}>|SparseStore<{}>;
|
const store = data[key as keyof DATA] as Store<any>;
|
||||||
store[freeId] = {...(assign as Record<string, HasGeneration>)[key] as {}, generation};
|
const component = (assign[key as keyof Assigner<DATA>] as Component<unknown>).clone();
|
||||||
|
component.generation = generation;
|
||||||
|
store[freeId] = component;
|
||||||
}
|
}
|
||||||
|
|
||||||
return [freeId, generation];
|
return [freeId, generation];
|
||||||
|
@ -92,7 +118,7 @@ export function Create<DATA extends Data>(data: DATA, assign: Assigner<DATA>, st
|
||||||
* @param generation entity ID generation
|
* @param generation entity ID generation
|
||||||
* @param state can be set to Liveness.INACTIVE to disable an entity without actually killing it, for later resurrection
|
* @param state can be set to Liveness.INACTIVE to disable an entity without actually killing it, for later resurrection
|
||||||
*/
|
*/
|
||||||
export function Remove<DATA extends Data>(data: DATA, [id, generation]: Id, state = Liveness.DEAD) {
|
export function Remove(data: StateForSchema<unknown>, [id, generation]: Id, state = Liveness.DEAD) {
|
||||||
if (data.entity[id] && data.entity[id].generation == generation) {
|
if (data.entity[id] && data.entity[id].generation == generation) {
|
||||||
data.entity[id].alive = state;
|
data.entity[id].alive = state;
|
||||||
}
|
}
|
||||||
|
@ -105,21 +131,24 @@ export function Remove<DATA extends Data>(data: DATA, [id, generation]: Id, stat
|
||||||
* @param components names of components to look for
|
* @param components names of components to look for
|
||||||
* @returns the cooresponding components, with unfound ones replaced by nulls
|
* @returns the cooresponding components, with unfound ones replaced by nulls
|
||||||
*/
|
*/
|
||||||
export function Lookup<DATA extends Data, K extends StoreKeys<DATA>[]>(data: DATA, [id, generation]: Id, ...components: K): MaybeStoreTypes<DATA, K> {
|
type MaybeStoreTypes<DATA, Q> = {
|
||||||
|
[I in keyof Q]: StoreTypeOf<DATA, Q[I]> | null;
|
||||||
|
};
|
||||||
|
export function Lookup<DATA extends StateForSchema<unknown>, Q extends StoreKeysOf<DATA>[]>(data: DATA, [id, generation]: Id, ...components: Q): MaybeStoreTypes<DATA, Q> {
|
||||||
const entity = data.entity[id];
|
const entity = data.entity[id];
|
||||||
// inactive entities are fine to lookup, but dead ones are not
|
// inactive entities are fine to lookup, but dead ones are not
|
||||||
if (entity && entity.generation == generation && entity.alive != Liveness.DEAD) {
|
if (entity && entity.generation == generation && entity.alive != Liveness.DEAD) {
|
||||||
return components.map(storeName => {
|
return components.map(storeName => {
|
||||||
const store = data[storeName] as unknown as Store<{}>|SparseStore<{}>;
|
const store = data[storeName as unknown as keyof DATA] as Store<any>;
|
||||||
const component = store[id];
|
const component = store[id];
|
||||||
if (component && component.generation == generation) {
|
if (component && component.generation == generation) {
|
||||||
return component;
|
return component;
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}) as MaybeStoreTypes<DATA, K>;
|
}) as MaybeStoreTypes<DATA, Q>;
|
||||||
} else {
|
} else {
|
||||||
return components.map(() => null) as MaybeStoreTypes<DATA, K>;
|
return components.map(() => null) as MaybeStoreTypes<DATA, Q>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -128,18 +157,21 @@ export function Lookup<DATA extends Data, K extends StoreKeys<DATA>[]>(data: DAT
|
||||||
* "id" can be used as a pseudo-component to get the Ids for a match
|
* "id" can be used as a pseudo-component to get the Ids for a match
|
||||||
* @returns an array of tuples containing the matching entity Components
|
* @returns an array of tuples containing the matching entity Components
|
||||||
*/
|
*/
|
||||||
export function Join<DATA extends Data, K extends (StoreKeys<DATA> | "id")[]>(data: DATA, ...components: K): StoreTypes<DATA, K>[] {
|
type ResultTypes<DATA, Q> = {
|
||||||
|
[I in keyof Q]: Q[I] extends "id" ? Id : StoreTypeOf<DATA, Q[I]>;
|
||||||
|
};
|
||||||
|
export function Join<DATA extends StateForSchema<unknown>, Q extends (StoreKeysOf<DATA> | "id")[]>(data: DATA, ...components: Q): ResultTypes<DATA, Q>[] {
|
||||||
const entities = data.entity;
|
const entities = data.entity;
|
||||||
const stores = components.map(name => {
|
const stores = components.map(name => {
|
||||||
if (name == "id") {
|
if (name == "id") {
|
||||||
return "id";
|
return "id";
|
||||||
} else {
|
} else {
|
||||||
return data[name] as unknown as (Store<{}>|SparseStore<{}>)
|
return data[name as unknown as keyof DATA] as Store<any>;
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const results: StoreTypes<DATA, K>[] = [];
|
const results: ResultTypes<DATA, Q>[] = [];
|
||||||
const firstStore = stores.filter(store => store !== "id")[0] as Store<{}>|SparseStore<{}>;
|
const firstStore = stores.filter(store => store !== "id")[0] as Store<any>;
|
||||||
if (Array.isArray(firstStore)) {
|
if (Array.isArray(firstStore)) {
|
||||||
for (let id = 0; id < firstStore.length; id++) {
|
for (let id = 0; id < firstStore.length; id++) {
|
||||||
JoinLoop(id, entities, stores, results);
|
JoinLoop(id, entities, stores, results);
|
||||||
|
@ -151,9 +183,9 @@ export function Join<DATA extends Data, K extends (StoreKeys<DATA> | "id")[]>(da
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
function JoinLoop<DATA extends Data, K extends (StoreKeys<DATA> | "id")[]>(id: number, entities: Store<EntityState>, stores: (Store<{}> | SparseStore<{}> | "id")[], results: StoreTypes<DATA, K>[]) {
|
function JoinLoop<DATA extends StateForSchema<unknown>, Q extends (StoreKeysOf<DATA> | "id")[]>(id: number, entities: EntityState[], stores: (Record<number, Component<{}>> | "id")[], results: ResultTypes<DATA, Q>[]) {
|
||||||
const fullId: Id = [id, -1];
|
const fullId: Id = [id, -1];
|
||||||
const result: (HasGeneration | Id)[] = [];
|
const result: (Component<{}> | Id)[] = [];
|
||||||
|
|
||||||
let generation = -1;
|
let generation = -1;
|
||||||
for (const store of stores) {
|
for (const store of stores) {
|
||||||
|
@ -177,5 +209,5 @@ function JoinLoop<DATA extends Data, K extends (StoreKeys<DATA> | "id")[]>(id: n
|
||||||
// backpatch generation now that it's known
|
// backpatch generation now that it's known
|
||||||
fullId[1] = generation;
|
fullId[1] = generation;
|
||||||
|
|
||||||
results.push(result as StoreTypes<DATA, K>);
|
results.push(result as ResultTypes<DATA, Q>);
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,7 @@ export function TransformCx(cx: CanvasRenderingContext2D, location: Location, dt
|
||||||
export function TfPolygon({points}: Polygon, {X, Y, Angle}: Location): Polygon {
|
export function TfPolygon({points}: Polygon, {X, Y, Angle}: Location): Polygon {
|
||||||
const sin = Math.sin(Angle);
|
const sin = Math.sin(Angle);
|
||||||
const cos = Math.cos(Angle);
|
const cos = Math.cos(Angle);
|
||||||
const result = new Polygon(new Array(points.length));
|
const result = {points: new Array(points.length)};
|
||||||
for(let i = 0; i < points.length; i += 2) {
|
for(let i = 0; i < points.length; i += 2) {
|
||||||
const x = points[i];
|
const x = points[i];
|
||||||
const y = points[i+1];
|
const y = points[i+1];
|
||||||
|
|
148
src/Ecs/test.ts
148
src/Ecs/test.ts
|
@ -3,11 +3,42 @@ import { KeyControl } from "../Applet/Keyboard";
|
||||||
import { Loop } from "../Applet/Loop";
|
import { Loop } from "../Applet/Loop";
|
||||||
import { DrawSet, Layer } from "../Applet/Render";
|
import { DrawSet, Layer } from "../Applet/Render";
|
||||||
import { FindCollisions } from "./Collision";
|
import { FindCollisions } from "./Collision";
|
||||||
import { CollisionClass, Data, Location, Polygon, RenderBounds } from "./Components";
|
import {
|
||||||
import { Create, Join, Liveness, Lookup, Remove, SparseStore, Store } from "./Data";
|
CollisionClass,
|
||||||
|
ComponentSchema,
|
||||||
|
Data,
|
||||||
|
Location,
|
||||||
|
PolygonComponent,
|
||||||
|
RenderBounds,
|
||||||
|
} from "./Components";
|
||||||
|
import {
|
||||||
|
Component,
|
||||||
|
copySparse,
|
||||||
|
Create,
|
||||||
|
EntityState,
|
||||||
|
Join,
|
||||||
|
Liveness,
|
||||||
|
Lookup,
|
||||||
|
Remove,
|
||||||
|
StateForSchema,
|
||||||
|
Store,
|
||||||
|
} from "./Data";
|
||||||
import { DumbMotion } from "./Location";
|
import { DumbMotion } from "./Location";
|
||||||
import { RunRenderBounds } from "./Renderers";
|
import { RunRenderBounds } from "./Renderers";
|
||||||
|
|
||||||
|
class Generic<T> extends Component<T> {
|
||||||
|
constructor(from: T) {
|
||||||
|
super(from);
|
||||||
|
Object.assign(this, from);
|
||||||
|
}
|
||||||
|
clone(): Generic<T> & T {
|
||||||
|
return new Generic<T>(this as unknown as T) as Generic<T> & T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function generic<T>(from: T): Component<T> & T {
|
||||||
|
return new Generic<T>(from) as Component<T> & T;
|
||||||
|
}
|
||||||
|
|
||||||
interface Apple {}
|
interface Apple {}
|
||||||
interface Banana {
|
interface Banana {
|
||||||
peeled: boolean
|
peeled: boolean
|
||||||
|
@ -16,38 +47,63 @@ interface Carrot {
|
||||||
cronch: number
|
cronch: number
|
||||||
}
|
}
|
||||||
|
|
||||||
class TestData extends Data {
|
interface TestSchema extends ComponentSchema {
|
||||||
entity = [
|
apple: Apple;
|
||||||
{generation: 5, alive: Liveness.ALIVE},
|
banana: Banana;
|
||||||
{generation: 5, alive: Liveness.DEAD},
|
carrot: Carrot;
|
||||||
{generation: 5, alive: Liveness.ALIVE},
|
}
|
||||||
{generation: 5, alive: Liveness.ALIVE},
|
|
||||||
{generation: 5, alive: Liveness.INACTIVE},
|
class TestData extends Data implements StateForSchema<TestSchema> {
|
||||||
{generation: 5, alive: Liveness.ALIVE},
|
apple: Store<Generic<Apple>>;
|
||||||
];
|
banana: Store<Generic<Banana>>;
|
||||||
apple: Store<Apple> = [
|
carrot: Store<Generic<Carrot>>;
|
||||||
{generation: 5},
|
constructor(from: Partial<TestData>) {
|
||||||
{generation: 5},
|
super(from);
|
||||||
{generation: -1},
|
this.apple = copySparse(from.apple);
|
||||||
{generation: -1},
|
this.banana = copySparse(from.banana);
|
||||||
{generation: 5},
|
this.carrot = copySparse(from.carrot);
|
||||||
{generation: 5},
|
}
|
||||||
];
|
clone(): TestData {
|
||||||
banana: SparseStore<Banana> = {
|
return new TestData(this);
|
||||||
3: {generation: 5, peeled: false},
|
}
|
||||||
4: {generation: 5, peeled: true},
|
}
|
||||||
};
|
|
||||||
carrot: SparseStore<Carrot> = {
|
function makeTestData(): TestData {
|
||||||
0: {generation: 5, cronch: 1},
|
return new TestData({
|
||||||
1: {generation: 5, cronch: 1},
|
entity: [
|
||||||
2: {generation: 4, cronch: 10},
|
new EntityState({generation: 5, alive: Liveness.ALIVE}),
|
||||||
3: {generation: 5, cronch: 1},
|
new EntityState({generation: 5, alive: Liveness.DEAD}),
|
||||||
};
|
new EntityState({generation: 5, alive: Liveness.ALIVE}),
|
||||||
|
new EntityState({generation: 5, alive: Liveness.ALIVE}),
|
||||||
|
new EntityState({generation: 5, alive: Liveness.INACTIVE}),
|
||||||
|
new EntityState({generation: 5, alive: Liveness.ALIVE}),
|
||||||
|
],
|
||||||
|
apple: [
|
||||||
|
generic({generation: 5}),
|
||||||
|
generic({generation: 5}),
|
||||||
|
generic({generation: -1}),
|
||||||
|
generic({generation: -1}),
|
||||||
|
generic({generation: 5}),
|
||||||
|
generic({generation: 5}),
|
||||||
|
],
|
||||||
|
banana: {
|
||||||
|
3: generic({generation: 5, peeled: false}),
|
||||||
|
4: generic({generation: 5, peeled: true}),
|
||||||
|
},
|
||||||
|
carrot: {
|
||||||
|
0: generic({generation: 5, cronch: 1}),
|
||||||
|
1: generic({generation: 5, cronch: 1}),
|
||||||
|
2: generic({generation: 4, cronch: 10}),
|
||||||
|
3: generic({generation: 5, cronch: 1}),
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class EcsJoinTest {
|
class EcsJoinTest {
|
||||||
constructor(pre: HTMLElement) {
|
constructor(pre: HTMLElement) {
|
||||||
const data = new TestData();
|
const data = new TestData({
|
||||||
|
|
||||||
|
});
|
||||||
pre.innerText = JSON.stringify({
|
pre.innerText = JSON.stringify({
|
||||||
"apples": Join(data, "apple"),
|
"apples": Join(data, "apple"),
|
||||||
"bananas": Join(data, "banana"),
|
"bananas": Join(data, "banana"),
|
||||||
|
@ -59,7 +115,7 @@ class EcsJoinTest {
|
||||||
|
|
||||||
class EcsLookupTest {
|
class EcsLookupTest {
|
||||||
constructor(pre: HTMLElement) {
|
constructor(pre: HTMLElement) {
|
||||||
const data = new TestData();
|
const data = makeTestData();
|
||||||
const applesMaybeCarrots = Join(data, "apple", "id").map(([apple, id]) => ({
|
const applesMaybeCarrots = Join(data, "apple", "id").map(([apple, id]) => ({
|
||||||
apple,
|
apple,
|
||||||
maybeCarrot: Lookup(data, id, "carrot")[0]
|
maybeCarrot: Lookup(data, id, "carrot")[0]
|
||||||
|
@ -70,7 +126,7 @@ class EcsLookupTest {
|
||||||
|
|
||||||
class EcsRemoveTest {
|
class EcsRemoveTest {
|
||||||
constructor(pre: HTMLElement) {
|
constructor(pre: HTMLElement) {
|
||||||
const data = new TestData();
|
const data = makeTestData();
|
||||||
const beforeDelete = Join(data, "apple", "carrot", "id",);
|
const beforeDelete = Join(data, "apple", "carrot", "id",);
|
||||||
Remove(data, [0, 5]);
|
Remove(data, [0, 5]);
|
||||||
const afterDelete = Join(data, "apple", "carrot", "id");
|
const afterDelete = Join(data, "apple", "carrot", "id");
|
||||||
|
@ -83,12 +139,12 @@ class EcsRemoveTest {
|
||||||
|
|
||||||
class EcsCreateTest {
|
class EcsCreateTest {
|
||||||
constructor(pre: HTMLElement) {
|
constructor(pre: HTMLElement) {
|
||||||
const data = new TestData();
|
const data = makeTestData();
|
||||||
const beforeCreate = Join(data, "apple", "banana", "carrot", "id");
|
const beforeCreate = Join(data, "apple", "banana", "carrot", "id");
|
||||||
const createdId = Create(data, {
|
const createdId = Create(data, {
|
||||||
apple: {},
|
apple: generic({}),
|
||||||
banana: {peeled: false},
|
banana: generic({peeled: false}),
|
||||||
carrot: {cronch: 11}
|
carrot: generic({cronch: 11})
|
||||||
});
|
});
|
||||||
const afterCreate = Join(data, "apple", "banana", "carrot", "id");
|
const afterCreate = Join(data, "apple", "banana", "carrot", "id");
|
||||||
pre.innerText = JSON.stringify({
|
pre.innerText = JSON.stringify({
|
||||||
|
@ -100,7 +156,7 @@ class EcsCreateTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
class LoopTest {
|
class LoopTest {
|
||||||
data = new Data();
|
data = new Data({});
|
||||||
|
|
||||||
constructor(public canvas: HTMLCanvasElement, cx: CanvasRenderingContext2D, keys: KeyControl) {
|
constructor(public canvas: HTMLCanvasElement, cx: CanvasRenderingContext2D, keys: KeyControl) {
|
||||||
const drawSet = new DrawSet();
|
const drawSet = new DrawSet();
|
||||||
|
@ -112,9 +168,9 @@ class LoopTest {
|
||||||
Y: 200,
|
Y: 200,
|
||||||
VAngle: Math.PI
|
VAngle: Math.PI
|
||||||
}),
|
}),
|
||||||
bounds: new Polygon([-50, 50, -60, 250, 60, 250, 50, 50]),
|
bounds: new PolygonComponent({points: [-50, 50, -60, 250, 60, 250, 50, 50]}),
|
||||||
collisionTargetClass: new CollisionClass("block"),
|
collisionTargetClass: new CollisionClass({ name: "block"}),
|
||||||
renderBounds: new RenderBounds("#0a0", 0),
|
renderBounds: new RenderBounds({color: "#0a0", layer: 0}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// triangles
|
// triangles
|
||||||
|
@ -125,12 +181,12 @@ class LoopTest {
|
||||||
Angle: angle,
|
Angle: angle,
|
||||||
VAngle: -Math.PI/10
|
VAngle: -Math.PI/10
|
||||||
}),
|
}),
|
||||||
bounds: new Polygon([70, 0, 55, 40, 85, 40]),
|
bounds: new PolygonComponent({points: [70, 0, 55, 40, 85, 40]}),
|
||||||
collisionSourceClass: new CollisionClass("tri"),
|
collisionSourceClass: new CollisionClass({ name: "tri"}),
|
||||||
renderBounds: new RenderBounds(
|
renderBounds: new RenderBounds({
|
||||||
"#d40",
|
color: "#d40",
|
||||||
0
|
layer: 0,
|
||||||
)
|
})
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const loop = new Loop(30,
|
const loop = new Loop(30,
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { PlaySfx } from "../Applet/Audio";
|
import { PlaySfx } from "../Applet/Audio";
|
||||||
import { Location, Polygon, RenderBounds } from "../Ecs/Components";
|
import { Location, Polygon, PolygonComponent, RenderBounds } from "../Ecs/Components";
|
||||||
import { Create, Id, Join, Lookup, Remove } from "../Ecs/Data";
|
import { Create, Id, Join, Lookup, Remove } from "../Ecs/Data";
|
||||||
import { Data, Lifetime, Teams, World } from "./GameComponents";
|
import { Data, Lifetime, Teams, World } from "./GameComponents";
|
||||||
|
|
||||||
|
@ -60,13 +60,20 @@ function SpawnPuff(data: Data, world: World, x: number, y: number, size: number,
|
||||||
VX: (Math.random() + 0.5) * 400 * Math.cos(angle),
|
VX: (Math.random() + 0.5) * 400 * Math.cos(angle),
|
||||||
VY: (Math.random() + 0.5) * 400 * -Math.sin(angle)
|
VY: (Math.random() + 0.5) * 400 * -Math.sin(angle)
|
||||||
}),
|
}),
|
||||||
bounds: new Polygon([
|
bounds: new PolygonComponent({points: [
|
||||||
-size, -size,
|
-size, -size,
|
||||||
-size, size,
|
-size, size,
|
||||||
size, size,
|
size, size,
|
||||||
size, -size
|
size, -size
|
||||||
]),
|
]}),
|
||||||
renderBounds: new RenderBounds(color, /*world.smokeLayer*/ 0),
|
renderBounds: new RenderBounds({
|
||||||
lifetime: new Lifetime(Math.random() / 3)
|
color,
|
||||||
|
// TODO: work out standard layers
|
||||||
|
layer: 1
|
||||||
|
}),
|
||||||
|
// TODO: randomization breaks determinism. Might be safe for a smoke puff that doesn't effect gameplay, but bad hygeine
|
||||||
|
lifetime: new Lifetime({
|
||||||
|
time: Math.random() / 3
|
||||||
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { KeyName } from "../Applet/Keyboard";
|
import { KeyName } from "../Applet/Keyboard";
|
||||||
import { DrawSet, Layer } from "../Applet/Render";
|
import { DrawSet, Layer } from "../Applet/Render";
|
||||||
import { Data as EcsData } from "../Ecs/Components";
|
import { ComponentSchema, Data as EcsData } from "../Ecs/Components";
|
||||||
import { copy, Join, SparseStore } from "../Ecs/Data";
|
import { Component, copySparse, Join, StateForSchema, Store } from "../Ecs/Data";
|
||||||
import { DumbMotion } from "../Ecs/Location";
|
import { DumbMotion } from "../Ecs/Location";
|
||||||
import { INPUT_FREQUENCY, LockstepProcessor } from "../Ecs/Lockstep";
|
import { INPUT_FREQUENCY, LockstepProcessor } from "../Ecs/Lockstep";
|
||||||
import { Buttons } from "./Input";
|
import { Buttons } from "./Input";
|
||||||
|
@ -57,20 +57,35 @@ export class World {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Data extends EcsData {
|
interface GameSchema extends ComponentSchema {
|
||||||
boss: SparseStore<Boss> = {};
|
boss: Boss;
|
||||||
bullet: SparseStore<Bullet> = {};
|
bullet: Bullet;
|
||||||
hp: SparseStore<Hp> = {};
|
hp: Hp;
|
||||||
lifetime: SparseStore<Lifetime> = {};
|
lifetime: Lifetime;
|
||||||
message: SparseStore<Message> = {};
|
message: Message;
|
||||||
|
}
|
||||||
|
|
||||||
constructor(source?: Partial<Data>) {
|
export class Data extends EcsData implements StateForSchema<GameSchema> {
|
||||||
super(source);
|
boss: Store<Boss>;
|
||||||
if(source?.boss) this.boss = copy(source.boss);
|
bullet: Store<Bullet>;
|
||||||
if(source?.bullet) this.bullet = copy(source.bullet);
|
hp: Store<Hp>;
|
||||||
if(source?.hp) this.hp = copy(source.hp);
|
lifetime: Store<Lifetime>;
|
||||||
if(source?.lifetime) this.lifetime = copy(source.lifetime);
|
message: Store<Message>;
|
||||||
if(source?.message) this.message = copy(source.message);
|
|
||||||
|
// globals
|
||||||
|
debugLayer = new Layer(2);
|
||||||
|
|
||||||
|
constructor(from: Partial<Data>) {
|
||||||
|
super(from);
|
||||||
|
this.boss = copySparse(from.boss);
|
||||||
|
this.bullet = copySparse(from.bullet);
|
||||||
|
this.hp = copySparse(from.hp);
|
||||||
|
this.lifetime = copySparse(from.lifetime);
|
||||||
|
this.message = copySparse(from.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
clone() {
|
||||||
|
return new Data(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -78,41 +93,74 @@ export enum Teams {
|
||||||
PLAYER,
|
PLAYER,
|
||||||
ENEMY
|
ENEMY
|
||||||
}
|
}
|
||||||
export class Bullet {
|
export class Bullet extends Component<Bullet> {
|
||||||
hit = false;
|
hit: boolean;
|
||||||
constructor(
|
team: Teams;
|
||||||
public team: Teams,
|
attack: number;
|
||||||
public attack: number
|
constructor(from: Partial<Bullet>) {
|
||||||
) {};
|
super(from);
|
||||||
|
this.hit = from.hit ?? false;
|
||||||
|
this.team = from.team ?? Teams.ENEMY;
|
||||||
|
this.attack = from.attack ?? 1;
|
||||||
|
}
|
||||||
|
clone(): Bullet {
|
||||||
|
return new Bullet(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class Hp extends Component<Hp> {
|
||||||
|
receivedDamage: number;
|
||||||
|
team: Teams;
|
||||||
|
hp: number;
|
||||||
|
constructor(from: Partial<Hp>) {
|
||||||
|
super(from);
|
||||||
|
this.receivedDamage = from.receivedDamage ?? 0;
|
||||||
|
this.team = from.team ?? Teams.ENEMY;
|
||||||
|
this.hp = from.hp ?? 10;
|
||||||
|
}
|
||||||
|
clone(): Hp {
|
||||||
|
return new Hp(this);
|
||||||
}
|
}
|
||||||
export class Hp {
|
|
||||||
receivedDamage = 0;
|
|
||||||
constructor(
|
|
||||||
public team: Teams,
|
|
||||||
public hp: number
|
|
||||||
) {};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Lifetime {
|
export class Lifetime extends Component<Lifetime> {
|
||||||
constructor(
|
time: number;
|
||||||
public time: number
|
constructor(from: Partial<Lifetime> & {time: number}) {
|
||||||
) {};
|
super(from);
|
||||||
|
this.time = from.time;
|
||||||
|
}
|
||||||
|
clone(): Lifetime {
|
||||||
|
return new Lifetime(this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Boss {
|
export class Boss extends Component<Boss> {
|
||||||
constructor(
|
name: string;
|
||||||
public name: string
|
constructor(from: Partial<Boss>) {
|
||||||
) {}
|
super(from);
|
||||||
|
this.name = from.name ?? "";
|
||||||
|
}
|
||||||
|
clone(): Boss {
|
||||||
|
return new Boss(this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Message {
|
export class Message extends Component<Message> {
|
||||||
targetY = 0;
|
targetY = 0;
|
||||||
constructor(
|
layer: number;
|
||||||
public layer: Layer,
|
color: string;
|
||||||
public color: string,
|
message: string;
|
||||||
public message: string,
|
timeout = 3;
|
||||||
public timeout = 3
|
constructor(from: Partial<Message>) {
|
||||||
) {}
|
super(from);
|
||||||
|
this.targetY = from.targetY ?? 0;
|
||||||
|
this.layer = from.layer ?? 1;
|
||||||
|
this.color = from.color ?? "#000";
|
||||||
|
this.message = from.message ?? "";
|
||||||
|
this.timeout = from.timeout ?? 3;
|
||||||
|
}
|
||||||
|
clone(): Message {
|
||||||
|
return new Message(this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Engine implements LockstepProcessor<KeyName[], Data> {
|
export class Engine implements LockstepProcessor<KeyName[], Data> {
|
||||||
|
|
|
@ -3,7 +3,7 @@ import subscribe from "callbag-subscribe";
|
||||||
|
|
||||||
import { KeyControl, KeyName } from "../Applet/Keyboard";
|
import { KeyControl, KeyName } from "../Applet/Keyboard";
|
||||||
import { DrawSet } from "../Applet/Render";
|
import { DrawSet } from "../Applet/Render";
|
||||||
import { Location, Polygon, RenderBounds } from "../Ecs/Components";
|
import { Location, Polygon, PolygonComponent, RenderBounds } from "../Ecs/Components";
|
||||||
import { Create } from "../Ecs/Data";
|
import { Create } from "../Ecs/Data";
|
||||||
import { RunRenderBounds } from "../Ecs/Renderers";
|
import { RunRenderBounds } from "../Ecs/Renderers";
|
||||||
import { LockstepClient } from "../Net/LockstepClient";
|
import { LockstepClient } from "../Net/LockstepClient";
|
||||||
|
@ -40,15 +40,18 @@ export class Main extends LockstepClient<KeyName[], Data> {
|
||||||
}
|
}
|
||||||
|
|
||||||
initState(patch: Partial<Data>) {
|
initState(patch: Partial<Data>) {
|
||||||
const newState = new Data();
|
const newState = new Data(patch);
|
||||||
|
|
||||||
Create(newState, {
|
Create(newState, {
|
||||||
location: new Location({
|
location: new Location({
|
||||||
X: 200,
|
X: 200,
|
||||||
Y: 200,
|
Y: 200,
|
||||||
}),
|
}),
|
||||||
bounds: new Polygon([-30, 0, 30, 0, 0, 40]),
|
bounds: new PolygonComponent({points: [-30, 0, 30, 0, 0, 40]}),
|
||||||
renderBounds: new RenderBounds("#a0f", 0),
|
renderBounds: new RenderBounds({
|
||||||
|
color: "#a0f",
|
||||||
|
layer: 0
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return newState;
|
return newState;
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Join, Remove } from "../Ecs/Data";
|
|
||||||
import { Data, World, GamePhase } from "./GameComponents";
|
|
||||||
import { DrawSet } from "../Applet/Render";
|
import { DrawSet } from "../Applet/Render";
|
||||||
|
import { Join, Remove } from "../Ecs/Data";
|
||||||
import { TransformCx } from "../Ecs/Location";
|
import { TransformCx } from "../Ecs/Location";
|
||||||
|
import { Data, GamePhase, World } from "./GameComponents";
|
||||||
|
|
||||||
/*export function SpawnMessage(color: string, text: string) {
|
/*export function SpawnMessage(color: string, text: string) {
|
||||||
return function(data: Data, world: World, x: number, timeoutDelta = 0): Id {
|
return function(data: Data, world: World, x: number, timeoutDelta = 0): Id {
|
||||||
|
@ -57,7 +57,7 @@ export function ReapMessages(data: Data, {width, height, debug}: World) {
|
||||||
|
|
||||||
export function RenderMessages(data: Data, drawSet: DrawSet) {
|
export function RenderMessages(data: Data, drawSet: DrawSet) {
|
||||||
drawSet.queue(...Join(data, "message", "location").map(
|
drawSet.queue(...Join(data, "message", "location").map(
|
||||||
([{layer, color, message}, location]) => layer.toRender((cx, dt) => {
|
([{layer, color, message}, location]) => data.layers[layer].toRender((cx, dt) => {
|
||||||
TransformCx(cx, location, dt);
|
TransformCx(cx, location, dt);
|
||||||
cx.font = `${FONT_SIZE}px monospace`;
|
cx.font = `${FONT_SIZE}px monospace`;
|
||||||
cx.fillStyle = color;
|
cx.fillStyle = color;
|
||||||
|
|
Loading…
Reference in a new issue