WIP: ECS / Network System #1

Draft
simponic wants to merge 13 commits from websockets into main
6 changed files with 91 additions and 78 deletions
Showing only changes of commit 45726367ee - Show all commits

View File

@ -1,7 +1,7 @@
import asyncio import asyncio
from abc import abstractmethod from abc import abstractmethod
from enum import Enum from enum import Enum
from typing import List, Optional from typing import List, Optional, Dict
from kennel.app import logger from kennel.app import logger
from kennel.engine.entities.entity import Entity, EntityManager from kennel.engine.entities.entity import Entity, EntityManager
@ -55,7 +55,9 @@ class Event:
class InitialStateEvent(Event): class InitialStateEvent(Event):
def __init__(self, world_width: int, world_height: int, entities: List[Entity]): def __init__(
self, world_width: int, world_height: int, entities: Dict[str, Entity]
):
self.world_width = world_width self.world_width = world_width
self.world_height = world_height self.world_height = world_height
self.entities = entities self.entities = entities
@ -63,7 +65,7 @@ class InitialStateEvent(Event):
EventType.INITIAL_STATE, EventType.INITIAL_STATE,
{ {
"world": {"width": world_width, "height": world_height}, "world": {"width": world_width, "height": world_height},
"entities": [entity.to_dict() for entity in entities], "entities": entities,
}, },
) )
@ -98,51 +100,66 @@ class EntityDeathEvent(Event):
class Publishable: class Publishable:
@abstractmethod @abstractmethod
async def publish(self, event: Event): async def publish(self, event: List[Event]):
pass pass
class EventProcessor: class UpstreamEventProcessor:
@abstractmethod @abstractmethod
def accept( def accept(
self, entity_manager: EntityManager, event: Event | tuple[Event, str] self, entity_manager: EntityManager, client_event: tuple[Event, str]
) -> None: ) -> Optional[Event]:
pass pass
class NetworkSystem(System): class NetworkSystem(System):
def __init__(self, event_processor: EventProcessor): event_processor: UpstreamEventProcessor
super().__init__(SystemType.NETWORK) sever_events: List[Event]
self.event_processor = event_processor 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.events = []
self.client_events = []
self.clients = {} self.clients = {}
async def update(self, entity_manager: EntityManager, delta_time: float) -> None: async def update(self, entity_manager: EntityManager, delta_time: float) -> None:
if len(self.events) + len(self.client_events) == 0: for event in self.client_upstream_events:
return produced_event = self.upstream_event_processor.accept(entity_manager, event)
for event in self.events + self.client_events: if produced_event is not None:
self.event_processor.accept(entity_manager, event) self.server_events.append(produced_event)
client_sendable = self.events + [event for event, _ in self.client_events] promises = [
await asyncio.gather( client.publish(self.server_events + events)
*[ for client, events in self.clients.values()
client.publish(event) if len(self.server_events + events) > 0
for client in self.clients.values()
for event in client_sendable
] ]
) await asyncio.gather(*promises)
self.events = []
self.client_events = []
def client_event(self, client_id: str, event: Event) -> None: self.server_events = []
self.client_events.append((event, client_id)) self.client_upstream_events = []
for client_id in self.clients.keys():
(client, _) = self.clients[client_id]
self.clients[client_id] = (client, [])
def add_event(self, event: Event) -> None: def server_global_event(self, event: Event) -> None:
self.events.append(event) 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: def add_client(self, client_id: str, client: Publishable) -> None:
self.clients[client_id] = client self.clients[client_id] = (client, [])
def remove_client(self, client_id: str) -> None: def remove_client(self, client_id: str) -> None:
del self.clients[client_id] del self.clients[client_id]

View File

