@ferret/editor
Level editing as commands and patches, for people and for AI agents; sessions and packages.
Classes
EditError class
class EditError extends Errorconstructor(message: string, readonly issues: ManifestIssue[] = []) |
packages/editor/src/session.ts:9
EditSession class
class EditSessionAn editing session over one level's manifest.json: every change is a patch checked against the manifest schema (and the
behaviours' parameter schemas) before it stays, with undo and redo. scene.glb is never touched, so a re-export from Unreal
keeps all of it: the manifest refers to nodes by name.
draft: LevelManifest | |
constructor(manifest: LevelManifest, readonly opts: SessionOptions = {}) | |
get canUndo(): boolean | |
get canRedo(): boolean | |
get dirty(): boolean | |
history(): string[] | |
check(): ManifestIssue[] | Everything wrong with the draft: the manifest schema, then each entity's parameters against its behaviour's schema. |
apply(label: string, ops: Op[]): void | Apply a patch. If the result is not valid nothing changes and the problems come back in the error. |
undo(): string | null | |
redo(): string | null | |
get navBuildChanged(): boolean | Did the NavMesh settings change since the last save? Then navmesh.bin has to be built again. |
setNavBuild(patch: Record<string, number | null>) | Set the NavMesh build settings (merged into the current ones; null removes one). The file name defaults to navmesh.bin. |
changes(): Op[] | What differs from the saved state. |
markSaved() | |
text(): string | |
entity(id: string): ManifestEntity | undefined | |
addEntity(e: ManifestEntity) | |
removeEntity(id: string) | |
setEntity(id: string, patch: Partial<Omit<ManifestEntity, 'id'>> & { params?: Record<string, unknown>; transform?: ManifestEntity['transform'] }) | Change an entity: transform parts and params are merged key by key (null removes one), the rest replaced. |
duplicateEntity(id: string, newId: string, offset: [number, number, number] = [1, 0, 0]) | |
addSpawn(s: ManifestSpawn) | |
removeSpawn(id: string) | |
setSpawn(id: string, patch: Partial<Omit<ManifestSpawn, 'id'>>) | |
addZone(z: Record<string, unknown> & { name: string }) | |
removeZone(name: string) | |
setZone(name: string, patch: Record<string, unknown>) | |
physics(): Record<string, unknown> | |
setMaterial(name: string, values: PhysicsMaterial) | Add or replace a material of the level (its values are those of a PhysicsMaterial). |
removeMaterial(name: string) | |
setConstraint(k: LevelConstraint) | Add a link between two bodies (by the names of their nodes), or replace the one with the same id. |
removeConstraint(id: string) | |
setFracture(f: LevelFracture) | Say that a body (by its node) breaks into authored pieces (nodes). |
removeFracture(node: string) | |
chemistry(): Record<string, any> | |
setChemistryOption(key: 'sets' | 'aqueous' | 'standard' | 'phases', value: unknown) | Standard species sets, aqueous, standard and phases of the level's chemistry block (null removes). |
setChemistrySpecies(name: string, def: Record<string, unknown>) | Add or replace a species of the level: { formula?, phase?, Hf, S?, cp, ... }. |
removeChemistrySpecies(name: string) | |
setChemistryReaction(name: string, def: Record<string, unknown>) | Add or replace a reaction of the level: { equation, A, Ea, ... } or { reactants, products, A, Ea, ... }. |
removeChemistryReaction(name: string) | |
setPhysics(key: string, value: unknown) | Set a top-level key of the block (capabilities, gravity, a capability's values, combine, pairs); null removes it. |
setStreaming(block: StreamingBlock | null) | Replace the whole streaming block (null removes it, and the entities' needs with it being refused by the check if any remain). |
setStreamField(key: string, value: unknown) | Change one setting of the block (loadRadius, unloadRadius, lodRadius, cell, texturesProgressive); null removes an optional one. |
setStreamCell(id: string, cell: StreamCellDef | null) | Add or replace a cell (a cut made elsewhere); null removes it. |
setTags(tags: string[] | null) | |
setLevelData(key: string, value: unknown) | Set a key of the level's free data (null removes it). |
graph(name: string): GraphDef | |
setGraph(name: string, def: GraphDef) | |
removeGraph(name: string) | |
setState(graph: string, state: string, def: StateDef) | |
removeState(graph: string, state: string, withTransitions = false) | Remove a state. The transitions that start or end in it must go too (withTransitions), or the edit is refused. |
setInitial(graph: string, state: string) | |
addTransition(graph: string, t: TransitionDef, index?: number) | Add a transition at the end, or at index (the first transition whose conditions hold wins, so the order matters). |
setTransition(graph: string, index: number, patch: Partial<TransitionDef> & Record<string, unknown>) | Change fields of a transition (merged; null removes one): its target, conditions, cross-fade duration. |
removeTransition(graph: string, index: number) | |
setGraphParam(graph: string, name: string, kind: 'float' | 'bool' | 'trigger' | null) | Declare a parameter (float, bool or trigger), or drop it with null. |
setCollider(node: string, collide: boolean) | Make a node solid or walk-through whatever the export says. |
removeCollider(node: string) |
packages/editor/src/session.ts:30
Functions
applyOps function
function applyOps(root: any, ops: Op[]): Op[]Apply operations in order to root (changed in place) and return the operations that undo them.
packages/editor/src/patch.ts:38
diff function
function diff(a: unknown, b: unknown, path = ''): Op[]The operations that turn a into b (objects by key, lists by position). For showing what an edit session changed.
packages/editor/src/patch.ts:82
escapePointer function
function escapePointer: (s: string) => stringpackages/editor/src/patch.ts:11
get function
function get(root: unknown, path: string): unknownpackages/editor/src/patch.ts:31
installClipEditor function
function installClipEditor(ctx: ClipEditorContext): { editor(): ClipEditor | null; clip(): ClipJson | null }packages/editor/src/clip.ts:24
installEditor function
function installEditor(ctx: EditorContext): { session(): EditSession | null }The editing commands (V2) on top of the inspector's: open a level's manifest, change entities, spawns, zones and collision
overrides with undo and redo, validate against the schemas and the loaded scene, and save manifest.json. Every command is
JSON in and out, so a person's panel and an AI agent (over MCP) edit the same way. After each change the level is reloaded
from the draft, so what you see is what the game will load.
Commands: edit.open, edit.close, edit.get, edit.entity.add / set / remove / duplicate, edit.spawn.add / set / remove, edit.zone.add / set / remove, edit.navmesh.rebuild / info, edit.anim.graph / set / remove, edit.anim.state.set / remove, edit.anim.initial, edit.anim.transition.add / set / remove, edit.anim.param, edit.collider.set / remove, edit.colliders.missing, edit.physics.get / set / material.set / material.remove / constraint.set / constraint.remove / fracture.set / fracture.remove, edit.chemistry.get / option / species.set / species.remove / reaction.set / reaction.remove, edit.patch, edit.undo, edit.redo, edit.history, edit.validate, edit.diff, edit.save, edit.reload.
packages/editor/src/commands.ts:40
packageEditing function
function packageEditing(mount: PackageMount, o: { persist?: (pkgId: string) => Promise<unknown> } = {}): Pick<EditorContext, 'fetch' | 'save' | 'saveFile'>Editing a level that lives in a .ferret package (PLAN.md 4.9): the editor reads the level through the mount and its saves go into the package's draft, so the level reloads with them and pkg.pack / pkg.save write the result. Give the result to installEditor({ ..., ...packageEditing(mount) }); persist runs after each save (write the changed files, pack again).
packages/editor/src/package.ts:9
Interfaces
ClipEditorContext interface
interface ClipEditorContextClip editing (plans/demo_fight.md, D2) on top of the engine's command registry: the commands a person's panel and an agent over MCP share. One clip is open at a time; every change goes through ClipEditor (checked, with undo), and a preview, when the host gives one, shows the clip on a body.
edit.clip.new / open / get / save / close, edit.clip.undo / redo / history, edit.clip.key.set / remove / copy, edit.clip.mirror, edit.clip.notify.set / remove, edit.clip.retime, edit.clip.info, edit.clip.validate, edit.clip.preview.
world: World | |
rest(skeleton: string): Promise<Rest> | Rest | the rest pose of a skeleton (read from a body of the library: restOf(bonesOf(model.root)) of @ferret/animation) |
load?(id: string): Promise<ClipJson | undefined> | ClipJson | undefined | a saved or library clip by id (edit.clip.open, and base of edit.clip.new) |
save?(clip: ClipJson): Promise<void> | void | write the clip (edit.clip.save); without it the command only returns the JSON |
preview?(clip: ClipJson, a: { body?: string; time?: number; play?: boolean; loop?: boolean }): Promise<unknown> | unknown | show the clip on a body: a pose at time (seconds), or play it; the host owns the bodies |
positionBones?: (bone: string) => boolean |
packages/editor/src/clip.ts:11
EditorContext interface
interface EditorContextworld: World | |
levels: LevelService | |
loader: PackageLoaderOptions | the options given to installPackageLoader: the editor hooks its draft in so a reload shows the edit |
load?: (def: LevelDef) => Promise<LevelManifest> | read a level's manifest (default: fetch <url>/manifest.json) |
save?: (def: LevelDef, text: string) => Promise<void> | write a level's manifest.json; without it edit.save only returns the text |
saveFile?: (def: LevelDef, name: string, data: Uint8Array) => Promise<void> | write a binary file of the level's folder (navmesh.bin after a rebuild); without it a rebuilt NavMesh is not saved |
fetch?: (url: string) => Promise<{ ok: boolean; json(): Promise<any> }> | replace fetch for the default loader |
packages/editor/src/commands.ts:6
SessionOptions interface
interface SessionOptionsschema?: (behaviour: string) => ParamSchema | undefined | the parameter schema of a behaviour, if it has one |
known?: (name: string) => boolean | is the behaviour or component registered? (unknown ones are reported, not refused: the game may add them later) |
onChange?: (label: string) => void |
packages/editor/src/session.ts:13
Types
Op type
type Op = | { op: 'add'; path: string; value: unknown } | { op: 'replace'; path: string; value: unknown } | { op: 'remove'; path: string }JSON-pointer patches, the one way the editor changes a manifest: each edit is a list of operations, and applying it gives back the list that undoes it. Paths are RFC 6901 pointers ("/entities/2/params/angle"); "-" appends to a list.
packages/editor/src/patch.ts:5
ParamSchema type
type ParamSchema = Record<string, { type: 'number' | 'string' | 'boolean' | 'array' | 'object'; min?: number; max?: number; enum?: unknown[]; required?: boolean }>What a behaviour's parameters may be: a component class can describe them with static schema.
packages/editor/src/session.ts:7