diff --git a/static/index.html b/static/index.html index 3770eaa..ca9ed9c 100644 --- a/static/index.html +++ b/static/index.html @@ -9,7 +9,14 @@ the kennel. -
+ +
+ +
diff --git a/static/package-lock.json b/static/package-lock.json index e85f7a7..b62cb6d 100644 --- a/static/package-lock.json +++ b/static/package-lock.json @@ -8,7 +8,8 @@ "name": "kennel", "version": "0.1.0", "dependencies": { - "jquery": "^3.7.1" + "jquery": "^3.7.1", + "laser-pen": "^1.0.1" }, "devDependencies": { "@rollup/plugin-inject": "^5.0.5", @@ -1230,6 +1231,12 @@ "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==" }, + "node_modules/laser-pen": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/laser-pen/-/laser-pen-1.0.1.tgz", + "integrity": "sha512-+K3CQK5ryLDDjX0pEdSIRXh89F6KSpm225DEymWMheg2/umhUO+na/4l8MGs2gee7VO2Mx3kyG288WGrofasoA==", + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.11", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz", diff --git a/static/package.json b/static/package.json index 1c698e0..ae50b17 100644 --- a/static/package.json +++ b/static/package.json @@ -18,6 +18,7 @@ "vite-plugin-dynamic-base": "^1.1.0" }, "dependencies": { - "jquery": "^3.7.1" + "jquery": "^3.7.1", + "laser-pen": "^1.0.1" } } diff --git a/static/src/component.ts b/static/src/component.ts deleted file mode 100644 index d338cad..0000000 --- a/static/src/component.ts +++ /dev/null @@ -1,8 +0,0 @@ -export enum ComponentType { - POSITION = "POSITION", - RENDERABLE = "RENDERABLE", -} - -export interface Component { - name: string; -} 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/mouse_controller.ts b/static/src/engine/debounce_publisher.ts similarity index 51% rename from static/src/mouse_controller.ts rename to static/src/engine/debounce_publisher.ts index bd8d51a..8ee4bb0 100644 --- a/static/src/mouse_controller.ts +++ b/static/src/engine/debounce_publisher.ts @@ -1,12 +1,11 @@ -import { Vec2 } from "./vector"; -export class MouseController { +export class DebouncePublisher { private last_event_time = Date.now(); - private last_movement: Vec2 | undefined; + private unpublished_data: T | undefined; private interval_id: number | undefined; constructor( - private readonly publisher: (new_movement: Vec2) => void | Promise, - private readonly debounce_ms = 200, + private readonly publisher: (data: T) => void | Promise, + private readonly debounce_ms = 100, ) {} public start() { @@ -14,7 +13,7 @@ export class MouseController { return; } this.interval_id = setInterval( - () => this.publish_movement(), + () => this.debounce_publish(), this.debounce_ms, ); } @@ -27,21 +26,21 @@ export class MouseController { delete this.interval_id; } - public move(x: number, y: number) { - this.last_movement = new Vec2(x, y); - this.publish_movement(); + public update(data: T) { + this.unpublished_data = data; + this.debounce_publish(); } - private publish_movement() { + private debounce_publish() { if ( Date.now() - this.last_event_time < this.debounce_ms || - typeof this.last_movement === "undefined" + typeof this.unpublished_data === "undefined" ) { return; } this.last_event_time = Date.now(); - this.publisher(this.last_movement.copy()); - delete this.last_movement; + 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..09365f2 --- /dev/null +++ b/static/src/engine/entity.ts @@ -0,0 +1,30 @@ +import { + Component, + ComponentType, + RenderableComponent, + TrailingPositionComponent, +} from "./component"; + +export enum EntityType { + LASER = "LASER", +} + +export interface Entity { + entity_type: EntityType; + id: string; + components: Record; +} + +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; +}; diff --git a/static/src/network.ts b/static/src/engine/events.ts similarity index 84% rename from static/src/network.ts rename to static/src/engine/events.ts index fac96df..60632f5 100644 --- a/static/src/network.ts +++ b/static/src/engine/events.ts @@ -1,3 +1,4 @@ +import { Entity } from "./entity"; export enum EventType { INITIAL_STATE = "INITIAL_STATE", SET_CONTROLLABLE = "SET_CONTROLLABLE", @@ -26,7 +27,7 @@ export interface InitialStateEvent extends Event { event_type: EventType.INITIAL_STATE; data: { world: { width: number; height: number }; - entities: any[]; + entities: Record; }; } @@ -38,6 +39,20 @@ export interface SetControllableEvent extends Event { }; } +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; 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 = new Map(); + private readonly component_entities: Map> = + new Map(); + private readonly systems: Map = 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(); + 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(); + 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(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 = 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..cea5123 --- /dev/null +++ b/static/src/engine/network.ts @@ -0,0 +1,135 @@ +import { + ComponentType, + PositionComponent, + TrailingPositionComponent, +} from "./component"; +import { 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(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); + } + 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(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..9cfa816 --- /dev/null +++ b/static/src/engine/render.ts @@ -0,0 +1,31 @@ +import { ComponentType, 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); + } + }); + } +} 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); + }, + ); + } +} diff --git a/static/src/entity.ts b/static/src/entity.ts deleted file mode 100644 index 811d05a..0000000 --- a/static/src/entity.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { Component } from "./component"; - -export enum EntityType { - LASER = "LASER", -} - -export interface Entity { - entity_type: EntityType; - id: string; - components: Record; -} diff --git a/static/src/main.ts b/static/src/main.ts index c3588de..1fa3340 100644 --- a/static/src/main.ts +++ b/static/src/main.ts @@ -1,95 +1,16 @@ import $ from "jquery"; -import { Vec2 } from "./vector"; -import { MouseController } from "./mouse_controller"; import { - EntityPositionUpdateEvent, EventPublisher, EventQueue, - EventType, - SetControllableEvent, WebsocketEventPublisher, WebSocketEventQueue, -} from "./network"; - -class KennelClient { - private running: boolean; - private last_update: number; - - private controllable_entities: Set = new Set(); - private mouse_controller: MouseController; - - constructor( - private readonly client_id: string, - private readonly event_queue: EventQueue, - private readonly event_publisher: EventPublisher, - ) { - this.last_update = 0; - this.running = false; - - this.mouse_controller = new MouseController((position: Vec2) => - this.on_mouse_move(position), - ); - } - - public start() { - this.running = true; - this.last_update = performance.now(); - this.mouse_controller.start(); - - const loop = (timestamp: number) => { - if (!this.running) return; - const dt = timestamp - this.last_update; - this.propogate_state_after(dt); - requestAnimationFrame(loop); // tail call recursion! /s - }; - requestAnimationFrame(loop); - - $(document).on("mousemove", (event) => { - this.mouse_controller.move(event.clientX, event.clientY); - }); - } - - public close() { - this.running = false; - this.mouse_controller.stop(); - this.controllable_entities.clear(); - $(document).off("mousemove"); - } - - private propogate_state_after(dt: number) { - const events = this.event_queue.peek(); - for (const event of events) { - switch (event.event_type) { - case EventType.SET_CONTROLLABLE: - this.process_set_controllable_event(event as SetControllableEvent); - break; - } - } - if (events.length > 0) { - console.log(events, dt); - } - this.event_queue.clear(); - this.event_publisher.publish(); - } - - private process_set_controllable_event(event: SetControllableEvent) { - if (event.data.client_id !== this.client_id) { - console.warn("got controllable event for client that is not us"); - return; - } - this.controllable_entities.add(event.data.id); - } - - private on_mouse_move(position: Vec2) { - for (const id of this.controllable_entities) { - const event: EntityPositionUpdateEvent = { - event_type: EventType.ENTITY_POSITION_UPDATE, - data: { id, position: { x: position.x, y: position.y } }, - }; - this.event_publisher.add(event); - } - } -} +} from "./engine/events"; +import { Game } from "./engine/game"; +import { NetworkSystem } from "./engine/network"; +import { RenderSystem } from "./engine/render"; +import { InputSystem } from "./engine/input"; +import { TrailingPositionSystem } from "./engine/trailing_position"; +import { drainPoints, setDelay } from "laser-pen"; $(async () => { const client_id = await fetch("/assign", { @@ -105,8 +26,25 @@ $(async () => { const queue: EventQueue = new WebSocketEventQueue(ws); const publisher: EventPublisher = new WebsocketEventPublisher(ws); - const kennel_client = new KennelClient(client_id, queue, publisher); - ws.onclose = () => kennel_client.close(); + const network_system = new NetworkSystem(queue, publisher); - kennel_client.start(); + const gamecanvas = $("#gamecanvas").get(0)!; + const input_system = new InputSystem(publisher, gamecanvas); + + setDelay(1_000); + const render_system = new RenderSystem(gamecanvas as HTMLCanvasElement); + + const trailing_position = new TrailingPositionSystem(drainPoints); + + const systems = [ + network_system, + trailing_position, + input_system, + render_system, + ]; + + const game = new Game(client_id, systems); + ws.onclose = () => game.stop(); + + game.start(); }); diff --git a/static/src/vector.ts b/static/src/vector.ts deleted file mode 100644 index 29e62df..0000000 --- a/static/src/vector.ts +++ /dev/null @@ -1,14 +0,0 @@ -export class Vec2 { - constructor( - public readonly x: number, - public readonly y: number, - ) {} - - public distance_to(that: Vec2): number { - return Math.sqrt((this.x - that.x) ** 2 + (this.y - that.y) ** 2); - } - - public copy(): Vec2 { - return new Vec2(this.x, this.y); - } -}