WIP: ECS / Network System #1
|
@ -17,5 +17,7 @@ class Config:
|
|||
|
||||
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()
|
||||
|
|
|
@ -5,6 +5,7 @@ from enum import Enum
|
|||
class ComponentType(str, Enum):
|
||||
POSITION = "POSITION"
|
||||
CONTROLLABLE = "CONTROLLABLE"
|
||||
MARKOV = "MARKOV"
|
||||
|
||||
|
||||
class Component:
|
||||
|
|
|
@ -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)}
|
|
@ -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
|
||||
# state_stochastic_matrix = [
|
||||
# state_stochastic_matrix = [ [1, 0]
|
||||
# # IDLE
|
||||
# [0.5, 0.1, 0.1, 0.1, 0.1, 0.05, 0.05, 0],
|
||||
# # FROLICKING
|
||||
|
|
|
@ -6,6 +6,7 @@ from kennel.engine.components.component import Component, ComponentType
|
|||
|
||||
class EntityType(str, Enum):
|
||||
LASER = "LASER"
|
||||
CAT = "CAT"
|
||||
|
||||
|
||||
class Entity:
|
||||
|
|
|
@ -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
|
|
@ -7,6 +7,7 @@ from kennel.engine.entities.entity import EntityManager
|
|||
class SystemType(str, Enum):
|
||||
NETWORK = "NETWORK"
|
||||
WORLD = "WORLD"
|
||||
MARKOV = "MARKOV"
|
||||
|
||||
|
||||
class System:
|
||||
|
@ -14,7 +15,7 @@ class System:
|
|||
self.system_type = system_type
|
||||
|
||||
@abstractmethod
|
||||
async def update(self, entity_manager: EntityManager, delta_time: float):
|
||||
async def update(self, entity_manager: EntityManager, delta_time: float) -> None:
|
||||
pass
|
||||
|
||||
|
||||
|
|
|
@ -1,18 +1,27 @@
|
|||
import asyncio
|
||||
import uuid
|
||||
import time
|
||||
from typing import List, Optional
|
||||
|
||||
from kennel.config import config
|
||||
from kennel.engine.components.component import ComponentType
|
||||
from kennel.engine.entities.entity import Entity, EntityManager
|
||||
from kennel.engine.entities.laser import Laser
|
||||
from kennel.engine.entities.cat import Cat
|
||||
from kennel.engine.game import Game
|
||||
from kennel.engine.systems.markov_transition_state_system import (
|
||||
MarkovTransitionStateSystem,
|
||||
)
|
||||
from kennel.engine.systems.network import (
|
||||
EntityPositionUpdateEvent,
|
||||
EntityBornEvent,
|
||||
EntityDeathEvent,
|
||||
Event,
|
||||
EventType,
|
||||
NetworkSystem,
|
||||
UpstreamEventProcessor,
|
||||
)
|
||||
from kennel.kennelcats import KennelCatService, KennelCat
|
||||
from kennel.engine.systems.system import SystemManager
|
||||
from kennel.engine.systems.world import WorldSystem
|
||||
|
||||
|
@ -58,12 +67,76 @@ class KennelEventProcessor(UpstreamEventProcessor):
|
|||
return event
|
||||
|
||||
|
||||
system_manager.add_system(WorldSystem(config.WORLD_WIDTH, config.WORLD_HEIGHT))
|
||||
system_manager.add_system(
|
||||
NetworkSystem(KennelEventProcessor()),
|
||||
)
|
||||
class KennelCatsManager:
|
||||
kennel_cat_service: KennelCatService
|
||||
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_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]:
|
||||
|
|
|
@ -37,10 +37,10 @@ class KennelCat:
|
|||
|
||||
|
||||
class KennelCatService:
|
||||
def __init__(self, endpoint: str):
|
||||
self.endpoint = endpoint
|
||||
def __init__(self, hc_endpoint: str):
|
||||
self.hc_endpoint = hc_endpoint
|
||||
|
||||
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()
|
||||
return [KennelCat.from_dict(cat) for cat in response.json()]
|
||||
|
|
|
@ -30,6 +30,7 @@ from kennel.kennel import (
|
|||
create_session_controllable_entities,
|
||||
entity_manager,
|
||||
kennel,
|
||||
kennel_cats_manager,
|
||||
system_manager,
|
||||
)
|
||||
|
||||
|
@ -43,12 +44,14 @@ loop = asyncio.get_event_loop()
|
|||
async def startup_event():
|
||||
logger.info("Starting Kennel...")
|
||||
loop.create_task(kennel.run())
|
||||
loop.create_task(kennel_cats_manager.start())
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
logger.info("Stopping Kennel...")
|
||||
kennel.stop()
|
||||
kennel_cats_manager.stop()
|
||||
loop.stop()
|
||||
logger.info("Kennel stopped")
|
||||
|
||||
|
|
|
@ -9,7 +9,14 @@
|
|||
<title>the kennel.</title>
|
||||
</head>
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -8,7 +8,8 @@
|
|||
"name": "kennel",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"jquery": "^3.7.1"
|
||||
"jquery": "^3.7.1",
|
||||
"laser-pen": "^1.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-inject": "^5.0.5",
|
||||
|
@ -1230,6 +1231,12 @@
|
|||
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz",
|
||||
"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": {
|
||||
"version": "0.30.11",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz",
|
||||
|
|
|
@ -4,10 +4,9 @@
|
|||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"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": {
|
||||
"@rollup/plugin-inject": "^5.0.5",
|
||||
|
@ -18,6 +17,7 @@
|
|||
"vite-plugin-dynamic-base": "^1.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"jquery": "^3.7.1"
|
||||
"jquery": "^3.7.1",
|
||||
"laser-pen": "^1.0.1"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
export enum ComponentType {
|
||||
POSITION = "POSITION",
|
||||
RENDERABLE = "RENDERABLE",
|
||||
}
|
||||
|
||||
export interface Component {
|
||||
name: string;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -1,12 +1,11 @@
|
|||
import { Vec2 } from "./vector";
|
||||
export class MouseController {
|
||||
export class DebouncePublisher<T> {
|
||||
private last_event_time = Date.now();
|
||||
private last_movement: Vec2 | undefined;
|
||||
private unpublished_data: T | undefined;
|
||||
private interval_id: number | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly publisher: (new_movement: Vec2) => void | Promise<void>,
|
||||
private readonly debounce_ms = 200,
|
||||
private readonly publisher: (data: T) => void | Promise<void>,
|
||||
private readonly debounce_ms = 100,
|
||||
) {}
|
||||
|
||||
public start() {
|
||||
|
@ -14,7 +13,7 @@ export class MouseController {
|
|||
return;
|
||||
}
|
||||
this.interval_id = setInterval(
|
||||
() => this.publish_movement(),
|
||||
() => this.debounce_publish(),
|
||||
this.debounce_ms,
|
||||
);
|
||||
}
|
||||
|
@ -27,21 +26,21 @@ export class MouseController {
|
|||
delete this.interval_id;
|
||||
}
|
||||
|
||||
public move(x: number, y: number) {
|
||||
this.last_movement = new Vec2(x, y);
|
||||
this.publish_movement();
|
||||
public update(data: T) {
|
||||
this.unpublished_data = data;
|
||||
this.debounce_publish();
|
||||
}
|
||||
|
||||
private publish_movement() {
|
||||
private debounce_publish() {
|
||||
if (
|
||||
Date.now() - this.last_event_time < this.debounce_ms ||
|
||||
typeof this.last_movement === "undefined"
|
||||
typeof this.unpublished_data === "undefined"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.last_event_time = Date.now();
|
||||
this.publisher(this.last_movement.copy());
|
||||
delete this.last_movement;
|
||||
this.publisher(this.unpublished_data);
|
||||
this.unpublished_data = undefined;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
};
|
|
@ -1,3 +1,4 @@
|
|||
import { Entity } from "./entity";
|
||||
export enum EventType {
|
||||
INITIAL_STATE = "INITIAL_STATE",
|
||||
SET_CONTROLLABLE = "SET_CONTROLLABLE",
|
||||
|
@ -26,7 +27,7 @@ export interface InitialStateEvent extends Event {
|
|||
event_type: EventType.INITIAL_STATE;
|
||||
data: {
|
||||
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 {
|
||||
peek(): Event[];
|
||||
clear(): void;
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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) {}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>;
|
||||
}
|
|
@ -1,95 +1,16 @@
|
|||
import $ from "jquery";
|
||||
import { Vec2 } from "./vector";
|
||||
import { MouseController } from "./mouse_controller";
|
||||
import {
|
||||
EntityPositionUpdateEvent,
|
||||
EventPublisher,
|
||||
EventQueue,
|
||||
EventType,
|
||||
SetControllableEvent,
|
||||
WebsocketEventPublisher,
|
||||
WebSocketEventQueue,
|
||||
} from "./network";
|
||||
|
||||
class KennelClient {
|
||||
private running: boolean;
|
||||
private last_update: number;
|
||||
|
||||
private controllable_entities: Set<string> = new Set();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
} from "./engine/events";
|
||||
import { Game } from "./engine/game";
|
||||
import { NetworkSystem } from "./engine/network";
|
||||
import { RenderSystem } from "./engine/render";
|
||||
import { InputSystem } from "./engine/input";
|
||||
import { TrailingPositionSystem } from "./engine/trailing_position";
|
||||
import { drainPoints, setDelay } from "laser-pen";
|
||||
|
||||
$(async () => {
|
||||
const client_id = await fetch("/assign", {
|
||||
|
@ -105,8 +26,25 @@ $(async () => {
|
|||
|
||||
const queue: EventQueue = new WebSocketEventQueue(ws);
|
||||
const publisher: EventPublisher = new WebsocketEventPublisher(ws);
|
||||
const kennel_client = new KennelClient(client_id, queue, publisher);
|
||||
ws.onclose = () => kennel_client.close();
|
||||
const network_system = new NetworkSystem(queue, publisher);
|
||||
|
||||
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();
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue