import 'dart:convert' as convert; import 'dart:ffi'; import 'dart:io'; import 'dart:typed_data'; import 'dart:ui'; import 'package:ffi/ffi.dart'; import 'package:flutter/rendering.dart'; import 'package:http/http.dart' as http; import 'package:flutter/services.dart'; import 'spine_flutter_bindings_generated.dart'; export 'spine_widget.dart'; import 'package:path/path.dart' as Path; int majorVersion() => _bindings.spine_major_version(); int minorVersion() => _bindings.spine_minor_version(); void reportLeaks() => _bindings.spine_report_leaks(); class Atlas { final Pointer _atlas; final List atlasPages; final List atlasPagePaints; bool _disposed; Atlas(this._atlas, this.atlasPages, this.atlasPagePaints): _disposed = false; static Future _load(String atlasFileName, Future Function(String name) loadFile) async { final atlasBytes = await loadFile(atlasFileName); final atlasData = convert.utf8.decode(atlasBytes); final atlasDataNative = atlasData.toNativeUtf8(); final atlas = _bindings.spine_atlas_load(atlasDataNative.cast()); calloc.free(atlasDataNative); if (atlas.ref.error.address != nullptr.address) { final Pointer error = atlas.ref.error.cast(); final message = error.toDartString(); _bindings.spine_atlas_dispose(atlas); throw Exception("Couldn't load atlas: " + message); } final atlasDir = Path.dirname(atlasFileName); List atlasPages = []; List atlasPagePaints = []; for (int i = 0; i < atlas.ref.numImagePaths; i++) { final Pointer atlasPageFile = atlas.ref.imagePaths[i].cast(); final imagePath = Path.join(atlasDir, atlasPageFile.toDartString()); var imageData = await loadFile(imagePath); final Codec codec = await instantiateImageCodec(imageData); final FrameInfo frameInfo = await codec.getNextFrame(); final Image image = frameInfo.image; atlasPages.add(image); atlasPagePaints.add(Paint() ..shader = ImageShader(image, TileMode.clamp, TileMode.clamp, Matrix4.identity().storage, filterQuality: FilterQuality.high) ..isAntiAlias = true ); } return Atlas(atlas, atlasPages, atlasPagePaints); } static Future fromAsset(AssetBundle assetBundle, String atlasFileName) async { return _load(atlasFileName, (file) async => (await assetBundle.load(file)).buffer.asUint8List()); } static Future fromFile(String atlasFileName) async { return _load(atlasFileName, (file) => File(file).readAsBytes()); } static Future fromUrl(String atlasFileName) async { return _load(atlasFileName, (file) async { return (await http.get(Uri.parse(file))).bodyBytes; }); } void dispose() { if (_disposed) return; _disposed = true; _bindings.spine_atlas_dispose(this._atlas); for (final image in atlasPages) image.dispose(); } } class SkeletonData { final Pointer _skeletonData; bool _disposed; SkeletonData(this._skeletonData): _disposed = false; static SkeletonData fromJson(Atlas atlas, String json) { final jsonNative = json.toNativeUtf8(); final skeletonData = _bindings.spine_skeleton_data_load_json(atlas._atlas, jsonNative.cast()); if (skeletonData.ref.error.address != nullptr.address) { final Pointer error = skeletonData.ref.error.cast(); final message = error.toDartString(); _bindings.spine_skeleton_data_dispose(skeletonData); throw Exception("Couldn't load skeleton data: " + message); } return SkeletonData(skeletonData); } static SkeletonData fromBinary(Atlas atlas, Uint8List binary) { final Pointer binaryNative = malloc.allocate(binary.lengthInBytes); binaryNative.asTypedList(binary.lengthInBytes).setAll(0, binary); final skeletonData = _bindings.spine_skeleton_data_load_binary(atlas._atlas, binaryNative.cast(), binary.lengthInBytes); malloc.free(binaryNative); if (skeletonData.ref.error.address != nullptr.address) { final Pointer error = skeletonData.ref.error.cast(); final message = error.toDartString(); _bindings.spine_skeleton_data_dispose(skeletonData); throw Exception("Couldn't load skeleton data: " + message); } return SkeletonData(skeletonData); } void dispose() { if (_disposed) return; _disposed = true; _bindings.spine_skeleton_data_dispose(this._skeletonData); } } class Skeleton { final spine_skeleton _skeleton; Skeleton(this._skeleton); } class TrackEntry { final spine_track_entry _entry; TrackEntry(this._entry); } class AnimationState { final spine_animation_state _state; AnimationState(this._state); /// Increments the track entry times, setting queued animations as current if needed /// @param delta delta time void update(double delta) { _bindings.spine_animation_state_update(_state, delta); } /// Poses the skeleton using the track entry animations. There are no side effects other than invoking listeners, so the /// animation state can be applied to multiple skeletons to pose them identically. void apply(Skeleton skeleton) { _bindings.spine_animation_state_apply(_state, skeleton._skeleton); } /// Removes all animations from all tracks, leaving skeletons in their previous pose. /// It may be desired to use AnimationState.setEmptyAnimations(float) to mix the skeletons back to the setup pose, /// rather than leaving them in their previous pose. void clearTracks() { _bindings.spine_animation_state_clear_tracks(_state); } /// Removes all animations from the tracks, leaving skeletons in their previous pose. /// It may be desired to use AnimationState.setEmptyAnimations(float) to mix the skeletons back to the setup pose, /// rather than leaving them in their previous pose. void clearTrack(int trackIndex) { _bindings.spine_animation_state_clear_track(_state, trackIndex); } /// Sets the current animation for a track, discarding any queued animations. /// @param loop If true, the animation will repeat. /// If false, it will not, instead its last frame is applied if played beyond its duration. /// In either case TrackEntry.TrackEnd determines when the track is cleared. /// @return /// A track entry to allow further customization of animation playback. References to the track entry must not be kept /// after AnimationState.Dispose. TrackEntry setAnimation(int trackIndex, String animationName, bool loop) { final animation = animationName.toNativeUtf8(); final entry = _bindings.spine_animation_state_set_animation(_state, trackIndex, animation.cast(), loop ? -1 : 0); calloc.free(animation); if (entry.address == nullptr.address) throw Exception("Couldn't set animation $animationName"); return TrackEntry(entry); } /// Adds an animation to be played delay seconds after the current or last queued animation /// for a track. If the track is empty, it is equivalent to calling setAnimation. /// @param delay /// Seconds to begin this animation after the start of the previous animation. May be <= 0 to use the animation /// duration of the previous track minus any mix duration plus the negative delay. /// /// @return A track entry to allow further customization of animation playback. References to the track entry must not be kept /// after AnimationState.Dispose TrackEntry addAnimation(int trackIndex, String animationName, bool loop, double delay) { final animation = animationName.toNativeUtf8(); final entry = _bindings.spine_animation_state_add_animation(_state, trackIndex, animation.cast(), loop ? -1 : 0, delay); calloc.free(animation); if (entry.address == nullptr.address) throw Exception("Couldn't add animation $animationName"); return TrackEntry(entry); } /// Sets an empty animation for a track, discarding any queued animations, and mixes to it over the specified mix duration. TrackEntry setEmptyAnimation(int trackIndex, double mixDuration) { final entry = _bindings.spine_animation_state_set_empty_animation(_state, trackIndex, mixDuration); return TrackEntry(entry); } /// Adds an empty animation to be played after the current or last queued animation for a track, and mixes to it over the /// specified mix duration. /// @return /// A track entry to allow further customization of animation playback. References to the track entry must not be kept after AnimationState.Dispose. /// /// @param trackIndex Track number. /// @param mixDuration Mix duration. /// @param delay Seconds to begin this animation after the start of the previous animation. May be <= 0 to use the animation /// duration of the previous track minus any mix duration plus the negative delay. TrackEntry addEmptyAnimation(int trackIndex, double mixDuration, double delay) { final entry = _bindings.spine_animation_state_add_empty_animation(_state, trackIndex, mixDuration, delay); return TrackEntry(entry); } /// Sets an empty animation for every track, discarding any queued animations, and mixes to it over the specified mix duration. void setEmptyAnimations(double mixDuration) { _bindings.spine_animation_state_set_empty_animations(_state, mixDuration); } double getTimeScale() { return _bindings.spine_animation_state_get_time_scale(_state); } void setTimeScale(double timeScale) { _bindings.spine_animation_state_set_time_scale(_state, timeScale); } } class SkeletonDrawable { final Atlas atlas; final SkeletonData skeletonData; late final Pointer _drawable; late final Skeleton skeleton; late final AnimationState animationState; final bool _ownsData; bool _disposed; SkeletonDrawable(this.atlas, this.skeletonData, this._ownsData): _disposed = false { _drawable = _bindings.spine_skeleton_drawable_create(skeletonData._skeletonData); skeleton = Skeleton(_drawable.ref.skeleton); animationState = AnimationState(_drawable.ref.animationState); } void update(double delta) { if (_disposed) return; _bindings.spine_skeleton_drawable_update(_drawable, delta); } List render() { if (_disposed) return []; Pointer nativeCmd = _bindings.spine_skeleton_drawable_render(_drawable); List commands = []; while(nativeCmd.address != nullptr.address) { final atlasPage = atlas.atlasPages[nativeCmd.ref.atlasPage]; commands.add(RenderCommand(nativeCmd, atlasPage.width.toDouble(), atlasPage.height.toDouble())); nativeCmd = nativeCmd.ref.next; } return commands; } void dispose() { if (_disposed) return; _disposed = true; if (_ownsData) { atlas.dispose(); skeletonData.dispose(); } _bindings.spine_skeleton_drawable_dispose(_drawable); } } class RenderCommand { late final Vertices vertices; late final int atlasPageIndex; RenderCommand(Pointer nativeCmd, double pageWidth, double pageHeight) { atlasPageIndex = nativeCmd.ref.atlasPage; int numVertices = nativeCmd.ref.numVertices; int numIndices = nativeCmd.ref.numIndices; final uvs = nativeCmd.ref.uvs.asTypedList(numVertices * 2); for (int i = 0; i < numVertices * 2; i += 2) { uvs[i] *= pageWidth; uvs[i+1] *= pageHeight; } // We pass the native data as views directly to Vertices.raw. According to the sources, the data // is copied, so it doesn't matter that we free up the underlying memory on the next // render call. See the implementation of Vertices.raw() here: // https://github.com/flutter/engine/blob/5c60785b802ad2c8b8899608d949342d5c624952/lib/ui/painting/vertices.cc#L21 vertices = Vertices.raw(VertexMode.triangles, nativeCmd.ref.positions.asTypedList(numVertices * 2), textureCoordinates: uvs, colors: nativeCmd.ref.colors.asTypedList(numVertices), indices: nativeCmd.ref.indices.asTypedList(numIndices) ); } } const String _libName = 'spine_flutter'; final DynamicLibrary _dylib = () { if (Platform.isMacOS || Platform.isIOS) { return DynamicLibrary.open('$_libName.framework/$_libName'); } if (Platform.isAndroid || Platform.isLinux) { return DynamicLibrary.open('lib$_libName.so'); } if (Platform.isWindows) { return DynamicLibrary.open('$_libName.dll'); } throw UnsupportedError('Unknown platform: ${Platform.operatingSystem}'); }(); final SpineFlutterBindings _bindings = SpineFlutterBindings(_dylib);