Refactor typings to enforce cloning hygiene, at expense of intrusive Component type

This commit is contained in:
Tangent Wantwight 2020-04-04 18:52:25 -04:00
parent 33eb28e338
commit 264db31ac7
9 changed files with 418 additions and 219 deletions

View File

@ -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

View File

@ -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 Location extends Component<Location> {
} X: number;
Y: number;
export class CollisionClass { Angle: number;
constructor( VX: number;
public name: string VY: number;
) {}; VAngle: number;
} constructor(from: Partial<Location>) {
super(from);
export class RenderBounds { this.X = from.X ?? 0;
constructor( this.Y = from.Y ?? 0;
public color = "#f00", this.Angle = from.Angle ?? 0;
public layer: number this.VX = from.VX ?? 0;
) {}; this.VY = from.VY ?? 0;
}; this.VAngle = from.VAngle ?? 0;
};
export class RenderSprite { clone() {
constructor( return new Location(this);
public sheet: SpriteSheet, }
public layer: number, }
public index = 0,
public offsetX = 0, export class CollisionClass extends Component<CollisionClass> {
public offsetY = 0 public name: string;
) {}; constructor(from: Partial<CollisionClass>) {
}; super(from);
this.name = from.name ?? "unknown";
export class Data extends CoreData { };
location: Store<Location> = []; clone() {
bounds: Store<Polygon> = []; return new CollisionClass(this);
renderBounds: SparseStore<RenderBounds> = {}; }
renderSprite: SparseStore<RenderSprite> = {}; }
collisionSourceClass: SparseStore<CollisionClass> = {};
collisionTargetClass: SparseStore<CollisionClass> = {}; export class RenderBounds extends Component<RenderBounds> {
public color: string;
layers: Layer[] = [new Layer(0)]; public layer: number;
constructor(from: Partial<RenderBounds>) {
constructor(source?: Partial<Data>) { super(from);
super(source); this.color = from.color ?? "#f00";
if(source?.location) this.location = source.location.map(b => ({...b})); this.layer = from.layer ?? 1;
if(source?.bounds) this.bounds = source.bounds.map(b => ({...b})); };
if(source?.renderBounds) this.renderBounds = copy(source.renderBounds); clone() {
if(source?.renderSprite) this.renderSprite = copy(source.renderSprite); return new RenderBounds(this);
if(source?.collisionSourceClass) this.collisionSourceClass = copy(source.collisionSourceClass); }
if(source?.collisionTargetClass) this.collisionTargetClass = copy(source.collisionTargetClass); };
export class RenderSprite extends Component<RenderSprite> {
// TODO: make this an id/handle for serializability
public sheet: SpriteSheet;
public layer: number;
public index: number;
public offsetX: number;
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 interface ComponentSchema {
location: Location;
bounds: PolygonComponent;
renderBounds: RenderBounds;
renderSprite: RenderSprite;
collisionSourceClass: CollisionClass;
collisionTargetClass: CollisionClass;
}
export class Data implements StateForSchema<ComponentSchema> {
entity: EntityState[];
location: Store<Location>;
bounds: Store<PolygonComponent>;
renderBounds: Store<RenderBounds>;
renderSprite: Store<RenderSprite>;
collisionSourceClass: Store<CollisionClass>;
collisionTargetClass: Store<CollisionClass>;
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);
} }
} }

View File

@ -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>>) {
export interface EntityState { this.generation = from.generation ?? -1;
alive: Liveness; }
abstract clone(): Component<T> & T;
} }
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,35 +75,37 @@ 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;
let generation = -1; let generation = -1;
for(let id = 0; id < entities.length; id++) { for (let id = 0; id < entities.length; id++) {
if(entities[id].alive == Liveness.DEAD) { if (entities[id].alive == Liveness.DEAD) {
freeId = id; freeId = id;
generation = entities[id].generation + 1; generation = entities[id].generation + 1;
break; break;
} }
} }
if(freeId == -1) { if (freeId == -1) {
freeId = entities.length; freeId = entities.length;
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,8 +118,8 @@ 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,36 +157,39 @@ 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);
} }
} else { } else {
for(const id in firstStore) { for (const id in firstStore) {
JoinLoop(Number(id), entities, stores, results); JoinLoop(Number(id), entities, stores, results);
} }
} }
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) {
if(store === "id") { if (store === "id") {
result.push(fullId); result.push(fullId);
continue; continue;
} }
@ -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>);
} }

View File

@ -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];

View File

@ -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,

View File

@ -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
})
}); });
} }

View File

@ -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 { export class Hp extends Component<Hp> {
receivedDamage = 0; receivedDamage: number;
constructor( team: Teams;
public team: Teams, hp: number;
public 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 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> {

View File

@ -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;

View File

@ -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;