diff --git a/examples/ecsify/vanilla/basic/src/app.ts b/examples/ecsify/vanilla/basic/src/app.ts index eeef4d43..549afa07 100644 --- a/examples/ecsify/vanilla/basic/src/app.ts +++ b/examples/ecsify/vanilla/basic/src/app.ts @@ -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 @@ -105,7 +106,7 @@ export function createGamePlugin(): TGamePlugin { } // Physics system -const physicsSystem: TPluginSystemFn = (app, dt = 0.016) => { +const physicsSystem = (app: TApp>, dt = 0.016) => { for (const [eid, pos, vel, rect] of app.queryComponents([ Entity, app.c.Position, @@ -140,7 +141,7 @@ const physicsSystem: TPluginSystemFn = (app, dt = 0.016) => { }; // Rendering system -const renderingSystem: TPluginSystemFn = (app) => { +const renderingSystem = (app: TApp>) => { ctx.clearRect(0, 0, canvas.width, canvas.height); for (const [pos, color, rect] of app.queryComponents([ diff --git a/packages/ecsify/src/app/create-app.ts b/packages/ecsify/src/app/create-app.ts index 8e8b8c05..6ebbd504 100644 --- a/packages/ecsify/src/app/create-app.ts +++ b/packages/ecsify/src/app/create-app.ts @@ -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< diff --git a/packages/ecsify/src/app/plugins/default-plugin.ts b/packages/ecsify/src/app/plugins/default-plugin.ts index 5463f6b8..cde99d2b 100644 --- a/packages/ecsify/src/app/plugins/default-plugin.ts +++ b/packages/ecsify/src/app/plugins/default-plugin.ts @@ -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 { @@ -14,13 +14,13 @@ export function createDefaultPlugin(): TDefaultPlugin { this.addComponent(eid, this.c.Removed); } }, - setup: (app) => { + setup: (app: TApp>) => { app.addSystem(cleanupSystem, { set: 'Last' }); } }; } -const cleanupSystem: TPluginSystemFn = (app) => { +const cleanupSystem = (app: TApp>) => { // Remove entities marked for removal for (const eid of app.queryEntities(With(app.c.Removed))) { app.destroyEntity(eid); diff --git a/packages/ecsify/src/app/types/app.ts b/packages/ecsify/src/app/types/app.ts index b932bb07..5abee8ce 100644 --- a/packages/ecsify/src/app/types/app.ts +++ b/packages/ecsify/src/app/types/app.ts @@ -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) depends on merging plugin shapes - * 3. Each plugin's setup function takes a TApp - * - * 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['appExtensions'] & { _pluginNames: string[]; @@ -265,11 +248,3 @@ export interface TInnerAppContext = { export type TPluginsFromAppContext = GAppContext extends TAppContext ? GPlugins : never; - -export type TAppContextFromPlugin = - GPlugin extends TPlugin ? GAppContext : never; - -export type TPluginSystemFn< - GPlugin extends TAnyPlugin, - GAppContext extends TAppContextFromPlugin = TAppContextFromPlugin -> = TSystemFn>; diff --git a/packages/ecsify/src/app/types/plugin.ts b/packages/ecsify/src/app/types/plugin.ts index 648d2e03..dfae0114 100644 --- a/packages/ecsify/src/app/types/plugin.ts +++ b/packages/ecsify/src/app/types/plugin.ts @@ -1,21 +1,16 @@ import { TComponentRef } from '../../component'; -import { TApp, TInnerAppContext } from './app'; - -export type TPluginComponents = Record; -export type TPluginResources = Record; -export type TPluginEvents = Record; +import { TApp } from './app'; // ============================================================================= // Plugin // ============================================================================= -export type TPlugin< - GShape extends TPluginShape, - GDeps extends TAnyPlugin[] = [], - GAppContext extends TInnerAppContext = TInnerAppContext< - TMergeTwoPluginShapes> - > -> = { +export type TPlugin = { + /** + * 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']; @@ -23,7 +18,13 @@ export type TPlugin< components?: GShape['components']; resources?: GShape['resources']; appExtensions?: GShape['appExtensions']; - setup?: (app: TApp) => void; + /** + * Setup function called when the plugin is added to the app. + * + * Uses `TApp` instead of the exact app context type to break circular type dependency. + * See: https://github.com/builder-group/community/issues/107 + */ + setup?: (app: TApp) => void; }; export interface TPluginShape { @@ -35,17 +36,17 @@ export interface TPluginShape { /** * ECS components added by the plugin. */ - components?: TPluginComponents; + components?: Record; /** * Global resources shared in the app. */ - resources?: TPluginResources; + resources?: Record; /** * Event types emitted or handled by the plugin. */ - events?: TPluginEvents; + events?: Record; /** * ECS system execution order sets. @@ -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) => void` - * - If TAnyPlugin used specific types, TypeScript would reject assignments like: - * `TPlugin` → `TAnyPlugin` - * - The error occurs because setup functions are contravariant in their parameter types - * - * **Example of the error without `any`:** - * ``` - * Type 'TPlugin' is not assignable to type 'TAnyPlugin' - * Types of property 'setup' are incompatible - * Type '(app: TApp) => void' is not assignable to type '(app: TApp) => void' - * ``` + * Type that represents any plugin regardless of its specific shape or dependencies. */ -export type TAnyPlugin = TPlugin; +export type TAnyPlugin = TPlugin; /** - * 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` ≠ `TPlugin` - * - `TPlugin` ≠ `TPlugin` - * If you don't account for all generic arities, inference will silently fail. + * - `TPlugin` ≠ `TPlugin` + * If you don't account for all generic arities, inference will resolve to `never`. */ export type TShapeFromPlugin = - GPlugin extends TPlugin + GPlugin extends TPlugin ? GShape - : GPlugin extends TPlugin + : GPlugin extends TPlugin ? GShape - : GPlugin extends TPlugin - ? GShape - : never; + : never; +/** + * Merges two plugin shapes by combining their properties. + */ export type TMergeTwoPluginShapes = { - 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 readonly [ infer GFirst, diff --git a/packages/ecsify/src/app/types/types.test.ts b/packages/ecsify/src/app/types/types.test.ts index 18e4a094..7eae9c34 100644 --- a/packages/ecsify/src/app/types/types.test.ts +++ b/packages/ecsify/src/app/types/types.test.ts @@ -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', () => { @@ -15,6 +16,11 @@ describe('types', () => { Rectangle: TCRectangle; Color: TCColor; }; + resources: { + game: { + score: number; + }; + }; }, [TDefaultPlugin] >; @@ -35,6 +41,11 @@ describe('types', () => { Rectangle: { width: [], height: [] }, Color: { value: [] } }, + resources: { + game: { + score: 0 + } + }, setup: (app) => {} }; } @@ -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();