├── .gitignore ├── .gitmodules ├── .swift-format ├── .swift-version ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Sources ├── Bonjour │ ├── Bonjour.swift │ ├── LocalNetworkDiscovery.swift │ ├── LocalNetworkListener.swift │ └── NetworkMonitor.swift ├── GameCenter │ ├── GKChallenge+Godot.swift │ ├── GKPlayer+Godot.swift │ ├── GameCenter+Achievements.swift │ ├── GameCenter+Challenges.swift │ ├── GameCenter+Friends.swift │ ├── GameCenter+Invites.swift │ ├── GameCenter+Leaderboards.swift │ ├── GameCenter.swift │ ├── GameCenterAchievement.swift │ ├── GameCenterAchievementChallenge.swift │ ├── GameCenterAchievementDescription.swift │ ├── GameCenterChallenge.swift │ ├── GameCenterInvite.swift │ ├── GameCenterLeaderboardEntry.swift │ ├── GameCenterMatchmakingProtocol.swift │ ├── GameCenterMultiplayerPeer+GameData.swift │ ├── GameCenterMultiplayerPeer+MatchmakingProtocol.swift │ ├── GameCenterMultiplayerPeer+PeerData.swift │ ├── GameCenterMultiplayerPeer.swift │ ├── GameCenterPlayer.swift │ ├── GameCenterPlayerLocal.swift │ ├── GameCenterScoreChallenge.swift │ └── GameCenterViewController.swift ├── Haptics │ └── Haptics.swift ├── InAppPurchase │ ├── IAPProduct.swift │ └── InAppPurchase.swift └── Settings │ ├── Settings+Observer.swift │ └── Settings.swift └── build.sh /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vscode/ 3 | .build/ 4 | .cache/ 5 | .swiftpm/ 6 | /bin/ -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "SwiftGodot"] 2 | path = SwiftGodot 3 | url = git@github.com:migueldeicaza/SwiftGodot.git 4 | -------------------------------------------------------------------------------- /.swift-format: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "fileScopedDeclarationPrivacy": { 4 | "accessLevel": "private" 5 | }, 6 | "indentation": { 7 | "tabs": 1 8 | }, 9 | "tabWidth": 4, 10 | "spacesAroundRangeFormationOperators": false, 11 | "indentConditionalCompilationBlocks": false, 12 | "indentSwitchCaseLabels": false, 13 | "lineBreakAroundMultilineExpressionChainComponents": false, 14 | "lineBreakBeforeControlFlowKeywords": false, 15 | "lineBreakBeforeEachArgument": true, 16 | "lineBreakBeforeEachGenericRequirement": false, 17 | "lineLength": 120, 18 | "maximumBlankLines": 1, 19 | "spacesBeforeEndOfLineComments": 1, 20 | "multiElementCollectionTrailingCommas": true, 21 | "noAssignmentInExpressions": { 22 | "allowedFunctions": [ 23 | "XCTAssertNoThrow" 24 | ] 25 | }, 26 | "prioritizeKeepingFunctionOutputTogether": false, 27 | "respectsExistingLineBreaks": true, 28 | "rules": { 29 | "AllPublicDeclarationsHaveDocumentation": false, 30 | "AlwaysUseLiteralForEmptyCollectionInit": false, 31 | "AlwaysUseLowerCamelCase": true, 32 | "AmbiguousTrailingClosureOverload": true, 33 | "BeginDocumentationCommentWithOneLineSummary": false, 34 | "DoNotUseSemicolons": true, 35 | "DontRepeatTypeInStaticProperties": true, 36 | "FileScopedDeclarationPrivacy": true, 37 | "FullyIndirectEnum": true, 38 | "GroupNumericLiterals": true, 39 | "IdentifiersMustBeASCII": true, 40 | "NeverForceUnwrap": false, 41 | "NeverUseForceTry": false, 42 | "NeverUseImplicitlyUnwrappedOptionals": false, 43 | "NoAccessLevelOnExtensionDeclaration": true, 44 | "NoAssignmentInExpressions": true, 45 | "NoBlockComments": true, 46 | "NoCasesWithOnlyFallthrough": true, 47 | "NoEmptyTrailingClosureParentheses": true, 48 | "NoLabelsInCasePatterns": true, 49 | "NoLeadingUnderscores": false, 50 | "NoParensAroundConditions": true, 51 | "NoPlaygroundLiterals": true, 52 | "NoVoidReturnOnFunctionSignature": true, 53 | "OmitExplicitReturns": false, 54 | "OneCasePerLine": true, 55 | "OneVariableDeclarationPerLine": true, 56 | "OnlyOneTrailingClosureArgument": true, 57 | "OrderedImports": true, 58 | "ReplaceForEachWithForLoop": true, 59 | "ReturnVoidInsteadOfEmptyTuple": true, 60 | "TypeNamesShouldBeCapitalized": true, 61 | "UseEarlyExits": false, 62 | "UseExplicitNilCheckInConditions": true, 63 | "UseLetInEveryBoundCaseVariable": true, 64 | "UseShorthandTypeNames": true, 65 | "UseSingleLinePropertyGetter": true, 66 | "UseSynthesizedInitializer": true, 67 | "UseTripleSlashForDocumentationComments": true, 68 | "UseWhereClausesInForLoops": false, 69 | "ValidateDocumentationComments": false 70 | } 71 | } -------------------------------------------------------------------------------- /.swift-version: -------------------------------------------------------------------------------- 1 | 6.1.1 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 rktprof 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "1da8474ca876ac16e8aff124b44912d25b22490643d9d37089a24326109a49f3", 3 | "pins" : [ 4 | { 5 | "identity" : "swift-argument-parser", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/apple/swift-argument-parser", 8 | "state" : { 9 | "revision" : "41982a3656a71c768319979febd796c6fd111d5c", 10 | "version" : "1.5.0" 11 | } 12 | }, 13 | { 14 | "identity" : "swift-syntax", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/swiftlang/swift-syntax", 17 | "state" : { 18 | "revision" : "0687f71944021d616d34d922343dcef086855920", 19 | "version" : "600.0.1" 20 | } 21 | } 22 | ], 23 | "version" : 3 24 | } 25 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | import PackageDescription 4 | 5 | var swiftSettings: [SwiftSetting] = [ 6 | .unsafeFlags(["-suppress-warnings"]), 7 | .swiftLanguageMode(.v5), 8 | ] 9 | 10 | let package = Package( 11 | name: "iOS Plugins", 12 | platforms: [ 13 | .iOS(.v17), 14 | .macOS(.v14), 15 | ], 16 | 17 | // MARK: Products 18 | products: [ 19 | .library( 20 | name: "Bonjour", 21 | type: .dynamic, 22 | targets: ["Bonjour"] 23 | ), 24 | .library( 25 | name: "GameCenter", 26 | type: .dynamic, 27 | targets: ["GameCenter"] 28 | ), 29 | .library( 30 | name: "Haptics", 31 | type: .dynamic, 32 | targets: ["Haptics"] 33 | ), 34 | .library( 35 | name: "InAppPurchase", 36 | type: .dynamic, 37 | targets: ["InAppPurchase"] 38 | ), 39 | .library( 40 | name: "Settings", 41 | type: .dynamic, 42 | targets: ["Settings"] 43 | ), 44 | ], 45 | 46 | // MARK: Dependencies 47 | dependencies: [ 48 | .package(name: "SwiftGodot", path: "SwiftGodot") 49 | ], 50 | 51 | // MARK: Targets 52 | targets: [ 53 | .target( 54 | name: "Bonjour", 55 | dependencies: ["SwiftGodot"], 56 | swiftSettings: swiftSettings 57 | ), 58 | .target( 59 | name: "GameCenter", 60 | dependencies: ["SwiftGodot"], 61 | swiftSettings: swiftSettings 62 | ), 63 | .target( 64 | name: "Haptics", 65 | dependencies: ["SwiftGodot"], 66 | swiftSettings: swiftSettings 67 | ), 68 | .target( 69 | name: "InAppPurchase", 70 | dependencies: ["SwiftGodot"], 71 | swiftSettings: swiftSettings 72 | ), 73 | .target( 74 | name: "Settings", 75 | dependencies: ["SwiftGodot"], 76 | swiftSettings: swiftSettings 77 | ), 78 | ] 79 | ) 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Some swift based extensions for iOS/macOS functionality, built on [SwiftGodot](https://github.com/migueldeicaza/SwiftGodot). 2 | 3 | Everything is provided as-is, I've tried to keep things simple and readable to make up for the (current) lack of documentation. 4 | 5 | ## Usage 6 | 7 | The `build.sh` script will build everything for iOS and macOS in release configuration and copy the libraries to the /Bin/ folder, you can override this with some parameters 8 | - `./build.sh ios/macos/all release/debug` 9 | 10 | Then create your .gdextension file, it would look something like: 11 | ``` 12 | [configuration] 13 | entry_symbol = "swift_entry_point" 14 | compatibility_minimum = 4.2 15 | 16 | [libraries] 17 | macos.debug = "res://addons/macos/libGameCenter.dylib" 18 | macos.release = "res://addons/macos/libGameCenter.dylib" 19 | ios.debug = "res://addons/ios/GameCenter.framework" 20 | ios.release = "res://addons/ios/GameCenter.framework" 21 | 22 | [dependencies] 23 | macos.debug = {"res://addons/macos/libSwiftGodot.dylib" : ""} 24 | macos.release = {"res://addons/macos/libSwiftGodot.dylib" : ""} 25 | ios.debug = {"res://addons/ios/SwiftGodot.framework" : ""} 26 | ios.release = {"res://addons/ios/SwiftGodot.framework" : ""} 27 | ``` 28 | 29 | In order to use the plugin in your godot project you have to do some workarounds because of how GDScript works. For example: while you can create an instance of the GameCenter class directly you can't limit it to a specific platform. This means that if you publish on iOS and Android you need to build the GameCenter plugin for Android, which doesn't make sense. And if you develop on both macOS and Windows you have to build the plugins for Windows as well. 30 | 31 | To get around this you have to use Variants like so: 32 | ```gdscript 33 | var _game_center: Variant = null 34 | 35 | func _init() -> void: 36 | if _game_center == null && ClassDB.class_exists("GameCenter"): 37 | _game_center = ClassDB.instantiate("GameCenter") 38 | ``` 39 | Which means you will not get any code completion or help at all. 40 | 41 | This also means that you can only get Variants back from the plugin, which means you need to know what type is returned for any specific function, which you find in the swift classes themselves. 42 | 43 | For example, in order to get GameCenter friends you do something like this 44 | ```gdscript 45 | func get_friends(on_complete: Callable) -> void: 46 | _game_center.load_friends(func(error: Variant, data: Variant) -> void: 47 | var friends: Array[Friend] = [] 48 | if error != OK: 49 | on_complete.call(friends) 50 | return 51 | 52 | for entry: Variant in data: 53 | var friend: Friend = Friend.new() 54 | friend.alias = entry.alias 55 | friend.display_name = entry.displayName 56 | friend.game_player_id = entry.gamePlayerID 57 | friend.team_player_id = entry.teamPlayerID 58 | friend.is_invitable = entry.isInvitable 59 | friends.append(friend) 60 | 61 | on_complete.call(friends) 62 | ``` 63 | 64 | Fortunately, this pattern works for most of your interaction with the plugins. 65 | 66 | **IMPORTANT NOTE:** Remember that you need to specify the correct number of arguments or Godot will just fail silently, this is why callbacks from swift has to look like this: 67 | ```swift 68 | onComplete.callDeferred(Variant(LeaderboardError.failedToLoadEntries.rawValue), nil, nil, Variant(0)) 69 | ``` 70 | 71 | ## Currently supports: 72 | 73 | **GameCenter** 74 | - Authentication 75 | - Leaderboards 76 | - Post score 77 | - Open overlay 78 | - Get leaderboard data 79 | - Friends 80 | - Get list of friends 81 | - Open Friends overlay 82 | - Friend invites 83 | - Achievements 84 | - Reward achievements 85 | - Open overlay 86 | - Get achievement data 87 | - Matchmaking 88 | - Challenges 89 | 90 | **Bonjour** 91 | - LAN discovery 92 | - Listener 93 | - Browser (find active listeners) 94 | - Endpoint resolution 95 | 96 | **InAppPurchase** 97 | - Get list of products 98 | - Purchase product 99 | 100 | **Device** 101 | - Taptic Engine (Simple taps & extended vibrations) 102 | 103 | **OS** 104 | - Settings.bundle support (Read and write from app settings under system settings) 105 | 106 | ## TODO: (assuming I can figure it out) 107 | 108 | Basic plugin documentation (for now, feel free to create an issue with a question) 109 | 110 | **GameCenter** 111 | - macOS Support (should mostly work but opening overlays doesn't) 112 | 113 | **Bonjour** 114 | - Bluetooth discovery 115 | 116 | **iCloud** 117 | - Cloud saves 118 | 119 | **Device** 120 | - Gyroscope 121 | - Taptic Engine (Play vibration from sound or external file) 122 | -------------------------------------------------------------------------------- /Sources/Bonjour/Bonjour.swift: -------------------------------------------------------------------------------- 1 | import SwiftGodot 2 | 3 | #initSwiftExtension( 4 | cdecl: "swift_entry_point", 5 | types: [ 6 | LocalNetworkListener.self, 7 | LocalNetworkDiscovery.self, 8 | NetworkMonitor.self, 9 | ] 10 | ) 11 | -------------------------------------------------------------------------------- /Sources/Bonjour/LocalNetworkDiscovery.swift: -------------------------------------------------------------------------------- 1 | import Network 2 | import SwiftGodot 3 | 4 | let OK: Int = 0 5 | 6 | @Godot 7 | class LocalNetworkDiscovery: RefCounted { 8 | /// Signal that triggers when a device is discovered 9 | /// 10 | /// > NOTE: If you need the address of the device you can use `resolve_endpoint` with the hash_value 11 | @Signal var deviceDiscovered: SignalWithArguments 12 | /// Signal that triggers when a device is lost 13 | @Signal var deviceLost: SignalWithArguments 14 | /// Signal that triggers when a device is updated 15 | @Signal var deviceUpdated: SignalWithArguments 16 | /// Signal that triggers when the local network permission is known 17 | @Signal var permissionDenied: SimpleSignal 18 | 19 | enum LocalNetworkStatus: Int { 20 | case permissionGranted = 0 21 | case permissionDenied = 1 22 | case error = 2 23 | } 24 | 25 | enum NetworkDiscoveryError: Int, Error { 26 | case failedToResolveEndpoint = 1 27 | case incompatibleIPV6Address = 2 28 | } 29 | 30 | var browser: NWBrowser? = nil 31 | var connection: NWConnection? = nil 32 | var discoveredDevices: [Int: (NWBrowser.Result)] = [:] 33 | 34 | deinit { 35 | stop() 36 | } 37 | 38 | /// Start looking for Bonjour devices on the local network. 39 | /// 40 | /// - Parameter: 41 | /// - typeDescriptor: A service descriptor used to discover a Bonjour service. 42 | @Callable 43 | func start(typeDescriptor: String) { 44 | DispatchQueue.main.async { 45 | let descriptor: NWBrowser.Descriptor = NWBrowser.Descriptor.bonjourWithTXTRecord( 46 | type: typeDescriptor, 47 | domain: "local." 48 | ) 49 | let browser: NWBrowser = NWBrowser(for: descriptor, using: .tcp) 50 | browser.stateUpdateHandler = self.stateChanged 51 | browser.browseResultsChangedHandler = self.resultsChanged 52 | 53 | browser.start(queue: DispatchQueue.global(qos: .userInitiated)) 54 | self.browser = browser 55 | } 56 | } 57 | 58 | /// Stop looking for Bonjour devices 59 | @Callable 60 | func stop() { 61 | browser?.cancel() 62 | } 63 | 64 | // MARK: Internal 65 | 66 | /// Resolve a Bonjour endpoint into an ip address and port. 67 | /// 68 | /// - Parameters: 69 | /// - hashValue: The hash value for the discovered bonjour service. 70 | /// - onComplete: Callback with parameter: (error: Variant, address: Variant, port: Variant) -> (error: Int, address: String, port: Int) 71 | @Callable(autoSnakeCase: true) 72 | func resolveEndpoint(hashValue: Int, onComplete: Callable) { 73 | DispatchQueue.main.async { 74 | // This whole thing is unfortunately necessary since you can't resolve an endpoint to host:port 75 | if let result: NWBrowser.Result = self.discoveredDevices[hashValue] { 76 | GD.print("Resolving endpoint \(result.endpoint)...") 77 | let endpoint: NWEndpoint = result.endpoint 78 | var port: Int = 0 79 | switch result.metadata { 80 | case .bonjour(let record): 81 | port = Int(record.dictionary["port"] ?? "0") ?? 0 82 | default: 83 | break 84 | } 85 | 86 | let networkParams: NWParameters = NWParameters.tcp 87 | networkParams.prohibitedInterfaceTypes = [.loopback] 88 | networkParams.serviceClass = NWParameters.ServiceClass.responsiveData 89 | 90 | let ip: NWProtocolIP.Options = 91 | networkParams.defaultProtocolStack.internetProtocol! as! NWProtocolIP.Options 92 | ip.version = .v4 93 | 94 | self.connection = NWConnection(to: endpoint, using: networkParams) 95 | self.connection?.stateUpdateHandler = { state in 96 | GD.print("Resolver state: \(state)") 97 | switch state { 98 | case .ready: 99 | if let innerEndpoint: NWEndpoint = self.connection?.currentPath?.remoteEndpoint, 100 | case .hostPort(let host, let tempPort) = innerEndpoint 101 | { 102 | self.connection?.cancel() 103 | 104 | var address_string: String = "" 105 | switch host { 106 | case .ipv4(let address): 107 | address_string = self.ipAddressToString(address) 108 | break 109 | case .ipv6(let address): 110 | if let ipv4Address = address.asIPv4 { 111 | address_string = self.ipAddressToString(ipv4Address) 112 | } else { 113 | GD.pushError("[Bonjour] Failed to resolve endpoint: Got incompatible IPv6 address") 114 | onComplete.callDeferred( 115 | Variant(NetworkDiscoveryError.incompatibleIPV6Address.rawValue), 116 | nil, 117 | nil 118 | ) 119 | return 120 | } 121 | default: 122 | break 123 | } 124 | 125 | onComplete.callDeferred(Variant(OK), Variant(address_string), Variant(port)) 126 | } 127 | default: 128 | break 129 | } 130 | } 131 | self.connection?.start(queue: DispatchQueue.global(qos: .userInteractive)) 132 | } else { 133 | GD.pushError("[Bonjour] Failed to resolve endpoint. Error: No endpoint corresponding to: \(hashValue)") 134 | onComplete.callDeferred( 135 | Variant(NetworkDiscoveryError.failedToResolveEndpoint.rawValue), 136 | nil, 137 | nil 138 | ) 139 | } 140 | } 141 | } 142 | 143 | func stateChanged(to newState: NWBrowser.State) { 144 | switch newState { 145 | case .failed(let error): 146 | GD.pushError("[Bonjour] LocalNetworkDiscovery failed: \(error)") 147 | case let .waiting(error): 148 | self.permissionDenied.emit() 149 | case .cancelled: 150 | self.browser = nil 151 | default: 152 | break 153 | } 154 | } 155 | 156 | func resultsChanged(updated: Set, changes: Set) { 157 | DispatchQueue.main.async { 158 | for change: NWBrowser.Result.Change in changes { 159 | switch change 160 | { 161 | case .added(let result): 162 | var serverPort: Int = 0 163 | 164 | switch result.metadata 165 | { 166 | case .bonjour(let record): 167 | serverPort = Int(record.dictionary["port"] ?? "0") ?? 0 168 | default: 169 | break 170 | } 171 | 172 | switch result.endpoint 173 | { 174 | case .service(let service): 175 | self.deviceDiscovered.emit(service.name, serverPort, result.hashValue) 176 | default: 177 | break 178 | } 179 | 180 | self.discoveredDevices[result.hashValue] = result 181 | 182 | case .removed(let result): 183 | self.discoveredDevices.removeValue(forKey: result.hashValue) 184 | 185 | switch result.endpoint 186 | { 187 | case .service(let service): 188 | self.deviceLost.emit(service.name, result.hashValue) 189 | default: 190 | break 191 | } 192 | 193 | case .changed(let old, let new, flags: _): 194 | var serverPort: Int = 0 195 | switch new.metadata 196 | { 197 | case .bonjour(let record): 198 | serverPort = Int(record.dictionary["port"] ?? "0") ?? 0 199 | default: 200 | break 201 | } 202 | 203 | switch new.endpoint 204 | { 205 | case .service(let service): 206 | self.deviceUpdated.emit(service.name, serverPort, old.hashValue, new.hashValue) 207 | default: 208 | break 209 | } 210 | 211 | self.discoveredDevices.removeValue(forKey: old.hashValue) 212 | self.discoveredDevices[new.hashValue] = new 213 | case .identical: 214 | break 215 | } 216 | } 217 | } 218 | } 219 | 220 | func ipAddressToString(_ address: IPv4Address) -> String { 221 | return String("\(address.rawValue[0]).\(address.rawValue[1]).\(address.rawValue[2]).\(address.rawValue[3])") 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /Sources/Bonjour/LocalNetworkListener.swift: -------------------------------------------------------------------------------- 1 | import Network 2 | import SwiftGodot 3 | 4 | @Godot 5 | class LocalNetworkListener: RefCounted { 6 | /// Signal that triggers when the local network permission is known 7 | /// NOTE: Does NOT work the same as for the browser, use the NetworkMonitor instead 8 | @Signal var permissionDenied: SimpleSignal 9 | 10 | @Signal var endpointAdded: SignalWithArguments 11 | @Signal var endpointRemoved: SignalWithArguments 12 | 13 | enum InterfaceType: Int { 14 | case wifi = 0 15 | case cellular = 1 16 | case wiredEthernet = 2 17 | case loopback = 3 18 | case other = 4 19 | } 20 | 21 | static let DEFAULT_PORT: Int = 64201 22 | var listener: NWListener? = nil 23 | 24 | deinit { 25 | stop() 26 | } 27 | 28 | /// Start listening for incoming network connections. 29 | /// 30 | /// - Parameter: 31 | /// - typeDescriptor: A service descriptor used to identify a Bonjour service. 32 | /// - name: The name that will show up when this device is discovered. 33 | /// - port: The port that will accept connections. 34 | /// - broadcastPort: The port that will be used to listen for connections (Default: 64201) 35 | @Callable 36 | func start(typeDescriptor: String, name: String, port: Int, broadcastPort: Int = DEFAULT_PORT) { 37 | do { 38 | let broadcast_port: NWEndpoint.Port? = NWEndpoint.Port(rawValue: UInt16(broadcastPort)) 39 | let listener: NWListener = try NWListener(using: .tcp, on: broadcast_port!) 40 | listener.service = .init(name: name, type: typeDescriptor, txtRecord: NWTXTRecord(["port": String(port)])) 41 | 42 | listener.stateUpdateHandler = self.stateChanged 43 | listener.newConnectionHandler = self.newConnection 44 | listener.serviceRegistrationUpdateHandler = self.serviceRegistrationChange 45 | 46 | listener.start(queue: DispatchQueue.global(qos: .userInitiated)) 47 | 48 | self.listener = listener 49 | } catch { 50 | GD.pushError("[Bonjour] Failed to start LocalNetworkListener: \(error)") 51 | } 52 | } 53 | 54 | /// Stop listening for incoming network connections. 55 | @Callable 56 | func stop() { 57 | self.listener?.cancel() 58 | } 59 | 60 | // MARK: Internal 61 | 62 | func stateChanged(to newState: NWListener.State) { 63 | // NOTE: The state is changed to ready even if there are no local network permissions 64 | switch newState { 65 | case .failed(let error): 66 | GD.pushError("[Bonjour] Listener failed. Error: \(error)") 67 | case let .waiting(error): 68 | // This does not seem to trigger, need a better solution 69 | GD.pushError("[Bonjour] Listener waiting: \(error)") 70 | self.permissionDenied.emit() 71 | case .cancelled: 72 | self.listener = nil 73 | default: 74 | break 75 | } 76 | } 77 | 78 | func serviceRegistrationChange(change: NWListener.ServiceRegistrationChange) { 79 | switch change { 80 | case .add(let endpoint): 81 | if let interface = endpoint.interface { 82 | var type: InterfaceType = .other 83 | switch interface.type { 84 | case .wifi: type = .wifi 85 | case .cellular: type = .cellular 86 | case .wiredEthernet: type = .wiredEthernet 87 | case .loopback: type = .loopback 88 | case .other: type = .other 89 | } 90 | self.endpointAdded.emit(interface.name, type.rawValue, interface.index) 91 | } else { 92 | self.endpointAdded.emit("unknown", InterfaceType.other.rawValue, 0) 93 | } 94 | 95 | case .remove(let endpoint): 96 | if let interface = endpoint.interface { 97 | var type: InterfaceType = .other 98 | switch interface.type { 99 | case .wifi: type = .wifi 100 | case .cellular: type = .cellular 101 | case .wiredEthernet: type = .wiredEthernet 102 | case .loopback: type = .loopback 103 | case .other: type = .other 104 | } 105 | self.endpointRemoved.emit(interface.name, type.rawValue, interface.index) 106 | } else { 107 | self.endpointRemoved.emit("unknown", InterfaceType.other.rawValue, 0) 108 | } 109 | } 110 | } 111 | 112 | func newConnection(connection: NWConnection) { 113 | // We need to start the connection here to allow client to resolve the endpoint 114 | // Client closes the connection as soon as they are done 115 | connection.start(queue: DispatchQueue.global(qos: .background)) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /Sources/Bonjour/NetworkMonitor.swift: -------------------------------------------------------------------------------- 1 | import Network 2 | import SwiftGodot 3 | 4 | @Godot 5 | class NetworkMonitor: RefCounted { 6 | /// Signal that triggers when the network path changes 7 | /// 8 | /// NOTE: This does not take into account Local Network permissions 9 | @Signal var networkStatusUpdated: SignalWithArguments 10 | 11 | enum NetworkStatus: Int { 12 | case available = 0 13 | case unavailable = 1 14 | case unknown = 2 15 | } 16 | 17 | enum NetworkPermission: Int { 18 | case allowed = 0 19 | case denied = 1 20 | case unknown = 2 21 | } 22 | 23 | var monitor: NWPathMonitor? = nil 24 | var connectionStatus: NetworkStatus = .unknown 25 | var permissionStatus: NetworkPermission = .unknown 26 | 27 | deinit { 28 | stop() 29 | } 30 | 31 | @Callable 32 | func start() { 33 | let monitor = NWPathMonitor() 34 | monitor.pathUpdateHandler = { path in 35 | switch path.status { 36 | case .satisfied: 37 | self.connectionStatus = NetworkStatus.available 38 | case .unsatisfied: 39 | self.connectionStatus = NetworkStatus.unavailable 40 | case .requiresConnection: 41 | self.connectionStatus = NetworkStatus.unknown 42 | } 43 | 44 | self.networkStatusUpdated.emit(self.connectionStatus.rawValue) 45 | } 46 | 47 | monitor.start(queue: DispatchQueue(label: "Monitor")) 48 | 49 | self.monitor = monitor 50 | } 51 | 52 | @Callable 53 | func stop() { 54 | monitor?.cancel() 55 | } 56 | 57 | @Callable(autoSnakeCase: true) 58 | func getCurrentStatus() -> Int { 59 | return connectionStatus.rawValue 60 | } 61 | 62 | @Callable(autoSnakeCase: true) 63 | func testLocalNetworkPermission(onComplete: Callable) { 64 | // NOTE: This is not a great solution, but until NWListener reports status correctly it's the only way 65 | // that I could think of. 66 | Task { 67 | do { 68 | let descriptor: NWBrowser.Descriptor = NWBrowser.Descriptor.bonjourWithTXTRecord( 69 | type: "_bonjour._tcp", 70 | domain: "local." 71 | ) 72 | 73 | let browser: NWBrowser = NWBrowser(for: descriptor, using: .tcp) 74 | self.permissionStatus = .allowed 75 | 76 | browser.stateUpdateHandler = { newState in 77 | GD.print("[Bonjour] LocalNetwork permission test status: \(newState)") 78 | switch newState { 79 | case let .waiting(error): 80 | if error.errorCode == -65570 { 81 | self.permissionStatus = .denied 82 | browser.cancel() 83 | } 84 | case .cancelled: 85 | switch self.permissionStatus { 86 | case .allowed: onComplete.callDeferred(Variant(true)) 87 | case .denied: onComplete.callDeferred(Variant(false)) 88 | case .unknown: onComplete.callDeferred(Variant(true)) 89 | } 90 | default: 91 | // NOTE: Can't use .ready here since it sometimes triggers before .waiting 92 | break 93 | } 94 | } 95 | 96 | browser.start(queue: DispatchQueue.global(qos: .userInitiated)) 97 | try await Task.sleep(nanoseconds: UInt64(0.5 * Double(NSEC_PER_SEC))) 98 | browser.cancel() 99 | } catch { 100 | GD.pushError("[Bonjour] Failed to test Local Network permission: \(error)") 101 | onComplete.callDeferred(Variant(false)) 102 | } 103 | 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /Sources/GameCenter/GKChallenge+Godot.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import GameKit 3 | 4 | extension GKChallenge { 5 | 6 | /// Get an integer ID of the challenge 7 | /// 8 | /// > NOTE: This is used in order to let Godot identify a challenge when declining 9 | /// 10 | /// - Returns: An integer generated from the issueDate 11 | func getChallengeID() -> Int { 12 | // Not an ideal solution, but we need an ID to identify challenges 13 | // might want to look into playerID + issue date 14 | return Int(self.issueDate.timeIntervalSince1970) 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /Sources/GameCenter/GKPlayer+Godot.swift: -------------------------------------------------------------------------------- 1 | // Extension to convert UIImage & NSImage to the Godot friendly Image type 2 | 3 | import GameKit 4 | import SwiftGodot 5 | 6 | #if canImport(UIKit) 7 | import UIKit 8 | #endif 9 | 10 | #if canImport(AppKit) 11 | import AppKit 12 | #endif 13 | 14 | extension GKPlayer { 15 | enum ImageConversionError: Error { 16 | case unsupportedPlatform 17 | case failedToGetImageData 18 | } 19 | 20 | func loadImage(size: GKPlayer.PhotoSize) async throws -> Image { 21 | #if canImport(UIKit) 22 | let photo: UIImage = try await self.loadPhoto(for: size) 23 | let picture = try convertImage(photo) 24 | return picture 25 | #elseif canImport(AppKit) 26 | let photo: NSImage = try await self.loadPhoto(for: size) 27 | let picture = try convertImage(photo) 28 | return picture 29 | #else 30 | throw ImageConversionError.unsupportedPlatform 31 | #endif 32 | } 33 | 34 | #if canImport(AppKit) 35 | func convertImage(_ image: NSImage) throws -> Image { 36 | guard let tiffRepresentation = image.tiffRepresentation else { 37 | throw ImageConversionError.failedToGetImageData 38 | } 39 | 40 | guard 41 | let pngData = NSBitmapImageRep(data: tiffRepresentation)?.representation( 42 | using: .png, 43 | properties: [:] 44 | ) 45 | else { 46 | throw ImageConversionError.failedToGetImageData 47 | } 48 | 49 | let image: Image = Image() 50 | image.loadPngFromBuffer(PackedByteArray([UInt8](pngData))) 51 | return image 52 | } 53 | #endif 54 | 55 | #if canImport(UIKit) 56 | func convertImage(_ image: UIImage) throws -> Image { 57 | guard let pngData: Data = image.pngData() else { 58 | throw ImageConversionError.failedToGetImageData 59 | } 60 | 61 | let image: Image = Image() 62 | image.loadPngFromBuffer(PackedByteArray([UInt8](pngData))) 63 | return image 64 | } 65 | #endif 66 | } 67 | -------------------------------------------------------------------------------- /Sources/GameCenter/GameCenter+Achievements.swift: -------------------------------------------------------------------------------- 1 | import GameKit 2 | import SwiftGodot 3 | 4 | extension GameCenter { 5 | 6 | enum AchievementError: Int, Error { 7 | case failedToLoadAchievement = 1 8 | case failedToLoadAchievementDescription = 2 9 | case failedToReset = 3 10 | 11 | case achievementNotFound = 4 12 | 13 | case failedToSetProgress = 5 14 | case failedToReportProgress = 6 15 | } 16 | 17 | /// Set Achievement progress. 18 | /// 19 | /// > NOTE: This updates the progress locally, you need to report the progress with reportAchievementProgress 20 | /// in order to update on the server 21 | /// 22 | /// - Parameters: 23 | /// - achievementID: The identifier for the achievement that you enter in App Store Connect. 24 | /// - percentComplete: A percentage value that states how far the player has progressed on the achievement. (0.0 - 100.0) 25 | /// - onComplete: Callback with parameter: (error: Variant) -> (error: Int) 26 | func setAchievementProgress(achievementID: String, percentComplete: Float, onComplete: Callable) { 27 | Task { 28 | do { 29 | if self.achievements == nil { 30 | try await updateAchievements() 31 | } 32 | 33 | if var achievement = self.achievements?.first(where: { $0.identifier == achievementID }) { 34 | achievement.percentComplete = Double(percentComplete) 35 | } else { 36 | var achievement = GKAchievement(identifier: achievementID) 37 | achievement.percentComplete = Double(percentComplete) 38 | 39 | if self.achievements == nil { 40 | self.achievements = [] 41 | } 42 | 43 | self.achievements?.append(achievement) 44 | } 45 | 46 | onComplete.callDeferred(Variant(OK)) 47 | } catch { 48 | GD.pushError("Failed to set achievement progress: \(error)") 49 | onComplete.callDeferred(Variant(AchievementError.failedToSetProgress.rawValue)) 50 | } 51 | } 52 | } 53 | 54 | /// Reports the player’s progress toward one or more achievements. 55 | /// 56 | /// - Parameters: 57 | /// - onComplete: Callback with parameter: (error: Variant) -> (error: Int) 58 | func reportAchievementProgress(onComplete: Callable) { 59 | Task { 60 | do { 61 | if !GKLocalPlayer.local.isAuthenticated { 62 | throw GKError(.notAuthenticated) 63 | } 64 | 65 | if let achievements = self.achievements { 66 | try await GKAchievement.report(achievements) 67 | } 68 | onComplete.callDeferred(Variant(OK)) 69 | } catch { 70 | GD.pushError("Failed to report achievement progress: \(error)") 71 | onComplete.callDeferred(Variant(AchievementError.failedToReportProgress.rawValue)) 72 | } 73 | } 74 | } 75 | 76 | /// Get achievement. 77 | /// 78 | /// - Parameters: 79 | /// - achievementID: The identifier for the achievement that you enter in App Store Connect. 80 | /// - onComplete: Callback with parameter: (error: Variant, achievement: Variant) -> (error: Int, achievement: ``GameCenterAchievement``) 81 | func getAchievement(achievementID: String, onComplete: Callable) { 82 | Task { 83 | do { 84 | if self.achievements == nil { 85 | try await updateAchievements() 86 | } 87 | 88 | if let achievement = self.achievements?.first(where: { $0.identifier == achievementID }) { 89 | onComplete.callDeferred(Variant(OK), Variant(GameCenterAchievement(achievement))) 90 | } else { 91 | throw AchievementError.achievementNotFound 92 | } 93 | 94 | } catch { 95 | GD.pushError("Failed to get achievement: \(error)") 96 | onComplete.callDeferred(Variant(AchievementError.failedToLoadAchievement.rawValue), nil) 97 | } 98 | } 99 | } 100 | 101 | /// Get achievement description. 102 | /// 103 | /// - Parameters: 104 | /// - achievementID: The identifier for the achievement that you enter in App Store Connect. 105 | /// - onComplete: Callback with parameter: (error: Variant, achievementDescription: Variant) -> (error: Int, achievementDescription: ``GameCenterAchievementDescription``) 106 | func getAchievementDescription(achievementID: String, onComplete: Callable) { 107 | Task { 108 | do { 109 | if self.achievementDescriptions == nil { 110 | try await updateAchievementDescriptions() 111 | } 112 | 113 | if let description = self.achievementDescriptions?.first(where: { $0.identifier == achievementID }) { 114 | onComplete.callDeferred(Variant(OK), Variant(GameCenterAchievementDescription(description))) 115 | } else { 116 | throw AchievementError.achievementNotFound 117 | } 118 | 119 | } catch { 120 | GD.pushError("Failed to get achievement description: \(error)") 121 | onComplete.callDeferred(Variant(AchievementError.failedToLoadAchievement.rawValue), nil) 122 | } 123 | } 124 | } 125 | 126 | /// Loads the achievements that you previously reported the player making progress toward. 127 | /// 128 | /// - Parameters: 129 | /// - onComplete: Callback with parameters: (error: Variant, achievements: Variant) -> (error: Int, achievements: [``GameCenterAchievement``]) 130 | func getAchievements(onComplete: Callable) { 131 | Task { 132 | do { 133 | if achievements == nil { 134 | try await updateAchievements() 135 | } 136 | 137 | var result = VariantArray() 138 | for entry: GKAchievement in self.achievements ?? [] { 139 | var achievement: GameCenterAchievement = GameCenterAchievement(entry) 140 | result.append(Variant(achievement)) 141 | } 142 | 143 | onComplete.callDeferred(Variant(OK), Variant(result)) 144 | } 145 | } 146 | } 147 | 148 | /// Downloads the localized descriptions of achievements from Game Center 149 | /// 150 | /// - Parameters: 151 | /// - onComplete: Callback with parameters: (error: Variant, achievements: Variant) -> (error: Int, achievements: [``GameCenterAchievementDescription``]) 152 | func getAchievementDescriptions(onComplete: Callable) { 153 | Task { 154 | do { 155 | if self.achievementDescriptions == nil { 156 | try await updateAchievementDescriptions() 157 | } 158 | 159 | var result = VariantArray() 160 | for entry: GKAchievementDescription in self.achievementDescriptions ?? [] { 161 | var achievement: GameCenterAchievementDescription = GameCenterAchievementDescription(entry) 162 | result.append(Variant(achievement)) 163 | } 164 | 165 | onComplete.callDeferred(Variant(OK), Variant(result)) 166 | } catch { 167 | GD.pushError("Failed to get achievements: \(error)") 168 | onComplete.callDeferred( 169 | Variant(AchievementError.failedToLoadAchievementDescription.rawValue), 170 | nil 171 | ) 172 | } 173 | } 174 | } 175 | 176 | /// Resets the percentage completed for all of the player’s achievements. 177 | /// 178 | /// - Parameters: 179 | /// - onComplete: Callback with parameters: (error: Variant) -> (error: Int) 180 | func resetAchievements(onComplete: Callable) { 181 | Task { 182 | do { 183 | try await GKAchievement.resetAchievements() 184 | onComplete.callDeferred(Variant(OK)) 185 | } catch { 186 | onComplete.callDeferred(Variant(AchievementError.failedToReset.rawValue)) 187 | } 188 | } 189 | } 190 | 191 | // MARK: UI Overlay 192 | 193 | /// Show GameCenter achievements overlay. 194 | /// 195 | /// - Parameters: 196 | /// - onClose: Called when the user closes the overlay. 197 | func showAchievementsOverlay(onClose: Callable) { 198 | #if canImport(UIKit) 199 | viewController.showUIController(GKGameCenterViewController(state: .achievements), onClose: onClose) 200 | #endif 201 | } 202 | 203 | /// Show GameCenter achievements overlay with a specific achievement. 204 | /// 205 | /// - Parameters: 206 | /// - achievementID: The identifier for the achievement that you enter in App Store Connect. 207 | /// - onClose: Called when the user closes the overlay. 208 | func showAchievementOverlay(achievementdID: String, onClose: Callable) { 209 | #if canImport(UIKit) 210 | viewController.showUIController(GKGameCenterViewController(achievementID: achievementdID), onClose: onClose) 211 | #endif 212 | } 213 | 214 | // MARK: Internal 215 | 216 | func updateAchievements() async throws { 217 | if GKLocalPlayer.local.isAuthenticated { 218 | self.achievements = try await GKAchievement.loadAchievements() 219 | } else { 220 | throw GKError(.notAuthenticated) 221 | } 222 | } 223 | 224 | func updateAchievementDescriptions() async throws { 225 | if GKLocalPlayer.local.isAuthenticated { 226 | self.achievementDescriptions = try await GKAchievementDescription.loadAchievementDescriptions() 227 | } else { 228 | throw GKError(.notAuthenticated) 229 | } 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /Sources/GameCenter/GameCenter+Challenges.swift: -------------------------------------------------------------------------------- 1 | import GameKit 2 | import SwiftGodot 3 | 4 | extension GameCenter { 5 | 6 | enum ChallengeError: Int, Error { 7 | case failedToLoadChallenges = 1 8 | case failedToLoadChallengableFriend = 2 9 | case noSuchChallenge = 3 10 | } 11 | 12 | /// Loads the list of outstanding challenges. 13 | /// 14 | /// - Parameters: 15 | /// - onComplete: Callback with parameters: (error: Variant, friends: Variant) -> (error: Int, friends: [``GameCenterPlayer``]) 16 | func loadReceivedChallenges(onComplete: Callable) { 17 | Task { 18 | do { 19 | var result = VariantArray() 20 | let challenges = try await GKChallenge.loadReceivedChallenges() 21 | 22 | for challenge in challenges { 23 | if challenge.state == .invalid { 24 | // We found an invalid challenge, not sure how to deal with this as we can't decline it 25 | GD.pushWarning("Found invalid challenge \(challenge)") 26 | } else { 27 | result.append(Variant(GameCenterChallenge.parseChallenge(challenge))) 28 | } 29 | } 30 | 31 | onComplete.callDeferred(Variant(OK), Variant(result)) 32 | } catch { 33 | GD.pushError("Error loading challenges: \(error)") 34 | onComplete.callDeferred(Variant(ChallengeError.failedToLoadChallenges.rawValue), nil) 35 | } 36 | } 37 | } 38 | 39 | /// Loads players to whom the local player can issue a challenge. 40 | /// 41 | /// - Parameters: 42 | /// - onComplete: Callback with parameters: (error: Variant, friends: Variant) -> (error: Int, friends: [``GameCenterPlayer``]) 43 | func loadChallengablePlayers(onComplete: Callable) { 44 | Task { 45 | do { 46 | var players = VariantArray() 47 | let friends = try await GKLocalPlayer.local.loadChallengableFriends() 48 | 49 | for friend in friends { 50 | players.append(Variant(GameCenterPlayer(friend))) 51 | } 52 | 53 | onComplete.callDeferred(Variant(OK), Variant(players)) 54 | 55 | } catch { 56 | GD.pushError("Error loading challengable friends: \(error)") 57 | onComplete.callDeferred(Variant(ChallengeError.failedToLoadChallengableFriend.rawValue), nil) 58 | } 59 | } 60 | } 61 | 62 | /// Provides a challenge compose view controller with preselected player identifiers and a message. 63 | /// 64 | /// > NOTE: This function will load a leaderboard filtered on the local player with a timeScope of ``GKLeaderboard.TimeScope.today`` 65 | /// leaderboards come in two types, "best score" or "most recent". If you use a "best score" leaderboard only todays best score can be used 66 | /// so if you want a player to be able to issue a challenge with their latest score you need to use a "most recent" leaderboard 67 | /// 68 | /// - Parameters: 69 | /// - leaderboardID: The ID of the leaderboard to load scores for. 70 | /// - receiverID: The ID of the player to receive the challenge 71 | /// - message: The preformatted message that GameKit sends to the players in the challenge. 72 | /// - onComplete: Callback with parameters: (error: Variant, receivers: Variant) -> (error: Int, receivers: [``String``]) 73 | func issueScoreChallenge( 74 | leaderboardID: String, 75 | receivers: [String], 76 | message: String, 77 | onComplete: Callable 78 | ) { 79 | Task { 80 | do { 81 | let friends = try await GKLocalPlayer.local.loadChallengableFriends() 82 | let receivers = friends.filter { receivers.contains($0.gamePlayerID) } 83 | 84 | let leaderboards: [GKLeaderboard] = try await GKLeaderboard.loadLeaderboards(IDs: [leaderboardID]) 85 | if let leaderboard: GKLeaderboard = try await leaderboards.first { 86 | let (local, entries) = try await leaderboard.loadEntries( 87 | for: [GKLocalPlayer.local], 88 | timeScope: .today 89 | ) 90 | 91 | if let local: GKLeaderboard.Entry { 92 | #if canImport(UIKit) 93 | DispatchQueue.main.async { 94 | // NOTE: We're actually using the old deprecated version that returns sentPlayers 95 | // as [String]? instead of the iOS 17+ [GKPlayer]? version for simplicity 96 | let challengeComposer = local.challengeComposeController( 97 | withMessage: message, 98 | players: receivers 99 | ) { 100 | composeController, 101 | didIssueChallenge, 102 | sentPlayers in 103 | 104 | var players = VariantArray() 105 | for player: String in sentPlayers ?? [] { 106 | players.append(Variant(player)) 107 | } 108 | 109 | composeController.dismiss(animated: true) 110 | onComplete.callDeferred(Variant(OK), Variant(players)) 111 | } 112 | 113 | self.viewController.getRootController()?.present(challengeComposer, animated: true) 114 | } 115 | #else 116 | onComplete.callDeferred(Variant(OK), nil) 117 | #endif 118 | } 119 | } 120 | } catch { 121 | GD.pushError("Error issuing challenge: \(error)") 122 | onComplete.callDeferred( 123 | Variant(ChallengeError.failedToLoadChallengableFriend.rawValue), 124 | nil 125 | ) 126 | } 127 | } 128 | } 129 | 130 | // TODO: Implement AchievementChallenge 131 | 132 | /// Decline a challenge. 133 | /// 134 | /// - Parameters: 135 | /// - challengeID: The ID of the challenge to decline 136 | func declineChallenge(challengeID: Int, onComplete: Callable) { 137 | Task { 138 | do { 139 | GD.print("[GameCenter] Cancelling challenge with ID: \(challengeID)") 140 | let challenges = try await GKChallenge.loadReceivedChallenges() 141 | 142 | guard let challenge = challenges.first(where: { $0.getChallengeID() == challengeID }) else { 143 | GD.pushError("[GameCenter] Failed to find challenge with ID: \(challengeID)") 144 | onComplete.callDeferred(Variant(ChallengeError.noSuchChallenge.rawValue)) 145 | return 146 | } 147 | 148 | challenge.decline() 149 | onComplete.callDeferred(Variant(OK)) 150 | } catch { 151 | GD.pushError("Error loading challenges: \(error)") 152 | onComplete.callDeferred(Variant(ChallengeError.failedToLoadChallenges.rawValue), nil) 153 | } 154 | } 155 | } 156 | 157 | // MARK: UI Overlay 158 | 159 | /// Show GameCenter challenges overlay. 160 | /// 161 | /// - Parameters: 162 | /// - onClose: Called when the user closes the overlay. 163 | func showChallengesOverlay(onClose: Callable) { 164 | #if canImport(UIKit) 165 | viewController.showUIController(GKGameCenterViewController(state: .challenges), onClose: onClose) 166 | #endif 167 | } 168 | 169 | // MARK: Internal 170 | 171 | /// Handles when a challenge is received. 172 | /// 173 | /// > NOTE: For some reason the issuingPlayer here is not complete, it does not contain gamePlayerID etc, just 174 | /// displayName and some other metadata 175 | func player(_ player: GKPlayer, didReceive challenge: GKChallenge) { 176 | if let issuingPlayer = challenge.issuingPlayer { 177 | // You recieved a challenge from issuingPlayer 178 | self.challengeReceived.emit( 179 | GameCenterChallenge.parseChallenge(challenge), 180 | GameCenterPlayer(issuingPlayer) 181 | ) 182 | } else { 183 | GD.pushWarning("[GameCenter] You recieved challenge from an unknown player: \(challenge)") 184 | } 185 | } 186 | 187 | // Handles when a challenge is completed. 188 | func player(_ player: GKPlayer, didComplete challenge: GKChallenge, issuedByFriend friendPlayer: GKPlayer) { 189 | // This seems to be triggered on both sides of the challenge so we do some checks 190 | if let issuingPlayer = challenge.issuingPlayer { 191 | if issuingPlayer == player { 192 | // Your challenge was completed by friendPlayer 193 | self.issuedChallengeCompleted.emit( 194 | GameCenterChallenge.parseChallenge(challenge), 195 | GameCenterPlayer(friendPlayer) 196 | ) 197 | } else { 198 | // You completed challenge from issuingPlayer 199 | self.challengeCompleted.emit( 200 | GameCenterChallenge.parseChallenge(challenge), 201 | GameCenterPlayer(issuingPlayer) 202 | ) 203 | } 204 | } else { 205 | GD.print( 206 | "[GameCenter] A challenge without issuer was completed: \(challenge) (friendPlayer: \(friendPlayer))" 207 | ) 208 | } 209 | } 210 | 211 | // Handles when a friend completes a challenge that the local player issues. 212 | // NOTE: Does not seem to trigger at all 213 | func player( 214 | _ player: GKPlayer, 215 | issuedChallengeWasCompleted challenge: GKChallenge, 216 | byFriend friendPlayer: GKPlayer 217 | ) { 218 | GD.print("[GameCenter] Your issued challenge was completed by: \(friendPlayer.displayName))") 219 | } 220 | 221 | /// Handles when the local player issues a challenge and the other player accepts. 222 | // NOTE: Does not seem to trigger at all 223 | func player(_ player: GKPlayer, wantsToPlay challenge: GKChallenge) { 224 | GD.print( 225 | "[GameCenter] Your issued challenge was accepted (player: \(player.displayName), challenge: \(challenge))" 226 | ) 227 | } 228 | 229 | // MARK: ChallengeDelegate 230 | 231 | class ChallengeDelegate: NSObject, GKLocalPlayerListener { 232 | var delegate: GameCenter 233 | 234 | required init(withDelegate delegate: GameCenter) { 235 | self.delegate = delegate 236 | super.init() 237 | } 238 | 239 | func player(_ player: GKPlayer, didComplete challenge: GKChallenge, issuedByFriend friendPlayer: GKPlayer) { 240 | delegate.player(player, didComplete: challenge, issuedByFriend: friendPlayer) 241 | } 242 | 243 | func player( 244 | _ player: GKPlayer, 245 | issuedChallengeWasCompleted challenge: GKChallenge, 246 | byFriend friendPlayer: GKPlayer 247 | ) { 248 | delegate.player(player, issuedChallengeWasCompleted: challenge, byFriend: friendPlayer) 249 | } 250 | 251 | func player(_ player: GKPlayer, wantsToPlay challenge: GKChallenge) { 252 | delegate.player(player, wantsToPlay: challenge) 253 | } 254 | 255 | func player(_ player: GKPlayer, didReceive challenge: GKChallenge) { 256 | delegate.player(player, didReceive: challenge) 257 | } 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /Sources/GameCenter/GameCenter+Friends.swift: -------------------------------------------------------------------------------- 1 | import GameKit 2 | import SwiftGodot 3 | 4 | extension GameCenter { 5 | 6 | enum FriendsError: Int, Error { 7 | case friendAccessRestricted = 1 8 | case failedToLoadFriends = 2 9 | case failedToLoadRecentPlayers = 3 10 | case noSuchFriend = 4 11 | } 12 | 13 | /// Load the friends of the authenticated player. 14 | /// 15 | /// - Parameters: 16 | /// - onComplete: Callback with parameters: (error: Variant, friends: Variant) -> (error: Int, friends: [``GameCenterPlayer``]) 17 | /// - includeImages: If true images will be loaded for each player 18 | func loadFriends(onComplete: Callable, includeImages: Bool = true) { 19 | Task { 20 | do { 21 | var players = VariantArray() 22 | self.friends = try await GKLocalPlayer.local.loadFriends() 23 | 24 | for friend in self.friends ?? [] { 25 | var player = GameCenterPlayer(friend) 26 | if includeImages, let image = try? await friend.loadImage(size: .small) { 27 | player.profilePicture = image 28 | } 29 | 30 | players.append(Variant(player)) 31 | } 32 | 33 | onComplete.callDeferred(Variant(OK), Variant(players)) 34 | 35 | } catch { 36 | GD.pushError("Error loading friends. \(error)") 37 | onComplete.callDeferred(Variant(FriendsError.failedToLoadFriends.rawValue), nil) 38 | } 39 | } 40 | } 41 | 42 | /// Loads players from the friends list or players that recently participated in a game with the local player. 43 | /// 44 | /// - Parameters: 45 | /// - onComplete: Callback with parameters: (error: Variant, players: Variant) -> (error: Int, players: [``GameCenterPlayer``]) 46 | /// - includeImages: If true images will be loaded for each player 47 | func loadRecentPlayers(onComplete: Callable, includeImages: Bool = true) { 48 | Task { 49 | do { 50 | var players = VariantArray() 51 | let recentPlayers = try await GKLocalPlayer.local.loadRecentPlayers() 52 | 53 | for recentPlayer in recentPlayers { 54 | var player = GameCenterPlayer(recentPlayer) 55 | if includeImages, let image = try? await recentPlayer.loadImage(size: .small) { 56 | player.profilePicture = image 57 | } 58 | players.append(Variant(player)) 59 | } 60 | 61 | onComplete.callDeferred(Variant(OK), Variant(players)) 62 | 63 | } catch { 64 | GD.pushError("Error loading recent players. \(error)") 65 | onComplete.callDeferred(Variant(FriendsError.failedToLoadRecentPlayers.rawValue), nil) 66 | } 67 | } 68 | } 69 | 70 | /// Load the profile picture of the given gamePlayerID. 71 | /// > NOTE: Only works on friends 72 | /// 73 | /// - Parameters 74 | /// - onComplete: Callback with parameters: (error: Variant, data: Variant) -> (error: Int, data: Image) 75 | func loadFriendPicture(gamePlayerID: String, onComplete: Callable) { 76 | if friends == nil { 77 | loadFriends(onComplete: Callable(), includeImages: false) 78 | } 79 | 80 | Task { 81 | do { 82 | if self.friends == nil { 83 | try await updateFriends() 84 | } 85 | 86 | if let friend = self.friends?.first(where: { $0.gamePlayerID == gamePlayerID }) { 87 | let image = try await friend.loadImage(size: .small) 88 | onComplete.callDeferred(Variant(OK), Variant(image)) 89 | } else { 90 | GD.pushError("Found no friend with id: \(gamePlayerID)") 91 | onComplete.callDeferred(Variant(FriendsError.noSuchFriend.rawValue), nil) 92 | } 93 | } catch { 94 | GD.pushError("Failed to load friend picture. \(error)") 95 | onComplete.callDeferred(Variant(GameCenterError.failedToLoadPicture.rawValue), nil) 96 | } 97 | } 98 | } 99 | 100 | /// Check for permission to load friends. 101 | /// 102 | /// Usage: 103 | /// ```python 104 | /// game_center.canAccessFriends(func(error: Variant, data: Variant) -> void: 105 | /// if error == OK: 106 | /// var friendPhoto:Image = data as Image 107 | /// ) 108 | /// ``` 109 | /// 110 | /// - Parameters: 111 | /// - onComplete: Callback with parameters: (error: Variant, status: Variant) -> (error: Int, status: Int) 112 | /// Possible status types: 113 | /// - notDetermined = 0 114 | /// - restricted = 1 115 | /// - denied = 2 116 | /// - authorized = 3 117 | func canAccessFriends(onComplete: Callable) { 118 | Task { 119 | do { 120 | let status = try await GKLocalPlayer.local.loadFriendsAuthorizationStatus() 121 | onComplete.callDeferred(Variant(OK), Variant(status.rawValue)) 122 | } catch { 123 | GD.pushError("Error accessing friends: \(error).") 124 | onComplete.callDeferred(Variant(FriendsError.friendAccessRestricted.rawValue), nil) 125 | } 126 | } 127 | } 128 | 129 | // MARK: UI Overlay 130 | 131 | /// Show GameCenter friends overlay. 132 | /// 133 | /// - Parameters: 134 | /// - onClose: Called when the user closes the overlay. 135 | func showFriendsOverlay(onClose: Callable) { 136 | #if canImport(UIKit) 137 | viewController.showUIController(GKGameCenterViewController(state: .localPlayerFriendsList), onClose: onClose) 138 | #endif 139 | } 140 | 141 | /// Show friend request creator. 142 | /// 143 | /// - Parameters: 144 | /// - onClose: Called when the user closes the overlay 145 | func showFriendRequestCreator() { 146 | #if canImport(UIKit) 147 | do { 148 | if let rootController = viewController.getRootController() { 149 | try GKLocalPlayer.local.presentFriendRequestCreator(from: rootController) 150 | } 151 | } catch { 152 | GD.pushError("Error showing friend request creator: \(error)") 153 | } 154 | #endif 155 | } 156 | 157 | // MARK: Internal 158 | 159 | func updateFriends() async throws { 160 | if GKLocalPlayer.local.isAuthenticated { 161 | self.friends = try await GKLocalPlayer.local.loadFriends() 162 | } else { 163 | throw GKError(.notAuthenticated) 164 | } 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /Sources/GameCenter/GameCenter+Invites.swift: -------------------------------------------------------------------------------- 1 | import GameKit 2 | import SwiftGodot 3 | 4 | extension GameCenter { 5 | 6 | enum InviteError: Int, Error { 7 | case failedToLoadInvites = 1 8 | } 9 | 10 | /// Get the invite with index. 11 | /// 12 | /// NOTE: There is no official functionality to load invites, so a list is kept which might hold expired invites 13 | /// 14 | /// - Parameters: 15 | /// - index: The index in the internal list of invites 16 | /// - onComplete: Callback with parameter: (error: Variant, data: Variant) -> (error: Int, data: GameCenterInvite) 17 | func getInvite(withIndex index: Int, onComplete: Callable) { 18 | guard let invites = self.invites else { 19 | onComplete.callDeferred(Variant(GameCenterError.notAvailable.rawValue), nil) 20 | return 21 | } 22 | guard index >= 0 || index < invites.count else { 23 | onComplete.callDeferred(Variant(GameCenterError.notAvailable.rawValue), nil) 24 | return 25 | } 26 | 27 | onComplete.callDeferred(Variant(OK), Variant(GameCenterInvite(invites[index]))) 28 | } 29 | 30 | /// Get all the currently active invites. 31 | /// 32 | /// NOTE: There is no official functionality to load invites, so a list is kept which might hold expired invites 33 | /// 34 | /// - Parameters: 35 | /// - onComplete: Callback with parameter: (error: Variant, data: Variant) -> (error: Int, data: [GameCenterInvite]) 36 | func getInvites(onComplete: Callable) { 37 | Task { 38 | do { 39 | var invites = VariantArray() 40 | for invite in self.invites ?? [] { 41 | invites.append(Variant(GameCenterInvite(invite))) 42 | } 43 | 44 | onComplete.callDeferred(Variant(OK), Variant(invites)) 45 | 46 | } catch { 47 | GD.pushError("Error loading invites. \(error)") 48 | onComplete.callDeferred(Variant(InviteError.failedToLoadInvites.rawValue), nil) 49 | } 50 | } 51 | } 52 | 53 | /// Remove invite with index 54 | /// 55 | /// - Parameters: 56 | /// - index: The index in the internal list of invites 57 | func removeInvite(withIndex index: Int) -> Bool { 58 | guard var invites = self.invites else { 59 | return false 60 | } 61 | 62 | guard index >= 0 || index < invites.count else { 63 | return false 64 | } 65 | 66 | invites.remove(at: index) 67 | self.inviteRemoved.emit(index) 68 | 69 | return true 70 | } 71 | 72 | // MARK: Internal 73 | 74 | func getInvite(withIndex index: Int) -> GKInvite? { 75 | guard var invites = self.invites else { 76 | return nil 77 | } 78 | 79 | guard index >= 0 || index < invites.count else { 80 | return nil 81 | } 82 | 83 | return invites[index] 84 | } 85 | 86 | func player(_ player: GKPlayer, didAccept invite: GKInvite) { 87 | GD.print("[GameCenter] Invite accepted: \(invite)") 88 | 89 | if self.invites == nil { 90 | invites = [] 91 | } 92 | 93 | self.invites!.append(invite) 94 | self.inviteAccepted.emit(invite.sender.displayName, Int(invites!.count - 1)) 95 | } 96 | 97 | func player(_ player: GKPlayer, didRequestMatchWithRecipients recipientPlayers: [GKPlayer]) { 98 | GD.print("[GameCenter] Invite sent to \(recipientPlayers)") 99 | var players = VariantArray() 100 | for recipient in recipientPlayers { 101 | players.append(Variant(GameCenterPlayer(recipient))) 102 | } 103 | 104 | self.inviteSent.emit(players) 105 | } 106 | 107 | // MARK: InviteDelegate 108 | 109 | class InviteDelegate: NSObject, GKLocalPlayerListener { 110 | var delegate: GKInviteEventListener 111 | 112 | required init(withDelegate delegate: GKInviteEventListener) { 113 | self.delegate = delegate 114 | super.init() 115 | } 116 | 117 | func player(_ player: GKPlayer, didAccept invite: GKInvite) { 118 | delegate.player?(player, didAccept: invite) 119 | } 120 | 121 | func player(_ player: GKPlayer, didRequestMatchWithRecipients recipientPlayers: [GKPlayer]) { 122 | delegate.player?(player, didRequestMatchWithRecipients: recipientPlayers) 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /Sources/GameCenter/GameCenter+Leaderboards.swift: -------------------------------------------------------------------------------- 1 | import GameKit 2 | import SwiftGodot 3 | 4 | extension GameCenter { 5 | 6 | enum LeaderboardError: Int, Error { 7 | case failedToLoadEntries = 1 8 | case failedToSubmitScore = 2 9 | } 10 | 11 | /// Submit leadboard score. 12 | /// 13 | /// - Parameters: 14 | /// - score: The score to submit- 15 | /// - leaderboardIDs: An array of leaderboard identifiers that you enter in App Store Connect. 16 | /// - onComplete: Callback with parameter: (error: Variant) -> (error: Int) 17 | @inline(__always) 18 | func submitScore(_ score: Int, leaderboardIDs: [String], context: Int, onComplete: Callable) { 19 | Task { 20 | do { 21 | try await GKLeaderboard.submitScore( 22 | score, 23 | context: context, 24 | player: GKLocalPlayer.local, 25 | leaderboardIDs: leaderboardIDs 26 | ) 27 | onComplete.callDeferred(Variant(OK)) 28 | } catch { 29 | GD.pushError("Error submitting score: \(error).") 30 | onComplete.callDeferred(Variant(LeaderboardError.failedToSubmitScore.rawValue)) 31 | } 32 | } 33 | } 34 | 35 | /// Get personal best for a leaderboard 36 | /// 37 | /// - Parameters: 38 | /// - leaderboardID: The identifier for the leaderboard that you enter in App Store Connect 39 | /// - onComplete: Callback with parameters: (error: Variant, localPlayer: Variant) -> (error: Int, localPlayer: GameCenterLeaderboardEntry) 40 | func getLocalPlayerEntry(loaderboardID: String, onComplete: Callable) { 41 | loadLeaderboard( 42 | for: [GKLocalPlayer.local], 43 | leaderboardID: loaderboardID, 44 | time: .allTime, 45 | onComplete: onComplete 46 | ) 47 | } 48 | 49 | /// Get global leaderboard. 50 | /// 51 | /// - Parameters: 52 | /// - leaderboardIDs: The identifier for the leaderboard that you enter in App Store Connect. 53 | /// - start: The start of the range to load 54 | /// - length: How many entires to load (max: 100) 55 | /// - onComplete: Callback with parameters: (error: Variant, localPlayer: Variant, players: Variant, count: Variant) -> (error: Int, localPlayer: GameCenterLeaderboardEntry, players: [``GameCenterLeaderboardEntry``], count: Int) 56 | func getGlobalScores(leaderboardID: String, start: Int, length: Int, onComplete: Callable) { 57 | let rangeStart: Int = max(start, 1) 58 | let rangeLength: Int = min(length, 100) 59 | loadLeaderboard( 60 | leaderboardID: leaderboardID, 61 | scope: .global, 62 | time: .allTime, 63 | range: NSMakeRange(rangeStart, rangeLength), 64 | onComplete: onComplete 65 | ) 66 | } 67 | 68 | /// Get friends leaderboard. 69 | /// 70 | /// - Parameters: 71 | /// - leaderboardIDs: The identifier for the leaderboard that you enter in App Store Connect. 72 | /// - start: The start of the range to load 73 | /// - length: How many entires to load (max: 100) 74 | /// - onComplete: Callback with parameters: (error: Variant, localPlayer: Variant, players: Variant, count: Variant) -> (error: Int, localPlayer: GameCenterLeaderboardEntry, players: [``GameCenterLeaderboardEntry``], count: Int) 75 | func getFriendsScores(leaderboardID: String, start: Int, length: Int, onComplete: Callable) { 76 | let rangeStart: Int = max(start, 1) 77 | let rangeLength: Int = min(length, 100) 78 | loadLeaderboard( 79 | leaderboardID: leaderboardID, 80 | scope: .friendsOnly, 81 | time: .allTime, 82 | range: NSMakeRange(rangeStart, rangeLength), 83 | onComplete: onComplete 84 | ) 85 | } 86 | 87 | /// Get the previous occurance of personal best for a leaderboard 88 | /// 89 | /// - Parameters: 90 | /// - leaderboardID: The identifier for the leaderboard that you enter in App Store Connect 91 | /// - onComplete: Callback with parameters: (error: Variant, localPlayer: Variant) -> (error: Int, localPlayer: GameCenterLeaderboardEntry) 92 | func getPreviousLocalPlayerEntry(loaderboardID: String, onComplete: Callable) { 93 | loadPreviousLeaderboard( 94 | for: [GKLocalPlayer.local], 95 | leaderboardID: loaderboardID, 96 | time: .allTime, 97 | onComplete: onComplete 98 | ) 99 | } 100 | 101 | /// Get the previous occurance of a recurring global leaderboard. 102 | /// 103 | /// - Parameters: 104 | /// - leaderboardIDs: The identifier for the leaderboard that you enter in App Store Connect. 105 | /// - start: The start of the range to load 106 | /// - length: How many entires to load (max: 100) 107 | /// - onComplete: Callback with parameters: (error: Variant, localPlayer: Variant, players: Variant, count: Variant) -> (error: Int, localPlayer: GameCenterLeaderboardEntry, players: [``GameCenterLeaderboardEntry``], count: Int) 108 | func getPreviousOccurance(leaderboardID: String, start: Int, length: Int, onComplete: Callable) { 109 | let rangeStart: Int = max(start, 1) 110 | let rangeLength: Int = min(length, 100) 111 | loadPreviousLeaderboard( 112 | leaderboardID: leaderboardID, 113 | scope: .global, 114 | time: .allTime, 115 | range: NSMakeRange(rangeStart, rangeLength), 116 | onComplete: onComplete 117 | ) 118 | } 119 | 120 | /// Get the previous occurance of a recurring friends leaderboard. 121 | /// 122 | /// - Parameters: 123 | /// - leaderboardIDs: The identifier for the leaderboard that you enter in App Store Connect. 124 | /// - start: The start of the range to load 125 | /// - length: How many entires to load (max: 100) 126 | /// - onComplete: Callback with parameters: (error: Variant, localPlayer: Variant, players: Variant, count: Variant) -> (error: Int, localPlayer: GameCenterLeaderboardEntry, players: [``GameCenterLeaderboardEntry``], count: Int) 127 | func getPreviousFriendsOccurance(leaderboardID: String, start: Int, length: Int, onComplete: Callable) { 128 | let rangeStart: Int = max(start, 1) 129 | let rangeLength: Int = min(length, 100) 130 | loadPreviousLeaderboard( 131 | leaderboardID: leaderboardID, 132 | scope: .friendsOnly, 133 | time: .allTime, 134 | range: NSMakeRange(rangeStart, rangeLength), 135 | onComplete: onComplete 136 | ) 137 | } 138 | 139 | // MARK: UI Overlay 140 | 141 | /// Show GameCenter leaderboards overlay. 142 | /// 143 | /// - Parameters: 144 | /// - onClose: Called when the user closes the overlay. 145 | func showLeaderboardsOverlay(onClose: Callable) { 146 | #if canImport(UIKit) 147 | viewController.showUIController(GKGameCenterViewController(state: .leaderboards), onClose: onClose) 148 | #endif 149 | } 150 | 151 | /// Show GameCenter leaderboard overlay for a specific leaderboard. 152 | /// 153 | /// - Parameters: 154 | /// - leaderboardID: The identifier for the leaderboard that you enter in App Store Connect. 155 | /// - onClose: Called when the user closes the overlay. 156 | func showLeaderboardOverlay(leaderboardID: String, onClose: Callable) { 157 | #if canImport(UIKit) 158 | viewController.showUIController( 159 | GKGameCenterViewController( 160 | leaderboardID: leaderboardID, 161 | playerScope: GKLeaderboard.PlayerScope.global, 162 | timeScope: .allTime 163 | ), 164 | onClose: onClose 165 | ) 166 | #endif 167 | } 168 | 169 | // MARK: Internal 170 | 171 | func loadLeaderboard( 172 | leaderboardID: String, 173 | scope: GKLeaderboard.PlayerScope, 174 | time: GKLeaderboard.TimeScope, 175 | range: NSRange, 176 | onComplete: Callable 177 | ) { 178 | Task { 179 | do { 180 | let leaderboards = try await GKLeaderboard.loadLeaderboards(IDs: [leaderboardID]) 181 | if let leaderboard: GKLeaderboard = leaderboards.first { 182 | let (local, entries, count) = try await leaderboard.loadEntries( 183 | for: scope, 184 | timeScope: time, 185 | range: range 186 | ) 187 | 188 | // Add the local player 189 | var localPlayer: Variant? = nil 190 | if let local: GKLeaderboard.Entry { 191 | localPlayer = Variant(GameCenterLeaderboardEntry(entry: local)) 192 | } 193 | 194 | // Get all the players in range 195 | var players = VariantArray() 196 | for entry: GKLeaderboard.Entry in entries { 197 | players.append(Variant(GameCenterLeaderboardEntry(entry: entry))) 198 | } 199 | 200 | onComplete.callDeferred(Variant(OK), localPlayer, Variant(players), Variant(count)) 201 | } else { 202 | onComplete.callDeferred( 203 | Variant(LeaderboardError.failedToLoadEntries.rawValue), 204 | nil, 205 | nil, 206 | Variant(0) 207 | ) 208 | } 209 | } catch { 210 | GD.pushError("Failed to get leaderboard: \(error)") 211 | onComplete.callDeferred( 212 | Variant(LeaderboardError.failedToLoadEntries.rawValue), 213 | nil, 214 | nil, 215 | Variant(0) 216 | ) 217 | } 218 | } 219 | } 220 | 221 | func loadLeaderboard( 222 | for players: [GKPlayer], 223 | leaderboardID: String, 224 | time: GKLeaderboard.TimeScope, 225 | onComplete: Callable 226 | ) { 227 | Task { 228 | do { 229 | let leaderboards = try await GKLeaderboard.loadLeaderboards(IDs: [leaderboardID]) 230 | if let leaderboard = leaderboards.first { 231 | let (local, entries) = try await leaderboard.loadEntries( 232 | for: players, 233 | timeScope: time 234 | ) 235 | 236 | // Add the local player 237 | var localPlayer: Variant? = nil 238 | if let local: GKLeaderboard.Entry { 239 | localPlayer = Variant(GameCenterLeaderboardEntry(entry: local)) 240 | } 241 | 242 | // Get all the players in range 243 | var players = VariantArray() 244 | for entry in entries { 245 | players.append(Variant(GameCenterLeaderboardEntry(entry: entry))) 246 | } 247 | 248 | onComplete.callDeferred( 249 | Variant(OK), 250 | localPlayer, 251 | Variant(players) 252 | ) 253 | } else { 254 | onComplete.callDeferred( 255 | Variant(LeaderboardError.failedToLoadEntries.rawValue), 256 | nil, 257 | nil 258 | ) 259 | } 260 | } catch { 261 | GD.pushError("Failed to get leaderboard: \(error)") 262 | onComplete.callDeferred( 263 | Variant(LeaderboardError.failedToLoadEntries.rawValue), 264 | nil, 265 | nil 266 | ) 267 | } 268 | } 269 | } 270 | 271 | func loadPreviousLeaderboard( 272 | leaderboardID: String, 273 | scope: GKLeaderboard.PlayerScope, 274 | time: GKLeaderboard.TimeScope, 275 | range: NSRange, 276 | onComplete: Callable 277 | ) { 278 | Task { 279 | do { 280 | let leaderboards = try await GKLeaderboard.loadLeaderboards(IDs: [leaderboardID]) 281 | if let leaderboard = try await leaderboards.first?.loadPreviousOccurrence() { 282 | let (local, entries, count) = try await leaderboard.loadEntries( 283 | for: scope, 284 | timeScope: time, 285 | range: range 286 | ) 287 | 288 | // Add the local player 289 | var localPlayer: Variant? = nil 290 | if let local: GKLeaderboard.Entry { 291 | localPlayer = Variant(GameCenterLeaderboardEntry(entry: local)) 292 | } 293 | 294 | // Get all the players in range 295 | var players = VariantArray() 296 | for entry in entries { 297 | players.append(Variant(GameCenterLeaderboardEntry(entry: entry))) 298 | } 299 | 300 | onComplete.callDeferred(Variant(OK), localPlayer, Variant(players), Variant(count)) 301 | } else { 302 | onComplete.callDeferred( 303 | Variant(LeaderboardError.failedToLoadEntries.rawValue), 304 | nil, 305 | nil, 306 | Variant(0) 307 | ) 308 | } 309 | } catch { 310 | GD.pushError("Failed to get leaderboard: \(error)") 311 | onComplete.callDeferred( 312 | Variant(LeaderboardError.failedToLoadEntries.rawValue), 313 | nil, 314 | nil, 315 | Variant(0) 316 | ) 317 | } 318 | } 319 | } 320 | 321 | func loadPreviousLeaderboard( 322 | for players: [GKPlayer], 323 | leaderboardID: String, 324 | time: GKLeaderboard.TimeScope, 325 | onComplete: Callable 326 | ) { 327 | Task { 328 | do { 329 | let leaderboards = try await GKLeaderboard.loadLeaderboards(IDs: [leaderboardID]) 330 | if let leaderboard = try await leaderboards.first?.loadPreviousOccurrence() { 331 | let (local, entries) = try await leaderboard.loadEntries( 332 | for: players, 333 | timeScope: time 334 | ) 335 | 336 | // Add the local player 337 | var localPlayer: Variant? = nil 338 | if let local: GKLeaderboard.Entry { 339 | localPlayer = Variant(GameCenterLeaderboardEntry(entry: local)) 340 | } 341 | 342 | // Get all the players in range 343 | var players = VariantArray() 344 | for entry in entries { 345 | players.append(Variant(GameCenterLeaderboardEntry(entry: entry))) 346 | } 347 | 348 | onComplete.callDeferred( 349 | Variant(OK), 350 | localPlayer, 351 | Variant(players) 352 | ) 353 | } else { 354 | onComplete.callDeferred( 355 | Variant(LeaderboardError.failedToLoadEntries.rawValue), 356 | nil, 357 | nil 358 | ) 359 | } 360 | } catch { 361 | GD.pushError("Failed to get leaderboard: \(error)") 362 | onComplete.callDeferred( 363 | Variant(LeaderboardError.failedToLoadEntries.rawValue), 364 | nil, 365 | nil 366 | ) 367 | } 368 | } 369 | } 370 | } 371 | -------------------------------------------------------------------------------- /Sources/GameCenter/GameCenter.swift: -------------------------------------------------------------------------------- 1 | import GameKit 2 | import SwiftGodot 3 | 4 | #if canImport(UIKit) 5 | import UIKit 6 | #endif 7 | 8 | #initSwiftExtension( 9 | cdecl: "swift_entry_point", 10 | types: [ 11 | GameCenter.self, 12 | GameCenterMultiplayerPeer.self, 13 | GameCenterPlayer.self, 14 | GameCenterPlayerLocal.self, 15 | GameCenterLeaderboardEntry.self, 16 | GameCenterAchievement.self, 17 | GameCenterChallenge.self, 18 | GameCenterScoreChallenge.self, 19 | GameCenterAchievementChallenge.self, 20 | ] 21 | ) 22 | 23 | let OK: Int = 0 24 | 25 | @Godot 26 | class GameCenter: RefCounted, GKInviteEventListener { 27 | enum GameCenterError: Int, Error { 28 | case unknownError = 1 29 | case notAuthenticated = 2 30 | case notAvailable = 3 31 | case failedToAuthenticate = 4 32 | case failedToLoadPicture = 8 33 | } 34 | 35 | /// Signal called when a challenge was received 36 | @Signal var challengeReceived: SignalWithArguments 37 | 38 | /// Signal called when you completed a challenge 39 | @Signal var challengeCompleted: SignalWithArguments 40 | 41 | /// Signal called when a challenge was completed 42 | @Signal var issuedChallengeCompleted: SignalWithArguments 43 | 44 | /// Signal called when an invite is accepted 45 | @Signal var inviteAccepted: SignalWithArguments 46 | 47 | /// Signal called when an invite is removed 48 | @Signal var inviteRemoved: SignalWithArguments 49 | 50 | /// Signal called when an invite is send 51 | @Signal var inviteSent: SignalWithArguments 52 | 53 | #if canImport(UIKit) 54 | var viewController: GameCenterViewController = GameCenterViewController() 55 | #endif 56 | 57 | static var instance: GameCenter? 58 | var player: GameCenterPlayer? 59 | 60 | var inviteDelegate: InviteDelegate? 61 | var challengeDelegate: ChallengeDelegate? 62 | 63 | internal(set) var friends: [GKPlayer]? 64 | internal(set) var invites: [GKInvite]? 65 | internal(set) var achievements: [GKAchievement]? 66 | internal(set) var achievementDescriptions: [GKAchievementDescription]? 67 | 68 | required init(_ context: InitContext) { 69 | super.init(context) 70 | GameCenter.instance = self 71 | inviteDelegate = InviteDelegate(withDelegate: self) 72 | challengeDelegate = ChallengeDelegate(withDelegate: self) 73 | } 74 | 75 | // MARK: Authentication 76 | 77 | /// Authenticate with gameCenter. 78 | /// 79 | /// - Parameters: 80 | /// - onComplete: Callback with parameter: (error: Variant, data: Variant) -> (error: Int, data: ``GameCenterPlayerLocal``) 81 | @Callable(autoSnakeCase: true) 82 | public func authenticate(onComplete: Callable = Callable()) { 83 | if GKLocalPlayer.local.isAuthenticated && self.player != nil { 84 | onComplete.call(Variant(OK), Variant(self.player!)) 85 | return 86 | } 87 | 88 | #if os(iOS) 89 | 90 | GKLocalPlayer.local.authenticateHandler = { loginController, error in 91 | guard loginController == nil else { 92 | self.viewController.getRootController()?.present(loginController!, animated: true) 93 | return 94 | } 95 | 96 | guard error == nil else { 97 | GD.pushError("Failed to authenticate \(error)") 98 | onComplete.callDeferred(Variant(GameCenterError.failedToAuthenticate.rawValue), nil) 99 | return 100 | } 101 | 102 | if self.inviteDelegate != nil { 103 | GKLocalPlayer.local.register(self.inviteDelegate!) 104 | } 105 | 106 | if self.challengeDelegate != nil { 107 | GKLocalPlayer.local.register(self.challengeDelegate!) 108 | } 109 | 110 | var player = GameCenterPlayerLocal(GKLocalPlayer.local) 111 | onComplete.callDeferred(Variant(OK), Variant(player)) 112 | } 113 | 114 | #elseif os(watchOS) 115 | 116 | GKLocalPlayer.local.authenticateHandler = { error in 117 | guard error == nil else { 118 | GD.pushError("Failed to authenticate \(error)") 119 | onComplete.callDeferred(Variant(GameCenterError.failedToAuthenticate.rawValue), nil) 120 | return 121 | } 122 | 123 | if self.inviteDelegate != nil { 124 | GKLocalPlayer.local.register(self.inviteDelegate!) 125 | } 126 | 127 | if self.challengeDelegate != nil { 128 | GKLocalPlayer.local.register(self.challengeDelegate!) 129 | } 130 | 131 | var player = GameCenterPlayerLocal(GKLocalPlayer.local) 132 | onComplete.callDeferred(Variant(OK), Variant(player)) 133 | } 134 | 135 | #elseif os(macOS) 136 | 137 | GKLocalPlayer.local.authenticateHandler = { loginController, error in 138 | guard loginController == nil else { 139 | // TODO: Figure out how to show login window on macOS 140 | return 141 | } 142 | 143 | guard error == nil else { 144 | GD.pushError("Failed to authenticate \(error)") 145 | onComplete.callDeferred(Variant(GameCenterError.failedToAuthenticate.rawValue), nil) 146 | return 147 | } 148 | 149 | if self.inviteDelegate != nil { 150 | GKLocalPlayer.local.register(self.inviteDelegate!) 151 | } 152 | 153 | if self.challengeDelegate != nil { 154 | GKLocalPlayer.local.register(self.challengeDelegate!) 155 | } 156 | 157 | var player = GameCenterPlayerLocal(GKLocalPlayer.local) 158 | onComplete.callDeferred(Variant(OK), Variant(player)) 159 | } 160 | 161 | #else 162 | GD.pushWarning("GameCenter not available on this platform") 163 | onComplete.call(Variant(GameCenterError.notAvailable.rawValue)) 164 | #endif 165 | } 166 | 167 | /// A Boolean value that indicates whether a local player has signed in to Game Center. 168 | func isAuthenticated() -> Bool { 169 | #if os(iOS) 170 | return GKLocalPlayer.local.isAuthenticated 171 | #else 172 | return false 173 | #endif 174 | } 175 | 176 | /// Get the local player 177 | /// 178 | /// - Parameters: 179 | /// - onComplete: Callback with parameter: (error: Variant, data: Variant) -> (error: Int, data: ``GameCenterPlayerLocal``) 180 | func getLocalPlayer(onComplete: Callable) { 181 | guard GKLocalPlayer.local.isAuthenticated && self.player != nil else { 182 | onComplete.call(Variant(GameCenterError.notAuthenticated.rawValue), nil) 183 | return 184 | } 185 | 186 | onComplete.call(Variant(OK), Variant(self.player!)) 187 | } 188 | 189 | /// Load the profile picture of the authenticated player. 190 | /// 191 | /// - Parameters: 192 | /// - onComplete: Callback with parameter: (error: Variant, data: Variant) -> (error: Int, data: Image) 193 | func loadProfilePicture(onComplete: Callable) { 194 | Task { 195 | do { 196 | let image = try await GKLocalPlayer.local.loadImage(size: .small) 197 | onComplete.callDeferred(Variant(OK), Variant(image)) 198 | } catch { 199 | GD.pushError("Failed to load profile picture. \(error)") 200 | onComplete.callDeferred(Variant(GameCenterError.failedToLoadPicture.rawValue), nil) 201 | } 202 | } 203 | } 204 | 205 | // MARK: UI Overlays 206 | 207 | /// Show GameCenter dashboard overlay. 208 | /// 209 | /// - Parameters: 210 | /// - onClose: Called when the user closes the overlay. 211 | func showOverlay(onClose: Callable) { 212 | #if canImport(UIKit) 213 | viewController.showUIController(GKGameCenterViewController(state: .dashboard), onClose: onClose) 214 | #endif 215 | } 216 | 217 | /// Show GameCenter player profile overlay. 218 | /// 219 | /// - Parameters: 220 | /// - onClose: Called when the user closes the overlay. 221 | func showProfileOverlay(onClose: Callable) { 222 | #if canImport(UIKit) 223 | viewController.showUIController(GKGameCenterViewController(state: .localPlayerProfile), onClose: onClose) 224 | #endif 225 | } 226 | 227 | /// Show GameCenter access point. 228 | /// 229 | /// - Parameters: 230 | /// - showHighlights: A Boolean value that indicates whether to display highlights for achievements and current ranks for leaderboards. 231 | func showAccessPoint(showHighlights: Bool) { 232 | GKAccessPoint.shared.location = .topTrailing 233 | GKAccessPoint.shared.showHighlights = showHighlights 234 | GKAccessPoint.shared.isActive = true 235 | } 236 | 237 | /// Hide GameCenter access point. 238 | func hideAccessPoint() { 239 | GKAccessPoint.shared.isActive = false 240 | } 241 | 242 | // MARK: > Godot callables 243 | // Because @Callable doesn't work in extensions 244 | 245 | // General 246 | 247 | @Callable 248 | func is_authenticated() -> Bool { 249 | return isAuthenticated() 250 | } 251 | 252 | @Callable 253 | func get_local_player(onComplete: Callable) { 254 | getLocalPlayer(onComplete: onComplete) 255 | } 256 | 257 | @Callable 258 | func load_profile_picture(onComplete: Callable) { 259 | loadProfilePicture(onComplete: onComplete) 260 | } 261 | 262 | @Callable 263 | func show_profile_overlay(onClose: Callable) { 264 | showProfileOverlay(onClose: onClose) 265 | } 266 | 267 | @Callable 268 | func show_access_point(showHighlights: Bool) { 269 | showAccessPoint(showHighlights: showHighlights) 270 | } 271 | 272 | @Callable 273 | func hide_access_point() { 274 | hideAccessPoint() 275 | } 276 | 277 | // MARK: Achievements 278 | 279 | @Callable 280 | func set_achievement_progress(achievementID: String, percentComplete: Float, onComplete: Callable) { 281 | setAchievementProgress(achievementID: achievementID, percentComplete: percentComplete, onComplete: onComplete) 282 | } 283 | 284 | @Callable 285 | func report_achievement_progress(onComplete: Callable) { 286 | reportAchievementProgress(onComplete: onComplete) 287 | } 288 | 289 | @Callable 290 | func get_achievement(achievementID: String, onComplete: Callable) { 291 | getAchievement(achievementID: achievementID, onComplete: onComplete) 292 | } 293 | 294 | @Callable 295 | func get_achievement_description(achievementID: String, onComplete: Callable) { 296 | getAchievementDescription(achievementID: achievementID, onComplete: onComplete) 297 | } 298 | 299 | @Callable 300 | func get_achievements(onComplete: Callable) { 301 | getAchievements(onComplete: onComplete) 302 | } 303 | 304 | @Callable 305 | func get_achievement_descriptions(onComplete: Callable) { 306 | getAchievementDescriptions(onComplete: onComplete) 307 | } 308 | 309 | @Callable 310 | func reset_achievements(onComplete: Callable) { 311 | resetAchievements(onComplete: onComplete) 312 | } 313 | 314 | @Callable 315 | func show_achievements_overlay(onClose: Callable) { 316 | showAchievementsOverlay(onClose: onClose) 317 | } 318 | 319 | @Callable 320 | func show_achievement_overlay(achievementdID: String, onClose: Callable) { 321 | showAchievementOverlay(achievementdID: achievementdID, onClose: onClose) 322 | } 323 | 324 | // MARK: Challenges 325 | 326 | @Callable 327 | func load_received_challenges(onComplete: Callable) { 328 | loadReceivedChallenges(onComplete: onComplete) 329 | } 330 | 331 | @Callable 332 | func load_challengable_players(onComplete: Callable) { 333 | loadChallengablePlayers(onComplete: onComplete) 334 | } 335 | 336 | @Callable 337 | func issue_score_challenge(leaderboardID: String, receivers: [String], message: String, onComplete: Callable) { 338 | issueScoreChallenge( 339 | leaderboardID: leaderboardID, 340 | receivers: receivers, 341 | message: message, 342 | onComplete: onComplete 343 | ) 344 | } 345 | 346 | @Callable 347 | func decline_challenge(challengeID: Int, onComplete: Callable) { 348 | declineChallenge(challengeID: challengeID, onComplete: onComplete) 349 | } 350 | 351 | @Callable 352 | func show_challenges_overlay(onClose: Callable) { 353 | showChallengesOverlay(onClose: onClose) 354 | } 355 | 356 | // MARK: Friends 357 | 358 | @Callable 359 | func load_friends(includeImages: Bool, onComplete: Callable) { 360 | loadFriends(onComplete: onComplete, includeImages: includeImages) 361 | } 362 | 363 | @Callable 364 | func load_recent_players(includeImages: Bool, onComplete: Callable) { 365 | loadRecentPlayers(onComplete: onComplete, includeImages: includeImages) 366 | } 367 | 368 | @Callable 369 | func load_friend_picture(gamePlayerID: String, onComplete: Callable) { 370 | loadFriendPicture(gamePlayerID: gamePlayerID, onComplete: onComplete) 371 | } 372 | 373 | @Callable 374 | func can_access_friends(onComplete: Callable) { 375 | canAccessFriends(onComplete: onComplete) 376 | } 377 | 378 | @Callable 379 | func show_friends_overlay(onClose: Callable) { 380 | showFriendsOverlay(onClose: onClose) 381 | } 382 | 383 | @Callable 384 | func show_friend_request_creator() { 385 | showFriendRequestCreator() 386 | } 387 | 388 | // MARK: Leaderboards 389 | 390 | @Callable 391 | func submit_score(score: Int, leaderboardIDs: [String], onComplete: Callable) { 392 | submitScore(score, leaderboardIDs: leaderboardIDs, context: 0, onComplete: onComplete) 393 | } 394 | 395 | @Callable 396 | func submit_score_with_context(score: Int, leaderboardIDs: [String], context: Int, onComplete: Callable) { 397 | submitScore(score, leaderboardIDs: leaderboardIDs, context: context, onComplete: onComplete) 398 | } 399 | 400 | @Callable 401 | func get_global_scores(leaderboardID: String, start: Int, length: Int, onComplete: Callable) { 402 | getGlobalScores(leaderboardID: leaderboardID, start: start, length: length, onComplete: onComplete) 403 | } 404 | 405 | @Callable 406 | func get_friends_scores(leaderboardID: String, start: Int, length: Int, onComplete: Callable) { 407 | getFriendsScores(leaderboardID: leaderboardID, start: start, length: length, onComplete: onComplete) 408 | } 409 | 410 | @Callable 411 | func get_previous_occurance(leaderboardID: String, start: Int, length: Int, onComplete: Callable) { 412 | getPreviousOccurance(leaderboardID: leaderboardID, start: start, length: length, onComplete: onComplete) 413 | } 414 | 415 | @Callable 416 | func get_previous_friends_occurance(leaderboardID: String, start: Int, length: Int, onComplete: Callable) { 417 | getPreviousFriendsOccurance(leaderboardID: leaderboardID, start: start, length: length, onComplete: onComplete) 418 | } 419 | 420 | @Callable 421 | func get_local_player_entry(leaderboardID: String, onComplete: Callable) { 422 | getLocalPlayerEntry(loaderboardID: leaderboardID, onComplete: onComplete) 423 | } 424 | 425 | @Callable 426 | func get_previous_local_player_entry(leaderboardID: String, onComplete: Callable) { 427 | getPreviousLocalPlayerEntry(loaderboardID: leaderboardID, onComplete: onComplete) 428 | } 429 | 430 | @Callable 431 | func show_leaderboards_overlay(onClose: Callable) { 432 | showLeaderboardsOverlay(onClose: onClose) 433 | } 434 | 435 | @Callable 436 | func show_leaderboard_overlay(leaderboardID: String, onClose: Callable) { 437 | showLeaderboardOverlay(leaderboardID: leaderboardID, onClose: onClose) 438 | } 439 | 440 | // MARK: Invites 441 | 442 | @Callable 443 | func get_invite(withIndex index: Int, onComplete: Callable) { 444 | getInvite(withIndex: index, onComplete: onComplete) 445 | } 446 | 447 | @Callable 448 | func get_invites(onComplete: Callable) { 449 | getInvites(onComplete: onComplete) 450 | } 451 | 452 | @Callable 453 | func remove_invite(withIndex index: Int) -> Bool { 454 | removeInvite(withIndex: index) 455 | } 456 | } 457 | -------------------------------------------------------------------------------- /Sources/GameCenter/GameCenterAchievement.swift: -------------------------------------------------------------------------------- 1 | import GameKit 2 | import SwiftGodot 3 | 4 | #if canImport(Foundation) 5 | import Foundation 6 | #endif 7 | 8 | /// Holds Achievement data in a Godot friendly format 9 | @Godot 10 | class GameCenterAchievement: RefCounted { 11 | @Export var identifier: String? 12 | @Export var player: GameCenterPlayer? 13 | 14 | @Export var isCompleted: Bool = false 15 | @Export var percentComplete: Float? 16 | 17 | @Export var lastReportedDate: Double? 18 | 19 | convenience init(_ achievement: GKAchievement) { 20 | self.init() 21 | 22 | self.identifier = achievement.identifier 23 | self.player = GameCenterPlayer(achievement.player) 24 | 25 | self.percentComplete = Float(achievement.percentComplete) 26 | self.isCompleted = achievement.isCompleted 27 | 28 | #if canImport(Foundation) 29 | // In order to read Date we need foundation, otherwise we crash 30 | self.lastReportedDate = achievement.lastReportedDate.timeIntervalSince1970 31 | #endif 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/GameCenter/GameCenterAchievementChallenge.swift: -------------------------------------------------------------------------------- 1 | import GameKit 2 | import SwiftGodot 3 | 4 | /// Holds AchievementChallenge data in a Godot friendly format 5 | @Godot 6 | class GameCenterAchievementChallenge: GameCenterChallenge { 7 | @Export var achievement: GameCenterAchievement? 8 | 9 | convenience init(achievementChallenge challenge: GKAchievementChallenge) { 10 | self.init(challenge: challenge) 11 | 12 | if let achievement = challenge.achievement { 13 | self.achievement = GameCenterAchievement(achievement) 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/GameCenter/GameCenterAchievementDescription.swift: -------------------------------------------------------------------------------- 1 | import GameKit 2 | import SwiftGodot 3 | 4 | /// Holds AchievementDescription data in a Godot friendly format 5 | @Godot 6 | class GameCenterAchievementDescription: RefCounted { 7 | @Export var identifier: String? 8 | @Export var title: String? 9 | 10 | @Export var unachievedDescription: String? 11 | @Export var achievedDescription: String? 12 | 13 | @Export var maximumPoints: Int? 14 | 15 | @Export var isHidden: Bool = false 16 | @Export var isReplayable: Bool = false 17 | 18 | @Export var rarityPercent: Float? 19 | 20 | convenience init(_ description: GKAchievementDescription) { 21 | self.init() 22 | 23 | self.identifier = description.identifier 24 | self.title = description.title 25 | 26 | self.unachievedDescription = description.unachievedDescription 27 | self.achievedDescription = description.achievedDescription 28 | 29 | self.maximumPoints = description.maximumPoints 30 | 31 | self.isHidden = description.isHidden 32 | self.isReplayable = description.isReplayable 33 | 34 | if #available(iOS 17, macOS 14, *), let rarityPercent = description.rarityPercent { 35 | self.rarityPercent = Float(rarityPercent) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/GameCenter/GameCenterChallenge.swift: -------------------------------------------------------------------------------- 1 | import GameKit 2 | import SwiftGodot 3 | 4 | #if canImport(Foundation) 5 | import Foundation 6 | #endif 7 | 8 | /// Holds Challenge data in a Godot friendly format 9 | @Godot 10 | class GameCenterChallenge: RefCounted { 11 | 12 | enum ChallengeState: Int, CaseIterable { 13 | case invalid = 0 14 | case pending = 1 15 | case completed = 2 16 | case declined = 3 17 | } 18 | 19 | @Export var challengeID: Int? 20 | 21 | @Export var issuingPlayer: GameCenterPlayer? 22 | @Export var receivingPlayer: GameCenterPlayer? 23 | 24 | @Export var message: String? 25 | 26 | @Export(.enum) var state: ChallengeState = .pending 27 | 28 | @Export var issueDate: Double? 29 | @Export var completionDate: Double? 30 | 31 | convenience init(challenge: GKChallenge) { 32 | self.init() 33 | 34 | self.challengeID = challenge.getChallengeID() 35 | if let issuingPlayer = challenge.issuingPlayer { 36 | self.issuingPlayer = GameCenterPlayer(issuingPlayer) 37 | } 38 | 39 | if let receivingPlayer = challenge.receivingPlayer { 40 | self.receivingPlayer = GameCenterPlayer(receivingPlayer) 41 | } 42 | 43 | self.message = challenge.message 44 | self.state = ChallengeState(rawValue: challenge.state.rawValue) ?? ChallengeState.pending 45 | 46 | #if canImport(Foundation) 47 | // In order to read Date we need foundation, otherwise we crash 48 | 49 | self.issueDate = challenge.issueDate.timeIntervalSince1970 50 | if let completionDate = challenge.completionDate { 51 | self.completionDate = completionDate.timeIntervalSince1970 52 | } 53 | #endif 54 | } 55 | } 56 | 57 | extension GameCenterChallenge { 58 | static func parseChallenge(_ challenge: GKChallenge) -> GameCenterChallenge { 59 | if let scoreChallenge = challenge as? GKScoreChallenge { 60 | return GameCenterScoreChallenge(scoreChallenge: scoreChallenge) 61 | } else if let achievementChallenge = challenge as? GKAchievementChallenge { 62 | return GameCenterAchievementChallenge(achievementChallenge: achievementChallenge) 63 | } else { 64 | return GameCenterChallenge(challenge: challenge) 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Sources/GameCenter/GameCenterInvite.swift: -------------------------------------------------------------------------------- 1 | import GameKit 2 | import SwiftGodot 3 | 4 | /// Holds invite data in a Godot friendly format 5 | @Godot 6 | class GameCenterInvite: RefCounted { 7 | @Export var sender: GameCenterPlayer? 8 | @Export var playerAttributes: Int = 0 9 | @Export var playerGroup: Int = 0 10 | @Export var isHosted: Bool = false 11 | 12 | convenience init(_ invite: GKInvite) { 13 | self.init() 14 | 15 | self.sender = GameCenterPlayer(invite.sender) 16 | self.playerAttributes = Int(invite.playerAttributes) // Not ideal, but godot doesn't support UInt32 17 | self.playerGroup = invite.playerGroup 18 | self.isHosted = invite.isHosted 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/GameCenter/GameCenterLeaderboardEntry.swift: -------------------------------------------------------------------------------- 1 | import GameKit 2 | import SwiftGodot 3 | 4 | #if canImport(Foundation) 5 | import Foundation 6 | #endif 7 | 8 | /// Holds Leaderboard data in a Godot friendly format 9 | @Godot 10 | class GameCenterLeaderboardEntry: RefCounted { 11 | @Export var context: Int? 12 | @Export var formattedScore: String = "" 13 | 14 | @Export var rank: Int = 0 15 | @Export var score: Int = 0 16 | 17 | @Export var player: GameCenterPlayer? 18 | 19 | @Export var date: Double? 20 | @Export var image: Image? 21 | 22 | convenience init(entry: GKLeaderboard.Entry, image: Image? = nil, excludeDate: Bool = false) { 23 | self.init() 24 | 25 | self.context = entry.context 26 | self.formattedScore = entry.formattedScore 27 | 28 | self.rank = entry.rank 29 | self.score = entry.score 30 | 31 | self.player = GameCenterPlayer(entry.player) 32 | self.image = image 33 | 34 | #if canImport(Foundation) 35 | // In order to read Date we need foundation, otherwise we crash 36 | // We also crash when reading date from entries within challenges for some reason 37 | if !excludeDate { 38 | //self.date = entry.date.timeIntervalSince1970 39 | } 40 | #endif 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/GameCenter/GameCenterMatchmakingProtocol.swift: -------------------------------------------------------------------------------- 1 | import GameKit 2 | 3 | protocol GameCenterMatchmakingProtocol { 4 | func match(_ match: GKMatch, player: GKPlayer, didChange state: GKPlayerConnectionState) 5 | func match(_ match: GKMatch, didFailWithError error: Error?) 6 | func match(_ match: GKMatch, shouldReinviteDisconnectedPlayer player: GKPlayer) -> Bool 7 | func match(_ match: GKMatch, didReceive data: Data, fromRemotePlayer player: GKPlayer) 8 | func match( 9 | _ match: GKMatch, 10 | didReceive data: Data, 11 | forRecipient recipient: GKPlayer, 12 | fromRemotePlayer player: GKPlayer 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /Sources/GameCenter/GameCenterMultiplayerPeer+GameData.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import GameKit 3 | import SwiftGodot 4 | 5 | struct DataPacket: Codable { 6 | var peerData: PeerData? 7 | var bytes: [UInt8]? 8 | } 9 | 10 | extension GameCenterMultiplayerPeer { 11 | func encode(peerData: PeerData) -> Data? { 12 | let data: DataPacket = DataPacket(peerData: peerData) 13 | return encode(dataPacket: data) 14 | } 15 | 16 | func encode(packedByteArray: PackedByteArray) -> Data? { 17 | let bytes = [UInt8](packedByteArray) 18 | let data: DataPacket = DataPacket(bytes: bytes) 19 | return encode(dataPacket: data) 20 | } 21 | 22 | func encode(byteArray: [UInt8]) -> Data? { 23 | let data: DataPacket = DataPacket(bytes: byteArray) 24 | return encode(dataPacket: data) 25 | } 26 | 27 | func encode(dataPacket: DataPacket) -> Data? { 28 | let encoder: PropertyListEncoder = PropertyListEncoder() 29 | encoder.outputFormat = .binary 30 | 31 | do { 32 | let data: Data = try encoder.encode(dataPacket) 33 | return data 34 | } catch { 35 | GD.pushError("Failed to encode data. Error: \(error)") 36 | return nil 37 | } 38 | } 39 | 40 | func decode(dataPacket: Data) -> DataPacket? { 41 | do { 42 | return try PropertyListDecoder().decode(DataPacket.self, from: dataPacket) 43 | } catch { 44 | GD.pushError("Failed to decode data. Error: \(error)") 45 | return nil 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/GameCenter/GameCenterMultiplayerPeer+MatchmakingProtocol.swift: -------------------------------------------------------------------------------- 1 | import GameKit 2 | import SwiftGodot 3 | 4 | extension GameCenterMultiplayerPeer: GameCenterMatchmakingProtocol { 5 | // Connection flow 6 | // The Godot MultiplayerPeerExtension system has some requirements that we have to work around, so the connection flow looks something like this: 7 | // connect to player -> send id + initiative roll -> add player locally -> if enough players -> decide host -> if we fail to decide host, use initiative roll -> connection complete 8 | 9 | func match(_ match: GKMatch, player: GKPlayer, didChange state: GKPlayerConnectionState) { 10 | switch state { 11 | case .connected: 12 | // Send my peer data to player 13 | if let localPeerData: PeerData = getPeerData(for: GKLocalPlayer.local) { 14 | sendPeerData(localPeerData, to: [player], with: .reliable) 15 | } else { 16 | GD.pushError("[GameCenterPeer] Found no local peerData to send") 17 | } 18 | 19 | case .disconnected: 20 | removePlayer(player) 21 | 22 | default: 23 | GD.pushWarning("[GameCenterPeer] \(player.displayName) Connection Unknown \(state)") 24 | } 25 | } 26 | 27 | func match(_ match: GKMatch, didFailWithError error: Error?) { 28 | if error != nil { 29 | GD.pushError("[GameCenterPeer] Match failed with error: \(error)") 30 | } else { 31 | GD.pushError("[GameCenterPeer] Match failed with unknown error") 32 | } 33 | 34 | self.matchmakingStatusUpdated.emit(MatchmakingStatus.failed.rawValue) 35 | } 36 | 37 | func match(_ match: GKMatch, shouldReinviteDisconnectedPlayer player: GKPlayer) -> Bool { 38 | GD.print("[GameCenterPeer] Disconnected, should reinvite: \(shouldReinvite)") 39 | if shouldReinvite { 40 | shouldReinvite = false 41 | return true 42 | } else { 43 | return false 44 | } 45 | } 46 | 47 | func match(_ match: GKMatch, didReceive data: Data, fromRemotePlayer player: GKPlayer) { 48 | do { 49 | let gameData = decode(dataPacket: data) 50 | 51 | if let peerData: PeerData = gameData?.peerData { 52 | // Player sent peerData 53 | //GD.print("<- RECEIVED\t peerData from \(player.displayName), id: \(peerData.id!)") 54 | setPeerData(for: player, data: peerData) 55 | 56 | if match.expectedPlayerCount == 0 { 57 | decideHost() 58 | } 59 | } else if let data: [UInt8] = gameData?.bytes { 60 | if let fromPeer: Int32 = getPeerID(for: player) { 61 | //GD.print("<- RECEIVED\t gameData(\(data.count) bytes) from \(player.displayName) (id: \(fromPeer))") 62 | let packet: Packet = Packet(data: data, from: fromPeer, channel: 0) 63 | incomingPackets.append(packet) 64 | } else { 65 | GD.pushError( 66 | "[GameCenterPeer] ERROR: Got data from unknown peer, peerData might have gotten lost. Closing connection" 67 | ) 68 | self.inviteStatusUpdated.emit(InviteStatus.handshakeFailed.rawValue, player.displayName) 69 | shouldReinvite = true 70 | disconnect() 71 | } 72 | } else { 73 | GD.pushWarning("[GameCenterPeer] Got unhandled data packet") 74 | } 75 | } catch { 76 | GD.pushError("[GameCenterPeer] Error when reciving data \(error)") 77 | } 78 | } 79 | 80 | func match( 81 | _ match: GKMatch, 82 | didReceive data: Data, 83 | forRecipient recipient: GKPlayer, 84 | fromRemotePlayer player: GKPlayer 85 | ) { 86 | if recipient == GKLocalPlayer.local { 87 | self.match(match, didReceive: data, fromRemotePlayer: player) 88 | } else { 89 | // TODO: Handle this case, are we a relay? 90 | } 91 | } 92 | 93 | func invitationResponseHandler(player: GKPlayer, response: GKInviteRecipientResponse) { 94 | switch response { 95 | case .accepted: 96 | self.inviteStatusUpdated.emit(InviteStatus.accepted.rawValue, player.displayName) 97 | case .declined: 98 | self.inviteStatusUpdated.emit(InviteStatus.declined.rawValue, player.displayName) 99 | case .failed: 100 | self.inviteStatusUpdated.emit(InviteStatus.failed.rawValue, player.displayName) 101 | case .incompatible: 102 | self.inviteStatusUpdated.emit(InviteStatus.incompatible.rawValue, player.displayName) 103 | case .unableToConnect: 104 | self.inviteStatusUpdated.emit(InviteStatus.unableToConnect.rawValue, player.displayName) 105 | case .noAnswer: 106 | self.inviteStatusUpdated.emit(InviteStatus.timeout.rawValue, player.displayName) 107 | } 108 | } 109 | 110 | // MARK: MatchDelegate 111 | 112 | // This class is just an intermediate because a @Godot class doesn't inherit from NSObject 113 | // which is required for GKMatchDelegate and GKLocalPlayerListener 114 | // TODO: Move GKLocalPlayerListener elsewhere 115 | class MatchDelegate: NSObject, GKMatchDelegate { 116 | var delegate: GameCenterMatchmakingProtocol 117 | 118 | required init(withDelegate delegate: GameCenterMatchmakingProtocol) { 119 | self.delegate = delegate 120 | super.init() 121 | } 122 | 123 | func match(_ match: GKMatch, player: GKPlayer, didChange state: GKPlayerConnectionState) { 124 | delegate.match(match, player: player, didChange: state) 125 | } 126 | 127 | func match(_ match: GKMatch, didFailWithError error: Error?) { 128 | delegate.match(match, didFailWithError: error) 129 | } 130 | 131 | func match(_ match: GKMatch, shouldReinviteDisconnectedPlayer player: GKPlayer) -> Bool { 132 | return delegate.match(match, shouldReinviteDisconnectedPlayer: player) 133 | } 134 | 135 | func match(_ match: GKMatch, didReceive data: Data, fromRemotePlayer player: GKPlayer) { 136 | delegate.match(match, didReceive: data, fromRemotePlayer: player) 137 | } 138 | 139 | func match( 140 | _ match: GKMatch, 141 | didReceive data: Data, 142 | forRecipient recipient: GKPlayer, 143 | fromRemotePlayer player: GKPlayer 144 | ) { 145 | delegate.match(match, didReceive: data, forRecipient: recipient, fromRemotePlayer: player) 146 | } 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /Sources/GameCenter/GameCenterMultiplayerPeer+PeerData.swift: -------------------------------------------------------------------------------- 1 | import GameKit 2 | import SwiftGodot 3 | 4 | struct PeerData: Codable { 5 | var id: Int32? 6 | var initiative: UInt32? 7 | } 8 | 9 | extension GameCenterMultiplayerPeer { 10 | 11 | func setPeerData(for player: GKPlayer, data: PeerData) { 12 | setPeerData(for: player.gamePlayerID, data: data) 13 | } 14 | 15 | func setPeerData(for gamePlayerID: String, data: PeerData) { 16 | peerMap[gamePlayerID] = data 17 | } 18 | 19 | func getPeerData(for player: GKPlayer) -> PeerData? { 20 | return getPeerData(for: player.gamePlayerID) 21 | } 22 | 23 | func getPeerData(for gamePlayerID: String) -> PeerData? { 24 | return peerMap[gamePlayerID] 25 | } 26 | 27 | func getNameFor(for peerID: Int32) -> String { 28 | if peerID == 0 { 29 | return "All players" 30 | } 31 | if peerID < 0 { 32 | var exclude: Int32 = -peerID 33 | if let currentMatch = match { 34 | for player in currentMatch.players { 35 | if let exclude = getPeerData(for: player)?.id { 36 | return "All players, excluding \(player.displayName)" 37 | } 38 | } 39 | } 40 | 41 | return "All players, excluding Unknown" 42 | } 43 | 44 | if peerID == uniqueID { 45 | return GameKit.GKLocalPlayer.local.displayName 46 | } 47 | 48 | if let currentMatch = match { 49 | for player in currentMatch.players { 50 | if let peerID = getPeerData(for: player)?.id { 51 | return player.displayName 52 | } 53 | } 54 | } 55 | 56 | return "Unknown" 57 | } 58 | 59 | func setPeerID(for player: GKPlayer, id: Int32) { 60 | setPeerID(for: player.gamePlayerID, id: id) 61 | } 62 | 63 | func setPeerID(for gamePlayerID: String, id: Int32) { 64 | if let peerData = peerMap[gamePlayerID] { 65 | peerMap[gamePlayerID] = PeerData(id: id, initiative: peerData.initiative) 66 | } else { 67 | peerMap[gamePlayerID] = PeerData(id: id) 68 | } 69 | } 70 | 71 | func getPeerID(for player: GKPlayer) -> Int32? { 72 | return getPeerID(for: player.gamePlayerID) 73 | } 74 | 75 | func getPeerID(for gamePlayerID: String) -> Int32? { 76 | return peerMap[gamePlayerID]?.id 77 | } 78 | 79 | func removePeer(withID gamePlayerID: String) -> PeerData? { 80 | return peerMap.removeValue(forKey: gamePlayerID) 81 | } 82 | 83 | func getInitiative(for player: GKPlayer) -> UInt32? { 84 | return getInitiative(for: player.gamePlayerID) 85 | } 86 | 87 | func getInitiative(for gamePlayerID: String) -> UInt32? { 88 | return peerMap[gamePlayerID]?.initiative 89 | } 90 | 91 | func getPlayerWithHighestInitiative() -> GKPlayer? { 92 | var highestInitiative: UInt32 = 0 93 | var playerID: String = "" 94 | 95 | for key: String in peerMap.keys { 96 | if let initiative = peerMap[key]?.initiative { 97 | if initiative > highestInitiative { 98 | highestInitiative = initiative 99 | playerID = key 100 | } 101 | } 102 | } 103 | 104 | return getPlayerWithID(gamePlayerID: playerID) 105 | } 106 | 107 | func getPlayerWithID(gamePlayerID: String) -> GKPlayer? { 108 | if let players: [GKPlayer] = match?.players { 109 | for player: GKPlayer in players { 110 | if player.gamePlayerID == gamePlayerID { 111 | return player 112 | } 113 | } 114 | } 115 | 116 | return nil 117 | } 118 | 119 | func getPlayerWithID(peerID: Int32) -> GKPlayer? { 120 | if let players: [GKPlayer] = match?.players { 121 | for player: GKPlayer in players { 122 | if getPeerID(for: player.gamePlayerID) == peerID { 123 | return player 124 | } 125 | } 126 | } 127 | 128 | return nil 129 | } 130 | 131 | func getPlayerCount() -> Int { 132 | return peerMap.count 133 | } 134 | 135 | func clearPeers() { 136 | peerMap.removeAll() 137 | } 138 | 139 | func generateInitiative() -> UInt32 { 140 | return UInt32.random(in: 1.. NOTE: This has no parameters to be compatible with the built-in `connected_to_server` signal 44 | @Signal var serverCreated: SimpleSignal 45 | /// Called when the `MatchmakingStatus` changes 46 | @Signal var matchmakingStatusUpdated: SignalWithArguments 47 | /// Called when `InviteStatus` is updated 48 | @Signal var inviteStatusUpdated: SignalWithArguments 49 | /// Called when host changes 50 | @Signal var hostChanged: SignalWithArguments 51 | 52 | struct Packet { 53 | var data: [UInt8] 54 | var from: Int32 = 0 55 | var channel: Int32 = 0 56 | var transferMode: MultiplayerPeer.TransferMode = .reliable 57 | } 58 | 59 | var delegate: MatchDelegate? 60 | 61 | var activeMode: Mode = .none 62 | var uniqueID: Int32 = 0 63 | var targetPeer: Int32 = 0 64 | 65 | var connectionStatus: MultiplayerPeer.ConnectionStatus = .disconnected 66 | 67 | var currentTransferMode: MultiplayerPeer.TransferMode = .reliable 68 | var currentTransferChannel: Int32 = 0 69 | var refuseConnections: Bool = false 70 | 71 | var incomingPackets: [Packet] = [] 72 | var currentPacket: Packet? 73 | 74 | var peerMap: [String: PeerData] = [:] // Maps gamePlayerID to PeerData 75 | var hostOriginalID: Int32? 76 | var match: GKMatch? 77 | var isMatching: Bool = false 78 | var shouldReinvite: Bool = false 79 | 80 | required init(_ context: InitContext) { 81 | super.init(context) 82 | connectionStatus = .connecting 83 | delegate = MatchDelegate(withDelegate: self) 84 | } 85 | 86 | // MARK: Godot callables 87 | 88 | /// Start a game by inviting players 89 | /// 90 | /// - Parameters: 91 | /// - playerIDs: An array of playerIDs to invite 92 | @Callable(autoSnakeCase: true) 93 | func invitePlayers(playerIDs: [String]) { 94 | Task { 95 | if isMatching { 96 | stopMatchmaking() 97 | } 98 | 99 | isMatching = true 100 | connectionStatus = .connecting 101 | 102 | // Generate PeerData 103 | // Note that generateUniqueID generates a UInt32 but they always request an Int32, so it's potentially 104 | // truncated here which might cause issues 105 | // TODO: Make sure we handle duplicate id's 106 | setPeerData( 107 | for: GKLocalPlayer.local, 108 | data: PeerData(id: Int32(generateUniqueId()), initiative: generateInitiative()) 109 | ) 110 | 111 | let request: GKMatchRequest = GKMatchRequest() 112 | let players: [GKPlayer] 113 | 114 | do { 115 | let players: [GKPlayer] = try await GKLocalPlayer.local.loadFriends(identifiedBy: playerIDs) 116 | request.recipients = players 117 | request.recipientResponseHandler = invitationResponseHandler 118 | } catch { 119 | GD.pushError("[GameCenterPeer] Could not find player. Error: \(error)") 120 | self.inviteStatusUpdated.emit(InviteStatus.notFound.rawValue, "") 121 | return 122 | } 123 | 124 | do { 125 | match = try await GKMatchmaker.shared().findMatch(for: request) 126 | match?.delegate = self.delegate 127 | } catch { 128 | GD.pushError("[GameCenterPeer] Failed to invite player. Error: \(error)") 129 | self.inviteStatusUpdated.emit(InviteStatus.timeout.rawValue, "") 130 | return 131 | } 132 | } 133 | } 134 | 135 | /// Cancel a pending invite to a specific player 136 | @Callable(autoSnakeCase: true) 137 | func cancelInvite(to playerID: String) { 138 | Task { 139 | do { 140 | let players: [GKPlayer] = try await GKLocalPlayer.local.loadFriends(identifiedBy: [playerID]) 141 | for player in players { 142 | GKMatchmaker.shared().cancelPendingInvite(to: player) 143 | } 144 | } catch { 145 | GD.pushError("[GameCenterPeer] Could not find player. Error: \(error)") 146 | self.inviteStatusUpdated.emit(InviteStatus.notFound.rawValue, "") 147 | return 148 | } 149 | } 150 | } 151 | 152 | /// Cancel all pending invites 153 | @Callable(autoSnakeCase: true) 154 | func cancelInvites() { 155 | GKMatchmaker.shared().cancel() 156 | } 157 | 158 | /// Join a game recieved through an invite 159 | /// 160 | /// > NOTE: You need to listen to the ``invite_received`` signal in the GameCenter class 161 | /// in order to get the invite index. 162 | /// 163 | /// - Parameters: 164 | /// - inviteIndex: The index of the invite you wish to join. 165 | @Callable(autoSnakeCase: true) 166 | func joinGame(inviteIndex: Int) { 167 | Task { 168 | if isMatching { 169 | stopMatchmaking() 170 | } 171 | 172 | connectionStatus = .connecting 173 | 174 | // Generate PeerData 175 | // Note that generateUniqueID generates a UInt32 but they always request an Int32, so it's potentially 176 | // truncated here which might cause issues 177 | // TODO: Make sure we handle duplicate id's 178 | setPeerData( 179 | for: GKLocalPlayer.local, 180 | data: PeerData(id: Int32(generateUniqueId()), initiative: generateInitiative()) 181 | ) 182 | 183 | if let invite: GKInvite = GameCenter.instance?.getInvite(withIndex: inviteIndex) { 184 | do { 185 | isMatching = true 186 | match = try await GKMatchmaker.shared().match(for: invite) 187 | match?.delegate = self.delegate 188 | 189 | GameCenter.instance?.removeInvite(withIndex: inviteIndex) 190 | } catch { 191 | GD.pushError("[GameCenterPeer] Unable to join game: \(error)") 192 | self.inviteStatusUpdated.emit(InviteStatus.timeout.rawValue, "") 193 | 194 | // NOTE: Removing the invite here will prevent retrying it, which might not be ideal 195 | GameCenter.instance?.removeInvite(withIndex: inviteIndex) 196 | return 197 | } 198 | } else { 199 | GD.pushError("[GameCenterPeer] Unable to join game: No invite at index \(inviteIndex)") 200 | self.inviteStatusUpdated.emit(InviteStatus.invalid.rawValue, "") 201 | return 202 | } 203 | } 204 | } 205 | 206 | /// Start matchmaking 207 | /// 208 | /// - Parameters: 209 | /// - minPlayers: The minimum amount of players required to start a game. 210 | /// - maxPlayers: The maximum amount of players. 211 | /// - playerGroup: A number identifying a subset of players invited to join a match. This number must match for players to find eachother 212 | /// - playerAttributes: A mask that specifies the role that the local player would like to play in the game. 213 | @Callable(autoSnakeCase: true) 214 | func startMatchmaking(minPlayers: Int, maxPlayers: Int, playerGroup: Int, playerAttributes: Int) { 215 | Task { 216 | if isMatching { 217 | stopMatchmaking() 218 | } 219 | 220 | connectionStatus = .connecting 221 | 222 | // Generate PeerData 223 | // Note that generateUniqueID generates a UInt32 but they always request an Int32, so it's potentially 224 | // truncated here which might cause issues 225 | // TODO: Make sure we handle duplicate id's 226 | setPeerData( 227 | for: GKLocalPlayer.local, 228 | data: PeerData(id: Int32(generateUniqueId()), initiative: generateInitiative()) 229 | ) 230 | 231 | let request: GKMatchRequest = GKMatchRequest() 232 | request.minPlayers = minPlayers 233 | request.maxPlayers = maxPlayers 234 | request.playerGroup = playerGroup 235 | request.playerAttributes = UInt32(playerAttributes) 236 | 237 | do { 238 | isMatching = true 239 | match = try await GKMatchmaker.shared().findMatch(for: request) 240 | match?.delegate = self.delegate 241 | } catch GKError.cancelled { 242 | // Handling user cancelled separately here because trying to emit a signal here causes a crash 243 | return 244 | } catch { 245 | // TODO: Handle all types of errors here, like Code 13: Matchmaking already in progress 246 | GD.pushError("[GameCenterPeer] Unable to find players: \(error)") 247 | self.matchmakingStatusUpdated.emit(MatchmakingStatus.timeout.rawValue) 248 | return 249 | } 250 | 251 | isMatching = false 252 | if match != nil { 253 | GKMatchmaker.shared().finishMatchmaking(for: match!) 254 | } 255 | 256 | self.matchmakingStatusUpdated.emit(MatchmakingStatus.successful.rawValue) 257 | } 258 | } 259 | 260 | /// Cancel matchmaking 261 | @Callable(autoSnakeCase: true) 262 | func stopMatchmaking() { 263 | isMatching = false 264 | GKMatchmaker.shared().cancel() 265 | GD.print("[GameCenterPeer] Stopped matchmaking") 266 | } 267 | 268 | /// Get current player activity 269 | /// 270 | /// Finds the number of players, across player groups, who recently requested a match. 271 | /// 272 | /// - Parameters: 273 | /// - onComplete: Callback with parameter: (error: Variant, players: Variant) -> (error: Int, players: Int) 274 | @Callable(autoSnakeCase: true) 275 | func getPlayerActivity(onComplete: Callable) { 276 | Task { 277 | // For some reason try await GKMatchmaker.shared().queryActivity() does not work, even though the docs say it should 278 | GKMatchmaker.shared().queryActivity { players, error in 279 | if error != nil { 280 | GD.pushError("[GameCenterPeer] Failed to get matchmaking activity. Error \(error!)") 281 | 282 | onComplete.callDeferred( 283 | Variant(MultiplayerPeerError.failedToGetPlayerActivity.rawValue), 284 | Variant(0) 285 | ) 286 | return 287 | } 288 | onComplete.callDeferred(Variant(OK), Variant(players)) 289 | } 290 | } 291 | } 292 | 293 | /// Get the current localPlayerID 294 | /// 295 | /// - Returns: The local player ID, or 0 if nothing is found 296 | @Callable(autoSnakeCase: true) 297 | func getLocalPlayerID() -> Int { 298 | return Int(getPeerID(for: GKLocalPlayer.local) ?? 0) 299 | } 300 | 301 | // MARK: MultiplayerPeer implementation 302 | 303 | override func _poll() { 304 | // We don't need polling since GKMatchDelegate supplies the data 305 | } 306 | 307 | override func _close() { 308 | stopMatchmaking() 309 | disconnect() 310 | } 311 | 312 | override func _disconnectPeer(pPeer: Int32, pForce: Bool) { 313 | GD.pushWarning("[GameCenterPeer] GKMatch is unable to disconnect players") 314 | } 315 | 316 | override func _getPacketScript() -> PackedByteArray { 317 | guard incomingPackets.count > 0 else { 318 | return PackedByteArray() 319 | } 320 | 321 | var currentPacket = incomingPackets.removeFirst() 322 | if currentPacket.from == hostOriginalID { 323 | currentPacket.from = HOST_ID 324 | } 325 | 326 | return PackedByteArray(currentPacket.data) 327 | } 328 | 329 | override func _putPacketScript(pBuffer: PackedByteArray) -> GodotError { 330 | if let currentMatch = match { 331 | do { 332 | let data = encode(packedByteArray: pBuffer) 333 | //GD.print("-> SENDING\t gameData(\(data!.count ?? -1) bytes) to \(getNameFor(for: targetPeer)) (id: \(targetPeer)), \(getTransferMode())") 334 | if activeMode == .server { 335 | if targetPeer == 0 { 336 | // Send to all players 337 | try currentMatch.sendData(toAllPlayers: data!, with: getTransferMode()) 338 | 339 | } else if targetPeer < 0 { 340 | // Send to all but one 341 | var exclude: Int32 = -targetPeer 342 | 343 | var players: [GKPlayer] = [] 344 | for player: GKPlayer in currentMatch.players { 345 | if getPeerID(for: player) == exclude { 346 | continue 347 | } 348 | players.append(player) 349 | } 350 | 351 | try currentMatch.send(data!, to: players, dataMode: getTransferMode()) 352 | 353 | } else { 354 | // Send to specific player 355 | if let player: GKPlayer = getPlayerWithID(peerID: targetPeer) { 356 | try currentMatch.send(data!, to: [player], dataMode: getTransferMode()) 357 | } 358 | } 359 | return GodotError.ok 360 | } else { 361 | if let player: GKPlayer = getPlayerWithID(peerID: HOST_ID) { 362 | try currentMatch.send(data!, to: [player], dataMode: getTransferMode()) 363 | } 364 | return GodotError.ok 365 | } 366 | 367 | } catch { 368 | GD.pushError("[GameCenterPeer] Failed to send data. Error \(error)") 369 | return GodotError.errConnectionError 370 | } 371 | } else { 372 | GD.pushError("[GameCenterPeer] Tried to send data before match was established.") 373 | return GodotError.errConnectionError 374 | } 375 | } 376 | 377 | override func _setTargetPeer(pPeer: Int32) { 378 | targetPeer = pPeer 379 | } 380 | 381 | override func _getAvailablePacketCount() -> Int32 { 382 | return Int32(incomingPackets.count) 383 | } 384 | 385 | override func _getPacketPeer() -> Int32 { 386 | if let packet = incomingPackets.first { 387 | if packet.from == hostOriginalID { 388 | return HOST_ID 389 | } 390 | 391 | return packet.from 392 | } 393 | 394 | return 0 395 | } 396 | 397 | override func _getPacketMode() -> MultiplayerPeer.TransferMode { 398 | if let packet = incomingPackets.first { 399 | return packet.transferMode 400 | } 401 | 402 | return .reliable 403 | } 404 | 405 | override func _getPacketChannel() -> Int32 { 406 | if let packet = incomingPackets.first { 407 | return packet.channel - RESERVED_CHANNELS + 1 408 | } 409 | 410 | return 0 411 | } 412 | 413 | override func _setTransferChannel(pChannel: Int32) { 414 | currentTransferChannel = pChannel 415 | } 416 | 417 | override func _getTransferChannel() -> Int32 { 418 | // A bug somewhere in the GDExtension implementations complains that this function isn't overridden 419 | return currentTransferChannel 420 | } 421 | 422 | override func _setTransferMode(pMode: MultiplayerPeer.TransferMode) { 423 | currentTransferMode = pMode 424 | } 425 | 426 | override func _getTransferMode() -> MultiplayerPeer.TransferMode { 427 | // A bug somewhere in the GDExtension implementations complains that this function isn't overridden 428 | return currentTransferMode 429 | } 430 | 431 | override func _setRefuseNewConnections(pEnable: Bool) { 432 | refuseConnections = pEnable 433 | } 434 | 435 | override func _isRefusingNewConnections() -> Bool { 436 | return refuseConnections 437 | } 438 | 439 | override func _isServer() -> Bool { 440 | return activeMode == .server 441 | } 442 | 443 | override func _isServerRelaySupported() -> Bool { 444 | return activeMode == .server || activeMode == .client 445 | } 446 | 447 | override func _getConnectionStatus() -> MultiplayerPeer.ConnectionStatus { 448 | return connectionStatus 449 | } 450 | 451 | override func _getMaxPacketSize() -> Int32 { 452 | return Int32.max 453 | } 454 | 455 | override func _getUniqueId() -> Int32 { 456 | return uniqueID 457 | } 458 | 459 | // MARK: Host management 460 | 461 | func decideHost() { 462 | Task { 463 | if let currentMatch = match { 464 | if let player = await currentMatch.chooseBestHostingPlayer() { 465 | // We got a new host 466 | self.setHost(player) 467 | 468 | } else if let player = self.getPlayerWithHighestInitiative() { 469 | // We need to pick a random host 470 | self.setHost(player) 471 | } 472 | } 473 | } 474 | } 475 | 476 | func setHost(_ host: GKPlayer) { 477 | //GD.print("[GameCenterPeer] Making \(host.displayName) the host (id: \(getPeerID(for: host)) -> \(HOST_ID))") 478 | hostOriginalID = getPeerID(for: host) 479 | 480 | self.hostChanged.emit(Int(hostOriginalID ?? 0), Int(HOST_ID)) 481 | setPeerID(for: host, id: HOST_ID) 482 | 483 | if host == GKLocalPlayer.local { 484 | activeMode = .server 485 | } else { 486 | activeMode = .client 487 | } 488 | 489 | finalizeMatch() 490 | } 491 | 492 | func finalizeMatch() { 493 | // When the host has been decided we can consider ourselves connected 494 | // The reason we do this is that we need to pick a player to be HOST_ID before then 495 | if let localPeerID = getPeerID(for: GKLocalPlayer.local) { 496 | // Setting the connectionStatus will trigger the connected_to_server event 497 | connectionStatus = .connected 498 | uniqueID = localPeerID 499 | 500 | // Because connected_to_server never triggers on servers (uniqueID == HOST_ID), 501 | // we need to let the host known that the connection is ready 502 | if uniqueID == HOST_ID { 503 | self.serverCreated.emit() 504 | } 505 | } else { 506 | GD.pushError("[GameCenterPeer] Failed to finalize match") 507 | connectionStatus = .disconnected 508 | close() 509 | 510 | self.matchmakingStatusUpdated.emit(MatchmakingStatus.failed.rawValue) 511 | } 512 | 513 | if let players = match?.players { 514 | for player in players { 515 | if let peerID = getPeerID(for: player) { 516 | emit(signal: SignalWith1Argument("peer_connected", argument1Name: "id"), Int(peerID)) 517 | } 518 | } 519 | } 520 | } 521 | 522 | func removePlayer(_ player: GKPlayer) { 523 | if let peerID: Int32 = getPeerID(for: player) { 524 | removePeer(withID: player.gamePlayerID) 525 | 526 | if peerID == HOST_ID && getLocalPlayerID() != peerID { 527 | GD.print("[GameCenterPeer] Host disconnected") 528 | disconnect() 529 | } else { 530 | GD.print("[GameCenterPeer] Player disconnected") 531 | emit(signal: SignalWith1Argument("peer_disconnected", argument1Name: "id"), Int(peerID)) 532 | } 533 | } else { 534 | GD.pushError("[GameCenterPeer] Tried to remove player but player wasn't mapped") 535 | } 536 | } 537 | 538 | func sendPeerData(_ peerData: PeerData, to players: [GKPlayer], with mode: GKMatch.SendDataMode) { 539 | do { 540 | //GD.print("-> SENDING\t peerData(id: \(peerData.id!), \(mode))") 541 | let data = encode(peerData: peerData) 542 | try match?.send(data!, to: players, dataMode: mode) 543 | } catch { 544 | GD.pushError("[GameCenterPeer] Failed to send peerData: \(error)") 545 | } 546 | } 547 | 548 | func disconnect() { 549 | // TODO: I suspect some things here happen too fast, but only sometimes 550 | // The result is that sometimes the disconnect signal is sent immediately 551 | // and sometimes it will time out after a time. 552 | 553 | // Theory: A disconnect is sent and awaits a return, but if it takes more than a certain time 554 | // the sender doesn't exist and all clients will wait for timeout 555 | // Solution: Maybe wait a second after match?.disconnect() 556 | 557 | connectionStatus = .disconnected 558 | activeMode = .none 559 | uniqueID = 0 560 | 561 | match?.disconnect() 562 | match = nil 563 | 564 | incomingPackets.removeAll() 565 | currentPacket = nil 566 | clearPeers() 567 | 568 | refuseConnections = false 569 | } 570 | 571 | func getTransferMode() -> GKMatch.SendDataMode { 572 | switch _getTransferMode() { 573 | case .reliable: 574 | return GKMatch.SendDataMode.reliable 575 | case .unreliable: 576 | return GKMatch.SendDataMode.unreliable 577 | case .unreliableOrdered: 578 | return GKMatch.SendDataMode.reliable 579 | } 580 | } 581 | } 582 | -------------------------------------------------------------------------------- /Sources/GameCenter/GameCenterPlayer.swift: -------------------------------------------------------------------------------- 1 | import GameKit 2 | import SwiftGodot 3 | 4 | /// Holds player data in a Godot friendly format 5 | @Godot 6 | class GameCenterPlayer: RefCounted { 7 | @Export var alias: String = "" 8 | @Export var displayName: String = "" 9 | 10 | @Export var gamePlayerID: String = "" 11 | @Export var teamPlayerID: String = "" 12 | 13 | @Export var isInvitable: Bool = false 14 | 15 | @Export var profilePicture: Image? 16 | 17 | /// Deprecated, use gamePlayerID instead. This is however needed for backwards compatibility 18 | @Export var playerID: String = "" 19 | 20 | convenience init(_ player: GKPlayer) { 21 | self.init() 22 | 23 | alias = player.alias 24 | displayName = player.displayName 25 | gamePlayerID = player.gamePlayerID 26 | teamPlayerID = player.teamPlayerID 27 | isInvitable = player.isInvitable 28 | 29 | playerID = player.playerID 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/GameCenter/GameCenterPlayerLocal.swift: -------------------------------------------------------------------------------- 1 | import GameKit 2 | import SwiftGodot 3 | 4 | /// Holds local player data in a Godot friendly format 5 | @Godot 6 | class GameCenterPlayerLocal: RefCounted { 7 | @Export var alias: String = "" 8 | @Export var displayName: String = "" 9 | 10 | @Export var gamePlayerID: String = "" 11 | @Export var teamPlayerID: String = "" 12 | 13 | @Export var isUnderage: Bool = false 14 | @Export var isMultiplayerGamingRestricted: Bool = false 15 | @Export var isPersonalizedCommunicationRestricted: Bool = false 16 | 17 | convenience init(_ player: GKLocalPlayer) { 18 | self.init() 19 | 20 | alias = player.alias 21 | displayName = player.displayName 22 | gamePlayerID = player.gamePlayerID 23 | teamPlayerID = player.teamPlayerID 24 | isUnderage = player.isUnderage 25 | isMultiplayerGamingRestricted = player.isMultiplayerGamingRestricted 26 | isPersonalizedCommunicationRestricted = player.isPersonalizedCommunicationRestricted 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/GameCenter/GameCenterScoreChallenge.swift: -------------------------------------------------------------------------------- 1 | import GameKit 2 | import SwiftGodot 3 | 4 | /// Holds ScoreChallenge data in a Godot friendly format 5 | @Godot 6 | class GameCenterScoreChallenge: GameCenterChallenge { 7 | @Export var score: Int = 0 8 | @Export var leaderboardEntry: GameCenterLeaderboardEntry? 9 | 10 | convenience init(scoreChallenge challenge: GKScoreChallenge) { 11 | self.init(challenge: challenge) 12 | 13 | if #available(iOS 17.4, macOS 14.4, *), let entry = challenge.leaderboardEntry { 14 | self.leaderboardEntry = GameCenterLeaderboardEntry(entry: entry, excludeDate: true) 15 | self.score = entry.score 16 | } else if let score = challenge.score { 17 | self.score = Int(score.value) 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/GameCenter/GameCenterViewController.swift: -------------------------------------------------------------------------------- 1 | #if canImport(UIKit) 2 | import SwiftGodot 3 | import GameKit 4 | import UIKit 5 | 6 | class GameCenterViewController: UIViewController, GKGameCenterControllerDelegate { 7 | var onControllerClosed: Callable? = nil 8 | 9 | func showUIController(_ viewController: GKGameCenterViewController, onClose: Callable?) { 10 | do { 11 | // TODO: Make sure we don't try to open more than one view 12 | onControllerClosed = onClose 13 | viewController.gameCenterDelegate = self 14 | try getRootController()?.present(viewController, animated: true, completion: nil) 15 | } catch { 16 | GD.pushError("Error: \(error).") 17 | } 18 | } 19 | 20 | func gameCenterViewControllerDidFinish(_ gameCenterViewController: GKGameCenterViewController) { 21 | gameCenterViewController.dismiss(animated: true, completion: { self.onControllerClosed?.call() }) 22 | } 23 | 24 | func getRootController() -> UIViewController? { 25 | return getMainWindow()?.rootViewController 26 | } 27 | 28 | func getMainWindow() -> UIWindow? { 29 | // As seen on: https://sarunw.com/posts/how-to-get-root-view-controller/ 30 | // NOTE: Does not neccessarily show in the correct window if there are multiple windows 31 | return UIApplication.shared.connectedScenes 32 | .compactMap { $0 as? UIWindowScene } 33 | .filter { $0.activationState == .foregroundActive } 34 | .first?.windows 35 | .first(where: \.isKeyWindow) 36 | } 37 | } 38 | #endif 39 | 40 | // TODO: Implement NSViewController variant for macOS 41 | -------------------------------------------------------------------------------- /Sources/Haptics/Haptics.swift: -------------------------------------------------------------------------------- 1 | #if canImport(CoreHaptics) 2 | 3 | import CoreHaptics 4 | import SwiftGodot 5 | 6 | #initSwiftExtension( 7 | cdecl: "swift_entry_point", 8 | types: [ 9 | Haptics.self 10 | ] 11 | ) 12 | 13 | let HAPTIC_STOP_REASON_SYSTEM_ERROR: Int = 1 14 | let HAPTIC_STOP_REASON_IDLE_TIMEOUT: Int = 2 15 | let HAPTIC_STOP_REASON_AUDIO_INTERRUPTED: Int = 3 16 | let HAPTIC_STOP_REASON_APPLICATION_SUSPENDED: Int = 4 17 | let HAPTIC_STOP_REASON_ENGINE_DESTROYED: Int = 5 18 | let HAPTIC_STOP_REASON_GAME_CONTROLLER_DISCONNECTED: Int = 6 19 | let HAPTIC_STOP_REASON_UNKNOWN_ERROR: Int = 7 20 | 21 | @Godot 22 | class Haptics: RefCounted { 23 | 24 | /// Called when the Haptic engine stops 25 | @Signal var engineStopped: SignalWithArguments 26 | 27 | var isHapticsSupported: Bool = false 28 | var engine: CHHapticEngine! 29 | 30 | required init(_ context:InitContext) { 31 | super.init(context) 32 | initializeEngine() 33 | } 34 | 35 | func initializeEngine() { 36 | guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { 37 | GD.pushError("Device does not support haptics") 38 | isHapticsSupported = false 39 | return 40 | } 41 | 42 | isHapticsSupported = true 43 | do { 44 | engine = try CHHapticEngine() 45 | engine.resetHandler = resetHandler 46 | engine.stoppedHandler = stoppedHandler 47 | try engine?.start() 48 | } catch { 49 | GD.pushError("Failed to initialize haptics: \(error)") 50 | } 51 | } 52 | 53 | // MARK: Godot functions 54 | 55 | @Callable(autoSnakeCase: true) 56 | func restartEngine() { 57 | do { 58 | try engine?.start() 59 | } catch { 60 | GD.pushError("Failed to restart haptics engine: \(error)") 61 | } 62 | } 63 | 64 | /// Play a single tap. 65 | /// 66 | /// - Parameters: 67 | /// - sharpness: The feel of the haptic event. 68 | /// - intensity: The strength of the haptic event. 69 | @Callable(autoSnakeCase: true) 70 | func playTap(sharpness: Float, intensity: Float) { 71 | if !isHapticsSupported { 72 | return 73 | } 74 | 75 | do { 76 | let pattern = try CHHapticPattern( 77 | events: [ 78 | CHHapticEvent( 79 | eventType: .hapticTransient, 80 | parameters: [ 81 | CHHapticEventParameter(parameterID: .hapticSharpness, value: sharpness), 82 | CHHapticEventParameter(parameterID: .hapticIntensity, value: intensity), 83 | ], 84 | relativeTime: 0 85 | ) 86 | ], 87 | parameters: [] 88 | ) 89 | try playPattern(pattern: pattern) 90 | } catch { 91 | GD.pushError("Failed to play haptic: \(error)") 92 | } 93 | } 94 | 95 | /// Play a longer haptic event. 96 | /// 97 | /// - Parameters: 98 | /// - sharpness: The feel of the haptic event. 99 | /// - intensity: The strength of the haptic event. 100 | /// - duration: The duration of the haptic event. 101 | @Callable(autoSnakeCase: true) 102 | func playEvent(sharpness: Float, intensity: Float, duration: Float) { 103 | if !isHapticsSupported { 104 | return 105 | } 106 | 107 | do { 108 | let pattern = try CHHapticPattern( 109 | events: [ 110 | CHHapticEvent( 111 | eventType: .hapticContinuous, 112 | parameters: [ 113 | CHHapticEventParameter(parameterID: .hapticSharpness, value: sharpness), 114 | CHHapticEventParameter(parameterID: .hapticIntensity, value: intensity), 115 | ], 116 | relativeTime: 0, 117 | duration: TimeInterval(duration) 118 | ) 119 | ], 120 | parameters: [] 121 | ) 122 | try playPattern(pattern: pattern) 123 | } catch { 124 | GD.pushError("Failed to play haptic: \(error)") 125 | } 126 | } 127 | 128 | /// - Returns: True if the device supports Haptics 129 | @Callable(autoSnakeCase: true) 130 | func supportsHaptics() -> Bool { 131 | CHHapticEngine.capabilitiesForHardware().supportsHaptics 132 | } 133 | 134 | // MARK: Internal 135 | 136 | func playPattern(pattern: CHHapticPattern) throws { 137 | try engine.makePlayer(with: pattern).start(atTime: 0) 138 | } 139 | 140 | func stoppedHandler(reason: CHHapticEngine.StoppedReason) { 141 | switch reason { 142 | case .audioSessionInterrupt: 143 | GD.print("Haptic engine stopped because the audio session was interrupted") 144 | self.engineStopped.emit(HAPTIC_STOP_REASON_AUDIO_INTERRUPTED) 145 | case .applicationSuspended: 146 | GD.print("Haptic engine stopped because the application was suspended") 147 | self.engineStopped.emit(HAPTIC_STOP_REASON_APPLICATION_SUSPENDED) 148 | case .idleTimeout: 149 | GD.print("Haptic engine stopped because idle timeout") 150 | self.engineStopped.emit(HAPTIC_STOP_REASON_IDLE_TIMEOUT) 151 | case .systemError: 152 | GD.print("Haptic engine stopped because of system error") 153 | self.engineStopped.emit(HAPTIC_STOP_REASON_SYSTEM_ERROR) 154 | case .engineDestroyed: 155 | GD.print("Haptic engine stopped because the engine was destroyed") 156 | self.engineStopped.emit(HAPTIC_STOP_REASON_ENGINE_DESTROYED) 157 | case .gameControllerDisconnect: 158 | GD.print("Haptic engine stopped because the game controller was disconnected") 159 | self.engineStopped.emit(HAPTIC_STOP_REASON_GAME_CONTROLLER_DISCONNECTED) 160 | default: 161 | GD.print("Haptic engine stopped because of unknown error: \(reason)") 162 | self.engineStopped.emit(HAPTIC_STOP_REASON_UNKNOWN_ERROR) 163 | } 164 | } 165 | 166 | func resetHandler() { 167 | GD.print("Restarting haptic engine") 168 | do { 169 | try engine.start() 170 | } catch { 171 | GD.pushError("Failed to restart haptic engine: \(error)") 172 | } 173 | } 174 | } 175 | #endif // CoreHaptics 176 | -------------------------------------------------------------------------------- /Sources/InAppPurchase/IAPProduct.swift: -------------------------------------------------------------------------------- 1 | import SwiftGodot 2 | 3 | @Godot 4 | class IAPProduct: RefCounted { 5 | static let TYPE_UNKNOWN: Int = 0 6 | static let TYPE_CONSUMABLE: Int = 1 7 | static let TYPE_NON_CONSUMABLE: Int = 2 8 | static let TYPE_AUTO_RENEWABLE: Int = 3 9 | static let TYPE_NON_RENEWABLE: Int = 4 10 | 11 | @Export var productID: String = "" 12 | @Export var displayName: String = "" 13 | @Export var storeDescription: String = "" 14 | @Export var displayPrice: String = "" 15 | @Export var type: Int = TYPE_UNKNOWN 16 | } 17 | -------------------------------------------------------------------------------- /Sources/InAppPurchase/InAppPurchase.swift: -------------------------------------------------------------------------------- 1 | import StoreKit 2 | import SwiftGodot 3 | 4 | #initSwiftExtension( 5 | cdecl: "swift_entry_point", 6 | types: [ 7 | InAppPurchase.self, 8 | IAPProduct.self, 9 | ] 10 | ) 11 | 12 | public enum StoreError: Error { 13 | case failedVerification 14 | } 15 | 16 | let OK: Int = 0 17 | 18 | @Godot 19 | class InAppPurchase: RefCounted { 20 | enum InAppPurchaseStatus: Int { 21 | case purchaseOK = 0 22 | case purchaseSuccessfulButUnverified = 2 23 | case purchasePendingAuthorization = 3 24 | case purchaseCancelledByUser = 4 25 | } 26 | enum InAppPurchaseError: Int, Error { 27 | case failedToGetProducts = 1 28 | case purchaseFailed = 2 29 | case noSuchProduct = 3 30 | case failedToRestorePurchases = 4 31 | } 32 | enum AppTransactionError: Int, Error { 33 | case ok = 0 34 | case unverified = 1 35 | case error = 2 36 | } 37 | 38 | /// Called when a product is puchased 39 | @Signal var productPurchased: SignalWithArguments 40 | /// Called when a purchase is revoked 41 | @Signal var productRevoked: SignalWithArguments 42 | 43 | private(set) var productIDs: [String] = [] 44 | 45 | private(set) var products: [Product] 46 | private(set) var purchasedProducts: Set = Set() 47 | 48 | var updateListenerTask: Task? = nil 49 | 50 | required init(_ context: InitContext) { 51 | products = [] 52 | super.init(context) 53 | } 54 | 55 | deinit { 56 | updateListenerTask?.cancel() 57 | } 58 | 59 | /// Initialize purchases 60 | /// 61 | /// - Parameters: 62 | /// - productIdentifiers: An array of product identifiers that you enter in App Store Connect. 63 | @Callable 64 | func initialize(productIDs: [String], onComplete: Callable) { 65 | self.productIDs = productIDs 66 | 67 | updateListenerTask = self.listenForTransactions() 68 | 69 | Task { 70 | await updateProducts() 71 | await updateProductStatus() 72 | 73 | onComplete.callDeferred() 74 | } 75 | } 76 | 77 | /// Purchase a product 78 | /// 79 | /// - Parameters: 80 | /// - productID: The identifier of the product that you enter in App Store Connect. 81 | /// - onComplete: Callback with parameter: (error: Variant, status: Variant) -> (error: Int `InAppPurchaseError`, status: Int `InAppPurchaseStatus`) 82 | @Callable 83 | func purchase(_ productID: String, onComplete: Callable) { 84 | Task { 85 | do { 86 | if let product: Product = try await getProduct(productID) { 87 | let result: Product.PurchaseResult = try await product.purchase() 88 | switch result { 89 | case .success(let verification): 90 | // Success 91 | let transaction: Transaction = try checkVerified(verification) 92 | await transaction.finish() 93 | 94 | self.purchasedProducts.insert(transaction.productID) 95 | 96 | onComplete.callDeferred( 97 | Variant(OK), 98 | Variant(InAppPurchaseStatus.purchaseOK.rawValue) 99 | ) 100 | break 101 | case .pending: 102 | // Transaction waiting on authentication or approval 103 | onComplete.callDeferred( 104 | Variant(OK), 105 | Variant(InAppPurchaseStatus.purchasePendingAuthorization.rawValue) 106 | ) 107 | break 108 | case .userCancelled: 109 | // User cancelled the purchase 110 | onComplete.callDeferred( 111 | Variant(OK), 112 | Variant(InAppPurchaseStatus.purchaseCancelledByUser.rawValue) 113 | ) 114 | break 115 | } 116 | } else { 117 | GD.pushError("IAP Product doesn't exist: \(productID)") 118 | onComplete.callDeferred( 119 | Variant(InAppPurchaseError.noSuchProduct.rawValue), 120 | nil 121 | ) 122 | } 123 | } catch { 124 | GD.pushError("IAP Failed to get products from App Store, error: \(error)") 125 | onComplete.callDeferred( 126 | Variant(InAppPurchaseError.purchaseFailed.rawValue), 127 | nil 128 | ) 129 | } 130 | } 131 | } 132 | 133 | /// Check if a product is purchased 134 | /// 135 | /// - Parameters: 136 | /// - productID: The identifier of the product that you enter in App Store Connect., 137 | /// 138 | /// - Returns: True if a product is purchased 139 | @Callable(autoSnakeCase: true) 140 | func isPurchased(_ productID: String) -> Bool { 141 | return purchasedProducts.contains(productID) 142 | } 143 | 144 | /// Get products 145 | /// 146 | /// - Parameters: 147 | /// - identifiers: An array of product identifiers that you enter in App Store Connect. 148 | /// - onComplete: Callback with parameters: (error: Variant, products: Variant) -> (error: Int, products: [``IAPProduct``]) 149 | @Callable(autoSnakeCase: true) 150 | func getProducts(identifiers: [String], onComplete: Callable) { 151 | Task { 152 | do { 153 | let storeProducts: [Product] = try await Product.products(for: identifiers) 154 | var products = VariantArray() 155 | 156 | for storeProduct: Product in storeProducts { 157 | var product: IAPProduct = IAPProduct() 158 | product.displayName = storeProduct.displayName 159 | product.displayPrice = storeProduct.displayPrice 160 | product.storeDescription = storeProduct.description 161 | product.productID = storeProduct.id 162 | switch storeProduct.type { 163 | case .consumable: 164 | product.type = IAPProduct.TYPE_CONSUMABLE 165 | case .nonConsumable: 166 | product.type = IAPProduct.TYPE_NON_CONSUMABLE 167 | case .autoRenewable: 168 | product.type = IAPProduct.TYPE_AUTO_RENEWABLE 169 | case .nonRenewable: 170 | product.type = IAPProduct.TYPE_NON_RENEWABLE 171 | default: 172 | product.type = IAPProduct.TYPE_UNKNOWN 173 | } 174 | 175 | products.append(Variant(product)) 176 | } 177 | onComplete.callDeferred(Variant(OK), Variant(products)) 178 | } catch { 179 | GD.pushError("Failed to get products from App Store, error: \(error)") 180 | onComplete.callDeferred( 181 | Variant(InAppPurchaseError.failedToGetProducts.rawValue), 182 | nil 183 | ) 184 | } 185 | } 186 | } 187 | 188 | /// Restore purchases 189 | /// 190 | /// - Parameter onComplete: Callback with parameter: (error: Variant) -> (error: Int) 191 | @Callable(autoSnakeCase: true) 192 | func restorePurchases(onComplete: Callable) { 193 | Task { 194 | do { 195 | try await AppStore.sync() 196 | onComplete.callDeferred(Variant(OK)) 197 | } catch { 198 | GD.pushError("Failed to restore purchases: \(error)") 199 | onComplete.callDeferred( 200 | Variant(InAppPurchaseError.failedToRestorePurchases.rawValue) 201 | ) 202 | } 203 | } 204 | } 205 | 206 | /// Get the current app environment 207 | /// 208 | /// NOTE: On iOS 16 this might display a system prompt that asks users to authenticate 209 | /// 210 | /// - Parameter onComplete: Callback with parameter: (error: Variant, data: Variant) -> (error: Int, data: String) 211 | @Callable(autoSnakeCase: true) 212 | public func getEnvironment(onComplete: Callable) { 213 | if #available(iOS 16.0, *) { 214 | Task { 215 | do { 216 | let result = try await AppTransaction.shared 217 | switch result { 218 | case .verified(let appTransaction): 219 | onComplete.callDeferred( 220 | Variant(AppTransactionError.ok.rawValue), 221 | Variant(appTransaction.environment.rawValue) 222 | ) 223 | case .unverified(let appTransaction, let verificationError): 224 | onComplete.callDeferred( 225 | Variant(AppTransactionError.unverified.rawValue), 226 | Variant(appTransaction.environment.rawValue) 227 | ) 228 | } 229 | } catch { 230 | GD.print("Failed to get appTransaction, error: \(error)") 231 | onComplete.callDeferred(Variant(AppTransactionError.error.rawValue), Variant("")) 232 | } 233 | } 234 | } else { 235 | guard let path = Bundle.main.appStoreReceiptURL?.path else { 236 | onComplete.callDeferred(Variant(AppTransactionError.error.rawValue), Variant("")) 237 | return 238 | } 239 | 240 | if path.contains("CoreSimulator") { 241 | onComplete.callDeferred(Variant(AppTransactionError.ok.rawValue), Variant("xcode")) 242 | } else if path.contains("sandboxReceipt") { 243 | onComplete.callDeferred(Variant(AppTransactionError.ok.rawValue), Variant("sandbox")) 244 | } else { 245 | onComplete.callDeferred(Variant(AppTransactionError.ok.rawValue), Variant("production")) 246 | } 247 | } 248 | } 249 | 250 | /// Refresh the App Store signed app transaction (only iOS 16+) 251 | /// 252 | /// NOTE: This will display a system prompt that asks users to authenticate 253 | @Callable(autoSnakeCase: true) 254 | public func refreshAppTransaction(onComplete: Callable) { 255 | if #available(iOS 16.0, *) { 256 | Task { 257 | do { 258 | try await AppTransaction.refresh() 259 | onComplete.callDeferred(Variant(AppTransactionError.ok.rawValue)) 260 | } catch { 261 | onComplete.callDeferred(Variant(AppTransactionError.unverified.rawValue)) 262 | } 263 | } 264 | } else { 265 | onComplete.callDeferred(Variant(OK)) 266 | } 267 | } 268 | 269 | // Internal functionality 270 | 271 | func getProduct(_ productIdentifier: String) async throws -> Product? { 272 | var product: [Product] = [] 273 | do { 274 | product = try await Product.products(for: [productIdentifier]) 275 | } catch { 276 | GD.pushError("Unable to get product with identifier: \(productIdentifier): \(error)") 277 | } 278 | 279 | return product.first 280 | } 281 | 282 | func updateProducts() async { 283 | do { 284 | let storeProducts = try await Product.products(for: productIDs) 285 | products = storeProducts 286 | } catch { 287 | GD.pushError("Failed to get products from App Store: \(error)") 288 | } 289 | } 290 | 291 | func updateProductStatus() async { 292 | for await result: VerificationResult in Transaction.currentEntitlements { 293 | guard case .verified(let transaction) = result else { 294 | continue 295 | } 296 | 297 | if transaction.revocationDate == nil { 298 | self.purchasedProducts.insert(transaction.productID) 299 | } else { 300 | self.purchasedProducts.remove(transaction.productID) 301 | } 302 | } 303 | } 304 | 305 | func checkVerified(_ result: VerificationResult) throws -> T { 306 | switch result { 307 | case .unverified: 308 | throw StoreError.failedVerification 309 | case .verified(let safe): 310 | return safe 311 | } 312 | } 313 | 314 | func listenForTransactions() -> Task { 315 | return Task.detached { 316 | for await result: VerificationResult in Transaction.updates { 317 | do { 318 | let transaction: Transaction = try self.checkVerified(result) 319 | if transaction.revocationDate == nil { 320 | self.productPurchased.emit(transaction.productID) 321 | } else { 322 | self.productRevoked.emit(transaction.productID) 323 | } 324 | 325 | await self.updateProductStatus() 326 | await transaction.finish() 327 | } catch { 328 | GD.pushWarning("Transaction failed verification") 329 | } 330 | } 331 | } 332 | } 333 | } 334 | -------------------------------------------------------------------------------- /Sources/Settings/Settings+Observer.swift: -------------------------------------------------------------------------------- 1 | #if canImport(Foundation) 2 | import Foundation 3 | import SwiftGodot 4 | 5 | extension Settings { 6 | class SettingsObserver: NSObject { 7 | let id: String 8 | private var onChange: (Any, Any) -> Void 9 | 10 | init(for id: String, onChange: @escaping (Any, Any) -> Void) { 11 | self.onChange = onChange 12 | self.id = id 13 | super.init() 14 | UserDefaults.standard.addObserver(self, forKeyPath: id, options: [.old, .new], context: nil) 15 | } 16 | 17 | deinit { 18 | UserDefaults.standard.removeObserver(self, forKeyPath: id, context: nil) 19 | } 20 | 21 | override func observeValue( 22 | forKeyPath keyPath: String?, 23 | of object: Any?, 24 | change: [NSKeyValueChangeKey: Any]?, 25 | context: UnsafeMutableRawPointer? 26 | ) { 27 | guard let change = change, object != nil, keyPath == self.id else { return } 28 | onChange(change[.oldKey] as Any, change[.newKey] as Any) 29 | } 30 | } 31 | } 32 | #endif 33 | -------------------------------------------------------------------------------- /Sources/Settings/Settings.swift: -------------------------------------------------------------------------------- 1 | #if canImport(Foundation) 2 | import Foundation 3 | import SwiftGodot 4 | 5 | #if canImport(UIKit) 6 | import UIKit 7 | #endif 8 | 9 | #initSwiftExtension( 10 | cdecl: "swift_entry_point", 11 | types: [ 12 | Settings.self 13 | ] 14 | ) 15 | 16 | @Godot 17 | class Settings: RefCounted { 18 | 19 | /// Signal called when a value is changed. Theoretically.. 20 | @Signal var valueChanged: SignalWithArguments 21 | 22 | let settings: UserDefaults! 23 | var subscriptions = Set() 24 | 25 | required init(_ context: InitContext) { 26 | self.settings = UserDefaults.standard 27 | super.init(context) 28 | 29 | observeChanges() 30 | } 31 | 32 | // MARK: String 33 | 34 | @Callable(autoSnakeCase: true) 35 | func getString(id: String) -> String { 36 | guard let value = settings.string(forKey: id) else { 37 | return "" 38 | } 39 | 40 | return value 41 | } 42 | 43 | @Callable(autoSnakeCase: true) 44 | func setString(id: String, value: String) { 45 | settings.set(value, forKey: id) 46 | } 47 | 48 | // MARK: Bool 49 | 50 | @Callable(autoSnakeCase: true) 51 | func getBool(id: String) -> Bool { 52 | return settings.bool(forKey: id) 53 | } 54 | 55 | @Callable(autoSnakeCase: true) 56 | func setBool(id: String, value: Bool) { 57 | settings.set(value, forKey: id) 58 | } 59 | 60 | // MARK: Integer 61 | 62 | @Callable(autoSnakeCase: true) 63 | func getInt(id: String) -> Int { 64 | return settings.integer(forKey: id) 65 | } 66 | 67 | @Callable(autoSnakeCase: true) 68 | func setInt(id: String, value: Int) { 69 | settings.set(value, forKey: id) 70 | } 71 | 72 | // MARK: Float 73 | 74 | @Callable(autoSnakeCase: true) 75 | func getFloat(id: String) -> Float { 76 | return settings.float(forKey: id) 77 | } 78 | 79 | @Callable(autoSnakeCase: true) 80 | func setFloat(id: String, value: Float) { 81 | settings.set(value, forKey: id) 82 | } 83 | 84 | // MARK: General 85 | 86 | @Callable(autoSnakeCase: true) 87 | func getValue(id: String) -> Variant? { 88 | guard let value = settings.value(forKey: id) else { 89 | GD.pushWarning("Unknown id: \(id)") 90 | return nil 91 | } 92 | 93 | switch value { 94 | case is Int: return Variant(value as! Int) 95 | case is Float: return Variant(value as! Float) 96 | case is String: return Variant(value as! String) 97 | case is Bool: return Variant(value as! Bool) 98 | default: 99 | GD.pushWarning("Unhandled value: \(value) for \(id)") 100 | return nil 101 | } 102 | } 103 | 104 | @Callable(autoSnakeCase: true) 105 | func getKeys() -> VariantArray { 106 | var keys = VariantArray() 107 | 108 | for key in self.settings.dictionaryRepresentation().keys { 109 | keys.append(Variant(key)) 110 | } 111 | 112 | return keys 113 | } 114 | 115 | @Callable(autoSnakeCase: true) 116 | func openAppSettings() { 117 | #if canImport(UIKit) 118 | if let appSettings = URL(string: UIApplication.openSettingsURLString) { 119 | UIApplication.shared.open(appSettings, options: [:], completionHandler: nil) 120 | } 121 | #endif 122 | } 123 | 124 | // MARK: Internal 125 | 126 | func observeChanges() { 127 | for key in self.settings.dictionaryRepresentation().keys { 128 | SettingsObserver(for: key) { old, new in 129 | GD.print("Setting \(key) changed: \(old) -> \(new)") 130 | self.valueChanged(key: key) 131 | 132 | // TODO: Figure out how to include the new value. 133 | // switch new { 134 | // case is Int: 135 | // valueChanged.emit(key, Variant(Int(new))) 136 | // case is Float: 137 | // valueChanged.emit(key, Variant(Float(new)) 138 | // case is String: 139 | // valueChanged.emit(key, Variant(String(new)) 140 | // case is Bool: 141 | // valueChanged.emit(key, Variant(Bool(new)) 142 | // default: GD.pushWarning("Unhandled value changed: \(new)") 143 | // } 144 | } 145 | } 146 | } 147 | 148 | func valueChanged(key: String) { 149 | valueChanged.emit(key) 150 | } 151 | } 152 | #endif 153 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/zsh 2 | 3 | # MARK: Help 4 | 5 | # Syntax: ./build.sh " 6 | # Valid platforms are: mac, ios & all (Default: all) 7 | # Valid configurations are: debug & release (Default: release) 8 | 9 | # MARK: Settings 10 | 11 | BINARY_PATH_IOS="Bin/ios" 12 | BUILD_PATH_IOS=".build/arm64-apple-ios" 13 | 14 | BINARY_PATH_MACOS="Bin/macos" 15 | BUILD_PATH_MACOS=".build" 16 | 17 | # MARK: Inputs 18 | 19 | TARGET=$1 20 | CONFIG=$2 21 | 22 | if [[ ! $TARGET ]]; then 23 | TARGET="all" 24 | fi 25 | 26 | if [[ ! $CONFIG ]]; then 27 | CONFIG="release" 28 | fi 29 | 30 | COPY_COMMANDS=() 31 | 32 | # MARK: Build iOS 33 | 34 | build_ios() { 35 | xcodebuild \ 36 | -scheme "iOS Plugins-Package" \ 37 | -destination 'generic/platform=iOS' \ 38 | -derivedDataPath "$BUILD_PATH_IOS" \ 39 | -clonedSourcePackagesDirPath ".build" \ 40 | -configuration "$1" \ 41 | -skipPackagePluginValidation \ 42 | -quiet 43 | 44 | if [[ $? -gt 0 ]]; then 45 | echo "${BOLD}${RED}Failed to build $target iOS library${RESET_FORMATTING}" 46 | return 1 47 | fi 48 | 49 | echo "${BOLD}${GREEN}iOS build succeeded${RESET_FORMATTING}" 50 | 51 | product_path="$BUILD_PATH_IOS/Build/Products/$1-iphoneos/PackageFrameworks" 52 | source_path="Sources" 53 | for source in $source_path/*; do 54 | COPY_COMMANDS+=("cp -af ""$product_path/$source:t:r.framework ""$BINARY_PATH_IOS") 55 | done 56 | 57 | COPY_COMMANDS+=("cp -af ""$product_path/SwiftGodot.framework ""$BINARY_PATH_IOS") 58 | 59 | return 0 60 | } 61 | 62 | # MARK: Build macOS 63 | 64 | build_macos() { 65 | swift build \ 66 | --configuration "$1" \ 67 | --scratch-path "$BUILD_PATH_MACOS" \ 68 | --quiet 69 | 70 | if [[ $? -gt 0 ]]; then 71 | echo "${BOLD}${RED}Failed to build macOS library${RESET_FORMATTING}" 72 | return 1 73 | fi 74 | 75 | echo "${BOLD}${GREEN}macOS build succeeded${RESET_FORMATTING}" 76 | 77 | if [[ $(uname -m) == "x86_64" ]]; then 78 | product_path="$BUILD_PATH_MACOS/x86_64-apple-macosx/$1" 79 | else 80 | product_path="$BUILD_PATH_MACOS/arm64-apple-macosx/$1" 81 | fi 82 | 83 | source_path="Sources" 84 | for folder in $source_path/* 85 | do 86 | COPY_COMMANDS+=("cp -af $product_path/lib$folder:t:r.dylib $BINARY_PATH_MACOS") 87 | done 88 | 89 | COPY_COMMANDS+=("cp -af $product_path/libSwiftGodot.dylib $BINARY_PATH_MACOS") 90 | 91 | return 0 92 | } 93 | 94 | # MARK: Pre & Post process 95 | 96 | build_libs() { 97 | echo "Building libraries..." 98 | 99 | if [[ "$1" == "all" || "$1" == "macos" ]]; then 100 | echo "${BOLD}${CYAN}Building macOS library ($2)...${RESET_FORMATTING}" 101 | build_macos "$2" 102 | fi 103 | 104 | if [[ "$1" == "all" || "$1" == "ios" ]]; then 105 | echo "${BOLD}${CYAN}Building iOS libraries ($2)...${RESET_FORMATTING}" 106 | build_ios "$2" 107 | fi 108 | 109 | if [[ ${#COPY_COMMANDS[@]} -gt 0 ]]; then 110 | echo "${BOLD}${CYAN}Copying binaries...${RESET_FORMATTING}" 111 | for instruction in ${COPY_COMMANDS[@]} 112 | do 113 | target=${instruction##* } 114 | if ! [[ -e "$target" ]]; then 115 | mkdir -p "$target" 116 | fi 117 | eval $instruction 118 | done 119 | fi 120 | 121 | echo "${BOLD}${GREEN}Finished building $2 libraries for $1 platforms${RESET_FORMATTING}" 122 | } 123 | 124 | # MARK: Formatting 125 | BOLD="$(tput bold)" 126 | GREEN="$(tput setaf 2)" 127 | CYAN="$(tput setaf 6)" 128 | RED="$(tput setaf 1)" 129 | RESET_FORMATTING="$(tput sgr0)" 130 | 131 | # MARK: Run 132 | build_libs "$TARGET" "$CONFIG" 133 | --------------------------------------------------------------------------------