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");
|
||||
await new Promise<void>((resolve) => {
|
||||
ws.onopen = () => {
|
||||
console.log("connected");
|
||||
};
|
||||
ws.onmessage = ({ data }) => {
|
||||
console.log(JSON.parse(data));
|
||||
};
|
||||
ws.onclose = (e) => {
|
||||
console.log("disconnected", e);
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
ws.onmessage = ({ 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 = () => {
|
||||
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