WIP: ECS / Network System #1

Draft
simponic wants to merge 13 commits from websockets into main
27 changed files with 674 additions and 150 deletions
Showing only changes of commit 8ec7f53682 - Show all commits

View File

@ -17,5 +17,7 @@ class Config:
COOKIE_MAX_AGE = int(os.getenv("COOKIE_MAX_AGE", 60 * 60 * 24 * 7)) # 1 week COOKIE_MAX_AGE = int(os.getenv("COOKIE_MAX_AGE", 60 * 60 * 24 * 7)) # 1 week
KENNEL_CATS_POLL_SEC = int(os.getenv("KENNEL_CATS_POLL_SEC", 10))
config = Config() config = Config()

View File

@ -5,6 +5,7 @@ from enum import Enum
class ComponentType(str, Enum): class ComponentType(str, Enum):
POSITION = "POSITION" POSITION = "POSITION"
CONTROLLABLE = "CONTROLLABLE" CONTROLLABLE = "CONTROLLABLE"
MARKOV = "MARKOV"
class Component: class Component:

View File

@ -0,0 +1,24 @@
from kennel.engine.components.component import Component, ComponentType
class MarkovTransitionState(Component):
def __init__(
self,
state_names: dict[int, str],
initial_state_vector: list[float],
transition_matrix: list[list[float]],
):
# TODO: Poll rate per state?
# TODO: State being an enum instead of a vector, just choose max and map
self.state_names = state_names
self.state = initial_state_vector
self.transition_matrix = transition_matrix
super().__init__(ComponentType.MARKOV)
def get_max_state_name(self, state_vector: list[float]):
max_val = max(state_vector)
return self.state_names[state_vector.index(max_val)]
def to_dict(self):
return {"state": self.get_max_state_name(self.state)}

View File

@ -1,6 +1,18 @@
from kennel.engine.components.position import Position
from .entity import Entity, EntityType
class Cat(Entity):
def __init__(self, id: str):
components = [Position(0, 0)]
super().__init__(EntityType.CAT, id, components)
# #
# # IDLE, FROLICKING, EEPY, ALERT, CHASING_CURSOR, CHASING_CAT, SCRATCHING, ITCHY # # IDLE, FROLICKING, EEPY, ALERT, CHASING_CURSOR, CHASING_CAT, SCRATCHING, ITCHY
# state_stochastic_matrix = [ # state_stochastic_matrix = [ [1, 0]
# # IDLE # # IDLE
# [0.5, 0.1, 0.1, 0.1, 0.1, 0.05, 0.05, 0], # [0.5, 0.1, 0.1, 0.1, 0.1, 0.05, 0.05, 0],
# # FROLICKING # # FROLICKING

View File

@ -6,6 +6,7 @@ from kennel.engine.components.component import Component, ComponentType
class EntityType(str, Enum): class EntityType(str, Enum):
LASER = "LASER" LASER = "LASER"
CAT = "CAT"
class Entity: class Entity:

View File

@ -0,0 +1,13 @@
from kennel.engine.components.component import ComponentType
from kennel.engine.entities.entity import EntityManager
from kennel.engine.systems.system import System, SystemType
from kennel.engine.systems.network import NetworkSystem
class MarkovTransitionStateSystem(System):
def __init__(self, network_system: NetworkSystem):
super().__init__(SystemType.MARKOV)
def update(self, entity_manager: EntityManager, delta_time: float):
entity_manager.get_entities_with_component(ComponentType.MARKOV)
return

View File

@ -7,6 +7,7 @@ from kennel.engine.entities.entity import EntityManager
class SystemType(str, Enum): class SystemType(str, Enum):
NETWORK = "NETWORK" NETWORK = "NETWORK"
WORLD = "WORLD" WORLD = "WORLD"
MARKOV = "MARKOV"
class System: class System:
@ -14,7 +15,7 @@ class System:
self.system_type = system_type self.system_type = system_type
@abstractmethod @abstractmethod
async def update(self, entity_manager: EntityManager, delta_time: float): async def update(self, entity_manager: EntityManager, delta_time: float) -> None:
pass pass

View File

