25 |
26 | var controller: AuthenticationServices.ASAuthorizationController?
27 | var proxy: Proxy?
28 |
29 | // maybe need to make this conform to ASAuthorizationControllerPresentationContextProviding
30 | // but I should add support for the DisplayServer's UIViewController extraction
31 | class Proxy: NSObject, ASAuthorizationControllerDelegate {
32 | weak var base: ASAuthorizationController?
33 |
34 | init(_ base: ASAuthorizationController) {
35 | self.base = base
36 | }
37 |
38 | @MainActor
39 | func authorizationController(controller: AuthenticationServices.ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
40 | guard let base else { return }
41 |
42 | if let appleIDCredential = authorization.credential as? AuthenticationServices.ASAuthorizationAppleIDCredential {
43 | let wrapped = ASAuthorizationAppleIDCredential(credential: appleIDCredential)
44 | base.authorization_completed.emit(wrapped)
45 | } else if let passwordCredential = authorization.credential as? AuthenticationServices.ASPasswordCredential {
46 | let wrapped = ASPasswordCredential(credential: passwordCredential)
47 | base.authorization_completed.emit(wrapped)
48 | } else {
49 | // Unknown credential type, might be the enterprise credential, but I dont think any games need that.
50 | base.authorization_completed.emit(nil)
51 | }
52 | }
53 |
54 | @MainActor
55 | func authorizationController(controller: AuthenticationServices.ASAuthorizationController, didCompleteWithError error: Error) {
56 | base?.authorization_failed.emit(error.localizedDescription)
57 | }
58 | }
59 |
60 | // The more specific version of it
61 | @Callable
62 | func signin_with_scopes(scopeStrings: VariantArray) {
63 | var requestedScopes: [ASAuthorization.Scope] = []
64 | for vscope in scopeStrings {
65 | guard let scope = String(vscope) else { continue }
66 | if scope == "email" {
67 | requestedScopes.append(.email)
68 | } else if scope == "full_name" {
69 | requestedScopes.append(.fullName)
70 | }
71 | }
72 |
73 | MainActor.assumeIsolated {
74 | let provider = ASAuthorizationAppleIDProvider()
75 | let request = provider.createRequest()
76 |
77 | request.requestedScopes = requestedScopes
78 |
79 | let controller = AuthenticationServices.ASAuthorizationController(authorizationRequests: [request])
80 | self.controller = controller
81 |
82 | let proxy = Proxy(self)
83 | self.proxy = proxy
84 |
85 | controller.delegate = proxy
86 |
87 | // Since most folks would use Godot, we might not need this
88 | // controller.presentationContextProvider = proxy
89 |
90 | controller.performRequests()
91 | }
92 | }
93 |
94 | // Just a general purpose easy-to-use version
95 | @Callable
96 | func signin() {
97 | MainActor.assumeIsolated {
98 | let appleIDProvider = ASAuthorizationAppleIDProvider()
99 | let request = appleIDProvider.createRequest()
100 | request.requestedScopes = [.fullName, .email]
101 |
102 | let controller = AuthenticationServices.ASAuthorizationController(authorizationRequests: [request])
103 | self.controller = controller
104 |
105 | let proxy = Proxy(self)
106 | self.proxy = proxy
107 |
108 | controller.delegate = proxy
109 |
110 | // Since most folks would use Godot, we might not need this
111 | //controller.presentationContextProvider = proxy
112 |
113 | controller.performRequests()
114 | }
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | Godot Plugins for deep Apple platform integration, works on MacOS and iOS.
4 |
5 |
6 |
7 | API Documentation | Download: Godot Asset Library | Download: GitHub Releases
8 |
9 |
10 |
11 | You can get a ready-to-use binary from the "releases" tab, just drag the contents into
12 | your addons directory. You can start testing right away on a Mac project, and for iOS,
13 | export your iOS project and run.
14 |
15 | This add-on currently includes comprehensive support for:
16 |
17 | * GameCenter [GameCenter Integration Guide](Sources/GodotApplePlugins/GameCenter/GameCenterGuide.md)
18 | * StoreKit2 (https://migueldeicaza.github.io/GodotApplePlugins/class_storekitmanager.html)
19 | * Sign-in with Apple (AuthenticationServices)
20 |
21 | The release contains both binaries for MacOS as dynamic libraries and
22 | an iOS xcframework compiled with the "Mergeable Library" feature.
23 | This means that for Debug builds, your Godot game contains a dynamic
24 | library (about 10 megs at the time of this writing) that does not need
25 | to be copied on every build speeding your development, but you can
26 | switch to "Release Mode" and set "Create Merged Binary" to "Manual"
27 | and you will further reduce the size of your executable (about 1.7
28 | megs at the time of this writing).
29 |
30 | # API Design
31 |
32 | The API surfaced by this add-ons is to be as close to possible to the Apple APIs (classes, methods names, enumerations) and to avoid attempting to provide an abstraction over them - as these tend to have impedance mismatches.
33 |
34 | In place of Apple delegate's pattern, I use Godot's callbacks - and I surfaced properties and methods use snake-case instead of Apple's camelCase, but beyond that, the mapping should be almost identical.
35 |
36 | Both GameCenter and AuthenticationServices APIs use class names that are 1:1 mappings to Apple's APIs as they use 2-letter namespaces (GK, AS) and they are not likely to conflicth with your code. For the StoreKit API, I chose to change the names as these APIs use terms that are too general (Store, Product) and could clash with your own code.
37 |
38 | # Notes on the APIs
39 |
40 | ## AuthenticationServices
41 |
42 | Make sure that your iOS or Mac app have the `com.apple.developer.applesignin` entitlment.
43 | When I am debugging this myself on macOS, I resign the official
44 | Godot download with this entitlement (you must download a provisioning profile that
45 | contains the entitlement, or the APIs will fail).
46 |
47 | For very simple uses, you can use:
48 |
49 | ```gdscript
50 | var auth_controller = ASAuthorizationController.new()
51 |
52 | func _ready():
53 | auth_controller.authorization_completed.connect(_on_authorization_completed)
54 | auth_controller.authorization_failed.connect(_on_authorization_failed)
55 |
56 | func _on_sign_in_button_pressed():
57 | # Request full name and email
58 | auth_controller.signin_with_scopes(["full_name", "email"])
59 |
60 | func _on_authorization_completed(credential):
61 | if credential is ASAuthorizationAppleIDCredential:
62 | print("User ID: ", credential.user)
63 | print("Email: ", credential.email)
64 | print("Full Name: ", credential.fullName)
65 | elif credential is ASPasswordCredential:
66 | print("User: ", credential.user)
67 | print("Password: ", credential.password)
68 | ```
69 |
70 | For more advance users, you will find that the API replicates Apple's API, and
71 | it surfaces the various features that you expect from it.
72 |
73 | ### Configure
74 |
75 | For iOS, set at Project -> Export -> iOS -> `entitlements/additional`:
76 |
77 | ```xml
78 | com.apple.developer.applesignin
79 |
80 | Default
81 |
82 | ```
83 |
84 | For macOS, set the same entitlements as above (eg. when running codesign):
85 |
86 | ```sh
87 | codesign --force --options=runtime --verbose --timestamp \
88 | --entitlements entitlements.plist --sign "" \
89 | "MyApp.app/Contents/MacOS/MyApp"
90 | ```
91 |
92 | where `entitlements.plist` contains again:
93 |
94 | ```xml
95 | com.apple.developer.applesignin
96 |
97 | Default
98 |
99 | ```
100 |
101 | # Size
102 |
103 | This addon adds 2.5 megabytes to your executable for release builds, but it is
104 | larger during development to speed up your development.
105 |
106 | Plain Godot Export, empty:
107 |
108 | ```
109 | Debug: 104 MB
110 | Release: 93 MB
111 | ```
112 |
113 | Godot Export, adding GodotApplePlugins with mergeable libraries:
114 |
115 | ```
116 | Debug: 107 MB
117 | Release: 95 MB
118 | ```
119 |
120 | If you manually disable mergeable libraries and build your own addon:
121 |
122 | ```
123 | Debug: 114 MB
124 | Release: 105 MB
125 | ```
126 |
127 | # Credits
128 |
129 | The "AuthenticationServices" code was derived from [Dragos Daian's/
130 | Nirmal Ac's](https://github.com/appsinacup/godot-apple-login) binding and
131 | Xogot's own use. Dragos also provided extensive technical guidance on
132 | putting together this addon for distribution. Thank you!
133 |
--------------------------------------------------------------------------------
/Sources/GodotApplePlugins/GameCenter/GKLocalPlayer.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GKLocalPlayer.swift
3 | // GodotApplePlugins
4 | //
5 | // Created by Miguel de Icaza on 11/17/25.
6 | //
7 |
8 | @preconcurrency import SwiftGodotRuntime
9 | import SwiftUI
10 | #if canImport(UIKit)
11 | import UIKit
12 | #else
13 | import AppKit
14 | #endif
15 |
16 | import GameKit
17 |
18 | @Godot
19 | class GKLocalPlayer: GKPlayer, @unchecked Sendable {
20 | var local: GameKit.GKLocalPlayer
21 |
22 | required init(_ context: InitContext) {
23 | local = GameKit.GKLocalPlayer.local
24 | super.init(context)
25 | player = local
26 | }
27 |
28 | init() {
29 | local = GameKit.GKLocalPlayer.local
30 | super.init(player: GameKit.GKLocalPlayer.local)
31 | }
32 |
33 | @Export var isAuthenticated: Bool { local.isAuthenticated }
34 | @Export var isUnderage: Bool { local.isUnderage }
35 | @Export var isMultiplayerGamingRestricted: Bool { local.isMultiplayerGamingRestricted }
36 | @Export var isPersonalizedCommunicationRestricted: Bool { local.isPersonalizedCommunicationRestricted }
37 |
38 | func friendDispatch(_ callback: Callable, _ friends: [GameKit.GKPlayer]?, _ error: (any Error)?) {
39 | let array = TypedArray()
40 |
41 | if let friends {
42 | for friend in friends {
43 | let gkplayer = GKPlayer(player: friend)
44 | array.append(gkplayer)
45 | }
46 | }
47 |
48 | _ = callback.call(Variant(array), mapError(error))
49 | }
50 |
51 | /// Loads the friends, the callback receives two arguments an `Array[GKPlayer]` and Variant
52 | /// if the variant value is not nil, it contains a string with the error message
53 | @Callable func load_friends(callback: Callable) {
54 | local.loadFriends { friends, error in
55 | self.friendDispatch(callback, friends, error)
56 | }
57 | }
58 |
59 | /// Loads the challengeable friends, the callback receives two arguments an array of GKPlayers and a String error
60 | /// either one can be null
61 | @Callable func load_challengeable_friends(callback: Callable) {
62 | local.loadChallengableFriends { friends, error in
63 | self.friendDispatch(callback, friends, error)
64 | }
65 | }
66 |
67 | /// Loads the recent friends, the callback receives two arguments an array of GKPlayers and a String error
68 | /// either one can be null
69 | @Callable func load_recent_friends(callback: Callable) {
70 | local.loadRecentPlayers { friends, error in
71 | self.friendDispatch(callback, friends, error)
72 | }
73 | }
74 |
75 | /// You get two return values back a dictionary containing the result values and an error.
76 | ///
77 | /// If the error is not nil:
78 | /// - "url": The URL for the public encryption key.
79 | /// - "data": PackedByteArray containing verification signature that GameKit generates, or nil
80 | /// - "salt": PackedByteArray containing a random NSString that GameKit uses to compute the hash and randomize it.
81 | /// - "timestamp": Int with signature’s creation date and time timestamp
82 | ///
83 | @Callable
84 | func fetch_items_for_identity_verification_signature(callback: Callable) {
85 | local.fetchItems { url, data, salt, timestamp, error in
86 | let result = VariantDictionary();
87 |
88 | if error == nil {
89 | let encodeData = data?.toPackedByteArray()
90 | let encodeSalt = salt?.toPackedByteArray()
91 |
92 | result["url"] = (Variant(url?.description ?? ""))
93 | result["data"] = encodeData != nil ? Variant(encodeData) : nil
94 | result["salt"] = encodeSalt != nil ? Variant(encodeSalt) : nil
95 | result["timestamp"] = Variant(timestamp)
96 | }
97 | _ = callback.call(Variant(result), mapError(error))
98 | }
99 | }
100 |
101 | @Callable
102 | func save_game_data(data: PackedByteArray, withName: String, callback: Callable) {
103 | guard let converted = data.asData() else {
104 | _ = callback.call(nil, Variant(String("Could not convert the packed array to Data)")))
105 | return
106 | }
107 | local.saveGameData(converted, withName: withName) { savedGame, error in
108 | var savedV: Variant? = nil
109 | if let savedGame = savedGame {
110 | savedV = Variant(GKSavedGame(saved: savedGame))
111 | }
112 | _ = callback.call(savedV, mapError(error))
113 | }
114 | }
115 |
116 | @Callable
117 | func fetch_saved_games(callback: Callable) {
118 | local.fetchSavedGames { savedGames, error in
119 | let ret = TypedArray()
120 | if let savedGames = savedGames {
121 | for sg in savedGames {
122 | ret.append(GKSavedGame(saved: sg))
123 | }
124 | }
125 | _ = callback.call(Variant(ret), mapError(error))
126 | }
127 | }
128 |
129 | @Callable
130 | func delete_saved_games(named: String, callback: Callable) {
131 | local.deleteSavedGames(withName: named) { error in
132 | _ = callback.call(mapError(nil))
133 | }
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/doc_classes/GKAchievement.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Represents an Apple Game Center achievement and provides helper methods to manage progress.
5 |
6 |
7 | Use [code skip-lint]GKAchievement[/code] to mirror the player's progress that you configured on App Store Connect. The static helpers expose the same workflows mentioned in the Achievements section of [code skip-lint]GameCenterGuide.md[/code]: listing the player's reported achievements, resetting them during testing, and sending new completion percentages. Apple's API reference is available at [url=https://developer.apple.com/documentation/gamekit/gkachievement]Apple's GKAchievement reference[/url].
8 |
9 | List all achievements (straight from the guide):
10 | [codeblocks]
11 | [gdscript]
12 | GKAchievement.load_achievements(func(achievements: Array[GKAchievement], error: Variant) -> void:
13 | if error:
14 | print("Load achievement error %s" % error)
15 | else:
16 | for achievement in achievements:
17 | print("Achievement: %s" % achievement.identifier)
18 | )
19 | [/gdscript]
20 | [/codeblocks]
21 |
22 | Report progress and reset utilities:
23 | [codeblocks]
24 | [gdscript]
25 | var id := "a001"
26 | var percentage := 100.0
27 |
28 | GKAchievement.load_achievements(func(achievements: Array[GKAchievement], error: Variant) -> void:
29 | if error:
30 | print("Load achievement error %s" % error)
31 | return
32 |
33 | for achievement in achievements:
34 | if achievement.identifier == id and not achievement.is_completed:
35 | achievement.percent_complete = percentage
36 | achievement.shows_completion_banner = true
37 | GKAchievement.report_achivement([achievement], func(submit_error: Variant) -> void:
38 | if submit_error:
39 | print("Error submitting achievement %s" % submit_error)
40 | else:
41 | print("Success!")
42 | )
43 | )
44 |
45 | GKAchievement.reset_achivements(func(error: Variant) -> void:
46 | if error:
47 | print("Error resetting achievements %s" % error)
48 | else:
49 | print("Reset complete")
50 | )
51 | [/gdscript]
52 | [/codeblocks]
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 | Loads the achievements that the local player has already reported. The callback is invoked with [code skip-lint]Array[GKAchievement][/code] and a [code skip-lint]Variant[/code] error argument ([code]null[/code] on success, or a localized error string).
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 | Submits the new state for the provided achievements. The callback receives a single [code skip-lint]Variant[/code] that is [code]null[/code] on success or contains an error string reported by GameKit. This is the method used in the "Report Progress" snippet inside [code skip-lint]GameCenterGuide.md[/code].
70 |
71 |
72 |
73 |
74 |
75 |
76 | Asks GameKit to clear every achievement for the local player. The callback receives [code]null[/code] on success or a [code skip-lint]Variant[/code] with the error description, matching Apple's [code]resetAchievements[/code] API.
77 |
78 |
79 |
80 |
81 |
82 | The achievement identifier configured on App Store Connect.
83 |
84 |
85 | Read-only flag that mirrors GameKit's [code]isCompleted[/code] property.
86 |
87 |
88 |
89 |
90 | The player's reported completion percentage (0-100). Update this and then call [method report_achivement] to submit it.
91 |
92 |
93 | The [code skip-lint]GKPlayer[/code] owner of this achievement, if GameKit was able to resolve it.
94 |
95 |
96 | Matches Apple's [code]showsCompletionBanner[/code] flag. Set it to true when you want the system to display the stock achievement toast when the progress hits 100%.
97 |
98 |
99 |
100 |
--------------------------------------------------------------------------------
/Sources/GodotApplePlugins/UI/AppleFilePicker.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppleFilePicker.swift
3 | // GodotApplePlugins
4 | //
5 | // Created by Miguel de Icaza on 12/18/25.
6 | //
7 |
8 | @preconcurrency import SwiftGodotRuntime
9 | import Foundation
10 | #if canImport(UIKit)
11 | import UIKit
12 | #else
13 | import AppKit
14 | #endif
15 | import UniformTypeIdentifiers
16 |
17 | @Godot
18 | public class AppleFilePicker: RefCounted, @unchecked Sendable {
19 | // AppleURL, path
20 | @Signal("url", "path") var file_selected: SignalWithArguments
21 |
22 | // [AppleURL], [path]
23 | @Signal("urls", "paths") var files_selected: SignalWithArguments, PackedStringArray>
24 |
25 | @Signal var canceled: SimpleSignal
26 |
27 | // Maintain a strong reference to the delegate so it isn't deallocated
28 | private var delegate: AnyObject?
29 |
30 | #if os(iOS)
31 | class PickerDelegate: NSObject, UIDocumentPickerDelegate {
32 | weak var parent: AppleFilePicker?
33 | let allowMultiple: Bool
34 |
35 | init(_ parent: AppleFilePicker, allowMultiple: Bool) {
36 | self.parent = parent
37 | self.allowMultiple = allowMultiple
38 | }
39 |
40 | func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
41 | defer { parent?.delegate = nil }
42 | guard !urls.isEmpty else {
43 | parent?.canceled.emit()
44 | return
45 | }
46 |
47 | if allowMultiple {
48 | let appleUrls = TypedArray()
49 | let paths = PackedStringArray()
50 |
51 | for url in urls {
52 | let appleURL = AppleURL()
53 | appleURL.url = url
54 | appleUrls.append(appleURL)
55 | paths.append(value: url.path)
56 | }
57 | parent?.files_selected.emit(appleUrls, paths)
58 | } else {
59 | if let url = urls.first {
60 | let appleURL = AppleURL()
61 | appleURL.url = url
62 | parent?.file_selected.emit(appleURL, url.path)
63 | }
64 | }
65 | }
66 |
67 | func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
68 | parent?.delegate = nil
69 | parent?.canceled.emit()
70 | }
71 | }
72 | #endif
73 |
74 | @Callable
75 | func pick_document(allowedTypes: [String], allowMultiple: Bool = false) {
76 | // Convert string extensions/types to UTTypes
77 | var utTypes: [UTType] = []
78 | for ext in allowedTypes {
79 | if let type = UTType(filenameExtension: ext) {
80 | utTypes.append(type)
81 | } else if let type = UTType(ext) {
82 | utTypes.append(type)
83 | }
84 | }
85 |
86 | // Default to content if no valid types provided, or maybe just public.item
87 | if utTypes.isEmpty {
88 | utTypes.append(.content)
89 | }
90 |
91 | DispatchQueue.main.async { [weak self] in
92 | guard let self = self else { return }
93 | self.showPicker(types: utTypes, allowMultiple: allowMultiple)
94 | }
95 | }
96 |
97 | @MainActor
98 | private func showPicker(types: [UTType], allowMultiple: Bool) {
99 | #if os(iOS)
100 | let picker = UIDocumentPickerViewController(forOpeningContentTypes: types, asCopy: false)
101 | let delegate = PickerDelegate(self, allowMultiple: allowMultiple)
102 | self.delegate = delegate
103 | picker.delegate = delegate
104 | picker.allowsMultipleSelection = allowMultiple
105 |
106 | presentOnTop(picker)
107 |
108 | #elseif os(macOS)
109 | let panel = NSOpenPanel()
110 | panel.allowedContentTypes = types
111 | panel.allowsMultipleSelection = allowMultiple
112 | panel.canChooseDirectories = false
113 | panel.canChooseFiles = true
114 |
115 | let handler: (NSApplication.ModalResponse) -> Void = { [weak self] response in
116 | guard let self = self else { return }
117 | if response == .OK {
118 | if allowMultiple {
119 | let appleUrls = TypedArray()
120 | let paths = PackedStringArray()
121 |
122 | for url in panel.urls {
123 | let appleURL = AppleURL()
124 | appleURL.url = url
125 | appleUrls.append(appleURL)
126 | paths.append(url.path)
127 | }
128 | self.files_selected.emit(appleUrls, paths)
129 | } else if let url = panel.url {
130 | let appleURL = AppleURL()
131 | appleURL.url = url
132 | self.file_selected.emit(appleURL, url.path)
133 | }
134 | } else {
135 | self.canceled.emit()
136 | }
137 | }
138 |
139 | if let window = NSApplication.shared.keyWindow ?? NSApplication.shared.mainWindow {
140 | panel.beginSheetModal(for: window, completionHandler: handler)
141 | } else {
142 | panel.begin(completionHandler: handler)
143 | }
144 | #endif
145 | }
146 | }
147 |
--------------------------------------------------------------------------------
/Sources/GodotApplePlugins/GameCenter/GKMatch.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GKMatch.swift
3 | // GodotApplePlugins
4 | //
5 | // Created by Miguel de Icaza on 11/18/25.
6 | //
7 |
8 |
9 | @preconcurrency import SwiftGodotRuntime
10 | import SwiftUI
11 | #if canImport(UIKit)
12 | import UIKit
13 | #else
14 | import AppKit
15 | #endif
16 | import GameKit
17 |
18 | @Godot
19 | class GKMatch: RefCounted, @unchecked Sendable {
20 | var gkmatch = GameKit.GKMatch()
21 | var delegate: Proxy?
22 |
23 | enum SendDataMode: Int, CaseIterable {
24 | case reliable
25 | case unreliable
26 | func toGameKit() -> GameKit.GKMatch.SendDataMode {
27 | switch self {
28 | case .reliable: return .reliable
29 | case .unreliable: return .unreliable
30 | }
31 | }
32 | }
33 |
34 | convenience init(match: GameKit.GKMatch) {
35 | self.init()
36 | self.gkmatch = match
37 |
38 | // This is just so the proxy is not deallocated right away
39 | self.delegate = Proxy(self)
40 | gkmatch.delegate = self.delegate
41 | }
42 |
43 | class Proxy: NSObject, GKMatchDelegate {
44 | weak var base: GKMatch?
45 |
46 | init(_ base: GKMatch) {
47 | self.base = base
48 | }
49 |
50 | func match(
51 | _ match: GameKit.GKMatch,
52 | didReceive: Data,
53 | fromRemotePlayer: GameKit.GKPlayer
54 | ) {
55 | base?.data_received.emit(didReceive.toPackedByteArray(), GKPlayer(player: fromRemotePlayer))
56 | }
57 |
58 | func match(
59 | _ match: GameKit.GKMatch,
60 | didReceive: Data,
61 | forRecipient: GameKit.GKPlayer,
62 | fromRemotePlayer: GameKit.GKPlayer
63 | ) {
64 | base?.data_received_for_recipient_from_player.emit(
65 | didReceive.toPackedByteArray(),
66 | GKPlayer(player: forRecipient),
67 | GKPlayer(player: fromRemotePlayer)
68 | )
69 | }
70 |
71 | func match(
72 | _ match: GameKit.GKMatch,
73 | player: GameKit.GKPlayer,
74 | didChange: GKPlayerConnectionState
75 | ) {
76 | base?.player_changed.emit(
77 | GKPlayer(player: player),
78 | didChange == .connected
79 | )
80 | }
81 |
82 | func match(
83 | _ match: GameKit.GKMatch,
84 | didFailWithError: (any Error)?
85 | ) {
86 | let res: String
87 | if let didFailWithError {
88 | res = didFailWithError.localizedDescription
89 | } else {
90 | res = "Generic error"
91 | }
92 | base?.did_fail_with_error.emit(res)
93 | }
94 |
95 | func match(
96 | _ match: GameKit.GKMatch,
97 | shouldReinviteDisconnectedPlayer: GameKit.GKPlayer
98 | ) -> Bool {
99 | guard let base, let cb = base.should_reinvite_disconnected_player else {
100 | return false
101 | }
102 | let retV = cb.call(Variant(GKPlayer(player: shouldReinviteDisconnectedPlayer)))
103 | if let ret = Bool(retV) {
104 | return ret
105 | }
106 | return false
107 | }
108 | }
109 |
110 | @Signal("data", "player") var data_received: SignalWithArguments
111 | @Signal("data", "recipient", "from_remote_player") var data_received_for_recipient_from_player: SignalWithArguments
112 |
113 | // The boolean indicates if it is connected (true) or disconncted(false
114 | @Signal("player", "connected") var player_changed: SignalWithArguments
115 | @Signal("message") var did_fail_with_error: SignalWithArguments
116 |
117 | // Connect to a function that accepts a GKPlayer and returns a boolean
118 | @Export var should_reinvite_disconnected_player: Callable?
119 |
120 | @Export var expected_player_count: Int { gkmatch.expectedPlayerCount }
121 | @Export var players: VariantArray {
122 | let result = VariantArray()
123 | for player in gkmatch.players {
124 | result.append(Variant(GKPlayer(player: player)))
125 | }
126 | return result
127 | }
128 |
129 | // TODO: these Godot errors could be better, or perhaps we should return a string? But I do not like
130 | // the idea of returning an empty string to say "ok"
131 | @Callable
132 | func send(data: PackedByteArray, toPlayers: VariantArray, dataMode: SendDataMode) -> GodotError {
133 | guard let sdata = data.asData() else {
134 | return .failed
135 | }
136 | var to: [GameKit.GKPlayer] = []
137 | for po in toPlayers {
138 | guard let po, let player = po.asObject(GKPlayer.self) else {
139 | continue
140 | }
141 | to.append(player.player)
142 | }
143 | do {
144 | try gkmatch.send(sdata, to: to, dataMode: dataMode.toGameKit())
145 | return .ok
146 | } catch {
147 | return .failed
148 | }
149 | }
150 |
151 | @Callable
152 | func send_data_to_all_players(data: PackedByteArray, dataMode: SendDataMode) -> GodotError {
153 | guard let sdata = data.asData() else {
154 | return .failed
155 | }
156 | do {
157 | try gkmatch.sendData(toAllPlayers: sdata, with: dataMode.toGameKit())
158 | return .ok
159 | } catch {
160 | return .failed
161 | }
162 | }
163 |
164 | @Callable
165 | func disconnect() {
166 | gkmatch.disconnect()
167 | }
168 | }
169 |
--------------------------------------------------------------------------------
/fix_doc_enums.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | #
3 | # fix_doc_enums.sh - Post-process generated doc XML.
4 | #
5 | # 1. Convert enum parameter types from "Class.Enum" to "int" + enum attr.
6 | # 2. Add [code skip-lint] to snippets that trip Godot's doc linter.
7 | #
8 |
9 | set -euo pipefail
10 |
11 | DOC_DIR="${1:-doc_classes}"
12 |
13 | if [ ! -d "$DOC_DIR" ]; then
14 | echo "Error: Directory '$DOC_DIR' not found" >&2
15 | echo "Usage: $0 [doc_classes_directory]" >&2
16 | exit 1
17 | fi
18 |
19 | python3 - "$DOC_DIR" <<'PY'
20 | import re
21 | import sys
22 | from pathlib import Path
23 |
24 | doc_dir = Path(sys.argv[1])
25 |
26 | ENUM_DOTTED_PATTERN = re.compile(r'type="([A-Z][A-Za-z0-9]*\.[A-Z][A-Za-z0-9]*)"')
27 | CODE_PATTERN = re.compile(r'\[code\]([A-Za-z0-9_.@\[\]\(\)]+)\[/code\]')
28 | ARRAY_PATTERN = re.compile(r'type="Array\[[^"]+\]"')
29 | ENUM_DECL_PATTERN = re.compile(r'enum="([A-Za-z0-9_.]+)"')
30 | CLASS_PATTERN = re.compile(r' tuple[str, bool]:
66 | changed = False
67 |
68 | def repl(match: re.Match) -> str:
69 | nonlocal changed
70 | changed = True
71 | return f'type="int" enum="{match.group(1)}"'
72 |
73 | text = ENUM_DOTTED_PATTERN.sub(repl, text)
74 |
75 | enum_names = {m.group(1).split(".")[-1] for m in ENUM_DECL_PATTERN.finditer(text)}
76 | alias_names = TYPE_ALIASES.get(class_name, set())
77 | enum_names.update(alias_names)
78 |
79 | for enum_name in sorted(enum_names):
80 | pattern = re.compile(rf'(]*\btype="){re.escape(enum_name)}(")')
81 | text, count = pattern.subn(r'\1int\2', text)
82 | if count:
83 | changed = True
84 |
85 | text, count = ARRAY_PATTERN.subn('type="Array"', text)
86 | if count:
87 | changed = True
88 |
89 | return text, changed
90 |
91 |
92 | def add_skip_lint(text: str) -> tuple[str, bool]:
93 | changed = False
94 |
95 | def repl(match: re.Match) -> str:
96 | nonlocal changed
97 | token = match.group(1)
98 | normalized = token.lstrip("@")
99 | normalized = normalized.rstrip("()")
100 | normalized = normalized.replace("[", "").replace("]", "")
101 | if token in LOWER_SKIP or normalized in LOWER_SKIP or (normalized and normalized[0].isupper()):
102 | changed = True
103 | return f"[code skip-lint]{token}[/code]"
104 | return match.group(0)
105 |
106 | return CODE_PATTERN.sub(repl, text), changed
107 |
108 |
109 | def rename_signal_params(text: str, class_name: str) -> tuple[str, bool]:
110 | if class_name not in SIGNAL_PARAM_RENAMES:
111 | return text, False
112 | changed = False
113 | for signal_name, renames in SIGNAL_PARAM_RENAMES[class_name].items():
114 | for index, new_name in renames.items():
115 | pattern = re.compile(
116 | rf'(.*? tuple[str, bool]:
127 | changed = False
128 |
129 | def open_repl(match: re.Match) -> str:
130 | nonlocal changed
131 | changed = True
132 | indent = match.group(1)
133 | return f"{indent}[codeblocks]\n{indent}[gdscript]"
134 |
135 | def close_repl(match: re.Match) -> str:
136 | indent = match.group(1)
137 | return f"{indent}[/gdscript]\n{indent}[/codeblocks]"
138 |
139 | new_text = CODEBLOCK_OPEN_PATTERN.sub(open_repl, text)
140 | new_text = CODEBLOCK_CLOSE_PATTERN.sub(close_repl, new_text)
141 | if new_text != text:
142 | changed = True
143 | return new_text, changed
144 |
145 |
146 | def process_file(path: Path) -> bool:
147 | text = path.read_text(encoding="utf-8")
148 | class_match = CLASS_PATTERN.search(text)
149 | class_name = class_match.group(1) if class_match else ""
150 |
151 | text, enums_changed = replace_enum_types(text, class_name)
152 | text, skip_changed = add_skip_lint(text)
153 | text, rename_changed = rename_signal_params(text, class_name)
154 | text, tabs_changed = convert_codeblocks(text)
155 |
156 | if enums_changed or skip_changed or rename_changed or tabs_changed:
157 | path.write_text(text, encoding="utf-8")
158 | return True
159 | return False
160 |
161 |
162 | changed = 0
163 | for xml_file in sorted(doc_dir.glob("*.xml")):
164 | if process_file(xml_file):
165 | changed += 1
166 | print(f"Fixed: {xml_file.name}")
167 |
168 | if changed == 0:
169 | print("No files needed fixing")
170 | else:
171 | print(f"Fixed {changed} file(s)")
172 | PY
173 |
--------------------------------------------------------------------------------
/Sources/GodotApplePlugins/GameCenter/GKAchievement.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppleAchievement.swift
3 | // GodotApplePlugins
4 | //
5 | // Created by Miguel de Icaza on 11/15/25.
6 | //
7 |
8 | @preconcurrency import SwiftGodotRuntime
9 | import SwiftUI
10 | #if canImport(UIKit)
11 | import UIKit
12 | #else
13 | import AppKit
14 | #endif
15 |
16 | import GameKit
17 |
18 | @Godot
19 | class GKAchievement: RefCounted, @unchecked Sendable {
20 | var achievement: GameKit.GKAchievement = GameKit.GKAchievement()
21 |
22 | convenience init(identifier: String, player: GKPlayer?) {
23 | self.init()
24 |
25 | if let player {
26 | self.achievement = GameKit.GKAchievement(identifier: identifier, player: player.player)
27 | } else {
28 | self.achievement = GameKit.GKAchievement(identifier: identifier)
29 | }
30 | }
31 |
32 | convenience init(achievement: GameKit.GKAchievement) {
33 | self.init()
34 | self.achievement = achievement
35 | }
36 |
37 | @Export var identifier: String {
38 | get { achievement.identifier }
39 | set { achievement.identifier = newValue }
40 | }
41 | @Export var player: GKPlayer { GKPlayer(player: achievement.player) }
42 | @Export var percentComplete: Double {
43 | get { achievement.percentComplete }
44 | set { achievement.percentComplete = newValue }
45 | }
46 | @Export var isCompleted: Bool { achievement.isCompleted }
47 | @Export var showsCompletionBanner: Bool {
48 | get { achievement.showsCompletionBanner }
49 | set { achievement.showsCompletionBanner = newValue }
50 | }
51 | @Export var lastReportedDate: Double {
52 | achievement.lastReportedDate.timeIntervalSince1970
53 | }
54 |
55 | /// The callback is invoked with nil on success, or a string with a description of the error
56 | @Callable()
57 | static func report_achivement(achivements: VariantArray, callback: Callable) {
58 | var array: [GameKit.GKAchievement] = []
59 | for va in achivements {
60 | guard let va else { continue }
61 | if let a = va.asObject(GKAchievement.self) {
62 | array.append(a.achievement)
63 | }
64 | }
65 | GameKit.GKAchievement.report(array) { error in
66 | _ = callback.call(mapError(error))
67 | }
68 | }
69 |
70 | /// The callback is invoked with nil on success, or a string with a description of the error
71 | @Callable
72 | static func reset_achivements(callback: Callable) {
73 | GameKit.GKAchievement.resetAchievements { error in
74 | _ = callback.call(mapError(error))
75 | }
76 | }
77 |
78 | /// Callback is invoked with two arguments an `Array[GKAchivement]` and an error argument
79 | /// on success the error i snil
80 | @Callable
81 | static func load_achievements(callback: Callable) {
82 | GameKit.GKAchievement.loadAchievements { achievements, error in
83 | let res = TypedArray()
84 |
85 | if let achievements {
86 | for ad in achievements {
87 | let ad = GKAchievement(achievement: ad)
88 | res.append(ad)
89 | }
90 | }
91 | _ = callback.call(Variant(res), mapError(error))
92 | }
93 | }
94 | }
95 |
96 | @Godot
97 | class GKAchievementDescription: RefCounted, @unchecked Sendable {
98 | var achievementDescription: GameKit.GKAchievementDescription = GameKit.GKAchievementDescription()
99 |
100 | convenience init(_ ad: GameKit.GKAchievementDescription) {
101 | self.init()
102 | self.achievementDescription = ad
103 | }
104 |
105 | @Export var identifier: String { achievementDescription.identifier }
106 | @Export var title: String { achievementDescription.title }
107 | @Export var unachievedDescription: String { achievementDescription.unachievedDescription }
108 | @Export var achievedDescription: String { achievementDescription.achievedDescription }
109 | @Export var maximumPoints: Int { achievementDescription.maximumPoints }
110 | @Export var isHidden: Bool { achievementDescription.isHidden }
111 | @Export var isReplayable: Bool { achievementDescription.isReplayable }
112 | @Export var groupIdentifier: String { achievementDescription.groupIdentifier ?? "" }
113 | /// A double with the valur or nil
114 | @Export var rarityPercent: Variant? {
115 | if let rp = achievementDescription.rarityPercent {
116 | return Variant(rp)
117 | } else {
118 | return nil
119 | }
120 | }
121 |
122 | /// Callback is invoked with two arguments an Image witht he image and an error argument
123 | /// either one can be nil.
124 | @Callable
125 | func load_image(callback: Callable) {
126 | achievementDescription.loadImage { image, error in
127 | if let error {
128 | _ = callback.call(nil, mapError(error))
129 | } else if let image, let godotImage = image.asGodotImage() {
130 | _ = callback.call(godotImage, nil)
131 | } else {
132 | _ = callback.call(nil, Variant("Could not load image"))
133 | }
134 | }
135 | }
136 |
137 | /// Callback is invoked with two arguments an array of GKAchivementDescriptions and an error argument
138 | /// either one can be nil.
139 | @Callable
140 | static func load_achievement_descriptions(callback: Callable) {
141 | GameKit.GKAchievementDescription.loadAchievementDescriptions { achievementDescriptions, error in
142 | let res = TypedArray()
143 |
144 | if let achievementDescriptions {
145 | for ad in achievementDescriptions {
146 | let ad = GKAchievementDescription(ad)
147 | res.append(ad)
148 | }
149 | }
150 | _ = callback.call(Variant(res), mapError(error))
151 | }
152 | }
153 | }
154 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: run xcframework check_swiftsyntax
2 |
3 | # Allow overriding common build knobs.
4 | CONFIG ?= Release
5 | DESTINATIONS ?= generic/platform=iOS platform=macOS,arch=arm64 platform=macOS,arch=x86_64
6 | DERIVED_DATA ?= $(CURDIR)/.xcodebuild
7 | WORKSPACE ?= .swiftpm/xcode/package.xcworkspace
8 | SCHEME ?= GodotApplePlugins
9 | FRAMEWORK_NAMES ?= GodotApplePlugins
10 | XCODEBUILD ?= xcodebuild
11 |
12 | run:
13 | @echo -e "Run make xcframework to produce the binary payloads for all platforms"
14 |
15 | build:
16 | set -e; \
17 | swift build; \
18 | for dest in $(DESTINATIONS); do \
19 | suffix=`echo $$dest | sed 's,generic/platform=[a-zA-Z]*,,' | sed 's,platform=[a-zA-Z]*,,' | sed 's/,arch=//'`; \
20 | platform_name=`echo $$dest | sed -n 's/.*platform=\([a-zA-Z0-9_]*\).*/\1/p'`; \
21 | if [ -z "$$platform_name" ]; then \
22 | platform_name="iOS"; \
23 | fi; \
24 | platform_lc=`echo $$platform_name | tr '[:upper:]' '[:lower:]'`; \
25 | arch_name=`echo $$dest | sed -n 's/.*arch=\([a-zA-Z0-9_]*\).*/\1/p'`; \
26 | if [ -z "$$arch_name" ] && [ "$$platform_lc" = "ios" ]; then \
27 | arch_name="arm64"; \
28 | fi; \
29 | if [ -z "$$arch_name" ] && [ "$$platform_lc" = "macos" ]; then \
30 | arch_name=`uname -m`; \
31 | fi; \
32 | echo HERE: $$suffix; \
33 | for framework in $(FRAMEWORK_NAMES); do \
34 | $(XCODEBUILD) \
35 | -workspace '$(WORKSPACE)' \
36 | -scheme $$framework \
37 | -configuration '$(CONFIG)' \
38 | -destination "$$dest" \
39 | -derivedDataPath "$(DERIVED_DATA)$$suffix" \
40 | build; \
41 | if [ "$$platform_lc" = "ios" ] || [ "$$platform_lc" = "macos" ]; then \
42 | $(CURDIR)/relink_without_swiftsyntax.sh \
43 | --derived-data "$(DERIVED_DATA)$$suffix" \
44 | --config "$(CONFIG)" \
45 | --framework $$framework \
46 | --platform $$platform_lc \
47 | --arch $$arch_name; \
48 | else \
49 | echo "Skipping SwiftSyntax relink for $$framework on $$dest (unsupported platform)"; \
50 | fi; \
51 | done; \
52 | done; \
53 |
54 | check_swiftsyntax:
55 | set -e; \
56 | pattern='SwiftSyntax|SwiftParser|SwiftDiagnostics|SwiftParserDiagnostics|SwiftBasicFormat|_SwiftSyntaxCShims'; \
57 | failed=0; \
58 | check_one() { \
59 | sdk="$$1"; bin="$$2"; label="$$3"; \
60 | if [ ! -f "$$bin" ]; then \
61 | echo "SKIP: $$label (missing: $$bin)"; \
62 | return 0; \
63 | fi; \
64 | if xcrun --sdk "$$sdk" nm -gU "$$bin" 2>/dev/null | grep -Eq "$$pattern"; then \
65 | echo "FAIL: $$label still contains SwiftSyntax-related symbols"; \
66 | failed=1; \
67 | else \
68 | echo "OK: $$label"; \
69 | fi; \
70 | }; \
71 | for framework in $(FRAMEWORK_NAMES); do \
72 | check_one iphoneos "$(DERIVED_DATA)/Build/Products/$(CONFIG)-iphoneos/PackageFrameworks/$$framework.framework/$$framework" "iOS/$$framework"; \
73 | check_one macosx "$(DERIVED_DATA)arm64/Build/Products/$(CONFIG)/PackageFrameworks/$$framework.framework/$$framework" "macOS arm64/$$framework"; \
74 | check_one macosx "$(DERIVED_DATA)x86_64/Build/Products/$(CONFIG)/PackageFrameworks/$$framework.framework/$$framework" "macOS x86_64/$$framework"; \
75 | done; \
76 | test "$$failed" -eq 0
77 |
78 | package: build dist
79 |
80 | dist:
81 | for framework in $(FRAMEWORK_NAMES); do \
82 | rm -rf $(CURDIR)/addons/$$framework/bin/$$framework.xcframework; \
83 | rm -rf $(CURDIR)/addons/$$framework/bin/$$framework*.framework; \
84 | $(XCODEBUILD) -create-xcframework \
85 | -framework $(DERIVED_DATA)/Build/Products/$(CONFIG)-iphoneos/PackageFrameworks/$$framework.framework \
86 | -output $(CURDIR)/addons/$$framework/bin/$${framework}.xcframework; \
87 | rsync -a $(DERIVED_DATA)x86_64/Build/Products/$(CONFIG)/PackageFrameworks/$${framework}.framework/ $(CURDIR)/addons/$$framework/bin/$${framework}_x64.framework; \
88 | rsync -a $(DERIVED_DATA)arm64/Build/Products/$(CONFIG)/PackageFrameworks/$${framework}.framework/ $(CURDIR)/addons/$$framework/bin/$${framework}.framework; \
89 | rsync -a doc_classes/ $(CURDIR)/addons/$$framework/bin/$${framework}_x64.framework/Resources/doc_classes/; \
90 | rsync -a doc_classes/ $(CURDIR)/addons/$$framework/bin/$${framework}.framework/Resources/doc_classes/; \
91 | done
92 |
93 | XCFRAMEWORK_GODOTAPPLEPLUGINS ?= $(CURDIR)/addons/GodotApplePlugins/bin/GodotApplePlugins.xcframework
94 |
95 | justgen:
96 | (cd test-apple-godot-api; ~/cvs/master-godot/editor/bin/godot.macos.editor.dev.arm64 --headless --path . --doctool .. --gdextension-docs)
97 |
98 | gendocs: justgen
99 | ./fix_doc_enums.sh
100 | $(MAKE) -C doctools html
101 |
102 | #
103 | # Quick hacks I use for rapid iteration
104 | #
105 | # My hack is that I build on Xcode for Mac and iPad first, then I
106 | # iterate by just rebuilding in one platform, and then running
107 | # "make o" here over and over, and my Godot project already has a
108 | # symlink here, so I can test quickly on desktop against the Mac
109 | # API.
110 | o:
111 | rm -rf '$(XCFRAMEWORK_GODOTAPPLEPLUGINS)'; \
112 | rm -rf addons/GodotApplePlugins/bin/GodotApplePlugins.framework; \
113 | $(XCODEBUILD) -create-xcframework \
114 | -framework ~/DerivedData/GodotApplePlugins-*/Build/Products/Debug-iphoneos/PackageFrameworks/GodotApplePlugins.framework/ \
115 | -output '$(XCFRAMEWORK_GODOTAPPLEPLUGINS)'
116 | cp -pr ~/DerivedData/GodotApplePlugins-*/Build/Products/Debug/PackageFrameworks/GodotApplePlugins.framework addons/GodotApplePlugins/bin/GodotApplePlugins.framework
117 | rsync -a doc_classes/ addons/GodotApplePlugins/bin/GodotApplePlugins.framework/Resources/doc_classes/
118 | #
119 | # This I am using to test on the "Exported" project I placed
120 | #
121 | XCFRAMEWORK_EXPORT_PATH=test-apple-godot-api/TestAppleGodotApi/dylibs/addons/GodotApplePlugins/bin/GodotApplePlugins.xcframework
122 | make oo:
123 | rm -rf $(XCFRAMEWORK_EXPORT_PATH)
124 | $(XCODEBUILD) -create-xcframework \
125 | -framework ~/DerivedData/GodotApplePlugins-*/Build/Products/Debug-iphoneos/PackageFrameworks/GodotApplePlugins.framework/ \
126 | -framework ~/DerivedData/GodotApplePlugins-*/Build/Products/Debug/PackageFrameworks/GodotApplePlugins.framework/ \
127 | -output '$(XCFRAMEWORK_EXPORT_PATH)'
128 |
--------------------------------------------------------------------------------
/Sources/GodotApplePlugins/StoreKit/SubscriptionStoreView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SubscriptionStoreView.swift
3 | // GodotApplePlugins
4 | //
5 | // Created by Miguel de Icaza on 11/21/25.
6 | //
7 |
8 | @preconcurrency import SwiftGodotRuntime
9 | import StoreKit
10 | import SwiftUI
11 |
12 | @Godot
13 | class SubscriptionStoreView: RefCounted, @unchecked Sendable {
14 | @Export var groupID: String = ""
15 | @Export var productIDs: PackedStringArray = PackedStringArray()
16 |
17 | // Enum for SubscriptionStoreControlStyle
18 | enum ControlStyle: Int, CaseIterable {
19 | case automatic
20 | case picker
21 | case buttons
22 | case compactPicker
23 | case prominentPicker
24 | case pagedPicker
25 | case pagedProminentPicker
26 | }
27 |
28 | @Export(.enum) var controlStyle: ControlStyle = .automatic
29 |
30 | struct ShowSubscriptionStoreView: View {
31 | @Environment(\.dismiss) private var dismiss
32 | var groupID: String
33 | var productIDs: [String]
34 |
35 | var controlStyle: ControlStyle
36 | var body: some View {
37 | Group {
38 | if groupID != "" {
39 | StoreKit.SubscriptionStoreView(groupID: groupID)
40 | } else {
41 | StoreKit.SubscriptionStoreView(productIDs: productIDs)
42 | }
43 | }
44 | .toolbar {
45 | ToolbarItem(placement: .cancellationAction) {
46 | Button("Close") {
47 | dismiss()
48 | }
49 | }
50 | }
51 | }
52 | }
53 |
54 | @Callable
55 | func present() {
56 | MainActor.assumeIsolated {
57 | var ids: [String] = []
58 | if productIDs.count > 0 {
59 | for id in productIDs {
60 | ids.append(id)
61 | }
62 | }
63 |
64 | let wrappedView = NavigationView {
65 | // Ugly, but not sure what to do other than AnyViewing itall.
66 | Group {
67 | switch controlStyle {
68 | case .automatic:
69 | if !groupID.isEmpty {
70 | StoreKit.SubscriptionStoreView(groupID: groupID)
71 | .subscriptionStoreControlStyle(.automatic)
72 | } else {
73 | StoreKit.SubscriptionStoreView(productIDs: ids)
74 | .subscriptionStoreControlStyle(.automatic)
75 | }
76 | case .picker:
77 | if !groupID.isEmpty {
78 | StoreKit.SubscriptionStoreView(groupID: groupID)
79 | .subscriptionStoreControlStyle(.automatic)
80 | } else {
81 | StoreKit.SubscriptionStoreView(productIDs: ids)
82 | .subscriptionStoreControlStyle(.automatic)
83 | }
84 | case .buttons:
85 | if !groupID.isEmpty {
86 | StoreKit.SubscriptionStoreView(groupID: groupID)
87 | .subscriptionStoreControlStyle(.automatic)
88 | } else {
89 | StoreKit.SubscriptionStoreView(productIDs: ids)
90 | .subscriptionStoreControlStyle(.automatic)
91 | }
92 | case .compactPicker:
93 | if !groupID.isEmpty {
94 | StoreKit.SubscriptionStoreView(groupID: groupID)
95 | .subscriptionStoreControlStyle(.automatic)
96 | } else {
97 | StoreKit.SubscriptionStoreView(productIDs: ids)
98 | .subscriptionStoreControlStyle(.automatic)
99 | }
100 | case .prominentPicker:
101 | if !groupID.isEmpty {
102 | StoreKit.SubscriptionStoreView(groupID: groupID)
103 | .subscriptionStoreControlStyle(.automatic)
104 | } else {
105 | StoreKit.SubscriptionStoreView(productIDs: ids)
106 | .subscriptionStoreControlStyle(.automatic)
107 | }
108 | case .pagedPicker:
109 | if !groupID.isEmpty {
110 | StoreKit.SubscriptionStoreView(groupID: groupID)
111 | .subscriptionStoreControlStyle(.automatic)
112 | } else {
113 | StoreKit.SubscriptionStoreView(productIDs: ids)
114 | .subscriptionStoreControlStyle(.automatic)
115 | }
116 | case .pagedProminentPicker:
117 | if !groupID.isEmpty {
118 | StoreKit.SubscriptionStoreView(groupID: groupID)
119 | .subscriptionStoreControlStyle(.automatic)
120 | } else {
121 | StoreKit.SubscriptionStoreView(productIDs: ids)
122 | .subscriptionStoreControlStyle(.automatic)
123 | }
124 | }
125 | }
126 | .toolbar {
127 | ToolbarItem(placement: .cancellationAction) {
128 | Button("Close") {
129 | dismissTopView()
130 | }
131 | }
132 | }
133 | }
134 | presentView(wrappedView)
135 | }
136 | }
137 |
138 | @Callable
139 | func dismiss() {
140 | Task { @MainActor in
141 | dismissTopView()
142 | }
143 | }
144 | }
145 |
--------------------------------------------------------------------------------
/doc_classes/StoreKitManager.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Manages StoreKit interactions such as requesting products, purchasing, and restoring purchases.
5 |
6 |
7 | This class handles the core StoreKit functionality. It provides methods to fetch product information from the App Store, initiate purchases,
8 | restore previous purchases and be notified of the current status of the purchase upon startup.
9 |
10 | Once you instantiate this class, connect to the the [signal transaction_updated] signal to
11 | get notification related to the status of your purchases - these are delivered to notify
12 | your application of what products your user is entitled to at startup. If you are using
13 | Apple's PurchaseIntent, you will also want to connect to the [signal purchas_intent].
14 |
15 | After you have set up your signals, you need to call [method start] when you are ready
16 | to receive those events.
17 |
18 | In addition, when you call the [method purchase] or [method purchase_with_options] you will
19 | want to setup a handler for the [signal purchase_completed] signal which is raised in
20 | response to different events during the purchasing.
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | Initiates the purchase of a specific product, e.g. [code]purchase(product)[/code]. This will raise the
30 | [signal purchase_completed] signal, either to indicate that an error took place, or the status of the
31 | purchase.
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | Initiates the purchase of a specific product, e.g. [code]purchase(product)[/code], and allows you to provide additional purchase options.
40 | This will raise the
41 | [signal purchase_completed] signal, either to indicate that an error took place, or the status of the
42 | purchase.
43 |
44 |
45 |
46 |
47 |
48 |
49 | Requests product information for a list of product identifiers, e.g. [code]request_products(["com.example.product1", "com.example.product2"])[/code].
50 | This method will raise the [signal products_request_completed] signal when the information is retrieved.
51 |
52 |
53 |
54 |
55 |
56 | Restores previously purchased non-consumable products and auto-renewable subscriptions. This will raise the
57 | [signal restore_completed] signal when the product purchased have been restored.
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 | Emitted when a product request completes.
67 | [param products] is an Array of [StoreProduct]s (or nulls).
68 | [param status] indicates success or failure.
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 | Emitted when a purchase completes.
77 | [param transaction] is the [StoreTransaction] on success.
78 | [param status] indicates the result (OK, cancelled, invalid product, etc.).
79 | [param error_message] contains error details if failed.
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 | Emitted when the restore process completes. `arg1` is the [enum StoreKitStatus], and `arg2` is an error message if applicable.
92 |
93 |
94 |
95 |
96 |
97 | Emitted when a transaction is updated (e.g., a subscription renews or a purchase is approved externally). `arg1` is the updated [StoreTransaction].
98 |
99 |
100 |
101 |
102 |
103 | The operation completed successfully.
104 |
105 |
106 | The product identifier is invalid.
107 |
108 |
109 | The operation was cancelled.
110 |
111 |
112 | The transaction could not be verified.
113 |
114 |
115 | The user cancelled the operation.
116 |
117 |
118 | The purchase is pending (e.g., waiting for parental approval).
119 |
120 |
121 | An unknown status occurred.
122 |
123 |
124 |
125 |
--------------------------------------------------------------------------------
/doc_classes/GKLocalPlayer.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Represents the signed-in Game Center player and exposes local-only APIs.
5 |
6 |
7 | Use [code skip-lint]GKLocalPlayer[/code] to inspect authentication flags and to fetch the local player's friends as shown in the "Players" section of [code skip-lint]GameCenterGuide.md[/code]. Every asynchronous call mirrors Apple's API and invokes your [code skip-lint]Callable[/code] with the requested data plus an optional error [code skip-lint]Variant[/code]. Refer to Apple's documentation at [url=https://developer.apple.com/documentation/gamekit/gklocalplayer]Apple's GKLocalPlayer reference[/url] for platform-specific behavior.
8 |
9 | Sample from the guide that fetches the local player when your scene loads:
10 | [codeblocks]
11 | [gdscript]
12 | var game_center: GameCenterManager
13 | var local: GKLocalPlayer
14 |
15 | func _ready() -> void:
16 | game_center = GameCenterManager.new()
17 | local = game_center.local_player
18 | print("ONREADY: local, is auth: %s" % local.is_authenticated)
19 | print("ONREADY: local, player ID: %s" % local.game_player_id)
20 | [/gdscript]
21 | [/codeblocks]
22 |
23 | Friends-related helpers mirror the snippets in the guide (assuming you stored the player reference in [code]local[/code]):
24 | [codeblocks]
25 | [gdscript]
26 | # Loads the local player's friends list if access is granted.
27 | local.load_friends(func(friends: Array[GKPlayer], error: Variant) -> void:
28 | if error:
29 | print(error)
30 | else:
31 | for friend in friends:
32 | print(friend.display_name)
33 | )
34 |
35 | # Loads players the local user can challenge.
36 | local.load_challengeable_friends(func(friends: Array[GKPlayer], error: Variant) -> void:
37 | if error:
38 | print(error)
39 | else:
40 | for friend in friends:
41 | print(friend.display_name)
42 | )
43 |
44 | # Loads the friends or recent players list.
45 | local.load_recent_friends(func(friends: Array[GKPlayer], error: Variant) -> void:
46 | if error:
47 | print(error)
48 | else:
49 | for friend in friends:
50 | print(friend.display_name)
51 | )
52 | [/gdscript]
53 | [/codeblocks]
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 | Calls Apple's [code]fetchItems[/code] helper for server-side authentication. The callback receives [code](Dictionary data, Variant error)[/code]. The dictionary contains the [code]url[/code], [code]data[/code], [code]salt[/code], and [code]timestamp[/code] keys described in the inline Swift documentation, letting your backend verify the player's identity.
70 |
71 |
72 |
73 |
74 |
75 |
76 | Use this API to retrieve the list of saved games, upon completion, this
77 | method invokes the provided callback with both an array of GKSavedGame objects
78 | and a variant error, if not nil it contains a string describing the problem.
79 |
80 |
81 |
82 |
83 |
84 |
85 | Loads players whom the local user can challenge. The callback receives [code](Array[GKPlayer] friends, Variant error)[/code] where either argument can be [code]null[/code].
86 |
87 |
88 |
89 |
90 |
91 |
92 | Fetches the friends list. The callback receives [code](Array[GKPlayer] friends, Variant error)[/code]; a non-null error [code skip-lint]Variant[/code] holds the localized error string.
93 |
94 |
95 |
96 |
97 |
98 |
99 | Loads friends that recently played together with the local player. The callback signature matches [method load_challengeable_friends].
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 | Saves the packed byte array as the game data with the specified name, upon
109 | completion the callback is invoked with both a GKSavedObject parameter and
110 | a Variant parameter for the error. The GKSavedObject is not-nil on success,
111 | and on error, the second parameter is not-nil and contains the error message.
112 |
113 |
114 |
115 |
116 |
117 | Reflects [code skip-lint]GKLocalPlayer.local.isAuthenticated[/code].
118 |
119 |
120 | True when multiplayer is disabled by parental controls.
121 |
122 |
123 | Indicates whether personalized communication is blocked for this account.
124 |
125 |
126 | Apple's [code]isUnderage[/code] flag for COPPA-compliant flows.
127 |
128 |
129 |
130 |
--------------------------------------------------------------------------------
/doc_classes/GKMatch.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Represents an active real-time Game Center match.
5 |
6 |
7 | [code skip-lint]GKMatch[/code] wraps Apple's realtime networking object. After starting matchmaking with [code skip-lint]GKMatchmakerViewController[/code] (see the "Realtime Matchmaking" section of [code skip-lint]GameCenterGuide.md[/code]) you will receive a [code skip-lint]GKMatch[/code] instance and can send data, monitor players, and respond to disconnections via Godot signals. For platform specifics see [url=https://developer.apple.com/documentation/gamekit/gkmatch]Apple's GKMatch reference[/url].
8 |
9 | Guide sample showing matchmaking and signal hookups:
10 | [codeblocks]
11 | [gdscript]
12 | var request := GKMatchRequest.new()
13 | request.max_players = 2
14 | request.min_players = 1
15 | request.invite_message = "Join me in a quest to fun"
16 |
17 | GKMatchmakerViewController.request_match(request, func(game_match: GKMatch, error: Variant) -> void:
18 | if error:
19 | print("Could not request a match %s" % error)
20 | return
21 |
22 | print("Got a match!")
23 | game_match.data_received.connect(func(data: PackedByteArray, from_player: GKPlayer) -> void:
24 | print("Received data from %s" % from_player.display_name)
25 | )
26 | game_match.data_received_for_recipient_from_player.connect(func(data: PackedByteArray, for_recipient: GKPlayer, from_remote_player: GKPlayer) -> void:
27 | print("Forwarded data for %s from %s" % [for_recipient.display_name, from_remote_player.display_name])
28 | )
29 | game_match.did_fail_with_error.connect(func(match_error: String) -> void:
30 | print("Match failed with %s" % match_error)
31 | )
32 | game_match.should_reinvite_disconnected_player = func(player: GKPlayer) -> bool:
33 | return true
34 | game_match.player_changed.connect(func(player: GKPlayer, connected: bool) -> void:
35 | print("Player %s changed to %s" % [player.display_name, connected])
36 | )
37 | )
38 | [/gdscript]
39 | [/codeblocks]
40 |
41 | Broadcast helper from the guide (assuming you keep the reference as [code]game_match[/code]):
42 | [codeblocks]
43 | [gdscript]
44 | var data := "How do you do fellow kids".to_utf8_buffer()
45 | game_match.send_data_to_all_players(data, GKMatch.SendDataMode.reliable)
46 | [/gdscript]
47 | [/codeblocks]
48 |
49 | Send to a subset of players (still using the [code]game_match[/code] variable):
50 | [codeblocks]
51 | [gdscript]
52 | var payload := PackedByteArray([1, 2, 3])
53 | var recipients := [first_player, second_player]
54 | game_match.send(payload, recipients, GKMatch.SendDataMode.reliable)
55 | [/gdscript]
56 | [/codeblocks]
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 | Calls [code skip-lint]GKMatch.disconnect()[/code] to leave the match immediately.
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 | Sends a payload to the specified [code skip-lint]Array[GKPlayer][/code]. Returns a [enum @GlobalScope.Error] value ([code skip-lint]OK[/code] on success, [code skip-lint]FAILED[/code] when the payload could not be converted or Apple reported an error). Choose a [enum SendDataMode] constant for [param dataMode].
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 | Broadcasts the packed bytes to everyone in the match. Return value matches [method send]. Use the [enum SendDataMode] constants.
82 |
83 |
84 |
85 |
86 |
87 | The number of additional players that GameKit is recruiting before the match can begin.
88 |
89 |
90 | Array of [code skip-lint]GKPlayer[/code] instances that are currently connected.
91 |
92 |
93 | Optional [code skip-lint]Callable[/code] that receives a [code skip-lint]GKPlayer[/code] and returns [code]true[/code] if the player should be reinvited after disconnecting (see the sample in [code skip-lint]GameCenterGuide.md[/code]).
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 | Emitted when another player sends data to the local device. Provides the raw bytes and the sender.
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 | Emitted when the local player receives data that was addressed to a specific recipient. Arguments provide the payload, the intended recipient, and the sender.
110 |
111 |
112 |
113 |
114 |
115 | Fires when Apple reports a networking error. The [code skip-lint]String[/code] argument contains the localized description.
116 |
117 |
118 |
119 |
120 |
121 |
122 | Notifies you when a player connects or disconnects. The boolean is [code]true[/code] for connected players and [code]false[/code] otherwise.
123 |
124 |
125 |
126 |
127 |
128 | Uses GameKit's reliable data channel (ordered delivery with retries).
129 |
130 |
131 | Uses GameKit's unreliable channel, useful for latency-sensitive updates.
132 |
133 |
134 |
135 |
--------------------------------------------------------------------------------
/Sources/GodotApplePlugins/GameCenter/GKMatchMakerViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GKMatchMakerViewController.swift
3 | // GodotApplePlugins
4 | //
5 | // Created by Miguel de Icaza on 11/17/25.
6 | //
7 | @preconcurrency import SwiftGodotRuntime
8 | import SwiftUI
9 | #if canImport(UIKit)
10 | import UIKit
11 | #else
12 | import AppKit
13 | #endif
14 | import GameKit
15 |
16 | @Godot
17 | class GKMatchmakerViewController: RefCounted, @unchecked Sendable {
18 | class Proxy: NSObject, GameKit.GKMatchmakerViewControllerDelegate, GKLocalPlayerListener {
19 | func matchmakerViewControllerWasCancelled(_ viewController: GameKit.GKMatchmakerViewController) {
20 | guard let base else { return }
21 | MainActor.assumeIsolated {
22 | #if os(macOS)
23 | base.dialogController?.dismiss(viewController)
24 | #else
25 | viewController.dismiss(animated: true)
26 | #endif
27 | base.cancelled.emit("")
28 | }
29 | }
30 |
31 | func matchmakerViewController(_ viewController: GameKit.GKMatchmakerViewController, didFailWithError error: any Error) {
32 | GD.print("GKMVC: didFailWithError")
33 | base?.failed_with_error.emit(String(describing: error))
34 | }
35 |
36 | func matchmakerViewController(_ viewController: GameKit.GKMatchmakerViewController, didFind match: GameKit.GKMatch) {
37 | base?.did_find_match.emit(GKMatch(match: match))
38 | }
39 |
40 | func matchmakerViewController(
41 | _ viewController: GameKit.GKMatchmakerViewController,
42 | didFindHostedPlayers players: [GameKit.GKPlayer]
43 | ) {
44 | let result = VariantArray()
45 | for player in players {
46 | result.append(Variant(GKPlayer(player: player)))
47 | }
48 | base?.did_find_hosted_players.emit(result)
49 | }
50 |
51 | weak var base: GKMatchmakerViewController?
52 | init(_ base: GKMatchmakerViewController) {
53 | self.base = base
54 | }
55 | }
56 |
57 | @Signal("detail") var cancelled: SignalWithArguments
58 |
59 | /// Matchmaking has failed with an error
60 | @Signal("message") var failed_with_error: SignalWithArguments
61 |
62 | /// A peer-to-peer match has been found, the game should start
63 | @Signal("match") var did_find_match: SignalWithArguments
64 |
65 | /// Players have been found for a server-hosted game, the game should start, receives an array of GKPlayers
66 | @Signal("players") var did_find_hosted_players: SignalWithArguments
67 |
68 | /// The view controller if we create it
69 | var vc: GameKit.GKMatchmakerViewController?
70 | /// Delegate class if the user is rolling his own
71 | var proxy: Proxy?
72 |
73 | #if os(macOS)
74 | /// When the user triggers the presentation, on macOS, we keep track of it
75 | var dialogController: GKDialogController? = nil
76 | #endif
77 |
78 | /// Returns a view controller for the specified request, configure the various callbacks, and then
79 | /// call `present` on it.
80 | @Callable static func create_controller(request: GKMatchRequest) -> GKMatchmakerViewController? {
81 | MainActor.assumeIsolated {
82 | if let vc = GameKit.GKMatchmakerViewController(matchRequest: request.request) {
83 | let v = GKMatchmakerViewController()
84 | let proxy = Proxy(v)
85 |
86 | v.vc = vc
87 | v.proxy = proxy
88 |
89 | vc.matchmakerDelegate = proxy
90 | return v
91 | }
92 | return nil
93 | }
94 | }
95 |
96 | // This is used for the custom request that is vastly simpler than rolling your own
97 | class RequestMatchDelegate: NSObject, GameKit.GKMatchmakerViewControllerDelegate, @unchecked Sendable {
98 | #if os(macOS)
99 | var dialogController: GKDialogController?
100 | #endif
101 | private let callback: Callable
102 | let done: () -> ()
103 | init(_ callback: Callable, done: @escaping () -> () = { }) {
104 | self.callback = callback
105 | self.done = done
106 | }
107 |
108 | func matchmakerViewController(
109 | _ viewController: GameKit.GKMatchmakerViewController,
110 | didFind match: GameKit.GKMatch
111 | ) {
112 | _ = self.callback.call(Variant(GKMatch(match: match)), nil)
113 | }
114 |
115 | func matchmakerViewControllerWasCancelled(
116 | _ source: GameKit.GKMatchmakerViewController
117 | ) {
118 | MainActor.assumeIsolated {
119 | #if os(iOS)
120 | source.dismiss(animated: true)
121 | #else
122 | dialogController?.dismiss(source)
123 |
124 | #endif
125 | _ = self.callback.call(nil, Variant("cancelled"))
126 | done()
127 | }
128 | }
129 |
130 | func matchmakerViewController(
131 | _ source: GameKit.GKMatchmakerViewController,
132 | didFailWithError: (any Error)
133 | ) {
134 | _ = self.callback.call(nil, Variant(didFailWithError.localizedDescription))
135 | done()
136 | }
137 | }
138 |
139 | /// Convenience method that is a version that sets up `create_controller` and calls the callback
140 | /// with two arguments, the first is the match on success, and the second is an error on failure, which can be
141 | /// one of the following strings: "cancelled",
142 | @Callable static func request_match(request: GKMatchRequest, callback: Callable) {
143 | MainActor.assumeIsolated {
144 | if let vc = GameKit.GKMatchmakerViewController(matchRequest: request.request) {
145 | var hold: RequestMatchDelegate?
146 |
147 | hold = RequestMatchDelegate(callback, done: {
148 | hold = nil
149 | })
150 | vc.matchmakerDelegate = hold
151 | GKMatchmakerViewController.present(controller: vc) {
152 | #if os(macOS)
153 | hold?.dialogController = $0 as? GKDialogController
154 | #endif
155 | }
156 | }
157 | }
158 | }
159 |
160 | @Callable func present() {
161 | guard let vc else {
162 | return
163 | }
164 | GKMatchmakerViewController.present(controller: vc) { v in
165 | #if os(macOS)
166 | dialogController = v as? GKDialogController
167 | #endif
168 | }
169 | }
170 |
171 | static func present(controller: GameKit.GKMatchmakerViewController, track: @MainActor (AnyObject) -> ()) {
172 | MainActor.assumeIsolated {
173 | #if os(iOS)
174 | presentOnTop(controller)
175 | #else
176 | let dialogController = GKDialogController.shared()
177 | dialogController.parentWindow = NSApplication.shared.mainWindow
178 | dialogController.present(controller)
179 | track(dialogController)
180 | #endif
181 | }
182 | }
183 | }
184 |
--------------------------------------------------------------------------------
/doc_classes/GKLeaderboard.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Accesses Apple Game Center leaderboards and their entries.
5 |
6 |
7 | [code skip-lint]GKLeaderboard[/code] lets you fetch configured leaderboards, submit scores, and read player ranks as described in the Leaderboards section of [code skip-lint]GameCenterGuide.md[/code]. All operations mirror Apple's asynchronous APIs, so every method expects a [code skip-lint]Callable[/code] that receives the requested data plus an optional error [code skip-lint]Variant[/code]. See Apple's reference at [url=https://developer.apple.com/documentation/gamekit/gkleaderboard]Apple's GKLeaderboard documentation[/url].
8 |
9 | Submitting a score (guide sample):
10 | [codeblocks]
11 | [gdscript]
12 | var local_player := GameCenterManager.new().local_player
13 |
14 | GKLeaderboard.load_leaderboards(["MyLeaderboard"], func(leaderboards: Array[GKLeaderboard], error: Variant) -> void:
15 | if error:
16 | print("Load leaderboard error %s" % error)
17 | return
18 |
19 | var score := 100
20 | var context := 0
21 | leaderboards[0].submit_score(score, context, local_player, func(submit_error: Variant) -> void:
22 | if submit_error:
23 | print("Error submitting leaderboard %s" % submit_error)
24 | )
25 | )
26 | [/gdscript]
27 | [/codeblocks]
28 |
29 | Loading all versus specific leaderboards:
30 | [codeblocks]
31 | [gdscript]
32 | # Loads all leaderboards configured for the app.
33 | GKLeaderboard.load_leaderboards([], func(all_leaderboards: Array[GKLeaderboard], error: Variant) -> void:
34 | print("Received %d leaderboards" % all_leaderboards.size())
35 | )
36 |
37 | # Loads only the identifiers you pass in.
38 | GKLeaderboard.load_leaderboards(["My leaderboard"], func(named_leaderboards: Array[GKLeaderboard], error: Variant) -> void:
39 | print("Received %d specific leaderboards" % named_leaderboards.size())
40 | )
41 | [/gdscript]
42 | [/codeblocks]
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 | Loads the local player's score together with the specified [code skip-lint]Array[/code] of [code skip-lint]GKPlayer[/code] objects for the given time scope (use the [enum TimeScope] values). The callback receives [code](GKLeaderboardEntry local, Array[GKLeaderboardEntry] scores, Variant error)[/code] where the first entry can be [code]null[/code] if the local player has not posted a score.
54 |
55 |
56 |
57 |
58 |
59 |
60 | Downloads the leaderboard icon. The callback arguments are [code](Image image, Variant error)[/code] with exactly one being [code]null[/code], matching the [code skip-lint]load_image[/code] helper shown in the guide.
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 | Fetches leaderboard metadata. Pass an empty array to load every leaderboard configured for the app, or provide specific identifiers. The callback receives [code skip-lint]Array[GKLeaderboard][/code] and a [code skip-lint]Variant[/code] error string ([code]null[/code] on success).
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 | Loads leaderboard entries for the local player. The callback receives [code](GKLeaderboardEntry local, Array[GKLeaderboardEntry] scores, Variant range, Variant error)[/code] where the first entry can be [code]null[/code] if the local player has not posted a score. The value `range` is the number of total player count that matched the scope. Supply [enum PlayerScope] and [enum TimeScope] integers for the first two parameters.
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 | Submits a score for the provided player. The callback receives a [code skip-lint]Variant[/code] with the error string or [code]null[/code] when the submission succeeds. See [code skip-lint]GameCenterGuide.md[/code] for an end-to-end example.
90 |
91 |
92 |
93 |
94 |
95 | Beta-only identifier for activity leaderboards (empty on platforms that do not expose the value).
96 |
97 |
98 | Dictionary copy of Apple's [code]activityProperties[/code], containing keys such as [code]eventStartDate[/code] when available.
99 |
100 |
101 | Leaderboard duration in seconds for recurring leaderboards.
102 |
103 |
104 | Group identifier that you configured in App Store Connect, or an empty string if not grouped.
105 |
106 |
107 |
108 |
109 |
110 |
111 | Display name shown to players.
112 |
113 |
114 | The Apple leaderboard type returned as an integer value: [code]0[/code] ([code skip-lint]classic[/code]), [code]1[/code] ([code skip-lint]recurring[/code]), or [code]2[/code] ([code skip-lint]unknown[/code]).
115 |
116 |
117 |
118 |
119 | A leaderboard that never expires, showing all-time rankings of all players.
120 |
121 |
122 | A leaderboard that recurs, allowing players a fresh start to compete and earn higher ranks in each ocurrence.
123 |
124 |
125 |
126 |
127 | Scans the current day.
128 |
129 |
130 | Restricts results to the current week.
131 |
132 |
133 | Returns scores across the entire history of the leaderboard.
134 |
135 |
136 | Loads data for all players of the game.
137 |
138 |
139 | Loads only data for friends of the local player.
140 |
141 |
142 |
143 |
--------------------------------------------------------------------------------