woaj
continuous-integration/drone/pr Build is passing Details

This commit is contained in:
Elizabeth Hunt 2024-09-07 20:20:07 -07:00
parent e4e31978ba
commit e47d451426
Signed by: simponic
GPG Key ID: 2909B9A7FF6213EE
17 changed files with 501 additions and 139 deletions

View File

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

View File

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

View File

@ -18,6 +18,7 @@
"vite-plugin-dynamic-base": "^1.1.0" "vite-plugin-dynamic-base": "^1.1.0"
}, },
"dependencies": { "dependencies": {
"jquery": "^3.7.1" "jquery": "^3.7.1",
"laser-pen": "^1.0.1"
} }
} }

View File

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

View File

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

View File

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

View File

@ -0,0 +1,30 @@
import {
Component,
ComponentType,
RenderableComponent,
TrailingPositionComponent,
} from "./component";
export enum EntityType {
LASER = "LASER",
}
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;
};

View File

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

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

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

View File

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

View File

@ -0,0 +1,135 @@
import {
ComponentType,
PositionComponent,
TrailingPositionComponent,
} from "./component";
import { 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);
}
return entity;
}
private process_entity_death_event(event: EntityDeathEvent, game: Game) {
console.log("an entity died D:", event);
const { id } = event.data;
game.remove_entity(id);
}
private process_set_controllable_event(
event: SetControllableEvent,
game: Game,
) {
console.log("got a controllable event", event);
if (event.data.client_id !== game.client_id) {
console.warn("got controllable event for client that is not us");
return;
}
const input_system = game.get_system<InputSystem>(SystemType.INPUT)!;
input_system.add_controllable_entity(event.data.id);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,14 +0,0 @@
export class Vec2 {
constructor(
public readonly x: number,
public readonly y: number,
) {}
public distance_to(that: Vec2): number {
return Math.sqrt((this.x - that.x) ** 2 + (this.y - that.y) ** 2);
}
public copy(): Vec2 {
return new Vec2(this.x, this.y);
}
}