WIP: ECS / Network System #1

Draft
simponic wants to merge 13 commits from websockets into main
50 changed files with 3928 additions and 639 deletions

View File

@ -4,12 +4,12 @@ type: docker
name: build
steps:
- name: run tests
- name: lint
image: python:3.12
commands:
- pip install poetry
- poetry install --with main,dev
- poetry run pytest
- poetry run ruff check kennel/*
trigger:
event:
@ -21,12 +21,12 @@ type: docker
name: deploy
steps:
- name: run tests
- name: run lier
image: python:3.12
commands:
- pip install poetry
- poetry install --with main,dev
- poetry run pytest
- poetry run ruff check kennel/*
- name: docker
image: plugins/docker
settings:

View File

@ -1,2 +0,0 @@
*.sqlite
*.db

8
kennel/app.py Normal file
View File

@ -0,0 +1,8 @@
import structlog
from fastapi import FastAPI
app = FastAPI(
servers=[{"url": "https://kennel.hatecomputers.club", "description": "prod"}]
)
logger = structlog.get_logger()

23
kennel/config.py Normal file
View File

@ -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()

View File

@ -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

View File

@ -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 {}

View File

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

View File

@ -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}

View File

@ -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()
},
}

View File

@ -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],
# ]
#
#
#
#

View File

@ -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()}

View File

@ -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)

53
kennel/engine/game.py Normal file
View File

@ -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")

View File

View File

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

View File

@ -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]

View File

@ -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)

View File

@ -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)

143
kennel/kennel.py Normal file
View File

@ -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]

49
kennel/kennelcats.py Normal file
View File

@ -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

View File

@ -1,57 +1,151 @@
import asyncio
import uuid
from typing import Annotated, List, Optional
import structlog
from fastapi import FastAPI, Request, Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
app = FastAPI(
servers = [
{"url": "https://kennel.hatecomputers.club", "description": "prod"}
]
from fastapi import (
Cookie,
Depends,
Request,
Response,
WebSocket,
WebSocketDisconnect,
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")
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)
loop = asyncio.get_event_loop()
structlog.contextvars.bind_contextvars(
status_code=response.status_code,
@app.on_event("startup")
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")
@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()
),
)
# 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")
for entity in session_entities:
logger.info(f"Adding entity {entity.id} for session {session}")
entity_manager.add_entity(entity)
return response
network_system.server_global_event(EntityBornEvent(entity))
network_system.client_downstream_event(
session, SetControllableEvent(entity.id, session)
)
templates = Jinja2Templates(directory="templates")
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")
async def healthcheck():
return Response("hello")
@app.get("/")
async def read_main(request: Request):
return templates.TemplateResponse(
request=request, name="index.html"
)
app.mount("/static", StaticFiles(directory = "static"), name = "static")
return Response("healthy")

33
kennel/middleware.py Normal file
View File

@ -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

1007
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -11,13 +11,13 @@ python = "^3.12"
fastapi = "^0.108.0"
structlog = "^23.2.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]
ruff = "^0.1.9"
httpx = "^0.26.0"
pytest = "^7.4.3"
pytest-cov = "^4.1.0"
[build-system]
requires = ["poetry-core"]

24
static/.gitignore vendored Normal file
View File

@ -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?

22
static/index.html Normal file
View File

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href=
"data:image/svg+xml,&lt;svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22&gt;&lt;text y=%22.9em%22 font-size=%2290%22&gt;🐶&lt;/text&gt;&lt;/svg&gt;">
<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>

View File

@ -1,5 +0,0 @@
window.onload = () => {
console.log('from js');
const kennelWindowEle = document.querySelector('#kennel-window');
kennelWindowEle.innerHTML = 'rendered from static/index.js';
}

1601
static/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
static/package.json Normal file
View File

@ -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"
}
}

1
static/public/vite.svg Normal file
View File

@ -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

View File

@ -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>;
}

View File

@ -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;
}
}

View File

@ -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;
};

107
static/src/engine/events.ts Normal file
View File

@ -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 = [];
}
}

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

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

View File

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

View File

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

120
static/src/engine/render.ts Normal file
View File

@ -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,
];
}
}

View File

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

View File

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

51
static/src/main.ts Normal file
View File

@ -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();
});

3
static/src/style.css Normal file
View File

@ -0,0 +1,3 @@
h1 {
color: red;
}

1
static/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

23
static/tsconfig.json Normal file
View File

@ -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"]
}

27
static/vite.config.ts Normal file
View File

@ -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/",
});

View File

@ -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>

View File

@ -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>")