diff --git a/kennel/config.py b/kennel/config.py index a731c84..8a09500 100644 --- a/kennel/config.py +++ b/kennel/config.py @@ -17,5 +17,7 @@ class Config: COOKIE_MAX_AGE = int(os.getenv("COOKIE_MAX_AGE", 60 * 60 * 24 * 7)) # 1 week + KENNEL_CATS_POLL_SEC = int(os.getenv("KENNEL_CATS_POLL_SEC", 10)) + config = Config() diff --git a/kennel/engine/components/component.py b/kennel/engine/components/component.py index 9f56407..90506b9 100644 --- a/kennel/engine/components/component.py +++ b/kennel/engine/components/component.py @@ -5,6 +5,7 @@ from enum import Enum class ComponentType(str, Enum): POSITION = "POSITION" CONTROLLABLE = "CONTROLLABLE" + MARKOV = "MARKOV" class Component: diff --git a/kennel/engine/components/markov_transition_state.py b/kennel/engine/components/markov_transition_state.py new file mode 100644 index 0000000..cec8223 --- /dev/null +++ b/kennel/engine/components/markov_transition_state.py @@ -0,0 +1,24 @@ +from kennel.engine.components.component import Component, ComponentType + + +class MarkovTransitionState(Component): + def __init__( + self, + state_names: dict[int, str], + initial_state_vector: list[float], + transition_matrix: list[list[float]], + ): + # TODO: Poll rate per state? + # TODO: State being an enum instead of a vector, just choose max and map + self.state_names = state_names + self.state = initial_state_vector + self.transition_matrix = transition_matrix + + super().__init__(ComponentType.MARKOV) + + def get_max_state_name(self, state_vector: list[float]): + max_val = max(state_vector) + return self.state_names[state_vector.index(max_val)] + + def to_dict(self): + return {"state": self.get_max_state_name(self.state)} diff --git a/kennel/engine/entities/cat.py b/kennel/engine/entities/cat.py index 02fdb33..edcde37 100644 --- a/kennel/engine/entities/cat.py +++ b/kennel/engine/entities/cat.py @@ -1,6 +1,18 @@ +from kennel.engine.components.position import Position + +from .entity import Entity, EntityType + + +class Cat(Entity): + def __init__(self, id: str): + components = [Position(0, 0)] + + super().__init__(EntityType.CAT, id, components) + + # # # IDLE, FROLICKING, EEPY, ALERT, CHASING_CURSOR, CHASING_CAT, SCRATCHING, ITCHY -# state_stochastic_matrix = [ +# state_stochastic_matrix = [ [1, 0] # # IDLE # [0.5, 0.1, 0.1, 0.1, 0.1, 0.05, 0.05, 0], # # FROLICKING diff --git a/kennel/engine/entities/entity.py b/kennel/engine/entities/entity.py index d40ab19..30e325a 100644 --- a/kennel/engine/entities/entity.py +++ b/kennel/engine/entities/entity.py @@ -6,6 +6,7 @@ from kennel.engine.components.component import Component, ComponentType class EntityType(str, Enum): LASER = "LASER" + CAT = "CAT" class Entity: diff --git a/kennel/engine/systems/markov_transition_state_system.py b/kennel/engine/systems/markov_transition_state_system.py new file mode 100644 index 0000000..ad3de65 --- /dev/null +++ b/kennel/engine/systems/markov_transition_state_system.py @@ -0,0 +1,13 @@ +from kennel.engine.components.component import ComponentType +from kennel.engine.entities.entity import EntityManager +from kennel.engine.systems.system import System, SystemType +from kennel.engine.systems.network import NetworkSystem + + +class MarkovTransitionStateSystem(System): + def __init__(self, network_system: NetworkSystem): + super().__init__(SystemType.MARKOV) + + def update(self, entity_manager: EntityManager, delta_time: float): + entity_manager.get_entities_with_component(ComponentType.MARKOV) + return diff --git a/kennel/engine/systems/system.py b/kennel/engine/systems/system.py index e3ddb27..8c577a6 100644 --- a/kennel/engine/systems/system.py +++ b/kennel/engine/systems/system.py @@ -7,6 +7,7 @@ from kennel.engine.entities.entity import EntityManager class SystemType(str, Enum): NETWORK = "NETWORK" WORLD = "WORLD" + MARKOV = "MARKOV" class System: @@ -14,7 +15,7 @@ class System: self.system_type = system_type @abstractmethod - async def update(self, entity_manager: EntityManager, delta_time: float): + async def update(self, entity_manager: EntityManager, delta_time: float) -> None: pass diff --git a/kennel/kennel.py b/kennel/kennel.py index b4d7b7b..7123b7f 100644 --- a/kennel/kennel.py +++ b/kennel/kennel.py @@ -1,18 +1,27 @@ +import asyncio import uuid +import time from typing import List, Optional from kennel.config import config from kennel.engine.components.component import ComponentType from kennel.engine.entities.entity import Entity, EntityManager from kennel.engine.entities.laser import Laser +from kennel.engine.entities.cat import Cat from kennel.engine.game import Game +from kennel.engine.systems.markov_transition_state_system import ( + MarkovTransitionStateSystem, +) from kennel.engine.systems.network import ( EntityPositionUpdateEvent, + EntityBornEvent, + EntityDeathEvent, Event, EventType, NetworkSystem, UpstreamEventProcessor, ) +from kennel.kennelcats import KennelCatService, KennelCat from kennel.engine.systems.system import SystemManager from kennel.engine.systems.world import WorldSystem @@ -58,12 +67,76 @@ class KennelEventProcessor(UpstreamEventProcessor): return event -system_manager.add_system(WorldSystem(config.WORLD_WIDTH, config.WORLD_HEIGHT)) -system_manager.add_system( - NetworkSystem(KennelEventProcessor()), -) +class KennelCatsManager: + kennel_cat_service: KennelCatService + entity_manager: EntityManager + network_system: NetworkSystem + last_seen: set[str] + poll_interval_sec: int + running: bool + + def __init__( + self, + kennel_cat_service: KennelCatService, + entity_manager: EntityManager, + network_system: NetworkSystem, + poll_interval_sec: int, + ): + self.kennel_cat_service = kennel_cat_service + self.entity_manager = entity_manager + self.network_system = network_system + self.poll_interval_sec = poll_interval_sec + + self.last_seen = set() + self.running = False + + async def start(self) -> None: + logger.info("starting kennel cats manager") + if self.running: + return + + self.running = True + while self.running: + logger.info("polling kennel cats service") + cats = self.kennel_cat_service.get_kennel() + cats_table = {cat.id: cat for cat in cats} + cat_ids = set([cat.id for cat in cats]) + + removed_cats = [cats_table[id] for id in self.last_seen.difference(cat_ids)] + added_cats = [cats_table[id] for id in cat_ids.difference(self.last_seen)] + logger.info(f"removing {removed_cats}") + logger.info(f"adding {added_cats}") + + for removed in removed_cats: + self.entity_manager.remove_entity(removed) + entity_death = EntityDeathEvent(removed.id) + self.network_system.server_global_event(entity_death) + + for added in added_cats: + new_cat = Cat(added.id) + self.entity_manager.add_entity(new_cat) + entity_born = EntityBornEvent(new_cat) + self.network_system.server_global_event(entity_born) + + self.last_seen = cat_ids + await asyncio.sleep(self.poll_interval_sec) + + def stop(self) -> None: + logger.info("stopping kennel cats manager") + self.running = False + + +network_system = NetworkSystem(KennelEventProcessor()) +world_system = WorldSystem(config.WORLD_WIDTH, config.WORLD_HEIGHT) +markov_transition_state_system = MarkovTransitionStateSystem(network_system) +system_manager.add_system(network_system) +system_manager.add_system(world_system) kennel = Game(entity_manager, system_manager, config.MIN_TIME_STEP) +kennel_cat_service = KennelCatService(config.HATECOMPUTERS_ENDPOINT) +kennel_cats_manager = KennelCatsManager( + kennel_cat_service, entity_manager, network_system, config.KENNEL_CATS_POLL_SEC +) def create_session_controllable_entities(session: str) -> List[Entity]: diff --git a/kennel/kennelcats.py b/kennel/kennelcats.py index 970e44f..2cb2f29 100644 --- a/kennel/kennelcats.py +++ b/kennel/kennelcats.py @@ -37,10 +37,10 @@ class KennelCat: class KennelCatService: - def __init__(self, endpoint: str): - self.endpoint = endpoint + def __init__(self, hc_endpoint: str): + self.hc_endpoint = hc_endpoint def get_kennel(self) -> List[KennelCat]: - response = requests.get(f"{self.endpoint}/kennel") + response = requests.get(f"{self.hc_endpoint}/kennel") response.raise_for_status() return [KennelCat.from_dict(cat) for cat in response.json()] diff --git a/kennel/main.py b/kennel/main.py index 22d3d39..49d62d4 100644 --- a/kennel/main.py +++ b/kennel/main.py @@ -30,6 +30,7 @@ from kennel.kennel import ( create_session_controllable_entities, entity_manager, kennel, + kennel_cats_manager, system_manager, ) @@ -43,12 +44,14 @@ loop = asyncio.get_event_loop() async def startup_event(): logger.info("Starting Kennel...") loop.create_task(kennel.run()) + loop.create_task(kennel_cats_manager.start()) @app.on_event("shutdown") async def shutdown_event(): logger.info("Stopping Kennel...") kennel.stop() + kennel_cats_manager.stop() loop.stop() logger.info("Kennel stopped") 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..b27d3de 100644 --- a/static/package.json +++ b/static/package.json @@ -4,10 +4,9 @@ "version": "0.1.0", "type": "module", "scripts": { - "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "watch": "./node_modules/nodemon/bin/nodemon.js --watch './src/**/*' -e ts,html --exec \"npm run build\"" + "dev": "./node_modules/nodemon/bin/nodemon.js --watch './src/**/*' -e ts,html --exec \"npm run build\"" }, "devDependencies": { "@rollup/plugin-inject": "^5.0.5", @@ -18,6 +17,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..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; +} + +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/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..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(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(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); + }, + ); + } +} 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..c11e41e 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)! as HTMLCanvasElement; + const input_system = new InputSystem(publisher, gamecanvas); + + setDelay(500); + const render_system = new RenderSystem(gamecanvas); + + 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); - } -}