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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions docs/development/adding-new-components.md
Original file line number Diff line number Diff line change
@@ -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<TData>`)

Each component definition file must default export an object that conforms to the `ComponentManifest<TData>` 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<TData>`**: 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<HealthData> = {
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<IComponentPack[]> = 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<YourDataInterface>` 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`.
```
173 changes: 173 additions & 0 deletions src/core/components/definitions/CoreContributions.spec.ts
Original file line number Diff line number Diff line change
@@ -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.
});
});
Loading
Loading