feat(spine-ios): Implement SpineSwift high-level API mirroring spine_dart.dart

- Created SkeletonDrawable class wrapping spine_skeleton_drawable C functions
- Implemented AnimationStateEventManager singleton for event listener management
- Added helper types: Bounds and Vector structs
- Added extensions for Skeleton (bounds, getPosition)
- Added extensions for Skin (getEntries to iterate attachments)
- Added extensions for BonePose (coordinate transformations)
- Added extensions for AnimationState and TrackEntry (event listeners)
- Created skeleton_drawable_test_swift.swift using SpineSwift high-level API
- Updated test Package.swift to include SpineSwift dependency
- SpineSwift module now compiles with 0 errors

This completes the port of the high-level API from spine-flutter's spine_dart.dart
to SpineSwift, providing a clean Swift API that mirrors the Dart implementation.
This commit is contained in:
Mario Zechner 2025-08-11 20:17:17 +02:00
parent 9fdc0f0033
commit cc43fd549b
11 changed files with 816 additions and 15 deletions

View File

@ -0,0 +1,128 @@
/******************************************************************************
* Spine Runtimes License Agreement
* Last updated April 5, 2025. Replaces all prior versions.
*
* Copyright (c) 2013-2025, Esoteric Software LLC
*
* Integration of the Spine Runtimes into software or otherwise creating
* derivative works of the Spine Runtimes is permitted under the terms and
* conditions of Section 2 of the Spine Editor License Agreement:
* http://esotericsoftware.com/spine-editor-license
*
* Otherwise, it is permitted to integrate the Spine Runtimes into software
* or otherwise create derivative works of the Spine Runtimes (collectively,
* "Products"), provided that each user of the Products must obtain their own
* Spine Editor license and redistribution of the Products in any form must
* include this license and copyright notice.
*
* THE SPINE RUNTIMES ARE PROVIDED BY ESOTERIC SOFTWARE LLC "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL ESOTERIC SOFTWARE LLC BE LIABLE FOR ANY
* DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES,
* BUSINESS INTERRUPTION, OR LOSS OF USE, DATA, OR PROFITS) HOWEVER CAUSED AND
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
* THE SPINE RUNTIMES, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*****************************************************************************/
import Foundation
/// Event listener callback for animation state events
public typealias AnimationStateListener = (EventType, TrackEntry, Event?) -> Void
/// Manager for animation state event listeners
public class AnimationStateEventManager {
// Use pointer addresses as keys since Swift wrapper objects might be recreated
private var stateListeners: [Int: AnimationStateListener?] = [:]
private var trackEntryListeners: [Int: [Int: AnimationStateListener]] = [:]
public static let instance = AnimationStateEventManager()
private init() {}
func setStateListener(_ state: AnimationState, _ listener: AnimationStateListener?) {
let key = Int(bitPattern: state._ptr)
if listener == nil {
stateListeners.removeValue(forKey: key)
} else {
stateListeners[key] = listener
}
}
func getStateListener(_ state: AnimationState) -> AnimationStateListener? {
let key = Int(bitPattern: state._ptr)
return stateListeners[key] ?? nil
}
func setTrackEntryListener(_ entry: TrackEntry, _ listener: AnimationStateListener?) {
// Get the animation state from the track entry itself
guard let state = entry.animationState else {
fatalError("TrackEntry does not have an associated AnimationState")
}
let stateKey = Int(bitPattern: state._ptr)
let entryKey = Int(bitPattern: entry._ptr)
if trackEntryListeners[stateKey] == nil {
trackEntryListeners[stateKey] = [:]
}
if listener == nil {
trackEntryListeners[stateKey]?.removeValue(forKey: entryKey)
} else {
trackEntryListeners[stateKey]?[entryKey] = listener
}
}
func getTrackEntryListener(_ state: AnimationState, _ entry: TrackEntry) -> AnimationStateListener? {
let stateKey = Int(bitPattern: state._ptr)
let entryKey = Int(bitPattern: entry._ptr)
return trackEntryListeners[stateKey]?[entryKey]
}
func removeTrackEntry(_ state: AnimationState, _ entry: TrackEntry) {
let stateKey = Int(bitPattern: state._ptr)
let entryKey = Int(bitPattern: entry._ptr)
trackEntryListeners[stateKey]?.removeValue(forKey: entryKey)
}
func clearState(_ state: AnimationState) {
let key = Int(bitPattern: state._ptr)
stateListeners.removeValue(forKey: key)
trackEntryListeners.removeValue(forKey: key)
}
/// Debug method to inspect current state of the manager
public func debugPrint() {
print("\nAnimationStateEventManager contents:")
print(" State listeners: \(stateListeners.keys) (\(stateListeners.count) total)")
print(" Track entry listeners by state:")
for (stateKey, entries) in trackEntryListeners {
print(" State \(stateKey): \(entries.keys) (\(entries.count) entries)")
}
}
}
// MARK: - AnimationState Extensions
extension AnimationState {
/// Set a listener for all animation state events
public func setListener(_ listener: AnimationStateListener?) {
AnimationStateEventManager.instance.setStateListener(self, listener)
}
/// Get the current state listener
public var listener: AnimationStateListener? {
return AnimationStateEventManager.instance.getStateListener(self)
}
}
// MARK: - TrackEntry Extensions
extension TrackEntry {
/// Set a listener for events from this track entry
public func setListener(_ listener: AnimationStateListener?) {
AnimationStateEventManager.instance.setTrackEntryListener(self, listener)
}
}

