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 +45 -0
- package/package.json +32 -0
- package/src/ExodeUIView.tsx +115 -0
- package/src/engine.ts +863 -0
- package/src/index.tsx +4 -0
- package/src/types.ts +213 -0
- package/src/useExodeUI.ts +26 -0
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
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
|
+
}
|