diff --git a/README.md b/README.md index 72c41795..594341e9 100644 --- a/README.md +++ b/README.md @@ -313,7 +313,14 @@ shown on the node and editable in the inspector. falls back to another one – the picker is how you switch on purpose. - **Live view**: Play always switches to the Game tab; switch to Graph while playing and the exec edges and nodes that just ran light up – the same canvas draws editor and game, so - this costs nothing extra. + this costs nothing extra. Each fire of an exec edge also sends a bead along it (0.3 s, at most + one per 0.15 s and edge): a key press is a single bead, `On update` an even chain. Beads run + on game time, so a paused game holds them where they are. Every data output shows, in the same colour, the value it last + carried (`0.017 dt`, `Pause box object`), refreshed ten times a second. With several objects + running the same behavior it is the one that produced last; in a loop, the last round. An + output nothing has asked for yet shows nothing. The graph stays editable meanwhile: the interpreter reads the runtime + copy's graph on every pulse, so an edit takes effect on the next frame and is discarded with + the run. A new `On start` node does not fire for a behavior that has already started. - **Runtime**: `BehaviorSystem` creates one `GraphInterpreter` per behavior entity; graphs only touch world entities. A runaway pulse throws (see *Loops* for the two limits) and stops play with a notice. @@ -670,7 +677,7 @@ npm run check # typecheck + lint + format:check + tests, what CI would ru # code is lost (tail's is reported), so a red run reads as green npm run build # writes dist/ and regenerates index.html export GM_REMOTE=user@host # optional: run the browser elsewhere; see below -node tools/seed-e2e.mjs && node tools/e2e.mjs # 578 editor checks, screenshots in /tmp/gm-e2e-*.png +node tools/seed-e2e.mjs && node tools/e2e.mjs # 588 editor checks, screenshots in /tmp/gm-e2e-*.png GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 – a subset while working on one step ./e2e.sh start [GM_STEPS] # the same run detached, see below ./e2e.sh status # live progress, or the last run's result diff --git a/src/core/graph/GraphInterpreter.ts b/src/core/graph/GraphInterpreter.ts index e64dd4c8..ca00c130 100644 --- a/src/core/graph/GraphInterpreter.ts +++ b/src/core/graph/GraphInterpreter.ts @@ -7,6 +7,7 @@ import { hasExecInput, inlineValue, isEventNode, + type NodeDef, type PortDef, type RepeatResult, } from './NodeRegistry'; @@ -25,6 +26,8 @@ const MAX_DEPTH = 1000; export interface TraceListener { onEdge(edgeId: string): void; onNode(nodeId: string): void; + /** What a node's outputs held when it last produced them, in the graph it belongs to. */ + onValues?(graph: Graph, nodeId: string, values: Record): void; } /** One graph's pulse state. A nested behavior runs in its own – node ids are unique per graph only. */ @@ -78,6 +81,7 @@ export class GraphInterpreter { this.steps = 0; this.frame.outputCache.set(node.id, { ...outputs }); this.trace?.onNode(node.id); + this.record(node.id, outputs); this.follow(node, 'exec', ctx, 0); fired++; } @@ -103,8 +107,10 @@ export class GraphInterpreter { const inputs = this.collectInputs(node, ctx); const result = def.run(ctx, inputs, node.params, def); const returned = result.call ? this.call(result.call, ctx, depth) : {}; - if (!def.read) this.frame.outputCache.set(node.id, { ...result.outputs, ...returned }); + const produced = { ...result.outputs, ...returned }; + if (!def.read) this.frame.outputCache.set(node.id, produced); this.trace?.onNode(node.id); + if (this.trace?.onValues) this.record(node.id, def.read ? this.readAll(node, def, ctx) : produced); if (result.repeat) this.loop(node, result.repeat, ctx, depth); const next = result.next === undefined ? [] : Array.isArray(result.next) ? result.next : [result.next]; for (const port of next) this.follow(node, port, ctx, depth); @@ -124,6 +130,7 @@ export class GraphInterpreter { const outer = this.frame; this.frame = emptyFrame(program.graph); this.frame.outputCache.set(start.id, { ...call.inputs }); + this.record(start.id, call.inputs); this.callStack.push(call.behaviorId); this.calledBehaviors.add(call.behaviorId); try { @@ -148,7 +155,9 @@ export class GraphInterpreter { if (++this.steps > MAX_STEPS_PER_FIRE) throw new Error('Behavior graph exceeded the step limit (endless loop?)'); this.frame.dataCache.clear(); - this.frame.outputCache.set(node.id, { ...outputs, [repeat.index]: round }); + const roundOutputs = { ...outputs, [repeat.index]: round }; + this.frame.outputCache.set(node.id, roundOutputs); + this.record(node.id, roundOutputs); this.follow(node, repeat.port, ctx, depth); } this.frame.dataCache.clear(); @@ -177,11 +186,9 @@ export class GraphInterpreter { if (!def) return null; if (def.read) { this.trace?.onNode(nodeId); - const input = (key: string): Value => { - const found = def.inputs.find((p) => p.key === key); - return found ? this.inputValue(source, found, ctx) : null; - }; - return def.read(ctx, source.params, input)[port] ?? null; + const values = this.readAll(source, def, ctx); + this.record(nodeId, values); + return values[port] ?? null; } // Exec nodes that have not run in this pulse have no value yet. if (isEventNode(def) || hasExecInput(def) || !def.run) return null; @@ -192,9 +199,26 @@ export class GraphInterpreter { const outputs = def.run(ctx, this.collectInputs(source, ctx), source.params, def).outputs ?? {}; this.frame.dataCache.set(nodeId, outputs); this.trace?.onNode(nodeId); + this.record(nodeId, outputs); return outputs[port] ?? null; } + private readAll(node: GraphNode, def: NodeDef, ctx: NodeContext): Record { + const input = (key: string): Value => { + const found = def.inputs.find((p) => p.key === key); + return found ? this.inputValue(node, found, ctx) : null; + }; + // `read` may hand back a whole bag (the Variables node returns the object's variables, live). + const all = def.read?.(ctx, node.params, input) ?? {}; + const own: Record = {}; + for (const port of def.outputs) if (port.key in all) own[port.key] = all[port.key] ?? null; + return own; + } + + private record(nodeId: string, values: Record): void { + this.trace?.onValues?.(this.frame.graph, nodeId, values); + } + private def(node: GraphNode) { return resolveDef(node, this.frame.graph, this.behaviors); } diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index dfd09965..67bdc067 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -198,6 +198,10 @@ export function installDebugHook(app: Application, state: EditorState, editor: E nodeSettings: (nodeId: string) => graphEditor.canvas.nodeSettings(nodeId), /** Each input row as drawn: its name, and the inline value beside it while unconnected. */ nodeInputs: (nodeId: string) => graphEditor.canvas.nodeInputs(nodeId), + /** Each output row as drawn: while playing, the value it last carried in front of its name. */ + nodeOutputs: (nodeId: string) => graphEditor.canvas.nodeOutputs(nodeId), + /** Beads in flight on exec edges as drawn, in graph coordinates; `progress` 0 = source, 1 = target. */ + beads: () => graphEditor.canvas.beadsDrawn(), /** Drawn rectangles on screen: what "fit" has to keep inside `canvasRect`. */ nodeRect: (nodeId: string) => graphEditor.canvas.nodeRect(nodeId), canvasRect: () => graphEditor.canvas.canvasRect(), diff --git a/src/editor/graph/EdgeLayer.ts b/src/editor/graph/EdgeLayer.ts index 7cc0e308..7c9e0c65 100644 --- a/src/editor/graph/EdgeLayer.ts +++ b/src/editor/graph/EdgeLayer.ts @@ -10,12 +10,14 @@ import { foldMarkLabel, foldMarkRadius, } from './folding'; +import { curvePoint, curveReach } from './beads'; import { type NodeView, portColor } from './NodeView'; /** How far a folded edge's stub reaches out of its port. */ const FOLD_STUB = 16; const FOLD_MARK_RADIUS = 5; const FOLD_LABEL_WIDTH = 96; +const BEAD_RADIUS = 4.5; /** One port's hidden links as drawn: what they stand in for, and where they can be clicked. */ export interface FoldMark { @@ -31,6 +33,7 @@ export interface FoldMark { /** The curves between nodes and the marks of the hidden ones, in graph coordinates. */ export class EdgeLayer extends Container { private readonly curves = new Graphics(); + private readonly beads = new Graphics(); private readonly chips = new Graphics(); /** Texts naming the far node of each folded edge; pooled, since a redraw rebuilds them all. */ private readonly labelLayer = new Container(); @@ -40,10 +43,11 @@ export class EdgeLayer extends Container { private marks: FoldMark[] = []; /** Edges the last draw really stroked as a curve – a hidden one is missing from it. */ private readonly stroked = new Set(); + private beadsDrawn: { edgeId: string; progress: number; x: number; y: number }[] = []; constructor() { super(); - this.addChild(this.curves, this.chips, this.labelLayer); + this.addChild(this.curves, this.beads, this.chips, this.labelLayer); } draw( @@ -51,14 +55,17 @@ export class EdgeLayer extends Container { views: ReadonlyMap, isHot: (edgeId: string) => boolean, selected: Set, + beads: (edgeId: string) => readonly number[] = () => [], ): void { this.curves.clear(); + this.beads.clear(); + this.beadsDrawn = []; this.chips.clear(); this.marks = []; this.ends = []; this.labelCount = 0; this.stroked.clear(); - for (const edge of edges) this.drawEdge(edge, views, isHot(edge.id), selected); + for (const edge of edges) this.drawEdge(edge, views, isHot(edge.id), selected, beads(edge.id)); // After the curves, so no line drawn later crosses a mark that stands for a hidden one. this.drawFoldMarks(); for (let i = this.labelCount; i < this.labels.length; i++) this.labels[i]!.visible = false; @@ -76,11 +83,16 @@ export class EdgeLayer extends Container { return this.stroked.has(edgeId); } + drawnBeads(): readonly { edgeId: string; progress: number; x: number; y: number }[] { + return this.beadsDrawn; + } + private drawEdge( edge: GraphEdge, views: ReadonlyMap, hot: boolean, selected: Set, + beads: readonly number[], ): void { const from = views.get(edge.from.node); const to = views.get(edge.to.node); @@ -106,6 +118,15 @@ export class EdgeLayer extends Container { alpha: (type === 'exec' || hot ? 1 : 0.85) * (edge.folded ? 0.6 : 1), }); this.stroked.add(edge.id); + for (const progress of beads) { + const { x, y } = curvePoint(start.x, start.y, end.x, end.y, progress); + // Light with a dark rim: a bead in the highlight colour would vanish on the edge it lights up. + this.beads + .circle(x, y, BEAD_RADIUS) + .fill(Theme.colors.text) + .stroke({ width: 1.5, color: Theme.colors.graphBackground }); + this.beadsDrawn.push({ edgeId: edge.id, progress, x, y }); + } } private addFoldEnd( @@ -167,6 +188,6 @@ export class EdgeLayer extends Container { /** Horizontal S-curve between two points. */ export function bezier(g: Graphics, x1: number, y1: number, x2: number, y2: number): void { - const dx = Math.max(40, Math.abs(x2 - x1) * 0.5); + const dx = curveReach(x1, x2); g.moveTo(x1, y1).bezierCurveTo(x1 + dx, y1, x2 - dx, y2, x2, y2); } diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index 1fe95f13..12525aa7 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -29,6 +29,7 @@ import type { BehaviorAsset } from '../../core/model/Asset'; import { behaviorLookup } from '../../core/model/behaviorPrograms'; import { findAsset } from '../../core/model/Project'; import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem'; +import { beadsInFlight, launchBead } from './beads'; import { activeDrag, clamp, @@ -64,6 +65,8 @@ const NODE_HEIGHT_ESTIMATE = 80; const FIT_MARGIN = 24; /** Pointer travel (screen pixels) before a press turns into a drag rather than a click. */ const DRAG_THRESHOLD = 3; +/** Live values change every frame; ten updates a second are still readable and cost far less text layout. */ +const LIVE_VALUE_INTERVAL_MS = 100; /** * The node graph of one behavior asset. Which behavior that is comes from the outside (`show`), @@ -109,6 +112,11 @@ export class GraphCanvas extends Widget { private tempEdgeShown = false; /** Fit the view once the editor has a real size (it may be hidden behind the Scene tab). */ private needsFit = true; + private valuesShownAt = 0; + /** In game time, so a paused game holds its beads. */ + private readonly beadTimes = new Map(); + private readonly beadFired = new Map(); + private beadAsset = ''; /** Supplies live execution data while playing (null otherwise). */ traceProvider: (assetId: string) => Trace | null = () => null; timeProvider: () => number = () => 0; @@ -291,10 +299,10 @@ export class GraphCanvas extends Widget { } const local = this.toLocal(e.global); const g = this.screenToGraph(local.x, local.y); - const mark = this.state.mode === 'edit' ? this.edgeLayer.markAt(g) : null; + const mark = this.edgeLayer.markAt(g); if (e.button === 2) { if (mark) this.openLinks((g) => g.edges.filter((it) => mark.edgeIds.includes(it.id)), e.global); - else if (this.assetId && this.state.mode === 'edit') this.openPalette(e.global.x, e.global.y, g); + else if (this.assetId) this.openPalette(e.global.x, e.global.y, g); return; } if (e.button === 0) { @@ -311,7 +319,7 @@ export class GraphCanvas extends Widget { private onNodePointerDown(view: NodeView, e: FederatedPointerEvent): void { // Panning takes precedence over the node: let the middle button bubble up to the canvas. if (e.button === 1) return; - if (!this.assetId || this.state.mode !== 'edit') return; + if (!this.assetId) return; e.stopPropagation(); const local = view.toLocal(e.global); const hit = view.portAt(local.x, local.y); @@ -427,7 +435,7 @@ export class GraphCanvas extends Widget { const local = this.toLocal(e.global); const inside = local.x >= 0 && local.x <= this.w && local.y >= 0 && local.y <= this.h; const asset = dragged.kind === 'asset' ? findAsset(this.state.project, dragged.id) : undefined; - if (!inside || !this.assetId || this.state.mode !== 'edit' || asset?.type !== 'behavior') { + if (!inside || !this.assetId || asset?.type !== 'behavior') { this.setIncoming(null); return; } @@ -465,7 +473,7 @@ export class GraphCanvas extends Widget { this.panning = null; const marquee = this.marquee; this.marquee = null; - if (marquee && this.assetId && this.state.mode === 'edit') { + if (marquee && this.assetId) { if (marquee.active) this.selectInMarquee(marquee); else this.state.select({ kind: 'asset', assetId: this.assetId }); } @@ -652,21 +660,32 @@ export class GraphCanvas extends Widget { const trace = playing ? this.traceProvider(asset.id) : null; const now = this.timeProvider(); + this.launchBeads(asset.id, trace); const isHot = (map: Map | undefined, id: string) => { const t = map?.get(id); return t !== undefined && now - t <= TRACE_FADE; }; const selectedNodes = new Set(this.state.selectedNodeIds); const target = this.dropTarget; + const wall = performance.now(); + const values = trace && wall - this.valuesShownAt >= LIVE_VALUE_INTERVAL_MS ? trace.values : null; + if (values) this.valuesShownAt = wall; for (const view of this.views.values()) { view.selected = selectedNodes.has(view.node.id); view.hot = isHot(trace?.nodes, view.node.id); + if (values) view.setLiveValues(values.get(view.node.id) ?? null, this.state.project); view.targetPort = target && target.nodeId === view.node.id ? { port: target.port, isOutput: target.isOutput } : null; view.redraw(); } - this.edgeLayer.draw(asset.graph.edges, this.views, (id) => isHot(trace?.edges, id), selectedNodes); + this.edgeLayer.draw( + asset.graph.edges, + this.views, + (id) => isHot(trace?.edges, id), + selectedNodes, + (id) => beadsInFlight(this.beadTimes.get(id) ?? [], now), + ); this.tempEdge.clear(); this.tempEdgeShown = false; @@ -794,6 +813,29 @@ export class GraphCanvas extends Widget { return this.views.get(nodeId)?.inputLines ?? null; } + private launchBeads(assetId: string, trace: Trace | null): void { + if (!trace || assetId !== this.beadAsset) { + this.beadTimes.clear(); + this.beadFired.clear(); + this.beadAsset = assetId; + } + for (const [id, time] of trace?.edges ?? []) { + if (this.beadFired.get(id) === time) continue; + this.beadFired.set(id, time); + const times = this.beadTimes.get(id) ?? []; + this.beadTimes.set(id, times); + launchBead(times, time); + } + } + + beadsDrawn(): readonly { edgeId: string; progress: number; x: number; y: number }[] { + return this.edgeLayer.drawnBeads(); + } + + nodeOutputs(nodeId: string): string[] | null { + return this.views.get(nodeId)?.outputLines ?? null; + } + /** Global screen position of a node's title bar centre. */ nodeGlobal(nodeId: string): { x: number; y: number } | null { const view = this.views.get(nodeId); diff --git a/src/editor/graph/GraphEditor.ts b/src/editor/graph/GraphEditor.ts index 80c913b2..9ef06deb 100644 --- a/src/editor/graph/GraphEditor.ts +++ b/src/editor/graph/GraphEditor.ts @@ -25,7 +25,7 @@ export class GraphEditor extends AssetTabEditor { this.breadcrumb, ); this.canvas.onOpenBehavior = (id) => this.open(id, descend(this.trail, this.currentAssetId, id)); - // Edits made while playing would be thrown away with the run. + // Play swaps in the runtime copy of the project, and back. state.on('mode', () => this.refresh()); } @@ -69,7 +69,6 @@ export class GraphEditor extends AssetTabEditor { } protected override syncExtras(asset: BehaviorAsset | null): void { - const editable = Boolean(asset) && this.state.mode === 'edit'; - this.snapBox.setEnabled(editable); + this.snapBox.setEnabled(Boolean(asset)); } } diff --git a/src/editor/graph/NodeView.ts b/src/editor/graph/NodeView.ts index c2177400..964e22a2 100644 --- a/src/editor/graph/NodeView.ts +++ b/src/editor/graph/NodeView.ts @@ -98,6 +98,10 @@ export class NodeView extends Widget { private inlineTexts = new Map(); /** Which sides carry a link – a collapsed node marks those with one dot each. */ private linked = { inputs: false, outputs: false }; + private liveTexts = new Map(); + private readonly valueTexts = new Map(); + /** Where an output's value may be drawn: right-aligned before its label, clear of the input text. */ + private valueSlots = new Map(); /** * Not fixed at construction: a nested-behavior node's ports come from another graph, so the @@ -202,6 +206,41 @@ export class NodeView extends Widget { return this.def.inputs.map((input) => this.inputText(input)); } + get outputLines(): string[] { + return this.def.outputs.map((output) => { + const value = this.valueTexts.get(output.key)?.text; + return value === undefined ? output.label : `${value} ${output.label}`; + }); + } + + /** Nothing clears them when play ends: the views are rebuilt then. */ + setLiveValues(values: Record | null, project: Project | null): void { + const next = new Map(); + for (const output of this.def.outputs) { + const value = values?.[output.key]; + if (output.type === 'exec' || value === undefined) continue; + next.set(output.key, formatLive(output, value, project)); + } + if (next.size === this.liveTexts.size && [...next].every(([k, v]) => this.liveTexts.get(k) === v)) return; + this.liveTexts = next; + this.placeValues(); + } + + private placeValues(): void { + for (const text of this.valueTexts.values()) text.destroy(); + this.valueTexts.clear(); + if (this.collapsed) return; + const style = textStyle('live'); + for (const [key, value] of this.liveTexts) { + const slot = this.valueSlots.get(key); + if (!slot || slot.right - slot.left < 12) continue; + const text = new Text({ text: truncate(value, style, slot.right - slot.left), style }); + text.position.set(slot.right - text.width, slot.top); + this.addChild(text); + this.valueTexts.set(key, text); + } + } + private inputText(input: PortDef): string { const inline = this.inlineTexts.get(input.key); return inline === undefined ? input.label : `${input.label} ${inline}`; @@ -247,6 +286,7 @@ export class NodeView extends Widget { private rebuildTexts(): void { for (const t of this.texts) t.destroy(); this.texts.length = 0; + this.valueSlots = new Map(); const add = ( text: string, style: TextStyleToken, @@ -259,6 +299,7 @@ export class NodeView extends Widget { node.position.set(align === 'left' ? x : x - node.width, y); this.addChild(node); this.texts.push(node); + return node; }; add( this.def.label, @@ -268,7 +309,10 @@ export class NodeView extends Widget { 'left', this.w - TITLE_X - (this.collapsed ? COLLAPSED_TITLE_END : PAD), ); - if (this.collapsed) return; + if (this.collapsed) { + this.placeValues(); + return; + } let y = TITLE_HEIGHT + PAD; for (const line of this.paramLines) { add(line, 'small', PAD + 2, y + 2); @@ -282,8 +326,9 @@ export class NodeView extends Widget { const input = this.def.inputs[i]; const output = this.def.outputs[i]; const rowTop = y + i * ROW_HEIGHT + 2; + let inputEnd = PAD; if (input) { - add( + const text = add( this.inputText(input), input.type === 'exec' ? 'body' : 'muted', PAD + PORT_RADIUS + 4, @@ -291,9 +336,10 @@ export class NodeView extends Widget { 'left', NODE_WIDTH / 2 + 20, ); + inputEnd = text.x + text.width; } - if (output) - add( + if (output) { + const text = add( output.label, output.type === 'exec' ? 'body' : 'muted', this.w - PAD - PORT_RADIUS - 4, @@ -301,7 +347,11 @@ export class NodeView extends Widget { 'right', NODE_WIDTH / 2 - 10, ); + if (output.type !== 'exec') + this.valueSlots.set(output.key, { left: inputEnd + 6, right: text.x - 6, top: rowTop + 1 }); + } } + this.placeValues(); } override redraw(): void { @@ -388,6 +438,12 @@ function formatField(def: PropertyDef, value: Value, project: Project | null): s return formatValue(value); } +function formatLive(output: PortDef, value: Value, project: Project | null): string { + if (value === null) return '—'; + if (value === '') return '""'; + return output.field ? formatField(output.field, value, project) : formatValue(value); +} + function formatValue(value: Value): string { // Whole numbers stay bare to keep nodes narrow; fractions match the field that edits them. if (typeof value === 'number') return Number.isInteger(value) ? String(value) : formatNumber(value); diff --git a/src/editor/graph/beads.ts b/src/editor/graph/beads.ts new file mode 100644 index 00000000..ee4145c7 --- /dev/null +++ b/src/editor/graph/beads.ts @@ -0,0 +1,40 @@ +/** Seconds a bead takes from the source port to the target port. */ +export const BEAD_TRAVEL = 0.3; +/** Shortest gap between two beads on one edge: an edge firing every frame shows a chain, not a solid line. */ +export const BEAD_SPACING = 0.15; + +/** Launch times of the beads on one edge, oldest first; drops those that have arrived. */ +export function launchBead(times: number[], now: number): void { + const last = times[times.length - 1]; + // The tolerance keeps a fire exactly one spacing later (nine frames at 60 fps) from rounding below it. + if (last !== undefined && now - last < BEAD_SPACING - 1e-9) return; + while (times.length > 0 && now - times[0]! > BEAD_TRAVEL) times.shift(); + times.push(now); +} + +/** How far each bead has come, 0 at the source and 1 at the target. */ +export function beadsInFlight(times: readonly number[], now: number): number[] { + return times.map((t) => (now - t) / BEAD_TRAVEL).filter((p) => p >= 0 && p <= 1); +} + +/** How far an edge's curve reaches out of its ports before it bends towards the other end. */ +export function curveReach(x1: number, x2: number): number { + return Math.max(40, Math.abs(x2 - x1) * 0.5); +} + +/** The point at `t` on the curve `bezier` draws from (x1, y1) to (x2, y2). */ +export function curvePoint( + x1: number, + y1: number, + x2: number, + y2: number, + t: number, +): { x: number; y: number } { + const dx = curveReach(x1, x2); + const u = 1 - t; + const [a, b, c, d] = [u * u * u, 3 * u * u * t, 3 * u * t * t, t * t * t]; + return { + x: a * x1 + b * (x1 + dx) + c * (x2 - dx) + d * x2, + y: a * y1 + b * y1 + c * y2 + d * y2, + }; +} diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index b179f8ea..29dcbf11 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -1,11 +1,13 @@ import { asNumber, type EntityHandle, + type Graph, type GraphNode, GraphInterpreter, isHandleEnabled, type NodeContext, type TraceListener, + type Value, } from '../../core/graph'; import { stepKeyRepeat } from '../../core/graph/keyRepeat'; import { behaviorLookup } from '../../core/model/behaviorPrograms'; @@ -24,12 +26,19 @@ export const TRACE_FADE = 0.35; export interface Trace { edges: Map; nodes: Map; + values: Map>; +} + +interface Produced { + time: number; + values: Record; } interface Instance { entity: Entity; interpreter: GraphInterpreter; - trace: Trace; + trace: Omit; + values: Map>; } /** @@ -100,13 +109,19 @@ export class BehaviorSystem implements System { continue; const asset = findAssetOf(this.project, entity.assetId, 'behavior'); if (!asset) continue; - const trace: Trace = { edges: new Map(), nodes: new Map() }; + const trace = { edges: new Map(), nodes: new Map() }; + const values = new Map>(); const interpreter = new GraphInterpreter(asset.graph, behaviorLookup(this.project)); interpreter.trace = { onEdge: (id) => trace.edges.set(id, world.time), onNode: (id) => trace.nodes.set(id, world.time), + onValues: (graph, id, produced) => { + let byNode = values.get(graph); + if (!byNode) values.set(graph, (byNode = new Map())); + byNode.set(id, { time: world.time, values: produced }); + }, } satisfies TraceListener; - const instance = { entity, interpreter, trace }; + const instance = { entity, interpreter, trace, values }; this.instances.push(instance); this.run(instance, (i, ctx) => i.fire('OnStart', ctx), 0); } @@ -203,15 +218,22 @@ export class BehaviorSystem implements System { * those that ran it as a node, which is the only way a nested behavior's graph ever lights up. */ traceFor(assetId: string): Trace { - const merged: Trace = { edges: new Map(), nodes: new Map() }; + const merged: Trace = { edges: new Map(), nodes: new Map(), values: new Map() }; const latest = (into: Map, from: Map) => { for (const [id, t] of from) into.set(id, Math.max(t, into.get(id) ?? -Infinity)); }; + const graph = findAssetOf(this.project, assetId, 'behavior')?.graph; + const newest = new Map(); for (const instance of this.instances) { if (instance.entity.assetId !== assetId && !instance.interpreter.calledBehaviors.has(assetId)) continue; latest(merged.edges, instance.trace.edges); latest(merged.nodes, instance.trace.nodes); + for (const [id, produced] of (graph && instance.values.get(graph)) ?? []) { + const known = newest.get(id); + if (!known || produced.time >= known.time) newest.set(id, produced); + } } + for (const [id, produced] of newest) merged.values.set(id, produced.values); return merged; } diff --git a/src/ui/Theme.ts b/src/ui/Theme.ts index 798f326c..2c4923fd 100644 --- a/src/ui/Theme.ts +++ b/src/ui/Theme.ts @@ -77,6 +77,7 @@ export type TextStyleToken = | 'override' | 'small' | 'warning' + | 'live' | 'title' | 'mono' | 'accent' @@ -107,6 +108,9 @@ export function textStyle(token: TextStyleToken = 'body'): TextStyle { case 'warning': style = new TextStyle({ ...base, fontSize: Theme.smallFontSize, fill: Theme.colors.danger }); break; + case 'live': + style = new TextStyle({ ...base, fontSize: Theme.smallFontSize, fill: Theme.colors.hot }); + break; case 'title': style = new TextStyle({ ...base, diff --git a/tests/core/live-values.test.ts b/tests/core/live-values.test.ts new file mode 100644 index 00000000..ce497c3c --- /dev/null +++ b/tests/core/live-values.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; +import { AddPortCommand } from '../../src/core/commands/portCommands'; +import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; +import { + BEHAVIOR_END, + BEHAVIOR_PARAM, + BEHAVIOR_START, + type Graph, + GraphInterpreter, + RUN_BEHAVIOR, + type TraceListener, + type Value, +} from '../../src/core/graph'; +import { behaviorLookup } from '../../src/core/model/behaviorPrograms'; +import { BehaviorSystem } from '../../src/runtime/systems/BehaviorSystem'; +import { Input } from '../../src/runtime/systems/Input'; +import { World } from '../../src/runtime/World'; +import { fakeContext, graphEditor, makeBehavior, makeProject, makeSpriteScene, place } from '../helpers'; + +function recorder() { + const seen = new Map>>(); + const listener: TraceListener = { + onEdge: () => {}, + onNode: () => {}, + onValues: (graph, id, values) => { + const byNode = seen.get(graph) ?? new Map>(); + seen.set(graph, byNode.set(id, values)); + }, + }; + return { seen, listener }; +} + +describe('live values', () => { + it('record what each node last produced, a read node after its own write', () => { + const fixture = makeProject(); + const behavior = makeBehavior(fixture, 'B', ['count']); + const edit = graphEditor(fixture, behavior); + const start = edit.addNode('OnStart'); + const count = edit.variables(['count'], { write: true }); + const add = edit.addNode('Add', { b: 1 }); + edit.wire([ + [start, 'exec', count, 'exec'], + [count, 'count', add, 'a'], + [add, 'result', count, 'count'], + ]); + const { seen, listener } = recorder(); + const interpreter = new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project)); + interpreter.trace = listener; + const ctx = fakeContext({ variables: { count: 5, other: 9 } }); + interpreter.fire('OnStart', ctx); + ctx.variables.count = 99; + + const values = seen.get(behavior.graph)!; + expect(values.get(add.id)).toEqual({ result: 6 }); + expect(values.get(count.id)).toEqual({ count: 6 }); + }); + + it('keep a nested behavior apart, under its own graph', () => { + const fixture = makeProject(); + const callee = makeBehavior(fixture, 'Double'); + const inner = graphEditor(fixture, callee); + const start = inner.addNode(BEHAVIOR_START); + const end = inner.addNode(BEHAVIOR_END); + fixture.stack.execute(new AddPortCommand(callee.id, start.id, { key: 'in1', kind: 'number' })); + fixture.stack.execute(new AddPortCommand(callee.id, end.id, { key: 'out1', kind: 'number' })); + const times = inner.addNode('Multiply', { b: 2 }); + inner.wire([ + [start, 'exec', end, 'exec'], + [start, 'in1', times, 'a'], + [times, 'result', end, 'out1'], + ]); + const caller = makeBehavior(fixture, 'Caller'); + const outer = graphEditor(fixture, caller); + const onStart = outer.addNode('OnStart'); + const call = outer.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id, in1: 21 }); + outer.connect(onStart, 'exec', call, 'exec'); + const { seen, listener } = recorder(); + const interpreter = new GraphInterpreter(caller.graph, behaviorLookup(fixture.project)); + interpreter.trace = listener; + interpreter.fire('OnStart', fakeContext()); + + expect(seen.get(callee.graph)?.get(start.id)).toEqual({ in1: 21 }); + expect(seen.get(callee.graph)?.get(times.id)).toEqual({ result: 42 }); + expect(seen.get(caller.graph)?.get(call.id)).toEqual({ out1: 42 }); + expect(seen.get(caller.graph)?.has(times.id)).toBe(false); + }); + + it('show the instance that produced last, not the last one in the list', () => { + const f = makeSpriteScene(); + const behavior = makeBehavior(f, 'Counter', ['count']); + const edit = graphEditor(f, behavior); + const update = edit.addNode('OnUpdate'); + const count = edit.variables(['count'], { write: true }); + const add = edit.addNode('Add', { b: 1 }); + edit.wire([ + [update, 'exec', count, 'exec'], + [count, 'count', add, 'a'], + [add, 'result', count, 'count'], + ]); + place(f, behavior.id, { x: 0, y: 0 }); + const second = place(f, behavior.id, { x: 0, y: 0 }); + f.stack.execute(new SetOverrideCommand(f.sceneId, second, 'count', 10)); + const world = World.instantiate(f.project, f.sceneId); + const system = new BehaviorSystem(f.project, new Input()); + world.addSystem(system); + + world.tick(1 / 60); + expect(system.traceFor(behavior.id).values.get(count.id)).toEqual({ count: 11 }); + + world.entities.find((e) => e.objectId === second)!.props.enabled = false; + world.tick(1 / 60); + expect(system.traceFor(behavior.id).values.get(count.id)).toEqual({ count: 2 }); + }); +}); diff --git a/tests/editor/beads.test.ts b/tests/editor/beads.test.ts new file mode 100644 index 00000000..85c09137 --- /dev/null +++ b/tests/editor/beads.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { + BEAD_SPACING, + BEAD_TRAVEL, + beadsInFlight, + curvePoint, + launchBead, +} from '../../src/editor/graph/beads'; + +describe('beads on a firing edge', () => { + it('a single fire sends one bead from the source to the target and then it is gone', () => { + const times: number[] = []; + launchBead(times, 2); + expect(beadsInFlight(times, 2)).toEqual([0]); + expect(beadsInFlight(times, 2 + BEAD_TRAVEL / 2)[0]).toBeCloseTo(0.5); + expect(beadsInFlight(times, 2 + BEAD_TRAVEL)).toHaveLength(1); + expect(beadsInFlight(times, 2 + BEAD_TRAVEL + 0.01)).toEqual([]); + }); + + it('a fire closer than the spacing to the last bead sends none', () => { + const times: number[] = []; + launchBead(times, 1); + launchBead(times, 1 + BEAD_SPACING / 2); + expect(times).toEqual([1]); + launchBead(times, 1 + BEAD_SPACING); + expect(times).toEqual([1, 1 + BEAD_SPACING]); + }); + + it('an edge firing every frame shows an evenly spaced chain that does not pile up', () => { + const times: number[] = []; + let now = 0; + for (let frame = 0; frame < 600; frame++) { + now = frame / 60; + launchBead(times, now); + } + const flying = beadsInFlight(times, now); + expect(flying.length).toBeGreaterThanOrEqual(2); + expect(times.length).toBeLessThanOrEqual(Math.ceil(BEAD_TRAVEL / BEAD_SPACING) + 1); + for (let i = 1; i < flying.length; i++) + expect(flying[i - 1]! - flying[i]!).toBeGreaterThanOrEqual(BEAD_SPACING / BEAD_TRAVEL - 1e-9); + }); +}); + +describe('the curve a bead follows', () => { + it('starts in the source port and ends in the target port', () => { + expect(curvePoint(10, 20, 300, 80, 0)).toEqual({ x: 10, y: 20 }); + expect(curvePoint(10, 20, 300, 80, 1)).toEqual({ x: 300, y: 80 }); + }); + + it('passes the middle of a curve half way, since the curve is symmetric', () => { + const mid = curvePoint(0, 0, 200, 100, 0.5); + expect(mid.x).toBeCloseTo(100); + expect(mid.y).toBeCloseTo(50); + }); + + it('bows out to the right between two ports stacked above each other, as the drawn curve does', () => { + expect(curvePoint(0, 0, 0, 100, 0.25).x).toBeCloseTo(11.25); + }); + + it('leaves its port horizontally, as the drawn curve does', () => { + const near = curvePoint(0, 0, 200, 100, 0.01); + expect(near.x).toBeGreaterThan(0); + expect(Math.abs(near.y / near.x)).toBeLessThan(0.05); + }); +}); diff --git a/todo-notes/done/graph-during-play-mode.md b/todo-notes/done/graph-during-play-mode.md new file mode 100644 index 00000000..cb691c59 --- /dev/null +++ b/todo-notes/done/graph-during-play-mode.md @@ -0,0 +1,8 @@ +graph während dem playmode lässt sich nicht editieren. + +- abklären was zu machen wäre um das zu ermöglichen +- abklären ob es möglich wäre aktuelle werte anzuzeigen (nicht nur den wie der flow läuft sondern auch was) + +--- +Umgesetzt (User-Wahl): Änderungen im Play wirken ab dem nächsten Frame und werden beim Stop mit verworfen, wie alles im Play. Ein neuer `On start` feuert für ein schon gestartetes Behavior nicht mehr. +Werte: vor jedem Daten-Ausgang der zuletzt gelieferte Wert, 10× pro Sekunde erneuert; bei mehreren Instanzen zählt die mit dem neuesten Wert. diff --git a/todo-notes/done/graph-flow-animation-during-play-mode.md b/todo-notes/done/graph-flow-animation-during-play-mode.md new file mode 100644 index 00000000..150c1798 --- /dev/null +++ b/todo-notes/done/graph-flow-animation-during-play-mode.md @@ -0,0 +1,5 @@ +eine simple animation die den flow symbolisiert. vielleicht edge circles die durch fließen, jedes mal wen ein event ausgelöst wird oder so? +mach vorschläge wie das dargestellt werden könnte. + +--- +Umgesetzt (User-Wahl): Perlen auf Exec-Kanten, 0,3 s pro Durchlauf, höchstens eine alle 0,15 s je Kante. Laufen auf Spielzeit, stehen also in der Pause. diff --git a/todo-notes/graph-during-play-mode.md b/todo-notes/graph-during-play-mode.md deleted file mode 100644 index 8b621971..00000000 --- a/todo-notes/graph-during-play-mode.md +++ /dev/null @@ -1,4 +0,0 @@ -graph während dem playmode lässt sich nicht editieren. - -- abklären was zu machen wäre um das zu ermöglichen -- abklären ob es möglich wäre aktuelle werte anzuzeigen (nicht nur den wie der flow läuft sondern auch was) diff --git a/tools/e2e/18-pause.mjs b/tools/e2e/18-pause.mjs index 319f7c1f..e6e9e4db 100644 --- a/tools/e2e/18-pause.mjs +++ b/tools/e2e/18-pause.mjs @@ -31,9 +31,24 @@ const pauseGraph = { }; export async function pause(t) { - const { check, sleep, json, has, shot, clickText, press, entity, world, stop } = t; + const { + check, + sleep, + json, + has, + shot, + click, + clickText, + press, + entity, + world, + stop, + select, + evaluate, + asset, + } = t; const playInfo = () => json('__gm.playInfo()'); - const boxX = async () => (await entity('p-box')).x; + const boxX = async () => (await entity('p-box'))?.x ?? NaN; const keyHits = async () => (await world()).log.filter((line) => line.includes(KEY_LOG)).length; // 19a. A box with a drifting behavior below it, written into the model directly: what is under @@ -143,10 +158,93 @@ export async function pause(t) { (await boxX()) > restartedX, `after stop ${JSON.stringify(afterStop)}, x ${restartedX} → ${await boxX()}`, ); + + // 19f. The graph stays editable while the game runs, with the mouse as in edit mode: a click + // selects the Move node, Delete removes it, and the running world stops drifting the box. + await select({ kind: 'asset', assetId: 'pauser' }); + await evaluate(`__gm.showTab('graph')`); + await sleep(150); + const outputs = (id) => json(`__gm.graph.nodeOutputs(${JSON.stringify(id)})`); + const live = { + owner: await outputs('p-owner'), + update: await outputs('p-update'), + move: await outputs('p-move'), + }; + check( + 'while playing, each output shows the value it last carried in front of its name', + live.owner[0] === 'Pause box object' && + /^0\.\d{3} {2}dt$/.test(live.update[1]) && + live.move[1] === 'false blocked x', + JSON.stringify(live), + ); + await shot('36b-graph-live-values'); + const beadsOn = async (edgeId) => + (await json('__gm.graph.beads()')).filter((b) => b.edgeId === edgeId).map((b) => b.progress); + const chain = await beadsOn('p-e1'); + const gaps = chain.slice(1).map((p, i) => chain[i] - p); + check( + 'an exec edge firing every frame carries an evenly spaced chain of beads', + chain.length >= 1 && chain.length <= 3 && gaps.every((g) => Math.abs(g - 0.5) < 0.1), + JSON.stringify(chain), + ); + check( + 'data edges carry no beads', + (await beadsOn('p-e2')).length === 0 && (await beadsOn('p-e3')).length === 0, + ); + const beforeKey = await beadsOn('p-e4'); + await press('ArrowRight'); + await sleep(80); + const onKey = await beadsOn('p-e4'); + await sleep(500); + const afterKey = await beadsOn('p-e4'); + check( + 'a single key press sends exactly one bead, and it arrives', + beforeKey.length === 0 && onKey.length === 1 && afterKey.length === 0, + JSON.stringify({ beforeKey, onKey, afterKey }), + ); + const movePos = await json(`__gm.graph.nodeAt('p-move')`); + await click(movePos.x, movePos.y); + await sleep(100); + check( + 'a click selects a node while playing', + JSON.stringify(await json('__gm.graph.selected()')) === '["p-move"]', + JSON.stringify(await json('__gm.graph.selected()')), + ); + const area = await json('__gm.graph.canvasRect()'); + await click(area.x + area.w - 15, area.y + area.h - 15, 'right'); + await sleep(100); + check('right-clicking the canvas opens the node palette while playing', await has('Search nodes…')); + await press('Escape'); + await sleep(100); + check( + 'Escape closes the palette and leaves the game running', + !(await has('Search nodes…')) && (await playInfo()).playing === true, + ); + await click(movePos.x, movePos.y); + await press('Delete'); + await sleep(150); + const stoppedAt = await boxX(); + await sleep(500); + check( + 'deleting the Move node while playing stops the drift on the next frames', + !(await asset('pauser')).graph.nodes.some((n) => n.id === 'p-move') && + Math.abs((await boxX()) - stoppedAt) < 0.001, + `x ${stoppedAt} → ${await boxX()}`, + ); await stop(); await sleep(200); + check( + 'stopping takes the values off the nodes', + JSON.stringify(await outputs('p-owner')) === '["object"]', + JSON.stringify(await outputs('p-owner')), + ); + check( + 'stopping discards the graph edit with the rest of the run', + (await asset('pauser')).graph.nodes.some((n) => n.id === 'p-move'), + JSON.stringify((await asset('pauser')).graph.nodes.map((n) => n.id)), + ); - // 19f. Play discarded its copy, so the box is back at the position the model holds. + // 19g. Play discarded its copy, so the box is back at the position the model holds. const left = await rig.remove(); check( 'the step leaves nothing of its own behind', diff --git a/tools/lib/harness.mjs b/tools/lib/harness.mjs index cf25b0f8..6af46c33 100644 --- a/tools/lib/harness.mjs +++ b/tools/lib/harness.mjs @@ -276,7 +276,7 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) { const storedObjects = () => json('__gm.state.activeScene.objects'); const asset = (id) => json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)})`); const world = () => json('__gm.world()'); - const entity = async (objectId) => (await world()).entities.find((e) => e.objectId === objectId); + const entity = async (objectId) => (await world())?.entities.find((e) => e.objectId === objectId); const select = (selection) => evaluate(`__gm.state.select(${JSON.stringify(selection)})`); /** * Runs `body` (JavaScript source) against the page's model and previews the result, the way a