WIP: ECS / Network System #1
|
@ -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:
|
||||
|
|
|
@ -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
|
180
kennel/main.py
180
kennel/main.py
|
@ -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")
|
||||
|
|
|
@ -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"
|
||||
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"]
|
||||
|
|
|
@ -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