WIP: ECS / Network System #1
			
				
			
		
		
		
	|  | @ -12,5 +12,5 @@ class Component: | |||
|         self.component_type = component_type | ||||
| 
 | ||||
|     @abstractmethod | ||||
|     def dict(self) -> dict: | ||||
|     def to_dict(self) -> dict: | ||||
|         pass | ||||
|  |  | |||
|  | @ -9,6 +9,6 @@ class Controllable(Component): | |||
|     def __repr__(self) -> str: | ||||
|         return f"Controllable(by={self.by})" | ||||
| 
 | ||||
|     def dict(self) -> dict: | ||||
|     def to_dict(self) -> dict: | ||||
|         # don't serialize who owns this | ||||
|         return {} | ||||
|  |  | |||
|  | @ -10,5 +10,5 @@ class Position(Component): | |||
|     def __repr__(self) -> str: | ||||
|         return f"Position(x={self.x}, y={self.y})" | ||||
| 
 | ||||
|     def dict(self) -> dict: | ||||
|     def to_dict(self) -> dict: | ||||
|         return {"x": self.x, "y": self.y} | ||||
|  |  | |||
|  | @ -27,7 +27,7 @@ class Entity: | |||
|         return { | ||||
|             "entity_type": self.entity_type, | ||||
|             "id": self.id, | ||||
|             "components": {k: v.dict() for k, v in self.components.items()}, | ||||
|             "components": {k: v.to_dict() for k, v in self.components.items()}, | ||||
|         } | ||||
| 
 | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,15 +1,17 @@ | |||
| from enum import Enum | ||||
| from kennel.engine.entities.entity import EntityManager | ||||
| from kennel.engine.entities.entity import Entity, EntityType, EntityManager | ||||
| from .system import System, SystemType | ||||
| from abc import abstractmethod | ||||
| from typing import Optional, List | ||||
| import asyncio | ||||
| 
 | ||||
| 
 | ||||
| class EventType(str, Enum): | ||||
|     INITIAL_STATE = "INITIAL_STATE" | ||||
|     SET_CONTROLLABLE = "SET_CONTROLLABLE" | ||||
|     ENTITY_BORN = "ENTITY_BORN" | ||||
|     ENTITY_POSITION_UPDATE = "ENTITY_POSITION_UPDATE" | ||||
|     ENTITY_DEATH = "ENTITY_DEATH" | ||||
|     ENTITY_POSITION_UPDATE = "ENTITY_POSITION_UPDATE" | ||||
| 
 | ||||
| 
 | ||||
| class Event: | ||||
|  | @ -17,29 +19,86 @@ class Event: | |||
|         self.event_type = event_type | ||||
|         self.data = data | ||||
| 
 | ||||
|     def __str__(self): | ||||
|     def __str__(self) -> str: | ||||
|         return f"Event({self.event_type}, {self.data})" | ||||
| 
 | ||||
|     def dict(self): | ||||
|     def to_dict(self) -> dict: | ||||
|         return {"event_type": self.event_type, "data": self.data} | ||||
| 
 | ||||
