From 849eebc75537fcc4fcbc77e9ef872c195bc469d6 Mon Sep 17 00:00:00 2001 From: Brandon Corfman Date: Fri, 17 Jul 2026 23:34:11 -0400 Subject: [PATCH] Phase 6e implemented --- ...rs-demo-editor-gaps-implementation-plan.md | 16 +- src/editor/EventsPanel.tsx | 168 +++++++++++++----- src/editor/Inspector.tsx | 47 ++++- ...attachment-inspector-set-property.test.tsx | 60 +++++++ .../events-panel-bounds-metadata.test.tsx | 24 ++- 5 files changed, 252 insertions(+), 63 deletions(-) diff --git a/.plans/stars-demo-editor-gaps-implementation-plan.md b/.plans/stars-demo-editor-gaps-implementation-plan.md index 88f44f34..0f8d060d 100644 --- a/.plans/stars-demo-editor-gaps-implementation-plan.md +++ b/.plans/stars-demo-editor-gaps-implementation-plan.md @@ -63,19 +63,19 @@ Mockups: `.plans/mockups/stars-bounds-event-no-code-actions.svg` and `.plans/moc ### 6E — Extend the existing Event Block GUI -- [ ] Add `Bounds` to the existing Event Block Trigger selector. -- [ ] Show the complete Event selector—Contact Entered, Contact Exited, Wrapped, Bounced, Clamped, and Stopped—plus contextual Axis/Side filters using existing select and two-column patterns; for this demo choose Event `Wrapped`, Axis `Y`, Side `Any`. -- [ ] Show a short outcome description and compatible behavior badge beneath the selector, as specified in `.plans/mockups/stars-bounds-event-family.svg`. -- [ ] Add `Event source` to action target/application choices only when the selected trigger supplies an instigator/source. -- [ ] Add `Set Property` to the action library and its existing attachment inspector: +- [x] Add `Bounds` to the existing Event Block Trigger selector. +- [x] Show the complete Event selector—Contact Entered, Contact Exited, Wrapped, Bounced, Clamped, and Stopped—plus contextual Axis/Side filters using existing select and two-column patterns; for this demo choose Event `Wrapped`, Axis `Y`, Side `Any`. +- [x] Show a short outcome description and compatible behavior badge beneath the selector, as specified in `.plans/mockups/stars-bounds-event-family.svg`. +- [x] Add `Event source` to action target/application choices only when the selected trigger supplies an instigator/source. +- [x] Add `Set Property` to the action library and its existing attachment inspector: - Target: Event source; - Property: X; - Value: Random range; - paired Min/Max: 0 and 720; - Seed plus Reroll using the same pattern as Scatter. -- [ ] Keep Bounds configuration in Move Until responsible only for detection/behavior. Put consequences in Actions/Events so other outcomes can reuse the same actions without growing the Move Until inspector. -- [ ] Add wiring-map labels that read as a sentence, for example: `When Stars wrap on Y → Set event source X to random 0..720`. -- [ ] Confirm the new workflow does not change canvas bounds gestures, selection semantics, or the existing path for simple Move Until wrapping. +- [x] Keep Bounds configuration in Move Until responsible only for detection/behavior. Put consequences in Actions/Events so other outcomes can reuse the same actions without growing the Move Until inspector. +- [x] Add wiring-map labels that read as a sentence, for example: `When Stars wrap on Y → Set event source X to random 0..720`. +- [x] Confirm the new workflow does not change canvas bounds gestures, selection semantics, or the existing path for simple Move Until wrapping. ### 6F — Enforce the no-code boundary diff --git a/src/editor/EventsPanel.tsx b/src/editor/EventsPanel.tsx index f3968475..e990c7c3 100644 --- a/src/editor/EventsPanel.tsx +++ b/src/editor/EventsPanel.tsx @@ -62,6 +62,46 @@ function displayBoundsBehavior(behavior: BoundsBehavior): string { return 'Stop'; } +function formatBoundsEventVerb(outcome: BoundsEventOutcome): string { + if (outcome === 'contact-entered') return 'enter bounds'; + if (outcome === 'contact-exited') return 'exit bounds'; + if (outcome === 'wrapped') return 'wrap'; + if (outcome === 'bounced') return 'bounce'; + if (outcome === 'clamped') return 'clamp'; + return 'stop'; +} + +function formatBoundsTriggerSentence(scene: SceneSpec, block: EventBlockSpec): string { + const trigger = block.trigger; + if (trigger?.type !== 'bounds') return `When ${block.name ?? block.id}`; + const targetLabel = getTargetLabel(scene, block.target); + const eventText = formatBoundsEventVerb((trigger.boundsEvent ?? 'wrapped') as BoundsEventOutcome); + const axis = trigger.axis && trigger.axis !== 'any' ? ` on ${trigger.axis.toUpperCase()}` : ''; + const side = trigger.side && trigger.side !== 'any' ? ` at ${BOUNDS_SIDE_LABELS[trigger.side]}` : ''; + return `When ${targetLabel} ${eventText}${axis}${side}`; +} + +function formatSetPropertyActionSentence(attachment: AttachmentSpec): string | undefined { + if (attachment.presetId !== 'SetProperty') return undefined; + const propertyLabels: Record = { + x: 'X', + y: 'Y', + tint: 'Tint', + alpha: 'Alpha', + visible: 'Visible', + vx: 'Velocity X', + vy: 'Velocity Y', + }; + const property = propertyLabels[String(attachment.params?.property ?? 'x')] ?? String(attachment.params?.property ?? 'property'); + const target = attachment.targetMode === 'event-source' ? 'event source' : 'owner'; + const valueSource = attachment.params?.valueSource as any; + let value = 'constant 0'; + if (valueSource?.kind === 'randomRange') value = `random ${Number(valueSource.min ?? 0)}..${Number(valueSource.max ?? 0)}`; + else if (valueSource?.kind === 'constant') value = String(valueSource.value ?? 0); + else if (valueSource?.kind === 'eventField') value = `event ${String(valueSource.field ?? '')}`; + return `Set ${target} ${property} to ${value}`; +} + function getKnownBoundsBehavior(scene: SceneSpec, target: TargetRef): BoundsBehavior | undefined { const behaviors = new Set(); for (const attachment of Object.values(scene.attachments ?? {})) { @@ -303,6 +343,24 @@ function EventWiringMap({ return { events, emittersByEvent, handlersByEvent }; }, [scene.attachments, scene.eventBlocks]); + const boundsWiringSentences = useMemo(() => { + const rows: Array<{ eventBlockId: Id; sentence: string }> = []; + for (const block of Object.values(scene.eventBlocks ?? {})) { + if (block.trigger?.type !== 'bounds') continue; + const actionSentences = Object.values(scene.attachments ?? {}) + .filter((attachment) => attachment.eventId === block.id) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + .map((attachment) => formatSetPropertyActionSentence(attachment)) + .filter((sentence): sentence is string => Boolean(sentence)); + if (actionSentences.length === 0) continue; + rows.push({ + eventBlockId: block.id, + sentence: `${formatBoundsTriggerSentence(scene, block)} → ${actionSentences.join(' then ')}`, + }); + } + return rows; + }, [scene]); + const selectedTargetLabel = getTargetLabel(scene, selectedTarget); const knownInputIds = useMemo(() => listInputActionIds(project), [project]); @@ -310,60 +368,74 @@ function EventWiringMap({
Scene-level map. Create handlers on selected target: {selectedTargetLabel}
{knownInputIds.length === 0 ? null : null} - {events.length === 0 ? ( + {events.length === 0 && boundsWiringSentences.length === 0 ? (
No emitted or handled events yet. Add an Emit Event action or an On Event trigger.
) : ( -
-
-
Emitters
- {events.map((eventName) => { - const emitters = emittersByEvent.get(eventName) ?? []; - return ( -
- {eventName} - {emitters.length === 0 ?
None
: null} - {emitters.map((e) => ( -
- +
+ ))} +
+ ); + })} +
+
+
Events
+ {events.map((eventName) => ( +
+ {eventName} +
+
- ))} -
- ); - })} -
-
-
Events
- {events.map((eventName) => ( -
- {eventName} -
- -
+
+ ))}
- ))} -
-
-
Handlers
- {events.map((eventName) => { - const handlers = handlersByEvent.get(eventName) ?? []; - return ( -
- {eventName} - {handlers.length === 0 ?
None
: null} - {handlers.map((h) => ( -
- {getTargetLabel(scene, h.target)} +
+
Handlers
+ {events.map((eventName) => { + const handlers = handlersByEvent.get(eventName) ?? []; + return ( +
+ {eventName} + {handlers.length === 0 ?
None
: null} + {handlers.map((h) => ( +
+ {getTargetLabel(scene, h.target)} +
+ ))}
- ))} -
- ); - })} -
-
+ ); + })} +
+
+ ) : null} + )} ); diff --git a/src/editor/Inspector.tsx b/src/editor/Inspector.tsx index 29bf0d8b..436fefc5 100644 --- a/src/editor/Inspector.tsx +++ b/src/editor/Inspector.tsx @@ -2110,6 +2110,8 @@ function AttachmentInspector({ attachment.target.type === 'entity' ? (scene.entities[attachment.target.entityId]?.name ?? attachment.target.entityId) : (scene.groups[attachment.target.groupId]?.name ?? attachment.target.groupId); + const eventBlockTrigger = attachment.eventId ? scene.eventBlocks?.[attachment.eventId]?.trigger : attachment.trigger; + const canTargetEventSource = eventBlockTrigger?.type === 'bounds'; const supportedPresetIds = new Set([ 'MoveUntil', 'MoveTo', @@ -2308,6 +2310,23 @@ function AttachmentInspector({ )} + {canTargetEventSource && ( + + )} ) : String(params.property ?? 'x') === 'visible' ? ( diff --git a/tests/editor/attachment-inspector-set-property.test.tsx b/tests/editor/attachment-inspector-set-property.test.tsx index 71d210ad..c2772b34 100644 --- a/tests/editor/attachment-inspector-set-property.test.tsx +++ b/tests/editor/attachment-inspector-set-property.test.tsx @@ -49,6 +49,66 @@ describe('Attachment inspector Set Property', () => { expect(markup).toContain('data-testid="attachment-setproperty-random-min"'); expect(markup).toContain('data-testid="attachment-setproperty-random-max"'); expect(markup).toContain('data-testid="attachment-setproperty-seed"'); + expect(markup).toContain('data-testid="attachment-setproperty-reroll"'); expect(markup).toMatch(/
[\s\S]*attachment-setproperty-random-min[\s\S]*attachment-setproperty-random-max[\s\S]*<\/div>/); }); + + it('shows Event source target choice only for source-providing triggers', () => { + const scene = baseScene(); + scene.eventBlocks = { + wrap: { + id: 'wrap', + target: { type: 'group', groupId: 'g1' }, + trigger: { type: 'bounds', boundsEvent: 'wrapped', axis: 'y', side: 'any' }, + } as any, + start: { + id: 'start', + target: { type: 'group', groupId: 'g1' }, + trigger: { type: 'start' }, + } as any, + }; + + const boundsMarkup = renderToStaticMarkup( + renderAttachmentInspector( + { + id: 'att-bounds', + target: { type: 'group', groupId: 'g1' }, + eventId: 'wrap', + targetMode: 'event-source', + presetId: 'SetProperty', + enabled: true, + order: 0, + params: { property: 'x', valueSource: { kind: 'constant', value: 0 } }, + } as any, + baseProject(), + scene, + { arrange: [], conditions: [], actions: [{ type: 'SetProperty', displayName: 'Set Property', category: 'state', implemented: true }] } as any, + () => {}, + () => {} + ) + ); + + const startMarkup = renderToStaticMarkup( + renderAttachmentInspector( + { + id: 'att-start', + target: { type: 'group', groupId: 'g1' }, + eventId: 'start', + presetId: 'SetProperty', + enabled: true, + order: 0, + params: { property: 'x', valueSource: { kind: 'constant', value: 0 } }, + } as any, + baseProject(), + scene, + { arrange: [], conditions: [], actions: [{ type: 'SetProperty', displayName: 'Set Property', category: 'state', implemented: true }] } as any, + () => {}, + () => {} + ) + ); + + expect(boundsMarkup).toContain('data-testid="attachment-target-mode-select"'); + expect(boundsMarkup).toContain('Event source'); + expect(startMarkup).not.toContain('data-testid="attachment-target-mode-select"'); + }); }); diff --git a/tests/editor/events-panel-bounds-metadata.test.tsx b/tests/editor/events-panel-bounds-metadata.test.tsx index e0537d27..1e41e1e6 100644 --- a/tests/editor/events-panel-bounds-metadata.test.tsx +++ b/tests/editor/events-panel-bounds-metadata.test.tsx @@ -37,7 +37,7 @@ async function renderBoundsPanel(behavior: 'wrap' | 'bounce' | 'limit' | 'stop') wrap: { id: 'wrap', target: { type: 'group', groupId: 'g1' }, - trigger: { type: 'bounds', boundsEvent: behavior === 'bounce' ? 'bounced' : 'wrapped', axis: 'y', side: 'bottom' }, + trigger: { type: 'bounds', boundsEvent: behavior === 'bounce' ? 'bounced' : 'wrapped', axis: 'y', side: 'any' }, } as any, }; scene.attachments = { @@ -53,6 +53,15 @@ async function renderBoundsPanel(behavior: 'wrap' | 'bounce' | 'limit' | 'stop') bounds: { minX: 0, maxX: 720, minY: 0, maxY: 1280 }, }, } as any, + rerollX: { + id: 'rerollX', + target: { type: 'group', groupId: 'g1' }, + eventId: 'wrap', + targetMode: 'event-source', + presetId: 'SetProperty', + order: 0, + params: { property: 'x', valueSource: { kind: 'randomRange', min: 0, max: 720, seed: 'wrap-x' } }, + } as any, }; const container = document.createElement('div'); @@ -106,4 +115,17 @@ describe('EventsPanel bounds event metadata', () => { expect(container.querySelector('[data-testid="event-bounds-compatibility-wrap"]')?.textContent).toContain('Compatible with Bounce'); await React.act(async () => root.unmount()); }); + + it('shows bounds wiring as a readable event-to-action sentence', async () => { + const { container, root } = await renderBoundsPanel('wrap'); + await React.act(async () => { + const wiringTab = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'Wiring'); + wiringTab?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(container.querySelector('[data-testid="event-wiring-bounds-wrap"]')?.textContent).toContain( + 'When g1 wrap on Y → Set event source X to random 0..720' + ); + await React.act(async () => root.unmount()); + }); });