Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions .plans/stars-demo-editor-gaps-implementation-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
168 changes: 120 additions & 48 deletions src/editor/EventsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
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<BoundsBehavior>();
for (const attachment of Object.values(scene.attachments ?? {})) {
Expand Down Expand Up @@ -303,67 +343,99 @@ 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]);

return (
<div>
<div className="muted">Scene-level map. Create handlers on selected target: {selectedTargetLabel}</div>
{knownInputIds.length === 0 ? null : null}
{events.length === 0 ? (
{events.length === 0 && boundsWiringSentences.length === 0 ? (
<div className="muted">No emitted or handled events yet. Add an Emit Event action or an On Event trigger.</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 10 }}>
<div>
<div className="panel-heading">Emitters</div>
{events.map((eventName) => {
const emitters = emittersByEvent.get(eventName) ?? [];
return (
<div key={`emitters-${eventName}`} className="inspector-block" style={{ marginTop: 10 }}>
<strong>{eventName}</strong>
{emitters.length === 0 ? <div className="muted">None</div> : null}
{emitters.map((e) => (
<div key={e.attachmentId} className="member-row">
<button className="tag-button" type="button" onClick={() => onSelectAttachment(e.attachmentId)}>
{getTargetLabel(scene, e.target)} · {e.eventId ? `Event ${e.eventId}` : 'OnSceneStart'} · EmitEvent
<>
{boundsWiringSentences.length > 0 ? (
<div className="inspector-block" style={{ marginTop: 10 }}>
<div className="panel-heading">Bounds</div>
{boundsWiringSentences.map((row) => (
<div key={row.eventBlockId} className="inspector-row" data-testid={`event-wiring-bounds-${row.eventBlockId}`}>
{row.sentence}
</div>
))}
</div>
) : null}
{events.length > 0 ? (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 10 }}>
<div>
<div className="panel-heading">Emitters</div>
{events.map((eventName) => {
const emitters = emittersByEvent.get(eventName) ?? [];
return (
<div key={`emitters-${eventName}`} className="inspector-block" style={{ marginTop: 10 }}>
<strong>{eventName}</strong>
{emitters.length === 0 ? <div className="muted">None</div> : null}
{emitters.map((e) => (
<div key={e.attachmentId} className="member-row">
<button className="tag-button" type="button" onClick={() => onSelectAttachment(e.attachmentId)}>
{getTargetLabel(scene, e.target)} · {e.eventId ? `Event ${e.eventId}` : 'OnSceneStart'} · EmitEvent
</button>
</div>
))}
</div>
);
})}
</div>
<div>
<div className="panel-heading">Events</div>
{events.map((eventName) => (
<div key={`events-${eventName}`} className="inspector-block" style={{ marginTop: 10 }}>
<strong>{eventName}</strong>
<div className="inspector-row">
<button className="tag-button" type="button" onClick={() => onCreateHandlerEventBlock(eventName)}>
Create handler on {selectedTargetLabel}
</button>
</div>
))}
</div>
);
})}
</div>
<div>
<div className="panel-heading">Events</div>
{events.map((eventName) => (
<div key={`events-${eventName}`} className="inspector-block" style={{ marginTop: 10 }}>
<strong>{eventName}</strong>
<div className="inspector-row">
<button className="tag-button" type="button" onClick={() => onCreateHandlerEventBlock(eventName)}>
Create handler on {selectedTargetLabel}
</button>
</div>
</div>
))}
</div>
))}
</div>
<div>
<div className="panel-heading">Handlers</div>
{events.map((eventName) => {
const handlers = handlersByEvent.get(eventName) ?? [];
return (
<div key={`handlers-${eventName}`} className="inspector-block" style={{ marginTop: 10 }}>
<strong>{eventName}</strong>
{handlers.length === 0 ? <div className="muted">None</div> : null}
{handlers.map((h) => (
<div key={h.eventBlockId} className="member-row">
<span className="muted">{getTargetLabel(scene, h.target)}</span>
<div>
<div className="panel-heading">Handlers</div>
{events.map((eventName) => {
const handlers = handlersByEvent.get(eventName) ?? [];
return (
<div key={`handlers-${eventName}`} className="inspector-block" style={{ marginTop: 10 }}>
<strong>{eventName}</strong>
{handlers.length === 0 ? <div className="muted">None</div> : null}
{handlers.map((h) => (
<div key={h.eventBlockId} className="member-row">
<span className="muted">{getTargetLabel(scene, h.target)}</span>
</div>
))}
</div>
))}
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
) : null}
</>
)}
</div>
);
Expand Down
47 changes: 41 additions & 6 deletions src/editor/Inspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -2308,6 +2310,23 @@ function AttachmentInspector({
</select>
</label>
)}
{canTargetEventSource && (
<label className="field">
<span>Target</span>
<select
aria-label="Action Target"
data-testid="attachment-target-mode-select"
value={attachment.targetMode === 'event-source' ? 'event-source' : 'owner'}
onChange={(e) => {
const targetMode = e.target.value === 'event-source' ? 'event-source' : undefined;
onUpdate({ ...attachment, targetMode });
}}
>
<option value="owner">Owner</option>
<option value="event-source">Event source</option>
</select>
</label>
)}
<label className="field">
<span>Type</span>
<select
Expand Down Expand Up @@ -2986,12 +3005,28 @@ function AttachmentInspector({
</div>
<label className="field">
<span>Seed</span>
<input
aria-label="Set Property Seed"
data-testid="attachment-setproperty-seed"
value={String((params.valueSource as any)?.seed ?? '')}
onChange={(e) => onUpdate({ ...attachment, params: { ...params, valueSource: { ...(params.valueSource as any), kind: 'randomRange', seed: e.target.value } } })}
/>
<div className="inspector-row inspector-inline-buttons">
<input
aria-label="Set Property Seed"
data-testid="attachment-setproperty-seed"
value={String((params.valueSource as any)?.seed ?? '')}
onChange={(e) => onUpdate({ ...attachment, params: { ...params, valueSource: { ...(params.valueSource as any), kind: 'randomRange', seed: e.target.value } } })}
/>
<button
className="button button-compact"
data-testid="attachment-setproperty-reroll"
type="button"
onClick={() => onUpdate({
...attachment,
params: {
...params,
valueSource: { ...(params.valueSource as any), kind: 'randomRange', seed: makeSeed('set-property') },
},
})}
>
Reroll
</button>
</div>
</label>
</>
) : String(params.property ?? 'x') === 'visible' ? (
Expand Down
60 changes: 60 additions & 0 deletions tests/editor/attachment-inspector-set-property.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(/<div class="inspector-grid-2">[\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"');
});
});
Loading
Loading