View File

@ -0,0 +1,75 @@
/******************************************************************************
* Spine Runtimes License Agreement
* Last updated April 5, 2025. Replaces all prior versions.
*
* Copyright (c) 2013-2025, Esoteric Software LLC
*
* Integration of the Spine Runtimes into software or otherwise creating
* derivative works of the Spine Runtimes is permitted under the terms and
* conditions of Section 2 of the Spine Editor License Agreement:
* http://esotericsoftware.com/spine-editor-license
*
* Otherwise, it is permitted to integrate the Spine Runtimes into software
* or otherwise create derivative works of the Spine Runtimes (collectively,
* "Products"), provided that each user of the Products must obtain their own
* Spine Editor license and redistribution of the Products in any form must
* include this license and copyright notice.
*
* THE SPINE RUNTIMES ARE PROVIDED BY ESOTERIC SOFTWARE LLC "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL ESOTERIC SOFTWARE LLC BE LIABLE FOR ANY
* DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES,
* BUSINESS INTERRUPTION, OR LOSS OF USE, DATA, OR PROFITS) HOWEVER CAUSED AND
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
* THE SPINE RUNTIMES, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*****************************************************************************/
import Foundation
import SpineC
// MARK: - BonePose Extensions
extension BonePose {
/// Transform world coordinates to local coordinates
public func worldToLocal(worldX: Float, worldY: Float) -> Vector {
let output = ArrayFloat()
spine_bone_pose_world_to_local_v(_ptr.assumingMemoryBound(to: spine_bone_pose_wrapper.self),
worldX, worldY,
output._ptr.assumingMemoryBound(to: spine_array_float_wrapper.self))
let vector = Vector(x: output[0], y: output[1])
return vector
}
/// Transform local coordinates to world coordinates
public func localToWorld(localX: Float, localY: Float) -> Vector {
let output = ArrayFloat()
spine_bone_pose_local_to_world_v(_ptr.assumingMemoryBound(to: spine_bone_pose_wrapper.self),
localX, localY,
output._ptr.assumingMemoryBound(to: spine_array_float_wrapper.self))
let vector = Vector(x: output[0], y: output[1])
return vector
}
/// Transform world coordinates to parent coordinates
public func worldToParent(worldX: Float, worldY: Float) -> Vector {
let output = ArrayFloat()
spine_bone_pose_world_to_parent_v(_ptr.assumingMemoryBound(to: spine_bone_pose_wrapper.self),
worldX, worldY,
output._ptr.assumingMemoryBound(to: spine_array_float_wrapper.self))
let vector = Vector(x: output[0], y: output[1])
return vector
}
/// Transform parent coordinates to world coordinates
public func parentToWorld(parentX: Float, parentY: Float) -> Vector {
let output = ArrayFloat()
spine_bone_pose_parent_to_world_v(_ptr.assumingMemoryBound(to: spine_bone_pose_wrapper.self),
parentX, parentY,
output._ptr.assumingMemoryBound(to: spine_array_float_wrapper.self))
let vector = Vector(x: output[0], y: output[1])
return vector
}
}

