Let a variable hold an object across behavior runs
A spawned object was only reachable in the run that spawned it. The Spawn output could not go into a variable because "object" was a port-only kind, so the next run had no way back to the instance. - "Object" is now a variable kind. The reference lives for the run only. The file stores the declaration and an empty value. - The live reference sits in slots beside the variable bag (graph/objectSlots.ts), not in it. The bag is the behavior's props, and syncToModel would have written an object into the scene. - A destroyed object, or one from a restarted or left scene, reads as nothing. A dead reference is dropped from its slot. - Keep (#229) hands the slots of kept variables on to the next call of a sub-behavior. - Properties of kind Behavior reads and writes another behavior's object variable through the same slots. - The inspector shows "(null)" for it and offers no input. - A persistent Variables asset with an object variable shows a warning under "Persistent". Saved values never keep the object, every run starts it at (null). Objects inside lists and struct fields are left out on purpose. Emptiness is checked with Equals against an empty b. A node "Is valid" does not exist yet. The test helper graphEditor().variables() now also reads the kinds of a Variables asset a node points to. Tests: - ./check.sh: 169 files, 2463 tests, CHECK EXIT 0 - e2e 6 + 23: ALL PASSED, 146 checks (new 23k, 2 checks) - e2e 32: ALL PASSED, 18 checks (new 32f-ii) - 10 unit tests in tests/core/object-variable.test.ts, one of them runs a persistent asset over two runs - 5 counter-probes (value in bag, isAlive always true, no Keep hand-over, Properties without slots, object in saved values) each fail their test - e2e check total 1406, computed only Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
923baf2557
commit
98211a8d54
16 files changed
+375
-11
No files matched your search
@@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
|||||||
The browser suite and the three example games need a machine with a GPU:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1403 checks (GM_STEPS=20 for one step)
|
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1406 checks (GM_STEPS=20 for one step)
|
||||||
# every tool signs in first – see the Accounts page
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
type PortDef,
|
type PortDef,
|
||||||
type RepeatResult,
|
type RepeatResult,
|
||||||
} from './NodeRegistry';
|
} from './NodeRegistry';
|
||||||
|
import { carryObjectSlots } from './objectSlots';
|
||||||
import { machineIdOf } from './stateMachine';
|
import { machineIdOf } from './stateMachine';
|
||||||
import type { Value } from './values';
|
import type { Value } from './values';
|
||||||
|
|
||||||
@@ -58,6 +59,7 @@ export function resumedVariables(
|
|||||||
const was = kept.includes(key) ? left?.[key] : undefined;
|
const was = kept.includes(key) ? left?.[key] : undefined;
|
||||||
variables[key] = was !== undefined && typeof was === typeof value ? was : value;
|
variables[key] = was !== undefined && typeof was === typeof value ? was : value;
|
||||||
}
|
}
|
||||||
|
if (left) carryObjectSlots(left, variables, kept);
|
||||||
return variables;
|
return variables;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -142,6 +142,8 @@ export interface NodeContext {
|
|||||||
time: number;
|
time: number;
|
||||||
dt: number;
|
dt: number;
|
||||||
findByName(name: string): EntityHandle | null;
|
findByName(name: string): EntityHandle | null;
|
||||||
|
/** Whether the object is still in the game – false once destroyed, or once its world has ended. */
|
||||||
|
isAlive(target: EntityHandle): boolean;
|
||||||
/** The nearest object called `name` below `parent` – a child before a grandchild; null for none. */
|
/** The nearest object called `name` below `parent` – a child before a grandchild; null for none. */
|
||||||
childByName(parent: EntityHandle, name: string): EntityHandle | null;
|
childByName(parent: EntityHandle, name: string): EntityHandle | null;
|
||||||
isKeyDown(key: string): boolean;
|
isKeyDown(key: string): boolean;
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import {
|
|||||||
propertyPort,
|
propertyPort,
|
||||||
propertyTargetOf,
|
propertyTargetOf,
|
||||||
} from '../../propertyPorts';
|
} from '../../propertyPorts';
|
||||||
import { asEntity, writeTyped } from '../../values';
|
import { readWithObjects, writeWithObjects } from '../../objectSlots';
|
||||||
|
import { asEntity } from '../../values';
|
||||||
import { exec, obj } from '../shared';
|
import { exec, obj } from '../shared';
|
||||||
|
|
||||||
export const Properties: NodeDef = {
|
export const Properties: NodeDef = {
|
||||||
@@ -27,12 +28,16 @@ export const Properties: NodeDef = {
|
|||||||
const shape = ports.some((p) => p.write) ? { inputs: [exec()], outputs: [exec()] } : {};
|
const shape = ports.some((p) => p.write) ? { inputs: [exec()], outputs: [exec()] } : {};
|
||||||
return node.collapsed ? { ...shape, label: collapsedTitle(ports, 'properties') } : shape;
|
return node.collapsed ? { ...shape, label: collapsedTitle(ports, 'properties') } : shape;
|
||||||
},
|
},
|
||||||
read: (_ctx, _params, input) => asEntity(input('object'))?.props ?? {},
|
// A behavior's variables are its props, so an object variable is read from its slot there too.
|
||||||
|
read: (ctx, _params, input, def) => {
|
||||||
|
const target = asEntity(input('object'));
|
||||||
|
return target ? readWithObjects(target.props, def.outputs, ctx.isAlive) : {};
|
||||||
|
},
|
||||||
run: (_ctx, inputs, _params, def) => {
|
run: (_ctx, inputs, _params, def) => {
|
||||||
const target = asEntity(inputs.object);
|
const target = asEntity(inputs.object);
|
||||||
if (target)
|
if (target)
|
||||||
for (const port of def.inputs)
|
for (const port of def.inputs)
|
||||||
if (port.type !== 'exec' && port.key !== 'object') writeTyped(target.props, port.key, inputs[port.key]);
|
if (port.type !== 'exec' && port.key !== 'object') writeWithObjects(target.props, port, inputs[port.key]);
|
||||||
return { next: 'exec' };
|
return { next: 'exec' };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -3,7 +3,7 @@ import { coerceValue, flatDef, type PropertyDef } from '../../../properties/Prop
|
|||||||
import type { NodeDef } from '../../NodeRegistry';
|
import type { NodeDef } from '../../NodeRegistry';
|
||||||
|
|
||||||
/** A node handing out one constant of a kind: its one setting, edited as that kind, is its output. */
|
/** A node handing out one constant of a kind: its one setting, edited as that kind, is its output. */
|
||||||
function literal(type: string, kind: Exclude<VariableKind, 'struct'>, description: string): NodeDef {
|
function literal(type: string, kind: Exclude<VariableKind, 'struct' | 'object'>, description: string): NodeDef {
|
||||||
const value: PropertyDef =
|
const value: PropertyDef =
|
||||||
kind === 'list'
|
kind === 'list'
|
||||||
? { key: 'value', label: 'Value', kind, default: [] }
|
? { key: 'value', label: 'Value', kind, default: [] }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { collapsedTitle, portDefOf, VARIABLES, VARIABLES_SETTING, variablesSourceOf } from '../../nested';
|
import { collapsedTitle, portDefOf, VARIABLES, VARIABLES_SETTING, variablesSourceOf } from '../../nested';
|
||||||
import { type NodeDef, withInlineDefault } from '../../NodeRegistry';
|
import { type NodeDef, withInlineDefault } from '../../NodeRegistry';
|
||||||
|
import { readWithObjects, writeWithObjects } from '../../objectSlots';
|
||||||
import { variableBag } from '../../sharedState';
|
import { variableBag } from '../../sharedState';
|
||||||
import { writeTyped } from '../../values';
|
|
||||||
import { exec } from '../shared';
|
import { exec } from '../shared';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,10 +25,11 @@ export const Variables: NodeDef = {
|
|||||||
const shape = ports.some((p) => p.write) ? { inputs: [exec()], outputs: [exec()] } : {};
|
const shape = ports.some((p) => p.write) ? { inputs: [exec()], outputs: [exec()] } : {};
|
||||||
return node.collapsed ? { ...shape, label: collapsedTitle(ports, 'variables') } : shape;
|
return node.collapsed ? { ...shape, label: collapsedTitle(ports, 'variables') } : shape;
|
||||||
},
|
},
|
||||||
read: (ctx, params) => variableBag(ctx, variablesSourceOf(params)),
|
read: (ctx, params, _input, def) =>
|
||||||
|
readWithObjects(variableBag(ctx, variablesSourceOf(params)), def.outputs, ctx.isAlive),
|
||||||
run: (ctx, inputs, params, def) => {
|
run: (ctx, inputs, params, def) => {
|
||||||
const bag = variableBag(ctx, variablesSourceOf(params));
|
const bag = variableBag(ctx, variablesSourceOf(params));
|
||||||
for (const port of def.inputs) if (port.type !== 'exec') writeTyped(bag, port.key, inputs[port.key]);
|
for (const port of def.inputs) if (port.type !== 'exec') writeWithObjects(bag, port, inputs[port.key]);
|
||||||
return { next: 'exec' };
|
return { next: 'exec' };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { PropertyBag } from '../model/Properties';
|
||||||
|
import type { EntityHandle } from './NodeContext';
|
||||||
|
import type { PortDef } from './NodeRegistry';
|
||||||
|
import { asEntity, type Value, writeTyped } from './values';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The objects the object variables of a bag hold, beside the bag rather than in it: what sits in a
|
||||||
|
* bag reaches the scene (`World.syncToModel` writes a behavior's changed values back as overrides),
|
||||||
|
* the saves and the inspector, and none of them can keep a live handle. Keyed weakly by the bag, so
|
||||||
|
* a run that ends takes its objects along.
|
||||||
|
*/
|
||||||
|
const slots = new WeakMap<PropertyBag, Map<string, EntityHandle>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The object the variable holds, null for none – or for one destroyed since it was written, so a
|
||||||
|
* graph never goes on acting on an object that has left the game.
|
||||||
|
*/
|
||||||
|
export function readObjectSlot(
|
||||||
|
bag: PropertyBag,
|
||||||
|
key: string,
|
||||||
|
isAlive: (target: EntityHandle) => boolean,
|
||||||
|
): EntityHandle | null {
|
||||||
|
const held = slots.get(bag)?.get(key);
|
||||||
|
if (!held) return null;
|
||||||
|
if (isAlive(held)) return held;
|
||||||
|
slots.get(bag)!.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeObjectSlot(bag: PropertyBag, key: string, target: EntityHandle | null): void {
|
||||||
|
let held = slots.get(bag);
|
||||||
|
if (!target) {
|
||||||
|
held?.delete(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!held) slots.set(bag, (held = new Map<string, EntityHandle>()));
|
||||||
|
held.set(key, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hands the objects `keys` hold in `from` on to `to` – a kept variable of the next call (#229). */
|
||||||
|
export function carryObjectSlots(from: PropertyBag, to: PropertyBag, keys: readonly string[]): void {
|
||||||
|
const held = slots.get(from);
|
||||||
|
if (!held) return;
|
||||||
|
for (const key of keys) {
|
||||||
|
const target = held.get(key);
|
||||||
|
if (target && key in to) writeObjectSlot(to, key, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The bag as a graph reads it through `outputs`: an object port answers with the object held now. */
|
||||||
|
export function readWithObjects(
|
||||||
|
bag: PropertyBag,
|
||||||
|
outputs: readonly PortDef[],
|
||||||
|
isAlive: (target: EntityHandle) => boolean,
|
||||||
|
): Record<string, Value> {
|
||||||
|
const objects = outputs.filter((port) => port.type === 'object');
|
||||||
|
if (objects.length === 0) return bag;
|
||||||
|
const values: Record<string, Value> = { ...bag };
|
||||||
|
for (const port of objects) values[port.key] = readObjectSlot(bag, port.key, isAlive);
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes one value arriving on `port`: an object into its slot, anything else typed into the bag. */
|
||||||
|
export function writeWithObjects(bag: PropertyBag, port: PortDef, value: Value | undefined): void {
|
||||||
|
if (port.type === 'object') writeObjectSlot(bag, port.key, asEntity(value));
|
||||||
|
else writeTyped(bag, port.key, value);
|
||||||
|
}
|
||||||
@@ -176,7 +176,22 @@ export type PropertyDef =
|
|||||||
kind: 'struct';
|
kind: 'struct';
|
||||||
default: StructValue;
|
default: StructValue;
|
||||||
fields: readonly StructFieldDef[];
|
fields: readonly StructFieldDef[];
|
||||||
};
|
}
|
||||||
|
/**
|
||||||
|
* An object variable holds a live handle, which no file and no save can keep: the bag carries only
|
||||||
|
* this empty placeholder, and the handle sits beside it for the run (see `graph/objectSlots.ts`).
|
||||||
|
*/
|
||||||
|
| ObjectDef;
|
||||||
|
|
||||||
|
export interface ObjectDef {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
kind: 'object';
|
||||||
|
default: typeof NO_OBJECT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What an object variable stores – always nothing, since an object exists only while the game runs. */
|
||||||
|
export const NO_OBJECT = '';
|
||||||
|
|
||||||
export interface PrimitiveFieldDef {
|
export interface PrimitiveFieldDef {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -745,6 +760,8 @@ export function variableDef(
|
|||||||
const declared = fields ?? [];
|
const declared = fields ?? [];
|
||||||
return { key, label: key, kind, fields: declared, default: structOf(declared, defaultValue) };
|
return { key, label: key, kind, fields: declared, default: structOf(declared, defaultValue) };
|
||||||
}
|
}
|
||||||
|
case 'object':
|
||||||
|
return { key, label: key, kind, default: NO_OBJECT };
|
||||||
default:
|
default:
|
||||||
return flatDef(key, kind, { value: defaultValue });
|
return flatDef(key, kind, { value: defaultValue });
|
||||||
}
|
}
|
||||||
@@ -785,6 +802,8 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyVal
|
|||||||
return isList(value) ? value.map(listItemOf) : def.default;
|
return isList(value) ? value.map(listItemOf) : def.default;
|
||||||
case 'struct':
|
case 'struct':
|
||||||
return structOf(def.fields, value);
|
return structOf(def.fields, value);
|
||||||
|
case 'object':
|
||||||
|
return NO_OBJECT;
|
||||||
default:
|
default:
|
||||||
return coerceAs(value, def.kind, def.default);
|
return coerceAs(value, def.kind, def.default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,7 +158,8 @@ export const KINDS = [
|
|||||||
field: true,
|
field: true,
|
||||||
port: true,
|
port: true,
|
||||||
},
|
},
|
||||||
{ kind: 'object', label: 'Object', travels: 'object', port: true },
|
/** A variable of it holds a live object for the run only; nothing of it is stored (`graph/objectSlots.ts`). */
|
||||||
|
{ kind: 'object', label: 'Object', travels: 'object', variable: true, port: true },
|
||||||
{ kind: 'list', label: 'List', travels: 'list', variable: true, port: true },
|
{ kind: 'list', label: 'List', travels: 'list', variable: true, port: true },
|
||||||
{ kind: 'struct', label: 'Struct', travels: 'struct', variable: true, port: true },
|
{ kind: 'struct', label: 'Struct', travels: 'struct', variable: true, port: true },
|
||||||
// Schema-only kinds: a setting has them, nothing a user declares does.
|
// Schema-only kinds: a setting has them, nothing a user declares does.
|
||||||
|
|||||||
@@ -254,6 +254,11 @@ function buildPersistence(host: InspectorHost, asset: VariablesAsset): void {
|
|||||||
}
|
}
|
||||||
addNote(host, 'Kept between runs, in this browser.', 'small');
|
addNote(host, 'Kept between runs, in this browser.', 'small');
|
||||||
addNote(host, 'The next run starts where one ended.', 'small');
|
addNote(host, 'The next run starts where one ended.', 'small');
|
||||||
|
// An object is an instance of this run only: the next run has new ones, so there is nothing to keep.
|
||||||
|
if (asset.variables.some((def) => def.kind === 'object')) {
|
||||||
|
addNote(host, 'Object variables are not kept,', 'warning');
|
||||||
|
addNote(host, 'every run starts them at (null).', 'warning');
|
||||||
|
}
|
||||||
host.body.add(
|
host.body.add(
|
||||||
new Button('Clear saved values', {
|
new Button('Clear saved values', {
|
||||||
variant: 'ghost',
|
variant: 'ghost',
|
||||||
|
|||||||
@@ -453,6 +453,10 @@ export function createField(
|
|||||||
parts: new Map([...fields].map(([key, sub]) => [key, sub.widget])),
|
parts: new Map([...fields].map(([key, sub]) => [key, sub.widget])),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case 'object': {
|
||||||
|
// Nothing to pick: the object is written by a graph while the game runs, and never stored.
|
||||||
|
return { widget: new Label('(null)', { style: 'muted' }), grow: true, setValue: () => {} };
|
||||||
|
}
|
||||||
case 'boolean': {
|
case 'boolean': {
|
||||||
const field = new Checkbox(Boolean(value));
|
const field = new Checkbox(Boolean(value));
|
||||||
field.onChange = handlers.onChange;
|
field.onChange = handlers.onChange;
|
||||||
|
|||||||
@@ -662,6 +662,8 @@ export class BehaviorSystem implements System {
|
|||||||
time: world.time,
|
time: world.time,
|
||||||
dt,
|
dt,
|
||||||
findByName: (name) => world.findByName(name) ?? null,
|
findByName: (name) => world.findByName(name) ?? null,
|
||||||
|
// By identity, not by id: a restarted scene builds new entities under the same ids.
|
||||||
|
isAlive: (target) => world.entities.some((e) => e === target),
|
||||||
childByName: (parent, name) => {
|
childByName: (parent, name) => {
|
||||||
const found = resolve(parent);
|
const found = resolve(parent);
|
||||||
return (found && world.childByName(found, name)) ?? null;
|
return (found && world.childByName(found, name)) ?? null;
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
/**
|
||||||
|
* An object as a variable: a spawned object written into one is there on the next frame, a
|
||||||
|
* destroyed one reads as nothing, and no handle ever reaches the scene, the file or a save.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { SetVariablesPersistentCommand } from '../../src/core/commands/assetCommands';
|
||||||
|
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||||||
|
import { resumedVariables, type SaveArea, type SavedValues, VARIABLES_PARAM } from '../../src/core/graph';
|
||||||
|
import type { EntityHandle } from '../../src/core/graph/NodeContext';
|
||||||
|
import { readObjectSlot, writeObjectSlot } from '../../src/core/graph/objectSlots';
|
||||||
|
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||||||
|
import { findScene } from '../../src/core/model/Project';
|
||||||
|
import { isKindWith, VARIABLE_KINDS } from '../../src/core/properties/kinds';
|
||||||
|
import { serializeProject } from '../../src/core/store/serialize';
|
||||||
|
import {
|
||||||
|
graphEditor,
|
||||||
|
makeBehavior,
|
||||||
|
makeSpriteScene,
|
||||||
|
makeVariables,
|
||||||
|
place,
|
||||||
|
roundTrip,
|
||||||
|
type SceneFixture,
|
||||||
|
startWorld,
|
||||||
|
} from '../helpers';
|
||||||
|
|
||||||
|
/** Spawns a Crate on start into `spawned`, and logs what `spawned` holds on every update. */
|
||||||
|
function spawner(f: SceneFixture): BehaviorAsset {
|
||||||
|
const behavior = makeBehavior(f, 'Spawner');
|
||||||
|
f.stack.execute(new AddVariableCommand(behavior.id, 'spawned', 'object'));
|
||||||
|
const edit = graphEditor(f, behavior);
|
||||||
|
const spawn = edit.addNode('Spawn', { asset: f.spriteId });
|
||||||
|
const set = edit.variables(['spawned'], { write: true });
|
||||||
|
const get = edit.variables(['spawned']);
|
||||||
|
const log = edit.addNode('Log');
|
||||||
|
edit.wire([
|
||||||
|
[edit.addNode('OnStart'), 'exec', spawn, 'exec'],
|
||||||
|
[spawn, 'exec', set, 'exec'],
|
||||||
|
[spawn, 'object', set, 'spawned'],
|
||||||
|
[edit.addNode('OnUpdate'), 'exec', log, 'exec'],
|
||||||
|
[get, 'spawned', log, 'message'],
|
||||||
|
]);
|
||||||
|
place(f, behavior.id, { x: 0, y: 0 });
|
||||||
|
return behavior;
|
||||||
|
}
|
||||||
|
|
||||||
|
const crates = (f: SceneFixture, run: ReturnType<typeof startWorld>) =>
|
||||||
|
run.world.entities.filter((e) => e.assetId === f.spriteId);
|
||||||
|
|
||||||
|
describe('the object kind as a variable', () => {
|
||||||
|
it('can be declared, but has no field and no list item', () => {
|
||||||
|
expect(VARIABLE_KINDS).toContain('object');
|
||||||
|
expect(isKindWith('field', 'object')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is stored as its declaration only, with nothing as its value', () => {
|
||||||
|
const f = makeSpriteScene('Crate');
|
||||||
|
const behavior = makeBehavior(f, 'Holder');
|
||||||
|
f.stack.execute(new AddVariableCommand(behavior.id, 'target', 'object'));
|
||||||
|
const loaded = roundTrip(f.project).assets.find((a) => a.id === behavior.id) as BehaviorAsset;
|
||||||
|
expect(loaded.variables.find((v) => v.key === 'target')).toMatchObject({ kind: 'object', default: '' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('an object variable in a running game', () => {
|
||||||
|
it('still holds the spawned object on the next frames', () => {
|
||||||
|
const f = makeSpriteScene('Crate');
|
||||||
|
spawner(f);
|
||||||
|
const run = startWorld(f, { behaviors: true });
|
||||||
|
for (let i = 0; i < 3; i++) run.frame();
|
||||||
|
expect(crates(f, run)).toHaveLength(1);
|
||||||
|
expect(run.world.log.slice(-2)).toEqual(['Crate', 'Crate']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads as nothing once the object is destroyed', () => {
|
||||||
|
const f = makeSpriteScene('Crate');
|
||||||
|
spawner(f);
|
||||||
|
const run = startWorld(f, { behaviors: true });
|
||||||
|
run.frame();
|
||||||
|
run.world.remove(crates(f, run)[0]!);
|
||||||
|
run.frame();
|
||||||
|
expect(run.world.log.at(-1)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tells "gone" by Equals against an empty input', () => {
|
||||||
|
const f = makeSpriteScene('Crate');
|
||||||
|
const behavior = spawner(f);
|
||||||
|
const edit = graphEditor(f, behavior);
|
||||||
|
const get = edit.variables(['spawned']);
|
||||||
|
const gone = edit.addNode('Equals');
|
||||||
|
const log = edit.addNode('Log');
|
||||||
|
edit.wire([
|
||||||
|
[edit.addNode('OnUpdate'), 'exec', log, 'exec'],
|
||||||
|
[get, 'spawned', gone, 'a'],
|
||||||
|
[gone, 'result', log, 'message'],
|
||||||
|
]);
|
||||||
|
const run = startWorld(f, { behaviors: true });
|
||||||
|
run.frame();
|
||||||
|
const alive = run.world.log.at(-1);
|
||||||
|
run.world.remove(crates(f, run)[0]!);
|
||||||
|
run.frame();
|
||||||
|
expect([alive, run.world.log.at(-1)]).toEqual(['false', 'true']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never reaches the behavior’s bag, the scene or the file', () => {
|
||||||
|
const f = makeSpriteScene('Crate');
|
||||||
|
const behavior = spawner(f);
|
||||||
|
const run = startWorld(f, { behaviors: true });
|
||||||
|
run.frame();
|
||||||
|
run.frame();
|
||||||
|
run.world.syncToModel();
|
||||||
|
const instance = run.world.entities.find((e) => e.assetId === behavior.id)!;
|
||||||
|
expect(instance.props.spawned).toBe('');
|
||||||
|
const placed = findScene(f.project, f.sceneId)!.objects.find((o) => o.assetId === behavior.id)!;
|
||||||
|
expect('spawned' in placed.overrides).toBe(false);
|
||||||
|
expect(() => JSON.stringify(serializeProject(f.project))).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is read and written on another behavior through Properties', () => {
|
||||||
|
const f = makeSpriteScene('Crate');
|
||||||
|
const holder = makeBehavior(f, 'Holder');
|
||||||
|
f.stack.execute(new AddVariableCommand(holder.id, 'target', 'object'));
|
||||||
|
const writer = makeBehavior(f, 'Writer');
|
||||||
|
const edit = graphEditor(f, writer);
|
||||||
|
const find = edit.addNode('ObjectByName', { name: 'Holder' });
|
||||||
|
const crate = edit.addNode('ObjectByName', { name: 'Crate' });
|
||||||
|
const set = edit.properties([{ key: 'target', kind: 'object', write: true }], {
|
||||||
|
objectType: 'behavior',
|
||||||
|
objectBehavior: holder.id,
|
||||||
|
});
|
||||||
|
const get = edit.properties([{ key: 'target', kind: 'object' }], {
|
||||||
|
objectType: 'behavior',
|
||||||
|
objectBehavior: holder.id,
|
||||||
|
});
|
||||||
|
const log = edit.addNode('Log');
|
||||||
|
edit.wire([
|
||||||
|
[edit.addNode('OnStart'), 'exec', set, 'exec'],
|
||||||
|
[find, 'object', set, 'object'],
|
||||||
|
[crate, 'object', set, 'target'],
|
||||||
|
[edit.addNode('OnUpdate'), 'exec', log, 'exec'],
|
||||||
|
[find, 'object', get, 'object'],
|
||||||
|
[get, 'target', log, 'message'],
|
||||||
|
]);
|
||||||
|
place(f, holder.id, { x: 0, y: 0 });
|
||||||
|
place(f, writer.id, { x: 0, y: 0 });
|
||||||
|
place(f, f.spriteId, { x: 0, y: 0 });
|
||||||
|
const run = startWorld(f, { behaviors: true });
|
||||||
|
run.frame();
|
||||||
|
run.frame();
|
||||||
|
expect(run.world.log.at(-1)).toBe('Crate');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('an object variable in a persistent Variables asset', () => {
|
||||||
|
/** On start: log what `target` holds, then spawn a Crate into it and count `score` up. */
|
||||||
|
function persistent() {
|
||||||
|
const f = makeSpriteScene('Crate');
|
||||||
|
const shared = makeVariables(f, 'Progress', ['score']);
|
||||||
|
f.stack.execute(new AddVariableCommand(shared.id, 'target', 'object'));
|
||||||
|
f.stack.execute(new SetVariablesPersistentCommand(shared.id, true));
|
||||||
|
const behavior = makeBehavior(f, 'Spawner');
|
||||||
|
const edit = graphEditor(f, behavior);
|
||||||
|
const params = { [VARIABLES_PARAM]: shared.id };
|
||||||
|
const read = edit.variables(['score', 'target'], { params });
|
||||||
|
const log = edit.addNode('Log');
|
||||||
|
const spawn = edit.addNode('Spawn', { asset: f.spriteId });
|
||||||
|
const add = edit.addNode('Add', { b: 1 });
|
||||||
|
const write = edit.variables(['score', 'target'], { write: true, params });
|
||||||
|
edit.wire([
|
||||||
|
[edit.addNode('OnStart'), 'exec', log, 'exec'],
|
||||||
|
[read, 'target', log, 'message'],
|
||||||
|
[log, 'exec', spawn, 'exec'],
|
||||||
|
[spawn, 'exec', write, 'exec'],
|
||||||
|
[spawn, 'object', write, 'target'],
|
||||||
|
[read, 'score', add, 'a'],
|
||||||
|
[add, 'result', write, 'score'],
|
||||||
|
]);
|
||||||
|
place(f, behavior.id, { x: 0, y: 0 });
|
||||||
|
return { f, shared };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('is saved as nothing and starts the next run at nothing, beside the kept values', () => {
|
||||||
|
const { f, shared } = persistent();
|
||||||
|
let saved: SavedValues = {};
|
||||||
|
const area: SaveArea = { read: () => structuredClone(saved), write: (s) => (saved = structuredClone(s)) };
|
||||||
|
const first = startWorld(f, { behaviors: true, saves: area });
|
||||||
|
first.frame();
|
||||||
|
expect(first.world.log).toEqual(['']);
|
||||||
|
const held = readObjectSlot(first.behaviors!.shared.variables[shared.id]!, 'target', () => true);
|
||||||
|
expect(held?.name).toBe('Crate');
|
||||||
|
expect(saved).toEqual({ [shared.id]: { score: 1, target: '' } });
|
||||||
|
const second = startWorld(f, { behaviors: true, saves: area });
|
||||||
|
second.frame();
|
||||||
|
expect(second.world.log).toEqual(['']);
|
||||||
|
expect(second.behaviors!.shared.variables[shared.id]!.score).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('object slots', () => {
|
||||||
|
const handle = (name: string) => ({ name }) as unknown as EntityHandle;
|
||||||
|
|
||||||
|
it('hand a kept object on to the next call, and only a kept one', () => {
|
||||||
|
const left = { target: '', other: '' };
|
||||||
|
const crate = handle('Crate');
|
||||||
|
writeObjectSlot(left, 'target', crate);
|
||||||
|
writeObjectSlot(left, 'other', crate);
|
||||||
|
const next = resumedVariables({ target: '', other: '' }, left, ['target']);
|
||||||
|
expect(readObjectSlot(next, 'target', () => true)).toBe(crate);
|
||||||
|
expect(readObjectSlot(next, 'other', () => true)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forget an object that is gone, even when it would come back under the same id', () => {
|
||||||
|
const bag = { target: '' };
|
||||||
|
writeObjectSlot(bag, 'target', handle('Crate'));
|
||||||
|
expect(readObjectSlot(bag, 'target', () => false)).toBeNull();
|
||||||
|
expect(readObjectSlot(bag, 'target', () => true)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
+6
-1
@@ -20,6 +20,7 @@ import {
|
|||||||
portKindOf,
|
portKindOf,
|
||||||
PROPERTIES,
|
PROPERTIES,
|
||||||
VARIABLES,
|
VARIABLES,
|
||||||
|
VARIABLES_PARAM,
|
||||||
} from '../src/core/graph';
|
} from '../src/core/graph';
|
||||||
import type { BehaviorAsset, SceneAsset, VariablesAsset } from '../src/core/model/Asset';
|
import type { BehaviorAsset, SceneAsset, VariablesAsset } from '../src/core/model/Asset';
|
||||||
import { findObject } from '../src/core/model/hierarchy';
|
import { findObject } from '../src/core/model/hierarchy';
|
||||||
@@ -154,7 +155,10 @@ export function graphEditor(fixture: ProjectFixture, behavior: BehaviorAsset): G
|
|||||||
stack.execute(command);
|
stack.execute(command);
|
||||||
const writes = (key: string) => write === true || (Array.isArray(write) && write.includes(key));
|
const writes = (key: string) => write === true || (Array.isArray(write) && write.includes(key));
|
||||||
const ports = keys.map((key): DeclaredPort => {
|
const ports = keys.map((key): DeclaredPort => {
|
||||||
const declared = behavior.variables.find((v) => v.key === key);
|
// A node pointed at a Variables asset declares that asset's variables, not the behavior's.
|
||||||
|
const source = fixture.project.assets.find((a) => a.id === params[VARIABLES_PARAM]);
|
||||||
|
const declarations = source?.type === 'variables' ? source.variables : behavior.variables;
|
||||||
|
const declared = declarations.find((v) => v.key === key);
|
||||||
const kind = declared ? portKindOf(declared) : 'number';
|
const kind = declared ? portKindOf(declared) : 'number';
|
||||||
return writes(key) ? { key, kind, write: true } : { key, kind };
|
return writes(key) ? { key, kind, write: true } : { key, kind };
|
||||||
});
|
});
|
||||||
@@ -224,6 +228,7 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
|
|||||||
logs,
|
logs,
|
||||||
stopped: false,
|
stopped: false,
|
||||||
findByName: () => null,
|
findByName: () => null,
|
||||||
|
isAlive: () => true,
|
||||||
childByName: () => null,
|
childByName: () => null,
|
||||||
isKeyDown: () => false,
|
isKeyDown: () => false,
|
||||||
pointer: { ...NO_POINTER },
|
pointer: { ...NO_POINTER },
|
||||||
|
|||||||
@@ -318,6 +318,24 @@ export async function variableNodes(t) {
|
|||||||
`vz = ${counted} after ~1 s`,
|
`vz = ${counted} after ~1 s`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 23k. An object variable: declared from the menu like any other, but with nothing to type – it is
|
||||||
|
// set by a graph while the game runs, so the row says (null) and the file keeps no value.
|
||||||
|
await t.select({ kind: 'asset', assetId: 'vp-logic' });
|
||||||
|
await clickLast('+ Variable');
|
||||||
|
await clickLast('Object');
|
||||||
|
const declared = (await asset('vp-logic')).variables.at(-1);
|
||||||
|
check(
|
||||||
|
'an Object variable is declared from the menu and stores nothing as its value',
|
||||||
|
declared?.kind === 'object' && declared.default === '' && (await has('(null)')),
|
||||||
|
JSON.stringify(declared),
|
||||||
|
);
|
||||||
|
await shot('06-object-variable');
|
||||||
|
await undo();
|
||||||
|
check(
|
||||||
|
'undo takes it back',
|
||||||
|
(await asset('vp-logic')).variables.every((v) => v.kind !== 'object'),
|
||||||
|
);
|
||||||
|
|
||||||
await mutate(`
|
await mutate(`
|
||||||
const p = __gm.state.project;
|
const p = __gm.state.project;
|
||||||
p.assets = p.assets.filter((a) => a.id !== 'vp-logic');
|
p.assets = p.assets.filter((a) => a.id !== 'vp-logic');
|
||||||
|
|||||||
@@ -176,6 +176,22 @@ export async function scope(t) {
|
|||||||
);
|
);
|
||||||
await shot('04-persistent');
|
await shot('04-persistent');
|
||||||
|
|
||||||
|
// 32f-ii. An object variable in a persistent asset is allowed, but is never saved: the inspector
|
||||||
|
// says so once there is one, and the note goes again with it.
|
||||||
|
const objectNote = 'Object variables are not kept,';
|
||||||
|
const noteBefore = await has(objectNote);
|
||||||
|
await clickLast('+ Variable');
|
||||||
|
await clickLast('Object');
|
||||||
|
const noteWith = await has(objectNote);
|
||||||
|
await shot('04b-persistent-object');
|
||||||
|
await t.undo();
|
||||||
|
const noteAfter = await has(objectNote);
|
||||||
|
check(
|
||||||
|
'a persistent asset with an Object variable notes that the object is not kept',
|
||||||
|
!noteBefore && noteWith && !noteAfter && (await asset('sc-vars')).variables.length === 1,
|
||||||
|
JSON.stringify({ noteBefore, noteWith, noteAfter }),
|
||||||
|
);
|
||||||
|
|
||||||
const scoreOfRun = async () => {
|
const scoreOfRun = async () => {
|
||||||
await t.play();
|
await t.play();
|
||||||
await sleep(600);
|
await sleep(600);
|
||||||
|
|||||||
Reference in new issue
Block a user