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:
1 parent
4900d7bbf6
commit
76aa441dce
33 files changed
+1905
-301
No files matched your search
@@ -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':
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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 {}; } });
|
||||
@@ -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,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];
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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],
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user