@ -1,18 +1,27 @@
import asyncio
import uuid import uuid
import time
from typing import List, Optional 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
from kennel.engine.entities.entity import Entity, EntityManager from kennel.engine.entities.entity import Entity, EntityManager
from kennel.engine.entities.laser import Laser from kennel.engine.entities.laser import Laser
from kennel.engine.entities.cat import Cat
from kennel.engine.game import Game from kennel.engine.game import Game
from kennel.engine.systems.markov_transition_state_system import (
MarkovTransitionStateSystem,
)
from kennel.engine.systems.network import ( from kennel.engine.systems.network import (
EntityPositionUpdateEvent, EntityPositionUpdateEvent,
EntityBornEvent,
EntityDeathEvent,
Event, Event,
EventType, EventType,
NetworkSystem, NetworkSystem,
UpstreamEventProcessor, UpstreamEventProcessor,
) )
from kennel.kennelcats import KennelCatService, KennelCat
from kennel.engine.systems.system import SystemManager from kennel.engine.systems.system import SystemManager
from kennel.engine.systems.world import WorldSystem from kennel.engine.systems.world import WorldSystem
@ -58,12 +67,76 @@ class KennelEventProcessor(UpstreamEventProcessor):
return event return event
system_manager.add_system(WorldSystem(config.WORLD_WIDTH, config.WORLD_HEIGHT)) class KennelCatsManager:
system_manager.add_system( kennel_cat_service: KennelCatService
NetworkSystem(KennelEventProcessor()), entity_manager: EntityManager
) network_system: NetworkSystem
last_seen: set[str]
poll_interval_sec: int
running: bool
def __init__(
self,
kennel_cat_service: KennelCatService,
entity_manager: EntityManager,
network_system: NetworkSystem,
poll_interval_sec: int,
):
self.kennel_cat_service = kennel_cat_service
self.entity_manager = entity_manager
self.network_system = network_system
self.poll_interval_sec = poll_interval_sec
self.last_seen = set()
self.running = False
async def start(self) -> None:
logger.info("starting kennel cats manager")
if self.running:
return
self.running = True
while self.running:
logger.info("polling kennel cats service")
cats = self.kennel_cat_service.get_kennel()
cats_table = {cat.id: cat for cat in cats}
cat_ids = set([cat.id for cat in cats])
removed_cats = [cats_table[id] for id in self.last_seen.difference(cat_ids)]
added_cats = [cats_table[id] for id in cat_ids.difference(self.last_seen)]
logger.info(f"removing {removed_cats}")
logger.info(f"adding {added_cats}")
for removed in removed_cats:
self.entity_manager.remove_entity(removed)
entity_death = EntityDeathEvent(removed.id)
self.network_system.server_global_event(entity_death)
for added in added_cats:
new_cat = Cat(added.id)
self.entity_manager.add_entity(new_cat)
entity_born = EntityBornEvent(new_cat)
self.network_system.server_global_event(entity_born)
self.last_seen = cat_ids
await asyncio.sleep(self.poll_interval_sec)
def stop(self) -> None:
logger.info("stopping kennel cats manager")
self.running = False
network_system = NetworkSystem(KennelEventProcessor())
world_system = WorldSystem(config.WORLD_WIDTH, config.WORLD_HEIGHT)
markov_transition_state_system = MarkovTransitionStateSystem(network_system)
system_manager.add_system(network_system)
system_manager.add_system(world_system)
kennel = Game(entity_manager, system_manager, config.MIN_TIME_STEP) kennel = Game(entity_manager, system_manager, config.MIN_TIME_STEP)
kennel_cat_service = KennelCatService(config.HATECOMPUTERS_ENDPOINT)
kennel_cats_manager = KennelCatsManager(
kennel_cat_service, entity_manager, network_system, config.KENNEL_CATS_POLL_SEC
)
def create_session_controllable_entities(session: str) -> List[Entity]: def create_session_controllable_entities(session: str) -> List[Entity]:

View File

@ -37,10 +37,10 @@ class KennelCat:
class KennelCatService: class KennelCatService:
def __init__(self, endpoint: str): def __init__(self, hc_endpoint: str):
self.endpoint = endpoint self.hc_endpoint = hc_endpoint
def get_kennel(self) -> List[KennelCat]: def get_kennel(self) -> List[KennelCat]:
response = requests.get(f"{self.endpoint}/kennel") response = requests.get(f"{self.hc_endpoint}/kennel")
response.raise_for_status() response.raise_for_status()
return [KennelCat.from_dict(cat) for cat in response.json()] return [KennelCat.from_dict(cat) for cat in response.json()]

