exodeui-react-native 1.0.0 → 1.0.2
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/index.js +1 -0
- package/package.json +10 -7
- package/src/ExodeUIView.tsx +127 -32
- package/src/__tests__/engine.test.ts +299 -0
- package/src/engine.ts +1430 -277
- package/src/physics/MatterPhysics.ts +66 -0
- package/src/physics/PhysicsEngine.ts +42 -0
- package/src/physics/index.ts +2 -0
- package/src/types.ts +160 -16
- package/src/useExodeUI.ts +31 -1
package/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './src/index';
|
package/package.json
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "exodeui-react-native",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "React Native runtime for ExodeUI animations",
|
|
5
|
-
"main": "
|
|
5
|
+
"main": "index.js",
|
|
6
6
|
"files": [
|
|
7
7
|
"src",
|
|
8
|
+
"index.js",
|
|
8
9
|
"README.md"
|
|
9
10
|
],
|
|
10
11
|
"scripts": {
|
|
11
|
-
"test": "
|
|
12
|
+
"test": "vitest run",
|
|
13
|
+
"test:watch": "vitest"
|
|
12
14
|
},
|
|
13
15
|
"keywords": [
|
|
14
16
|
"react-native",
|
|
@@ -24,9 +26,10 @@
|
|
|
24
26
|
},
|
|
25
27
|
"devDependencies": {
|
|
26
28
|
"@shopify/react-native-skia": "^2.4.14",
|
|
27
|
-
"@types/matter-js": "^0.20.2"
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
"
|
|
29
|
+
"@types/matter-js": "^0.20.2",
|
|
30
|
+
"@types/node": "^25.0.9",
|
|
31
|
+
"jsdom": "^27.4.0",
|
|
32
|
+
"typescript": "^5.0.0",
|
|
33
|
+
"vitest": "^4.0.17"
|
|
31
34
|
}
|
|
32
35
|
}
|
package/src/ExodeUIView.tsx
CHANGED
|
@@ -1,32 +1,42 @@
|
|
|
1
|
-
import React, { useRef, useEffect, useState,
|
|
2
|
-
import { View, StyleSheet,
|
|
1
|
+
import React, { useRef, useEffect, useState, forwardRef, useImperativeHandle } from 'react';
|
|
2
|
+
import { View, StyleSheet, LayoutChangeEvent, ViewStyle, PanResponder, PanResponderInstance, GestureResponderEvent, Image } from 'react-native';
|
|
3
3
|
import { Canvas, Skia, Picture, SkPicture } from '@shopify/react-native-skia';
|
|
4
4
|
import { ExodeUIEngine } from './engine';
|
|
5
|
-
import { Artboard, Fit, Alignment } from './types';
|
|
5
|
+
import { Artboard, Fit, Alignment, ComponentEvent } from './types';
|
|
6
6
|
|
|
7
7
|
export interface ExodeUIViewProps {
|
|
8
8
|
artboard?: Artboard;
|
|
9
|
-
|
|
9
|
+
src?: string;
|
|
10
|
+
style?: ViewStyle;
|
|
10
11
|
autoPlay?: boolean;
|
|
11
12
|
fit?: Fit;
|
|
12
13
|
alignment?: Alignment;
|
|
13
|
-
onReady?: (engine:
|
|
14
|
+
onReady?: (engine: any) => void; // Changed type to any as per instruction
|
|
14
15
|
onTrigger?: (triggerName: string, animationName: string) => void;
|
|
15
16
|
onInputUpdate?: (nameOrId: string, value: any) => void;
|
|
17
|
+
onComponentChange?: (event: ComponentEvent) => void; // Added
|
|
18
|
+
onToggle?: (name: string, checked: boolean) => void; // Changed value to checked
|
|
19
|
+
onInputChange?: (name: string, text: string) => void; // Changed value to text
|
|
20
|
+
onInputFocus?: (name: string) => void;
|
|
21
|
+
onInputBlur?: (name: string) => void;
|
|
16
22
|
}
|
|
17
23
|
|
|
18
24
|
export const ExodeUIView = forwardRef<any, ExodeUIViewProps>(
|
|
19
|
-
({ artboard, style, autoPlay = true, fit = 'Contain', alignment = 'Center', onReady, onTrigger, onInputUpdate }, ref) => {
|
|
25
|
+
({ artboard, src, style, autoPlay = true, fit = 'Contain', alignment = 'Center', onReady, onTrigger, onInputUpdate, onInputFocus, onInputBlur, onToggle, onInputChange, onComponentChange }, ref) => {
|
|
20
26
|
|
|
21
27
|
const engineRef = useRef<ExodeUIEngine>(new ExodeUIEngine());
|
|
22
28
|
const [picture, setPicture] = useState<SkPicture | null>(null);
|
|
23
29
|
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
|
|
24
30
|
const lastTimeRef = useRef<number>(0);
|
|
25
31
|
const rafRef = useRef<number | undefined>(undefined);
|
|
32
|
+
const dimsRef = useRef(dimensions);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
dimsRef.current = dimensions;
|
|
36
|
+
}, [dimensions]);
|
|
26
37
|
|
|
27
38
|
useImperativeHandle(ref, () => ({
|
|
28
39
|
getEngine: () => engineRef.current,
|
|
29
|
-
// Expose other methods if needed
|
|
30
40
|
}));
|
|
31
41
|
|
|
32
42
|
useEffect(() => {
|
|
@@ -34,26 +44,78 @@ export const ExodeUIView = forwardRef<any, ExodeUIViewProps>(
|
|
|
34
44
|
}, [fit, alignment]);
|
|
35
45
|
|
|
36
46
|
useEffect(() => {
|
|
37
|
-
if (onTrigger)
|
|
38
|
-
engineRef.current.setTriggerCallback(onTrigger);
|
|
39
|
-
}
|
|
47
|
+
if (onTrigger) engineRef.current.setTriggerCallback(onTrigger);
|
|
40
48
|
}, [onTrigger]);
|
|
41
49
|
|
|
42
50
|
useEffect(() => {
|
|
43
|
-
if (onInputUpdate)
|
|
44
|
-
engineRef.current.setInputUpdateCallback(onInputUpdate);
|
|
45
|
-
}
|
|
51
|
+
if (onInputUpdate) engineRef.current.setInputUpdateCallback(onInputUpdate);
|
|
46
52
|
}, [onInputUpdate]);
|
|
47
53
|
|
|
48
54
|
useEffect(() => {
|
|
49
|
-
if (
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
55
|
+
if (onInputFocus) engineRef.current.setInputFocusCallback(onInputFocus);
|
|
56
|
+
}, [onInputFocus]);
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (onInputBlur) engineRef.current.setInputBlurCallback(onInputBlur);
|
|
60
|
+
}, [onInputBlur]);
|
|
61
|
+
|
|
62
|
+
// New useEffect hooks for additional callbacks
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (onToggle) engineRef.current.setToggleCallback(onToggle);
|
|
65
|
+
}, [onToggle]);
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (onInputChange) engineRef.current.setInputChangeCallback(onInputChange);
|
|
69
|
+
}, [onInputChange]);
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (onComponentChange) engineRef.current.setComponentChangeCallback(onComponentChange);
|
|
73
|
+
}, [onComponentChange]);
|
|
74
|
+
|
|
75
|
+
// Load Artboard
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
const loadArtboard = async () => {
|
|
78
|
+
let data = artboard;
|
|
79
|
+
|
|
80
|
+
// Handle numeric asset IDs (from require)
|
|
81
|
+
if (typeof data === 'number') {
|
|
82
|
+
const source = Image.resolveAssetSource(data);
|
|
83
|
+
if (source && source.uri) {
|
|
84
|
+
try {
|
|
85
|
+
const response = await fetch(source.uri);
|
|
86
|
+
data = await response.json() as Artboard;
|
|
87
|
+
} catch (e) {
|
|
88
|
+
console.error('[ExodeUIView] Failed to fetch artboard from asset ID:', data, e);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!data && src) {
|
|
95
|
+
try {
|
|
96
|
+
const response = await fetch(src);
|
|
97
|
+
data = await response.json() as Artboard;
|
|
98
|
+
} catch (e) {
|
|
99
|
+
console.error('[ExodeUIView] Failed to fetch artboard from src:', src, e);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (data) {
|
|
105
|
+
console.log('[ExodeUIView] ✅ Successfully loaded artboard:', data.name || 'Untitled', 'Objects:', data.objects?.length);
|
|
106
|
+
engineRef.current.load(data);
|
|
107
|
+
if (onReady) onReady(engineRef.current);
|
|
108
|
+
} else {
|
|
109
|
+
console.warn('[ExodeUIView] ❌ No artboard data found to load');
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
loadArtboard();
|
|
114
|
+
}, [artboard, src, onReady]); // Added onReady to dependencies
|
|
54
115
|
|
|
55
116
|
const onLayout = (event: LayoutChangeEvent) => {
|
|
56
117
|
const { width, height } = event.nativeEvent.layout;
|
|
118
|
+
console.log(`[ExodeUIView] Layout: ${width}x${height}`);
|
|
57
119
|
setDimensions({ width, height });
|
|
58
120
|
};
|
|
59
121
|
|
|
@@ -61,6 +123,11 @@ export const ExodeUIView = forwardRef<any, ExodeUIViewProps>(
|
|
|
61
123
|
const loop = (timestamp: number) => {
|
|
62
124
|
if (lastTimeRef.current === 0) lastTimeRef.current = timestamp;
|
|
63
125
|
const dt = (timestamp - lastTimeRef.current) / 1000;
|
|
126
|
+
if (dt > 1) { // Cap dt to avoid large jumps if tab backgrounded
|
|
127
|
+
lastTimeRef.current = timestamp;
|
|
128
|
+
rafRef.current = requestAnimationFrame(loop);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
64
131
|
lastTimeRef.current = timestamp;
|
|
65
132
|
|
|
66
133
|
const engine = engineRef.current;
|
|
@@ -87,22 +154,49 @@ export const ExodeUIView = forwardRef<any, ExodeUIViewProps>(
|
|
|
87
154
|
return () => {
|
|
88
155
|
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
|
89
156
|
};
|
|
90
|
-
}, [autoPlay, dimensions]);
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
157
|
+
}, [autoPlay, dimensions]);
|
|
158
|
+
|
|
159
|
+
const panResponder = useRef(
|
|
160
|
+
PanResponder.create({
|
|
161
|
+
onStartShouldSetPanResponder: () => true,
|
|
162
|
+
onStartShouldSetPanResponderCapture: () => true,
|
|
163
|
+
onMoveShouldSetPanResponder: () => true,
|
|
164
|
+
onMoveShouldSetPanResponderCapture: () => true,
|
|
165
|
+
onPanResponderGrant: (e) => {
|
|
166
|
+
const { locationX, locationY } = e.nativeEvent;
|
|
167
|
+
console.log(`[ExodeUIView] PanResponder Grant: ${locationX.toFixed(1)}, ${locationY.toFixed(1)}`);
|
|
168
|
+
engineRef.current.handlePointerInput('PointerDown', locationX, locationY, dimsRef.current.width, dimsRef.current.height);
|
|
169
|
+
},
|
|
170
|
+
onPanResponderMove: (e) => {
|
|
171
|
+
const { locationX, locationY } = e.nativeEvent;
|
|
172
|
+
engineRef.current.handlePointerInput('PointerMove', locationX, locationY, dimsRef.current.width, dimsRef.current.height);
|
|
173
|
+
},
|
|
174
|
+
onPanResponderRelease: (e) => {
|
|
175
|
+
const { locationX, locationY } = e.nativeEvent;
|
|
176
|
+
console.log(`[ExodeUIView] PanResponder Release: ${locationX.toFixed(1)}, ${locationY.toFixed(1)}`);
|
|
177
|
+
engineRef.current.handlePointerInput('click', locationX, locationY, dimsRef.current.width, dimsRef.current.height);
|
|
178
|
+
engineRef.current.handlePointerInput('PointerUp', locationX, locationY, dimsRef.current.width, dimsRef.current.height);
|
|
179
|
+
},
|
|
180
|
+
onPanResponderTerminate: (e) => {
|
|
181
|
+
const { locationX, locationY } = e.nativeEvent;
|
|
182
|
+
engineRef.current.handlePointerInput('PointerUp', locationX, locationY, dimsRef.current.width, dimsRef.current.height);
|
|
183
|
+
},
|
|
184
|
+
onShouldBlockNativeResponder: () => true,
|
|
185
|
+
onPanResponderTerminationRequest: () => false,
|
|
186
|
+
})
|
|
187
|
+
).current;
|
|
96
188
|
|
|
97
189
|
return (
|
|
98
|
-
<View
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
190
|
+
<View
|
|
191
|
+
style={[styles.container, style]}
|
|
192
|
+
onLayout={onLayout}
|
|
193
|
+
{...panResponder.panHandlers}
|
|
194
|
+
>
|
|
195
|
+
<View style={StyleSheet.absoluteFill} pointerEvents="none">
|
|
196
|
+
<Canvas style={{ flex: 1 }}>
|
|
197
|
+
{picture && <Picture picture={picture} />}
|
|
198
|
+
</Canvas>
|
|
199
|
+
</View>
|
|
106
200
|
</View>
|
|
107
201
|
);
|
|
108
202
|
}
|
|
@@ -111,5 +205,6 @@ export const ExodeUIView = forwardRef<any, ExodeUIViewProps>(
|
|
|
111
205
|
const styles = StyleSheet.create({
|
|
112
206
|
container: {
|
|
113
207
|
overflow: 'hidden',
|
|
208
|
+
backgroundColor: '#0F0F1A', // Match playground background
|
|
114
209
|
}
|
|
115
210
|
});
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { ExodeUIEngine } from '../engine';
|
|
3
|
+
import { Artboard, ConditionOp } from '../types';
|
|
4
|
+
|
|
5
|
+
// Mock Skia since it requires native bindings
|
|
6
|
+
vi.mock('@shopify/react-native-skia', () => ({
|
|
7
|
+
Skia: {
|
|
8
|
+
Color: vi.fn((color: string) => color),
|
|
9
|
+
Path: {
|
|
10
|
+
Make: vi.fn(() => ({
|
|
11
|
+
addRect: vi.fn(),
|
|
12
|
+
addRRect: vi.fn(),
|
|
13
|
+
addOval: vi.fn(),
|
|
14
|
+
moveTo: vi.fn(),
|
|
15
|
+
lineTo: vi.fn(),
|
|
16
|
+
close: vi.fn(),
|
|
17
|
+
})),
|
|
18
|
+
},
|
|
19
|
+
RRectXY: vi.fn((rect, rx, ry) => ({ rect, rx, ry })),
|
|
20
|
+
Paint: vi.fn(() => ({
|
|
21
|
+
setStyle: vi.fn(),
|
|
22
|
+
setColor: vi.fn(),
|
|
23
|
+
setAntiAlias: vi.fn(),
|
|
24
|
+
})),
|
|
25
|
+
},
|
|
26
|
+
PaintStyle: {
|
|
27
|
+
Fill: 'fill',
|
|
28
|
+
Stroke: 'stroke',
|
|
29
|
+
},
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
// Mock Matter.js
|
|
33
|
+
vi.mock('matter-js', () => ({
|
|
34
|
+
default: {
|
|
35
|
+
Engine: {
|
|
36
|
+
create: vi.fn(() => ({
|
|
37
|
+
gravity: { x: 0, y: 1 },
|
|
38
|
+
world: {},
|
|
39
|
+
})),
|
|
40
|
+
update: vi.fn(),
|
|
41
|
+
},
|
|
42
|
+
Bodies: {
|
|
43
|
+
rectangle: vi.fn((x, y, w, h, options) => ({
|
|
44
|
+
position: { x, y },
|
|
45
|
+
angle: options?.angle || 0,
|
|
46
|
+
isStatic: options?.isStatic || false,
|
|
47
|
+
})),
|
|
48
|
+
circle: vi.fn((x, y, r, options) => ({
|
|
49
|
+
position: { x, y },
|
|
50
|
+
angle: options?.angle || 0,
|
|
51
|
+
isStatic: options?.isStatic || false,
|
|
52
|
+
})),
|
|
53
|
+
},
|
|
54
|
+
World: {
|
|
55
|
+
add: vi.fn(),
|
|
56
|
+
},
|
|
57
|
+
Body: {
|
|
58
|
+
setPosition: vi.fn(),
|
|
59
|
+
setAngle: vi.fn(),
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
const mockArtboard: Artboard = {
|
|
65
|
+
name: 'Test Artboard',
|
|
66
|
+
width: 1000,
|
|
67
|
+
height: 1000,
|
|
68
|
+
backgroundColor: '#ffffff',
|
|
69
|
+
objects: [
|
|
70
|
+
{
|
|
71
|
+
type: 'Shape',
|
|
72
|
+
id: 'obj1',
|
|
73
|
+
name: 'Rectangle',
|
|
74
|
+
transform: { x: 0, y: 0, rotation: 0, scale_x: 1, scale_y: 1 },
|
|
75
|
+
geometry: { type: 'Rectangle', width: 100, height: 100 },
|
|
76
|
+
style: { fill: { type: 'Solid', color: '#ff0000', opacity: 1 } }
|
|
77
|
+
}
|
|
78
|
+
],
|
|
79
|
+
animations: [
|
|
80
|
+
{
|
|
81
|
+
id: 'anim1',
|
|
82
|
+
name: 'Rotate',
|
|
83
|
+
duration: 1,
|
|
84
|
+
tracks: [
|
|
85
|
+
{
|
|
86
|
+
object_id: 'obj1',
|
|
87
|
+
property: 'rotation',
|
|
88
|
+
keyframes: [
|
|
89
|
+
{ time: 0, value: 0, easing: 'Linear' },
|
|
90
|
+
{ time: 1, value: 360, easing: 'Linear' }
|
|
91
|
+
]
|
|
92
|
+
}
|
|
93
|
+
]
|
|
94
|
+
}
|
|
95
|
+
],
|
|
96
|
+
stateMachine: {
|
|
97
|
+
inputs: [
|
|
98
|
+
{ id: 'in1', name: 'isRotating', value: { type: 'Boolean', value: false } }
|
|
99
|
+
],
|
|
100
|
+
layers: [
|
|
101
|
+
{
|
|
102
|
+
name: 'Base Layer',
|
|
103
|
+
entryStateId: 'state1',
|
|
104
|
+
states: [
|
|
105
|
+
{
|
|
106
|
+
id: 'state1',
|
|
107
|
+
name: 'Idle',
|
|
108
|
+
x: 0, y: 0,
|
|
109
|
+
transitions: [
|
|
110
|
+
{
|
|
111
|
+
id: 't1',
|
|
112
|
+
targetStateId: 'state2',
|
|
113
|
+
duration: 0,
|
|
114
|
+
conditions: [
|
|
115
|
+
{ inputId: 'in1', op: ConditionOp.IsTrue }
|
|
116
|
+
]
|
|
117
|
+
}
|
|
118
|
+
]
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: 'state2',
|
|
122
|
+
name: 'Rotating',
|
|
123
|
+
x: 100, y: 0,
|
|
124
|
+
animationId: 'anim1',
|
|
125
|
+
transitions: [
|
|
126
|
+
{
|
|
127
|
+
id: 't2',
|
|
128
|
+
targetStateId: 'state1',
|
|
129
|
+
duration: 0,
|
|
130
|
+
conditions: [
|
|
131
|
+
{ inputId: 'in1', op: ConditionOp.IsFalse }
|
|
132
|
+
]
|
|
133
|
+
}
|
|
134
|
+
]
|
|
135
|
+
}
|
|
136
|
+
]
|
|
137
|
+
}
|
|
138
|
+
]
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
describe('ExodeUIEngine (React Native)', () => {
|
|
143
|
+
let engine: ExodeUIEngine;
|
|
144
|
+
|
|
145
|
+
beforeEach(() => {
|
|
146
|
+
engine = new ExodeUIEngine();
|
|
147
|
+
engine.load(mockArtboard);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should initialize with the entry state', () => {
|
|
151
|
+
expect(engine.getActiveStateIds()).toContain('state1');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should transition to state2 when input is set to true', () => {
|
|
155
|
+
engine.setInputBool('isRotating', true);
|
|
156
|
+
expect(engine.getActiveStateIds()).toContain('state2');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('should transition back to state1 when input is set to false', () => {
|
|
160
|
+
engine.setInputBool('isRotating', true);
|
|
161
|
+
expect(engine.getActiveStateIds()).toContain('state2');
|
|
162
|
+
|
|
163
|
+
engine.setInputBool('isRotating', false);
|
|
164
|
+
expect(engine.getActiveStateIds()).toContain('state1');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should apply animation correctly after time advancement', () => {
|
|
168
|
+
engine.setInputBool('isRotating', true); // Enter rotating state
|
|
169
|
+
|
|
170
|
+
// Advance time by 0.5s (half of duration)
|
|
171
|
+
engine.advance(0.5);
|
|
172
|
+
|
|
173
|
+
const state = engine.getObjectState('obj1');
|
|
174
|
+
// Rotation goes from 0 to 360 over 1s, so at 0.5s it should be 180
|
|
175
|
+
expect(state.rotation).toBe(180);
|
|
176
|
+
|
|
177
|
+
// Advance to the end
|
|
178
|
+
engine.advance(0.5);
|
|
179
|
+
expect(engine.getObjectState('obj1').rotation).toBe(360);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should handle numeric inputs and conditions', () => {
|
|
183
|
+
const artboardWithNumber: Artboard = {
|
|
184
|
+
...mockArtboard,
|
|
185
|
+
stateMachine: {
|
|
186
|
+
inputs: [
|
|
187
|
+
{ id: 'num1', name: 'powerLevel', value: { type: 'Number', value: 0 } }
|
|
188
|
+
],
|
|
189
|
+
layers: [
|
|
190
|
+
{
|
|
191
|
+
name: 'Base Layer',
|
|
192
|
+
entryStateId: 'state1',
|
|
193
|
+
states: [
|
|
194
|
+
{
|
|
195
|
+
id: 'state1',
|
|
196
|
+
name: 'Low',
|
|
197
|
+
x: 0, y: 0,
|
|
198
|
+
transitions: [
|
|
199
|
+
{
|
|
200
|
+
id: 't1',
|
|
201
|
+
targetStateId: 'state2',
|
|
202
|
+
duration: 0,
|
|
203
|
+
conditions: [
|
|
204
|
+
{ inputId: 'num1', op: ConditionOp.GreaterThan, value: 50 }
|
|
205
|
+
]
|
|
206
|
+
}
|
|
207
|
+
]
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
id: 'state2',
|
|
211
|
+
name: 'High',
|
|
212
|
+
x: 100, y: 0,
|
|
213
|
+
transitions: []
|
|
214
|
+
}
|
|
215
|
+
]
|
|
216
|
+
}
|
|
217
|
+
]
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
engine.load(artboardWithNumber);
|
|
222
|
+
expect(engine.getActiveStateIds()).toContain('state1');
|
|
223
|
+
|
|
224
|
+
engine.setInputNumber('powerLevel', 100);
|
|
225
|
+
expect(engine.getActiveStateIds()).toContain('state2');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('should handle color interpolation', () => {
|
|
229
|
+
const artboardWithColor: Artboard = {
|
|
230
|
+
...mockArtboard,
|
|
231
|
+
animations: [
|
|
232
|
+
{
|
|
233
|
+
id: 'colorAnim',
|
|
234
|
+
name: 'ColorChange',
|
|
235
|
+
duration: 1,
|
|
236
|
+
tracks: [
|
|
237
|
+
{
|
|
238
|
+
object_id: 'obj1',
|
|
239
|
+
property: 'style.fill.color',
|
|
240
|
+
keyframes: [
|
|
241
|
+
{ time: 0, value: '#ff0000', easing: 'Linear' },
|
|
242
|
+
{ time: 1, value: '#0000ff', easing: 'Linear' }
|
|
243
|
+
]
|
|
244
|
+
}
|
|
245
|
+
]
|
|
246
|
+
}
|
|
247
|
+
],
|
|
248
|
+
stateMachine: {
|
|
249
|
+
inputs: [
|
|
250
|
+
{ id: 'in1', name: 'animate', value: { type: 'Boolean', value: false } }
|
|
251
|
+
],
|
|
252
|
+
layers: [
|
|
253
|
+
{
|
|
254
|
+
name: 'Base Layer',
|
|
255
|
+
entryStateId: 'state1',
|
|
256
|
+
states: [
|
|
257
|
+
{
|
|
258
|
+
id: 'state1',
|
|
259
|
+
name: 'Idle',
|
|
260
|
+
x: 0, y: 0,
|
|
261
|
+
transitions: [
|
|
262
|
+
{
|
|
263
|
+
id: 't1',
|
|
264
|
+
targetStateId: 'state2',
|
|
265
|
+
duration: 0,
|
|
266
|
+
conditions: [
|
|
267
|
+
{ inputId: 'in1', op: ConditionOp.IsTrue }
|
|
268
|
+
]
|
|
269
|
+
}
|
|
270
|
+
]
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
id: 'state2',
|
|
274
|
+
name: 'Animating',
|
|
275
|
+
x: 100, y: 0,
|
|
276
|
+
animationId: 'colorAnim',
|
|
277
|
+
transitions: []
|
|
278
|
+
}
|
|
279
|
+
]
|
|
280
|
+
}
|
|
281
|
+
]
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
engine.load(artboardWithColor);
|
|
286
|
+
engine.setInputBool('animate', true);
|
|
287
|
+
|
|
288
|
+
// At t=0, color should be red
|
|
289
|
+
const initialState = engine.getObjectState('obj1');
|
|
290
|
+
expect(initialState.style.fill.color).toBe('#ff0000');
|
|
291
|
+
|
|
292
|
+
// Advance to 0.5s (halfway)
|
|
293
|
+
engine.advance(0.5);
|
|
294
|
+
const halfwayState = engine.getObjectState('obj1');
|
|
295
|
+
// #ff0000 (255,0,0) -> #0000ff (0,0,255)
|
|
296
|
+
// Halfway is (128, 0, 128) -> #800080
|
|
297
|
+
expect(halfwayState.style.fill.color).toBe('#800080');
|
|
298
|
+
});
|
|
299
|
+
});
|