|     @staticmethod | ||||
|     def from_dict(data: dict): | ||||
|     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: List[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": [entity.to_dict() for entity in 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: Event): | ||||
|         pass | ||||
| 
 | ||||
| 
 | ||||
| class ClientEventTransformer: | ||||
|     @abstractmethod | ||||
|     def apply(self, event: Event) -> Event: | ||||
|         pass | ||||
| 
 | ||||
| 
 | ||||
| class EventProcessor: | ||||
|     @abstractmethod | ||||
|     def accept(self, entity_manager: EntityManager, event: Event) -> None: | ||||
|  | @ -47,37 +106,32 @@ class EventProcessor: | |||
| 
 | ||||
| 
 | ||||
| class NetworkSystem(System): | ||||
|     def __init__( | ||||
|         self, | ||||
|         event_processor: EventProcessor, | ||||
|         client_event_transformer: ClientEventTransformer, | ||||
|     ): | ||||
|     def __init__(self, event_processor: EventProcessor): | ||||
|         super().__init__(SystemType.NETWORK) | ||||
|         self.event_processor = event_processor | ||||
|         self.client_event_transformer = client_event_transformer | ||||
| 
 | ||||
|         self.events = [] | ||||
|         self.client_events = [] | ||||
|         self.clients = {} | ||||
| 
 | ||||
|     async def update(self, entity_manager: EntityManager, delta_time: float) -> None: | ||||
|         for event in self.events: | ||||
|         if len(self.events) + len(self.client_events) == 0: | ||||
|             return | ||||
|         for event in self.events + self.client_events: | ||||
|             self.event_processor.accept(entity_manager, event) | ||||
|         client_events = [ | ||||
|             self.client_event_transformer.apply(entity_manager, event) | ||||
|             for event in self.events | ||||
|         ] | ||||
|         client_sendable = self.events + [event for event, _ in self.client_events] | ||||
|         await asyncio.gather( | ||||
|             *[ | ||||
|                 client.publish(event) | ||||
|                 for client in self.clients.values() | ||||
|                 for event in client_events | ||||
|                 for event in client_sendable | ||||
|             ] | ||||
|         ) | ||||
|         self.events = [] | ||||
|         self.client_events = [] | ||||
| 
 | ||||
|     def client_event(self, client_id: str, event: Event) -> None: | ||||
|         event.data["client_id"] = client_id | ||||
|         self.events.append(event) | ||||
|         self.client_events.append((event, client_id)) | ||||
| 
 | ||||
|     def add_event(self, event: Event) -> None: | ||||
|         self.events.append(event) | ||||
|  |  | |||
|  | @ -6,7 +6,6 @@ from kennel.engine.systems.system import SystemManager | |||
| from kennel.engine.systems.network import ( | ||||
|     NetworkSystem, | ||||
|     EventProcessor, | ||||
|     ClientEventTransformer, | ||||
|     Event, | ||||
|     EventType, | ||||
| ) | ||||
|  | @ -21,28 +20,33 @@ system_manager = SystemManager() | |||
| 
 | ||||
| 
 | ||||
| class KennelEventProcessor(EventProcessor): | ||||
|     def accept(self, entity_manager: EntityManager, event: Event) -> None: | ||||
|     def accept( | ||||
|         self, entity_manager: EntityManager, event: type[Event | tuple[Event, str]] | ||||
|     ) -> None: | ||||
|         if isinstance(event, tuple): | ||||
|             client_event, client_id = event | ||||
|             self._process_client_event(entity_manager, client_event, client_id) | ||||
|             return | ||||
| 
 | ||||
|     def _process_client_event( | ||||
|         self, entity_manager: EntityManager, event: Event, client_id: str | ||||
|     ) -> None: | ||||
|         if event.event_type == EventType.ENTITY_POSITION_UPDATE: | ||||
|             self._process_entity_position_update(entity_manager, event) | ||||
|             self._process_entity_position_update(entity_manager, event, client_id) | ||||
| 
 | ||||
|     def _process_entity_position_update( | ||||
|         self, entity_manager: EntityManager, event: Event | ||||
|         self, entity_manager: EntityManager, event: Event, client_id: str | ||||
|     ) -> None: | ||||
|         entity = entity_manager.get_entity(event.data["entity_id"]) | ||||
|         entity = entity_manager.get_entity(event.data["id"]) | ||||
|         if entity is None: | ||||
|             logger.error(f"Entity {event.data['entity_id']} does not exist") | ||||
|             return | ||||
|         if event.data["client_id"] is None: | ||||
|             logger.error("Client ID is required for position updates") | ||||
|             logger.error(f"Entity(id={event.data['id']}) does not exist") | ||||
|             return | ||||
|         controllable = entity.get_component(ComponentType.CONTROLLABLE) | ||||
|         if controllable is None: | ||||
|             logger.error(f"Entity {entity} is not controllable") | ||||
|             return | ||||
|         if controllable.by != event.data["client_id"]: | ||||
|             logger.error( | ||||
|                 f"Entity {entity} is not controllable by client {event.data['client_id']}" | ||||
|             ) | ||||
|         if 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: | ||||
|  | @ -51,30 +55,14 @@ class KennelEventProcessor(EventProcessor): | |||
|         entity.add_component(position) | ||||
| 
 | ||||
| 
 | ||||
| class KennelClientEventTransformer(ClientEventTransformer): | ||||
|     def apply(self, entity_manager: EntityManager, event: Event) -> Event: | ||||
|         if event.event_type == EventType.ENTITY_BORN: | ||||
|             id = event.data["id"] | ||||
|             entity = entity_manager.get_entity(id) | ||||
|             if entity is None: | ||||
|                 logger.error(f"Entity {id} does not exist") | ||||
|                 return event | ||||
|             for component_type in entity.components: | ||||
|                 component = entity.get_component(component_type) | ||||
|                 if component is not None: | ||||
|                     event.data[component_type] = component.dict() | ||||
| 
 | ||||
|         return event | ||||
| 
 | ||||
| 
 | ||||
| system_manager.add_system(WorldSystem(config.WORLD_WIDTH, config.WORLD_HEIGHT)) | ||||
| system_manager.add_system( | ||||
|     NetworkSystem(EventProcessor(), KennelClientEventTransformer()), | ||||
|     NetworkSystem(KennelEventProcessor()), | ||||
| ) | ||||
| 
 | ||||
| kennel = Game(entity_manager, system_manager, config.MIN_TIME_STEP) | ||||
| 
 | ||||
| 
 | ||||
| def create_session_controllable_entities(session: str) -> List[Entity]: | ||||
|     laser = Laser(uuid.uuid4().hex, session) | ||||
|     laser = Laser(uuid.uuid4().hex[:10], session) | ||||
|     return [laser] | ||||
|  |  | |||
|  | @ -11,7 +11,14 @@ from fastapi import ( | |||
| from fastapi.responses import FileResponse | ||||
| from fastapi.staticfiles import StaticFiles | ||||
| from kennel.engine.systems.system import SystemType | ||||
| from kennel.engine.systems.network import Event, Publishable, EventType | ||||
| from kennel.engine.systems.network import ( | ||||
|     Event, | ||||
|     Publishable, | ||||
|     EventType, | ||||
|     InitialStateEvent, | ||||
|     SetControllableEvent, | ||||
|     EntityBornEvent, | ||||
| ) | ||||
| from typing import Annotated, Optional | ||||
| from kennel.kennel import ( | ||||
|     kennel, | ||||
|  | @ -76,7 +83,7 @@ class WebSocketClient(Publishable): | |||
|         self.websocket = websocket | ||||
| 
 | ||||
|     async def publish(self, event: Event): | ||||
|         await self.websocket.send_json(event.dict()) | ||||
|         await self.websocket.send_json([event.event_type, event.data]) | ||||
| 
 | ||||
| 
 | ||||
| @app.websocket("/ws") | ||||
|  | @ -85,19 +92,16 @@ async def websocket_endpoint( | |||
|     session: Annotated[str, Depends(get_cookie_or_token)], | ||||
| ): | ||||
|     await websocket.accept() | ||||
|     client = WebSocketClient(websocket) | ||||
|     logger.info(f"Websocket connection established for session {session}") | ||||
| 
 | ||||
|     await websocket.send_json( | ||||
|         { | ||||
|             "event_type": EventType.INITIAL_STATE, | ||||
|             "data": { | ||||
|                 "world": {"width": config.WORLD_WIDTH, "height": config.WORLD_HEIGHT}, | ||||
|                 "entities": kennel.entity_manager.to_dict(), | ||||
|             }, | ||||
|         } | ||||
|     initial_state = InitialStateEvent( | ||||
|         config.WORLD_WIDTH, config.WORLD_HEIGHT, kennel.entity_manager.to_dict() | ||||
|     ) | ||||
|     await client.publish(initial_state) | ||||
| 
 | ||||
|     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: | ||||
|  | @ -106,14 +110,26 @@ async def websocket_endpoint( | |||
|         for entity in session_entities: | ||||
|             logger.info(f"Adding entity {entity.id} for session {session}") | ||||
|             entity_manager.add_entity(entity) | ||||
|             network_system.add_event(Event(EventType.ENTITY_BORN, {"id": entity.id})) | ||||
|             network_system.add_event(EntityBornEvent(entity)) | ||||
| 
 | ||||
|             set_controllable_event = SetControllableEvent(entity.id, session) | ||||
|             await client.publish(set_controllable_event) | ||||
| 
 | ||||
|         network_system.add_client(session, WebSocketClient(websocket)) | ||||
|         while True: | ||||
|             message = await websocket.receive_json() | ||||
|             network_system.client_event(session, Event.from_dict(message)) | ||||
|             if type(message) is not 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_event(session, event) | ||||
|     except WebSocketDisconnect as e: | ||||
|         logger.info(f"Websocket connection closed by client: {session}") | ||||
|     except Exception as e: | ||||
|         logger.error(f"WebSocket exception {e}") | ||||
|         logger.error("Exception occurred", exc_info=e) | ||||
|     finally: | ||||
|         logger.info("Websocket connection closed") | ||||
|         for entity in session_entities: | ||||
|  |  | |||
|  | @ -0,0 +1,8 @@ | |||
| export enum ComponentType { | ||||
|   POSITION = "POSITION", | ||||
|   RENDERABLE = "RENDERABLE", | ||||
| } | ||||
| 
 | ||||
| export interface Component { | ||||
|   name: string; | ||||
| } | ||||
|  | @ -0,0 +1,11 @@ | |||
| import type { Component } from "./component"; | ||||
| 
 | ||||
| export enum EntityType { | ||||
|   LASER = "LASER", | ||||
| } | ||||
| 
 | ||||
| export interface Entity { | ||||
|   entity_type: EntityType; | ||||
|   id: string; | ||||
|   components: Record<string, Component>; | ||||
| } | ||||
|  | @ -0,0 +1,28 @@ | |||
| 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 InitialStateEvent extends Event { | ||||
|   event_type: EventType.INITIAL_STATE; | ||||
|   data: { | ||||
|     world: { width: number; height: number }; | ||||
|     entities: any[]; | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| export interface SetControllableEvent extends Event { | ||||
|   event_type: EventType.SET_CONTROLLABLE; | ||||
|   data: { | ||||
|     id: string; | ||||
|     client_id: string; | ||||
|   }; | ||||
| } | ||||
|  | @ -1,18 +1,51 @@ | |||
| import $ from "jquery"; | ||||
| import { Vec2 } from "./vector"; | ||||
| import { EventType, type SetControllableEvent, type Event } from "./event"; | ||||
| import { MouseController } from "./mouse_controller"; | ||||
| 
 | ||||
| $(document).ready(async () => { | ||||
|   await fetch("/assign", { | ||||
|   const session_id = await fetch("/assign", { | ||||
|     credentials: "include", | ||||
|   }) | ||||
|     .then((res) => res.json()) | ||||
|     .then(({ session }) => session); | ||||
| 
 | ||||
|   const controllable_entities = new Set<string>(); | ||||
|   const control_callback = (movement: Vec2) => { | ||||
|     for (const id of controllable_entities) { | ||||
|       const message = JSON.stringify({ | ||||
|         event_type: EventType.ENTITY_POSITION_UPDATE, | ||||
|         data: { id, position: movement }, | ||||
|       }); | ||||
|       ws.send(message); | ||||
|     } | ||||
|   }; | ||||
|   const mouse_controller = new MouseController(control_callback); | ||||
|   $(document).on("mousemove", (event) => { | ||||
|     mouse_controller.move(event.clientX, event.clientY); | ||||
|   }); | ||||
|   mouse_controller.start(); | ||||
| 
 | ||||
|   const ws = new WebSocket("/ws"); | ||||
|   ws.onopen = () => { | ||||
|     console.log("connected"); | ||||
|   }; | ||||
|   await new Promise<void>((resolve) => { | ||||
|     ws.onopen = () => { | ||||
|       console.log("connected"); | ||||
|       resolve(); | ||||
|     }; | ||||
|   }); | ||||
|   ws.onmessage = ({ data }) => { | ||||
|     console.log(JSON.parse(data)); | ||||
|     const [event_type, event_data] = JSON.parse(data); | ||||
|     const message = { event_type, data: event_data } as Event; | ||||
| 
 | ||||
|     console.log("Received message", message); | ||||
|     if (message.event_type === EventType.SET_CONTROLLABLE) { | ||||
|       const event = message as SetControllableEvent; | ||||
|       if (event.data.client_id === session_id) { | ||||
|         controllable_entities.add(event.data.id); | ||||
|       } | ||||
|     } | ||||
|   }; | ||||
|   ws.onclose = (e) => { | ||||
|     console.log("disconnected", e); | ||||
|   ws.onclose = () => { | ||||
|     controllable_entities.clear(); | ||||
|   }; | ||||
| }); | ||||
|  |  | |||
|  | @ -0,0 +1,54 @@ | |||
| import { Vec2 } from "./vector"; | ||||
| 
 | ||||
| export class MouseController { | ||||
|   private readonly debounce_ms = 400; | ||||
|   private readonly movement_threshold = 40; | ||||
|   private last_event_time = Date.now(); | ||||
|   private movement_queue: Vec2[] = []; | ||||
|   private interval_id: number | null = null; | ||||
| 
 | ||||
|   constructor(private readonly callback: (new_movement: Vec2) => void) {} | ||||
| 
 | ||||
|   public start() { | ||||
|     if (this.interval_id !== null) { | ||||
|       return; | ||||
|     } | ||||
|     this.interval_id = setInterval(() => { | ||||
|       this.publish_movement(); | ||||
|     }, this.debounce_ms); | ||||
|   } | ||||
| 
 | ||||
|   public stop() { | ||||
|     if (this.interval_id === null) { | ||||
|       return; | ||||
|     } | ||||
|     clearInterval(this.interval_id); | ||||
|     this.interval_id = null; | ||||
|   } | ||||
| 
 | ||||
|   public move(x: number, y: number) { | ||||
|     const new_movement = new Vec2(x, y); | ||||
|     const last_movement = this.movement_queue.at(-1); | ||||
|     this.movement_queue.push(new_movement); | ||||
|     if ( | ||||
|       typeof last_movement === "undefined" || | ||||
|       new_movement.distance_to(last_movement) < this.movement_threshold | ||||
|     ) { | ||||
|       return; | ||||
|     } | ||||
|     this.publish_movement(); | ||||
|   } | ||||
| 
 | ||||
|   private publish_movement() { | ||||
|     if ( | ||||
|       Date.now() - this.last_event_time < this.debounce_ms || | ||||
|       this.movement_queue.length === 0 | ||||
|     ) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     this.last_event_time = Date.now(); | ||||
|     this.callback(this.movement_queue.at(-1)!); | ||||
|     this.movement_queue = []; | ||||
|   } | ||||
| } | ||||
|  | @ -0,0 +1,10 @@ | |||
| export class Vec2 { | ||||
|   constructor( | ||||
|     private readonly x: number, | ||||
|     private readonly y: number, | ||||
|   ) {} | ||||
| 
 | ||||
|   public distance_to(that: Vec2): number { | ||||
|     return Math.sqrt((this.x - that.x) ** 2 + (this.y - that.y) ** 2); | ||||
|   } | ||||
| } | ||||
|  | @ -1,9 +1,9 @@ | |||
| { | ||||
|   "compilerOptions": { | ||||
|     "target": "ES2020", | ||||
|     "target": "ES2022", | ||||
|     "useDefineForClassFields": true, | ||||
|     "module": "ESNext", | ||||
|     "lib": ["ES2020", "DOM", "DOM.Iterable"], | ||||
|     "lib": ["ES2022", "DOM", "DOM.Iterable"], | ||||
|     "skipLibCheck": true, | ||||
| 
 | ||||
|     /* Bundler mode */ | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue