Enabled switch, text assets, mouse input and the jump-and-run example game

Engine additions that the example game needed:
- `enabled` as a shared property of every placeable asset (default on, an
  override on the scene object). A switched-off object is not drawn, does not
  collide and its behavior does not run - and neither does anything parented
  below it. Disabled objects are now hidden in the scene view as well, so the
  editor shows what the game shows; they stay in the hierarchy panel, which is
  where they are selected and switched back on.
- Asset type `text` (text, fontSize, color, align), rendered in scene and game.
- Pointer input in the game view: `On click`, `Mouse position`, `Mouse button`,
  `Is hovered`, hit-tested against colliders, else sprite/text footprints.
- New nodes: Set/Is enabled, Set text, Is asset, Name of, Equals, To text,
  Join text, Floor/Ceil/Round, Modulo, Random, Time, Restart game.

Tooling: shared CDP harness (tools/harness.mjs), central project registry with
a guard that keeps destructive scripts off the playground (tools/projects.mjs),
plus smoke suites for the demo scene and the jump-and-run game.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-07 20:52:10 +02:00
1 parent 4900d7bbf6
commit 76aa441dce
33 files changed
+1905 -301

No files matched your search

+2
View File
@@ -17,6 +17,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
return { ...base, type: 'camera' };
case 'sprite':
return { ...base, type: 'sprite' };
case 'text':
return { ...base, type: 'text' };
case 'behavior':
return { ...base, type: 'behavior', variables: [], graph: createEmptyGraph() };
case 'collider':
+3 -2
View File
@@ -2,7 +2,7 @@ import { nodeDef } from '../graph/NodeRegistry';
import type { BehaviorAsset } from '../model/Asset';
import type { PropertyValue } from '../model/Properties';
import { findAsset, findUsages, type Project } from '../model/Project';
import { type PropertyDef, type VariableKind, variableDef } from '../properties/PropertySchema';
import { isCommonProp, type PropertyDef, type VariableKind, variableDef } from '../properties/PropertySchema';
import { type Command, CommandError } from './Command';
export function requireBehavior(project: Project, assetId: string): BehaviorAsset {
@@ -128,5 +128,6 @@ export class RenameVariableCommand implements Command {
}
export function isValidVariableName(name: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
// Common property keys (enabled) are reserved: a variable of that name would shadow the switch.
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && !isCommonProp(name);
}
+30
View File
@@ -1,4 +1,5 @@
import type { PropertyBag, PropertyValue } from '../model/Properties';
import { enabledAlongChain } from '../properties/enabled';
import type { PropertyDef } from '../properties/PropertySchema';
import type { PortType } from './Graph';
@@ -11,6 +12,8 @@ export type Value = PropertyValue | EntityHandle | null;
*/
export interface EntityHandle {
id: string;
/** Asset this entity was instantiated from (what `Is asset` compares against). */
assetId: string;
name: string;
/** Position local to `parent` (world position for top-level entities). */
x: number;
@@ -34,6 +37,23 @@ export function worldPositionOf(handle: EntityHandle): { x: number; y: number; z
return result;
}
/** Whether a handle and every ancestor of it are switched on (see `properties/enabled.ts`). */
export function isHandleEnabled(handle: EntityHandle): boolean {
return enabledAlongChain(handle, (h) => h.props, (h) => h.parent);
}
/** Where the mouse is (world units) and what it did this frame. */
export interface PointerState {
x: number;
y: number;
/** Whether the pointer is over the game view at all. */
inside: boolean;
down: boolean;
/** Went down / up during this frame. */
pressed: boolean;
released: boolean;
}
/**
* Services a graph may use while running. Implemented by the runtime (BehaviorSystem) and
* by test doubles. Everything the graph does to the world goes through here, so the core
@@ -51,6 +71,14 @@ export interface NodeContext {
dt: number;
findByName(name: string): EntityHandle | null;
isKeyDown(key: string): boolean;
/** Mouse state of this frame, in world units (see `PointerState`). */
pointer: PointerState;
/**
* Whether a world point lies on `target` – on its colliders when it has any, otherwise on
* its own footprint or that of anything parented below it (so a button made of a container
* with a sprite and a label is hit as a whole).
*/
hitTest(target: EntityHandle, x: number, y: number): boolean;
/**
* Whether any collider of `a` overlaps any collider of `b` right now. A collider entity
* stands for itself; any other entity stands for its direct collider children.
@@ -70,6 +98,8 @@ export interface NodeContext {
spawn(assetId: string, position: { x: number; y: number; z: number }, parent: EntityHandle | null): EntityHandle | null;
destroy(target: EntityHandle): void;
stop(): void;
/** Ends the run and starts it again from the saved scene (everything spawned is lost). */
restart(): void;
}
export interface PortDef {
+195 -1
View File
@@ -1,5 +1,5 @@
import type { PropertyDef } from '../properties/PropertySchema';
import { asBoolean, asEntity, asNumber, asString, type PortDef, registerNode, worldPositionOf } from './NodeRegistry';
import { asBoolean, asEntity, asNumber, asString, isHandleEnabled, type PortDef, registerNode, worldPositionOf } from './NodeRegistry';
/**
* The built-in node set. Each entry is data + a `run` function; everything else (palette,
@@ -38,6 +38,15 @@ const collisionOutputs = (): PortDef[] => [exec(), obj('other'), obj('otherColli
registerNode({ type: 'OnCollisionEnter', label: 'On collision enter', category: 'event', description: 'Runs once when a collider of the owner starts overlapping another collider.', inputs: [], outputs: collisionOutputs(), params: [] });
registerNode({ type: 'OnCollisionStay', label: 'On collision stay', category: 'event', description: 'Runs every frame while a collider of the owner overlaps another collider.', inputs: [], outputs: collisionOutputs(), params: [] });
registerNode({ type: 'OnCollisionExit', label: 'On collision exit', category: 'event', description: 'Runs once when a collider of the owner stops overlapping another collider.', inputs: [], outputs: collisionOutputs(), params: [] });
registerNode({
type: 'OnClick',
label: 'On click',
category: 'event',
description: 'Runs when the mouse is pressed on the owner (or on anything parented below it) in the Game tab.',
inputs: [],
outputs: [exec(), num('x', 'world x'), num('y', 'world y')],
params: [],
});
// --- flow ---------------------------------------------------------------------------------
@@ -76,6 +85,30 @@ registerNode({
params: [],
run: (_ctx, inputs) => ({ outputs: { parent: asEntity(inputs.object)?.parent ?? null } }),
});
registerNode({
type: 'NameOf',
label: 'Name of',
category: 'data',
description: 'The name of an object (empty when there is none).',
inputs: [obj('object')],
outputs: [{ key: 'name', label: 'name', type: 'string' }],
params: [],
run: (_ctx, inputs) => ({ outputs: { name: asEntity(inputs.object)?.name ?? '' } }),
});
registerNode({
type: 'IsAsset',
label: 'Is asset',
category: 'data',
description: 'Whether an object is an instance of the chosen asset – the way to tell what you collided with.',
inputs: [obj('object')],
outputs: [bool('result')],
// Placeholder options: the graph inspector lists the project's placeable assets.
params: [{ key: 'asset', label: 'Asset', kind: 'enum', default: '', options: [] }],
run: (_ctx, inputs, params) => {
const assetId = String(params.asset ?? '');
return { outputs: { result: Boolean(assetId) && asEntity(inputs.object)?.assetId === assetId } };
},
});
registerNode({
type: 'ObjectByName',
label: 'Object by name',
@@ -198,6 +231,124 @@ registerNode({
run: (_c, i) => ({ outputs: { result: Math.min(Math.max(asNumber(i.value), asNumber(i.min)), asNumber(i.max)) } }),
});
registerNode({ type: 'Floor', label: 'Floor', category: 'data', description: 'Rounds down to a whole number.', inputs: [num('value')], outputs: [num('result')], params: [], run: (_c, i) => ({ outputs: { result: Math.floor(asNumber(i.value)) } }) });
registerNode({ type: 'Ceil', label: 'Ceil', category: 'data', description: 'Rounds up to a whole number.', inputs: [num('value')], outputs: [num('result')], params: [], run: (_c, i) => ({ outputs: { result: Math.ceil(asNumber(i.value)) } }) });
registerNode({ type: 'Round', label: 'Round', category: 'data', description: 'Rounds to the nearest whole number.', inputs: [num('value')], outputs: [num('result')], params: [], run: (_c, i) => ({ outputs: { result: Math.round(asNumber(i.value)) } }) });
registerNode({
type: 'Modulo',
label: 'Modulo',
category: 'data',
description: 'Remainder of a ÷ b (0 when b is 0) – seconds within a minute, for instance.',
inputs: [num('a'), { ...num('b'), default: 1 }],
outputs: [num('result')],
params: [],
run: (_c, i) => {
const b = asNumber(i.b);
return { outputs: { result: b === 0 ? 0 : asNumber(i.a) % b } };
},
});
registerNode({
type: 'Random',
label: 'Random',
category: 'data',
description: 'A random number between min and max (min included, max not).',
inputs: [{ ...num('min'), default: 0 }, { ...num('max'), default: 1 }],
outputs: [num('result')],
params: [],
run: (_c, i) => {
const min = asNumber(i.min);
return { outputs: { result: min + Math.random() * (asNumber(i.max) - min) } };
},
});
registerNode({
type: 'Time',
label: 'Time',
category: 'data',
description: 'Seconds since the game started, and the length of the current frame.',
inputs: [],
outputs: [num('time'), num('dt')],
params: [],
run: (ctx) => ({ outputs: { time: ctx.time, dt: ctx.dt } }),
});
// --- data: text ------------------------------------------------------------------------------
registerNode({
type: 'ToText',
label: 'To text',
category: 'data',
description: 'Turns a number into text with a fixed number of decimals.',
inputs: [any('value'), num('decimals')],
outputs: [{ key: 'text', label: 'text', type: 'string' }],
params: [],
run: (_c, i) => {
const value = i.value;
const decimals = Math.max(0, Math.min(10, Math.floor(asNumber(i.decimals))));
return { outputs: { text: typeof value === 'string' ? value : asNumber(value).toFixed(decimals) } };
},
});
registerNode({
type: 'Join',
label: 'Join text',
category: 'data',
description: 'Sticks two values together as text ("Coins: " + 3).',
inputs: [any('a'), any('b')],
outputs: [{ key: 'text', label: 'text', type: 'string' }],
params: [],
run: (_c, i) => ({ outputs: { text: asString(i.a) + asString(i.b) } }),
});
registerNode({
type: 'Equals',
label: 'Equals',
category: 'data',
description: 'Whether two values are the same. Objects compare by identity, everything else as text.',
inputs: [any('a'), any('b')],
outputs: [bool('result')],
params: [],
run: (_c, i) => {
const a = asEntity(i.a);
const b = asEntity(i.b);
if (a || b) return { outputs: { result: a === b } };
return { outputs: { result: asString(i.a) === asString(i.b) } };
},
});
// --- data: mouse ------------------------------------------------------------------------------
registerNode({
type: 'MousePosition',
label: 'Mouse position',
category: 'data',
description: 'Where the mouse is in the Game tab, in world units.',
inputs: [],
outputs: [num('x'), num('y'), bool('inside', 'in view')],
params: [],
run: (ctx) => ({ outputs: { x: ctx.pointer.x, y: ctx.pointer.y, inside: ctx.pointer.inside } }),
});
registerNode({
type: 'MouseButton',
label: 'Mouse button',
category: 'data',
description: 'State of the mouse button: held, and whether it went down or up this frame.',
inputs: [],
outputs: [bool('down'), bool('pressed'), bool('released')],
params: [],
run: (ctx) => ({ outputs: { down: ctx.pointer.down, pressed: ctx.pointer.pressed, released: ctx.pointer.released } }),
});
registerNode({
type: 'IsHovered',
label: 'Is hovered',
category: 'data',
description: 'Whether the mouse is over an object (its colliders, else its own or its children\'s footprint).',
inputs: [obj('object')],
outputs: [bool('result')],
params: [],
run: (ctx, i) => {
const target = asEntity(i.object);
return { outputs: { result: Boolean(target && ctx.pointer.inside && ctx.hitTest(target, ctx.pointer.x, ctx.pointer.y)) } };
},
});
// --- data: vectors -------------------------------------------------------------------------
registerNode({ type: 'Length', label: 'Length', category: 'data', description: 'Length of the vector (x, y).', inputs: [num('x'), num('y')], outputs: [num('result')], params: [], run: (_c, i) => ({ outputs: { result: Math.hypot(asNumber(i.x), asNumber(i.y)) } }) });
@@ -332,6 +483,48 @@ registerNode({
return { next: 'exec' };
},
});
registerNode({
type: 'SetEnabled',
label: 'Set enabled',
category: 'action',
description: 'Switches an object on or off. A disabled object is not drawn, does not collide and its behavior stops – and so does everything parented below it. This is how screens (title, win, lose) are shown and hidden.',
inputs: [exec(), obj('object'), { ...bool('enabled'), default: true }],
outputs: [exec()],
params: [],
run: (_ctx, i) => {
const target = asEntity(i.object);
if (target) target.props.enabled = asBoolean(i.enabled);
return { next: 'exec' };
},
});
registerNode({
type: 'IsEnabled',
label: 'Is enabled',
category: 'data',
description: 'Whether an object is switched on – "own" is its own switch, "result" also requires every parent to be on.',
inputs: [obj('object')],
outputs: [bool('result'), bool('own', 'own switch')],
params: [],
run: (_ctx, i) => {
const target = asEntity(i.object);
if (!target) return { outputs: { result: false, own: false } };
return { outputs: { result: isHandleEnabled(target), own: target.props.enabled !== false } };
},
});
registerNode({
type: 'SetText',
label: 'Set text',
category: 'action',
description: 'Writes the content of a text object (same as Set property "text").',
inputs: [exec(), obj('object'), any('text')],
outputs: [exec()],
params: [],
run: (_ctx, i) => {
const target = asEntity(i.object);
if (target) target.props.text = asString(i.text);
return { next: 'exec' };
},
});
registerNode({
type: 'Destroy',
label: 'Destroy',
@@ -376,3 +569,4 @@ registerNode({
},
});
registerNode({ type: 'StopGame', label: 'Stop game', category: 'action', description: 'Ends play mode.', inputs: [exec()], outputs: [], params: [], run: (ctx) => { ctx.stop(); return {}; } });
registerNode({ type: 'RestartGame', label: 'Restart game', category: 'action', description: 'Starts the scene over from its saved state – everything spawned, moved or destroyed is reset.', inputs: [exec()], outputs: [], params: [], run: (ctx) => { ctx.restart(); return {}; } });
+9 -1
View File
@@ -20,6 +20,14 @@ export interface CameraAsset extends AssetBase {
type: 'camera';
}
/**
* A line of text drawn in the scene (titles, HUD readouts, buttons). Its content is the
* ordinary property `text`, so behaviors change it with `Set text` (or `Set property`).
*/
export interface TextAsset extends AssetBase {
type: 'text';
}
/** A scene is an asset that additionally owns the objects placed into it. */
export interface SceneAsset extends AssetBase {
type: 'scene';
@@ -57,7 +65,7 @@ export interface ContainerAsset extends AssetBase {
objects: SceneObject[];
}
export type Asset = SpriteAsset | CameraAsset | SceneAsset | BehaviorAsset | ColliderAsset | ContainerAsset;
export type Asset = SpriteAsset | CameraAsset | TextAsset | SceneAsset | BehaviorAsset | ColliderAsset | ContainerAsset;
export function isSceneAsset(asset: Asset): asset is SceneAsset {
return asset.type === 'scene';
+1 -1
View File
@@ -1,5 +1,5 @@
/** All asset types. Scenes are assets too, but cannot be placed in scenes. */
export const ASSET_TYPES = ['scene', 'camera', 'sprite', 'behavior', 'collider', 'container'] as const;
export const ASSET_TYPES = ['scene', 'camera', 'sprite', 'text', 'behavior', 'collider', 'container'] as const;
export type AssetType = (typeof ASSET_TYPES)[number];
+30 -4
View File
@@ -24,6 +24,21 @@ export type PropertySchema = readonly PropertyDef[];
export const VARIABLE_KINDS = ['number', 'boolean', 'text'] as const;
export type VariableKind = (typeof VARIABLE_KINDS)[number];
/**
* Properties every *placeable* asset has, whatever its type. `enabled` is the master switch:
* a disabled object is not drawn, does not collide and its behavior does not run – and the
* same goes for everything parented below it. Assets default to on, so switching a single
* instance off is an ordinary override. Scenes have no such switch (they are not placed).
*/
export const COMMON_PROPS: PropertySchema = [{ key: 'enabled', label: 'Enabled', kind: 'boolean', default: true }];
const COMMON_KEYS = new Set(COMMON_PROPS.map((d) => d.key));
/** Whether a key belongs to the common properties (the inspector groups those separately). */
export function isCommonProp(key: string): boolean {
return COMMON_KEYS.has(key);
}
/** Property schema per asset type. Position is deliberately absent: it belongs to scene objects. */
export const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
scene: [{ key: 'backgroundColor', label: 'Background', kind: 'color', default: 0x2a2d34 }],
@@ -49,26 +64,37 @@ export const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
],
// A container is pure structure: its content is the template, not a property.
container: [],
text: [
{ key: 'text', label: 'Text', kind: 'text', default: 'Text' },
{ key: 'fontSize', label: 'Font size', kind: 'number', default: 32, min: 1, step: 1 },
{ key: 'color', label: 'Color', kind: 'color', default: 0xffffff },
{ key: 'align', label: 'Align', kind: 'enum', default: 'center', options: [{ value: 'left', label: 'Left' }, { value: 'center', label: 'Center' }, { value: 'right', label: 'Right' }] },
],
};
/** Type schema with the common properties in front – the effective schema of an asset type. */
const FULL_SCHEMAS: Record<AssetType, PropertySchema> = Object.fromEntries(
(Object.keys(ASSET_SCHEMAS) as AssetType[]).map((type) => [type, type === 'scene' ? ASSET_SCHEMAS[type] : [...COMMON_PROPS, ...ASSET_SCHEMAS[type]]]),
) as Record<AssetType, PropertySchema>;
/** Properties every scene object has regardless of its asset type. */
export const SCENE_OBJECT_SCHEMA: PropertySchema = [
{ key: 'x', label: 'X', kind: 'number', default: 0, step: 1 },
{ key: 'y', label: 'Y', kind: 'number', default: 0, step: 1 },
];
/** Schema shared by every asset of a type. Behaviors add per-asset variables on top. */
/** Schema shared by every asset of a type (common properties first). */
export function schemaFor(type: AssetType): PropertySchema {
return ASSET_SCHEMAS[type];
return FULL_SCHEMAS[type];
}
/** The effective schema of one asset: the type schema, plus declared variables for behaviors. */
export function schemaForAsset(asset: Asset): PropertySchema {
return asset.type === 'behavior' ? asset.variables : ASSET_SCHEMAS[asset.type];
return asset.type === 'behavior' ? [...COMMON_PROPS, ...asset.variables] : FULL_SCHEMAS[asset.type];
}
export function defaultsFor(type: AssetType): PropertyBag {
return defaultsOf(ASSET_SCHEMAS[type]);
return defaultsOf(FULL_SCHEMAS[type]);
}
export function defaultsOf(schema: PropertySchema): PropertyBag {
+61
View File
@@ -0,0 +1,61 @@
import type { AssetType } from '../model/AssetTypes';
import type { PropertyBag, Vec2 } from '../model/Properties';
import { cameraFrame } from './camera';
import { colliderSize } from './collider';
import { defaultsFor } from './PropertySchema';
/**
* Footprints in world units – how much room an object takes up. Shared by the viewport
* (culling, selection gizmos), by pointer hit tests in the running game and by "Add
* collider", so all three agree on how big a thing is.
*/
/** World-unit size of the behavior symbol (a diamond). */
export const BEHAVIOR_SYMBOL = 28;
/** World-unit size of the container symbol (a bracketed square). */
export const CONTAINER_SYMBOL = 24;
/** Rough width of one character relative to the font size (the UI font is a plain sans). */
const CHAR_WIDTH = 0.55;
const LINE_HEIGHT = 1.25;
/** Size a text object covers, estimated from its content and font size. */
export function textSize(props: PropertyBag): { w: number; h: number } {
const d = defaultsFor('text');
const size = Math.max(1, Number(props.fontSize ?? d.fontSize));
const lines = String(props.text ?? d.text).split('\n');
const longest = lines.reduce((max, line) => Math.max(max, line.length), 0);
return { w: Math.max(size * CHAR_WIDTH, longest * size * CHAR_WIDTH), h: lines.length * size * LINE_HEIGHT };
}
/** Horizontal anchor of a text object: 0 = left edge on the position, 0.5 = centred, 1 = right. */
export function textAnchor(props: PropertyBag): number {
const align = String(props.align ?? defaultsFor('text').align);
return align === 'left' ? 0 : align === 'right' ? 1 : 0.5;
}
/** Footprint of an object of the given type, centred on its position (text: see `textAnchor`). */
export function footprintOf(type: AssetType, props: PropertyBag): { w: number; h: number } {
switch (type) {
case 'sprite':
return { w: Number(props.width ?? 1), h: Number(props.height ?? 1) };
case 'text':
return textSize(props);
case 'collider':
return colliderSize(props);
case 'behavior':
return { w: BEHAVIOR_SYMBOL, h: BEHAVIOR_SYMBOL };
case 'container':
return { w: CONTAINER_SYMBOL, h: CONTAINER_SYMBOL };
default:
return cameraFrame(props);
}
}
/** Whether a world point lies inside the footprint of an object placed at `centre`. */
export function footprintContains(type: AssetType, props: PropertyBag, centre: Vec2, point: Vec2): boolean {
const { w, h } = footprintOf(type, props);
const offset = type === 'text' ? (0.5 - textAnchor(props)) * w : 0;
const x = centre.x + offset;
return Math.abs(point.x - x) <= w / 2 && Math.abs(point.y - centre.y) <= h / 2;
}
+6
View File
@@ -27,6 +27,12 @@ export function colliderSize(props: PropertyBag): { w: number; h: number } {
return shape.kind === 'circle' ? { w: shape.r * 2, h: shape.r * 2 } : { w: shape.w, h: shape.h };
}
/** Whether a point lies inside a shape (used for pointer hit tests). */
export function shapeContains(shape: ColliderShape, point: Vec2): boolean {
if (shape.kind === 'circle') return (point.x - shape.x) ** 2 + (point.y - shape.y) ** 2 <= shape.r * shape.r;
return Math.abs(point.x - shape.x) * 2 <= shape.w && Math.abs(point.y - shape.y) * 2 <= shape.h;
}
/** Whether two shapes overlap. Shapes that merely touch along an edge do not overlap. */
export function shapesOverlap(a: ColliderShape, b: ColliderShape): boolean {
if (a.kind === 'box' && b.kind === 'box') {
+26
View File
@@ -0,0 +1,26 @@
import type { PropertyBag } from '../model/Properties';
/**
* The `enabled` switch (see `COMMON_PROPS`). Disabling an object takes it out of the running
* game completely – not drawn, no collisions, no behavior ticks – and the same holds for its
* children, because "enabled" is only true when the whole chain up to the root is on. That is
* what makes a container usable as a screen: switch it on to show it, off to hide it.
*/
/** Whether a resolved property bag says "on". A missing value counts as on. */
export function propsEnabled(props: PropertyBag): boolean {
return props.enabled !== false;
}
/**
* Whether `start` and every ancestor of it are switched on. Generic over the shape so the
* model (scene objects), the viewport (draw items) and the runtime (entities) share one rule.
*/
export function enabledAlongChain<T>(start: T, propsOf: (item: T) => PropertyBag, parentOf: (item: T) => T | null): boolean {
const seen = new Set<T>();
for (let current: T | null = start; current && !seen.has(current); current = parentOf(current)) {
if (!propsEnabled(propsOf(current))) return false;
seen.add(current);
}
return true;
}
+4 -3
View File
@@ -5,7 +5,7 @@ import { isAssetType } from '../model/AssetTypes';
import type { PropertyBag, PropertyValue } from '../model/Properties';
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
import type { SceneObject } from '../model/SceneObject';
import { coerceValue, type PropertyDef, type PropertySchema, schemaFor, VARIABLE_KINDS, variableDef, type VariableKind } from '../properties/PropertySchema';
import { coerceValue, COMMON_PROPS, isCommonProp, type PropertyDef, type PropertySchema, schemaFor, VARIABLE_KINDS, variableDef, type VariableKind } from '../properties/PropertySchema';
/**
* Turns untrusted JSON (server file, import) into a well-formed Project. Unknown keys are
@@ -25,7 +25,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
if (a.type === 'behavior') {
const vars = normalizeVariables(a.variables);
variables.set(a.id, vars);
schemas.set(a.id, vars);
schemas.set(a.id, [...COMMON_PROPS, ...vars]);
} else {
schemas.set(a.id, schemaFor(a.type));
}
@@ -113,7 +113,8 @@ function normalizeVariables(raw: unknown): PropertyDef[] {
const v = asRecord(item);
const key = typeof v.key === 'string' ? v.key : '';
const kind = (VARIABLE_KINDS as readonly string[]).includes(String(v.kind)) ? (v.kind as VariableKind) : 'number';
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || seen.has(key)) continue;
// Common property keys are reserved – a variable of that name would shadow the switch.
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || seen.has(key) || isCommonProp(key)) continue;
seen.add(key);
const def = variableDef(key, kind);
def.default = coerceValue(def, (v.default ?? def.default) as PropertyValue) as never;
+10 -2
View File
@@ -69,6 +69,8 @@ function installDebugHook(app: Application, state: EditorState, editor: Editor):
drawOrder: () => viewport.drawOrder(),
/** Screen position of an object in the Scene tab (its world position through the view). */
objectScreen: (id: string) => viewport.objectScreen(id),
/** The same for the Game tab (through the camera), so tests can click what the player sees. */
gameScreen: (id: string) => gameView.objectScreen(id),
graph: {
current: () => graphEditor.currentAssetId,
portAt: (nodeId: string, port: string, isOutput: boolean) => graphEditor.portGlobal(nodeId, port, isOutput),
@@ -320,6 +322,12 @@ class Editor {
this.stop();
return;
}
// Restart game: throw the run away and start the scene again from the saved model.
if (world.restartRequested) {
this.stop();
this.play();
return;
}
// Mirror what the systems did into the runtime copy so the panels show live values.
// A destroyed entity removes its object – that is a structural change the lists must see.
if (world.syncToModel()) this.state.preview();
@@ -339,13 +347,13 @@ class Editor {
}
/** Read-only view of the running world for tests. */
worldSnapshot(): { entities: { objectId: string; x: number; y: number; worldX: number; worldY: number; parent: string; props: Record<string, unknown> }[]; log: string[] } | null {
worldSnapshot(): { entities: { objectId: string; assetId: string; name: string; x: number; y: number; worldX: number; worldY: number; parent: string; props: Record<string, unknown> }[]; log: string[] } | null {
const world = this.world;
if (!world) return null;
return {
entities: world.entities.map((e) => {
const w = world.worldPosition(e);
return { objectId: e.objectId, x: e.x, y: e.y, worldX: w.x, worldY: w.y, parent: e.parent?.objectId ?? '', props: { ...e.props } };
return { objectId: e.objectId, assetId: e.assetId, name: e.name, x: e.x, y: e.y, worldX: w.x, worldY: w.y, parent: e.parent?.objectId ?? '', props: { ...e.props } };
}),
log: [...world.log],
};
+2 -2
View File
@@ -7,7 +7,7 @@ import { findAsset, findUsages } from '../../core/model/Project';
import { Button, Dialog, ListView, Menu, type MenuItem, Panel, ScrollContainer } from '../../ui';
import { type EditorState, objectSelection } from '../state';
const TYPE_LABELS: Record<AssetType, string> = { scene: 'Scene', camera: 'Camera', sprite: 'Sprite', behavior: 'Behavior', collider: 'Collider', container: 'Container' };
const TYPE_LABELS: Record<AssetType, string> = { scene: 'Scene', camera: 'Camera', sprite: 'Sprite', text: 'Text', behavior: 'Behavior', collider: 'Collider', container: 'Container' };
/** Lists all assets of the project; creates, places and deletes them. */
export class AssetPanel extends Panel {
@@ -45,7 +45,7 @@ export class AssetPanel extends Panel {
id: asset.id,
label: asset.name,
hint: asset.type === 'scene' && asset.id === active ? 'scene ●' : asset.type,
swatch: asset.type === 'sprite' ? Number(asset.props.color ?? 0) : undefined,
swatch: asset.type === 'sprite' || asset.type === 'text' ? Number(asset.props.color ?? 0) : undefined,
})),
);
this.syncSelection();
+64 -43
View File
@@ -25,7 +25,7 @@ import { isSelfOrAncestor, treeOrder } from '../../core/model/hierarchy';
import type { Position, PropertyValue } from '../../core/model/Properties';
import { findAsset, findScene, findSceneObject } from '../../core/model/Project';
import type { SceneObject } from '../../core/model/SceneObject';
import { type PropertyDef, schemaForAsset, VARIABLE_KINDS, type VariableKind } from '../../core/properties/PropertySchema';
import { COMMON_PROPS, isCommonProp, type PropertyDef, schemaForAsset, VARIABLE_KINDS, type VariableKind } from '../../core/properties/PropertySchema';
import { isModified, isOverridden, resolveAssetProps, resolveSceneObjectProps } from '../../core/properties/resolve';
import { Button, HStack, Label, Menu, type MenuItem, Panel, ScrollContainer, TextField, Theme, ui, VStack } from '../../ui';
import { createContainer, removeObjects } from '../actions';
@@ -135,13 +135,19 @@ export class Inspector extends Panel {
this.body.add(this.labelled('Name', this.nameField));
if (asset.type === 'behavior') {
this.buildAssetProps(asset, COMMON_PROPS);
this.buildVariables(asset);
return;
}
// Asset properties: values that differ from the schema default are marked and resettable.
this.buildAssetProps(asset, schemaForAsset(asset));
}
/** Asset properties: values that differ from the schema default are marked and resettable. */
private buildAssetProps(asset: Asset, schema: readonly PropertyDef[]): void {
const assetId = asset.id;
const values = resolveAssetProps(asset);
for (const def of schemaForAsset(asset)) {
for (const def of schema) {
const row = createFieldRow(def, values[def.key] ?? def.default, this.assetHandlers(assetId, def), () => this.state.execute(new ResetAssetPropertyCommand(assetId, def.key)));
row.setModified(isModified(asset, def.key));
this.fields.set(def.key, row);
@@ -269,6 +275,11 @@ export class Inspector extends Panel {
this.body.add(linkRow);
}
// The master switch, right where it is looked for: above everything type-specific.
for (const def of COMMON_PROPS) {
this.body.add(this.objectPropertyRow(sceneId, objectId, def, resolveSceneObjectProps(asset, object), templateLinkOf(this.state.project, scene, object) ?? null).row);
}
// Position – belongs to the scene object only, local to the parent. Z is the draw order (higher = on top).
const parent = object.parentId ? findSceneObject(scene, object.parentId) : undefined;
this.body.add(new Label(parent ? 'POSITION (LOCAL TO PARENT)' : 'POSITION', { style: 'title' }));
@@ -302,48 +313,15 @@ export class Inspector extends Panel {
this.body.add(row.row);
}
// Asset properties (or behavior variables) with overrides.
// Asset properties (or behavior variables) with overrides. The common switch (Enabled)
// is shown on its own, above the position – it exists for every type and is not a
// "property of a sprite" in the same sense.
const schema = schemaForAsset(asset);
if (schema.length > 0 || asset.type === 'behavior') this.body.add(new Label(asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES', { style: 'title' }));
if (schema.length === 0 && asset.type === 'behavior') this.body.add(new Label('The behavior declares no variables.', { style: 'muted' }));
const values = resolveSceneObjectProps(asset, object);
for (const def of schema) {
let original: PropertyValue | undefined;
let hadOverride = false;
const row = createFieldRow(
def,
values[def.key] ?? def.default,
{
onInput: (value) => {
const target = this.currentObject(sceneId, objectId);
if (!target) return;
if (original === undefined) {
hadOverride = isOverridden(target, def.key);
original = target.overrides[def.key] ?? values[def.key] ?? def.default;
}
target.overrides[def.key] = value;
this.state.preview();
},
onChange: (value) => {
const target = this.currentObject(sceneId, objectId);
if (!target) return;
if (original !== undefined) {
if (hadOverride) target.overrides[def.key] = original;
else delete target.overrides[def.key];
}
original = undefined;
this.state.execute(new SetOverrideCommand(sceneId, objectId, def.key, value));
},
},
() => this.state.execute(link ? new RevertOverrideToTemplateCommand(sceneId, objectId, def.key) : new ClearOverrideCommand(sceneId, objectId, def.key)),
[
...(link ? [{ label: 'Apply to container', onSelect: () => this.state.execute(new ApplyOverrideToContainerCommand(sceneId, objectId, def.key)) }] : []),
{ label: 'Apply to asset', onSelect: () => this.state.execute(new ApplyOverrideToAssetCommand(sceneId, objectId, def.key)) },
],
);
this.fields.set(def.key, row);
this.body.add(row.row);
}
const own = schema.filter((def) => !isCommonProp(def.key));
if (own.length > 0 || asset.type === 'behavior') this.body.add(new Label(asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES', { style: 'title' }));
if (own.length === 0 && asset.type === 'behavior') this.body.add(new Label('The behavior declares no variables.', { style: 'muted' }));
for (const def of own) this.body.add(this.objectPropertyRow(sceneId, objectId, def, values, link ?? null).row);
this.markObjectRows(object, asset, link);
if (asset.type === 'container') {
@@ -368,6 +346,49 @@ export class Inspector extends Panel {
}
}
/**
* One editable property of a scene object: writes an override, offers ↺ (back to the asset
* or, inside a container, to the template) and ↑ (apply upwards). Registered in `fields`
* so live refreshes and `markObjectRows` find it.
*/
private objectPropertyRow(sceneId: string, objectId: string, def: PropertyDef, values: Record<string, PropertyValue>, link: TemplateLink | null): FieldRow {
let original: PropertyValue | undefined;
let hadOverride = false;
const row = createFieldRow(
def,
values[def.key] ?? def.default,
{
onInput: (value) => {
const target = this.currentObject(sceneId, objectId);
if (!target) return;
if (original === undefined) {
hadOverride = isOverridden(target, def.key);
original = target.overrides[def.key] ?? values[def.key] ?? def.default;
}
target.overrides[def.key] = value;
this.state.preview();
},
onChange: (value) => {
const target = this.currentObject(sceneId, objectId);
if (!target) return;
if (original !== undefined) {
if (hadOverride) target.overrides[def.key] = original;
else delete target.overrides[def.key];
}
original = undefined;
this.state.execute(new SetOverrideCommand(sceneId, objectId, def.key, value));
},
},
() => this.state.execute(link ? new RevertOverrideToTemplateCommand(sceneId, objectId, def.key) : new ClearOverrideCommand(sceneId, objectId, def.key)),
[
...(link ? [{ label: 'Apply to container', onSelect: () => this.state.execute(new ApplyOverrideToContainerCommand(sceneId, objectId, def.key)) }] : []),
{ label: 'Apply to asset', onSelect: () => this.state.execute(new ApplyOverrideToAssetCommand(sceneId, objectId, def.key)) },
],
);
this.fields.set(def.key, row);
return row;
}
/**
* "Parent: <name> ▾" – picks another object of the same scene, or none. The object's own
* subtree is excluded (that would be a cycle). For behaviors the parent is the owner.
+54 -1
View File
@@ -1,4 +1,6 @@
import { worldPositionOf } from '../core/graph';
import { isHandleEnabled, type PointerState, worldPositionOf } from '../core/graph';
import { footprintContains } from '../core/properties/bounds';
import { colliderShape, shapeContains } from '../core/properties/collider';
import { instantiateContainer } from '../core/model/container';
import { newId } from '../core/ids';
import type { Asset } from '../core/model/Asset';
@@ -23,6 +25,13 @@ export class World {
backgroundColor = 0x000000;
/** Set by a behavior (Stop game node); the editor leaves play mode on the next tick. */
stopRequested = false;
/** Set by a behavior (Restart game node); the editor restarts the scene on the next tick. */
restartRequested = false;
/**
* Mouse state in world units, fed by the game view (the only place with the camera
* transform). `pressed`/`released` last for one tick, like the keyboard edges.
*/
readonly pointer: PointerState = { x: 0, y: 0, inside: false, down: false, pressed: false, released: false };
/** Messages logged by behaviors (Log node), newest last. */
readonly log: string[] = [];
private readonly systems: System[] = [];
@@ -160,6 +169,50 @@ export class World {
tick(dt: number): void {
this.elapsed += dt;
for (const system of this.systems) system.update(this, dt);
// The pointer edges belong to exactly one tick, whatever the systems did with them.
this.pointer.pressed = false;
this.pointer.released = false;
}
/** Game view: where the mouse is (world units) and whether it is over the view at all. */
movePointer(x: number, y: number, inside: boolean): void {
this.pointer.x = x;
this.pointer.y = y;
this.pointer.inside = inside;
}
/** Game view: the mouse button changed. Sets the edge flag the current tick reports. */
setPointerDown(down: boolean): void {
if (down === this.pointer.down) return;
this.pointer.down = down;
if (down) this.pointer.pressed = true;
else this.pointer.released = true;
}
/**
* Whether a world point lies on an entity: on the colliders of its subtree when it has
* any, otherwise on the box of a sprite or text in it. Disabled parts never answer, so a
* hidden screen cannot swallow clicks meant for what is drawn behind it.
*/
hitTest(entity: Entity, x: number, y: number): boolean {
const point = { x, y };
const parts = [entity, ...this.descendantsOf(entity)].filter((e) => isHandleEnabled(e));
const colliders = parts.filter((e) => e.type === 'collider');
if (colliders.length > 0) return colliders.some((c) => shapeContains(colliderShape(c.props, this.worldPosition(c)), point));
return parts.some((e) => (e.type === 'sprite' || e.type === 'text') && footprintContains(e.type, e.props, this.worldPosition(e), point));
}
/** Everything parented below an entity, at any depth. */
descendantsOf(entity: Entity): Entity[] {
const result: Entity[] = [];
const visit = (parent: Entity) => {
for (const child of this.childrenOf(parent)) {
result.push(child);
visit(child);
}
};
visit(entity);
return result;
}
get time(): number {
+18 -3
View File
@@ -1,4 +1,4 @@
import { type EntityHandle, GraphInterpreter, type NodeContext, type TraceListener } from '../../core/graph';
import { type EntityHandle, GraphInterpreter, isHandleEnabled, type NodeContext, type TraceListener } from '../../core/graph';
import { findAsset, type Project } from '../../core/model/Project';
import type { Entity } from '../Entity';
import type { World } from '../World';
@@ -44,12 +44,14 @@ export class BehaviorSystem implements System {
/**
* One instance per behavior entity: entities that appeared (placed while playing) get an
* interpreter and their `On start`; instances whose entity is gone are dropped.
* interpreter and their `On start`; instances whose entity is gone are dropped. A behavior
* that is switched off has no instance yet – it gets one, and its `On start`, the moment it
* is switched on. That is what lets a hidden screen wake up when it is shown.
*/
private syncInstances(world: World): void {
this.instances = this.instances.filter((i) => world.entities.includes(i.entity));
for (const entity of world.entities) {
if (entity.type !== 'behavior' || this.instances.some((i) => i.entity === entity)) continue;
if (entity.type !== 'behavior' || !isHandleEnabled(entity) || this.instances.some((i) => i.entity === entity)) continue;
const asset = findAsset(this.project, entity.assetId);
if (!asset || asset.type !== 'behavior') continue;
const trace: Trace = { edges: new Map(), nodes: new Map() };
@@ -66,6 +68,7 @@ export class BehaviorSystem implements System {
const keyOf = (node: { params: Record<string, unknown> }) => String(node.params.key ?? '');
for (const instance of this.instances) {
if (!world.entities.includes(instance.entity)) continue; // destroyed during play
if (!isHandleEnabled(instance.entity)) continue; // switched off (itself or a parent)
this.run(instance, (i, ctx) => {
for (const key of this.input.justPressed) i.fire('OnKeyDown', ctx, {}, (n) => keyOf(n) === key);
for (const key of this.input.justReleased) i.fire('OnKeyUp', ctx, {}, (n) => keyOf(n) === key);
@@ -81,6 +84,10 @@ export class BehaviorSystem implements System {
}
}
this.fireCollisions(instance, i, ctx);
const pointer = world.pointer;
if (pointer.pressed && world.hitTest(instance.entity.parent ?? instance.entity, pointer.x, pointer.y)) {
i.fire('OnClick', ctx, { x: pointer.x, y: pointer.y });
}
i.fire('OnUpdate', ctx, { dt });
}, dt);
}
@@ -144,6 +151,11 @@ export class BehaviorSystem implements System {
dt,
findByName: (name) => world.findByName(name) ?? null,
isKeyDown: (key) => this.input.isDown(key),
pointer: world.pointer,
hitTest: (target, x, y) => {
const found = world.entities.find((e) => e === target || e.id === target.id);
return Boolean(found && world.hitTest(found, x, y));
},
isTouching: (a, b) => {
const ea = world.entities.find((e) => e === a || e.id === a.id);
const eb = world.entities.find((e) => e === b || e.id === b.id);
@@ -172,6 +184,9 @@ export class BehaviorSystem implements System {
stop: () => {
world.stopRequested = true;
},
restart: () => {
world.restartRequested = true;
},
};
}
+6 -4
View File
@@ -1,3 +1,4 @@
import { isHandleEnabled } from '../../core/graph';
import { type Axis, axisCorrection, colliderShape, colliderSize, isTrigger, shapesOverlap } from '../../core/properties/collider';
import type { Entity } from '../Entity';
import type { World } from '../World';
@@ -27,7 +28,8 @@ export class CollisionSystem implements System {
private previous = new Map<string, CollisionPair>();
update(world: World): void {
const colliders = world.entities.filter((e) => e.type === 'collider');
// Switched-off colliders (or ones below something switched off) take no part at all.
const colliders = world.entities.filter((e) => e.type === 'collider' && isHandleEnabled(e));
const current = new Map<string, CollisionPair>();
for (let i = 0; i < colliders.length; i++) {
for (let j = i + 1; j < colliders.length; j++) {
@@ -77,8 +79,8 @@ export class CollisionSystem implements System {
* colliders and colliders that travel with it (descendants) never block.
*/
moveAndSlide(world: World, entity: Entity, dx: number, dy: number): { blockedX: boolean; blockedY: boolean } {
const own = collidersOf(world, entity).filter((c) => !isTrigger(c.props));
const obstacles = own.length === 0 ? [] : world.entities.filter((c) => c.type === 'collider' && !isTrigger(c.props) && !this.travelsWith(c, entity));
const own = collidersOf(world, entity).filter((c) => !isTrigger(c.props) && isHandleEnabled(c));
const obstacles = own.length === 0 ? [] : world.entities.filter((c) => c.type === 'collider' && !isTrigger(c.props) && isHandleEnabled(c) && !this.travelsWith(c, entity));
return { blockedX: this.slideAxis(world, entity, own, obstacles, 'x', dx), blockedY: this.slideAxis(world, entity, own, obstacles, 'y', dy) };
}
@@ -155,5 +157,5 @@ export function bodyOf(collider: Entity): Entity {
/** A collider stands for itself; any other entity stands for its direct collider children. */
export function collidersOf(world: World, entity: Entity): Entity[] {
if (entity.type === 'collider') return [entity];
return world.childrenOf(entity).filter((e) => e.type === 'collider');
return world.childrenOf(entity).filter((e) => e.type === 'collider' && isHandleEnabled(e));
}
+97 -22
View File
@@ -1,12 +1,15 @@
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Rectangle, Text, TextStyle } from 'pixi.js';
import { CompoundCommand } from '../core/commands/CompoundCommand';
import { MoveSceneObjectCommand } from '../core/commands/sceneObjectCommands';
import { isHandleEnabled } from '../core/graph';
import { type AssetType, isSymbolType } from '../core/model/AssetTypes';
import { type Position, type PropertyBag, sortByZ, type Vec2 } from '../core/model/Properties';
import { subtreeRoots, treeOrder, worldPosition } from '../core/model/hierarchy';
import { findAsset } from '../core/model/Project';
import { BEHAVIOR_SYMBOL, CONTAINER_SYMBOL, footprintOf, textAnchor } from '../core/properties/bounds';
import { cameraFrame, rectAround, rectsIntersect, type WorldRect } from '../core/properties/camera';
import { type ColliderShape, colliderShape, colliderSize, isTrigger } from '../core/properties/collider';
import { enabledAlongChain } from '../core/properties/enabled';
import { type ColliderShape, colliderShape, isTrigger } from '../core/properties/collider';
import { resolveSceneObjectProps } from '../core/properties/resolve';
import type { World } from '../runtime/World';
import { Theme, textStyle, Widget, type Size } from '../ui';
@@ -24,12 +27,10 @@ interface DrawItem {
props: PropertyBag;
/** Id of the parent item, empty when top-level. */
parentId: string;
/** Effective switch: this object and all its parents are on (see `properties/enabled.ts`). */
enabled: boolean;
}
/** World-unit size of the behavior symbol (a diamond). */
const BEHAVIOR_SYMBOL = 28;
/** World-unit size of the container symbol (a bracketed square). */
const CONTAINER_SYMBOL = 24;
/** Pixels the pointer must travel before a press on empty space becomes a rubber band. */
const MARQUEE_THRESHOLD = 3;
/** Screen-pixel half-width of the band along a collider's outline that grabs the collider. */
@@ -119,6 +120,8 @@ export class Viewport extends Widget {
private readonly gizmos = new Graphics();
private readonly nodes = new Map<string, Graphics>();
private readonly labels = new Map<string, Text>();
/** Rendered content of text objects: a sibling of the item's node, kept next to it in z. */
private readonly texts = new Map<string, Text>();
private playWorld: World | null = null;
/** Edit-mode view: world point at the viewport centre and zoom. */
private view = { x: 0, y: 0, zoom: 1 };
@@ -143,7 +146,13 @@ export class Viewport extends Widget {
this.cursor = 'default';
this.on('pointerdown', (e: FederatedPointerEvent) => {
if (this.game) return;
if (this.game) {
if (e.button === 0) {
this.reportPointer(e);
this.playWorld?.setPointerDown(true);
}
return;
}
if (e.button === 1 || (e.button === 0 && e.target === this && e.shiftKey)) {
this.panning = { startX: e.global.x, startY: e.global.y, viewX: this.view.x, viewY: this.view.y };
e.stopPropagation();
@@ -157,6 +166,10 @@ export class Viewport extends Widget {
}
});
this.on('globalpointermove', (e: FederatedPointerEvent) => {
if (this.game) {
this.reportPointer(e);
return;
}
if (this.panning) {
this.view.x = this.panning.viewX - (e.global.x - this.panning.startX) / this.view.zoom;
this.view.y = this.panning.viewY - (e.global.y - this.panning.startY) / this.view.zoom;
@@ -184,6 +197,10 @@ export class Viewport extends Widget {
}
});
const finish = () => {
if (this.game) {
this.playWorld?.setPointerDown(false);
return;
}
this.panning = null;
const marquee = this.marquee;
this.marquee = null;
@@ -231,6 +248,21 @@ export class Viewport extends Widget {
state.on('runtime', () => { if (this.visible) this.redraw(); });
}
/**
* Game view: hands the mouse position to the running world in world units, so graphs can
* use it (Mouse position, Is hovered, On click). Outside the camera frame counts as "not
* in view" – that region is letterbox, not game.
*/
private reportPointer(e: FederatedPointerEvent): void {
const world = this.playWorld;
const view = this.currentView();
if (!world || !view) return;
const local = this.toLocal(e.global);
const point = this.screenToWorld(local.x, local.y, view);
const inside = local.x >= view.rect.x && local.x <= view.rect.x + view.rect.w && local.y >= view.rect.y && local.y <= view.rect.y + view.rect.h;
world.movePointer(point.x, point.y, inside);
}
/** Game view: the world to draw (null = not playing). Ignored by the scene view. */
setWorld(world: World | null): void {
this.playWorld = world;
@@ -261,9 +293,10 @@ export class Viewport extends Widget {
/** Global screen position of an object's world position (test hook), null when not drawn. */
objectScreen(objectId: string): Vec2 | null {
const view = this.currentView();
const item = this.lastItems.find((i) => i.id === objectId);
if (!item) return null;
const local = this.worldToScreen(item.x, item.y);
if (!item || !view) return null;
const local = this.worldToScreen(item.x, item.y, view);
const origin = this.getGlobalPosition();
return { x: origin.x + local.x, y: origin.y + local.y };
}
@@ -316,7 +349,10 @@ export class Viewport extends Widget {
if (!world) return { items: [], background: LETTERBOX_COLOR };
return {
background: world.backgroundColor,
items: world.entities.filter((e) => !isSymbolType(e.type)).map((e) => ({ id: e.objectId, type: e.type, name: e.name, ...world.worldPosition(e), props: e.props, parentId: e.parent?.objectId ?? '' })),
// Symbols (camera, behaviors) are editor-only, switched-off objects do not exist for the game.
items: world.entities
.filter((e) => !isSymbolType(e.type) && isHandleEnabled(e))
.map((e) => ({ id: e.objectId, type: e.type, name: e.name, ...world.worldPosition(e), props: e.props, parentId: e.parent?.objectId ?? '', enabled: true })),
};
}
const scene = this.state.activeScene;
@@ -326,18 +362,17 @@ export class Viewport extends Widget {
for (const { object } of treeOrder(scene)) {
const asset = findAsset(this.state.project, object.assetId);
if (!asset) continue;
items.push({ id: object.id, type: asset.type, name: object.name || asset.name, ...worldPosition(scene, object), props: resolveSceneObjectProps(asset, object), parentId: object.parentId });
items.push({ id: object.id, type: asset.type, name: object.name || asset.name, ...worldPosition(scene, object), props: resolveSceneObjectProps(asset, object), parentId: object.parentId, enabled: true });
}
// Effective switch of every item: off when it or any parent is off (it is then not drawn).
const byId = new Map(items.map((i) => [i.id, i]));
for (const item of items) item.enabled = enabledAlongChain(item, (i) => i.props, (i) => byId.get(i.parentId) ?? null);
return { items, background: Number(scene.props.backgroundColor ?? Theme.colors.viewport) };
}
/** Size of an item's footprint in world units (sprite box or camera frame). */
/** Size of an item's footprint in world units (sprite box, text box, camera frame …). */
private itemSize(item: DrawItem): { w: number; h: number } {
if (item.type === 'sprite') return { w: Number(item.props.width ?? 1), h: Number(item.props.height ?? 1) };
if (item.type === 'behavior') return { w: BEHAVIOR_SYMBOL, h: BEHAVIOR_SYMBOL };
if (item.type === 'container') return { w: CONTAINER_SYMBOL, h: CONTAINER_SYMBOL };
if (item.type === 'collider') return colliderSize(item.props);
return cameraFrame(item.props);
return footprintOf(item.type, item.props);
}
override redraw(): void {
@@ -370,7 +405,9 @@ export class Viewport extends Widget {
for (const item of items) {
seen.add(item.id);
const size = this.itemSize(item);
const onScreen = rectsIntersect(rectAround(item.x, item.y, size.w, size.h), bounds);
// A switched-off object is not drawn at all – in the scene view as in the game. It stays
// in the hierarchy panel, which is where it is selected and switched back on.
const onScreen = item.enabled && rectsIntersect(rectAround(item.x, item.y, size.w, size.h), bounds);
let node = this.nodes.get(item.id);
if (!node) {
node = new Graphics();
@@ -383,6 +420,7 @@ export class Viewport extends Widget {
node.visible = onScreen;
node.eventMode = editing && onScreen ? 'static' : 'none';
this.syncLabel(item, editing && onScreen, view.scale);
this.syncText(item, onScreen, view.scale);
if (!onScreen) continue;
this.visibleCount++;
node.position.set(item.x, item.y);
@@ -406,6 +444,8 @@ export class Viewport extends Widget {
const r = CONTAINER_SYMBOL / 2;
node.roundRect(-r, -r, CONTAINER_SYMBOL, CONTAINER_SYMBOL, 3).fill({ color: Theme.colors.container, alpha: 0.25 }).stroke({ width: 2, color: Theme.colors.container });
for (const [bx, by] of [[-7, -7], [1, -7], [-7, 1], [1, 1]] as const) node.rect(bx, by, 6, 6).fill(Theme.colors.container);
} else if (item.type === 'text') {
node.hitArea = new Rectangle(-size.w / 2 - (textAnchor(item.props) - 0.5) * size.w, -size.h / 2, size.w, size.h);
} else if (item.type === 'collider') {
// Outline plus a faint fill so the shape reads as "area". Colliders usually sit right on
// top of their body, so only the outline band is hit-testable: clicks inside still reach
@@ -421,6 +461,8 @@ export class Viewport extends Widget {
this.drawParentLinks(items, editing, view.scale);
for (const [id, node] of this.nodes) {
if (!seen.has(id)) {
this.texts.get(id)?.destroy();
this.texts.delete(id);
node.destroy();
this.nodes.delete(id);
this.labels.get(id)?.destroy();
@@ -443,6 +485,9 @@ export class Viewport extends Widget {
for (const item of ordered) {
const node = this.nodes.get(item.id);
if (node) this.objects.addChild(node);
// The rendered text is a sibling (Graphics may not have children), kept right above its box.
const text = this.texts.get(item.id);
if (text) this.objects.addChild(text);
}
}
@@ -461,9 +506,9 @@ export class Viewport extends Widget {
if (!editing) return;
const byId = new Map(items.map((i) => [i.id, i]));
for (const item of items) {
if (!this.isSymbol(item) || !item.parentId || item.type === 'collider') continue;
if (!this.isSymbol(item) || !item.parentId || item.type === 'collider' || !item.enabled) continue;
const parent = byId.get(item.parentId);
if (!parent) continue;
if (!parent || !parent.enabled) continue;
const color = item.type === 'behavior' ? Theme.colors.behavior : Theme.colors.textMuted;
dashedLine(this.links, item.x, item.y, parent.x, parent.y, 8 / scale, 6 / scale);
this.links.stroke({ width: 1.5 / scale, color, alpha: 0.8 });
@@ -471,6 +516,35 @@ export class Viewport extends Widget {
}
}
/**
* Content of a text object: a Pixi Text parented to the item's node, so it moves, scales and
* fades with it. Its resolution follows the zoom in coarse steps – re-rendering the glyph
* texture on every wheel notch would be wasteful, blurry text is worse.
*/
private syncText(item: DrawItem, onScreen: boolean, scale: number): void {
let text = this.texts.get(item.id);
if (item.type !== 'text' || !onScreen) {
if (text) text.visible = false;
return;
}
if (!text) {
text = new Text({ text: '', style: new TextStyle({ fontFamily: Theme.font, fontSize: 32, fill: 0xffffff }) });
text.eventMode = 'none';
this.texts.set(item.id, text);
this.objects.addChild(text);
}
text.visible = true;
text.position.set(item.x, item.y);
text.text = String(item.props.text ?? '');
const size = Math.max(1, Number(item.props.fontSize ?? 32));
if (text.style.fontSize !== size) text.style.fontSize = size;
const color = Number(item.props.color ?? 0xffffff);
if (text.style.fill !== color) text.style.fill = color;
text.anchor.set(textAnchor(item.props), 0.5);
const resolution = Math.min(4, Math.max(1, Math.round(scale * 2) / 2 * 2));
if (text.resolution !== resolution) text.resolution = resolution;
}
private syncLabel(item: DrawItem, show: boolean, scale: number): void {
let label = this.labels.get(item.id);
if (!show || !this.isSymbol(item)) {
@@ -511,8 +585,9 @@ export class Viewport extends Widget {
const s = this.state.selection;
if (s?.kind === 'object') {
for (const id of s.objectIds) {
// Nothing is outlined for a switched-off object – it is not drawn either.
const item = items.find((i) => i.id === id);
if (!item) continue;
if (!item || !item.enabled) continue;
const { w, h } = this.itemSize(item);
const tl = this.worldToScreen(item.x - w / 2, item.y - h / 2, view);
const sw = w * view.scale;
@@ -544,7 +619,7 @@ export class Viewport extends Widget {
const maxX = Math.max(a.x, b.x);
const minY = Math.min(a.y, b.y);
const maxY = Math.max(a.y, b.y);
const ids = this.lastItems.filter((i) => i.x >= minX && i.x <= maxX && i.y >= minY && i.y <= maxY).map((i) => i.id);
const ids = this.lastItems.filter((i) => i.enabled && i.x >= minX && i.x <= maxX && i.y >= minY && i.y <= maxY).map((i) => i.id);
this.state.select(objectSelection(scene.id, ids));
}