@ -1,5 +1,5 @@
import uuid import uuid
from typing import List from typing import List, Optional
from kennel.config import config from kennel.config import config
from kennel.engine.components.component import ComponentType from kennel.engine.components.component import ComponentType
@ -9,7 +9,7 @@ from kennel.engine.game import Game
from kennel.engine.systems.network import ( from kennel.engine.systems.network import (
EntityPositionUpdateEvent, EntityPositionUpdateEvent,
Event, Event,
EventProcessor, UpstreamEventProcessor,
EventType, EventType,
NetworkSystem, NetworkSystem,
) )
@ -22,27 +22,22 @@ entity_manager = EntityManager()
system_manager = SystemManager() system_manager = SystemManager()
class KennelEventProcessor(EventProcessor): class KennelEventProcessor(UpstreamEventProcessor):
def accept( def accept(
self, entity_manager: EntityManager, event: Event | tuple[Event, str] self, entity_manager: EntityManager, event: tuple[Event, str]
) -> None: ) -> Optional[Event]:
if isinstance(event, tuple):
client_event, client_id = event client_event, client_id = event
self._process_client_event(entity_manager, client_event, client_id) if client_event.event_type == EventType.ENTITY_POSITION_UPDATE:
return return self._process_entity_position_update(
entity_manager, client_event, client_id
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, client_id)
def _process_entity_position_update( def _process_entity_position_update(
self, self,
entity_manager: EntityManager, entity_manager: EntityManager,
event: EntityPositionUpdateEvent, event: EntityPositionUpdateEvent,
client_id: str, client_id: str,
) -> None: ) -> Optional[Event]:
entity = entity_manager.get_entity(event.data["id"]) entity = entity_manager.get_entity(event.data["id"])
if entity is None: if entity is None:
logger.error(f"Entity(id={event.data['id']}) does not exist") logger.error(f"Entity(id={event.data['id']}) does not exist")
@ -60,6 +55,7 @@ class KennelEventProcessor(EventProcessor):
entity.add_component(position) entity.add_component(position)
entity_manager.add_entity(entity) entity_manager.add_entity(entity)
return event
system_manager.add_system(WorldSystem(config.WORLD_WIDTH, config.WORLD_HEIGHT)) system_manager.add_system(WorldSystem(config.WORLD_WIDTH, config.WORLD_HEIGHT))

View File

