├── .github ├── FUNDING.yml └── workflows │ ├── build-documentation.yml │ ├── build-multiplatform.yml │ ├── swiftlint.yml │ └── tests.yml ├── .gitignore ├── .spi.yml ├── .spm.swift ├── .swiftlint.yml ├── LICENCE.md ├── Package.swift ├── README.md ├── Sources └── GameControllerKit │ ├── GCKAction.swift │ ├── GCKController.swift │ ├── GCKControllerType.swift │ ├── GCKControllerView.swift │ ├── GCKMovePosition.swift │ └── GameControllerKit.swift └── Tests └── GameControllerKitTests └── GameControllerKitTests.swift /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: 0xWDG 2 | -------------------------------------------------------------------------------- /.github/workflows/build-documentation.yml: -------------------------------------------------------------------------------- 1 | # .github/workflows/build-documentation.yml 2 | 3 | name: build-documentation 4 | 5 | on: 6 | # Run on push to main branch 7 | push: 8 | branches: 9 | - main 10 | 11 | # Dispatch if triggered using Github (website) 12 | workflow_dispatch: 13 | 14 | jobs: 15 | Build-documentation: 16 | runs-on: macos-latest 17 | steps: 18 | - name: Build documentation 19 | uses: 0xWDG/build-documentation@main 20 | -------------------------------------------------------------------------------- /.github/workflows/build-multiplatform.yml: -------------------------------------------------------------------------------- 1 | # .github/workflows/build-multiplatform.yml 2 | 3 | name: Build-Packages 4 | 5 | on: 6 | # Run on pull_request 7 | pull_request: 8 | 9 | # Dispatch if triggered using Github (website) 10 | workflow_dispatch: 11 | 12 | jobs: 13 | Build-Packages: 14 | runs-on: macos-latest 15 | steps: 16 | - name: Build Swift Packages 17 | uses: 0xWDG/build-swift@main -------------------------------------------------------------------------------- /.github/workflows/swiftlint.yml: -------------------------------------------------------------------------------- 1 | name: Run Swiftlint 2 | on: 3 | push: 4 | pull_request: 5 | workflow_dispatch: 6 | 7 | jobs: 8 | swiftlint: 9 | runs-on: macos-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | 13 | - name: SwiftLint 14 | run: | 15 | brew install swiftlint 16 | swiftlint --reporter github-actions-logging --strict -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Run tests on Linux and macOS 2 | on: 3 | push: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | test_linux: 8 | if: false 9 | runs-on: ubuntu-latest 10 | continue-on-error: true 11 | steps: 12 | - uses: actions/checkout@v3 13 | 14 | - name: Swift test 15 | run: swift test 16 | 17 | test_macos: 18 | runs-on: macos-latest 19 | steps: 20 | - uses: actions/checkout@v3 21 | 22 | - name: Swift test 23 | run: swift test 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## User settings 2 | xcuserdata/ 3 | 4 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 5 | *.xcscmblueprint 6 | *.xccheckout 7 | 8 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 9 | build/ 10 | DerivedData/ 11 | *.moved-aside 12 | *.pbxuser 13 | !default.pbxuser 14 | *.mode1v3 15 | !default.mode1v3 16 | *.mode2v3 17 | !default.mode2v3 18 | *.perspectivev3 19 | !default.perspectivev3 20 | 21 | ## Obj-C/Swift specific 22 | *.hmap 23 | 24 | ## App packaging 25 | *.ipa 26 | *.dSYM.zip 27 | *.dSYM 28 | 29 | ## Playgrounds 30 | timeline.xctimeline 31 | playground.xcworkspace 32 | 33 | ### Swift Package Manager 34 | Packages/ 35 | Package.pins 36 | Package.resolved 37 | # *.xcodeproj 38 | # 39 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 40 | # hence it is not needed unless you have added a package configuration file to your project 41 | .swiftpm 42 | .build/ 43 | 44 | ### CocoaPods 45 | Pods/ 46 | *.xcworkspace 47 | 48 | ### Carthage 49 | Carthage/Checkouts 50 | Carthage/Build/ 51 | 52 | ### Accio dependency management 53 | Dependencies/ 54 | .accio/ 55 | 56 | ### fastlane 57 | fastlane/report.xml 58 | fastlane/Preview.html 59 | fastlane/screenshots/**/*.png 60 | fastlane/test_output 61 | 62 | ### Code Injection 63 | iOSInjectionProject/ 64 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [GameControllerKit] -------------------------------------------------------------------------------- /.spm.swift: -------------------------------------------------------------------------------- 1 | #!/usr/bin/swift 2 | 3 | // 4 | // spm.swift 5 | // This script will add a header to all .swift files in the current directory. 6 | // And can test the package for various platforms. 7 | // 8 | // Created by Wesley de Groot on 2024-08-06. 9 | // https://wesleydegroot.nl 10 | // 11 | // https://github.com/0xWDG/spm-template 12 | // MIT License 13 | 14 | // To compile this script to a binary, run: 15 | // swiftc .spm.swift -o spm 16 | 17 | // swiftlint:disable all 18 | import Foundation 19 | 20 | let fileManager = FileManager.default 21 | var internalProductName: String? 22 | var productName: String { 23 | get { 24 | if let productName = internalProductName { 25 | return productName 26 | } 27 | 28 | if fileManager.fileExists(atPath: "Package.swift") { 29 | guard let package = try? String(contentsOf: URL(fileURLWithPath: "Package.swift"), encoding: .utf8), 30 | let productName = package 31 | .components(separatedBy: .newlines) 32 | .first(where: { $0.contains("name:") })? 33 | .components(separatedBy: .whitespaces) 34 | .last? 35 | .replacingOccurrences(of: "\"", with: "") 36 | .replacingOccurrences(of: ",", with: "") else { 37 | printC("Could not find product name in Package.swift", color: CLIColors.red) 38 | exit(2) 39 | } 40 | 41 | return productName 42 | } else { 43 | printC("Package.swift not found, please provide package name", color: CLIColors.red) 44 | return "" 45 | } 46 | } 47 | set { 48 | internalProductName = newValue 49 | } 50 | } 51 | 52 | struct CLIColors { 53 | static let red = "\u{001B}[0;31m" 54 | static let green = "\u{001B}[0;32m" 55 | static let yellow = "\u{001B}[0;33m" 56 | static let orange = "\u{001B}[0;38;5;208m" 57 | static let blue = "\u{001B}[0;34m" 58 | static let magenta = "\u{001B}[0;35m" 59 | static let cyan = "\u{001B}[0;36m" 60 | static let white = "\u{001B}[0;37m" 61 | static let reset = "\u{001B}[0;0m" 62 | static let clear = "\u{001B}[0;0m" 63 | } 64 | 65 | func printUsage() { 66 | print("Usage: \(CommandLine.arguments[0]) ") 67 | print("Commands:") 68 | print(" create - Add a header to all .swift files in the current directory") 69 | print(" header - Update the header for all .swift files in the current directory") 70 | print(" readme - Generate a README.md file for the package") 71 | print(" build - Build the package for all platforms") 72 | print(" test - Test the package for all platforms") 73 | } 74 | 75 | func printC(_ text: String, terminator: String = "\n", color: String = CLIColors.reset) { 76 | if terminator == "\n" { 77 | print("\(color)\(text) \(CLIColors.reset)") 78 | } else { 79 | print("\(color)\(text)\(CLIColors.reset)", terminator: terminator) 80 | fflush(stdout) 81 | } 82 | } 83 | 84 | if CommandLine.argc < 2 { 85 | printUsage() 86 | exit(1) 87 | } 88 | 89 | if CommandLine.arguments[1] == "create" && CommandLine.argc < 3 { 90 | print("Usage: \(CommandLine.arguments[0]) create ") 91 | exit(1) 92 | } 93 | 94 | if CommandLine.arguments[1] == "create" && CommandLine.argc == 3 { 95 | productName = CommandLine.arguments[2] 96 | 97 | if !fileManager.fileExists(atPath: "Package.swift") { 98 | let process = Process() 99 | process.launchPath = "/usr/bin/env" 100 | process.arguments = ["swift", "package", "init", "--name", productName] 101 | process.launch() 102 | process.waitUntilExit() 103 | } 104 | 105 | /// Change the first line of the Package.swift file 106 | let package = try String(contentsOf: URL(fileURLWithPath: "Package.swift"), encoding: .utf8) 107 | let newPackage = package 108 | .components(separatedBy: .newlines) 109 | .enumerated() 110 | .map { index, line in 111 | if index == 0 { 112 | return "// swift-tools-version: 5.8.0" 113 | } else { 114 | return line 115 | } 116 | } 117 | .joined(separator: "\n") 118 | try? newPackage.write(to: URL(fileURLWithPath: "Package.swift"), atomically: true, encoding: .utf8) 119 | printC("Downgraded swift-tools-version to 5.8.0", color: CLIColors.green) 120 | 121 | let spi = """ 122 | version: 1 123 | builder: 124 | configs: 125 | - documentation_targets: [\(productName)] 126 | """ 127 | try spi.write(to: URL(fileURLWithPath: ".spi.yml"), atomically: true, encoding: .utf8) 128 | 129 | header() 130 | generateReadme() 131 | } 132 | 133 | if CommandLine.arguments[1] == "header" { 134 | header() 135 | } 136 | 137 | if CommandLine.arguments[1] == "readme" { 138 | generateReadme() 139 | } 140 | 141 | if CommandLine.arguments[1] == "build" { 142 | if !fileManager.fileExists(atPath: "Package.swift") { 143 | printC("Package.swift not found", color: CLIColors.red) 144 | exit(2) 145 | } 146 | 147 | // Find platforms in Package.swift 148 | let package = try String(contentsOf: URL(fileURLWithPath: "Package.swift"), encoding: .utf8) 149 | var platforms: [String] = [] 150 | var fails = 0 151 | 152 | if package.contains(".iOS") { 153 | platforms.append("iOS") 154 | } 155 | 156 | if package.contains(".macOS") { 157 | platforms.append("macOS") 158 | } 159 | 160 | if package.contains(".watchOS") { 161 | platforms.append("watchOS") 162 | } 163 | 164 | if package.contains(".visionOS") { 165 | platforms.append("xrOS") 166 | } 167 | 168 | if package.contains(".tvOS") { 169 | platforms.append("tvOS") 170 | } 171 | 172 | if package.contains(".maccatalyst") { 173 | platforms.append("MacCatalyst") 174 | } 175 | 176 | if package.contains(".driverkit") { 177 | printC("DriverKit is not supported, skipped", color: CLIColors.orange) 178 | // platforms.append("DriverKit") 179 | } 180 | 181 | if package.contains(".linux") { 182 | printC("Linux is not supported, skipped", color: CLIColors.orange) 183 | // platforms.append("Linux") 184 | } 185 | 186 | if package.contains(".android") { 187 | printC("Android is not supported, skipped", color: CLIColors.orange) 188 | // platforms.append("Android") 189 | } 190 | 191 | if platforms.isEmpty { 192 | printC("No platforms found in Package.swift, defaulting to all", color: CLIColors.orange) 193 | platforms = ["iOS", "tvOS", "xrOS", "watchOS", "macOS"] 194 | } 195 | 196 | printC("Build \(productName) for \(platforms.joined(separator: ", "))...") 197 | 198 | for (number, platform) in platforms.enumerated() { 199 | printC("Building \(productName) on \(platform). (\(number + 1)/\(platforms.count))", terminator: "\r") 200 | let process = Process() 201 | process.launchPath = "/usr/bin/env" 202 | process.arguments = [ 203 | "xcrun", 204 | "xcodebuild", 205 | "clean", 206 | "build", 207 | "-quiet", 208 | "-scheme", productName, 209 | "-destination", "generic/platform=\(platform)" 210 | ] 211 | process.launch() 212 | process.waitUntilExit() 213 | // Check if the process was successful 214 | if process.terminationStatus != 0 { 215 | fails += 1 216 | printC("Failed to build for \(platform) (\(number + 1)/\(platforms.count))", color: CLIColors.red) 217 | } else { 218 | printC("Build for \(platform) successful (\(number + 1)/\(platforms.count)) ", color: CLIColors.green) 219 | } 220 | } 221 | 222 | if fails > 0 { 223 | printC("Failed to build for \(fails) platforms", color: CLIColors.red) 224 | } else { 225 | printC("Build for all platforms successful", color: CLIColors.green) 226 | } 227 | 228 | exit(0) 229 | } 230 | 231 | if CommandLine.arguments[1] == "test" { 232 | printC("Testing is not yet implemented", color: CLIColors.red) 233 | exit(99) 234 | } 235 | 236 | func header() { 237 | // Search for all .swift files 238 | let enumerator = fileManager.enumerator(atPath: ".") 239 | while let element = enumerator?.nextObject() as? String { 240 | if element.hasSuffix(".swift") { 241 | var headerLines = 0 242 | 243 | let dateFormatter = DateFormatter() 244 | dateFormatter.dateFormat = "yyyy-MM-dd" 245 | let date = dateFormatter.string(from: Date()) 246 | 247 | var createdBy = "// Created by Wesley de Groot on \(date)." 248 | let file = element 249 | let path = URL(fileURLWithPath: file) 250 | guard let contents = try? String(contentsOf: path, encoding: .utf8) else { 251 | printC("Failed to read \(file)", color: CLIColors.red) 252 | continue 253 | } 254 | let filename = file.components(separatedBy: "/").last 255 | var lines = contents.components(separatedBy: .newlines) 256 | 257 | if lines.isEmpty { 258 | break 259 | } 260 | 261 | if lines[0].hasPrefix("#!") || file == "Package.swift" { 262 | continue 263 | } 264 | 265 | for line in lines { 266 | if line.hasPrefix("//") { 267 | if line.contains("Created by") { 268 | createdBy = line 269 | } 270 | 271 | headerLines += 1 272 | } else { 273 | break 274 | } 275 | } 276 | 277 | lines.removeFirst(Int(headerLines)) 278 | 279 | let header = [ 280 | "//", 281 | "// \(filename ?? "")", 282 | "// \(productName)", 283 | "//", 284 | createdBy, 285 | "// https://wesleydegroot.nl", 286 | "//", 287 | "// https://github.com/0xWDG/\(productName)", 288 | "// MIT License", 289 | "//" 290 | ] 291 | 292 | lines.insert(contentsOf: header, at: 0) 293 | let newContents = lines.joined(separator: "\n") 294 | do { 295 | try newContents.write(to: path, atomically: true, encoding: .utf8) 296 | printC("Updated header for \(file)", color: CLIColors.green) 297 | } catch { 298 | printC("Failed to update header for \(file)", color: CLIColors.red) 299 | } 300 | } 301 | } 302 | } 303 | 304 | func generateReadme() { 305 | var readme = """ 306 | # PACKAGENAME 307 | 308 | PACKAGENAME is a Swift Package for ... 309 | 310 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2F0xWDG%2FPACKAGENAME%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/0xWDG/PACKAGENAME) 311 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2F0xWDG%2FPACKAGENAME%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/0xWDG/PACKAGENAME) 312 | [![Swift Package Manager](https://img.shields.io/badge/SPM-compatible-brightgreen.svg)](https://swift.org/package-manager) 313 | ![License](https://img.shields.io/github/license/0xWDG/PACKAGENAME) 314 | 315 | ## Requirements 316 | 317 | - Swift 5.9+ (Xcode 15+) 318 | - iOS 13+, macOS 10.15+ 319 | 320 | ## Installation (Pakage.swift) 321 | 322 | ```swift 323 | dependencies: [ 324 | .package(url: "https://github.com/0xWDG/PACKAGENAME.git", branch: "main"), 325 | ], 326 | targets: [ 327 | .target(name: "MyTarget", dependencies: [ 328 | .product(name: "PACKAGENAME", package: "PACKAGENAME"), 329 | ]), 330 | ] 331 | ``` 332 | 333 | ## Installation (Xcode) 334 | 335 | 1. In Xcode, open your project and navigate to **File** → **Swift Packages** → **Add Package Dependency...** 336 | 2. Paste the repository URL (`https://github.com/0xWDG/PACKAGENAME`) and click **Next**. 337 | 3. Click **Finish**. 338 | 339 | ## Usage 340 | 341 | ```swift 342 | import SwiftUI 343 | import PACKAGENAME 344 | 345 | struct ContentView: View { 346 | var body: some View { 347 | VStack { 348 | /// ... 349 | } 350 | .padding() 351 | } 352 | } 353 | ``` 354 | 355 | ## Contact 356 | 357 | We can get in touch via [Mastodon](https://mastodon.social/@0xWDG), [Twitter/X](https://twitter.com/0xWDG), [Discord](https://discordapp.com/users/918438083861573692), [Email](mailto:email@wesleydegroot.nl), [Website](https://wesleydegroot.nl). 358 | 359 | Interested learning more about Swift? [Check out my blog](https://wesleydegroot.nl/blog/). 360 | """ 361 | 362 | readme = readme.replacingOccurrences(of: "PACKAGENAME", with: productName) 363 | 364 | try? readme.write(to: URL(fileURLWithPath: "README.md"), atomically: true, encoding: .utf8) 365 | } 366 | 367 | if CommandLine.arguments[1] != "executable" { 368 | let process = Process() 369 | process.launchPath = "/usr/bin/env" 370 | process.arguments = ["swiftc", CommandLine.arguments[0], "-o", "spm"] 371 | process.launch() 372 | process.waitUntilExit() 373 | 374 | if process.terminationStatus != 0 { 375 | printC("Failed to compile script", color: CLIColors.red) 376 | exit(4) 377 | } else { 378 | printC("Script compiled successfully", color: CLIColors.green) 379 | } 380 | } 381 | // swiftlint:enable all 382 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | excluded: 2 | - "*resource_bundle_accessor*" # SwiftPM Generated 3 | - ".build/*" 4 | 5 | opt_in_rules: 6 | - missing_docs 7 | - empty_count 8 | - empty_string 9 | - toggle_bool 10 | - unused_optional_binding 11 | - valid_ibinspectable 12 | - modifier_order 13 | - first_where 14 | - fatal_error_message 15 | - force_unwrapping 16 | 17 | -------------------------------------------------------------------------------- /LICENCE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Wesley de Groot, email@WesleydeGroot.nl 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.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.8.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "GameControllerKit", 8 | platforms: [ 9 | .macOS(.v12), 10 | .iOS(.v16), 11 | .tvOS(.v16) 12 | ], 13 | products: [ 14 | // Products define the executables and libraries a package produces, making them visible to other packages. 15 | .library( 16 | name: "GameControllerKit", 17 | targets: ["GameControllerKit"]) 18 | ], 19 | targets: [ 20 | // Targets are the basic building blocks of a package, defining a module or a test suite. 21 | // Targets can depend on other targets in this package and products from dependencies. 22 | .target( 23 | name: "GameControllerKit"), 24 | .testTarget( 25 | name: "GameControllerKitTests", 26 | dependencies: ["GameControllerKit"]) 27 | ] 28 | ) 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GameControllerKit 2 | 3 | GameControllerKit is a Swift package that makes it easy to work with game controllers on iOS, macOS, and tvOS. It provides a simple API to connect to game controllers, read input from them, and control their lights and haptics. 4 | 5 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2F0xWDG%2FGameControllerKit%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/0xWDG/GameControllerKit) 6 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2F0xWDG%2FGameControllerKit%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/0xWDG/GameControllerKit) 7 | [![Swift Package Manager](https://img.shields.io/badge/SPM-compatible-brightgreen.svg)](https://swift.org/package-manager) 8 | ![License](https://img.shields.io/github/license/0xWDG/GameControllerKit) 9 | 10 | ## Requirements 11 | 12 | - Swift 5.9+ (Xcode 15+) 13 | - iOS 13+, macOS 10.15+, tvOS 16+ 14 | 15 | ## Installation (Pakage.swift) 16 | 17 | ```swift 18 | dependencies: [ 19 | .package(url: "https://github.com/0xWDG/GameControllerKit.git", branch: "main"), 20 | ], 21 | targets: [ 22 | .target(name: "MyTarget", dependencies: [ 23 | .product(name: "GameControllerKit", package: "GameControllerKit"), 24 | ]), 25 | ] 26 | ``` 27 | 28 | ## Installation (Xcode) 29 | 30 | 1. In Xcode, open your project and navigate to **File** → **Swift Packages** → **Add Package Dependency...** 31 | 2. Paste the repository URL (`https://github.com/0xWDG/GameControllerKit`) and click **Next**. 32 | 3. Click **Finish**. 33 | 34 | ## Usage 35 | 36 | ```swift 37 | import SwiftUI 38 | import GameControllerKit 39 | 40 | struct ContentView: View { 41 | /// The game controller kit 42 | @State 43 | var gameController = GameControllerKit() 44 | 45 | /// Log 46 | @State 47 | var log: [String] = [] 48 | 49 | var body: some View { 50 | VStack { 51 | Button { 52 | gameController.set(color: .GCKRandom) 53 | } label: { 54 | Text("Random Color") 55 | } 56 | 57 | Text("Controller: \(gameController.controller?.productCategory ?? "None"), " + 58 | "\((gameController.controllerType ?? .generic).description)") 59 | Text("Last action:\n\(String(describing: gameController.lastAction)).") 60 | 61 | GCKControllerView() 62 | .environmentObject(gameController) 63 | 64 | List { 65 | ForEach(log.reversed(), id: \.self) { text in 66 | Text(text) 67 | } 68 | } 69 | } 70 | .padding() 71 | .onAppear { 72 | gameController.set(handler: handler) 73 | UIApplication.shared.isIdleTimerDisabled = true 74 | } 75 | } 76 | 77 | /// Handler 78 | /// 79 | /// - Parameters: 80 | /// - action: action 81 | /// - pressed: is the button pressed? 82 | /// - controller: which controller? 83 | public func handler( 84 | action: GCKAction, 85 | pressed: Bool, 86 | controller: GCKController 87 | ) { 88 | log.append( 89 | "\(String(describing: action))(\(action.position.arrowRepresentation)) \(pressed ? "Pressed" : "Unpressed"), " + 90 | "Controller #id \(String(describing: controller.playerIndex.rawValue))" 91 | ) 92 | 93 | if action == .buttonA && pressed { 94 | // set to a random color 95 | gameController.set(color: .GCKRandom) 96 | } 97 | } 98 | } 99 | ``` 100 | 101 | ## Image of Usage Demo App 102 | 103 | ### iOS 104 | ![C65552DF-04CC-493E-AD73-C385A7CEC53C](https://github.com/user-attachments/assets/7bae192c-41ae-42d5-ad52-e204de73b3a0) 105 | 106 | ### MacOS 107 | AA801C52-88A1-4326-A5DC-3A04DF491077 108 | 109 | ### tvOS 110 | ![Screenshot 2024-08-29 at 14 43 51](https://github.com/user-attachments/assets/77def389-784e-44b5-9df8-80b675fdb8bf) 111 | 112 | ## Contact 113 | 114 | 🦋 [@0xWDG](https://bsky.app/profile/0xWDG.bsky.social) 115 | 🐘 [mastodon.social/@0xWDG](https://mastodon.social/@0xWDG) 116 | 🐦 [@0xWDG](https://x.com/0xWDG) 117 | 🧵 [@0xWDG](https://www.threads.net/@0xWDG) 118 | 🌐 [wesleydegroot.nl](https://wesleydegroot.nl) 119 | 🤖 [Discord](https://discordapp.com/users/918438083861573692) 120 | 121 | Interested learning more about Swift? [Check out my blog](https://wesleydegroot.nl/blog/). 122 | 123 | -------------------------------------------------------------------------------- /Sources/GameControllerKit/GCKAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GCKAction.swift 3 | // GameControllerKit 4 | // 5 | // Created by Wesley de Groot on 2024-08-19. 6 | // https://wesleydegroot.nl 7 | // 8 | // https://github.com/0xWDG/GameControllerKit 9 | // MIT License 10 | // 11 | 12 | import Foundation 13 | 14 | /// Represents the names of buttons available on a standard game controller. 15 | public enum GCKAction: Comparable { 16 | /// The A button, often used for main actions. 17 | /// ("Cross" on PlayStation Controllers) 18 | case buttonA 19 | 20 | /// The B button, often used for secondary actions or canceling. 21 | /// ("Circle" on PlayStation Controllers) 22 | case buttonB 23 | 24 | /// The X button, typically used for alternative actions. 25 | /// ("Square" on PlayStation Controllers) 26 | case buttonX 27 | 28 | /// The Y button, also used for alternative actions or menu navigation. 29 | /// ("Triangle" on PlayStation Controllers) 30 | case buttonY 31 | 32 | /// The left shoulder button, usually triggers shoulder actions or item switching. 33 | /// ("L1" on PlayStation Controllers) 34 | case leftShoulder 35 | 36 | /// The right shoulder button, similar to the left shoulder in use. ("R1" on PlayStation Controllers) 37 | case rightShoulder 38 | 39 | /// The left trigger, often used for actions like aiming or braking. ("L2" on PlayStation Controllers) 40 | case leftTrigger 41 | 42 | /// The right trigger, commonly used for actions such as shooting or accelerating. ("R2" on PlayStation Controllers) 43 | case rightTrigger 44 | 45 | /// The menu button, typically brings up in-game or app menus. ("Options" on PlayStation Controllers) 46 | case buttonMenu 47 | 48 | /// The options button, can be used for additional in-game options. ("Share" on PlayStation Controllers) 49 | case buttonOptions 50 | 51 | /// The home button, often used to exit to the main menu or dashboard. ("PlayStation" on PlayStation Controllers) 52 | case buttonHome 53 | 54 | /// The directional pad up button. 55 | case dpadUp 56 | 57 | /// The directional pad down button. 58 | case dpadDown 59 | 60 | /// The directional pad left button. 61 | case dpadLeft 62 | 63 | /// The directional pad right button. 64 | case dpadRight 65 | 66 | /// The left thumbstick click button, used for additional actions. 67 | case leftThumbstickButton 68 | 69 | /// The right thumbstick click button, similar to the left thumbstick button. 70 | case rightThumbstickButton 71 | 72 | /// The right thumbstick 73 | /// 74 | /// - Parameter x: X-axis 75 | /// - Parameter y: Y-axis 76 | case leftThumbstick(x: Float, y: Float) 77 | // swiftlint:disable:previous identifier_name 78 | 79 | /// The right thumbstick 80 | /// 81 | /// - Parameter x: X-axis 82 | /// - Parameter y: Y-axis 83 | case rightThumbstick(x: Float, y: Float) 84 | // swiftlint:disable:previous identifier_name 85 | 86 | /// PlayStation: The touchpad button, which can also act as a clickable button. 87 | case touchpadButton 88 | 89 | /// PlayStation: The top part of the touchpad, 90 | /// acting as the up directional input that is touched or pressed by the primary finger. 91 | case touchpadPrimaryUp 92 | 93 | /// PlayStation: The bottom part of the touchpad, 94 | /// acting as the up directional input that is touched or pressed by the primary finger. 95 | case touchpadPrimaryDown 96 | 97 | /// PlayStation: The left part of the touchpad, 98 | /// acting as the up directional input that is touched or pressed by the primary finger. 99 | case touchpadPrimaryLeft 100 | 101 | /// PlayStation: The right part of the touchpad, 102 | /// acting as the up directional input that is touched or pressed by the primary finger. 103 | case touchpadPrimaryRight 104 | 105 | /// PlayStation: The top part of the touchpad, 106 | /// acting as the up directional input that is touched or pressed by the secondary finger. 107 | case touchpadSecondaryUp 108 | 109 | /// PlayStation: The bottom part of the touchpad, 110 | /// acting as the up directional input that is touched or pressed by the secondary finger. 111 | case touchpadSecondaryDown 112 | 113 | /// PlayStation: The left part of the touchpad, 114 | /// acting as the up directional input that is touched or pressed by the secondary finger. 115 | case touchpadSecondaryLeft 116 | 117 | /// PlayStation: The right part of the touchpad, 118 | /// acting as the up directional input that is touched or pressed by the secondary finger. 119 | case touchpadSecondaryRight 120 | 121 | /// Xbox: The paddle 1 button element, which has a P1 label on the back of the controller. 122 | case paddleButton1 123 | 124 | /// Xbox: The paddle 2 button element, which has a P2 label on the back of the controller. 125 | case paddleButton2 126 | 127 | /// Xbox: The paddle 3 button element, which has a P2 label on the back of the controller. 128 | case paddleButton3 129 | 130 | /// Xbox: The paddle 4 button element, which has a P2 label on the back of the controller. 131 | case paddleButton4 132 | 133 | /// Xbox: The share button on an Xbox Series X|S controller or later. 134 | case shareButton 135 | 136 | /// None: initial value 137 | case none 138 | 139 | /// Get the position of the thumbstick. 140 | public var position: GCKMovePosition { 141 | var position: GCKMovePosition = .unknown 142 | 143 | switch self { 144 | case .leftThumbstick(let xPos, let yPos), .rightThumbstick(let xPos, let yPos): 145 | if yPos == 1.0 { 146 | position = .up 147 | } else if xPos > 0 && xPos < 1 && yPos > 0 && yPos < 1 { 148 | position = .upRight 149 | } else if xPos == 1.0 { 150 | position = .right 151 | } else if xPos > 0 && xPos < 1 && yPos < 0 && yPos > -1 { 152 | position = .downRight 153 | } else if yPos == -1.0 { 154 | position = .down 155 | } else if xPos < 0 && xPos > -1 && yPos < 0 && yPos > -1 { 156 | position = .downLeft 157 | } else if xPos == -1.0 { 158 | position = .left 159 | } else if xPos < 0 && xPos > -1 && yPos > 0 && yPos < 1 { 160 | position = .upLeft 161 | } else if xPos == 0 && yPos == 0 { 162 | position = .centered 163 | } else { 164 | position = .unknown 165 | } 166 | 167 | default: 168 | position = GCKMovePosition.unknown 169 | } 170 | 171 | return position 172 | } 173 | 174 | /// Is the current action a thumbstick action 175 | public var thumbStickAction: Bool { 176 | return switch self { 177 | case 178 | .leftThumbstick, 179 | .rightThumbstick: 180 | true 181 | 182 | default: 183 | false 184 | } 185 | } 186 | 187 | /// Is the current action a touchpad action (Playstation Only) 188 | public var touchPadAction: Bool { 189 | return switch self { 190 | case 191 | .touchpadButton, 192 | .touchpadPrimaryUp, 193 | .touchpadPrimaryRight, 194 | .touchpadPrimaryDown, 195 | .touchpadPrimaryLeft, 196 | .touchpadSecondaryUp, 197 | .touchpadSecondaryRight, 198 | .touchpadSecondaryDown, 199 | .touchpadSecondaryLeft: 200 | true 201 | 202 | default: 203 | false 204 | } 205 | } 206 | 207 | /// Is the current action a paddle action (Xbox Only) 208 | public var paddleAction: Bool { 209 | return switch self { 210 | case 211 | .paddleButton1, 212 | .paddleButton2, 213 | .paddleButton3, 214 | .paddleButton4: 215 | true 216 | 217 | default: 218 | false 219 | } 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /Sources/GameControllerKit/GCKController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GCKController.swift 3 | // GameControllerKit 4 | // 5 | // Created by Wesley de Groot on 2024-08-19. 6 | // https://wesleydegroot.nl 7 | // 8 | // https://github.com/0xWDG/GameControllerKit 9 | // MIT License 10 | // 11 | 12 | import Foundation 13 | import GameController 14 | 15 | /// Represents the names of buttons available on a standard game controller. 16 | /// 17 | /// This is a typealias to ``GCController``. 18 | public typealias GCKController = GCController 19 | 20 | extension GCKController { 21 | /// Does the current controller has a touchpad? 22 | public var hasTouchPad: Bool { 23 | if self.physicalInputProfile is GCDualSenseGamepad { 24 | return true 25 | } 26 | 27 | if self.physicalInputProfile is GCDualShockGamepad { 28 | return true 29 | } 30 | 31 | return false 32 | } 33 | 34 | /// Does the current controller have paddle buttons? 35 | public var hasPaddleButtons: Bool { 36 | return self.physicalInputProfile is GCXboxGamepad 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/GameControllerKit/GCKControllerType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GCKControllerType.swift 3 | // GameControllerKit 4 | // 5 | // Created by Wesley de Groot on 2024-08-19. 6 | // https://wesleydegroot.nl 7 | // 8 | // https://github.com/0xWDG/GameControllerKit 9 | // MIT License 10 | // 11 | 12 | import Foundation 13 | 14 | /// Represents the type of game controller connected. 15 | public enum GCKControllerType { 16 | /// A DualSense controller (PlayStation 5). 17 | case dualSense 18 | 19 | /// A DualShock controller (PlayStation 4). 20 | case dualShock 21 | 22 | /// An Xbox controller. 23 | case xbox 24 | 25 | /// An Siri Remote controller 26 | case siriRemote 27 | 28 | /// A generic controller type, for other controllers. 29 | case generic 30 | 31 | /// Description of the game controller 32 | public var description: String { 33 | switch self { 34 | case .dualSense: 35 | "DualSense" 36 | case .dualShock: 37 | "DualShock" 38 | case .xbox: 39 | "Xbox" 40 | case .siriRemote: 41 | "Siri Remote" 42 | case .generic: 43 | "Genric" 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/GameControllerKit/GCKControllerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ControllerView.swift 3 | // GCK 4 | // 5 | // Created by Wesley de Groot on 20/08/2024. 6 | // 7 | 8 | import SwiftUI 9 | import GameController 10 | 11 | /// Controller view. 12 | public struct GCKControllerView: View { 13 | // swiftlint:disable:previous type_body_length 14 | 15 | @EnvironmentObject 16 | var GCKit: GameControllerKit 17 | 18 | var action: GCKAction { 19 | GCKit.lastAction 20 | } 21 | 22 | /// Initialize a controller view. 23 | public init() { } 24 | 25 | public var body: some View { 26 | VStack { 27 | // L2, R2 28 | shoulder2 29 | shoulder1 30 | 31 | selectMenu 32 | 33 | HStack { 34 | dPad 35 | if let controller = GCKit.controller, 36 | controller.hasTouchPad { 37 | touchPad 38 | } 39 | buttons 40 | } 41 | 42 | thumbSticks 43 | } 44 | .padding(50) 45 | } 46 | 47 | var shoulder2: some View { 48 | HStack { 49 | Text("L2") 50 | .padding(3) 51 | .frame(width: 75, height: 25) 52 | .background(Rectangle().stroke()) 53 | .background( 54 | Rectangle() 55 | .fill() 56 | .foregroundStyle( 57 | action == .leftTrigger ? .yellow : .clear 58 | ) 59 | ) 60 | 61 | Spacer() 62 | Text("R2") 63 | .padding(3) 64 | .frame(width: 75, height: 25) 65 | .background(Rectangle().stroke()) 66 | .background( 67 | Rectangle() 68 | .fill() 69 | .foregroundStyle( 70 | action == .rightTrigger ? .yellow : .clear 71 | ) 72 | ) 73 | } 74 | } 75 | 76 | var shoulder1: some View { 77 | HStack { 78 | Text("L1") 79 | .padding(3) 80 | .frame(width: 75, height: 25) 81 | .background(Rectangle().stroke()) 82 | .background( 83 | Rectangle() 84 | .fill() 85 | .foregroundStyle( 86 | action == .leftShoulder ? .yellow : .clear 87 | ) 88 | ) 89 | 90 | Spacer() 91 | Text("R1") 92 | .padding(3) 93 | .frame(width: 75, height: 25) 94 | .background(Rectangle().stroke()) 95 | .background( 96 | Rectangle() 97 | .fill() 98 | .foregroundStyle( 99 | action == .rightShoulder ? .yellow : .clear 100 | ) 101 | ) 102 | } 103 | } 104 | 105 | var selectMenu: some View { 106 | HStack { 107 | Spacer() 108 | 109 | Text("O") 110 | .padding(3) 111 | .frame(width: 25, height: 25) 112 | .background(Rectangle().stroke()) 113 | .background( 114 | Rectangle() 115 | .fill() 116 | .foregroundStyle( 117 | action == .buttonOptions ? .yellow : .clear 118 | ) 119 | ) 120 | 121 | Spacer() 122 | 123 | Text("M") 124 | .padding(3) 125 | .frame(width: 25, height: 25) 126 | .background(Rectangle().stroke()) 127 | .background( 128 | Rectangle() 129 | .fill() 130 | .foregroundStyle( 131 | action == .buttonMenu ? .yellow : .clear 132 | ) 133 | ) 134 | 135 | Spacer() 136 | } 137 | } 138 | 139 | var dPad: some View { 140 | VStack { 141 | Text("U") 142 | .padding(3) 143 | .frame(width: 25, height: 25) 144 | .background(Rectangle().stroke()) 145 | .background( 146 | Rectangle() 147 | .fill() 148 | .foregroundStyle( 149 | action == .dpadUp ? .yellow : .clear 150 | ) 151 | ) 152 | 153 | HStack { 154 | Text("L") 155 | .padding(3) 156 | .frame(width: 25, height: 25) 157 | .background(Rectangle().stroke()) 158 | .background( 159 | Rectangle() 160 | .fill() 161 | .foregroundStyle( 162 | action == .dpadLeft ? .yellow : .clear 163 | ) 164 | ) 165 | .padding(.trailing, 25) 166 | 167 | Text("R") 168 | .padding(3) 169 | .frame(width: 25, height: 25) 170 | .background(Rectangle().stroke()) 171 | .background( 172 | Rectangle() 173 | .fill() 174 | .foregroundStyle( 175 | action == .dpadRight ? .yellow : .clear 176 | ) 177 | ) 178 | } 179 | 180 | Text("D") 181 | .padding(3) 182 | .frame(width: 25, height: 25) 183 | .background(Rectangle().stroke()) 184 | .background( 185 | Rectangle() 186 | .fill() 187 | .foregroundStyle( 188 | action == .dpadDown ? .yellow : .clear 189 | ) 190 | ) 191 | } 192 | } 193 | 194 | var touchPad: some View { 195 | HStack { 196 | Spacer() 197 | 198 | Text( 199 | action.touchPadAction 200 | ? String(describing: action) 201 | : "TouchPad" 202 | ) 203 | .padding(3) 204 | .frame(minWidth: 100, minHeight: 50) 205 | .background( 206 | Rectangle() 207 | .stroke() 208 | .foregroundStyle( 209 | Color(cgColor: .init( 210 | red: CGFloat(GCKit.controller?.light?.color.red ?? 0), 211 | green: CGFloat(GCKit.controller?.light?.color.green ?? 0), 212 | blue: CGFloat(GCKit.controller?.light?.color.blue ?? 0), 213 | alpha: 1 214 | )) 215 | ) 216 | ) 217 | .background( 218 | Rectangle() 219 | .fill() 220 | .foregroundStyle(action.touchPadAction ? .yellow : .clear) 221 | ) 222 | .padding(.bottom, 50) 223 | 224 | Spacer() 225 | } 226 | } 227 | 228 | var buttons: some View { 229 | VStack { 230 | Text("Y") 231 | .padding(3) 232 | .frame(width: 25, height: 25) 233 | .background( 234 | RoundedRectangle(cornerRadius: 25) 235 | .stroke() 236 | ) 237 | .background( 238 | RoundedRectangle(cornerRadius: 25) 239 | .fill() 240 | .foregroundStyle( 241 | action == .buttonY ? .yellow : .clear 242 | ) 243 | ) 244 | 245 | HStack { 246 | Text("X") 247 | .padding(3) 248 | .frame(width: 25, height: 25) 249 | .background( 250 | RoundedRectangle(cornerRadius: 25) 251 | .stroke() 252 | ) 253 | .background( 254 | RoundedRectangle(cornerRadius: 25) 255 | .fill() 256 | .foregroundStyle( 257 | action == .buttonX ? .yellow : .clear 258 | ) 259 | ) 260 | .padding(.trailing, 25) 261 | 262 | Text("B") 263 | .padding(3) 264 | .frame(width: 25, height: 25) 265 | .background( 266 | RoundedRectangle(cornerRadius: 25) 267 | .stroke() 268 | ) 269 | .background( 270 | RoundedRectangle(cornerRadius: 25) 271 | .fill() 272 | .foregroundStyle( 273 | action == .buttonB ? .yellow : .clear 274 | ) 275 | ) 276 | } 277 | 278 | Text("A") 279 | .padding(3) 280 | .frame(width: 25, height: 25) 281 | .background( 282 | RoundedRectangle(cornerRadius: 25) 283 | .stroke() 284 | ) 285 | .background( 286 | RoundedRectangle(cornerRadius: 25) 287 | .fill() 288 | .foregroundStyle( 289 | action == .buttonA ? .yellow : .clear 290 | ) 291 | ) 292 | } 293 | } 294 | 295 | var thumbSticks: some View { 296 | HStack { 297 | Spacer() 298 | 299 | Text("L") 300 | .padding(3) 301 | .frame(width: 50, height: 50) 302 | .background( 303 | RoundedRectangle(cornerRadius: 25) 304 | .stroke() 305 | ) 306 | .background( 307 | RoundedRectangle(cornerRadius: 25) 308 | .fill() 309 | .foregroundStyle( 310 | action == .leftThumbstickButton ? .yellow : .clear 311 | ) 312 | ) 313 | .overlay { 314 | if case .leftThumbstick = action { 315 | switch action.position { 316 | case .up: 317 | Text("↑") 318 | .padding(.bottom) 319 | 320 | case .upRight: 321 | Text("↗") 322 | .padding(.bottom) 323 | .padding(.leading) 324 | 325 | case .right: 326 | Text("→") 327 | .padding(.leading) 328 | 329 | case .downRight: 330 | Text("↘") 331 | .padding(.top) 332 | .padding(.leading) 333 | 334 | case .down: 335 | Text("↓") 336 | .padding(.top) 337 | 338 | case .downLeft: 339 | Text("↙") 340 | .padding(.top) 341 | .padding(.trailing) 342 | 343 | case .left: 344 | Text("←") 345 | .padding(.trailing) 346 | 347 | case .upLeft: 348 | Text("↖") 349 | .padding(.bottom) 350 | .padding(.trailing) 351 | 352 | default: 353 | Text("") 354 | } 355 | } 356 | } 357 | 358 | Spacer() 359 | 360 | Text("H") 361 | .padding(3) 362 | .frame(width: 25, height: 25) 363 | .background( 364 | RoundedRectangle(cornerRadius: 25) 365 | .stroke() 366 | ) 367 | .background( 368 | RoundedRectangle(cornerRadius: 25) 369 | .fill() 370 | .foregroundStyle( 371 | action == .buttonHome ? .yellow : .clear 372 | ) 373 | ) 374 | 375 | Spacer() 376 | 377 | Text("R") 378 | .padding(3) 379 | .frame(width: 50, height: 50) 380 | .background( 381 | RoundedRectangle(cornerRadius: 25) 382 | .stroke() 383 | ) 384 | .background( 385 | RoundedRectangle(cornerRadius: 25) 386 | .fill() 387 | .foregroundStyle( 388 | action == .rightThumbstickButton ? .yellow : .clear 389 | ) 390 | ) 391 | .overlay { 392 | if case .rightThumbstick = action { 393 | switch action.position { 394 | case .up: 395 | Text("↑") 396 | .padding(.bottom) 397 | 398 | case .upRight: 399 | Text("↗") 400 | .padding(.bottom) 401 | .padding(.leading) 402 | 403 | case .right: 404 | Text("→") 405 | .padding(.leading) 406 | 407 | case .downRight: 408 | Text("↘") 409 | .padding(.top) 410 | .padding(.leading) 411 | 412 | case .down: 413 | Text("↓") 414 | .padding(.top) 415 | 416 | case .downLeft: 417 | Text("↙") 418 | .padding(.top) 419 | .padding(.trailing) 420 | 421 | case .left: 422 | Text("←") 423 | .padding(.trailing) 424 | 425 | case .upLeft: 426 | Text("↖") 427 | .padding(.bottom) 428 | .padding(.trailing) 429 | 430 | default: 431 | Text("") 432 | } 433 | } 434 | } 435 | 436 | Spacer() 437 | } 438 | } 439 | 440 | var paddleButtons: some View { 441 | VStack { 442 | HStack { 443 | Text("Paddle 1") 444 | .padding(3) 445 | .frame(width: 75, height: 25) 446 | .background(Rectangle().stroke()) 447 | .background( 448 | Rectangle() 449 | .fill() 450 | .foregroundStyle( 451 | action == .paddleButton1 ? .yellow : .clear 452 | ) 453 | ) 454 | 455 | Spacer() 456 | Text("Paddle 2") 457 | .padding(3) 458 | .frame(width: 75, height: 25) 459 | .background(Rectangle().stroke()) 460 | .background( 461 | Rectangle() 462 | .fill() 463 | .foregroundStyle( 464 | action == .paddleButton2 ? .yellow : .clear 465 | ) 466 | ) 467 | } 468 | HStack { 469 | Text("Paddle 3") 470 | .padding(3) 471 | .frame(width: 75, height: 25) 472 | .background(Rectangle().stroke()) 473 | .background( 474 | Rectangle() 475 | .fill() 476 | .foregroundStyle( 477 | action == .paddleButton3 ? .yellow : .clear 478 | ) 479 | ) 480 | 481 | Spacer() 482 | Text("Paddle 4") 483 | .padding(3) 484 | .frame(width: 75, height: 25) 485 | .background(Rectangle().stroke()) 486 | .background( 487 | Rectangle() 488 | .fill() 489 | .foregroundStyle( 490 | action == .paddleButton4 ? .yellow : .clear 491 | ) 492 | ) 493 | } 494 | } 495 | } 496 | } 497 | 498 | struct GCKControllerViewPreview: PreviewProvider { 499 | static var previews: some View { 500 | GCKControllerView() 501 | .environmentObject(GameControllerKit()) 502 | } 503 | } 504 | // swiftlint:disable:this file_length 505 | -------------------------------------------------------------------------------- /Sources/GameControllerKit/GCKMovePosition.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GCKMovePosition.swift 3 | // GameControllerKit 4 | // 5 | // Created by Wesley de Groot on 2024-08-19. 6 | // https://wesleydegroot.nl 7 | // 8 | // https://github.com/0xWDG/GameControllerKit 9 | // MIT License 10 | // 11 | 12 | import Foundation 13 | 14 | /// Moding position of the thumbstick. 15 | public enum GCKMovePosition: Comparable { 16 | /// The thumbstick position is up 17 | case up 18 | // swiftlint:disable:previous identifier_name 19 | 20 | /// The thumbstick position is up - right 21 | case upRight 22 | 23 | /// The thumbstick position is right 24 | case right 25 | 26 | /// The thumbstick position is down - right 27 | case downRight 28 | 29 | /// The thumbstick position is down 30 | case down 31 | 32 | /// The thumbstick position is down - left 33 | case downLeft 34 | 35 | /// The thumbstick position is left 36 | case left 37 | 38 | /// The thumbstick position is up - left 39 | case upLeft 40 | 41 | /// The thumbstick position is centered 42 | case centered 43 | 44 | /// The thumbstick position is unknown (usually a phase in between a position and center) 45 | /// if it keeps being unknown, check if the event you are sending is correct. 46 | case unknown 47 | 48 | public var arrowRepresentation: String { 49 | switch self { 50 | case .up: 51 | "↑" 52 | case .upRight: 53 | "↗" 54 | case .right: 55 | "→" 56 | case .downRight: 57 | "↘" 58 | case .down: 59 | "↓" 60 | case .downLeft: 61 | "↙" 62 | case .left: 63 | "←" 64 | case .upLeft: 65 | "↖" 66 | case .centered: 67 | "•" 68 | case .unknown: 69 | "" 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Sources/GameControllerKit/GameControllerKit.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GameControllerKit.swift 3 | // GameControllerKit 4 | // 5 | // Created by Wesley de Groot on 2024-08-19. 6 | // https://wesleydegroot.nl 7 | // 8 | // https://github.com/0xWDG/GameControllerKit 9 | // MIT License 10 | // 11 | 12 | import Foundation 13 | import GameController 14 | import CoreHaptics 15 | import OSLog 16 | 17 | /// Game Controller Kit 18 | /// 19 | /// GameControllerKit is a Swift package that makes it easy to work with 20 | /// game controllers on iOS, macOS, and tvOS. It provides a simple API to 21 | /// connect to game controllers, read input from them, and control their 22 | /// lights and haptics. 23 | public class GameControllerKit: ObservableObject { 24 | /// Event Handler 25 | public typealias GCKEventHandler = (_ action: GCKAction, _ pressed: Bool, _ controller: GCKController) -> Void 26 | 27 | /// The type of game controller that is currently connected, if any. 28 | /// This property is nil if no controller is connected. 29 | @Published 30 | public var controllerType: GCKControllerType? = .none 31 | 32 | /// The game controller that is currently connected, if any. (this is always the first controller) 33 | @Published 34 | public var controller: GCKController? 35 | 36 | /// The game controllers that are currently connected, if any. 37 | @Published 38 | public var controllers: [GCKController] = [] 39 | 40 | /// The current state of the left thumbstick. 41 | @Published 42 | public var leftThumbstick: GCKMovePosition = .centered 43 | 44 | /// The current state of the right thumbstick. 45 | @Published 46 | public var rightThumbstick: GCKMovePosition = .centered 47 | 48 | /// The last action done by the controller 49 | @Published 50 | public var lastAction: GCKAction = .none 51 | 52 | /// Action handler for the actions performed by the user on the controller 53 | private var eventHandler: GCKEventHandler? 54 | 55 | /// Game Controller Kit logger. 56 | private var logger = Logger( 57 | subsystem: "nl.wesleydegroot.GameControllerKit", 58 | category: "GameControllerKit" 59 | ) 60 | 61 | /// Indicates whether a game controller is currently connected. 62 | public var isConnected: Bool = false 63 | 64 | /// Initializes a new GameControllerKit instance. 65 | /// It sets up notification observers for when game controllers connect or disconnect. 66 | /// 67 | /// - Parameter logger: Custom ``Logger`` instance. (optional) 68 | public init(logger: Logger? = nil) { 69 | NotificationCenter.default.addObserver( 70 | forName: .GCControllerDidConnect, 71 | object: nil, 72 | queue: .main, 73 | using: controllerDidConnect 74 | ) 75 | 76 | NotificationCenter.default.addObserver( 77 | forName: .GCControllerDidDisconnect, 78 | object: nil, 79 | queue: .main, 80 | using: controllerDidDisconnect 81 | ) 82 | 83 | self.eventHandler = { [weak self] button, pressed, controller in 84 | let message = "Controller #\(String(describing: controller.playerIndex.rawValue)), " + 85 | "Button \(String(describing: button)) \(button.position.arrowRepresentation) " + 86 | "is \(pressed ? "Pressed" : "Unpressed")" 87 | 88 | self?.logger.info("\(String(describing: message))") 89 | } 90 | 91 | if let logger = logger { 92 | self.logger = logger 93 | } 94 | } 95 | 96 | /// Set color of the controllers light 97 | /// 98 | /// Use the light settings to signal the user or to create a more immersive experience. 99 | /// If the controller doesn’t provide light settings, this property is nil. 100 | /// 101 | /// - Parameter color: Color 102 | public func set(color: GCColor) { 103 | controller?.light?.color = color 104 | } 105 | 106 | /// Set the event handler 107 | /// 108 | /// This function allows you to setup a custom event handler, 109 | /// which you need to receive inputs from the controller. 110 | /// 111 | /// - Parameter handler: event handler 112 | public func set(handler: @escaping GCKEventHandler) { 113 | self.eventHandler = handler 114 | } 115 | 116 | /// Plays random colors on your controller (if supported) 117 | /// This is currently only supported on a DualSense and DualShock controller (Playstation) 118 | public func randomColors() { 119 | for counter in 0...10 { 120 | DispatchQueue.main.asyncAfter(deadline: .now() + (Double(counter)/0.99)) { 121 | self.set(color: .GCKRandom) 122 | } 123 | } 124 | } 125 | 126 | /// Play haptics 127 | /// 128 | /// This plays haptics (vibrations) on the gamecontroller. 129 | /// 130 | /// - Parameter url: Haptics file 131 | public func playHaptics(url: URL) { 132 | guard let haptics = self.controller?.haptics?.createEngine(withLocality: .default) else { 133 | logger.fault("Couldn't initialize haptics") 134 | return 135 | } 136 | 137 | do { 138 | // Start the engine in case it's idle. 139 | try haptics.start() 140 | 141 | // Tell the engine to play a pattern. 142 | try haptics.playPattern(from: url) 143 | } catch { 144 | // Process engine startup errors. 145 | logger.fault("An error occured playing haptics: \(error).") 146 | } 147 | } 148 | 149 | // MARK: - Connect/Disconnect functions 150 | /// Controller did connect 151 | /// 152 | /// This function handles the connection of a controller. 153 | /// If it is the first controller it will set to the primary controller 154 | @objc private func controllerDidConnect(_ notification: Notification) { 155 | controllers = GCController.controllers() 156 | 157 | guard !controllers.isEmpty else { 158 | logger.fault("Failed to get the controller") 159 | return 160 | } 161 | 162 | for (index, currentController) in controllers.enumerated() { 163 | currentController.playerIndex = GCControllerPlayerIndex(rawValue: index) ?? .indexUnset 164 | 165 | let currentControllerType: GCKControllerType = switch currentController.physicalInputProfile { 166 | case is GCDualSenseGamepad: 167 | .dualSense 168 | 169 | case is GCDualShockGamepad: 170 | .dualShock 171 | 172 | case is GCXboxGamepad: 173 | .xbox 174 | 175 | case is GCMicroGamepad: 176 | .siriRemote 177 | default: 178 | .generic 179 | } 180 | 181 | let contr = String(describing: currentControllerType) 182 | logger.info( 183 | "Did connect controller \(currentController.productCategory) recognized as \(contr)." 184 | ) 185 | 186 | if !isConnected && currentControllerType != .siriRemote { 187 | isConnected = true 188 | controller = currentController 189 | controllerType = currentControllerType 190 | 191 | logger.info( 192 | "Did set controller \(currentController.productCategory) as main (first) controller." 193 | ) 194 | } 195 | 196 | setupController(controller: currentController) 197 | } 198 | } 199 | 200 | /// Controller did disconnect 201 | /// 202 | /// This function handles the disconnection of a controller. 203 | @objc private func controllerDidDisconnect(_ notification: Notification) { 204 | controllers = GCController.controllers() 205 | 206 | if controller == notification.object as? GCController? { 207 | logger.debug("The primary controller is disconnected") 208 | isConnected = false 209 | self.controllerType = nil 210 | 211 | if !controllers.isEmpty { 212 | logger.debug("Setup a new primary controller") 213 | controllerDidConnect(notification) 214 | } 215 | 216 | return 217 | } 218 | 219 | logger.debug("A controller is disconnected") 220 | } 221 | 222 | // MARK: - Setup controller 223 | 224 | /// Set up controller 225 | /// 226 | /// This function sets up the controller, 227 | /// it looks which type it is and then map the elements to the corresponding responders. 228 | /// 229 | /// - Parameter controller: Controller 230 | func setupController(controller: GCController) { 231 | // swiftlint:disable:previous function_body_length 232 | var buttons: [(GCControllerButtonInput?, GCKAction)] = [] 233 | 234 | if let gamepad = controller.extendedGamepad { 235 | buttons.append(contentsOf: [ 236 | (gamepad.buttonA, .buttonA), 237 | (gamepad.buttonB, .buttonB), 238 | (gamepad.buttonX, .buttonX), 239 | (gamepad.buttonY, .buttonY), 240 | (gamepad.leftShoulder, .leftShoulder), 241 | (gamepad.rightShoulder, .rightShoulder), 242 | (gamepad.leftTrigger, .leftTrigger), 243 | (gamepad.rightTrigger, .rightTrigger), 244 | (gamepad.buttonMenu, .buttonMenu), 245 | (gamepad.buttonOptions, .buttonOptions), 246 | (gamepad.buttonHome, .buttonHome), 247 | (gamepad.leftThumbstickButton, .leftThumbstickButton), 248 | (gamepad.rightThumbstickButton, .rightThumbstickButton), 249 | (gamepad.dpad.up, .dpadUp), 250 | (gamepad.dpad.down, .dpadDown), 251 | (gamepad.dpad.left, .dpadLeft), 252 | (gamepad.dpad.right, .dpadRight) 253 | ]) 254 | 255 | if let playstationGamepad = controller.physicalInputProfile as? GCDualSenseGamepad { 256 | buttons.append( 257 | contentsOf: [ 258 | (playstationGamepad.touchpadButton, .touchpadButton), 259 | (playstationGamepad.touchpadPrimary.up, .touchpadPrimaryUp), 260 | (playstationGamepad.touchpadPrimary.right, .touchpadPrimaryRight), 261 | (playstationGamepad.touchpadPrimary.left, .touchpadPrimaryLeft), 262 | (playstationGamepad.touchpadPrimary.down, .touchpadPrimaryDown), 263 | (playstationGamepad.touchpadSecondary.up, .touchpadSecondaryUp), 264 | (playstationGamepad.touchpadSecondary.right, .touchpadSecondaryRight), 265 | (playstationGamepad.touchpadSecondary.down, .touchpadSecondaryDown), 266 | (playstationGamepad.touchpadSecondary.left, .touchpadSecondaryLeft) 267 | ] 268 | ) 269 | } 270 | 271 | if let playstationGamepad = controller.physicalInputProfile as? GCDualShockGamepad { 272 | buttons.append( 273 | contentsOf: [ 274 | (playstationGamepad.touchpadButton, .touchpadButton), 275 | (playstationGamepad.touchpadPrimary.up, .touchpadPrimaryUp), 276 | (playstationGamepad.touchpadPrimary.right, .touchpadPrimaryRight), 277 | (playstationGamepad.touchpadPrimary.left, .touchpadPrimaryLeft), 278 | (playstationGamepad.touchpadPrimary.down, .touchpadPrimaryDown), 279 | (playstationGamepad.touchpadSecondary.up, .touchpadSecondaryUp), 280 | (playstationGamepad.touchpadSecondary.right, .touchpadSecondaryRight), 281 | (playstationGamepad.touchpadSecondary.down, .touchpadSecondaryDown), 282 | (playstationGamepad.touchpadSecondary.left, .touchpadSecondaryLeft) 283 | ] 284 | ) 285 | } 286 | 287 | if let xboxGamepad = controller.physicalInputProfile as? GCXboxGamepad { 288 | buttons.append( 289 | contentsOf: [ 290 | (xboxGamepad.buttonShare, .shareButton), 291 | (xboxGamepad.paddleButton1, .paddleButton1), 292 | (xboxGamepad.paddleButton2, .paddleButton2), 293 | (xboxGamepad.paddleButton3, .paddleButton3), 294 | (xboxGamepad.paddleButton4, .paddleButton4) 295 | ] 296 | ) 297 | } 298 | 299 | for (button, name) in buttons { 300 | button?.valueChangedHandler = { [weak self] (_, _, pressed) in 301 | self?.lastAction = name 302 | self?.eventHandler?(name, pressed, controller) 303 | } 304 | } 305 | 306 | gamepad.leftThumbstick.valueChangedHandler = { (_, xPos, yPos) in 307 | let action: GCKAction = .leftThumbstick(x: xPos, y: yPos) 308 | self.lastAction = action 309 | self.leftThumbstick = action.position 310 | self.eventHandler?(action, false, controller) 311 | } 312 | 313 | gamepad.rightThumbstick.valueChangedHandler = { (_, xPos, yPos) in 314 | let action: GCKAction = .rightThumbstick(x: xPos, y: yPos) 315 | self.lastAction = action 316 | self.rightThumbstick = action.position 317 | self.eventHandler?(action, false, controller) 318 | } 319 | } 320 | } 321 | } 322 | 323 | extension GCColor { 324 | /// Random color 325 | /// 326 | /// - Returns: A random color. 327 | public static var GCKRandom: GCColor { 328 | return GCColor( 329 | red: .random(in: 0...1), 330 | green: .random(in: 0...1), 331 | blue: .random(in: 0...1) 332 | ) 333 | } 334 | } 335 | -------------------------------------------------------------------------------- /Tests/GameControllerKitTests/GameControllerKitTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GameControllerKitTests.swift 3 | // GameControllerKit 4 | // 5 | // Created by Wesley de Groot on 2024-08-19. 6 | // https://wesleydegroot.nl 7 | // 8 | // https://github.com/0xWDG/GameControllerKit 9 | // MIT License 10 | // 11 | import XCTest 12 | @testable import GameControllerKit 13 | 14 | final class GameControllerKitTests: XCTestCase { 15 | func testExample() throws { 16 | // XCTest Documentation 17 | // https://developer.apple.com/documentation/xctest 18 | 19 | // Defining Test Cases and Test Methods 20 | // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods 21 | } 22 | } 23 | --------------------------------------------------------------------------------