WIP: ECS / Network System #1
|
@ -4,12 +4,12 @@ type: docker
|
||||||
name: build
|
name: build
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: run tests
|
- name: lint
|
||||||
image: python:3.12
|
image: python:3.12
|
||||||
commands:
|
commands:
|
||||||
- pip install poetry
|
- pip install poetry
|
||||||
- poetry install --with main,dev
|
- poetry install --with main,dev
|
||||||
- poetry run pytest
|
- poetry run ruff check kennel/*
|
||||||
|
|
||||||
trigger:
|
trigger:
|
||||||
event:
|
event:
|
||||||
|
@ -21,12 +21,12 @@ type: docker
|
||||||
name: deploy
|
name: deploy
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: run tests
|
- name: run lier
|
||||||
image: python:3.12
|
image: python:3.12
|
||||||
commands:
|
commands:
|
||||||
- pip install poetry
|
- pip install poetry
|
||||||
- poetry install --with main,dev
|
- poetry install --with main,dev
|
||||||
- poetry run pytest
|
- poetry run ruff check kennel/*
|
||||||
- name: docker
|
- name: docker
|
||||||
image: plugins/docker
|
image: plugins/docker
|
||||||
settings:
|
settings:
|
||||||
|
|
|
@ -1,2 +0,0 @@
|
||||||
*.sqlite
|
|
||||||
*.db
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
import structlog
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
servers=[{"url": "https://kennel.hatecomputers.club", "description": "prod"}]
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = structlog.get_logger()
|
|
@ -0,0 +1,23 @@
|
||||||
|
import os
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
WORLD_WIDTH = int(os.getenv("WORLD_WIDTH", 1000))
|
||||||
|
WORLD_HEIGHT = int(os.getenv("WORLD_HEIGHT", 1000))
|
||||||
|
|
||||||
|
HATECOMPUTERS_ENDPOINT = os.getenv(
|
||||||
|
"HATECOMPUTERS_ENDPOINT", "https://hatecomputers.club"
|
||||||
|
)
|
||||||
|
|
||||||
|
MIN_TIME_STEP = float(os.getenv("MIN_TIME_STEP", 0.05))
|
||||||
|
|
||||||
|
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()
|
|
@ -0,0 +1,18 @@
|
||||||
|
from abc import abstractmethod
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class ComponentType(str, Enum):
|
||||||
|
POSITION = "POSITION"
|
||||||
|
CONTROLLABLE = "CONTROLLABLE"
|
||||||
|
MARKOV = "MARKOV"
|
||||||
|
SPRITESHEET = "SPRITESHEET"
|
||||||
|
|
||||||
|
|
||||||
|
class Component:
|
||||||
|
def __init__(self, component_type: ComponentType):
|
||||||
|
self.component_type = component_type
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
pass
|
|
@ -0,0 +1,14 @@
|
||||||
|
from .component import Component, ComponentType
|
||||||
|
|
||||||
|
|
||||||
|
class Controllable(Component):
|
||||||
|
def __init__(self, by: str):
|
||||||
|
super().__init__(ComponentType.CONTROLLABLE)
|
||||||
|
self.by = by
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"Controllable(by={self.by})"
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
# don't serialize who owns this
|
||||||
|
return {}
|
|
@ -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)}
|
|
@ -0,0 +1,14 @@
|
||||||
|
from .component import Component, ComponentType
|
||||||
|
|
||||||
|
|
||||||
|
class Position(Component):
|
||||||
|
def __init__(self, x: float, y: float):
|
||||||
|
super().__init__(ComponentType.POSITION)
|
||||||
|
self.x = x
|
||||||
|
self.y = y
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"Position(x={self.x}, y={self.y})"
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {"x": self.x, "y": self.y}
|
|
@ -0,0 +1,63 @@
|
||||||
|
from .component import Component, ComponentType
|
||||||
|
|
||||||
|
|
||||||
|
class SpriteSpec:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
ms_per_frame: int,
|
||||||
|
top_x: int,
|
||||||
|
top_y: int,
|
||||||
|
end_x: int,
|
||||||
|
end_y: int,
|
||||||
|
frames: int,
|
||||||
|
):
|
||||||
|
self.ms_per_frame = ms_per_frame
|
||||||
|
self.frames = frames
|
||||||
|
self.top_x = top_x
|
||||||
|
self.top_y = top_y
|
||||||
|
self.end_x = end_x
|
||||||
|
self.end_y = end_y
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"ms_per_frame": self.ms_per_frame,
|
||||||
|
"top_x": self.top_x,
|
||||||
|
"top_y": self.top_y,
|
||||||
|
"end_x": self.end_x,
|
||||||
|
"end_y": self.end_y,
|
||||||
|
"frames": self.frames,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"SpriteSpec(ms_per_frame={self.ms_per_frame}, top_x={self.top_x}, top_y={self.top_y}, end_x={self.end_x}, end_y={self.end_y}, frames={self.frames})"
|
||||||
|
|
||||||
|
|
||||||
|
class SpriteSheet(Component):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
source: str,
|
||||||
|
state_to_spritespec: dict[str, SpriteSpec],
|
||||||
|
initial_state: str,
|
||||||
|
):
|
||||||
|
super().__init__(ComponentType.SPRITESHEET)
|
||||||
|
self.source = source
|
||||||
|
self.state_to_spritespec = state_to_spritespec
|
||||||
|
|
||||||
|
# these are only really used for client initialization
|
||||||
|
self.initial_state = initial_state
|
||||||
|
self.current_frame = 0
|
||||||
|
self.last_update = 0
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"SpriteSheet(source={self.source}, state_to_spritespec={self.state_to_spritespec}, initial_state={self.initial_state}, current_frame={self.current_frame}, last_update={self.last_update})"
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"source": self.source,
|
||||||
|
"current_frame": self.current_frame,
|
||||||
|
"last_update": self.last_update,
|
||||||
|
"current_state": self.initial_state,
|
||||||
|
"state_to_spritespec": {
|
||||||
|
k: v.to_dict() for k, v in self.state_to_spritespec.items()
|
||||||
|
},
|
||||||
|
}
|
|
@ -0,0 +1,83 @@
|
||||||
|
from kennel.engine.components.position import Position
|
||||||
|
from kennel.engine.components.sprite_sheet import SpriteSheet, SpriteSpec
|
||||||
|
from enum import Enum
|
||||||
|
from .entity import Entity, EntityType
|
||||||
|
|
||||||
|
|
||||||
|
class CatState(str, Enum):
|
||||||
|
IDLE = "IDLE"
|
||||||
|
FROLICKING = "FROLICKING"
|
||||||
|
EEPY = "EEPY"
|
||||||
|
ALERT = "ALERT"
|
||||||
|
CHASING_CURSOR = "CHASING_CURSOR"
|
||||||
|
CHASING_CAT = "CHASING_CAT"
|
||||||
|
SCRATCHING = "SCRATCHING"
|
||||||
|
ITCHY = "ITCHY"
|
||||||
|
MAKING_BISCUITS = "MAKING_BISCUITS"
|
||||||
|
|
||||||
|
|
||||||
|
class CatSpriteState(str, Enum):
|
||||||
|
ALERT = "ALERT"
|
||||||
|
MAKING_BISCUITS = "MAKING_BISCUITS"
|
||||||
|
|
||||||
|
|
||||||
|
class Cat(Entity):
|
||||||
|
def __init__(self, id: str, spritesheet_source: str):
|
||||||
|
state_to_spritespec = self.get_state_to_spritespec()
|
||||||
|
components = [
|
||||||
|
Position(50, 50),
|
||||||
|
SpriteSheet(
|
||||||
|
spritesheet_source, state_to_spritespec, CatSpriteState.MAKING_BISCUITS
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
super().__init__(EntityType.CAT, id, components)
|
||||||
|
|
||||||
|
def get_state_to_spritespec(self):
|
||||||
|
creature_width = 32
|
||||||
|
creature_height = 32
|
||||||
|
return {
|
||||||
|
CatSpriteState.ALERT: SpriteSpec(
|
||||||
|
100,
|
||||||
|
creature_width * 7,
|
||||||
|
creature_height * 3,
|
||||||
|
creature_width * 8,
|
||||||
|
creature_height * 4,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
CatSpriteState.MAKING_BISCUITS: SpriteSpec(
|
||||||
|
300,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
creature_width,
|
||||||
|
creature_height * 2,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# # IDLE, FROLICKING, EEPY, ALERT, CHASING_CURSOR, CHASING_CAT, SCRATCHING, ITCHY
|
||||||
|
# state_stochastic_matrix = [ [1, 0]
|
||||||
|
# # IDLE
|
||||||
|
# [0.5, 0.1, 0.1, 0.1, 0.1, 0.05, 0.05, 0],
|
||||||
|
# # FROLICKING
|
||||||
|
# [0.1, 0.5, 0.1, 0.1, 0.1, 0.05, 0.05, 0],
|
||||||
|
# # EEPY
|
||||||
|
# [0.1, 0.1, 0.5, 0.1, 0.1, 0.05, 0.05, 0],
|
||||||
|
# # ALERT
|
||||||
|
# [0.1, 0.1, 0.1, 0.5, 0.1, 0.05, 0.05, 0],
|
||||||
|
# # CHASING_CURSOR
|
||||||
|
# [0.1, 0.1, 0.1, 0.1, 0.5, 0.05, 0.05, 0],
|
||||||
|
# # CHASING_CAT
|
||||||
|
# [0.1, 0.1, 0.1, 0.1, 0.1, 0.5, 0.05, 0],
|
||||||
|
# # SCRATCHING
|
||||||
|
# [0.1, 0.1, 0.1, 0.1, 0.1, 0.05, 0.5, 0],
|
||||||
|
# # ITCHY
|
||||||
|
# [0, 0, 0, 0, 0, 0, 0, 1],
|
||||||
|
# ]
|
||||||
|
#
|
||||||
|
#
|
||||||
|
|
||||||
|
#
|
||||||
|
#
|
|
@ -0,0 +1,74 @@
|
||||||
|
from enum import Enum
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from kennel.engine.components.component import Component, ComponentType
|
||||||
|
|
||||||
|
|
||||||
|
class EntityType(str, Enum):
|
||||||
|
LASER = "LASER"
|
||||||
|
CAT = "CAT"
|
||||||
|
|
||||||
|
|
||||||
|
class Entity:
|
||||||
|
def __init__(
|
||||||
|
self, entity_type: EntityType, id: str, component_list: List[Component]
|
||||||
|
):
|
||||||
|
self.entity_type = entity_type
|
||||||
|
self.id = id
|
||||||
|
self.components = {}
|
||||||
|
for component in component_list:
|
||||||
|
self.add_component(component)
|
||||||
|
|
||||||
|
def get_component(self, component_type: ComponentType) -> Optional[Component]:
|
||||||
|
return self.components[component_type]
|
||||||
|
|
||||||
|
def add_component(self, component: Component) -> None:
|
||||||
|
self.components[component.component_type] = component
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"entity_type": self.entity_type,
|
||||||
|
"id": self.id,
|
||||||
|
"components": {k: v.to_dict() for k, v in self.components.items()},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class EntityManager:
|
||||||
|
def __init__(self):
|
||||||
|
self.entities = {}
|
||||||
|
self.component_entities = {}
|
||||||
|
|
||||||
|
def update(self) -> None:
|
||||||
|
self.component_entities = {}
|
||||||
|
for entity in self.entities.values():
|
||||||
|
for component in entity.components.values():
|
||||||
|
if component.component_type not in self.component_entities:
|
||||||
|
self.component_entities[component.component_type] = set()
|
||||||
|
self.component_entities[component.component_type].add(entity)
|
||||||
|
|
||||||
|
def get_entities_with_component(
|
||||||
|
self, component_type: ComponentType
|
||||||
|
) -> List[Entity]:
|
||||||
|
return self.component_entities.get(component_type, [])
|
||||||
|
|
||||||
|
def add_entity(self, entity: Entity) -> None:
|
||||||
|
self.entities[entity.id] = entity
|
||||||
|
for component in entity.components.values():
|
||||||
|
if component.component_type not in self.component_entities:
|
||||||
|
self.component_entities[component.component_type] = set()
|
||||||
|
self.component_entities[component.component_type].add(entity)
|
||||||
|
|
||||||
|
def remove_entity(self, entity_id: str) -> None:
|
||||||
|
if entity_id not in self.entities:
|
||||||
|
return
|
||||||
|
entity = self.entities[entity_id]
|
||||||
|
for component in entity.components.values():
|
||||||
|
if component.component_type in self.component_entities:
|
||||||
|
self.component_entities[component.component_type].remove(entity)
|
||||||
|
del self.entities[entity_id]
|
||||||
|
|
||||||
|
def get_entity(self, entity_id: str) -> Optional[Entity]:
|
||||||
|
return self.entities.get(entity_id)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {k: v.to_dict() for k, v in self.entities.items()}
|
|
@ -0,0 +1,14 @@
|
||||||
|
from kennel.engine.components.controllable import Controllable
|
||||||
|
from kennel.engine.components.position import Position
|
||||||
|
|
||||||
|
from .entity import Entity, EntityType
|
||||||
|
|
||||||
|
|
||||||
|
class Laser(Entity):
|
||||||
|
def __init__(self, id: str, controllable_by: str):
|
||||||
|
components = [
|
||||||
|
Position(0, 0),
|
||||||
|
Controllable(controllable_by),
|
||||||
|
]
|
||||||
|
|
||||||
|
super().__init__(EntityType.LASER, id, components)
|
|
@ -0,0 +1,53 @@
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
|
from kennel.app import logger
|
||||||
|
from kennel.engine.entities.entity import EntityManager
|
||||||
|
from kennel.engine.systems.system import SystemManager
|
||||||
|
|
||||||
|
|
||||||
|
class Game:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
entity_manager: EntityManager,
|
||||||
|
system_manager: SystemManager,
|
||||||
|
min_time_step: float,
|
||||||
|
):
|
||||||
|
self.entity_manager = entity_manager
|
||||||
|
self.system_manager = system_manager
|
||||||
|
self.min_time_step = min_time_step
|
||||||
|
|
||||||
|
self.last_time = time.time()
|
||||||
|
self.running = False
|
||||||
|
|
||||||
|
async def update(self, delta_time: float) -> None:
|
||||||
|
self.entity_manager.update()
|
||||||
|
await self.system_manager.update(self.entity_manager, delta_time)
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
logger.info("Game started")
|
||||||
|
self.running = True
|
||||||
|
self.last_time = time.time()
|
||||||
|
steps_since_log = 0
|
||||||
|
|
||||||
|
while self.running:
|
||||||
|
current_time = time.time()
|
||||||
|
if (int(current_time) - int(self.last_time)) == 1 and int(
|
||||||
|
current_time
|
||||||
|
) % 10 == 0:
|
||||||
|
logger.info(f"Game loop: {steps_since_log} steps since last log time")
|
||||||
|
steps_since_log = 0
|
||||||
|
|
||||||
|
delta_time = current_time - self.last_time
|
||||||
|
self.last_time = current_time
|
||||||
|
|
||||||
|
await self.update(delta_time)
|
||||||
|
sleep_time = max(self.min_time_step - (time.time() - current_time), 0)
|
||||||
|
if sleep_time > 0:
|
||||||
|
await asyncio.sleep(sleep_time)
|
||||||
|
|
||||||
|
steps_since_log += 1
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self.running = False
|
||||||
|
logger.info("Game stopped")
|
|
@ -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
|
|
@ -0,0 +1,165 @@
|
||||||
|
import asyncio
|
||||||
|
from abc import abstractmethod
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
from kennel.app import logger
|
||||||
|
from kennel.engine.entities.entity import Entity, EntityManager
|
||||||
|
|
||||||
|
from .system import System, SystemType
|
||||||
|
|
||||||
|
|
||||||
|
class EventType(str, Enum):
|
||||||
|
INITIAL_STATE = "INITIAL_STATE"
|
||||||
|
SET_CONTROLLABLE = "SET_CONTROLLABLE"
|
||||||
|
ENTITY_BORN = "ENTITY_BORN"
|
||||||
|
ENTITY_DEATH = "ENTITY_DEATH"
|
||||||
|
ENTITY_POSITION_UPDATE = "ENTITY_POSITION_UPDATE"
|
||||||
|
|
||||||
|
|
||||||
|
class Event:
|
||||||
|
def __init__(self, event_type: EventType, data: dict):
|
||||||
|
self.event_type = event_type
|
||||||
|
self.data = data
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"Event({self.event_type}, {self.data})"
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {"event_type": self.event_type, "data": self.data}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
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: Dict[str, 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": 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: List[Event]):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class UpstreamEventProcessor:
|
||||||
|
@abstractmethod
|
||||||
|
def accept(
|
||||||
|
self, entity_manager: EntityManager, client_event: tuple[Event, str]
|
||||||
|
) -> Optional[Event]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class NetworkSystem(System):
|
||||||
|
event_processor: UpstreamEventProcessor
|
||||||
|
sever_events: List[Event]
|
||||||
|
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.clients = {}
|
||||||
|
|
||||||
|
async def update(self, entity_manager: EntityManager, delta_time: float) -> None:
|
||||||
|
for event in self.client_upstream_events:
|
||||||
|
produced_event = self.upstream_event_processor.accept(entity_manager, event)
|
||||||
|
if produced_event is not None:
|
||||||
|
self.server_events.append(produced_event)
|
||||||
|
promises = [
|
||||||
|
client.publish(self.server_events + events)
|
||||||
|
for client, events in self.clients.values()
|
||||||
|
if len(self.server_events + events) > 0
|
||||||
|
]
|
||||||
|
await asyncio.gather(*promises)
|
||||||
|
|
||||||
|
self.server_events = []
|
||||||
|
self.client_upstream_events = []
|
||||||
|
for client_id in self.clients.keys():
|
||||||
|
(client, _) = self.clients[client_id]
|
||||||
|
self.clients[client_id] = (client, [])
|
||||||
|
|
||||||
|
def server_global_event(self, event: Event) -> None:
|
||||||
|
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:
|
||||||
|
self.clients[client_id] = (client, [])
|
||||||
|
|
||||||
|
def remove_client(self, client_id: str) -> None:
|
||||||
|
del self.clients[client_id]
|
|
@ -0,0 +1,34 @@
|
||||||
|
from abc import abstractmethod
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from kennel.engine.entities.entity import EntityManager
|
||||||
|
|
||||||
|
|
||||||
|
class SystemType(str, Enum):
|
||||||
|
NETWORK = "NETWORK"
|
||||||
|
WORLD = "WORLD"
|
||||||
|
MARKOV = "MARKOV"
|
||||||
|
|
||||||
|
|
||||||
|
class System:
|
||||||
|
def __init__(self, system_type: SystemType):
|
||||||
|
self.system_type = system_type
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def update(self, entity_manager: EntityManager, delta_time: float) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SystemManager:
|
||||||
|
def __init__(self):
|
||||||
|
self.systems = {}
|
||||||
|
|
||||||
|
def add_system(self, system: System) -> None:
|
||||||
|
self.systems[system.system_type] = system
|
||||||
|
|
||||||
|
def get_system(self, system_type: SystemType) -> System:
|
||||||
|
return self.systems.get(system_type)
|
||||||
|
|
||||||
|
async def update(self, entity_manager: EntityManager, delta_time: float) -> None:
|
||||||
|
for system in self.systems.values():
|
||||||
|
await system.update(entity_manager, delta_time)
|
|
@ -0,0 +1,24 @@
|
||||||
|
from kennel.app import logger
|
||||||
|
from kennel.engine.components.component import ComponentType
|
||||||
|
from kennel.engine.entities.entity import EntityManager
|
||||||
|
from kennel.engine.systems.system import System, SystemType
|
||||||
|
|
||||||
|
|
||||||
|
class WorldSystem(System):
|
||||||
|
def __init__(self, width: int, height: int):
|
||||||
|
super().__init__(SystemType.WORLD)
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
|
||||||
|
async def update(self, entity_manager: EntityManager, delta_time: float):
|
||||||
|
entities = entity_manager.get_entities_with_component(ComponentType.POSITION)
|
||||||
|
for entity in entities:
|
||||||
|
position = entity.get_component(ComponentType.POSITION)
|
||||||
|
if position is None:
|
||||||
|
logger.error(f"Entity {entity} has no position component")
|
||||||
|
continue
|
||||||
|
|
||||||
|
position.x = max(0, min(self.width, position.x))
|
||||||
|
position.y = max(0, min(self.height, position.y))
|
||||||
|
|
||||||
|
entity.add_component(position)
|
|
@ -0,0 +1,143 @@
|
||||||
|
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
|
||||||
|
|
||||||
|
from .app import logger
|
||||||
|
|
||||||
|
entity_manager = EntityManager()
|
||||||
|
system_manager = SystemManager()
|
||||||
|
|
||||||
|
|
||||||
|
class KennelEventProcessor(UpstreamEventProcessor):
|
||||||
|
def accept(
|
||||||
|
self, entity_manager: EntityManager, event: tuple[Event, str]
|
||||||
|
) -> Optional[Event]:
|
||||||
|
client_event, client_id = event
|
||||||
|
if client_event.event_type == EventType.ENTITY_POSITION_UPDATE:
|
||||||
|
return self._process_entity_position_update(
|
||||||
|
entity_manager, client_event, client_id
|
||||||
|
)
|
||||||
|
|
||||||
|
def _process_entity_position_update(
|
||||||
|
self,
|
||||||
|
entity_manager: EntityManager,
|
||||||
|
event: EntityPositionUpdateEvent,
|
||||||
|
client_id: str,
|
||||||
|
) -> Optional[Event]:
|
||||||
|
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 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)
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
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}, 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, added.spritesheet)
|
||||||
|
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]:
|
||||||
|
laser = Laser(uuid.uuid4().hex[:10], session)
|
||||||
|
return [laser]
|
|
@ -0,0 +1,49 @@
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
class KennelCat:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
id: str,
|
||||||
|
user_id: str,
|
||||||
|
name: str,
|
||||||
|
link: str,
|
||||||
|
description: str,
|
||||||
|
spritesheet: str,
|
||||||
|
created_at: datetime,
|
||||||
|
):
|
||||||
|
self.id = id
|
||||||
|
self.user_id = user_id
|
||||||
|
self.name = name
|
||||||
|
self.link = link
|
||||||
|
self.description = description
|
||||||
|
self.spritesheet = spritesheet
|
||||||
|
self.created_at = created_at
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(dict: dict) -> "KennelCat":
|
||||||
|
return KennelCat(
|
||||||
|
id=dict["id"],
|
||||||
|
user_id=dict["user_id"],
|
||||||
|
name=dict["name"],
|
||||||
|
link=dict["link"],
|
||||||
|
description=dict["description"],
|
||||||
|
spritesheet=dict["spritesheet"],
|
||||||
|
created_at=datetime.fromisoformat(dict["created_at"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class KennelCatService:
|
||||||
|
def __init__(self, hc_endpoint: str):
|
||||||
|
self.hc_endpoint = hc_endpoint
|
||||||
|
|
||||||
|
def get_kennel(self) -> List[KennelCat]:
|
||||||
|
response = requests.get(f"{self.hc_endpoint}/kennel")
|
||||||
|
response.raise_for_status()
|
||||||
|
cats = [KennelCat.from_dict(cat) for cat in response.json()]
|
||||||
|
for cat in cats:
|
||||||
|
cat.spritesheet = self.hc_endpoint + cat.spritesheet
|
||||||
|
return cats
|
182
kennel/main.py
182
kennel/main.py
|
@ -1,57 +1,151 @@
|
||||||
|
import asyncio
|
||||||
import uuid
|
import uuid
|
||||||
|
from typing import Annotated, List, Optional
|
||||||
|
|
||||||
import structlog
|
from fastapi import (
|
||||||
from fastapi import FastAPI, Request, Response
|
Cookie,
|
||||||
from fastapi.staticfiles import StaticFiles
|
Depends,
|
||||||
from fastapi.templating import Jinja2Templates
|
Request,
|
||||||
|
Response,
|
||||||
app = FastAPI(
|
WebSocket,
|
||||||
servers = [
|
WebSocketDisconnect,
|
||||||
{"url": "https://kennel.hatecomputers.club", "description": "prod"}
|
WebSocketException,
|
||||||
]
|
status,
|
||||||
)
|
)
|
||||||
logger = structlog.get_logger()
|
from fastapi.responses import FileResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from kennel.app import app, logger
|
||||||
|
from kennel.config import config
|
||||||
|
from kennel.engine.systems.network import (
|
||||||
|
EntityBornEvent,
|
||||||
|
Event,
|
||||||
|
EventType,
|
||||||
|
InitialStateEvent,
|
||||||
|
Publishable,
|
||||||
|
SetControllableEvent,
|
||||||
|
)
|
||||||
|
from kennel.engine.systems.system import SystemType
|
||||||
|
from kennel.kennel import (
|
||||||
|
create_session_controllable_entities,
|
||||||
|
entity_manager,
|
||||||
|
kennel,
|
||||||
|
kennel_cats_manager,
|
||||||
|
system_manager,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.mount("/static", StaticFiles(directory="static/dist"), name="static")
|
||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
loop = asyncio.get_event_loop()
|
||||||
async def logger_middleware(request: Request, call_next):
|
|
||||||
structlog.contextvars.clear_contextvars()
|
|
||||||
structlog.contextvars.bind_contextvars(
|
|
||||||
path=request.url.path,
|
|
||||||
method=request.method,
|
|
||||||
client_host=request.client.host,
|
|
||||||
request_id=str(uuid.uuid4()),
|
|
||||||
)
|
|
||||||
response = await call_next(request)
|
|
||||||
|
|
||||||
structlog.contextvars.bind_contextvars(
|
|
||||||
status_code=response.status_code,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Exclude /healthcheck endpoint from producing logs
|
@app.on_event("startup")
|
||||||
if request.url.path != "/healthcheck":
|
async def startup_event():
|
||||||
if 400 <= response.status_code < 500:
|
logger.info("Starting Kennel...")
|
||||||
logger.warn("Client error")
|
loop.create_task(kennel.run())
|
||||||
elif response.status_code >= 500:
|
loop.create_task(kennel_cats_manager.start())
|
||||||
logger.error("Server error")
|
|
||||||
else:
|
|
||||||
logger.info("OK")
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
templates = Jinja2Templates(directory="templates")
|
@app.on_event("shutdown")
|
||||||
|
async def shutdown_event():
|
||||||
|
logger.info("Stopping Kennel...")
|
||||||
|
kennel.stop()
|
||||||
|
kennel_cats_manager.stop()
|
||||||
|
loop.stop()
|
||||||
|
logger.info("Kennel stopped")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def index(request: Request):
|
||||||
|
return FileResponse("static/dist/index.html", media_type="text/html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/assign")
|
||||||
|
async def assign(
|
||||||
|
response: Response,
|
||||||
|
session: Annotated[Optional[str], Cookie()] = None,
|
||||||
|
):
|
||||||
|
if session is None:
|
||||||
|
session = str(uuid.uuid4().hex)
|
||||||
|
response.set_cookie(key="session", value=session, max_age=config.COOKIE_MAX_AGE)
|
||||||
|
return {"session": session}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_cookie_or_token(
|
||||||
|
websocket: WebSocket,
|
||||||
|
session: Annotated[str | None, Cookie()] = None,
|
||||||
|
):
|
||||||
|
if session is None:
|
||||||
|
raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION)
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketClient(Publishable):
|
||||||
|
def __init__(self, websocket: WebSocket):
|
||||||
|
self.websocket = websocket
|
||||||
|
|
||||||
|
async def publish(self, events: List[Event]):
|
||||||
|
await self.websocket.send_json([event.to_dict() for event in events])
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/ws")
|
||||||
|
async def websocket_endpoint(
|
||||||
|
websocket: WebSocket,
|
||||||
|
session: Annotated[str, Depends(get_cookie_or_token)],
|
||||||
|
):
|
||||||
|
await websocket.accept()
|
||||||
|
logger.info(f"Websocket connection established for session {session}")
|
||||||
|
|
||||||
|
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:
|
||||||
|
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:
|
||||||
|
logger.info(f"Adding entity {entity.id} for session {session}")
|
||||||
|
entity_manager.add_entity(entity)
|
||||||
|
|
||||||
|
network_system.server_global_event(EntityBornEvent(entity))
|
||||||
|
network_system.client_downstream_event(
|
||||||
|
session, SetControllableEvent(entity.id, session)
|
||||||
|
)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
message = await websocket.receive_json()
|
||||||
|
if not isinstance(message, 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_upstream_event(session, event)
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
logger.info(f"Websocket connection closed by client: {session}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Exception occurred", exc_info=e)
|
||||||
|
finally:
|
||||||
|
network_system.remove_client(session)
|
||||||
|
for entity in session_entities:
|
||||||
|
logger.info(f"Removing entity {entity.id} for session {session}")
|
||||||
|
entity_manager.remove_entity(entity.id)
|
||||||
|
network_system.add_event(Event(EventType.ENTITY_DEATH, {"id": entity.id}))
|
||||||
|
|
||||||
|
await websocket.close()
|
||||||
|
logger.info("Websocket connection closed")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/healthcheck")
|
@app.get("/healthcheck")
|
||||||
async def healthcheck():
|
async def healthcheck():
|
||||||
return Response("hello")
|
return Response("healthy")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
|
||||||
async def read_main(request: Request):
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request=request, name="index.html"
|
|
||||||
)
|
|
||||||
|
|
||||||
app.mount("/static", StaticFiles(directory = "static"), name = "static")
|
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from .app import app, logger
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def logger_middleware(request: Request, call_next):
|
||||||
|
structlog.contextvars.clear_contextvars()
|
||||||
|
structlog.contextvars.bind_contextvars(
|
||||||
|
path=request.url.path,
|
||||||
|
method=request.method,
|
||||||
|
client_host=request.client.host,
|
||||||
|
request_id=str(uuid.uuid4()),
|
||||||
|
)
|
||||||
|
response = await call_next(request)
|
||||||
|
|
||||||
|
structlog.contextvars.bind_contextvars(
|
||||||
|
status_code=response.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Exclude /healthcheck endpoint from producing logs
|
||||||
|
if request.url.path != "/healthcheck":
|
||||||
|
if 400 <= response.status_code < 500:
|
||||||
|
logger.warn("Client error")
|
||||||
|
elif response.status_code >= 500:
|
||||||
|
logger.error("Server error")
|
||||||
|
else:
|
||||||
|
logger.info("OK")
|
||||||
|
|
||||||
|
return response
|
File diff suppressed because it is too large
Load Diff
|
@ -11,13 +11,13 @@ python = "^3.12"
|
||||||
fastapi = "^0.108.0"
|
fastapi = "^0.108.0"
|
||||||
structlog = "^23.2.0"
|
structlog = "^23.2.0"
|
||||||
uvicorn = { extras = ["standard"], version = "^0.25.0" }
|
uvicorn = { extras = ["standard"], version = "^0.25.0" }
|
||||||
jinja2 = "^3.1.4"
|
python-dotenv = "^1.0.1"
|
||||||
|
websockets = "^12.0"
|
||||||
|
requests = "^2.32.3"
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
[tool.poetry.group.dev.dependencies]
|
||||||
ruff = "^0.1.9"
|
ruff = "^0.1.9"
|
||||||
httpx = "^0.26.0"
|
httpx = "^0.26.0"
|
||||||
pytest = "^7.4.3"
|
|
||||||
pytest-cov = "^4.1.0"
|
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core"]
|
requires = ["poetry-core"]
|
||||||
|
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
|
@ -0,0 +1,22 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="icon" href=
|
||||||
|
"data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🐶</text></svg>">
|
||||||
|
<meta name="viewport" content=
|
||||||
|
"width=device-width, initial-scale=1.0">
|
||||||
|
<title>the kennel.</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<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>
|
|
@ -1,5 +0,0 @@
|
||||||
window.onload = () => {
|
|
||||||
console.log('from js');
|
|
||||||
const kennelWindowEle = document.querySelector('#kennel-window');
|
|
||||||
kennelWindowEle.innerHTML = 'rendered from static/index.js';
|
|
||||||
}
|
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"name": "kennel",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"dev": "./node_modules/nodemon/bin/nodemon.js --watch './src/**/*' -e ts,html --exec \"npm run build\""
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@rollup/plugin-inject": "^5.0.5",
|
||||||
|
"@types/jquery": "^3.5.30",
|
||||||
|
"nodemon": "^3.1.4",
|
||||||
|
"typescript": "^5.5.3",
|
||||||
|
"vite": "^5.4.1",
|
||||||
|
"vite-plugin-dynamic-base": "^1.1.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"jquery": "^3.7.1",
|
||||||
|
"laser-pen": "^1.0.1"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
After Width: | Height: | Size: 1.5 KiB |
|
@ -0,0 +1,46 @@
|
||||||
|
export enum ComponentType {
|
||||||
|
POSITION = "POSITION",
|
||||||
|
RENDERABLE = "RENDERABLE",
|
||||||
|
TRAILING_POSITION = "TRAILING_POSITION",
|
||||||
|
SPRITESHEET = "SPRITESHEET",
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpriteSpec {
|
||||||
|
ms_per_frame: number;
|
||||||
|
frames: number;
|
||||||
|
top_x: number;
|
||||||
|
top_y: number;
|
||||||
|
end_x: number;
|
||||||
|
end_y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpriteSheetComponent extends Component {
|
||||||
|
name: ComponentType.SPRITESHEET;
|
||||||
|
source: string;
|
||||||
|
sheet?: HTMLImageElement;
|
||||||
|
|
||||||
|
current_frame: number;
|
||||||
|
last_update: number;
|
||||||
|
current_state: string;
|
||||||
|
|
||||||
|
state_to_spritespec: Record<string, SpriteSpec>;
|
||||||
|
}
|
|
@ -0,0 +1,46 @@
|
||||||
|
export class DebouncePublisher<T> {
|
||||||
|
private last_event_time = Date.now();
|
||||||
|
private unpublished_data: T | undefined;
|
||||||
|
private interval_id: number | undefined;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly publisher: (data: T) => void | Promise<void>,
|
||||||
|
private readonly debounce_ms = 100,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public start() {
|
||||||
|
if (typeof this.interval_id !== "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.interval_id = setInterval(
|
||||||
|
() => this.debounce_publish(),
|
||||||
|
this.debounce_ms,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public stop() {
|
||||||
|
if (this.interval_id === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearInterval(this.interval_id);
|
||||||
|
delete this.interval_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public update(data: T) {
|
||||||
|
this.unpublished_data = data;
|
||||||
|
this.debounce_publish();
|
||||||
|
}
|
||||||
|
|
||||||
|
private debounce_publish() {
|
||||||
|
if (
|
||||||
|
Date.now() - this.last_event_time < this.debounce_ms ||
|
||||||
|
typeof this.unpublished_data === "undefined"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.last_event_time = Date.now();
|
||||||
|
this.publisher(this.unpublished_data);
|
||||||
|
this.unpublished_data = undefined;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,39 @@
|
||||||
|
import {
|
||||||
|
Component,
|
||||||
|
ComponentType,
|
||||||
|
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;
|
||||||
|
return base;
|
||||||
|
};
|
|
@ -0,0 +1,107 @@
|
||||||
|
import { Entity } from "./entity";
|
||||||
|
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: Record<string, Entity>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetControllableEvent extends Event {
|
||||||
|
event_type: EventType.SET_CONTROLLABLE;
|
||||||
|
data: {
|
||||||
|
id: string;
|
||||||
|
client_id: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }) => {
|
||||||
|
this.queue = this.queue.concat(JSON.parse(data));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WebsocketEventPublisher implements EventPublisher {
|
||||||
|
private queue: Event[];
|
||||||
|
|
||||||
|
constructor(private readonly websocket: WebSocket) {
|
||||||
|
this.queue = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public add(event: Event) {
|
||||||
|
this.queue.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
public publish() {
|
||||||
|
if (this.queue.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.websocket.send(JSON.stringify(this.queue));
|
||||||
|
this.queue = [];
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,120 @@
|
||||||
|
import {
|
||||||
|
ComponentType,
|
||||||
|
PositionComponent,
|
||||||
|
TrailingPositionComponent,
|
||||||
|
SpriteSheetComponent,
|
||||||
|
SpriteSpec,
|
||||||
|
} 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 &&
|
||||||
|
ComponentType.SPRITESHEET in entity.components
|
||||||
|
) {
|
||||||
|
const position = entity.components[
|
||||||
|
ComponentType.POSITION
|
||||||
|
] as PositionComponent;
|
||||||
|
const spritesheet = entity.components[
|
||||||
|
ComponentType.SPRITESHEET
|
||||||
|
] as SpriteSheetComponent;
|
||||||
|
|
||||||
|
if (typeof spritesheet.sheet === "undefined") {
|
||||||
|
const img = new Image();
|
||||||
|
img.src = spritesheet.source;
|
||||||
|
spritesheet.sheet = img;
|
||||||
|
}
|
||||||
|
const spritespec =
|
||||||
|
spritesheet.state_to_spritespec[spritesheet.current_state];
|
||||||
|
|
||||||
|
this.blit_sprite(ctx, spritesheet, position);
|
||||||
|
|
||||||
|
spritesheet.last_update += dt;
|
||||||
|
if (spritesheet.last_update > spritespec.ms_per_frame) {
|
||||||
|
spritesheet.current_frame++;
|
||||||
|
spritesheet.current_frame %= spritespec.frames;
|
||||||
|
spritesheet.last_update = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private blit_sprite(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
spritesheet: SpriteSheetComponent,
|
||||||
|
position: { x: number; y: number },
|
||||||
|
) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(position.x, position.y);
|
||||||
|
ctx.translate(-position.x, -position.y);
|
||||||
|
|
||||||
|
if (typeof spritesheet.sheet === "undefined") return;
|
||||||
|
|
||||||
|
const spritespec =
|
||||||
|
spritesheet.state_to_spritespec[spritesheet.current_state];
|
||||||
|
|
||||||
|
ctx.drawImage(
|
||||||
|
spritesheet.sheet,
|
||||||
|
...this.get_sprite_args(spritesheet.current_frame, spritespec),
|
||||||
|
...this.get_draw_args(spritespec, position),
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
private get_sprite_args(
|
||||||
|
current_frame: number,
|
||||||
|
sprite_spec: SpriteSpec,
|
||||||
|
): [sx: number, sy: number, sw: number, sh: number] {
|
||||||
|
const [width, height] = this.get_dimensions(sprite_spec);
|
||||||
|
return [
|
||||||
|
sprite_spec.top_x,
|
||||||
|
sprite_spec.top_y + current_frame * height,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private get_draw_args(
|
||||||
|
sprite_spec: SpriteSpec,
|
||||||
|
position: { x: number; y: number },
|
||||||
|
): [dx: number, dy: number, dw: number, dh: number] {
|
||||||
|
const [width, height] = this.get_dimensions(sprite_spec);
|
||||||
|
return [position.x - width / 2, position.y - height / 2, width, height];
|
||||||
|
}
|
||||||
|
|
||||||
|
private get_dimensions(sprite_spec: SpriteSpec): [number, number] {
|
||||||
|
return [
|
||||||
|
sprite_spec.end_x - sprite_spec.top_x,
|
||||||
|
(sprite_spec.end_y - sprite_spec.top_y) / sprite_spec.frames,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,29 @@
|
||||||
|
import { ComponentType, TrailingPositionComponent } from "./component";
|
||||||
|
import { Game } from "./game";
|
||||||
|
import { System, SystemType } from "./system";
|
||||||
|
|
||||||
|
interface Point {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
time: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TrailingPositionSystem extends System {
|
||||||
|
constructor(
|
||||||
|
private readonly point_filter: (trail_point: Array<Point>) => Array<Point>
|
||||||
|
) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,51 @@
|
||||||
|
import $ from "jquery";
|
||||||
|
import {
|
||||||
|
EventPublisher,
|
||||||
|
EventQueue,
|
||||||
|
WebsocketEventPublisher,
|
||||||
|
WebSocketEventQueue,
|
||||||
|
} 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, setRoundCap } from "laser-pen";
|
||||||
|
|
||||||
|
$(async () => {
|
||||||
|
const client_id = await fetch("/assign", {
|
||||||
|
credentials: "include",
|
||||||
|
})
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then(({ session }) => session);
|
||||||
|
|
||||||
|
const ws = new WebSocket("/ws");
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
ws.onopen = () => resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const queue: EventQueue = new WebSocketEventQueue(ws);
|
||||||
|
const publisher: EventPublisher = new WebsocketEventPublisher(ws);
|
||||||
|
const network_system = new NetworkSystem(queue, publisher);
|
||||||
|
|
||||||
|
const gamecanvas = $("#gamecanvas").get(0)! as HTMLCanvasElement;
|
||||||
|
const input_system = new InputSystem(publisher, gamecanvas);
|
||||||
|
|
||||||
|
setDelay(500);
|
||||||
|
setRoundCap(true);
|
||||||
|
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();
|
||||||
|
});
|
|
@ -0,0 +1,3 @@
|
||||||
|
h1 {
|
||||||
|
color: red;
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { fileURLToPath, URL } from "node:url";
|
||||||
|
import { dynamicBase } from "vite-plugin-dynamic-base";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import inject from "@rollup/plugin-inject";
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
dynamicBase({
|
||||||
|
// dynamic public path var string, default window.__dynamic_base__
|
||||||
|
publicPath: "",
|
||||||
|
// dynamic load resources on index.html, default false. maybe change default true
|
||||||
|
transformIndexHtml: false,
|
||||||
|
}),
|
||||||
|
inject({
|
||||||
|
// => that should be first under plugins array
|
||||||
|
$: "jquery",
|
||||||
|
jQuery: "jquery",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
base: "/static/",
|
||||||
|
});
|
|
@ -1,14 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
|
||||||
<title>Kennel Club</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="kennel-window"></div>
|
|
||||||
<script src="{{ url_for('static', path='/index.js') }}"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
|
@ -1,18 +0,0 @@
|
||||||
from http import HTTPStatus
|
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from kennel.main import app
|
|
||||||
from structlog.testing import capture_logs
|
|
||||||
|
|
||||||
client = TestClient(app)
|
|
||||||
|
|
||||||
|
|
||||||
def test_healthcheck():
|
|
||||||
response = client.get("/healthcheck")
|
|
||||||
assert response.status_code == HTTPStatus.OK
|
|
||||||
assert response.text == "hello"
|
|
||||||
|
|
||||||
def test_main():
|
|
||||||
response = client.get("/")
|
|
||||||
assert response.status_code == HTTPStatus.OK
|
|
||||||
assert response.text.startswith("<!DOCTYPE html>")
|
|
Loading…
Reference in New Issue