exodeui-react-native 1.0.0

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/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # exodeui-react-native
2
+
3
+ The React Native Runtime for the ExodeUI Animation Engine. Render ExodeUI animations natively using Skia.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install exodeui-react-native @shopify/react-native-skia
9
+ # or
10
+ yarn add exodeui-react-native @shopify/react-native-skia
11
+ ```
12
+
13
+ ## Setup
14
+
15
+ Ensure you have configured `@shopify/react-native-skia` in your project according to their [installation guide](https://shopify.github.io/react-native-skia/docs/getting-started/installation).
16
+
17
+ ## Basic Usage
18
+
19
+ ```tsx
20
+ import { ExodeUIView } from 'exodeui-react-native';
21
+ import animationData from './my-animation.json';
22
+
23
+ function App() {
24
+ return (
25
+ <ExodeUIView
26
+ artboard={animationData}
27
+ style={{ width: 300, height: 300 }}
28
+ />
29
+ );
30
+ }
31
+ ```
32
+
33
+ ## API
34
+
35
+ ### `<ExodeUIView />`
36
+
37
+ | Prop | Type | Default | Description |
38
+ |------|------|---------|-------------|
39
+ | `artboard` | `object` | `undefined` | The JSON animation data object. |
40
+ | `style` | `ViewStyle` | `-` | Styles for the container view. |
41
+ | `onReady` | `(engine: any) => void` | `undefined` | Callback fired when the engine is loaded. |
42
+
43
+ ## License
44
+
45
+ MIT
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "exodeui-react-native",
3
+ "version": "1.0.0",
4
+ "description": "React Native runtime for ExodeUI animations",
5
+ "main": "src/index.tsx",
6
+ "files": [
7
+ "src",
8
+ "README.md"
9
+ ],
10
+ "scripts": {
11
+ "test": "echo \"Error: no test specified\" && exit 1"
12
+ },
13
+ "keywords": [
14
+ "react-native",
15
+ "animation",
16
+ "exodeui"
17
+ ],
18
+ "author": "",
19
+ "license": "ISC",
20
+ "peerDependencies": {
21
+ "@shopify/react-native-skia": "*",
22
+ "react": "*",
23
+ "react-native": "*"
24
+ },
25
+ "devDependencies": {
26
+ "@shopify/react-native-skia": "^2.4.14",
27
+ "@types/matter-js": "^0.20.2"
28
+ },
29
+ "dependencies": {
30
+ "matter-js": "^0.20.0"
31
+ }
32
+ }
@@ -0,0 +1,115 @@
1
+ import React, { useRef, useEffect, useState, useImperativeHandle, forwardRef } from 'react';
2
+ import { View, StyleSheet, TouchableWithoutFeedback, LayoutChangeEvent, Platform } from 'react-native';
3
+ import { Canvas, Skia, Picture, SkPicture } from '@shopify/react-native-skia';
4
+ import { ExodeUIEngine } from './engine';
5
+ import { Artboard, Fit, Alignment } from './types';
6
+
7
+ export interface ExodeUIViewProps {
8
+ artboard?: Artboard;
9
+ style?: any;
10
+ autoPlay?: boolean;
11
+ fit?: Fit;
12
+ alignment?: Alignment;
13
+ onReady?: (engine: ExodeUIEngine) => void;
14
+ onTrigger?: (triggerName: string, animationName: string) => void;
15
+ onInputUpdate?: (nameOrId: string, value: any) => void;
16
+ }
17
+
18
+ export const ExodeUIView = forwardRef<any, ExodeUIViewProps>(
19
+ ({ artboard, style, autoPlay = true, fit = 'Contain', alignment = 'Center', onReady, onTrigger, onInputUpdate }, ref) => {
20
+
21
+ const engineRef = useRef<ExodeUIEngine>(new ExodeUIEngine());
22
+ const [picture, setPicture] = useState<SkPicture | null>(null);
23
+ const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
24
+ const lastTimeRef = useRef<number>(0);
25
+ const rafRef = useRef<number | undefined>(undefined);
26
+
27
+ useImperativeHandle(ref, () => ({
28
+ getEngine: () => engineRef.current,
29
+ // Expose other methods if needed
30
+ }));
31
+
32
+ useEffect(() => {
33
+ engineRef.current.setLayout(fit, alignment);
34
+ }, [fit, alignment]);
35
+
36
+ useEffect(() => {
37
+ if (onTrigger) {
38
+ engineRef.current.setTriggerCallback(onTrigger);
39
+ }
40
+ }, [onTrigger]);
41
+
42
+ useEffect(() => {
43
+ if (onInputUpdate) {
44
+ engineRef.current.setInputUpdateCallback(onInputUpdate);
45
+ }
46
+ }, [onInputUpdate]);
47
+
48
+ useEffect(() => {
49
+ if (artboard) {
50
+ engineRef.current.load(artboard);
51
+ if (onReady) onReady(engineRef.current);
52
+ }
53
+ }, [artboard]);
54
+
55
+ const onLayout = (event: LayoutChangeEvent) => {
56
+ const { width, height } = event.nativeEvent.layout;
57
+ setDimensions({ width, height });
58
+ };
59
+
60
+ useEffect(() => {
61
+ const loop = (timestamp: number) => {
62
+ if (lastTimeRef.current === 0) lastTimeRef.current = timestamp;
63
+ const dt = (timestamp - lastTimeRef.current) / 1000;
64
+ lastTimeRef.current = timestamp;
65
+
66
+ const engine = engineRef.current;
67
+ const { width, height } = dimensions;
68
+
69
+ if (width > 0 && height > 0) {
70
+ if (autoPlay) {
71
+ engine.advance(dt);
72
+ }
73
+
74
+ // Record Picture
75
+ const recorder = Skia.PictureRecorder();
76
+ const canvas = recorder.beginRecording(Skia.XYWHRect(0, 0, width, height));
77
+ engine.render(canvas, width, height);
78
+ const pic = recorder.finishRecordingAsPicture();
79
+ setPicture(pic);
80
+ }
81
+
82
+ rafRef.current = requestAnimationFrame(loop);
83
+ };
84
+
85
+ rafRef.current = requestAnimationFrame(loop);
86
+
87
+ return () => {
88
+ if (rafRef.current) cancelAnimationFrame(rafRef.current);
89
+ };
90
+ }, [autoPlay, dimensions]); // depend on dimensions to restart loop/recording? Actually loop uses ref values ideally but dimensions state update triggers re-render anyway.
91
+
92
+ const handleTouch = (e: any) => {
93
+ const { locationX, locationY } = e.nativeEvent;
94
+ engineRef.current.handlePointerInput('click', locationX, locationY, dimensions.width, dimensions.height);
95
+ };
96
+
97
+ return (
98
+ <View style={[styles.container, style]} onLayout={onLayout}>
99
+ <TouchableWithoutFeedback onPress={handleTouch}>
100
+ <View style={StyleSheet.absoluteFill}>
101
+ <Canvas style={{ flex: 1 }}>
102
+ {picture && <Picture picture={picture} />}
103
+ </Canvas>
104
+ </View>
105
+ </TouchableWithoutFeedback>
106
+ </View>
107
+ );
108
+ }
109
+ );
110
+
111
+ const styles = StyleSheet.create({
112
+ container: {
113
+ overflow: 'hidden',
114
+ }
115
+ });
package/src/engine.ts ADDED
@@ -0,0 +1,863 @@
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';
4
+
5
+ export class ExodeUIEngine {
6
+ private artboard: Artboard | null = null;
7
+ private objectStates: Map<string, any> = new Map();
8
+
9
+ // Physics State
10
+ private physicsEngine: Matter.Engine | null = null;
11
+ private physicsBodies: Map<string, Matter.Body> = new Map();
12
+
13
+ // State Machine State
14
+ private activeStateMachine: StateMachine | null = null;
15
+ private inputs: Map<string, any> = new Map(); // id -> value
16
+ private inputNameMap: Map<string, string[]> = new Map(); // name -> id[] mapping
17
+ private layerStates: Map<string, {
18
+ currentStateIds: string[];
19
+ animation: Animation | null;
20
+ time: number;
21
+ currentState: State | null;
22
+ }> = new Map();
23
+
24
+ private imageCache = new Map<string, SkImage>();
25
+
26
+ private layout: Layout = { fit: 'Contain', alignment: 'Center' };
27
+
28
+ private onTrigger?: (triggerName: string, animationName: string) => void;
29
+ private onInputUpdate?: (nameOrId: string, value: any) => void;
30
+
31
+ setTriggerCallback(cb: (triggerName: string, animationName: string) => void) {
32
+ this.onTrigger = cb;
33
+ }
34
+
35
+ setInputUpdateCallback(cb: (nameOrId: string, value: any) => void) {
36
+ this.onInputUpdate = cb;
37
+ }
38
+
39
+ constructor() {}
40
+
41
+ // Helper to update Map AND notify listener
42
+ private setInternalInput(id: string, value: any) {
43
+ this.inputs.set(id, value);
44
+ if (this.onInputUpdate) {
45
+ this.onInputUpdate(id, value);
46
+ }
47
+ }
48
+
49
+ setLayout(fit: Fit, alignment: Alignment) {
50
+ this.layout = { fit, alignment };
51
+ }
52
+
53
+ getActiveStateIds(layerName: string = 'Base Layer'): string[] {
54
+ const state = this.layerStates.get(layerName);
55
+ return state ? state.currentStateIds : [];
56
+ }
57
+
58
+ load(data: Artboard) {
59
+ this.artboard = data;
60
+ this.reset();
61
+ }
62
+
63
+ reset() {
64
+ if (!this.artboard) return;
65
+ this.objectStates.clear();
66
+ this.inputs.clear();
67
+ this.inputNameMap.clear();
68
+ this.layerStates.clear();
69
+ this.physicsBodies.clear();
70
+ this.physicsEngine = null;
71
+
72
+ // 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
+ }
126
+
127
+ if (body) {
128
+ Matter.World.add(this.physicsEngine!.world, body);
129
+ this.physicsBodies.set(obj.id, body);
130
+ }
131
+ }
132
+ });
133
+ }
134
+
135
+ // Initialize State Machine
136
+ if (this.artboard.stateMachine) {
137
+ this.activeStateMachine = this.artboard.stateMachine;
138
+
139
+ // Init Inputs
140
+ this.activeStateMachine.inputs.forEach((input: any) => {
141
+ this.inputs.set(input.id, input.value.value);
142
+
143
+ // Map Name -> IDs
144
+ const existingIds = this.inputNameMap.get(input.name) || [];
145
+ existingIds.push(input.id);
146
+ this.inputNameMap.set(input.name, existingIds);
147
+ });
148
+
149
+ // Init Layers
150
+ this.activeStateMachine.layers.forEach((layer: any) => {
151
+ // Initial Entry
152
+ const entryState = layer.states.find((s: State) => s.id === layer.entryStateId);
153
+
154
+ if (entryState) {
155
+ this.enterStates(layer.name, [entryState.id]);
156
+ }
157
+ });
158
+ }
159
+
160
+ 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');
163
+ if (onLoadAnim) {
164
+ this.layerStates.set('intro', {
165
+ currentStateIds: ['onLoad'],
166
+ animation: onLoadAnim,
167
+ time: 0,
168
+ currentState: null
169
+ });
170
+ }
171
+ }
172
+ }
173
+
174
+ private enterStates(layerName: string, stateIds: string[]) {
175
+ if (stateIds.length === 0) return;
176
+
177
+ let anim: Animation | null = null;
178
+ let selectedState: State | null = null;
179
+
180
+ if (this.artboard && this.activeStateMachine) {
181
+ const layer = this.activeStateMachine.layers.find(l => l.name === layerName);
182
+ 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];
186
+ const state = layer.states.find(s => s.id === sId);
187
+
188
+ if (state) {
189
+ // Priority 1: Direct ID Match
190
+ if (state.animationId) {
191
+ anim = this.artboard.animations.find(a => a.id === state.animationId) || null;
192
+ }
193
+ // Priority 2: Name Match (Legacy)
194
+ 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;
196
+ }
197
+
198
+ if (anim) {
199
+ selectedState = state; // Store the state
200
+ break; // Found one!
201
+ }
202
+ }
203
+ }
204
+ }
205
+ }
206
+
207
+ this.layerStates.set(layerName, {
208
+ currentStateIds: stateIds,
209
+ animation: anim,
210
+ time: 0,
211
+ currentState: selectedState
212
+ });
213
+ }
214
+
215
+ // Input Control
216
+ setInputBool(nameOrId: string, value: boolean) {
217
+ this.updateInput(nameOrId, value);
218
+ }
219
+
220
+ setInputNumber(nameOrId: string, value: number) {
221
+ this.updateInput(nameOrId, value);
222
+ }
223
+
224
+ fireTrigger(nameOrId: string) {
225
+ this.updateInput(nameOrId, true);
226
+ }
227
+
228
+ setInputText(nameOrId: string, value: string) {
229
+ this.updateInput(nameOrId, value);
230
+ }
231
+
232
+ private updateInput(nameOrId: string, value: any) {
233
+ console.log(`[Engine] updateInput: ${nameOrId} -> ${value}`);
234
+
235
+ let inputType: string | undefined;
236
+ // Resolve Type
237
+ if (this.inputs.has(nameOrId)) {
238
+ const input = this.activeStateMachine?.inputs.find(i => i.id === nameOrId);
239
+ if (input && typeof input.value === 'object') {
240
+ inputType = input.value.type;
241
+ }
242
+ } else {
243
+ const ids = this.inputNameMap.get(nameOrId);
244
+ if (ids && ids.length > 0) {
245
+ const input = this.activeStateMachine?.inputs.find(i => i.id === ids[0]);
246
+ if (input && typeof input.value === 'object') {
247
+ inputType = input.value.type;
248
+ }
249
+ }
250
+ }
251
+
252
+ let finalValue = value;
253
+ if (inputType === 'Trigger') {
254
+ if (value === 1) finalValue = true;
255
+ else if (value === 0) finalValue = false;
256
+ }
257
+
258
+ // 1. Try treating as ID
259
+ if (this.inputs.has(nameOrId)) {
260
+ this.setInternalInput(nameOrId, finalValue);
261
+ }
262
+
263
+ // 2. Try treating as Name (Broadcast)
264
+ const ids = this.inputNameMap.get(nameOrId);
265
+ if (ids) {
266
+ ids.forEach(id => this.setInternalInput(id, finalValue));
267
+ }
268
+
269
+ // TRIGGER HARD RESET logic
270
+ 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
+ }
289
+ }
290
+ });
291
+ });
292
+ });
293
+ return;
294
+ }
295
+
296
+ this.evaluateTransitions();
297
+ }
298
+
299
+ private evaluateTransitions() {
300
+ if (!this.activeStateMachine) return;
301
+
302
+ this.activeStateMachine.layers.forEach((layer) => {
303
+ const layerState = this.layerStates.get(layer.name);
304
+ if (!layerState || layerState.currentStateIds.length === 0) return;
305
+
306
+ const nextStateIds: string[] = [];
307
+ let hasTransition = false;
308
+
309
+ for (const currentId of layerState.currentStateIds) {
310
+ const currentState = layer.states.find((s) => s.id === currentId);
311
+ if (!currentState) continue;
312
+
313
+ let transitioned = false;
314
+
315
+ for (const trans of currentState.transitions) {
316
+ if (this.checkConditions(trans.conditions)) {
317
+ nextStateIds.push(trans.targetStateId);
318
+ transitioned = true;
319
+ hasTransition = true;
320
+
321
+ // Reset Trigger/Number inputs used in this transition
322
+ if (trans.conditions) {
323
+ trans.conditions.forEach((cond: any) => {
324
+ const input = this.activeStateMachine?.inputs.find(i => i.id === cond.inputId);
325
+ 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);
334
+ if (this.inputNameMap.has(input.name)) {
335
+ const ids = this.inputNameMap.get(input.name);
336
+ ids?.forEach(id => this.setInternalInput(id, 0));
337
+ }
338
+ }
339
+ }
340
+ });
341
+ }
342
+ }
343
+ }
344
+
345
+ if (!transitioned) {
346
+ nextStateIds.push(currentId);
347
+ }
348
+ }
349
+
350
+ if (hasTransition) {
351
+ const uniqueIds = Array.from(new Set(nextStateIds));
352
+ this.enterStates(layer.name, uniqueIds);
353
+ }
354
+ });
355
+ }
356
+
357
+ private checkConditions(conditions: any[]): boolean {
358
+ if (!conditions || conditions.length === 0) return true;
359
+
360
+ return conditions.every((cond) => {
361
+ const inputId = cond.inputId;
362
+ const op = cond.op;
363
+ const targetValue = cond.value;
364
+
365
+ const inputValue = this.inputs.get(inputId);
366
+
367
+ if (inputValue === undefined) return false;
368
+
369
+ let result = false;
370
+ switch (op) {
371
+ case 'Equal': result = inputValue == targetValue; break;
372
+ case 'NotEqual': result = inputValue != targetValue; break;
373
+ case 'GreaterThan': result = inputValue > (targetValue as number); break;
374
+ case 'LessThan': result = inputValue < (targetValue as number); break;
375
+ case 'GreaterEqual': result = inputValue >= (targetValue as number); break;
376
+ case 'LessEqual': result = inputValue <= (targetValue as number); break;
377
+ case 'IsTrue': result = inputValue === true; break;
378
+ case 'IsFalse': result = inputValue === false; break;
379
+ default: result = false;
380
+ }
381
+ return result;
382
+ });
383
+ }
384
+
385
+ // Trigger Management
386
+ private activeTriggers: Map<string, {
387
+ triggerId: string;
388
+ animation: Animation;
389
+ time: number;
390
+ phase: 'entry' | 'hold' | 'exit';
391
+ elapsedHold: number;
392
+ }> = new Map();
393
+
394
+ advance(dt: number) {
395
+ if (!this.artboard) return;
396
+
397
+ // 1. Step Physics
398
+ 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
408
+ }
409
+ });
410
+ }
411
+
412
+ // Advance State Machine
413
+ if (this.activeStateMachine) {
414
+ this.evaluateTransitions();
415
+
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;
422
+
423
+ if (state.time > duration) {
424
+ if (shouldLoop) {
425
+ state.time %= duration;
426
+ } else {
427
+ state.time = duration;
428
+ }
429
+ }
430
+
431
+ this.applyAnimation(state.animation, state.time);
432
+ }
433
+ });
434
+ }
435
+
436
+ // Advance Active Triggers
437
+ this.activeTriggers.forEach((state, objectId) => {
438
+ if (state.phase === 'entry') {
439
+ state.time += dt;
440
+ if (state.time >= state.animation.duration) {
441
+ state.phase = 'hold';
442
+ state.elapsedHold = 0;
443
+ state.time = state.animation.duration;
444
+ }
445
+ this.applyAnimation(state.animation, state.time);
446
+ } else if (state.phase === 'hold') {
447
+ const trigger = this.artboard?.objects.find(o => o.id === objectId)?.triggers?.find(t => t.id === state.triggerId);
448
+ const holdDuration = (trigger?.duration || 0);
449
+ state.elapsedHold += dt * 1000;
450
+
451
+ if (state.elapsedHold >= holdDuration) {
452
+ if (trigger?.exitAnimationId) {
453
+ const exitAnim = this.artboard?.animations.find(a => a.id === trigger.exitAnimationId);
454
+ if (exitAnim) {
455
+ state.phase = 'exit';
456
+ state.animation = exitAnim;
457
+ state.time = 0;
458
+ } else {
459
+ this.activeTriggers.delete(objectId);
460
+ }
461
+ } else {
462
+ this.activeTriggers.delete(objectId);
463
+ }
464
+ }
465
+ } else if (state.phase === 'exit') {
466
+ state.time += dt;
467
+ if (state.time >= state.animation.duration) {
468
+ this.activeTriggers.delete(objectId);
469
+ } else {
470
+ this.applyAnimation(state.animation, state.time);
471
+ }
472
+ }
473
+ });
474
+ }
475
+
476
+ handlePointerInput(type: string, canvasX: number, canvasY: number, canvasWidth: number, canvasHeight: number) {
477
+ if (!this.artboard) return;
478
+
479
+ const transform = this.calculateTransform(
480
+ canvasWidth, canvasHeight,
481
+ this.artboard.width, this.artboard.height,
482
+ this.layout.fit,
483
+ this.layout.alignment
484
+ );
485
+
486
+ const artboardX = ((canvasX - transform.tx) / transform.scaleX) - (this.artboard.width / 2);
487
+ const artboardY = ((canvasY - transform.ty) / transform.scaleY) - (this.artboard.height / 2);
488
+
489
+ this.handlePointerEvent(type, artboardX, artboardY);
490
+ }
491
+
492
+ private handlePointerEvent(type: string, x: number, y: number) {
493
+ if (!this.artboard) return;
494
+
495
+ for (let i = this.artboard.objects.length - 1; i >= 0; i--) {
496
+ const obj = this.artboard.objects[i];
497
+ const isHit = this.hitTest(obj, x, y);
498
+
499
+ if (isHit) {
500
+ // Check interactions
501
+ const interactions = (obj as any).interactions || [];
502
+ const matchingInteraction = interactions.find((int: any) => int.event === type || (int.event === 'onClick' && type === 'click'));
503
+
504
+ if (matchingInteraction) {
505
+ if (matchingInteraction.action === 'setInput') {
506
+ this.updateInput(matchingInteraction.targetInputId, matchingInteraction.value);
507
+ } else if (matchingInteraction.action === 'fireTrigger') {
508
+ this.updateInput(matchingInteraction.targetInputId, true);
509
+ }
510
+ }
511
+
512
+ // Check triggers
513
+ const triggers = obj.triggers || [];
514
+ const matchingTrigger = triggers.find(t => t.eventType === type || (t.eventType === 'onClick' && type === 'click'));
515
+
516
+ if (matchingTrigger && matchingTrigger.entryAnimationId) {
517
+ const anim = this.artboard.animations.find(a => a.id === matchingTrigger.entryAnimationId);
518
+ if (anim) {
519
+ this.activeTriggers.set(obj.id, {
520
+ triggerId: matchingTrigger.id,
521
+ animation: anim,
522
+ time: 0,
523
+ phase: 'entry',
524
+ elapsedHold: 0
525
+ });
526
+
527
+ if (this.onTrigger) {
528
+ this.onTrigger(matchingTrigger.name, anim.name);
529
+ }
530
+ return;
531
+ }
532
+ }
533
+ }
534
+ }
535
+ }
536
+
537
+ private hitTest(obj: ShapeObject, x: number, y: number): boolean {
538
+ const state = this.objectStates.get(obj.id);
539
+ if (!state) return false;
540
+
541
+ const w = (obj.geometry as any).width || 100;
542
+ const h = (obj.geometry as any).height || 100;
543
+
544
+ const dx = x - state.x;
545
+ const dy = y - state.y;
546
+
547
+ return Math.abs(dx) <= w / 2 && Math.abs(dy) <= h / 2;
548
+ }
549
+
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
+
555
+ if (isPhysicsControlled && (['x', 'y', 'rotation'].includes(track.property) || track.property.startsWith('transform.'))) {
556
+ return;
557
+ }
558
+
559
+ const objState = this.objectStates.get(track.object_id);
560
+ if (!objState) return;
561
+
562
+ const value = this.interpolate(track.keyframes, time);
563
+
564
+ if (track.property.includes('.')) {
565
+ const parts = track.property.split('.');
566
+ let current = objState;
567
+ for (let i = 0; i < parts.length - 1; i++) {
568
+ if (!current[parts[i]]) current[parts[i]] = {};
569
+ current = current[parts[i]];
570
+ }
571
+ current[parts[parts.length - 1]] = value;
572
+ } else {
573
+ objState[track.property] = value;
574
+ }
575
+
576
+ // Sync State -> Physics Body (for Kinematic/Static animations)
577
+ const body = this.physicsBodies.get(track.object_id);
578
+ if (body && body.isStatic) {
579
+ if (track.property === 'x' || track.property === 'transform.x') {
580
+ Matter.Body.setPosition(body, { x: value, y: body.position.y });
581
+ } else if (track.property === 'y' || track.property === 'transform.y') {
582
+ Matter.Body.setPosition(body, { x: body.position.x, y: value });
583
+ } else if (track.property === 'rotation' || track.property === 'transform.rotation') {
584
+ Matter.Body.setAngle(body, value * (Math.PI / 180));
585
+ }
586
+ }
587
+ });
588
+ }
589
+
590
+ private interpolate(keyframes: any[], time: number): any {
591
+ if (keyframes.length === 0) return 0;
592
+
593
+ let bg = keyframes[0];
594
+ let end = keyframes[keyframes.length - 1];
595
+
596
+ if (time <= bg.time) return bg.value;
597
+ if (time >= end.time) return end.value;
598
+
599
+ for (let i = 0; i < keyframes.length - 1; i++) {
600
+ if (time >= keyframes[i].time && time < keyframes[i+1].time) {
601
+ bg = keyframes[i];
602
+ end = keyframes[i+1];
603
+ break;
604
+ }
605
+ }
606
+
607
+ const duration = end.time - bg.time;
608
+ const t = (time - bg.time) / duration;
609
+
610
+ if (typeof bg.value === 'string' && bg.value.startsWith('#')) {
611
+ return this.interpolateColor(bg.value, end.value, t);
612
+ }
613
+
614
+ if (typeof bg.value === 'number') {
615
+ return bg.value + ((end.value as number) - bg.value) * t;
616
+ }
617
+ return bg.value;
618
+ }
619
+
620
+ private interpolateColor(c1: string, c2: string, t: number): string {
621
+ const parse = (c: string) => {
622
+ const hex = c.replace('#', '');
623
+ return {
624
+ r: parseInt(hex.substring(0, 2), 16),
625
+ g: parseInt(hex.substring(2, 4), 16),
626
+ b: parseInt(hex.substring(4, 6), 16)
627
+ };
628
+ };
629
+
630
+ const start = parse(c1);
631
+ const end = parse(c2);
632
+
633
+ const r = Math.round(start.r + (end.r - start.r) * t);
634
+ const g = Math.round(start.g + (end.g - start.g) * t);
635
+ const b = Math.round(start.b + (end.b - start.b) * t);
636
+
637
+ const toHex = (n: number) => {
638
+ const h = Math.max(0, Math.min(255, n)).toString(16);
639
+ return h.length === 1 ? '0' + h : h;
640
+ };
641
+
642
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
643
+ }
644
+
645
+ render(canvas: SkCanvas, width: number, height: number) {
646
+ if (!this.artboard) {
647
+ return;
648
+ }
649
+
650
+ canvas.clear(Skia.Color(this.artboard.backgroundColor || '#000000'));
651
+
652
+ const abWidth = this.artboard.width;
653
+ const abHeight = this.artboard.height;
654
+
655
+ // Calculate Layout Transform
656
+ const transform = this.calculateTransform(
657
+ width, height,
658
+ abWidth, abHeight,
659
+ this.layout.fit,
660
+ this.layout.alignment
661
+ );
662
+
663
+ canvas.save();
664
+ canvas.translate(transform.tx, transform.ty);
665
+ canvas.scale(transform.scaleX, transform.scaleY);
666
+
667
+ // Center the Artboard Origin
668
+ canvas.translate(abWidth / 2, abHeight / 2);
669
+
670
+ // Clip to artboard bounds
671
+ const clipPath = Skia.Path.Make();
672
+ clipPath.addRect({ x: -abWidth / 2, y: -abHeight / 2, width: abWidth, height: abHeight });
673
+ canvas.clipPath(clipPath, 1, true);
674
+
675
+ this.artboard.objects.forEach((obj: ShapeObject) => {
676
+ this.renderObject(canvas, obj);
677
+ });
678
+
679
+ canvas.restore();
680
+ }
681
+
682
+ private calculateTransform(
683
+ availableWidth: number, availableHeight: number,
684
+ contentWidth: number, contentHeight: number,
685
+ fit: Fit, alignment: Alignment
686
+ ): { scaleX: number, scaleY: number, tx: number, ty: number } {
687
+ let scaleX = 1;
688
+ let scaleY = 1;
689
+ let minScale = 1;
690
+
691
+ const contentRatio = contentWidth / contentHeight;
692
+ const screenRatio = availableWidth / availableHeight;
693
+
694
+ switch (fit) {
695
+ case 'Contain':
696
+ if (screenRatio > contentRatio) {
697
+ minScale = availableHeight / contentHeight;
698
+ } else {
699
+ minScale = availableWidth / contentWidth;
700
+ }
701
+ scaleX = scaleY = minScale;
702
+ break;
703
+ case 'Cover':
704
+ if (screenRatio > contentRatio) {
705
+ minScale = availableWidth / contentWidth;
706
+ } else {
707
+ minScale = availableHeight / contentHeight;
708
+ }
709
+ scaleX = scaleY = minScale;
710
+ break;
711
+ case 'Fill':
712
+ scaleX = availableWidth / contentWidth;
713
+ scaleY = availableHeight / contentHeight;
714
+ break;
715
+ case 'FitWidth':
716
+ minScale = availableWidth / contentWidth;
717
+ scaleX = scaleY = minScale;
718
+ break;
719
+ case 'FitHeight':
720
+ minScale = availableHeight / contentHeight;
721
+ scaleX = scaleY = minScale;
722
+ break;
723
+ case 'None':
724
+ scaleX = scaleY = 1;
725
+ break;
726
+ case 'ScaleDown':
727
+ minScale = Math.min(availableWidth / contentWidth, availableHeight / contentHeight);
728
+ if (minScale > 1) minScale = 1;
729
+ scaleX = scaleY = minScale;
730
+ break;
731
+ default:
732
+ scaleX = scaleY = 1;
733
+ break;
734
+ }
735
+
736
+ const scaledWidth = contentWidth * scaleX;
737
+ const scaledHeight = contentHeight * scaleY;
738
+
739
+ let tx = 0;
740
+ let ty = 0;
741
+
742
+ if (alignment.includes('Left')) {
743
+ tx = 0;
744
+ } else if (alignment.includes('Right')) {
745
+ tx = availableWidth - scaledWidth;
746
+ } else {
747
+ tx = (availableWidth - scaledWidth) / 2;
748
+ }
749
+
750
+ if (alignment.includes('Top')) {
751
+ ty = 0;
752
+ } else if (alignment.includes('Bottom')) {
753
+ ty = availableHeight - scaledHeight;
754
+ } else {
755
+ ty = (availableHeight - scaledHeight) / 2;
756
+ }
757
+
758
+ return { scaleX, scaleY, tx, ty };
759
+ }
760
+
761
+ private renderObject(canvas: SkCanvas, obj: ShapeObject) {
762
+ const state = this.objectStates.get(obj.id);
763
+ if (!state) return;
764
+
765
+ const geometry = state.geometry || obj.geometry;
766
+
767
+ const w = (geometry as any).width || 0;
768
+ const h = (geometry as any).height || 0;
769
+
770
+ const cx = state.x;
771
+ const cy = state.y;
772
+
773
+ canvas.save();
774
+ canvas.translate(cx, cy);
775
+ canvas.rotate(state.rotation, 0, 0);
776
+ canvas.scale(state.scale_x, state.scale_y);
777
+
778
+ const style = state.style || obj.style;
779
+
780
+ if (geometry.type === 'Text') {
781
+ // Text rendering omitted
782
+ } else if (geometry.type === 'Image') {
783
+ // Image rendering omitted
784
+ } else {
785
+ // Shapes
786
+ const path = Skia.Path.Make();
787
+
788
+ if (geometry.type === 'Rectangle') {
789
+ 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));
792
+ } else {
793
+ path.addRect(rect);
794
+ }
795
+ } else if (geometry.type === 'Ellipse') {
796
+ path.addOval({ x: -w/2, y: -h/2, width: w, height: h });
797
+ } else if (geometry.type === 'Triangle') {
798
+ path.moveTo(0, -h/2);
799
+ path.lineTo(w/2, h/2);
800
+ path.lineTo(-w/2, h/2);
801
+ path.close();
802
+ } else if (geometry.type === 'Star') {
803
+ const ir = geometry.inner_radius;
804
+ const or = geometry.outer_radius;
805
+ const sp = geometry.points || 5;
806
+ for (let i = 0; i < sp * 2; i++) {
807
+ const a = (i * Math.PI / sp) - (Math.PI / 2);
808
+ const rad = i % 2 === 0 ? or : ir;
809
+ const px = rad * Math.cos(a);
810
+ const py = rad * Math.sin(a);
811
+ if (i === 0) path.moveTo(px, py);
812
+ else path.lineTo(px, py);
813
+ }
814
+ path.close();
815
+ }
816
+
817
+ if (style.fill) {
818
+ const paint = Skia.Paint();
819
+ paint.setColor(Skia.Color(style.fill.color));
820
+ paint.setAlphaf((state.opacity ?? 1) * (style.fill.opacity ?? 1));
821
+ paint.setStyle(PaintStyle.Fill);
822
+
823
+ if (style.shadow && style.shadow.opacity > 0) {
824
+ const alphaHex = Math.round(style.shadow.opacity * 255).toString(16).padStart(2, '0');
825
+ const colorWithAlpha = `${style.shadow.color}${alphaHex}`;
826
+ paint.setImageFilter(Skia.ImageFilter.MakeDropShadow(
827
+ style.shadow.offsetX, style.shadow.offsetY,
828
+ style.shadow.blur, style.shadow.blur,
829
+ Skia.Color(colorWithAlpha)
830
+ ));
831
+ }
832
+ if (style.blur && style.blur.amount > 0) {
833
+ paint.setMaskFilter(Skia.MaskFilter.MakeBlur(BlurStyle.Normal, style.blur.amount, true));
834
+ }
835
+
836
+ canvas.drawPath(path, paint);
837
+ }
838
+ if (style.stroke) {
839
+ const paint = Skia.Paint();
840
+ paint.setColor(Skia.Color(style.stroke.color));
841
+ paint.setStrokeWidth(style.stroke.width);
842
+ paint.setAlphaf((state.opacity ?? 1) * (style.stroke.opacity ?? 1));
843
+ paint.setStyle(PaintStyle.Stroke);
844
+
845
+ if (style.shadow && style.shadow.opacity > 0) {
846
+ const alphaHex = Math.round(style.shadow.opacity * 255).toString(16).padStart(2, '0');
847
+ const colorWithAlpha = `${style.shadow.color}${alphaHex}`;
848
+ paint.setImageFilter(Skia.ImageFilter.MakeDropShadow(
849
+ style.shadow.offsetX, style.shadow.offsetY,
850
+ style.shadow.blur, style.shadow.blur,
851
+ Skia.Color(colorWithAlpha)
852
+ ));
853
+ }
854
+ if (style.blur && style.blur.amount > 0) {
855
+ paint.setMaskFilter(Skia.MaskFilter.MakeBlur(BlurStyle.Normal, style.blur.amount, true));
856
+ }
857
+
858
+ canvas.drawPath(path, paint);
859
+ }
860
+ }
861
+ canvas.restore();
862
+ }
863
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,4 @@
1
+ export * from './types';
2
+ export * from './ExodeUIView';
3
+ export * from './useExodeUI';
4
+ export * from './engine';
package/src/types.ts ADDED
@@ -0,0 +1,213 @@
1
+ export interface Transform {
2
+ x: number;
3
+ y: number;
4
+ rotation: number;
5
+ scale_x: number;
6
+ scale_y: number;
7
+ }
8
+
9
+ export interface Shadow {
10
+ color: string;
11
+ blur: number;
12
+ offsetX: number;
13
+ offsetY: number;
14
+ opacity: number;
15
+ }
16
+
17
+ export interface Blur {
18
+ amount: number;
19
+ }
20
+
21
+ export interface Style {
22
+ fill?: {
23
+ type: 'Solid' | 'LinearGradient' | 'RadialGradient' | 'None';
24
+ color: string;
25
+ opacity: number;
26
+ };
27
+ stroke?: {
28
+ color: string;
29
+ width: number;
30
+ opacity: number;
31
+ };
32
+ shadow?: Shadow;
33
+ blur?: Blur;
34
+ palette?: string[];
35
+ }
36
+
37
+ export type Geometry =
38
+ | { type: 'Rectangle'; width: number; height: number; corner_radius?: number }
39
+ | { type: 'Ellipse'; width: number; height: number }
40
+ | { type: 'Text'; text: string; fontSize: number; fontFamily: string; textInputId?: string }
41
+ | { type: 'SVG'; svgContent: string; width: number; height: number; preserveAspectRatio: boolean; colors: string[] }
42
+ | { type: 'Triangle'; width: number; height: number }
43
+ | { type: 'Polygon'; radius: number; sides: number }
44
+ | { type: 'Star'; inner_radius: number; outer_radius: number; points: number }
45
+ | { type: 'Line'; length: number }
46
+ | { type: 'SVG'; svgContent: string; width: number; height: number; preserveAspectRatio: boolean; colors: string[] }
47
+ | { type: 'Image'; src: string; width: number; height: number };
48
+
49
+ export interface Trigger {
50
+ id: string;
51
+ name: string;
52
+ eventType: 'onClick' | 'onHold' | 'pointerDown' | 'pointerUp' | 'hover';
53
+ entryAnimationId?: string | null;
54
+ exitAnimationId?: string | null;
55
+ duration?: number;
56
+ loop?: boolean;
57
+ }
58
+
59
+ export interface Interaction {
60
+ id: string;
61
+ event: 'onClick' | 'onHold' | 'pointerDown' | 'pointerUp' | 'hover';
62
+ action: 'setInput' | 'fireTrigger';
63
+ targetInputId: string;
64
+ value?: any;
65
+ }
66
+
67
+ export interface Physics {
68
+ enabled: boolean;
69
+ bodyType: 'Dynamic' | 'Static';
70
+ mass: number;
71
+ density: number;
72
+ friction: number; // 0..1
73
+ restitution: number; // 0..1 (Bounciness)
74
+ frictionAir: number; // 0..1 (Damping)
75
+ isSensor: boolean; // For triggers only?
76
+ }
77
+
78
+
79
+ export interface ShapeObject {
80
+ type: 'Shape';
81
+ id: string;
82
+ name: string;
83
+ transform: Transform;
84
+ geometry: Geometry;
85
+ style: Style;
86
+ triggers?: Trigger[];
87
+ interactions?: Interaction[];
88
+ physics?: Physics;
89
+ }
90
+
91
+ export interface Keyframe {
92
+ time: number;
93
+ value: number | string;
94
+ easing: string;
95
+ }
96
+
97
+ export interface Track {
98
+ object_id: string;
99
+ property: string; // e.g., "rotation", "x"
100
+ keyframes: Keyframe[];
101
+ }
102
+
103
+ export interface Animation {
104
+ id: string;
105
+ name: string;
106
+ duration: number;
107
+ tracks: Track[];
108
+ }
109
+
110
+ // --- State Machine Types ---
111
+
112
+ export enum ConditionOp {
113
+ Equal = 'Equal',
114
+ NotEqual = 'NotEqual',
115
+ GreaterThan = 'GreaterThan',
116
+ LessThan = 'LessThan',
117
+ GreaterEqual = 'GreaterEqual',
118
+ LessEqual = 'LessEqual',
119
+ IsTrue = 'IsTrue',
120
+ IsFalse = 'IsFalse'
121
+ }
122
+
123
+ export interface Condition {
124
+ inputId: string;
125
+ op: ConditionOp;
126
+ value?: number | boolean;
127
+ }
128
+
129
+ export interface StateAction {
130
+ type: 'SetInput' | 'FireTrigger';
131
+ inputId: string;
132
+ value?: boolean | number;
133
+ }
134
+
135
+ export interface Transition {
136
+ id: string;
137
+ targetStateId: string;
138
+ duration: number;
139
+ delay?: number;
140
+ conditions: Condition[];
141
+ }
142
+
143
+ export interface State {
144
+ id: string;
145
+ name: string;
146
+ // UI Position for Graph
147
+ x: number;
148
+ y: number;
149
+ transitions: Transition[];
150
+ // Animation Binding
151
+ animationId?: string | null;
152
+ loop?: boolean;
153
+ speed?: number;
154
+ // Advanced Logic
155
+ onEntry?: StateAction[];
156
+ onExit?: StateAction[];
157
+ }
158
+
159
+ export type InputValue =
160
+ | { type: 'Boolean', value: boolean }
161
+ | { type: 'Number', value: number }
162
+ | { type: 'Trigger', value: boolean }
163
+ | { type: 'Text', value: string };
164
+
165
+ export interface InputNodePosition {
166
+ id: string;
167
+ x: number;
168
+ y: number;
169
+ }
170
+
171
+ export interface Input {
172
+ id: string;
173
+ name: string;
174
+ value: InputValue;
175
+ x?: number; // Deprecated
176
+ y?: number; // Deprecated
177
+ nodes?: InputNodePosition[];
178
+ }
179
+
180
+ export interface Layer {
181
+ name: string;
182
+ entryStateId: string;
183
+ entryConditions?: Condition[];
184
+ states: State[];
185
+ }
186
+
187
+ export interface StateMachine {
188
+ inputs: Input[];
189
+ layers: Layer[];
190
+ }
191
+
192
+ export interface Artboard {
193
+ name: string;
194
+ width: number;
195
+ height: number;
196
+ backgroundColor: string;
197
+ objects: ShapeObject[];
198
+ animations: Animation[];
199
+ stateMachine?: StateMachine;
200
+ physics?: {
201
+ gravity: { x: number; y: number };
202
+ wind: { x: number; y: number };
203
+ };
204
+ }
205
+
206
+ export type Fit = 'Cover' | 'Contain' | 'Fill' | 'FitWidth' | 'FitHeight' | 'None' | 'ScaleDown';
207
+
208
+ export type Alignment = 'Center' | 'TopLeft' | 'TopCenter' | 'TopRight' | 'CenterLeft' | 'CenterRight' | 'BottomLeft' | 'BottomCenter' | 'BottomRight';
209
+
210
+ export interface Layout {
211
+ fit: Fit;
212
+ alignment: Alignment;
213
+ }
@@ -0,0 +1,26 @@
1
+ import { useState, useCallback } from 'react';
2
+ import { ExodeUIEngine } from './engine';
3
+
4
+ export function useExodeUI() {
5
+ const [engine, setEngine] = useState<ExodeUIEngine | null>(null);
6
+
7
+ const setInputBool = useCallback((name: string, value: boolean) => {
8
+ engine?.setInputBool(name, value);
9
+ }, [engine]);
10
+
11
+ const setInputNumber = useCallback((name: string, value: number) => {
12
+ engine?.setInputNumber(name, value);
13
+ }, [engine]);
14
+
15
+ const fireTrigger = useCallback((name: string) => {
16
+ engine?.fireTrigger(name);
17
+ }, [engine]);
18
+
19
+ return {
20
+ setEngine,
21
+ engine,
22
+ setInputBool,
23
+ setInputNumber,
24
+ fireTrigger
25
+ };
26
+ }