View File

@ -30,6 +30,7 @@ from kennel.kennel import (
create_session_controllable_entities, create_session_controllable_entities,
entity_manager, entity_manager,
kennel, kennel,
kennel_cats_manager,
system_manager, system_manager,
) )
@ -43,12 +44,14 @@ loop = asyncio.get_event_loop()
async def startup_event(): async def startup_event():
logger.info("Starting Kennel...") logger.info("Starting Kennel...")
loop.create_task(kennel.run()) loop.create_task(kennel.run())
loop.create_task(kennel_cats_manager.start())
@app.on_event("shutdown") @app.on_event("shutdown")
async def shutdown_event(): async def shutdown_event():
logger.info("Stopping Kennel...") logger.info("Stopping Kennel...")
kennel.stop() kennel.stop()
kennel_cats_manager.stop()
loop.stop() loop.stop()
logger.info("Kennel stopped") logger.info("Kennel stopped")

View File

@ -9,7 +9,14 @@
<title>the kennel.</title> <title>the kennel.</title>
</head> </head>
<body> <body>
<div id="app"></div> <noscript>
<div style="text-align: center">
<h1>yeah, unfortunately you need javascript ;3</h1>
</div>
</noscript>
<div id="app">
<canvas id="gamecanvas"></canvas>
</div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>

View File

@ -8,7 +8,8 @@
"name": "kennel", "name": "kennel",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"jquery": "^3.7.1" "jquery": "^3.7.1",
"laser-pen": "^1.0.1"
}, },
"devDependencies": { "devDependencies": {
"@rollup/plugin-inject": "^5.0.5", "@rollup/plugin-inject": "^5.0.5",
@ -1230,6 +1231,12 @@
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz",
"integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==" "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg=="
}, },
"node_modules/laser-pen": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/laser-pen/-/laser-pen-1.0.1.tgz",
"integrity": "sha512-+K3CQK5ryLDDjX0pEdSIRXh89F6KSpm225DEymWMheg2/umhUO+na/4l8MGs2gee7VO2Mx3kyG288WGrofasoA==",
"license": "MIT"
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.11", "version": "0.30.11",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz",

View File

@ -4,10 +4,9 @@
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview", "preview": "vite preview",
"watch": "./node_modules/nodemon/bin/nodemon.js --watch './src/**/*' -e ts,html --exec \"npm run build\"" "dev": "./node_modules/nodemon/bin/nodemon.js --watch './src/**/*' -e ts,html --exec \"npm run build\""
}, },
"devDependencies": { "devDependencies": {
"@rollup/plugin-inject": "^5.0.5", "@rollup/plugin-inject": "^5.0.5",
@ -18,6 +17,7 @@
"vite-plugin-dynamic-base": "^1.1.0" "vite-plugin-dynamic-base": "^1.1.0"
}, },
"dependencies": { "dependencies": {
"jquery": "^3.7.1" "jquery": "^3.7.1",
"laser-pen": "^1.0.1"
} }
} }

View File

@ -1,8 +0,0 @@
export enum ComponentType {
POSITION = "POSITION",
RENDERABLE = "RENDERABLE",
}
export interface Component {
name: string;
}

View File

@ -0,0 +1,24 @@
export enum ComponentType {
POSITION = "POSITION",
RENDERABLE = "RENDERABLE",
TRAILING_POSITION = "TRAILING_POSITION",
}
export interface Component {
name: ComponentType;
}
export interface PositionComponent extends Component {
name: ComponentType.POSITION;
x: number;
y: number;
}
export interface TrailingPositionComponent extends Component {
name: ComponentType.TRAILING_POSITION;
trails: Array<{ x: number; y: number; time: number }>;
}
export interface RenderableComponent extends Component {
name: ComponentType.RENDERABLE;
}

View File

