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
+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