Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions examples/ecsify/vanilla/basic/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import {
createApp,
createDefaultPlugin,
Entity,
type TApp,
type TAppContext,
type TDefaultPlugin,
type TPlugin,
type TPluginSystemFn
type TPlugin
} from 'ecsify';

// Get canvas context
Expand Down Expand Up @@ -105,7 +106,7 @@ export function createGamePlugin(): TGamePlugin {
}

// Physics system
const physicsSystem: TPluginSystemFn<TGamePlugin> = (app, dt = 0.016) => {
const physicsSystem = (app: TApp<TAppContext<[TDefaultPlugin, TGamePlugin]>>, dt = 0.016) => {
for (const [eid, pos, vel, rect] of app.queryComponents([
Entity,
app.c.Position,
Expand Down Expand Up @@ -140,7 +141,7 @@ const physicsSystem: TPluginSystemFn<TGamePlugin> = (app, dt = 0.016) => {
};

// Rendering system
const renderingSystem: TPluginSystemFn<TGamePlugin> = (app) => {
const renderingSystem = (app: TApp<TAppContext<[TDefaultPlugin, TGamePlugin]>>) => {
ctx.clearRect(0, 0, canvas.width, canvas.height);

for (const [pos, color, rect] of app.queryComponents([
Expand Down
2 changes: 1 addition & 1 deletion packages/ecsify/src/app/create-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export function createApp<

// Call setup if it exists
if (plugin.setup != null) {
plugin.setup(this);
plugin.setup(this as unknown as TApp);
}

return this as unknown as TApp<
Expand Down
6 changes: 3 additions & 3 deletions packages/ecsify/src/app/plugins/default-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { TEntityId } from '../../entity';
import { With } from '../../query';
import { TApp, TAppContext, TPlugin, TPluginSystemFn } from '../types';
import { TApp, TAppContext, TPlugin } from '../types';

export function createDefaultPlugin(): TDefaultPlugin {
return {
Expand All @@ -14,13 +14,13 @@ export function createDefaultPlugin(): TDefaultPlugin {
this.addComponent(eid, this.c.Removed);
}
},
setup: (app) => {
setup: (app: TApp<TAppContext<[TDefaultPlugin]>>) => {
app.addSystem(cleanupSystem, { set: 'Last' });
}
};
}

const cleanupSystem: TPluginSystemFn<TDefaultPlugin> = (app) => {
const cleanupSystem = (app: TApp<TAppContext<[TDefaultPlugin]>>) => {
// Remove entities marked for removal
for (const eid of app.queryEntities(With(app.c.Removed))) {
app.destroyEntity(eid);
Expand Down
27 changes: 1 addition & 26 deletions packages/ecsify/src/app/types/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,27 +15,10 @@ import {
} from '../../query';
import { TAddSystemOptions, TSystemFn, TSystemRegistry } from '../../system';
import { TAnyPlugin } from '../types';
import { TMergePlugins, TPlugin } from './plugin';
import { TMergePlugins } from './plugin';

/**
* The core App type that represents an ECS application instance.
*
* NOTE: Due to circular type dependencies, this type cannot be fully resolved at module level:
* 1. TApp depends on GAppContext
* 2. GAppContext (TAppContext<GPlugins>) depends on merging plugin shapes
* 3. Each plugin's setup function takes a TApp<GAppContext>
*
* This creates an unresolvable circular dependency at module level:
* TApp -> TAppContext -> TMergePlugins -> TPlugin -> TApp
*
* The type system can only resolve this within function scope where it can:
* - Defer type resolution until the function is analyzed
* - Use control flow analysis to track relationships
* - Build a complete type graph before resolving generics
*
* At module level, TypeScript must resolve types during the initial pass,
* before it has full context, causing it to resolve to 'never' when it hits
* the circular constraint.
*/
export type TApp<GAppContext extends TAppContext = TAppContext> = GAppContext['appExtensions'] & {
_pluginNames: string[];
Expand Down Expand Up @@ -265,11 +248,3 @@ export interface TInnerAppContext<GMergedPlugins extends Record<string, any> = {

export type TPluginsFromAppContext<GAppContext extends TAppContext> =
GAppContext extends TAppContext<infer GPlugins> ? GPlugins : never;

export type TAppContextFromPlugin<GPlugin extends TAnyPlugin> =
GPlugin extends TPlugin<any, any, infer GAppContext> ? GAppContext : never;

export type TPluginSystemFn<
GPlugin extends TAnyPlugin,
GAppContext extends TAppContextFromPlugin<GPlugin> = TAppContextFromPlugin<GPlugin>
> = TSystemFn<GAppContext['systemSets'], TApp<GAppContext>>;
94 changes: 39 additions & 55 deletions packages/ecsify/src/app/types/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,30 @@
import { TComponentRef } from '../../component';
import { TApp, TInnerAppContext } from './app';

export type TPluginComponents = Record<string, TComponentRef>;
export type TPluginResources = Record<string, unknown>;
export type TPluginEvents = Record<string, unknown>;
import { TApp } from './app';

// =============================================================================
// Plugin
// =============================================================================

export type TPlugin<
GShape extends TPluginShape,
GDeps extends TAnyPlugin[] = [],
GAppContext extends TInnerAppContext = TInnerAppContext<
TMergeTwoPluginShapes<GShape, TMergePlugins<GDeps>>
>
> = {
export type TPlugin<GShape extends TPluginShape, GDeps extends TAnyPlugin[] = []> = {
/**
* Internal marker to preserve generic type information during TypeScript's type flattening.
* Without this, TypeScript cannot infer GShape from TPlugin instances.
*/
__brand?: [GShape, GDeps];
name: GShape['name'];
deps: {
[K in keyof GDeps]: GDeps[K]['name'];
};
components?: GShape['components'];
resources?: GShape['resources'];
appExtensions?: GShape['appExtensions'];
setup?: (app: TApp<GAppContext>) => void;
/**
* Setup function called when the plugin is added to the app.
*
* Uses `TApp<any>` instead of the exact app context type to break circular type dependency.
* See: https://github.com/builder-group/community/issues/107
*/
setup?: (app: TApp<any>) => void;
};

export interface TPluginShape {
Expand All @@ -35,17 +36,17 @@ export interface TPluginShape {
/**
* ECS components added by the plugin.
*/
components?: TPluginComponents;
components?: Record<string, TComponentRef>;

/**
* Global resources shared in the app.
*/
resources?: TPluginResources;
resources?: Record<string, unknown>;

/**
* Event types emitted or handled by the plugin.
*/
events?: TPluginEvents;
events?: Record<string, unknown>;

/**
* ECS system execution order sets.
Expand All @@ -59,62 +60,45 @@ export interface TPluginShape {
}

/**
* A "top type" that can represent any plugin regardless of its specific shape, dependencies, or app context.
*
* All generic parameters MUST be `any` to avoid TypeScript compatibility issues:
*
* **Why `any` is required:**
* - Specific plugins have precise setup function signatures: `(app: TApp<SpecificContext>) => void`
* - If TAnyPlugin used specific types, TypeScript would reject assignments like:
* `TPlugin<ShapeA, DepsB, ContextC>` → `TAnyPlugin`
* - The error occurs because setup functions are contravariant in their parameter types
*
* **Example of the error without `any`:**
* ```
* Type 'TPlugin<SpecificShape, SpecificDeps, SpecificContext>' is not assignable to type 'TAnyPlugin'
* Types of property 'setup' are incompatible
* Type '(app: TApp<SpecificContext>) => void' is not assignable to type '(app: TApp<OtherContext>) => void'
* ```
* Type that represents any plugin regardless of its specific shape or dependencies.
*/
export type TAnyPlugin = TPlugin<any, any, any>;
export type TAnyPlugin = TPlugin<any, any>;

/**
* Extracts the `GShape` type from any variation of a TPlugin.
* Extracts the shape type from a TPlugin.
*
* Note: TypeScript's `infer` in conditional types only works when the matched type
* has the *same number of generic parameters* as the one you're checking against.
* That means:
* - `TPlugin<A>` ≠ `TPlugin<A, any, any>`
* - `TPlugin<A, B>` ≠ `TPlugin<A, B, any>`
* If you don't account for all generic arities, inference will silently fail.
* - `TPlugin<A>` ≠ `TPlugin<A, any>`
* If you don't account for all generic arities, inference will resolve to `never`.
*/
export type TShapeFromPlugin<GPlugin> =
GPlugin extends TPlugin<infer GShape, any, any>
GPlugin extends TPlugin<infer GShape, any>
? GShape
: GPlugin extends TPlugin<infer GShape, any>
: GPlugin extends TPlugin<infer GShape>
? GShape
: GPlugin extends TPlugin<infer GShape>
? GShape
: never;
: never;

/**
* Merges two plugin shapes by combining their properties.
*/
export type TMergeTwoPluginShapes<A, B> = {
components: (A extends { components: infer AC } ? AC : {}) &
(B extends { components: infer BC } ? BC : {});
resources: (A extends { resources: infer AR } ? AR : {}) &
(B extends { resources: infer BR } ? BR : {});
events: (A extends { events: infer AE } ? AE : {}) & (B extends { events: infer BE } ? BE : {});
appExtensions: (A extends { appExtensions: infer AA } ? AA : {}) &
(B extends { appExtensions: infer BA } ? BA : {});
components: (A extends { components: infer GComponents } ? GComponents : {}) &
(B extends { components: infer GComponents } ? GComponents : {});
resources: (A extends { resources: infer GResources } ? GResources : {}) &
(B extends { resources: infer GResources } ? GResources : {});
events: (A extends { events: infer GEvents } ? GEvents : {}) &
(B extends { events: infer GEvents } ? GEvents : {});
appExtensions: (A extends { appExtensions: infer GAppExtensions } ? GAppExtensions : {}) &
(B extends { appExtensions: infer GAppExtensions } ? GAppExtensions : {});
systemSets:
| (A extends { systemSets: infer AS } ? AS : never)
| (B extends { systemSets: infer BS } ? BS : never);
| (A extends { systemSets: infer GSystemSets } ? GSystemSets : never)
| (B extends { systemSets: infer GSystemSets } ? GSystemSets : never);
};

/**
* Recursively merges plugin *shapes* (not full plugin types).
*
* This avoids TypeScript recursion limits and circular type constraints
* caused by merging plugin objects directly (e.g., with `setup` functions).
* Recursively merges plugin shapes from an array of plugins.
*/
export type TMergePlugins<GPlugins extends TAnyPlugin[]> = GPlugins extends readonly [
infer GFirst,
Expand Down
60 changes: 20 additions & 40 deletions packages/ecsify/src/app/types/types.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { describe, it } from 'vitest';
import { createApp } from '../create-app';
import { createDefaultPlugin, TDefaultPlugin } from '../plugins';
import { TPlugin } from './plugin';
import { TAppContext } from './app';
import { TMergePlugins, TPlugin } from './plugin';

describe('types', () => {
it('should work', () => {
Expand All @@ -15,6 +16,11 @@ describe('types', () => {
Rectangle: TCRectangle;
Color: TCColor;
};
resources: {
game: {
score: number;
};
};
},
[TDefaultPlugin]
>;
Expand All @@ -35,6 +41,11 @@ describe('types', () => {
Rectangle: { width: [], height: [] },
Color: { value: [] }
},
resources: {
game: {
score: 0
}
},
setup: (app) => {}
};
}
Expand All @@ -44,46 +55,15 @@ describe('types', () => {
systemSets: ['First', 'Update', 'Last']
});

app.update();
});
});
app.r.game;
app.c.Position;

type TGamePlugin = TPlugin<
{
name: 'Game';
components: {
Position: TCPosition;
Velocity: TCVelocity;
Rectangle: TCRectangle;
Color: TCColor;
};
},
[TDefaultPlugin]
>;
type TGameAppContext = TAppContext<[TDefaultPlugin, TGamePlugin]>;
type TGameSystemSets = TGameAppContext['systemSets'];

// Define components
type TCPosition = { x: number[]; y: number[] };
type TCVelocity = { dx: number[]; dy: number[] };
type TCRectangle = { width: number[]; height: number[] };
type TCColor = { value: string[] };
type TGameMergedPlugins = TMergePlugins<[TDefaultPlugin, TGamePlugin]>;
type TGameMergedSystemSets = TGameMergedPlugins['systemSets'];

function createGamePlugin(): TGamePlugin {
return {
name: 'Game',
deps: ['Default'],
components: {
Position: { x: [], y: [] },
Velocity: { dx: [], dy: [] },
Rectangle: { width: [], height: [] },
Color: { value: [] }
},
setup: (app) => {}
};
}

const app = createApp({
plugins: [createDefaultPlugin(), createGamePlugin()] as const,
systemSets: ['First', 'Update', 'Last']
app.update();
});
});

app.update();
Loading