@ -1,12 +1,11 @@
import { Vec2 } from "./vector"; export class DebouncePublisher<T> {
export class MouseController {
private last_event_time = Date.now(); private last_event_time = Date.now();
private last_movement: Vec2 | undefined; private unpublished_data: T | undefined;
private interval_id: number | undefined; private interval_id: number | undefined;
constructor( constructor(
private readonly publisher: (new_movement: Vec2) => void | Promise<void>, private readonly publisher: (data: T) => void | Promise<void>,
private readonly debounce_ms = 200, private readonly debounce_ms = 100,
) {} ) {}
public start() { public start() {
@ -14,7 +13,7 @@ export class MouseController {
return; return;
} }
this.interval_id = setInterval( this.interval_id = setInterval(
() => this.publish_movement(), () => this.debounce_publish(),
this.debounce_ms, this.debounce_ms,
); );
} }
@ -27,21 +26,21 @@ export class MouseController {
delete this.interval_id; delete this.interval_id;
} }
public move(x: number, y: number) { public update(data: T) {
this.last_movement = new Vec2(x, y); this.unpublished_data = data;
this.publish_movement(); this.debounce_publish();
} }
private publish_movement() { private debounce_publish() {
if ( if (
Date.now() - this.last_event_time < this.debounce_ms || Date.now() - this.last_event_time < this.debounce_ms ||
typeof this.last_movement === "undefined" typeof this.unpublished_data === "undefined"
) { ) {
return; return;
} }
this.last_event_time = Date.now(); this.last_event_time = Date.now();
this.publisher(this.last_movement.copy()); this.publisher(this.unpublished_data);
delete this.last_movement; this.unpublished_data = undefined;
} }
} }

View File

@ -0,0 +1,45 @@
import {
Component,
ComponentType,
PositionComponent,
RenderableComponent,
TrailingPositionComponent,
} from "./component";
export enum EntityType {
LASER = "LASER",
CAT = "CAT",
}
export interface Entity {
entity_type: EntityType;
id: string;
components: Record<ComponentType, Component>;
}
export const create_laser = (base: Entity) => {
const trailing_position: TrailingPositionComponent = {
name: ComponentType.TRAILING_POSITION,
trails: [],
};
base.components[ComponentType.TRAILING_POSITION] = trailing_position;
const renderable: RenderableComponent = {
name: ComponentType.RENDERABLE,
};
base.components[ComponentType.RENDERABLE] = renderable;
return base;
};
export const create_cat = (base: Entity) => {
const renderable: RenderableComponent = {
name: ComponentType.RENDERABLE,
};
base.components[ComponentType.RENDERABLE] = renderable;
base.components[ComponentType.POSITION] = {
component: ComponentType.POSITION,
x: Math.random() * 1_000,
y: Math.random() * 1_000,
} as unknown as PositionComponent; // TODO: hack
return base;
};

View File

