aboutsummaryrefslogtreecommitdiff
path: root/static/src/engine
diff options
context:
space:
mode:
Diffstat (limited to 'static/src/engine')
-rw-r--r--static/src/engine/component.ts24
-rw-r--r--static/src/engine/debounce_publisher.ts46
-rw-r--r--static/src/engine/entity.ts45
-rw-r--r--static/src/engine/events.ts107
-rw-r--r--static/src/engine/game.ts112
-rw-r--r--static/src/engine/input.ts49
-rw-r--r--static/src/engine/network.ts138
-rw-r--r--static/src/engine/render.ts46
-rw-r--r--static/src/engine/system.ts14
-rw-r--r--static/src/engine/trailing_position.ts33
10 files changed, 614 insertions, 0 deletions
diff --git a/static/src/engine/component.ts b/static/src/engine/component.ts
new file mode 100644
index 0000000..9607fec
--- /dev/null
+++ b/static/src/engine/component.ts
@@ -0,0 +1,24 @@
+export enum ComponentType {
+ POSITION = "POSITION",
+ RENDERABLE = "RENDERABLE",
+ TRAILING_POSITION = "TRAILING_POSITION",
+}
+
+export interface Component {
+ name: ComponentType;
+}
+
+export interface PositionComponent extends Component {
+ name: ComponentType.POSITION;
+ x: number;
+ y: number;
+}
+
+export interface TrailingPositionComponent extends Component {
+ name: ComponentType.TRAILING_POSITION;
+ trails: Array<{ x: number; y: number; time: number }>;
+}
+
+export interface RenderableComponent extends Component {
+ name: ComponentType.RENDERABLE;
+}
diff --git a/static/src/engine/debounce_publisher.ts b/static/src/engine/debounce_publisher.ts
new file mode 100644
index 0000000..8ee4bb0
--- /dev/null
+++ b/static/src/engine/debounce_publisher.ts
@@ -0,0 +1,46 @@
+export class DebouncePublisher<T> {
+ private last_event_time = Date.now();
+ private unpublished_data: T | undefined;
+ private interval_id: number | undefined;
+
+ constructor(
+ private readonly publisher: (data: T) => void | Promise<void>,
+ private readonly debounce_ms = 100,
+ ) {}
+
+ public start() {
+ if (typeof this.interval_id !== "undefined") {
+ return;
+ }
+ this.interval_id = setInterval(
+ () => this.debounce_publish(),
+ this.debounce_ms,
+ );
+ }
+
+ public stop() {
+ if (this.interval_id === null) {
+ return;
+ }
+ clearInterval(this.interval_id);
+ delete this.interval_id;
+ }
+
+ public update(data: T) {
+ this.unpublished_data = data;
+ this.debounce_publish();
+ }
+
+ private debounce_publish() {
+ if (
+ Date.now() - this.last_event_time < this.debounce_ms ||
+ typeof this.unpublished_data === "undefined"
+ ) {
+ return;
+ }
+
+ this.last_event_time = Date.now();
+ this.publisher(this.unpublished_data);
+ this.unpublished_data = undefined;
+ }
+}
diff --git a/static/src/engine/entity.ts b/static/src/engine/entity.ts
new file mode 100644
index 0000000..2c8d38e
--- /dev/null
+++ b/static/src/engine/entity.ts
@@ -0,0 +1,45 @@
+import {
+ Component,
+ ComponentType,
+ PositionComponent,
+ RenderableComponent,
+ TrailingPositionComponent,
+} from "./component";
+
+export enum EntityType {
+ LASER = "LASER",
+ CAT = "CAT",
+}
+
+export interface Entity {
+ entity_type: EntityType;
+ id: string;
+ components: Record<ComponentType, Component>;
+}
+
+export const create_laser = (base: Entity) => {
+ const trailing_position: TrailingPositionComponent = {
+ name: ComponentType.TRAILING_POSITION,
+ trails: [],
+ };
+ base.components[ComponentType.TRAILING_POSITION] = trailing_position;
+
+ const renderable: RenderableComponent = {
+ name: ComponentType.RENDERABLE,
+ };
+ base.components[ComponentType.RENDERABLE] = renderable;
+ return base;
+};
+
+export const create_cat = (base: Entity) => {
+ const renderable: RenderableComponent = {
+ name: ComponentType.RENDERABLE,
+ };
+ base.components[ComponentType.RENDERABLE] = renderable;
+ base.components[ComponentType.POSITION] = {
+ component: ComponentType.POSITION,
+ x: Math.random() * 1_000,
+ y: Math.random() * 1_000,
+ } as unknown as PositionComponent; // TODO: hack
+ return base;
+};
diff --git a/static/src/engine/events.ts b/static/src/engine/events.ts
new file mode 100644
index 0000000..60632f5
--- /dev/null
+++ b/static/src/engine/events.ts
@@ -0,0 +1,107 @@
+import { Entity } from "./entity";
+export enum EventType {
+ INITIAL_STATE = "INITIAL_STATE",
+ SET_CONTROLLABLE = "SET_CONTROLLABLE",
+ ENTITY_BORN = "ENTITY_BORN",
+ ENTITY_DEATH = "ENTITY_DEATH",
+ ENTITY_POSITION_UPDATE = "ENTITY_POSITION_UPDATE",
+}
+
+export interface Event {
+ event_type: EventType;
+ data: any;
+}
+
+export interface EntityPositionUpdateEvent extends Event {
+ event_type: EventType.ENTITY_POSITION_UPDATE;
+ data: {
+ id: string;
+ position: {
+ x: number;
+ y: number;
+ };
+ };
+}
+
+export interface InitialStateEvent extends Event {
+ event_type: EventType.INITIAL_STATE;
+ data: {
+ world: { width: number; height: number };
+ entities: Record<string, Entity>;
+ };
+}
+
+export interface SetControllableEvent extends Event {
+ event_type: EventType.SET_CONTROLLABLE;
+ data: {
+ id: string;
+ client_id: string;
+ };
+}
+
+export interface EntityBornEvent extends Event {
+ event_type: EventType.ENTITY_BORN;
+ data: {
+ entity: Entity;
+ };
+}
+
+export interface EntityDeathEvent extends Event {
+ event_type: EventType.ENTITY_DEATH;
+ data: {
+ id: string;
+ };
+}
+
+export interface EventQueue {
+ peek(): Event[];
+ clear(): void;
+}
+
+export interface EventPublisher {
+ add(event: Event): void;
+ publish(): void;
+}
+
+export class WebSocketEventQueue implements EventQueue {
+ private queue: Event[];
+
+ constructor(websocket: WebSocket) {
+ this.queue = [];
+ this.listen_to(websocket);
+ }
+
+ public peek() {
+ return this.queue;
+ }
+
+ public clear() {
+ this.queue = [];
+ }
+
+ private listen_to(websocket: WebSocket) {
+ websocket.onmessage = ({ data }) => {
+ this.queue = this.queue.concat(JSON.parse(data));
+ };
+ }
+}
+
+export class WebsocketEventPublisher implements EventPublisher {
+ private queue: Event[];
+
+ constructor(private readonly websocket: WebSocket) {
+ this.queue = [];
+ }
+
+ public add(event: Event) {
+ this.queue.push(event);
+ }
+
+ public publish() {
+ if (this.queue.length === 0) {
+ return;
+ }
+ this.websocket.send(JSON.stringify(this.queue));
+ this.queue = [];
+ }
+}
diff --git a/static/src/engine/game.ts b/static/src/engine/game.ts
new file mode 100644
index 0000000..64f63b5
--- /dev/null
+++ b/static/src/engine/game.ts
@@ -0,0 +1,112 @@
+import { System, SystemType } from "./system";
+import { Entity } from "./entity";
+import { ComponentType } from "./component";
+
+export class Game {
+ private running: boolean;
+ private last_update: number;
+
+ private readonly entities: Map<string, Entity> = new Map();
+ private readonly component_entities: Map<ComponentType, Set<string>> =
+ new Map();
+ private readonly systems: Map<SystemType, System> = new Map();
+ private readonly system_order: SystemType[];
+
+ constructor(
+ public readonly client_id: string,
+ systems: System[],
+ ) {
+ this.last_update = performance.now();
+ this.running = false;
+
+ systems.forEach((system) => this.systems.set(system.system_type, system));
+ this.system_order = systems.map(({ system_type }) => system_type);
+ }
+
+ public start() {
+ if (this.running) return;
+
+ console.log("starting game");
+ this.running = true;
+ this.last_update = performance.now();
+
+ const game_loop = (timestamp: number) => {
+ if (!this.running) return;
+
+ // rebuild component -> { entity } map
+ this.component_entities.clear();
+ Array.from(this.entities.values()).forEach((entity) =>
+ Object.values(entity.components).forEach((component) => {
+ const set =
+ this.component_entities.get(component.name) ?? new Set<string>();
+ set.add(entity.id);
+ this.component_entities.set(component.name, set);
+ }),
+ );
+
+ const dt = timestamp - this.last_update;
+
+ this.system_order.forEach((system_type) =>
+ this.systems.get(system_type)!.update(dt, this),
+ );
+
+ this.last_update = timestamp;
+ requestAnimationFrame(game_loop); // tail call recursion! /s
+ };
+ requestAnimationFrame(game_loop);
+ }
+
+ public stop() {
+ if (!this.running) return;
+ this.running = false;
+ }
+
+ public for_each_entity_with_component(
+ component: ComponentType,
+ callback: (entity: Entity) => void,
+ ) {
+ this.component_entities.get(component)?.forEach((entity_id) => {
+ const entity = this.entities.get(entity_id);
+ if (!entity) return;
+
+ callback(entity);
+ });
+ }
+
+ public get_entity(id: string) {
+ return this.entities.get(id);
+ }
+
+ public put_entity(entity: Entity) {
+ const old_entity = this.entities.get(entity.id);
+ if (old_entity) this.clear_entity_components(old_entity);
+
+ Object.values(entity.components).forEach((component) => {
+ const set =
+ this.component_entities.get(component.name) ?? new Set<string>();
+ set.add(entity.id);
+ this.component_entities.set(component.name, set);
+ });
+ this.entities.set(entity.id, entity);
+ }
+
+ public remove_entity(id: string) {
+ const entity = this.entities.get(id);
+ if (typeof entity === "undefined") return;
+
+ this.clear_entity_components(entity);
+ this.entities.delete(id);
+ }
+
+ private clear_entity_components(entity: Entity) {
+ Object.values(entity.components).forEach((component) => {
+ const set = this.component_entities.get(component.name);
+ if (typeof set === "undefined") return;
+ set.delete(entity.id);
+ });
+ }
+
+ public get_system<T extends System>(system_type: SystemType): T | undefined {
+ return this.systems.get(system_type) as T;
+ }
+}
diff --git a/static/src/engine/input.ts b/static/src/engine/input.ts
new file mode 100644
index 0000000..c1728e9
--- /dev/null
+++ b/static/src/engine/input.ts
@@ -0,0 +1,49 @@
+import { DebouncePublisher } from "./debounce_publisher";
+import { EntityPositionUpdateEvent, EventPublisher, EventType } from "./events";
+import { Game } from "./game";
+import { System, SystemType } from "./system";
+
+export class InputSystem extends System {
+ private readonly controllable_entities: Set<string> = new Set();
+ private readonly mouse_movement_debouncer: DebouncePublisher<{
+ x: number;
+ y: number;
+ }>;
+
+ constructor(
+ private readonly message_publisher: EventPublisher,
+ target: HTMLElement,
+ ) {
+ super(SystemType.INPUT);
+
+ this.mouse_movement_debouncer = new DebouncePublisher((data) =>
+ this.publish_mouse_movement(data),
+ );
+
+ target.addEventListener("mousemove", (event) => {
+ this.mouse_movement_debouncer.update({
+ x: event.clientX,
+ y: event.clientY,
+ });
+ });
+ }
+
+ private publish_mouse_movement({ x, y }: { x: number; y: number }) {
+ console.log(`publishing mouse movement at (${x}, ${y})`);
+ for (const entity_id of this.controllable_entities) {
+ this.message_publisher.add({
+ event_type: EventType.ENTITY_POSITION_UPDATE,
+ data: {
+ id: entity_id,
+ position: { x, y },
+ },
+ } as EntityPositionUpdateEvent);
+ }
+ }
+
+ public add_controllable_entity(entity_id: string) {
+ this.controllable_entities.add(entity_id);
+ }
+
+ public update(_dt: number, _game: Game) {}
+}
diff --git a/static/src/engine/network.ts b/static/src/engine/network.ts
new file mode 100644
index 0000000..850da65
--- /dev/null
+++ b/static/src/engine/network.ts
@@ -0,0 +1,138 @@
+import {
+ ComponentType,
+ PositionComponent,
+ TrailingPositionComponent,
+} from "./component";
+import { create_cat, create_laser, Entity, EntityType } from "./entity";
+import {
+ EntityBornEvent,
+ EntityDeathEvent,
+ EntityPositionUpdateEvent,
+ EventPublisher,
+ EventQueue,
+ EventType,
+ InitialStateEvent,
+ SetControllableEvent,
+} from "./events";
+import { Game } from "./game";
+import { InputSystem } from "./input";
+import { RenderSystem } from "./render";
+import { System, SystemType } from "./system";
+
+export class NetworkSystem extends System {
+ constructor(
+ private readonly event_queue: EventQueue,
+ private readonly event_publisher: EventPublisher,
+ ) {
+ super(SystemType.NETWORK);
+ }
+
+ public update(_dt: number, game: Game) {
+ const events = this.event_queue.peek();
+ for (const event of events) {
+ switch (event.event_type) {
+ case EventType.INITIAL_STATE:
+ this.process_initial_state_event(event as InitialStateEvent, game);
+ break;
+ case EventType.SET_CONTROLLABLE:
+ this.process_set_controllable_event(
+ event as SetControllableEvent,
+ game,
+ );
+ break;
+ case EventType.ENTITY_BORN:
+ this.process_entity_born_event(event as EntityBornEvent, game);
+ break;
+ case EventType.ENTITY_POSITION_UPDATE:
+ this.process_entity_position_update_event(
+ event as EntityPositionUpdateEvent,
+ game,
+ );
+ break;
+ case EventType.ENTITY_DEATH:
+ this.process_entity_death_event(event as EntityDeathEvent, game);
+ break;
+ }
+ }
+
+ this.event_queue.clear();
+ this.event_publisher.publish();
+ }
+
+ private process_initial_state_event(event: InitialStateEvent, game: Game) {
+ console.log("received initial state", event);
+ const { world, entities } = event.data;
+ const render_system = game.get_system<RenderSystem>(SystemType.RENDER);
+ if (!render_system) {
+ console.error("render system not found");
+ return;
+ }
+ render_system.set_world_dimensions(world.width, world.height);
+ Object.values(entities).forEach((entity) =>
+ game.put_entity(this.process_new_entity(entity)),
+ );
+ }
+
+ private process_entity_position_update_event(
+ event: EntityPositionUpdateEvent,
+ game: Game,
+ ) {
+ console.log("received entity position update", event);
+ const { position, id } = event.data;
+ const entity = game.get_entity(id);
+ if (typeof entity === "undefined") return;
+
+ const position_component = entity.components[
+ ComponentType.POSITION
+ ] as PositionComponent;
+ position_component.x = position.x;
+ position_component.y = position.y;
+
+ if (ComponentType.TRAILING_POSITION in entity.components) {
+ const trailing_position = entity.components[
+ ComponentType.TRAILING_POSITION
+ ] as TrailingPositionComponent;
+ trailing_position.trails.push({
+ x: position_component.x,
+ y: position_component.y,
+ time: Date.now(),
+ });
+ }
+ }
+
+ private process_entity_born_event(event: EntityBornEvent, game: Game) {
+ console.log("received a new entity", event);
+ const { entity } = event.data;
+ game.put_entity(this.process_new_entity(entity));
+ }
+
+ private process_new_entity(entity: Entity): Entity {
+ if (entity.entity_type === EntityType.LASER) {
+ return create_laser(entity);
+ }
+ if (entity.entity_type === EntityType.CAT) {
+ return create_cat(entity);
+ }
+ return entity;
+ }
+
+ private process_entity_death_event(event: EntityDeathEvent, game: Game) {
+ console.log("an entity died D:", event);
+ const { id } = event.data;
+ game.remove_entity(id);
+ }
+
+ private process_set_controllable_event(
+ event: SetControllableEvent,
+ game: Game,
+ ) {
+ console.log("got a controllable event", event);
+ if (event.data.client_id !== game.client_id) {
+ console.warn("got controllable event for client that is not us");
+ return;
+ }
+
+ const input_system = game.get_system<InputSystem>(SystemType.INPUT)!;
+ input_system.add_controllable_entity(event.data.id);
+ }
+}
diff --git a/static/src/engine/render.ts b/static/src/engine/render.ts
new file mode 100644
index 0000000..8f0343a
--- /dev/null
+++ b/static/src/engine/render.ts
@@ -0,0 +1,46 @@
+import {
+ ComponentType,
+ PositionComponent,
+ TrailingPositionComponent,
+} from "./component";
+import { Game } from "./game";
+import { System, SystemType } from "./system";
+import { drawLaserPen } from "laser-pen";
+
+export class RenderSystem extends System {
+ constructor(private readonly canvas: HTMLCanvasElement) {
+ super(SystemType.RENDER);
+ }
+
+ public set_world_dimensions(width: number, height: number) {
+ this.canvas.width = width;
+ this.canvas.height = height;
+ }
+
+ public update(_dt: number, game: Game) {
+ const ctx = this.canvas.getContext("2d");
+ if (ctx === null) return;
+ ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
+
+ game.for_each_entity_with_component(ComponentType.RENDERABLE, (entity) => {
+ if (ComponentType.TRAILING_POSITION in entity.components) {
+ const trailing_position = entity.components[
+ ComponentType.TRAILING_POSITION
+ ] as TrailingPositionComponent;
+ if (trailing_position.trails.length < 3) return;
+ drawLaserPen(ctx, trailing_position.trails);
+ return;
+ }
+
+ if (ComponentType.POSITION in entity.components) {
+ const position = entity.components[
+ ComponentType.POSITION
+ ] as PositionComponent;
+ ctx.beginPath();
+ ctx.arc(position.x, position.y, 50, 0, 2 * Math.PI);
+ ctx.stroke();
+ return;
+ }
+ });
+ }
+}
diff --git a/static/src/engine/system.ts b/static/src/engine/system.ts
new file mode 100644
index 0000000..e19cf5a
--- /dev/null
+++ b/static/src/engine/system.ts
@@ -0,0 +1,14 @@
+import { Game } from "./game";
+
+export enum SystemType {
+ INPUT = "INPUT",
+ NETWORK = "NETWORK",
+ RENDER = "RENDER",
+ TRAILING_POSITION = "TRAILING_POSITION",
+}
+
+export abstract class System {
+ constructor(public readonly system_type: SystemType) {}
+
+ abstract update(dt: number, game: Game): void;
+}
diff --git a/static/src/engine/trailing_position.ts b/static/src/engine/trailing_position.ts
new file mode 100644
index 0000000..1ea22d3
--- /dev/null
+++ b/static/src/engine/trailing_position.ts
@@ -0,0 +1,33 @@
+import { ComponentType, TrailingPositionComponent } from "./component";
+import { Game } from "./game";
+import { System, SystemType } from "./system";
+
+export class TrailingPositionSystem extends System {
+ constructor(
+ private readonly point_filter: (
+ trail_point: {
+ x: number;
+ y: number;
+ time: number;
+ }[],
+ ) => {
+ x: number;
+ y: number;
+ time: number;
+ }[],
+ ) {
+ super(SystemType.TRAILING_POSITION);
+ }
+
+ public update(_dt: number, game: Game) {
+ game.for_each_entity_with_component(
+ ComponentType.TRAILING_POSITION,
+ (entity) => {
+ const trailing_position = entity.components[
+ ComponentType.TRAILING_POSITION
+ ] as TrailingPositionComponent;
+ trailing_position.trails = this.point_filter(trailing_position.trails);
+ },
+ );
+ }
+}