WIP: ECS / Network System #1
			
				
			
		
		
		
	|  | @ -4,12 +4,12 @@ type: docker | |||
| name: build | ||||
| 
 | ||||
| steps: | ||||
|   - name: run tests | ||||
|   - name: lint | ||||
|     image: python:3.12 | ||||
|     commands: | ||||
|       - pip install poetry | ||||
|       - poetry install --with main,dev | ||||
|       - poetry run pytest | ||||
|       - poetry run ruff check kennel/* | ||||
| 
 | ||||
| trigger: | ||||
|   event: | ||||
|  | @ -21,12 +21,12 @@ type: docker | |||
| name: deploy | ||||
| 
 | ||||
| steps: | ||||
|   - name: run tests | ||||
|   - name: run lier | ||||
|     image: python:3.12 | ||||
|     commands: | ||||
|       - pip install poetry | ||||
|       - poetry install --with main,dev | ||||
|       - poetry run pytest | ||||
|       - poetry run ruff check kennel/* | ||||
|   - name: docker | ||||
|     image: plugins/docker | ||||
|     settings: | ||||
|  |  | |||
|  | @ -1,2 +0,0 @@ | |||
| *.sqlite | ||||
| *.db | ||||
|  | @ -0,0 +1,8 @@ | |||
| import structlog | ||||
| from fastapi import FastAPI | ||||
| 
 | ||||
| app = FastAPI( | ||||
|     servers=[{"url": "https://kennel.hatecomputers.club", "description": "prod"}] | ||||
| ) | ||||
| 
 | ||||
| logger = structlog.get_logger() | ||||
|  | @ -0,0 +1,23 @@ | |||
| import os | ||||
| 
 | ||||
| from dotenv import load_dotenv | ||||
| 
 | ||||
| load_dotenv() | ||||
| 
 | ||||
| 
 | ||||
| class Config: | ||||
|     WORLD_WIDTH = int(os.getenv("WORLD_WIDTH", 1000)) | ||||
|     WORLD_HEIGHT = int(os.getenv("WORLD_HEIGHT", 1000)) | ||||
| 
 | ||||
|     HATECOMPUTERS_ENDPOINT = os.getenv( | ||||
|         "HATECOMPUTERS_ENDPOINT", "https://hatecomputers.club" | ||||
|     ) | ||||
| 
 | ||||
|     MIN_TIME_STEP = float(os.getenv("MIN_TIME_STEP", 0.05)) | ||||
| 
 | ||||
|     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() | ||||
|  | @ -0,0 +1,18 @@ | |||
| from abc import abstractmethod | ||||
| from enum import Enum | ||||
| 
 | ||||
| 
 | ||||
| class ComponentType(str, Enum): | ||||
|     POSITION = "POSITION" | ||||
|     CONTROLLABLE = "CONTROLLABLE" | ||||
|     MARKOV = "MARKOV" | ||||
|     SPRITESHEET = "SPRITESHEET" | ||||
| 
 | ||||
| 
 | ||||
| class Component: | ||||
|     def __init__(self, component_type: ComponentType): | ||||
|         self.component_type = component_type | ||||
| 
 | ||||
|     @abstractmethod | ||||
|     def to_dict(self) -> dict: | ||||
|         pass | ||||
|  | @ -0,0 +1,14 @@ | |||
| from .component import Component, ComponentType | ||||
| 
 | ||||
| 
 | ||||
| class Controllable(Component): | ||||
|     def __init__(self, by: str): | ||||
|         super().__init__(ComponentType.CONTROLLABLE) | ||||
|         self.by = by | ||||
| 
 | ||||
|     def __repr__(self) -> str: | ||||
|         return f"Controllable(by={self.by})" | ||||
| 
 | ||||
|     def to_dict(self) -> dict: | ||||
|         # don't serialize who owns this | ||||
|         return {} | ||||
|  | @ -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)} | ||||
|  | @ -0,0 +1,14 @@ | |||
| from .component import Component, ComponentType | ||||
| 
 | ||||
| 
 | ||||
| class Position(Component): | ||||
|     def __init__(self, x: float, y: float): | ||||
|         super().__init__(ComponentType.POSITION) | ||||
|         self.x = x | ||||
|         self.y = y | ||||
| 
 | ||||
|     def __repr__(self) -> str: | ||||
|         return f"Position(x={self.x}, y={self.y})" | ||||
| 
 | ||||
|     def to_dict(self) -> dict: | ||||
|         return {"x": self.x, "y": self.y} | ||||
|  | @ -0,0 +1,63 @@ | |||
| from .component import Component, ComponentType | ||||
| 
 | ||||
| 
 | ||||
| class SpriteSpec: | ||||
|     def __init__( | ||||
|         self, | ||||
|         ms_per_frame: int, | ||||
|         top_x: int, | ||||
|         top_y: int, | ||||
|         end_x: int, | ||||
|         end_y: int, | ||||
|         frames: int, | ||||
|     ): | ||||
|         self.ms_per_frame = ms_per_frame | ||||
|         self.frames = frames | ||||
|         self.top_x = top_x | ||||
|         self.top_y = top_y | ||||
|         self.end_x = end_x | ||||
|         self.end_y = end_y | ||||
| 
 | ||||
|     def to_dict(self) -> dict: | ||||
|         return { | ||||
|             "ms_per_frame": self.ms_per_frame, | ||||
|             "top_x": self.top_x, | ||||
|             "top_y": self.top_y, | ||||
|             "end_x": self.end_x, | ||||
|             "end_y": self.end_y, | ||||
|             "frames": self.frames, | ||||
|         } | ||||
| 
 | ||||
|     def __repr__(self) -> str: | ||||
|         return f"SpriteSpec(ms_per_frame={self.ms_per_frame}, top_x={self.top_x}, top_y={self.top_y}, end_x={self.end_x}, end_y={self.end_y}, frames={self.frames})" | ||||
| 
 | ||||
| 
 | ||||
| class SpriteSheet(Component): | ||||
|     def __init__( | ||||
|         self, | ||||
|         source: str, | ||||
|         state_to_spritespec: dict[str, SpriteSpec], | ||||
|         initial_state: str, | ||||
|     ): | ||||
|         super().__init__(ComponentType.SPRITESHEET) | ||||
|         self.source = source | ||||
|         self.state_to_spritespec = state_to_spritespec | ||||
| 
 | ||||
|         # these are only really used for client initialization | ||||
|         self.initial_state = initial_state | ||||
|         self.current_frame = 0 | ||||
|         self.last_update = 0 | ||||
| 
 | ||||
|     def __repr__(self) -> str: | ||||
|         return f"SpriteSheet(source={self.source}, state_to_spritespec={self.state_to_spritespec}, initial_state={self.initial_state}, current_frame={self.current_frame}, last_update={self.last_update})" | ||||
| 
 | ||||
|     def to_dict(self) -> dict: | ||||
|         return { | ||||
|             "source": self.source, | ||||
|             "current_frame": self.current_frame, | ||||
|             "last_update": self.last_update, | ||||
|             "current_state": self.initial_state, | ||||
|             "state_to_spritespec": { | ||||
|                 k: v.to_dict() for k, v in self.state_to_spritespec.items() | ||||
|             }, | ||||
|         } | ||||
|  | @ -0,0 +1,83 @@ | |||
| from kennel.engine.components.position import Position | ||||
| from kennel.engine.components.sprite_sheet import SpriteSheet, SpriteSpec | ||||
| from enum import Enum | ||||
| from .entity import Entity, EntityType | ||||
| 
 | ||||
| 
 | ||||
| class CatState(str, Enum): | ||||
|     IDLE = "IDLE" | ||||
|     FROLICKING = "FROLICKING" | ||||
|     EEPY = "EEPY" | ||||
|     ALERT = "ALERT" | ||||
|     CHASING_CURSOR = "CHASING_CURSOR" | ||||
|     CHASING_CAT = "CHASING_CAT" | ||||
|     SCRATCHING = "SCRATCHING" | ||||
|     ITCHY = "ITCHY" | ||||
|     MAKING_BISCUITS = "MAKING_BISCUITS" | ||||
| 
 | ||||
| 
 | ||||
| class CatSpriteState(str, Enum): | ||||
|     ALERT = "ALERT" | ||||
|     MAKING_BISCUITS = "MAKING_BISCUITS" | ||||
| 
 | ||||
| 
 | ||||
| class Cat(Entity): | ||||
|     def __init__(self, id: str, spritesheet_source: str): | ||||
|         state_to_spritespec = self.get_state_to_spritespec() | ||||
|         components = [ | ||||
|             Position(50, 50), | ||||
|             SpriteSheet( | ||||
|                 spritesheet_source, state_to_spritespec, CatSpriteState.MAKING_BISCUITS | ||||
|             ), | ||||
|         ] | ||||
| 
 | ||||
|         super().__init__(EntityType.CAT, id, components) | ||||
| 
 | ||||
|     def get_state_to_spritespec(self): | ||||
|         creature_width = 32 | ||||
|         creature_height = 32 | ||||
|         return { | ||||
|             CatSpriteState.ALERT: SpriteSpec( | ||||
|                 100, | ||||
|                 creature_width * 7, | ||||
|                 creature_height * 3, | ||||
|                 creature_width * 8, | ||||
|                 creature_height * 4, | ||||
|                 1, | ||||
|             ), | ||||
|             CatSpriteState.MAKING_BISCUITS: SpriteSpec( | ||||
|                 300, | ||||
|                 0, | ||||
|                 0, | ||||
|                 creature_width, | ||||
|                 creature_height * 2, | ||||
|                 2, | ||||
|             ), | ||||
|         } | ||||
| 
 | ||||
| 
 | ||||
| # | ||||
| # # IDLE, FROLICKING, EEPY, ALERT, CHASING_CURSOR, CHASING_CAT, SCRATCHING, ITCHY | ||||
| # state_stochastic_matrix = [ [1, 0] | ||||
| #     # IDLE | ||||
| #     [0.5, 0.1, 0.1, 0.1, 0.1, 0.05, 0.05, 0], | ||||
| #     # FROLICKING | ||||
| #     [0.1, 0.5, 0.1, 0.1, 0.1, 0.05, 0.05, 0], | ||||
| #     # EEPY | ||||
| #     [0.1, 0.1, 0.5, 0.1, 0.1, 0.05, 0.05, 0], | ||||
| #     # ALERT | ||||
| #     [0.1, 0.1, 0.1, 0.5, 0.1, 0.05, 0.05, 0], | ||||
| #     # CHASING_CURSOR | ||||
| #     [0.1, 0.1, 0.1, 0.1, 0.5, 0.05, 0.05, 0], | ||||
| #     # CHASING_CAT | ||||
| #     [0.1, 0.1, 0.1, 0.1, 0.1, 0.5, 0.05, 0], | ||||
| #     # SCRATCHING | ||||
| #     [0.1, 0.1, 0.1, 0.1, 0.1, 0.05, 0.5, 0], | ||||
| #     # ITCHY | ||||
| #     [0, 0, 0, 0, 0, 0, 0, 1], | ||||
| # ] | ||||
| # | ||||
| # | ||||
| 
 | ||||
| # | ||||
| # | ||||
|  | @ -0,0 +1,74 @@ | |||
| from enum import Enum | ||||
| from typing import List, Optional | ||||
| 
 | ||||
| from kennel.engine.components.component import Component, ComponentType | ||||
| 
 | ||||
| 
 | ||||
| class EntityType(str, Enum): | ||||
|     LASER = "LASER" | ||||
|     CAT = "CAT" | ||||
| 
 | ||||
| 
 | ||||
| class Entity: | ||||
|     def __init__( | ||||
|         self, entity_type: EntityType, id: str, component_list: List[Component] | ||||
|     ): | ||||
|         self.entity_type = entity_type | ||||
|         self.id = id | ||||
|         self.components = {} | ||||
|         for component in component_list: | ||||
|             self.add_component(component) | ||||
| 
 | ||||
|     def get_component(self, component_type: ComponentType) -> Optional[Component]: | ||||
|         return self.components[component_type] | ||||
| 
 | ||||
|     def add_component(self, component: Component) -> None: | ||||
|         self.components[component.component_type] = component | ||||
| 
 | ||||
|     def to_dict(self) -> dict: | ||||
|         return { | ||||
|             "entity_type": self.entity_type, | ||||
|             "id": self.id, | ||||
|             "components": {k: v.to_dict() for k, v in self.components.items()}, | ||||
|         } | ||||
| 
 | ||||
| 
 | ||||
| class EntityManager: | ||||
|     def __init__(self): | ||||
|         self.entities = {} | ||||
|         self.component_entities = {} | ||||
| 
 | ||||
|     def update(self) -> None: | ||||
|         self.component_entities = {} | ||||
|         for entity in self.entities.values(): | ||||
|             for component in entity.components.values(): | ||||
|                 if component.component_type not in self.component_entities: | ||||
|                     self.component_entities[component.component_type] = set() | ||||
|                 self.component_entities[component.component_type].add(entity) | ||||
| 
 | ||||
|     def get_entities_with_component( | ||||
|         self, component_type: ComponentType | ||||
|     ) -> List[Entity]: | ||||
|         return self.component_entities.get(component_type, []) | ||||
| 
 | ||||
|     def add_entity(self, entity: Entity) -> None: | ||||
|         self.entities[entity.id] = entity | ||||
|         for component in entity.components.values(): | ||||
|             if component.component_type not in self.component_entities: | ||||
|                 self.component_entities[component.component_type] = set() | ||||
|             self.component_entities[component.component_type].add(entity) | ||||
| 
 | ||||
|     def remove_entity(self, entity_id: str) -> None: | ||||
|         if entity_id not in self.entities: | ||||
|             return | ||||
|         entity = self.entities[entity_id] | ||||
|         for component in entity.components.values(): | ||||
|             if component.component_type in self.component_entities: | ||||
|                 self.component_entities[component.component_type].remove(entity) | ||||
|         del self.entities[entity_id] | ||||
| 
 | ||||
|     def get_entity(self, entity_id: str) -> Optional[Entity]: | ||||
|         return self.entities.get(entity_id) | ||||
| 
 | ||||
|     def to_dict(self) -> dict: | ||||
|         return {k: v.to_dict() for k, v in self.entities.items()} | ||||
|  | @ -0,0 +1,14 @@ | |||
| from kennel.engine.components.controllable import Controllable | ||||
| from kennel.engine.components.position import Position | ||||
| 
 | ||||
| from .entity import Entity, EntityType | ||||
| 
 | ||||
| 
 | ||||
| class Laser(Entity): | ||||
|     def __init__(self, id: str, controllable_by: str): | ||||
|         components = [ | ||||
|             Position(0, 0), | ||||
|             Controllable(controllable_by), | ||||
|         ] | ||||
| 
 | ||||
|         super().__init__(EntityType.LASER, id, components) | ||||
|  | @ -0,0 +1,53 @@ | |||
| import asyncio | ||||
| import time | ||||
| 
 | ||||
| from kennel.app import logger | ||||
| from kennel.engine.entities.entity import EntityManager | ||||
| from kennel.engine.systems.system import SystemManager | ||||
| 
 | ||||
| 
 | ||||
| class Game: | ||||
|     def __init__( | ||||
|         self, | ||||
|         entity_manager: EntityManager, | ||||
|         system_manager: SystemManager, | ||||
|         min_time_step: float, | ||||
|     ): | ||||
|         self.entity_manager = entity_manager | ||||
|         self.system_manager = system_manager | ||||
|         self.min_time_step = min_time_step | ||||
| 
 | ||||
|         self.last_time = time.time() | ||||
|         self.running = False | ||||
| 
 | ||||
|     async def update(self, delta_time: float) -> None: | ||||
|         self.entity_manager.update() | ||||
|         await self.system_manager.update(self.entity_manager, delta_time) | ||||
| 
 | ||||
|     async def run(self) -> None: | ||||
|         logger.info("Game started") | ||||
|         self.running = True | ||||
|         self.last_time = time.time() | ||||
|         steps_since_log = 0 | ||||
| 
 | ||||
|         while self.running: | ||||
|             current_time = time.time() | ||||
|             if (int(current_time) - int(self.last_time)) == 1 and int( | ||||
|                 current_time | ||||
|             ) % 10 == 0: | ||||
|                 logger.info(f"Game loop: {steps_since_log} steps since last log time") | ||||
|                 steps_since_log = 0 | ||||
| 
 | ||||
|             delta_time = current_time - self.last_time | ||||
|             self.last_time = current_time | ||||
| 
 | ||||
|             await self.update(delta_time) | ||||
|             sleep_time = max(self.min_time_step - (time.time() - current_time), 0) | ||||
|             if sleep_time > 0: | ||||
|                 await asyncio.sleep(sleep_time) | ||||
| 
 | ||||
|             steps_since_log += 1 | ||||
| 
 | ||||
|     def stop(self) -> None: | ||||
|         self.running = False | ||||
|         logger.info("Game stopped") | ||||
|  | @ -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 | ||||
|  | @ -0,0 +1,165 @@ | |||
| import asyncio | ||||
| from abc import abstractmethod | ||||
| from enum import Enum | ||||
| from typing import Dict, List, Optional | ||||
| 
 | ||||
| from kennel.app import logger | ||||
| from kennel.engine.entities.entity import Entity, EntityManager | ||||
| 
 | ||||
| from .system import System, SystemType | ||||
| 
 | ||||
| 
 | ||||
| class EventType(str, Enum): | ||||
|     INITIAL_STATE = "INITIAL_STATE" | ||||
|     SET_CONTROLLABLE = "SET_CONTROLLABLE" | ||||
|     ENTITY_BORN = "ENTITY_BORN" | ||||
|     ENTITY_DEATH = "ENTITY_DEATH" | ||||
|     ENTITY_POSITION_UPDATE = "ENTITY_POSITION_UPDATE" | ||||
| 
 | ||||
| 
 | ||||
| class Event: | ||||
|     def __init__(self, event_type: EventType, data: dict): | ||||
|         self.event_type = event_type | ||||
|         self.data = data | ||||
| 
 | ||||
|     def __str__(self) -> str: | ||||
|         return f"Event({self.event_type}, {self.data})" | ||||
| 
 | ||||
|     def to_dict(self) -> dict: | ||||
|         return {"event_type": self.event_type, "data": self.data} | ||||
| 
 | ||||
|     @staticmethod | ||||
|     def from_dict(data: dict) -> Optional["Event"]: | ||||
|         if "event_type" not in data or "data" not in data: | ||||
|             return | ||||
|         event_type = data["event_type"] | ||||
|         if event_type == EventType.INITIAL_STATE: | ||||
|             return InitialStateEvent( | ||||
|                 data["data"]["world"]["width"], | ||||
|                 data["data"]["world"]["height"], | ||||
|                 data["data"]["entities"], | ||||
|             ) | ||||
|         if event_type == EventType.SET_CONTROLLABLE: | ||||
|             return SetControllableEvent(data["data"]["id"], data["data"]["client_id"]) | ||||
|         if event_type == EventType.ENTITY_BORN: | ||||
|             return EntityBornEvent(data["data"]) | ||||
|         if event_type == EventType.ENTITY_POSITION_UPDATE: | ||||
|             return EntityPositionUpdateEvent( | ||||
|                 data["data"]["id"], data["data"]["position"] | ||||
|             ) | ||||
|         if event_type == EventType.ENTITY_DEATH: | ||||
|             return EntityDeathEvent(data["data"]["id"]) | ||||
| 
 | ||||
|         logger.warn(f"Unknown event type: {data['event_type']}") | ||||
|         return Event(EventType(data["event_type"]), data["data"]) | ||||
| 
 | ||||
| 
 | ||||
| class InitialStateEvent(Event): | ||||
|     def __init__( | ||||
|         self, world_width: int, world_height: int, entities: Dict[str, Entity] | ||||
|     ): | ||||
|         self.world_width = world_width | ||||
|         self.world_height = world_height | ||||
|         self.entities = entities | ||||
|         super().__init__( | ||||
|             EventType.INITIAL_STATE, | ||||
|             { | ||||
|                 "world": {"width": world_width, "height": world_height}, | ||||
|                 "entities": entities, | ||||
|             }, | ||||
|         ) | ||||
| 
 | ||||
| 
 | ||||
| class SetControllableEvent(Event): | ||||
|     def __init__(self, entity_id: str, client_id: str): | ||||
|         self.entity_id = entity_id | ||||
|         self.client_id = client_id | ||||
|         super().__init__( | ||||
|             EventType.SET_CONTROLLABLE, {"id": entity_id, "client_id": client_id} | ||||
|         ) | ||||
| 
 | ||||
| 
 | ||||
| class EntityBornEvent(Event): | ||||
|     def __init__(self, entity: Entity): | ||||
|         self.entity = entity | ||||
|         super().__init__(EventType.ENTITY_BORN, {"entity": entity.to_dict()}) | ||||
| 
 | ||||
| 
 | ||||
| class EntityPositionUpdateEvent(Event): | ||||
|     def __init__(self, entity_id: str, position: dict): | ||||
|         super().__init__( | ||||
|             EventType.ENTITY_POSITION_UPDATE, | ||||
|             {"id": entity_id, "position": position}, | ||||
|         ) | ||||
| 
 | ||||
| 
 | ||||
| class EntityDeathEvent(Event): | ||||
|     def __init__(self, entity_id: str): | ||||
|         super().__init__(EventType.ENTITY_DEATH, {"id": entity_id}) | ||||
| 
 | ||||
| 
 | ||||
| class Publishable: | ||||
|     @abstractmethod | ||||
|     async def publish(self, event: List[Event]): | ||||
|         pass | ||||
| 
 | ||||
| 
 | ||||
| class UpstreamEventProcessor: | ||||
|     @abstractmethod | ||||
|     def accept( | ||||
|         self, entity_manager: EntityManager, client_event: tuple[Event, str] | ||||
|     ) -> Optional[Event]: | ||||
|         pass | ||||
| 
 | ||||
| 
 | ||||
| class NetworkSystem(System): | ||||
|     event_processor: UpstreamEventProcessor | ||||
|     sever_events: List[Event] | ||||
|     client_upstream_events: List[tuple[Event, str]] | ||||
|     clients: Dict[str, tuple[Publishable, List[Event]]] | ||||
| 
 | ||||
|     def __init__(self, upstream_event_processor: UpstreamEventProcessor): | ||||
|         super().__init__(SystemType.NETWORK) | ||||
|         self.upstream_event_processor = upstream_event_processor | ||||
| 
 | ||||
|         self.server_events = []  # events to propogate to the entire network | ||||
|         self.client_upstream_events = []  # events that come from the clients | ||||
| 
 | ||||
|         self.clients = {} | ||||
| 
 | ||||
|     async def update(self, entity_manager: EntityManager, delta_time: float) -> None: | ||||
|         for event in self.client_upstream_events: | ||||
|             produced_event = self.upstream_event_processor.accept(entity_manager, event) | ||||
|             if produced_event is not None: | ||||
|                 self.server_events.append(produced_event) | ||||
|         promises = [ | ||||
|             client.publish(self.server_events + events) | ||||
|             for client, events in self.clients.values() | ||||
|             if len(self.server_events + events) > 0 | ||||
|         ] | ||||
|         await asyncio.gather(*promises) | ||||
| 
 | ||||
|         self.server_events = [] | ||||
|         self.client_upstream_events = [] | ||||
|         for client_id in self.clients.keys(): | ||||
|             (client, _) = self.clients[client_id] | ||||
|             self.clients[client_id] = (client, []) | ||||
| 
 | ||||
|     def server_global_event(self, event: Event) -> None: | ||||
|         self.server_events.append(event) | ||||
| 
 | ||||
|     def client_upstream_event(self, client_id: str, event: Event) -> None: | ||||
|         self.client_upstream_events.append((event, client_id)) | ||||
| 
 | ||||
|     def client_downstream_event(self, client_id: str, event: Event) -> None: | ||||
|         if client_id not in self.clients: | ||||
|             logger.info(f"client {client_id} not found") | ||||
|             return | ||||
|         (client, events) = self.clients[client_id] | ||||
|         self.clients[client_id] = (client, events + [event]) | ||||
| 
 | ||||
|     def add_client(self, client_id: str, client: Publishable) -> None: | ||||
|         self.clients[client_id] = (client, []) | ||||
| 
 | ||||
|     def remove_client(self, client_id: str) -> None: | ||||
|         del self.clients[client_id] | ||||
|  | @ -0,0 +1,34 @@ | |||
| from abc import abstractmethod | ||||
| from enum import Enum | ||||
| 
 | ||||
| from kennel.engine.entities.entity import EntityManager | ||||
| 
 | ||||
| 
 | ||||
| class SystemType(str, Enum): | ||||
|     NETWORK = "NETWORK" | ||||
|     WORLD = "WORLD" | ||||
|     MARKOV = "MARKOV" | ||||
| 
 | ||||
| 
 | ||||
| class System: | ||||
|     def __init__(self, system_type: SystemType): | ||||
|         self.system_type = system_type | ||||
| 
 | ||||
|     @abstractmethod | ||||
|     async def update(self, entity_manager: EntityManager, delta_time: float) -> None: | ||||
|         pass | ||||
| 
 | ||||
| 
 | ||||
| class SystemManager: | ||||
|     def __init__(self): | ||||
|         self.systems = {} | ||||
| 
 | ||||
|     def add_system(self, system: System) -> None: | ||||
|         self.systems[system.system_type] = system | ||||
| 
 | ||||
|     def get_system(self, system_type: SystemType) -> System: | ||||
|         return self.systems.get(system_type) | ||||
| 
 | ||||
|     async def update(self, entity_manager: EntityManager, delta_time: float) -> None: | ||||
|         for system in self.systems.values(): | ||||
|             await system.update(entity_manager, delta_time) | ||||
|  | @ -0,0 +1,24 @@ | |||
| from kennel.app import logger | ||||
| from kennel.engine.components.component import ComponentType | ||||
| from kennel.engine.entities.entity import EntityManager | ||||
| from kennel.engine.systems.system import System, SystemType | ||||
| 
 | ||||
| 
 | ||||
| class WorldSystem(System): | ||||
|     def __init__(self, width: int, height: int): | ||||
|         super().__init__(SystemType.WORLD) | ||||
|         self.width = width | ||||
|         self.height = height | ||||
| 
 | ||||
|     async def update(self, entity_manager: EntityManager, delta_time: float): | ||||
|         entities = entity_manager.get_entities_with_component(ComponentType.POSITION) | ||||
|         for entity in entities: | ||||
|             position = entity.get_component(ComponentType.POSITION) | ||||
|             if position is None: | ||||
|                 logger.error(f"Entity {entity} has no position component") | ||||
|                 continue | ||||
| 
 | ||||
|             position.x = max(0, min(self.width, position.x)) | ||||
|             position.y = max(0, min(self.height, position.y)) | ||||
| 
 | ||||
|             entity.add_component(position) | ||||
|  | @ -0,0 +1,143 @@ | |||
| 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 | ||||
| 
 | ||||
| from .app import logger | ||||
| 
 | ||||
| entity_manager = EntityManager() | ||||
| system_manager = SystemManager() | ||||
| 
 | ||||
| 
 | ||||
| class KennelEventProcessor(UpstreamEventProcessor): | ||||
|     def accept( | ||||
|         self, entity_manager: EntityManager, event: tuple[Event, str] | ||||
|     ) -> Optional[Event]: | ||||
|         client_event, client_id = event | ||||
|         if client_event.event_type == EventType.ENTITY_POSITION_UPDATE: | ||||
|             return self._process_entity_position_update( | ||||
|                 entity_manager, client_event, client_id | ||||
|             ) | ||||
| 
 | ||||
|     def _process_entity_position_update( | ||||
|         self, | ||||
|         entity_manager: EntityManager, | ||||
|         event: EntityPositionUpdateEvent, | ||||
|         client_id: str, | ||||
|     ) -> Optional[Event]: | ||||
|         entity = entity_manager.get_entity(event.data["id"]) | ||||
|         if entity is None: | ||||
|             logger.error(f"Entity(id={event.data['id']}) does not exist") | ||||
|             return | ||||
|         controllable = entity.get_component(ComponentType.CONTROLLABLE) | ||||
|         if controllable is None or controllable.by != client_id: | ||||
|             logger.error(f"Entity {entity} is not controllable by client {client_id}") | ||||
|             return | ||||
|         position = entity.get_component(ComponentType.POSITION) | ||||
|         if position is None: | ||||
|             logger.error(f"Entity {entity} has no position") | ||||
|             return | ||||
|         position.x = event.data["position"]["x"] | ||||
|         position.y = event.data["position"]["y"] | ||||
| 
 | ||||
|         entity.add_component(position) | ||||
|         entity_manager.add_entity(entity) | ||||
|         return event | ||||
| 
 | ||||
| 
 | ||||
| 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}, 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, added.spritesheet) | ||||
|                 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]: | ||||
|     laser = Laser(uuid.uuid4().hex[:10], session) | ||||
|     return [laser] | ||||
|  | @ -0,0 +1,49 @@ | |||
| from datetime import datetime | ||||
| from typing import List | ||||
| 
 | ||||
| import requests | ||||
| 
 | ||||
| 
 | ||||
| class KennelCat: | ||||
|     def __init__( | ||||
|         self, | ||||
|         id: str, | ||||
|         user_id: str, | ||||
|         name: str, | ||||
|         link: str, | ||||
|         description: str, | ||||
|         spritesheet: str, | ||||
|         created_at: datetime, | ||||
|     ): | ||||
|         self.id = id | ||||
|         self.user_id = user_id | ||||
|         self.name = name | ||||
|         self.link = link | ||||
|         self.description = description | ||||
|         self.spritesheet = spritesheet | ||||
|         self.created_at = created_at | ||||
| 
 | ||||
|     @staticmethod | ||||
|     def from_dict(dict: dict) -> "KennelCat": | ||||
|         return KennelCat( | ||||
|             id=dict["id"], | ||||
|             user_id=dict["user_id"], | ||||
|             name=dict["name"], | ||||
|             link=dict["link"], | ||||
|             description=dict["description"], | ||||
|             spritesheet=dict["spritesheet"], | ||||
|             created_at=datetime.fromisoformat(dict["created_at"]), | ||||
|         ) | ||||
| 
 | ||||
| 
 | ||||
| class KennelCatService: | ||||
|     def __init__(self, hc_endpoint: str): | ||||
|         self.hc_endpoint = hc_endpoint | ||||
| 
 | ||||
|     def get_kennel(self) -> List[KennelCat]: | ||||
|         response = requests.get(f"{self.hc_endpoint}/kennel") | ||||
|         response.raise_for_status() | ||||
|         cats = [KennelCat.from_dict(cat) for cat in response.json()] | ||||
|         for cat in cats: | ||||
|             cat.spritesheet = self.hc_endpoint + cat.spritesheet | ||||
|         return cats | ||||
							
								
								
									
										182
									
								
								kennel/main.py
								
								
								
								
							
							
						
						
									
										182
									
								
								kennel/main.py
								
								
								
								
							|  | @ -1,57 +1,151 @@ | |||
| import asyncio | ||||
| import uuid | ||||
| from typing import Annotated, List, Optional | ||||
| 
 | ||||
| import structlog | ||||
| from fastapi import FastAPI, Request, Response | ||||
| from fastapi import ( | ||||
|     Cookie, | ||||
|     Depends, | ||||
|     Request, | ||||
|     Response, | ||||
|     WebSocket, | ||||
|     WebSocketDisconnect, | ||||
|     WebSocketException, | ||||
|     status, | ||||
| ) | ||||
| from fastapi.responses import FileResponse | ||||
| from fastapi.staticfiles import StaticFiles | ||||
| from fastapi.templating import Jinja2Templates | ||||
| 
 | ||||
| app = FastAPI( | ||||
|     servers = [ | ||||
|         {"url": "https://kennel.hatecomputers.club", "description": "prod"} | ||||
|     ] | ||||
| from kennel.app import app, logger | ||||
| from kennel.config import config | ||||
| from kennel.engine.systems.network import ( | ||||
|     EntityBornEvent, | ||||
|     Event, | ||||
|     EventType, | ||||
|     InitialStateEvent, | ||||
|     Publishable, | ||||
|     SetControllableEvent, | ||||
| ) | ||||
| logger = structlog.get_logger() | ||||
| 
 | ||||
| 
 | ||||
| @app.middleware("http") | ||||
| async def logger_middleware(request: Request, call_next): | ||||
|     structlog.contextvars.clear_contextvars() | ||||
|     structlog.contextvars.bind_contextvars( | ||||
|         path=request.url.path, | ||||
|         method=request.method, | ||||
|         client_host=request.client.host, | ||||
|         request_id=str(uuid.uuid4()), | ||||
|     ) | ||||
|     response = await call_next(request) | ||||
| 
 | ||||
|     structlog.contextvars.bind_contextvars( | ||||
|         status_code=response.status_code, | ||||
| from kennel.engine.systems.system import SystemType | ||||
| from kennel.kennel import ( | ||||
|     create_session_controllable_entities, | ||||
|     entity_manager, | ||||
|     kennel, | ||||
|     kennel_cats_manager, | ||||
|     system_manager, | ||||
| ) | ||||
| 
 | ||||
|     # Exclude /healthcheck endpoint from producing logs | ||||
|     if request.url.path != "/healthcheck": | ||||
|         if 400 <= response.status_code < 500: | ||||
|             logger.warn("Client error") | ||||
|         elif response.status_code >= 500: | ||||
|             logger.error("Server error") | ||||
|         else: | ||||
|             logger.info("OK") | ||||
| app.mount("/static", StaticFiles(directory="static/dist"), name="static") | ||||
| 
 | ||||
|     return response | ||||
| 
 | ||||
| templates = Jinja2Templates(directory="templates") | ||||
| loop = asyncio.get_event_loop() | ||||
| 
 | ||||
| 
 | ||||
| @app.on_event("startup") | ||||
| 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") | ||||
| 
 | ||||
| 
 | ||||
| @app.get("/") | ||||
| async def index(request: Request): | ||||
|     return FileResponse("static/dist/index.html", media_type="text/html") | ||||
| 
 | ||||
| 
 | ||||
| @app.get("/assign") | ||||
| async def assign( | ||||
|     response: Response, | ||||
|     session: Annotated[Optional[str], Cookie()] = None, | ||||
| ): | ||||
|     if session is None: | ||||
|         session = str(uuid.uuid4().hex) | ||||
|         response.set_cookie(key="session", value=session, max_age=config.COOKIE_MAX_AGE) | ||||
|     return {"session": session} | ||||
| 
 | ||||
| 
 | ||||
| async def get_cookie_or_token( | ||||
|     websocket: WebSocket, | ||||
|     session: Annotated[str | None, Cookie()] = None, | ||||
| ): | ||||
|     if session is None: | ||||
|         raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION) | ||||
|     return session | ||||
| 
 | ||||
| 
 | ||||
| class WebSocketClient(Publishable): | ||||
|     def __init__(self, websocket: WebSocket): | ||||
|         self.websocket = websocket | ||||
| 
 | ||||
|     async def publish(self, events: List[Event]): | ||||
|         await self.websocket.send_json([event.to_dict() for event in events]) | ||||
| 
 | ||||
| 
 | ||||
| @app.websocket("/ws") | ||||
| async def websocket_endpoint( | ||||
|     websocket: WebSocket, | ||||
|     session: Annotated[str, Depends(get_cookie_or_token)], | ||||
| ): | ||||
|     await websocket.accept() | ||||
|     logger.info(f"Websocket connection established for session {session}") | ||||
| 
 | ||||
|     session_entities = create_session_controllable_entities(session) | ||||
|     logger.info(f"Creating {len(session_entities)} entities for session {session}") | ||||
|     try: | ||||
|         network_system = system_manager.get_system(SystemType.NETWORK) | ||||
|         if network_system is None: | ||||
|             raise "Network system not found" | ||||
|         network_system.add_client(session, WebSocketClient(websocket)) | ||||
| 
 | ||||
|         network_system.client_downstream_event( | ||||
|             session, | ||||
|             InitialStateEvent( | ||||
|                 config.WORLD_WIDTH, config.WORLD_HEIGHT, kennel.entity_manager.to_dict() | ||||
|             ), | ||||
|         ) | ||||
| 
 | ||||
|         for entity in session_entities: | ||||
|             logger.info(f"Adding entity {entity.id} for session {session}") | ||||
|             entity_manager.add_entity(entity) | ||||
| 
 | ||||
|             network_system.server_global_event(EntityBornEvent(entity)) | ||||
|             network_system.client_downstream_event( | ||||
|                 session, SetControllableEvent(entity.id, session) | ||||
|             ) | ||||
| 
 | ||||
|         while True: | ||||
|             message = await websocket.receive_json() | ||||
|             if not isinstance(message, list): | ||||
|                 message = [message] | ||||
|             events = [Event.from_dict(event) for event in message] | ||||
|             if not all([event is not None for event in events]): | ||||
|                 logger.info(f"Invalid events in: {message}"[:100]) | ||||
|                 continue | ||||
|             for event in events: | ||||
|                 network_system.client_upstream_event(session, event) | ||||
|     except WebSocketDisconnect: | ||||
|         logger.info(f"Websocket connection closed by client: {session}") | ||||
|     except Exception as e: | ||||
|         logger.error("Exception occurred", exc_info=e) | ||||
|     finally: | ||||
|         network_system.remove_client(session) | ||||
|         for entity in session_entities: | ||||
|             logger.info(f"Removing entity {entity.id} for session {session}") | ||||
|             entity_manager.remove_entity(entity.id) | ||||
|             network_system.add_event(Event(EventType.ENTITY_DEATH, {"id": entity.id})) | ||||
| 
 | ||||
|         await websocket.close() | ||||
|         logger.info("Websocket connection closed") | ||||
| 
 | ||||
| 
 | ||||
| @app.get("/healthcheck") | ||||
| async def healthcheck(): | ||||
|     return Response("hello") | ||||
| 
 | ||||
| 
 | ||||
| @app.get("/") | ||||
| async def read_main(request: Request): | ||||
|     return templates.TemplateResponse( | ||||
|         request=request, name="index.html" | ||||
|     ) | ||||
| 
 | ||||
| app.mount("/static", StaticFiles(directory = "static"), name = "static") | ||||
| 
 | ||||
|     return Response("healthy") | ||||
|  |  | |||
|  | @ -0,0 +1,33 @@ | |||
| import uuid | ||||
| 
 | ||||
| import structlog | ||||
| from fastapi import Request | ||||
| 
 | ||||
| from .app import app, logger | ||||
| 
 | ||||
| 
 | ||||
| @app.middleware("http") | ||||
| async def logger_middleware(request: Request, call_next): | ||||
|     structlog.contextvars.clear_contextvars() | ||||
|     structlog.contextvars.bind_contextvars( | ||||
|         path=request.url.path, | ||||
|         method=request.method, | ||||
|         client_host=request.client.host, | ||||
|         request_id=str(uuid.uuid4()), | ||||
|     ) | ||||
|     response = await call_next(request) | ||||
| 
 | ||||
|     structlog.contextvars.bind_contextvars( | ||||
|         status_code=response.status_code, | ||||
|     ) | ||||
| 
 | ||||
|     # Exclude /healthcheck endpoint from producing logs | ||||
|     if request.url.path != "/healthcheck": | ||||
|         if 400 <= response.status_code < 500: | ||||
|             logger.warn("Client error") | ||||
|         elif response.status_code >= 500: | ||||
|             logger.error("Server error") | ||||
|         else: | ||||
|             logger.info("OK") | ||||
| 
 | ||||
|     return response | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							|  | @ -11,13 +11,13 @@ python = "^3.12" | |||
| fastapi = "^0.108.0" | ||||
| structlog = "^23.2.0" | ||||
| uvicorn = { extras = ["standard"], version = "^0.25.0" } | ||||
| jinja2 = "^3.1.4" | ||||
| python-dotenv = "^1.0.1" | ||||
| websockets = "^12.0" | ||||
| requests = "^2.32.3" | ||||
| 
 | ||||
| [tool.poetry.group.dev.dependencies] | ||||
| ruff = "^0.1.9" | ||||
| httpx = "^0.26.0" | ||||
| pytest = "^7.4.3" | ||||
| pytest-cov = "^4.1.0" | ||||
| 
 | ||||
| [build-system] | ||||
| requires = ["poetry-core"] | ||||
|  |  | |||
|  | @ -0,0 +1,24 @@ | |||
| # Logs | ||||
| logs | ||||
| *.log | ||||
| npm-debug.log* | ||||
| yarn-debug.log* | ||||
| yarn-error.log* | ||||
| pnpm-debug.log* | ||||
| lerna-debug.log* | ||||
| 
 | ||||
| node_modules | ||||
| dist | ||||
| dist-ssr | ||||
| *.local | ||||
| 
 | ||||
| # Editor directories and files | ||||
| .vscode/* | ||||
| !.vscode/extensions.json | ||||
| .idea | ||||
| .DS_Store | ||||
| *.suo | ||||
| *.ntvs* | ||||
| *.njsproj | ||||
| *.sln | ||||
| *.sw? | ||||
|  | @ -0,0 +1,22 @@ | |||
| <!DOCTYPE html> | ||||
| <html lang="en"> | ||||
| <head> | ||||
|   <meta charset="UTF-8"> | ||||
|   <link rel="icon" href= | ||||
|   "data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🐶</text></svg>"> | ||||
|   <meta name="viewport" content= | ||||
|   "width=device-width, initial-scale=1.0"> | ||||
|   <title>the kennel.</title> | ||||
| </head> | ||||
| <body> | ||||
|   <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> | ||||
|  | @ -1,5 +0,0 @@ | |||
| window.onload = () => { | ||||
|     console.log('from js'); | ||||
|     const kennelWindowEle = document.querySelector('#kennel-window'); | ||||
|     kennelWindowEle.innerHTML = 'rendered from static/index.js'; | ||||
| } | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							|  | @ -0,0 +1,23 @@ | |||
| { | ||||
|   "name": "kennel", | ||||
|   "private": true, | ||||
|   "version": "0.1.0", | ||||
|   "type": "module", | ||||
|   "scripts": { | ||||
|     "build": "tsc && vite build", | ||||
|     "preview": "vite preview", | ||||
|     "dev": "./node_modules/nodemon/bin/nodemon.js --watch './src/**/*' -e ts,html --exec \"npm run build\"" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@rollup/plugin-inject": "^5.0.5", | ||||
|     "@types/jquery": "^3.5.30", | ||||
|     "nodemon": "^3.1.4", | ||||
|     "typescript": "^5.5.3", | ||||
|     "vite": "^5.4.1", | ||||
|     "vite-plugin-dynamic-base": "^1.1.0" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "jquery": "^3.7.1", | ||||
|     "laser-pen": "^1.0.1" | ||||
|   } | ||||
| } | ||||
|  | @ -0,0 +1 @@ | |||
| <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg> | ||||
| After Width: | Height: | Size: 1.5 KiB | 
|  | @ -0,0 +1,46 @@ | |||
| export enum ComponentType { | ||||
|   POSITION = "POSITION", | ||||
|   RENDERABLE = "RENDERABLE", | ||||
|   TRAILING_POSITION = "TRAILING_POSITION", | ||||
|   SPRITESHEET = "SPRITESHEET", | ||||
| } | ||||
| 
 | ||||
