···11+# Cosmic
22+The experimental pixi.js based engine for tile-based 2D games, created strictly for educational purposes.
33+44+## Requirements
55+To run the demo project and experiment with the engine, the following tools are required.
66+77+- Bun v1.3+
88+99+## Try it out!
1010+You can try out the ever evolving demo project at any time, by running the following commands.
1111+1212+```sh
1313+bun install
1414+```
1515+1616+```sh
1717+bun dev
1818+```
1919+2020+## Architecture
2121+The following will explain the data-flow and general architecture of the engine, including the main class, the ECS and more!
2222+2323+### The `Cosmic` class
2424+2525+### The `Loop` class
2626+Loops are singular, scoped and enqueue-able looping directives.
2727+2828+For example, there might be a "rendering" loop, which draws the state of various entities onto the screen, on each frame, using drawing APIs.
2929+3030+These are enqueued separately, rather than having one hard-coded main loop, to allow varying execution speeds, like a rendering pipeline running on every frame, and a physics pipeline running at a calculated interval of 60 FPS.
3131+3232+### The `Layer` class
3333+3434+### The `EntityComponentSystem` class
3535+3636+### The `Store` class
3737+Stores are utilized as a means of shared state throughout the engine.
3838+3939+They are defined as static DTO-like (data transfer object) classes that extend the `Store` class.
···11-import { System, Entity, InputManager } from "@cosmic/core";
11+import { Loop } from "@cosmic/core";
2233/**
44 * Enum representing the different modes in which the Cosmic engine can operate.
55 */
66export enum CosmicMode {
77- /** Enables debugging features and logs additional information. */
88- DEVELOPMENT,
77+ /** Enables debugging features and logs additional information. */
88+ DEVELOPMENT,
99+1010+ /** Enables production mode, optimized for high performance. */
1111+ PRODUCTION,
1212+}
1313+1414+/**
1515+ * Enum representing the different states in which the Cosmic engine can operate.
1616+ */
1717+export enum CosmicState {
1818+ /** Indicates the main engine pulse to be active. */
1919+ ACTIVE,
9201010- /** Enables production mode, optimized for high performance. */
1111- PRODUCTION,
2121+ /** Indicates the main engine pulse is not currently active. */
2222+ INACTIVE,
1223}
13241425/**
1526 * The Cosmic engine, a powerful tool for tile-based 2D games.
2727+ * Accessed through the `Cosmic.instance` property.
2828+ *
2929+ * @example
3030+ * const engine = Cosmic.instance;
3131+ *
3232+ * engine.enqueue(new Loop("rendering", (dt) => {
3333+ * console.log(`Delta time: ${dt}`);
3434+ * }));
1635 */
1736export class Cosmic {
1818- /**
1919- * The engine's current, and only, instance.
2020- */
2121- public _instance?: Cosmic;
3737+ protected static _instance?: Cosmic;
3838+ public static get instance() {
3939+ if (!this._instance) {
4040+ this._instance = new Cosmic(CosmicMode.PRODUCTION);
4141+ }
22422323- /**
2424- * Returns a reference to the current engine instance.
2525- */
2626- get instance() {
2727- if (!this._instance) {
2828- this._instance = new Cosmic(CosmicMode.PRODUCTION);
2929- }
4343+ return this._instance;
4444+ }
30453131- return this._instance;
3232- }
4646+ public constructor(
4747+ /** The mode in which the engine is operating. */
4848+ public mode: CosmicMode,
33493434- /**
3535- * The mode in which the engine is operating.
3636- */
3737- public mode: CosmicMode;
5050+ /** Declares whether the engine is currently running. */
5151+ protected state: CosmicState = CosmicState.INACTIVE,
5252+ ) {
5353+ requestAnimationFrame(this.pulse.bind(this));
5454+ }
38553939- public constructor(mode: CosmicMode) {
4040- this.mode = mode;
4141- }
5656+ /** The last recorded frame timestamp. */
5757+ protected lastTimestamp: number = performance.now();
5858+5959+ /** The current calculated deltatime in seconds */
6060+ protected deltaTime: number = 0;
6161+6262+ /** Stores the engine's enqueued external loops */
6363+ protected loops: Loop[] = [];
6464+6565+ /**
6666+ * Appends a `Loop` to the engine's main pulse.
6767+ *
6868+ * @param {Loop} loop - The loop to enqueue.
6969+ */
7070+ public enqueue(loop: Loop) {
7171+ if (!loop.name) throw new Error("[Cosmic::enqueue] The provided loop is missing the `name` property");
7272+7373+ if (!loop.pulse) throw new Error("[Cosmic::enqueue] The provided loop is missing a `pulse` method");
7474+7575+ this.loops.push(loop);
7676+ }
7777+7878+ /** Initializes the main engine pulse. */
7979+ public start() {
8080+ if (this.state === CosmicState.ACTIVE) return;
8181+8282+ this.state = CosmicState.ACTIVE;
8383+ }
8484+8585+ /** Stops the main engine pulse. */
8686+ public stop() {
8787+ if (this.state === CosmicState.INACTIVE) return;
8888+8989+ this.state = CosmicState.INACTIVE;
9090+ }
9191+9292+ /** The main engine pulse, hooked to requestAnimationFrame. Executes all enqueued loops once per frame, and calculates new deltaTime in seconds. */
9393+ protected pulse(timestamp: number) {
9494+ this.deltaTime = (timestamp - this.lastTimestamp) / 1000;
9595+ this.lastTimestamp = timestamp;
9696+9797+ for (const loop of this.loops) {
9898+ if (this.mode === CosmicMode.DEVELOPMENT) {
9999+ console.log(`Running enqueued loop: ${loop.name}`);
100100+ }
101101+102102+ loop.pulse(this.deltaTime);
103103+ }
104104+105105+ // Move on to the next frame
106106+ requestAnimationFrame(this.pulse.bind(this));
107107+ }
42108}
+23
packages/core/src/core/Loop.ts
···11+/**
22+ * Defines a singular scoped loop, which may be enqueued in the engine's main loop.
33+ *
44+ * @param {string} name - The unique name for this loop, e.g. "rendering" or "entity-component-system".
55+ *
66+ * @example
77+ * const renderingLoop = new Loop("rendering", (dt: number) => {
88+ * console.log(`Delta time: ${dt}`);
99+ * });
1010+ */
1111+export class Loop {
1212+ public constructor(
1313+ /** The unique name for this loop, e.g. "rendering" or "entity-component-system". */
1414+ public name: string,
1515+1616+ /**
1717+ * The loop's pulse, called by the engine on requestAnimationFrame.
1818+ *
1919+ * @param {number} dt - The delta time value passed on from the main engine loop.
2020+ */
2121+ public pulse: (dt: number) => void
2222+ ) { }
2323+}
+1
packages/core/src/index.ts
···11export * from "./core/Cosmic";
22+export * from "./core/Loop";
···11-{
22- "name": "@cosmic/kit",
33- "version": "0.1.0",
44- "description": "Collection of components, systems and helpers to aid developers of games using the Cosmic engine.",
55- "exports": {
66- "./components": {
77- "import": "./src/components.ts",
88- "require": "./src/components.cjs"
99- },
1010- "./systems": {
1111- "import": "./src/systems.ts",
1212- "require": "./src/systems.cjs"
1313- }
1414- },
1515- "devDependencies": {
1616- "@cosmic/core": "workspace:*"
1717- }
1818-}
-7
packages/kit/src/components.ts
···11-export * from "./components/PlayerControlled";
22-export * from "./components/Renderable";
33-export * from "./components/Transform";
44-export * from "./components/Collider";
55-export * from "./components/Physics";
66-export * from "./components/Movable";
77-export * from "./components/Name";
···11-export class Transform {
22- constructor(
33- public x: number = 0,
44- public y: number = 0,
55- public width: number = 32,
66- public height: number = 32,
77-88- // Todo: Consider moving this to Collider.ts
99- public isCollidingWithEntity: boolean = false,
1010- public isCollidingWithCanvasBoundaries: boolean = false,
1111- ) {}
1212-}
-4
packages/kit/src/systems.ts
···11-export * from "./systems/PlayerControlSystem";
22-export * from "./systems/CollisionSystem";
33-export * from "./systems/RenderingSystem";
44-export * from "./systems/PhysicsSystem";
-129
packages/kit/src/systems/CollisionSystem.ts
···11-import { Entity, System } from "@cosmic/core";
22-import { Transform, Collider } from "@cosmic/kit/components";
33-44-export class CollisionSystem extends System {
55- public readonly requiredComponents = new Set([Transform.name, Collider.name]);
66-77- public update(entities: Entity[], _deltaTime: number) {
88- // Reset collision flag for all first
99- for (const entity of entities) {
1010- const transform = entity.getComponent(Transform)!;
1111- transform.isCollidingWithEntity = false;
1212- }
1313-1414- // Check each pair once
1515- for (let i = 0; i < entities.length; i++) {
1616- const entityA = entities[i];
1717- const aTransform = entityA.getComponent(Transform)!;
1818- const aCollider = entityA.getComponent(Collider)!;
1919-2020- for (let j = i + 1; j < entities.length; j++) {
2121- const entityB = entities[j];
2222- const bTransform = entityB.getComponent(Transform)!;
2323- const bCollider = entityB.getComponent(Collider)!;
2424-2525- if (this.areColliding(aTransform, bTransform)) {
2626- aTransform.isCollidingWithEntity = true;
2727- bTransform.isCollidingWithEntity = true;
2828-2929- this.resolveOverlap(aTransform, bTransform, aCollider, bCollider);
3030- }
3131- }
3232- }
3333- }
3434-3535- protected areColliding(a: Transform, b: Transform): boolean {
3636- // AABB vs AABB
3737- return (
3838- a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y
3939- );
4040- }
4141-4242- protected resolveOverlap(a: Transform, b: Transform, colA: Collider, colB: Collider) {
4343- // Centers
4444- const centerAx = a.x + a.width / 2;
4545- const centerAy = a.y + a.height / 2;
4646- const centerBx = b.x + b.width / 2;
4747- const centerBy = b.y + b.height / 2;
4848-4949- const dx = centerAx - centerBx;
5050- const dy = centerAy - centerBy;
5151-5252- const combinedHalfWidths = (a.width + b.width) / 2;
5353- const combinedHalfHeights = (a.height + b.height) / 2;
5454-5555- const overlapX = combinedHalfWidths - Math.abs(dx);
5656- const overlapY = combinedHalfHeights - Math.abs(dy);
5757-5858- if (overlapX <= 0 || overlapY <= 0) return; // just in case
5959-6060- const aStatic = colA.isStatic;
6161- const bStatic = colB.isStatic;
6262-6363- // Decide which axis to resolve along (least penetration)
6464- if (overlapX < overlapY) {
6565- // Horizontal
6666- if (dx > 0) {
6767- // A is to the right of B
6868- this.pushApartX(a, b, overlapX, +1, aStatic, bStatic);
6969- } else {
7070- // A is to the left of B
7171- this.pushApartX(a, b, overlapX, -1, aStatic, bStatic);
7272- }
7373- } else {
7474- // Vertical
7575- if (dy > 0) {
7676- // A is below B
7777- this.pushApartY(a, b, overlapY, +1, aStatic, bStatic);
7878- } else {
7979- // A is above B
8080- this.pushApartY(a, b, overlapY, -1, aStatic, bStatic);
8181- }
8282- }
8383- }
8484-8585- private pushApartX(
8686- a: Transform,
8787- b: Transform,
8888- overlap: number,
8989- direction: 1 | -1, // +1: A right of B, -1: A left of B
9090- aStatic: boolean,
9191- bStatic: boolean,
9292- ) {
9393- if (aStatic && bStatic) return;
9494-9595- if (aStatic && !bStatic) {
9696- // Only move B
9797- b.x -= direction * overlap;
9898- } else if (!aStatic && bStatic) {
9999- // Only move A
100100- a.x += direction * overlap;
101101- } else {
102102- // Both dynamic → split
103103- const half = overlap / 2;
104104- a.x += direction * half;
105105- b.x -= direction * half;
106106- }
107107- }
108108-109109- private pushApartY(
110110- a: Transform,
111111- b: Transform,
112112- overlap: number,
113113- direction: 1 | -1, // +1: A below B, -1: A above B
114114- aStatic: boolean,
115115- bStatic: boolean,
116116- ) {
117117- if (aStatic && bStatic) return;
118118-119119- if (aStatic && !bStatic) {
120120- b.y -= direction * overlap;
121121- } else if (!aStatic && bStatic) {
122122- a.y += direction * overlap;
123123- } else {
124124- const half = overlap / 2;
125125- a.y += direction * half;
126126- b.y -= direction * half;
127127- }
128128- }
129129-}