Let a value be a bundle of named fields
PropertyValue was number | string | boolean | list. A struct joins it, which costs two things the typechecker found rather than I did. An object on a data edge used to mean a live entity, so asEntity would have taken a struct for one and asString would have read .name off it. EntityHandle now carries a symbol brand – a symbol so no field name can impersonate it, and it never serializes, which suits a thing that exists only while the game runs. It is built in exactly one place. String(value) would have written a struct as [object Object] at 39 call sites. They go through textOf(), which is identical for primitives. normalizeGraph dropped everything but primitives from node params, so the List node lost its value on every save ([3,1,4] round-tripped to undefined). Variables were spared: they have a schema, params have none.
This commit is contained in:
1 parent
3b3f374e10
commit
2cf788eedd
34 files changed
+284
-82
No files matched your search
@@ -1,7 +1,7 @@
|
|||||||
import type { PropertyBag } from '../model/Properties';
|
import type { PropertyBag } from '../model/Properties';
|
||||||
|
|
||||||
/** `exec` carries control flow, the others values; `any` accepts every value type. */
|
/** `exec` carries control flow, the others values; `any` accepts every value type. */
|
||||||
export type PortType = 'exec' | 'number' | 'boolean' | 'string' | 'object' | 'list' | 'any';
|
export type PortType = 'exec' | 'number' | 'boolean' | 'string' | 'object' | 'list' | 'struct' | 'any';
|
||||||
|
|
||||||
export interface PortRef {
|
export interface PortRef {
|
||||||
node: string;
|
node: string;
|
||||||
@@ -19,8 +19,9 @@ export interface GraphNode {
|
|||||||
y: number;
|
y: number;
|
||||||
params: PropertyBag;
|
params: PropertyBag;
|
||||||
/**
|
/**
|
||||||
* Ports the node declares itself, on top of the fixed ones of its type. Only the behavior
|
* Ports the node declares itself, on top of the fixed ones of its type; the types that have
|
||||||
* start/end nodes carry this – they are what gives a nested behavior its signature.
|
* them say so with `NodeDef.declaresPorts`. A behavior's start/end node is what gives the
|
||||||
|
* behavior its signature; an Expand/Pack node is what gives a struct its fields.
|
||||||
*/
|
*/
|
||||||
ports?: DeclaredPort[];
|
ports?: DeclaredPort[];
|
||||||
}
|
}
|
||||||
@@ -31,7 +32,7 @@ export interface DeclaredPort {
|
|||||||
kind: DeclaredPortKind;
|
kind: DeclaredPortKind;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DECLARED_PORT_KINDS = ['number', 'boolean', 'text', 'object', 'list'] as const;
|
export const DECLARED_PORT_KINDS = ['number', 'boolean', 'text', 'object', 'list', 'struct'] as const;
|
||||||
export type DeclaredPortKind = (typeof DECLARED_PORT_KINDS)[number];
|
export type DeclaredPortKind = (typeof DECLARED_PORT_KINDS)[number];
|
||||||
|
|
||||||
export function declaredPortType(kind: DeclaredPortKind): PortType {
|
export function declaredPortType(kind: DeclaredPortKind): PortType {
|
||||||
|
|||||||
@@ -2,11 +2,19 @@ import type { PropertyBag } from '../model/Properties';
|
|||||||
import { sumAlongChain } from '../model/walk';
|
import { sumAlongChain } from '../model/walk';
|
||||||
import { enabledAlongChain } from '../properties/enabled';
|
import { enabledAlongChain } from '../properties/enabled';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks a live entity apart from a struct value: both reach a data edge as an object. A symbol
|
||||||
|
* rather than a field, so no struct field name can impersonate it – and it never serializes,
|
||||||
|
* which is right for a thing that exists only while the game runs.
|
||||||
|
*/
|
||||||
|
export const ENTITY_HANDLE: unique symbol = Symbol('entity handle');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What a graph sees of a running entity; the runtime's Entity satisfies this shape. Mutating
|
* What a graph sees of a running entity; the runtime's Entity satisfies this shape. Mutating
|
||||||
* `x`/`y`/`props` is how actions take effect.
|
* `x`/`y`/`props` is how actions take effect.
|
||||||
*/
|
*/
|
||||||
export interface EntityHandle {
|
export interface EntityHandle {
|
||||||
|
readonly [ENTITY_HANDLE]: true;
|
||||||
id: string;
|
id: string;
|
||||||
assetId: string;
|
assetId: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -93,9 +93,12 @@ export function hasExecInput(def: NodeDef): boolean {
|
|||||||
return def.inputs.some((p) => p.type === 'exec');
|
return def.inputs.some((p) => p.type === 'exec');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Data inputs that may carry an inline value while unconnected. */
|
/**
|
||||||
|
* Data inputs that may carry an inline value while unconnected. An object is a live handle and a
|
||||||
|
* struct is assembled from its parts, so neither has a value that could be typed on one line.
|
||||||
|
*/
|
||||||
export function inlineInputs(def: NodeDef): PortDef[] {
|
export function inlineInputs(def: NodeDef): PortDef[] {
|
||||||
return def.inputs.filter((p) => p.type !== 'exec' && p.type !== 'object');
|
return def.inputs.filter((p) => p.type !== 'exec' && p.type !== 'object' && p.type !== 'struct');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function inlineInputDef(port: PortDef): PropertyDef {
|
export function inlineInputDef(port: PortDef): PropertyDef {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { PropertyBag } from '../model/Properties';
|
import { type PropertyBag, textOf } from '../model/Properties';
|
||||||
import { type DeclaredPort, declaredPortType, type Graph, type GraphNode } from './Graph';
|
import { type DeclaredPort, declaredPortType, type Graph, type GraphNode } from './Graph';
|
||||||
import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry';
|
import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry';
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ export function resolveDef(node: GraphNode, lookup: BehaviorLookup): NodeDef | u
|
|||||||
*/
|
*/
|
||||||
export function callProblem(node: GraphNode, lookup: BehaviorLookup, ownerId = ''): string | null {
|
export function callProblem(node: GraphNode, lookup: BehaviorLookup, ownerId = ''): string | null {
|
||||||
if (node.type !== RUN_BEHAVIOR) return null;
|
if (node.type !== RUN_BEHAVIOR) return null;
|
||||||
const id = String(node.params[BEHAVIOR_PARAM] ?? '');
|
const id = textOf(node.params[BEHAVIOR_PARAM] ?? '');
|
||||||
if (!id) return 'No behavior picked';
|
if (!id) return 'No behavior picked';
|
||||||
if (id === ownerId) return 'A behavior cannot run itself';
|
if (id === ownerId) return 'A behavior cannot run itself';
|
||||||
const program = lookup(id);
|
const program = lookup(id);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { NodeDef } from '../../NodeRegistry';
|
|||||||
import { asEntity, writeTyped } from '../../values';
|
import { asEntity, writeTyped } from '../../values';
|
||||||
import { any, exec, obj } from '../shared';
|
import { any, exec, obj } from '../shared';
|
||||||
|
|
||||||
|
import { textOf } from '../../../model/Properties';
|
||||||
export const SetProperty: NodeDef = {
|
export const SetProperty: NodeDef = {
|
||||||
type: 'SetProperty',
|
type: 'SetProperty',
|
||||||
label: 'Set property',
|
label: 'Set property',
|
||||||
@@ -12,7 +13,7 @@ export const SetProperty: NodeDef = {
|
|||||||
params: [{ key: 'property', label: 'Property', kind: 'text', default: 'width' }],
|
params: [{ key: 'property', label: 'Property', kind: 'text', default: 'width' }],
|
||||||
run: (_ctx, inputs, params) => {
|
run: (_ctx, inputs, params) => {
|
||||||
const target = asEntity(inputs.object);
|
const target = asEntity(inputs.object);
|
||||||
const key = String(params.property ?? '');
|
const key = textOf(params.property ?? '');
|
||||||
if (target && key) writeTyped(target.props, key, inputs.value);
|
if (target && key) writeTyped(target.props, key, inputs.value);
|
||||||
return { next: 'exec' };
|
return { next: 'exec' };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { NodeDef } from '../../NodeRegistry';
|
|||||||
import { asEntity, asNumber } from '../../values';
|
import { asEntity, asNumber } from '../../values';
|
||||||
import { assetParam, exec, num, obj } from '../shared';
|
import { assetParam, exec, num, obj } from '../shared';
|
||||||
|
|
||||||
|
import { textOf } from '../../../model/Properties';
|
||||||
export const Spawn: NodeDef = {
|
export const Spawn: NodeDef = {
|
||||||
type: 'Spawn',
|
type: 'Spawn',
|
||||||
label: 'Spawn',
|
label: 'Spawn',
|
||||||
@@ -13,7 +14,7 @@ export const Spawn: NodeDef = {
|
|||||||
outputs: [exec(), obj('object')],
|
outputs: [exec(), obj('object')],
|
||||||
params: [assetParam],
|
params: [assetParam],
|
||||||
run: (ctx, inputs, params) => {
|
run: (ctx, inputs, params) => {
|
||||||
const assetId = String(params.asset ?? '');
|
const assetId = textOf(params.asset ?? '');
|
||||||
const position = { x: asNumber(inputs.x), y: asNumber(inputs.y), z: asNumber(inputs.z) };
|
const position = { x: asNumber(inputs.x), y: asNumber(inputs.y), z: asNumber(inputs.z) };
|
||||||
const spawned = assetId ? ctx.spawn(assetId, position, asEntity(inputs.parent)) : null;
|
const spawned = assetId ? ctx.spawn(assetId, position, asEntity(inputs.parent)) : null;
|
||||||
if (!spawned) ctx.log(assetId ? `Spawn: asset ${assetId} not found` : 'Spawn: no asset selected');
|
if (!spawned) ctx.log(assetId ? `Spawn: asset ${assetId} not found` : 'Spawn: no asset selected');
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { NodeDef } from '../../NodeRegistry';
|
import type { NodeDef } from '../../NodeRegistry';
|
||||||
import { bool, normalize, num } from '../shared';
|
import { bool, normalize, num } from '../shared';
|
||||||
|
|
||||||
|
import { textOf } from '../../../model/Properties';
|
||||||
export const AxisInput: NodeDef = {
|
export const AxisInput: NodeDef = {
|
||||||
type: 'AxisInput',
|
type: 'AxisInput',
|
||||||
label: 'Axis input',
|
label: 'Axis input',
|
||||||
@@ -15,7 +16,7 @@ export const AxisInput: NodeDef = {
|
|||||||
{ key: 'down', label: 'Down', kind: 'key', default: 'ArrowDown' },
|
{ key: 'down', label: 'Down', kind: 'key', default: 'ArrowDown' },
|
||||||
],
|
],
|
||||||
run: (ctx, _inputs, params) => {
|
run: (ctx, _inputs, params) => {
|
||||||
const held = (key: string, fallback: string) => (ctx.isKeyDown(String(params[key] ?? fallback)) ? 1 : 0);
|
const held = (key: string, fallback: string) => (ctx.isKeyDown(textOf(params[key] ?? fallback)) ? 1 : 0);
|
||||||
const dx = held('right', 'ArrowRight') - held('left', 'ArrowLeft');
|
const dx = held('right', 'ArrowRight') - held('left', 'ArrowLeft');
|
||||||
const dy = held('down', 'ArrowDown') - held('up', 'ArrowUp');
|
const dy = held('down', 'ArrowDown') - held('up', 'ArrowUp');
|
||||||
const { x, y, length } = normalize(dx, dy);
|
const { x, y, length } = normalize(dx, dy);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { NodeDef } from '../../NodeRegistry';
|
|||||||
import { asNumber } from '../../values';
|
import { asNumber } from '../../values';
|
||||||
import { bool, num } from '../shared';
|
import { bool, num } from '../shared';
|
||||||
|
|
||||||
|
import { textOf } from '../../../model/Properties';
|
||||||
const OPERATORS: Record<string, (a: number, b: number) => boolean> = {
|
const OPERATORS: Record<string, (a: number, b: number) => boolean> = {
|
||||||
'==': (a, b) => a === b,
|
'==': (a, b) => a === b,
|
||||||
'!=': (a, b) => a !== b,
|
'!=': (a, b) => a !== b,
|
||||||
@@ -30,7 +31,7 @@ export const Compare: NodeDef = {
|
|||||||
run: (_ctx, inputs, params) => {
|
run: (_ctx, inputs, params) => {
|
||||||
// The fallback matches the param's declared default, so a node with no setting behaves
|
// The fallback matches the param's declared default, so a node with no setting behaves
|
||||||
// like a freshly created one.
|
// like a freshly created one.
|
||||||
const compare = OPERATORS[String(params.op)] ?? OPERATORS['<']!;
|
const compare = OPERATORS[textOf(params.op)] ?? OPERATORS['<']!;
|
||||||
return { outputs: { result: compare(asNumber(inputs.a), asNumber(inputs.b)) } };
|
return { outputs: { result: compare(asNumber(inputs.a), asNumber(inputs.b)) } };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -2,6 +2,7 @@ import type { NodeDef } from '../../NodeRegistry';
|
|||||||
import { asEntity } from '../../values';
|
import { asEntity } from '../../values';
|
||||||
import { any, obj } from '../shared';
|
import { any, obj } from '../shared';
|
||||||
|
|
||||||
|
import { textOf } from '../../../model/Properties';
|
||||||
export const GetProperty: NodeDef = {
|
export const GetProperty: NodeDef = {
|
||||||
type: 'GetProperty',
|
type: 'GetProperty',
|
||||||
label: 'Get property',
|
label: 'Get property',
|
||||||
@@ -11,6 +12,6 @@ export const GetProperty: NodeDef = {
|
|||||||
outputs: [any('value')],
|
outputs: [any('value')],
|
||||||
params: [{ key: 'property', label: 'Property', kind: 'text', default: 'width' }],
|
params: [{ key: 'property', label: 'Property', kind: 'text', default: 'width' }],
|
||||||
run: (_ctx, inputs, params) => ({
|
run: (_ctx, inputs, params) => ({
|
||||||
outputs: { value: asEntity(inputs.object)?.props[String(params.property ?? '')] ?? null },
|
outputs: { value: asEntity(inputs.object)?.props[textOf(params.property ?? '')] ?? null },
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -2,6 +2,7 @@ import type { NodeDef } from '../../NodeRegistry';
|
|||||||
import { asEntity } from '../../values';
|
import { asEntity } from '../../values';
|
||||||
import { assetParam, bool, obj } from '../shared';
|
import { assetParam, bool, obj } from '../shared';
|
||||||
|
|
||||||
|
import { textOf } from '../../../model/Properties';
|
||||||
export const IsAsset: NodeDef = {
|
export const IsAsset: NodeDef = {
|
||||||
type: 'IsAsset',
|
type: 'IsAsset',
|
||||||
label: 'Is asset',
|
label: 'Is asset',
|
||||||
@@ -11,7 +12,7 @@ export const IsAsset: NodeDef = {
|
|||||||
outputs: [bool('result')],
|
outputs: [bool('result')],
|
||||||
params: [assetParam],
|
params: [assetParam],
|
||||||
run: (_ctx, inputs, params) => {
|
run: (_ctx, inputs, params) => {
|
||||||
const assetId = String(params.asset ?? '');
|
const assetId = textOf(params.asset ?? '');
|
||||||
return { outputs: { result: Boolean(assetId) && asEntity(inputs.object)?.assetId === assetId } };
|
return { outputs: { result: Boolean(assetId) && asEntity(inputs.object)?.assetId === assetId } };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { NodeDef } from '../../NodeRegistry';
|
import type { NodeDef } from '../../NodeRegistry';
|
||||||
import { bool, keyParam } from '../shared';
|
import { bool, keyParam } from '../shared';
|
||||||
|
|
||||||
|
import { textOf } from '../../../model/Properties';
|
||||||
export const KeyIsDown: NodeDef = {
|
export const KeyIsDown: NodeDef = {
|
||||||
type: 'KeyIsDown',
|
type: 'KeyIsDown',
|
||||||
label: 'Key is down',
|
label: 'Key is down',
|
||||||
@@ -9,5 +10,5 @@ export const KeyIsDown: NodeDef = {
|
|||||||
inputs: [],
|
inputs: [],
|
||||||
outputs: [bool('down')],
|
outputs: [bool('down')],
|
||||||
params: [keyParam],
|
params: [keyParam],
|
||||||
run: (ctx, _inputs, params) => ({ outputs: { down: ctx.isKeyDown(String(params.key ?? '')) } }),
|
run: (ctx, _inputs, params) => ({ outputs: { down: ctx.isKeyDown(textOf(params.key ?? '')) } }),
|
||||||
};
|
};
|
||||||
@@ -9,6 +9,7 @@ export const bool = (key: string, label = key): PortDef => ({ key, label, type:
|
|||||||
export const str = (key: string, label = key): PortDef => ({ key, label, type: 'string' });
|
export const str = (key: string, label = key): PortDef => ({ key, label, type: 'string' });
|
||||||
export const obj = (key: string, label = key): PortDef => ({ key, label, type: 'object' });
|
export const obj = (key: string, label = key): PortDef => ({ key, label, type: 'object' });
|
||||||
export const list = (key: string, label = key): PortDef => ({ key, label, type: 'list' });
|
export const list = (key: string, label = key): PortDef => ({ key, label, type: 'list' });
|
||||||
|
export const struct = (key: string, label = key): PortDef => ({ key, label, type: 'struct' });
|
||||||
export const any = (key: string, label = key): PortDef => ({ key, label, type: 'any' });
|
export const any = (key: string, label = key): PortDef => ({ key, label, type: 'any' });
|
||||||
|
|
||||||
export const withDefault = (port: PortDef, value: PropertyValue): PortDef => ({ ...port, default: value });
|
export const withDefault = (port: PortDef, value: PropertyValue): PortDef => ({ ...port, default: value });
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { NodeDef } from '../../NodeRegistry';
|
import type { NodeDef } from '../../NodeRegistry';
|
||||||
import { any, variableParam } from '../shared';
|
import { any, variableParam } from '../shared';
|
||||||
|
|
||||||
|
import { textOf } from '../../../model/Properties';
|
||||||
export const GetVariable: NodeDef = {
|
export const GetVariable: NodeDef = {
|
||||||
type: 'GetVariable',
|
type: 'GetVariable',
|
||||||
label: 'Get variable',
|
label: 'Get variable',
|
||||||
@@ -10,6 +11,6 @@ export const GetVariable: NodeDef = {
|
|||||||
outputs: [any('value')],
|
outputs: [any('value')],
|
||||||
params: [variableParam],
|
params: [variableParam],
|
||||||
run: (ctx, _inputs, params) => ({
|
run: (ctx, _inputs, params) => ({
|
||||||
outputs: { value: ctx.variables[String(params.variable ?? '')] ?? null },
|
outputs: { value: ctx.variables[textOf(params.variable ?? '')] ?? null },
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -2,6 +2,7 @@ import type { NodeDef } from '../../NodeRegistry';
|
|||||||
import { writeTyped } from '../../values';
|
import { writeTyped } from '../../values';
|
||||||
import { any, exec, variableParam } from '../shared';
|
import { any, exec, variableParam } from '../shared';
|
||||||
|
|
||||||
|
import { textOf } from '../../../model/Properties';
|
||||||
export const SetVariable: NodeDef = {
|
export const SetVariable: NodeDef = {
|
||||||
type: 'SetVariable',
|
type: 'SetVariable',
|
||||||
label: 'Set variable',
|
label: 'Set variable',
|
||||||
@@ -11,7 +12,7 @@ export const SetVariable: NodeDef = {
|
|||||||
outputs: [exec()],
|
outputs: [exec()],
|
||||||
params: [variableParam],
|
params: [variableParam],
|
||||||
run: (ctx, inputs, params) => {
|
run: (ctx, inputs, params) => {
|
||||||
const key = String(params.variable ?? '');
|
const key = textOf(params.variable ?? '');
|
||||||
if (key) writeTyped(ctx.variables, key, inputs.value);
|
if (key) writeTyped(ctx.variables, key, inputs.value);
|
||||||
return { next: 'exec' };
|
return { next: 'exec' };
|
||||||
},
|
},
|
||||||
|
|||||||
+59
-14
@@ -1,9 +1,22 @@
|
|||||||
import { isList, type ListValue, type PropertyBag, type PropertyValue } from '../model/Properties';
|
import {
|
||||||
import type { EntityHandle } from './NodeContext';
|
type FieldValue,
|
||||||
|
isList,
|
||||||
|
isStruct,
|
||||||
|
type ListValue,
|
||||||
|
type PropertyBag,
|
||||||
|
type PropertyValue,
|
||||||
|
type StructValue,
|
||||||
|
textOf,
|
||||||
|
} from '../model/Properties';
|
||||||
|
import { ENTITY_HANDLE, type EntityHandle } from './NodeContext';
|
||||||
|
|
||||||
/** A value travelling along a data edge. Objects are live entity handles (play mode only). */
|
/** A value travelling along a data edge. Objects are live entity handles (play mode only). */
|
||||||
export type Value = PropertyValue | EntityHandle | null;
|
export type Value = PropertyValue | EntityHandle | null;
|
||||||
|
|
||||||
|
export function isEntity(value: Value | undefined): value is EntityHandle {
|
||||||
|
return typeof value === 'object' && value !== null && ENTITY_HANDLE in value;
|
||||||
|
}
|
||||||
|
|
||||||
export function asNumber(value: Value | undefined): number {
|
export function asNumber(value: Value | undefined): number {
|
||||||
if (typeof value === 'number') return value;
|
if (typeof value === 'number') return value;
|
||||||
if (typeof value === 'boolean') return value ? 1 : 0;
|
if (typeof value === 'boolean') return value ? 1 : 0;
|
||||||
@@ -12,6 +25,8 @@ export function asNumber(value: Value | undefined): number {
|
|||||||
return Number.isFinite(n) ? n : 0;
|
return Number.isFinite(n) ? n : 0;
|
||||||
}
|
}
|
||||||
if (isList(value)) return value.length;
|
if (isList(value)) return value.length;
|
||||||
|
// A bundle counts as its parts, the same way a list counts as its items.
|
||||||
|
if (isStructValue(value)) return Object.keys(value).length;
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,7 +34,9 @@ export function asNumber(value: Value | undefined): number {
|
|||||||
export function asList(value: Value | undefined): ListValue {
|
export function asList(value: Value | undefined): ListValue {
|
||||||
if (isList(value)) return value;
|
if (isList(value)) return value;
|
||||||
if (value === null || value === undefined) return [];
|
if (value === null || value === undefined) return [];
|
||||||
return typeof value === 'object' ? [value.name] : [value];
|
if (isEntity(value)) return [value.name];
|
||||||
|
if (isStructValue(value)) return Object.values(value).map(asListItem);
|
||||||
|
return [value];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A value narrowed to what a list may hold: handles and nested lists have no stored form. */
|
/** A value narrowed to what a list may hold: handles and nested lists have no stored form. */
|
||||||
@@ -29,6 +46,11 @@ export function asListItem(value: Value | undefined): number | string | boolean
|
|||||||
return asString(value);
|
return asString(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A value narrowed to what a struct field may hold: a list stays a list, anything deeper flattens. */
|
||||||
|
export function asField(value: Value | undefined): FieldValue {
|
||||||
|
return isList(value) ? value : asListItem(value);
|
||||||
|
}
|
||||||
|
|
||||||
export function asBoolean(value: Value | undefined): boolean {
|
export function asBoolean(value: Value | undefined): boolean {
|
||||||
if (typeof value === 'string') return value !== '' && value !== 'false' && value !== '0';
|
if (typeof value === 'string') return value !== '' && value !== 'false' && value !== '0';
|
||||||
return Boolean(value);
|
return Boolean(value);
|
||||||
@@ -36,15 +58,42 @@ export function asBoolean(value: Value | undefined): boolean {
|
|||||||
|
|
||||||
/** Entities read as their name, so a handle can be logged or joined into text directly. */
|
/** Entities read as their name, so a handle can be logged or joined into text directly. */
|
||||||
export function asString(value: Value | undefined): string {
|
export function asString(value: Value | undefined): string {
|
||||||
if (value === null || value === undefined) return '';
|
if (value === null) return '';
|
||||||
if (isList(value)) return value.join(', ');
|
return isEntity(value) ? value.name : textOf(value);
|
||||||
if (typeof value === 'object') return value.name;
|
|
||||||
return String(value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function asEntity(value: Value | undefined): EntityHandle | null {
|
export function asEntity(value: Value | undefined): EntityHandle | null {
|
||||||
if (!value || typeof value !== 'object' || isList(value)) return null;
|
return isEntity(value) ? value : null;
|
||||||
return value;
|
}
|
||||||
|
|
||||||
|
/** A struct value, or the empty one: a number has no fields to become, so nothing is invented. */
|
||||||
|
export function asStruct(value: Value | undefined): StructValue {
|
||||||
|
return isStructValue(value) ? value : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStructValue(value: Value | undefined): value is StructValue {
|
||||||
|
return !isEntity(value) && isStruct(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A struct slot keeps the fields it was declared with: what arrives fills the ones it names and
|
||||||
|
* the rest stay put. That is what lets one node set some values of a bundle without erasing it.
|
||||||
|
*/
|
||||||
|
export function mergeStruct(current: StructValue, value: Value | undefined): StructValue {
|
||||||
|
const incoming = asStruct(value);
|
||||||
|
const merged: Record<string, PropertyValue> = {};
|
||||||
|
for (const [field, own] of Object.entries(current))
|
||||||
|
merged[field] = field in incoming ? asTypeOf(own, incoming[field]) : own;
|
||||||
|
return merged as StructValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `value` converted to the type the slot already holds, so a number stays a number. */
|
||||||
|
export function asTypeOf(current: PropertyValue | undefined, value: Value | undefined): PropertyValue {
|
||||||
|
if (isList(current)) return asList(value);
|
||||||
|
if (isStruct(current)) return mergeStruct(current, value);
|
||||||
|
if (typeof current === 'number') return asNumber(value);
|
||||||
|
if (typeof current === 'boolean') return asBoolean(value);
|
||||||
|
return asString(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,9 +101,5 @@ export function asEntity(value: Value | undefined): EntityHandle | null {
|
|||||||
* variable stays a number no matter what arrives on an `any` input.
|
* variable stays a number no matter what arrives on an `any` input.
|
||||||
*/
|
*/
|
||||||
export function writeTyped(bag: PropertyBag, key: string, value: Value | undefined): void {
|
export function writeTyped(bag: PropertyBag, key: string, value: Value | undefined): void {
|
||||||
const current = bag[key];
|
bag[key] = asTypeOf(bag[key], value);
|
||||||
if (isList(current)) bag[key] = asList(value);
|
|
||||||
else if (typeof current === 'number') bag[key] = asNumber(value);
|
|
||||||
else if (typeof current === 'boolean') bag[key] = asBoolean(value);
|
|
||||||
else bag[key] = asString(value);
|
|
||||||
}
|
}
|
||||||
@@ -4,19 +4,52 @@
|
|||||||
*/
|
*/
|
||||||
export type ListValue = readonly (number | string | boolean)[];
|
export type ListValue = readonly (number | string | boolean)[];
|
||||||
|
|
||||||
|
/** What a struct field may hold: no field is itself a struct, for the same reason a list is flat. */
|
||||||
|
export type FieldValue = number | string | boolean | ListValue;
|
||||||
|
|
||||||
|
/** Several named values travelling as one, so a bundle needs one port instead of one per part. */
|
||||||
|
export type StructValue = { readonly [field: string]: FieldValue };
|
||||||
|
|
||||||
/** Colors are stored as 0xRRGGBB numbers. */
|
/** Colors are stored as 0xRRGGBB numbers. */
|
||||||
export type PropertyValue = number | string | boolean | ListValue;
|
export type PropertyValue = FieldValue | StructValue;
|
||||||
|
|
||||||
export type PropertyBag = Record<string, PropertyValue>;
|
export type PropertyBag = Record<string, PropertyValue>;
|
||||||
|
|
||||||
export const isList = (value: unknown): value is ListValue => Array.isArray(value);
|
export const isList = (value: unknown): value is ListValue => Array.isArray(value);
|
||||||
|
|
||||||
/** Values are compared by content, because two equal lists are never the same array. */
|
/**
|
||||||
|
* Model-level only: a live entity handle is an object too, and the graph tells the two apart by
|
||||||
|
* the handle's brand (see `values.ts`). Nothing in a PropertyBag is ever a handle.
|
||||||
|
*/
|
||||||
|
export const isStruct = (value: unknown): value is StructValue =>
|
||||||
|
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A property value as text. Nothing may hand a PropertyValue to `String()` directly: a struct would
|
||||||
|
* come out as "[object Object]" and a list without separators.
|
||||||
|
*/
|
||||||
|
export function textOf(value: PropertyValue | undefined): string {
|
||||||
|
if (value === undefined) return '';
|
||||||
|
if (isList(value)) return value.join(', ');
|
||||||
|
if (isStruct(value))
|
||||||
|
return Object.entries(value)
|
||||||
|
.map(([field, item]) => `${field}: ${textOf(item)}`)
|
||||||
|
.join(', ');
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Values are compared by content, because two equal lists (or structs) are never the same object. */
|
||||||
export function sameValue(a: PropertyValue | undefined, b: PropertyValue | undefined): boolean {
|
export function sameValue(a: PropertyValue | undefined, b: PropertyValue | undefined): boolean {
|
||||||
if (isList(a) || isList(b)) {
|
if (isList(a) || isList(b)) {
|
||||||
if (!isList(a) || !isList(b) || a.length !== b.length) return false;
|
if (!isList(a) || !isList(b) || a.length !== b.length) return false;
|
||||||
return a.every((item, i) => item === b[i]);
|
return a.every((item, i) => item === b[i]);
|
||||||
}
|
}
|
||||||
|
if (isStruct(a) || isStruct(b)) {
|
||||||
|
if (!isStruct(a) || !isStruct(b)) return false;
|
||||||
|
const keys = Object.keys(a);
|
||||||
|
// Fields are never structs, so this recurses exactly one level deep.
|
||||||
|
return keys.length === Object.keys(b).length && keys.every((key) => sameValue(a[key], b[key]));
|
||||||
|
}
|
||||||
return a === b;
|
return a === b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { TextureAsset, TextureFrame } from './Asset';
|
import type { TextureAsset, TextureFrame } from './Asset';
|
||||||
import type { PropertyBag } from './Properties';
|
import { type PropertyBag, textOf } from './Properties';
|
||||||
import { findAsset, type Project } from './Project';
|
import { findAsset, type Project } from './Project';
|
||||||
|
|
||||||
/** Mirrors `TextureStore::isValidName` on the server, where the name is derived from the bytes. */
|
/** Mirrors `TextureStore::isValidName` on the server, where the name is derived from the bytes. */
|
||||||
@@ -56,7 +56,7 @@ export function textureRefIds(asset: TextureAsset): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolveTexture(project: Project, props: PropertyBag): TextureRef | null {
|
export function resolveTexture(project: Project, props: PropertyBag): TextureRef | null {
|
||||||
const id = String(props.texture ?? '');
|
const id = textOf(props.texture ?? '');
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
const asset = findAsset(project, id);
|
const asset = findAsset(project, id);
|
||||||
if (asset) return asset.type === 'texture' ? refFor(asset, null) : null;
|
if (asset) return asset.type === 'texture' ? refFor(asset, null) : null;
|
||||||
@@ -68,7 +68,7 @@ function refFor(asset: TextureAsset, frame: TextureFrame | null): TextureRef | n
|
|||||||
if (!isTextureFile(asset.file)) return null;
|
if (!isTextureFile(asset.file)) return null;
|
||||||
return {
|
return {
|
||||||
file: asset.file,
|
file: asset.file,
|
||||||
smooth: String(asset.props.filter ?? 'linear') !== 'nearest',
|
smooth: textOf(asset.props.filter ?? 'linear') !== 'nearest',
|
||||||
rect: frame ? { x: frame.x, y: frame.y, w: frame.w, h: frame.h } : null,
|
rect: frame ? { x: frame.x, y: frame.y, w: frame.w, h: frame.h } : null,
|
||||||
sourceWidth: asset.width,
|
sourceWidth: asset.width,
|
||||||
sourceHeight: asset.height,
|
sourceHeight: asset.height,
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import type { Asset } from '../model/Asset';
|
import type { Asset } from '../model/Asset';
|
||||||
import { type AssetType, isPlaceableType } from '../model/AssetTypes';
|
import { type AssetType, isPlaceableType } from '../model/AssetTypes';
|
||||||
import { isList, type ListValue, type PropertyBag, type PropertyValue } from '../model/Properties';
|
import {
|
||||||
|
type FieldValue,
|
||||||
|
isList,
|
||||||
|
type ListValue,
|
||||||
|
type PropertyBag,
|
||||||
|
type PropertyValue,
|
||||||
|
textOf,
|
||||||
|
} from '../model/Properties';
|
||||||
|
|
||||||
/** The inspector is generated from these entries, so a new property is one line in a schema below. */
|
/** The inspector is generated from these entries, so a new property is one line in a schema below. */
|
||||||
export type PropertyDef =
|
export type PropertyDef =
|
||||||
@@ -176,7 +183,7 @@ export function variableDef(
|
|||||||
case 'boolean':
|
case 'boolean':
|
||||||
return { key, label: key, kind, default: Boolean(defaultValue ?? false) };
|
return { key, label: key, kind, default: Boolean(defaultValue ?? false) };
|
||||||
case 'text':
|
case 'text':
|
||||||
return { key, label: key, kind, default: defaultValue === undefined ? '' : String(defaultValue) };
|
return { key, label: key, kind, default: defaultValue === undefined ? '' : textOf(defaultValue) };
|
||||||
case 'list':
|
case 'list':
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
@@ -189,7 +196,7 @@ export function variableDef(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Clamps / coerces a raw value so it satisfies the definition. */
|
/** Clamps / coerces a raw value so it satisfies the definition. */
|
||||||
export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyValue {
|
export function coerceValue(def: PropertyDef, value: PropertyValue): FieldValue {
|
||||||
switch (def.kind) {
|
switch (def.kind) {
|
||||||
case 'number': {
|
case 'number': {
|
||||||
let n = typeof value === 'number' ? value : Number(value);
|
let n = typeof value === 'number' ? value : Number(value);
|
||||||
@@ -199,7 +206,7 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyVal
|
|||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
case 'color': {
|
case 'color': {
|
||||||
const n = typeof value === 'number' ? value : parseInt(String(value).replace('#', ''), 16);
|
const n = typeof value === 'number' ? value : parseInt(textOf(value).replace('#', ''), 16);
|
||||||
return Number.isFinite(n) ? Math.max(0, Math.min(0xffffff, Math.floor(n))) : def.default;
|
return Number.isFinite(n) ? Math.max(0, Math.min(0xffffff, Math.floor(n))) : def.default;
|
||||||
}
|
}
|
||||||
case 'boolean':
|
case 'boolean':
|
||||||
@@ -208,9 +215,9 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyVal
|
|||||||
case 'key':
|
case 'key':
|
||||||
case 'asset':
|
case 'asset':
|
||||||
case 'texture':
|
case 'texture':
|
||||||
return String(value);
|
return textOf(value);
|
||||||
case 'enum': {
|
case 'enum': {
|
||||||
const s = String(value);
|
const s = textOf(value);
|
||||||
return def.options.some((o) => o.value === s) ? s : def.default;
|
return def.options.some((o) => o.value === s) ? s : def.default;
|
||||||
}
|
}
|
||||||
case 'list':
|
case 'list':
|
||||||
@@ -232,15 +239,15 @@ export function listFromText(text: string, items: ListItemKind): ListValue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function coerceListItem(value: PropertyValue, items: ListItemKind): number | string | boolean {
|
export function coerceListItem(value: PropertyValue, items: ListItemKind): number | string | boolean {
|
||||||
if (items === 'text') return isList(value) ? value.join(', ') : String(value);
|
if (items === 'text') return isList(value) ? value.join(', ') : textOf(value);
|
||||||
if (items === 'boolean') {
|
if (items === 'boolean') {
|
||||||
if (typeof value === 'boolean') return value;
|
if (typeof value === 'boolean') return value;
|
||||||
// A list is typed as text, so the spelling has to count: Boolean('false') would be true.
|
// A list is typed as text, so the spelling has to count: Boolean('false') would be true.
|
||||||
const s = String(value).trim().toLowerCase();
|
const s = textOf(value).trim().toLowerCase();
|
||||||
return !(s === '' || s === 'false' || s === '0' || s === 'no');
|
return !(s === '' || s === 'false' || s === '0' || s === 'no');
|
||||||
}
|
}
|
||||||
if (items === 'color') {
|
if (items === 'color') {
|
||||||
const c = typeof value === 'number' ? value : parseInt(String(value).replace('#', ''), 16);
|
const c = typeof value === 'number' ? value : parseInt(textOf(value).replace('#', ''), 16);
|
||||||
return Number.isFinite(c) ? Math.max(0, Math.min(0xffffff, Math.floor(c))) : 0;
|
return Number.isFinite(c) ? Math.max(0, Math.min(0xffffff, Math.floor(c))) : 0;
|
||||||
}
|
}
|
||||||
const n = typeof value === 'number' ? value : Number(value);
|
const n = typeof value === 'number' ? value : Number(value);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* "Add collider", so all three agree on how big a thing is.
|
* "Add collider", so all three agree on how big a thing is.
|
||||||
*/
|
*/
|
||||||
import type { AssetType } from '../model/AssetTypes';
|
import type { AssetType } from '../model/AssetTypes';
|
||||||
import type { PropertyBag, Vec2 } from '../model/Properties';
|
import { type PropertyBag, textOf, type Vec2 } from '../model/Properties';
|
||||||
import { cameraFrame } from './camera';
|
import { cameraFrame } from './camera';
|
||||||
import { colliderSize } from './collider';
|
import { colliderSize } from './collider';
|
||||||
import { defaultsFor } from './PropertySchema';
|
import { defaultsFor } from './PropertySchema';
|
||||||
@@ -21,7 +21,7 @@ const LINE_HEIGHT = 1.25;
|
|||||||
/** Estimated from the content and font size, without measuring the text. */
|
/** Estimated from the content and font size, without measuring the text. */
|
||||||
export function textSize(props: PropertyBag): { w: number; h: number } {
|
export function textSize(props: PropertyBag): { w: number; h: number } {
|
||||||
const size = Math.max(1, Number(props.fontSize ?? TEXT_DEFAULTS.fontSize));
|
const size = Math.max(1, Number(props.fontSize ?? TEXT_DEFAULTS.fontSize));
|
||||||
const lines = String(props.text ?? TEXT_DEFAULTS.text).split('\n');
|
const lines = textOf(props.text ?? TEXT_DEFAULTS.text).split('\n');
|
||||||
const longest = lines.reduce((max, line) => Math.max(max, line.length), 0);
|
const longest = lines.reduce((max, line) => Math.max(max, line.length), 0);
|
||||||
return {
|
return {
|
||||||
w: Math.max(size * CHAR_WIDTH, longest * size * CHAR_WIDTH),
|
w: Math.max(size * CHAR_WIDTH, longest * size * CHAR_WIDTH),
|
||||||
@@ -31,7 +31,7 @@ export function textSize(props: PropertyBag): { w: number; h: number } {
|
|||||||
|
|
||||||
/** 0 = left edge on the position, 0.5 = centred, 1 = right. */
|
/** 0 = left edge on the position, 0.5 = centred, 1 = right. */
|
||||||
export function textAnchor(props: PropertyBag): number {
|
export function textAnchor(props: PropertyBag): number {
|
||||||
const align = String(props.align ?? TEXT_DEFAULTS.align);
|
const align = textOf(props.align ?? TEXT_DEFAULTS.align);
|
||||||
return align === 'left' ? 0 : align === 'right' ? 1 : 0.5;
|
return align === 'left' ? 0 : align === 'right' ? 1 : 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
import type { Asset, TextureFrame } from '../model/Asset';
|
import type { Asset, TextureFrame } from '../model/Asset';
|
||||||
import { isAssetType } from '../model/AssetTypes';
|
import { isAssetType } from '../model/AssetTypes';
|
||||||
import { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance';
|
import { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance';
|
||||||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
import type { FieldValue, PropertyBag, PropertyValue } from '../model/Properties';
|
||||||
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
|
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
|
||||||
import type { SceneObject } from '../model/SceneObject';
|
import type { SceneObject } from '../model/SceneObject';
|
||||||
import { isTextureFile, normalizeFrameRect } from '../model/texture';
|
import { isTextureFile, normalizeFrameRect } from '../model/texture';
|
||||||
@@ -209,10 +209,11 @@ function normalizeGraph(raw: unknown): Graph {
|
|||||||
const type = typeof n.type === 'string' ? n.type : '';
|
const type = typeof n.type === 'string' ? n.type : '';
|
||||||
if (!id || nodeIds.has(id) || !nodeDef(type)) continue;
|
if (!id || nodeIds.has(id) || !nodeDef(type)) continue;
|
||||||
nodeIds.add(id);
|
nodeIds.add(id);
|
||||||
|
// Params carry both settings and inline input values, so there is no schema to coerce against.
|
||||||
const params: PropertyBag = {};
|
const params: PropertyBag = {};
|
||||||
for (const [key, value] of Object.entries(asRecord(n.params))) {
|
for (const [key, value] of Object.entries(asRecord(n.params))) {
|
||||||
if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean')
|
const stored = asStoredValue(value);
|
||||||
params[key] = value;
|
if (stored !== undefined) params[key] = stored;
|
||||||
}
|
}
|
||||||
const node: GraphNode = { id, type, x: toNumber(n.x), y: toNumber(n.y), params };
|
const node: GraphNode = { id, type, x: toNumber(n.x), y: toNumber(n.y), params };
|
||||||
const ports = normalizePorts(n.ports);
|
const ports = normalizePorts(n.ports);
|
||||||
@@ -296,10 +297,22 @@ function asRecord(value: unknown): Record<string, unknown> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A value from an untyped slot (instance edits carry no schema to coerce against). Lists are kept
|
* A value from an untyped slot (node params, instance edits). Lists and structs are kept but
|
||||||
* but flattened away one level, so nothing nested can enter the model behind the type's back.
|
* flattened one level, so nothing deeper than the model allows can enter behind the type's back.
|
||||||
*/
|
*/
|
||||||
function asStoredValue(value: unknown): PropertyValue | undefined {
|
function asStoredValue(value: unknown): PropertyValue | undefined {
|
||||||
|
const field = asFieldValue(value);
|
||||||
|
if (field !== undefined) return field;
|
||||||
|
if (!value || typeof value !== 'object') return undefined;
|
||||||
|
const fields: Record<string, FieldValue> = {};
|
||||||
|
for (const [key, item] of Object.entries(value)) {
|
||||||
|
const stored = asFieldValue(item);
|
||||||
|
if (stored !== undefined) fields[key] = stored;
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asFieldValue(value: unknown): FieldValue | undefined {
|
||||||
if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') return value;
|
if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') return value;
|
||||||
if (Array.isArray(value))
|
if (Array.isArray(value))
|
||||||
return value.filter(
|
return value.filter(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
|
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
|
||||||
|
import { textOf } from '../../core/model/Properties';
|
||||||
import type { Command } from '../../core/commands/Command';
|
import type { Command } from '../../core/commands/Command';
|
||||||
import { CompoundCommand } from '../../core/commands/CompoundCommand';
|
import { CompoundCommand } from '../../core/commands/CompoundCommand';
|
||||||
import {
|
import {
|
||||||
@@ -380,7 +381,7 @@ export class GraphCanvas extends Widget {
|
|||||||
/** Selecting the asset is what opens it: the graph tab follows the selection. */
|
/** Selecting the asset is what opens it: the graph tab follows the selection. */
|
||||||
private openCalledBehavior(node: GraphNode): boolean {
|
private openCalledBehavior(node: GraphNode): boolean {
|
||||||
if (node.type !== RUN_BEHAVIOR) return false;
|
if (node.type !== RUN_BEHAVIOR) return false;
|
||||||
const id = String(node.params[BEHAVIOR_PARAM] ?? '');
|
const id = textOf(node.params[BEHAVIOR_PARAM] ?? '');
|
||||||
const asset = id ? findAsset(this.state.project, id) : undefined;
|
const asset = id ? findAsset(this.state.project, id) : undefined;
|
||||||
if (asset?.type !== 'behavior') return false;
|
if (asset?.type !== 'behavior') return false;
|
||||||
this.state.select({ kind: 'asset', assetId: id });
|
this.state.select({ kind: 'asset', assetId: id });
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
} from '../../core/graph';
|
} from '../../core/graph';
|
||||||
import type { BehaviorAsset } from '../../core/model/Asset';
|
import type { BehaviorAsset } from '../../core/model/Asset';
|
||||||
import type { Project } from '../../core/model/Project';
|
import type { Project } from '../../core/model/Project';
|
||||||
import { isList, type PropertyValue } from '../../core/model/Properties';
|
import { isList, type PropertyValue, textOf } from '../../core/model/Properties';
|
||||||
import { assetRefLabel } from '../../core/properties/assetRef';
|
import { assetRefLabel } from '../../core/properties/assetRef';
|
||||||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||||
import { formatNumber, Theme, textStyle, type TextStyleToken, truncate, Widget } from '../../ui';
|
import { formatNumber, Theme, textStyle, type TextStyleToken, truncate, Widget } from '../../ui';
|
||||||
@@ -60,6 +60,8 @@ export function portColor(type: PortType): number {
|
|||||||
return c.portObject;
|
return c.portObject;
|
||||||
case 'list':
|
case 'list':
|
||||||
return c.portList;
|
return c.portList;
|
||||||
|
case 'struct':
|
||||||
|
return c.portStruct;
|
||||||
case 'any':
|
case 'any':
|
||||||
return c.portAny;
|
return c.portAny;
|
||||||
}
|
}
|
||||||
@@ -296,8 +298,8 @@ function formatParam(
|
|||||||
project: Project | null,
|
project: Project | null,
|
||||||
): string {
|
): string {
|
||||||
if (value === undefined || value === '') return '—';
|
if (value === undefined || value === '') return '—';
|
||||||
if (param.kind === 'key') return keyLabel(String(value));
|
if (param.kind === 'key') return keyLabel(textOf(value));
|
||||||
if (param.kind === 'asset') return assetRefLabel(project, String(value));
|
if (param.kind === 'asset') return assetRefLabel(project, textOf(value));
|
||||||
if (
|
if (
|
||||||
param.kind === 'enum' &&
|
param.kind === 'enum' &&
|
||||||
param.key === 'variable' &&
|
param.key === 'variable' &&
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { isList, type PropertyValue } from '../../core/model/Properties';
|
import { isList, type PropertyValue, textOf } from '../../core/model/Properties';
|
||||||
import type { Project } from '../../core/model/Project';
|
import type { Project } from '../../core/model/Project';
|
||||||
import {
|
import {
|
||||||
assetChoiceLabel,
|
assetChoiceLabel,
|
||||||
@@ -187,8 +187,8 @@ export function createField(
|
|||||||
return { widget: field, grow: true, setValue: (v) => field.setNumber(Number(v)) };
|
return { widget: field, grow: true, setValue: (v) => field.setNumber(Number(v)) };
|
||||||
}
|
}
|
||||||
case 'text': {
|
case 'text': {
|
||||||
const field = new TextField(String(value), { onInput: handlers.onInput, onChange: handlers.onChange });
|
const field = new TextField(textOf(value), { onInput: handlers.onInput, onChange: handlers.onChange });
|
||||||
return { widget: field, grow: true, setValue: (v) => field.setValue(String(v)) };
|
return { widget: field, grow: true, setValue: (v) => field.setValue(textOf(v)) };
|
||||||
}
|
}
|
||||||
case 'color': {
|
case 'color': {
|
||||||
const field = new ColorField(Number(value), { onInput: handlers.onInput, onChange: handlers.onChange });
|
const field = new ColorField(Number(value), { onInput: handlers.onInput, onChange: handlers.onChange });
|
||||||
@@ -210,7 +210,7 @@ export function createField(
|
|||||||
}
|
}
|
||||||
case 'enum': {
|
case 'enum': {
|
||||||
const labelOf = (v: PropertyValue) =>
|
const labelOf = (v: PropertyValue) =>
|
||||||
def.options.find((o) => o.value === String(v))?.label ?? (String(v) || '—');
|
def.options.find((o) => o.value === textOf(v))?.label ?? (textOf(v) || '—');
|
||||||
const button = new Dropdown(labelOf(value));
|
const button = new Dropdown(labelOf(value));
|
||||||
button.onClick = () => {
|
button.onClick = () => {
|
||||||
new Menu(
|
new Menu(
|
||||||
@@ -220,13 +220,13 @@ export function createField(
|
|||||||
return { widget: button, grow: true, setValue: (v) => button.setText(labelOf(v)) };
|
return { widget: button, grow: true, setValue: (v) => button.setText(labelOf(v)) };
|
||||||
}
|
}
|
||||||
case 'key': {
|
case 'key': {
|
||||||
const button = new KeyCaptureButton(String(value), (key) => handlers.onChange(key));
|
const button = new KeyCaptureButton(textOf(value), (key) => handlers.onChange(key));
|
||||||
return { widget: button, grow: true, setValue: (v) => button.setKey(String(v)) };
|
return { widget: button, grow: true, setValue: (v) => button.setKey(textOf(v)) };
|
||||||
}
|
}
|
||||||
case 'asset': {
|
case 'asset': {
|
||||||
// The current id is kept here rather than read back off the label: the label is a name,
|
// The current id is kept here rather than read back off the label: the label is a name,
|
||||||
// two assets may share one, and a dangling reference has no name at all.
|
// two assets may share one, and a dangling reference has no name at all.
|
||||||
let current = String(value);
|
let current = textOf(value);
|
||||||
const button = new Dropdown(assetRefLabel(ctx.project(), current));
|
const button = new Dropdown(assetRefLabel(ctx.project(), current));
|
||||||
button.onClick = () => {
|
button.onClick = () => {
|
||||||
const mark = (id: string, text: string) => (id === current ? `● ${text}` : text);
|
const mark = (id: string, text: string) => (id === current ? `● ${text}` : text);
|
||||||
@@ -246,7 +246,7 @@ export function createField(
|
|||||||
widget: button,
|
widget: button,
|
||||||
grow: true,
|
grow: true,
|
||||||
setValue: (v) => {
|
setValue: (v) => {
|
||||||
current = String(v);
|
current = textOf(v);
|
||||||
button.setText(assetRefLabel(ctx.project(), current));
|
button.setText(assetRefLabel(ctx.project(), current));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -254,7 +254,7 @@ export function createField(
|
|||||||
case 'texture': {
|
case 'texture': {
|
||||||
// Kept for the same reason as in the asset case, and more urgently: a frame's label
|
// Kept for the same reason as in the asset case, and more urgently: a frame's label
|
||||||
// repeats its sheet's name, so several entries can read identically.
|
// repeats its sheet's name, so several entries can read identically.
|
||||||
let current = String(value);
|
let current = textOf(value);
|
||||||
const button = new Dropdown(textureRefLabel(ctx.project(), current));
|
const button = new Dropdown(textureRefLabel(ctx.project(), current));
|
||||||
button.onClick = () => {
|
button.onClick = () => {
|
||||||
const mark = (id: string, text: string) => (id === current ? `● ${text}` : text);
|
const mark = (id: string, text: string) => (id === current ? `● ${text}` : text);
|
||||||
@@ -272,7 +272,7 @@ export function createField(
|
|||||||
widget: button,
|
widget: button,
|
||||||
grow: true,
|
grow: true,
|
||||||
setValue: (v) => {
|
setValue: (v) => {
|
||||||
current = String(v);
|
current = textOf(v);
|
||||||
button.setText(textureRefLabel(ctx.project(), current));
|
button.setText(textureRefLabel(ctx.project(), current));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const KIND_LABELS: Record<DeclaredPortKind, string> = {
|
|||||||
text: 'Text',
|
text: 'Text',
|
||||||
object: 'Object',
|
object: 'Object',
|
||||||
list: 'List',
|
list: 'List',
|
||||||
|
struct: 'Struct',
|
||||||
};
|
};
|
||||||
|
|
||||||
const KIND_MARKS: Record<DeclaredPortKind, string> = {
|
const KIND_MARKS: Record<DeclaredPortKind, string> = {
|
||||||
@@ -22,6 +23,7 @@ const KIND_MARKS: Record<DeclaredPortKind, string> = {
|
|||||||
text: 'Aa',
|
text: 'Aa',
|
||||||
object: '◇',
|
object: '◇',
|
||||||
list: '≡',
|
list: '≡',
|
||||||
|
struct: '⊞',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from 'pixi.js';
|
} from 'pixi.js';
|
||||||
import { AddFrameCommand, SetFrameRectCommand } from '../../core/commands/frameCommands';
|
import { AddFrameCommand, SetFrameRectCommand } from '../../core/commands/frameCommands';
|
||||||
import type { TextureAsset, TextureFrame } from '../../core/model/Asset';
|
import type { TextureAsset, TextureFrame } from '../../core/model/Asset';
|
||||||
import type { Vec2 } from '../../core/model/Properties';
|
import { textOf, type Vec2 } from '../../core/model/Properties';
|
||||||
import { findAsset } from '../../core/model/Project';
|
import { findAsset } from '../../core/model/Project';
|
||||||
import { type FrameRect, MIN_FRAME_SIZE, normalizeFrameRect } from '../../core/model/texture';
|
import { type FrameRect, MIN_FRAME_SIZE, normalizeFrameRect } from '../../core/model/texture';
|
||||||
import { type Size, Theme, textStyle, ui, Widget, zoomFactor } from '../../ui';
|
import { type Size, Theme, textStyle, ui, Widget, zoomFactor } from '../../ui';
|
||||||
@@ -335,7 +335,7 @@ export class TextureCanvas extends Widget {
|
|||||||
private pictureOf(asset: TextureAsset) {
|
private pictureOf(asset: TextureAsset) {
|
||||||
return textureLibrary.get({
|
return textureLibrary.get({
|
||||||
file: asset.file,
|
file: asset.file,
|
||||||
smooth: String(asset.props.filter ?? 'linear') !== 'nearest',
|
smooth: textOf(asset.props.filter ?? 'linear') !== 'nearest',
|
||||||
rect: null,
|
rect: null,
|
||||||
sourceWidth: asset.width,
|
sourceWidth: asset.width,
|
||||||
sourceHeight: asset.height,
|
sourceHeight: asset.height,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Container, type FederatedPointerEvent, Graphics, Text, TextStyle } from 'pixi.js';
|
import { Container, type FederatedPointerEvent, Graphics, Text, TextStyle } from 'pixi.js';
|
||||||
import { isSymbolType } from '../../core/model/AssetTypes';
|
import { isSymbolType } from '../../core/model/AssetTypes';
|
||||||
import { sortByZ, type Vec2 } from '../../core/model/Properties';
|
import { sortByZ, type Vec2, textOf } from '../../core/model/Properties';
|
||||||
import { footprintOf, textAnchor } from '../../core/properties/bounds';
|
import { footprintOf, textAnchor } from '../../core/properties/bounds';
|
||||||
import { rectAround, rectsIntersect } from '../../core/properties/camera';
|
import { rectAround, rectsIntersect } from '../../core/properties/camera';
|
||||||
import type { EditorState } from '../state';
|
import type { EditorState } from '../state';
|
||||||
@@ -213,7 +213,7 @@ export abstract class WorldViewport extends Widget {
|
|||||||
}
|
}
|
||||||
text.visible = true;
|
text.visible = true;
|
||||||
text.position.set(item.x, item.y);
|
text.position.set(item.x, item.y);
|
||||||
text.text = String(item.props.text ?? '');
|
text.text = textOf(item.props.text ?? '');
|
||||||
const size = Math.max(1, Number(item.props.fontSize ?? 32));
|
const size = Math.max(1, Number(item.props.fontSize ?? 32));
|
||||||
if (text.style.fontSize !== size) text.style.fontSize = size;
|
if (text.style.fontSize !== size) text.style.fontSize = size;
|
||||||
const color = Number(item.props.color ?? 0xffffff);
|
const color = Number(item.props.color ?? 0xffffff);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { ENTITY_HANDLE } from '../core/graph/NodeContext';
|
||||||
import type { AssetType } from '../core/model/AssetTypes';
|
import type { AssetType } from '../core/model/AssetTypes';
|
||||||
import type { PropertyBag } from '../core/model/Properties';
|
import type { PropertyBag } from '../core/model/Properties';
|
||||||
|
|
||||||
@@ -8,6 +9,8 @@ import type { PropertyBag } from '../core/model/Properties';
|
|||||||
* editor panels can show and edit them while playing.
|
* editor panels can show and edit them while playing.
|
||||||
*/
|
*/
|
||||||
export interface Entity {
|
export interface Entity {
|
||||||
|
/** What tells a graph this object is a live entity and not a struct value. */
|
||||||
|
readonly [ENTITY_HANDLE]: true;
|
||||||
id: string;
|
id: string;
|
||||||
/** Scene object this entity was instantiated from (for selection highlighting). */
|
/** Scene object this entity was instantiated from (for selection highlighting). */
|
||||||
objectId: string;
|
objectId: string;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { isHandleEnabled, type PointerState, worldPositionOf } from '../core/graph';
|
import { ENTITY_HANDLE, isHandleEnabled, type PointerState, worldPositionOf } from '../core/graph';
|
||||||
import { newId } from '../core/ids';
|
import { newId } from '../core/ids';
|
||||||
import type { Asset } from '../core/model/Asset';
|
import type { Asset } from '../core/model/Asset';
|
||||||
import { isPlaceableType } from '../core/model/AssetTypes';
|
import { isPlaceableType } from '../core/model/AssetTypes';
|
||||||
@@ -118,6 +118,7 @@ export class World {
|
|||||||
let entity = this.findByObjectId(object.id);
|
let entity = this.findByObjectId(object.id);
|
||||||
if (!entity) {
|
if (!entity) {
|
||||||
entity = {
|
entity = {
|
||||||
|
[ENTITY_HANDLE]: true,
|
||||||
id: `e-${object.id}`,
|
id: `e-${object.id}`,
|
||||||
objectId: object.id,
|
objectId: object.id,
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from '../../core/graph';
|
} from '../../core/graph';
|
||||||
import { stepKeyRepeat } from '../../core/graph/keyRepeat';
|
import { stepKeyRepeat } from '../../core/graph/keyRepeat';
|
||||||
import { behaviorLookup } from '../../core/model/behaviorPrograms';
|
import { behaviorLookup } from '../../core/model/behaviorPrograms';
|
||||||
import type { PropertyBag } from '../../core/model/Properties';
|
import { type PropertyBag, textOf } from '../../core/model/Properties';
|
||||||
import { findAsset, type Project } from '../../core/model/Project';
|
import { findAsset, type Project } from '../../core/model/Project';
|
||||||
import type { Entity } from '../Entity';
|
import type { Entity } from '../Entity';
|
||||||
import type { World } from '../World';
|
import type { World } from '../World';
|
||||||
@@ -95,7 +95,7 @@ export class BehaviorSystem implements System {
|
|||||||
|
|
||||||
update(world: World, dt: number): void {
|
update(world: World, dt: number): void {
|
||||||
this.syncInstances(world);
|
this.syncInstances(world);
|
||||||
const keyOf = (node: { params: PropertyBag }) => String(node.params.key ?? '');
|
const keyOf = (node: { params: PropertyBag }) => textOf(node.params.key ?? '');
|
||||||
for (const instance of this.instances) {
|
for (const instance of this.instances) {
|
||||||
if (!world.entities.includes(instance.entity)) continue; // destroyed during play
|
if (!world.entities.includes(instance.entity)) continue; // destroyed during play
|
||||||
if (!isHandleEnabled(instance.entity)) continue; // switched off (itself or a parent)
|
if (!isHandleEnabled(instance.entity)) continue; // switched off (itself or a parent)
|
||||||
@@ -136,7 +136,7 @@ export class BehaviorSystem implements System {
|
|||||||
* in `nodeState`, so two nodes on the same key repeat on their own schedules.
|
* in `nodeState`, so two nodes on the same key repeat on their own schedules.
|
||||||
*/
|
*/
|
||||||
private fireKeyDown(i: GraphInterpreter, ctx: NodeContext, node: GraphNode, dt: number): void {
|
private fireKeyDown(i: GraphInterpreter, ctx: NodeContext, node: GraphNode, dt: number): void {
|
||||||
const key = String(node.params.key ?? '');
|
const key = textOf(node.params.key ?? '');
|
||||||
const stored = i.nodeState.get(node.id);
|
const stored = i.nodeState.get(node.id);
|
||||||
const { state, fires } = stepKeyRepeat(
|
const { state, fires } = stepKeyRepeat(
|
||||||
stored ? { held: stored.held ?? 0, repeats: stored.repeats ?? 0 } : null,
|
stored ? { held: stored.held ?? 0, repeats: stored.repeats ?? 0 } : null,
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export const Theme = {
|
|||||||
portString: 0xe6a8ff,
|
portString: 0xe6a8ff,
|
||||||
portObject: 0x56ccf2,
|
portObject: 0x56ccf2,
|
||||||
portList: 0xf2a75c,
|
portList: 0xf2a75c,
|
||||||
|
portStruct: 0xd4c25a,
|
||||||
portAny: 0xbdbdbd,
|
portAny: 0xbdbdbd,
|
||||||
/** Ring around the port a running edge drag would connect to. */
|
/** Ring around the port a running edge drag would connect to. */
|
||||||
portTarget: 0xffffff,
|
portTarget: 0xffffff,
|
||||||
|
|||||||
@@ -486,6 +486,15 @@ describe('behavior serialization', () => {
|
|||||||
expect(cleaned.graph.nodes.length).toBe(behavior.graph.nodes.length);
|
expect(cleaned.graph.nodes.length).toBe(behavior.graph.nodes.length);
|
||||||
expect(cleaned.graph.edges.length).toBe(behavior.graph.edges.length);
|
expect(cleaned.graph.edges.length).toBe(behavior.graph.edges.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps a node param that is a whole list or a struct, not just a single value', () => {
|
||||||
|
const { project, behavior, edit } = emptyBehavior();
|
||||||
|
const list = edit.addNode('List', { value: [3, 1, 4] });
|
||||||
|
const struct = edit.addNode('Number', { bundle: { x: 2, tags: ['a'] } });
|
||||||
|
const loaded = findAsset(roundTrip(project), behavior.id) as BehaviorAsset;
|
||||||
|
expect(findNode(loaded.graph, list.id)!.params.value).toEqual([3, 1, 4]);
|
||||||
|
expect(findNode(loaded.graph, struct.id)!.params.bundle).toEqual({ x: 2, tags: ['a'] });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('unknown node types', () => {
|
describe('unknown node types', () => {
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
|
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||||||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||||
import { sameValue } from '../../src/core/model/Properties';
|
import { sameValue, textOf } from '../../src/core/model/Properties';
|
||||||
import { findAsset, objectHolders, type Project } from '../../src/core/model/Project';
|
import { findAsset, objectHolders, type Project } from '../../src/core/model/Project';
|
||||||
import { deserializeProject } from '../../src/core/store/serialize';
|
import { deserializeProject } from '../../src/core/store/serialize';
|
||||||
import { coerceValue, listFromText, listToText, variableDef } from '../../src/core/properties/PropertySchema';
|
import { coerceValue, listFromText, listToText, variableDef } from '../../src/core/properties/PropertySchema';
|
||||||
import { deviatesFromAsset } from '../../src/core/properties/resolve';
|
import { deviatesFromAsset } from '../../src/core/properties/resolve';
|
||||||
import { makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers';
|
import { asEntity, asStruct, writeTyped } from '../../src/core/graph';
|
||||||
|
import { fakeEntity, makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers';
|
||||||
|
|
||||||
const findObject = (project: Project, id: string) =>
|
const findObject = (project: Project, id: string) =>
|
||||||
objectHolders(project)
|
objectHolders(project)
|
||||||
@@ -94,3 +95,43 @@ describe('a list as a value', () => {
|
|||||||
expect(coerceValue(def, ['1', 2])).toEqual([1, 2]);
|
expect(coerceValue(def, ['1', 2])).toEqual([1, 2]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('a struct as a value', () => {
|
||||||
|
it('compares by content, one level deep, and tells a different shape apart', () => {
|
||||||
|
expect(sameValue({ x: 1, tags: ['a'] }, { x: 1, tags: ['a'] })).toBe(true);
|
||||||
|
expect(sameValue({ x: 1, tags: ['a'] }, { x: 1, tags: ['b'] })).toBe(false);
|
||||||
|
expect(sameValue({ x: 1 }, { x: 1, y: 2 })).toBe(false);
|
||||||
|
expect(sameValue({ x: 1 }, [1])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads as text field by field, never as "[object Object]"', () => {
|
||||||
|
expect(textOf({ x: 1, tags: ['a', 'b'] })).toBe('x: 1, tags: a, b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not a live entity, and a live entity is not one', () => {
|
||||||
|
const handle = fakeEntity('Player');
|
||||||
|
expect(asStruct(handle)).toEqual({});
|
||||||
|
expect(asStruct({ x: 1 })).toEqual({ x: 1 });
|
||||||
|
expect(asEntity({ x: 1 })).toBeNull();
|
||||||
|
expect(asEntity(handle)).toBe(handle);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** What lets one node set some values of a bundle without erasing the rest of it. */
|
||||||
|
it('keeps the fields of the slot it is written into, filling only the ones that arrive', () => {
|
||||||
|
const bag = { piece: { x: 1, y: 2, name: 'T' } };
|
||||||
|
writeTyped(bag, 'piece', { y: 9, gone: 5 });
|
||||||
|
expect(bag.piece).toEqual({ x: 1, y: 9, name: 'T' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts each field to the type that field already held', () => {
|
||||||
|
const bag = { piece: { count: 0, on: false, label: '' } };
|
||||||
|
writeTyped(bag, 'piece', { count: '7', on: 'yes', label: 42 });
|
||||||
|
expect(bag.piece).toEqual({ count: 7, on: true, label: '42' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a non-struct written into a struct slot leaves the declared fields standing', () => {
|
||||||
|
const bag = { piece: { x: 1 } };
|
||||||
|
writeTyped(bag, 'piece', 5);
|
||||||
|
expect(bag.piece).toEqual({ x: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
+24
-3
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
|
import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
|
||||||
import { type EntityHandle, worldPositionOf } from '../../src/core/graph';
|
import { ENTITY_HANDLE, type EntityHandle, worldPositionOf } from '../../src/core/graph';
|
||||||
import { hasParentCycle, parentChain } from '../../src/core/model/walk';
|
import { hasParentCycle, parentChain } from '../../src/core/model/walk';
|
||||||
import { makeSpriteScene, place, startWorld } from '../helpers';
|
import { makeSpriteScene, place, startWorld } from '../helpers';
|
||||||
|
|
||||||
@@ -41,8 +41,29 @@ describe('parentChain', () => {
|
|||||||
|
|
||||||
describe('cycle-safe walks in the runtime', () => {
|
describe('cycle-safe walks in the runtime', () => {
|
||||||
it('world position of a cyclic chain terminates and counts each ancestor once', () => {
|
it('world position of a cyclic chain terminates and counts each ancestor once', () => {
|
||||||
const a: EntityHandle = { id: 'a', assetId: 'x', name: 'a', x: 1, y: 10, z: 0, props: {}, parent: null };
|
const brand = { [ENTITY_HANDLE]: true } as const;
|
||||||
const b: EntityHandle = { id: 'b', assetId: 'x', name: 'b', x: 2, y: 20, z: 0, props: {}, parent: a };
|
const a: EntityHandle = {
|
||||||
|
...brand,
|
||||||
|
id: 'a',
|
||||||
|
assetId: 'x',
|
||||||
|
name: 'a',
|
||||||
|
x: 1,
|
||||||
|
y: 10,
|
||||||
|
z: 0,
|
||||||
|
props: {},
|
||||||
|
parent: null,
|
||||||
|
};
|
||||||
|
const b: EntityHandle = {
|
||||||
|
...brand,
|
||||||
|
id: 'b',
|
||||||
|
assetId: 'x',
|
||||||
|
name: 'b',
|
||||||
|
x: 2,
|
||||||
|
y: 20,
|
||||||
|
z: 0,
|
||||||
|
props: {},
|
||||||
|
parent: a,
|
||||||
|
};
|
||||||
a.parent = b;
|
a.parent = b;
|
||||||
expect(worldPositionOf(b)).toEqual({ x: 3, y: 30, z: 0 });
|
expect(worldPositionOf(b)).toEqual({ x: 3, y: 30, z: 0 });
|
||||||
});
|
});
|
||||||
|
|||||||
+3
-2
@@ -4,6 +4,7 @@ import { AddNodeCommand, ConnectCommand } from '../src/core/commands/graphComman
|
|||||||
import { PlaceAssetCommand } from '../src/core/commands/sceneObjectCommands';
|
import { PlaceAssetCommand } from '../src/core/commands/sceneObjectCommands';
|
||||||
import { AddVariableCommand } from '../src/core/commands/variableCommands';
|
import { AddVariableCommand } from '../src/core/commands/variableCommands';
|
||||||
import {
|
import {
|
||||||
|
ENTITY_HANDLE,
|
||||||
type EntityHandle,
|
type EntityHandle,
|
||||||
type GraphNode,
|
type GraphNode,
|
||||||
type NodeContext,
|
type NodeContext,
|
||||||
@@ -132,12 +133,12 @@ export const NO_POINTER: PointerState = {
|
|||||||
|
|
||||||
/** A top-level entity handle named after itself, with empty props. */
|
/** A top-level entity handle named after itself, with empty props. */
|
||||||
export function fakeEntity(name: string, x = 0, assetId = `asset-${name}`): EntityHandle {
|
export function fakeEntity(name: string, x = 0, assetId = `asset-${name}`): EntityHandle {
|
||||||
return { id: name, assetId, name, x, y: 0, z: 0, props: {}, parent: null };
|
return { [ENTITY_HANDLE]: true, id: name, assetId, name, x, y: 0, z: 0, props: {}, parent: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An entity handle "h" carrying the given props, optionally below a parent. */
|
/** An entity handle "h" carrying the given props, optionally below a parent. */
|
||||||
export function fakeHandle(props: PropertyBag, parent: EntityHandle | null = null): EntityHandle {
|
export function fakeHandle(props: PropertyBag, parent: EntityHandle | null = null): EntityHandle {
|
||||||
return { id: 'h', assetId: 'a', name: 'h', x: 0, y: 0, z: 0, props, parent };
|
return { [ENTITY_HANDLE]: true, id: 'h', assetId: 'a', name: 'h', x: 0, y: 0, z: 0, props, parent };
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FakeContext = NodeContext & { logs: string[]; stopped: boolean };
|
export type FakeContext = NodeContext & { logs: string[]; stopped: boolean };
|
||||||
|
|||||||
Reference in new issue
Block a user