@ -1,6 +1,6 @@
import asyncio import asyncio
import uuid import uuid
from typing import Annotated, Optional from typing import Annotated, Optional, List
from fastapi import ( from fastapi import (
Cookie, Cookie,
@ -82,8 +82,8 @@ class WebSocketClient(Publishable):
def __init__(self, websocket: WebSocket): def __init__(self, websocket: WebSocket):
self.websocket = websocket self.websocket = websocket
async def publish(self, event: Event): async def publish(self, events: List[Event]):
await self.websocket.send_json([event.event_type, event.data]) await self.websocket.send_json([event.to_dict() for event in events])
@app.websocket("/ws") @app.websocket("/ws")
@ -95,27 +95,30 @@ async def websocket_endpoint(
client = WebSocketClient(websocket) client = WebSocketClient(websocket)
logger.info(f"Websocket connection established for session {session}") logger.info(f"Websocket connection established for session {session}")
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) session_entities = create_session_controllable_entities(session)
logger.info(f"Creating {len(session_entities)} entities for session {session}") logger.info(f"Creating {len(session_entities)} entities for session {session}")
try: try:
network_system = system_manager.get_system(SystemType.NETWORK) network_system = system_manager.get_system(SystemType.NETWORK)
if network_system is None: if network_system is None:
raise "Network system not found" 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: for entity in session_entities:
logger.info(f"Adding entity {entity.id} for session {session}") logger.info(f"Adding entity {entity.id} for session {session}")
entity_manager.add_entity(entity) entity_manager.add_entity(entity)
network_system.add_event(EntityBornEvent(entity))
set_controllable_event = SetControllableEvent(entity.id, session) network_system.server_global_event(EntityBornEvent(entity))
await client.publish(set_controllable_event) network_system.client_downstream_event(
session, SetControllableEvent(entity.id, session)
)
network_system.add_client(session, WebSocketClient(websocket))
while True: while True:
message = await websocket.receive_json() message = await websocket.receive_json()
if not isinstance(message, list): if not isinstance(message, list):
@ -125,20 +128,20 @@ async def websocket_endpoint(
logger.info(f"Invalid events in: {message}"[:100]) logger.info(f"Invalid events in: {message}"[:100])
continue continue
for event in events: for event in events:
network_system.client_event(session, event) network_system.client_upstream_event(session, event)
except WebSocketDisconnect: except WebSocketDisconnect:
logger.info(f"Websocket connection closed by client: {session}") logger.info(f"Websocket connection closed by client: {session}")
except Exception as e: except Exception as e:
logger.error("Exception occurred", exc_info=e) logger.error("Exception occurred", exc_info=e)
finally: finally:
logger.info("Websocket connection closed") network_system.remove_client(session)
for entity in session_entities: for entity in session_entities:
logger.info(f"Removing entity {entity.id} for session {session}") logger.info(f"Removing entity {entity.id} for session {session}")
entity_manager.remove_entity(entity.id) entity_manager.remove_entity(entity.id)
network_system.add_event(Event(EventType.ENTITY_DEATH, {"id": entity.id})) network_system.add_event(Event(EventType.ENTITY_DEATH, {"id": entity.id}))
network_system.remove_client(session)
await websocket.close() await websocket.close()
logger.info("Websocket connection closed")
@app.get("/healthcheck") @app.get("/healthcheck")

View File

@ -65,9 +65,11 @@ class KennelClient {
break; break;
} }
} }
if (events.length > 0) {
console.log(events, dt); console.log(events, dt);
}
this.event_queue.clear(); this.event_queue.clear();
this.event_publisher.publish();
} }
private process_set_controllable_event(event: SetControllableEvent) { private process_set_controllable_event(event: SetControllableEvent) {

View File

@ -7,7 +7,6 @@ export class MouseController {
constructor( constructor(
private readonly publisher: (new_movement: Vec2) => void | Promise<void>, private readonly publisher: (new_movement: Vec2) => void | Promise<void>,
private readonly debounce_ms = 200, private readonly debounce_ms = 200,
private readonly l2_norm_threshold = 40,
) {} ) {}
public start() { public start() {
@ -29,20 +28,14 @@ export class MouseController {
} }
public move(x: number, y: number) { public move(x: number, y: number) {
const new_movement = new Vec2(x, y); this.last_movement = new Vec2(x, y);
if (
typeof this.last_movement !== "undefined" &&
new_movement.distance_to(this.last_movement) >= this.l2_norm_threshold
) {
this.publish_movement(); this.publish_movement();
} }
this.last_movement = new_movement;
}
private publish_movement() { private publish_movement() {
if ( if (
typeof this.last_movement === "undefined" || Date.now() - this.last_event_time < this.debounce_ms ||
Date.now() - this.last_event_time < this.debounce_ms typeof this.last_movement === "undefined"
) { ) {
return; return;
} }

View File

@ -66,8 +66,7 @@ export class WebSocketEventQueue implements EventQueue {
private listen_to(websocket: WebSocket) { private listen_to(websocket: WebSocket) {
websocket.onmessage = ({ data }) => { websocket.onmessage = ({ data }) => {
const [event_type, event_data] = JSON.parse(data); this.queue = this.queue.concat(JSON.parse(data));
this.queue.push({ event_type, data: event_data } as Event);
}; };
} }
} }
@ -84,6 +83,9 @@ export class WebsocketEventPublisher implements EventPublisher {
} }
public publish() { public publish() {
if (this.queue.length === 0) {
return;
}
this.websocket.send(JSON.stringify(this.queue)); this.websocket.send(JSON.stringify(this.queue));
this.queue = []; this.queue = [];
} }