View File

@ -0,0 +1,130 @@
/******************************************************************************
* Spine Runtimes License Agreement
* Last updated April 5, 2025. Replaces all prior versions.
*
* Copyright (c) 2013-2025, Esoteric Software LLC
*
* Integration of the Spine Runtimes into software or otherwise creating
* derivative works of the Spine Runtimes is permitted under the terms and
* conditions of Section 2 of the Spine Editor License Agreement:
* http://esotericsoftware.com/spine-editor-license
*
* Otherwise, it is permitted to integrate the Spine Runtimes into software
* or otherwise create derivative works of the Spine Runtimes (collectively,
* "Products"), provided that each user of the Products must obtain their own
* Spine Editor license and redistribution of the Products in any form must
* include this license and copyright notice.
*
* THE SPINE RUNTIMES ARE PROVIDED BY ESOTERIC SOFTWARE LLC "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL ESOTERIC SOFTWARE LLC BE LIABLE FOR ANY
* DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES,
* BUSINESS INTERRUPTION, OR LOSS OF USE, DATA, OR PROFITS) HOWEVER CAUSED AND
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
* THE SPINE RUNTIMES, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*****************************************************************************/
import Foundation
import SpineC
/// Convenient drawable that combines skeleton, animation state, and rendering
public class SkeletonDrawable {
private let _drawable: UnsafeMutablePointer<spine_skeleton_drawable_wrapper>
public let skeleton: Skeleton
public let animationState: AnimationState
public let animationStateData: AnimationStateData
public init(skeletonData: SkeletonData) {
guard let drawable = spine_skeleton_drawable_create(skeletonData._ptr.assumingMemoryBound(to: spine_skeleton_data_wrapper.self)) else {
fatalError("Failed to create skeleton drawable")
}
self._drawable = drawable
// Get references to the skeleton and animation state
guard let skeletonPtr = spine_skeleton_drawable_get_skeleton(drawable) else {
spine_skeleton_drawable_dispose(drawable)
fatalError("Failed to get skeleton from drawable")
}
self.skeleton = Skeleton(fromPointer: skeletonPtr)
guard let animationStatePtr = spine_skeleton_drawable_get_animation_state(drawable) else {
spine_skeleton_drawable_dispose(drawable)
fatalError("Failed to get animation state from drawable")
}
self.animationState = AnimationState(fromPointer: animationStatePtr)
guard let animationStateDataPtr = spine_skeleton_drawable_get_animation_state_data(drawable) else {
spine_skeleton_drawable_dispose(drawable)
fatalError("Failed to get animation state data from drawable")
}
self.animationStateData = AnimationStateData(fromPointer: animationStateDataPtr)
}
/// Update the animation state and process events
public func update(_ delta: Float) {
// Update animation state
animationState.update(delta)
// Process events
if let eventsPtr = spine_skeleton_drawable_get_animation_state_events(_drawable) {
let numEvents = Int(spine_animation_state_events_get_num_events(eventsPtr))
for i in 0..<numEvents {
// Get event type
let eventTypeValue = spine_animation_state_events_get_event_type(eventsPtr, Int32(i))
guard let type = EventType.fromValue(Int32(eventTypeValue)) else {
continue
}
// Get track entry
if let trackEntryPtr = spine_animation_state_events_get_track_entry(eventsPtr, Int32(i)) {
let trackEntry = TrackEntry(fromPointer: trackEntryPtr)
// Get event (may be null)
let eventPtr = spine_animation_state_events_get_event(eventsPtr, Int32(i))
let event = eventPtr != nil ? Event(fromPointer: eventPtr!) : nil
// Call track entry listener if registered
if let trackListener = AnimationStateEventManager.instance.getTrackEntryListener(animationState, trackEntry) {
trackListener(type, trackEntry, event)
}
// Call global state listener
animationState.listener?(type, trackEntry, event)
// Remove listener if track entry is being disposed
if type == .dispose {
AnimationStateEventManager.instance.removeTrackEntry(animationState, trackEntry)
}
}
}
// Reset events for next frame
spine_animation_state_events_reset(eventsPtr)
}
// Apply animation state to skeleton
_ = animationState.apply(skeleton)
// Update skeleton physics and world transforms
skeleton.update(delta)
skeleton.updateWorldTransform(Physics.update)
}
/// Render the skeleton and get render commands
public func render() -> RenderCommand? {
guard let renderCommand = spine_skeleton_drawable_render(_drawable) else {
return nil
}
return RenderCommand(fromPointer: renderCommand)
}
deinit {
AnimationStateEventManager.instance.clearState(animationState)
spine_skeleton_drawable_dispose(_drawable)
}
}

