How to load SKScene from a swift package?

I am willing to implement an iOS SpriteKit game that contains small games, each small game will be in separate swift packages.

I am using this initializer to load a scene from a sks file,
SKScene(fileNamed: "GameScene"), however, the docs say that the file must be in the main bundle, is there a way to load the scene from a module? something similar to what we have with colors
Color("colorName", bundle: .module)?

One solution would be to get the contents of the file as Data from whatever Bundle you put the scene in and then convert it to SKScene using NSKeyedUnarchiver

1 Like

Sounds plausible. Try this (untested):

extension SKNode {
    convenience init?(data: Data) {
        guard let archive = try? NSKeyedUnarchiver(forReadingFrom: data) else { return nil }
        self.init(coder: archive)
    }
    convenience init?(url: URL) {
        guard let data = try? Data(contentsOf: url) else { return nil }
        self.init(data: data)
    }
    convenience init?(_ name: String, bundle: Bundle) {
        guard let url = bundle.url(forResource: name, withExtension: nil) else { return nil }
        self.init(url: url)
    }
}

Thank you all!

I ended up using Tera's suggestion and edited a little bit to be closer to the default initializer

Unfortuenlly these workarounds won't work as expected, we still need to add our textures in the main bundle, what's happening is that SpriteKit is looking for textures inside the main bundle instead of the module, we can for sure re-set the texture from the bundle, but it doesn't makes sense.

How badly you want it? If the answer is "very" – you may swizzle +[SKTexture textureWithImageNamed:] and substitute it with a call that looks for the texture in your non-main bundle.

It's a missing feature and obviously many things could go wrong down that rabbit hole, so I can't recommend it.

What a SpriteKit can read from the bundle? just textures right?

Turns out that this solution will load the scene without any nodes :thinking:
The other solution provided by @iGabriel works, but textures should be in the main bundle

In my testing not loading properly has to do with init(coder:) and secure decoding. I'm afraid the only way to make it work is to unarchive it yourself. It will look uglier because of not using the init but it definitely works

Here's some working code:

static func load(_ name: String, bundle: Bundle) -> Self? {
    guard let url = bundle.url(forResource: name, withExtension: nil), let data = try? Data(contentsOf: url) else {
        return nil
    }
    
    do {
        let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data)
        unarchiver.requiresSecureCoding = false
        unarchiver.setClass(self.classForKeyedUnarchiver(), forClassName: "SKScene")
        
        let scene = unarchiver.decodeObject(forKey: NSKeyedArchiveRootObjectKey)
        unarchiver.finishDecoding()
        
        return scene as? Self
    } catch {
        return nil
    }
}
1 Like

Great. Furthermore, if you override the texture class you may adjust it within the initialiser – no need for swizzling:

        ...
        unarchiver.setClass(MyTexture.self, forClassName: "SKTexture")
        ...

class MyTexture: SKTexture {
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        let name = value(forKey: "subTextureName")
        print(name)
        // get image from a proper bundle
        // override texture
    }
}

Untested.

Thank you @iGabriel, This also working:

	static func fromBundle(fileName: String, bundle: Bundle) -> SKScene? {
		guard
			let path = bundle.path(forResource: fileName, ofType: "sks"),
			let data = FileManager.default.contents(atPath: path)
		else {
			return nil
		}
		do {
			return try NSKeyedUnarchiver.unarchivedObject(ofClass: SKScene.self, from: data)
		} catch {
			fatalError(error.localizedDescription)
		}
	}

But we still have a problem with textures, we may need to swizzle the function like @tera mentioned

That's interesting, will give it a shot right now

Seems like the name isn't kept in the SKTexture class :thinking:

Try this droid. That's undocumented land so ... here be dragons:

class MyTexture: SKTexture {
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        let name = value(forKey: "_imgName")
        print(name)
        // get image from a proper bundle
        // override texture
    }
}

Edit: "imgName" (no underscore prefix) works as well.

Thank you @tera and @iGabriel!

It's working (for now :eyes:), here is the code that I put into a shared swift package:

import SpriteKit

@objc(ModuleUnarchiver)
fileprivate final class ModuleUnarchiver: NSKeyedUnarchiver {
	let bundle: Bundle

	init(forReadingFrom data: Data, bundle: Bundle) throws {
		self.bundle = bundle

		try super.init(forReadingFrom: data)
	}
}

@objc(ModuleSpriteNode)
fileprivate final class ModuleSpriteNode: SKSpriteNode {
	required init?(coder: NSCoder) {
		super.init(coder: coder)
		
		if
			let moduleUnarchiver = coder as? ModuleUnarchiver,
			let texture = value(forKey: "texture") as? SKTexture,
			let name = texture.value(forKey: "imgName") as? String,
			let image = UIImage(named: name, in: moduleUnarchiver.bundle, with: nil)
		{
			self.texture = .init(image: image)
		}
	}
}

public extension SKScene {
	static func load<T>(_ fileName: String, bundle: Bundle) -> T? where T: SKScene {
		guard
			let path = bundle.path(forResource: fileName, ofType: "sks"),
			let data = FileManager.default.contents(atPath: path)
		else {
			return nil
		}
		do {
			let unarchiver = try ModuleUnarchiver(forReadingFrom: data, bundle: bundle)
			unarchiver.requiresSecureCoding = false
			unarchiver.setClass(ModuleSpriteNode.self, forClassName: "SKSpriteNode")
			unarchiver.setClass(self.classForKeyedUnarchiver(), forClassName: "SKScene")

			let scene = unarchiver.decodeObject(forKey: NSKeyedArchiveRootObjectKey)
			unarchiver.finishDecoding()

			return scene as? T
		} catch {
			fatalError(error.localizedDescription)
		}
	}
}

The only thing is that I keep seeing this error message

SKTexture: Error loading image resource: "dog"

And that's because we are calling super.init(coder: coder) for our ModuleSpriteNode, but not a big deal for now.

1 Like

Good.

To get rid of the warning you may try something like this:

class MyTexture: SKTexture {
    static var prohibitLoadImageData = false
    
    @objc override func loadImageData() {
        if !Self.prohibitLoadImageData {
            super.loadImageData()
        }
    }
}
...
            unarchiver.setClass(MyTexture.self, forClassName: "SKTexture")

            MyTexture.prohibitLoadImageData = true
            let scene = unarchiver.decodeObject(forKey: NSKeyedArchiveRootObjectKey)
            MyTexture.prohibitLoadImageData = false

Note that that would require using obj-c bridging header with :

@interface SKTexture (Extras)
- (void)loadImageData;
@end

untested.