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;
|
||||
|
||||
Reference in new issue
Block a user