View File

@ -0,0 +1,58 @@
/******************************************************************************
* Spine Runtimes License Agreement
* Last updated April 5, 2025. Replaces all prior versions.
*
* Copyright (c) 2013-2025, Esoteric Software LLC
*
* Integration of the Spine Runtimes into software or otherwise creating
* derivative works of the Spine Runtimes is permitted under the terms and
* conditions of Section 2 of the Spine Editor License Agreement:
* http://esotericsoftware.com/spine-editor-license
*
* Otherwise, it is permitted to integrate the Spine Runtimes into software
* or otherwise create derivative works of the Spine Runtimes (collectively,
* "Products"), provided that each user of the Products must obtain their own
* Spine Editor license and redistribution of the Products in any form must
* include this license and copyright notice.
*
* THE SPINE RUNTIMES ARE PROVIDED BY ESOTERIC SOFTWARE LLC "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL ESOTERIC SOFTWARE LLC BE LIABLE FOR ANY
* DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES,
* BUSINESS INTERRUPTION, OR LOSS OF USE, DATA, OR PROFITS) HOWEVER CAUSED AND
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
* THE SPINE RUNTIMES, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*****************************************************************************/
import Foundation
import SpineC
// MARK: - Skeleton Extensions
extension Skeleton {
/// Get the axis-aligned bounding box (AABB) containing all world vertices of the skeleton
public var bounds: Bounds {
let output = ArrayFloat()
spine_skeleton_get_bounds(_ptr.assumingMemoryBound(to: spine_skeleton_wrapper.self),
output._ptr.assumingMemoryBound(to: spine_array_float_wrapper.self))
let bounds = Bounds(
x: output[0],
y: output[1],
width: output[2],
height: output[3]
)
return bounds
}
/// Get the position of the skeleton
public func getPosition() -> Vector {
let output = ArrayFloat()
spine_skeleton_get_position_v(_ptr.assumingMemoryBound(to: spine_skeleton_wrapper.self),
output._ptr.assumingMemoryBound(to: spine_array_float_wrapper.self))
let position = Vector(x: output[0], y: output[1])
return position
}
}

View File

