From f2e5d1300090603970e48cc9f3177909a3e77f81 Mon Sep 17 00:00:00 2001 From: Matthieu Jolimaitre Date: Tue, 9 Apr 2024 04:25:34 +0200 Subject: [PATCH] ecs refactor --- client/main.ts | 2 +- common/utils.ts | 97 ++++++++++++++++- server/components/display.ts | 6 ++ server/components/world.ts | 101 ++++++++++++++++++ server/engine.ts | 200 ++++++++++++++++++----------------- server/entities/enemy.ts | 52 ++++----- server/entities/player.ts | 95 +++++++++++------ server/main.ts | 27 +++-- 8 files changed, 410 insertions(+), 170 deletions(-) create mode 100644 server/components/display.ts create mode 100644 server/components/world.ts diff --git a/client/main.ts b/client/main.ts index 362af52..44c5720 100755 --- a/client/main.ts +++ b/client/main.ts @@ -55,7 +55,7 @@ class InputHandler { const command = new Deno.Command("stdbuf", { args, stdin: "inherit", stdout: "piped" }); const output = await command.output(); const result = new TextDecoder().decode(output.stdout); - console.log(`${String.fromCharCode(0x08)} `); + console.log(`${String.fromCharCode(0x08)} ${String.fromCharCode(0o33)}[A`); return result[0]; } diff --git a/common/utils.ts b/common/utils.ts index 159d4e8..c9b4539 100644 --- a/common/utils.ts +++ b/common/utils.ts @@ -195,9 +195,9 @@ export class Vec2 { return this.add(other.neg()); } - public inside(corner_tl: Vec2, corner_br: Vec2) { - if (!range(corner_tl.x(), corner_br.x()).includes(this.x())) return false; - if (!range(corner_tl.y(), corner_br.y()).includes(this.y())) return false; + public inside(min: Vec2, max: Vec2) { + if (!range(min.x(), max.x()).includes(this.x())) return false; + if (!range(min.y(), max.y()).includes(this.y())) return false; return true; } @@ -220,6 +220,10 @@ export class Vec2 { public overlaps(other: Vec2) { return this.distance(other) < 1; } + + public clone() { + return new Vec2(this.x(), this.y()); + } } export function v2(x: number, y: number) { @@ -260,7 +264,8 @@ export async function run(cmd: string, ...args: string[]) { } export type Prototyped

= { constructor: { prototype: P } }; -export type Constructible = new (...args: unknown[]) => I; +// deno-lint-ignore no-explicit-any +export type Constructible = new (...args: any[]) => I; export class ClassMap { private inner; @@ -280,6 +285,10 @@ export class ClassMap { assertInstanceOf(value, class_); return value; } + + has>(class_: C) { + return this.inner.has(class_.prototype); + } } Deno.test("test_classmap", () => { @@ -320,3 +329,83 @@ Deno.test("test_classmap", () => { export function maybe(item: T) { return item as T | undefined; } + +export type Awaitable = T | Promise; + +export function first(iter: Iterable) { + for (const item of iter) return item; + return null; +} + +export function take_n(iter: Iterable, n: number) { + const result = [] as T[]; + for (const item of iter) { + if (result.length >= n) break; + result.push(item); + } + return result; +} + +export function* line(from: Vec2, to: Vec2) { + let current = from.clone(); + while (current.neq(to)) { + yield current; + if (current.x() - to.x() > 0.5) current = current.sub(v2(1, 0)); + if (current.x() - to.x() < -0.5) current = current.add(v2(1, 0)); + if (current.y() - to.y() > 0.5) current = current.sub(v2(0, 1)); + if (current.y() - to.y() < -0.5) current = current.add(v2(0, 1)); + } +} + +export function* spiral(origin: Vec2) { + yield origin; + for (const i of range(0, 999)) { + const size = i * 2 + 1; + const corner_A = origin.add(v2(-size / 2, -size / 2)); + const corner_B = origin.add(v2(size / 2, -size / 2)); + const corner_C = origin.add(v2(size / 2, size / 2)); + const corner_D = origin.add(v2(-size / 2, size / 2)); + yield* line(corner_A, corner_B); + yield* line(corner_B, corner_C); + yield* line(corner_C, corner_D); + yield* line(corner_D, corner_A); + } +} + +// Deno.test("test_spiral", () => { +// // 2 | 10 11 12 13 14 +// // 1 | 25 2 3 4 15 +// // 0 | 24 9 1 5 16 +// // -1 | 23 8 7 6 17 +// // -2 | 22 21 20 19 18 +// // ---+--------------- +// // -2 -1 0 1 2 +// const result = [...take_n(spiral(v2(0, 0)), 25)]; +// const expects = [ +// v2(0, 0), +// v2(1, 1), +// v2(), +// v2(), +// v2(), +// v2(), +// v2(), +// v2(), +// v2(), +// v2(), +// v2(), +// v2(), +// v2(), +// v2(), +// v2(), +// v2(), +// v2(), +// v2(), +// v2(), +// v2(), +// v2(), +// v2(), +// v2(), +// v2(), +// v2(), +// ] +// }); diff --git a/server/components/display.ts b/server/components/display.ts new file mode 100644 index 0000000..8004381 --- /dev/null +++ b/server/components/display.ts @@ -0,0 +1,6 @@ +export class CompDisplay { + display; + constructor(display: string) { + this.display = display; + } +} diff --git a/server/components/world.ts b/server/components/world.ts new file mode 100644 index 0000000..2c55f57 --- /dev/null +++ b/server/components/world.ts @@ -0,0 +1,101 @@ +import { spiral } from "../../common/utils.ts"; +import { chunks, enumerate, log_from, Vec2 } from "../../common/utils.ts"; +import { Engine, Entity, Query } from "../engine.ts"; +import { CompDisplay } from "./display.ts"; +const log = log_from(import.meta); + +export class CompPos { + position; + entity; + + constructor(entity: Entity, position: Vec2) { + this.position = position; + this.entity = entity; + } + + move(displacement: Vec2) { + this.position = this.position.add(displacement); + } + + move_collide(engine: Engine, displacement: Vec2) { + let result = false; + for (const step of displacement_steps(this.position, displacement)) { + const collider_exists = engine.query_one(query_at(step)) !== null; + if (collider_exists) return result; + this.position = step; + result = true; + } + return result; + } +} + +function* displacement_steps(position: Vec2, displacement: Vec2) { + let pos = position; + let left = displacement; + while (left.len() >= 1) { + if (left.x() >= 1) { + pos = pos.add(new Vec2(1, 0)); + left = left.sub(new Vec2(1, 0)); + } + if (left.x() <= -1) { + pos = pos.add(new Vec2(-1, 0)); + left = left.sub(new Vec2(-1, 0)); + } + if (left.y() >= 1) { + pos = pos.add(new Vec2(0, 1)); + left = left.sub(new Vec2(0, 1)); + } + if (left.y() <= -1) { + pos = pos.add(new Vec2(0, -1)); + left = left.sub(new Vec2(0, -1)); + } + yield pos; + } +} + +export function query_at(pos: Vec2) { + return Query.filter(CompPos, (c) => c.position.overlaps(pos)); +} + +export function query_in_rect(min: Vec2, max: Vec2) { + return Query.filter(CompPos, (c) => c.position.inside(min, max)); +} + +class CompStructure {} + +export function sys_spawn_obstacle(pos: Vec2, display: string) { + return (engine: Engine) => + engine.spawn((entity) => + entity.insert( + new CompStructure(), + new CompDisplay(display), + new CompPos(entity, pos), + ) + ); +} + +export async function sys_spawn_structure(file_path: string, origin: Vec2) { + const content = await Deno.readTextFile(file_path); + return (engine: Engine) => { + let count = 0; + for (const [y, line] of enumerate(content.split("\n"))) { + for (const [x, chars] of enumerate(chunks(line, 2))) { + const display = chars[0] + chars[1]; + if (display === " ") continue; + engine.run(sys_spawn_obstacle(origin.add(new Vec2(x, y)), display)); + count += 1; + } + } + log("Spawned", count, "tiles from", file_path); + }; +} + +export function sys_find_free_pos(target: Vec2) { + return (engine: Engine) => { + for (const pos of spiral(target)) { + const found = engine.query_one(query_at(pos)); + if (found === null) return pos; + } + throw new Error("Unreachable."); + }; +} diff --git a/server/engine.ts b/server/engine.ts index bf13550..2d5b3b4 100644 --- a/server/engine.ts +++ b/server/engine.ts @@ -1,123 +1,92 @@ import { assertEquals } from "https://deno.land/std@0.221.0/assert/mod.ts"; -import { chunks, enumerate, log_from, Prototyped, range, Vec2 } from "../common/utils.ts"; -import { ClientInterface } from "./network.ts"; -import { Session } from "./entities/player.ts"; +import { Awaitable, log_from, Prototyped, Vec2 } from "../common/utils.ts"; import { ClassMap } from "../common/utils.ts"; -import { maybe } from "../common/utils.ts"; +import { wait } from "../common/utils.ts"; +import { Constructible } from "../common/utils.ts"; +import { first } from "../common/utils.ts"; const log = log_from(import.meta); -export class WorldEntity { - display; - world; +export class Entity { identifier; - position; - compontents; + components; + engine; - constructor(world: World, identifier: number, display: string, position: Vec2, ...components: Prototyped[]) { - this.display = display; - this.world = world; + constructor(identifier: number, engine: Engine) { this.identifier = identifier; - this.position = position; - this.compontents = new ClassMap(); - for (const c of components) this.compontents.insert(c); - } -} - -export class World { - next_id; - entities; - - constructor() { - this.next_id = 0; - this.entities = new Map(); + this.engine = engine; + this.components = new ClassMap(); } - spawn_entity(display: string, pos: Vec2) { - const entity = new WorldEntity(this, this.next_id++, display, pos); - this.entities.set(entity.identifier, entity); - return entity; + insert(...components: Prototyped[]) { + for (const c of components) this.components.insert(c); + return this; } - async spawn_structure(file_path: string, origin: Vec2) { - const content = await Deno.readTextFile(file_path); - let count = 0; - for (const [y, line] of enumerate(content.split("\n"))) { - for (const [x, chars] of enumerate(chunks(line, 2))) { - const display = chars[0] + chars[1]; - if (display === " ") continue; - this.spawn_entity(display, origin.add(new Vec2(x, y))); - count += 1; - } - } - log("Spawned", count, "tiles from", file_path); + get(class_: Constructible) { + return this.components.get(class_); } - render(center: Vec2, size: Vec2) { - const radius = size.scale(0.5); - const result = Array.from(range(0, size.y())).map(() => Array.from(range(0, size.x())).map(() => " ")); - const top_left = center.sub(radius); - const bottom_right = center.add(radius); - for (const entity of this.entities.values()) { - if (!entity.position.inside(top_left, bottom_right)) continue; - const local_pos = entity.position.sub(top_left); - result[local_pos.y()][local_pos.x()] = entity.display; - } - return result.map((line) => line.join("")).toReversed().join("\n"); - } - - move_collide(entity_id: number, displacement: Vec2) { - let result = false; - const entity = this.get_entity(entity_id); - if (entity === undefined) return result; - for (const step of displacement_steps(entity.position, displacement)) { - const collision = this.get_entity_at(step); - if (collision !== undefined) return result; - entity.position = step; - result = true; - } + get_force(class_: Constructible) { + const result = this.get(class_); + if (result === null) throw new Error(); return result; } - - get_entity(entity_id: number) { - return this.entities.get(entity_id); - } - - *get_entities_at(pos: Vec2) { - for (const entity of this.entities.values()) if (entity.position.overlaps(pos)) yield entity; - } - - get_entity_at(pos: Vec2) { - return maybe([...this.get_entities_at(pos)][0]); - } - - *get_entities_in_range(min: Vec2, max: Vec2) { - for (const entity of this.entities.values()) if (entity.position.inside(min, max)) yield entity; - } - - get_entity_in_range(min: Vec2, max: Vec2) { - return maybe([...this.get_entities_in_range(min, max)][0]); - } - - kill(entity_id: number) { - this.entities.delete(entity_id); - } } export class Engine { - world; + next_identifier; + entities; - constructor(world: World) { - this.world = world; + constructor() { + this.next_identifier = 0; + this.entities = new Map(); } - async start() { + start() { log("Started engine."); } - add_session(client: ClientInterface) { - const player_entity = this.world.spawn_entity("`/", new Vec2(0, 0)); - const session = new Session(client, this.world, player_entity); - session.spin(); + spawn(op: (entity: Entity) => unknown) { + const identifier = this.next_identifier++; + const entity = new Entity(identifier, this); + op(entity); + this.entities.set(identifier, entity); + return entity; + } + + run(sys: (engine: Engine) => T) { + return sys(this); + } + + async global_system_loop(sys: (engine: Engine) => Awaitable, interval: number) { + while (true) { + await sys(this); + await wait(interval); + } + } + + *query_all(query: Query) { + yield* query.run(this.entities.values()); + } + + query_one(query: Query) { + return first(this.query_all(query)); + } + + get(identifier: number) { + const value = this.entities.get(identifier); + if (value === undefined) return null; + return value; + } + + get_force(identifier: number) { + const entity = this.get(identifier); + if (entity === null) throw new Error(); + return entity; + } + + delete(identifier: number) { + return this.entities.delete(identifier); } } @@ -151,3 +120,42 @@ Deno.test("test_displacement", () => { [new Vec2(2, 2), new Vec2(3, 3), new Vec2(4, 4), new Vec2(5, 5), new Vec2(5, 6), new Vec2(5, 7)], ); }); + +export class Query { + private test; + + constructor(test: (entity: Entity) => boolean) { + this.test = test; + } + + and(query: Query) { + return new Query((entity) => this.test(entity) && query.test(entity)); + } + + static with(...components: Constructible[]) { + return new Query((entity) => { + for (const comp of components) if (!entity.components.has(comp)) return false; + return true; + }); + } + + with(...components: Constructible[]) { + return this.and(Query.with(...components)); + } + + static filter(component: Constructible, filter: (comp: I, entity: Entity) => boolean) { + return new Query((entity) => { + const comp = entity.get(component); + if (comp === null) return false; + return filter(comp, entity); + }); + } + + filter(component: Constructible, filter: (comp: I, entity: Entity) => boolean) { + return this.and(Query.filter(component, filter)); + } + + *run(entities: Iterable) { + for (const entity of entities) if (this.test(entity)) yield entity; + } +} diff --git a/server/entities/enemy.ts b/server/entities/enemy.ts index cd7b461..134b76d 100644 --- a/server/entities/enemy.ts +++ b/server/entities/enemy.ts @@ -1,33 +1,25 @@ -import { World, WorldEntity } from "../engine.ts"; -import { v2, Vec2 } from "../../common/utils.ts"; -import { wait } from "../../common/utils.ts"; +import { Vec2 } from "../../common/utils.ts"; +import { CompDisplay } from "../components/display.ts"; +import { CompPos } from "../components/world.ts"; +import { Engine } from "../engine.ts"; -export class Enemy { - entity; - alive; - - constructor(entity: WorldEntity) { - this.entity = entity; - this.alive = true; - } - - static spawn(world: World, pos: Vec2) { - const entity = world.spawn_entity("èé", pos); - } - - async spin() { - while (this.alive) { - await wait(500); - const target = this.find_target(); - } - } - - find_target() { - const world = this.entity.world; - const local_origin = this.entity.position; - const found = world.get_entities_in_range(local_origin.sub(v2(3, 3)), local_origin.add(v2(3, 3))); - for (const entity of found) { - // if (entity.compontents.get()) - } +export class CompEnemy { + target; + life; + constructor() { + this.target = null as number | null; + this.life = 10; } } + +export function sys_spawn_enemy(pos: Vec2) { + return (engine: Engine) => { + return engine.spawn((entity) => + entity.insert( + new CompEnemy(), + new CompPos(entity, pos), + new CompDisplay("èé"), + ) + ); + }; +} diff --git a/server/entities/player.ts b/server/entities/player.ts index cd9b66b..a8ffebb 100644 --- a/server/entities/player.ts +++ b/server/entities/player.ts @@ -1,21 +1,32 @@ import { mts } from "../../common/mod.ts"; -import { log_from, Vec2 } from "../../common/utils.ts"; -import { World, WorldEntity } from "../engine.ts"; +import { range } from "../../common/utils.ts"; +import { log_from, v2, Vec2 } from "../../common/utils.ts"; +import { CompDisplay } from "../components/display.ts"; +import { sys_find_free_pos } from "../components/world.ts"; +import { CompPos, query_in_rect } from "../components/world.ts"; +import { Engine, Entity } from "../engine.ts"; import { ClientInterface } from "../network.ts"; const log = log_from(import.meta); export class Session { client; - world; - player_entity; + engine; + entity; - constructor(client: ClientInterface, world: World, player_entity: WorldEntity) { + constructor(client: ClientInterface, engine: Engine, entity: Entity) { this.client = client; - this.world = world; - this.player_entity = player_entity; + this.engine = engine; + this.entity = entity; } - async spin() { + static init(client: ClientInterface, engine: Engine) { + const spawn_pos = engine.run(sys_find_free_pos(v2(0, 0))); + const entity = engine.run(sys_spawn_player(spawn_pos)); + const self = new Session(client, engine, entity); + return self; + } + + async start() { try { for await (const input of this.client.inputs.iter()) { if (input.kind !== "request_display") log("Received", input); @@ -23,39 +34,61 @@ export class Session { if (input.kind === "ping") this.client.outputs.send({ kind: "ping_response", content: input.content }); if (input.kind === "request_display") this.send_display(input.content.width, input.content.height); if (input.kind === "input") this.handle_input(input); - if (input.kind === "exit") this.world.kill(this.player_entity.identifier); + if (input.kind === "exit") this.engine.delete(this.entity.identifier); } } catch (error) { console.error("Session loop failed, ", error); - this.world.kill(this.player_entity.identifier); + this.engine.delete(this.entity.identifier); } } + handle_input(input: mts.MsgInput) { + if (input.content.control === "up") this.move(v2(0, 1)); + if (input.content.control === "down") this.move(v2(0, -1)); + if (input.content.control === "left") this.move(v2(-1, 0)); + if (input.content.control === "right") this.move(v2(1, 0)); + } + send_display(width: number, height: number) { - const raw = this.world.render(this.player_entity.position, new Vec2(width, height)); + const raw = this.engine.run(sys_render_world(this.entity.get_force(CompPos).position, v2(width, height))); this.client.outputs.send({ kind: "display", content: { raw } }); } - handle_input(input: mts.MsgInput) { - if (input.content.control === "up") this.world.move_collide(this.player_entity.identifier, new Vec2(0, 1)); - if (input.content.control === "down") this.world.move_collide(this.player_entity.identifier, new Vec2(0, -1)); - if (input.content.control === "left") this.world.move_collide(this.player_entity.identifier, new Vec2(-1, 0)); - if (input.content.control === "right") this.world.move_collide(this.player_entity.identifier, new Vec2(1, 0)); - } -} - -export class PlayerComponent { - entity; - - constructor(entity: WorldEntity) { - this.entity = entity; - } - move(direction: Vec2) { - return this.entity.world.move_collide(this.entity.identifier, direction); - } - - render(res: Vec2) { - return this.entity.world.render(this.entity.position, res); + const pos = this.entity.get_force(CompPos); + pos.move_collide(this.engine, direction); + } +} + +export function sys_render_world(center: Vec2, size: Vec2) { + return (engine: Engine) => { + const radius = size.scale(0.5); + const result = Array.from(range(0, size.y())).map(() => Array.from(range(0, size.x())).map(() => " ")); + const min = center.sub(radius); + const max = center.add(radius); + for (const entity of engine.query_all(query_in_rect(min, max).with(CompDisplay))) { + const local_pos = entity.get_force(CompPos).position.sub(min); + result[local_pos.y()][local_pos.x()] = entity.get_force(CompDisplay).display; + } + return result.map((line) => line.join("")).toReversed().join("\n"); + }; +} + +export function sys_spawn_player(position: Vec2) { + return (engine: Engine) => { + return engine.spawn((entity) => + entity.insert( + new CompPlayer(), + new CompPos(entity, position), + new CompDisplay("`/"), + ) + ); + }; +} + +class CompPlayer { + life; + constructor() { + this.life = 10; } } diff --git a/server/main.ts b/server/main.ts index 500c20c..18357df 100755 --- a/server/main.ts +++ b/server/main.ts @@ -1,24 +1,35 @@ #!/bin/env -S deno run --allow-net --allow-read -import { log_from, Vec2 } from "../common/utils.ts"; -import { Engine, World } from "./engine.ts"; -import { Enemy } from "./entities/enemy.ts"; +import { log_from, v2, wait } from "../common/utils.ts"; +import { Engine } from "./engine.ts"; +import { Session } from "./entities/player.ts"; +import { sys_spawn_obstacle, sys_spawn_structure } from "./components/world.ts"; import { Gateway } from "./network.ts"; +import { sys_spawn_enemy } from "./entities/enemy.ts"; +import { spiral } from "../common/utils.ts"; const log = log_from(import.meta); async function main() { log("Starting."); - const world = new World(); - await world.spawn_structure("../data/structures/houses.txt", new Vec2(2, 2)); - const engine = new Engine(world); + const engine = new Engine(); engine.start(); + engine.run(await sys_spawn_structure("../data/structures/houses.txt", v2(2, 2))); + engine.run(sys_spawn_enemy(v2(1, 1))); - Enemy.spawn(world, new Vec2(-3, -3)); + (async () => { + await wait(5_000); + for (const pos of spiral(v2(-8, -8))) { + await wait(500); + engine.run(sys_spawn_obstacle(pos, "@@")); + } + })(); const port = 9999; const gateway = new Gateway(port); log("Awaiting connection."); - for await (const session of gateway.accept()) engine.add_session(session); + for await (const client of gateway.accept()) { + Session.init(client, engine).start(); + } } if (import.meta.main) await main();