Let a variable hold an object across behavior runs
game-maker / gate (push) Successful in 3m15s
game-maker / suite (push) Failing after 9m2s

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:
adminandClaude Opus 5.5 committed 2026-09-26 20:15:16 +02:00
1 parent 923baf2557
commit 98211a8d54
16 files changed
+375 -11

No files matched your search

+2
View File
@@ -12,6 +12,7 @@ import {
type PortDef,
type RepeatResult,
} from './NodeRegistry';
import { carryObjectSlots } from './objectSlots';
import { machineIdOf } from './stateMachine';
import type { Value } from './values';
@@ -58,6 +59,7 @@ export function resumedVariables(
const was = kept.includes(key) ? left?.[key] : undefined;
variables[key] = was !== undefined && typeof was === typeof value ? was : value;
}
if (left) carryObjectSlots(left, variables, kept);
return variables;
}
+2
View File
@@ -142,6 +142,8 @@ export interface NodeContext {
time: number;
dt: number;
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. */
childByName(parent: EntityHandle, name: string): EntityHandle | null;
isKeyDown(key: string): boolean;
+8 -3
View File
@@ -6,7 +6,8 @@ import {
propertyPort,
propertyTargetOf,
} from '../../propertyPorts';
import { asEntity, writeTyped } from '../../values';
import { readWithObjects, writeWithObjects } from '../../objectSlots';
import { asEntity } from '../../values';
import { exec, obj } from '../shared';
export const Properties: NodeDef = {
@@ -27,12 +28,16 @@ export const Properties: NodeDef = {
const shape = ports.some((p) => p.write) ? { inputs: [exec()], outputs: [exec()] } : {};
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) => {
const target = asEntity(inputs.object);
if (target)
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' };
},
};
+1 -1
View File
@@ -3,7 +3,7 @@ import { coerceValue, flatDef, type PropertyDef } from '../../../properties/Prop
import type { NodeDef } from '../../NodeRegistry';
/** 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 =
kind === 'list'
? { key: 'value', label: 'Value', kind, default: [] }
+4 -3
View File
@@ -1,7 +1,7 @@
import { collapsedTitle, portDefOf, VARIABLES, VARIABLES_SETTING, variablesSourceOf } from '../../nested';
import { type NodeDef, withInlineDefault } from '../../NodeRegistry';
import { readWithObjects, writeWithObjects } from '../../objectSlots';
import { variableBag } from '../../sharedState';
import { writeTyped } from '../../values';
import { exec } from '../shared';
/**
@@ -25,10 +25,11 @@ export const Variables: NodeDef = {
const shape = ports.some((p) => p.write) ? { inputs: [exec()], outputs: [exec()] } : {};
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) => {
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' };
},
};
+67
View File
@@ -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);
}