mirror of
https://github.com/EsotericSoftware/spine-runtimes.git
synced 2025-12-21 01:36:02 +08:00
405 lines
16 KiB
Swift
405 lines
16 KiB
Swift
/******************************************************************************
|
|
* 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 SwiftUI
|
|
import SpineCppLite
|
|
|
|
public var version: String {
|
|
return "\(majorVersion).\(minorVersion)"
|
|
}
|
|
|
|
public var majorVersion: Int {
|
|
return Int(spine_major_version())
|
|
}
|
|
|
|
public var minorVersion: Int {
|
|
return Int(spine_minor_version())
|
|
}
|
|
|
|
/// ``Atlas`` data loaded from a `.atlas` file and its corresponding `.png` files. For each atlas image,
|
|
/// a corresponding `UIImage` is constructed, which is used when rendering a skeleton
|
|
/// that uses this atlas.
|
|
///
|
|
/// Use the static methods ``Atlas/fromBundle(_:bundle:)``, ``Atlas/fromFile(_:)``, and ``Atlas/fromHttp(_:)`` to load an atlas. Call ``Atlas/dispose()`
|
|
/// when the atlas is no longer in use to release its resources.
|
|
public extension Atlas {
|
|
|
|
/// Loads an ``Atlas`` from the file with name `atlasFileName` in the `main` bundle or the optionally provided [bundle].
|
|
///
|
|
/// Throws an `Error` in case the atlas could not be loaded.
|
|
static func fromBundle(_ atlasFileName: String, bundle: Bundle = .main) async throws -> (Atlas, [UIImage]) {
|
|
let data = try await FileSource.bundle(fileName: atlasFileName, bundle: bundle).load()
|
|
return try await Self.fromData(data: data) { name in
|
|
return try await FileSource.bundle(fileName: name, bundle: bundle).load()
|
|
}
|
|
}
|
|
|
|
/// Loads an ``Atlas`` from the file URL `atlasFile`.
|
|
///
|
|
/// Throws an `Error` in case the atlas could not be loaded.
|
|
static func fromFile(_ atlasFile: URL) async throws -> (Atlas, [UIImage]) {
|
|
let data = try await FileSource.file(atlasFile).load()
|
|
return try await Self.fromData(data: data) { name in
|
|
let dir = atlasFile.deletingLastPathComponent()
|
|
let file = dir.appendingPathComponent(name)
|
|
return try await FileSource.file(file).load()
|
|
}
|
|
}
|
|
|
|
/// Loads an ``Atlas`` from the http URL `atlasURL`.
|
|
///
|
|
/// Throws an `Error` in case the atlas could not be loaded.
|
|
static func fromHttp(_ atlasURL: URL) async throws -> (Atlas, [UIImage]) {
|
|
let data = try await FileSource.http(atlasURL).load()
|
|
return try await Self.fromData(data: data) { name in
|
|
let dir = atlasURL.deletingLastPathComponent()
|
|
let file = dir.appendingPathComponent(name)
|
|
return try await FileSource.http(file).load()
|
|
}
|
|
}
|
|
|
|
private static func fromData(data: Data, loadFile: (_ name: String) async throws -> Data) async throws -> (Atlas, [UIImage]) {
|
|
guard let atlasData = String(data: data, encoding: .utf8) else {
|
|
throw SpineError("Couldn't read atlas bytes as utf8 string")
|
|
}
|
|
let atlas = try atlasData.utf8CString.withUnsafeBufferPointer {
|
|
guard let atlas = spine_atlas_load($0.baseAddress) else {
|
|
throw SpineError("Couldn't load atlas data")
|
|
}
|
|
return atlas
|
|
}
|
|
if let error = spine_atlas_get_error(atlas) {
|
|
let message = String(cString: error)
|
|
spine_atlas_dispose(atlas)
|
|
throw SpineError("Couldn't load atlas: \(message)")
|
|
}
|
|
|
|
var atlasPages = [UIImage]()
|
|
let numImagePaths = spine_atlas_get_num_image_paths(atlas);
|
|
|
|
for i in 0..<numImagePaths {
|
|
guard let atlasPageFilePointer = spine_atlas_get_image_path(atlas, i) else {
|
|
continue
|
|
}
|
|
let atlasPageFile = String(cString: atlasPageFilePointer)
|
|
let imageData = try await loadFile(atlasPageFile)
|
|
guard let image = UIImage(data: imageData) else {
|
|
continue
|
|
}
|
|
atlasPages.append(image)
|
|
}
|
|
|
|
return (Atlas(atlas), atlasPages)
|
|
}
|
|
}
|
|
|
|
public extension SkeletonData {
|
|
|
|
/// Loads a ``SkeletonData`` from the file with name `skeletonFileName` in the main bundle or the optionally provided `bundle`.
|
|
/// Uses the provided ``Atlas`` to resolve attachment images.
|
|
///
|
|
/// Throws an `Error` in case the skeleton data could not be loaded.
|
|
static func fromBundle(atlas: Atlas, skeletonFileName: String, bundle: Bundle = .main) async throws -> SkeletonData {
|
|
return try fromData(
|
|
atlas: atlas,
|
|
data: try await FileSource.bundle(fileName: skeletonFileName, bundle: bundle).load(),
|
|
isJson: skeletonFileName.hasSuffix(".json")
|
|
)
|
|
}
|
|
|
|
/// Loads a ``SkeletonData`` from the file URL `skeletonFile`. Uses the provided ``Atlas`` to resolve attachment images.
|
|
///
|
|
/// Throws an `Error` in case the skeleton data could not be loaded.
|
|
static func fromFile(atlas: Atlas, skeletonFile: URL) async throws -> SkeletonData {
|
|
return try fromData(
|
|
atlas: atlas,
|
|
data: try await FileSource.file(skeletonFile).load(),
|
|
isJson: skeletonFile.absoluteString.hasSuffix(".json")
|
|
)
|
|
}
|
|
|
|
/// Loads a ``SkeletonData`` from the http URL `skeletonFile`. Uses the provided ``Atlas`` to resolve attachment images.
|
|
///
|
|
/// Throws an `Error` in case the skeleton data could not be loaded.
|
|
static func fromHttp(atlas: Atlas, skeletonURL: URL) async throws -> SkeletonData {
|
|
return try fromData(
|
|
atlas: atlas,
|
|
data: try await FileSource.http(skeletonURL).load(),
|
|
isJson: skeletonURL.absoluteString.hasSuffix(".json")
|
|
)
|
|
}
|
|
|
|
/// Loads a ``SkeletonData`` from the ``binary`` skeleton `Data`, using the provided ``Atlas`` to resolve attachment images.
|
|
///
|
|
/// Throws an `Error` in case the skeleton data could not be loaded.
|
|
static func fromData(atlas: Atlas, data: Data) throws -> SkeletonData {
|
|
let result = try data.withUnsafeBytes{
|
|
try $0.withMemoryRebound(to: UInt8.self) { buffer in
|
|
guard let ptr = buffer.baseAddress else {
|
|
throw SpineError("Couldn't read atlas binary")
|
|
}
|
|
return spine_skeleton_data_load_binary(
|
|
atlas.wrappee,
|
|
ptr,
|
|
Int32(buffer.count)
|
|
)
|
|
}
|
|
}
|
|
guard let result else {
|
|
throw SpineError("Couldn't load skeleton data")
|
|
}
|
|
defer {
|
|
spine_skeleton_data_result_dispose(result)
|
|
}
|
|
if let error = spine_skeleton_data_result_get_error(result) {
|
|
let message = String(cString: error)
|
|
throw SpineError("Couldn't load skeleton data: \(message)")
|
|
}
|
|
guard let data = spine_skeleton_data_result_get_data(result) else {
|
|
throw SpineError("Couldn't load skeleton data from result")
|
|
}
|
|
return SkeletonData(data)
|
|
}
|
|
|
|
/// Loads a ``SkeletonData`` from the `json` string, using the provided ``Atlas`` to resolve attachment
|
|
/// images.
|
|
///
|
|
/// Throws an `Error` in case the atlas could not be loaded.
|
|
static func fromJson(atlas: Atlas, json: String) throws -> SkeletonData {
|
|
let result = try json.utf8CString.withUnsafeBufferPointer { buffer in
|
|
guard
|
|
let basePtr = buffer.baseAddress,
|
|
let result = spine_skeleton_data_load_json(atlas.wrappee, basePtr) else {
|
|
throw SpineError("Couldn't load skeleton data json")
|
|
}
|
|
return result
|
|
}
|
|
defer {
|
|
spine_skeleton_data_result_dispose(result)
|
|
}
|
|
if let error = spine_skeleton_data_result_get_error(result) {
|
|
let message = String(cString: error)
|
|
throw SpineError("Couldn't load skeleton data: \(message)")
|
|
}
|
|
guard let data = spine_skeleton_data_result_get_data(result) else {
|
|
throw SpineError("Couldn't load skeleton data from result")
|
|
}
|
|
return SkeletonData(data)
|
|
}
|
|
|
|
private static func fromData(atlas: Atlas, data: Data, isJson: Bool) throws -> SkeletonData {
|
|
if isJson {
|
|
guard let json = String(data: data, encoding: .utf8) else {
|
|
throw SpineError("Couldn't read skeleton data json string")
|
|
}
|
|
return try fromJson(atlas: atlas, json: json)
|
|
} else {
|
|
return try fromData(atlas: atlas, data: data)
|
|
}
|
|
}
|
|
}
|
|
|
|
internal extension SkeletonDrawable {
|
|
|
|
func render() -> [RenderCommand] {
|
|
var commands = [RenderCommand]()
|
|
if disposed { return commands }
|
|
|
|
var nativeCmd = spine_skeleton_drawable_render(wrappee)
|
|
repeat {
|
|
if let ncmd = nativeCmd {
|
|
commands.append(RenderCommand(ncmd))
|
|
nativeCmd = spine_render_command_get_next(ncmd)
|
|
} else {
|
|
nativeCmd = nil
|
|
}
|
|
} while (nativeCmd != nil)
|
|
|
|
return commands
|
|
}
|
|
}
|
|
|
|
internal extension RenderCommand {
|
|
|
|
var numVertices: Int {
|
|
Int(spine_render_command_get_num_vertices(wrappee))
|
|
}
|
|
|
|
func positions(numVertices: Int) -> [Float] {
|
|
let num = numVertices * 2
|
|
let ptr = spine_render_command_get_positions(wrappee)
|
|
guard let validPtr = ptr else { return [] }
|
|
let buffer = UnsafeBufferPointer(start: validPtr, count: num)
|
|
return Array(buffer)
|
|
}
|
|
|
|
func uvs(numVertices: Int) -> [Float] {
|
|
let num = numVertices * 2
|
|
let ptr = spine_render_command_get_uvs(wrappee)
|
|
guard let validPtr = ptr else { return [] }
|
|
let buffer = UnsafeBufferPointer(start: validPtr, count: num)
|
|
return Array(buffer)
|
|
}
|
|
|
|
func colors(numVertices: Int) ->[Int32] {
|
|
let num = numVertices
|
|
let ptr = spine_render_command_get_colors(wrappee)
|
|
guard let validPtr = ptr else { return [] }
|
|
let buffer = UnsafeBufferPointer(start: validPtr, count: num)
|
|
return Array(buffer)
|
|
}
|
|
}
|
|
|
|
public extension Skin {
|
|
|
|
/// Constructs a new empty ``Skin`` using the given `name`. Skins constructed this way must be manually disposed via the `dispose` method
|
|
/// if they are no longer used.
|
|
static func create(name: String) -> Skin {
|
|
return Skin(spine_skin_create(name))
|
|
}
|
|
}
|
|
|
|
// Helper
|
|
|
|
public extension CGRect {
|
|
|
|
/// Construct a `CGRect` from ``Bounds``
|
|
init(bounds: Bounds) {
|
|
self = CGRect(
|
|
x: CGFloat(bounds.x),
|
|
y: CGFloat(bounds.y),
|
|
width: CGFloat(bounds.width),
|
|
height: CGFloat(bounds.height)
|
|
)
|
|
}
|
|
}
|
|
|
|
internal enum FileSource {
|
|
case bundle(fileName: String, bundle: Bundle = .main)
|
|
case file(URL)
|
|
case http(URL)
|
|
|
|
internal func load() async throws -> Data {
|
|
switch self {
|
|
case .bundle(let fileName, let bundle):
|
|
let components = fileName.split(separator: ".")
|
|
guard components.count > 1, let ext = components.last else {
|
|
throw SpineError("Provide both file name and file extension")
|
|
}
|
|
let name = components.dropLast(1).joined(separator: ".")
|
|
|
|
guard let fileUrl = bundle.url(forResource: name, withExtension: String(ext)) else {
|
|
throw SpineError("Could not load file with name \(name) from bundle")
|
|
}
|
|
return try Data(contentsOf: fileUrl, options: [])
|
|
case .file(let fileUrl):
|
|
return try Data(contentsOf: fileUrl, options: [])
|
|
case .http(let url):
|
|
if #available(iOS 15.0, *) {
|
|
let (temp, response) = try await URLSession.shared.download(from: url)
|
|
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
|
|
throw URLError(.badServerResponse)
|
|
}
|
|
return try Data(contentsOf: temp, options: [])
|
|
} else {
|
|
let lock = NSRecursiveLock()
|
|
nonisolated(unsafe)
|
|
var isCancelled = false
|
|
nonisolated(unsafe)
|
|
var taskHolder:URLSessionDownloadTask? = nil
|
|
return try await withTaskCancellationHandler {
|
|
try await withCheckedThrowingContinuation { continuation in
|
|
let task = URLSession.shared.downloadTask(with: url) { temp, response, error in
|
|
if let error {
|
|
continuation.resume(throwing: error)
|
|
} else {
|
|
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
|
|
continuation.resume(throwing: URLError(.badServerResponse))
|
|
return
|
|
}
|
|
guard let temp else {
|
|
continuation.resume(throwing: SpineError("Could not download file."))
|
|
return
|
|
}
|
|
do {
|
|
continuation.resume(returning: try Data(contentsOf: temp, options: []))
|
|
} catch {
|
|
continuation.resume(throwing: error)
|
|
}
|
|
}
|
|
}
|
|
task.resume()
|
|
let shouldCancel = lock.withLock {
|
|
if !isCancelled {
|
|
taskHolder = task
|
|
}
|
|
return isCancelled
|
|
}
|
|
if shouldCancel {
|
|
task.cancel()
|
|
}
|
|
}
|
|
} onCancel: {
|
|
lock.withLock {
|
|
isCancelled = true
|
|
let value = taskHolder
|
|
taskHolder = nil
|
|
return value
|
|
}?.cancel()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public struct SpineError: Error, CustomStringConvertible {
|
|
|
|
public let description: String
|
|
|
|
internal init(_ description: String) {
|
|
self.description = description
|
|
}
|
|
|
|
}
|
|
|
|
public extension SkeletonBounds {
|
|
static func create() -> SkeletonBounds {
|
|
return SkeletonBounds(spine_skeleton_bounds_create())
|
|
}
|
|
}
|
|
|
|
@objc public extension Atlas {
|
|
|
|
var imagePathCount:Int32 {
|
|
spine_atlas_get_num_image_paths(wrappee)
|
|
}
|
|
|
|
}
|