diff --git a/packages/docs/src/components/MotionEntity.tsx b/packages/docs/src/components/MotionEntity.tsx index 818ea74b..fdb1ba24 100644 --- a/packages/docs/src/components/MotionEntity.tsx +++ b/packages/docs/src/components/MotionEntity.tsx @@ -79,9 +79,9 @@ export const MotionEntity: FC = ({ children, animate: animate return ( {children} diff --git a/packages/lib/src/Application.tsx b/packages/lib/src/Application.tsx index 61ec7d5e..c37aa240 100644 --- a/packages/lib/src/Application.tsx +++ b/packages/lib/src/Application.tsx @@ -1,4 +1,4 @@ -import React, { FC, PropsWithChildren, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import React, { FC, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { FILLMODE_NONE, FILLMODE_FILL_WINDOW, @@ -9,62 +9,26 @@ import { TouchDevice, Entity as PcEntity, RESOLUTION_FIXED, + type GraphicsDevice, } from 'playcanvas'; import { AppContext, ParentContext } from './hooks'; import { PointerEventsContext } from './contexts/pointer-events-context'; import { usePicker } from './utils/picker'; import { PhysicsProvider } from './contexts/physics-context'; - -interface GraphicsOptions { - /** Boolean that indicates if the canvas contains an alpha buffer. */ - alpha?: boolean, //true, - /** Boolean that indicates that the drawing buffer is requested to have a depth buffer of at least 16 bits. */ - depth?: boolean, //true - /** Boolean that indicates that the drawing buffer is requested to have a stencil buffer of at least 8 bits. */ - stencil?: boolean, //true - /** Boolean that indicates whether or not to perform anti-aliasing if possible. */ - antialias?: boolean, //true - /** Boolean that indicates that the page compositor will assume the drawing buffer contains colors with pre-multiplied alpha. */ - premultipliedAlpha?: boolean, //true - /** If the value is true the buffers will not be cleared and will preserve their values until cleared or overwritten by the author. */ - preserveDrawingBuffer?: boolean, //false - /** A hint to the user agent indicating what configuration of GPU is suitable for the WebGL context. */ - powerPreference?: 'default' | 'high-performance' | 'low-power' // 'default' - /** Boolean that indicates if a context will be created if the system performance is low or if no hardware GPU is available. */ - failIfMajorPerformanceCaveat?: boolean,//false - /** Boolean that hints the user agent to reduce the latency by desynchronizing the canvas paint cycle from the event loop. */ - desynchronized?: boolean,//false - /** BBoolean that hints to the user agent to use a compatible graphics adapter for an immersive XR device. */ - xrCompatible?: boolean,//false -} - -interface ApplicationProps extends PropsWithChildren { - /** The class name to attach to the canvas component */ - className?: string, - /** A style object added to the canvas component */ - style?: Record - /** Controls how the canvas fills the window and resizes when the window changes. */ - fillMode?: typeof FILLMODE_NONE | typeof FILLMODE_FILL_WINDOW | typeof FILLMODE_KEEP_ASPECT - /** Change the resolution of the canvas, and set the way it behaves when the window is resized. */ - resolutionMode?: typeof RESOLUTION_AUTO | typeof RESOLUTION_FIXED - /** Clamps per-frame delta time to an upper bound. Useful since returning from a tab deactivation can generate huge values for dt, which can adversely affect game state. */ - maxDeltaTime?: number - /** Scales the global time delta. */ - timeScale?: number, - /** Whether to use the PlayCanvas Physics system. */ - usePhysics?: boolean, - /** Graphics Settings */ - graphicsDeviceOptions?: GraphicsOptions -} - - -interface ApplicationWithoutCanvasProps extends ApplicationProps { - /** A ref to a html canvas element */ - canvasRef: React.RefObject; -} +import { validateAndSanitizeProps, createComponentDefinition, Schema, getNullApplication } from './utils/validation'; +import { PublicProps } from './utils/types-utils'; /** - * The **Application** component is the root node of the PlayCanvas React api. It creates a canvas element + * The **Application** component is the root node of the PlayCanvas React API. It creates a canvas element + * and initializes a PlayCanvas application instance. + * + * @param {ApplicationProps} props - The props to pass to the application component. + * @returns {React.ReactNode} - The application component. + * + * @example + * + * + * */ export const Application: React.FC = ({ children, @@ -90,18 +54,42 @@ export const Application: React.FC = ({ /** * An alternative Application component that does not create a canvas element. - * This allows you to create a canvas independently from Playcanvas and pass this in as a ref. + * This allows you to create a canvas independently from PlayCanvas and pass it in as a ref. + * + * @param {ApplicationWithoutCanvasProps} props - The props to pass to the application component. + * @returns {React.ReactNode} The application component. + * + * @example + * const canvasRef = useRef(null); + * + * return ( + * <> + * + * + * + * + * + * ); */ -export const ApplicationWithoutCanvas: FC = ({ - children, - canvasRef, - fillMode = FILLMODE_NONE, - resolutionMode = RESOLUTION_AUTO, - maxDeltaTime = 0.1, - timeScale = 1, - usePhysics = false, - ...otherProps -}) => { +export const ApplicationWithoutCanvas: FC = (props) => { + + const { children, ...propsToValidate } = props; + + const validatedProps = validateAndSanitizeProps( + propsToValidate, + componentDefinition + ); + + const { + canvasRef, + fillMode = FILLMODE_NONE, + resolutionMode = RESOLUTION_AUTO, + maxDeltaTime = 0.1, + timeScale = 1, + usePhysics = false, + ...otherProps + } = validatedProps; + const graphicsDeviceOptions = { alpha: true, depth: true, @@ -152,7 +140,7 @@ export const ApplicationWithoutCanvas: FC = ({ if (!app) return; app.maxDeltaTime = maxDeltaTime; app.timeScale = timeScale; - }, [app]) + }, [app, maxDeltaTime, timeScale]) if (!app) return null; @@ -167,4 +155,67 @@ export const ApplicationWithoutCanvas: FC = ({ ); -}; \ No newline at end of file +}; + +type GraphicsOptions = Partial> + +interface ApplicationProps extends Partial> { + /** The class name to attach to the canvas component */ + className?: string, + /** A style object added to the canvas component */ + style?: Record + /** Controls how the canvas fills the window and resizes when the window changes. */ + fillMode?: typeof FILLMODE_NONE | typeof FILLMODE_FILL_WINDOW | typeof FILLMODE_KEEP_ASPECT + /** Change the resolution of the canvas, and set the way it behaves when the window is resized. */ + resolutionMode?: typeof RESOLUTION_AUTO | typeof RESOLUTION_FIXED + // /** Clamps per-frame delta time to an upper bound. Useful since returning from a tab deactivation can generate huge values for dt, which can adversely affect game state. */ + maxDeltaTime?: number + // /** Scales the global time delta. */ + timeScale?: number, + /** Whether to use the PlayCanvas Physics system. */ + usePhysics?: boolean, + /** Graphics Settings */ + graphicsDeviceOptions?: GraphicsOptions, + /** The children of the application */ + children?: React.ReactNode, +} + + +interface ApplicationWithoutCanvasProps extends ApplicationProps { + /** A ref to a html canvas element */ + canvasRef: React.RefObject; +} + +const componentDefinition = createComponentDefinition( + "Application", + () => getNullApplication(), + (app) => app.destroy() +) + +componentDefinition.schema = { + ...componentDefinition.schema, + canvasRef: { + validate: (value: unknown) => { + return value !== null && + typeof value === 'object' && + 'current' in value; + }, + errorMsg: (value: unknown) => `canvasRef must be a React ref object. Received: ${value}`, + default: null + }, + usePhysics: { + validate: (value: unknown) => typeof value === 'boolean', + errorMsg: (value: unknown) => `usePhysics must be a boolean. Received: ${value}`, + default: false + }, + fillMode: { + validate: (value: unknown) => typeof value === 'string' && [FILLMODE_NONE, FILLMODE_FILL_WINDOW, FILLMODE_KEEP_ASPECT].includes(value), + errorMsg: () => `"fillMode" must be one of: ${FILLMODE_NONE}, ${FILLMODE_FILL_WINDOW}, ${FILLMODE_KEEP_ASPECT}`, + default: FILLMODE_NONE + }, + resolutionMode: { + validate: (value: unknown) => typeof value === 'string' && [RESOLUTION_AUTO, RESOLUTION_FIXED].includes(value), + errorMsg: () => `"resolutionMode" must be one of: ${RESOLUTION_AUTO}, ${RESOLUTION_FIXED}`, + default: RESOLUTION_AUTO + } +} as Schema \ No newline at end of file diff --git a/packages/lib/src/Container.tsx b/packages/lib/src/Container.tsx index 5902683a..cf750a21 100644 --- a/packages/lib/src/Container.tsx +++ b/packages/lib/src/Container.tsx @@ -44,7 +44,7 @@ export const Container: FC = ({ asset, children, ...props }) => if(!asset?.resource) return null; - return + return { children } ; }; diff --git a/packages/lib/src/Entity.tsx b/packages/lib/src/Entity.tsx index 2d858cab..4b0373b0 100644 --- a/packages/lib/src/Entity.tsx +++ b/packages/lib/src/Entity.tsx @@ -5,39 +5,63 @@ import { ReactNode, forwardRef, useImperativeHandle, useLayoutEffect, useMemo } import { useParent, ParentContext, useApp } from './hooks'; import { SyntheticMouseEvent, SyntheticPointerEvent } from './utils/synthetic-event'; import { usePointerEventsContext } from './contexts/pointer-events-context'; +import { PublicProps } from './utils/types-utils'; +import { validateAndSanitizeProps, createComponentDefinition, ComponentDefinition, Schema } from './utils/validation'; + +/** + * The Entity component is the fundamental building block of a PlayCanvas scene. + * It represents a node in the scene graph and can have components attached to it. + * + * @example + * // Basic usage + * + * + * + * + * @example + * // With pointer events + * console.log('Clicked!')} + * onClick={(e) => console.log('Mouse clicked!')} + * > + * + * + * + * @param {EntityProps} props - Component props + */ +export const Entity = forwardRef (function Entity( + props, + ref +) : React.ReactElement | null { -type PointerEventCallback = (event: SyntheticPointerEvent) => void; -type MouseEventCallback = (event: SyntheticMouseEvent) => void; - -interface EntityProps { - name?: string; - position?: number[]; - scale?: number[]; - rotation?: number[]; - onPointerUp?: PointerEventCallback; - onPointerDown?: PointerEventCallback; - onPointerOver?: PointerEventCallback; - onPointerOut?: PointerEventCallback; - onClick?: MouseEventCallback; - children?: ReactNode -} - + const { children, ...propsToValidate } = props; + const safeProps = validateAndSanitizeProps( + propsToValidate, + componentDefinition as ComponentDefinition + ); -export const Entity = forwardRef (function Entity( - { + const { + /** The name of the entity */ name = 'Untitled', - children, + /** The local position of the entity */ position = [0, 0, 0], + /** The local scale of the entity */ scale = [1, 1, 1], + /** The local rotation of the entity */ rotation = [0, 0, 0], + /** The callback for the pointer down event */ onPointerDown, + /** The callback for the pointer up event */ onPointerUp, + /** The callback for the pointer over event */ onPointerOver, + /** The callback for the pointer out event */ onPointerOut, + /** The callback for the click event */ onClick, - }, - ref -) : React.ReactElement | null { + } : EntityProps = safeProps; + const parent = useParent(); const app = useApp(); const pointerEvents = usePointerEventsContext(); @@ -46,7 +70,7 @@ export const Entity = forwardRef (function Entity( const hasPointerEvents = !!(onPointerDown || onPointerUp || onPointerOver || onPointerOut || onClick); // Create the entity only when 'app' changes - const entity = useMemo(() => new PcEntity(name, app), [app]) as PcEntity + const entity = useMemo(() => new PcEntity(undefined, app), [app]) as PcEntity useImperativeHandle(ref, () => entity); @@ -99,4 +123,55 @@ export const Entity = forwardRef (function Entity( {children || null} ); -}); \ No newline at end of file +}); + +type PointerEventCallback = (event: SyntheticPointerEvent) => void; +type MouseEventCallback = (event: SyntheticMouseEvent) => void; + +export interface EntityProps extends Partial> { + name?: string; + position?: [number, number, number]; + scale?: [number, number, number]; + rotation?: [number, number, number, number?]; + onPointerUp?: PointerEventCallback; + onPointerDown?: PointerEventCallback; + onPointerOver?: PointerEventCallback; + onPointerOut?: PointerEventCallback; + onClick?: MouseEventCallback; + children?: ReactNode; +} + +const componentDefinition = createComponentDefinition( + "Entity", + () => new PcEntity(), + (entity) => entity.destroy() +) + +componentDefinition.schema = { + ...componentDefinition.schema, + onPointerDown: { + validate: (val: unknown) => typeof val === 'function', + errorMsg: (val: unknown) => `Invalid value for prop "onPointerDown": "${val}". Expected a function.`, + default: undefined + }, + onPointerUp: { + validate: (val: unknown) => typeof val === 'function', + errorMsg: (val: unknown) => `Invalid value for prop "onPointerUp": "${val}". Expected a function.`, + default: undefined + }, + onPointerOver: { + validate: (val: unknown) => typeof val === 'function', + errorMsg: (val: unknown) => `Invalid value for prop "onPointerOver": "${val}". Expected a function.`, + default: undefined + }, + onPointerOut: { + validate: (val: unknown) => typeof val === 'function', + errorMsg: (val: unknown) => `Invalid value for prop "onPointerOut": "${val}". Expected a function.`, + default: undefined + }, + onClick: { + validate: (val: unknown) => typeof val === 'function', + errorMsg: (val: unknown) => `Invalid value for prop "onClick": "${val}". Expected a function.`, + default: undefined + } +} as Schema \ No newline at end of file diff --git a/packages/lib/src/components/Anim.tsx b/packages/lib/src/components/Anim.tsx index 3eba694e..9ac9450b 100644 --- a/packages/lib/src/components/Anim.tsx +++ b/packages/lib/src/components/Anim.tsx @@ -3,19 +3,30 @@ import { FC, useLayoutEffect } from "react"; import { useComponent, useParent } from "../hooks"; import { AnimComponent, Asset, Entity } from "playcanvas"; - -interface AnimProps { - [key: string]: unknown; - asset : Asset -} +import { PublicProps } from "../utils/types-utils"; +import { WithCssColors } from "../utils/color"; +import { validateAndSanitizeProps, createComponentDefinition, ComponentDefinition, getStaticNullApplication } from "../utils/validation"; /** - * The Camera component is used to define the position and properties of a camera entity. + * The Anim component allows an entity to play animations. + * GLB's and GLTF's often contain animations. When you attach this component to an entity, + * it will automatically animate them. You'll also need a Render component to display the entity. + * + * @param {AnimProps} props - The props to pass to the animation component. + * @see https://api.playcanvas.com/engine/classes/AnimComponent.html + * + * @example + * + * + * + * */ export const Anim: FC = ({ asset, ...props }) => { + const safeProps = validateAndSanitizeProps(props as Record, componentDefinition as ComponentDefinition); + // Create the anim component - useComponent("anim", props); + useComponent("anim", safeProps as Partial); // Get the associated Entity const entity : Entity = useParent(); @@ -38,4 +49,15 @@ export const Anim: FC = ({ asset, ...props }) => { }, [asset?.id, entity.getGuid()]) return null; -} \ No newline at end of file +} + +interface AnimProps extends Partial>> { + asset : Asset +} + + +const componentDefinition = createComponentDefinition( + "Anim", + () => new Entity("mock-anim", getStaticNullApplication()).addComponent('anim') as AnimComponent, + (component) => (component as AnimComponent).system.destroy() +) \ No newline at end of file diff --git a/packages/lib/src/components/Camera.tsx b/packages/lib/src/components/Camera.tsx index 7f6659d4..96312f9f 100644 --- a/packages/lib/src/components/Camera.tsx +++ b/packages/lib/src/components/Camera.tsx @@ -2,22 +2,38 @@ import { FC } from "react"; import { useComponent } from "../hooks"; -import { Color } from "playcanvas"; -import { useColors } from "../utils/color"; - -interface CameraProps { - [key: string]: unknown; - clearColor?: Color | string -} +import { CameraComponent, Entity } from "playcanvas"; +import { useColors, WithCssColors } from "../utils/color"; +import { PublicProps } from "../utils/types-utils"; +import { validateAndSanitizeProps, createComponentDefinition, ComponentDefinition, getStaticNullApplication } from "../utils/validation"; /** - * The Camera component is used to define the position and properties of a camera entity. + * The Camera component makes an entity behave like a camera and gives you a view into the scene. + * Moving the container entity will move the camera. + * + * @param {CameraProps} props - The props to pass to the camera component. + * + * @example + * + * + * */ export const Camera: FC = (props) => { - const colorProps = useColors(props, ['clearColor']) + const safeProps = validateAndSanitizeProps(props as Record, componentDefinition as ComponentDefinition); + + const colorProps = useColors(safeProps, ['clearColor']) - useComponent("camera", { ...props, ...colorProps }); + useComponent("camera", { ...safeProps, ...colorProps }); return null; -} \ No newline at end of file +} + +type CameraProps = Partial>>; + +const componentDefinition = createComponentDefinition( + "Camera", + () => new Entity("mock-camera", getStaticNullApplication()).addComponent('camera') as CameraComponent, + (component) => (component as CameraComponent).system.destroy(), + "CameraComponent" +) \ No newline at end of file diff --git a/packages/lib/src/components/Collision.tsx b/packages/lib/src/components/Collision.tsx index 0fd55c8a..c92349f9 100644 --- a/packages/lib/src/components/Collision.tsx +++ b/packages/lib/src/components/Collision.tsx @@ -3,31 +3,55 @@ import { FC, useEffect } from "react"; import { useComponent, useParent } from "../hooks"; import { usePhysics } from "../contexts/physics-context"; -import { warnOnce } from "../utils/warn-once"; - -type CollisionProps = { - [key: string]: unknown; - type?: string; -} +import { validateAndSanitizeProps, warnOnce, createComponentDefinition, ComponentDefinition, getStaticNullApplication } from "../utils/validation"; +import { CollisionComponent, Entity } from "playcanvas"; +import { PublicProps } from "../utils/types-utils"; +/** + * The Collision component adds a collider to the entity. This enables the entity to collide with other entities. + * You can manually define the shape of the collider with the `type` prop, or let the component infer the shape from a `Render` component. + * @param {CollisionProps} props - The props to pass to the collision component. + * @see https://api.playcanvas.com/engine/classes/CollisionComponent.html + * + * @example + * + * + * // will infer the shape from the render component + * + */ export const Collision: FC = (props) => { + const entity = useParent(); const { isPhysicsEnabled, isPhysicsLoaded, physicsError } = usePhysics(); + const safeProps = validateAndSanitizeProps(props, componentDefinition as ComponentDefinition); + useEffect(() => { if (!isPhysicsEnabled) { warnOnce( - 'The `` component requires `usePhysics` to be set on the Application. ' + - 'Please add `` to your root component.', - false // Show in both dev and prod + 'The `` component requires physics to be enabled.\n\n' + + 'To fix this:\n' + + '1. Add `usePhysics` prop to your root Application component:\n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n\n' + + '2. Make sure you have the required dependencies installed:\n' + + ' npm install sync-ammo\n\n' + + 'For more information, see: https://playcanvas-react.vercel.app/docs/physics' ); } if (physicsError) { warnOnce( - `Failed to initialize physics: ${physicsError.message}. ` + - "Run `npm install sync-ammo` in your project, if you haven't done so already.", - false // Show in both dev and prod + `Physics initialization failed: ${physicsError.message}\n\n` + + 'To fix this:\n' + + '1. Install the required dependency:\n' + + ' npm install sync-ammo\n\n' + + '2. Make sure your bundler is configured to handle WASM files\n\n' + + '3. Check that your server is configured to serve .wasm files with the correct MIME type\n\n' + + 'For more information, see: https://playcanvas-react.vercel.app/docs/physics#troubleshooting' ); } }, [isPhysicsEnabled, physicsError]); @@ -36,7 +60,33 @@ export const Collision: FC = (props) => { const type = entity.render && props.type === undefined ? entity.render.type : props.type; // Always call useComponent - it will handle component lifecycle internally - useComponent(isPhysicsLoaded ? "collision" : null, { ...props, type }); + useComponent(isPhysicsLoaded ? "collision" : null, { ...safeProps, type }); return null; +} + +interface CollisionProps extends Partial> { + type?: "box" + | "capsule" + | "compound" + | "cone" + | "cylinder" + | "mesh" + | "sphere" +} + +const componentDefinition = createComponentDefinition( + "Collision", + () => new Entity("mock-collision", getStaticNullApplication()).addComponent('collision') as CollisionComponent, + (component) => (component as CollisionComponent).system.destroy(), + "CollisionComponent" +) + +componentDefinition.schema = { + ...componentDefinition.schema, + type: { + validate: (value: unknown) => typeof value === 'string' && ['box', 'capsule', 'compound', 'cone', 'cylinder', 'mesh', 'sphere'].includes(value as string), + errorMsg: (value: unknown) => `Invalid value for prop "type": ${value}. Expected one of: "box", "capsule", "compound", "cone", "cylinder", "mesh", "sphere".`, + default: "box" + } } \ No newline at end of file diff --git a/packages/lib/src/components/GSplat.tsx b/packages/lib/src/components/GSplat.tsx index 4a9e4f70..9a4c7fd1 100644 --- a/packages/lib/src/components/GSplat.tsx +++ b/packages/lib/src/components/GSplat.tsx @@ -2,15 +2,23 @@ import { FC, useLayoutEffect, useRef } from "react"; import { useParent } from "../hooks"; -import { Asset, Entity } from "playcanvas"; +import { Asset, Entity, GSplatComponent } from "playcanvas"; +import { PublicProps } from "../utils/types-utils"; +import { Schema, validateAndSanitizeProps, createComponentDefinition, ComponentDefinition, getStaticNullApplication } from "../utils/validation"; -interface GSplatProps { - vertex?: string; - fragment?: string; - asset: Asset; -} +/** + * The GSplat component allows an entity to render a Gaussian Splat. + * @param {GSplatProps} props - The props to pass to the GSplat component. + * @see https://api.playcanvas.com/engine/classes/GSplatComponent.html + * @example + * const { data: splat } = useSplat('./splat.ply') + * + */ +export const GSplat: FC = (props) => { + + const safeProps = validateAndSanitizeProps(props, componentDefinition as ComponentDefinition); -export const GSplat: FC = ({ vertex, fragment, asset }) => { + const { asset, vertex, fragment } = safeProps; const parent: Entity = useParent(); const assetRef = useRef(null); @@ -27,4 +35,41 @@ export const GSplat: FC = ({ vertex, fragment, asset }) => { }, [asset]); return null; -}; \ No newline at end of file +}; + +interface GSplatProps extends Partial> { + /** + * The asset to use for the GSplat. + */ + asset: Asset; + /** + * The vertex shader to use for the GSplat. + */ + vertex?: string; + /** + * The fragment shader to use for the GSplat. + */ + fragment?: string; +} + +const componentDefinition = createComponentDefinition( + "GSplat", + () => new Entity("mock-gsplat", getStaticNullApplication()).addComponent('gsplat') as GSplatComponent, + (component) => (component as GSplatComponent).system.destroy(), + "GSplatComponent" +) + +// include additional props +componentDefinition.schema = { + ...componentDefinition.schema, + vertex: { + validate: (value: unknown) => typeof value === 'string', + errorMsg: (value: unknown) => `Vertex shader must be a string, received ${value}`, + default: null // Allows engine to handle the default shader + }, + fragment: { + validate: (value: unknown) => typeof value === 'string', + errorMsg: (value: unknown) => `Fragment shader must be a string, received ${value}`, + default: null // Allows engine to handle the default shader + } +} as Schema \ No newline at end of file diff --git a/packages/lib/src/components/Light.tsx b/packages/lib/src/components/Light.tsx index 27de0098..7e2dae64 100644 --- a/packages/lib/src/components/Light.tsx +++ b/packages/lib/src/components/Light.tsx @@ -1,16 +1,48 @@ import { FC } from "react"; import { useComponent } from "../hooks"; -import { useColors } from "../utils/color"; - -type LightProps = { - type: "directional" | "omni" | "spot"; -} +import { useColors, WithCssColors } from "../utils/color"; +import { Entity, LightComponent } from "playcanvas"; +import { PublicProps } from "../utils/types-utils"; +import { validateAndSanitizeProps, createComponentDefinition, ComponentDefinition, getStaticNullApplication, Schema } from "../utils/validation"; +/** + * The Light component adds a light source to the entity. A light can be a directional, omni, or spot light. + * Lights can be moved and orientated with the `position` and `rotation` props of its entity. + * + * @param {LightProps} props - The props to pass to the light component. + * @see https://api.playcanvas.com/engine/classes/LightComponent.html + * + * @example + * + * + * + */ export const Light: FC = (props) => { - const colorProps = useColors(props, ['color']) + const safeProps = validateAndSanitizeProps(props as Partial, componentDefinition as ComponentDefinition); + const colorProps = useColors(safeProps, ['color']) - useComponent("light", { ...props, ...colorProps }); + useComponent("light", { ...safeProps, ...colorProps }); return null - -} \ No newline at end of file + +} + +interface LightProps extends Partial>> { + type: "directional" | "omni" | "spot"; +} + +const componentDefinition = createComponentDefinition( + "Light", + () => new Entity('mock-light', getStaticNullApplication()).addComponent('light') as LightComponent, + (component) => (component as LightComponent).system.destroy(), + "LightComponent" +) + +componentDefinition.schema = { + ...componentDefinition.schema, + type: { + validate: (value: unknown) => typeof value === 'string' && ['directional', 'omni', 'spot'].includes(value as string), + errorMsg: (value: unknown) => `Invalid value for prop "type": ${value}. Expected one of: "directional", "omni", "spot".`, + default: "directional" + } +} as Schema \ No newline at end of file diff --git a/packages/lib/src/components/Render.tsx b/packages/lib/src/components/Render.tsx index 94b24913..ff96ac5e 100644 --- a/packages/lib/src/components/Render.tsx +++ b/packages/lib/src/components/Render.tsx @@ -3,34 +3,79 @@ import { FC } from "react"; import { useComponent } from "../hooks"; import { Container } from "../Container"; -import { Asset } from "playcanvas"; +import { Asset, Entity, type RenderComponent as PcRenderComponent } from "playcanvas"; +import { PublicProps } from "../utils/types-utils"; +import { ComponentProps } from "../hooks/use-component"; +import { getStaticNullApplication, validateAndSanitizeProps, ComponentDefinition } from "../utils/validation"; +import { createComponentDefinition } from "../utils/validation"; -interface RenderProps { - type: string; - asset?: Asset; - children?: React.ReactNode; - [key: string]: unknown; -} - -const RenderComponent: FC = (props) => { +const RenderComponent: FC = (props) => { useComponent("render", props); return null; } /** - * Create a render component on an entity. If the asset is a container, - * it will be rendered as a container. Otherwise, it will be rendered as a - * render component. + * A Render component allows an entity to render a 3D model. You can specify the type of model to render with the `type` prop, + * which can be a primitive shape, or a model asset. + * + * @param {RenderProps} props - The props to pass to the render component. + * @see https://api.playcanvas.com/engine/classes/RenderComponent.html + * + * @example + * const { data: asset } = useAsset('./statue.glb') + * + * + * + * + * @example + * + * + * */ -export const Render: FC = (props) => { +export const Render: FC = (props : RenderProps) => { + + const safeProps = validateAndSanitizeProps(props, componentDefinition as ComponentDefinition); // Render a container if the asset is a container - if (props.asset?.type === 'container') { - return - { props.children } + if (safeProps.asset?.type === 'container') { + return + { safeProps.children } } // Otherwise, render the component - return ; -} \ No newline at end of file + return ; +} + + +const primitiveTypes = ["asset", "box", "capsule", "cone", "cylinder", "plane", "sphere", "torus"] as const; +type PrimitiveType = typeof primitiveTypes[number]; + +interface RenderProps extends Omit>, 'asset'> { + type: PrimitiveType; + asset?: Asset; + children?: React.ReactNode; +} + +const componentDefinition = createComponentDefinition( + "Render", + () => new Entity('mock-render', getStaticNullApplication()).addComponent('render') as PcRenderComponent, + (component) => (component as PcRenderComponent).system.destroy(), + "RenderComponent" +) + +componentDefinition.schema = { + ...componentDefinition.schema, + asset: { + validate: (value: unknown) => value instanceof Asset, + errorMsg: (value: unknown) => `Invalid value for prop "asset": ${value}. Expected an Asset.`, + default: undefined + }, + type: { + validate: (value: unknown) => typeof value === 'string' && primitiveTypes.includes(value as PrimitiveType), + errorMsg: (value: unknown) => `Invalid value for prop "type": ${value}. Expected one of: "${primitiveTypes.join('", "')}".`, + default: "box" + } +} + +export default Render; diff --git a/packages/lib/src/components/RigidBody.tsx b/packages/lib/src/components/RigidBody.tsx index ab81dba1..97f5c24f 100644 --- a/packages/lib/src/components/RigidBody.tsx +++ b/packages/lib/src/components/RigidBody.tsx @@ -1,45 +1,89 @@ import { FC, useEffect } from "react"; import { useComponent, useParent } from "../hooks"; import { usePhysics } from "../contexts/physics-context"; -import { warnOnce } from "../utils/warn-once"; - -type RigidBodyProps = { - [key: string]: unknown; - type?: string; -} +import { createComponentDefinition, getStaticNullApplication, warnOnce, ComponentDefinition, validateAndSanitizeProps } from "../utils/validation"; +import { PublicProps } from "../utils/types-utils"; +import { Entity, RigidBodyComponent } from "playcanvas"; +/** + * Adding a RigidBody component to an entity allows it to participate in the physics simulation. + * Rigid bodies have mass, and can be moved around by forces. Ensure `usePhysics` is set on the Application + * to use this component. + * + * @param {RigidBodyProps} props - The props to pass to the rigid body component. + * @see https://api.playcanvas.com/engine/classes/RigidBodyComponent.html + * @example + * + * + * + */ export const RigidBody: FC = (props) => { const entity = useParent(); const { isPhysicsEnabled, isPhysicsLoaded, physicsError } = usePhysics(); + const safeProps = validateAndSanitizeProps(props, componentDefinition as ComponentDefinition); + useEffect(() => { if (!isPhysicsEnabled) { warnOnce( - 'The `` component requires `usePhysics` to be set on the Application. ' + - 'Please add `` to your root component.', - false // Show in both dev and prod + 'The `` component requires physics to be enabled.\n\n' + + 'To fix this:\n' + + '1. Add `usePhysics` prop to your root Application component:\n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n\n' + + '2. Make sure you have the required dependencies installed:\n' + + ' npm install sync-ammo\n\n' + + 'For more information, see: https://playcanvas-react.vercel.app/docs/physics' ); } if (physicsError) { warnOnce( - `Failed to initialize physics: ${physicsError.message}. ` + - "Run `npm install sync-ammo` in your project, if you haven't done so already.", - false // Show in both dev and prod + `Physics initialization failed: ${physicsError.message}\n\n` + + 'To fix this:\n' + + '1. Install the required dependency:\n' + + ' npm install sync-ammo\n\n' + + '2. Make sure your bundler is configured to handle WASM files\n\n' + + '3. Check that your server is configured to serve .wasm files with the correct MIME type\n\n' + + 'For more information, see: https://playcanvas-react.vercel.app/docs/physics#troubleshooting' ); } }, [isPhysicsEnabled, physicsError]); - // @ts-expect-error Ammo is defined in the global scope in the browser - if(isPhysicsLoaded && !globalThis.Ammo ) { - throw new Error('The `` component requires `usePhysics` to be set on the Application. `` ') - } - // If no type is defined, infer if possible from a render component const type = entity.render && props.type === undefined ? entity.render.type : props.type; // Always call useComponent - it will handle component lifecycle internally - useComponent(isPhysicsLoaded ? "rigidbody" : null, { ...props, type }); + useComponent(isPhysicsLoaded ? "rigidbody" : null, { ...safeProps, type }); return null; +} + +interface RigidBodyProps extends Partial> { + type?: "box" + | "capsule" + | "compound" + | "cone" + | "cylinder" + | "mesh" + | "sphere" +} + +const componentDefinition = createComponentDefinition( + "RigidBody", + () => new Entity("mock-rigidbody", getStaticNullApplication()).addComponent('rigidbody') as RigidBodyComponent, + (component) => (component as RigidBodyComponent).system.destroy(), + "RigidBodyComponent" +) + +componentDefinition.schema = { + ...componentDefinition.schema, + type: { + validate: (value: unknown) => typeof value === 'string' && ['box', 'capsule', 'compound', 'cone', 'cylinder', 'mesh', 'sphere'].includes(value as string), + errorMsg: (value: unknown) => `Invalid value for prop "type": ${value}. Expected one of: "box", "capsule", "compound", "cone", "cylinder", "mesh", "sphere".`, + default: "box" + } } \ No newline at end of file diff --git a/packages/lib/src/components/Script.tsx b/packages/lib/src/components/Script.tsx index f29f7716..83a20f23 100644 --- a/packages/lib/src/components/Script.tsx +++ b/packages/lib/src/components/Script.tsx @@ -2,29 +2,57 @@ import { AppBase, Entity, Script as PcScript } from "playcanvas"; import { useScript } from "../hooks" import { FC, memo, useMemo } from "react"; import { shallowEquals } from "../utils/shallow-equals"; +import { ComponentDefinition, validateAndSanitizeProps } from "../utils/validation"; -// type PcScriptWithoutPrivateName = Omit & { -// __name: string; -// }; -// type PcScriptWithoutPrivateName = { -// new (args: { app: AppBase; entity: Entity; }): PcScript -// __name: string; -// }; +/** + * The Script component allows you to hook into the entity's lifecycle. This allows you to + * run code during the frame update loop, or when the entity is created or destroyed. + * Use this for high-performance code that needs to run on every frame. + * + * @param {ScriptProps} props - The props to pass to the script component. + * @see https://api.playcanvas.com/engine/classes/Script.html + * @example + * // A Rotator script that rotates the entity around the Y axis + * class Rotator extends Script { + * update(dt: number) { + * this.entity.rotate(0, 1, 0, dt); + * } + * } + *