diff --git a/providers/claude/plugin/skills/needle-engine/SKILL.md b/providers/claude/plugin/skills/needle-engine/SKILL.md index 7f201f9..b7f1beb 100644 --- a/providers/claude/plugin/skills/needle-engine/SKILL.md +++ b/providers/claude/plugin/skills/needle-engine/SKILL.md @@ -1,15 +1,72 @@ --- name: needle-engine description: Automatically provides Needle Engine context when working in a Needle Engine web project. Use this skill when editing TypeScript components, Vite config, GLB assets, or anything related to @needle-tools/engine. +reviewed-against: "@needle-tools/engine@4.15.0" +last-reviewed: "2026-03" +allowed-tools: + - needle_search + - Read + - Write + - Edit + - Bash --- # Needle Engine You are an expert in Needle Engine — a web-first 3D engine built on Three.js with a Unity/Blender-based workflow. -## Key concepts +## When to Use This Skill -**Needle Engine** ships 3D scenes from Unity (or Blender) as GLB files and renders them in the browser using Three.js. TypeScript components attached to GameObjects in Unity are serialized into the GLB and re-hydrated at runtime in the browser. +**Use when the user is:** +- Editing TypeScript files that import from `@needle-tools/engine` +- Working on a project with `vite.config.ts` that uses `needlePlugins` +- Loading or debugging `.glb` files exported from Unity or Blender +- Using the Needle Engine Blender addon +- Asking about component lifecycle, serialization, XR, networking, or deployment + +**Do NOT use for:** +- Pure Three.js projects with no Needle Engine +- Non-web Unity/Blender work with no GLB export + +--- + +## Quick Start + +```html + + + +``` + +Minimal TypeScript component: +```ts +import { Behaviour, serializable, registerType } from "@needle-tools/engine"; + +@registerType +export class HelloWorld extends Behaviour { + @serializable() message: string = "Hello!"; + + start() { + console.log(this.message); + } +} +``` + +> ⚠️ **TypeScript config required:** `tsconfig.json` must have `"experimentalDecorators": true` and `"useDefineForClassFields": false` for decorators to work. + +--- + +## Key Concepts + +**Needle Engine** ships 3D scenes from Unity or Blender as GLB files and renders them in the browser using Three.js. TypeScript components attached to objects are serialized into the GLB and re-hydrated at runtime in the browser. + +### Unity workflow +Components are C# MonoBehaviours in Unity. The Needle Unity package auto-generates TypeScript stubs and exports the scene as GLB on play/build. + +### Blender workflow +Components are added via the **Needle Engine Blender addon** — custom properties panel in Blender. The addon exports the scene as GLB with Needle component data embedded. TypeScript component files live in your web project alongside the GLB, exactly like with Unity. ### Embedding in HTML ```html @@ -18,7 +75,12 @@ You are an expert in Needle Engine — a web-first 3D engine built on Three.js w ``` Access the context programmatically: `document.querySelector("needle-engine").context` -### Component lifecycle (mirrors Unity MonoBehaviour) +--- + +## Component Lifecycle + +Mirrors Unity MonoBehaviour exactly: + ```ts import { Behaviour, serializable, registerType } from "@needle-tools/engine"; @@ -26,46 +88,145 @@ import { Behaviour, serializable, registerType } from "@needle-tools/engine"; export class MyComponent extends Behaviour { @serializable() myValue: number = 1; - awake() {} // called once when instantiated - start() {} // called once on first frame - update() {} // called every frame - onEnable() {} - onDisable() {} - onDestroy() {} + awake() {} // once on instantiate (before Start) + start() {} // once on first frame + update() {} // every frame + lateUpdate() {} // every frame, after all update() calls + fixedUpdate() {} // fixed timestep (physics) + onEnable() {} // when component/GO becomes active + onDisable() {} // when component/GO becomes inactive + onDestroy() {} // on removal onBeforeRender(_frame: XRFrame | null) {} } ``` -### Serialization +### Physics Callbacks (requires Rapier collider on GameObject) + +Add inside your component class: +```ts +onCollisionEnter(col: Collision) {} +onCollisionStay(col: Collision) {} +onCollisionExit(col: Collision) {} +onTriggerEnter(col: Collision) {} +onTriggerStay(col: Collision) {} +onTriggerExit(col: Collision) {} +``` + +### Coroutines + +Add inside your component class: +```ts +start() { + this.startCoroutine(this.myRoutine()); +} + +*myRoutine() { + console.log("Start"); + yield; // wait one frame + // yield WaitForSeconds(2); // wait N seconds + console.log("One frame later"); +} +``` + +--- + +## Serialization + - `@registerType` — makes the class discoverable by the GLB deserializer - `@serializable()` — marks a field for GLB deserialization (primitives) - `@serializable(Object3D)` — for Three.js object references - `@serializable(Texture)` — for textures (import Texture from "three") - `@serializable(RGBAColor)` — for colors +- `@serializable(AssetReference)` — for lazily-loaded GLB assets + +--- + +## Accessing the Scene -### Accessing the scene ```ts this.context.scene // THREE.Scene this.context.mainCamera // active camera (THREE.Camera) this.context.renderer // THREE.WebGLRenderer -this.context.time.frame // current frame number +this.context.time.frame // current frame number this.context.time.deltaTime // seconds since last frame -this.gameObject // the THREE.Object3D this component is on +this.gameObject // the THREE.Object3D this component is on ``` -### Finding components +--- + +## Finding Components + ```ts this.gameObject.getComponent(MyComponent) this.gameObject.getComponentInChildren(MyComponent) this.context.scene.getComponentInChildren(MyComponent) -// Global search (import as standalone functions from "@needle-tools/engine") +// Global search import { findObjectOfType, findObjectsOfType } from "@needle-tools/engine"; findObjectOfType(MyComponent, this.context) findObjectsOfType(MyComponent, this.context) ``` -### Input handling +--- + +## Instantiate & Destroy + +```ts +import { GameObject } from "@needle-tools/engine"; + +// Clone an object (like Unity Instantiate) +const clone = GameObject.instantiate(this.gameObject); +// With position/rotation: +const clone2 = GameObject.instantiate(prefab, { position: new Vector3(1,0,0) }); + +// Destroy +GameObject.destroy(clone); // removes object + calls onDestroy on all components +this.gameObject.removeComponent(comp); // removes component (does NOT call onDestroy) +``` + +--- + +## Animation + +```ts +import { Animator } from "@needle-tools/engine"; + +const animator = this.gameObject.getComponent(Animator); +animator?.play("Run"); // play by state name +animator?.setFloat("Speed", 1.5); // Animator parameters +animator?.setBool("IsJumping", true); +animator?.setTrigger("Jump"); +``` + +--- + +## Loading Assets at Runtime + +```ts +import { AssetReference } from "@needle-tools/engine"; + +@registerType +export class LazyLoader extends Behaviour { + @serializable(AssetReference) sceneRef!: AssetReference; + + async loadIt() { + const instance = await this.sceneRef.instantiate({ parent: this.gameObject }); + } +} +``` + +Or load a GLB by URL at runtime: +```ts +import { AssetReference } from "@needle-tools/engine"; + +const ref = AssetReference.getOrCreate(this.context.domElement.baseURI, "assets/extra.glb"); +const instance = await ref.instantiate({ parent: this.gameObject }); +``` + +--- + +## Input Handling + ```ts // Polling if (this.context.input.getPointerDown(0)) { /* pointer pressed */ } @@ -75,17 +236,33 @@ if (this.context.input.getKeyDown("Space")) { /* space pressed */ } this.gameObject.addEventListener("pointerdown", (e: NEPointerEvent) => { }); ``` -### Physics & raycasting +### Custom Events Between Components +```ts +// Dispatch +this.gameObject.dispatchEvent(new CustomEvent("myEvent", { detail: { score: 42 } })); + +// Listen from another component +other.gameObject.addEventListener("myEvent", (e: CustomEvent) => { + console.log(e.detail.score); +}); +``` + +--- + +## Physics & Raycasting + ```ts // Default raycasts hit visible geometry — no colliders needed -// Uses mesh BVH (bounding volume hierarchy) for accelerated raycasting, BVH is generated on a worker const hits = this.context.physics.raycast(); // Physics-based raycasts (require colliders, uses Rapier physics engine) const physicsHits = this.context.physics.raycastPhysics(); ``` -### Networking & multiplayer +--- + +## Networking & Multiplayer + Needle Engine has built-in multiplayer. Add a `SyncedRoom` component to enable networking. - `@syncField()` — automatically syncs a field across all connected clients @@ -94,29 +271,118 @@ Needle Engine has built-in multiplayer. Add a `SyncedRoom` component to enable n - Key components: `SyncedRoom`, `SyncedTransform`, `PlayerSync`, `Voip` - Uses WebSockets + optional WebRTC peer-to-peer connections -### WebXR (VR & AR) +--- + +## WebXR (VR & AR) + Needle Engine has built-in WebXR support for VR and AR across Meta Quest, Apple Vision Pro, and mobile AR. - Add the `WebXR` component to enable VR/AR sessions - Use `XRRig` to define the user's starting position — the user is parented to the rig during XR sessions - Available components: `WebXRImageTracking`, `WebXRPlaneTracking`, `XRControllerModel`, `NeedleXRSession` -## Creating a new project +--- -Use `create-needle` to scaffold a new Needle Engine project: -```bash -npm create needle my-app # default Vite template -npm create needle my-app -t react # React template -npm create needle my-app -t vue # Vue.js template +## Unity → Needle Cheat Sheet + +| Unity (C#) | Needle Engine (TypeScript) | +|---|---| +| `MonoBehaviour` | `Behaviour` | +| `[SerializeField]` | `@serializable()` | +| `[RequireComponent]` | `@requireComponent(Type)` | +| `Instantiate(prefab)` | `GameObject.instantiate(obj)` | +| `Destroy(obj)` | `GameObject.destroy(obj)` | +| `GetComponent()` | `this.gameObject.getComponent(T)` | +| `FindObjectOfType()` | `findObjectOfType(T, ctx)` | +| `transform.position` | `this.gameObject.position` | +| `transform.rotation` | `this.gameObject.quaternion` | +| `Resources.Load()` | `@serializable(AssetReference)` | +| `StartCoroutine()` | `this.startCoroutine()` | +| `Time.deltaTime` | `this.context.time.deltaTime` | +| `Camera.main` | `this.context.mainCamera` | +| `Debug.Log()` | `console.log()` | +| `OnCollisionEnter()` | `onCollisionEnter(col: Collision)` | +| `OnTriggerEnter()` | `onTriggerEnter(col: Collision)` | + +--- + +## Engine Hooks + +Standalone lifecycle hooks — use these outside of a component class (e.g. in framework setup code): + +```ts +import { onStart, onUpdate, onBeforeRender, onDestroy } from "@needle-tools/engine"; + +onStart((ctx) => { + // runs once when the scene is ready + console.log("Scene ready", ctx.scene); +}); + +onUpdate((ctx) => { + // runs every frame + const dt = ctx.time.deltaTime; +}); + +onBeforeRender((ctx) => { + // runs just before Three.js renders each frame +}); + +onDestroy((ctx) => { + // runs when the context is torn down +}); +``` + +--- + +## Frontend UI ↔ 3D Scene Communication + +Needle Engine is a standard web component — any JavaScript on the page can talk to it. + +**JS → 3D:** get a component and call methods on it directly +```js +const ctx = document.querySelector("needle-engine").context; +const gm = ctx.scene.getComponentInChildren(GameManager); +gm?.addScore(10); +``` + +**3D → JS:** dispatch a custom DOM event from inside a component +```ts +// In your component: +this.context.domElement.dispatchEvent( + new CustomEvent("score-changed", { bubbles: true, detail: { score: 42 } }) +); +``` +```js +// Anywhere on the page (React, Svelte, Vue, vanilla): +document.querySelector("needle-engine") + .addEventListener("score-changed", (e) => console.log(e.detail.score)); ``` -Available templates: `vite` (default), `react`, `vue`, `sveltekit`, `svelte`, `nextjs`, `react-three-fiber`. +See [references/integration.md](references/integration.md) for full React, Svelte, and Vue examples. + +--- + +## Creating a New Project + +```bash +npm create needle my-app # Vite (default) +npm create needle my-app -t react # React + Vite +npm create needle my-app -t vue # Vue + Vite +npm create needle my-app -t sveltekit # SvelteKit +npm create needle my-app -t nextjs # Next.js +npm create needle my-app -t react-three-fiber # R3F +``` -Use `npm create needle --list` to see all available templates. +**Starter templates on GitHub:** +- [Vite](https://github.com/needle-tools/needle-engine-support/tree/main/packages/create/templates/vite) +- [React](https://github.com/needle-tools/needle-engine-support/tree/main/packages/create/templates/react) +- [Vue](https://github.com/needle-tools/needle-engine-support/tree/main/packages/create/templates/vue) +- [SvelteKit](https://github.com/needle-tools/needle-engine-support/tree/main/packages/create/templates/sveltekit) +- [Next.js](https://github.com/needle-tools/needle-engine-support/tree/main/packages/create/templates/nextjs) -## Vite plugin system +--- -Needle Engine ships a set of Vite plugins via `needlePlugins(command, config, userSettings)`. Custom project plugins go in `vite.config.ts`. +## Vite Plugin System ```ts import { defineConfig } from "vite"; @@ -129,47 +395,66 @@ export default defineConfig(async ({ command }) => ({ })); ``` +--- + ## Deployment -Projects can be deployed to: -- **Needle Cloud** — official hosting with automatic optimization (`npx needle-cloud deploy`) -- **Vercel** / **Netlify** — standard web hosting -- **itch.io** — for games and interactive experiences -- **Any static host** — Needle Engine projects are standard Vite web apps +- **Needle Cloud** — `npx needle-cloud deploy` +- **Vercel / Netlify** — standard Vite web app +- **itch.io** — for games +- **Any static host** — `npm run build` produces a standard dist folder From Unity, use built-in deployment components (e.g. `DeployToNeedleCloud`, `DeployToNetlify`). -## Progressive loading (`@needle-tools/gltf-progressive`) +--- -Needle Engine includes `@needle-tools/gltf-progressive` for progressive streaming of 3D models and textures. It creates a tiny initial file with embedded low-quality proxy geometry, then streams higher-quality LODs on demand. Results in ~90% smaller initial downloads with instant display. +## Progressive Loading (`@needle-tools/gltf-progressive`) -Works standalone with any three.js project: ```ts -import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js"; -import { WebGLRenderer } from "three"; import { useNeedleProgressive } from "@needle-tools/gltf-progressive"; +useNeedleProgressive(gltfLoader, renderer); +gltfLoader.load(url, (gltf) => scene.add(gltf.scene)); +``` -const gltfLoader = new GLTFLoader(); -const renderer = new WebGLRenderer(); +In Needle Engine projects this is built in — configure via **Compression & LOD Settings** in Unity. -// Register once — progressive loading happens automatically for all subsequent loads -useNeedleProgressive(gltfLoader, renderer); +--- + +## Searching the Documentation + +Use the `needle_search` MCP tool to find relevant docs, forum posts, and community answers: -gltfLoader.load(url, (gltf) => scene.add(gltf.scene)); ``` +needle_search("how to play animation clip from code") +needle_search("SyncedTransform multiplayer") +needle_search("deploy to Needle Cloud CI") +``` + +Use this *before* guessing at API details — the docs are the source of truth. + +--- -In Needle Engine projects, progressive loading is built in and can be configured via the **Compression & LOD Settings** component in Unity. +## Common Gotchas + +- `@registerType` is required or the component won't be instantiated from GLB (Unity/Blender export adds this automatically, but hand-written components need it) +- GLB assets go in `assets/`, static files (fonts, images) in `public/` +- `useDefineForClassFields: false` must be set in `tsconfig.json` — otherwise decorators silently break field initialization +- `@syncField()` only triggers on reassignment — mutating an array/object in place won't sync; do `this.arr = this.arr` +- Physics callbacks (`onCollisionEnter` etc.) require a Rapier `Collider` component on the GameObject +- `removeComponent()` does NOT call `onDestroy` — use `GameObject.destroy()` for full cleanup + +--- + +## References + +- 📖 [Full API Reference](references/api.md) — complete lifecycle, decorators, and context API +- 🔗 [Framework Integration](references/integration.md) — React, Svelte, Vue, vanilla JS examples + hook reference +- 🐛 [Troubleshooting](references/troubleshooting.md) — common errors and fixes +- 🧩 [Component Template](templates/my-component.ts) — annotated starter component ## Important URLs + - Docs: https://engine.needle.tools/docs/ - Samples: https://engine.needle.tools/samples/ - GitHub: https://github.com/needle-tools/needle-engine-support - npm: https://www.npmjs.com/package/@needle-tools/engine - -## Searching the documentation - -Use the `needle_search` MCP tool to find relevant docs, forum posts, and community answers. - -## Common gotchas -- Components must use `@registerType` or they won't be instantiated from GLB (this is handled automatically when exporting from Unity or Blender, but must be added manually for hand-written components) -- GLB assets are in `assets/`, static files in `include/` or `public/` diff --git a/providers/claude/plugin/skills/needle-engine/references/api.md b/providers/claude/plugin/skills/needle-engine/references/api.md new file mode 100644 index 0000000..7f4bf73 --- /dev/null +++ b/providers/claude/plugin/skills/needle-engine/references/api.md @@ -0,0 +1,239 @@ +# Needle Engine — Full API Reference + +## Lifecycle Methods (complete) + +All methods are optional — only implement what you need. + +```ts +class MyComponent extends Behaviour { + // Initialization + awake() // first, before Start, even if disabled + onEnable() // whenever component/GO becomes active + start() // once, on first enabled frame + + // Per-frame + update() // every frame + lateUpdate() // every frame, after all update() runs + fixedUpdate() // fixed timestep (default 50Hz, used for physics) + onBeforeRender(frame: XRFrame | null) // just before Three.js renders + + // Deactivation / cleanup + onDisable() // when component/GO becomes inactive + onDestroy() // called by GameObject.destroy() — NOT by removeComponent() + + // Physics (requires Rapier Collider component on same GameObject) + onCollisionEnter(col: Collision) + onCollisionStay(col: Collision) + onCollisionExit(col: Collision) + onTriggerEnter(col: Collision) + onTriggerStay(col: Collision) + onTriggerExit(col: Collision) +} +``` + +--- + +## Decorators + +| Decorator | Purpose | +|---|---| +| `@registerType` | Required on every component — registers the class for GLB deserialization | +| `@serializable()` | Serialize/deserialize a primitive (number, string, boolean) | +| `@serializable(Type)` | Serialize/deserialize a typed field (Object3D, Texture, Color, etc.) | +| `@syncField()` | Auto-sync field over the network in a SyncedRoom | +| `@syncField(onChange)` | Sync + call a callback when value changes remotely | +| `@requireComponent(Type)` | Ensure a component of Type exists on the same GO | + +### Serializable Types (import from correct source) + +```ts +import { RGBAColor, AssetReference } from "@needle-tools/engine"; +import { Object3D, Texture, Vector2, Vector3, Color } from "three"; + +@serializable(Object3D) myRef!: Object3D; +@serializable(Texture) tex!: Texture; +@serializable(RGBAColor) col!: RGBAColor; +@serializable(AssetReference) asset!: AssetReference; +@serializable(Vector3) pos!: Vector3; +``` + +--- + +## Context API (`this.context`) + +```ts +this.context.scene // THREE.Scene +this.context.mainCamera // THREE.Camera (currently active) +this.context.renderer // THREE.WebGLRenderer +this.context.domElement // HTML element + +// Time +this.context.time.frame // frame counter (number) +this.context.time.deltaTime // seconds since last frame +this.context.time.time // total elapsed seconds +this.context.time.realtimeSinceStartup + +// Input +this.context.input.getPointerDown(index) // pointer just pressed +this.context.input.getPointerUp(index) // pointer just released +this.context.input.getPointerPressed(index) // pointer held +this.context.input.getPointerPosition(index) // {x, y} in screen pixels +this.context.input.getKeyDown(key) // "Space", "ArrowLeft", "a", etc. +this.context.input.getKeyUp(key) +this.context.input.getKeyPressed(key) + +// Physics +this.context.physics.raycast() // hits visible geometry (no collider needed) +this.context.physics.raycastPhysics() // hits Rapier colliders only +this.context.physics.engine // access Rapier world directly + +// Network +this.context.connection // active network connection (if SyncedRoom present) +``` + +--- + +## GameObject Utilities + +```ts +import { GameObject } from "@needle-tools/engine"; + +// Component access +go.getComponent(Type) +go.getComponentInChildren(Type) +go.getComponentInParent(Type) +go.getComponents(Type) // all matching on same GO +go.getComponentsInChildren(Type) + +// Lifecycle +GameObject.instantiate(source, opts?) // clone; opts: { position, rotation, parent } +GameObject.destroy(obj) // destroys GO + calls onDestroy on components +obj.removeComponent(comp) // removes without calling onDestroy + +// Active state +go.visible = false // hides in scene (still ticks) +GameObject.setActive(go, false) // disables lifecycle callbacks + +// Tag / name +go.name // string +go.userData.tags // string[] (set from Unity via Tag component) +``` + +--- + +## Finding Objects + +```ts +import { findObjectOfType, findObjectsOfType } from "@needle-tools/engine"; + +findObjectOfType(MyComponent, ctx) // first match in scene +findObjectsOfType(MyComponent, ctx) // all matches +ctx.scene.getObjectByName("Player") // by name (Three.js built-in) +``` + +--- + +## Coroutines + +Generator functions that can yield across frames: + +```ts +import { WaitForSeconds } from "@needle-tools/engine"; + +start() { + this.startCoroutine(this.flashLight()); +} + +*flashLight() { + while (true) { + this.light.visible = !this.light.visible; + yield WaitForSeconds(0.5); // wait 0.5 seconds + // yield; // wait exactly one frame + } +} + +// Stop all coroutines on this component: +this.stopAllCoroutines(); +``` + +--- + +## Animation + +```ts +import { Animator } from "@needle-tools/engine"; + +const anim = this.gameObject.getComponent(Animator); + +anim.play("Run"); // play by state name +anim.setFloat("Speed", 1.5); // Animator parameters (match Unity parameter names) +anim.setBool("IsGrounded", true); +anim.setTrigger("Jump"); +anim.speed = 0.5; // global playback speed multiplier +``` + +--- + +## Asset Loading at Runtime + +```ts +import { AssetReference } from "@needle-tools/engine"; + +// Declare in component (set in Unity Inspector) +@serializable(AssetReference) prefab!: AssetReference; + +async start() { + // Load and instantiate + const instance = await this.prefab.instantiate({ parent: this.gameObject }); + + // Or just load the GLTF (no instantiate) + const gltf = await this.prefab.loadAssetAsync(); +} +``` + +Load a GLB by URL at runtime: +```ts +import { AssetReference } from "@needle-tools/engine"; + +const ref = AssetReference.getOrCreate(this.context.domElement.baseURI, "assets/extra.glb"); +const instance = await ref.instantiate({ parent: this.gameObject }); +``` + +--- + +## Networking + +```ts +import { SyncedRoom, SyncedTransform } from "@needle-tools/engine"; + +// In a component with a SyncedRoom in the scene: +@syncField() score: number = 0; // auto-syncs on change + +// Complex type — must reassign to trigger sync: +@syncField() items: string[] = []; +this.items.push("sword"); +this.items = this.items; // ← triggers sync + +// Low-level RPC: +this.context.connection.send("my-event", { data: 42 }); +this.context.connection.beginListen("my-event", (msg) => { console.log(msg.data); }); +``` + +--- + +## WebXR + +```ts +import { WebXR, XRRig, NeedleXRSession } from "@needle-tools/engine"; + +// In code: check XR state +if (this.context.xr?.isInXR) { ... } +this.context.xr?.session // XRSession + +// XR controller input +onBeforeRender(frame: XRFrame | null) { + if (!frame) return; + const controllers = this.context.xr?.controllers; + // controllers[0].gamepad, controllers[0].raycastHit, etc. +} +``` diff --git a/providers/claude/plugin/skills/needle-engine/references/integration.md b/providers/claude/plugin/skills/needle-engine/references/integration.md new file mode 100644 index 0000000..54aeff6 --- /dev/null +++ b/providers/claude/plugin/skills/needle-engine/references/integration.md @@ -0,0 +1,144 @@ +# Needle Engine — Framework Integration + +## React + +### Listen for 3D events in a component +```tsx +import { useEffect, useState } from "react"; + +function ScoreDisplay() { + const [score, setScore] = useState(0); + + useEffect(() => { + const ne = document.querySelector("needle-engine"); + const handler = (e: CustomEvent) => setScore(e.detail.score); + ne?.addEventListener("score-changed", handler as EventListener); + return () => ne?.removeEventListener("score-changed", handler as EventListener); + }, []); + + return
Score: {score}
; +} +``` + +### Call into the 3D scene from React +```tsx +function GameControls() { + const addScore = () => { + const ctx = (document.querySelector("needle-engine") as any)?.context; + ctx?.scene.getComponentInChildren(GameManager)?.addScore(10); + }; + return ; +} +``` + +### Wait for scene ready before accessing components +```tsx +useEffect(() => { + const ne = document.querySelector("needle-engine"); + const onReady = () => { + const ctx = (ne as any).context; + // safe to access components here + }; + ne?.addEventListener("loadingfinished", onReady); + return () => ne?.removeEventListener("loadingfinished", onReady); +}, []); +``` + +--- + +## Svelte / SvelteKit + +```svelte + + +

Score: {score}

+ + +``` + +--- + +## Vue + +```vue + + + +``` + +--- + +## Vanilla JS / No Framework + +```html + + + +``` + +--- + +## Engine Hooks Reference + +These standalone functions from `@needle-tools/engine` mirror the component lifecycle but work outside of a class: + +| Hook | When it fires | +|---|---| +| `onStart(cb)` | Once when the context/scene is ready | +| `onUpdate(cb)` | Every frame (before rendering) | +| `onBeforeRender(cb)` | Just before Three.js renders | +| `onDestroy(cb)` | When the context is torn down | + +All callbacks receive `(ctx: Context)` as their argument. + +```ts +import { onStart, onUpdate, onBeforeRender, onDestroy } from "@needle-tools/engine"; + +onStart((ctx) => { /* setup */ }); +onUpdate((ctx) => { /* per-frame logic */ }); +onDestroy((ctx) => { /* cleanup */ }); +``` diff --git a/providers/claude/plugin/skills/needle-engine/references/troubleshooting.md b/providers/claude/plugin/skills/needle-engine/references/troubleshooting.md new file mode 100644 index 0000000..341ef09 --- /dev/null +++ b/providers/claude/plugin/skills/needle-engine/references/troubleshooting.md @@ -0,0 +1,163 @@ +# Needle Engine — Troubleshooting + +## Component Not Instantiated from GLB + +**Symptom:** Component exists in Unity/Blender scene but `getComponent(MyComponent)` returns null at runtime. + +**Causes & fixes:** +1. **Missing `@registerType`** — Every component class must have `@registerType` above the class declaration. Without it the GLB deserializer can't match the class name to the serialized data. +2. **Class not imported** — The file containing the class must be imported somewhere in your entry point (`main.ts`). Tree-shaking can eliminate unreferenced classes. +3. **Name mismatch** — The C# class name in Unity must exactly match the TypeScript class name. Check for typos. +4. **Wrong namespace** — If the Unity C# class is in a namespace, the TypeScript class must match (or the codegen mapping must be set up). + +```ts +// ✅ Correct +@registerType +export class MyComponent extends Behaviour { ... } + +// ❌ Wrong — missing @registerType +export class MyComponent extends Behaviour { ... } +``` + +--- + +## Decorators Not Working / Fields Always Undefined + +**Symptom:** `@serializable` fields are always their default TypeScript values; deserialized values never appear. + +**Fix:** Check `tsconfig.json`: +```json +{ + "compilerOptions": { + "experimentalDecorators": true, + "useDefineForClassFields": false // ← CRITICAL — must be false + } +} +``` + +`useDefineForClassFields: true` (the TS5+ default) causes class field initializers to run *after* decorators, overwriting deserialized values. + +--- + +## GLB Not Loading / Scene Is Empty + +**Checklist:** +1. Is the `src` path on `` correct? Paths are relative to the HTML file. +2. Is the file in `assets/` (not `src/` or `public/`)? Static assets belong in `assets/` for Vite to copy them. +3. Check browser console for 404 errors on the GLB request. +4. If the file exists but scene is empty: check if the root object is active in Unity before export. +5. CORS issues when loading from a different origin — serve from the same host or configure CORS headers. + +--- + +## `@syncField` Not Syncing + +**Symptom:** Field changes locally but other clients don't see updates. + +**Causes:** +1. **No `SyncedRoom`** in the scene — networking requires a `SyncedRoom` component. +2. **Mutating array/object in place** — `this.arr.push(x)` does NOT trigger sync. You must reassign: `this.arr = [...this.arr, x]` or `this.arr = this.arr`. +3. **Missing `@registerType`** on the component — sync relies on class registration. +4. **Not connected** — check `this.context.connection.isConnected`. + +--- + +## Physics Callbacks Never Fire + +**Symptom:** `onCollisionEnter`, `onTriggerEnter`, etc. never called. + +**Requirements:** +- Rapier physics must be active — add a `Rigidbody` or `Collider` component in Unity on both objects +- The GameObject must have a `Collider` component (Box, Sphere, Mesh, etc.) +- For trigger events, the collider must be set to **Is Trigger** in Unity +- Both objects need Rapier colliders — mesh-only objects don't participate in physics events + +--- + +## `onDestroy` Not Called When Removing Component + +**By design:** `this.gameObject.removeComponent(comp)` removes the component from update loops but does **not** call `onDestroy`. This is consistent with Unity's `DestroyImmediate` on a component (vs the object). + +**Fix:** Use `GameObject.destroy(go)` to fully clean up an object and all its components. If you need cleanup on component removal specifically, call `onDestroy()` manually before `removeComponent()`. + +--- + +## Animation Not Playing + +**Checklist:** +1. `Animator` component must be on the same or parent GameObject +2. State name must match exactly what's in the Unity AnimatorController +3. Check that `animator.runtimeAnimatorController` is set (not null) +4. If calling `play()` in `awake()`, try `start()` instead — the animator may not be initialized yet + +--- + +## Vite Build Fails with Decorator Errors + +Typical error: `Experimental support for decorators is a feature that is subject to change` + +**Fix:** Ensure `tsconfig.json` has: +```json +"experimentalDecorators": true +``` + +And verify that `vite.config.ts` uses the Needle plugins (they configure esbuild/swc for decorator support automatically): +```ts +import { needlePlugins } from "@needle-tools/engine/vite"; +``` + +--- + +## TypeScript Errors on `this.context` or `this.gameObject` + +**Symptom:** TS error: Property 'context' does not exist on type 'MyComponent' + +**Fix:** Make sure you extend `Behaviour` (not `Component` or a plain class): +```ts +import { Behaviour } from "@needle-tools/engine"; +export class MyComponent extends Behaviour { ... } +``` + +--- + +## XR Session Doesn't Start + +**Checklist:** +1. Must be served over **HTTPS** (or localhost) — WebXR is blocked on plain HTTP +2. `WebXR` component must be in the scene (added in Unity or created in TS) +3. Device must support WebXR — test with [WebXR Emulator](https://chrome.google.com/webstore/detail/webxr-api-emulator) in Chrome +4. Check browser console for XR-related permission errors + +--- + +## Performance: Frame Rate Drop + +**Common causes:** +- Per-frame `new Vector3()` / `new THREE.Color()` allocations — reuse objects +- `getComponent()` called every frame — cache the result in `start()` +- `findObjectOfType()` called every frame — very slow, use `start()` or events +- Too many draw calls — use instancing or merge geometries in Unity before export +- Large uncompressed textures — enable **Texture Compression** in Unity Needle settings + +```ts +// ❌ Bad — allocates every frame +update() { + const pos = new Vector3(1, 0, 0); + this.gameObject.position.copy(pos); +} + +// ✅ Good — reuse +private _pos = new Vector3(1, 0, 0); +update() { + this.gameObject.position.copy(this._pos); +} +``` + +--- + +## Getting More Help + +- Search docs: `needle_search("your question here")` +- [Needle Engine Docs](https://engine.needle.tools/docs/) +- [Community Forum / GitHub Discussions](https://github.com/needle-tools/needle-engine-support/discussions) +- [Discord](https://discord.needle.tools) diff --git a/providers/claude/plugin/skills/needle-engine/templates/my-component.ts b/providers/claude/plugin/skills/needle-engine/templates/my-component.ts new file mode 100644 index 0000000..b0aea0f --- /dev/null +++ b/providers/claude/plugin/skills/needle-engine/templates/my-component.ts @@ -0,0 +1,87 @@ +/** + * Needle Engine — Annotated Component Template + * + * Copy this file and rename the class to get started. + * Remove any lifecycle methods you don't need. + */ + +import { Behaviour, serializable, registerType, GameObject } from "@needle-tools/engine"; +import { Object3D } from "three"; + +// @registerType — required for GLB deserialization. +// Without this, the component won't be instantiated from GLB. +@registerType +export class MyComponent extends Behaviour { + + // --------------------------------------------------------------------------- + // Serialized Fields + // These values are set in the Unity Inspector and baked into the GLB. + // --------------------------------------------------------------------------- + + /** A simple number field — set in Unity Inspector */ + @serializable() + speed: number = 1; + + /** A reference to another object in the scene */ + @serializable(Object3D) + target?: Object3D; + + // --------------------------------------------------------------------------- + // Private State + // --------------------------------------------------------------------------- + + private _elapsed: number = 0; + + // --------------------------------------------------------------------------- + // Lifecycle + // --------------------------------------------------------------------------- + + awake() { + // Called once when the component is instantiated (even if disabled). + // Use for initialization that doesn't depend on other components being ready. + } + + start() { + // Called once on the first frame the component is active. + // Other components are initialized by now — safe to call getComponent() etc. + console.log(`${this.name} started on ${this.gameObject.name}`); + } + + update() { + // Called every frame. Use this.context.time.deltaTime for frame-rate independence. + this._elapsed += this.context.time.deltaTime; + + // Example: rotate this object + this.gameObject.rotation.y += this.speed * this.context.time.deltaTime; + } + + onEnable() { + // Called each time this component becomes active. + } + + onDisable() { + // Called each time this component becomes inactive. + } + + onDestroy() { + // Called when this component/object is destroyed (via GameObject.destroy()). + // Note: NOT called by removeComponent() — only by destroy(). + // Clean up event listeners, timers, or external references here. + } + + // --------------------------------------------------------------------------- + // Physics callbacks (require a Rapier Collider on this GameObject) + // --------------------------------------------------------------------------- + + // onCollisionEnter(col: Collision) { } + // onTriggerEnter(col: Collision) { } + + // --------------------------------------------------------------------------- + // Example: coroutine + // --------------------------------------------------------------------------- + + private *exampleCoroutine() { + // yield; // wait one frame + // yield WaitForSeconds(1); // wait 1 second + } +}