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/index.js +1 -0
- package/package.json +10 -7
- package/src/ExodeUIView.tsx +110 -27
- package/src/__tests__/engine.test.ts +299 -0
- package/src/engine.ts +529 -178
- 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 +159 -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.1",
|
|
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,22 +1,32 @@
|
|
|
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
5
|
import { Artboard, Fit, Alignment } from './types';
|
|
6
6
|
|
|
7
|
+
// Assuming ComponentEvent is defined elsewhere or will be defined.
|
|
8
|
+
// For now, let's define a placeholder if not provided.
|
|
9
|
+
type ComponentEvent = any;
|
|
10
|
+
|
|
7
11
|
export interface ExodeUIViewProps {
|
|
8
12
|
artboard?: Artboard;
|
|
9
|
-
|
|
13
|
+
src?: string;
|
|
14
|
+
style?: ViewStyle;
|
|
10
15
|
autoPlay?: boolean;
|
|
11
16
|
fit?: Fit;
|
|
12
17
|
alignment?: Alignment;
|
|
13
|
-
onReady?: (engine:
|
|
18
|
+
onReady?: (engine: any) => void; // Changed type to any as per instruction
|
|
14
19
|
onTrigger?: (triggerName: string, animationName: string) => void;
|
|
15
20
|
onInputUpdate?: (nameOrId: string, value: any) => void;
|
|
21
|
+
onComponentChange?: (event: ComponentEvent) => void; // Added
|
|
22
|
+
onToggle?: (name: string, checked: boolean) => void; // Changed value to checked
|
|
23
|
+
onInputChange?: (name: string, text: string) => void; // Changed value to text
|
|
24
|
+
onInputFocus?: (name: string) => void;
|
|
25
|
+
onInputBlur?: (name: string) => void;
|
|
16
26
|
}
|
|
17
27
|
|
|
18
28
|
export const ExodeUIView = forwardRef<any, ExodeUIViewProps>(
|
|
19
|
-
({ artboard, style, autoPlay = true, fit = 'Contain', alignment = 'Center', onReady, onTrigger, onInputUpdate }, ref) => {
|
|
29
|
+
({ artboard, src, style, autoPlay = true, fit = 'Contain', alignment = 'Center', onReady, onTrigger, onInputUpdate, onInputFocus, onInputBlur, onToggle, onInputChange, onComponentChange }, ref) => {
|
|
20
30
|
|
|
21
31
|
const engineRef = useRef<ExodeUIEngine>(new ExodeUIEngine());
|
|
22
32
|
const [picture, setPicture] = useState<SkPicture | null>(null);
|
|
@@ -26,7 +36,6 @@ export const ExodeUIView = forwardRef<any, ExodeUIViewProps>(
|
|
|
26
36
|
|
|
27
37
|
useImperativeHandle(ref, () => ({
|
|
28
38
|
getEngine: () => engineRef.current,
|
|
29
|
-
// Expose other methods if needed
|
|
30
39
|
}));
|
|
31
40
|
|
|
32
41
|
useEffect(() => {
|
|
@@ -34,26 +43,76 @@ export const ExodeUIView = forwardRef<any, ExodeUIViewProps>(
|
|
|
34
43
|
}, [fit, alignment]);
|
|
35
44
|
|
|
36
45
|
useEffect(() => {
|
|
37
|
-
if (onTrigger)
|
|
38
|
-
engineRef.current.setTriggerCallback(onTrigger);
|
|
39
|
-
}
|
|
46
|
+
if (onTrigger) engineRef.current.setTriggerCallback(onTrigger);
|
|
40
47
|
}, [onTrigger]);
|
|
41
48
|
|
|
42
49
|
useEffect(() => {
|
|
43
|
-
if (onInputUpdate)
|
|
44
|
-
engineRef.current.setInputUpdateCallback(onInputUpdate);
|
|
45
|
-
}
|
|
50
|
+
if (onInputUpdate) engineRef.current.setInputUpdateCallback(onInputUpdate);
|
|
46
51
|
}, [onInputUpdate]);
|
|
47
52
|
|
|
48
53
|
useEffect(() => {
|
|
49
|
-
if (
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
+
if (onInputFocus) engineRef.current.setInputFocusCallback(onInputFocus);
|
|
55
|
+
}, [onInputFocus]);
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (onInputBlur) engineRef.current.setInputBlurCallback(onInputBlur);
|
|
59
|
+
}, [onInputBlur]);
|
|
60
|
+
|
|
61
|
+
// New useEffect hooks for additional callbacks
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (onToggle) engineRef.current.setToggleCallback(onToggle);
|
|
64
|
+
}, [onToggle]);
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (onInputChange) engineRef.current.setInputChangeCallback(onInputChange);
|
|
68
|
+
}, [onInputChange]);
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (onComponentChange) engineRef.current.setComponentChangeCallback(onComponentChange);
|
|
72
|
+
}, [onComponentChange]);
|
|
73
|
+
|
|
74
|
+
// Load Artboard
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
const loadArtboard = async () => {
|
|
77
|
+
let data = artboard;
|
|
78
|
+
|
|
79
|
+
// Handle numeric asset IDs (from require)
|
|
80
|
+
if (typeof data === 'number') {
|
|
81
|
+
const source = Image.resolveAssetSource(data);
|
|
82
|
+
if (source && source.uri) {
|
|
83
|
+
try {
|
|
84
|
+
const response = await fetch(source.uri);
|
|
85
|
+
data = await response.json() as Artboard;
|
|
86
|
+
} catch (e) {
|
|
87
|
+
console.error('[ExodeUIView] Failed to fetch artboard from asset ID:', data, e);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!data && src) {
|
|
94
|
+
try {
|
|
95
|
+
const response = await fetch(src);
|
|
96
|
+
data = await response.json() as Artboard;
|
|
97
|
+
} catch (e) {
|
|
98
|
+
console.error('[ExodeUIView] Failed to fetch artboard from src:', src, e);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (data) {
|
|
104
|
+
console.log('[ExodeUIView] Loading artboard:', data.name || 'Untitled');
|
|
105
|
+
engineRef.current.load(data);
|
|
106
|
+
if (onReady) onReady(engineRef.current);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
loadArtboard();
|
|
111
|
+
}, [artboard, src, onReady]); // Added onReady to dependencies
|
|
54
112
|
|
|
55
113
|
const onLayout = (event: LayoutChangeEvent) => {
|
|
56
114
|
const { width, height } = event.nativeEvent.layout;
|
|
115
|
+
console.log(`[ExodeUIView] Layout: ${width}x${height}`);
|
|
57
116
|
setDimensions({ width, height });
|
|
58
117
|
};
|
|
59
118
|
|
|
@@ -61,6 +120,11 @@ export const ExodeUIView = forwardRef<any, ExodeUIViewProps>(
|
|
|
61
120
|
const loop = (timestamp: number) => {
|
|
62
121
|
if (lastTimeRef.current === 0) lastTimeRef.current = timestamp;
|
|
63
122
|
const dt = (timestamp - lastTimeRef.current) / 1000;
|
|
123
|
+
if (dt > 1) { // Cap dt to avoid large jumps if tab backgrounded
|
|
124
|
+
lastTimeRef.current = timestamp;
|
|
125
|
+
rafRef.current = requestAnimationFrame(loop);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
64
128
|
lastTimeRef.current = timestamp;
|
|
65
129
|
|
|
66
130
|
const engine = engineRef.current;
|
|
@@ -87,22 +151,40 @@ export const ExodeUIView = forwardRef<any, ExodeUIViewProps>(
|
|
|
87
151
|
return () => {
|
|
88
152
|
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
|
89
153
|
};
|
|
90
|
-
}, [autoPlay, dimensions]);
|
|
154
|
+
}, [autoPlay, dimensions]);
|
|
91
155
|
|
|
92
|
-
const
|
|
156
|
+
const handleResponderStart = (e: any) => {
|
|
157
|
+
const { locationX, locationY } = e.nativeEvent;
|
|
158
|
+
engineRef.current.handlePointerInput('PointerDown', locationX, locationY, dimensions.width, dimensions.height);
|
|
159
|
+
return true;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const handleResponderRelease = (e: any) => {
|
|
93
163
|
const { locationX, locationY } = e.nativeEvent;
|
|
94
164
|
engineRef.current.handlePointerInput('click', locationX, locationY, dimensions.width, dimensions.height);
|
|
165
|
+
engineRef.current.handlePointerInput('PointerUp', locationX, locationY, dimensions.width, dimensions.height);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const handleResponderMove = (e: any) => {
|
|
169
|
+
const { locationX, locationY } = e.nativeEvent;
|
|
170
|
+
engineRef.current.handlePointerInput('PointerMove', locationX, locationY, dimensions.width, dimensions.height);
|
|
95
171
|
};
|
|
96
172
|
|
|
97
173
|
return (
|
|
98
|
-
<View
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
174
|
+
<View
|
|
175
|
+
style={[styles.container, style]}
|
|
176
|
+
onLayout={onLayout}
|
|
177
|
+
onStartShouldSetResponder={() => true}
|
|
178
|
+
onResponderGrant={handleResponderStart}
|
|
179
|
+
onResponderMove={handleResponderMove}
|
|
180
|
+
onResponderRelease={handleResponderRelease}
|
|
181
|
+
onResponderTerminate={handleResponderRelease}
|
|
182
|
+
>
|
|
183
|
+
<View style={StyleSheet.absoluteFill} pointerEvents="none">
|
|
184
|
+
<Canvas style={{ flex: 1 }}>
|
|
185
|
+
{picture && <Picture picture={picture} />}
|
|
186
|
+
</Canvas>
|
|
187
|
+
</View>
|
|
106
188
|
</View>
|
|
107
189
|
);
|
|
108
190
|
}
|
|
@@ -111,5 +193,6 @@ export const ExodeUIView = forwardRef<any, ExodeUIViewProps>(
|
|
|
111
193
|
const styles = StyleSheet.create({
|
|
112
194
|
container: {
|
|
113
195
|
overflow: 'hidden',
|
|
196
|
+
backgroundColor: '#0F0F1A', // Match playground background
|
|
114
197
|
}
|
|
115
198
|
});
|
|
@@ -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
|
+
});
|