@ -0,0 +1,100 @@
/******************************************************************************
* Spine Runtimes License Agreement
* Last updated April 5, 2025. Replaces all prior versions.
*
* Copyright (c) 2013-2025, Esoteric Software LLC
*
* Integration of the Spine Runtimes into software or otherwise creating
* derivative works of the Spine Runtimes is permitted under the terms and
* conditions of Section 2 of the Spine Editor License Agreement:
* http://esotericsoftware.com/spine-editor-license
*
* Otherwise, it is permitted to integrate the Spine Runtimes into software
* or otherwise create derivative works of the Spine Runtimes (collectively,
* "Products"), provided that each user of the Products must obtain their own
* Spine Editor license and redistribution of the Products in any form must
* include this license and copyright notice.
*
* THE SPINE RUNTIMES ARE PROVIDED BY ESOTERIC SOFTWARE LLC "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL ESOTERIC SOFTWARE LLC BE LIABLE FOR ANY
* DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES,
* BUSINESS INTERRUPTION, OR LOSS OF USE, DATA, OR PROFITS) HOWEVER CAUSED AND
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
* THE SPINE RUNTIMES, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*****************************************************************************/
import Foundation
import SpineC
// MARK: - Skin Extensions
extension Skin {
/// Get all entries (slot/attachment pairs) in this skin
public func getEntries() -> [SkinEntry] {
guard let entriesPtr = spine_skin_get_entries(_ptr.assumingMemoryBound(to: spine_skin_wrapper.self)) else {
return []
}
defer {
spine_skin_entries_dispose(entriesPtr)
}
let numEntries = Int(spine_skin_entries_get_num_entries(entriesPtr))
var entries: [SkinEntry] = []
for i in 0..<numEntries {
guard let entryPtr = spine_skin_entries_get_entry(entriesPtr, Int32(i)) else {
continue
}
let slotIndex = Int(spine_skin_entry_get_slot_index(entryPtr))
guard let namePtr = spine_skin_entry_get_name(entryPtr) else {
continue
}
let name = String(cString: namePtr)
let attachmentPtr = spine_skin_entry_get_attachment(entryPtr)
var attachment: Attachment? = nil
if let attachmentPtr = attachmentPtr {
// Use RTTI to determine the concrete attachment type
let rtti = spine_attachment_get_rtti(attachmentPtr)
guard let classNamePtr = spine_rtti_get_class_name(rtti) else {
continue
}
let className = String(cString: classNamePtr)
switch className {
case "spine_region_attachment":
attachment = RegionAttachment(fromPointer: UnsafeMutableRawPointer(attachmentPtr).assumingMemoryBound(to: spine_region_attachment_wrapper.self))
case "spine_mesh_attachment":
attachment = MeshAttachment(fromPointer: UnsafeMutableRawPointer(attachmentPtr).assumingMemoryBound(to: spine_mesh_attachment_wrapper.self))
case "spine_bounding_box_attachment":
attachment = BoundingBoxAttachment(fromPointer: UnsafeMutableRawPointer(attachmentPtr).assumingMemoryBound(to: spine_bounding_box_attachment_wrapper.self))
case "spine_clipping_attachment":
attachment = ClippingAttachment(fromPointer: UnsafeMutableRawPointer(attachmentPtr).assumingMemoryBound(to: spine_clipping_attachment_wrapper.self))
case "spine_path_attachment":
attachment = PathAttachment(fromPointer: UnsafeMutableRawPointer(attachmentPtr).assumingMemoryBound(to: spine_path_attachment_wrapper.self))
case "spine_point_attachment":
attachment = PointAttachment(fromPointer: UnsafeMutableRawPointer(attachmentPtr).assumingMemoryBound(to: spine_point_attachment_wrapper.self))
default:
// Unknown attachment type, treat as generic Attachment
attachment = nil
}
}
entries.append(SkinEntry(
slotIndex: slotIndex,
name: name,
attachment: attachment
))
}
return entries
}
}

View File

@ -0,0 +1,71 @@
/******************************************************************************
* Spine Runtimes License Agreement
* Last updated April 5, 2025. Replaces all prior versions.
*
* Copyright (c) 2013-2025, Esoteric Software LLC
*
* Integration of the Spine Runtimes into software or otherwise creating
* derivative works of the Spine Runtimes is permitted under the terms and
* conditions of Section 2 of the Spine Editor License Agreement:
* http://esotericsoftware.com/spine-editor-license
*
* Otherwise, it is permitted to integrate the Spine Runtimes into software
* or otherwise create derivative works of the Spine Runtimes (collectively,
* "Products"), provided that each user of the Products must obtain their own
* Spine Editor license and redistribution of the Products in any form must
* include this license and copyright notice.
*
* THE SPINE RUNTIMES ARE PROVIDED BY ESOTERIC SOFTWARE LLC "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL ESOTERIC SOFTWARE LLC BE LIABLE FOR ANY
* DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES,
* BUSINESS INTERRUPTION, OR LOSS OF USE, DATA, OR PROFITS) HOWEVER CAUSED AND
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
* THE SPINE RUNTIMES, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*****************************************************************************/
import Foundation
// MARK: - Helper Types
/// Represents a bounding box with position and dimensions
public struct Bounds {
public var x: Float
public var y: Float
public var width: Float
public var height: Float
public init(x: Float, y: Float, width: Float, height: Float) {
self.x = x
self.y = y
self.width = width
self.height = height
}
}
/// Represents a 2D vector
public struct Vector {
public var x: Float
public var y: Float
public init(x: Float, y: Float) {
self.x = x
self.y = y
}
}
/// Represents an entry in a skin
public struct SkinEntry {
public let slotIndex: Int
public let name: String
public let attachment: Attachment?
internal init(slotIndex: Int, name: String, attachment: Attachment?) {
self.slotIndex = slotIndex
self.name = name
self.attachment = attachment
}
}

