exodeui-react-native 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/engine.ts CHANGED
@@ -1,14 +1,13 @@
1
1
  import { SkCanvas, SkImage, SkPaint, PaintStyle, Skia, SkPath, SkColor, BlurStyle, SkImageFilter, SkMaskFilter } from '@shopify/react-native-skia';
2
- import { Artboard, Animation, ShapeObject, StateMachine, State, Fit, Alignment, Layout } from './types';
3
- import Matter from 'matter-js';
2
+ import { Artboard, Animation as SDKAnimation, ShapeObject, StateMachine, State, Fit, Alignment, Layout, LogicNode, LogicOp, Constraint, ComponentEvent } from './types';
3
+ import { PhysicsEngine, MatterPhysics } from './physics';
4
4
 
5
5
  export class ExodeUIEngine {
6
6
  private artboard: Artboard | null = null;
7
7
  private objectStates: Map<string, any> = new Map();
8
8
 
9
9
  // Physics State
10
- private physicsEngine: Matter.Engine | null = null;
11
- private physicsBodies: Map<string, Matter.Body> = new Map();
10
+ private physicsEngine: PhysicsEngine | null = null;
12
11
 
13
12
  // State Machine State
14
13
  private activeStateMachine: StateMachine | null = null;
@@ -16,9 +15,9 @@ export class ExodeUIEngine {
16
15
  private inputNameMap: Map<string, string[]> = new Map(); // name -> id[] mapping
17
16
  private layerStates: Map<string, {
18
17
  currentStateIds: string[];
19
- animation: Animation | null;
18
+ animations: { animation: SDKAnimation; state: State | null }[];
20
19
  time: number;
21
- currentState: State | null;
20
+ duration: number;
22
21
  }> = new Map();
23
22
 
24
23
  private imageCache = new Map<string, SkImage>();
@@ -27,6 +26,22 @@ export class ExodeUIEngine {
27
26
 
28
27
  private onTrigger?: (triggerName: string, animationName: string) => void;
29
28
  private onInputUpdate?: (nameOrId: string, value: any) => void;
29
+ private onComponentChange?: (event: ComponentEvent) => void;
30
+
31
+ // Specific Component Listeners
32
+ private onToggle?: (name: string, checked: boolean) => void;
33
+ private onInputChange?: (name: string, text: string) => void;
34
+ private onInputFocus?: (name: string) => void;
35
+ private onInputBlur?: (name: string) => void;
36
+
37
+ // Track triggers that were just fired in the current frame
38
+ private justFiredTriggers: Set<string> = new Set();
39
+
40
+ // Interaction State
41
+ private focusedId: string | null = null;
42
+
43
+ // Physics/Animation State
44
+ private objectVelocities: Map<string, { vx: number; vy: number }> = new Map();
30
45
 
31
46
  setTriggerCallback(cb: (triggerName: string, animationName: string) => void) {
32
47
  this.onTrigger = cb;
@@ -36,6 +51,26 @@ export class ExodeUIEngine {
36
51
  this.onInputUpdate = cb;
37
52
  }
38
53
 
54
+ setComponentChangeCallback(cb: (event: ComponentEvent) => void) {
55
+ this.onComponentChange = cb;
56
+ }
57
+
58
+ setToggleCallback(cb: (name: string, checked: boolean) => void) {
59
+ this.onToggle = cb;
60
+ }
61
+
62
+ setInputChangeCallback(cb: (name: string, text: string) => void) {
63
+ this.onInputChange = cb;
64
+ }
65
+
66
+ setInputFocusCallback(cb: (name: string) => void) {
67
+ this.onInputFocus = cb;
68
+ }
69
+
70
+ setInputBlurCallback(cb: (name: string) => void) {
71
+ this.onInputBlur = cb;
72
+ }
73
+
39
74
  constructor() {}
40
75
 
41
76
  // Helper to update Map AND notify listener
@@ -55,81 +90,155 @@ export class ExodeUIEngine {
55
90
  return state ? state.currentStateIds : [];
56
91
  }
57
92
 
93
+ getObjectState(id: string): any {
94
+ return this.objectStates.get(id);
95
+ }
96
+
97
+
98
+ updateConstraint(objectId: string, index: number, properties: Partial<Constraint>) {
99
+ const obj = this.artboard?.objects.find(o => o.id === objectId);
100
+ if (obj && obj.constraints && obj.constraints[index]) {
101
+ obj.constraints[index] = { ...obj.constraints[index], ...properties } as any;
102
+
103
+ // Clear velocity if spring is disabled or reset
104
+ if (properties.useSpring === false) {
105
+ this.objectVelocities.delete(objectId);
106
+ }
107
+ }
108
+ }
109
+
110
+ updateObjectOptions(id: string, newOptions: any) {
111
+ const state = this.objectStates.get(id);
112
+ if (state) {
113
+ state.options = { ...state.options, ...newOptions };
114
+ }
115
+ }
116
+
117
+ public updateGraphData(nameOrId: string, data: number[]) {
118
+ if (!this.artboard) return;
119
+
120
+ // Find the graph object
121
+ const obj = this.artboard.objects.find(o => o.id === nameOrId || o.name === nameOrId);
122
+ if (obj && obj.geometry.type === 'LineGraph') {
123
+ const geo = obj.geometry as any;
124
+
125
+ if (!geo.datasets || geo.datasets.length === 0) {
126
+ geo.datasets = [{
127
+ id: 'default',
128
+ label: 'Runtime Data',
129
+ data: data,
130
+ lineColor: '#3b82f6',
131
+ lineWidth: 2,
132
+ showArea: false
133
+ }];
134
+ } else {
135
+ geo.datasets[0].data = data;
136
+ }
137
+
138
+ const state = this.objectStates.get(obj.id);
139
+ if (state && state.geometry && state.geometry.type === 'LineGraph') {
140
+ if (!state.geometry.datasets || state.geometry.datasets.length === 0) {
141
+ state.geometry.datasets = JSON.parse(JSON.stringify(geo.datasets));
142
+ } else {
143
+ state.geometry.datasets[0].data = [...data];
144
+ }
145
+ }
146
+
147
+ if (geo.datasets[0].inputId) {
148
+ this.updateInput(geo.datasets[0].inputId, data);
149
+ }
150
+ } else {
151
+ this.updateInput(nameOrId, data);
152
+ }
153
+ }
154
+
58
155
  load(data: Artboard) {
156
+ if (!data) {
157
+ console.warn('[ExodeUIEngine] Attempted to load null artboard');
158
+ return;
159
+ }
160
+ console.log(`[ExodeUIEngine] Loading artboard: ${data.name} (${data.width}x${data.height})`);
59
161
  this.artboard = data;
60
162
  this.reset();
163
+ this.advance(0);
61
164
  }
62
165
 
63
166
  reset() {
64
167
  if (!this.artboard) return;
65
168
  this.objectStates.clear();
169
+ this.objectVelocities.clear();
66
170
  this.inputs.clear();
67
171
  this.inputNameMap.clear();
68
172
  this.layerStates.clear();
69
- this.physicsBodies.clear();
70
- this.physicsEngine = null;
173
+
174
+ if (this.physicsEngine) {
175
+ this.physicsEngine.destroy();
176
+ this.physicsEngine = null;
177
+ }
71
178
 
72
179
  // Initialize object states
73
- this.artboard.objects.forEach((obj: ShapeObject) => {
74
- // Deep copy initial state
75
- this.objectStates.set(obj.id, {
76
- ...obj.transform,
77
- style: JSON.parse(JSON.stringify(obj.style || {})),
78
- geometry: JSON.parse(JSON.stringify(obj.geometry || {}))
79
- });
80
- });
81
-
82
- // Initialize Physics Engine if any object has physics enabled
83
- const hasPhysics = this.artboard.objects.some(obj => obj.physics?.enabled);
84
- if (hasPhysics) {
85
- this.physicsEngine = Matter.Engine.create();
86
-
87
- // Configure Global Physics (Gravity)
88
- if (this.artboard.physics) {
89
- this.physicsEngine.gravity.x = this.artboard.physics.gravity.x;
90
- this.physicsEngine.gravity.y = this.artboard.physics.gravity.y;
91
- } else {
92
- this.physicsEngine.gravity.y = 1; // Default
93
- }
94
-
95
- this.artboard.objects.forEach(obj => {
96
- if (obj.physics?.enabled && obj.type === 'Shape') {
97
- const state = this.objectStates.get(obj.id);
98
- const w = (obj.geometry as any).width || 100;
99
- const h = (obj.geometry as any).height || 100;
100
-
101
- const options: Matter.IChamferableBodyDefinition = {
102
- isStatic: obj.physics.bodyType === 'Static',
103
- label: obj.id,
104
- friction: obj.physics.friction,
105
- restitution: obj.physics.restitution,
106
- mass: obj.physics.mass,
107
- frictionAir: obj.physics.frictionAir,
108
- isSensor: obj.physics.isSensor,
109
- angle: state.rotation * (Math.PI / 180) // Convert deg to rad
110
- };
111
-
112
- if (obj.physics.density) {
113
- options.density = obj.physics.density;
114
- }
115
-
116
- let body: Matter.Body | null = null;
117
-
118
- if (obj.geometry.type === 'Rectangle' || obj.geometry.type === 'Image') {
119
- body = Matter.Bodies.rectangle(state.x, state.y, w, h, options);
120
- } else if (obj.geometry.type === 'Ellipse') {
121
- const r = (w + h) / 4;
122
- body = Matter.Bodies.circle(state.x, state.y, r, options);
123
- } else {
124
- body = Matter.Bodies.rectangle(state.x, state.y, w, h, options);
125
- }
180
+ if (this.artboard.objects) {
181
+ this.artboard.objects?.forEach((obj: ShapeObject) => {
182
+ // Deep copy initial state
183
+ const transform = obj.transform || { x: 0, y: 0, rotation: 0, scale_x: 1, scale_y: 1 };
184
+ this.objectStates.set(obj.id, {
185
+ x: transform.x ?? 0,
186
+ y: transform.y ?? 0,
187
+ rotation: transform.rotation ?? 0,
188
+ scale_x: transform.scale_x ?? 1,
189
+ scale_y: transform.scale_y ?? 1,
190
+ width: (obj as any).width || (obj.geometry as any).width || 100,
191
+ height: (obj as any).height || (obj.geometry as any).height || 100,
192
+ cornerRadius: (obj as any).cornerRadius ?? (obj as any).corner_radius ?? 0,
193
+ opacity: obj.opacity !== undefined ? obj.opacity : 1,
194
+ visible: obj.visible !== undefined ? obj.visible : (obj.isVisible !== undefined ? obj.isVisible : true),
195
+ blendMode: obj.blendMode || 'Normal',
196
+ style: JSON.parse(JSON.stringify(obj.style || {})),
197
+ geometry: JSON.parse(JSON.stringify(obj.geometry || {})),
198
+ options: (obj as any).options ? JSON.parse(JSON.stringify((obj as any).options)) : {}
199
+ });
200
+ });
126
201
 
127
- if (body) {
128
- Matter.World.add(this.physicsEngine!.world, body);
129
- this.physicsBodies.set(obj.id, body);
202
+ // Initialize Physics Engine if any object has physics enabled
203
+ const hasPhysics = this.artboard.objects.some(obj => obj.physics?.enabled);
204
+ if (hasPhysics) {
205
+ this.physicsEngine = new MatterPhysics();
206
+
207
+ const gravity = this.artboard.physics
208
+ ? { x: this.artboard.physics.gravity.x, y: this.artboard.physics.gravity.y }
209
+ : { x: 0, y: 1 }; // Default
210
+
211
+ this.physicsEngine.init(gravity).then(() => {
212
+ if (!this.physicsEngine) return;
213
+
214
+ this.artboard?.objects?.forEach(obj => {
215
+ if (obj.physics?.enabled && obj.type === 'Shape') {
216
+ const state = this.objectStates.get(obj.id);
217
+ if (!state) return;
218
+
219
+ const w = (obj.geometry as any).width || 100;
220
+ const h = (obj.geometry as any).height || 100;
221
+
222
+ this.physicsEngine!.createBody({
223
+ id: obj.id,
224
+ type: obj.geometry.type as any,
225
+ x: state.x,
226
+ y: state.y,
227
+ width: w,
228
+ height: h,
229
+ rotation: state.rotation * (Math.PI / 180),
230
+ isStatic: obj.physics.bodyType === 'Static',
231
+ mass: obj.physics.mass,
232
+ friction: obj.physics.friction,
233
+ restitution: obj.physics.restitution,
234
+ frictionAir: obj.physics.frictionAir,
235
+ density: obj.physics.density,
236
+ isSensor: obj.physics.isSensor
237
+ });
130
238
  }
131
- }
132
- });
239
+ });
240
+ });
241
+ }
133
242
  }
134
243
 
135
244
  // Initialize State Machine
@@ -137,7 +246,7 @@ export class ExodeUIEngine {
137
246
  this.activeStateMachine = this.artboard.stateMachine;
138
247
 
139
248
  // Init Inputs
140
- this.activeStateMachine.inputs.forEach((input: any) => {
249
+ this.activeStateMachine.inputs?.forEach((input: any) => {
141
250
  this.inputs.set(input.id, input.value.value);
142
251
 
143
252
  // Map Name -> IDs
@@ -147,7 +256,7 @@ export class ExodeUIEngine {
147
256
  });
148
257
 
149
258
  // Init Layers
150
- this.activeStateMachine.layers.forEach((layer: any) => {
259
+ this.activeStateMachine.layers?.forEach((layer: any) => {
151
260
  // Initial Entry
152
261
  const entryState = layer.states.find((s: State) => s.id === layer.entryStateId);
153
262
 
@@ -155,17 +264,18 @@ export class ExodeUIEngine {
155
264
  this.enterStates(layer.name, [entryState.id]);
156
265
  }
157
266
  });
267
+
268
+ this.evaluateTransitions();
158
269
  }
159
270
 
160
271
  if (!this.artboard.stateMachine && this.artboard.animations.length > 0) {
161
- // Check for 'onLoad' animation to play by default
162
- const onLoadAnim = this.artboard.animations.find((a: Animation) => a.name === 'onLoad');
272
+ const onLoadAnim = this.artboard.animations.find((a: SDKAnimation) => a.name === 'onLoad');
163
273
  if (onLoadAnim) {
164
274
  this.layerStates.set('intro', {
165
275
  currentStateIds: ['onLoad'],
166
- animation: onLoadAnim,
276
+ animations: [{ animation: onLoadAnim, state: { loop: true } as any }],
167
277
  time: 0,
168
- currentState: null
278
+ duration: onLoadAnim.duration
169
279
  });
170
280
  }
171
281
  }
@@ -174,30 +284,26 @@ export class ExodeUIEngine {
174
284
  private enterStates(layerName: string, stateIds: string[]) {
175
285
  if (stateIds.length === 0) return;
176
286
 
177
- let anim: Animation | null = null;
178
- let selectedState: State | null = null;
287
+ const activeAnims: { animation: SDKAnimation; state: State | null }[] = [];
288
+ let maxDuration = 0;
179
289
 
180
290
  if (this.artboard && this.activeStateMachine) {
181
291
  const layer = this.activeStateMachine.layers.find(l => l.name === layerName);
182
292
  if (layer) {
183
- // Iterate backwards to find the last state that actually HAS an animation
184
- for (let i = stateIds.length - 1; i >= 0; i--) {
185
- const sId = stateIds[i];
293
+ for (const sId of stateIds) {
186
294
  const state = layer.states.find(s => s.id === sId);
187
-
188
295
  if (state) {
189
- // Priority 1: Direct ID Match
296
+ let anim: SDKAnimation | null = null;
190
297
  if (state.animationId) {
191
298
  anim = this.artboard.animations.find(a => a.id === state.animationId) || null;
192
299
  }
193
- // Priority 2: Name Match (Legacy)
194
300
  if (!anim) {
195
- anim = this.artboard.animations.find((a: Animation) => a.name === state.name || a.id === state.name || (state.name === "Entry" && a.name === "Rotate")) || null;
301
+ anim = this.artboard.animations.find((a: SDKAnimation) => a.name === state.name || a.id === state.name) || null;
196
302
  }
197
303
 
198
304
  if (anim) {
199
- selectedState = state; // Store the state
200
- break; // Found one!
305
+ activeAnims.push({ animation: anim, state });
306
+ maxDuration = Math.max(maxDuration, anim.duration);
201
307
  }
202
308
  }
203
309
  }
@@ -206,9 +312,9 @@ export class ExodeUIEngine {
206
312
 
207
313
  this.layerStates.set(layerName, {
208
314
  currentStateIds: stateIds,
209
- animation: anim,
315
+ animations: activeAnims,
210
316
  time: 0,
211
- currentState: selectedState
317
+ duration: maxDuration
212
318
  });
213
319
  }
214
320
 
@@ -229,11 +335,17 @@ export class ExodeUIEngine {
229
335
  this.updateInput(nameOrId, value);
230
336
  }
231
337
 
338
+ setInputNumberArray(nameOrId: string, value: number[]) {
339
+ this.updateInput(nameOrId, value);
340
+ }
341
+
342
+ setInputStringArray(nameOrId: string, value: string[]) {
343
+ this.updateInput(nameOrId, value);
344
+ }
345
+
232
346
  private updateInput(nameOrId: string, value: any) {
233
- console.log(`[Engine] updateInput: ${nameOrId} -> ${value}`);
234
347
 
235
348
  let inputType: string | undefined;
236
- // Resolve Type
237
349
  if (this.inputs.has(nameOrId)) {
238
350
  const input = this.activeStateMachine?.inputs.find(i => i.id === nameOrId);
239
351
  if (input && typeof input.value === 'object') {
@@ -255,42 +367,36 @@ export class ExodeUIEngine {
255
367
  else if (value === 0) finalValue = false;
256
368
  }
257
369
 
258
- // 1. Try treating as ID
259
370
  if (this.inputs.has(nameOrId)) {
260
371
  this.setInternalInput(nameOrId, finalValue);
261
372
  }
262
373
 
263
- // 2. Try treating as Name (Broadcast)
264
374
  const ids = this.inputNameMap.get(nameOrId);
265
375
  if (ids) {
266
- ids.forEach(id => this.setInternalInput(id, finalValue));
376
+ ids?.forEach(id => this.setInternalInput(id, finalValue));
267
377
  }
268
378
 
269
- // TRIGGER HARD RESET logic
270
379
  if (inputType === 'Trigger' && finalValue === true) {
271
- // Find transition usage to jump immediately
272
- this.activeStateMachine?.layers.forEach(layer => {
273
- layer.states.forEach(state => {
274
- state.transitions.forEach(trans => {
275
- if (trans.conditions) {
276
- const usesTrigger = trans.conditions.some((cond: any) => {
277
- const condInputId = cond.inputId;
278
- return condInputId === nameOrId || (ids && ids.includes(condInputId));
279
- });
280
-
281
- if (usesTrigger && this.checkConditions(trans.conditions)) {
282
- this.enterStates(layer.name, [trans.targetStateId]);
283
- this.setInternalInput(nameOrId, false);
284
- if (ids) {
285
- ids.forEach(id => this.setInternalInput(id, false));
286
- }
287
- return;
288
- }
380
+ this.justFiredTriggers.add(nameOrId);
381
+ if (ids) {
382
+ ids?.forEach(id => this.justFiredTriggers.add(id));
383
+ }
384
+ }
385
+
386
+ // Sync with compatible components (ListView/Dropdown)
387
+ if (Array.isArray(finalValue)) {
388
+ this.artboard?.objects?.forEach(obj => {
389
+ if (obj.inputId === nameOrId || obj.name === nameOrId) {
390
+ const state = this.objectStates.get(obj.id);
391
+ if (state && state.options) {
392
+ if (obj.type === 'ListView' || (obj as any).variant === 'listview') {
393
+ state.options.items = finalValue;
394
+ } else if (obj.type === 'Dropdown' || (obj as any).variant === 'dropdown') {
395
+ state.options.optionsList = finalValue;
289
396
  }
290
- });
291
- });
397
+ }
398
+ }
292
399
  });
293
- return;
294
400
  }
295
401
 
296
402
  this.evaluateTransitions();
@@ -299,7 +405,7 @@ export class ExodeUIEngine {
299
405
  private evaluateTransitions() {
300
406
  if (!this.activeStateMachine) return;
301
407
 
302
- this.activeStateMachine.layers.forEach((layer) => {
408
+ this.activeStateMachine.layers?.forEach((layer) => {
303
409
  const layerState = this.layerStates.get(layer.name);
304
410
  if (!layerState || layerState.currentStateIds.length === 0) return;
305
411
 
@@ -318,19 +424,12 @@ export class ExodeUIEngine {
318
424
  transitioned = true;
319
425
  hasTransition = true;
320
426
 
321
- // Reset Trigger/Number inputs used in this transition
322
427
  if (trans.conditions) {
323
- trans.conditions.forEach((cond: any) => {
428
+ trans.conditions?.forEach((cond: any) => {
324
429
  const input = this.activeStateMachine?.inputs.find(i => i.id === cond.inputId);
325
430
  if (input && typeof input.value === 'object') {
326
- if (input.value.type === 'Trigger') {
327
- this.setInternalInput(cond.inputId, false);
328
- if (this.inputNameMap.has(input.name)) {
329
- const ids = this.inputNameMap.get(input.name);
330
- ids?.forEach(id => this.setInternalInput(id, false));
331
- }
332
- } else if (input.value.type === 'Number') {
333
- this.setInternalInput(cond.inputId, 0);
431
+ if (input.value.type === 'Number') {
432
+ this.setInternalInput(cond.inputId, 0);
334
433
  if (this.inputNameMap.has(input.name)) {
335
434
  const ids = this.inputNameMap.get(input.name);
336
435
  ids?.forEach(id => this.setInternalInput(id, 0));
@@ -347,11 +446,63 @@ export class ExodeUIEngine {
347
446
  }
348
447
  }
349
448
 
449
+ if (!hasTransition) {
450
+ const globalTransition = this.findGlobalTransition(layer, layerState.currentStateIds);
451
+ if (globalTransition) {
452
+ nextStateIds.length = 0;
453
+ nextStateIds.push(globalTransition.targetStateId);
454
+ hasTransition = true;
455
+
456
+ if (globalTransition.conditions) {
457
+ globalTransition.conditions?.forEach((cond: any) => {
458
+ const input = this.activeStateMachine?.inputs.find(i => i.id === cond.inputId);
459
+ if (input && typeof input.value === 'object') {
460
+ if (input.value.type === 'Number') {
461
+ this.setInternalInput(cond.inputId, 0);
462
+ if (this.inputNameMap.has(input.name)) {
463
+ const ids = this.inputNameMap.get(input.name);
464
+ ids?.forEach(id => this.setInternalInput(id, 0));
465
+ }
466
+ }
467
+ }
468
+ });
469
+ }
470
+ }
471
+ }
472
+
350
473
  if (hasTransition) {
351
474
  const uniqueIds = Array.from(new Set(nextStateIds));
352
475
  this.enterStates(layer.name, uniqueIds);
353
476
  }
354
477
  });
478
+
479
+ if (this.justFiredTriggers.size > 0) {
480
+ this.justFiredTriggers?.forEach(id => {
481
+ this.setInternalInput(id, false);
482
+ const input = this.activeStateMachine?.inputs.find(i => i.id === id);
483
+ if (input && this.inputNameMap.has(input.name)) {
484
+ this.inputNameMap.get(input.name)?.forEach(nid => this.setInternalInput(nid, false));
485
+ }
486
+ });
487
+ this.justFiredTriggers.clear();
488
+ }
489
+ }
490
+
491
+ private findGlobalTransition(layer: any, currentStateIds: string[]): any | null {
492
+ for (const state of layer.states) {
493
+ const isActive = currentStateIds.includes(state.id);
494
+ if (isActive && this.justFiredTriggers.size === 0) continue;
495
+
496
+ for (const trans of state.transitions) {
497
+ const targetsActive = currentStateIds.includes(trans.targetStateId);
498
+ if (targetsActive && this.justFiredTriggers.size === 0) continue;
499
+
500
+ if (this.checkConditions(trans.conditions)) {
501
+ return trans;
502
+ }
503
+ }
504
+ }
505
+ return null;
355
506
  }
356
507
 
357
508
  private checkConditions(conditions: any[]): boolean {
@@ -362,7 +513,7 @@ export class ExodeUIEngine {
362
513
  const op = cond.op;
363
514
  const targetValue = cond.value;
364
515
 
365
- const inputValue = this.inputs.get(inputId);
516
+ const inputValue = this.evaluateLogicTree(inputId);
366
517
 
367
518
  if (inputValue === undefined) return false;
368
519
 
@@ -382,10 +533,57 @@ export class ExodeUIEngine {
382
533
  });
383
534
  }
384
535
 
385
- // Trigger Management
536
+ private evaluateLogicTree(sourceId: string, sourceHandleId?: string, visited: Set<string> = new Set()): any {
537
+ if (visited.has(sourceId)) return false;
538
+ visited.add(sourceId);
539
+
540
+ const logicNode = this.activeStateMachine?.logicNodes?.find((n: LogicNode) => n.id === sourceId);
541
+ if (logicNode) {
542
+ const getInputValue = (portId: string, defaultValue: any) => {
543
+ const inputPort = logicNode.inputs.find((i: any) => i.id === portId);
544
+ if (!inputPort) return defaultValue;
545
+
546
+ if (inputPort.sourceId) {
547
+ return this.evaluateLogicTree(inputPort.sourceId, inputPort.sourceHandleId, visited);
548
+ }
549
+ return inputPort.value !== undefined ? inputPort.value : defaultValue;
550
+ };
551
+
552
+ switch (logicNode.op) {
553
+ case LogicOp.AND: return getInputValue('a', false) && getInputValue('b', false);
554
+ case LogicOp.OR: return getInputValue('a', false) || getInputValue('b', false);
555
+ case LogicOp.NOT: return !getInputValue('in', false);
556
+ case LogicOp.XOR: return !!getInputValue('a', false) !== !!getInputValue('b', false);
557
+ default: return 0;
558
+ }
559
+ }
560
+
561
+ let value = this.inputs.get(sourceId);
562
+ if (this.justFiredTriggers.has(sourceId)) {
563
+ value = true;
564
+ }
565
+
566
+ if (sourceHandleId && value !== undefined) {
567
+ const input = this.activeStateMachine?.inputs.find(i => i.id === sourceId);
568
+ if (input && input.value.type === 'Number' && typeof value === 'number') {
569
+ const threshold = (input.value as any).defaultValue ?.(input.value as any).value;
570
+ if (sourceHandleId === 'source-greater') return value > threshold;
571
+ if (sourceHandleId === 'source-less') return value < threshold;
572
+ if (sourceHandleId === 'source-equal') return value === threshold;
573
+ }
574
+ if (sourceHandleId === 'source-true') return value === true;
575
+ if (sourceHandleId === 'source-false') return value === false;
576
+ if (sourceHandleId === 'source-fire') {
577
+ return this.justFiredTriggers.has(sourceId);
578
+ }
579
+ }
580
+
581
+ return value !== undefined ? value : 0;
582
+ }
583
+
386
584
  private activeTriggers: Map<string, {
387
585
  triggerId: string;
388
- animation: Animation;
586
+ animation: SDKAnimation;
389
587
  time: number;
390
588
  phase: 'entry' | 'hold' | 'exit';
391
589
  elapsedHold: number;
@@ -396,15 +594,22 @@ export class ExodeUIEngine {
396
594
 
397
595
  // 1. Step Physics
398
596
  if (this.physicsEngine) {
399
- Matter.Engine.update(this.physicsEngine, dt * 1000);
400
-
401
- // Sync Physics -> State
402
- this.physicsBodies.forEach((body, id) => {
403
- const state = this.objectStates.get(id);
404
- if (state) {
405
- state.x = body.position.x;
406
- state.y = body.position.y;
407
- state.rotation = body.angle * (180 / Math.PI); // Rad -> Deg
597
+ this.physicsEngine.step(dt);
598
+
599
+ this.artboard.objects?.forEach(obj => {
600
+ if (obj.physics?.enabled) {
601
+ const pos = this.physicsEngine!.getPosition(obj.id);
602
+ const rot = this.physicsEngine!.getRotation(obj.id);
603
+ const state = this.objectStates.get(obj.id);
604
+
605
+ if (state && pos !== null) {
606
+ state.x = pos.x;
607
+ state.y = pos.y;
608
+ }
609
+
610
+ if (state && rot !== null) {
611
+ state.rotation = rot * (180 / Math.PI);
612
+ }
408
613
  }
409
614
  });
410
615
  }
@@ -413,31 +618,35 @@ export class ExodeUIEngine {
413
618
  if (this.activeStateMachine) {
414
619
  this.evaluateTransitions();
415
620
 
416
- this.layerStates.forEach(state => {
417
- if (state.animation) {
418
- state.time += dt;
419
-
420
- const duration = state.animation.duration;
421
- const shouldLoop = state.currentState?.loop !== false;
621
+ this.layerStates?.forEach(layerState => {
622
+ if (layerState.animations.length > 0) {
623
+ layerState.time += dt;
422
624
 
423
- if (state.time > duration) {
424
- if (shouldLoop) {
425
- state.time %= duration;
625
+ if (layerState.time > layerState.duration) {
626
+ const canLoop = layerState.animations.some(a => a.state?.loop === true);
627
+ if (canLoop) {
628
+ layerState.time %= layerState.duration;
426
629
  } else {
427
- state.time = duration;
630
+ layerState.time = layerState.duration;
428
631
  }
429
632
  }
430
633
 
431
- this.applyAnimation(state.animation, state.time);
634
+ layerState.animations?.forEach(animObj => {
635
+ this.applyAnimation(animObj.animation, layerState.time);
636
+ });
432
637
  }
433
638
  });
434
639
  }
435
640
 
641
+ // Solve Constraints
642
+ this.solveConstraints(dt);
643
+
436
644
  // Advance Active Triggers
437
- this.activeTriggers.forEach((state, objectId) => {
645
+ this.activeTriggers?.forEach((state, objectId) => {
438
646
  if (state.phase === 'entry') {
439
647
  state.time += dt;
440
648
  if (state.time >= state.animation.duration) {
649
+ const trigger = this.artboard?.objects.find(o => o.id === objectId)?.triggers?.find(t => t.id === state.triggerId);
441
650
  state.phase = 'hold';
442
651
  state.elapsedHold = 0;
443
652
  state.time = state.animation.duration;
@@ -471,6 +680,97 @@ export class ExodeUIEngine {
471
680
  }
472
681
  }
473
682
  });
683
+
684
+ // 4. Apply Bindings
685
+ this.applyBindings();
686
+ }
687
+
688
+ private applyBindings() {
689
+ if (!this.artboard) return;
690
+ this.artboard.objects?.forEach(obj => {
691
+ const bindings = (obj as any).bindings || [];
692
+ if (bindings.length > 0) {
693
+ bindings?.forEach((binding: any) => {
694
+ const val = this.evaluateLogicTree(binding.inputId);
695
+ const state = this.objectStates.get(obj.id);
696
+ if (state && val !== undefined) {
697
+ state[binding.property] = val;
698
+ }
699
+ });
700
+ }
701
+ });
702
+ }
703
+
704
+ private solveConstraints(dt: number) {
705
+ if (!this.artboard || !this.artboard.objects) return;
706
+
707
+ this.artboard.objects?.forEach(obj => {
708
+ if (obj.constraints && obj.constraints.length > 0) {
709
+ obj.constraints?.forEach(constraint => {
710
+ this.applyConstraint(obj.id, constraint, dt);
711
+ });
712
+ }
713
+ });
714
+ }
715
+
716
+ private applyConstraint(objectId: string, constraint: Constraint, dt: number) {
717
+ const state = this.objectStates.get(objectId);
718
+ const targetState = this.objectStates.get(constraint.targetId);
719
+
720
+ if (!state || !targetState) return;
721
+
722
+ const strength = constraint.strength ?? 1;
723
+
724
+ switch (constraint.type) {
725
+ case 'Translation':
726
+ if (constraint.useSpring) {
727
+ this.applySpring(objectId, 'x', targetState.x, constraint, dt);
728
+ this.applySpring(objectId, 'y', targetState.y, constraint, dt);
729
+ } else {
730
+ if (constraint.copyX !== false) state.x += (targetState.x - state.x) * strength;
731
+ if (constraint.copyY !== false) state.y += (targetState.y - state.y) * strength;
732
+ }
733
+ break;
734
+ case 'Rotation':
735
+ if (constraint.useSpring) {
736
+ this.applySpring(objectId, 'rotation', targetState.rotation, constraint, dt);
737
+ } else {
738
+ state.rotation += (targetState.rotation - state.rotation) * strength;
739
+ }
740
+ break;
741
+ case 'Scale':
742
+ if (constraint.useSpring) {
743
+ this.applySpring(objectId, 'scale_x', targetState.scale_x, constraint, dt);
744
+ this.applySpring(objectId, 'scale_y', targetState.scale_y, constraint, dt);
745
+ } else {
746
+ state.scale_x += (targetState.scale_x - state.scale_x) * strength;
747
+ state.scale_y += (targetState.scale_y - state.scale_y) * strength;
748
+ }
749
+ break;
750
+ }
751
+ }
752
+
753
+ private applySpring(objectId: string, property: string, targetValue: number, constraint: Constraint, dt: number) {
754
+ const state = this.objectStates.get(objectId);
755
+ if (!state) return;
756
+
757
+ const current = state[property];
758
+ const velocityKey = `${objectId}_${property}`;
759
+ let v = this.objectVelocities.get(velocityKey) || { vx: 0, vy: 0 };
760
+
761
+ const k = constraint.stiffness ?? 100;
762
+ const d = constraint.damping ?? 10;
763
+ const m = constraint.mass ?? 1;
764
+
765
+ // F = -k * (x - target) - d * v
766
+ const x = current - targetValue;
767
+ const f = -k * x - d * v.vx;
768
+ const a = f / m;
769
+
770
+ v.vx += a * dt;
771
+ state[property] += v.vx * dt;
772
+
773
+ this.objectVelocities.set(velocityKey, v);
474
774
  }
475
775
 
476
776
  handlePointerInput(type: string, canvasX: number, canvasY: number, canvasWidth: number, canvasHeight: number) {
@@ -492,12 +792,15 @@ export class ExodeUIEngine {
492
792
  private handlePointerEvent(type: string, x: number, y: number) {
493
793
  if (!this.artboard) return;
494
794
 
795
+ let hitId: string | null = null;
495
796
  for (let i = this.artboard.objects.length - 1; i >= 0; i--) {
496
797
  const obj = this.artboard.objects[i];
497
798
  const isHit = this.hitTest(obj, x, y);
498
799
 
499
800
  if (isHit) {
500
- // Check interactions
801
+ hitId = obj.id;
802
+
803
+ // Handle interactions
501
804
  const interactions = (obj as any).interactions || [];
502
805
  const matchingInteraction = interactions.find((int: any) => int.event === type || (int.event === 'onClick' && type === 'click'));
503
806
 
@@ -509,7 +812,23 @@ export class ExodeUIEngine {
509
812
  }
510
813
  }
511
814
 
512
- // Check triggers
815
+ // Handle Component Listeners
816
+ if (type === 'click' || type === 'PointerDown') {
817
+ const state = this.objectStates.get(obj.id);
818
+ const options = state?.options || {};
819
+
820
+ if (obj.type === 'Toggle' || (obj as any).variant === 'toggle') {
821
+ const newValue = !options.checked;
822
+ this.updateObjectOptions(obj.id, { checked: newValue });
823
+ if (this.onToggle) this.onToggle(obj.name, newValue);
824
+ if (obj.inputId) this.updateInput(obj.inputId, newValue);
825
+ } else if ((obj as any).type === 'TextInput' || (obj as any).variant === 'textinput') {
826
+ this.focusedId = obj.id;
827
+ if (this.onInputFocus) this.onInputFocus(obj.name);
828
+ }
829
+ }
830
+
831
+ // Handle triggers (Animations)
513
832
  const triggers = obj.triggers || [];
514
833
  const matchingTrigger = triggers.find(t => t.eventType === type || (t.eventType === 'onClick' && type === 'click'));
515
834
 
@@ -530,28 +849,39 @@ export class ExodeUIEngine {
530
849
  return;
531
850
  }
532
851
  }
852
+ break;
853
+ }
854
+ }
855
+
856
+ if (!hitId && (type === 'click' || type === 'PointerDown')) {
857
+ if (this.focusedId) {
858
+ const focusedObj = this.artboard.objects.find(o => o.id === this.focusedId);
859
+ if (focusedObj && this.onInputBlur) this.onInputBlur(focusedObj.name);
860
+ this.focusedId = null;
533
861
  }
534
862
  }
535
863
  }
536
864
 
537
865
  private hitTest(obj: ShapeObject, x: number, y: number): boolean {
538
866
  const state = this.objectStates.get(obj.id);
539
- if (!state) return false;
867
+ if (!state || state.visible === false) return false;
540
868
 
541
- const w = (obj.geometry as any).width || 100;
542
- const h = (obj.geometry as any).height || 100;
869
+ const w = state.width || (obj.geometry as any).width || 100;
870
+ const h = state.height || (obj.geometry as any).height || 100;
543
871
 
544
872
  const dx = x - state.x;
545
873
  const dy = y - state.y;
546
874
 
875
+ // Basic AABB check
547
876
  return Math.abs(dx) <= w / 2 && Math.abs(dy) <= h / 2;
548
877
  }
549
878
 
550
- private applyAnimation(anim: Animation, time: number) {
551
- anim.tracks.forEach((track: any) => {
552
- const isPhysicsControlled = this.physicsBodies.has(track.object_id) &&
553
- this.physicsBodies.get(track.object_id)?.isStatic === false;
554
-
879
+ private applyAnimation(anim: SDKAnimation, time: number) {
880
+ anim.tracks?.forEach((track: any) => {
881
+ // Skip physics controlled objects if they are dynamic
882
+ const isPhysicsControlled = this.physicsEngine &&
883
+ this.physicsEngine.isDynamic(track.object_id);
884
+
555
885
  if (isPhysicsControlled && (['x', 'y', 'rotation'].includes(track.property) || track.property.startsWith('transform.'))) {
556
886
  return;
557
887
  }
@@ -574,14 +904,13 @@ export class ExodeUIEngine {
574
904
  }
575
905
 
576
906
  // Sync State -> Physics Body (for Kinematic/Static animations)
577
- const body = this.physicsBodies.get(track.object_id);
578
- if (body && body.isStatic) {
907
+ if (this.physicsEngine && !this.physicsEngine.isDynamic(track.object_id)) {
579
908
  if (track.property === 'x' || track.property === 'transform.x') {
580
- Matter.Body.setPosition(body, { x: value, y: body.position.y });
909
+ this.physicsEngine.setPosition(track.object_id, value, objState.y);
581
910
  } else if (track.property === 'y' || track.property === 'transform.y') {
582
- Matter.Body.setPosition(body, { x: body.position.x, y: value });
911
+ this.physicsEngine.setPosition(track.object_id, objState.x, value);
583
912
  } else if (track.property === 'rotation' || track.property === 'transform.rotation') {
584
- Matter.Body.setAngle(body, value * (Math.PI / 180));
913
+ this.physicsEngine.setRotation(track.object_id, value * (Math.PI / 180));
585
914
  }
586
915
  }
587
916
  });
@@ -605,6 +934,7 @@ export class ExodeUIEngine {
605
934
  }
606
935
 
607
936
  const duration = end.time - bg.time;
937
+ if (duration <= 0) return end.value;
608
938
  const t = (time - bg.time) / duration;
609
939
 
610
940
  if (typeof bg.value === 'string' && bg.value.startsWith('#')) {
@@ -620,6 +950,13 @@ export class ExodeUIEngine {
620
950
  private interpolateColor(c1: string, c2: string, t: number): string {
621
951
  const parse = (c: string) => {
622
952
  const hex = c.replace('#', '');
953
+ if (hex.length === 3) {
954
+ return {
955
+ r: parseInt(hex[0] + hex[0], 16),
956
+ g: parseInt(hex[1] + hex[1], 16),
957
+ b: parseInt(hex[2] + hex[2], 16)
958
+ };
959
+ }
623
960
  return {
624
961
  r: parseInt(hex.substring(0, 2), 16),
625
962
  g: parseInt(hex.substring(2, 4), 16),
@@ -647,7 +984,13 @@ export class ExodeUIEngine {
647
984
  return;
648
985
  }
649
986
 
650
- canvas.clear(Skia.Color(this.artboard.backgroundColor || '#000000'));
987
+ try {
988
+ const bg = this.artboard.backgroundColor || '#000000';
989
+ canvas.clear(Skia.Color(bg));
990
+ } catch (e) {
991
+ console.error('[ExodeUIEngine] Rendering failed (clear):', e);
992
+ canvas.clear(Skia.Color('#000000'));
993
+ }
651
994
 
652
995
  const abWidth = this.artboard.width;
653
996
  const abHeight = this.artboard.height;
@@ -672,7 +1015,7 @@ export class ExodeUIEngine {
672
1015
  clipPath.addRect({ x: -abWidth / 2, y: -abHeight / 2, width: abWidth, height: abHeight });
673
1016
  canvas.clipPath(clipPath, 1, true);
674
1017
 
675
- this.artboard.objects.forEach((obj: ShapeObject) => {
1018
+ this.artboard.objects?.forEach((obj: ShapeObject) => {
676
1019
  this.renderObject(canvas, obj);
677
1020
  });
678
1021
 
@@ -760,35 +1103,39 @@ export class ExodeUIEngine {
760
1103
 
761
1104
  private renderObject(canvas: SkCanvas, obj: ShapeObject) {
762
1105
  const state = this.objectStates.get(obj.id);
763
- if (!state) return;
1106
+ if (!state || state.visible === false) return;
764
1107
 
765
1108
  const geometry = state.geometry || obj.geometry;
766
1109
 
767
- const w = (geometry as any).width || 0;
768
- const h = (geometry as any).height || 0;
1110
+ const w = state.width || (geometry as any).width || 0;
1111
+ const h = state.height || (geometry as any).height || 0;
769
1112
 
770
- const cx = state.x;
771
- const cy = state.y;
1113
+ const cx = state.x || 0;
1114
+ const cy = state.y || 0;
1115
+ const rotation = state.rotation || 0;
1116
+ const scaleX = state.scale_x === undefined ? 1 : state.scale_x;
1117
+ const scaleY = state.scale_y === undefined ? 1 : state.scale_y;
772
1118
 
773
1119
  canvas.save();
774
1120
  canvas.translate(cx, cy);
775
- canvas.rotate(state.rotation, 0, 0);
776
- canvas.scale(state.scale_x, state.scale_y);
1121
+ canvas.rotate(rotation, 0, 0);
1122
+ canvas.scale(scaleX, scaleY);
777
1123
 
778
1124
  const style = state.style || obj.style;
779
1125
 
780
1126
  if (geometry.type === 'Text') {
781
- // Text rendering omitted
1127
+ // Simple text rendering placeholder
782
1128
  } else if (geometry.type === 'Image') {
783
- // Image rendering omitted
1129
+ // Image rendering placeholder
784
1130
  } else {
785
1131
  // Shapes
786
1132
  const path = Skia.Path.Make();
787
1133
 
788
1134
  if (geometry.type === 'Rectangle') {
789
1135
  const rect = { x: -w/2, y: -h/2, width: w, height: h };
790
- if (geometry.corner_radius) {
791
- path.addRRect(Skia.RRectXY(rect, geometry.corner_radius, geometry.corner_radius));
1136
+ if (state.cornerRadius || geometry.corner_radius) {
1137
+ const cr = state.cornerRadius || geometry.corner_radius;
1138
+ path.addRRect(Skia.RRectXY(rect, cr, cr));
792
1139
  } else {
793
1140
  path.addRect(rect);
794
1141
  }
@@ -800,8 +1147,8 @@ export class ExodeUIEngine {
800
1147
  path.lineTo(-w/2, h/2);
801
1148
  path.close();
802
1149
  } else if (geometry.type === 'Star') {
803
- const ir = geometry.inner_radius;
804
- const or = geometry.outer_radius;
1150
+ const ir = geometry.inner_radius || 20;
1151
+ const or = geometry.outer_radius || 50;
805
1152
  const sp = geometry.points || 5;
806
1153
  for (let i = 0; i < sp * 2; i++) {
807
1154
  const a = (i * Math.PI / sp) - (Math.PI / 2);
@@ -816,7 +1163,11 @@ export class ExodeUIEngine {
816
1163
 
817
1164
  if (style.fill) {
818
1165
  const paint = Skia.Paint();
819
- paint.setColor(Skia.Color(style.fill.color));
1166
+ try {
1167
+ paint.setColor(Skia.Color(style.fill.color || '#000000'));
1168
+ } catch (e) {
1169
+ paint.setColor(Skia.Color('#FF00FF')); // Debug pink
1170
+ }
820
1171
  paint.setAlphaf((state.opacity ?? 1) * (style.fill.opacity ?? 1));
821
1172
  paint.setStyle(PaintStyle.Fill);
822
1173