| 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; | ||||
| } | ||||
| 
 | ||||
| export interface SpriteSpec { | ||||
|   ms_per_frame: number; | ||||
|   frames: number; | ||||
|   top_x: number; | ||||
|   top_y: number; | ||||
|   end_x: number; | ||||
|   end_y: number; | ||||
| } | ||||
| 
 | ||||
| export interface SpriteSheetComponent extends Component { | ||||
|   name: ComponentType.SPRITESHEET; | ||||
|   source: string; | ||||
|   sheet?: HTMLImageElement; | ||||
| 
 | ||||
|   current_frame: number; | ||||
|   last_update: number; | ||||
|   current_state: string; | ||||
| 
 | ||||
|   state_to_spritespec: Record<string, SpriteSpec>; | ||||
| } | ||||
|  | @ -0,0 +1,46 @@ | |||
| export class DebouncePublisher<T> { | ||||
|   private last_event_time = Date.now(); | ||||
|   private unpublished_data: T | undefined; | ||||
|   private interval_id: number | undefined; | ||||
| 
 | ||||
|   constructor( | ||||
|     private readonly publisher: (data: T) => void | Promise<void>, | ||||
|     private readonly debounce_ms = 100, | ||||
|   ) {} | ||||
| 
 | ||||
|   public start() { | ||||
|     if (typeof this.interval_id !== "undefined") { | ||||
|       return; | ||||
|     } | ||||
|     this.interval_id = setInterval( | ||||
|       () => this.debounce_publish(), | ||||
|       this.debounce_ms, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   public stop() { | ||||
|     if (this.interval_id === null) { | ||||
|       return; | ||||
|     } | ||||
|     clearInterval(this.interval_id); | ||||
|     delete this.interval_id; | ||||
|   } | ||||
| 
 | ||||
|   public update(data: T) { | ||||
|     this.unpublished_data = data; | ||||
|     this.debounce_publish(); | ||||
|   } | ||||
| 
 | ||||
|   private debounce_publish() { | ||||
|     if ( | ||||
|       Date.now() - this.last_event_time < this.debounce_ms || | ||||
|       typeof this.unpublished_data === "undefined" | ||||
|     ) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     this.last_event_time = Date.now(); | ||||
|     this.publisher(this.unpublished_data); | ||||
|     this.unpublished_data = undefined; | ||||
|   } | ||||
| } | ||||
|  | @ -0,0 +1,39 @@ | |||
| import { | ||||
|   Component, | ||||
|   ComponentType, | ||||
|   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; | ||||
|   return base; | ||||
| }; | ||||
|  | @ -0,0 +1,107 @@ | |||
| import { Entity } from "./entity"; | ||||
| export enum EventType { | ||||
|   INITIAL_STATE = "INITIAL_STATE", | ||||
|   SET_CONTROLLABLE = "SET_CONTROLLABLE", | ||||
|   ENTITY_BORN = "ENTITY_BORN", | ||||
|   ENTITY_DEATH = "ENTITY_DEATH", | ||||
|   ENTITY_POSITION_UPDATE = "ENTITY_POSITION_UPDATE", | ||||
| } | ||||
| 
 | ||||
| export interface Event { | ||||
|   event_type: EventType; | ||||
|   data: any; | ||||
| } | ||||
| 
 | ||||
| export interface EntityPositionUpdateEvent extends Event { | ||||
|   event_type: EventType.ENTITY_POSITION_UPDATE; | ||||
|   data: { | ||||
|     id: string; | ||||
|     position: { | ||||
|       x: number; | ||||
|       y: number; | ||||
|     }; | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| export interface InitialStateEvent extends Event { | ||||
|   event_type: EventType.INITIAL_STATE; | ||||
|   data: { | ||||
|     world: { width: number; height: number }; | ||||
|     entities: Record<string, Entity>; | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| export interface SetControllableEvent extends Event { | ||||
|   event_type: EventType.SET_CONTROLLABLE; | ||||
|   data: { | ||||
|     id: string; | ||||
|     client_id: string; | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| export interface EntityBornEvent extends Event { | ||||
|   event_type: EventType.ENTITY_BORN; | ||||
|   data: { | ||||
|     entity: Entity; | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| export interface EntityDeathEvent extends Event { | ||||
|   event_type: EventType.ENTITY_DEATH; | ||||
|   data: { | ||||
|     id: string; | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| export interface EventQueue { | ||||
|   peek(): Event[]; | ||||
|   clear(): void; | ||||
| } | ||||
| 
 | ||||
| export interface EventPublisher { | ||||
|   add(event: Event): void; | ||||
|   publish(): void; | ||||
| } | ||||
| 
 | ||||
| export class WebSocketEventQueue implements EventQueue { | ||||
|   private queue: Event[]; | ||||
| 
 | ||||
|   constructor(websocket: WebSocket) { | ||||
|     this.queue = []; | ||||
|     this.listen_to(websocket); | ||||
|   } | ||||
| 
 | ||||
|   public peek() { | ||||
|     return this.queue; | ||||
|   } | ||||
| 
 | ||||
|   public clear() { | ||||
|     this.queue = []; | ||||
|   } | ||||
| 
 | ||||
|   private listen_to(websocket: WebSocket) { | ||||
|     websocket.onmessage = ({ data }) => { | ||||
|       this.queue = this.queue.concat(JSON.parse(data)); | ||||
|     }; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export class WebsocketEventPublisher implements EventPublisher { | ||||
|   private queue: Event[]; | ||||
| 
 | ||||
|   constructor(private readonly websocket: WebSocket) { | ||||
|     this.queue = []; | ||||
|   } | ||||
| 
 | ||||
|   public add(event: Event) { | ||||
|     this.queue.push(event); | ||||
|   } | ||||
| 
 | ||||
|   public publish() { | ||||
|     if (this.queue.length === 0) { | ||||
|       return; | ||||
|     } | ||||
|     this.websocket.send(JSON.stringify(this.queue)); | ||||
|     this.queue = []; | ||||
|   } | ||||
| } | ||||
|  | @ -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,120 @@ | |||
| import { | ||||
|   ComponentType, | ||||
|   PositionComponent, | ||||
|   TrailingPositionComponent, | ||||
|   SpriteSheetComponent, | ||||
|   SpriteSpec, | ||||
| } 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 && | ||||
|         ComponentType.SPRITESHEET in entity.components | ||||
|       ) { | ||||
|         const position = entity.components[ | ||||
|           ComponentType.POSITION | ||||
|         ] as PositionComponent; | ||||
|         const spritesheet = entity.components[ | ||||
|           ComponentType.SPRITESHEET | ||||
|         ] as SpriteSheetComponent; | ||||
| 
 | ||||
|         if (typeof spritesheet.sheet === "undefined") { | ||||
|           const img = new Image(); | ||||
|           img.src = spritesheet.source; | ||||
|           spritesheet.sheet = img; | ||||
|         } | ||||
|         const spritespec = | ||||
|           spritesheet.state_to_spritespec[spritesheet.current_state]; | ||||
| 
 | ||||
|         this.blit_sprite(ctx, spritesheet, position); | ||||
| 
 | ||||
|         spritesheet.last_update += dt; | ||||
|         if (spritesheet.last_update > spritespec.ms_per_frame) { | ||||
|           spritesheet.current_frame++; | ||||
|           spritesheet.current_frame %= spritespec.frames; | ||||
|           spritesheet.last_update = 0; | ||||
|         } | ||||
| 
 | ||||
|         return; | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   private blit_sprite( | ||||
|     ctx: CanvasRenderingContext2D, | ||||
|     spritesheet: SpriteSheetComponent, | ||||
|     position: { x: number; y: number }, | ||||
|   ) { | ||||
|     ctx.save(); | ||||
|     ctx.translate(position.x, position.y); | ||||
|     ctx.translate(-position.x, -position.y); | ||||
| 
 | ||||
|     if (typeof spritesheet.sheet === "undefined") return; | ||||
| 
 | ||||
|     const spritespec = | ||||
|       spritesheet.state_to_spritespec[spritesheet.current_state]; | ||||
| 
 | ||||
|     ctx.drawImage( | ||||
|       spritesheet.sheet, | ||||
|       ...this.get_sprite_args(spritesheet.current_frame, spritespec), | ||||
|       ...this.get_draw_args(spritespec, position), | ||||
|     ); | ||||
| 
 | ||||
|     ctx.restore(); | ||||
|   } | ||||
| 
 | ||||
|   private get_sprite_args( | ||||
|     current_frame: number, | ||||
|     sprite_spec: SpriteSpec, | ||||
|   ): [sx: number, sy: number, sw: number, sh: number] { | ||||
|     const [width, height] = this.get_dimensions(sprite_spec); | ||||
|     return [ | ||||
|       sprite_spec.top_x, | ||||
|       sprite_spec.top_y + current_frame * height, | ||||
|       width, | ||||
|       height, | ||||
|     ]; | ||||
|   } | ||||
| 
 | ||||
|   private get_draw_args( | ||||
|     sprite_spec: SpriteSpec, | ||||
|     position: { x: number; y: number }, | ||||
|   ): [dx: number, dy: number, dw: number, dh: number] { | ||||
|     const [width, height] = this.get_dimensions(sprite_spec); | ||||
|     return [position.x - width / 2, position.y - height / 2, width, height]; | ||||
|   } | ||||
| 
 | ||||
|   private get_dimensions(sprite_spec: SpriteSpec): [number, number] { | ||||
|     return [ | ||||
|       sprite_spec.end_x - sprite_spec.top_x, | ||||
|       (sprite_spec.end_y - sprite_spec.top_y) / sprite_spec.frames, | ||||
|     ]; | ||||
|   } | ||||
| } | ||||
|  | @ -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,29 @@ | |||
| import { ComponentType, TrailingPositionComponent } from "./component"; | ||||
| import { Game } from "./game"; | ||||
| import { System, SystemType } from "./system"; | ||||
| 
 | ||||
| interface Point { | ||||
|   x: number; | ||||
|   y: number; | ||||
|   time: number; | ||||
| } | ||||
| 
 | ||||
| export class TrailingPositionSystem extends System { | ||||
|   constructor( | ||||
|     private readonly point_filter: (trail_point: Array<Point>) => Array<Point> | ||||
|   ) { | ||||
|     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); | ||||
|       } | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | @ -0,0 +1,51 @@ | |||
| import $ from "jquery"; | ||||
| import { | ||||
|   EventPublisher, | ||||
|   EventQueue, | ||||
|   WebsocketEventPublisher, | ||||
|   WebSocketEventQueue, | ||||
| } 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, setRoundCap } from "laser-pen"; | ||||
| 
 | ||||
| $(async () => { | ||||
|   const client_id = await fetch("/assign", { | ||||
|     credentials: "include", | ||||
|   }) | ||||
|     .then((res) => res.json()) | ||||
|     .then(({ session }) => session); | ||||
| 
 | ||||
|   const ws = new WebSocket("/ws"); | ||||
|   await new Promise<void>((resolve) => { | ||||
|     ws.onopen = () => resolve(); | ||||
|   }); | ||||
| 
 | ||||
|   const queue: EventQueue = new WebSocketEventQueue(ws); | ||||
|   const publisher: EventPublisher = new WebsocketEventPublisher(ws); | ||||
|   const network_system = new NetworkSystem(queue, publisher); | ||||
| 
 | ||||
|   const gamecanvas = $("#gamecanvas").get(0)! as HTMLCanvasElement; | ||||
|   const input_system = new InputSystem(publisher, gamecanvas); | ||||
| 
 | ||||
|   setDelay(500); | ||||
|   setRoundCap(true); | ||||
|   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(); | ||||
| }); | ||||
|  | @ -0,0 +1,3 @@ | |||
| h1 { | ||||
|   color: red; | ||||
| } | ||||
|  | @ -0,0 +1 @@ | |||
| /// <reference types="vite/client" />
 | ||||
|  | @ -0,0 +1,23 @@ | |||
| { | ||||
|   "compilerOptions": { | ||||
|     "target": "ES2022", | ||||
|     "useDefineForClassFields": true, | ||||
|     "module": "ESNext", | ||||
|     "lib": ["ES2022", "DOM", "DOM.Iterable"], | ||||
|     "skipLibCheck": true, | ||||
| 
 | ||||
|     /* Bundler mode */ | ||||
|     "moduleResolution": "bundler", | ||||
|     "allowImportingTsExtensions": true, | ||||
|     "isolatedModules": true, | ||||
|     "moduleDetection": "force", | ||||
|     "noEmit": true, | ||||
| 
 | ||||
|     /* Linting */ | ||||
|     "strict": true, | ||||
|     "noUnusedLocals": true, | ||||
|     "noUnusedParameters": true, | ||||
|     "noFallthroughCasesInSwitch": true | ||||
|   }, | ||||
|   "include": ["src"] | ||||
| } | ||||
|  | @ -0,0 +1,27 @@ | |||
| import { fileURLToPath, URL } from "node:url"; | ||||
| import { dynamicBase } from "vite-plugin-dynamic-base"; | ||||
| import { defineConfig } from "vite"; | ||||
| import inject from "@rollup/plugin-inject"; | ||||
| 
 | ||||
| // https://vitejs.dev/config/
 | ||||
| export default defineConfig({ | ||||
|   plugins: [ | ||||
|     dynamicBase({ | ||||
|       // dynamic public path var string, default window.__dynamic_base__
 | ||||
|       publicPath: "", | ||||
|       // dynamic load resources on index.html, default false. maybe change default true
 | ||||
|       transformIndexHtml: false, | ||||
|     }), | ||||
|     inject({ | ||||
|       // => that should be first under plugins array
 | ||||
|       $: "jquery", | ||||
|       jQuery: "jquery", | ||||
|     }), | ||||
|   ], | ||||
|   resolve: { | ||||
|     alias: { | ||||
|       "@": fileURLToPath(new URL("./src", import.meta.url)), | ||||
|     }, | ||||
|   }, | ||||
|   base: "/static/", | ||||
| }); | ||||
|  | @ -1,14 +0,0 @@ | |||
| <!DOCTYPE html> | ||||
| <html lang="en"> | ||||
|   <head> | ||||
|     <meta charset="UTF-8"> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||
|     <meta http-equiv="X-UA-Compatible" content="ie=edge"> | ||||
|     <title>Kennel Club</title> | ||||
|   </head> | ||||
|   <body> | ||||
|     <div id="kennel-window"></div> | ||||
|     <script src="{{ url_for('static', path='/index.js') }}"></script> | ||||
|   </body> | ||||
| </html> | ||||
| 
 | ||||
|  | @ -1,18 +0,0 @@ | |||
| from http import HTTPStatus | ||||
| 
 | ||||
| from fastapi.testclient import TestClient | ||||
| from kennel.main import app | ||||
| from structlog.testing import capture_logs | ||||
| 
 | ||||
| client = TestClient(app) | ||||
| 
 | ||||
| 
 | ||||
| def test_healthcheck(): | ||||
|     response = client.get("/healthcheck") | ||||
|     assert response.status_code == HTTPStatus.OK | ||||
|     assert response.text == "hello" | ||||
| 
 | ||||
| def test_main(): | ||||
|     response = client.get("/") | ||||
|     assert response.status_code == HTTPStatus.OK | ||||
|     assert response.text.startswith("<!DOCTYPE html>") | ||||
		Loading…
	
		Reference in New Issue