From 3362755b06d022120d1dc0a9dc5843e46c6d9021 Mon Sep 17 00:00:00 2001 From: Frank Mayer Date: Fri, 26 Jul 2024 02:45:49 +0200 Subject: [PATCH] feat!: added state BREAKING CHANGE: split schedule in dynamic (new) and static --- apps/demo/src/counter.ts | 23 +++++++-- apps/demo/src/index.ts | 8 +-- lib/app.ts | 42 ++++++++++++---- lib/builtin/plugins/default.ts | 6 +-- lib/builtin/plugins/html.ts | 8 +-- lib/builtin/plugins/index.ts | 1 + lib/builtin/plugins/router.ts | 15 ++++++ lib/builtin/resources/basePath.ts | 7 +++ lib/builtin/state/location.ts | 28 +++++++++-- lib/builtin/systems/location.ts | 47 +++++++++++++++++ lib/debug.ts | 40 ++++++++++++--- lib/identify.ts | 4 ++ lib/index.ts | 2 + lib/resource.ts | 2 +- lib/schedule.ts | 68 +++++++++++++++++++++---- lib/state.ts | 21 ++++++++ lib/system.ts | 20 ++++++++ lib/traits.ts | 37 ++++++++++++++ lib/world.ts | 83 ++++++++++++++++++++++++++----- package.json | 1 + 20 files changed, 406 insertions(+), 57 deletions(-) create mode 100644 lib/builtin/plugins/router.ts create mode 100644 lib/builtin/resources/basePath.ts create mode 100644 lib/builtin/systems/location.ts create mode 100644 lib/state.ts create mode 100644 lib/traits.ts diff --git a/apps/demo/src/counter.ts b/apps/demo/src/counter.ts index d925381..8805f02 100644 --- a/apps/demo/src/counter.ts +++ b/apps/demo/src/counter.ts @@ -1,16 +1,21 @@ import { type App, Commands, + Entity, + inState, + OnEnter, + OnExit, query, res, - Schedule, UiAnchor, UiButton, UiInteraction, UiNode, UiStyle, UiText, + Update, } from "@tsukinoko-kun/ecs.ts" +import { Location } from "../../../lib/builtin/state" // this resource is used to store the counter value class Counter { @@ -42,6 +47,7 @@ function spawnUi() { new UiText("ECS.ts on GitHub"), new UiStyle().set("display", "block"), ) + parent.spawn(new UiAnchor("./meep"), new UiText("Meep")) parent.spawn( new UiButton(), new UiText("Click me!"), @@ -74,10 +80,17 @@ function updateButtonText() { } } +function despawnUi() { + for (const [entity] of query.root([Entity], query.and(UiNode))) { + Commands.despawn(entity) + } +} + // this plugin bundles everything that is needed for this counter example to work -export function counterPlugin(app: App) { +export function CounterPlugin(app: App) { Commands.insertResource(new Counter()) - app.addSystem(Schedule.Startup, spawnUi) - .addSystem(Schedule.Update, incrementCounter) - .addSystem(Schedule.Update, updateButtonText) + app.addSystem(OnEnter(Location.fromPath("/")), spawnUi) + .addSystem(Update, incrementCounter.runIf(inState(Location.fromPath("/")))) + .addSystem(Update, updateButtonText.runIf(inState(Location.fromPath("/")))) + .addSystem(OnExit(Location.fromPath("/")), despawnUi) } diff --git a/apps/demo/src/index.ts b/apps/demo/src/index.ts index e2695e5..f29a71b 100644 --- a/apps/demo/src/index.ts +++ b/apps/demo/src/index.ts @@ -1,5 +1,5 @@ -import { App, DefaultPlugin, HtmlPlugin } from "@tsukinoko-kun/ecs.ts" -import { counterPlugin } from "./counter" +import { App, DefaultPlugin, HtmlPlugin, RouterPlugin } from "@tsukinoko-kun/ecs.ts" +import { CounterPlugin } from "./counter" const app = new App() @@ -8,7 +8,9 @@ app .addPlugin(DefaultPlugin) // the HtmlPlugin is for rendering the UI to the DOM .addPlugin(HtmlPlugin("#app")) + // the RouterPlugin is for working with the browser's location (URL) + .addPlugin(RouterPlugin.withBasePath("/ecs.ts/")) // the counterPlugin is our custom plugin - .addPlugin(counterPlugin) + .addPlugin(CounterPlugin) app.run() diff --git a/lib/app.ts b/lib/app.ts index 223256f..288dfb9 100644 --- a/lib/app.ts +++ b/lib/app.ts @@ -1,9 +1,10 @@ import type { Plugin } from "./plugin" import { inWorld, setCurrentWorld, World } from "./world" import type { System } from "./system" -import { Schedule } from "./schedule" +import { First, Last, PostStartup, PostUpdate, PreStartup, PreUpdate, type Schedule, Startup, Update } from "./schedule" import { Time } from "./builtin" import { Debug } from "./debug" +import type { State } from "./state" export class App { private readonly plugins = new Array() @@ -26,6 +27,11 @@ export class App { return this } + public insertState(stateValue: State): this { + this.world.insertState(stateValue) + return this + } + public addSystem(schedule: Schedule, system: System): this { this.world.addSystem(schedule, system) return this @@ -34,9 +40,18 @@ export class App { public async run(): Promise { setCurrentWorld(this.world) - await Promise.all(this.world.getSystemsBySchedule(Schedule.PreStartup).map((system) => system())) - await Promise.all(this.world.getSystemsBySchedule(Schedule.Startup).map((system) => system())) - await Promise.all(this.world.getSystemsBySchedule(Schedule.PostStartup).map((system) => system())) + await Promise.all(this.world.getSystemsBySchedule(PreStartup).map((system) => system())) + await Promise.all(this.world.getSystemsBySchedule(Startup).map((system) => system())) + await Promise.all(this.world.getSystemsBySchedule(PostStartup).map((system) => system())) + + const transitions = this.world.applyNextState() + if (transitions.enter.length) { + for (const [schedule, systems] of this.world.getDynamicSystems()) { + if (schedule([], transitions.enter)) { + await Promise.all(systems.map((system) => system())) + } + } + } setCurrentWorld(null) @@ -49,13 +64,22 @@ export class App { time.elapsed = elapsed } - await Promise.all(this.world.getSystemsBySchedule(Schedule.First).map((system) => system())) + await Promise.all(this.world.getSystemsBySchedule(First).map((system) => system())) - await Promise.all(this.world.getSystemsBySchedule(Schedule.PreUpdate).map((system) => system())) - await Promise.all(this.world.getSystemsBySchedule(Schedule.Update).map((system) => system())) - await Promise.all(this.world.getSystemsBySchedule(Schedule.PostUpdate).map((system) => system())) + await Promise.all(this.world.getSystemsBySchedule(PreUpdate).map((system) => system())) + await Promise.all(this.world.getSystemsBySchedule(Update).map((system) => system())) + await Promise.all(this.world.getSystemsBySchedule(PostUpdate).map((system) => system())) - await Promise.all(this.world.getSystemsBySchedule(Schedule.Last).map((system) => system())) + await Promise.all(this.world.getSystemsBySchedule(Last).map((system) => system())) + + const transitions = this.world.applyNextState() + if (transitions.exit.length || transitions.enter.length) { + for (const [schedule, systems] of this.world.getDynamicSystems()) { + if (schedule(transitions.exit, transitions.enter)) { + await Promise.all(systems.map((system) => system())) + } + } + } setCurrentWorld(null) requestAnimationFrame(update) diff --git a/lib/builtin/plugins/default.ts b/lib/builtin/plugins/default.ts index 1ad5589..ec7904e 100644 --- a/lib/builtin/plugins/default.ts +++ b/lib/builtin/plugins/default.ts @@ -1,12 +1,12 @@ import type { Plugin } from "../../plugin" import { LogicalButtonInput, PhysicalButtonInput } from "../resources" -import { Schedule } from "../../schedule" +import { Last } from "../../schedule" import { resetLogicalButtonInput, resetPhysicalButtonInput } from "../systems/buttonInput" import { Commands } from "../../commands" export const DefaultPlugin: Plugin = (app) => { Commands.insertResource(new LogicalButtonInput()) Commands.insertResource(new PhysicalButtonInput()) - app.addSystem(Schedule.Last, resetLogicalButtonInput) - app.addSystem(Schedule.Last, resetPhysicalButtonInput) + app.addSystem(Last, resetLogicalButtonInput) + app.addSystem(Last, resetPhysicalButtonInput) } diff --git a/lib/builtin/plugins/html.ts b/lib/builtin/plugins/html.ts index 1b1e505..4f884e9 100644 --- a/lib/builtin/plugins/html.ts +++ b/lib/builtin/plugins/html.ts @@ -1,6 +1,6 @@ import type { Plugin } from "../../plugin" import { HtmlRoot } from "../resources" -import { Schedule } from "../../schedule" +import { Last, Startup, Update } from "../../schedule" import { cleanupHtmlInteraction, htmlInteraction, renderHtmlRoot } from "../systems" import { Commands } from "../../commands" @@ -9,8 +9,8 @@ export function HtmlPlugin(rootElement: Element): Plugin export function HtmlPlugin(root: string | Element): Plugin { return (app) => { Commands.insertResource(new HtmlRoot(root)) - app.addSystem(Schedule.Update, renderHtmlRoot) - app.addSystem(Schedule.Startup, htmlInteraction) - app.addSystem(Schedule.Last, cleanupHtmlInteraction) + app.addSystem(Update, renderHtmlRoot) + app.addSystem(Startup, htmlInteraction) + app.addSystem(Last, cleanupHtmlInteraction) } } diff --git a/lib/builtin/plugins/index.ts b/lib/builtin/plugins/index.ts index c641cec..cddd625 100644 --- a/lib/builtin/plugins/index.ts +++ b/lib/builtin/plugins/index.ts @@ -1,2 +1,3 @@ export * from "./default" export * from "./html" +export * from "./router" diff --git a/lib/builtin/plugins/router.ts b/lib/builtin/plugins/router.ts new file mode 100644 index 0000000..cc03827 --- /dev/null +++ b/lib/builtin/plugins/router.ts @@ -0,0 +1,15 @@ +import type { App } from "../../app" +import { Location } from "../state" +import { updateLocationState } from "../systems/location" +import { Startup } from "../../schedule" +import { BasePath } from "../resources/basePath" + +export function RouterPlugin(app: App) { + app.insertResource(new BasePath("/")).insertState(Location.windowLocation()).addSystem(Startup, updateLocationState) +} + +RouterPlugin.withBasePath = (basePath: string) => (app: App) => { + app.insertResource(new BasePath(basePath)) + .insertState(Location.windowLocation()) + .addSystem(Startup, updateLocationState) +} diff --git a/lib/builtin/resources/basePath.ts b/lib/builtin/resources/basePath.ts new file mode 100644 index 0000000..33a949a --- /dev/null +++ b/lib/builtin/resources/basePath.ts @@ -0,0 +1,7 @@ +export class BasePath { + public readonly basePath: string + + public constructor(basePath: string) { + this.basePath = basePath + } +} diff --git a/lib/builtin/state/location.ts b/lib/builtin/state/location.ts index 0e2510b..d18c9c7 100644 --- a/lib/builtin/state/location.ts +++ b/lib/builtin/state/location.ts @@ -1,14 +1,36 @@ import type { Equals } from "../../traits" +import { res } from "../../resource" +import { BasePath } from "../resources/basePath" + +function trimBasePath(basePath: string, path: string): string { + if (basePath === "/") { + return path + } + if (path.startsWith(basePath)) { + path = path.slice(basePath.length) + } + if (!path.startsWith("/")) { + path = "/" + path + } + return path +} export class Location implements Equals { - private readonly path: string + public path: string - public constructor(path: string) { + private constructor(path: string) { this.path = path } public static windowLocation(): Location { - return new Location(window.location.pathname) + const bp = res(BasePath).basePath + const pn = window.location.pathname + return new Location(trimBasePath(bp, pn)) + } + + public static fromPath(path: string): Location { + const bp = res(BasePath).basePath + return new Location(trimBasePath(bp, path)) } public equals(other: this): boolean { diff --git a/lib/builtin/systems/location.ts b/lib/builtin/systems/location.ts new file mode 100644 index 0000000..35b3bf8 --- /dev/null +++ b/lib/builtin/systems/location.ts @@ -0,0 +1,47 @@ +import { nextState } from "../../state" +import { Location } from "../state" +import { inWorld, useWorld } from "../../world" + +export function updateLocationState() { + const origin = window.location.origin + const world = useWorld() + + window.addEventListener("popstate", (ev) => { + ev.preventDefault() + inWorld(world, () => { + nextState(Location.windowLocation()) + }) + }) + + window.addEventListener("click", (ev) => { + let ns: Location | null = null + + for (const el of parentChain(ev.target as Element)) { + if (el.tagName === "A") { + const url = new URL((el as HTMLAnchorElement).href) + if (url.origin !== origin) { + return + } + ev.preventDefault() + history.pushState({}, "", (el as HTMLAnchorElement).href) + ns = new Location(url.pathname) + break + } + } + + if (ns) { + inWorld(world, () => { + nextState(ns!) + }) + } + }) +} + +function* parentChain(el: Element): IterableIterator { + yield el + let p = el.parentElement + while (p) { + yield p + p = p.parentElement + } +} diff --git a/lib/debug.ts b/lib/debug.ts index 75335e3..3d99726 100644 --- a/lib/debug.ts +++ b/lib/debug.ts @@ -1,19 +1,16 @@ import type { World } from "./world" import type { Entity } from "./entity" -import { Schedule } from "./schedule" +import { First, Last, PostStartup, PostUpdate, PreStartup, PreUpdate, Startup, Update } from "./schedule" +import { identDisplay } from "./identify" export const Debug = { worlds: new Array(), } -function symbolDisplayName(symbol: symbol): string { - return symbol.toString().replace(/^Symbol\((.*)\)$/, "$1") -} - function debugEntity(w: World, e: Entity): object { return { id: e.id, - components: Array.from(w.getEntityComponents(e).keys()).map(symbolDisplayName), + components: Array.from(w.getEntityComponents(e).keys()).map(identDisplay), children: e.children.map((child) => debugEntity(w, child)), } } @@ -27,10 +24,37 @@ function systemName(system: Function): string { const entities = world.getEntities().map((entity) => debugEntity(world, entity)) let systems = {} for (const [schedule, systemList] of world.getSystems().entries()) { - const s = typeof schedule === "string" ? schedule : Schedule[schedule] + let s = schedule.toString() + switch (schedule) { + case PreStartup: + s = "PreStartup" + break + case Startup: + s = "Startup" + break + case PostStartup: + s = "PostStartup" + break + case First: + s = "First" + break + case PreUpdate: + s = "PreUpdate" + break + case Update: + s = "PostStartup" + break + case PostUpdate: + s = "PostUpdate" + break + case Last: + s = "Last" + break + } // @ts-ignore systems[s] = systemList.map((system) => systemName(system)) } - return { entities, systems } + const state = Array.from(world.getStates()).map((s) => JSON.parse(JSON.stringify(s))) + return { entities, systems, state } }), }) diff --git a/lib/identify.ts b/lib/identify.ts index e30a887..9a3b79d 100644 --- a/lib/identify.ts +++ b/lib/identify.ts @@ -2,6 +2,10 @@ export type Ident = symbol const __identifier__ = Symbol("__identifier__") +export function identDisplay(symbol: Ident): string { + return symbol.toString().replace(/^Symbol\((.*)\)$/, "$1") +} + export function identify(o: T | (new (...arg: any[]) => T)): Ident { if (__identifier__ in o) { return o[__identifier__] as Ident diff --git a/lib/index.ts b/lib/index.ts index 2593956..38b97ce 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -6,7 +6,9 @@ export * from "./plugin" export * from "./query" export * from "./resource" export * from "./schedule" +export * from "./state" export * from "./system" +export * from "./traits" export * from "./world" export * from "./builtin" diff --git a/lib/resource.ts b/lib/resource.ts index fa83c53..abf8e1d 100644 --- a/lib/resource.ts +++ b/lib/resource.ts @@ -1,6 +1,6 @@ import { useWorld } from "./world" -export function res(type: { new (...args: any[]): T }): T { +export function res(type: { new (...args: any[]): T }): T { const world = useWorld() return world.getResource(type) } diff --git a/lib/schedule.ts b/lib/schedule.ts index c8765b1..8b566b4 100644 --- a/lib/schedule.ts +++ b/lib/schedule.ts @@ -1,10 +1,60 @@ -export enum Schedule { - PreStartup, - Startup, - PostStartup, - First, - PreUpdate, - Update, - PostUpdate, - Last, +import type { State } from "./state" +import { eq } from "./traits" + +export type Schedule = StaticSchedule | DynamicSchedule + +export type StaticSchedule = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 + +export const PreStartup: StaticSchedule = 0 +export const Startup: StaticSchedule = 1 +export const PostStartup: StaticSchedule = 2 +export const First: StaticSchedule = 3 +export const PreUpdate: StaticSchedule = 4 +export const Update: StaticSchedule = 5 +export const PostUpdate: StaticSchedule = 6 +export const Last: StaticSchedule = 7 + +export type DynamicSchedule = (prev: ReadonlyArray, next: ReadonlyArray) => boolean + +export function OnEnter(state: State): DynamicSchedule { + return (_, next) => { + for (const nextS of next) { + if (eq(nextS, state)) { + return true + } + } + return false + } +} + +export function OnExit(state: State): DynamicSchedule { + return (prev, _) => { + for (const prevS of prev) { + if (eq(prevS, state)) { + return true + } + } + return false + } +} + +export function OnTransition(from: T, to: T): DynamicSchedule { + return (prev, next) => { + let fromFound = false + for (const prevS of prev) { + if (eq(prevS, from)) { + fromFound = true + } + } + if (!fromFound) { + return false + } + + for (const nextS of next) { + if (eq(nextS, to)) { + return true + } + } + return false + } } diff --git a/lib/state.ts b/lib/state.ts new file mode 100644 index 0000000..6f2dbbf --- /dev/null +++ b/lib/state.ts @@ -0,0 +1,21 @@ +import { useWorld } from "./world" +import { type DeepReadonly, eq, type Eq } from "./traits" + +export type State = object & Eq + +export function state(type: { new (...args: any[]): T }): DeepReadonly & State { + const world = useWorld() + return world.getState(type) as DeepReadonly & State +} + +export function nextState(value: State): void { + const world = useWorld() + return world.nextState(value) +} + +export function inState(value: T): () => boolean { + return () => { + const s = state((value as { constructor: { new (...args: any[]): T } }).constructor) + return eq(s, value) + } +} diff --git a/lib/system.ts b/lib/system.ts index fa2230c..3faa7d8 100644 --- a/lib/system.ts +++ b/lib/system.ts @@ -1 +1,21 @@ export type System = () => void | Promise + +declare global { + interface Function { + runIf(this: T, condition: () => boolean): T + } +} + +Function.prototype.runIf = function (this: T, condition: () => boolean): T { + const fn = (...args: any[]) => { + if (condition()) { + return this(...args) + } + } + + return fn as unknown as T +} + +function fn(x: string): number { + return x.length +} diff --git a/lib/traits.ts b/lib/traits.ts new file mode 100644 index 0000000..eb29823 --- /dev/null +++ b/lib/traits.ts @@ -0,0 +1,37 @@ +export interface Equals { + equals(other: this): boolean +} + +export interface Comparable { + compare(other: this): number +} + +export type Eq = Equals | Comparable | string | number | boolean + +export function eq(a: T, b: T): boolean { + if (a === b) { + return true + } + if (typeof a === "object" && typeof b === "object") { + if ("equals" in a) { + return a.equals(b as Equals) + } else if ("compare" in a) { + return a.compare(b as Comparable) === 0 + } + // @ts-ignore + return a.valueOf() === b.valueOf() || a.toString() === b.toString() + } + return false +} + +export type DeepReadonly = T extends (infer R)[] + ? DeepReadonlyArray + : T extends object + ? DeepReadonlyObject + : T + +interface DeepReadonlyArray extends ReadonlyArray> {} + +type DeepReadonlyObject = { + readonly [P in keyof T]: DeepReadonly +} diff --git a/lib/world.ts b/lib/world.ts index a798323..a767771 100644 --- a/lib/world.ts +++ b/lib/world.ts @@ -1,8 +1,10 @@ import { Entity } from "./entity" import { type Component } from "./component" import type { System } from "./system" -import { Schedule } from "./schedule" -import { type Ident, identify } from "./identify" +import type { DynamicSchedule, Schedule, StaticSchedule } from "./schedule" +import { type Ident, identDisplay, identify } from "./identify" +import { eq } from "./traits" +import type { State } from "./state" let currentWorld: World | null = null @@ -33,15 +35,22 @@ export function inWorld(world: World, fn: (world: World) => void): void { export class World { private readonly entities = new Array() private readonly components = new Map>() - private readonly systems = new Map>() + private readonly staticSystems = new Map>() + private readonly dynamicSystems = new Array<[DynamicSchedule, System[]]>() private readonly resources = new Map() + private readonly state = new Map() + private _nextStates = new Map() - public getSystems(): ReadonlyMap> { - return this.systems + public getSystems(): ReadonlyMap> { + return this.staticSystems } - public getSystemsBySchedule(schedule: Schedule): ReadonlyArray { - return this.systems.get(schedule) ?? [] + public getSystemsBySchedule(schedule: StaticSchedule): ReadonlyArray { + return this.staticSystems.get(schedule) ?? [] + } + + public getDynamicSystems(): ReadonlyArray<[DynamicSchedule, System[]]> { + return this.dynamicSystems } public getEntities(): ReadonlyArray { @@ -79,11 +88,23 @@ export class World { public getResource(t: { new (...args: any[]): T }): T { const r = this.resources.get(identify(t)) if (!r) { - throw new Error(`Resource ${t.name} does not exist`) + throw new Error(`Resource ${t.name} does not exist in the world`) } return r as T } + public getState(t: { new (...args: any[]): T }): T { + const s = this.state.get(identify(t)) + if (!s) { + throw new Error(`State ${t.name} does not exist in the world`) + } + return s as T + } + + public getStates(): IterableIterator { + return this.state.values() + } + /** @internal */ public spawnEmpty(): Entity { const e = new Entity() @@ -167,14 +188,52 @@ export class World { } } - public addSystem(schedule: Schedule, system: System): void { - if (!this.systems.has(schedule)) { - this.systems.set(schedule, []) + public addSystem(schedule: Schedule, ...system: System[]): void { + if (typeof schedule === "number" && Number.isInteger(schedule)) { + if (!this.staticSystems.has(schedule)) { + this.staticSystems.set(schedule, []) + } + this.staticSystems.get(schedule)!.push(...system) + } else if (typeof schedule === "function") { + this.dynamicSystems.push([schedule, system]) + } else { + throw new Error(`Invalid schedule ${schedule}`) } - this.systems.get(schedule)!.push(system) } public insertResource(r: T): void { this.resources.set(identify(r), r) } + + public insertState(stateValue: State): void { + this.state.set(identify(stateValue), stateValue) + this._nextStates.set(identify(stateValue), stateValue) + } + + public nextState(stateValue: State): void { + const i = identify(stateValue) + if (this.state.has(i)) { + const old = this.state.get(i)! + if (!eq(old, stateValue)) { + this._nextStates.set(i, stateValue) + } else { + console.warn(`State ${identDisplay(i)} was set to the same value`) + } + } else { + throw new Error(`State ${identDisplay(i)} does not exist in the world`) + } + } + + /** @internal */ + public applyNextState(): { enter: ReadonlyArray; exit: ReadonlyArray } { + const enter = new Array() + const exit = new Array() + for (const [i, state] of this._nextStates) { + exit.push(this.state.get(i)!) + this.state.set(i, state) + enter.push(state) + } + this._nextStates.clear() + return { enter, exit } + } } diff --git a/package.json b/package.json index 1c5558f..f7bebb1 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "ecs": "bin/ecs.mjs" }, "scripts": { + "format": "prettier --write .", "demo:dev": "pnpm -r --filter=\"demo\" run dev", "demo:build": "pnpm -r --filter=\"demo\" run build", "extension:build": "pnpm -r --filter=\"extension\" run build"