get "cats" up there
	
		
			
	
		
	
	
		
			
				
	
				continuous-integration/drone/pr Build is failing
				
					Details
				
			
		
	
				
					
				
			
				
	
				continuous-integration/drone/pr Build is failing
				
					Details
				
			
		
	This commit is contained in:
		
							parent
							
								
									e4e31978ba
								
							
						
					
					
						commit
						8ec7f53682
					
				|  | @ -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() | ||||
|  |  | |||
|  | @ -5,6 +5,7 @@ from enum import Enum | |||
| class ComponentType(str, Enum): | ||||
|     POSITION = "POSITION" | ||||
|     CONTROLLABLE = "CONTROLLABLE" | ||||
|     MARKOV = "MARKOV" | ||||
| 
 | ||||
| 
 | ||||
| class Component: | ||||
|  |  | |||
|  | @ -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)} | ||||
|  | @ -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 | ||||
|  |  | |||
|  | @ -6,6 +6,7 @@ from kennel.engine.components.component import Component, ComponentType | |||
| 
 | ||||
| class EntityType(str, Enum): | ||||
|     LASER = "LASER" | ||||
|     CAT = "CAT" | ||||
| 
 | ||||
| 
 | ||||
| class Entity: | ||||
|  |  | |||
|  | @ -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 | ||||
|  | @ -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 | ||||
| 
 | ||||
| 
 | ||||
|  |  | |||
|  | @ -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]: | ||||
|  |  | |||
|  | @ -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()] | ||||
|  |  | |||
|  | @ -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") | ||||
| 
 | ||||
|  |  | |||
|  | @ -9,7 +9,14 @@ | |||
|   <title>the kennel.</title> | ||||
| </head> | ||||
| <body> | ||||
|   <div id="app"></div> | ||||
|   <noscript> | ||||
|     <div style="text-align: center"> | ||||
|       <h1>yeah, unfortunately you need javascript ;3</h1> | ||||
|     </div> | ||||
|   </noscript> | ||||
|   <div id="app"> | ||||
|     <canvas id="gamecanvas"></canvas> | ||||
|   </div> | ||||
|   <script type="module" src="/src/main.ts"></script> | ||||
| </body> | ||||
| </html> | ||||
|  |  | |||
|  | @ -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", | ||||
|  |  | |||
|  | @ -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" | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -1,8 +0,0 @@ | |||
| export enum ComponentType { | ||||
|   POSITION = "POSITION", | ||||
|   RENDERABLE = "RENDERABLE", | ||||
| } | ||||
| 
 | ||||
| export interface Component { | ||||
|   name: string; | ||||
| } | ||||
|  | @ -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; | ||||
| } | ||||
|  | @ -1,12 +1,11 @@ | |||
| import { Vec2 } from "./vector"; | ||||
| export class MouseController { | ||||
| export class DebouncePublisher<T> { | ||||
|   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<void>, | ||||
|     private readonly debounce_ms = 200, | ||||
|     private readonly publisher: (data: T) => void | Promise<void>, | ||||
|     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; | ||||
|   } | ||||
| } | ||||
|  | @ -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; | ||||
| }; | ||||
|  | @ -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<string, Entity>; | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
|  | @ -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; | ||||
|  | @ -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; | ||||
|   } | ||||
| } | ||||
|  | @ -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) {} | ||||
| } | ||||
|  | @ -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); | ||||
|   } | ||||
| } | ||||
|  | @ -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; | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
| } | ||||
|  | @ -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; | ||||
| } | ||||
|  | @ -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); | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | @ -1,11 +0,0 @@ | |||
| import type { Component } from "./component"; | ||||
| 
 | ||||
| export enum EntityType { | ||||
|   LASER = "LASER", | ||||
| } | ||||
| 
 | ||||
| export interface Entity { | ||||
|   entity_type: EntityType; | ||||
|   id: string; | ||||
|   components: Record<string, Component>; | ||||
| } | ||||
|  | @ -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<string> = 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(); | ||||
| }); | ||||
|  |  | |||
|  | @ -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); | ||||
|   } | ||||
| } | ||||
		Loading…
	
		Reference in New Issue