@ -1,3 +1,4 @@
import { Entity } from "./entity";
export enum EventType { export enum EventType {
INITIAL_STATE = "INITIAL_STATE", INITIAL_STATE = "INITIAL_STATE",
SET_CONTROLLABLE = "SET_CONTROLLABLE", SET_CONTROLLABLE = "SET_CONTROLLABLE",
@ -26,7 +27,7 @@ export interface InitialStateEvent extends Event {
event_type: EventType.INITIAL_STATE; event_type: EventType.INITIAL_STATE;
data: { data: {
world: { width: number; height: number }; world: { width: number; height: number };
entities: any[]; entities: Record<string, Entity>;
}; };
} }
@ -38,6 +39,20 @@ export interface SetControllableEvent extends Event {
}; };
} }
export interface EntityBornEvent extends Event {
event_type: EventType.ENTITY_BORN;
data: {
entity: Entity;
};
}
export interface EntityDeathEvent extends Event {
event_type: EventType.ENTITY_DEATH;
data: {
id: string;
};
}
export interface EventQueue { export interface EventQueue {
peek(): Event[]; peek(): Event[];
clear(): void; clear(): void;

112
static/src/engine/game.ts Normal file
View File

@ -0,0 +1,112 @@
import { System, SystemType } from "./system";
import { Entity } from "./entity";
import { ComponentType } from "./component";
export class Game {
private running: boolean;
private last_update: number;
private readonly entities: Map<string, Entity> = new Map();
private readonly component_entities: Map<ComponentType, Set<string>> =
new Map();
private readonly systems: Map<SystemType, System> = new Map();
private readonly system_order: SystemType[];
constructor(
public readonly client_id: string,
systems: System[],
) {
this.last_update = performance.now();
this.running = false;
systems.forEach((system) => this.systems.set(system.system_type, system));
this.system_order = systems.map(({ system_type }) => system_type);
}
public start() {
if (this.running) return;
console.log("starting game");
this.running = true;
this.last_update = performance.now();
const game_loop = (timestamp: number) => {
if (!this.running) return;
// rebuild component -> { entity } map
this.component_entities.clear();
Array.from(this.entities.values()).forEach((entity) =>
Object.values(entity.components).forEach((component) => {
const set =
this.component_entities.get(component.name) ?? new Set<string>();
set.add(entity.id);
this.component_entities.set(component.name, set);
}),
);
const dt = timestamp - this.last_update;
this.system_order.forEach((system_type) =>
this.systems.get(system_type)!.update(dt, this),
);
this.last_update = timestamp;
requestAnimationFrame(game_loop); // tail call recursion! /s
};
requestAnimationFrame(game_loop);
}
public stop() {
if (!this.running) return;
this.running = false;
}
public for_each_entity_with_component(
component: ComponentType,
callback: (entity: Entity) => void,
) {
this.component_entities.get(component)?.forEach((entity_id) => {
const entity = this.entities.get(entity_id);
if (!entity) return;
callback(entity);
});
}
public get_entity(id: string) {
return this.entities.get(id);
}
public put_entity(entity: Entity) {
const old_entity = this.entities.get(entity.id);
if (old_entity) this.clear_entity_components(old_entity);
Object.values(entity.components).forEach((component) => {
const set =
this.component_entities.get(component.name) ?? new Set<string>();
set.add(entity.id);
this.component_entities.set(component.name, set);
});
this.entities.set(entity.id, entity);
}
public remove_entity(id: string) {
const entity = this.entities.get(id);
if (typeof entity === "undefined") return;
this.clear_entity_components(entity);
this.entities.delete(id);
}
private clear_entity_components(entity: Entity) {
Object.values(entity.components).forEach((component) => {
const set = this.component_entities.get(component.name);
if (typeof set === "undefined") return;
set.delete(entity.id);
});
}
public get_system<T extends System>(system_type: SystemType): T | undefined {
return this.systems.get(system_type) as T;
}
}

View File

@ -0,0 +1,49 @@
import { DebouncePublisher } from "./debounce_publisher";
import { EntityPositionUpdateEvent, EventPublisher, EventType } from "./events";
import { Game } from "./game";
import { System, SystemType } from "./system";
export class InputSystem extends System {
private readonly controllable_entities: Set<string> = new Set();
private readonly mouse_movement_debouncer: DebouncePublisher<{
x: number;
y: number;
}>;
constructor(
private readonly message_publisher: EventPublisher,
target: HTMLElement,
) {
super(SystemType.INPUT);
this.mouse_movement_debouncer = new DebouncePublisher((data) =>
this.publish_mouse_movement(data),
);
target.addEventListener("mousemove", (event) => {
this.mouse_movement_debouncer.update({
x: event.clientX,
y: event.clientY,
});
});
}
private publish_mouse_movement({ x, y }: { x: number; y: number }) {
console.log(`publishing mouse movement at (${x}, ${y})`);
for (const entity_id of this.controllable_entities) {
this.message_publisher.add({
event_type: EventType.ENTITY_POSITION_UPDATE,
data: {
id: entity_id,
position: { x, y },
},
} as EntityPositionUpdateEvent);
}
}
public add_controllable_entity(entity_id: string) {
this.controllable_entities.add(entity_id);
}
public update(_dt: number, _game: Game) {}
}

View File

@ -0,0 +1,138 @@
import {
ComponentType,
PositionComponent,
TrailingPositionComponent,
} from "./component";
import { create_cat, create_laser, Entity, EntityType } from "./entity";
import {
EntityBornEvent,
EntityDeathEvent,
EntityPositionUpdateEvent,
EventPublisher,
EventQueue,
EventType,
InitialStateEvent,
SetControllableEvent,
} from "./events";
import { Game } from "./game";
import { InputSystem } from "./input";
import { RenderSystem } from "./render";
import { System, SystemType } from "./system";
export class NetworkSystem extends System {
constructor(
private readonly event_queue: EventQueue,
private readonly event_publisher: EventPublisher,
) {
super(SystemType.NETWORK);
}
public update(_dt: number, game: Game) {
const events = this.event_queue.peek();
for (const event of events) {
switch (event.event_type) {
case EventType.INITIAL_STATE:
this.process_initial_state_event(event as InitialStateEvent, game);
break;
case EventType.SET_CONTROLLABLE:
this.process_set_controllable_event(
event as SetControllableEvent,
game,
);
break;
case EventType.ENTITY_BORN:
this.process_entity_born_event(event as EntityBornEvent, game);
break;
case EventType.ENTITY_POSITION_UPDATE:
this.process_entity_position_update_event(
event as EntityPositionUpdateEvent,
game,
);
break;
case EventType.ENTITY_DEATH:
this.process_entity_death_event(event as EntityDeathEvent, game);
break;
}
}
this.event_queue.clear();
this.event_publisher.publish();
}
private process_initial_state_event(event: InitialStateEvent, game: Game) {
console.log("received initial state", event);
const { world, entities } = event.data;
const render_system = game.get_system<RenderSystem>(SystemType.RENDER);
if (!render_system) {
console.error("render system not found");
return;
}
render_system.set_world_dimensions(world.width, world.height);
Object.values(entities).forEach((entity) =>
game.put_entity(this.process_new_entity(entity)),
);
}
private process_entity_position_update_event(
event: EntityPositionUpdateEvent,
game: Game,
) {
console.log("received entity position update", event);
const { position, id } = event.data;
const entity = game.get_entity(id);
if (typeof entity === "undefined") return;
const position_component = entity.components[
ComponentType.POSITION
] as PositionComponent;
position_component.x = position.x;
position_component.y = position.y;
if (ComponentType.TRAILING_POSITION in entity.components) {
const trailing_position = entity.components[
ComponentType.TRAILING_POSITION
] as TrailingPositionComponent;
trailing_position.trails.push({
x: position_component.x,
y: position_component.y,
time: Date.now(),
});
}
}
private process_entity_born_event(event: EntityBornEvent, game: Game) {
console.log("received a new entity", event);
const { entity } = event.data;
game.put_entity(this.process_new_entity(entity));
}
private process_new_entity(entity: Entity): Entity {
if (entity.entity_type === EntityType.LASER) {
return create_laser(entity);
}
if (entity.entity_type === EntityType.CAT) {
return create_cat(entity);
}
return entity;
}
private process_entity_death_event(event: EntityDeathEvent, game: Game) {
console.log("an entity died D:", event);
const { id } = event.data;
game.remove_entity(id);
}
private process_set_controllable_event(
event: SetControllableEvent,
game: Game,
) {
console.log("got a controllable event", event);
if (event.data.client_id !== game.client_id) {
console.warn("got controllable event for client that is not us");
return;
}
const input_system = game.get_system<InputSystem>(SystemType.INPUT)!;
input_system.add_controllable_entity(event.data.id);
}
}

View File

@ -0,0 +1,46 @@
import {
ComponentType,
PositionComponent,
TrailingPositionComponent,
} from "./component";
import { Game } from "./game";
import { System, SystemType } from "./system";
import { drawLaserPen } from "laser-pen";
export class RenderSystem extends System {
constructor(private readonly canvas: HTMLCanvasElement) {
super(SystemType.RENDER);
}
public set_world_dimensions(width: number, height: number) {
this.canvas.width = width;
this.canvas.height = height;
}
public update(_dt: number, game: Game) {
const ctx = this.canvas.getContext("2d");
if (ctx === null) return;
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
game.for_each_entity_with_component(ComponentType.RENDERABLE, (entity) => {
if (ComponentType.TRAILING_POSITION in entity.components) {
const trailing_position = entity.components[
ComponentType.TRAILING_POSITION
] as TrailingPositionComponent;
if (trailing_position.trails.length < 3) return;
drawLaserPen(ctx, trailing_position.trails);
return;
}
if (ComponentType.POSITION in entity.components) {
const position = entity.components[
ComponentType.POSITION
] as PositionComponent;
ctx.beginPath();
ctx.arc(position.x, position.y, 50, 0, 2 * Math.PI);
ctx.stroke();
return;
}
});
}
}

View File

@ -0,0 +1,14 @@
import { Game } from "./game";
export enum SystemType {
INPUT = "INPUT",
NETWORK = "NETWORK",
RENDER = "RENDER",
TRAILING_POSITION = "TRAILING_POSITION",
}
export abstract class System {
constructor(public readonly system_type: SystemType) {}
abstract update(dt: number, game: Game): void;
}

View File

@ -0,0 +1,33 @@
import { ComponentType, TrailingPositionComponent } from "./component";
import { Game } from "./game";
import { System, SystemType } from "./system";
export class TrailingPositionSystem extends System {
constructor(
private readonly point_filter: (
trail_point: {
x: number;
y: number;
time: number;
}[],
) => {
x: number;
y: number;
time: number;
}[],
) {
super(SystemType.TRAILING_POSITION);
}
public update(_dt: number, game: Game) {
game.for_each_entity_with_component(
ComponentType.TRAILING_POSITION,
(entity) => {
const trailing_position = entity.components[
ComponentType.TRAILING_POSITION
] as TrailingPositionComponent;
trailing_position.trails = this.point_filter(trailing_position.trails);
},
);
}
}

View File

@ -1,11 +0,0 @@
import type { Component } from "./component";
export enum EntityType {
LASER = "LASER",
}
export interface Entity {
entity_type: EntityType;
id: string;
components: Record<string, Component>;
}

View File

@ -1,95 +1,16 @@
import $ from "jquery"; import $ from "jquery";
import { Vec2 } from "./vector";
import { MouseController } from "./mouse_controller";
import { import {
EntityPositionUpdateEvent,
EventPublisher, EventPublisher,
EventQueue, EventQueue,
EventType,
SetControllableEvent,
WebsocketEventPublisher, WebsocketEventPublisher,
WebSocketEventQueue, WebSocketEventQueue,
} from "./network"; } from "./engine/events";
import { Game } from "./engine/game";
class KennelClient { import { NetworkSystem } from "./engine/network";
private running: boolean; import { RenderSystem } from "./engine/render";
private last_update: number; import { InputSystem } from "./engine/input";
import { TrailingPositionSystem } from "./engine/trailing_position";
private controllable_entities: Set<string> = new Set(); import { drainPoints, setDelay } from "laser-pen";
private mouse_controller: MouseController;
constructor(
private readonly client_id: string,
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) => {
if (!this.running) return;
const dt = timestamp - this.last_update;
this.propogate_state_after(dt);
requestAnimationFrame(loop); // tail call recursion! /s
};
requestAnimationFrame(loop);
$(document).on("mousemove", (event) => {
this.mouse_controller.move(event.clientX, event.clientY);
});
}
public close() {
this.running = false;
this.mouse_controller.stop();
this.controllable_entities.clear();
$(document).off("mousemove");
}
private propogate_state_after(dt: number) {
const events = this.event_queue.peek();
for (const event of events) {
switch (event.event_type) {
case EventType.SET_CONTROLLABLE:
this.process_set_controllable_event(event as SetControllableEvent);
break;
}
}
if (events.length > 0) {
console.log(events, dt);
}
this.event_queue.clear();
this.event_publisher.publish();
}
private process_set_controllable_event(event: SetControllableEvent) {
if (event.data.client_id !== this.client_id) {
console.warn("got controllable event for client that is not us");
return;
}
this.controllable_entities.add(event.data.id);
}
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.add(event);
}
}
}
$(async () => { $(async () => {
const client_id = await fetch("/assign", { const client_id = await fetch("/assign", {
@ -105,8 +26,25 @@ $(async () => {
const queue: EventQueue = new WebSocketEventQueue(ws); const queue: EventQueue = new WebSocketEventQueue(ws);
const publisher: EventPublisher = new WebsocketEventPublisher(ws); const publisher: EventPublisher = new WebsocketEventPublisher(ws);
const kennel_client = new KennelClient(client_id, queue, publisher); const network_system = new NetworkSystem(queue, publisher);
ws.onclose = () => kennel_client.close();
kennel_client.start(); const gamecanvas = $("#gamecanvas").get(0)! as HTMLCanvasElement;
const input_system = new InputSystem(publisher, gamecanvas);
setDelay(500);
const render_system = new RenderSystem(gamecanvas);
const trailing_position = new TrailingPositionSystem(drainPoints);
const systems = [
network_system,
trailing_position,
input_system,
render_system,
];
const game = new Game(client_id, systems);
ws.onclose = () => game.stop();
game.start();
}); });

View File

@ -1,14 +0,0 @@
export class Vec2 {
constructor(
public readonly x: number,
public readonly y: number,
) {}
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);
}
}