WIP: ECS / Network System #1

Draft
simponic wants to merge 13 commits from websockets into main
6 changed files with 187 additions and 90 deletions
Showing only changes of commit d35ab6cc85 - Show all commits

View File

@ -8,6 +8,8 @@ from kennel.engine.systems.network import (
EventProcessor,
Event,
EventType,
Entity,
EntityPositionUpdateEvent,
)
from kennel.engine.systems.world import WorldSystem
from kennel.config import config
@ -35,24 +37,28 @@ class KennelEventProcessor(EventProcessor):
self._process_entity_position_update(entity_manager, event, client_id)
def _process_entity_position_update(
self, entity_manager: EntityManager, event: Event, client_id: str
self,
entity_manager: EntityManager,
event: EntityPositionUpdateEvent,
client_id: str,
) -> None:
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:
logger.error(f"Entity {entity} is not controllable")
return
if controllable.by != client_id:
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)
system_manager.add_system(WorldSystem(config.WORLD_WIDTH, config.WORLD_HEIGHT))

View File

@ -1,28 +0,0 @@
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;
};
}

View File

@ -1,51 +1,93 @@
import $ from "jquery";
import { Vec2 } from "./vector";
import { EventType, type SetControllableEvent, type Event } from "./event";
import {
EventType,
type SetControllableEvent,
type Event,
type EventPublisher,
} from "./event";
import { MouseController } from "./mouse_controller";
import {
EntityPositionUpdateEvent,
EventProcessor,
EventQueue,
WebSocketEventQueue,
} from "./network";
$(document).ready(async () => {
class KennelClient {
private running: boolean;
private last_update: number;
private controllable_entities: Set<string>;
private mouse_controller: MouseController;
constructor(
private readonly event_queue: EventQueue,
private readonly event_publisher: EventPublisher,
) {
this.last_update = 0;
this.running = false;
this.mouse_controller = new MouseController((position: Vec2) =>
this.on_mouse_move(position),
);
}
public start() {
this.running = true;
this.last_update = performance.now();
this.mouse_controller.start();
const loop = (timestamp: number) => {
const dt = timestamp - this.last_update;
this.propogate_state_after(dt);
requestAnimationFrame(loop); // tail call recursion! /s
};
requestAnimationFrame(loop);
}
public close() {
this.running = false;
this.mouse_controller.stop();
this.controllable_entities.clear();
}
private propogate_state_after(dt: number) {
// TODO: interpolate cats and lasers and stuff
}
private on_mouse_move(position: Vec2) {
for (const id of this.controllable_entities) {
const event: EntityPositionUpdateEvent = {
event_type: EventType.ENTITY_POSITION_UPDATE,
data: { id, position: { x: position.x, y: position.y } },
};
this.event_publisher.send(event);
}
}
}
$(async () => {
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 },
const ws = new WebSocket("/ws");
await new Promise<void>((resolve) => {
ws.onopen = () => resolve();
});
ws.send(message);
}
};
const mouse_controller = new MouseController(control_callback);
const queue: EventQueue = new WebSocketEventQueue(ws);
const kennel_client = new KennelClient(session_id, null);
ws.onclose = () => kennel_client.close();
const mouse_controller = new MouseController(on_mouse_move);
$(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");
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();
};
});

View File

@ -1,21 +1,23 @@
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;
private last_movement: Vec2 | undefined;
private interval_id: number | undefined;
constructor(private readonly callback: (new_movement: Vec2) => void) {}
constructor(
private readonly publisher: (new_movement: Vec2) => void | Promise<void>,
private readonly debounce_ms = 200,
private readonly l2_norm_threshold = 40,
) {}
public start() {
if (this.interval_id !== null) {
if (typeof this.interval_id !== "undefined") {
return;
}
this.interval_id = setInterval(() => {
this.publish_movement();
}, this.debounce_ms);
this.interval_id = setInterval(
() => this.publish_movement(),
this.debounce_ms,
);
}
public stop() {
@ -23,32 +25,30 @@ export class MouseController {
return;
}
clearInterval(this.interval_id);
this.interval_id = null;
delete this.interval_id;
}
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
typeof this.last_movement !== "undefined" &&
new_movement.distance_to(this.last_movement) >= this.l2_norm_threshold
) {
return;
}
this.publish_movement();
}
this.last_movement = new_movement;
}
private publish_movement() {
if (
Date.now() - this.last_event_time < this.debounce_ms ||
this.movement_queue.length === 0
typeof this.last_movement === "undefined" ||
Date.now() - this.last_event_time < this.debounce_ms
) {
return;
}
this.last_event_time = Date.now();
this.callback(this.movement_queue.at(-1)!);
this.movement_queue = [];
this.publisher(this.last_movement.copy());
delete this.last_movement;
}
}

73
static/src/network.ts Normal file
View File

@ -0,0 +1,73 @@
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: any[];
};
}
export interface SetControllableEvent extends Event {
event_type: EventType.SET_CONTROLLABLE;
data: {
id: string;
client_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 }) => {
const [event_type, event_data] = JSON.parse(data);
this.queue.push({ event_type, data: event_data } as Event);
};
}
}

View File

@ -7,4 +7,8 @@ export class Vec2 {
public distance_to(that: Vec2): number {
return Math.sqrt((this.x - that.x) ** 2 + (this.y - that.y) ** 2);
}
public copy(): Vec2 {
return new Vec2(this.x, this.y);
}
}