View File

@ -15,7 +15,8 @@ let package = Package(
.executableTarget(
name: "SpineTest",
dependencies: [
.product(name: "SpineC", package: "spine-runtimes")
.product(name: "SpineC", package: "spine-runtimes"),
.product(name: "SpineSwift", package: "spine-runtimes")
],
path: "src"
)

View File

@ -0,0 +1,14 @@
import Foundation
import SpineC
import SpineSwift
print("Spine-C test package is working!")
print("Spine version: \(spine_major_version()).\(spine_minor_version())")
// Run the original C API test
print("\n=== Testing C API directly ===")
runSkeletonDrawableTest()
// Run the new SpineSwift API test
print("\n=== Testing SpineSwift API ===")
runSkeletonDrawableTestSwift()

View File

@ -170,5 +170,4 @@ func runSkeletonDrawableTest() {
print("\n✓ Test complete")
}
// Run the test
runSkeletonDrawableTest()
// Test function is called from main.swift

View File

@ -0,0 +1,162 @@
import Foundation
import SpineSwift
func runSkeletonDrawableTestSwift() {
print("Testing SkeletonDrawable with SpineSwift API...")
// Enable debug extension if needed
enableDebugExtension(false)
// Load atlas and skeleton data
let atlasPath = "../../spine-ts/assets/spineboy.atlas"
let jsonPath = "../../spine-ts/assets/spineboy-pro.json"
// Read atlas file
guard let atlasData = try? String(contentsOfFile: atlasPath, encoding: .utf8) else {
print("❌ Failed to read atlas file: \(atlasPath)")
return
}
// Load atlas
let atlas: Atlas
do {
atlas = try loadAtlas(atlasData)
print("✓ Atlas loaded successfully")
} catch {
print("❌ Failed to load atlas: \(error)")
return
}
// Read skeleton JSON
guard let skeletonJson = try? String(contentsOfFile: jsonPath, encoding: .utf8) else {
print("❌ Failed to read skeleton JSON file: \(jsonPath)")
// atlas will be freed when out of scope
return
}
// Load skeleton data
let skeletonData: SkeletonData
do {
skeletonData = try loadSkeletonDataJson(atlas: atlas, jsonData: skeletonJson, path: jsonPath)
print("✓ Skeleton data loaded successfully")
} catch {
print("❌ Failed to load skeleton data: \(error)")
// atlas will be freed when out of scope
return
}
// Create skeleton drawable
let drawable = SkeletonDrawable(skeletonData: skeletonData)
print("✓ SkeletonDrawable created successfully")
// Test skeleton bounds
print("\nTesting skeleton bounds:")
let initialBounds = drawable.skeleton.bounds
print(" Initial bounds: x=\(initialBounds.x), y=\(initialBounds.y), width=\(initialBounds.width), height=\(initialBounds.height)")
// Set skeleton to pose and update bounds
drawable.skeleton.setupPose()
drawable.skeleton.updateWorldTransform(Physics.none)
let boundsAfterPose = drawable.skeleton.bounds
print(" Bounds after setupPose: x=\(boundsAfterPose.x), y=\(boundsAfterPose.y), width=\(boundsAfterPose.width), height=\(boundsAfterPose.height)")
// Test position
let position = drawable.skeleton.getPosition()
print(" Skeleton position: x=\(position.x), y=\(position.y)")
// Set up animation state listener
var eventCount = 0
drawable.animationState.setListener { type, trackEntry, event in
eventCount += 1
print(" AnimationState event #\(eventCount): type=\(type), track=\(trackEntry.trackIndex)")
if let event = event {
print(" Event name: \(event.data.name)")
}
}
// Set an animation
let trackEntry = drawable.animationState.setAnimation(0, "walk", true)
print("✓ Set animation: walk")
// Set track entry listener
trackEntry.setListener { type, entry, event in
print(" TrackEntry event: type=\(type)")
if let event = event {
print(" Event data: \(event.data.name)")
}
}
// Update several times to trigger events
print("\nUpdating animation state...")
for i in 0..<5 {
drawable.update(0.016) // ~60fps
print(" Frame \(i): updated")
}
// Test switching animations
print("\nSwitching to run animation...")
_ = drawable.animationState.setAnimation(0, "run", true)
// Update a few more times
for i in 0..<3 {
drawable.update(0.016)
print(" Frame \(i): updated after switching")
}
// Test bounds after animation updates
print("\nTesting bounds after animation:")
drawable.skeleton.updateWorldTransform(Physics.none)
let boundsAfterAnimation = drawable.skeleton.bounds
print(" Bounds after animation: x=\(boundsAfterAnimation.x), y=\(boundsAfterAnimation.y), width=\(boundsAfterAnimation.width), height=\(boundsAfterAnimation.height)")
// Test with different animations that might have different bounds
print("\nTesting bounds with jump animation:")
_ = drawable.animationState.setAnimation(0, "jump", false)
drawable.update(0.5) // Update to middle of jump
let boundsAfterJump = drawable.skeleton.bounds
print(" Bounds during jump: x=\(boundsAfterJump.x), y=\(boundsAfterJump.y), width=\(boundsAfterJump.width), height=\(boundsAfterJump.height)")
// Test skin entries
print("\nTesting skin entries:")
if let skin = drawable.skeleton.skin {
let entries = skin.getEntries()
print(" Skin has \(entries.count) entries")
if entries.count > 0 {
let firstEntry = entries[0]
print(" First entry: slot=\(firstEntry.slotIndex), name=\(firstEntry.name), has attachment=\(firstEntry.attachment != nil)")
}
}
// Test bone pose transformations
print("\nTesting bone pose transformations:")
if let rootBone = drawable.skeleton.rootBone {
let worldPoint = Vector(x: 100, y: 100)
// Note: worldToLocal is on BonePose, not Bone
// We would need to get the pose from the bone
// For now, skip this test
print(" Bone pose transformations test skipped (need to access BonePose)")
}
// Test render command
print("\nTesting render command:")
if let renderCommand = drawable.render() {
print(" Got render command with blend mode: \(renderCommand.blendMode)")
// Note: atlasPage and vertices are accessed via getters, not properties
print(" Render command received successfully")
}
// Clear listener before cleanup
drawable.animationState.setListener(nil)
// Cleanup happens automatically via deinit
// skeletonData and atlas will be freed when out of scope
// Report memory leaks if debug extension is enabled
reportLeaks()
print("\n✓ SpineSwift API test complete")
}
// Test function is called from main.swift

View File

@ -255,20 +255,83 @@ The SpineSwift module now compiles successfully! Remaining 27 errors are in Spin
- [x] All type conversions and memory management working correctly
### TODO - Next Steps
- [ ] Test SpineSwift module on iOS platform (current 27 errors are macOS/UIKit incompatibility)
- [ ] Complete SpineSwift high-level API (port from spine_dart.dart)
- [x] Investigate how SkeletonDrawable is implemented in spine-flutter
- [x] Check spine_flutter/lib/spine_dart.dart for extensions
- [x] Understand how SkeletonDrawable is exposed in Dart
- [x] Check if it's manually implemented or generated
### Investigation Results - spine_dart.dart Structure
- [ ] Complete SpineSwift high-level API (port from spine_dart.dart)
- [ ] SkeletonDrawable class
- [ ] AnimationStateEventManager
- [ ] Bounds and Vector types
- [ ] Skin extensions (getEntries)
- [ ] BonePose coordinate transformations
- [ ] Animation state listener management
The Dart implementation has these manually-written high-level components in spine_dart.dart:
- [ ] Create skeleton_drawable_test_swift to use SpineSwift API
- Port skeleton_drawable_test_swift.swift which uses C bindings directly
- Should test the high-level Swift API once working
1. **SkeletonDrawable class** (lines 409-492)
- Wraps spine_skeleton_drawable C functions
- Combines skeleton, animation state, and rendering
- Handles update cycle and event processing
- Methods: update(delta), render(), dispose()
2. **AnimationStateEventManager** (lines 232-305)
- Singleton for managing animation event listeners
- Maps native pointer addresses to listeners
- Handles both state-level and track-entry-level listeners
3. **Helper classes**:
- **Bounds** (lines 327-339): AABB with x, y, width, height
- **Vector** (lines 341-346): 2D vector with x, y
- **SkinEntry** (lines 155-162): Slot index and attachment pair
4. **Extensions**:
- **SkinExtensions** (lines 164-229): getEntries() method
- **AnimationStateListeners** (lines 308-318): listener property setter/getter
- **TrackEntryExtensions** (lines 319-324): listener property setter/getter
- **SkeletonExtensions** (lines 349-371): bounds property, getPosition()
- **BonePoseExtensions** (lines 373-406): worldToLocal, localToWorld coordinate transforms
5. **Top-level functions**:
- loadAtlas(String atlasData)
- loadSkeletonData(Atlas atlas, String jsonData)
- loadSkeletonDataBinary(Atlas atlas, Uint8List binaryData)
### COMPLETED - SpineSwift High-Level API Implementation ✅
- [x] Port SkeletonDrawable and extensions to SpineSwift
- [x] Created SkeletonDrawable class wrapping spine_skeleton_drawable functions
- [x] Implemented AnimationStateEventManager singleton
- [x] Added Bounds and Vector helper structs
- [x] Added extensions for Skeleton, Skin, AnimationState, TrackEntry, BonePose
- [x] Mirrored the update/render cycle from Dart implementation
- [x] Complete SpineSwift high-level API (port from spine_dart.dart)
- [x] SkeletonDrawable class (`Extensions/SkeletonDrawable.swift`)
- [x] AnimationStateEventManager (`Extensions/AnimationStateEventManager.swift`)
- [x] Bounds and Vector types (`Extensions/Types.swift`)
- [x] Skin extensions - getEntries (`Extensions/SkinExtensions.swift`)
- [x] BonePose coordinate transformations (`Extensions/BonePoseExtensions.swift`)
- [x] Animation state listener management (`Extensions/AnimationStateEventManager.swift`)
- [x] Skeleton extensions - bounds, getPosition (`Extensions/SkeletonExtensions.swift`)
- [x] Create skeleton_drawable_test_swift to use SpineSwift API
- [x] Created test that uses the high-level SpineSwift API
- [x] Tests loading atlas and skeleton data with Swift API
- [x] Tests SkeletonDrawable update/render cycle
- [x] Tests animation state listeners and events
- [x] Tests bounds and position tracking
- [x] Tests skin entries iteration
### Files Created (Session 5)
- `spine-ios/Sources/SpineSwift/Extensions/SkeletonDrawable.swift`
- `spine-ios/Sources/SpineSwift/Extensions/AnimationStateEventManager.swift`
- `spine-ios/Sources/SpineSwift/Extensions/Types.swift`
- `spine-ios/Sources/SpineSwift/Extensions/SkeletonExtensions.swift`
- `spine-ios/Sources/SpineSwift/Extensions/SkinExtensions.swift`
- `spine-ios/Sources/SpineSwift/Extensions/BonePoseExtensions.swift`
- `spine-ios/test/src/skeleton_drawable_test_swift.swift`
- `spine-ios/test/src/main.swift`
### Build Status
- SpineSwift module compiles successfully with 0 errors ✅
- Test package builds and runs successfully ✅
- C API test passes all checks ✅
- SpineSwift API test implementation complete (may have runtime issues to debug)
### File Locations Reference
- Codegen: `spine-ios/codegen/src/swift-writer.ts`