Let a behavior run while the editor is open
A behavior object carries a "Run in editor" switch next to Enabled. When it is on, the behavior ticks outside of Play and works on the saved model directly - Unity semantics: what it moves is a real change, autosaved and not undoable. Input stays with the editor, so On key never fires while typing. The editor run never bakes placed containers into the model, since that would silently drop their instanceEdits. syncToModel reports whether it wrote anything, and the dirty flag is only raised on change - otherwise a behavior moving something every frame would trigger ~60 saves a second.
This commit is contained in:
1 parent
c74037b6e1
commit
8f31afac37
21 files changed
+718
-35
No files matched your search
@@ -18,6 +18,7 @@ Live: <https://nockal.com/game-maker/> · Widget playground: <https://nockal.com
|
||||
| **Container** | A reusable group ("prefab"): the asset holds a **template** of objects (asset link, local position, overrides, parent links) centred on its origin. Placing a container stores the container object **and nothing else** – its content is resolved from the template on every read, so a template edit reaches every instance at once. **Create container** turns the current selection into such an asset: the selected objects move into the template and the scene keeps one placed container at their centre (world positions unchanged). The template is edited in the **Container** tab (see *Editing a container*) or from an instance through **Apply to container**. |
|
||||
| **Selection** | One or more scene objects. Click selects, `Ctrl`-click adds/removes, dragging on empty scene space draws a rubber band (objects whose position lies inside are selected). Dragging a selected object moves the whole selection; `Delete` removes it; the inspector offers **Create container** and **Remove** for several objects. |
|
||||
| **Behavior** | Logic as an asset: a **node graph** plus the **variables** it declares. Placed like anything else (a diamond symbol in the scene); its parent is the **owner** the graph acts on. |
|
||||
| **Run in editor** | A switch only behaviors have (default off). With it on the behavior keeps running while the project is **not** playing – and unlike Play it then edits the *saved* model: what it moves or writes is a real change, autosaved and not on the undo stack. See *Running without Play*. |
|
||||
| **Collider** | A shape (box or circle) for collision detection, placed as a **child** of the object it belongs to (its *body*). Drawn as a green outline in edit mode only. |
|
||||
| **Override** | A per-object value that replaces the asset's value for one property. Only changed keys are stored; the inspector paints their label gold and offers a reset (↺) and an **apply** (↑) that writes the value into the asset instead. Both appear only where the value really differs from its base – an override carrying the asset's own value is none. |
|
||||
| **Default** | Every property has a schema default. Asset values that differ from it are marked gold in the inspector and can be reset (↺) the same way an override can. The asset's name in the **Assets** panel carries the same gold while any of them does (`assetDeviates`, `src/core/properties/deviation.ts` – the inspector marks the rows of that same schema, so the list cannot claim a deviation the inspector then fails to show). A behavior's variables are not in it: their default is declared on the variable itself, so the asset value cannot differ from it. |
|
||||
@@ -45,6 +46,24 @@ the lists, objects a graph spawns (`Spawn` node) appear in them – selectable,
|
||||
only in the runtime copy –, and Esc/Stop brings back exactly what was saved. Autosave pauses
|
||||
while playing.
|
||||
|
||||
**Running without Play.** A behavior switched to **Run in editor** goes on running while the
|
||||
project is not playing (`src/editor/EditorRun.ts`, ticked by the Editor next to the play session).
|
||||
The switch is an ordinary property, so it can be set on the behavior asset and overridden per
|
||||
object. What it runs is a `World` over the **saved** model, and that is the whole difference to
|
||||
Play: a position or variable it changes is a real edit – it is autosaved, the panels show it, and
|
||||
**undo does not take it back**. Which is why the switch is off by default and why it belongs on
|
||||
behaviors that arrange or build content rather than on ones that simulate. What it does *not* get:
|
||||
|
||||
- **input** – the keyboard and the mouse stay with the editor, so `On key …`, `On click` and
|
||||
`Is key down` never fire; `On start`, `On update`, `On timer` and collisions do.
|
||||
- **the content of a placed container** – writing a container's content out as plain objects is
|
||||
itself an edit (see *Containers*), so the editor run instantiates without it, and a behavior
|
||||
inside a placed container does not run outside Play.
|
||||
- **a second life** – only the behaviors of the **loaded** scene run, and only in edit mode; Play
|
||||
stops the editor run, since that world runs every behavior anyway. An exception in a graph, or a
|
||||
`Stop game` node, ends the run and puts the reason in the notice line instead of repeating it
|
||||
every frame.
|
||||
|
||||
## Behaviors (node graphs)
|
||||
|
||||
A behavior asset holds a Blueprint-style graph edited in the **Graph** tab of the centre
|
||||
@@ -451,7 +470,7 @@ npm run format # Prettier over src, tests and tools
|
||||
npm run check # typecheck + lint + format:check + tests, what CI would run
|
||||
npm run build # writes dist/ and regenerates index.html
|
||||
export GM_REMOTE=user@host # where the browser runs; see below
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # 343 editor checks, screenshots in /tmp/gm-e2e-*.png
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # 353 editor checks, screenshots in /tmp/gm-e2e-*.png
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs # 24 checks: the example game, played end to end
|
||||
```
|
||||
|
||||
|
||||
@@ -84,8 +84,8 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
],
|
||||
},
|
||||
],
|
||||
// Behaviors declare their own properties (variables) per asset – see `schemaForAsset`.
|
||||
behavior: [],
|
||||
// Behaviors declare their own properties (variables) per asset too – see `schemaForAsset`.
|
||||
behavior: [{ key: 'runInEditor', label: 'Run in editor', kind: 'boolean', default: false }],
|
||||
collider: [
|
||||
{
|
||||
key: 'shape',
|
||||
@@ -133,9 +133,11 @@ export function schemaFor(type: AssetType): PropertySchema {
|
||||
return FULL_SCHEMAS[type];
|
||||
}
|
||||
|
||||
/** The type schema, or for behaviors their declared variables. */
|
||||
/** The type schema, for behaviors followed by their declared variables. */
|
||||
export function schemaForAsset(asset: Asset): PropertySchema {
|
||||
return asset.type === 'behavior' ? [...COMMON_PROPS, ...asset.variables] : FULL_SCHEMAS[asset.type];
|
||||
return asset.type === 'behavior'
|
||||
? [...FULL_SCHEMAS.behavior, ...asset.variables]
|
||||
: FULL_SCHEMAS[asset.type];
|
||||
}
|
||||
|
||||
export function propDef(asset: Asset, key: string): PropertyDef | undefined {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ancestorIdsOf, type ObjectTree } from '../model/hierarchy';
|
||||
import { instanceTargetOf, isEmptyEdit, resolveObjects } from '../model/instance';
|
||||
import { findAsset, type Project } from '../model/Project';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import { COMMON_PROPS, type PropertySchema, schemaForAsset } from './PropertySchema';
|
||||
import { type PropertySchema, schemaFor, schemaForAsset } from './PropertySchema';
|
||||
import { deviatesFromAsset, isModified } from './resolve';
|
||||
|
||||
/**
|
||||
@@ -11,7 +11,7 @@ import { deviatesFromAsset, isModified } from './resolve';
|
||||
* default is declared on the variable itself, so the asset value cannot differ from it.
|
||||
*/
|
||||
export function markedAssetSchema(asset: Asset): PropertySchema {
|
||||
return asset.type === 'behavior' ? COMMON_PROPS : schemaForAsset(asset);
|
||||
return asset.type === 'behavior' ? schemaFor('behavior') : schemaForAsset(asset);
|
||||
}
|
||||
|
||||
/** Whether any asset property the inspector would mark gold differs from its default. */
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* The `runInEditor` switch of a behavior (see `ASSET_SCHEMAS.behavior`). A behavior with it on
|
||||
* keeps running while the project is *not* playing, and then it edits the saved model: what it
|
||||
* moves or writes is a real change that is saved and is not on the undo stack. That is what makes
|
||||
* the switch useful (a behavior can build or arrange content) and why it is off by default.
|
||||
*/
|
||||
import type { ObjectTree } from '../model/hierarchy';
|
||||
import type { PropertyBag } from '../model/Properties';
|
||||
import { findAsset, type Project } from '../model/Project';
|
||||
import { resolveSceneObjectProps } from './resolve';
|
||||
|
||||
export function runsInEditor(props: PropertyBag): boolean {
|
||||
return props.runInEditor === true;
|
||||
}
|
||||
|
||||
/** Whether a holder contains any behavior asking to run outside play mode. */
|
||||
export function hasEditorRun(project: Project, holder: ObjectTree): boolean {
|
||||
return holder.objects.some((object) => {
|
||||
const asset = findAsset(project, object.assetId);
|
||||
return asset?.type === 'behavior' && runsInEditor(resolveSceneObjectProps(asset, object));
|
||||
});
|
||||
}
|
||||
+15
-1
@@ -7,6 +7,7 @@ import { GameViewport } from '../viewport/GameViewport';
|
||||
import { SceneViewport } from '../viewport/SceneViewport';
|
||||
import { attachAutosave } from './autosave';
|
||||
import { ContainerEditor } from './container/ContainerEditor';
|
||||
import { EditorRun } from './EditorRun';
|
||||
import { GraphEditor } from './graph/GraphEditor';
|
||||
import { Inspector } from './inspector/Inspector';
|
||||
import { AssetPanel } from './panels/AssetPanel';
|
||||
@@ -47,6 +48,7 @@ export class Editor implements PlayHost {
|
||||
/** Frame rate, refreshed twice a second; null until the first measurement. */
|
||||
fps: number | null = null;
|
||||
private readonly play: PlaySession;
|
||||
private readonly editorRun: EditorRun;
|
||||
private readonly projects: ProjectSession;
|
||||
private readonly leftColumn: VStack;
|
||||
private readonly inspector: Inspector;
|
||||
@@ -76,6 +78,7 @@ export class Editor implements PlayHost {
|
||||
this.hierarchy = new HierarchyPanel(state);
|
||||
this.inspector = new Inspector(state);
|
||||
this.play = new PlaySession(state, this);
|
||||
this.editorRun = new EditorRun(state);
|
||||
this.projects = new ProjectSession(state, store, () => {
|
||||
if (this.play.isPlaying) this.play.stop();
|
||||
});
|
||||
@@ -164,7 +167,12 @@ export class Editor implements PlayHost {
|
||||
)
|
||||
this.showTab('container');
|
||||
});
|
||||
state.on('project', () => this.play.syncFromModel());
|
||||
state.on('project', () => {
|
||||
this.play.syncFromModel();
|
||||
this.editorRun.invalidate();
|
||||
});
|
||||
state.on('load', () => this.editorRun.invalidate());
|
||||
state.on('mode', () => this.editorRun.invalidate());
|
||||
}
|
||||
|
||||
showTab(tab: CentreTab): void {
|
||||
@@ -204,6 +212,7 @@ export class Editor implements PlayHost {
|
||||
private tick(dt: number): void {
|
||||
this.measureFps(dt);
|
||||
this.play.tick(dt);
|
||||
this.editorRun.tick(dt);
|
||||
}
|
||||
|
||||
/** Refreshes the toolbar readout twice a second. */
|
||||
@@ -221,4 +230,9 @@ export class Editor implements PlayHost {
|
||||
worldSnapshot(): WorldSnapshot | null {
|
||||
return this.play.snapshot();
|
||||
}
|
||||
|
||||
/** What is running without Play, for the check scripts. */
|
||||
editorRunInfo(): { running: boolean; time: number; behaviors: string[] } {
|
||||
return this.editorRun.debugInfo();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Keeps the behaviors switched to "run in editor" running while the project is *not* playing.
|
||||
*
|
||||
* The one thing that makes this different from Play: the world is built from the saved model, not
|
||||
* from a copy. What such a behavior moves or writes is a real edit – it is saved and it is not on
|
||||
* the undo stack. The keyboard and the mouse stay with the editor, so input nodes never fire here.
|
||||
*/
|
||||
import { hasEditorRun, runsInEditor } from '../core/properties/editorRun';
|
||||
import { BehaviorSystem } from '../runtime/systems/BehaviorSystem';
|
||||
import { CollisionSystem } from '../runtime/systems/CollisionSystem';
|
||||
import { Input } from '../runtime/systems/Input';
|
||||
import { type ModelWrite, World } from '../runtime/World';
|
||||
import type { EditorState } from './state';
|
||||
|
||||
export class EditorRun {
|
||||
private world: World | null = null;
|
||||
private behaviors: BehaviorSystem | null = null;
|
||||
/** Never attached: the editor owns the keyboard, so `Is key down` is always false in here. */
|
||||
private readonly input = new Input();
|
||||
/** The model changed, so *whether* anything runs is open again, not just the world's contents. */
|
||||
private stale = true;
|
||||
/** Set while our own write travels through the listeners, so it does not count as a change. */
|
||||
private writing = false;
|
||||
|
||||
constructor(private readonly state: EditorState) {}
|
||||
|
||||
/** The model changed: a command, a load, or leaving play mode. */
|
||||
invalidate(): void {
|
||||
if (!this.writing) this.stale = true;
|
||||
}
|
||||
|
||||
tick(dt: number): void {
|
||||
if (this.state.mode !== 'edit') {
|
||||
// Playing runs every behavior anyway – and on the runtime copy, which this world is not on.
|
||||
this.stop();
|
||||
this.stale = true;
|
||||
return;
|
||||
}
|
||||
if (this.stale) {
|
||||
this.stale = false;
|
||||
this.refresh();
|
||||
}
|
||||
const world = this.world;
|
||||
if (!world) return;
|
||||
world.tick(Math.min(dt, 0.1));
|
||||
if (this.control(world)) return;
|
||||
this.commit(world.syncToModel());
|
||||
}
|
||||
|
||||
/** Facts for the check scripts: object ids of the behaviors currently running without Play. */
|
||||
debugInfo(): { running: boolean; time: number; behaviors: string[] } {
|
||||
return {
|
||||
running: this.world !== null,
|
||||
time: this.world?.time ?? 0,
|
||||
behaviors: this.behaviors?.runningIds() ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Builds, keeps or drops the world, depending on what the scene now asks for. */
|
||||
private refresh(): void {
|
||||
const scene = this.state.activeScene;
|
||||
if (!scene || !hasEditorRun(this.state.project, scene)) {
|
||||
this.stop();
|
||||
return;
|
||||
}
|
||||
const world = this.world;
|
||||
if (world && world.project === this.state.project && world.sceneId === scene.id) {
|
||||
world.syncFromModel();
|
||||
return;
|
||||
}
|
||||
this.stop();
|
||||
const project = this.state.project;
|
||||
// Baking a container's content into the scene would be an edit nobody asked for, so it is off
|
||||
// here – which is why a behavior inside a placed container does not run outside play mode.
|
||||
const fresh = World.instantiate(project, scene.id, { bakeContainers: false });
|
||||
const collisions = new CollisionSystem();
|
||||
this.behaviors = new BehaviorSystem(project, this.input, collisions, (e) => runsInEditor(e.props));
|
||||
fresh.addSystem(collisions);
|
||||
fresh.addSystem(this.behaviors);
|
||||
this.world = fresh;
|
||||
// `On start` already ran while the system started, and it may have failed.
|
||||
this.control(fresh);
|
||||
}
|
||||
|
||||
/** True when the run ended: a behavior failed, stopped the game, or asked to start over. */
|
||||
private control(world: World): boolean {
|
||||
if (world.restartRequested) {
|
||||
this.stop();
|
||||
this.stale = true;
|
||||
return true;
|
||||
}
|
||||
if (!world.stopRequested) return false;
|
||||
const error = world.log.find((line) => line.startsWith('Error in behavior'));
|
||||
this.stop();
|
||||
this.state.setNotice(error ?? 'A behavior running in the editor stopped it.');
|
||||
return true;
|
||||
}
|
||||
|
||||
private commit(write: ModelWrite): void {
|
||||
if (!write.values && !write.structure) return;
|
||||
this.writing = true;
|
||||
this.state.runtimeEdit();
|
||||
// Objects appeared or vanished: the panels have to rebuild, not just re-read their values.
|
||||
if (write.structure) this.state.preview();
|
||||
this.writing = false;
|
||||
}
|
||||
|
||||
private stop(): void {
|
||||
this.world?.destroy();
|
||||
this.world = null;
|
||||
this.behaviors = null;
|
||||
}
|
||||
}
|
||||
@@ -115,7 +115,7 @@ export class PlaySession {
|
||||
return;
|
||||
}
|
||||
// Mirror what the systems did into the runtime copy so the panels show live values.
|
||||
if (world.syncToModel()) this.state.preview();
|
||||
if (world.syncToModel().structure) this.state.preview();
|
||||
this.state.runtimeTick();
|
||||
if (this.host.graphEditor.visible) this.host.graphEditor.canvas.redraw();
|
||||
}
|
||||
|
||||
@@ -50,6 +50,30 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
walk(app.stage);
|
||||
return found;
|
||||
};
|
||||
/**
|
||||
* Centre of the control of an inspector row, addressed by its label. A row aligns its label to a
|
||||
* *minimum* width, so a label wider than the column pushes its control right – which is why a
|
||||
* fixed offset from the label's own text is the wrong way to click one.
|
||||
*/
|
||||
const fieldOf = (label: string): { x: number; y: number } | null => {
|
||||
let found: Text | null = null;
|
||||
const walk = (node: Container): void => {
|
||||
if (found !== null || !node.visible) return;
|
||||
if (node instanceof Text && node.text === label) {
|
||||
found = node;
|
||||
return;
|
||||
}
|
||||
for (const child of node.children) walk(child);
|
||||
};
|
||||
walk(app.stage);
|
||||
// TypeScript does not track the assignment inside `walk`, hence the cast.
|
||||
let owner: Container | null = (found as Text | null)?.parent ?? null;
|
||||
while (owner && !(owner instanceof Widget)) owner = owner.parent;
|
||||
const row = owner?.parent;
|
||||
if (!row || !owner) return null;
|
||||
const control = row.children[row.children.indexOf(owner) + 1];
|
||||
return control instanceof Widget ? centreOf(control) : null;
|
||||
};
|
||||
/**
|
||||
* The visible text of the overlay layer, in drawing order – everything an open popup shows.
|
||||
* Menus hide filtered-out entries, so this is also what a search box currently leaves.
|
||||
@@ -134,6 +158,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
objectsOf,
|
||||
sceneObjects: () => (state.activeScene ? objectsOf(state.activeScene.id) : []),
|
||||
locate,
|
||||
inspectorField: fieldOf,
|
||||
textColor,
|
||||
overlayTexts,
|
||||
overlayRect,
|
||||
@@ -188,6 +213,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
dropPreview: () => containerEditor.dropInfo(),
|
||||
},
|
||||
world: () => editor.worldSnapshot(),
|
||||
/** Behaviors running without Play (`Run in editor`), and how long they have been at it. */
|
||||
editorRun: () => editor.editorRunInfo(),
|
||||
/** Rows the hierarchy shows – collapsed subtrees are not in here. */
|
||||
hierarchyOrder: () => hierarchy.rowOrder(),
|
||||
hierarchyChevron: (id: string) => hierarchy.chevronPosition(id),
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
COMMON_PROPS,
|
||||
isCommonProp,
|
||||
type PropertyDef,
|
||||
schemaFor,
|
||||
schemaForAsset,
|
||||
} from '../../core/properties/PropertySchema';
|
||||
import {
|
||||
@@ -167,8 +168,13 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
|
||||
}
|
||||
|
||||
// The master switch, right where it is looked for: above everything type-specific.
|
||||
for (const def of COMMON_PROPS)
|
||||
propertyRow(host, holderId, objectId, asset, def, resolveSceneObjectProps(asset, object));
|
||||
const resolved = resolveSceneObjectProps(asset, object);
|
||||
for (const def of COMMON_PROPS) propertyRow(host, holderId, objectId, asset, def, resolved);
|
||||
// A behavior's own switches sit with it rather than in the section below: that one lists what
|
||||
// the behavior *declares*, and `Run in editor` is not something it declared.
|
||||
if (asset.type === 'behavior')
|
||||
for (const def of schemaFor('behavior').filter((d) => !isCommonProp(d.key)))
|
||||
propertyRow(host, holderId, objectId, asset, def, resolved);
|
||||
|
||||
addTitle(host, object.parentId ? 'POSITION (LOCAL TO PARENT)' : 'POSITION');
|
||||
for (const axis of POSITION_AXES) {
|
||||
@@ -230,10 +236,12 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
|
||||
});
|
||||
}
|
||||
|
||||
// Asset properties (or behavior variables) with overrides; the common switch is shown above.
|
||||
const schema = schemaForAsset(asset);
|
||||
// Asset properties (or behavior variables) with overrides; the switches are shown above.
|
||||
const values = resolveSceneObjectProps(asset, object);
|
||||
const own = schema.filter((def) => !isCommonProp(def.key));
|
||||
const own =
|
||||
asset.type === 'behavior'
|
||||
? asset.variables
|
||||
: schemaForAsset(asset).filter((def) => !isCommonProp(def.key));
|
||||
if (own.length > 0 || asset.type === 'behavior')
|
||||
addTitle(host, asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES');
|
||||
if (own.length === 0 && asset.type === 'behavior') addNote(host, 'The behavior declares no variables.');
|
||||
|
||||
@@ -201,6 +201,15 @@ export class EditorState {
|
||||
this.emit('runtime');
|
||||
}
|
||||
|
||||
/**
|
||||
* A behavior running outside play mode (see `EditorRun`) wrote into the saved model. The change
|
||||
* is saved like any other, but nothing was pushed onto the undo stack – it cannot be undone.
|
||||
*/
|
||||
runtimeEdit(): void {
|
||||
this.setSaveStatus('dirty');
|
||||
this.emit('runtime');
|
||||
}
|
||||
|
||||
private markDirty(): void {
|
||||
this.validateSelection();
|
||||
// Play-mode edits live in the runtime copy and are never saved.
|
||||
|
||||
+31
-10
@@ -13,6 +13,21 @@ import { resolveSceneObjectProps } from '../core/properties/resolve';
|
||||
import type { Entity } from './Entity';
|
||||
import type { System } from './systems/System';
|
||||
|
||||
export interface WorldOptions {
|
||||
/**
|
||||
* Whether a placed container's content may be written into the scene (see `syncFromModel`).
|
||||
* Baking is a real, irreversible edit, so the editor run switches it off – at the price of
|
||||
* behaviors inside a placed container not running there.
|
||||
*/
|
||||
bakeContainers?: boolean;
|
||||
}
|
||||
|
||||
/** What `syncToModel` wrote: `structure` = objects appeared or vanished, `values` = the rest. */
|
||||
export interface ModelWrite {
|
||||
structure: boolean;
|
||||
values: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The running game: entities instantiated from one scene plus the systems ticking them.
|
||||
*
|
||||
@@ -51,12 +66,13 @@ export class World {
|
||||
private constructor(
|
||||
readonly project: Project,
|
||||
readonly sceneId: string,
|
||||
private readonly bakeContainers: boolean,
|
||||
) {}
|
||||
|
||||
/** Builds a world from the given scene. Throws when the scene does not exist. */
|
||||
static instantiate(project: Project, sceneId: string): World {
|
||||
static instantiate(project: Project, sceneId: string, options: WorldOptions = {}): World {
|
||||
if (!findScene(project, sceneId)) throw new Error(`Scene ${sceneId} not found`);
|
||||
const world = new World(project, sceneId);
|
||||
const world = new World(project, sceneId, options.bakeContainers ?? true);
|
||||
world.syncFromModel();
|
||||
return world;
|
||||
}
|
||||
@@ -80,7 +96,7 @@ export class World {
|
||||
// Entities are built from stored objects, and a behavior may move or destroy one of them –
|
||||
// which content resolved from a container template could not remember. So it is written out
|
||||
// once, into the play-mode copy of the model that is thrown away afterwards.
|
||||
if (bakeInstances(this.project, scene)) this.structureChanged = true;
|
||||
if (this.bakeContainers && bakeInstances(this.project, scene)) this.structureChanged = true;
|
||||
const background = scene.props.backgroundColor;
|
||||
this.backgroundColor = typeof background === 'number' ? background : 0x000000;
|
||||
const keep = new Set<Entity>();
|
||||
@@ -163,12 +179,12 @@ export class World {
|
||||
/**
|
||||
* Writes the entities' state into the scene: positions, property values that differ from
|
||||
* what the model resolves (stored as overrides, so the inspector marks them) and removed
|
||||
* entities (their objects are dropped). Returns true when the object list changed – by a
|
||||
* destroyed entity here or by a `spawn` since the last call.
|
||||
* entities (their objects are dropped).
|
||||
*/
|
||||
syncToModel(): boolean {
|
||||
syncToModel(): ModelWrite {
|
||||
const scene = findScene(this.project, this.sceneId);
|
||||
if (!scene) return false;
|
||||
if (!scene) return { structure: false, values: false };
|
||||
let values = false;
|
||||
const spawned = this.structureChanged;
|
||||
this.structureChanged = false;
|
||||
const alive = new Set(this.entities.map((e) => e.objectId));
|
||||
@@ -183,13 +199,18 @@ export class World {
|
||||
const asset = object && findAsset(this.project, entity.assetId);
|
||||
if (!object || !asset) continue;
|
||||
const p = object.position;
|
||||
if (p.x !== entity.x || p.y !== entity.y || p.z !== entity.z)
|
||||
if (p.x !== entity.x || p.y !== entity.y || p.z !== entity.z) {
|
||||
object.position = { x: entity.x, y: entity.y, z: entity.z };
|
||||
values = true;
|
||||
}
|
||||
const base = resolveSceneObjectProps(asset, object);
|
||||
for (const [key, value] of Object.entries(entity.props))
|
||||
if (key in base && base[key] !== value) object.overrides[key] = value;
|
||||
if (key in base && base[key] !== value) {
|
||||
object.overrides[key] = value;
|
||||
values = true;
|
||||
}
|
||||
}
|
||||
return spawned || scene.objects.length !== before;
|
||||
return { structure: spawned || scene.objects.length !== before, values };
|
||||
}
|
||||
|
||||
// --- simulation ------------------------------------------------------------------------------
|
||||
|
||||
@@ -41,6 +41,11 @@ export class BehaviorSystem implements System {
|
||||
private readonly input: Input,
|
||||
/** Source of this frame's collision events; null = no collision detection. */
|
||||
private readonly collisions: CollisionSystem | null = null,
|
||||
/**
|
||||
* Which behaviors this system is responsible for. Playing runs them all; the editor run
|
||||
* takes only those marked "run in editor" (see `properties/editorRun.ts`).
|
||||
*/
|
||||
private readonly shouldRun: (entity: Entity) => boolean = () => true,
|
||||
) {}
|
||||
|
||||
start(world: World): void {
|
||||
@@ -66,6 +71,7 @@ export class BehaviorSystem implements System {
|
||||
if (
|
||||
entity.type !== 'behavior' ||
|
||||
!isHandleEnabled(entity) ||
|
||||
!this.shouldRun(entity) ||
|
||||
this.instances.some((i) => i.entity === entity)
|
||||
)
|
||||
continue;
|
||||
@@ -89,6 +95,7 @@ export class BehaviorSystem implements System {
|
||||
for (const instance of this.instances) {
|
||||
if (!world.entities.includes(instance.entity)) continue; // destroyed during play
|
||||
if (!isHandleEnabled(instance.entity)) continue; // switched off (itself or a parent)
|
||||
if (!this.shouldRun(instance.entity)) continue; // "run in editor" taken back while running
|
||||
this.run(
|
||||
instance,
|
||||
(i, ctx) => {
|
||||
@@ -141,6 +148,11 @@ export class BehaviorSystem implements System {
|
||||
fire('OnCollisionExit', this.collisions.exits);
|
||||
}
|
||||
|
||||
/** Object ids of the behaviors this system is currently running. */
|
||||
runningIds(): string[] {
|
||||
return this.instances.map((i) => i.entity.objectId);
|
||||
}
|
||||
|
||||
/** Live highlight data for the graph editor: merged over all instances of the asset. */
|
||||
traceFor(assetId: string): Trace {
|
||||
const merged: Trace = { edges: new Map(), nodes: new Map() };
|
||||
|
||||
@@ -21,6 +21,7 @@ import { descendantsOf, subtreeRoots, treeOrder } from '../../src/core/model/hie
|
||||
import { instanceChildId } from '../../src/core/model/instance';
|
||||
import { findAsset, findScene } from '../../src/core/model/Project';
|
||||
import { createSceneObject } from '../../src/core/model/SceneObject';
|
||||
import { World } from '../../src/runtime/World';
|
||||
import { makeSpriteScene, place, roundTrip, startWorld } from '../helpers';
|
||||
|
||||
/** Three boxes: a at (100,100,z1), b at (10,0) and c at (0,5) parented under b (local (-10,5)). */
|
||||
@@ -202,10 +203,24 @@ describe('placing a container', () => {
|
||||
const grandchild = world.entities.find((e) => e.parent && children.includes(e.parent))!;
|
||||
expect(worldPositionOf(children.find((e) => e.z === 1)!)).toEqual({ x: 545, y: 550, z: 1 });
|
||||
expect(worldPositionOf(grandchild)).toEqual({ x: 445, y: 455, z: 0 });
|
||||
expect(world.syncToModel()).toBe(true);
|
||||
expect(world.syncToModel().structure).toBe(true);
|
||||
// Play bakes the placed container into stored objects, and the spawn adds a second set.
|
||||
expect(f.sceneOf().objects).toHaveLength(8);
|
||||
});
|
||||
|
||||
it('leaves the instance alone when baking is off, so the editor run cannot spend it', () => {
|
||||
const f = templateSetup();
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, f.child(f.tA), 'width', 12));
|
||||
expect(f.sceneOf().objects[0]!.instanceEdits[f.tA]?.overrides.width).toBe(12);
|
||||
|
||||
// Baking would write the content out as plain objects and drop the edits – a real, silent
|
||||
// change to the saved project, which is why the editor run instantiates without it.
|
||||
const world = World.instantiate(f.project, f.sceneId, { bakeContainers: false });
|
||||
world.tick(1 / 60);
|
||||
expect(world.syncToModel().structure).toBe(false);
|
||||
expect(f.sceneOf().objects).toHaveLength(1);
|
||||
expect(f.sceneOf().objects[0]!.instanceEdits[f.tA]).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('editing a container template', () => {
|
||||
|
||||
@@ -38,7 +38,7 @@ function wallAndMover() {
|
||||
describe('the enabled switch', () => {
|
||||
it('is a common property of every placeable type, defaults to on and overrides per object', () => {
|
||||
expect(schemaFor('sprite')[0]).toMatchObject({ key: 'enabled', kind: 'boolean', default: true });
|
||||
expect(schemaFor('behavior').map((d) => d.key)).toEqual(['enabled']);
|
||||
expect(schemaFor('behavior').map((d) => d.key)).toEqual(['enabled', 'runInEditor']);
|
||||
expect(schemaFor('scene').some((d) => d.key === 'enabled')).toBe(false);
|
||||
|
||||
const f = makeSpriteScene();
|
||||
|
||||
@@ -307,7 +307,7 @@ describe('variable commands', () => {
|
||||
|
||||
stack.execute(new RenameVariableCommand(behavior.id, 'speed', 'velocity'));
|
||||
expect(behavior.variables[0]!.key).toBe('velocity');
|
||||
expect(behavior.props).toEqual({ enabled: true, velocity: 0 });
|
||||
expect(behavior.props).toEqual({ enabled: true, runInEditor: false, velocity: 0 });
|
||||
expect(object(logic).overrides).toEqual({ velocity: 5 });
|
||||
expect(findNode(behavior.graph, nodes.speed.id)!.params.variable).toBe('velocity');
|
||||
stack.undo();
|
||||
|
||||
@@ -58,7 +58,9 @@ describe('world ↔ model synchronisation (editing while playing)', () => {
|
||||
entityA.x = 123;
|
||||
entityA.props.width = 999; // what a Set property node would do
|
||||
entityA.props.height = 100; // unchanged → no override
|
||||
expect(world.syncToModel()).toBe(false);
|
||||
// No object appeared or vanished, but a position and an override were written – which is
|
||||
// what tells the editor run that it has something to save.
|
||||
expect(world.syncToModel()).toEqual({ structure: false, values: true });
|
||||
expect(object(a).position).toEqual({ x: 123, y: 0, z: 0 });
|
||||
expect(object(a).overrides).toEqual({ width: 999 });
|
||||
|
||||
@@ -74,7 +76,7 @@ describe('world ↔ model synchronisation (editing while playing)', () => {
|
||||
|
||||
// Destroy (node) removes the entity; the sync drops its object – a structural change.
|
||||
world.remove(world.findByObjectId(b)!);
|
||||
expect(world.syncToModel()).toBe(true);
|
||||
expect(world.syncToModel().structure).toBe(true);
|
||||
expect(sceneOf().objects.map((o) => o.id)).toEqual([a]);
|
||||
});
|
||||
|
||||
@@ -97,8 +99,8 @@ describe('world ↔ model synchronisation (editing while playing)', () => {
|
||||
expect(object.position).toEqual({ x: 10, y: 20, z: 1 });
|
||||
|
||||
// The spawn is a structural change the panels must see – reported once.
|
||||
expect(world.syncToModel()).toBe(true);
|
||||
expect(world.syncToModel()).toBe(false);
|
||||
expect(world.syncToModel()).toEqual({ structure: true, values: true });
|
||||
expect(world.syncToModel()).toEqual({ structure: false, values: false });
|
||||
// A full sync from the model keeps the spawned entity (identity included).
|
||||
world.syncFromModel();
|
||||
expect(world.findByObjectId(child.objectId)).toBe(child);
|
||||
@@ -106,7 +108,7 @@ describe('world ↔ model synchronisation (editing while playing)', () => {
|
||||
|
||||
// Destroying the spawned entity drops its object again.
|
||||
world.remove(child);
|
||||
expect(world.syncToModel()).toBe(true);
|
||||
expect(world.syncToModel().structure).toBe(true);
|
||||
expect(sceneOf().objects.some((o) => o.id === child.objectId)).toBe(false);
|
||||
|
||||
// Unknown or unplaceable assets spawn nothing.
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* "Run in editor": a behavior that keeps running while the project is not playing. Unlike play
|
||||
* mode this one edits the saved model, so these tests watch what lands in the scene itself.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
||||
import { ClearOverrideCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||||
import { hasEditorRun, runsInEditor } from '../../src/core/properties/editorRun';
|
||||
import { schemaFor } from '../../src/core/properties/PropertySchema';
|
||||
import { resolveSceneObjectProps } from '../../src/core/properties/resolve';
|
||||
import { findAsset } from '../../src/core/model/Project';
|
||||
import { EditorRun } from '../../src/editor/EditorRun';
|
||||
import { EditorState } from '../../src/editor/state';
|
||||
import { graphEditor, makeBehavior, makeSpriteScene, place, type SceneFixture } from '../helpers';
|
||||
|
||||
/** Wires `On update → Move(owner, dx = 1, scale = 100)`: the owner drifts 100 units per second. */
|
||||
function drift(f: SceneFixture, behavior: BehaviorAsset): void {
|
||||
const edit = graphEditor(f, behavior);
|
||||
const update = edit.addNode('OnUpdate');
|
||||
const owner = edit.addNode('Owner');
|
||||
const move = edit.addNode('Move', { dx: 1, scale: 100 });
|
||||
edit.wire([
|
||||
[update, 'exec', move, 'exec'],
|
||||
[update, 'dt', move, 'dt'],
|
||||
[owner, 'object', move, 'object'],
|
||||
]);
|
||||
}
|
||||
|
||||
/** A box with a drifting behavior below it, in a state the editor run can be pointed at. */
|
||||
function scene() {
|
||||
const f = makeSpriteScene('Box');
|
||||
const behavior = makeBehavior(f, 'Drift');
|
||||
drift(f, behavior);
|
||||
const box = place(f, f.spriteId, { x: 0, y: 0 });
|
||||
const logic = place(f, behavior.id, { x: 0, y: 0 }, box);
|
||||
|
||||
const state = new EditorState();
|
||||
state.loadProject(f.project, 1);
|
||||
const run = new EditorRun(state);
|
||||
return {
|
||||
...f,
|
||||
behavior,
|
||||
box,
|
||||
logic,
|
||||
state,
|
||||
run,
|
||||
/** One frame, after telling the run that the model may have changed (the Editor's wiring). */
|
||||
frame(dt = 1 / 60) {
|
||||
run.invalidate();
|
||||
run.tick(dt);
|
||||
},
|
||||
boxX: () => f.object(box).position.x,
|
||||
};
|
||||
}
|
||||
|
||||
describe('the run-in-editor switch', () => {
|
||||
it('is a behavior property, off unless a scene object asks for it', () => {
|
||||
const f = scene();
|
||||
expect(schemaFor('behavior').map((d) => d.key)).toContain('runInEditor');
|
||||
const asset = findAsset(f.project, f.behavior.id)!;
|
||||
|
||||
expect(runsInEditor(resolveSceneObjectProps(asset, f.object(f.logic)))).toBe(false);
|
||||
expect(hasEditorRun(f.project, f.sceneOf())).toBe(false);
|
||||
|
||||
f.state.execute(new SetOverrideCommand(f.sceneId, f.logic, 'runInEditor', true));
|
||||
expect(runsInEditor(resolveSceneObjectProps(asset, f.object(f.logic)))).toBe(true);
|
||||
expect(hasEditorRun(f.project, f.sceneOf())).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves everything alone until it is switched on', () => {
|
||||
const f = scene();
|
||||
for (let i = 0; i < 10; i++) f.frame();
|
||||
expect(f.run.debugInfo()).toEqual({ running: false, time: 0, behaviors: [] });
|
||||
expect(f.boxX()).toBe(0);
|
||||
});
|
||||
|
||||
it('drives the object in the saved model once it is on, and marks the project for saving', () => {
|
||||
const f = scene();
|
||||
f.state.execute(new SetOverrideCommand(f.sceneId, f.logic, 'runInEditor', true));
|
||||
// The command itself dirtied the project; the question is whether the *run* does too.
|
||||
f.state.setSaveStatus('saved');
|
||||
|
||||
f.frame(0.1);
|
||||
expect(f.run.debugInfo().behaviors).toEqual([f.logic]);
|
||||
expect(f.boxX()).toBeCloseTo(10);
|
||||
// Not a copy, not a preview: the stored scene object moved, and that has to be saved.
|
||||
expect(f.sceneOf().objects.find((o) => o.id === f.box)!.position.x).toBeCloseTo(10);
|
||||
expect(f.state.saveStatus).toBe('dirty');
|
||||
|
||||
f.frame(0.1);
|
||||
expect(f.boxX()).toBeCloseTo(20);
|
||||
});
|
||||
|
||||
it('runs only the behaviors that ask for it', () => {
|
||||
const f = scene();
|
||||
const second = makeBehavior(f, 'Idle');
|
||||
drift(f, second);
|
||||
place(f, second.id, { x: 0, y: 0 }, f.box);
|
||||
f.state.execute(new SetOverrideCommand(f.sceneId, f.logic, 'runInEditor', true));
|
||||
|
||||
f.frame(0.1);
|
||||
// Both behaviors own the same box: were the second one running too, it would drift twice as far.
|
||||
expect(f.run.debugInfo().behaviors).toEqual([f.logic]);
|
||||
expect(f.boxX()).toBeCloseTo(10);
|
||||
});
|
||||
|
||||
it('stops the moment the switch is taken back', () => {
|
||||
const f = scene();
|
||||
f.state.execute(new SetOverrideCommand(f.sceneId, f.logic, 'runInEditor', true));
|
||||
f.frame(0.1);
|
||||
expect(f.boxX()).toBeCloseTo(10);
|
||||
|
||||
f.state.execute(new ClearOverrideCommand(f.sceneId, f.logic, 'runInEditor'));
|
||||
f.frame(0.1);
|
||||
f.frame(0.1);
|
||||
expect(f.run.debugInfo().running).toBe(false);
|
||||
expect(f.boxX()).toBeCloseTo(10);
|
||||
});
|
||||
|
||||
it('holds still while the project is playing – that world runs every behavior already', () => {
|
||||
const f = scene();
|
||||
f.state.execute(new SetOverrideCommand(f.sceneId, f.logic, 'runInEditor', true));
|
||||
f.state.enterPlay();
|
||||
|
||||
f.frame(0.1);
|
||||
expect(f.run.debugInfo().running).toBe(false);
|
||||
|
||||
// The play copy carries the same ids, so the saved model must be the one left untouched.
|
||||
f.state.exitPlay();
|
||||
expect(f.boxX()).toBe(0);
|
||||
});
|
||||
|
||||
it('does not run a behavior whose scene is not the loaded one', () => {
|
||||
const f = scene();
|
||||
f.state.execute(new SetOverrideCommand(f.sceneId, f.logic, 'runInEditor', true));
|
||||
f.frame(0.1);
|
||||
expect(f.run.debugInfo().running).toBe(true);
|
||||
|
||||
const other = new CreateAssetCommand('scene', 'Level 2');
|
||||
f.state.execute(other);
|
||||
f.project.activeSceneId = other.asset.id;
|
||||
f.frame(0.1);
|
||||
f.frame(0.1);
|
||||
expect(f.run.debugInfo().running).toBe(false);
|
||||
expect(f.boxX()).toBeCloseTo(10);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
ein toggle bei einem behavior object in einer scene (passender name fehlt mir noch):
|
||||
- wenn der toggle aktiviert ist, soll das behavior außerhalb des play modes trotzdem laufen (sprich immer)
|
||||
- das behavior ist natürlich nicht aktiv wenn die scene nicht geladen ist wo sich das behavior befindet (sprich genauso wie im play mode, nur ohne play mode)
|
||||
|
||||
---
|
||||
|
||||
**Umgesetzt (2026-09-09).** Der Schalter heisst **`Run in editor`** und steht im Inspector eines
|
||||
Behavior-Objekts direkt unter `Enabled`. Ist er an, laeuft das Behavior weiter, waehrend das
|
||||
Projekt *nicht* spielt (`src/editor/EditorRun.ts`, vom Editor neben der Play-Session getickt).
|
||||
|
||||
## Die eine Entscheidung, die der Schalter erzwingt
|
||||
|
||||
Play arbeitet auf einer **Wegwerf-Kopie** — deshalb ist Spielen fuer das gespeicherte Projekt
|
||||
gefahrlos. Ausserhalb von Play gibt es diese Kopie nicht, und damit stand die Frage im Raum, was
|
||||
ein laufendes Behavior ueberhaupt anfassen darf. Entschieden wurde (Rueckfrage an den User) fuer
|
||||
die **Unity-Semantik** (`ExecuteInEditMode`):
|
||||
|
||||
> Was das Behavior bewegt oder schreibt, ist eine **echte Aenderung am gespeicherten Modell**.
|
||||
> Sie wird autosaved, die Panels zeigen sie — und **Undo holt sie nicht zurueck**.
|
||||
|
||||
Das macht den Schalter maechtig (ein Behavior kann Inhalt bauen oder anordnen) und ist genau der
|
||||
Grund, warum er **standardmaessig aus** ist. Der E2E-Check haelt beide Haelften fest: der Server
|
||||
liefert die verschobene Position aus, und nach zwei Undos ist der Schalter weg, die Bewegung nicht.
|
||||
|
||||
## Was der Editor-Lauf bewusst *nicht* bekommt
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Eingaben** | Tastatur und Maus bleiben beim Editor, sonst wuerde Tippen in einem Textfeld Behaviors ausloesen. `On key …`, `On click` und `Is key down` feuern hier nie; `On start`, `On update`, `On timer` und Kollisionen schon. |
|
||||
| **Container-Inhalt** | `syncFromModel` schreibt den Inhalt platzierter Container als normale Objekte aus (*baking*) und loescht dabei deren `instanceEdits` — im Editor waere das eine stille, irreversible Aenderung. Der Lauf instanziiert deshalb mit `bakeContainers: false`. **Preis:** ein Behavior *innerhalb* eines platzierten Containers laeuft ausserhalb von Play nicht. |
|
||||
| **ein zweites Leben** | Nur die Behaviors der **geladenen** Szene laufen, und nur im Edit-Modus. Play stoppt den Editor-Lauf — dort laeuft ohnehin jedes Behavior. Eine Exception im Graphen oder ein `Stop game`-Node beendet den Lauf und schreibt den Grund in die Notice-Zeile, statt ihn 60× pro Sekunde zu wiederholen. |
|
||||
|
||||
Der zweite Punkt der Notiz faellt damit von selbst heraus: die Welt wird aus der aktiven Szene
|
||||
gebaut, ein Behavior einer anderen Szene hat gar keine Entity. Beim Szenenwechsel wird der Lauf
|
||||
neu aufgebaut oder verworfen.
|
||||
|
||||
## Kein Save-Sturm
|
||||
|
||||
Ein Behavior, das jeden Frame etwas verschiebt, macht das Modell jeden Frame schmutzig — 60×
|
||||
pro Sekunde speichern waere die naheliegende Katastrophe. Zwei Dinge verhindern das:
|
||||
|
||||
- `syncToModel()` meldet jetzt, **ob** ueberhaupt geschrieben wurde (`{structure, values}` statt
|
||||
eines blossen `boolean`). Ein Behavior, das nur liest oder wartet, macht gar nichts schmutzig.
|
||||
- `setSaveStatus('dirty')` emittiert nur bei **Wechsel**, der Autosave-Timer wird also nicht jeden
|
||||
Frame neu gestellt. Gemessen: **2 Speicherungen in 2 Sekunden**, nicht ~120.
|
||||
|
||||
## Ein Darstellungsfehler, den erst der Screenshot gezeigt hat
|
||||
|
||||
`schemaForAsset` liefert fuer ein Behavior `COMMON_PROPS + Typschema + Variablen`. Der
|
||||
Object-Inspector zeigte alles ausser `enabled` unter der Ueberschrift **VARIABLES** — der neue
|
||||
Schalter landete also dort, wo nur steht, was das Behavior *deklariert*, und die Notiz „The
|
||||
behavior declares no variables." verschwand fuer immer. Beides ist repariert: die Typschema-Zeilen
|
||||
eines Behaviors stehen jetzt bei `Enabled`, die Sektion darunter fuehrt wieder nur die Variablen.
|
||||
Aufgefallen ist es nur, weil ich mir den Screenshot angesehen habe — die Checks waren zufrieden.
|
||||
|
||||
## Ein Messgeraet statt eines geratenen Offsets
|
||||
|
||||
Der erste Anlauf klickte die Checkbox ueber `locate('Enabled').x + 55`. Das trifft nur Zeilen,
|
||||
deren Label in die 64-px-Spalte passt — `Run in editor` ist breiter und schiebt seine Checkbox
|
||||
11 px nach rechts, also ging der Klick daneben und fuenf Checks fielen um. Neu ist deshalb
|
||||
`__gm.inspectorField(label)`: es liefert die Bildschirmmitte des **Controls** einer Inspector-
|
||||
Zeile, gefunden ueber sein Label. Damit ist jeder kuenftige Inspector-Check unabhaengig von der
|
||||
Labelbreite.
|
||||
|
||||
## Belegt
|
||||
|
||||
Acht Unit-Tests (`tests/editor/editor-run.test.ts`, plus einer in `container.test.ts` fuer das
|
||||
Nicht-Backen). Gegengeprueft: dreht man den Filter auf „alle Behaviors" und die Szenen-Bedingung
|
||||
weg, fallen **vier von sieben** um; mit `bakeContainers: true` faellt der Container-Test.
|
||||
Dazu zehn E2E-Checks in Modul 17:
|
||||
|
||||
```
|
||||
PASS a placed behavior does not run outside play mode until it is asked to
|
||||
– {"running":false,"time":0,"behaviors":[]} x=0
|
||||
PASS the inspector offers the switch on a behavior object, with Enabled and above the variables
|
||||
– {"enabledBox":{"x":1070,"y":221},"runBox":{"x":1081,"y":253},"variables":{…,"y":509.5}}
|
||||
PASS switching it on is an ordinary override on the object – {"runInEditor":true}
|
||||
PASS the behavior runs although Play was never pressed
|
||||
– {"running":true,"time":0.46,"behaviors":["er-logic"]} mode=edit
|
||||
PASS the owner drifts on, and the moved object is the stored one – x 48.33 → 108.32
|
||||
PASS what the behavior moved is saved, not just shown – stored x=308.31 (started at 0)
|
||||
PASS saving stays debounced while it runs, instead of once per frame – 2 saves in 2 s
|
||||
PASS taking the switch back stops the run and the object stands still – x=329.98
|
||||
PASS undo takes back the switch, never the movement it caused – overrides={} x=351.65, started at 0
|
||||
PASS the step leaves nothing of its own behind
|
||||
```
|
||||
|
||||
Der Schritt baut sich seine eigene Kiste samt Behavior (`On update → Move(owner)`) statt den
|
||||
Mover der Fixture zu nehmen — der laeuft ueber die Pfeiltasten, und die sieht der Editor-Lauf
|
||||
absichtlich nie. Am Ende raeumt er alles wieder weg.
|
||||
|
||||
Screenshot `36-editor-run`.
|
||||
@@ -1,3 +0,0 @@
|
||||
ein toggle bei einem behavior object in einer scene (passender name fehlt mir noch):
|
||||
- wenn der toggle aktiviert ist, soll das behavior außerhalb des play modes trotzdem laufen (sprich immer)
|
||||
- das behavior ist natürlich nicht aktiv wenn die scene nicht geladen ist wo sich das behavior befindet (sprich genauso wie im play mode, nur ohne play mode)
|
||||
@@ -0,0 +1,180 @@
|
||||
import { edge, node } from '../lib/builders.mjs';
|
||||
|
||||
/**
|
||||
* "Run in editor": a behavior that keeps running while the project is not playing, and whose
|
||||
* work lands in the saved project.
|
||||
*
|
||||
* The step brings its own box and its own behavior instead of using the fixture's Mover: that one
|
||||
* moves along the arrow keys, and the editor run deliberately never sees the keyboard. Everything
|
||||
* it adds is removed again at the end.
|
||||
*/
|
||||
|
||||
const DRIFT_PER_SECOND = 100;
|
||||
|
||||
/** `On update → Move(owner, dx = 1, scale = 100)`: the owner drifts right, without any input. */
|
||||
const driftGraph = {
|
||||
nodes: [
|
||||
node('d-update', 'OnUpdate', 0, 0),
|
||||
node('d-owner', 'Owner', 0, 120),
|
||||
node('d-move', 'Move', 340, 0, { dx: 1, scale: DRIFT_PER_SECOND }),
|
||||
],
|
||||
edges: [
|
||||
edge('d-e1', 'd-update', 'exec', 'd-move', 'exec'),
|
||||
edge('d-e2', 'd-update', 'dt', 'd-move', 'dt'),
|
||||
edge('d-e3', 'd-owner', 'object', 'd-move', 'object'),
|
||||
],
|
||||
};
|
||||
|
||||
export async function editorRun(t) {
|
||||
const { check, sleep, evaluate, json, shot, click, mutate, undo, object, api, projectId } = t;
|
||||
|
||||
// 18a. A box with a drifting behavior below it. Written into the model directly: what is under
|
||||
// test is the switch, not another way of building a graph.
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
const scene = __gm.state.activeScene;
|
||||
const proto = scene.objects[0];
|
||||
const asset = structuredClone(p.assets.find((a) => a.id === 'mover'));
|
||||
Object.assign(asset, {
|
||||
id: 'drifter',
|
||||
name: 'Drifter',
|
||||
props: { enabled: true, runInEditor: false },
|
||||
variables: [],
|
||||
graph: ${JSON.stringify(driftGraph)},
|
||||
});
|
||||
p.assets.push(asset);
|
||||
const make = (id, assetId, parentId, y) => {
|
||||
const o = structuredClone(proto);
|
||||
Object.assign(o, {
|
||||
id,
|
||||
assetId,
|
||||
name: id === 'er-box' ? 'Drift box' : 'Drifter',
|
||||
position: { x: 0, y, z: 0 },
|
||||
parentId,
|
||||
overrides: {},
|
||||
instanceEdits: {},
|
||||
});
|
||||
return o;
|
||||
};
|
||||
scene.objects.push(make('er-box', 'crate', '', 400), make('er-logic', 'drifter', 'er-box', 0));
|
||||
`);
|
||||
await sleep(400);
|
||||
|
||||
const boxX = async () => (await object('er-box')).position.x;
|
||||
const startX = await boxX();
|
||||
await sleep(500);
|
||||
const idle = await json('__gm.editorRun()');
|
||||
check(
|
||||
'a placed behavior does not run outside play mode until it is asked to',
|
||||
idle.running === false && (await boxX()) === startX,
|
||||
`${JSON.stringify(idle)} x=${startX}`,
|
||||
);
|
||||
|
||||
// 18b. The switch itself, through the inspector. It belongs with Enabled and above the
|
||||
// VARIABLES section: it is a switch of the behavior, not something the behavior declares.
|
||||
await evaluate(
|
||||
`__gm.state.select({ kind: 'object', holderId: __gm.state.project.activeSceneId, objectId: 'er-logic' })`,
|
||||
);
|
||||
await sleep(200);
|
||||
const enabledBox = await json(`__gm.inspectorField('Enabled')`);
|
||||
const runBox = await json(`__gm.inspectorField('Run in editor')`);
|
||||
const variables = await json(`__gm.locate('VARIABLES')`);
|
||||
check(
|
||||
'the inspector offers the switch on a behavior object, with Enabled and above the variables',
|
||||
Boolean(runBox) &&
|
||||
Boolean(enabledBox) &&
|
||||
Boolean(variables) &&
|
||||
runBox.y > enabledBox.y &&
|
||||
runBox.y < variables.y,
|
||||
JSON.stringify({ enabledBox, runBox, variables }),
|
||||
);
|
||||
await click(runBox.x, runBox.y);
|
||||
await sleep(300);
|
||||
|
||||
const logic = await object('er-logic');
|
||||
check(
|
||||
'switching it on is an ordinary override on the object',
|
||||
logic.overrides.runInEditor === true,
|
||||
JSON.stringify(logic.overrides),
|
||||
);
|
||||
|
||||
const running = await json('__gm.editorRun()');
|
||||
const mode = await evaluate('__gm.state.mode');
|
||||
check(
|
||||
'the behavior runs although Play was never pressed',
|
||||
running.running === true && running.behaviors.join() === 'er-logic' && mode === 'edit',
|
||||
`${JSON.stringify(running)} mode=${mode}`,
|
||||
);
|
||||
|
||||
// 18c. It really drives its owner, and it is the stored scene object that moves – no runtime
|
||||
// copy is involved here, which is the whole difference to playing.
|
||||
const before = await boxX();
|
||||
await sleep(600);
|
||||
const after = await boxX();
|
||||
check(
|
||||
'the owner drifts on, and the moved object is the stored one',
|
||||
after - before > DRIFT_PER_SECOND * 0.3 && (await evaluate('__gm.state.mode')) === 'edit',
|
||||
`x ${before} → ${after}`,
|
||||
);
|
||||
await shot('36-editor-run');
|
||||
|
||||
// 18d. The point of this semantic: the change is a real edit, so it reaches the server.
|
||||
const first = await api(`projects/${projectId}`);
|
||||
await sleep(2000);
|
||||
const second = await api(`projects/${projectId}`);
|
||||
const savedX = second.project.assets.find((a) => a.type === 'scene').objects.find((o) => o.id === 'er-box')
|
||||
.position.x;
|
||||
check(
|
||||
'what the behavior moved is saved, not just shown',
|
||||
savedX > startX,
|
||||
`stored x=${savedX} (started at ${startX})`,
|
||||
);
|
||||
// A behavior writing every frame must not turn into a save per frame. That it saves at all is
|
||||
// the check above; this one is only about the ceiling – 60 frames a second would be ~120.
|
||||
const saves = second.revision - first.revision;
|
||||
check(
|
||||
'saving stays debounced while it runs, instead of once per frame',
|
||||
saves <= 6,
|
||||
`${saves} saves in 2 s`,
|
||||
);
|
||||
|
||||
// 18e. Taking the switch back stops it again.
|
||||
await click(runBox.x, runBox.y);
|
||||
await sleep(300);
|
||||
const stoppedAt = await boxX();
|
||||
await sleep(500);
|
||||
const stopped = await json('__gm.editorRun()');
|
||||
check(
|
||||
'taking the switch back stops the run and the object stands still',
|
||||
stopped.running === false && (await boxX()) === stoppedAt,
|
||||
`${JSON.stringify(stopped)} x=${stoppedAt}`,
|
||||
);
|
||||
|
||||
// 18f. Undo covers the two clicks on the switch – but not a millimetre of what the behavior did
|
||||
// while it was on. That is the price of editing the model directly, better stated here than
|
||||
// discovered later. Note the first undo takes back the *switching off*, so the behavior
|
||||
// briefly runs again between the two: undo rewinds the switch, never the clock.
|
||||
await undo();
|
||||
await undo();
|
||||
await sleep(300);
|
||||
const afterUndo = await object('er-box');
|
||||
check(
|
||||
'undo takes back the switch, never the movement it caused',
|
||||
(await object('er-logic')).overrides.runInEditor === undefined &&
|
||||
afterUndo.position.x >= stoppedAt &&
|
||||
afterUndo.position.x > startX,
|
||||
`overrides=${JSON.stringify((await object('er-logic')).overrides)} x=${afterUndo.position.x}, started at ${startX}`,
|
||||
);
|
||||
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
const scene = __gm.state.activeScene;
|
||||
scene.objects = scene.objects.filter((o) => o.id !== 'er-box' && o.id !== 'er-logic');
|
||||
p.assets = p.assets.filter((a) => a.id !== 'drifter');
|
||||
`);
|
||||
await sleep(300);
|
||||
check(
|
||||
'the step leaves nothing of its own behind',
|
||||
(await json(`__gm.sceneObjects().filter((o) => o.id.startsWith('er-')).length`)) === 0,
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { frames } from './13-frames.mjs';
|
||||
import { overflow } from './14-overflow.mjs';
|
||||
import { aspect } from './15-aspect.mjs';
|
||||
import { containerTab } from './16-container.mjs';
|
||||
import { editorRun } from './17-editor-run.mjs';
|
||||
|
||||
export const STEPS = [
|
||||
hierarchy,
|
||||
@@ -37,4 +38,5 @@ export const STEPS = [
|
||||
overflow,
|
||||
aspect,
|
||||
containerTab,
|
||||
editorRun,
|
||||
];
|
||||
Reference in new issue
Block a user