diff --git a/kennel/engine/components/component.py b/kennel/engine/components/component.py index c8ef2d0..da55138 100644 --- a/kennel/engine/components/component.py +++ b/kennel/engine/components/component.py @@ -12,5 +12,5 @@ class Component: self.component_type = component_type @abstractmethod - def dict(self) -> dict: + def to_dict(self) -> dict: pass diff --git a/kennel/engine/components/controllable.py b/kennel/engine/components/controllable.py index 25ec835..a9173af 100644 --- a/kennel/engine/components/controllable.py +++ b/kennel/engine/components/controllable.py @@ -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 {} diff --git a/kennel/engine/components/position.py b/kennel/engine/components/position.py index 2a4da4a..5c5b372 100644 --- a/kennel/engine/components/position.py +++ b/kennel/engine/components/position.py @@ -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} diff --git a/kennel/engine/entities/entity.py b/kennel/engine/entities/entity.py index 6623512..1d8c34d 100644 --- a/kennel/engine/entities/entity.py +++ b/kennel/engine/entities/entity.py @@ -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()}, } diff --git a/kennel/engine/systems/network.py b/kennel/engine/systems/network.py index c9696c8..8dbf4cf 100644 --- a/kennel/engine/systems/network.py +++ b/kennel/engine/systems/network.py @@ -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) diff --git a/kennel/kennel.py b/kennel/kennel.py index 61847a5..bb0438c 100644 --- a/kennel/kennel.py +++ b/kennel/kennel.py @@ -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] diff --git a/kennel/main.py b/kennel/main.py index 6f661cb..fc0fd0e 100644 --- a/kennel/main.py +++ b/kennel/main.py @@ -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: diff --git a/static/src/component.ts b/static/src/component.ts new file mode 100644 index 0000000..d338cad --- /dev/null +++ b/static/src/component.ts @@ -0,0 +1,8 @@ +export enum ComponentType { + POSITION = "POSITION", + RENDERABLE = "RENDERABLE", +} + +export interface Component { + name: string; +} diff --git a/static/src/entity.ts b/static/src/entity.ts new file mode 100644 index 0000000..811d05a --- /dev/null +++ b/static/src/entity.ts @@ -0,0 +1,11 @@ +import type { Component } from "./component"; + +export enum EntityType { + LASER = "LASER", +} + +export interface Entity { + entity_type: EntityType; + id: string; + components: Record; +} diff --git a/static/src/event.ts b/static/src/event.ts new file mode 100644 index 0000000..cd50122 --- /dev/null +++ b/static/src/event.ts @@ -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; + }; +} diff --git a/static/src/main.ts b/static/src/main.ts index 8d12587..fc04b72 100644 --- a/static/src/main.ts +++ b/static/src/main.ts @@ -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(); + 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((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(); }; }); diff --git a/static/src/mouse_controller.ts b/static/src/mouse_controller.ts new file mode 100644 index 0000000..b8849b4 --- /dev/null +++ b/static/src/mouse_controller.ts @@ -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 = []; + } +} diff --git a/static/src/vector.ts b/static/src/vector.ts new file mode 100644 index 0000000..e7be9fa --- /dev/null +++ b/static/src/vector.ts @@ -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); + } +} diff --git a/static/tsconfig.json b/static/tsconfig.json index 0511b9f..8f3e483 100644 --- a/static/tsconfig.json +++ b/static/tsconfig.json @@ -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 */