ecs refactor

This commit is contained in:
Matthieu Jolimaitre 2024-04-09 04:25:34 +02:00
parent 28b026a614
commit f2e5d13000
8 changed files with 410 additions and 170 deletions

View file

@ -0,0 +1,6 @@
export class CompDisplay {
display;
constructor(display: string) {
this.display = display;
}
}

101
server/components/world.ts Normal file
View file

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

View file

@ -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<number, WorldEntity>();
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<I>(class_: Constructible<I>) {
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<I>(class_: Constructible<I>) {
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<number, Entity>();
}
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<T>(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<I>(component: Constructible<I>, 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<I>(component: Constructible<I>, filter: (comp: I, entity: Entity) => boolean) {
return this.and(Query.filter(component, filter));
}
*run(entities: Iterable<Entity>) {
for (const entity of entities) if (this.test(entity)) yield entity;
}
}

View file

@ -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("èé"),
)
);
};
}

View file

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

View file

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