├── .gitattributes ├── WebApp ├── Build │ ├── AppIcon.png │ ├── assets │ │ ├── Up_Idle.png │ │ ├── Down_Idle.png │ │ ├── Down_Pushed.png │ │ ├── Next_Idle.png │ │ ├── Next_Pushed.png │ │ ├── Up_Pushed.png │ │ ├── Previous_Idle.png │ │ └── Previous_Pushed.png │ ├── genFavicon.py │ ├── buildApp.sh │ └── index.html ├── .gitignore ├── README.md ├── Package.swift └── Sources │ └── WebApp │ └── main.swift ├── SnakeMacApp ├── README.md ├── Build │ ├── AppIcon.png │ ├── Info.plist │ └── build.sh ├── .gitignore ├── Package.swift └── Sources │ └── SnakeMacApp │ ├── app.swift │ └── Content.swift ├── public ├── Favicon.ico ├── assets │ ├── Up_Idle.png │ ├── Down_Idle.png │ ├── Next_Idle.png │ ├── Up_Pushed.png │ ├── Down_Pushed.png │ ├── Next_Pushed.png │ ├── Previous_Idle.png │ └── Previous_Pushed.png ├── 96b62c0e72bb92ba.wasm ├── index.html └── 9c03432b889f6e7e.js ├── .gitignore ├── wasm_setup.sh ├── SnakeSwiftCore ├── README.md ├── .gitignore ├── Sources │ └── SnakeSwiftCore │ │ ├── globalVar.swift │ │ ├── event.swift │ │ ├── board.swift │ │ ├── loop.swift │ │ ├── run.swift │ │ ├── keyboard.swift │ │ └── renderer.swift ├── Tests │ └── SnakeSwiftCoreTests │ │ └── SnakeSwiftCoreTests.swift └── Package.swift ├── LICENSE └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | public/** linguist-generated 2 | -------------------------------------------------------------------------------- /WebApp/Build/AppIcon.png: -------------------------------------------------------------------------------- 1 | ../../SnakeMacApp/Build/AppIcon.png -------------------------------------------------------------------------------- /SnakeMacApp/README.md: -------------------------------------------------------------------------------- 1 | # SnakeMacApp 2 | 3 | A description of this package. 4 | -------------------------------------------------------------------------------- /public/Favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jomy10/SnakeSwift/HEAD/public/Favicon.ico -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | WebApp/Bundle 2 | **/Package.resolved 3 | **/.DS_Store 4 | **/.vscode 5 | **/.swiftpm 6 | -------------------------------------------------------------------------------- /public/assets/Up_Idle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jomy10/SnakeSwift/HEAD/public/assets/Up_Idle.png -------------------------------------------------------------------------------- /public/assets/Down_Idle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jomy10/SnakeSwift/HEAD/public/assets/Down_Idle.png -------------------------------------------------------------------------------- /public/assets/Next_Idle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jomy10/SnakeSwift/HEAD/public/assets/Next_Idle.png -------------------------------------------------------------------------------- /public/assets/Up_Pushed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jomy10/SnakeSwift/HEAD/public/assets/Up_Pushed.png -------------------------------------------------------------------------------- /SnakeMacApp/Build/AppIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jomy10/SnakeSwift/HEAD/SnakeMacApp/Build/AppIcon.png -------------------------------------------------------------------------------- /SnakeMacApp/Build/Info.plist: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jomy10/SnakeSwift/HEAD/SnakeMacApp/Build/Info.plist -------------------------------------------------------------------------------- /public/96b62c0e72bb92ba.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jomy10/SnakeSwift/HEAD/public/96b62c0e72bb92ba.wasm -------------------------------------------------------------------------------- /public/assets/Down_Pushed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jomy10/SnakeSwift/HEAD/public/assets/Down_Pushed.png -------------------------------------------------------------------------------- /public/assets/Next_Pushed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jomy10/SnakeSwift/HEAD/public/assets/Next_Pushed.png -------------------------------------------------------------------------------- /WebApp/Build/assets/Up_Idle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jomy10/SnakeSwift/HEAD/WebApp/Build/assets/Up_Idle.png -------------------------------------------------------------------------------- /public/assets/Previous_Idle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jomy10/SnakeSwift/HEAD/public/assets/Previous_Idle.png -------------------------------------------------------------------------------- /wasm_setup.sh: -------------------------------------------------------------------------------- 1 | export PATH="/Library/Developer/Toolchains/swift-wasm-5.5.0-RELEASE.xctoolchain/usr/bin":"${PATH}" 2 | -------------------------------------------------------------------------------- /WebApp/Build/assets/Down_Idle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jomy10/SnakeSwift/HEAD/WebApp/Build/assets/Down_Idle.png -------------------------------------------------------------------------------- /WebApp/Build/assets/Down_Pushed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jomy10/SnakeSwift/HEAD/WebApp/Build/assets/Down_Pushed.png -------------------------------------------------------------------------------- /WebApp/Build/assets/Next_Idle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jomy10/SnakeSwift/HEAD/WebApp/Build/assets/Next_Idle.png -------------------------------------------------------------------------------- /WebApp/Build/assets/Next_Pushed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jomy10/SnakeSwift/HEAD/WebApp/Build/assets/Next_Pushed.png -------------------------------------------------------------------------------- /WebApp/Build/assets/Up_Pushed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jomy10/SnakeSwift/HEAD/WebApp/Build/assets/Up_Pushed.png -------------------------------------------------------------------------------- /public/assets/Previous_Pushed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jomy10/SnakeSwift/HEAD/public/assets/Previous_Pushed.png -------------------------------------------------------------------------------- /WebApp/Build/assets/Previous_Idle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jomy10/SnakeSwift/HEAD/WebApp/Build/assets/Previous_Idle.png -------------------------------------------------------------------------------- /WebApp/Build/assets/Previous_Pushed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jomy10/SnakeSwift/HEAD/WebApp/Build/assets/Previous_Pushed.png -------------------------------------------------------------------------------- /SnakeSwiftCore/README.md: -------------------------------------------------------------------------------- 1 | # SnakeSwiftCore 2 | 3 | This package contains the core game logic. 4 | 5 | Currently supports `WASI` and `macOS` 6 | -------------------------------------------------------------------------------- /WebApp/Build/genFavicon.py: -------------------------------------------------------------------------------- 1 | from PIL import Image 2 | 3 | filename = r'Build/AppIcon.png' 4 | img = Image.open(filename) 5 | img.save('../public/favicon.ico') 6 | -------------------------------------------------------------------------------- /SnakeSwiftCore/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | -------------------------------------------------------------------------------- /WebApp/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | Build/assets/ 9 | -------------------------------------------------------------------------------- /SnakeMacApp/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | Build/AppIconDesign.afphoto 9 | Build/AppIcon.iconset 10 | Snake.app -------------------------------------------------------------------------------- /WebApp/README.md: -------------------------------------------------------------------------------- 1 | # Snake Web App 2 | Snake game written in Swift running on WebAssembly using TokamakUI and JavaScriptKit. 3 | 4 | This package only contains the UI for the Web version of the game. The core game logic is located in [`SnakeSwiftCore`](../SnakeSwiftCore). 5 | -------------------------------------------------------------------------------- /SnakeSwiftCore/Sources/SnakeSwiftCore/globalVar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // globalVar.swift 3 | // 4 | // Variables that have to stay in scope 5 | // 6 | // Created by Jonas Everaert on 14/03/2022. 7 | // 8 | 9 | public var renderer: GraphicsRenderer? = nil 10 | 11 | /// Game loop running at x fps 12 | public var loop: GameLoop? 13 | -------------------------------------------------------------------------------- /WebApp/Build/buildApp.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env zsh 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")" 6 | cd .. 7 | carton bundle --custom-index-page Build/index.html 8 | rm -r ../public || true 9 | mv Bundle ../public 10 | 11 | # Build favicon 12 | py Build/genFavicon.py 13 | 14 | # Copy assets 15 | cp -r Build/assets ../public/assets 16 | -------------------------------------------------------------------------------- /WebApp/Build/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /SnakeSwiftCore/Tests/SnakeSwiftCoreTests/SnakeSwiftCoreTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SnakeSwiftCore 3 | 4 | final class SnakeSwiftCoreTests: XCTestCase { 5 | func testExample() throws { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | // XCTAssertEqual(SnakeSwiftCore().text, "Hello, World!") 10 | // TODO 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /SnakeMacApp/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 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: "SnakeMacApp", 8 | platforms: [.macOS(.v12)], 9 | dependencies: [ 10 | .package(name: "SnakeSwiftCore", path: "../SnakeSwiftCore") 11 | ], 12 | targets: [ 13 | .executableTarget( 14 | name: "SnakeMacApp", 15 | dependencies: [ 16 | "SnakeSwiftCore" 17 | ], 18 | resources: [ 19 | 20 | ] 21 | ), 22 | ] 23 | ) 24 | -------------------------------------------------------------------------------- /WebApp/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 2 | import PackageDescription 3 | let package = Package( 4 | name: "WebApp", 5 | platforms: [.macOS(.v12)], 6 | products: [ 7 | .executable(name: "WebApp", targets: ["WebApp"]) 8 | ], 9 | dependencies: [ 10 | .package(name: "Tokamak", url: "https://github.com/Jomy10/Tokamak", from: "0.9.2"), 11 | .package(name: "SnakeSwiftCore", path: "../SnakeSwiftCore"), 12 | ], 13 | targets: [ 14 | .target( 15 | name: "WebApp", 16 | dependencies: [ 17 | .product(name: "TokamakDOM", package: "Tokamak"), 18 | .product(name: "SnakeSwiftCore", package: "SnakeSwiftCore") 19 | ], 20 | linkerSettings: [ 21 | .unsafeFlags(["-Xlinker", "--stack-first", "-Xlinker", "-z", "-Xlinker", "stack-size=16777216"]) 22 | ] 23 | ), 24 | ] 25 | ) 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Jonas Everaert 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /SnakeSwiftCore/Sources/SnakeSwiftCore/event.swift: -------------------------------------------------------------------------------- 1 | // 2 | // event.swift 3 | // 4 | // GameEvent 5 | // 6 | // Created by Jonas Everaert on 13/03/2022. 7 | // 8 | 9 | import Foundation 10 | 11 | public enum GameEvent { 12 | case MoveUp 13 | case MoveDown 14 | case MoveLeft 15 | case MoveRight 16 | /// Player gets bigger 17 | case AddTail 18 | } 19 | 20 | extension GameEvent { 21 | public var isMoveAction: Bool { 22 | if self == .MoveUp || self == .MoveDown || self == .MoveLeft || self == .MoveRight { 23 | return true 24 | } else { 25 | return false 26 | } 27 | } 28 | 29 | public func isOpositeOf(_ other: GameEvent) -> Bool { 30 | if self == .MoveUp && other == .MoveDown || 31 | self == .MoveDown && other == .MoveUp || 32 | self == .MoveRight && other == .MoveLeft || 33 | self == .MoveLeft && other == .MoveRight 34 | { 35 | return true 36 | } else { 37 | return false 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /SnakeSwiftCore/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 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: "SnakeSwiftCore", 8 | platforms: [.macOS(.v12)], 9 | products: [ 10 | .library( 11 | name: "SnakeSwiftCore", 12 | targets: ["SnakeSwiftCore"]), 13 | ], 14 | dependencies: [ 15 | .package(name: "Tokamak", url: "https://github.com/Jomy10/Tokamak", from: "0.9.2") 16 | ], 17 | targets: [ 18 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 19 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 20 | .target( 21 | name: "SnakeSwiftCore", 22 | dependencies: [ 23 | .product(name: "TokamakShim", package: "Tokamak") 24 | ]), 25 | .testTarget( 26 | name: "SnakeSwiftCoreTests", 27 | dependencies: ["SnakeSwiftCore"]), 28 | ] 29 | ) 30 | -------------------------------------------------------------------------------- /SnakeSwiftCore/Sources/SnakeSwiftCore/board.swift: -------------------------------------------------------------------------------- 1 | // 2 | // board.swift 3 | // 4 | // Board and coordinates 5 | // 6 | // Created by Jonas Everaert on 13/03/2022. 7 | // 8 | 9 | import Foundation 10 | 11 | /// Position on the board 12 | public struct Coordinate { 13 | var x, y: Int 14 | } 15 | 16 | extension Coordinate { 17 | public static func randomCoordinate(minX: Int = 0, maxX: Int, minY: Int = 0, maxY: Int) -> Coordinate { 18 | let x = Int.random(in: minX...maxX-1) 19 | let y = Int.random(in: minY...maxY-1) 20 | return Coordinate(x: x, y: y) 21 | } 22 | } 23 | 24 | extension Coordinate: Equatable { 25 | public static func == (lhs: Coordinate, rhs: Coordinate) -> Bool { 26 | return lhs.x == rhs.x && lhs.y == rhs.y 27 | } 28 | } 29 | 30 | /// The current state of the game 31 | public struct BoardState { 32 | public let size: (rows: Int, cols: Int) 33 | /// Next position of a food item 34 | public var food: Coordinate 35 | /// Location of the player and its tail pares 36 | public var player: [Coordinate] 37 | /// The player's current score 38 | public var score: Int = 0 39 | } 40 | -------------------------------------------------------------------------------- /SnakeMacApp/Sources/SnakeMacApp/app.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// Snake game for macOS 4 | @main 5 | struct SnakeApp: App { 6 | @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate 7 | 8 | var body: some Scene { 9 | WindowGroup("Snake") { 10 | ContentView() 11 | } 12 | } 13 | } 14 | 15 | /// Without this, there will just be a window without an actual app 16 | class AppDelegate: NSObject, NSApplicationDelegate { 17 | func applicationDidFinishLaunching(_ notification: Notification) { 18 | NSApplication.shared.setActivationPolicy(.regular) 19 | NSApplication.shared.activate(ignoringOtherApps: true) 20 | NSApplication.shared.windows.forEach { window in window.center() } 21 | } 22 | 23 | /// Close application when the window is closed 24 | func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { 25 | return true 26 | } 27 | 28 | func applicationWillUpdate(_ notification: Notification) { 29 | if let menu = NSApplication.shared.mainMenu { 30 | // Remove default menu items that we don't need 31 | // removeAll(where) 32 | menu.items.removeAll { $0.title == "Edit" } 33 | menu.items.removeAll { $0.title == "File" } 34 | menu.items.removeAll { $0.title == "Help" } 35 | } 36 | } 37 | 38 | // TODO: find a way to prevent multiple window/tabs to be open 39 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Snake Game 2 | 3 | Snake game built using Swift, playable on any browser using WebAssembly and on macOS. 4 | 5 | ## Browser 6 | UI built using [Tokamak](https://github.com/TokamakUI/Tokamak), which is a SwiftUI compatible framework for WebAssembly. Interaction with the DOM is done through [JavaScriptKit](https://github.com/swiftwasm/JavaScriptKit). Built and bundles using [Carton](https://github.com/swiftwasm/carton). 7 | 8 | Source code for the browser UI in [WebApp](WebApp). 9 | 10 | ## macOS 11 | Since Tokamak is a SwiftUI compatible framework, a Mac app can be easily created without too much adjustments. 12 | 13 | Source code for the mac version in [SnakeMacApp](SnakeWebApp). 14 | 15 | ## Core library 16 | The core library contains all the game logic. This imports TokamakShim, which uses SwiftUI for compatible platforms, TokamakDOM for web and TokamakGTK for Linux. Using conditional compilation (`#if`), platform-specific functions are handled (e.g. using Foundation for macOS and JavaScriptKit for the web). 17 | 18 | ## Contributing 19 | If you find any bugs or have any improvement suggestions, please open an issue first. If you get green light, you can open a pull request. 20 | 21 | If you want to work on any open issues, just comment on the issue with your intentions. If you end up not working not working on it anymore, please comment again. 22 | 23 | ## License 24 | Licensed under MIT license. 25 | -------------------------------------------------------------------------------- /SnakeMacApp/Build/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env zsh 2 | 3 | cd "$(dirname "$0")" 4 | 5 | cd .. 6 | rm -r Snake.app 7 | 8 | mkdir Snake.app 9 | mkdir Snake.app/Contents 10 | mkdir Snake.app/Contents/MacOS 11 | mkdir Snake.app/Contents/Resources 12 | 13 | cp Build/Info.plist Snake.app/Contents/Info.plist 14 | 15 | # Build app icon 16 | app_icon_png="Build/AppIcon.png" 17 | output_icon_path="Build/AppIcon.iconset/" 18 | mkdir $output_icon_path 19 | sips -z 16 16 $app_icon_png --out "${output_icon_path}/icon_16x16.png" 20 | sips -z 32 32 $app_icon_png --out "${output_icon_path}/icon_16x16@2x.png" 21 | sips -z 32 32 $app_icon_png --out "${output_icon_path}/icon_32x32.png" 22 | sips -z 64 64 $app_icon_png --out "${output_icon_path}/icon_32x32@2x.png" 23 | sips -z 128 128 $app_icon_png --out "${output_icon_path}/icon_128x128.png" 24 | sips -z 256 256 $app_icon_png --out "${output_icon_path}/icon_128x128@2x.png" 25 | sips -z 256 256 $app_icon_png --out "${output_icon_path}/icon_256x256.png" 26 | sips -z 512 512 $app_icon_png --out "${output_icon_path}/icon_256x256@2x.png" 27 | sips -z 512 512 $app_icon_png --out "${output_icon_path}/icon_512x512.png" 28 | 29 | iconutil -c icns $output_icon_path -o "Snake.app/Contents/Resources/AppIcon.icns" 30 | 31 | rm -r $output_icon_path 32 | 33 | # Build binary 34 | swift build -c release 35 | 36 | # Copy binary 37 | cp "$(swift build -c release --show-bin-path)/SnakeMacApp" Snake.app/Contents/MacOS/Snake 38 | -------------------------------------------------------------------------------- /SnakeSwiftCore/Sources/SnakeSwiftCore/loop.swift: -------------------------------------------------------------------------------- 1 | // 2 | // loop.swift 3 | // 4 | // The game loop logic 5 | // 6 | // Created by Jonas Everaert on 13/03/2022. 7 | // 8 | 9 | import Foundation 10 | #if os(WASI) 11 | import JavaScriptKit 12 | #endif 13 | 14 | #if os(WASI) 15 | /// GameLoop for TokamakUI using Javascript timer 16 | public struct GameLoop { 17 | private var fps: Int 18 | private var timer: JSTimer? 19 | private let callback: () -> () 20 | } 21 | 22 | extension GameLoop { 23 | /// - `callback`: gets executed every frame 24 | public init(fps: Int = 2, callback: @escaping () -> ()) { 25 | self.fps = fps 26 | let milisecDelay = (1.0 / Double(self.fps)) * 1000.0 27 | self.callback = callback 28 | self.timer = JSTimer(millisecondsDelay: milisecDelay, isRepeating: true, callback: callback) 29 | } 30 | } 31 | 32 | extension GameLoop { 33 | /// Set a new fps 34 | public mutating func newFps(fps: Int) { 35 | self.fps = fps 36 | let milisecDelay = (1.0 / Double(self.fps)) * 1000.0 37 | self.timer = JSTimer(millisecondsDelay: milisecDelay, isRepeating: true, callback: self.callback) 38 | } 39 | } 40 | #else 41 | /// GameLoop for SwiftUI using the Foundation Timer 42 | public struct GameLoop { 43 | private var fps: Int 44 | private var timer: Timer 45 | private let callback: (Timer) -> () 46 | } 47 | 48 | extension GameLoop { 49 | public init(fps: Int = 2, callback: @escaping (Timer) -> ()) { 50 | self.fps = fps 51 | let secDelay = (1.0 / Double(self.fps)) 52 | self.callback = callback 53 | self.timer = Timer.scheduledTimer(withTimeInterval: secDelay, repeats: true, block: callback) 54 | } 55 | } 56 | 57 | extension GameLoop { 58 | public mutating func newFps(fps: Int) { 59 | self.fps = fps 60 | let secDelay = (1.0 / Double(self.fps)) 61 | self.timer.invalidate() // Stop current timr 62 | self.timer = Timer.scheduledTimer(withTimeInterval: secDelay, repeats: true, block: self.callback) 63 | } 64 | 65 | public func stop() { 66 | self.timer.invalidate() 67 | } 68 | } 69 | #endif 70 | 71 | extension GameLoop { 72 | public func getFps() -> Int { 73 | self.fps 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /SnakeSwiftCore/Sources/SnakeSwiftCore/run.swift: -------------------------------------------------------------------------------- 1 | // 2 | // run.swift 3 | // 4 | // Start a game 5 | // 6 | // Created by Jonas Everaert on 14/03/2022. 7 | // 8 | 9 | import Foundation 10 | // TokamakShim imorts TokamakDOM for WASI, GTK for linux and SwiftUI for platforms that can use SwiftUI 11 | import TokamakShim 12 | 13 | public func startGame( 14 | gc: inout GraphicsContext, 15 | cSize: CGSize, 16 | onPoint: @escaping (Int) -> (), 17 | onGameOver: @escaping (Int) -> (), 18 | /// Update view on draw 19 | onDraw: @escaping () -> () = {} 20 | ) { 21 | print("Start") 22 | // Setup code 23 | // The renderer and GameLooop should only be initialized once 24 | if renderer == nil { 25 | renderer = GraphicsRenderer( 26 | context: &gc, 27 | canvasSize: cSize, 28 | increaseDifficultyCallback: { fps, points in 29 | let currentFps = loop?.getFps() ?? 2 30 | var maxSpeed: Int = 9 31 | if points > 10 { 32 | if points > 500 { 33 | maxSpeed = 100 34 | } else if points > 200 { 35 | maxSpeed = 50 36 | } else if points > 100 { 37 | maxSpeed = 30 38 | } else if points > 50 { 39 | maxSpeed = 20 40 | } else if points > 20 { 41 | maxSpeed = 13 42 | } else if points > 15 { 43 | maxSpeed = 10 44 | } 45 | } 46 | loop?.newFps(fps: min(currentFps + fps, maxSpeed)) 47 | }, 48 | gameOverCallback: { finalScore in 49 | #if os(WASI) 50 | loop = nil 51 | #else 52 | loop?.stop() 53 | #endif 54 | onGameOver(finalScore) 55 | }, 56 | scoreCallback: { newScore in 57 | onPoint(newScore) 58 | }, 59 | drawCallback: onDraw 60 | ) 61 | 62 | // handle keyboard input 63 | KeyboardHandler.listen(renderer: renderer!) 64 | 65 | // Game loop 66 | startGameLoop(renderer: renderer!) 67 | } else { 68 | renderer!.set(graphicsContext: &gc) 69 | } 70 | 71 | // Set new size (when resized) 72 | renderer!.onResize(newSize: cSize) 73 | 74 | // Redraw on resize as well 75 | renderer!.drawFrame() 76 | } 77 | 78 | public func startGameLoop(renderer: GraphicsRenderer) { 79 | // Start with 2 frames per second (snake moves twice per second) 80 | #if os(WASI) 81 | loop = GameLoop(fps: 2, callback: { 82 | renderer.handleNextFrame() 83 | }) 84 | #else 85 | loop = GameLoop(fps: 2, callback: { _ in 86 | renderer.handleNextFrame() 87 | }) 88 | #endif 89 | } 90 | -------------------------------------------------------------------------------- /SnakeMacApp/Sources/SnakeMacApp/Content.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Foundation 3 | import SnakeSwiftCore 4 | 5 | struct ContentView: View { 6 | @State var highScore: Int 7 | @State var currentScore: Int = 0 8 | @State var gameOver = false 9 | 10 | // hacky solution, should be replaced when I find a solution to the problem where 11 | // the canvas does not get redrawn until one of the UI elements gets updated. 12 | @State var update = false 13 | 14 | init() { 15 | let defaults = UserDefaults.standard 16 | self.highScore = defaults.integer(forKey: "highscore") 17 | // Disable keyboard funk sound 18 | NSEvent.addLocalMonitorForEvents(matching: .keyDown) { _ in return nil } 19 | } 20 | 21 | var body: some View { 22 | HStack { 23 | Canvas( 24 | renderer: { gc, cSize in 25 | startGame( 26 | gc: &gc, 27 | cSize: cSize, 28 | onPoint: { newScore in 29 | self.currentScore = newScore 30 | }, onGameOver: { finalScore in 31 | self.currentScore = finalScore 32 | self.gameOver = true 33 | if finalScore > self.highScore { 34 | let defaults = UserDefaults.standard 35 | defaults.set(finalScore, forKey: "highscore") 36 | self.highScore = finalScore 37 | } 38 | // also part of the hacky solution 39 | }, onDraw: { 40 | self.update = !update 41 | } 42 | ) 43 | } 44 | ).frame(width: 720, height: 720, alignment: .center) 45 | VStack { 46 | ZStack { 47 | Text("Highscore: \(self.highScore)") 48 | .fixedSize(horizontal: true, vertical: true) 49 | // Part of the hacky solution 50 | Text("\(self.update)" as String) 51 | .hidden() 52 | } 53 | if self.gameOver { 54 | Text("Game Over") 55 | .fixedSize(horizontal: true, vertical: true) 56 | Text("Your score is: \(self.currentScore)") 57 | .fixedSize(horizontal: true, vertical: true) 58 | Button("Play again") { 59 | self.currentScore = 0 60 | self.gameOver = false 61 | renderer!.resetGame() 62 | startGameLoop(renderer: renderer!) 63 | } 64 | } else { 65 | Text("Score: \(self.currentScore)") 66 | .fixedSize(horizontal: true, vertical: true) 67 | } 68 | }.padding() 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /SnakeSwiftCore/Sources/SnakeSwiftCore/keyboard.swift: -------------------------------------------------------------------------------- 1 | // 2 | // keyboard.swift 3 | // 4 | // Handle keyboard input (WASD and arrow keys) 5 | // 6 | // Created by Jonas Everaert on 13/03/2022. 7 | // 8 | 9 | #if os(WASI) 10 | import JavaScriptKit 11 | #elseif os(macOS) 12 | import CoreGraphics 13 | #endif 14 | 15 | public class KeyboardHandler { 16 | let renderer: GraphicsRenderer 17 | 18 | public init(_ renderer: GraphicsRenderer) { 19 | self.renderer = renderer 20 | } 21 | } 22 | 23 | #if os(WASI) 24 | extension KeyboardHandler { 25 | public func handleKeyIn(key: JSValue) { 26 | if key == "ArrowUp" || key == "KeyW" { 27 | self.move(GameEvent.MoveUp) 28 | } else if key == "ArrowLeft" || key == "KeyA" { 29 | self.move(GameEvent.MoveLeft) 30 | } else if key == "ArrowDown" || key == "KeyS" { 31 | self.move(GameEvent.MoveDown) 32 | } else if key == "ArrowRight" || key == "KeyD" { 33 | self.move(GameEvent.MoveRight) 34 | } 35 | } 36 | 37 | public func move(_ dir: GameEvent) { 38 | self.renderer.events.append(dir) 39 | } 40 | 41 | public static func listen(renderer: GraphicsRenderer) { 42 | let document = JSObject.global.document.object! 43 | let keyboardHandler = KeyboardHandler(renderer) 44 | 45 | let eventListener = JSClosure { key in 46 | (key as [JSValue]).forEach { val in 47 | if let _val = val.object?.code { 48 | keyboardHandler.handleKeyIn(key: _val) 49 | } 50 | } 51 | return JSValue.undefined 52 | } 53 | let _ = document.addEventListener!("keydown", JSValue.object(eventListener)) 54 | } 55 | } 56 | #elseif os(macOS) 57 | extension KeyboardHandler { 58 | public func handleKeyIn() { 59 | if CGKeyCode.kVK_UpArrow.isPressed || CGKeyCode.kVK_ANSI_W.isPressed { 60 | self.renderer.events.append(GameEvent.MoveUp) 61 | } else if CGKeyCode.kVK_DownArrow.isPressed || CGKeyCode.kVK_ANSI_S.isPressed { 62 | self.renderer.events.append(GameEvent.MoveDown) 63 | } else if CGKeyCode.kVK_LeftArrow.isPressed || CGKeyCode.kVK_ANSI_A.isPressed { 64 | self.renderer.events.append(GameEvent.MoveLeft) 65 | } else if CGKeyCode.kVK_RightArrow.isPressed || CGKeyCode.kVK_ANSI_D.isPressed { 66 | self.renderer.events.append(GameEvent.MoveRight) 67 | } 68 | } 69 | 70 | public static func listen(renderer: GraphicsRenderer) { 71 | let keyboardHandler = KeyboardHandler(renderer) 72 | 73 | let sem = DispatchSemaphore(value: 0) 74 | let queue = DispatchQueue(label: "key_polling", attributes: .concurrent) 75 | queue.async { 76 | while true { 77 | keyboardHandler.handleKeyIn() 78 | 79 | let _ = sem.wait(timeout: .now() + .milliseconds(50)) 80 | } 81 | } 82 | } 83 | } 84 | 85 | extension CGKeyCode { 86 | // All keycodes: https://gist.github.com/chipjarred/cbb324c797aec865918a8045c4b51d14 87 | // Credits to: https://stackoverflow.com/a/68206622/14874405 88 | public static let kVK_LeftArrow : CGKeyCode = 0x7B 89 | public static let kVK_RightArrow : CGKeyCode = 0x7C 90 | public static let kVK_DownArrow : CGKeyCode = 0x7D 91 | public static let kVK_UpArrow : CGKeyCode = 0x7E 92 | public static let kVK_ANSI_W : CGKeyCode = 0x0D // AZERTY: Z 93 | public static let kVK_ANSI_A : CGKeyCode = 0x00 // AZERTY: Q 94 | public static let kVK_ANSI_S : CGKeyCode = 0x01 95 | public static let kVK_ANSI_D : CGKeyCode = 0x02 96 | 97 | public var isPressed: Bool { 98 | CGEventSource.keyState(.combinedSessionState, key: self) 99 | } 100 | } 101 | #endif 102 | -------------------------------------------------------------------------------- /SnakeSwiftCore/Sources/SnakeSwiftCore/renderer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // renderer.swift 3 | // 4 | // The renderer that renders to the Canvas 5 | // 6 | // Created by Jonas Everaert on 13/03/2022. 7 | // 8 | 9 | import Foundation 10 | import TokamakShim 11 | 12 | /// Renders the content to a `Canvas`. In this caes the `html canvas` 13 | public class GraphicsRenderer { 14 | private var gc: GraphicsContext 15 | /// Canvas size 16 | private var size: CGSize 17 | private var cellSize: CGFloat 18 | private var board: BoardState 19 | /// The events that have to be executed in this frame 20 | public var events: [GameEvent] 21 | /// The current direction the snake is moving in (`MoveUp`, `MoveLeft`, `MoveDown`, `MoveDown`) 22 | private var moveDirection: GameEvent 23 | /// Increases the difficulty by the given parameter 24 | private let increaseDifficulty: (_ fps: Int, _ points: Int) -> () 25 | /// Called on game over witth the player's score 26 | private let gameOver: (Int) -> () 27 | /// Called when the player makes a point 28 | private let newScore: (Int) -> () 29 | /// Called when drawing to canvas has finished, only applicable for non-WASI targets 30 | private let drawn: () -> () 31 | 32 | public init( 33 | context gc: inout GraphicsContext, 34 | canvasSize size: CGSize, 35 | boardSize: Int = 50, 36 | increaseDifficultyCallback: @escaping (Int, Int) -> (), 37 | gameOverCallback: @escaping (Int) -> (), 38 | scoreCallback: @escaping (Int) -> (), 39 | /// Called when drawn to canvas has finished (needed on macOS for now) 40 | drawCallback: @escaping () -> () = {} 41 | ) { 42 | self.gc = gc 43 | self.size = size 44 | self.board = BoardState( 45 | size: (rows: boardSize, cols: boardSize), 46 | food: Coordinate.randomCoordinate( 47 | // somewhere in front of the player, close to the player 48 | minX: (boardSize/4), maxX: (boardSize/2)-2, 49 | minY: (boardSize/2)-(boardSize/4), maxY: (boardSize/2)+(boardSize/4) 50 | ), 51 | // 3 long player 52 | player: [ 53 | Coordinate(x: boardSize/2 - 1, y: boardSize/2), 54 | Coordinate(x: boardSize/2, y: boardSize/2), 55 | Coordinate(x: boardSize/2 + 1, y: boardSize/2) 56 | ]) 57 | self.cellSize = min(self.size.width, self.size.height) / CGFloat(self.board.size.cols) 58 | self.events = [] 59 | self.moveDirection = GameEvent.MoveLeft 60 | self.increaseDifficulty = increaseDifficultyCallback 61 | self.gameOver = gameOverCallback 62 | self.newScore = scoreCallback 63 | self.drawn = drawCallback 64 | } 65 | } 66 | 67 | 68 | extension GraphicsRenderer { 69 | public func set(graphicsContext gc: inout GraphicsContext) { 70 | self.gc = gc 71 | } 72 | 73 | public func resetGame() { 74 | self.board = BoardState( 75 | size: (rows: self.board.size.rows, cols: self.board.size.cols), 76 | food: Coordinate.randomCoordinate( 77 | // somewhere in front of the player, close to the player 78 | minX: (self.board.size.cols/4), maxX: (self.board.size.cols/2)-2, 79 | minY: (self.board.size.rows/2)-(self.board.size.rows/4), maxY: (self.board.size.rows/2)+(self.board.size.rows/4) 80 | ), 81 | // 3 long player 82 | player: [ 83 | Coordinate(x: self.board.size.cols/2 - 1, y: self.board.size.rows/2), 84 | Coordinate(x: self.board.size.cols/2, y: self.board.size.rows/2), 85 | Coordinate(x: self.board.size.cols/2 + 1, y: self.board.size.rows/2) 86 | ]) 87 | self.events = [] 88 | self.moveDirection = GameEvent.MoveLeft 89 | } 90 | } 91 | 92 | extension GraphicsRenderer { 93 | /// Call when canvas is resized 94 | public func onResize(newSize: CGSize) { 95 | self.size = newSize 96 | self.cellSize = min(self.size.width, self.size.height) / CGFloat(self.board.size.cols) 97 | } 98 | 99 | /// Move player, eat food, calculate new food position, draw scene 100 | public func handleNextFrame() { 101 | // Handle events 102 | var addTailThisFrame = false 103 | var newMoveDir = self.moveDirection 104 | while !events.isEmpty { 105 | let event = events.remove(at: 0) 106 | if event.isMoveAction { 107 | // Don't allow 180° turns 108 | if !self.moveDirection.isOpositeOf(event) { 109 | newMoveDir = event 110 | } 111 | } else if event == .AddTail { 112 | addTailThisFrame = true 113 | } 114 | } 115 | self.moveDirection = newMoveDir 116 | 117 | // Move character 118 | switch self.moveDirection { 119 | case .MoveLeft: 120 | // Insert new head at new position 121 | self.board.player.insert( 122 | Coordinate( 123 | x: self.board.player[0].x - 1, 124 | y: self.board.player[0].y 125 | ), at: 0 126 | ) 127 | // Remove last tail 128 | if addTailThisFrame == false { 129 | let _ = self.board.player.popLast() 130 | } 131 | case .MoveRight: 132 | // Insert new head at new position 133 | self.board.player.insert( 134 | Coordinate( 135 | x: self.board.player[0].x + 1, 136 | y: self.board.player[0].y 137 | ), at: 0 138 | ) 139 | // Remove last tail 140 | if addTailThisFrame == false { 141 | let _ = self.board.player.popLast() 142 | } 143 | case .MoveUp: 144 | // Insert new head at new position 145 | self.board.player.insert( 146 | Coordinate( 147 | x: self.board.player[0].x, 148 | y: self.board.player[0].y - 1 149 | ), at: 0 150 | ) 151 | // Remove last tail 152 | if addTailThisFrame == false { 153 | let _ = self.board.player.popLast() 154 | } 155 | case .MoveDown: 156 | // Insert new head at new position 157 | self.board.player.insert( 158 | Coordinate( 159 | x: self.board.player[0].x, 160 | y: self.board.player[0].y + 1 161 | ), at: 0 162 | ) 163 | // Remove last tail 164 | if addTailThisFrame == false { 165 | let _ = self.board.player.popLast() 166 | } 167 | default: 168 | print("Unexpected:", self.moveDirection) 169 | } 170 | 171 | // Handle eat 172 | if self.board.player[0] == self.board.food { 173 | // New food location 174 | var randLoc = Coordinate.randomCoordinate( 175 | maxX: self.board.size.rows, maxY: self.board.size.cols) 176 | 177 | var insidePlayer = true 178 | while insidePlayer { 179 | insidePlayer = false 180 | self.board.player.forEach { pCo in 181 | if pCo == randLoc { 182 | insidePlayer = true 183 | randLoc = Coordinate.randomCoordinate( 184 | maxX: self.board.size.rows, maxY: self.board.size.cols) 185 | } 186 | } 187 | } 188 | 189 | self.board.food = randLoc 190 | 191 | // Increase player score 192 | self.board.score += 1 193 | self.newScore(self.board.score) 194 | 195 | // Increase player length 196 | self.events.append(.AddTail) 197 | 198 | // Increase difficulty 199 | self.increaseDifficulty(1, self.board.score) 200 | } 201 | 202 | // Handle edge of screen 203 | self.board.player.enumerated().forEach { idx, co in 204 | if co.x < 0 { 205 | self.board.player[idx].x = self.board.size.cols - 1 206 | } else if co.x > self.board.size.cols - 1 { 207 | self.board.player[idx].x = 0 208 | } 209 | 210 | if co.y < 0 { 211 | self.board.player[idx].y = self.board.size.rows - 1 212 | } else if co.y > self.board.size.rows - 1 { 213 | self.board.player[idx].y = 0 214 | } 215 | } 216 | 217 | // Handle snake bite itself 218 | if self.board.player[1...self.board.player.count-1] 219 | .contains(self.board.player[0]) 220 | { 221 | self.gameOver(self.board.score) 222 | } 223 | 224 | // Draw frame again 225 | self.drawFrame() 226 | } 227 | 228 | /// Draws the current state of the game 229 | public func drawFrame() { 230 | // bg 231 | self.gc.fill( 232 | Path(CGRect(x: 0, y: 0, width: self.size.width, height: self.size.height)), 233 | with: GraphicsContext.Shading.color(.secondary) 234 | ) 235 | // Draw food item 236 | self.gc.fill(self.foodPath(), with: GraphicsContext.Shading.color(.red)) 237 | // Draw player 238 | self.board.player.forEach { co in 239 | self.gc.fill( 240 | self.rectPath(at: (x: co.x, y: co.y)), 241 | with: GraphicsContext.Shading.color(.green) 242 | ) 243 | } 244 | #if !os(WASI) 245 | self.drawn() 246 | #endif 247 | } 248 | 249 | /// `Path` for a rectangle 250 | public func rectPath(at coordinate: (x: Int, y: Int)) -> Path { 251 | return Path(CGRect( 252 | x: CGFloat(coordinate.x) * self.cellSize, 253 | y: CGFloat(coordinate.y) * self.cellSize, 254 | width: self.cellSize, 255 | height: self.cellSize)) 256 | } 257 | 258 | /// Returns the path of a food item 259 | public func foodPath() -> Path { 260 | return rectPath(at: (x: self.board.food.x, y: self.board.food.y)) 261 | } 262 | } 263 | -------------------------------------------------------------------------------- /WebApp/Sources/WebApp/main.swift: -------------------------------------------------------------------------------- 1 | // 2 | // main.swift 3 | // 4 | // The main Tokamak App. Contains all the UI elements (only for the web). 5 | // 6 | 7 | import TokamakDOM 8 | import Foundation 9 | import SnakeSwiftCore 10 | import JavaScriptKit 11 | 12 | struct TokamakApp: App { 13 | var body: some Scene { 14 | WindowGroup("Snake Game") { 15 | ContentView() 16 | } 17 | } 18 | } 19 | 20 | struct ContentView: View { 21 | @State var highScore: Int = 0 22 | @State var currentScore: Int = 0 23 | @State var gameOver = false 24 | 25 | /// Size of top bar in `HTML` 26 | let topBarSize = CGSize(width: 74, height: 55) 27 | let sideBarSize = CGSize(width: 298, height: 68) 28 | 29 | let mobile$controllerSize = CGSize(width: 895, height: 246) 30 | let mobile$topBarSize = CGSize(width: 0, height: 55 + 68) 31 | 32 | @State var canvasSize: CGFloat = 0 33 | /// sidebar width after calculating canvas size 34 | @State var newSideBarWidth: CGFloat = 0 35 | 36 | @State var isMobile = false 37 | 38 | @State var mobile$isMovingUp: Bool = false 39 | @State var mobile$isMovingDown: Bool = false 40 | @State var mobile$isMovingLeft: Bool = false 41 | @State var mobile$isMovingRight: Bool = false 42 | 43 | @State var mobile$keyboardHandler: KeyboardHandler? 44 | 45 | var body: some View { 46 | GeometryReader { proxy in 47 | VStack { 48 | Text("Snake") 49 | .font(.system(size: self.isMobile ? 20 : 30)) 50 | .padding() 51 | if self.isMobile { 52 | SideBarView(currentScore: self.$currentScore, highScore: self.$highScore, gameOver: self.$gameOver, width: self.$newSideBarWidth) 53 | } 54 | 55 | HStack { 56 | HTML("div", ["id": "CanvasContainer", "style": "display: flex; justify-content: center; width: 100%;"]) { 57 | SizedCanvas( 58 | renderer: {gc, cSize in 59 | startGame( 60 | gc: &gc, 61 | cSize: cSize, 62 | onPoint: { newScore in 63 | self.currentScore = newScore 64 | }, onGameOver: { finalScore in 65 | self.currentScore = finalScore 66 | self.gameOver = true 67 | if finalScore > self.highScore { 68 | LocalStorage.standard.store(key: "highScore", value: finalScore as Int?) 69 | self.highScore = finalScore 70 | } 71 | } 72 | ) 73 | }, 74 | size: CGSize(width: self.canvasSize, height: self.canvasSize) 75 | ) 76 | } 77 | 78 | if !self.isMobile { 79 | SideBarView(currentScore: self.$currentScore, highScore: self.$highScore, gameOver: self.$gameOver, width: self.$newSideBarWidth) 80 | } 81 | } 82 | 83 | if self.isMobile { 84 | // Controller 85 | HStack { 86 | Spacer() 87 | ZStack { 88 | VStack { 89 | Spacer() 90 | ControllerButtonView(.MoveUp, pressed: self.$mobile$isMovingUp, setDirection: self.setDirection(_:)) 91 | Spacer() 92 | ControllerButtonView(.MoveDown, pressed: self.$mobile$isMovingDown, setDirection: self.setDirection(_:)) 93 | Spacer() 94 | } 95 | HStack { 96 | Spacer() 97 | ControllerButtonView(.MoveLeft, pressed: self.$mobile$isMovingLeft, setDirection: self.setDirection(_:)) 98 | Spacer() 99 | ControllerButtonView(.MoveRight, pressed: self.$mobile$isMovingRight, setDirection: self.setDirection(_:)) 100 | Spacer() 101 | } 102 | } 103 | Spacer() 104 | } 105 | } 106 | } 107 | .onAppear { 108 | self.highScore = LocalStorage.standard.read(key: "highScore") ?? 0 109 | 110 | let screenSize = proxy.size 111 | if !self.isMobile { 112 | let sWidth = screenSize.width - self.sideBarSize.width 113 | let sHeight = screenSize.height - self.topBarSize.height - 50 /*Extra bottom padding*/ 114 | self.canvasSize = min( 115 | sWidth < 100 116 | ? screenSize.width < 100 ? 100 117 | : screenSize.width - 40 : sWidth, 118 | sHeight < 0 119 | ? screenSize.height < 100 ? 9999 : screenSize.height 120 | : sHeight ) 121 | self.newSideBarWidth = max(screenSize.width - self.canvasSize - (screenSize.width / 3) /*Extra padding for canvas (so it isn't against the edge*/, self.sideBarSize.width /*min width*/) 122 | } else { 123 | let maxHeight = screenSize.height - self.mobile$topBarSize.height - self.mobile$controllerSize.height 124 | self.canvasSize = min(screenSize.width - 40 /*padding*/, maxHeight < 100 ? 999999 : maxHeight) 125 | self.newSideBarWidth = screenSize.width 126 | } 127 | 128 | // This has to be in onAppear, otherwise it does not work 129 | // TODO: init once 130 | let window = JSObject.global.window.object! 131 | let navigator = window.navigator.object! 132 | let userAgent = navigator.userAgent.jsValue().string! 133 | let mobileRegex = try! NSRegularExpression(pattern: "Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|mobi") 134 | // Should we use the more thorough version? https://stackoverflow.com/a/3540295/14874405 135 | if mobileRegex.firstMatch(in: userAgent, options: [], range: NSRange.init(location: 0, length: userAgent.count)) != nil { 136 | self.isMobile = true 137 | } 138 | } 139 | } 140 | } 141 | 142 | private func setDirection(_ dir: GameEvent) { 143 | if self.mobile$keyboardHandler == nil { 144 | self.mobile$keyboardHandler = KeyboardHandler(renderer!) 145 | } 146 | 147 | self.mobile$isMovingUp = false 148 | self.mobile$isMovingDown = false 149 | self.mobile$isMovingLeft = false 150 | self.mobile$isMovingRight = false 151 | 152 | switch dir { 153 | case .MoveUp: 154 | self.mobile$isMovingUp = true 155 | case .MoveDown: 156 | self.mobile$isMovingDown = true 157 | case .MoveLeft: 158 | self.mobile$isMovingLeft = true 159 | case .MoveRight: 160 | self.mobile$isMovingRight = true 161 | default: 162 | print("Unexpected: \(dir)") 163 | } 164 | 165 | self.mobile$keyboardHandler!.move(dir) 166 | } 167 | } 168 | 169 | fileprivate struct ControllerButtonView: View { 170 | private let idleSrc: String 171 | private let pressedSrc: String 172 | @Binding private var pressed: Bool 173 | private let setDirection: (GameEvent) -> () 174 | private let dir: GameEvent 175 | private let name: String 176 | 177 | init(_ type: GameEvent, pressed: Binding