From f1617b0b9f460f93d41549f417c2c137bb16f248 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Sat, 27 Jun 2026 19:31:10 +0200 Subject: [PATCH] feat(site): land the physics, aseprite, ldtk, and React guides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands five package guides into the guide IA, each with API-verified code blocks: - assets/aseprite, assets/ldtk — new chapters in the Assets part - physics/physics-basics, physics/joints-and-dynamics — new Physics part - integrations/react — new Integrations part guide-structure.ts gains the two new parts and the two Assets chapters with levels, learning goals, prerequisites, and API links (all resolving to existing API pages). tsconfig.guides.json gains source paths for @codexo/exojs-{physics, aseprite,ldtk} (+/register for the two extensions) so the extracted snippets type-check against engine source. Verified: typecheck:guides green (82 snippets), guide-structure reconciliation test 19/19, and the new pages render (HTTP 200, sidebar shows Physics + Integrations, no console errors). --- site/src/content/guide/assets/aseprite.mdx | 170 +++++++++++ site/src/content/guide/assets/ldtk.mdx | 169 +++++++++++ site/src/content/guide/integrations/react.mdx | 249 +++++++++++++++++ .../guide/physics/joints-and-dynamics.mdx | 263 ++++++++++++++++++ .../content/guide/physics/physics-basics.mdx | 257 +++++++++++++++++ site/src/lib/guide-structure.ts | 69 +++++ tsconfig.guides.json | 7 +- 7 files changed, 1183 insertions(+), 1 deletion(-) create mode 100644 site/src/content/guide/assets/aseprite.mdx create mode 100644 site/src/content/guide/assets/ldtk.mdx create mode 100644 site/src/content/guide/integrations/react.mdx create mode 100644 site/src/content/guide/physics/joints-and-dynamics.mdx create mode 100644 site/src/content/guide/physics/physics-basics.mdx diff --git a/site/src/content/guide/assets/aseprite.mdx b/site/src/content/guide/assets/aseprite.mdx new file mode 100644 index 00000000..303f7298 --- /dev/null +++ b/site/src/content/guide/assets/aseprite.mdx @@ -0,0 +1,170 @@ +--- +title: 'Aseprite sprite sheets' +description: 'Load Aseprite JSON sprite sheet exports as AsepriteSheet assets through the official @codexo/exojs-aseprite extension.' +--- + +# Aseprite sprite sheets + +[Aseprite](https://www.aseprite.org/) is a popular pixel-art and animation editor. The official +`@codexo/exojs-aseprite` extension adds an `asepriteSheet` asset type that loads Aseprite's JSON +sheet export through the normal [`Loader`](/ExoJS/en/api/loader/) pipeline — fetching the JSON, +resolving and loading the packed image as a `Texture`, and returning an +[`AsepriteSheet`](/ExoJS/en/api/aseprite-sheet/) with a frame `Spritesheet` and one playable clip +per Aseprite tag. + +> **Note:** `AsepriteSheet` ships as an official ExoJS extension package, separate from the core. +> Install `@codexo/exojs-aseprite` alongside `@codexo/exojs`: +> ```sh +> npm install @codexo/exojs @codexo/exojs-aseprite +> ``` + +Export your sprite from Aseprite as a JSON sheet (**File → Export Sprite Sheet**, with *Output → JSON +Data* enabled). Either *Array* or *Hash* frame layout works, and tags become animation clips. + +Like all extensions, the core ships nothing Aseprite-specific — an `Application` only understands +the `asepriteSheet` type once you activate the extension. There are two ways to do that. + +## Activation + +### Explicit (recommended) + +Pass `asepriteExtension` to `ApplicationOptions.extensions`. This is explicit, tree-shakeable, and +order-independent — the extension is bound to exactly this `Application`: + +```js +import { Application } from '@codexo/exojs'; +import { asepriteExtension } from '@codexo/exojs-aseprite'; + +const app = new Application({ extensions: [asepriteExtension] }); +``` + +The package root (`@codexo/exojs-aseprite`) is side-effect-free: importing it registers nothing +globally. You decide which `Application` gets the extension. + +### `/register` convenience + +For an app that always wants Aseprite support, import the `/register` entry once at startup. It +registers `asepriteExtension` in the global `ExtensionRegistry` and re-exports the same public API: + +```js +import '@codexo/exojs-aseprite/register'; +import { Application } from '@codexo/exojs'; + +const app = new Application(); // picks up asepriteExtension automatically +``` + +The `/register` import must run **before** you construct the `Application` whose extensions are +read from the global registry. The explicit `extensions: [...]` form works regardless of import +order. + +### Core-only + +An `Application` constructed with an explicit empty list takes **no** extensions — not even +globally registered ones — and will not recognise the `asepriteSheet` type: + +```js +import { Application } from '@codexo/exojs'; + +const app = new Application({ extensions: [] }); // core only; no AsepriteSheet +``` + +This is the same add-only model the [Tiled maps](/ExoJS/en/guide/assets/tiled-maps/) and +[Particles](/ExoJS/en/guide/effects/particles/) extensions use. + +## Loading a sheet + +Once the extension is active, load the JSON export by the `AsepriteSheet` type token or by the +`'asepriteSheet'` config-map type name: + +```ts no-check +import { AsepriteSheet } from '@codexo/exojs-aseprite'; + +// By type token (most explicit) +const sheet = await loader.load(AsepriteSheet, 'sprites/hero.json'); + +// By config-map type name — the public `'asepriteSheet'` lookup +const fromConfig = await loader.load({ + hero: { type: 'asepriteSheet', source: 'sprites/hero.json' }, +}); +``` + +Unlike the Tiled adapter, the Aseprite binding does **not** claim a file extension — a bare +`loader.load('sprites/hero.json')` will not route here. Always name the `AsepriteSheet` token or the +`'asepriteSheet'` type. The handler fetches the JSON, validates it (throwing `AsepriteFormatError` +on a malformed document), resolves `meta.image` relative to the JSON source, sub-loads it as a +`Texture` through the same loader, and returns a fully-parsed `AsepriteSheet`. + +## Playing tag animations + +`AsepriteSheet.createAnimatedSprite()` returns an +[`AnimatedSprite`](/ExoJS/en/api/animated-sprite/) with every Aseprite tag pre-defined as a named, +playable clip. Call `play(tag)` with a tag name to start it: + +```js +async load(loader) { + this.sheet = await loader.load(AsepriteSheet, 'sprites/hero.json'); +} + +init() { + this.player = this.sheet.createAnimatedSprite(); + this.player.play('walk'); + this.root.addChild(this.player); +} + +update(delta) { + this.player.update(delta); // advance the active clip +} +``` + +Like any `AnimatedSprite`, you must call `player.update(delta)` each frame to advance playback — see +the [Animation](/ExoJS/en/guide/rendering/animation/) guide for the full clip/playback API. + +The parsed clips are also exposed directly on `sheet.clips` — a read-only `Map` keyed by tag name — +so you can inspect what was imported or build a sprite by hand: + +```js no-check +sheet.clips.size; // number of tags +sheet.clips.has('walk'); // true when the 'walk' tag exists +sheet.clips.keys(); // iterate tag names + +sheet.spritesheet; // the underlying Spritesheet (frames keyed by index string) +``` + +## Frame timing (fps) + +Each clip's `fps` is derived from the per-frame `duration` values Aseprite exports (milliseconds per +frame), averaged across the tag's `from`→`to` range: + +- A tag whose frames are all 100 ms produces **10 fps**; mixed durations are averaged (100/200/300 ms + → 200 ms → **5 fps**). +- When every frame duration in the tag is zero, the clip falls back to **12 fps**. + +Every clip is created with `loop: true`. Aseprite's ping-pong directions are recorded as looping but +are **not** expanded into a reversed segment — a ping-pong tag plays only its declared `from`→`to` +range. Frame indices in a tag that fall outside the frame array are skipped, and a tag whose entire +range is out of bounds produces no clip. + +## Not yet supported + +- **Slices** — slice data (`AsepriteSlice` / `AsepriteSliceKey`) is parsed into the raw document + types but is **not** yet surfaced as a runtime API; there is no slice accessor on `AsepriteSheet`. +- **Layers / nested tags** — Aseprite layer metadata is preserved in the parsed data but is not + interpreted into clips. + +## Texture ownership + +The packed image an `AsepriteSheet` uses is loaded through the `Loader` and owned by the **loader +cache**. `AsepriteSheet.destroy()` releases the underlying `Spritesheet` frames; release the shared +texture through the loader's own unload path when nothing else needs it. + +## Where to look next + +- **Package README:** [`@codexo/exojs-aseprite`](https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-aseprite/README.md) + — install, entry points, and the supported-format matrix. +- **API reference:** [`AsepriteSheet`](/ExoJS/en/api/aseprite-sheet/) documents `clips`, + `spritesheet`, and `createAnimatedSprite()`; [`AnimatedSprite`](/ExoJS/en/api/animated-sprite/) + documents clip definition and playback; [`Loader`](/ExoJS/en/api/loader/) documents `load(...)` + and the `'asepriteSheet'` config-map type name. +- **Guides:** [Sprites](/ExoJS/en/guide/rendering/sprites/) and + [Loading & resources](/ExoJS/en/guide/assets/loading-and-resources/) cover the rendering and asset + model the sheet plugs into. diff --git a/site/src/content/guide/assets/ldtk.mdx b/site/src/content/guide/assets/ldtk.mdx new file mode 100644 index 00000000..e0305f16 --- /dev/null +++ b/site/src/content/guide/assets/ldtk.mdx @@ -0,0 +1,169 @@ +--- +title: 'LDtk levels' +description: 'Load LDtk (.ldtk) level files and convert each level to a renderable TileMap through the official @codexo/exojs-ldtk extension.' +--- + +# LDtk levels + +[LDtk](https://ldtk.io/) is a modern, open-source level editor. The official `@codexo/exojs-ldtk` +extension adds an `ldtkMap` asset type that loads an LDtk project (`.ldtk`) through the normal +[`Loader`](/ExoJS/en/api/loader/) pipeline — fetching the JSON, loading every referenced tileset +image, and converting **each LDtk level** into a runtime [`TileMap`](/ExoJS/en/api/tile-map/) you +can drop straight into the scene graph. + +> **Note:** `LdtkMap` ships as an official ExoJS extension package, separate from the core. It is +> built on `@codexo/exojs-tilemap` (a regular dependency, installed transitively — you do not need +> to add it yourself). Install `@codexo/exojs-ldtk` alongside `@codexo/exojs`: +> ```sh +> npm install @codexo/exojs @codexo/exojs-ldtk +> ``` + +Because each LDtk level becomes a generic `TileMap`, you render LDtk worlds with the same tilemap +node used everywhere else in the engine — there is no LDtk-specific renderer. + +Like all extensions, the core ships nothing LDtk-specific — an `Application` only understands +`.ldtk` files once you activate the extension. There are two ways to do that. + +## Activation + +### Explicit (recommended) + +Pass `ldtkExtension` to `ApplicationOptions.extensions`. This is explicit, tree-shakeable, and +order-independent — the extension is bound to exactly this `Application`: + +```js +import { Application } from '@codexo/exojs'; +import { ldtkExtension } from '@codexo/exojs-ldtk'; + +const app = new Application({ extensions: [ldtkExtension] }); +``` + +`ldtkExtension` depends on `tilemapExtension`, so activating it enables **both** loading and tilemap +rendering — the tile-chunk renderer bindings are materialised automatically. The package root +(`@codexo/exojs-ldtk`) is side-effect-free: importing it registers nothing globally. + +### `/register` convenience + +For an app that always wants LDtk support, import the `/register` entry once at startup. It +registers `ldtkExtension` (and its `tilemapExtension` dependency) in the global `ExtensionRegistry` +and re-exports the same public API: + +```js +import '@codexo/exojs-ldtk/register'; +import { Application } from '@codexo/exojs'; + +const app = new Application(); // picks up ldtkExtension automatically +``` + +The `/register` import must run **before** you construct the `Application` whose extensions are read +from the global registry. The explicit `extensions: [...]` form works regardless of import order. + +### Core-only + +An `Application` constructed with an explicit empty list takes **no** extensions — not even +globally registered ones — and will not recognise `.ldtk` files: + +```js +import { Application } from '@codexo/exojs'; + +const app = new Application({ extensions: [] }); // core only; no LdtkMap +``` + +This is the same add-only model the [Tiled maps](/ExoJS/en/guide/assets/tiled-maps/) extension uses. + +## Loading a world + +Once the extension is active, load a `.ldtk` file by type token, by path (the `.ldtk` extension is +registered), or by the `'ldtkMap'` config-map type name: + +```ts no-check +import { LdtkMap } from '@codexo/exojs-ldtk'; + +// By type token (most explicit) +const world = await loader.load(LdtkMap, 'https://example.com/levels/world.ldtk'); + +// By path — the `.ldtk` extension is registered, so the type is inferred +const sameWorld = await loader.load('https://example.com/levels/world.ldtk'); + +// By config-map type name — the public `'ldtkMap'` lookup +const fromConfig = await loader.load({ + world: { type: 'ldtkMap', source: 'https://example.com/levels/world.ldtk' }, +}); +``` + +> **Note:** the LDtk loader resolves tileset image paths against the map's own URL with the `URL` +> constructor, which requires that URL to be **absolute (origin-qualified)**. Load `.ldtk` files by +> an absolute `https://…` URL, or set `loader.basePath` to an absolute origin so relative map paths +> resolve to absolute URLs. A bare relative or root-relative path will fail tileset resolution. + +## The LdtkMap result + +A loaded `LdtkMap` exposes one runtime `TileMap` per LDtk level, plus the raw parsed document: + +```js no-check +world.levels; // readonly TileMap[] — one per level, in document order +world.getLevelByName('Level_0'); // TileMap | undefined — lookup by LDtk identifier +world.data; // the raw parsed LdtkData document +``` + +Each level's `TileMap` carries the LDtk tile layers (as `TileLayer`s) and entity layers (as data-only +`ObjectLayer`s, with each entity's position, size, and scalar fields preserved as object +properties). World-space placement is stored in `TileMap.properties` (`worldX` / `worldY`). + +## Rendering a level + +Wrap a level's `TileMap` in a `TileMapNode` and add it to the scene. `TileMapNode` is the generic +tilemap node from `@codexo/exojs-tilemap` (the package LDtk builds on): + +```ts no-check +import { TileMapNode } from '@codexo/exojs-tilemap'; + +const level = world.getLevelByName('Level_0') ?? world.levels[0]; +scene.root.addChild(new TileMapNode(level)); +``` + +To stream a multi-level world, add a node per level — each level is its own `TileMap`: + +```js no-check +for (const level of world.levels) { + scene.root.addChild(new TileMapNode(level)); +} +``` + +`TileMapNode` handles per-chunk culling and renders the level's layers in document order. For +interleaving actors between tile layers, use `TileMap.createView()` to obtain independently +placeable layer nodes — see the [`TileMapView`](/ExoJS/en/api/tile-map-view/) API. + +## Converting manually (advanced) + +The loader does the fetch-and-convert for you, but the conversion step is also available directly. +`ldtkToTileMap` turns a raw `LdtkData` document into an `LdtkMap` of runtime `TileMap`s — useful for +custom pipelines or for converting data you already hold in memory: + +```ts no-check +import { ldtkToTileMap } from '@codexo/exojs-ldtk'; + +const converted = ldtkToTileMap(world.data, { source: 'https://example.com/levels/world.ldtk' }); +``` + +Without an `options.tilesets` map, tile layers are created with correct dimensions but no tile data — +the asset-loading path supplies the runtime tilesets for you. Tile flip flags are exposed as the +constants `ldtkFlipNone`, `ldtkFlipX`, `ldtkFlipY`, and `ldtkFlipXy` for reading raw `LdtkTileData`. + +## Texture ownership + +Tileset textures are loaded through the `Loader` and owned by the **loader cache** — not by the +`LdtkMap`. `LdtkMap.destroy()` destroys the owned runtime `TileMap`s but deliberately does **not** +unload tileset textures (they may be shared) or remove any scene nodes — the application owns those. + +## Where to look next + +- **Package README:** [`@codexo/exojs-ldtk`](https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-ldtk/README.md) + — install, entry points, and the supported-feature matrix. +- **API reference:** [`LdtkMap`](/ExoJS/en/api/ldtk-map/) documents `levels`, `getLevelByName`, and + `data`; [`TileMap`](/ExoJS/en/api/tile-map/) and [`TileMapNode`](/ExoJS/en/api/tile-map-node/) + document the runtime tilemap and its renderer. +- **Sibling extension:** the [Tiled maps](/ExoJS/en/guide/assets/tiled-maps/) chapter covers the + same explicit-vs-`/register` activation for the other map-editor adapter. +- **Loading model:** [Loading & resources](/ExoJS/en/guide/assets/loading-and-resources/) covers the + asset pipeline the loader plugs into. diff --git a/site/src/content/guide/integrations/react.mdx b/site/src/content/guide/integrations/react.mdx new file mode 100644 index 00000000..ee001378 --- /dev/null +++ b/site/src/content/guide/integrations/react.mdx @@ -0,0 +1,249 @@ +--- +title: 'React integration' +description: 'Mount an ExoJS Application inside a React tree and drive scenes declaratively with the @codexo/exojs-react bindings.' +--- + +# React integration + +The official `@codexo/exojs-react` package lets you host an ExoJS +[`Application`](/ExoJS/en/api/application/) inside a React component tree: it owns the +canvas, manages the app lifecycle for you, switches [`Scene`](/ExoJS/en/api/scene/)s +declaratively, and lets React HUD overlays read the running app through context. + +It is a plain **React binding**, not an engine extension — there is no `/register` entry and +nothing to wire into `ApplicationOptions`. You use it from `.tsx` files alongside the rest of +your React UI. + +> **Note:** This package sits next to the core engine and needs React as a peer. Install all +> three: +> ```sh +> npm install @codexo/exojs @codexo/exojs-react react +> ``` +> `@codexo/exojs` and `react` (>= 18) are peer dependencies; `react-dom` is the usual host +> renderer. The package ships pre-built ESM with type declarations and type-checks against both +> `@types/react` 18 and 19. + +## Two layers + +The package is intentionally split into two layers — pick the one that matches how much DOM +control you want: + +- **`useExoApplication` — headless.** A hook that creates and owns the `Application` and binds + it to a `` **you** render. It produces no DOM of its own, so you keep full control over + the canvas element, its container, and its styling. +- **`` — batteries-included.** A component that renders a positioned wrapper `
` + plus a React-managed ``, and provides the app to descendants via context. HUD overlays + and the declarative scene API work out of the box. + +`` is built on top of `useExoApplication`; everything below the scene API applies to +both. + +## The headless hook + +`useExoApplication(options?, onReady?)` returns the app and a ref to attach to your own canvas: + +```tsx no-check +import { useExoApplication } from '@codexo/exojs-react'; + +function Game() { + const { app, canvasRef } = useExoApplication({ canvas: { width: 800, height: 600 } }); + // `app` is null until the canvas is mounted; render it however you like. + return ; +} +``` + +The hook returns `{ app, canvasRef }`: `app` is the `Application` (or `null` until the canvas +mounts and the app is created), and `canvasRef` is a stable ref you attach to the `` the +app should bind to. The optional `onReady` callback fires once each time an app is created. + +### Lifecycle and reactivity + +`options` is an `ExoApplicationOptions` — the same shape as `ApplicationOptions`, but the +`canvas.element` / `canvas.mount` fields are managed for you (the hook binds the app to the +canvas it references). You still pass `canvas.width`, `canvas.height`, `canvas.sizingMode`, +`clearColor`, `backend`, and so on. + +The hook treats most options as captured-at-creation, but live-syncs a few without tearing the +app down: + +- The app is **recreated** only when the render `backend` changes — WebGL2 ↔ WebGPU cannot be + hot-swapped, so this is the one identity option. +- `canvas.width` / `canvas.height` are applied **live** via `app.resize(...)`. +- `canvas.sizingMode` is applied **live** via the `app.sizingMode` setter. +- `clearColor` is applied **live** via the `app.clearColor` setter (keyed on its channel values, + so a fresh `Color` with identical channels does not re-assign). +- Options without a live setter (`canvas.pixelRatio`, `seed`, `extensions`, …) are captured at + creation. Change the `backend` or remount to apply them. + +On unmount the hook calls `app.destroy()`. The engine never removes a canvas it did not create, +so React stays the sole owner of the `` element. + +> **Note:** With the default `'fixed'` sizing mode the engine never touches the canvas CSS, so +> you may style it freely. The `'fill'`, `'fit'`, `'shrink'`, and `'letterbox'` modes manage +> `canvas.style` themselves — size the container and let them observe it, rather than fighting +> them with an inline `style`. + +## The batteries-included canvas + +`` renders a `position: relative` wrapper `
` containing the canvas, and provides +the app via context so descendants (HUD, the scene API) can reach it. Because the wrapper is +positioned, absolutely-positioned children sit over the canvas with no extra setup. + +```tsx no-check +import { ExoCanvas } from '@codexo/exojs-react'; + +function Game() { + return ( + +
HUD overlay
+
+ ); +} +``` + +Props: + +- `options` — the `ExoApplicationOptions` forwarded to the app (same reactivity as above). +- `onReady` — called once each time the app is (re)created. +- Layout props (`style`, `className`, and any other `
` attributes) apply to the **wrapper**. + Size the wrapper to drive the `'fill'` / `'letterbox'` sizing modes. +- `canvasProps` — forwarded to the inner `` (its own `style`/`className`); `ref`, + `width`, and `height` are managed by the engine and cannot be set here. +- `children` — rendered as an overlay once the app exists, with the app available via context. + +For full control with no wrapper element, drop down to `useExoApplication`. + +## Declarative scenes + +`` switches the one active scene by name. Declare each scene with `` and select +the active one through the `active` prop. The first activation calls `app.start(scene)` (which +initializes the backend and starts the frame loop); later switches call +`app.scene.setScene(scene, …)` with the optional `transition`. + +The scene classes you reference are ordinary ExoJS scenes — the React layer only decides which +one is active: + +```ts +import { Scene } from '@codexo/exojs'; + +export class TitleScene extends Scene {} +export class GameScene extends Scene {} +``` + +```tsx no-check +import { ExoCanvas, Scenes, Scene } from '@codexo/exojs-react'; +import { TitleScene, GameScene } from './scenes'; + +function Game({ screen }: { screen: 'title' | 'game' }) { + return ( + + + + + + + + + ); +} +``` + +Each `` takes a unique `name`, the scene `component` class to instantiate, and optional +`children` that render as the active scene's overlay. The `transition` (a core `SceneTransition`, +e.g. a fade whose `duration` is in milliseconds) only applies to switches, not the first start. +If `active` matches no ``, the active scene is cleared. + +`useActiveScene()` reads the live scene instance from the nearest ``, so an overlay can +react to scene state: + +```tsx no-check +import { useActiveScene } from '@codexo/exojs-react'; +import type { GameScene } from './scenes'; + +function Hud() { + const scene = useActiveScene(); + if (scene === null) return null; + return
Score: {scene.score}
; +} +``` + +For the simplest case — a single scene with no switching — use `useScene(SceneClass, deps?)` +instead. It instantiates and activates one scene, returning the instance once it is live (or +`null` while loading), and clears it on unmount or when `deps` change. + +## Reaching the app from descendants + +Any component rendered inside `` can read the running app: + +- `useExoApp()` returns the `Application` and **throws** an actionable error if there is no + `` ancestor — use it in components that require the app. +- `useExoContext()` returns `Application | null` (no throw) for optional access. +- `ExoContext` is the underlying context object, exported for advanced use (testing, custom + providers). + +```tsx no-check +import { useExoApp } from '@codexo/exojs-react'; + +function FrameCounter() { + const app = useExoApp(); // throws if rendered outside + return Frame: {app.frameCount}; +} +``` + +## End-to-end + +A small app that hosts the canvas, switches between two scenes with a fade, and overlays React +HUD on the active scene: + +```tsx no-check +import { useState } from 'react'; +import { ExoCanvas, Scenes, Scene, useActiveScene } from '@codexo/exojs-react'; +import { TitleScene, GameScene } from './scenes'; + +function Hud() { + const scene = useActiveScene(); + return ( +
+ {scene?.constructor.name} +
+ ); +} + +export function App() { + const [screen, setScreen] = useState<'title' | 'game'>('title'); + + return ( + + + + + + + + + + + ); +} +``` + +React owns the screen state and the HUD; ExoJS owns the canvas, the renderer, and the per-frame +loop. The two stay cleanly separated. + +## Where to look next + +- **Package README:** [`@codexo/exojs-react`](https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-react/README.md) + — install, the export table, and the reactivity model. +- **Scenes & lifecycle:** the [Scenes & lifecycle](/ExoJS/en/guide/runtime/scenes-and-lifecycle/) + chapter explains `app.start`, `app.scene.setScene`, transitions, and the hooks the React layer + drives for you. +- **API reference:** the [`Application`](/ExoJS/en/api/application/) and + [`Scene`](/ExoJS/en/api/scene/) pages document the underlying engine surface the bindings wrap. diff --git a/site/src/content/guide/physics/joints-and-dynamics.mdx b/site/src/content/guide/physics/joints-and-dynamics.mdx new file mode 100644 index 00000000..e025cef4 --- /dev/null +++ b/site/src/content/guide/physics/joints-and-dynamics.mdx @@ -0,0 +1,263 @@ +--- +title: 'Joints, sleeping & CCD' +description: 'Connect physics bodies with distance, revolute, weld, prismatic, wheel and mouse joints, let resting bodies sleep to save CPU, and stop fast projectiles tunnelling with continuous collision.' +--- + +# Joints, sleeping & CCD + +This chapter builds on [Physics basics](/ExoJS/en/guide/physics/physics-basics/) — a +[`PhysicsWorld`](/ExoJS/en/api/physics-world/) stepped from `Scene.fixedUpdate`, with +static and dynamic bodies. Here we connect those bodies with **joints**, let bodies at +rest **sleep**, and stop fast bodies from **tunnelling** with continuous collision. + +## Joints + +A [`Joint`](/ExoJS/en/api/joint/) is a constraint between two bodies, solved alongside +contacts in the sub-step loop. The pattern is always the same: construct the joint, +then register it with `world.addJoint(...)`. Remove it with `world.removeJoint(joint)`, +which wakes both bodies so they respond to the lost constraint. + +```ts no-check +const joint = world.addJoint(new RevoluteJoint({ bodyA, bodyB, anchor })); +// ...later... +world.removeJoint(joint); +``` + +Many joints take a `hertz` (and `dampingRatio`) pair: leaving `hertz` at `0` makes the +constraint **rigid**, while `hertz > 0` turns it into a **damped spring** at that +frequency. Anchors are given in **world space at construction** and stored body-locally, +so they travel with the bodies afterwards. + +### Distance joints + +A [`DistanceJoint`](/ExoJS/en/api/distance-joint/) holds two anchor points a fixed +`length` apart — a rigid rod by default, or a spring with `hertz > 0`: + +```ts +import { BoxShape, DistanceJoint, PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; + +const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); +const anchor = world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 0 } })); +const bob = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 150 }, colliders: [{ shape: new BoxShape(16, 16) }] })); + +// Rigid rod: holds the bob exactly 100px from the anchor. +world.addJoint(new DistanceJoint({ bodyA: anchor, bodyB: bob, length: 100 })); + +// Or a soft spring that sags and bobs under gravity: +world.addJoint(new DistanceJoint({ bodyA: anchor, bodyB: bob, length: 100, hertz: 2.5, dampingRatio: 1 })); +``` + +Specifying `minLength` and/or `maxLength` turns it into a **rope/limit**: the bodies move +freely while the separation is within the band and the joint only engages (rigidly) at the +bounds: + +```ts +import { BoxShape, DistanceJoint, PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; + +const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); +const anchor = world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 0 } })); +const bob = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 50 }, colliders: [{ shape: new BoxShape(16, 16) }] })); + +// A rope: the bob falls freely until it reaches 100px, then the rope holds. +world.addJoint(new DistanceJoint({ bodyA: anchor, bodyB: bob, maxLength: 100 })); +``` + +### Revolute joints + +A [`RevoluteJoint`](/ExoJS/en/api/revolute-joint/) pins a shared `anchor` point on two +bodies — a hinge they rotate freely about. Enable a **motor** to drive the relative +angular velocity, or a **limit** to clamp the swing: + +```ts +import { BoxShape, PhysicsBody, PhysicsWorld, RevoluteJoint } from '@codexo/exojs-physics'; + +const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); +const anchor = world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 0 } })); +const arm = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 70, y: 0 }, colliders: [{ shape: new BoxShape(100, 10) }] })); + +// A free hinge at the origin — the arm swings under gravity. +world.addJoint(new RevoluteJoint({ bodyA: anchor, bodyB: arm, anchor: { x: 0, y: 0 } })); + +// A powered hinge — a motor driving it toward 5 rad/s, capped torque: +world.addJoint( + new RevoluteJoint({ bodyA: anchor, bodyB: arm, anchor: { x: 0, y: 0 }, enableMotor: true, motorSpeed: 5, maxMotorTorque: 1e8 }), +); + +// A limited hinge — the relative angle is clamped to ±45°: +world.addJoint( + new RevoluteJoint({ bodyA: anchor, bodyB: arm, anchor: { x: 0, y: 0 }, enableLimit: true, lowerAngle: -Math.PI / 4, upperAngle: Math.PI / 4 }), +); +``` + +### Weld joints + +A [`WeldJoint`](/ExoJS/en/api/weld-joint/) rigidly locks the relative position **and** +orientation of two bodies, so they move as one rigid body. Both locks default to rigid; +set `linearHertz` / `angularHertz` for a springy weld: + +```ts +import { BoxShape, PhysicsBody, PhysicsWorld, WeldJoint } from '@codexo/exojs-physics'; + +const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); +const a = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [{ shape: new BoxShape(20, 20) }] })); +const b = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 24, y: 0 }, colliders: [{ shape: new BoxShape(20, 20) }] })); + +world.addJoint(new WeldJoint({ bodyA: a, bodyB: b })); +``` + +### Prismatic joints + +A [`PrismaticJoint`](/ExoJS/en/api/prismatic-joint/) constrains a body to **slide along +one axis** relative to another — perpendicular translation and rotation are locked. It +takes an `axis` (normalised internally) and supports a linear motor (`maxMotorForce`) and +translation limits: + +```ts +import { BoxShape, PhysicsBody, PhysicsWorld, PrismaticJoint } from '@codexo/exojs-physics'; + +const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); +const rail = world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 0 } })); +const slider = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [{ shape: new BoxShape(20, 20) }] })); + +world.addJoint( + new PrismaticJoint({ + bodyA: rail, + bodyB: slider, + anchor: { x: 0, y: 0 }, + axis: { x: 1, y: 0 }, // slide horizontally + enableMotor: true, + motorSpeed: 100, + maxMotorForce: 1e8, + enableLimit: true, + lowerTranslation: 0, + upperTranslation: 200, + }), +); +``` + +### Wheel joints + +A [`WheelJoint`](/ExoJS/en/api/wheel-joint/) is the vehicle primitive: the wheel is free to +**spin**, sprung along a **suspension axis** (a soft spring via `hertz` / `dampingRatio`), +and locked **laterally**. A rotation motor drives it: + +```ts +import { BoxShape, CircleShape, PhysicsBody, PhysicsWorld, WheelJoint } from '@codexo/exojs-physics'; + +const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); +const chassis = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [{ shape: new BoxShape(120, 20) }] })); +const wheel = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 30 }, colliders: [{ shape: new CircleShape(10) }] })); + +world.addJoint( + new WheelJoint({ + bodyA: chassis, + bodyB: wheel, + anchor: { x: 0, y: 30 }, + axis: { x: 0, y: 1 }, // suspension travels vertically + hertz: 5, + dampingRatio: 0.7, + enableMotor: true, + motorSpeed: 20, + maxMotorTorque: 1e6, + }), +); +``` + +### Mouse joints + +A [`MouseJoint`](/ExoJS/en/api/mouse-joint/) softly pulls a single body's grab point toward +a movable `target` — the cursor-drag primitive. It is a single-body constraint; reassign +`target` each frame to drag, and bound the pull with `maxForce` (so heavy bodies lag): + +```ts +import { BoxShape, MouseJoint, PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; + +const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); +const body = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [{ shape: new BoxShape(20, 20) }] })); + +const drag = world.addJoint(new MouseJoint({ body, target: { x: 0, y: 0 }, hertz: 5, dampingRatio: 0.7, maxForce: 10000 })); +drag.target = { x: 50, y: -30 }; // update from the pointer position each frame +``` + +Both jointed bodies share one **sleep island**, so a jointed pair sleeps and wakes +together — which brings us to sleeping. + +## Sleeping + +A body that has stayed below the velocity thresholds long enough is put to **sleep**: it +stops integrating and is skipped by the solver until something wakes it. In a scene with +many resting bodies — a settled stack, scattered debris — this is a large CPU saving, and +it removes the last traces of resting jitter. + +Sleeping is **island-aware**: connected bodies (touching contacts and joints form an +island) sleep and wake as a unit, so a tower never half-sleeps. A body wakes the instant it +is touched by an awake body, hit by an `applyImpulse`/`applyForce`, or moved with +`setTransform`. + +It is on by default; tune it through `PhysicsWorld` options: + +```ts +import { PhysicsWorld } from '@codexo/exojs-physics'; + +const world = new PhysicsWorld({ + gravity: { x: 0, y: 1000 }, + enableSleeping: true, // default; set false to never sleep + sleepLinearVelocity: 5, // px/s — at or below this a body is a sleep candidate + sleepAngularVelocity: 0.06, // rad/s + timeToSleep: 0.5, // seconds below the thresholds before sleeping +}); +``` + +Per body, opt a single body out with `allowSleep = false` (it, and its whole island, stay +awake), read `isSleeping`, or force it awake with `wake()`: + +```ts no-check +body.allowSleep = false; // this body — and its island — never sleeps +const resting = body.isSleeping; // true once it has come to rest +body.wake(); // force it awake (e.g. before applying a scripted impulse) +``` + +## Continuous collision (bullet mode) + +The solver runs detection **once per fixed step**, so a body that travels farther than an +obstacle is thick in a single step can pass straight through it — tunnelling. For fast +projectiles, flag the body as a **bullet** (`isBullet`) and it is swept along its motion +each step against every other body; if the sweep would cross a surface, the body is clamped +just short of it and its velocity is resolved about the surface normal: + +```ts +import { BoxShape, CircleShape, PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; + +const world = new PhysicsWorld({ gravity: { x: 0, y: 0 } }); + +// A thin wall the projectile would otherwise skip over. +world.add(new PhysicsBody({ type: 'static', position: { x: 200, y: 0 }, colliders: [{ shape: new BoxShape(4, 400) }] })); + +// A fast bullet — swept each step so it stops at the wall instead of tunnelling. +const bullet = world.add( + new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, isBullet: true, colliders: [{ shape: new CircleShape(6) }] }), +); +bullet.linearVelocityX = 6000; // ~100px per fixed step — far more than the 4px wall is thick +``` + +`isBullet` is a plain flag you can toggle at runtime (`bullet.isBullet = true`). The impact +response is a **velocity reflect** about the true surface normal: a non-bouncy body slides +along the surface (keeping its tangential velocity), while a bouncy one (`restitution` near +`1`) rebounds elastically. The swept test runs against static, kinematic **and** dynamic +bodies; sensors never block. + +> **Documented limit.** CCD sweeps the body's **centre point**, which is ideal for small, +> point-like projectiles (bullets, pellets). A **large** fast body can still clip a corner, +> because only its centre is swept — for those, raise +> [`subStepCount`](/ExoJS/en/api/physics-world/) (or the step rate) so each step covers less +> distance, or thicken the geometry it must not pass. A full swept-shape time-of-impact for +> large bodies is a future enhancement. + +## Where to go next + +- **API reference:** [`DistanceJoint`](/ExoJS/en/api/distance-joint/), + [`RevoluteJoint`](/ExoJS/en/api/revolute-joint/), [`WeldJoint`](/ExoJS/en/api/weld-joint/), + [`PrismaticJoint`](/ExoJS/en/api/prismatic-joint/), [`WheelJoint`](/ExoJS/en/api/wheel-joint/), + [`MouseJoint`](/ExoJS/en/api/mouse-joint/) and [`PhysicsWorld`](/ExoJS/en/api/physics-world/). +- **Start here:** [Physics basics](/ExoJS/en/guide/physics/physics-basics/) covers worlds, + bodies, colliders, stepping and sprite binding. diff --git a/site/src/content/guide/physics/physics-basics.mdx b/site/src/content/guide/physics/physics-basics.mdx new file mode 100644 index 00000000..8e4c4ef4 --- /dev/null +++ b/site/src/content/guide/physics/physics-basics.mdx @@ -0,0 +1,257 @@ +--- +title: 'Physics basics' +description: 'Add 2D rigid-body physics with the @codexo/exojs-physics library: build a PhysicsWorld, drop bodies onto colliders, and step it from Scene.fixedUpdate.' +--- + +# Physics basics + +`@codexo/exojs-physics` is the official 2D rigid-body engine for ExoJS — a native, +warm-started **TGS-Soft** solver (the Box2D-v3 "soft step") with shapes, colliders, +bodies, contacts, sensors, queries, joints, sleeping and continuous collision. It +turns a static scene graph into one where boxes fall, stack, bounce and collide. + +> **Note:** physics ships as a separate package. Install `@codexo/exojs-physics` +> alongside `@codexo/exojs`: +> ```sh +> npm install @codexo/exojs @codexo/exojs-physics +> ``` + +## A library, not an extension + +Unlike [Tiled](/ExoJS/en/guide/assets/tiled-maps/) or +[Particles](/ExoJS/en/guide/effects/particles/), physics is **not** an +`Application` extension. It contributes no renderer and no asset type, so there is +**no `/register` entry and no `extensions: [...]` activation**. `@codexo/exojs` is a +peer dependency; you construct a [`PhysicsWorld`](/ExoJS/en/api/physics-world/) +directly and step it yourself: + +```ts +import { PhysicsWorld } from '@codexo/exojs-physics'; + +const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); +``` + +The world holds **no module-level state**, so any number of worlds run in complete +isolation — one per scene, or several side by side. Gravity is in px/s² with **+Y +pointing down** (the ExoJS screen convention), so "down" is a positive `y`. + +## Bodies and colliders + +A [`PhysicsBody`](/ExoJS/en/api/physics-body/) is a transform plus a mass model; a +[`Collider`](/ExoJS/en/api/collider/) is the geometry attached to it. A body owns one +or more colliders, and its mass, centre of mass and rotational inertia are computed +from their shape and density. Every body has a **type**: + +- `'static'` — never moves (floors, walls). Infinite mass. +- `'dynamic'` — integrates under gravity, forces and contacts. This is the default. +- `'kinematic'` — moves only by the velocity you set; immovable under contacts (moving platforms). + +Construct a body freely, then hand it to `world.add(...)`. Colliders can be passed +as plain option objects in `colliders: [...]` — you don't have to build a `Collider` +instance yourself: + +```ts +import { BoxShape, PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; + +const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); + +// A static floor: an immovable body with a single box collider. +world.add( + new PhysicsBody({ + type: 'static', + position: { x: 0, y: 400 }, + colliders: [{ shape: new BoxShape(800, 40) }], + }), +); + +// A dynamic crate that falls onto the floor. +const crate = world.add( + new PhysicsBody({ + type: 'dynamic', + position: { x: 0, y: 0 }, + colliders: [{ shape: new BoxShape(32, 32), density: 1, friction: 0.5, restitution: 0.1 }], + }), +); +``` + +`world.add(...)` returns the body, so you can keep a reference (`crate` above) to read +its position or drive it later. Collider material is set per collider: + +- `density` (default `1`) — mass per px²; feeds the body's mass model. +- `friction` (default `0.2`) — Coulomb friction coefficient. +- `restitution` (default `0`) — bounciness in `[0, 1]`. + +The two built-in shapes are [`BoxShape(width, height)`](/ExoJS/en/api/box-shape/) and +[`CircleShape(radius)`](/ExoJS/en/api/circle-shape/) (there is also a general +`PolygonShape` for convex outlines): + +```ts +import { CircleShape, PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; + +const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); + +const ball = world.add( + new PhysicsBody({ + type: 'dynamic', + position: { x: 0, y: -200 }, + colliders: [{ shape: new CircleShape(12), density: 1, restitution: 0.6 }], + }), +); +``` + +## Stepping the world + +Physics is **caller-driven**: nothing moves until you call `world.step(seconds)`. The +world accumulates elapsed time into a fixed timestep (default `1 / 60 s`) and runs as +many fixed sub-steps as needed, so the simulation is **frame-rate independent** and +**deterministic** — the same inputs replay identically. + +That makes [`Scene.fixedUpdate`](/ExoJS/en/guide/runtime/scenes-and-lifecycle/) the +right hook to step it. `fixedUpdate(delta)` runs zero or more times per frame with a +constant `delta` — exactly what a stable simulation wants. Leave camera, UI and purely +visual work in `update`; put `world.step(...)` in `fixedUpdate`: + +```ts +import { Scene, type Time } from '@codexo/exojs'; +import { BoxShape, PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; + +class GameScene extends Scene { + private readonly world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); + + public override init(): void { + this.world.add( + new PhysicsBody({ + type: 'static', + position: { x: 0, y: 400 }, + colliders: [{ shape: new BoxShape(800, 40) }], + }), + ); + } + + public override fixedUpdate(delta: Time): void { + this.world.step(delta.seconds); + } +} +``` + +The fixed-step size is the application's `fixedTimeStep` (seconds, default `1 / 60`): + +```ts +import { Application } from '@codexo/exojs'; + +const app = new Application({ fixedTimeStep: 1 / 60 }); +``` + +> Stepping inside `update` works too — pass `delta.seconds` the same way — but +> `fixedUpdate` is preferred because its constant delta keeps the simulation +> deterministic regardless of display refresh rate. + +## Binding bodies to sprites + +A body is pure simulation — it has no visual. To draw it, link it to a `Drawable` +(such as a `Sprite`) with a [`PhysicsBinding`](/ExoJS/en/api/physics-binding/). After +each `step`, the binding writes the body's **position and rotation** onto the node +(the body's angle is radians; the node's rotation is set in degrees for you): + +```ts +import { Sprite } from '@codexo/exojs'; +import { CircleShape, PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; + +const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); +const sprite = new Sprite(null); + +const body = world.add( + new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [{ shape: new CircleShape(12) }] }), +); + +world.bind(body, sprite); // sprite now tracks the body every step +``` + +`world.attach(node, def)` is the one-call shortcut for the common case — it creates a +body with a single collider, adds it and binds it in one go: + +```ts +import { Sprite } from '@codexo/exojs'; +import { CircleShape, PhysicsWorld } from '@codexo/exojs-physics'; + +const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); +const sprite = new Sprite(null); + +world.attach(sprite, { type: 'dynamic', position: { x: 0, y: 0 }, shape: new CircleShape(12), restitution: 0.5 }); +``` + +If you'd rather position things yourself, skip the binding and read the body's +transform after each step — `body.x`, `body.y` (world position) and `body.angle` +(radians): + +```ts no-check +sprite.setPosition(body.x, body.y); +sprite.setRotation(MathUtils.radiansToDegrees(body.angle)); +``` + +## Worked example: a ball drops onto a floor + +Putting it together — a static floor, a dynamic ball bound to a sprite, stepped from +`fixedUpdate`: + +```ts +import { Loader, type RenderingContext, Scene, Sprite, Texture, type Time } from '@codexo/exojs'; +import { BoxShape, CircleShape, PhysicsBody, PhysicsWorld } from '@codexo/exojs-physics'; + +class DropScene extends Scene { + private readonly world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } }); + private ball!: Sprite; + + public override async load(loader: Loader): Promise { + await loader.load(Texture, { ball: 'image/ball.png' }); + } + + public override init(loader: Loader): void { + // Static floor — an immovable body, no sprite needed. + this.world.add( + new PhysicsBody({ + type: 'static', + position: { x: 0, y: 360 }, + colliders: [{ shape: new BoxShape(800, 40) }], + }), + ); + + // Dynamic ball: a sprite plus a body + circle collider, linked by `attach`. + this.ball = new Sprite(loader.get(Texture, 'ball')); + this.addChild(this.ball); + + this.world.attach(this.ball, { + type: 'dynamic', + position: { x: 0, y: -200 }, + shape: new CircleShape(12), + restitution: 0.5, + }); + } + + public override fixedUpdate(delta: Time): void { + this.world.step(delta.seconds); + } + + public override draw(context: RenderingContext): void { + context.backend.clear(); + context.render(this.root); + } +} +``` + +The ball falls under gravity, hits the floor, bounces a few times (restitution `0.5`) +and settles. Because the body is bound to the sprite, the sprite follows automatically — +you never touch its position in `update`. + +## Where to go next + +- **Joints, sleeping & CCD:** the next chapter, + [Joints, sleeping & CCD](/ExoJS/en/guide/physics/joints-and-dynamics/), connects bodies + with hinges, ropes, motors and springs, explains how resting bodies sleep to save CPU, + and shows how to stop fast projectiles from tunnelling. +- **API reference:** [`PhysicsWorld`](/ExoJS/en/api/physics-world/), + [`PhysicsBody`](/ExoJS/en/api/physics-body/), [`Collider`](/ExoJS/en/api/collider/), + [`BoxShape`](/ExoJS/en/api/box-shape/), [`CircleShape`](/ExoJS/en/api/circle-shape/) and + [`PhysicsBinding`](/ExoJS/en/api/physics-binding/). +- **The frame loop:** [Scenes & lifecycle](/ExoJS/en/guide/runtime/scenes-and-lifecycle/) + covers `fixedUpdate`, `update` and `draw` and the order they run in. diff --git a/site/src/lib/guide-structure.ts b/site/src/lib/guide-structure.ts index 3334883a..1ec0a7e3 100644 --- a/site/src/lib/guide-structure.ts +++ b/site/src/lib/guide-structure.ts @@ -257,6 +257,28 @@ const RAW_PARTS: ReadonlyArray = [ prerequisites: ['assets/loading-and-resources'], apiLinks: ['loader'], }, + { + slug: 'aseprite', + level: 'intermediate', + learningGoals: [ + 'activate the Aseprite extension explicitly or via /register', + 'load an Aseprite JSON sheet as an AsepriteSheet', + 'play tag animations with createAnimatedSprite', + ], + prerequisites: ['assets/loading-and-resources'], + apiLinks: ['aseprite-sheet', 'animated-sprite', 'loader'], + }, + { + slug: 'ldtk', + level: 'intermediate', + learningGoals: [ + 'activate the LDtk extension explicitly or via /register', + 'load a .ldtk world and render each level as a TileMap', + 'understand tileset texture ownership and absolute-URL resolution', + ], + prerequisites: ['assets/loading-and-resources'], + apiLinks: ['ldtk-map', 'tile-map', 'tile-map-node', 'loader'], + }, ], }, { @@ -536,6 +558,35 @@ const RAW_PARTS: ReadonlyArray = [ }, ], }, + { + slug: 'physics', + title: 'Physics', + description: 'Add 2D rigid-body physics with the @codexo/exojs-physics library: worlds, bodies, colliders, joints, sleeping, and continuous collision.', + chapters: [ + { + slug: 'physics-basics', + level: 'intermediate', + learningGoals: [ + 'build a PhysicsWorld and add static and dynamic bodies', + 'step the simulation from Scene.fixedUpdate', + 'bind bodies to sprites so visuals follow the simulation', + ], + prerequisites: ['runtime/scenes-and-lifecycle'], + apiLinks: ['physics-world', 'physics-body', 'collider', 'box-shape', 'circle-shape', 'physics-binding'], + }, + { + slug: 'joints-and-dynamics', + level: 'advanced', + learningGoals: [ + 'connect bodies with distance, revolute, weld, prismatic, wheel, and mouse joints', + 'let resting bodies sleep to save CPU', + 'stop fast projectiles tunnelling with continuous collision', + ], + prerequisites: ['physics/physics-basics'], + apiLinks: ['joint', 'distance-joint', 'revolute-joint', 'weld-joint', 'prismatic-joint', 'wheel-joint', 'mouse-joint', 'physics-world'], + }, + ], + }, { slug: 'recipes', title: 'Recipes', @@ -649,6 +700,24 @@ const RAW_PARTS: ReadonlyArray = [ }, ], }, + { + slug: 'integrations', + title: 'Integrations', + description: 'Embed ExoJS in other ecosystems — starting with hosting an Application inside a React component tree.', + chapters: [ + { + slug: 'react', + level: 'intermediate', + learningGoals: [ + 'mount an ExoJS Application in a React tree with useExoApplication or ExoCanvas', + 'switch scenes declaratively with ', + 'read the running app and active scene from React overlays', + ], + prerequisites: ['runtime/scenes-and-lifecycle'], + apiLinks: ['application', 'scene'], + }, + ], + }, { slug: 'shipping', title: 'Shipping', diff --git a/tsconfig.guides.json b/tsconfig.guides.json index 48b43552..76943c09 100644 --- a/tsconfig.guides.json +++ b/tsconfig.guides.json @@ -43,7 +43,12 @@ "@codexo/exojs-tiled": ["./packages/exojs-tiled/src/index.ts"], "@codexo/exojs-tiled/register": ["./packages/exojs-tiled/src/register.ts"], "@codexo/exojs-tilemap": ["./packages/exojs-tilemap/src/index.ts"], - "@codexo/exojs-tilemap/register": ["./packages/exojs-tilemap/src/register.ts"] + "@codexo/exojs-tilemap/register": ["./packages/exojs-tilemap/src/register.ts"], + "@codexo/exojs-physics": ["./packages/exojs-physics/src/index.ts"], + "@codexo/exojs-aseprite": ["./packages/exojs-aseprite/src/index.ts"], + "@codexo/exojs-aseprite/register": ["./packages/exojs-aseprite/src/register.ts"], + "@codexo/exojs-ldtk": ["./packages/exojs-ldtk/src/index.ts"], + "@codexo/exojs-ldtk/register": ["./packages/exojs-ldtk/src/register.ts"] } }, "include": [".workspace/generated/guide-typecheck/**/*.ts", ".workspace/generated/guide-typecheck/**/*.js", "src/typings.d.ts"]