diff --git a/docs/development/adding-new-components.md b/docs/development/adding-new-components.md new file mode 100644 index 00000000..4f039a40 --- /dev/null +++ b/docs/development/adding-new-components.md @@ -0,0 +1,147 @@ +# Adding New Component Types + +This document outlines the new, streamlined process for defining and registering new component types in the game engine. The system now uses a `ComponentManifest` approach with auto-discovery, eliminating the need for manual registration in a central file. + +## Overview + +New components are defined by creating a "manifest" file. This file exports an object that describes the component's properties, data schema, behavior, and editor representation. The engine automatically discovers these manifest files and makes the components available. + +## Directory Structure + +Place your new component manifest files in: +`src/core/components/definitions/` + +Each component should have its own file, typically named after the component (e.g., `myCustomComponent.ts`). + +## The Component Manifest (`ComponentManifest`) + +Each component definition file must default export an object that conforms to the `ComponentManifest` interface. + +The structure of `ComponentManifest` is defined in `src/core/components/types.ts`. Key properties include: + +* **`id: string`**: A unique string identifier for your component (e.g., `"Health"`, `"CustomMover"`). This is crucial. +* **`name: string`**: The user-friendly name displayed in the editor (e.g., `"Health"`, `"Custom Mover"`). +* **`category: ComponentCategory`**: The category in the editor's "Add Component" menu. Import `ComponentCategory` from `@core/types/component-registry`. + * Examples: `ComponentCategory.Core`, `ComponentCategory.Rendering`, `ComponentCategory.Physics`, `ComponentCategory.Gameplay`, etc. +* **`description: string`**: A brief description of what the component does. +* **`icon: React.ReactNode`**: A React node for the icon in the editor. Typically use `react-icons`. + * Example: `React.createElement(FiHeart, { className: 'w-4 h-4' })` +* **`schema: z.ZodSchema`**: A Zod schema defining the structure, types, and default values for your component's data. `TData` should be your component's data interface. +* **`getDefaultData: () => TData`**: A function that returns an instance of `TData` with default values. It's highly recommended to use your Zod schema to provide these defaults: `() => YourSchema.parse({})`. +* **`getRenderingContributions?: (data: TData) => IRenderingContributions`**: Optional. If your component influences how an entity is rendered (e.g., visibility, material properties, geometry type), define this function. It should return an object conforming to `IRenderingContributions` (defined in `src/core/components/types.ts`). +* **`getPhysicsContributions?: (data: TData) => IPhysicsContributions`**: Optional. If your component influences physics (e.g., rigid body properties, colliders), define this function. It should return an object conforming to `IPhysicsContributions` (defined in `src/core/components/types.ts`). +* **`onAdd?: (entityId: number, data: TData) => void`**: Optional. A callback function executed when this component is added to an entity. `data` is the fully validated data (including defaults). +* **`onRemove?: (entityId: number) => void`**: Optional. A callback function executed when this component is removed from an entity. +* **`removable?: boolean`**: Optional (defaults to `true`). Determines if the component can be removed from an entity in the editor. Core components like `Transform` are often set to `false`. +* **`dependencies?: string[]`**: Optional. An array of component `id`s that this component requires to function correctly. The system may enforce these dependencies in the future (currently informational). +* **`conflicts?: string[]`**: Optional. An array of component `id`s that cannot coexist with this component on the same entity. The system may enforce this in the future (currently informational). + +## Defining Component Data and Schema (Zod) + +Define an interface for your component's data (`TData`) and a corresponding Zod schema. + +```typescript +// Example: src/core/components/definitions/health.ts + +import { z } from 'zod'; +import React from 'react'; +import { FiHeart } from 'react-icons/fi'; +import { ComponentManifest } from '../types'; // Path to ComponentManifest definition +import { ComponentCategory } from '@core/types/component-registry'; // Path to ComponentCategory enum + +// 1. Define the data interface +export interface HealthData { + currentHealth: number; + maxHealth: number; + canBeDamaged: boolean; +} + +// 2. Define the Zod schema +// This schema validates the data and provides default values. +export const HealthSchema = z.object({ + currentHealth: z.number().int().min(0).default(100), + maxHealth: z.number().int().min(1).default(100), + canBeDamaged: z.boolean().default(true), +}); + +// 3. Create the manifest +const healthManifest: ComponentManifest = { + id: 'Health', // Unique ID + name: 'Health', // Display name + category: ComponentCategory.Gameplay, // Editor category + description: 'Manages the health and damage state of an entity.', + icon: React.createElement(FiHeart, { className: 'w-4 h-4' }), // Icon for the editor + schema: HealthSchema, // The Zod schema for validation and defaults + getDefaultData: () => HealthSchema.parse({}), // Function to get default data instance + onAdd: (entityId, data) => { + console.log(`Health component added to entity ${entityId} with data:`, data); + // Example: Initialize health-related systems or UI for this entity + }, + onRemove: (entityId) => { + console.log(`Health component removed from entity ${entityId}`); + // Example: Clean up health-related systems or UI + }, + removable: true, // This component can be removed in the editor + dependencies: ['Transform'], // Example: Health might conceptually depend on Transform +}; + +export default healthManifest; // Default export the manifest object +``` + +## Automatic Registration + +Once you create your component manifest file (e.g., `health.ts`) in the `src/core/components/definitions/` directory, the engine's build process (specifically Vite's `import.meta.glob`) will automatically detect and register it. You **do not** need to add it to any central list or registry file manually. + +The `dynamicComponentRegistry.ts` handles this auto-discovery. + +## Component IDs and `AutoKnownComponentTypes` + +The `id` field (e.g., `"Health"`) in your manifest is the canonical identifier for your component. This string ID is what you should use when: +* Referring to components in `dependencies` or `conflicts` arrays. +* Adding components to `AUTO_COMPONENT_PACKS`. +* Querying or interacting with components via the `ComponentManager`. + +While the system automatically generates an `AutoKnownComponentTypes` object (located in `src/core/lib/ecs/dynamicComponentRegistry.ts`), which maps uppercase snake_case versions of IDs (e.g., `HEALTH: "Health"`) for convenience in some legacy or internal areas, direct string IDs are preferred for most operations. + +## Adding to Component Packs + +Component packs group related components for easy addition in the editor from the "Add Component" menu. Packs are currently defined as a static array in `src/core/lib/ecs/dynamicComponentRegistry.ts` within the `AUTO_COMPONENT_PACKS` constant. + +To add your new component to an existing pack, or to create a new pack including your component, you'll need to manually edit this array. + +Example: +```typescript +// In src/core/lib/ecs/dynamicComponentRegistry.ts +// ... +// Ensure you import IComponentPack from './types' or similar +// and any necessary icons (e.g., FiShield from 'react-icons/fi') + +export const AUTO_COMPONENT_PACKS: Readonly = Object.freeze([ + // ... other packs ... + { + id: 'character-essentials', + name: 'Character Essentials', + description: 'Basic components for a game character.', + icon: React.createElement(FiShield, { className: 'w-4 h-4' }), // Example icon + category: ComponentCategory.Gameplay, // Example category + components: ['Transform', 'MeshRenderer', 'Health', 'CustomMover'], // Added 'Health' and 'CustomMover' IDs + }, +]); +// ... +``` +*(The process for defining component packs might be refined in the future, potentially moving towards auto-discovery for packs as well.)* + +## Summary + +1. **Create Manifest File**: Create a `yourComponent.ts` file (e.g., `health.ts`) in `src/core/components/definitions/`. +2. **Define Data Interface**: Define an interface for your component's data (e.g., `HealthData`). +3. **Define Zod Schema**: Create a Zod schema (e.g., `HealthSchema`) for the data interface, including default values for each field. +4. **Create Manifest Object**: Implement and default export a `ComponentManifest` object. + * Fill in `id`, `name`, `category`, `description`, `icon`. + * Assign your Zod schema to the `schema` property. + * Set `getDefaultData: () => YourSchema.parse({})`. + * Implement optional lifecycle hooks (`onAdd`, `onRemove`), contribution functions (`getRenderingContributions`, `getPhysicsContributions`), and other properties (`removable`, `dependencies`, `conflicts`) as needed. +5. **(Optional) Add to Packs**: If desired, manually add your component's `id` to the `components` array of relevant packs in `AUTO_COMPONENT_PACKS` within `src/core/lib/ecs/dynamicComponentRegistry.ts`. + +Your new component will then be automatically discovered by the engine and available for use in the editor and through the `ComponentManager`. +``` diff --git a/src/core/components/definitions/CoreContributions.spec.ts b/src/core/components/definitions/CoreContributions.spec.ts new file mode 100644 index 00000000..2d5b905e --- /dev/null +++ b/src/core/components/definitions/CoreContributions.spec.ts @@ -0,0 +1,173 @@ +import { describe, it, expect } from 'vitest'; + +// Import Actual Manifests +import transformManifest from '@core/components/definitions/transform'; +import meshRendererManifest from '@core/components/definitions/meshRenderer'; +import rigidBodyManifest from '@core/components/definitions/rigidBody'; +import meshColliderManifest from '@core/components/definitions/meshCollider'; +import cameraManifest from '@core/components/definitions/camera'; + +// Import Data Types and Contribution Interfaces +import { IRenderingContributions, IPhysicsContributions } from '@core/components/types'; +import { TransformData } from './transform'; // Assuming transform.ts exports TransformData +import { MeshRendererData } from './meshRenderer'; // Assuming meshRenderer.ts exports MeshRendererData +import { RigidBodyData } from './rigidBody'; // Assuming rigidBody.ts exports RigidBodyData +import { MeshColliderData } from './meshCollider'; // Assuming meshCollider.ts exports MeshColliderData +import { CameraData } from './camera'; // Assuming camera.ts exports CameraData + +describe('Core Component Manifest Contributions', () => { + + describe(transformManifest.id + ' Contributions', () => { + it('should not have getRenderingContributions defined', () => { + expect(transformManifest.getRenderingContributions).toBeUndefined(); + }); + + it('should not have getPhysicsContributions defined', () => { + expect(transformManifest.getPhysicsContributions).toBeUndefined(); + }); + }); + + describe(meshRendererManifest.id + ' Contributions', () => { + it('should return correct rendering contributions with default data', () => { + const defaultData = meshRendererManifest.getDefaultData(); + // Manifest guarantees getRenderingContributions exists + const contributions = meshRendererManifest.getRenderingContributions!(defaultData); + + expect(contributions.meshType).toBe('Cube'); // Default meshId is 'cube' + expect(contributions.visible).toBe(true); // Default enabled is true + expect(contributions.castShadow).toBe(true); // Default castShadows is true + expect(contributions.receiveShadow).toBe(true); // Default receiveShadows is true + expect(contributions.material?.color).toBe('#3399ff'); // Default color + expect(contributions.material?.metalness).toBe(0.0); + expect(contributions.material?.roughness).toBe(0.5); + expect(contributions.material?.emissive).toBe('#000000'); + expect(contributions.material?.emissiveIntensity).toBe(0.0); + }); + + it('should reflect custom data in rendering contributions', () => { + const customData: MeshRendererData = { + meshId: 'sphere', + materialId: 'customMat', + enabled: false, + castShadows: false, + receiveShadows: false, + material: { color: '#FF0000', metalness: 0.8, roughness: 0.2, emissive: '#00FF00', emissiveIntensity: 0.5 }, + }; + const contributions = meshRendererManifest.getRenderingContributions!(customData); + + expect(contributions.meshType).toBe('Sphere'); + expect(contributions.visible).toBe(false); + expect(contributions.castShadow).toBe(false); + expect(contributions.receiveShadow).toBe(false); // Based on castShadows: false, receiveShadows: false + expect(contributions.material?.color).toBe('#FF0000'); + expect(contributions.material?.metalness).toBe(0.8); + expect(contributions.material?.roughness).toBe(0.2); + expect(contributions.material?.emissive).toBe('#00FF00'); + expect(contributions.material?.emissiveIntensity).toBe(0.5); + }); + }); + + describe(rigidBodyManifest.id + ' Contributions', () => { + it('should return correct physics contributions with default data', () => { + const defaultData = rigidBodyManifest.getDefaultData(); + const contributions = rigidBodyManifest.getPhysicsContributions!(defaultData); + + expect(contributions.enabled).toBe(true); + expect(contributions.rigidBodyProps?.type).toBe('dynamic'); + expect(contributions.rigidBodyProps?.mass).toBe(1); + expect(contributions.rigidBodyProps?.gravityScale).toBe(1); + expect(contributions.rigidBodyProps?.canSleep).toBe(true); + expect(contributions.rigidBodyProps?.linearDamping).toBe(0); + expect(contributions.rigidBodyProps?.angularDamping).toBe(0); + expect(contributions.rigidBodyProps?.friction).toBe(0.7); + expect(contributions.rigidBodyProps?.restitution).toBe(0.3); + expect(contributions.rigidBodyProps?.density).toBe(1); + }); + + it('should reflect custom data in physics contributions', () => { + const customData: RigidBodyData = { + bodyType: 'static', + mass: 100, + enabled: false, + gravityScale: 0, + canSleep: false, + linearDamping: 0.5, + angularDamping: 0.5, + material: { friction: 0.2, restitution: 0.8, density: 2 }, + }; + const contributions = rigidBodyManifest.getPhysicsContributions!(customData); + + expect(contributions.enabled).toBe(false); + expect(contributions.rigidBodyProps?.type).toBe('static'); + expect(contributions.rigidBodyProps?.mass).toBe(100); + expect(contributions.rigidBodyProps?.gravityScale).toBe(0); + expect(contributions.rigidBodyProps?.canSleep).toBe(false); + expect(contributions.rigidBodyProps?.linearDamping).toBe(0.5); + expect(contributions.rigidBodyProps?.angularDamping).toBe(0.5); + expect(contributions.rigidBodyProps?.friction).toBe(0.2); + expect(contributions.rigidBodyProps?.restitution).toBe(0.8); + expect(contributions.rigidBodyProps?.density).toBe(2); + }); + }); + + describe(meshColliderManifest.id + ' Contributions', () => { + it('should return correct physics contributions with default data', () => { + const defaultData = meshColliderManifest.getDefaultData(); + const contributions = meshColliderManifest.getPhysicsContributions!(defaultData); + + expect(contributions.enabled).toBe(true); + // MeshCollider primarily contributes material properties to rigidBodyProps + expect(contributions.rigidBodyProps?.friction).toBe(0.7); + expect(contributions.rigidBodyProps?.restitution).toBe(0.3); + expect(contributions.rigidBodyProps?.density).toBe(1); + // It doesn't define body type or mass itself + expect(contributions.rigidBodyProps?.type).toBeUndefined(); + expect(contributions.rigidBodyProps?.mass).toBeUndefined(); + }); + + it('should reflect custom data in physics contributions', () => { + const customData: MeshColliderData = { + enabled: false, + colliderType: 'sphere', // This doesn't directly affect contributions structure here + isTrigger: true, // This also doesn't directly affect contributions structure here + center: [0,0,0], + size: { radius: 1 }, + physicsMaterial: { friction: 0.1, restitution: 0.9, density: 0.5 }, + }; + const contributions = meshColliderManifest.getPhysicsContributions!(customData); + + expect(contributions.enabled).toBe(false); + expect(contributions.rigidBodyProps?.friction).toBe(0.1); + expect(contributions.rigidBodyProps?.restitution).toBe(0.9); + expect(contributions.rigidBodyProps?.density).toBe(0.5); + }); + + it('should return enabled: false if data.enabled is false', () => { + const customData: MeshColliderData = { + ...meshColliderManifest.getDefaultData(), + enabled: false, + }; + const contributions = meshColliderManifest.getPhysicsContributions!(customData); + expect(contributions.enabled).toBe(false); + // rigidBodyProps might be undefined or empty if enabled is false, + // depending on implementation, current implementation still returns them. + // Let's check they are still there based on current manifest logic. + expect(contributions.rigidBodyProps?.friction).toBe(0.7); + }); + }); + + describe(cameraManifest.id + ' Contributions', () => { + it('should return correct rendering contributions with default data', () => { + const defaultData = cameraManifest.getDefaultData(); + const contributions = cameraManifest.getRenderingContributions!(defaultData); + + expect(contributions.meshType).toBe('CameraGizmo'); + expect(contributions.visible).toBe(true); + expect(contributions.castShadow).toBe(false); + expect(contributions.receiveShadow).toBe(false); + }); + + // Camera's getRenderingContributions is not data-dependent, so one test is sufficient. + // If it became data-dependent, more tests would be needed. + }); +}); diff --git a/src/core/components/definitions/camera.ts b/src/core/components/definitions/camera.ts new file mode 100644 index 00000000..ac482bb1 --- /dev/null +++ b/src/core/components/definitions/camera.ts @@ -0,0 +1,62 @@ +import { z } from 'zod'; +import React from 'react'; +import { FiCamera } from 'react-icons/fi'; +import { ComponentManifest, IRenderingContributions } from '../types'; +import { ComponentCategory } from '@core/types/component-registry'; + +// Define Data Interface (CameraData) +interface CameraData { + preset: string; // e.g., 'unity-default', 'custom' + fov: number; // Field of View + near: number; // Near clipping plane + far: number; // Far clipping plane + isMain: boolean; // Whether this is the main camera + enableControls: boolean; // If camera controls are enabled (e.g., OrbitControls) + target: [number, number, number]; // Target position for controls or lookAt + projectionType: 'perspective' | 'orthographic'; + clearDepth: boolean; // Whether the camera should clear the depth buffer + renderPriority: number; // Order of rendering for multiple cameras +} + +// Define Zod Schema (CameraSchema) +const CameraSchema = z.object({ + preset: z.string().default('unity-default'), + fov: z.number().min(1).max(179).default(30), // Typical FoV range + near: z.number().positive().default(0.1), + far: z.number().positive().default(1000), // Increased default far plane + isMain: z.boolean().default(false), + enableControls: z.boolean().default(true), + target: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + projectionType: z.enum(['perspective', 'orthographic']).default('perspective'), + clearDepth: z.boolean().default(true), + renderPriority: z.number().int().default(0), +}); + +// Create Manifest +const cameraManifest: ComponentManifest = { + id: 'Camera', // From old KnownComponentTypes.CAMERA + name: 'Camera', + category: ComponentCategory.Rendering, + description: 'Provides a viewpoint for rendering the scene.', + icon: React.createElement(FiCamera, { className: 'w-4 h-4' }), + schema: CameraSchema, + getDefaultData: () => CameraSchema.parse({}), + getRenderingContributions: (data: CameraData): IRenderingContributions => { + // Logic adapted from old ComponentRegistry.ts + // The old registry returned a specific meshType: 'Camera' which might be for editor visualization + // and set visible, castShadow, receiveShadow. + // The actual camera behavior (fov, near, far, etc.) is handled by the CameraSystem/GameCameraManager + // using the component's data directly, not typically through rendering contributions of this shape. + // This contribution is more about how the camera *entity itself* might be visualized in the editor. + return { + meshType: 'CameraGizmo', // Suggesting a more specific name for editor visualization + visible: true, // The gizmo itself should be visible in editor + castShadow: false, // Gizmos typically don't cast shadows + receiveShadow: false, // Gizmos typically don't receive shadows + // Other properties like FoV, near, far from `data` will be used by the camera system itself. + }; + }, + removable: false, // Cameras are often core scene elements, though this can be true if desired +}; + +export default cameraManifest; diff --git a/src/core/components/definitions/meshCollider.ts b/src/core/components/definitions/meshCollider.ts new file mode 100644 index 00000000..c4bc36d8 --- /dev/null +++ b/src/core/components/definitions/meshCollider.ts @@ -0,0 +1,104 @@ +import { z } from 'zod'; +import React from 'react'; +import { FiShield } from 'react-icons/fi'; +import { ComponentManifest, IPhysicsContributions } from '../types'; +import { ComponentCategory } from '@core/types/component-registry'; + +// Define Data Interface (MeshColliderData) +interface MeshColliderData { + enabled: boolean; + colliderType: 'box' | 'sphere' | 'capsule' | 'cylinder' | 'cone' | 'hull' | 'trimesh'; // Common collider types + isTrigger: boolean; + center: [number, number, number]; + size: { // Size properties can vary based on colliderType + width?: number; // For box + height?: number; // For box, capsule, cylinder + depth?: number; // For box + radius?: number; // For sphere, capsule, cylinder, cone + capsuleRadius?: number; // Rapier specific for capsule + capsuleHeight?: number; // Rapier specific for capsule (half height) + // For hull/trimesh, vertices/indices would be needed, complex for generic schema. + // For now, focusing on primitives. + }; + physicsMaterial: { // Renamed from 'material' in some old contexts to avoid confusion + friction: number; + restitution: number; + density: number; // Often used to calculate mass if not overridden by RigidBody + }; +} + +// Define Zod Schema (MeshColliderSchema) +const MeshColliderSchema = z.object({ + enabled: z.boolean().default(true), + colliderType: z.enum(['box', 'sphere', 'capsule', 'cylinder', 'cone', 'hull', 'trimesh']).default('box'), + isTrigger: z.boolean().default(false), + center: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + size: z.object({ // Defaulting to a 1x1x1 box equivalent + width: z.number().optional().default(1), + height: z.number().optional().default(1), + depth: z.number().optional().default(1), + radius: z.number().optional().default(0.5), // Default for sphere/capsule + capsuleRadius: z.number().optional().default(0.5), + capsuleHeight: z.number().optional().default(1), // Half height of the cylindrical part + }).default({ width: 1, height: 1, depth: 1, radius: 0.5, capsuleRadius: 0.5, capsuleHeight: 1}), + physicsMaterial: z.object({ + friction: z.number().min(0).default(0.7), + restitution: z.number().min(0).default(0.3), + density: z.number().min(0).default(1), + }), +}); + +// Create Manifest +const meshColliderManifest: ComponentManifest = { + id: 'MeshCollider', // From old KnownComponentTypes.MESH_COLLIDER + name: 'Mesh Collider', + category: ComponentCategory.Physics, + description: 'Defines the collision shape for an entity.', + icon: React.createElement(FiShield, { className: 'w-4 h-4' }), + schema: MeshColliderSchema, + getDefaultData: () => MeshColliderSchema.parse({}), + getPhysicsContributions: (data: MeshColliderData): IPhysicsContributions => { + // Logic adapted from old ComponentRegistry.ts + // The old registry version mainly contributed material properties. + // A full collider contribution would be more complex and involve geometry generation. + // For now, following the old pattern of primarily contributing material aspects + // and assuming the actual collider geometry setup happens elsewhere based on 'colliderType' and 'size'. + if (!data.enabled) { + return { enabled: false }; + } + + // The primary role of MeshCollider's physics contribution here is to provide + // material properties. The actual collider shape definition will be used by + // the physics system when constructing the physics body. + // We can also pass collider-specific properties if IPhysicsContributions supports it. + // For now, it mostly affects rigidBodyProps. + return { + // Colliders would be defined more explicitly here if IPhysicsContributions had a more detailed structure for them. + // e.g., colliders: [{ type: data.colliderType, size: data.size, material: data.physicsMaterial, isTrigger: data.isTrigger }] + // For now, we pass material properties that might influence the overall rigid body. + rigidBodyProps: { // These props might merge with or be overridden by RigidBody component's contributions + friction: data.physicsMaterial.friction, + restitution: data.physicsMaterial.restitution, + density: data.physicsMaterial.density, // Density is often part of collider definition + }, + // It's important that the physics system knows this entity *has* a collider. + // This can be implicit if rigidBodyProps are present, or explicit. + // Adding a conceptual 'hasCollider' or similar if the system needs it. + // For now, enabling physics contributions means a collider is intended. + enabled: data.enabled, + // If IPhysicsContributions had a specific field for colliders: + // colliders: [ + // { + // type: data.colliderType, + // isTrigger: data.isTrigger, + // center: data.center, + // size: data.size, + // material: data.physicsMaterial, + // } + // ] + }; + }, + removable: true, +}; + +export default meshColliderManifest; diff --git a/src/core/components/definitions/meshRenderer.ts b/src/core/components/definitions/meshRenderer.ts new file mode 100644 index 00000000..f6869854 --- /dev/null +++ b/src/core/components/definitions/meshRenderer.ts @@ -0,0 +1,61 @@ +import { z } from 'zod'; +import React from 'react'; +import { FiEye } from 'react-icons/fi'; +import { ComponentManifest, IRenderingContributions } from '../types'; +import { ComponentCategory } from '@core/types/component-registry'; + +interface MeshRendererData { + meshId: string; // e.g., 'cube', 'sphere' + materialId: string; // Identifier for a material asset or definition + enabled: boolean; + castShadows: boolean; + receiveShadows: boolean; + material: { + color: string; // hex color + metalness: number; + roughness: number; + emissive: string; // hex color + emissiveIntensity: number; + }; +} + +const MeshRendererSchema = z.object({ + meshId: z.string().default('cube'), + materialId: z.string().default('default'), // Default material identifier + enabled: z.boolean().default(true), + castShadows: z.boolean().default(true), + receiveShadows: z.boolean().default(true), + material: z.object({ + color: z.string().regex(/^#[0-9a-fA-F]{6}$/, "Must be a valid hex color").default('#3399ff'), + metalness: z.number().min(0).max(1).default(0.0), + roughness: z.number().min(0).max(1).default(0.5), + emissive: z.string().regex(/^#[0-9a-fA-F]{6}$/, "Must be a valid hex color").default('#000000'), + emissiveIntensity: z.number().min(0).default(0.0), + }), +}); + +const meshRendererManifest: ComponentManifest = { + id: 'MeshRenderer', // Matches KnownComponentTypes.MESH_RENDERER + name: 'Mesh Renderer', + category: ComponentCategory.Rendering, + description: 'Renders 3D mesh geometry.', + icon: React.createElement(FiEye, { className: 'w-4 h-4' }), + schema: MeshRendererSchema, + getDefaultData: () => MeshRendererSchema.parse({}), // Get defaults from Zod + getRenderingContributions: (data: MeshRendererData): IRenderingContributions => { + const meshIdToTypeMap: { [key: string]: string } = { + cube: 'Cube', sphere: 'Sphere', cylinder: 'Cylinder', + cone: 'Cone', torus: 'Torus', plane: 'Plane', capsule: 'Cube', // Assuming capsule maps to a Cube for rendering + }; + return { + meshType: meshIdToTypeMap[data.meshId] || 'Cube', // Default to Cube if meshId is unknown + material: data.material, + visible: data.enabled, + castShadow: data.castShadows, + receiveShadow: data.receiveShadows, + }; + }, + removable: true, +}; + +export default meshRendererManifest; diff --git a/src/core/components/definitions/rigidBody.ts b/src/core/components/definitions/rigidBody.ts new file mode 100644 index 00000000..13f79e54 --- /dev/null +++ b/src/core/components/definitions/rigidBody.ts @@ -0,0 +1,80 @@ +import { z } from 'zod'; +import React from 'react'; +import { FiZap } from 'react-icons/fi'; +import { ComponentManifest, IPhysicsContributions } from '../types'; +import { ComponentCategory } from '@core/types/component-registry'; + +// Define Data Interface (RigidBodyData) +interface RigidBodyData { + bodyType: 'dynamic' | 'kinematicPositionBased' | 'kinematicVelocityBased' | 'static'; + mass: number; + enabled: boolean; + gravityScale: number; + canSleep: boolean; + linearDamping: number; // Added based on typical RigidBody needs, adjust if not in old registry + angularDamping: number; // Added based on typical RigidBody needs, adjust if not in old registry + material: { + friction: number; + restitution: number; + density: number; + }; + // initialVelocity?: [number, number, number]; // Optional, if needed from old data + // initialAngularVelocity?: [number, number, number]; // Optional, if needed +} + +// Define Zod Schema (RigidBodySchema) +// Note: Rapier (used by R3F physics) uses 'kinematicPositionBased' and 'kinematicVelocityBased' +// The old 'kinematic' might map to 'kinematicPositionBased'. Defaulting to 'dynamic'. +const RigidBodySchema = z.object({ + bodyType: z.enum(['dynamic', 'kinematicPositionBased', 'kinematicVelocityBased', 'static']).default('dynamic'), + mass: z.number().min(0).default(1), + enabled: z.boolean().default(true), + gravityScale: z.number().default(1), + canSleep: z.boolean().default(true), + linearDamping: z.number().default(0), // Default based on Rapier + angularDamping: z.number().default(0), // Default based on Rapier + material: z.object({ + friction: z.number().min(0).default(0.7), + restitution: z.number().min(0).default(0.3), + density: z.number().min(0).default(1), + }), + // initialVelocity: z.tuple([z.number(), z.number(), z.number()]).optional(), + // initialAngularVelocity: z.tuple([z.number(), z.number(), z.number()]).optional(), +}); + +// Create Manifest +const rigidBodyManifest: ComponentManifest = { + id: 'RigidBody', // From old KnownComponentTypes.RIGID_BODY + name: 'Rigid Body', + category: ComponentCategory.Physics, + description: 'Enables physics simulation for an entity, making it a rigid body.', + icon: React.createElement(FiZap, { className: 'w-4 h-4' }), + schema: RigidBodySchema, + getDefaultData: () => RigidBodySchema.parse({}), + getPhysicsContributions: (data: RigidBodyData): IPhysicsContributions => { + // Logic adapted from old ComponentRegistry.ts + // The old registry had data.type and data.bodyType sometimes. Standardizing to data.bodyType. + // It also had data.material.friction etc directly under rigidBodyProps in contributions. + // The new structure for IPhysicsContributions has rigidBodyProps which then contains friction etc. + // This adapter function maps the component's data to these contribution props. + return { + rigidBodyProps: { + type: data.bodyType, // This will be used by the physics system + mass: data.mass, + friction: data.material.friction, // Pass material properties for the physics engine + restitution: data.material.restitution, + density: data.material.density, // Though density + colliders usually determine mass, it can be a direct prop + gravityScale: data.gravityScale, + canSleep: data.canSleep, + linearDamping: data.linearDamping, + angularDamping: data.angularDamping, + // initialVelocity: data.initialVelocity, + // initialAngularVelocity: data.initialAngularVelocity, + }, + enabled: data.enabled, // Whether the physics body is active + }; + }, + removable: true, +}; + +export default rigidBodyManifest; diff --git a/src/core/components/definitions/transform.ts b/src/core/components/definitions/transform.ts new file mode 100644 index 00000000..2e8b8588 --- /dev/null +++ b/src/core/components/definitions/transform.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; +import React from 'react'; +import { FiMove } from 'react-icons/fi'; +import { ComponentManifest } from '../types'; +import { ComponentCategory } from '@core/types/component-registry'; + +interface TransformData { + position: [number, number, number]; + rotation: [number, number, number, number]; // Quaternion + scale: [number, number, number]; +} + +const TransformSchema = z.object({ + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.tuple([z.number(), z.number(), z.number(), z.number()]).default([0, 0, 0, 1]), // Quaternion default + scale: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), +}); + +const transformManifest: ComponentManifest = { + id: 'Transform', // Matches KnownComponentTypes.TRANSFORM + name: 'Transform', + category: ComponentCategory.Core, + description: 'Position, rotation, and scale of an entity.', + icon: React.createElement(FiMove, { className: 'w-4 h-4' }), + schema: TransformSchema, + getDefaultData: () => TransformSchema.parse({}), // Get defaults from Zod + removable: false, + // No rendering or physics contributions from Transform itself in this model +}; + +export default transformManifest; diff --git a/src/core/components/types.ts b/src/core/components/types.ts new file mode 100644 index 00000000..78878c0c --- /dev/null +++ b/src/core/components/types.ts @@ -0,0 +1,59 @@ +import { z } from 'zod'; +import React from 'react'; +import { ComponentCategory } from '@core/types/component-registry'; // Ensure this path is correct + +export interface IRenderingContributions { + geometry?: React.ReactNode; + material?: { + color?: string; + metalness?: number; + roughness?: number; + emissive?: string; + emissiveIntensity?: number; + }; + visible?: boolean; + castShadow?: boolean; + receiveShadow?: boolean; + meshType?: string; +} + +export interface IPhysicsContributions { + colliders?: React.ReactNode[]; + rigidBodyProps?: { + type?: string; + mass?: number; + friction?: number; + restitution?: number; + density?: number; + gravityScale?: number; + canSleep?: boolean; + }; + enabled?: boolean; +} + +export interface ComponentManifest { + id: string; + name: string; + category: ComponentCategory; + description: string; + icon: React.ReactNode; + schema: z.ZodSchema; + getDefaultData: () => TData; + getRenderingContributions?: (data: TData) => IRenderingContributions; + getPhysicsContributions?: (data: TData) => IPhysicsContributions; + onAdd?: (entityId: number, data: TData) => void; + onRemove?: (entityId: number) => void; + removable?: boolean; + dependencies?: string[]; + conflicts?: string[]; +} + +// Component pack definition (moved from old ComponentRegistry.ts) +export interface IComponentPack { + id: string; + name: string; + description: string; + icon: React.ReactNode; // Should be React.ReactNode for consistency with ComponentManifest + components: string[]; // Array of component IDs + category: ComponentCategory; // Use ComponentCategory enum +} diff --git a/src/core/lib/ecs/ComponentManager.spec.ts b/src/core/lib/ecs/ComponentManager.spec.ts new file mode 100644 index 00000000..eff6c263 --- /dev/null +++ b/src/core/lib/ecs/ComponentManager.spec.ts @@ -0,0 +1,615 @@ +import { describe, it, expect, vi, beforeEach, afterEach, SpyInstance } from 'vitest'; +import { z } from 'zod'; +import { ComponentManifest, ComponentCategory } from '@core/components/types'; +import { ComponentManager } from './ComponentManager'; // The class to test +import * as DynamicRegistry from './dynamicComponentRegistry'; // To mock its functions +import { EntityId } from './types'; // Added + +// --- Actual Manifest Imports --- +import transformManifestActual from '@core/components/definitions/transform'; +import meshRendererManifestActual from '@core/components/definitions/meshRenderer'; +import rigidBodyManifestActual from '@core/components/definitions/rigidBody'; +import meshColliderManifestActual from '@core/components/definitions/meshCollider'; +import cameraManifestActual from '@core/components/definitions/camera'; + +// --- Mocks for dynamicComponentRegistry --- +// We'll mock the functions ComponentManager relies on +vi.mock('./dynamicComponentRegistry', async (importOriginal) => { + const actual = await importOriginal(); // Get actual types and non-mocked parts + return { + ...actual, // Spread actual to keep other exports intact if any + getComponentDefinition: vi.fn(), + getAllComponentDefinitions: vi.fn(), + // Mock other helpers if ComponentManager starts using them directly + }; +}); + +// --- Mock Manifest Definitions --- +const mockHealthData = { current: 100, max: 100 }; +const mockHealthSchema = z.object({ + current: z.number().default(mockHealthData.current), + max: z.number().default(mockHealthData.max), +}); +const mockHealthManifest: ComponentManifest = { + id: 'Health', + name: 'Health', + category: ComponentCategory.Gameplay, + description: 'Manages health', + icon: null, + schema: mockHealthSchema, + getDefaultData: () => mockHealthSchema.parse({}), // Uses Zod defaults + onAdd: vi.fn(), + onRemove: vi.fn(), +}; + +const mockTransformData = { position: [0,0,0], rotation: [0,0,0,1], scale: [1,1,1] }; +const mockTransformSchema = z.object({ + position: z.tuple([z.number(), z.number(), z.number()]).default([0,0,0]), + rotation: z.tuple([z.number(), z.number(), z.number(), z.number()]).default([0,0,0,1]), + scale: z.tuple([z.number(), z.number(), z.number()]).default([1,1,1]), +}); +const mockTransformManifest: ComponentManifest = { + id: 'Transform', // This ID is special as it's in componentMap in ComponentManager + name: 'Transform', + category: ComponentCategory.Core, + description: 'Position, rotation, scale', + icon: null, + schema: mockTransformSchema, + getDefaultData: () => mockTransformSchema.parse({}), + removable: false, + onAdd: vi.fn(), + onRemove: vi.fn(), +}; + +// Mock for a component that doesn't have a bitecs mapping +const mockCustomData = { value: "test" }; +const mockCustomSchema = z.object({ value: z.string().default("default") }); +const mockCustomComponentManifest: ComponentManifest = { + id: 'CustomComponent', + name: 'Custom Component', + category: ComponentCategory.Gameplay, + description: 'A custom non-bitecs component', + icon: null, + schema: mockCustomSchema, + getDefaultData: () => mockCustomSchema.parse({}), + onAdd: vi.fn(), + onRemove: vi.fn(), +}; + + +// --- Mock bitecs --- +// ComponentManager interacts with bitecs. We will mock these interactions +// for most tests, but unmock for specific bitecs interaction tests. +const mockBitecs = { + addComponent: vi.fn(), + removeComponent: vi.fn(), // This is bitecsRemoveComponent in ComponentManager + hasComponent: vi.fn(), // This is bitecsHasComponent in ComponentManager + defineQuery: vi.fn(), + removeEntity: vi.fn(), + addEntity: vi.fn().mockImplementation(() => 0 as EntityId), // Default mock for addEntity + // Mock other bitecs exports if needed +}; +vi.mock('bitecs', () => mockBitecs); + +// --- Import actual bitecs for specific tests --- +import * as ActualBitecs from 'bitecs'; +import { Transform as BitecsTransformObject } from '@core/lib/ecs/BitECSComponents'; // Actual Transform component for bitecs +import { ECSWorld } from '@core/lib/ecs/World'; // Actual world + + +// --- Test Suite --- +describe('ComponentManager', () => { + let componentManager: ComponentManager; + let mockGetDefinition: SpyInstance; + let mockGetAllDefinitions: SpyInstance; + let consoleErrorSpy: SpyInstance; + let consoleWarnSpy: SpyInstance; + + // Helper to cast to mocked functions for type safety in tests + const asMock = (fn: any) => fn as vi.Mock; + + beforeEach(() => { + vi.clearAllMocks(); // Clear all mocks before each test + + // Setup spies for console messages + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Initialize ComponentManager instance for each test + componentManager = ComponentManager.getInstance(); + + // Assign mocked functions from the registry + mockGetDefinition = DynamicRegistry.getComponentDefinition as SpyInstance; + mockGetAllDefinitions = DynamicRegistry.getAllComponentDefinitions as SpyInstance; + + // Default mock implementations + mockGetDefinition.mockImplementation((type: string) => { + if (type === 'Health') return mockHealthManifest; + if (type === 'Transform') return mockTransformManifest; + if (type === 'CustomComponent') return mockCustomComponentManifest; + return undefined; + }); + mockGetAllDefinitions.mockReturnValue([ + mockHealthManifest, + mockTransformManifest, + mockCustomComponentManifest, + // Add actuals if getRegisteredComponentTypes is tested within these suites + // or ensure the mock for it is comprehensive enough. + ]); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + consoleWarnSpy.mockRestore(); + // Basic cleanup for ComponentManager state, assuming entity IDs are somewhat unique per describe block. + // More robust cleanup might involve iterating known entity IDs used in tests. + // Or, if ComponentManager had a reset method for tests, call it here. + // For now, relying on distinct entity IDs and specific component removals if needed. + }); + + // Existing tests for general ComponentManager logic (using mock manifests) + describe('addComponent() with mock manifests', () => { + const entityId = 1; + + it('should add a component with valid initial data and call onAdd', () => { + const initialData = { current: 50, max: 120 }; + const added = componentManager.addComponent(entityId, 'Health', initialData); + + expect(mockGetDefinition).toHaveBeenCalledWith('Health'); + expect(added).toBeDefined(); + expect(added?.data).toEqual(initialData); // Valid data is used + expect(mockHealthManifest.onAdd).toHaveBeenCalledWith(entityId, initialData); + // For manifest-only components, check internal storage: + // expect(componentManager.getComponentData(entityId, 'Health')).toEqual(initialData); + }); + + it('should add a component using default data if none provided and call onAdd', () => { + const added = componentManager.addComponent(entityId, 'Health'); + const expectedDefault = mockHealthManifest.getDefaultData(); + + expect(mockGetDefinition).toHaveBeenCalledWith('Health'); + expect(added).toBeDefined(); + expect(added?.data).toEqual(expectedDefault); + expect(mockHealthManifest.onAdd).toHaveBeenCalledWith(entityId, expectedDefault); + }); + + it('should use default data if initial data is invalid, and warn', () => { + const invalidInitialData = { current: -10, max: "not-a-number" } as any; // Clearly invalid + const added = componentManager.addComponent(entityId, 'Health', invalidInitialData); + const expectedDefault = mockHealthManifest.getDefaultData(); + + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid initial data for component "Health"')); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Using default data for "Health"')); + expect(added?.data).toEqual(expectedDefault); + expect(mockHealthManifest.onAdd).toHaveBeenCalledWith(entityId, expectedDefault); + }); + + it('should fail to add if initial data and default data are invalid, and error', () => { + const invalidInitialData = { current: -10 } as any; + // Sabotage getDefaultData for this specific test + const faultyManifest = { ...mockHealthManifest, getDefaultData: ()_=> ({current: "bad", max: "verybad"})} as any; + mockGetDefinition.mockReturnValueOnce(faultyManifest); + + const added = componentManager.addComponent(entityId, 'Health', invalidInitialData); + + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid initial data for component "Health"')); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Default data for component "Health" is also invalid')); + expect(added).toBeUndefined(); + expect(faultyManifest.onAdd).not.toHaveBeenCalled(); + }); + + + it('should return undefined and error if component type is not registered', () => { + mockGetDefinition.mockReturnValueOnce(undefined); + const added = componentManager.addComponent(entityId, 'UnknownComponent', {}); + expect(added).toBeUndefined(); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Component type "UnknownComponent" not registered.')); + }); + + it('should add a bitecs-mapped component (e.g., Transform) correctly', () => { + const initialTransformData = { position: [1,2,3], rotation: [0,0,0,1], scale: [1,1,1]}; + componentManager.addComponent(entityId, 'Transform', initialTransformData); + // Check if bitecs.addComponent was called (it's mocked) + expect(vi.mocked(await import('bitecs')).addComponent).toHaveBeenCalled(); + expect(mockTransformManifest.onAdd).toHaveBeenCalledWith(entityId, initialTransformData); + }); + + it('should add a manifest-only (non-bitecs) component correctly', () => { + const initialCustomData = { value: "my-value" }; + componentManager.addComponent(entityId, 'CustomComponent', initialCustomData); + + // bitecs.addComponent should NOT have been called for this one + expect(vi.mocked(await import('bitecs')).addComponent).not.toHaveBeenCalled(); + expect(mockCustomComponentManifest.onAdd).toHaveBeenCalledWith(entityId, initialCustomData); + expect(componentManager.getComponentData(entityId, 'CustomComponent')).toEqual(initialCustomData); + }); + }); + + describe('removeComponent() with mock manifests', () => { + const entityId = 1; + + it('should remove an existing component and call onRemove', () => { + componentManager.addComponent(entityId, 'Health', { current: 50, max: 100 }); // Add first + mockHealthManifest.onAdd?.mockClear(); // Clear previous onAdd call + + const removed = componentManager.removeComponent(entityId, 'Health'); + expect(removed).toBe(true); + expect(mockHealthManifest.onRemove).toHaveBeenCalledWith(entityId); + expect(componentManager.hasComponent(entityId, 'Health')).toBe(false); + }); + + it('should return false if trying to remove a non-existent component', () => { + const removed = componentManager.removeComponent(entityId, 'Health'); // Not added yet + expect(removed).toBe(false); + expect(mockHealthManifest.onRemove).not.toHaveBeenCalled(); + }); + + it('should remove a bitecs-mapped component (e.g., Transform) correctly', async () => { + // Assume Transform component needs to be "present" in bitecs mock for removal + asMock(vi.mocked(await import('bitecs')).hasComponent).mockReturnValue(true); + componentManager.addComponent(entityId, 'Transform'); // Add it first + mockTransformManifest.onAdd?.mockClear(); + + const removed = componentManager.removeComponent(entityId, 'Transform'); + expect(removed).toBe(true); + expect(vi.mocked(await import('bitecs')).removeComponent).toHaveBeenCalled(); + expect(mockTransformManifest.onRemove).toHaveBeenCalledWith(entityId); + }); + }); + + describe('updateComponent() with mock manifests', () => { + const entityId = 1; + + it('should update an existing component with valid partial data', () => { + componentManager.addComponent(entityId, 'Health', { current: 100, max: 100 }); + const partialUpdate = { current: 75 }; + const success = componentManager.updateComponent(entityId, 'Health', partialUpdate); + + expect(success).toBe(true); + const updatedData = componentManager.getComponentData(entityId, 'Health'); + expect(updatedData).toEqual({ current: 75, max: 100 }); + }); + + it('should fail to update if partial data leads to invalid state, and error', () => { + componentManager.addComponent(entityId, 'Health', { current: 100, max: 100 }); + const invalidUpdate = { current: "very-invalid" } as any; + const success = componentManager.updateComponent(entityId, 'Health', invalidUpdate); + + expect(success).toBe(false); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid data for component "Health"')); + const originalData = componentManager.getComponentData(entityId, 'Health'); + expect(originalData).toEqual({ current: 100, max: 100 }); // Data should not have changed + }); + + it('should return false and warn if component not found on entity', () => { + const success = componentManager.updateComponent(entityId, 'Health', { current: 90 }); + expect(success).toBe(false); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Component "Health" not found on entity 1. Cannot update.')); + }); + + it('should return false if component type is not registered', () => { + mockGetDefinition.mockReturnValueOnce(undefined); + const success = componentManager.updateComponent(entityId, 'Unknown', { current: 90 }); + expect(success).toBe(false); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Component type "Unknown" not registered. Cannot update.')); + }); + }); + + describe('Data Access and Checks with mock manifests', () => { + const entityId = 1; + const healthData = { current: 80, max: 150 }; + + beforeEach(() => { + componentManager.addComponent(entityId, 'Health', healthData); + componentManager.addComponent(entityId, 'CustomComponent', { value: "custom" }); + }); + + it('getComponentData() should retrieve correct data', () => { + expect(componentManager.getComponentData(entityId, 'Health')).toEqual(healthData); + expect(componentManager.getComponentData(entityId, 'CustomComponent')).toEqual({value: "custom"}); + }); + + it('getComponent() should retrieve component structure', () => { + const component = componentManager.getComponent(entityId, 'Health'); + expect(component).toEqual({ entityId, type: 'Health', data: healthData }); + }); + + it('hasComponent() should return true for existing, false for non-existing', () => { + expect(componentManager.hasComponent(entityId, 'Health')).toBe(true); + expect(componentManager.hasComponent(entityId, 'CustomComponent')).toBe(true); + expect(componentManager.hasComponent(entityId, 'Transform')).toBe(false); // Not added + expect(componentManager.hasComponent(2, 'Health')).toBe(false); // Different entity + }); + + it('getComponentsForEntity() should return all components for an entity', () => { + const components = componentManager.getComponentsForEntity(entityId); + expect(components.length).toBe(2); + expect(components).toContainEqual({ entityId, type: 'Health', data: healthData }); + expect(components).toContainEqual({ entityId, type: 'CustomComponent', data: { value: "custom" } }); + }); + }); + + describe('getRegisteredComponentTypes() with mock manifests', () => { + it('should return all component types from the mocked dynamic registry', () => { + const types = componentManager.getRegisteredComponentTypes(); + // Based on the default mock for getAllComponentDefinitions in the general beforeEach + expect(types).toEqual(['Health', 'Transform', 'CustomComponent']); + expect(mockGetAllDefinitions).toHaveBeenCalled(); + }); + }); + + // --- Tests for Core Component Manifests --- + const coreManifests = [ + { name: 'Transform', manifest: transformManifestActual, + validData: { position: [1,1,1], rotation: [0,0,0,1], scale: [2,2,2] }, + invalidData: { position: [1,1,'a'] }, // Invalid type + partialData: { scale: [3,3,3] } + }, + { name: 'MeshRenderer', manifest: meshRendererManifestActual, + validData: { meshId: 'sphere', materialId: 'customMat', enabled: true, castShadows: true, receiveShadows: true, material: { color: '#FF0000', metalness: 0.5, roughness: 0.2, emissive: '#00FF00', emissiveIntensity: 1 } }, + invalidData: { material: { color: 'not-a-hex' } }, + partialData: { enabled: false, material: { roughness: 0.8 } } + }, + { name: 'RigidBody', manifest: rigidBodyManifestActual, + validData: { bodyType: 'dynamic', mass: 5, enabled: true, gravityScale: 1.5, canSleep: false, linearDamping: 0.1, angularDamping: 0.1, material: { friction: 0.5, restitution: 0.5, density: 1.2 } }, + invalidData: { mass: -1 }, // mass must be >= 0 + partialData: { linearDamping: 0.5, material: { friction: 0.8 } } + }, + { name: 'MeshCollider', manifest: meshColliderManifestActual, + validData: { enabled: true, colliderType: 'sphere', isTrigger: true, center: [0,0.5,0], size: { radius: 0.5 }, physicsMaterial: { friction: 0.6, restitution: 0.4, density: 1.1 } }, + invalidData: { colliderType: 'unknown' }, // Invalid enum + partialData: { isTrigger: false, physicsMaterial: { restitution: 0.2 } } + }, + { name: 'Camera', manifest: cameraManifestActual, + validData: { preset: 'custom', fov: 60, near: 0.01, far: 2000, isMain: true, enableControls: false, target: [1,2,3], projectionType: 'orthographic', clearDepth: false, renderPriority: 10 }, + invalidData: { fov: 200 }, // fov > 179 + partialData: { isMain: false, far: 1500 } + }, + ]; + + coreManifests.forEach(({ name, manifest, validData, invalidData, partialData }) => { + describe(`${name} Component Integration (Actual Manifest)`, () => { + let entityIdCounter = 100; // Start from a higher counter to avoid collision with other tests + let currentEntityId: EntityId; + + beforeEach(() => { + currentEntityId = entityIdCounter++ as EntityId; + // Ensure getComponentDefinition returns the actual manifest for this test suite + // This overrides the general mock for the specific component ID + asMock(DynamicRegistry.getComponentDefinition).mockImplementation((type: string) => { + if (type === manifest.id) return manifest; + // Fallback to other mocks if needed, or return undefined + if (type === 'Health') return mockHealthManifest; + if (type === 'CustomComponent') return mockCustomComponentManifest; + // The 'Transform' from mockTransformManifest might conflict if not careful, + // but since these tests are namespaced by manifest.id, it should be fine. + if (type === 'Transform' && manifest.id !== 'Transform') return mockTransformManifest; + return undefined; + }); + }); + + afterEach(() => { + // Attempt to clean up the component added to avoid state leakage + if (componentManager.hasComponent(currentEntityId, manifest.id)) { + componentManager.removeComponent(currentEntityId, manifest.id); + } + // Restore the general mock implementation for getComponentDefinition + asMock(DynamicRegistry.getComponentDefinition).mockImplementation((type: string) => { + if (type === 'Health') return mockHealthManifest; + if (type === 'Transform') return mockTransformManifest; // General mock transform + if (type === 'CustomComponent') return mockCustomComponentManifest; + return undefined; + }); + }); + + describe(`addComponent() - ${name}`, () => { + it('should add with valid initial data and call onAdd if defined', () => { + const onAddSpy = manifest.onAdd ? vi.spyOn(manifest, 'onAdd').mockImplementation(() => {}) : undefined; + + componentManager.addComponent(currentEntityId, manifest.id, validData); + const storedData = componentManager.getComponentData(currentEntityId, manifest.id); + + // Zod parsing can transform data (e.g. add defaults for unspecified optionals) + // So we check if the storedData contains what we provided. + // For a more precise check, parse validData with the schema first. + const expectedData = manifest.schema.parse(validData); + expect(storedData).toEqual(expectedData); + + if (manifest.onAdd && onAddSpy) { + expect(onAddSpy).toHaveBeenCalledWith(currentEntityId, storedData); + onAddSpy.mockRestore(); + } + }); + + it('should use default data and log error if initial data is invalid', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + componentManager.addComponent(currentEntityId, manifest.id, invalidData); + + const storedData = componentManager.getComponentData(currentEntityId, manifest.id); + const defaultData = manifest.getDefaultData(); + const parsedDefault = manifest.schema.parse(defaultData); // CM stores parsed default data + + expect(storedData).toEqual(parsedDefault); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining(`Invalid initial data for component "${manifest.id}"`)); + consoleErrorSpy.mockRestore(); + }); + + it('should add with default data when no initial data is provided', () => { + componentManager.addComponent(currentEntityId, manifest.id); + const storedData = componentManager.getComponentData(currentEntityId, manifest.id); + const defaultData = manifest.getDefaultData(); + const parsedDefault = manifest.schema.parse(defaultData); + expect(storedData).toEqual(parsedDefault); + }); + }); + + describe(`updateComponent() - ${name}`, () => { + it('should update with valid partial data', () => { + componentManager.addComponent(currentEntityId, manifest.id); // Add with defaults + const initialData = componentManager.getComponentData(currentEntityId, manifest.id); + + componentManager.updateComponent(currentEntityId, manifest.id, partialData); + + const storedData = componentManager.getComponentData(currentEntityId, manifest.id); + const expectedData = { ...initialData, ...partialData }; // Simple merge for check + + // For more robust check, parse the merged object with Zod schema + // This ensures that defaults for other fields are preserved correctly after partial update + const expectedParsedData = manifest.schema.parse(expectedData); + expect(storedData).toEqual(expectedParsedData); + + // Check that only partialData fields changed, and others remain from initialData (or defaults) + Object.keys(partialData as any).forEach(key => { + expect((storedData as any)[key]).toEqual((expectedParsedData as any)[key]); + }); + + }); + + it('should not update and log error if partial data leads to invalid merged state', () => { + componentManager.addComponent(currentEntityId, manifest.id, validData); // Add with valid data + const initialStoredData = componentManager.getComponentData(currentEntityId, manifest.id); + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + // Use the 'invalidData' as a 'partialInvalidData' if it makes sense, + // or craft specific invalid partial data. + // Example: if validData has {a:1, b:2}, and schema needs b > 0 + // invalidPartialData could be {b: -5} + let specificInvalidPartial = invalidData; // Using the top-level invalidData for this test + // This might not always make sense for a "partial" update + // depending on the schema. + // A better approach for `invalidPartialData` for some schemas: + if (name === 'Transform') specificInvalidPartial = { position: 'not-an-array' } as any; + if (name === 'MeshRenderer') specificInvalidPartial = { material: { color: 123 } } as any; + // ... and so on for other components, ensuring the partial update itself is the cause of invalidity + + componentManager.updateComponent(currentEntityId, manifest.id, specificInvalidPartial); + + const finalStoredData = componentManager.getComponentData(currentEntityId, manifest.id); + expect(finalStoredData).toEqual(initialStoredData); // Data should not have changed + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining(`Invalid data for component "${manifest.id}"`)); + consoleErrorSpy.mockRestore(); + }); + }); + }); + }); + + // TODO: Add tests for removeComponentsForEntity if its logic becomes more complex than just iterating removeComponent + // TODO: Test bitecs interaction more thoroughly if specific behaviors beyond calling the functions are critical +}); + + +describe('Transform Component - Direct Bitecs Interaction', () => { + let componentManager: ComponentManager; + let worldInstance: ActualBitecs.IWorld; + let entityId: EntityId; + let originalBitecsMockState: any; // To store the state of the mock + + beforeAll(() => { + // Store the original mock state + originalBitecsMockState = { ...mockBitecs }; + + // Unmock specific functions or the entire module for these tests + // We want actual bitecs.addComponent etc. to run + vi.doUnmock('bitecs'); + // Re-import actual bitecs if necessary, or ensure they are available + // The import * as ActualBitecs should make them available. + }); + + afterAll(() => { + // Restore the global mock for other test suites + vi.doMock('bitecs', () => originalBitecsMockState); + }); + + beforeEach(async () => { + // Reset modules to ensure ComponentManager gets the actual bitecs if it was dynamically importing + // However, ComponentManager imports bitecs statically, so this might not be strictly needed here + // if the unmocking at the describe level is effective. + // vi.resetModules(); + + worldInstance = ECSWorld.getInstance().getWorld(); // Get the actual world instance + componentManager = ComponentManager.getInstance(); // Get/create instance + + // Ensure getComponentDefinition returns the actual transformManifest + asMock(DynamicRegistry.getComponentDefinition).mockImplementation((type: string) => { + if (type === transformManifestActual.id) { + return transformManifestActual; + } + // Fallback for other types if any are incidentally used + if (type === 'Health') return mockHealthManifest; + if (type === 'CustomComponent') return mockCustomComponentManifest; + return undefined; + }); + asMock(DynamicRegistry.getAllComponentDefinitions).mockReturnValue([transformManifestActual]); + + + // Create a new entity for each test directly using bitecs + entityId = ActualBitecs.addEntity(worldInstance) as EntityId; + }); + + afterEach(() => { + // Clean up the entity + if (worldInstance && entityId !== undefined) { + // Remove transform component if it exists to clean up bitecs store for the entity + if (ActualBitecs.hasComponent(worldInstance, BitecsTransformObject, entityId)) { + ActualBitecs.removeComponent(worldInstance, BitecsTransformObject, entityId); + } + ActualBitecs.removeEntity(worldInstance, entityId); + } + vi.clearAllMocks(); // Clear mocks like getComponentDefinition + }); + + it('should update bitecs Transform store when adding with initial data', () => { + const initialData = { position: [1, 2, 3], rotation: [0, 1, 0, 0], scale: [2, 2, 2] }; + componentManager.addComponent(entityId, transformManifestActual.id, initialData); + + // Verify data in the actual bitecs Transform component store + expect(Array.from(BitecsTransformObject.position[entityId])).toEqual(initialData.position); + expect(Array.from(BitecsTransformObject.rotation[entityId])).toEqual(initialData.rotation); + expect(Array.from(BitecsTransformObject.scale[entityId])).toEqual(initialData.scale); + }); + + it('should update bitecs Transform store when adding with default data', () => { + componentManager.addComponent(entityId, transformManifestActual.id); + const defaultData = transformManifestActual.getDefaultData(); + + expect(Array.from(BitecsTransformObject.position[entityId])).toEqual(defaultData.position); + expect(Array.from(BitecsTransformObject.rotation[entityId])).toEqual(defaultData.rotation); + expect(Array.from(BitecsTransformObject.scale[entityId])).toEqual(defaultData.scale); + }); + + it('should update bitecs Transform store when updating component data', () => { + componentManager.addComponent(entityId, transformManifestActual.id); // Add with defaults + const updatedData = { position: [5, 5, 5], scale: [0.5, 0.5, 0.5] }; + componentManager.updateComponent(entityId, transformManifestActual.id, updatedData); + + const expectedRotation = transformManifestActual.getDefaultData().rotation; + + expect(Array.from(BitecsTransformObject.position[entityId])).toEqual(updatedData.position); + expect(Array.from(BitecsTransformObject.rotation[entityId])).toEqual(expectedRotation); + expect(Array.from(BitecsTransformObject.scale[entityId])).toEqual(updatedData.scale); + }); + + it('should not remove Transform from bitecs store as it is non-removable', () => { + const initialData = transformManifestActual.getDefaultData(); + componentManager.addComponent(entityId, transformManifestActual.id, initialData); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + // ComponentManager's removeComponent internally checks `isComponentRemovable` + // which for Transform (removable: false) should prevent actual removal. + // The `onRemove` hook in the manifest should also not be called. + const onRemoveSpy = vi.spyOn(transformManifestActual, 'onRemove' as any).mockImplementation(() => {}); + + componentManager.removeComponent(entityId, transformManifestActual.id); + + consoleErrorSpy.mockRestore(); + if (onRemoveSpy) onRemoveSpy.mockRestore(); // Restore if spy was created + + // Verify that the component is still in bitecs and data is unchanged + expect(ActualBitecs.hasComponent(worldInstance, BitecsTransformObject, entityId)).toBe(true); + expect(Array.from(BitecsTransformObject.position[entityId])).toEqual(initialData.position); + expect(Array.from(BitecsTransformObject.rotation[entityId])).toEqual(initialData.rotation); + expect(Array.from(BitecsTransformObject.scale[entityId])).toEqual(initialData.scale); + expect(onRemoveSpy).not.toHaveBeenCalled(); // onRemove hook should not be called for non-removable + }); +}); diff --git a/src/core/lib/ecs/ComponentManager.ts b/src/core/lib/ecs/ComponentManager.ts index e4a72f16..592bb3bf 100644 --- a/src/core/lib/ecs/ComponentManager.ts +++ b/src/core/lib/ecs/ComponentManager.ts @@ -1,5 +1,6 @@ -import { addComponent, hasComponent, removeComponent } from 'bitecs'; +import { addComponent, hasComponent as bitecsHasComponent, removeComponent as bitecsRemoveComponent } from 'bitecs'; +import { getComponentDefinition, getAllComponentDefinitions } from './dynamicComponentRegistry'; // Added import { Camera, MeshCollider, MeshRenderer, RigidBody, Transform } from './BitECSComponents'; import { getCameraData, @@ -44,6 +45,7 @@ export class ComponentManager { private static instance: ComponentManager; private world = ECSWorld.getInstance().getWorld(); private eventListeners: ComponentEventListener[] = []; + private manifestComponentData: Map> = new Map(); // Added for non-bitecs components private constructor() { // Private constructor for singleton @@ -71,32 +73,75 @@ export class ComponentManager { this.eventListeners.forEach((listener) => listener(event)); } - addComponent(entityId: EntityId, type: ComponentType, data: TData): IComponent { - const bitECSComponent = componentMap[type as keyof typeof componentMap]; + addComponent(entityId: EntityId, type: ComponentType, initialData?: TData): IComponent | undefined { + const manifest = getComponentDefinition(type); + if (!manifest) { + console.error(`[ComponentManager] Component type "${type}" not registered.`); + return undefined; + } - if (!bitECSComponent) { - console.warn(`Component type ${type} not supported in BitECS implementation.`); - return { entityId, type, data }; + let dataToStore: any; + if (initialData !== undefined) { + const validationResult = manifest.schema.safeParse(initialData); + if (validationResult.success) { + dataToStore = validationResult.data; + } else { + console.error(`[ComponentManager] Invalid initial data for component "${type}" on entity ${entityId}:`, validationResult.error.format()); + // Fallback to default data + const defaultData = manifest.getDefaultData(); + const defaultValidationResult = manifest.schema.safeParse(defaultData); + if (defaultValidationResult.success) { + console.warn(`[ComponentManager] Using default data for "${type}" on entity ${entityId} due to invalid initial data.`); + dataToStore = defaultValidationResult.data; + } else { + console.error(`[ComponentManager] Default data for component "${type}" is also invalid. Aborting addComponent.`, defaultValidationResult.error.format()); + return undefined; + } + } + } else { + const defaultData = manifest.getDefaultData(); + const validationResult = manifest.schema.safeParse(defaultData); + if (validationResult.success) { + dataToStore = validationResult.data; + } else { + console.error(`[ComponentManager] Default data for component "${type}" is invalid. Aborting addComponent.`, validationResult.error.format()); + return undefined; + } } - // Add the component to the entity - addComponent(this.world, bitECSComponent, entityId); + const bitECSComponent = componentMap[type as keyof typeof componentMap]; + if (bitECSComponent) { + // It's a BitECS managed component + if (!bitecsHasComponent(this.world, bitECSComponent, entityId)) { + addComponent(this.world, bitECSComponent, entityId); + } + this.setComponentDataInternal(entityId, type, dataToStore); // Use internal setter + } else { + // It's a manifest-only component + if (!this.manifestComponentData.has(entityId)) { + this.manifestComponentData.set(entityId, new Map()); + } + if (this.manifestComponentData.get(entityId)!.has(type)) { + console.warn(`[ComponentManager] Component "${type}" already exists on entity ${entityId}. Overwriting.`); + } + this.manifestComponentData.get(entityId)!.set(type, dataToStore); + } - // Set the component data using conversion functions - this.setComponentData(entityId, type, data); + if (manifest.onAdd) { + manifest.onAdd(entityId, dataToStore); + } - // Emit event for reactive updates this.emitEvent({ type: 'component-added', entityId, componentType: type, - data, + data: dataToStore, }); - return { entityId, type, data }; + return { entityId, type, data: dataToStore as TData }; } - private setComponentData(entityId: EntityId, type: ComponentType, data: TData): void { + private setComponentDataInternal(entityId: EntityId, type: ComponentType, data: TData): void { switch (type) { case KnownComponentTypes.TRANSFORM: setTransformData(entityId, data as ITransformData); @@ -116,67 +161,101 @@ export class ComponentManager { } } - private getComponentDataInternal( + private getComponentDataInternal( // Renamed from setComponentData to setComponentDataInternal entityId: EntityId, type: ComponentType, ): TData | undefined { - switch (type) { - case KnownComponentTypes.TRANSFORM: - return getTransformData(entityId) as TData; - case KnownComponentTypes.MESH_RENDERER: - return getMeshRendererData(entityId) as TData; - case KnownComponentTypes.RIGID_BODY: - return getRigidBodyData(entityId) as TData; - case KnownComponentTypes.MESH_COLLIDER: - return getMeshColliderData(entityId) as TData; - case KnownComponentTypes.CAMERA: - return getCameraData(entityId) as TData; - default: - return undefined; + const bitECSComponent = componentMap[type as keyof typeof componentMap]; + if (bitECSComponent) { + if (!bitecsHasComponent(this.world, bitECSComponent, entityId)) return undefined; + // For BitECS components, defer to specific getters + switch (type) { + case KnownComponentTypes.TRANSFORM: + return getTransformData(entityId) as TData; + case KnownComponentTypes.MESH_RENDERER: + return getMeshRendererData(entityId) as TData; + case KnownComponentTypes.RIGID_BODY: + return getRigidBodyData(entityId) as TData; + case KnownComponentTypes.MESH_COLLIDER: + return getMeshColliderData(entityId) as TData; + case KnownComponentTypes.CAMERA: + return getCameraData(entityId) as TData; + default: + // Should not happen if componentMap is exhaustive for KnownComponentTypes + console.warn(`[ComponentManager] Unhandled BitECS component type in getComponentDataInternal: ${type}`); + return undefined; + } + } else { + // For manifest-only components + return this.manifestComponentData.get(entityId)?.get(type) as TData | undefined; } } getComponent(entityId: EntityId, type: ComponentType): IComponent | undefined { - const bitECSComponent = componentMap[type as keyof typeof componentMap]; - - if (!bitECSComponent || !hasComponent(this.world, bitECSComponent, entityId)) { + if (!this.hasComponent(entityId, type)) { return undefined; } - const data = this.getComponentDataInternal(entityId, type); - return data ? { entityId, type, data } : undefined; + // Ensure data is not undefined, though hasComponent should guarantee this. + return data !== undefined ? { entityId, type, data } : undefined; } getComponentData(entityId: EntityId, type: ComponentType): TData | undefined { - const bitECSComponent = componentMap[type as keyof typeof componentMap]; - - if (!bitECSComponent || !hasComponent(this.world, bitECSComponent, entityId)) { + if (!this.hasComponent(entityId, type)) { return undefined; } - return this.getComponentDataInternal(entityId, type); } updateComponent(entityId: EntityId, type: ComponentType, data: Partial): boolean { - const bitECSComponent = componentMap[type as keyof typeof componentMap]; + const manifest = getComponentDefinition(type); + if (!manifest) { + console.error(`[ComponentManager] Component type "${type}" not registered. Cannot update.`); + return false; + } - if (!bitECSComponent || !hasComponent(this.world, bitECSComponent, entityId)) { + if (!this.hasComponent(entityId, type)) { + console.warn(`[ComponentManager] Component "${type}" not found on entity ${entityId}. Cannot update.`); return false; } - // Get existing data and merge with updates const existingData = this.getComponentDataInternal(entityId, type); - if (!existingData) return false; + if (existingData === undefined) { // Should be caught by hasComponent, but as a safeguard + console.error(`[ComponentManager] Failed to retrieve existing data for "${type}" on entity ${entityId}. Cannot update.`); + return false; + } + + const mergedData = { ...existingData, ...data }; + const validationResult = manifest.schema.safeParse(mergedData); - const updatedData = { ...existingData, ...data }; - this.setComponentData(entityId, type, updatedData); + if (!validationResult.success) { + console.error(`[ComponentManager] Invalid data for component "${type}" on entity ${entityId} after update:`, validationResult.error.format()); + return false; + } + const validatedData = validationResult.data as TData; + + const bitECSComponent = componentMap[type as keyof typeof componentMap]; + if (bitECSComponent) { + this.setComponentDataInternal(entityId, type, validatedData); + } else { + const entityDataMap = this.manifestComponentData.get(entityId); + if (entityDataMap) { + entityDataMap.set(type, validatedData); + } else { + // This case should ideally not be reached if hasComponent passed. + console.error(`[ComponentManager] Entity data map not found for entity ${entityId} during update of "${type}".`); + return false; + } + } + + // TODO: Consider onUpdate hook from manifest if needed in the future. + // if (manifest.onUpdate) manifest.onUpdate(entityId, validatedData); - // Emit event for reactive updates this.emitEvent({ type: 'component-updated', entityId, componentType: type, - data: updatedData, + data: validatedData, }); return true; @@ -185,33 +264,62 @@ export class ComponentManager { getComponentsForEntity(entityId: EntityId): IComponent[] { const components: IComponent[] = []; + // BitECS components Object.entries(componentMap).forEach(([typeString, bitECSComponent]) => { - if (hasComponent(this.world, bitECSComponent, entityId)) { - const data = this.getComponentDataInternal(entityId, typeString); - if (data) { - components.push({ entityId, type: typeString, data }); + if (bitecsHasComponent(this.world, bitECSComponent, entityId)) { + const data = this.getComponentDataInternal(entityId, typeString as ComponentType); + if (data !== undefined) { + components.push({ entityId, type: typeString as ComponentType, data }); } } }); + // Manifest-only components + const entityManifestComponents = this.manifestComponentData.get(entityId); + if (entityManifestComponents) { + entityManifestComponents.forEach((data, typeString) => { + // Avoid duplicating if a component type could somehow be in both (should not happen with current logic) + if (!componentMap[typeString as keyof typeof componentMap]) { + components.push({ entityId, type: typeString, data }); + } + }); + } return components; } hasComponent(entityId: EntityId, type: ComponentType): boolean { const bitECSComponent = componentMap[type as keyof typeof componentMap]; - return bitECSComponent ? hasComponent(this.world, bitECSComponent, entityId) : false; + if (bitECSComponent && bitecsHasComponent(this.world, bitECSComponent, entityId)) { + return true; + } + return this.manifestComponentData.get(entityId)?.has(type) ?? false; } removeComponent(entityId: EntityId, type: ComponentType): boolean { - const bitECSComponent = componentMap[type as keyof typeof componentMap]; - - if (!bitECSComponent || !hasComponent(this.world, bitECSComponent, entityId)) { + if (!this.hasComponent(entityId, type)) { return false; } - removeComponent(this.world, bitECSComponent, entityId); + const manifest = getComponentDefinition(type); + if (manifest && manifest.onRemove) { + // Pass the current data to onRemove if the hook needs it. + // const currentData = this.getComponentDataInternal(entityId, type); + manifest.onRemove(entityId /*, currentData */); + } + + const bitECSComponent = componentMap[type as keyof typeof componentMap]; + if (bitECSComponent) { + bitecsRemoveComponent(this.world, bitECSComponent, entityId); + } else { + const entityDataMap = this.manifestComponentData.get(entityId); + if (entityDataMap) { + entityDataMap.delete(type); + if (entityDataMap.size === 0) { + this.manifestComponentData.delete(entityId); + } + } + } - // Emit event for reactive updates this.emitEvent({ type: 'component-removed', entityId, @@ -222,11 +330,30 @@ export class ComponentManager { } removeComponentsForEntity(entityId: EntityId): void { - Object.values(componentMap).forEach((bitECSComponent) => { - if (hasComponent(this.world, bitECSComponent, entityId)) { - removeComponent(this.world, bitECSComponent, entityId); + // Remove BitECS components + Object.entries(componentMap).forEach(([typeString, bitECSComponent]) => { + if (bitecsHasComponent(this.world, bitECSComponent, entityId)) { + const manifest = getComponentDefinition(typeString as ComponentType); + if (manifest && manifest.onRemove) { + manifest.onRemove(entityId); + } + bitecsRemoveComponent(this.world, bitECSComponent, entityId); } }); + + // Remove manifest-only components + const entityManifestComponents = this.manifestComponentData.get(entityId); + if (entityManifestComponents) { + entityManifestComponents.forEach((_data, typeString) => { + const manifest = getComponentDefinition(typeString as ComponentType); + if (manifest && manifest.onRemove) { + manifest.onRemove(entityId); + } + }); + this.manifestComponentData.delete(entityId); + } + // Note: Emitting individual 'component-removed' events here might be noisy. + // Consider a single 'entity-cleared-components' event or rely on callers to know. } getEntitiesWithComponent(componentType: ComponentType): EntityId[] { @@ -281,7 +408,8 @@ export class ComponentManager { } getRegisteredComponentTypes(): ComponentType[] { - return Object.keys(componentMap); + // This should reflect all components discoverable by the dynamic registry + return getAllComponentDefinitions().map(def => def.id); } // Helper methods for specific component types diff --git a/src/core/lib/ecs/ComponentRegistry.ts b/src/core/lib/ecs/ComponentRegistry.ts deleted file mode 100644 index 70c841e8..00000000 --- a/src/core/lib/ecs/ComponentRegistry.ts +++ /dev/null @@ -1,443 +0,0 @@ -import React from 'react'; -import { FiBox, FiCamera, FiEye, FiMove, FiShield, FiZap } from 'react-icons/fi'; - -import { KnownComponentTypes } from './IComponent'; - -// Rendering contributions that a component can provide -export interface IRenderingContributions { - geometry?: React.ReactNode; - material?: { - color?: string; - metalness?: number; - roughness?: number; - emissive?: string; - emissiveIntensity?: number; - }; - visible?: boolean; - castShadow?: boolean; - receiveShadow?: boolean; - meshType?: string; // For geometry selection -} - -// Physics contributions that a component can provide -export interface IPhysicsContributions { - colliders?: React.ReactNode[]; - rigidBodyProps?: { - type?: string; - mass?: number; - friction?: number; - restitution?: number; - density?: number; - gravityScale?: number; - canSleep?: boolean; - }; - enabled?: boolean; -} - -// Component pack definition -export interface IComponentPack { - id: string; - name: string; - description: string; - icon: React.ReactNode; - components: string[]; - category: string; -} - -// Main component definition with all metadata and behavior -export interface IComponentDefinition { - id: string; - name: string; - description: string; - icon: React.ReactNode; - category: string; - - // Default data when component is added - getDefaultData: ( - entityId?: number, - getComponentData?: (entityId: number, componentType: string) => any, - ) => any; - - // How this component affects rendering - getRenderingContributions?: (data: any) => IRenderingContributions; - - // How this component affects physics - getPhysicsContributions?: (data: any) => IPhysicsContributions; - - // Whether this component can be removed - removable?: boolean; -} - -// Helper to get default material data -const getDefaultMaterialData = ( - entityId?: number, - getComponentData?: (entityId: number, componentType: string) => any, -) => { - if (entityId === undefined || !getComponentData) { - return { - color: '#3399ff', - metalness: 0.0, - roughness: 0.5, - emissive: '#000000', - emissiveIntensity: 0.0, - }; - } - - const materialData = getComponentData(entityId, 'material') as any; - let color = '#3399ff'; // Default blue - - if (materialData?.color) { - if (Array.isArray(materialData.color)) { - // Convert RGB array to hex - const [r, g, b] = materialData.color; - color = `#${Math.round(r * 255) - .toString(16) - .padStart(2, '0')}${Math.round(g * 255) - .toString(16) - .padStart(2, '0')}${Math.round(b * 255) - .toString(16) - .padStart(2, '0')}`; - } else if (typeof materialData.color === 'string') { - color = materialData.color; - } - } - - return { - color, - metalness: 0.0, - roughness: 0.5, - emissive: '#000000', - emissiveIntensity: 0.0, - }; -}; - -// Centralized component definitions -export const COMPONENT_REGISTRY: Record = { - [KnownComponentTypes.TRANSFORM]: { - id: KnownComponentTypes.TRANSFORM, - name: 'Transform', - description: 'Position, rotation, and scale', - icon: React.createElement(FiMove, { className: 'w-4 h-4' }), - category: 'Core', - removable: false, - getDefaultData: () => ({ - position: [0, 0, 0], - rotation: [0, 0, 0], - scale: [1, 1, 1], - }), - }, - - [KnownComponentTypes.MESH_RENDERER]: { - id: KnownComponentTypes.MESH_RENDERER, - name: 'Mesh Renderer', - description: 'Renders 3D mesh geometry', - icon: React.createElement(FiEye, { className: 'w-4 h-4' }), - category: 'Rendering', - removable: true, - getDefaultData: (entityId, getComponentData) => ({ - meshId: 'cube', - materialId: 'default', - enabled: true, - castShadows: true, - receiveShadows: true, - material: getDefaultMaterialData(entityId, getComponentData), - }), - getRenderingContributions: (data) => { - // Convert meshId to meshType for geometry selection - const meshIdToTypeMap: { [key: string]: string } = { - cube: 'Cube', - sphere: 'Sphere', - cylinder: 'Cylinder', - cone: 'Cone', - torus: 'Torus', - plane: 'Plane', - capsule: 'Cube', // Fallback to cube for now - }; - - return { - meshType: meshIdToTypeMap[data.meshId] || 'Cube', - material: data.material, - visible: data.enabled ?? true, - castShadow: data.castShadows ?? true, - receiveShadow: data.receiveShadows ?? true, - }; - }, - }, - - [KnownComponentTypes.RIGID_BODY]: { - id: KnownComponentTypes.RIGID_BODY, - name: 'Rigid Body', - description: 'Physics simulation body', - icon: React.createElement(FiZap, { className: 'w-4 h-4' }), - category: 'Physics', - removable: true, - getDefaultData: () => ({ - type: 'dynamic', - mass: 1, - enabled: true, - bodyType: 'dynamic', - gravityScale: 1, - canSleep: true, - material: { - friction: 0.7, - restitution: 0.3, - density: 1, - }, - }), - getPhysicsContributions: (data) => { - console.log('[Rigid Body Debug] Input data:', data); - const result = { - rigidBodyProps: { - type: data.bodyType || data.type, - mass: data.mass ?? 1, - friction: data.material?.friction ?? 0.7, - restitution: data.material?.restitution ?? 0.3, - density: data.material?.density ?? 1, - gravityScale: data.gravityScale ?? 1, - canSleep: data.canSleep ?? true, - }, - enabled: data.enabled ?? true, - }; - console.log('[Rigid Body Debug] Output:', result); - return result; - }, - }, - - [KnownComponentTypes.MESH_COLLIDER]: { - id: KnownComponentTypes.MESH_COLLIDER, - name: 'Mesh Collider', - description: 'Physics collision detection', - icon: React.createElement(FiShield, { className: 'w-4 h-4' }), - category: 'Physics', - removable: true, - getDefaultData: () => ({ - enabled: true, - colliderType: 'box', - isTrigger: false, - center: [0, 0, 0], - size: { - width: 1, - height: 1, - depth: 1, - radius: 0.5, - capsuleRadius: 0.5, - capsuleHeight: 2, - }, - physicsMaterial: { - friction: 0.7, - restitution: 0.3, - density: 1, - }, - }), - getPhysicsContributions: (data) => { - if (!data.enabled) { - return { enabled: false }; - } - - return { - rigidBodyProps: { - // Only contribute material properties, not body type or mass - friction: data.physicsMaterial?.friction ?? 0.7, - restitution: data.physicsMaterial?.restitution ?? 0.3, - density: data.physicsMaterial?.density ?? 1, - }, - enabled: true, - }; - }, - }, - - [KnownComponentTypes.CAMERA]: { - id: KnownComponentTypes.CAMERA, - name: 'Camera', - description: 'Camera for rendering perspectives', - icon: React.createElement(FiCamera, { className: 'w-4 h-4' }), - category: 'Rendering', - removable: false, - getDefaultData: () => ({ - preset: 'unity-default', - fov: 30, - near: 0.1, - far: 10, - isMain: false, - enableControls: true, - target: [0, 0, 0], - projectionType: 'perspective', - clearDepth: true, - renderPriority: 0, - }), - getRenderingContributions: () => { - return { - meshType: 'Camera', // Special camera shape - visible: true, - castShadow: false, - receiveShadow: false, - }; - }, - }, -}; - -// Component packs using the registry -export const COMPONENT_PACKS: IComponentPack[] = [ - { - id: 'physics-basics', - name: 'Physics Basics', - description: 'Rigid body + mesh collider for basic physics', - icon: React.createElement(FiZap, { className: 'w-4 h-4' }), - components: [KnownComponentTypes.RIGID_BODY, KnownComponentTypes.MESH_COLLIDER], - category: 'Physics', - }, - { - id: 'rendering-basics', - name: 'Rendering Basics', - description: 'Complete rendering setup', - icon: React.createElement(FiBox, { className: 'w-4 h-4' }), - components: [KnownComponentTypes.MESH_RENDERER], - category: 'Rendering', - }, - { - id: 'complete-entity', - name: 'Complete Entity', - description: 'Transform + rendering for a complete visible entity', - icon: React.createElement(FiBox, { className: 'w-4 h-4' }), - components: [KnownComponentTypes.TRANSFORM, KnownComponentTypes.MESH_RENDERER], - category: 'Core', - }, - { - id: 'physics-entity', - name: 'Physics Entity', - description: 'Complete physics-enabled entity with rendering', - icon: React.createElement(FiBox, { className: 'w-4 h-4' }), - components: [ - KnownComponentTypes.TRANSFORM, - KnownComponentTypes.MESH_RENDERER, - KnownComponentTypes.RIGID_BODY, - KnownComponentTypes.MESH_COLLIDER, - ], - category: 'Physics', - }, -]; - -// Helper functions for the registry -export const getComponentDefinition = (componentType: string): IComponentDefinition | undefined => { - return COMPONENT_REGISTRY[componentType]; -}; - -export const getAllComponentDefinitions = (): IComponentDefinition[] => { - return Object.values(COMPONENT_REGISTRY); -}; - -export const getComponentsByCategory = (): Record => { - const categories: Record = {}; - Object.values(COMPONENT_REGISTRY).forEach((def) => { - if (!categories[def.category]) { - categories[def.category] = []; - } - categories[def.category].push(def); - }); - return categories; -}; - -export const isComponentRemovable = (componentType: string): boolean => { - const definition = getComponentDefinition(componentType); - return definition?.removable ?? true; -}; - -// Helper to get default data for a component -export const getComponentDefaultData = ( - componentType: string, - entityId?: number, - getComponentData?: (entityId: number, componentType: string) => any, -): any => { - const definition = getComponentDefinition(componentType); - if (!definition) { - return {}; - } - return definition.getDefaultData(entityId, getComponentData); -}; - -// Helper to combine rendering contributions from all components -export const combineRenderingContributions = ( - entityComponents: Array<{ type: string; data: any }>, -): IRenderingContributions => { - const combined: IRenderingContributions = { - visible: true, - castShadow: true, - receiveShadow: true, - meshType: 'Cube', - material: { - color: '#3399ff', - metalness: 0, - roughness: 0.5, - emissive: '#000000', - emissiveIntensity: 0, - }, - }; - - entityComponents.forEach(({ type, data }) => { - const definition = getComponentDefinition(type); - if (definition?.getRenderingContributions) { - const contributions = definition.getRenderingContributions(data); - Object.assign(combined, contributions); - if (contributions.material) { - Object.assign(combined.material!, contributions.material); - } - } - }); - - return combined; -}; - -// Helper to combine physics contributions from all components -export const combinePhysicsContributions = ( - entityComponents: Array<{ type: string; data: any }>, -): IPhysicsContributions => { - const combined: IPhysicsContributions = { - enabled: false, - rigidBodyProps: { - type: 'dynamic', - mass: 1, - friction: 0.7, - restitution: 0.3, - density: 1, - gravityScale: 1, - canSleep: true, - }, - colliders: [], - }; - - // Process components in two passes: - // 1. First pass: collect all contributions - // 2. Second pass: ensure RigidBody type takes precedence - - let rigidBodyType: string | undefined; - - entityComponents.forEach(({ type, data }) => { - const definition = getComponentDefinition(type); - if (definition?.getPhysicsContributions) { - const contributions = definition.getPhysicsContributions(data); - - if (contributions.enabled) { - combined.enabled = true; - } - - // Store RigidBody type separately to ensure it takes precedence - if (type === KnownComponentTypes.RIGID_BODY && contributions.rigidBodyProps?.type) { - rigidBodyType = contributions.rigidBodyProps.type; - } - - if (contributions.rigidBodyProps) { - Object.assign(combined.rigidBodyProps!, contributions.rigidBodyProps); - } - if (contributions.colliders) { - combined.colliders!.push(...contributions.colliders); - } - } - }); - - // Ensure RigidBody type always takes precedence - if (rigidBodyType) { - combined.rigidBodyProps!.type = rigidBodyType; - } - - return combined; -}; diff --git a/src/core/lib/ecs/dynamicComponentRegistry.spec.ts b/src/core/lib/ecs/dynamicComponentRegistry.spec.ts new file mode 100644 index 00000000..eb9c5ebf --- /dev/null +++ b/src/core/lib/ecs/dynamicComponentRegistry.spec.ts @@ -0,0 +1,282 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { z } from 'zod'; +import { ComponentManifest, ComponentCategory, IRenderingContributions, IPhysicsContributions } from '@core/components/types'; + +// --- Mock Manifest Definitions --- +const mockValidManifest1: ComponentManifest<{ val: number }> = { + id: 'Test1', + name: 'Test Component 1', + category: ComponentCategory.Gameplay, + description: 'Description for Test1', + icon: null, // Using null for simplicity in tests + schema: z.object({ val: z.number().default(1) }), + getDefaultData: () => ({ val: 1 }), + getRenderingContributions: (data) => ({ visible: data.val > 0 }), +}; + +const mockValidManifest2: ComponentManifest<{ name: string }> = { + id: 'Test2', + name: 'Test Component 2', + category: ComponentCategory.Core, + description: 'Description for Test2', + icon: null, + schema: z.object({ name: z.string().default('defaultName') }), + getDefaultData: () => ({ name: 'defaultName' }), + removable: false, +}; + +const mockValidManifest3Physics: ComponentManifest<{ force: number }> = { + id: 'PhysicsComp', + name: 'Physics Component', + category: ComponentCategory.Physics, + description: 'Description for PhysicsComp', + icon: null, + schema: z.object({ force: z.number().default(10) }), + getDefaultData: () => ({ force: 10 }), + getPhysicsContributions: (data) => ({ rigidBodyProps: { mass: data.force / 2 } }), +}; + +const mockDuplicateIdManifest: ComponentManifest<{ val: string }> = { + id: 'Test1', // Duplicate ID + name: 'Duplicate Test Component', + category: ComponentCategory.Rendering, + description: 'Description for Duplicate', + icon: null, + schema: z.object({ val: z.string().default('duplicate') }), + getDefaultData: () => ({ val: 'duplicate' }), +}; + +// --- Mock import.meta.glob --- +// This will be used to dynamically set the mock for each test case if needed +let mockGlobModules: Record } | {}> = {}; + +vi.mock('/*', () => ({ + // The path used in dynamicComponentRegistry is '/src/core/components/definitions/*.ts' + // Vite's import.meta.glob resolves paths relative to the project root. + // For the mock, we need to ensure the key matches what import.meta.glob expects. + // Assuming the test runner is at the project root, this relative path should work. + // If tests are run from a different CWD, this path might need adjustment for the mock. + // The crucial part is that the module being tested uses a path that resolves here. + // Given the module uses '/src/core/components/definitions/*.ts', we mock that exact path. + '/src/core/components/definitions/*.ts': { + get eager() { return true; }, // Make it behave like { eager: true } + get modules() { return mockGlobModules; }, // Return our dynamic mock + // Provide the glob function itself + glob: (pattern: string, options: { eager: boolean }) => { + if (options.eager) { + return mockGlobModules; + } + // For non-eager, return a promise (not used by the module AFAIK) + return Promise.resolve(mockGlobModules); + } + } +}), { virtual: true }); + + +// --- Dynamic Registry Module --- +// We need to import the module *after* setting up the mocks +// and re-import it if mockGlobModules changes between describe blocks. +// For now, let's assume one import at the top is fine if we manage mockGlobModules carefully. +// If tests interfere, we'll need dynamic imports within `beforeEach` or `describe`. +import * as DynamicRegistry from './dynamicComponentRegistry'; + + +describe('dynamicComponentRegistry', () => { + let consoleWarnSpy: ReturnType; + + beforeEach(() => { + // Reset mocks and spies before each test + vi.resetModules(); // This is crucial to re-evaluate the module with new mocks + consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); // Suppress warnings in tests + }); + + afterEach(() => { + mockGlobModules = {}; // Clear mock modules + consoleWarnSpy.mockRestore(); + vi.doUnmock('/*'); // Clean up the glob mock + }); + + describe('Component Discovery and AUTO_COMPONENT_REGISTRY', () => { + it('should be empty if no components are found', async () => { + mockGlobModules = {}; + const { AUTO_COMPONENT_REGISTRY } = await import('./dynamicComponentRegistry'); + expect(Object.keys(AUTO_COMPONENT_REGISTRY).length).toBe(0); + }); + + it('should register a single valid component', async () => { + mockGlobModules = { + '/src/core/components/definitions/test1.ts': { default: mockValidManifest1 }, + }; + const { AUTO_COMPONENT_REGISTRY } = await import('./dynamicComponentRegistry'); + expect(Object.keys(AUTO_COMPONENT_REGISTRY).length).toBe(1); + expect(AUTO_COMPONENT_REGISTRY.Test1).toEqual(mockValidManifest1); + }); + + it('should register multiple valid components', async () => { + mockGlobModules = { + '/src/core/components/definitions/test1.ts': { default: mockValidManifest1 }, + '/src/core/components/definitions/test2.ts': { default: mockValidManifest2 }, + }; + const { AUTO_COMPONENT_REGISTRY } = await import('./dynamicComponentRegistry'); + expect(Object.keys(AUTO_COMPONENT_REGISTRY).length).toBe(2); + expect(AUTO_COMPONENT_REGISTRY.Test1).toEqual(mockValidManifest1); + expect(AUTO_COMPONENT_REGISTRY.Test2).toEqual(mockValidManifest2); + }); + + it('should warn and not overwrite with a duplicate component ID', async () => { + mockGlobModules = { + '/src/core/components/definitions/test1.ts': { default: mockValidManifest1 }, + '/src/core/components/definitions/duplicateTest1.ts': { default: mockDuplicateIdManifest }, + }; + const { AUTO_COMPONENT_REGISTRY } = await import('./dynamicComponentRegistry'); + expect(Object.keys(AUTO_COMPONENT_REGISTRY).length).toBe(1); + expect(AUTO_COMPONENT_REGISTRY.Test1.name).toBe('Test Component 1'); // Original should be kept + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Duplicate component ID found: Test1')); + }); + + it('should warn if a module has no default export', async () => { + mockGlobModules = { + '/src/core/components/definitions/noDefault.ts': {}, // No default export + }; + const { AUTO_COMPONENT_REGISTRY } = await import('./dynamicComponentRegistry'); + expect(Object.keys(AUTO_COMPONENT_REGISTRY).length).toBe(0); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('is missing a default export or an \'id\' property')); + }); + + it('should warn if a module default export has no id', async () => { + mockGlobModules = { + '/src/core/components/definitions/noId.ts': { default: { name: 'No ID Comp' } as any }, + }; + const { AUTO_COMPONENT_REGISTRY } = await import('./dynamicComponentRegistry'); + expect(Object.keys(AUTO_COMPONENT_REGISTRY).length).toBe(0); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('is missing a default export or an \'id\' property')); + }); + }); + + describe('AutoKnownComponentTypes', () => { + it('should generate correct enum-like keys', async () => { + mockGlobModules = { + '/src/core/components/definitions/test1.ts': { default: mockValidManifest1 }, + '/src/core/components/definitions/physicsComp.ts': { default: mockValidManifest3Physics }, + }; + const { AutoKnownComponentTypes } = await import('./dynamicComponentRegistry'); + expect(AutoKnownComponentTypes.TEST_1).toBe('Test1'); + expect(AutoKnownComponentTypes.PHYSICS_COMP).toBe('PhysicsComp'); + }); + it('should be empty if no components are found', async () => { + mockGlobModules = {}; + const { AutoKnownComponentTypes } = await import('./dynamicComponentRegistry'); + expect(Object.keys(AutoKnownComponentTypes).length).toBe(0); + }); + }); + + describe('Helper Functions', () => { + // Re-import module to ensure mocks are applied correctly for this describe block + let registry: typeof DynamicRegistry; + + beforeEach(async () => { + // Setup with some components for helper function tests + mockGlobModules = { + '/src/core/components/definitions/test1.ts': { default: mockValidManifest1 }, + '/src/core/components/definitions/test2.ts': { default: mockValidManifest2 }, + '/src/core/components/definitions/physicsComp.ts': { default: mockValidManifest3Physics }, + }; + registry = await import('./dynamicComponentRegistry'); + }); + + describe('getComponentDefinition()', () => { + it('should return the manifest for an existing component ID', () => { + expect(registry.getComponentDefinition('Test1')).toEqual(mockValidManifest1); + }); + + it('should return undefined for a non-existing component ID', () => { + expect(registry.getComponentDefinition('NonExistent')).toBeUndefined(); + }); + }); + + describe('getAllComponentDefinitions()', () => { + it('should return an array of all registered manifests', () => { + const allDefs = registry.getAllComponentDefinitions(); + expect(allDefs).toBeInstanceOf(Array); + expect(allDefs.length).toBe(3); + expect(allDefs).toContainEqual(mockValidManifest1); + expect(allDefs).toContainEqual(mockValidManifest2); + expect(allDefs).toContainEqual(mockValidManifest3Physics); + }); + }); + + describe('getComponentsByCategory()', () => { + it('should group components by their category', () => { + const byCategory = registry.getComponentsByCategory(); + expect(byCategory[ComponentCategory.Gameplay]).toEqual([mockValidManifest1]); + expect(byCategory[ComponentCategory.Core]).toEqual([mockValidManifest2]); + expect(byCategory[ComponentCategory.Physics]).toEqual([mockValidManifest3Physics]); + expect(byCategory[ComponentCategory.Rendering]).toBeUndefined(); + }); + }); + + describe('isComponentRemovable()', () => { + it('should return true for a component that is removable (or not specified, defaulting to true)', () => { + expect(registry.isComponentRemovable('Test1')).toBe(true); // removable not specified + }); + + it('should return false for a component explicitly set as not removable', () => { + expect(registry.isComponentRemovable('Test2')).toBe(false); // removable: false + }); + + it('should return true for a non-existent component (defaulting behavior)', () => { + expect(registry.isComponentRemovable('NonExistent')).toBe(true); + }); + }); + + describe('getComponentDefaultData()', () => { + it('should return the default data from the manifest schema', () => { + expect(registry.getComponentDefaultData('Test1')).toEqual({ val: 1 }); + expect(registry.getComponentDefaultData('Test2')).toEqual({ name: 'defaultName' }); + }); + + it('should return an empty object for a non-existent component and warn', () => { + expect(registry.getComponentDefaultData('NonExistent')).toEqual({}); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Manifest not found for component ID: NonExistent')); + }); + }); + + // Tests for combineRenderingContributions and combinePhysicsContributions + // These are more complex as they depend on component data passed at runtime + describe('combineRenderingContributions()', () => { + it('should combine contributions from multiple components', () => { + const entityComponents = [ + { type: 'Test1', data: { val: 10 } }, // mockValidManifest1, visible: true + { type: 'Test2', data: { name: 'test' } } // mockValidManifest2, no rendering contribution + ]; + const combined = registry.combineRenderingContributions(entityComponents); + expect(combined.visible).toBe(true); // From Test1 + expect(combined.material?.color).toBe('#3399ff'); // Default material color + }); + + it('should return default contributions if no component provides rendering info', () => { + const entityComponents = [{ type: 'Test2', data: { name: 'test' } }]; // No rendering contributions + const combined = registry.combineRenderingContributions(entityComponents); + expect(combined.visible).toBe(true); // Default + expect(combined.castShadow).toBe(true); // Default + }); + }); + + describe('combinePhysicsContributions()', () => { + it('should combine contributions from physics components', () => { + const entityComponents = [ + { type: 'PhysicsComp', data: { force: 20 } } // mass: 10 + ]; + const combined = registry.combinePhysicsContributions(entityComponents); + expect(combined.enabled).toBe(true); // PhysicsComp enables it + expect(combined.rigidBodyProps?.mass).toBe(10); + }); + + it('should handle components with no physics contributions gracefully', () => { + const entityComponents = [{ type: 'Test1', data: { val: 1 } }]; // No physics contributions + const combined = registry.combinePhysicsContributions(entityComponents); + expect(combined.enabled).toBe(false); // Default + }); + }); + }); +}); diff --git a/src/core/lib/ecs/dynamicComponentRegistry.ts b/src/core/lib/ecs/dynamicComponentRegistry.ts new file mode 100644 index 00000000..c5110164 --- /dev/null +++ b/src/core/lib/ecs/dynamicComponentRegistry.ts @@ -0,0 +1,221 @@ +import { ComponentManifest, IRenderingContributions, IPhysicsContributions, IComponentPack, ComponentCategory } from '@core/components/types'; + +interface DiscoveredComponentModule { + default: ComponentManifest; +} + +// Note: Vite specific import. Ensure environment supports import.meta.glob +const modules = import.meta.glob('/src/core/components/definitions/*.ts', { eager: true }); + +const discoveredComponents: Record> = {}; +const componentManifestsList: ComponentManifest[] = []; + +for (const path in modules) { + const manifestModule = modules[path]; + if (manifestModule && manifestModule.default && manifestModule.default.id) { + const manifest = manifestModule.default; + if (discoveredComponents[manifest.id]) { + console.warn(`Duplicate component ID found: ${manifest.id} from path ${path}. Check your component definitions.`); + } + discoveredComponents[manifest.id] = manifest; + componentManifestsList.push(manifest); + } else { + console.warn(`Component manifest from path ${path} is missing a default export or an 'id' property.`); + } +} + +export const AUTO_COMPONENT_REGISTRY: Readonly>> = Object.freeze(discoveredComponents); + +const knownTypes: Record = {}; +componentManifestsList.forEach(m => { + const enumKey = m.id.replace(/([A-Z])/g, '_$1').toUpperCase().replace(/^_/, ''); + knownTypes[enumKey] = m.id; +}); +export const AutoKnownComponentTypes = Object.freeze(knownTypes) as Record; + +// Assuming IComponentPack is imported from './types' or defined appropriately +// If not, a minimal local definition might be needed: +// interface IComponentPack { id: string; name: string; description: string; icon: React.ReactNode; components: string[]; category: string; } +import React from 'react'; // Required for JSX in icons +import { FiZap, FiBox, FiCamera, FiShield, FiMove, FiEye } from 'react-icons/fi'; // Import necessary icons + +export const AUTO_COMPONENT_PACKS: Readonly = Object.freeze([ + { + id: 'physics-basics', + name: 'Physics Basics', + description: 'Rigid body + mesh collider for basic physics', + icon: React.createElement(FiZap, { className: 'w-4 h-4' }), + components: ['RigidBody', 'MeshCollider'], // Using string IDs + category: ComponentCategory.Physics, + }, + { + id: 'rendering-basics', + name: 'Rendering Basics', + description: 'Complete rendering setup', + icon: React.createElement(FiBox, { className: 'w-4 h-4' }), + components: ['MeshRenderer'], // Using string ID + category: ComponentCategory.Rendering, + }, + { + id: 'complete-entity', + name: 'Complete Entity', + description: 'Transform + rendering for a complete visible entity', + icon: React.createElement(FiBox, { className: 'w-4 h-4' }), + components: ['Transform', 'MeshRenderer'], // Using string IDs + category: ComponentCategory.Core, + }, + { + id: 'physics-entity', + name: 'Physics Entity', + description: 'Complete physics-enabled entity with rendering', + icon: React.createElement(FiBox, { className: 'w-4 h-4' }), + components: ['Transform', 'MeshRenderer', 'RigidBody', 'MeshCollider'], // Using string IDs + category: ComponentCategory.Physics, + }, + // Add other packs if they were present in the original COMPONENT_PACKS + // For example, if there was a camera pack: + // { + // id: 'camera-pack', + // name: 'Camera Setup', + // description: 'Basic camera', + // icon: React.createElement(FiCamera, { className: 'w-4 h-4' }), + // components: ['Camera'], // Assuming a 'Camera' component manifest will exist + // category: 'Rendering', + // }, +]); + +export const getComponentDefinition = (componentId: string): ComponentManifest | undefined => { + return AUTO_COMPONENT_REGISTRY[componentId]; +}; + +export const getAllComponentDefinitions = (): ComponentManifest[] => { + return Object.values(AUTO_COMPONENT_REGISTRY); +}; + +// IRenderingContributions and IPhysicsContributions are now imported at the top. + +export const getComponentsByCategory = (): Record[]> => { + const categories: Record[]> = {}; + Object.values(AUTO_COMPONENT_REGISTRY).forEach((manifest) => { + if (!categories[manifest.category]) { + categories[manifest.category] = []; + } + categories[manifest.category].push(manifest); + }); + return categories; +}; + +export const isComponentRemovable = (componentId: string): boolean => { + const manifest = getComponentDefinition(componentId); + return manifest?.removable ?? true; // Default to true if manifest or removable is not defined +}; + +export const getComponentDefaultData = (componentId: string): any => { + const manifest = getComponentDefinition(componentId); + if (!manifest) { + console.warn(`[getComponentDefaultData] Manifest not found for component ID: ${componentId}`); + return {}; + } + return manifest.getDefaultData(); +}; + +// Helper to combine rendering contributions from all components on an entity +export const combineRenderingContributions = ( + entityComponents: Array<{ type: string; data: any }>, +): IRenderingContributions => { + const combined: IRenderingContributions = { + visible: true, + castShadow: true, + receiveShadow: true, + // meshType: 'Cube', // Default meshType is not needed here, should come from a component + material: { + color: '#3399ff', // Default color if no component specifies one + metalness: 0, + roughness: 0.5, + emissive: '#000000', + emissiveIntensity: 0, + }, + }; + + entityComponents.forEach(({ type, data }) => { + const manifest = getComponentDefinition(type); + if (manifest?.getRenderingContributions) { + const contributions = manifest.getRenderingContributions(data); + // Merge contributions, with component-specific values overriding defaults or previous ones + if (contributions.geometry !== undefined) combined.geometry = contributions.geometry; + if (contributions.material) { + combined.material = { ...combined.material, ...contributions.material }; + } + if (contributions.visible !== undefined) combined.visible = contributions.visible; + if (contributions.castShadow !== undefined) combined.castShadow = contributions.castShadow; + if (contributions.receiveShadow !== undefined) combined.receiveShadow = contributions.receiveShadow; + if (contributions.meshType !== undefined) combined.meshType = contributions.meshType; + } + }); + + return combined; +}; + +// Helper to combine physics contributions from all components on an entity +export const combinePhysicsContributions = ( + entityComponents: Array<{ type: string; data: any }>, +): IPhysicsContributions => { + const combined: IPhysicsContributions = { + enabled: false, // Physics is disabled by default unless a component enables it + rigidBodyProps: { // Default rigid body props + // type: 'dynamic', // Type should come from a component (e.g. RigidBody) + mass: 1, + friction: 0.7, + restitution: 0.3, + density: 1, + gravityScale: 1, + canSleep: true, + }, + colliders: [], + }; + + let primaryRigidBodyType: string | undefined; + + entityComponents.forEach(({ type, data }) => { + const manifest = getComponentDefinition(type); + if (manifest?.getPhysicsContributions) { + const contributions = manifest.getPhysicsContributions(data); + + if (contributions.enabled !== undefined) { + // If any component enables physics, the combined physics should be enabled. + // If a component explicitly disables it, it could be tricky. + // Current logic: if ANY component says enabled:true, then it's enabled. + if(contributions.enabled) combined.enabled = true; + } + + if (contributions.rigidBodyProps) { + // Store the type from the primary physics component (e.g. RigidBody) + // This assumes 'RigidBody' or similar component is responsible for defining the body type. + // For now, let's assume the component with id 'RigidBody' dictates the type. + if (type === 'RigidBody' && contributions.rigidBodyProps.type) { + primaryRigidBodyType = contributions.rigidBodyProps.type; + } + // Merge other props. + combined.rigidBodyProps = { ...combined.rigidBodyProps, ...contributions.rigidBodyProps }; + } + + if (contributions.colliders) { + combined.colliders = [...(combined.colliders || []), ...contributions.colliders]; + } + } + }); + + // Apply the primary rigid body type if it was found + if (primaryRigidBodyType && combined.rigidBodyProps) { + combined.rigidBodyProps.type = primaryRigidBodyType; + } else if (!combined.rigidBodyProps?.type && combined.enabled) { + // If physics is enabled but no component specified a type (e.g. RigidBody), default to dynamic + // This might or might not be desired. For now, we ensure 'type' is set if enabled. + // combined.rigidBodyProps.type = 'dynamic'; + // Re-evaluating this: if no component defines a body type, it shouldn't default here. + // The PhysicsSystem should handle entities that have physics contributions but no explicit RigidBody type. + } + + + return combined; +}; diff --git a/src/editor/components/inspector/adapters/CameraAdapter.tsx b/src/editor/components/inspector/adapters/CameraAdapter.tsx index 76fb430f..a9278229 100644 --- a/src/editor/components/inspector/adapters/CameraAdapter.tsx +++ b/src/editor/components/inspector/adapters/CameraAdapter.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { KnownComponentTypes } from '@/core/lib/ecs/IComponent'; +import { isComponentRemovable } from '@core/lib/ecs/dynamicComponentRegistry'; import { ICameraData } from '@/core/lib/ecs/components/CameraComponent'; import { CameraSection } from '@/editor/components/panels/InspectorPanel/Camera/CameraSection'; @@ -21,20 +21,25 @@ export const CameraAdapter: React.FC = ({ if (!data) return null; + const componentId = 'Camera'; // Using string literal ID + const handleUpdate = (updates: Partial) => { const newData = { ...data, ...updates }; - updateComponent(KnownComponentTypes.CAMERA, newData); + updateComponent(componentId, newData); }; - const handleRemove = removeComponent - ? () => removeComponent(KnownComponentTypes.CAMERA) - : undefined; + const canRemove = isComponentRemovable(componentId); + const handleRemoveLogic = React.useCallback(() => { + if (removeComponent && canRemove) { + removeComponent(componentId); + } + }, [removeComponent, canRemove, componentId]); return ( ); diff --git a/src/editor/components/inspector/adapters/MeshColliderAdapter.tsx b/src/editor/components/inspector/adapters/MeshColliderAdapter.tsx index 2780512d..565cd04c 100644 --- a/src/editor/components/inspector/adapters/MeshColliderAdapter.tsx +++ b/src/editor/components/inspector/adapters/MeshColliderAdapter.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { KnownComponentTypes } from '@/core/lib/ecs/IComponent'; +import { isComponentRemovable } from '@core/lib/ecs/dynamicComponentRegistry'; import { MeshColliderSection } from '@/editor/components/panels/InspectorPanel/MeshCollider/MeshColliderSection'; interface IMeshColliderAdapterProps { @@ -41,15 +41,17 @@ export const MeshColliderAdapter: React.FC = ({ }, }; + const componentId = 'MeshCollider'; // Using string literal ID + const handleUpdate = (newData: any) => { if (newData === null) { // Remove component - if (removeComponent) { - removeComponent(KnownComponentTypes.MESH_COLLIDER); + if (removeComponent && isComponentRemovable(componentId)) { + removeComponent(componentId); } } else { // Update component - updateComponent(KnownComponentTypes.MESH_COLLIDER, newData); + updateComponent(componentId, newData); } }; diff --git a/src/editor/components/inspector/adapters/MeshRendererAdapter.tsx b/src/editor/components/inspector/adapters/MeshRendererAdapter.tsx index 18f793c8..1f67b87d 100644 --- a/src/editor/components/inspector/adapters/MeshRendererAdapter.tsx +++ b/src/editor/components/inspector/adapters/MeshRendererAdapter.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { KnownComponentTypes } from '@/core/lib/ecs/IComponent'; +import { isComponentRemovable } from '@core/lib/ecs/dynamicComponentRegistry'; import { MeshRendererSection } from '@/editor/components/panels/InspectorPanel/MeshRenderer/MeshRendererSection'; import { useEntityData } from '@/editor/hooks/useEntityData'; @@ -61,15 +61,17 @@ export const MeshRendererAdapter: React.FC = ({ }, }; + const componentId = 'MeshRenderer'; // Using string literal ID + const handleUpdate = (newData: any) => { if (newData === null) { // Remove component - if (removeComponent) { - removeComponent(KnownComponentTypes.MESH_RENDERER); + if (removeComponent && isComponentRemovable(componentId)) { + removeComponent(componentId); } } else { // Update component - updateComponent(KnownComponentTypes.MESH_RENDERER, newData); + updateComponent(componentId, newData); // Sync color changes to the old material component for viewport compatibility if (newData.material?.color) { diff --git a/src/editor/components/inspector/adapters/RigidBodyAdapter.tsx b/src/editor/components/inspector/adapters/RigidBodyAdapter.tsx index 11e4aa90..494232a6 100644 --- a/src/editor/components/inspector/adapters/RigidBodyAdapter.tsx +++ b/src/editor/components/inspector/adapters/RigidBodyAdapter.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { KnownComponentTypes } from '@/core/lib/ecs/IComponent'; +import { isComponentRemovable } from '@core/lib/ecs/dynamicComponentRegistry'; import { RigidBodySection } from '@/editor/components/panels/InspectorPanel/RigidBody/RigidBodySection'; interface IRigidBodyAdapterProps { @@ -68,13 +68,18 @@ export const RigidBodyAdapter: React.FC = ({ } : null; + const rigidBodyComponentId = 'RigidBody'; + const meshColliderComponentId = 'MeshCollider'; + const handleRigidBodyUpdate = (newData: any) => { if (newData === null) { // Remove rigid body component - removeComponent(KnownComponentTypes.RIGID_BODY); + if (isComponentRemovable(rigidBodyComponentId)) { + removeComponent(rigidBodyComponentId); + } } else { // Update rigid body component - updateComponent(KnownComponentTypes.RIGID_BODY, newData); + updateComponent(rigidBodyComponentId, newData); } }; @@ -82,15 +87,19 @@ export const RigidBodyAdapter: React.FC = ({ console.log('[RigidBodyAdapter] Mesh collider update:', { newData, hasMeshCollider }); if (newData === null) { // Remove mesh collider component - removeComponent(KnownComponentTypes.MESH_COLLIDER); + if (isComponentRemovable(meshColliderComponentId)) { + removeComponent(meshColliderComponentId); + } } else { // Add or update mesh collider component if (hasMeshCollider) { console.log('[RigidBodyAdapter] Updating existing mesh collider'); - updateComponent(KnownComponentTypes.MESH_COLLIDER, newData); + updateComponent(meshColliderComponentId, newData); } else { console.log('[RigidBodyAdapter] Adding new mesh collider'); - addComponent(KnownComponentTypes.MESH_COLLIDER, newData); + // Default data for new MeshCollider should be handled by AddComponentMenu or a similar mechanism + // For now, assuming newData contains appropriate default values if it's a new component. + addComponent(meshColliderComponentId, newData); } } }; diff --git a/src/editor/components/inspector/adapters/TransformAdapter.tsx b/src/editor/components/inspector/adapters/TransformAdapter.tsx index c7e88d56..15bb751a 100644 --- a/src/editor/components/inspector/adapters/TransformAdapter.tsx +++ b/src/editor/components/inspector/adapters/TransformAdapter.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { KnownComponentTypes } from '@/core/lib/ecs/IComponent'; +import { isComponentRemovable } from '@core/lib/ecs/dynamicComponentRegistry'; import { TransformSection } from '@/editor/components/panels/InspectorPanel/Transform/TransformSection'; interface ITransformAdapterProps { @@ -20,10 +20,12 @@ export const TransformAdapter: React.FC = ({ if (!data) return null; + const componentId = 'Transform'; // Using string literal ID + const handlePositionChange = React.useCallback( (position: [number, number, number]) => { console.log(`[Transform] Position changed for entity ${entityId}:`, position); - updateComponent(KnownComponentTypes.TRANSFORM, { + updateComponent(componentId, { ...data, position, }); @@ -34,7 +36,7 @@ export const TransformAdapter: React.FC = ({ const handleRotationChange = React.useCallback( (rotation: [number, number, number]) => { console.log(`[Transform] Rotation changed for entity ${entityId}:`, rotation); - updateComponent(KnownComponentTypes.TRANSFORM, { + updateComponent(componentId, { ...data, rotation, }); @@ -45,7 +47,7 @@ export const TransformAdapter: React.FC = ({ const handleScaleChange = React.useCallback( (scale: [number, number, number]) => { console.log(`[Transform] Scale changed for entity ${entityId}:`, scale); - updateComponent(KnownComponentTypes.TRANSFORM, { + updateComponent(componentId, { ...data, scale, }); @@ -53,11 +55,12 @@ export const TransformAdapter: React.FC = ({ [data, updateComponent, entityId], ); + const canRemove = isComponentRemovable(componentId); const handleRemove = React.useCallback(() => { - if (removeComponent) { - removeComponent(KnownComponentTypes.TRANSFORM); + if (removeComponent && canRemove) { + removeComponent(componentId); } - }, [removeComponent]); + }, [removeComponent, canRemove]); return ( = ({ setPosition={handlePositionChange} setRotation={handleRotationChange} setScale={handleScaleChange} - onRemove={removeComponent ? handleRemove : undefined} + onRemove={canRemove && removeComponent ? handleRemove : undefined} /> ); }; diff --git a/src/editor/components/menus/AddComponentMenu.tsx b/src/editor/components/menus/AddComponentMenu.tsx index 865dbfcc..8f03cbee 100644 --- a/src/editor/components/menus/AddComponentMenu.tsx +++ b/src/editor/components/menus/AddComponentMenu.tsx @@ -1,11 +1,17 @@ import React, { useMemo, useState } from 'react'; -import { FiBox, FiEye, FiMove, FiPackage, FiSearch, FiShield, FiX, FiZap } from 'react-icons/fi'; -import { TbCube } from 'react-icons/tb'; +import { FiBox, FiPackage, FiSearch, FiX } from 'react-icons/fi'; // Removed unused icons FiEye, FiMove, FiShield, FiZap, TbCube -import { KnownComponentTypes } from '@/core/lib/ecs/IComponent'; import { isValidEntityId } from '@/core/lib/ecs/utils'; import { useComponentManager } from '@/editor/hooks/useComponentManager'; -import { useEntityData } from '@/editor/hooks/useEntityData'; +// import { useEntityData } from '@/editor/hooks/useEntityData'; // Not used after refactor +import { + getAllComponentDefinitions, + getComponentDefaultData, + AUTO_COMPONENT_PACKS, + getComponentsByCategory as getRegistryComponentsByCategory, // Renamed to avoid conflict +} from '@core/lib/ecs/dynamicComponentRegistry'; +import { ComponentManifest } from '@core/components/types'; // For typing +import { IComponentPack } from '@core/lib/ecs/types'; // Assuming IComponentPack is here /** * Add Component Menu System @@ -59,95 +65,8 @@ interface IAddComponentMenuProps { onClose: () => void; } -// Component definitions with icons and metadata -interface IComponentDefinition { - id: string; - name: string; - description: string; - icon: React.ReactNode; - category: string; -} - -// Component pack definitions -interface IComponentPack { - id: string; - name: string; - description: string; - icon: React.ReactNode; - components: string[]; - category: string; -} - -const COMPONENT_DEFINITIONS: IComponentDefinition[] = [ - { - id: KnownComponentTypes.TRANSFORM, - name: 'Transform', - description: 'Position, rotation, and scale', - icon: , - category: 'Core', - }, - { - id: KnownComponentTypes.MESH_RENDERER, - name: 'Mesh Renderer', - description: 'Renders 3D mesh geometry', - icon: , - category: 'Rendering', - }, - { - id: KnownComponentTypes.RIGID_BODY, - name: 'Rigid Body', - description: 'Physics simulation body', - icon: , - category: 'Physics', - }, - { - id: KnownComponentTypes.MESH_COLLIDER, - name: 'Mesh Collider', - description: 'Physics collision detection', - icon: , - category: 'Physics', - }, -]; - -const COMPONENT_PACKS: IComponentPack[] = [ - { - id: 'physics-basics', - name: 'Physics Basics', - description: 'Rigid body + mesh collider for basic physics', - icon: , - components: [KnownComponentTypes.RIGID_BODY, KnownComponentTypes.MESH_COLLIDER], - category: 'Physics', - }, - { - id: 'rendering-basics', - name: 'Rendering Basics', - description: 'Complete rendering setup', - icon: , - components: [KnownComponentTypes.MESH_RENDERER], - category: 'Rendering', - }, - { - id: 'complete-entity', - name: 'Complete Entity', - description: 'Transform + rendering for a complete visible entity', - icon: , - components: [KnownComponentTypes.TRANSFORM, KnownComponentTypes.MESH_RENDERER], - category: 'Core', - }, - { - id: 'physics-entity', - name: 'Physics Entity', - description: 'Complete physics-enabled entity with rendering', - icon: , - components: [ - KnownComponentTypes.TRANSFORM, - KnownComponentTypes.MESH_RENDERER, - KnownComponentTypes.RIGID_BODY, - KnownComponentTypes.MESH_COLLIDER, - ], - category: 'Physics', - }, -]; +// Removed local IComponentDefinition and IComponentPack, will use ComponentManifest from core types +// Removed local COMPONENT_DEFINITIONS and COMPONENT_PACKS export const AddComponentMenu: React.FC = ({ entityId, @@ -155,63 +74,33 @@ export const AddComponentMenu: React.FC = ({ onClose, }) => { const componentManager = useComponentManager(); - const { getComponentData } = useEntityData(); + // const { getComponentData } = useEntityData(); // Not used after refactor const [searchTerm, setSearchTerm] = useState(''); const [selectedTab, setSelectedTab] = useState<'components' | 'packs'>('components'); - // Helper function to get default material data - const getDefaultMaterialData = (entityId: number) => { - const materialData = getComponentData(entityId, 'material') as any; - let color = '#3399ff'; // Default blue like old ECS system - - if (materialData?.color) { - if (Array.isArray(materialData.color)) { - // Convert RGB array to hex - const [r, g, b] = materialData.color; - color = `#${Math.round(r * 255) - .toString(16) - .padStart(2, '0')}${Math.round(g * 255) - .toString(16) - .padStart(2, '0')}${Math.round(b * 255) - .toString(16) - .padStart(2, '0')}`; - } else if (typeof materialData.color === 'string') { - color = materialData.color; - } - } + // Removed local getDefaultMaterialData, default data now comes from manifest.getDefaultData() - return { - color, - metalness: 0.0, - roughness: 0.5, - emissive: '#000000', - emissiveIntensity: 0.0, - }; - }; - - // Get components for this entity using new ECS system const entityComponents = useMemo(() => { if (!isValidEntityId(entityId)) return []; return componentManager.getComponentsForEntity(entityId); }, [entityId, componentManager]); - // Get available components from KnownComponentTypes + const allManifests = useMemo(() => getAllComponentDefinitions(), []); + const allPacks = useMemo(() => AUTO_COMPONENT_PACKS as IComponentPack[], []); // Cast if necessary, ensure AUTO_COMPONENT_PACKS matches IComponentPack structure + const availableComponents = useMemo(() => { if (!isValidEntityId(entityId)) return []; - const existingTypes = entityComponents.map((c) => c.type); + const existingTypes = new Set(entityComponents.map((c) => c.type)); + return allManifests.filter((manifest) => !existingTypes.has(manifest.id)); + }, [entityId, entityComponents, allManifests]); - return COMPONENT_DEFINITIONS.filter((comp) => !existingTypes.includes(comp.id)); - }, [entityId, entityComponents]); - - // Get available component packs const availablePacks = useMemo(() => { if (!isValidEntityId(entityId)) return []; - const existingTypes = entityComponents.map((c) => c.type); - - return COMPONENT_PACKS.filter((pack) => - pack.components.some((compId) => !existingTypes.includes(compId)), + const existingTypes = new Set(entityComponents.map((c) => c.type)); + return allPacks.filter((pack) => + pack.components.some((compId) => !existingTypes.has(compId)), ); - }, [entityId, entityComponents]); + }, [entityId, entityComponents, allPacks]); // Filter by search term const filteredComponents = useMemo(() => { @@ -238,12 +127,12 @@ export const AddComponentMenu: React.FC = ({ // Group components by category const componentsByCategory = useMemo(() => { - const categories: Record = {}; - filteredComponents.forEach((comp) => { - if (!categories[comp.category]) { - categories[comp.category] = []; + const categories: Record[]> = {}; + filteredComponents.forEach((manifest) => { + if (!categories[manifest.category]) { + categories[manifest.category] = []; } - categories[comp.category].push(comp); + categories[manifest.category].push(manifest); }); return categories; }, [filteredComponents]); @@ -259,63 +148,17 @@ export const AddComponentMenu: React.FC = ({ return categories; }, [filteredPacks]); - const handleAddComponent = (componentType: string) => { + const handleAddComponent = (componentId: string) => { if (!isValidEntityId(entityId)) return; - // Add component with default data based on type - let defaultData = {}; - switch (componentType) { - case KnownComponentTypes.TRANSFORM: - defaultData = { position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] }; - break; - case KnownComponentTypes.MESH_RENDERER: { - defaultData = { - meshId: 'cube', - materialId: 'default', - enabled: true, - material: getDefaultMaterialData(entityId), - }; - break; - } - case KnownComponentTypes.RIGID_BODY: - defaultData = { - type: 'dynamic', - mass: 1, - enabled: true, - bodyType: 'dynamic', - gravityScale: 1, - canSleep: true, - material: { - friction: 0.7, - restitution: 0.3, - density: 1, - }, - }; - break; - case KnownComponentTypes.MESH_COLLIDER: - defaultData = { - enabled: true, - colliderType: 'box', - isTrigger: false, - center: [0, 0, 0], - size: { - width: 1, - height: 1, - depth: 1, - radius: 0.5, - capsuleRadius: 0.5, - capsuleHeight: 2, - }, - physicsMaterial: { - friction: 0.7, - restitution: 0.3, - density: 1, - }, - }; - break; + const defaultData = getComponentDefaultData(componentId); + if (Object.keys(defaultData).length === 0 && !allManifests.find(m => m.id === componentId)?.getDefaultData) { + // This check is primarily for components that might not yet be refactored and are not in AUTO_COMPONENT_REGISTRY + // or if getComponentDefaultData returned empty due to some issue. + console.warn(`[AddComponentMenu] Could not get default data for component ID: ${componentId}. Adding with empty data.`); } - componentManager.addComponent(entityId, componentType, defaultData); + componentManager.addComponent(entityId, componentId, defaultData); onClose(); }; @@ -341,15 +184,15 @@ export const AddComponentMenu: React.FC = ({ return (
{visibleComponents.map((component) => { - const definition = COMPONENT_DEFINITIONS.find((def) => def.id === component.type); + const manifest = allManifests.find((m) => m.id === component.type); return (
- {definition?.icon || } - {definition?.name || component.type} + {manifest?.icon || } + {manifest?.name || component.type}
); })} @@ -359,8 +202,8 @@ export const AddComponentMenu: React.FC = ({ title={`+${hiddenCount} more: ${entityComponents .slice(maxVisible) .map((c) => { - const def = COMPONENT_DEFINITIONS.find((d) => d.id === c.type); - return def?.name || c.type; + const manifest = allManifests.find((m) => m.id === c.type); + return manifest?.name || c.type; }) .join(', ')}`} > @@ -462,14 +305,15 @@ export const AddComponentMenu: React.FC = ({ >
- {component.icon} + {/* Ensure manifest.icon is a ReactNode. If it's a string or path, handle appropriately */} + {typeof component.icon === 'function' ? component.icon({}) : component.icon}
{component.name}
- {component.description} + {component.description} {/* Ensure description is a string */}
@@ -502,25 +346,26 @@ export const AddComponentMenu: React.FC = ({ >
- {pack.icon} + {/* Ensure pack.icon is a ReactNode */} + {typeof pack.icon === 'function' ? pack.icon({}) : pack.icon}
{pack.name}
- {pack.description} + {pack.description} {/* Ensure description is a string */}
{pack.components.map((compId) => { - const def = COMPONENT_DEFINITIONS.find((d) => d.id === compId); + const manifest = allManifests.find((m) => m.id === compId); return ( - {def?.icon || } - {def?.name || compId} + {manifest?.icon || } + {manifest?.name || compId} ); })} @@ -574,132 +419,54 @@ export const CompactAddComponentMenu: React.FC = onClose, }) => { const componentManager = useComponentManager(); - const { getComponentData } = useEntityData(); + // const { getComponentData } = useEntityData(); // Not used const [searchTerm, setSearchTerm] = useState(''); - // Helper function to get default material data - const getDefaultMaterialData = (entityId: number) => { - const materialData = getComponentData(entityId, 'material') as any; - let color = '#3399ff'; // Default blue like old ECS system - - if (materialData?.color) { - if (Array.isArray(materialData.color)) { - // Convert RGB array to hex - const [r, g, b] = materialData.color; - color = `#${Math.round(r * 255) - .toString(16) - .padStart(2, '0')}${Math.round(g * 255) - .toString(16) - .padStart(2, '0')}${Math.round(b * 255) - .toString(16) - .padStart(2, '0')}`; - } else if (typeof materialData.color === 'string') { - color = materialData.color; - } - } + // Removed local getDefaultMaterialData - return { - color, - metalness: 0.0, - roughness: 0.5, - emissive: '#000000', - emissiveIntensity: 0.0, - }; - }; - - // Get components for this entity using new ECS system const entityComponents = useMemo(() => { if (!isValidEntityId(entityId)) return []; return componentManager.getComponentsForEntity(entityId); }, [entityId, componentManager]); - // Get available components and packs + const allManifests = useMemo(() => getAllComponentDefinitions(), []); + const allPacks = useMemo(() => AUTO_COMPONENT_PACKS as IComponentPack[], []); + const availableComponents = useMemo(() => { if (!isValidEntityId(entityId)) return []; - const existingTypes = entityComponents.map((c) => c.type); - return COMPONENT_DEFINITIONS.filter((comp) => !existingTypes.includes(comp.id)); - }, [entityId, entityComponents]); + const existingTypes = new Set(entityComponents.map((c) => c.type)); + return allManifests.filter((manifest) => !existingTypes.has(manifest.id)); + }, [entityId, entityComponents, allManifests]); const availablePacks = useMemo(() => { if (!isValidEntityId(entityId)) return []; - const existingTypes = entityComponents.map((c) => c.type); - return COMPONENT_PACKS.filter((pack) => - pack.components.some((compId) => !existingTypes.includes(compId)), + const existingTypes = new Set(entityComponents.map((c) => c.type)); + return allPacks.filter((pack) => + pack.components.some((compId) => !existingTypes.has(compId)), ); - }, [entityId, entityComponents]); + }, [entityId, entityComponents, allPacks]); - // Filter by search term - combine components and packs const filteredItems = useMemo(() => { const term = searchTerm.toLowerCase(); const components = availableComponents.filter( - (comp) => - comp.name.toLowerCase().includes(term) || comp.description.toLowerCase().includes(term), + (manifest) => + manifest.name.toLowerCase().includes(term) || manifest.description.toLowerCase().includes(term), ); const packs = availablePacks.filter( (pack) => pack.name.toLowerCase().includes(term) || pack.description.toLowerCase().includes(term), ); - - return [...packs, ...components]; // Show packs first + // Type assertion to treat ComponentManifest and IComponentPack as a common type for the list + return [...packs, ...components] as (ComponentManifest | IComponentPack)[]; }, [availableComponents, availablePacks, searchTerm]); - const handleAddComponent = (componentType: string) => { + const handleAddComponent = (componentId: string) => { if (!isValidEntityId(entityId)) return; - - // Add component with default data based on type - let defaultData = {}; - switch (componentType) { - case KnownComponentTypes.TRANSFORM: - defaultData = { position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] }; - break; - case KnownComponentTypes.MESH_RENDERER: { - defaultData = { - meshId: 'cube', - materialId: 'default', - enabled: true, - material: getDefaultMaterialData(entityId), - }; - break; - } - case KnownComponentTypes.RIGID_BODY: - defaultData = { - type: 'dynamic', - mass: 1, - enabled: true, - bodyType: 'dynamic', - gravityScale: 1, - canSleep: true, - material: { - friction: 0.7, - restitution: 0.3, - density: 1, - }, - }; - break; - case KnownComponentTypes.MESH_COLLIDER: - defaultData = { - enabled: true, - colliderType: 'box', - isTrigger: false, - center: [0, 0, 0], - size: { - width: 1, - height: 1, - depth: 1, - radius: 0.5, - capsuleRadius: 0.5, - capsuleHeight: 2, - }, - physicsMaterial: { - friction: 0.7, - restitution: 0.3, - density: 1, - }, - }; - break; + const defaultData = getComponentDefaultData(componentId); + if (Object.keys(defaultData).length === 0 && !allManifests.find(m => m.id === componentId)?.getDefaultData) { + console.warn(`[CompactAddComponentMenu] Could not get default data for component ID: ${componentId}. Adding with empty data.`); } - - componentManager.addComponent(entityId, componentType, defaultData); + componentManager.addComponent(entityId, componentId, defaultData); onClose(); }; @@ -716,12 +483,10 @@ export const CompactAddComponentMenu: React.FC = }); }; - const handleItemClick = (item: IComponentDefinition | IComponentPack) => { - if ('components' in item) { - // It's a pack - handleAddPack(item); - } else { - // It's a component + const handleItemClick = (item: ComponentManifest | IComponentPack) => { + if ('components' in item) { // Check if it's an IComponentPack by looking for 'components' property + handleAddPack(item as IComponentPack); + } else { // Otherwise, it's a ComponentManifest handleAddComponent(item.id); } }; @@ -765,7 +530,8 @@ export const CompactAddComponentMenu: React.FC = : 'text-cyan-400 group-hover:text-cyan-300' }`} > - {item.icon} + {/* Ensure item.icon is ReactNode */} + {typeof item.icon === 'function' ? item.icon({}) : item.icon}
@@ -773,7 +539,7 @@ export const CompactAddComponentMenu: React.FC = {isPack && (Pack)}
- {item.description} + {item.description} {/* Ensure description is string */}