├── .gitignore ├── LICENSE.md ├── README.md ├── Screenshot.png ├── Swune.xcodeproj ├── project.pbxproj └── xcshareddata │ └── xcschemes │ └── Swune.xcscheme └── Swune ├── AppDelegate.swift ├── Assets.xcassets ├── AccentColor.colorset │ └── Contents.json ├── AppIcon.appiconset │ └── Contents.json └── Contents.json ├── AvatarView.swift ├── Base.lproj ├── LaunchScreen.storyboard └── Main.storyboard ├── Data ├── Buildings.json ├── Level1.json ├── Particles.json └── Units.json ├── Engine ├── Angle.swift ├── Animation.swift ├── Assets.swift ├── Bounds.swift ├── Building.swift ├── Entity.swift ├── Game.swift ├── Level.swift ├── Particle.swift ├── Pathfinder.swift ├── Projectile.swift ├── Tilemap.swift ├── Unit.swift └── World.swift ├── GameViewController.swift ├── Graphics ├── construction-yard.png ├── crater.png ├── explosion1.png ├── explosion2.png ├── explosion3.png ├── explosion4.png ├── explosion5.png ├── explosion6.png ├── factory.png ├── harvester-e-blue.png ├── harvester-e-red.png ├── harvester-n-blue.png ├── harvester-n-red.png ├── harvester-ne-blue.png ├── harvester-ne-red.png ├── harvester-nw-blue.png ├── harvester-nw-red.png ├── harvester-s-blue.png ├── harvester-s-red.png ├── harvester-se-blue.png ├── harvester-se-red.png ├── harvester-sw-blue.png ├── harvester-sw-red.png ├── harvester-w-blue.png ├── harvester-w-red.png ├── heavy-spice.png ├── pause.png ├── refinery-active1-blue.png ├── refinery-active1-red.png ├── refinery-active2-blue.png ├── refinery-active2-red.png ├── refinery-active3-blue.png ├── refinery-active3-red.png ├── refinery-active4-blue.png ├── refinery-active4-red.png ├── refinery-active5-blue.png ├── refinery-active5-red.png ├── refinery-active6-blue.png ├── refinery-active6-red.png ├── refinery.png ├── refinery1.png ├── refinery2.png ├── refinery3.png ├── refinery4.png ├── refinery5.png ├── refinery6.png ├── reticle.png ├── sand.png ├── silo.png ├── slab.png ├── small-explosion1.png ├── small-explosion2.png ├── small-explosion3.png ├── small-explosion4.png ├── small-explosion5.png ├── small-explosion6.png ├── smoke1.png ├── smoke2.png ├── smoke3.png ├── smoke4.png ├── smoke5.png ├── smoke6.png ├── smoke7.png ├── spice.png ├── stone.png ├── target.png ├── title.png ├── trike-e-blue.png ├── trike-e-red.png ├── trike-n-blue.png ├── trike-n-red.png ├── trike-ne-blue.png ├── trike-ne-red.png ├── trike-nw-blue.png ├── trike-nw-red.png ├── trike-s-blue.png ├── trike-s-red.png ├── trike-se-blue.png ├── trike-se-red.png ├── trike-sw-blue.png ├── trike-sw-red.png ├── trike-w-blue.png └── trike-w-red.png ├── Info.plist ├── SceneDelegate.swift ├── TitleViewController.swift ├── UIImage+Swune.swift └── UILabel+Swune.swift /.gitignore: -------------------------------------------------------------------------------- 1 | /Assets 2 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Nick Lockwood 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Swift 5](https://img.shields.io/badge/swift-5-red.svg?style=flat)](https://developer.apple.com/swift) 2 | [![License](https://img.shields.io/badge/license-MIT-lightgrey.svg)](https://opensource.org/licenses/MIT) 3 | [![Twitter](https://img.shields.io/badge/twitter-@nicklockwood-blue.svg)](http://twitter.com/nicklockwood) 4 | 5 | # Swune 6 | 7 | Swune is a partial reimplementation of the [Dune II](https://en.wikipedia.org/wiki/Dune_II) RTS game using Swift and UIKit. 8 | 9 | ![Screenshot](Screenshot.png) 10 | 11 | ## How to Install 12 | 13 | To play Swune, just download the Swune Xcode project and run it. It has no dependencies 14 | 15 | Swune is designed for iOS, but also runs on macOS in the MacCatalyst environment. 16 | 17 | ## How to Play 18 | 19 | Swune is designed for touch controls but can be played comfortably with a mouse. 20 | 21 | The game is currently just a single level demo. You begin at the top of the map with a Construction Yard and a handful of units. 22 | 23 | The aim of the mission is to destroy all enemy buildings and gather 1100 units of Spice. To do this you will need to: 24 | 25 | * Build a Spice Refinery (select the Construction Yard and then select the avatar in the top-right for build options) 26 | * Build an army of units to overwhelm the enemy (being careful to retain at least 1100 credits in the bank) 27 | * Locate the enemy base and destroy all buildings (destroying enemy units is optional) 28 | 29 | ## How it Works 30 | 31 | The Swune codebase is loosely based on my earlier [Swiftenstein](https://github.com/nicklockwood/Swiftenstein) project, and the spinoff [Retro Rampage](https://github.com/nicklockwood/RetroRampage) game development tutorial. It shares some code with the latter, such as the pathfinding routines. 32 | 33 | While it may seem like an FPS and an RTS have little in common (besides having both enjoyed a heyday in the mid-late 1990s) the engines are actually very similar due to both sharing a tile-based 2D map and sprites. 34 | 35 | In short, the game works by using a timer to repeatedly update the game logic and then redraw the screen. Drawing is done entirely using UIKit, which is more than powerful enough to simulate a 16-bit era 2D game. 36 | 37 | ## Credits 38 | 39 | Swune is entirely the work of Nick Lockwood, including all graphics and code. The game mechanics are inspired by [UnDUNE II](https://liquidream.itch.io/undune2) by Paul Nicholas, which is itself based on the original Dune II: Battle for Arrakis by Westwood Studios. 40 | -------------------------------------------------------------------------------- /Screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Screenshot.png -------------------------------------------------------------------------------- /Swune.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 55; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 010565BD27BF985D001DAE32 /* Bounds.swift in Sources */ = {isa = PBXBuildFile; fileRef = 010565BC27BF985D001DAE32 /* Bounds.swift */; }; 11 | 010565BF27BFB12D001DAE32 /* Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 010565BE27BFB12D001DAE32 /* Assets.swift */; }; 12 | 010565C127C2F5A1001DAE32 /* Graphics in Resources */ = {isa = PBXBuildFile; fileRef = 010565C027C2F5A1001DAE32 /* Graphics */; }; 13 | 010565C327C2F5F1001DAE32 /* UIImage+Swune.swift in Sources */ = {isa = PBXBuildFile; fileRef = 010565C227C2F5F1001DAE32 /* UIImage+Swune.swift */; }; 14 | 010EF8C727DE5FBA004FB83C /* TitleViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 010EF8C627DE5FBA004FB83C /* TitleViewController.swift */; }; 15 | 010EF8C927DE740C004FB83C /* UILabel+Swune.swift in Sources */ = {isa = PBXBuildFile; fileRef = 010EF8C827DE740C004FB83C /* UILabel+Swune.swift */; }; 16 | 010EF8CB27DEAD3E004FB83C /* Game.swift in Sources */ = {isa = PBXBuildFile; fileRef = 010EF8CA27DEAD3E004FB83C /* Game.swift */; }; 17 | 017097E027B9932C002ED344 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017097DF27B9932C002ED344 /* AppDelegate.swift */; }; 18 | 017097E227B9932C002ED344 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017097E127B9932C002ED344 /* SceneDelegate.swift */; }; 19 | 017097E427B9932C002ED344 /* GameViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017097E327B9932C002ED344 /* GameViewController.swift */; }; 20 | 017097E727B9932C002ED344 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 017097E527B9932C002ED344 /* Main.storyboard */; }; 21 | 017097E927B9932E002ED344 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 017097E827B9932E002ED344 /* Assets.xcassets */; }; 22 | 017097EC27B9932E002ED344 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 017097EA27B9932E002ED344 /* LaunchScreen.storyboard */; }; 23 | 017097F427B99352002ED344 /* Tilemap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017097F327B99352002ED344 /* Tilemap.swift */; }; 24 | 017097F627B9995C002ED344 /* World.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017097F527B9995C002ED344 /* World.swift */; }; 25 | 017097F927B9A0FF002ED344 /* Pathfinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017097F827B9A0FF002ED344 /* Pathfinder.swift */; }; 26 | 017097FC27B9D317002ED344 /* Unit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017097FB27B9D317002ED344 /* Unit.swift */; }; 27 | 017097FE27B9D508002ED344 /* Building.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017097FD27B9D508002ED344 /* Building.swift */; }; 28 | 0170980027BC30AB002ED344 /* Projectile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017097FF27BC30AB002ED344 /* Projectile.swift */; }; 29 | 0170980427BC613B002ED344 /* Level.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0170980327BC613B002ED344 /* Level.swift */; }; 30 | 0170980627BC622F002ED344 /* Data in Resources */ = {isa = PBXBuildFile; fileRef = 0170980527BC622F002ED344 /* Data */; }; 31 | 0170980827BD876C002ED344 /* Animation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0170980727BD876C002ED344 /* Animation.swift */; }; 32 | 0170980C27BDA714002ED344 /* Angle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0170980B27BDA714002ED344 /* Angle.swift */; }; 33 | 0170980E27BDBD68002ED344 /* Entity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0170980D27BDBD68002ED344 /* Entity.swift */; }; 34 | 0170981027BDD6D3002ED344 /* Particle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0170980F27BDD6D3002ED344 /* Particle.swift */; }; 35 | 0170981227BE879F002ED344 /* AvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0170981127BE879F002ED344 /* AvatarView.swift */; }; 36 | /* End PBXBuildFile section */ 37 | 38 | /* Begin PBXFileReference section */ 39 | 010565BC27BF985D001DAE32 /* Bounds.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bounds.swift; sourceTree = ""; }; 40 | 010565BE27BFB12D001DAE32 /* Assets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Assets.swift; sourceTree = ""; }; 41 | 010565C027C2F5A1001DAE32 /* Graphics */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Graphics; sourceTree = ""; }; 42 | 010565C227C2F5F1001DAE32 /* UIImage+Swune.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Swune.swift"; sourceTree = ""; }; 43 | 010EF8C627DE5FBA004FB83C /* TitleViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitleViewController.swift; sourceTree = ""; }; 44 | 010EF8C827DE740C004FB83C /* UILabel+Swune.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UILabel+Swune.swift"; sourceTree = ""; }; 45 | 010EF8CA27DEAD3E004FB83C /* Game.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Game.swift; sourceTree = ""; }; 46 | 017097DC27B9932C002ED344 /* Swune.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Swune.app; sourceTree = BUILT_PRODUCTS_DIR; }; 47 | 017097DF27B9932C002ED344 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 48 | 017097E127B9932C002ED344 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 49 | 017097E327B9932C002ED344 /* GameViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameViewController.swift; sourceTree = ""; }; 50 | 017097E627B9932C002ED344 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 51 | 017097E827B9932E002ED344 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 52 | 017097EB27B9932E002ED344 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 53 | 017097ED27B9932E002ED344 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 54 | 017097F327B99352002ED344 /* Tilemap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tilemap.swift; sourceTree = ""; }; 55 | 017097F527B9995C002ED344 /* World.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = World.swift; sourceTree = ""; }; 56 | 017097F827B9A0FF002ED344 /* Pathfinder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Pathfinder.swift; sourceTree = ""; }; 57 | 017097FB27B9D317002ED344 /* Unit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Unit.swift; sourceTree = ""; }; 58 | 017097FD27B9D508002ED344 /* Building.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Building.swift; sourceTree = ""; }; 59 | 017097FF27BC30AB002ED344 /* Projectile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Projectile.swift; sourceTree = ""; }; 60 | 0170980327BC613B002ED344 /* Level.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Level.swift; sourceTree = ""; }; 61 | 0170980527BC622F002ED344 /* Data */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Data; sourceTree = ""; }; 62 | 0170980727BD876C002ED344 /* Animation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Animation.swift; sourceTree = ""; }; 63 | 0170980B27BDA714002ED344 /* Angle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Angle.swift; sourceTree = ""; }; 64 | 0170980D27BDBD68002ED344 /* Entity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Entity.swift; sourceTree = ""; }; 65 | 0170980F27BDD6D3002ED344 /* Particle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Particle.swift; sourceTree = ""; }; 66 | 0170981127BE879F002ED344 /* AvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarView.swift; sourceTree = ""; }; 67 | /* End PBXFileReference section */ 68 | 69 | /* Begin PBXFrameworksBuildPhase section */ 70 | 017097D927B9932C002ED344 /* Frameworks */ = { 71 | isa = PBXFrameworksBuildPhase; 72 | buildActionMask = 2147483647; 73 | files = ( 74 | ); 75 | runOnlyForDeploymentPostprocessing = 0; 76 | }; 77 | /* End PBXFrameworksBuildPhase section */ 78 | 79 | /* Begin PBXGroup section */ 80 | 017097D327B9932C002ED344 = { 81 | isa = PBXGroup; 82 | children = ( 83 | 017097DE27B9932C002ED344 /* Swune */, 84 | 017097DD27B9932C002ED344 /* Products */, 85 | ); 86 | sourceTree = ""; 87 | }; 88 | 017097DD27B9932C002ED344 /* Products */ = { 89 | isa = PBXGroup; 90 | children = ( 91 | 017097DC27B9932C002ED344 /* Swune.app */, 92 | ); 93 | name = Products; 94 | sourceTree = ""; 95 | }; 96 | 017097DE27B9932C002ED344 /* Swune */ = { 97 | isa = PBXGroup; 98 | children = ( 99 | 0170980527BC622F002ED344 /* Data */, 100 | 010565C027C2F5A1001DAE32 /* Graphics */, 101 | 017097F727B9A0DE002ED344 /* Engine */, 102 | 017097DF27B9932C002ED344 /* AppDelegate.swift */, 103 | 017097E127B9932C002ED344 /* SceneDelegate.swift */, 104 | 010EF8C627DE5FBA004FB83C /* TitleViewController.swift */, 105 | 017097E327B9932C002ED344 /* GameViewController.swift */, 106 | 010565C227C2F5F1001DAE32 /* UIImage+Swune.swift */, 107 | 010EF8C827DE740C004FB83C /* UILabel+Swune.swift */, 108 | 0170981127BE879F002ED344 /* AvatarView.swift */, 109 | 017097E527B9932C002ED344 /* Main.storyboard */, 110 | 017097E827B9932E002ED344 /* Assets.xcassets */, 111 | 017097EA27B9932E002ED344 /* LaunchScreen.storyboard */, 112 | 017097ED27B9932E002ED344 /* Info.plist */, 113 | ); 114 | path = Swune; 115 | sourceTree = ""; 116 | }; 117 | 017097F727B9A0DE002ED344 /* Engine */ = { 118 | isa = PBXGroup; 119 | children = ( 120 | 010565BE27BFB12D001DAE32 /* Assets.swift */, 121 | 0170980B27BDA714002ED344 /* Angle.swift */, 122 | 010565BC27BF985D001DAE32 /* Bounds.swift */, 123 | 0170980727BD876C002ED344 /* Animation.swift */, 124 | 0170980327BC613B002ED344 /* Level.swift */, 125 | 017097F327B99352002ED344 /* Tilemap.swift */, 126 | 017097F827B9A0FF002ED344 /* Pathfinder.swift */, 127 | 0170980F27BDD6D3002ED344 /* Particle.swift */, 128 | 017097FF27BC30AB002ED344 /* Projectile.swift */, 129 | 017097F527B9995C002ED344 /* World.swift */, 130 | 0170980D27BDBD68002ED344 /* Entity.swift */, 131 | 017097FD27B9D508002ED344 /* Building.swift */, 132 | 017097FB27B9D317002ED344 /* Unit.swift */, 133 | 010EF8CA27DEAD3E004FB83C /* Game.swift */, 134 | ); 135 | path = Engine; 136 | sourceTree = ""; 137 | }; 138 | /* End PBXGroup section */ 139 | 140 | /* Begin PBXNativeTarget section */ 141 | 017097DB27B9932C002ED344 /* Swune */ = { 142 | isa = PBXNativeTarget; 143 | buildConfigurationList = 017097F027B9932E002ED344 /* Build configuration list for PBXNativeTarget "Swune" */; 144 | buildPhases = ( 145 | 017097D827B9932C002ED344 /* Sources */, 146 | 017097D927B9932C002ED344 /* Frameworks */, 147 | 017097DA27B9932C002ED344 /* Resources */, 148 | ); 149 | buildRules = ( 150 | ); 151 | dependencies = ( 152 | ); 153 | name = Swune; 154 | productName = Swune; 155 | productReference = 017097DC27B9932C002ED344 /* Swune.app */; 156 | productType = "com.apple.product-type.application"; 157 | }; 158 | /* End PBXNativeTarget section */ 159 | 160 | /* Begin PBXProject section */ 161 | 017097D427B9932C002ED344 /* Project object */ = { 162 | isa = PBXProject; 163 | attributes = { 164 | BuildIndependentTargetsInParallel = 1; 165 | LastSwiftUpdateCheck = 1320; 166 | LastUpgradeCheck = 1320; 167 | TargetAttributes = { 168 | 017097DB27B9932C002ED344 = { 169 | CreatedOnToolsVersion = 13.2.1; 170 | }; 171 | }; 172 | }; 173 | buildConfigurationList = 017097D727B9932C002ED344 /* Build configuration list for PBXProject "Swune" */; 174 | compatibilityVersion = "Xcode 13.0"; 175 | developmentRegion = en; 176 | hasScannedForEncodings = 0; 177 | knownRegions = ( 178 | en, 179 | Base, 180 | ); 181 | mainGroup = 017097D327B9932C002ED344; 182 | productRefGroup = 017097DD27B9932C002ED344 /* Products */; 183 | projectDirPath = ""; 184 | projectRoot = ""; 185 | targets = ( 186 | 017097DB27B9932C002ED344 /* Swune */, 187 | ); 188 | }; 189 | /* End PBXProject section */ 190 | 191 | /* Begin PBXResourcesBuildPhase section */ 192 | 017097DA27B9932C002ED344 /* Resources */ = { 193 | isa = PBXResourcesBuildPhase; 194 | buildActionMask = 2147483647; 195 | files = ( 196 | 017097EC27B9932E002ED344 /* LaunchScreen.storyboard in Resources */, 197 | 0170980627BC622F002ED344 /* Data in Resources */, 198 | 017097E927B9932E002ED344 /* Assets.xcassets in Resources */, 199 | 017097E727B9932C002ED344 /* Main.storyboard in Resources */, 200 | 010565C127C2F5A1001DAE32 /* Graphics in Resources */, 201 | ); 202 | runOnlyForDeploymentPostprocessing = 0; 203 | }; 204 | /* End PBXResourcesBuildPhase section */ 205 | 206 | /* Begin PBXSourcesBuildPhase section */ 207 | 017097D827B9932C002ED344 /* Sources */ = { 208 | isa = PBXSourcesBuildPhase; 209 | buildActionMask = 2147483647; 210 | files = ( 211 | 0170980827BD876C002ED344 /* Animation.swift in Sources */, 212 | 010EF8C927DE740C004FB83C /* UILabel+Swune.swift in Sources */, 213 | 017097F427B99352002ED344 /* Tilemap.swift in Sources */, 214 | 010565BD27BF985D001DAE32 /* Bounds.swift in Sources */, 215 | 017097E427B9932C002ED344 /* GameViewController.swift in Sources */, 216 | 0170981027BDD6D3002ED344 /* Particle.swift in Sources */, 217 | 017097E027B9932C002ED344 /* AppDelegate.swift in Sources */, 218 | 010565BF27BFB12D001DAE32 /* Assets.swift in Sources */, 219 | 017097FC27B9D317002ED344 /* Unit.swift in Sources */, 220 | 017097E227B9932C002ED344 /* SceneDelegate.swift in Sources */, 221 | 010EF8C727DE5FBA004FB83C /* TitleViewController.swift in Sources */, 222 | 017097F927B9A0FF002ED344 /* Pathfinder.swift in Sources */, 223 | 010565C327C2F5F1001DAE32 /* UIImage+Swune.swift in Sources */, 224 | 0170980C27BDA714002ED344 /* Angle.swift in Sources */, 225 | 0170980027BC30AB002ED344 /* Projectile.swift in Sources */, 226 | 017097F627B9995C002ED344 /* World.swift in Sources */, 227 | 010EF8CB27DEAD3E004FB83C /* Game.swift in Sources */, 228 | 0170980E27BDBD68002ED344 /* Entity.swift in Sources */, 229 | 0170981227BE879F002ED344 /* AvatarView.swift in Sources */, 230 | 0170980427BC613B002ED344 /* Level.swift in Sources */, 231 | 017097FE27B9D508002ED344 /* Building.swift in Sources */, 232 | ); 233 | runOnlyForDeploymentPostprocessing = 0; 234 | }; 235 | /* End PBXSourcesBuildPhase section */ 236 | 237 | /* Begin PBXVariantGroup section */ 238 | 017097E527B9932C002ED344 /* Main.storyboard */ = { 239 | isa = PBXVariantGroup; 240 | children = ( 241 | 017097E627B9932C002ED344 /* Base */, 242 | ); 243 | name = Main.storyboard; 244 | sourceTree = ""; 245 | }; 246 | 017097EA27B9932E002ED344 /* LaunchScreen.storyboard */ = { 247 | isa = PBXVariantGroup; 248 | children = ( 249 | 017097EB27B9932E002ED344 /* Base */, 250 | ); 251 | name = LaunchScreen.storyboard; 252 | sourceTree = ""; 253 | }; 254 | /* End PBXVariantGroup section */ 255 | 256 | /* Begin XCBuildConfiguration section */ 257 | 017097EE27B9932E002ED344 /* Debug */ = { 258 | isa = XCBuildConfiguration; 259 | buildSettings = { 260 | ALWAYS_SEARCH_USER_PATHS = NO; 261 | CLANG_ANALYZER_NONNULL = YES; 262 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 263 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; 264 | CLANG_CXX_LIBRARY = "libc++"; 265 | CLANG_ENABLE_MODULES = YES; 266 | CLANG_ENABLE_OBJC_ARC = YES; 267 | CLANG_ENABLE_OBJC_WEAK = YES; 268 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 269 | CLANG_WARN_BOOL_CONVERSION = YES; 270 | CLANG_WARN_COMMA = YES; 271 | CLANG_WARN_CONSTANT_CONVERSION = YES; 272 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 273 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 274 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 275 | CLANG_WARN_EMPTY_BODY = YES; 276 | CLANG_WARN_ENUM_CONVERSION = YES; 277 | CLANG_WARN_INFINITE_RECURSION = YES; 278 | CLANG_WARN_INT_CONVERSION = YES; 279 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 280 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 281 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 282 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 283 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 284 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 285 | CLANG_WARN_STRICT_PROTOTYPES = YES; 286 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 287 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 288 | CLANG_WARN_UNREACHABLE_CODE = YES; 289 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 290 | COPY_PHASE_STRIP = NO; 291 | DEBUG_INFORMATION_FORMAT = dwarf; 292 | ENABLE_STRICT_OBJC_MSGSEND = YES; 293 | ENABLE_TESTABILITY = YES; 294 | GCC_C_LANGUAGE_STANDARD = gnu11; 295 | GCC_DYNAMIC_NO_PIC = NO; 296 | GCC_NO_COMMON_BLOCKS = YES; 297 | GCC_OPTIMIZATION_LEVEL = 0; 298 | GCC_PREPROCESSOR_DEFINITIONS = ( 299 | "DEBUG=1", 300 | "$(inherited)", 301 | ); 302 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 303 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 304 | GCC_WARN_UNDECLARED_SELECTOR = YES; 305 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 306 | GCC_WARN_UNUSED_FUNCTION = YES; 307 | GCC_WARN_UNUSED_VARIABLE = YES; 308 | IPHONEOS_DEPLOYMENT_TARGET = 14.0; 309 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 310 | MTL_FAST_MATH = YES; 311 | ONLY_ACTIVE_ARCH = YES; 312 | SDKROOT = iphoneos; 313 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 314 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 315 | }; 316 | name = Debug; 317 | }; 318 | 017097EF27B9932E002ED344 /* Release */ = { 319 | isa = XCBuildConfiguration; 320 | buildSettings = { 321 | ALWAYS_SEARCH_USER_PATHS = NO; 322 | CLANG_ANALYZER_NONNULL = YES; 323 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 324 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; 325 | CLANG_CXX_LIBRARY = "libc++"; 326 | CLANG_ENABLE_MODULES = YES; 327 | CLANG_ENABLE_OBJC_ARC = YES; 328 | CLANG_ENABLE_OBJC_WEAK = YES; 329 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 330 | CLANG_WARN_BOOL_CONVERSION = YES; 331 | CLANG_WARN_COMMA = YES; 332 | CLANG_WARN_CONSTANT_CONVERSION = YES; 333 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 334 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 335 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 336 | CLANG_WARN_EMPTY_BODY = YES; 337 | CLANG_WARN_ENUM_CONVERSION = YES; 338 | CLANG_WARN_INFINITE_RECURSION = YES; 339 | CLANG_WARN_INT_CONVERSION = YES; 340 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 341 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 342 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 343 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 344 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 345 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 346 | CLANG_WARN_STRICT_PROTOTYPES = YES; 347 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 348 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 349 | CLANG_WARN_UNREACHABLE_CODE = YES; 350 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 351 | COPY_PHASE_STRIP = NO; 352 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 353 | ENABLE_NS_ASSERTIONS = NO; 354 | ENABLE_STRICT_OBJC_MSGSEND = YES; 355 | GCC_C_LANGUAGE_STANDARD = gnu11; 356 | GCC_NO_COMMON_BLOCKS = YES; 357 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 358 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 359 | GCC_WARN_UNDECLARED_SELECTOR = YES; 360 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 361 | GCC_WARN_UNUSED_FUNCTION = YES; 362 | GCC_WARN_UNUSED_VARIABLE = YES; 363 | IPHONEOS_DEPLOYMENT_TARGET = 14.0; 364 | MTL_ENABLE_DEBUG_INFO = NO; 365 | MTL_FAST_MATH = YES; 366 | SDKROOT = iphoneos; 367 | SWIFT_COMPILATION_MODE = wholemodule; 368 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 369 | VALIDATE_PRODUCT = YES; 370 | }; 371 | name = Release; 372 | }; 373 | 017097F127B9932E002ED344 /* Debug */ = { 374 | isa = XCBuildConfiguration; 375 | buildSettings = { 376 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 377 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 378 | CODE_SIGN_STYLE = Automatic; 379 | CURRENT_PROJECT_VERSION = 1; 380 | DEVELOPMENT_TEAM = 8VQKF583ED; 381 | GENERATE_INFOPLIST_FILE = YES; 382 | INFOPLIST_FILE = Swune/Info.plist; 383 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 384 | INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; 385 | INFOPLIST_KEY_UIMainStoryboardFile = Main; 386 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 387 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 388 | LD_RUNPATH_SEARCH_PATHS = ( 389 | "$(inherited)", 390 | "@executable_path/Frameworks", 391 | ); 392 | MARKETING_VERSION = 1.0; 393 | PRODUCT_BUNDLE_IDENTIFIER = com.charcoaldesign.Swune; 394 | PRODUCT_NAME = "$(TARGET_NAME)"; 395 | SUPPORTS_MACCATALYST = YES; 396 | SWIFT_EMIT_LOC_STRINGS = YES; 397 | SWIFT_VERSION = 5.0; 398 | TARGETED_DEVICE_FAMILY = "1,2"; 399 | }; 400 | name = Debug; 401 | }; 402 | 017097F227B9932E002ED344 /* Release */ = { 403 | isa = XCBuildConfiguration; 404 | buildSettings = { 405 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 406 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 407 | CODE_SIGN_STYLE = Automatic; 408 | CURRENT_PROJECT_VERSION = 1; 409 | DEVELOPMENT_TEAM = 8VQKF583ED; 410 | GENERATE_INFOPLIST_FILE = YES; 411 | INFOPLIST_FILE = Swune/Info.plist; 412 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 413 | INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; 414 | INFOPLIST_KEY_UIMainStoryboardFile = Main; 415 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 416 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 417 | LD_RUNPATH_SEARCH_PATHS = ( 418 | "$(inherited)", 419 | "@executable_path/Frameworks", 420 | ); 421 | MARKETING_VERSION = 1.0; 422 | PRODUCT_BUNDLE_IDENTIFIER = com.charcoaldesign.Swune; 423 | PRODUCT_NAME = "$(TARGET_NAME)"; 424 | SUPPORTS_MACCATALYST = YES; 425 | SWIFT_EMIT_LOC_STRINGS = YES; 426 | SWIFT_VERSION = 5.0; 427 | TARGETED_DEVICE_FAMILY = "1,2"; 428 | }; 429 | name = Release; 430 | }; 431 | /* End XCBuildConfiguration section */ 432 | 433 | /* Begin XCConfigurationList section */ 434 | 017097D727B9932C002ED344 /* Build configuration list for PBXProject "Swune" */ = { 435 | isa = XCConfigurationList; 436 | buildConfigurations = ( 437 | 017097EE27B9932E002ED344 /* Debug */, 438 | 017097EF27B9932E002ED344 /* Release */, 439 | ); 440 | defaultConfigurationIsVisible = 0; 441 | defaultConfigurationName = Release; 442 | }; 443 | 017097F027B9932E002ED344 /* Build configuration list for PBXNativeTarget "Swune" */ = { 444 | isa = XCConfigurationList; 445 | buildConfigurations = ( 446 | 017097F127B9932E002ED344 /* Debug */, 447 | 017097F227B9932E002ED344 /* Release */, 448 | ); 449 | defaultConfigurationIsVisible = 0; 450 | defaultConfigurationName = Release; 451 | }; 452 | /* End XCConfigurationList section */ 453 | }; 454 | rootObject = 017097D427B9932C002ED344 /* Project object */; 455 | } 456 | -------------------------------------------------------------------------------- /Swune.xcodeproj/xcshareddata/xcschemes/Swune.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /Swune/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // Swune 4 | // 5 | // Created by Nick Lockwood on 13/02/2022. 6 | // 7 | 8 | import UIKit 9 | 10 | @main 11 | class AppDelegate: UIResponder, UIApplicationDelegate { 12 | 13 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 14 | // Override point for customization after application launch. 15 | return true 16 | } 17 | 18 | // MARK: UISceneSession Lifecycle 19 | 20 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 21 | // Called when a new scene session is being created. 22 | // Use this method to select a configuration to create the new scene with. 23 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 24 | } 25 | 26 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { 27 | // Called when the user discards a scene session. 28 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. 29 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return. 30 | } 31 | 32 | 33 | } 34 | 35 | -------------------------------------------------------------------------------- /Swune/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Swune/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Swune/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Swune/AvatarView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AvatarView.swift 3 | // Swune 4 | // 5 | // Created by Nick Lockwood on 17/02/2022. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | private let borderWidth: CGFloat = 4 12 | private let barHeight: CGFloat = 8 13 | 14 | class AvatarView: UIButton { 15 | private let avatarView = UIImageView() 16 | private let progressView = UIView() 17 | 18 | var image: UIImage? { 19 | didSet { avatarView.image = image } 20 | } 21 | 22 | var progress: Double = 0 { 23 | didSet { 24 | progressView.frame.size.width = avatarView.frame.width * progress 25 | } 26 | } 27 | 28 | var barColor: UIColor = .green { 29 | didSet { 30 | progressView.backgroundColor = barColor 31 | } 32 | } 33 | 34 | override init(frame: CGRect) { 35 | super.init(frame: frame) 36 | backgroundColor = .black 37 | avatarView.frame = bounds.inset(by: UIEdgeInsets( 38 | top: borderWidth, 39 | left: borderWidth, 40 | bottom: borderWidth * 2 + barHeight, 41 | right: borderWidth 42 | )) 43 | avatarView.backgroundColor = .gray 44 | avatarView.clipsToBounds = true 45 | avatarView.contentMode = .scaleAspectFill 46 | avatarView.layer.magnificationFilter = .nearest 47 | avatarView.autoresizingMask = [.flexibleWidth, .flexibleHeight] 48 | addSubview(avatarView) 49 | progressView.frame = CGRect( 50 | x: avatarView.frame.minX, 51 | y: avatarView.frame.maxY + borderWidth, 52 | width: 0, 53 | height: barHeight 54 | ) 55 | progressView.autoresizingMask = [.flexibleTopMargin, .flexibleWidth] 56 | addSubview(progressView) 57 | showsMenuAsPrimaryAction = true 58 | } 59 | 60 | convenience init() { 61 | self.init(frame: CGRect( 62 | x: 0, 63 | y: 0, 64 | width: 96 + borderWidth * 2, 65 | height: 96 + borderWidth * 3 + barHeight 66 | )) 67 | } 68 | 69 | required init?(coder: NSCoder) { 70 | fatalError("init(coder:) has not been implemented") 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Swune/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Swune/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /Swune/Data/Buildings.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "yard", 4 | "name": "Construction Yard", 5 | "width": 2, 6 | "height": 2, 7 | "health": 5, 8 | "cost": 0, 9 | "buildTime": 0, 10 | "idle": { 11 | "duration": 0, 12 | "framesByAngle": [ 13 | ["construction-yard"], 14 | ] 15 | }, 16 | "constructions": [ 17 | "slab", 18 | "largeSlab", 19 | "refinery", 20 | "factory" 21 | ] 22 | }, 23 | { 24 | "id": "refinery", 25 | "name": "Refinery", 26 | "width": 3, 27 | "height": 2, 28 | "health": 5, 29 | "cost": 600, 30 | "buildTime": 5, 31 | "role": "refinery", 32 | "unit": "harvester", 33 | "spiceCapacity": 1000, 34 | "idle": { 35 | "duration": 2, 36 | "framesByAngle": [[ 37 | "refinery1", 38 | "refinery2", 39 | "refinery3", 40 | "refinery4", 41 | "refinery5", 42 | "refinery6" 43 | ]] 44 | }, 45 | "active": { 46 | "duration": 1, 47 | "framesByAngle": [[ 48 | "refinery-active1", 49 | "refinery-active2", 50 | "refinery-active3", 51 | "refinery-active4", 52 | "refinery-active5", 53 | "refinery-active6" 54 | ]] 55 | } 56 | }, 57 | { 58 | "id": "silo", 59 | "name": "Spice Silo", 60 | "width": 2, 61 | "height": 2, 62 | "health": 3, 63 | "cost": 200, 64 | "buildTime": 3, 65 | "spiceCapacity": 1000, 66 | "idle": { 67 | "duration": 0, 68 | "framesByAngle": [ 69 | ["silo"] 70 | ] 71 | } 72 | }, 73 | { 74 | "id": "factory", 75 | "name": "Factory", 76 | "width": 2, 77 | "height": 2, 78 | "health": 3, 79 | "cost": 400, 80 | "buildTime": 5, 81 | "constructions": [ 82 | "harvester", 83 | "trike" 84 | ], 85 | "idle": { 86 | "duration": 0, 87 | "framesByAngle": [ 88 | ["factory"], 89 | ] 90 | } 91 | }, 92 | { 93 | "id": "slab", 94 | "name": "Concrete Slab", 95 | "width": 1, 96 | "height": 1, 97 | "health": 1, 98 | "cost": 5, 99 | "buildTime": 1, 100 | "role": "slab", 101 | "idle": { 102 | "duration": 0, 103 | "framesByAngle": [ 104 | ["slab"], 105 | ] 106 | } 107 | }, 108 | { 109 | "id": "largeSlab", 110 | "name": "Large Slab", 111 | "width": 2, 112 | "height": 2, 113 | "health": 1, 114 | "cost": 20, 115 | "buildTime": 3, 116 | "role": "slab", 117 | "idle": { 118 | "duration": 0, 119 | "framesByAngle": [ 120 | ["slab"], 121 | ] 122 | } 123 | } 124 | ] 125 | -------------------------------------------------------------------------------- /Swune/Data/Level1.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "tiles": [ 4 | " ", 5 | " ", 6 | " ", 7 | " 11111 4411111 ", 8 | " 1111114444 ", 9 | " 1114444 ", 10 | " 114444 11 ", 11 | " 111 13331 ", 12 | " 1 13331 ", 13 | " 111 1111111 ", 14 | " 2 1111 111 ", 15 | " 11 ", 16 | " 55 2 1111 25 ", 17 | " 255 111 255 ", 18 | " 52 1111 2555 52 ", 19 | " 11 11111 255 ", 20 | " 11113333111 2 ", 21 | " 1113331111111 2 ", 22 | " 1111111111 2 ", 23 | " 1111 ", 24 | " 11111111 ", 25 | " 11111111 ", 26 | " 1111 ", 27 | " 1111 2 ", 28 | " 1111 1111 ", 29 | " 1111 ", 30 | " ", 31 | " 444111 22552 ", 32 | " 144444111 22555522 ", 33 | " 14444411 2 25522 ", 34 | " 441111 2 22 ", 35 | " " 36 | ], 37 | "buildings": [ 38 | { 39 | "type": "yard", 40 | "team": 1, 41 | "x": 9, 42 | "y": 5 43 | }, 44 | { 45 | "type": "refinery", 46 | "team": 2, 47 | "x": 9, 48 | "y": 27 49 | }, 50 | { 51 | "type": "yard", 52 | "team": 2, 53 | "x": 12, 54 | "y": 28 55 | }, 56 | ], 57 | "units": [ 58 | { 59 | "type": "harvester", 60 | "team": 1, 61 | "x": 30, 62 | "y": 16 63 | }, 64 | { 65 | "type": "trike", 66 | "team": 1, 67 | "x": 18, 68 | "y": 9 69 | }, 70 | { 71 | "type": "trike", 72 | "team": 1, 73 | "x": 10, 74 | "y": 7 75 | }, 76 | { 77 | "type": "trike", 78 | "team": 2, 79 | "x": 8, 80 | "y": 29 81 | }, 82 | { 83 | "type": "trike", 84 | "team": 2, 85 | "x": 14, 86 | "y": 28 87 | }, 88 | { 89 | "type": "harvester", 90 | "team": 2, 91 | "x": 15, 92 | "y": 23 93 | } 94 | ], 95 | "goal": { 96 | "spice": 1100, 97 | "destroyAllBuildings": true 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Swune/Data/Particles.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "explosion", 4 | "width": 2, 5 | "height": 2, 6 | "animation": { 7 | "duration": 1, 8 | "framesByAngle": [[ 9 | "explosion1", 10 | "explosion2", 11 | "explosion3", 12 | "explosion4", 13 | "explosion5", 14 | "explosion6" 15 | ]] 16 | } 17 | }, 18 | { 19 | "id": "smallExplosion", 20 | "width": 1, 21 | "height": 1, 22 | "animation": { 23 | "duration": 0.5, 24 | "framesByAngle": [[ 25 | "small-explosion1", 26 | "small-explosion2", 27 | "small-explosion3", 28 | "small-explosion4", 29 | "small-explosion5", 30 | "small-explosion6" 31 | ]] 32 | } 33 | }, 34 | { 35 | "id": "smoke", 36 | "width": 1, 37 | "height": 1, 38 | "animation": { 39 | "duration": 1, 40 | "framesByAngle": [[ 41 | "smoke1", 42 | "smoke2", 43 | "smoke3", 44 | "smoke4", 45 | "smoke5", 46 | "smoke6", 47 | "smoke7" 48 | ]] 49 | } 50 | } 51 | ] 52 | -------------------------------------------------------------------------------- /Swune/Data/Units.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "harvester", 4 | "name": "Harvester", 5 | "speed": 2, 6 | "turnSpeed": 1, 7 | "health": 2, 8 | "cost": 200, 9 | "buildTime": 5, 10 | "role": "harvester", 11 | "idle": { 12 | "duration": 0, 13 | "framesByAngle": [ 14 | ["harvester-n"], 15 | ["harvester-ne"], 16 | ["harvester-e"], 17 | ["harvester-se"], 18 | ["harvester-s"], 19 | ["harvester-sw"], 20 | ["harvester-w"], 21 | ["harvester-nw"] 22 | ] 23 | } 24 | }, 25 | { 26 | "id": "trike", 27 | "name": "Trike", 28 | "speed": 4, 29 | "turnSpeed": 2, 30 | "health": 1, 31 | "cost": 100, 32 | "buildTime": 3, 33 | "idle": { 34 | "duration": 0, 35 | "framesByAngle": [ 36 | ["trike-n"], 37 | ["trike-ne"], 38 | ["trike-e"], 39 | ["trike-se"], 40 | ["trike-s"], 41 | ["trike-sw"], 42 | ["trike-w"], 43 | ["trike-nw"] 44 | ] 45 | } 46 | } 47 | ] 48 | -------------------------------------------------------------------------------- /Swune/Engine/Angle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Angle.swift 3 | // Swune 4 | // 5 | // Created by Nick Lockwood on 16/02/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Angle: RawRepresentable, Hashable, Codable { 11 | var rawValue: Double { 12 | didSet { normalize() } 13 | } 14 | 15 | init(rawValue: Double) { 16 | self.rawValue = rawValue 17 | normalize() 18 | } 19 | 20 | func delta(from direction: Angle) -> Double { 21 | var da = direction.radians - radians 22 | if da > .pi { 23 | da -= .pi * 2 24 | } else if da < -.pi { 25 | da += .pi * 2 26 | } 27 | return da 28 | } 29 | 30 | private mutating func normalize() { 31 | while radians < 0 { 32 | radians += .pi * 2 33 | } 34 | while radians > .pi * 2 { 35 | radians -= .pi * 2 36 | } 37 | } 38 | } 39 | 40 | extension Angle { 41 | var radians: Double { 42 | get { rawValue } 43 | set { rawValue = newValue } 44 | } 45 | 46 | static let zero = Angle(radians: 0) 47 | 48 | init(radians: Double) { 49 | self.init(rawValue: radians) 50 | } 51 | 52 | init?(x: Double, y: Double) { 53 | guard x != 0 || y != 0 else { 54 | return nil 55 | } 56 | self.init(radians: atan2(x, -y)) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Swune/Engine/Animation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Animation.swift 3 | // Swune 4 | // 5 | // Created by Nick Lockwood on 16/02/2022. 6 | // 7 | 8 | typealias Sprite = String 9 | 10 | struct Animation: Decodable { 11 | var duration: Double 12 | var framesByAngle: [[Sprite]] 13 | var loopCount: Int? 14 | 15 | func frame(angle: Angle, time: Double) -> Sprite? { 16 | guard time >= 0 else { 17 | return nil 18 | } 19 | var angleIndex = Int(( 20 | angle.radians / (2 * .pi) * Double(framesByAngle.count) 21 | ).rounded(.toNearestOrAwayFromZero)) 22 | angleIndex = angleIndex % framesByAngle.count 23 | let frames = framesByAngle[angleIndex] 24 | var frameIndex = (duration > 0) ? 25 | Int(time / duration * Double(frames.count)) : 0 26 | if let loopCount = loopCount, loopCount > 0 { 27 | frameIndex = min(loopCount * frames.count - 1, frameIndex) 28 | } 29 | frameIndex = frameIndex % frames.count 30 | return frames[frameIndex] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Swune/Engine/Assets.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Assets.swift 3 | // Swune 4 | // 5 | // Created by Nick Lockwood on 18/02/2022. 6 | // 7 | 8 | enum AssetError: Error { 9 | case unknownUnitType(EntityTypeID) 10 | case unknownBuildingType(EntityTypeID) 11 | case unknownEntityType(EntityTypeID) 12 | case unknownParticleType(ParticleTypeID) 13 | } 14 | 15 | struct Assets { 16 | var unitTypes: [EntityTypeID: UnitType] 17 | var buildingTypes: [EntityTypeID: BuildingType] 18 | var particleTypes: [ParticleTypeID: ParticleType] 19 | 20 | init( 21 | unitTypes: [UnitType], 22 | buildingTypes: [BuildingType], 23 | particleTypes: [ParticleType] 24 | ) { 25 | self.unitTypes = Dictionary( 26 | uniqueKeysWithValues: unitTypes.map { ($0.id, $0) } 27 | ) 28 | self.buildingTypes = Dictionary( 29 | uniqueKeysWithValues: buildingTypes.map { ($0.id, $0) } 30 | ) 31 | self.particleTypes = Dictionary( 32 | uniqueKeysWithValues: particleTypes.map { ($0.id, $0) } 33 | ) 34 | } 35 | } 36 | 37 | extension Assets { 38 | func entityType(for id: EntityTypeID) -> EntityType? { 39 | if let unitType = unitTypes[id] { 40 | return unitType 41 | } 42 | if let buildingType = buildingTypes[id] { 43 | return buildingType 44 | } 45 | assertionFailure() 46 | return nil 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Swune/Engine/Bounds.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Bounds.swift 3 | // Swune 4 | // 5 | // Created by Nick Lockwood on 18/02/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | typealias Point = (x: Double, y: Double) 11 | 12 | struct Bounds { 13 | var x, y, width, height: Double 14 | 15 | var coords: [TileCoord] { 16 | var coords = [TileCoord]() 17 | for y in Int(y) ..< Int(ceil(y + height)) { 18 | for x in Int(x) ..< Int(ceil(x + width)) { 19 | coords.append(TileCoord(x: x, y: y)) 20 | } 21 | } 22 | return coords 23 | } 24 | 25 | var center: Point { 26 | (x: x + width / 2, y: y + height / 2) 27 | } 28 | 29 | func contains(_ p: Point) -> Bool { 30 | p.x >= x && p.y >= y && p.x < x + width && p.y < y + height 31 | } 32 | 33 | func contains(_ coord: TileCoord) -> Bool { 34 | contains(coord.center) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Swune/Engine/Building.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Building.swift 3 | // Swune 4 | // 5 | // Created by Nick Lockwood on 14/02/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | enum BuildingRole: String, Decodable { 11 | case `default` 12 | case slab 13 | case refinery 14 | } 15 | 16 | struct BuildingType: EntityType, Decodable { 17 | var id: EntityTypeID 18 | var name: String 19 | var width: Int 20 | var height: Int 21 | var health: Double 22 | var cost: Int 23 | var buildTime: Double 24 | var role: BuildingRole? 25 | var idle: Animation 26 | var active: Animation? 27 | var constructions: [EntityTypeID]? 28 | var unit: EntityTypeID? 29 | var spiceCapacity: Int? 30 | 31 | var avatarName: String? { 32 | idle.frame(angle: .zero, time: 0) 33 | } 34 | } 35 | 36 | class Construction { 37 | var type: EntityType 38 | var elapsedTime: Double 39 | 40 | var buildTime: Double { 41 | type.buildTime 42 | } 43 | 44 | var progress: Double { 45 | elapsedTime / buildTime 46 | } 47 | 48 | var cost: Int { 49 | Int(Double(type.cost) * (1 - progress)) 50 | } 51 | 52 | init(type: EntityType) { 53 | self.type = type 54 | self.elapsedTime = 0 55 | } 56 | 57 | // MARK: Serialization 58 | 59 | struct State: Codable { 60 | var type: EntityTypeID 61 | var elapsedTime: Double 62 | } 63 | 64 | var state: State { 65 | .init(type: type.id, elapsedTime: elapsedTime) 66 | } 67 | 68 | init(state: State, assets: Assets) throws { 69 | guard let type = assets.entityType(for: state.type) else { 70 | throw AssetError.unknownEntityType(state.type) 71 | } 72 | self.type = type 73 | self.elapsedTime = state.elapsedTime 74 | } 75 | } 76 | 77 | class Building { 78 | let id: EntityID 79 | var type: BuildingType 80 | var team: Int 81 | var x, y: Int 82 | var health: Double 83 | var lastSmoked: Double 84 | var elapsedTime: Double 85 | var construction: Construction? 86 | var building: Building? 87 | var unit: Unit? { 88 | didSet { elapsedTime = 0 } 89 | } 90 | 91 | var role: BuildingRole { 92 | type.role ?? .default 93 | } 94 | 95 | init(id: EntityID, type: BuildingType, team: Int, coord: TileCoord) { 96 | self.id = id 97 | self.type = type 98 | self.team = team 99 | self.x = coord.x 100 | self.y = coord.y 101 | self.health = type.health 102 | self.lastSmoked = -.greatestFiniteMagnitude 103 | self.elapsedTime = 0 104 | } 105 | 106 | // MARK: Serialization 107 | 108 | struct State: Codable { 109 | var id: EntityID 110 | var type: EntityTypeID 111 | var team: Int 112 | var x, y: Int 113 | var health: Double 114 | var lastSmoked: Double 115 | var elapsedTime: Double 116 | var construction: Construction.State? 117 | var buildings: [Building.State] 118 | var units: [Unit.State] 119 | } 120 | 121 | var state: State { 122 | .init( 123 | id: id, 124 | type: type.id, 125 | team: team, 126 | x: x, 127 | y: y, 128 | health: health, 129 | lastSmoked: lastSmoked, 130 | elapsedTime: elapsedTime, 131 | construction: construction?.state, 132 | buildings: building.map { [$0.state] } ?? [], 133 | units: unit.map { [$0.state] } ?? [] 134 | ) 135 | } 136 | 137 | init(state: State, assets: Assets) throws { 138 | guard let type = assets.buildingTypes[state.type] else { 139 | throw AssetError.unknownBuildingType(state.type) 140 | } 141 | self.id = state.id 142 | self.type = type 143 | self.team = state.team 144 | self.x = state.x 145 | self.y = state.y 146 | self.health = state.health 147 | self.lastSmoked = state.lastSmoked 148 | self.elapsedTime = state.elapsedTime 149 | self.construction = try state.construction.flatMap { 150 | try Construction(state: $0, assets: assets) 151 | } 152 | self.building = try state.buildings.last.map { 153 | try Building(state: $0, assets: assets) 154 | } 155 | self.unit = try state.units.last.map { 156 | try Unit(state: $0, assets: assets) 157 | } 158 | } 159 | } 160 | 161 | extension Building: Entity { 162 | var bounds: Bounds { 163 | .init( 164 | x: Double(x), 165 | y: Double(y), 166 | width: Double(type.width), 167 | height: Double(type.height) 168 | ) 169 | } 170 | 171 | var imageName: String? { 172 | if unit != nil, let active = type.active { 173 | return active.frame(angle: .zero, time: elapsedTime) 174 | } 175 | return type.idle.frame(angle: .zero, time: elapsedTime) 176 | } 177 | 178 | var avatarName: String? { 179 | type.avatarName 180 | } 181 | 182 | var maxHealth: Double { 183 | type.health 184 | } 185 | 186 | func update(timeStep: Double, in world: World) { 187 | elapsedTime += timeStep 188 | // Handle damage 189 | if health <= 0 { 190 | world.remove(self) 191 | let coords = bounds.coords 192 | for (i, coord) in coords.enumerated().shuffled() { 193 | let explosion = world.emitExplosion(at: coord.center) 194 | explosion.elapsedTime = -(Double(i) / Double(coords.count)) * 0.5 195 | world.map.setTile(.crater, at: coord) 196 | } 197 | construction = nil 198 | building = nil 199 | world.postMessage( 200 | team == playerTeam ? 201 | "\(type.name) destroyed." : 202 | "Enemy building destroyed." 203 | ) 204 | return 205 | } else if health < 0.5 * maxHealth { 206 | let cooldown = health / maxHealth / 2 207 | if world.elapsedTime - lastSmoked > cooldown { 208 | let w = bounds.width / 2, h = bounds.height / 2 209 | let x = bounds.x + w + .random(in: -w * 0.75 ... w * 0.75) 210 | let y = bounds.y + h + .random(in: -h * 0.75 ... h * 0.5) 211 | world.emitSmoke(from: (x, y)) 212 | lastSmoked = world.elapsedTime 213 | } 214 | } 215 | // Handle construction 216 | if let construction = construction { 217 | let previousTime = construction.elapsedTime 218 | var cost = construction.cost 219 | construction.elapsedTime = min( 220 | construction.elapsedTime + timeStep, 221 | construction.buildTime 222 | ) 223 | cost -= construction.cost 224 | let funds = world.teams[team]?.spice ?? 0 225 | guard cost <= funds else { 226 | construction.elapsedTime = previousTime 227 | return 228 | } 229 | world.teams[team]?.spice -= cost 230 | guard construction.progress >= 1 else { 231 | return 232 | } 233 | self.construction = nil 234 | switch construction.type { 235 | case let buildingType as BuildingType: 236 | building = world.create { id in 237 | let building = Building( 238 | id: id, 239 | type: buildingType, 240 | team: team, 241 | coord: self.bounds.coords[0] 242 | ) 243 | if let nearest = world.nearestFreeRect( 244 | to: bounds, 245 | for: building 246 | ) { 247 | building.x = nearest.x 248 | building.y = nearest.y 249 | } else { 250 | assertionFailure() 251 | } 252 | if let typeID = building.type.unit { 253 | if let unitType = world.assets.unitTypes[typeID] { 254 | building.unit = world.create { id in 255 | Unit(id: id, type: unitType, team: team, coord: nil) 256 | } 257 | } else { 258 | assertionFailure() 259 | } 260 | } 261 | return building 262 | } 263 | case let unitType as UnitType: 264 | let unit = world.create { id in 265 | Unit(id: id, type: unitType, team: team, coord: nil) 266 | } 267 | world.spawnUnit(unit, from: bounds) 268 | default: 269 | assertionFailure() 270 | } 271 | } 272 | // Role-specific logic 273 | if let unit = unit { 274 | switch unit.role { 275 | case .harvester: 276 | unit.path = [] 277 | let unloadingTimeStep = type.active?.duration ?? 1 278 | if elapsedTime >= unloadingTimeStep { 279 | if unit.spice == 0 { 280 | world.spawnUnit(unit, from: bounds) 281 | self.unit = nil 282 | } else { 283 | elapsedTime -= unloadingTimeStep 284 | let delta = min(unit.spice, 100) 285 | unit.spice -= delta 286 | let capacity = world.spiceCapacity(for: team) 287 | let spice = world.teams[team]?.spice ?? 0 288 | world.teams[team]?.spice = min(spice + delta, capacity) 289 | if spice + delta > capacity { 290 | world.postMessage("Spice lost. Build silo.") 291 | unit.spice = 0 292 | } 293 | } 294 | } 295 | case .default: 296 | assertionFailure() 297 | } 298 | } 299 | } 300 | } 301 | 302 | extension World { 303 | var buildings: [Building] { 304 | entities.compactMap { $0 as? Building } 305 | } 306 | 307 | var selectedBuilding: Building? { 308 | selectedEntity as? Building 309 | } 310 | 311 | func constructionTypes(for buildingType: BuildingType) -> [EntityType] { 312 | (buildingType.constructions ?? []).compactMap { 313 | assets.entityType(for: $0) 314 | } 315 | } 316 | 317 | func spawnUnit(_ unit: Unit, from bounds: Bounds) { 318 | guard let nearest = nearestFreeTile(to: bounds) else { 319 | return // TODO: error 320 | } 321 | unit.x = Double(nearest.x) 322 | unit.y = Double(nearest.y) 323 | let dx = unit.x + 0.5 - (bounds.x + bounds.width / 2) 324 | let dy = unit.y + 0.5 - (bounds.y + bounds.height / 2) 325 | unit.angle = Angle(x: dx, y: dy) ?? .zero 326 | add(unit) 327 | } 328 | 329 | func pickBuilding(at coord: TileCoord) -> Building? { 330 | for case let building as Building in entities 331 | where building.bounds.contains(coord) 332 | { 333 | return building 334 | } 335 | return nil 336 | } 337 | 338 | func canPlaceBuilding(_ building: Building) -> Bool { 339 | canPlaceBuilding(building, at: building.bounds) 340 | } 341 | 342 | func canPlaceBuilding(_ building: Building, at bounds: Bounds) -> Bool { 343 | guard isBuildableSpace(at: bounds, for: building.role) else { 344 | return false 345 | } 346 | switch building.role { 347 | case .slab: 348 | return true 349 | case .refinery, .default: 350 | return isNextToBuilding(at: bounds, team: building.team) 351 | } 352 | } 353 | 354 | func placeBuilding(_ building: Building) -> Bool { 355 | guard canPlaceBuilding(building) else { 356 | return false 357 | } 358 | switch building.role { 359 | case .slab: 360 | for coord in building.bounds.coords { 361 | if map.tile(at: coord) == .stone { 362 | map.setTile(.slab, at: coord) 363 | } 364 | } 365 | case .refinery, .default: 366 | if building.bounds.coords.contains(where: { 367 | map.tile(at: $0) != .slab 368 | }) { 369 | // Apply 50% damage for not placing on slab 370 | building.health /= 2 371 | } 372 | add(building) 373 | } 374 | if let selectedBuilding = selectedBuilding, 375 | selectedBuilding.building === building 376 | { 377 | selectedBuilding.building = nil 378 | selectedBuilding.construction = nil 379 | } 380 | return true 381 | } 382 | } 383 | 384 | private extension World { 385 | func nearestFreeTile(to bounds: Bounds) -> TileCoord? { 386 | let coords = bounds.coords 387 | var visited = Set(coords) 388 | var unvisited = coords 389 | while let next = unvisited.popLast() { 390 | visited.insert(next) 391 | for node in nodesAdjacentTo(next) where !visited.contains(node) { 392 | if map.tile(at: node).isPassable, pickEntity(at: node) == nil { 393 | return node 394 | } 395 | unvisited.insert(node, at: 0) 396 | } 397 | } 398 | return nil 399 | } 400 | 401 | func nearestFreeRect( 402 | to bounds: Bounds, 403 | for building: Building 404 | ) -> TileCoord? { 405 | let coords = bounds.coords 406 | var visited = Set(coords) 407 | var unvisited = coords 408 | var buildable = [Node]() 409 | var unbuildable = [Node]() 410 | while let next = unvisited.popLast() { 411 | visited.insert(next) 412 | for node in nodesAdjacentTo(next) where !visited.contains(node) { 413 | let bounds = Bounds( 414 | x: Double(node.x), 415 | y: Double(node.y), 416 | width: Double(building.type.width), 417 | height: Double(building.type.height) 418 | ) 419 | if isNextToBuilding(at: bounds, team: building.team) { 420 | if isBuildableSpace(at: bounds, for: building.role) { 421 | buildable.append(node) 422 | } else { 423 | unbuildable.append(node) 424 | } 425 | unvisited.insert(node, at: 0) 426 | } 427 | } 428 | } 429 | if !buildable.isEmpty { 430 | // Select buildable coord with best stone/slab coverage 431 | let preferred: Tile = building.role == .slab ? .stone : .slab 432 | let coordSlabs: [(coord: TileCoord, slabCount: Int)] = buildable.map { 433 | var coords = [TileCoord]() 434 | for y in 0 ..< building.type.height { 435 | for x in 0 ..< building.type.width { 436 | coords.append(TileCoord(x: x + $0.x, y: y + $0.y)) 437 | } 438 | } 439 | return ($0, coords.filter { 440 | map.tile(at: $0) == preferred 441 | }.count) 442 | } 443 | return coordSlabs.sorted(by: { 444 | $0.slabCount > $1.slabCount 445 | }).first?.coord 446 | } 447 | return unbuildable.first 448 | } 449 | 450 | func isBuildableSpace(at bounds: Bounds, for role: BuildingRole) -> Bool { 451 | switch role { 452 | case .slab: 453 | return bounds.coords.contains(where: { coord in 454 | switch map.tile(at: coord) { 455 | case .stone, .crater: 456 | return true 457 | case .slab, .sand, .spice, .heavySpice, .boulder: 458 | return false 459 | } 460 | }) 461 | case .refinery, .default: 462 | return bounds.coords.allSatisfy { coord in 463 | switch map.tile(at: coord) { 464 | case .stone, .crater, .slab: 465 | return pickEntity(at: coord) == nil 466 | case .sand, .spice, .heavySpice, .boulder: 467 | return false 468 | } 469 | } 470 | } 471 | } 472 | 473 | func isNextToBuilding(at bounds: Bounds, team: Int) -> Bool { 474 | let adjacentNodes = nodesAdjacentTo(bounds) 475 | return buildings.contains(where: { 476 | $0.team == team && !adjacentNodes.isDisjoint(with: $0.bounds.coords) 477 | }) 478 | } 479 | } 480 | -------------------------------------------------------------------------------- /Swune/Engine/Entity.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Entity.swift 3 | // Swune 4 | // 5 | // Created by Nick Lockwood on 16/02/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | struct EntityTypeID: RawRepresentable, Hashable, Codable { 11 | var rawValue: String 12 | } 13 | 14 | extension EntityTypeID: ExpressibleByStringLiteral { 15 | init(stringLiteral value: StringLiteralType) { 16 | self.init(rawValue: value) 17 | } 18 | } 19 | 20 | protocol EntityType { 21 | var id: EntityTypeID { get } 22 | var name: String { get } 23 | var cost: Int { get } 24 | var buildTime: Double { get } 25 | var avatarName: String? { get } 26 | } 27 | 28 | struct EntityID: RawRepresentable, Hashable, Codable { 29 | var rawValue: Int 30 | } 31 | 32 | protocol Entity: AnyObject { 33 | var id: EntityID { get } 34 | var team: Int { get } 35 | var health: Double { get set } 36 | var maxHealth: Double { get } 37 | var bounds: Bounds { get } 38 | var avatarName: String? { get } 39 | 40 | func update(timeStep: Double, in world: World) 41 | } 42 | 43 | extension Entity { 44 | func nearestCoord(to coord: TileCoord) -> TileCoord { 45 | bounds.coords.min(by: { 46 | $0.distance(from: coord) < $1.distance(from: coord) 47 | }) ?? TileCoord(x: Int(bounds.x), y: Int(bounds.y)) 48 | } 49 | 50 | func distance(from coord: TileCoord) -> Double { 51 | bounds.coords.map { $0.distance(from: coord) }.min() ?? .infinity 52 | } 53 | 54 | func distance(from entity: Entity) -> Double { 55 | entity.bounds.coords.map { distance(from: $0) }.min() ?? .infinity 56 | } 57 | } 58 | 59 | extension World { 60 | func description(for entity: Entity) -> String? { 61 | switch entity { 62 | case let building as Building: 63 | guard let capacity = building.type.spiceCapacity else { 64 | return building.type.name 65 | } 66 | let totalSpice = teams[building.team]?.spice ?? 0 67 | let totalCapacity = spiceCapacity(for: building.team) 68 | let spice = Int(Double(capacity) * ( 69 | Double(totalSpice) / Double(totalCapacity) 70 | )) 71 | return "\(building.type.name) (\(spice) / \(capacity))" 72 | case let unit as Unit: 73 | guard unit.spiceCapacity > 0 else { 74 | return unit.type.name 75 | } 76 | return "\(unit.type.name) (\(unit.spice) / \(unit.spiceCapacity))" 77 | default: 78 | return nil 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Swune/Engine/Game.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Game.swift 3 | // Swune 4 | // 5 | // Created by Nick Lockwood on 13/03/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | private func loadJSON(_ name: String) throws -> T { 11 | let url = Bundle.main.url( 12 | forResource: name, 13 | withExtension: "json", 14 | subdirectory: "Data" 15 | )! 16 | let data = try Data(contentsOf: url) 17 | return try JSONDecoder().decode(T.self, from: data) 18 | } 19 | 20 | let savedGameURL: URL = FileManager.default 21 | .urls(for: .documentDirectory, in: .userDomainMask)[0] 22 | .appendingPathComponent("quicksave.json") 23 | 24 | func loadAssets() -> Assets { 25 | try! Assets( 26 | unitTypes: loadJSON("Units"), 27 | buildingTypes: loadJSON("Buildings"), 28 | particleTypes: loadJSON("Particles") 29 | ) 30 | } 31 | 32 | func loadLevel() -> Level { 33 | try! loadJSON("Level1") 34 | } 35 | 36 | func loadState() -> World.State? { 37 | if FileManager.default.fileExists(atPath: savedGameURL.path) { 38 | do { 39 | let data = try Data(contentsOf: savedGameURL) 40 | return try JSONDecoder().decode(World.State.self, from: data) 41 | } catch { 42 | print("Error restoring state: \(error)") 43 | } 44 | } 45 | return nil 46 | } 47 | 48 | func restoreState(_ state: World.State?, with assets: Assets) -> World? { 49 | if let state = state { 50 | do { 51 | return try .init(state: state, assets: assets) 52 | } catch { 53 | print("\(error)") 54 | } 55 | } 56 | return nil 57 | } 58 | 59 | func saveState(_ state: World.State?) { 60 | guard let state = state else { 61 | return 62 | } 63 | do { 64 | let data = try JSONEncoder().encode(state) 65 | try data.write(to: savedGameURL, options: .atomic) 66 | } catch { 67 | print("\(error)") 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Swune/Engine/Level.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Level.swift 3 | // Swune 4 | // 5 | // Created by Nick Lockwood on 15/02/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Goal: Codable { 11 | var spice: Int? 12 | var destroyAllBuildings: Bool? 13 | var destroyAllUnits: Bool? 14 | } 15 | 16 | struct Level: Codable { 17 | struct Entity: Codable { 18 | var type: EntityTypeID 19 | var team: Int 20 | var x: Int 21 | var y: Int 22 | } 23 | 24 | var version: Int 25 | var tiles: [String] 26 | var buildings: [Entity] 27 | var units: [Entity] 28 | var goal: Goal? 29 | } 30 | -------------------------------------------------------------------------------- /Swune/Engine/Particle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Particle.swift 3 | // Swune 4 | // 5 | // Created by Nick Lockwood on 17/02/2022. 6 | // 7 | 8 | enum ParticleTypeID: String, Hashable, Codable { 9 | case explosion 10 | case smallExplosion 11 | case smoke 12 | } 13 | 14 | struct ParticleType: Decodable { 15 | var id: ParticleTypeID 16 | var width: Double 17 | var height: Double 18 | var animation: Animation 19 | } 20 | 21 | class Particle { 22 | var type: ParticleType 23 | var x, y: Double 24 | var dx, dy: Double 25 | var elapsedTime: Double 26 | 27 | var bounds: Bounds { 28 | .init( 29 | x: x - type.width / 2, 30 | y: y - type.height / 2, 31 | width: type.width, 32 | height: type.height 33 | ) 34 | } 35 | 36 | var imageName: String? { 37 | type.animation.frame(angle: .zero, time: elapsedTime) 38 | } 39 | 40 | init( 41 | type: ParticleType, 42 | x: Double, 43 | y: Double, 44 | dx: Double = 0, 45 | dy: Double = 0 46 | ) { 47 | self.type = type 48 | self.x = x 49 | self.y = y 50 | self.dx = dx 51 | self.dy = dy 52 | self.elapsedTime = 0 53 | } 54 | 55 | func update(timeStep: Double, in world: World) { 56 | elapsedTime += timeStep 57 | x += dx * timeStep 58 | y += dy * timeStep 59 | if elapsedTime > type.animation.duration { 60 | world.removeParticle(self) 61 | } 62 | } 63 | 64 | // MARK: Serialization 65 | 66 | struct State: Codable { 67 | var type: ParticleTypeID 68 | var x, y: Double 69 | var dx, dy: Double 70 | var elapsedTime: Double = 0 71 | } 72 | 73 | var state: State { 74 | .init( 75 | type: type.id, 76 | x: x, 77 | y: y, 78 | dx: dx, 79 | dy: dy, 80 | elapsedTime: elapsedTime 81 | ) 82 | } 83 | 84 | init(state: State, assets: Assets) throws { 85 | guard let type = assets.particleTypes[state.type] else { 86 | throw AssetError.unknownParticleType(state.type) 87 | } 88 | self.type = type 89 | self.x = state.x 90 | self.y = state.y 91 | self.dx = state.dx 92 | self.dy = state.dy 93 | self.elapsedTime = state.elapsedTime 94 | } 95 | } 96 | 97 | extension World { 98 | func removeParticle(_ particle: Particle) { 99 | if let index = particles.firstIndex(where: { $0 === particle }) { 100 | particles.remove(at: index) 101 | } 102 | } 103 | 104 | @discardableResult 105 | func emitSmallExplosion(at x: Double, _ y: Double) -> Particle { 106 | let explosion = Particle( 107 | type: assets.particleTypes[.smallExplosion]!, 108 | x: x, 109 | y: y 110 | ) 111 | particles.append(explosion) 112 | return explosion 113 | } 114 | 115 | @discardableResult 116 | func emitExplosion(at point: Point) -> Particle { 117 | let explosion = Particle( 118 | type: assets.particleTypes[.explosion]!, 119 | x: point.x, 120 | y: point.y 121 | ) 122 | particles.append(explosion) 123 | for _ in 0 ..< 5 { 124 | let x = point.x + .random(in: -0.5 ... 0.5) 125 | let y = point.y + .random(in: -0.5 ... 0.5) 126 | emitSmoke(from: (x, y)) 127 | } 128 | screenShake += 3 129 | return explosion 130 | } 131 | 132 | @discardableResult 133 | func emitSmoke(from point: Point) -> Particle { 134 | let smoke = Particle( 135 | type: assets.particleTypes[.smoke]!, 136 | x: point.x, 137 | y: point.y, 138 | dx: 1, 139 | dy: -1 140 | ) 141 | particles.append(smoke) 142 | return smoke 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /Swune/Engine/Pathfinder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Pathfinder.swift 3 | // Engine 4 | // 5 | // Created by Nick Lockwood on 10/02/2020. 6 | // Copyright © 2020 Nick Lockwood. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public protocol Graph { 12 | associatedtype Node: Hashable 13 | 14 | func nodesConnectedTo(_ node: Node) -> [Node] 15 | func estimatedDistance(from a: Node, to b: Node) -> Double 16 | func stepDistance(from a: Node, to b: Node) -> Double 17 | } 18 | 19 | private class Path { 20 | let head: Node 21 | let tail: Path? 22 | let distanceTravelled: Double 23 | let totalDistance: Double 24 | 25 | init(head: Node, tail: Path?, stepDistance: Double, remaining: Double) { 26 | self.head = head 27 | self.tail = tail 28 | self.distanceTravelled = (tail?.distanceTravelled ?? 0) + stepDistance 29 | self.totalDistance = distanceTravelled + remaining 30 | } 31 | 32 | var nodes: [Node] { 33 | var nodes = [head] 34 | var tail = self.tail 35 | while let path = tail { 36 | nodes.insert(path.head, at: 0) 37 | tail = path.tail 38 | } 39 | nodes.removeFirst() 40 | return nodes 41 | } 42 | } 43 | 44 | public extension Graph { 45 | func findPath(from start: Node, to end: Node, maxDistance: Double) -> [Node] { 46 | var visited = Set([start]) 47 | var paths = [Path( 48 | head: start, 49 | tail: nil, 50 | stepDistance: 0, 51 | remaining: estimatedDistance(from: start, to: end) 52 | )] 53 | 54 | while let path = paths.popLast() { 55 | // Finish if goal reached 56 | if path.head == end { 57 | return path.nodes 58 | } 59 | 60 | // Get connected nodes 61 | for node in nodesConnectedTo(path.head) where !visited.contains(node) { 62 | visited.insert(node) 63 | let next = Path( 64 | head: node, 65 | tail: path, 66 | stepDistance: stepDistance(from: path.head, to: node), 67 | remaining: estimatedDistance(from: node, to: end) 68 | ) 69 | // Skip this node if max distance exceeded 70 | if next.totalDistance > maxDistance { 71 | break 72 | } 73 | // Insert shortest path last 74 | if let index = paths.firstIndex(where: { 75 | $0.totalDistance <= next.totalDistance 76 | }) { 77 | paths.insert(next, at: index) 78 | } else { 79 | paths.append(next) 80 | } 81 | } 82 | } 83 | 84 | // Unreachable 85 | return [] 86 | } 87 | 88 | func findPath( 89 | from start: Node, 90 | to end: @escaping (Node) -> Bool, 91 | maxDistance: Double 92 | ) -> [Node] { 93 | var visited = Set([start]) 94 | var paths = [Path( 95 | head: start, 96 | tail: nil, 97 | stepDistance: 0, 98 | remaining: 0 99 | )] 100 | 101 | while let path = paths.popLast() { 102 | // Finish if goal reached 103 | if end(path.head) { 104 | return path.nodes 105 | } 106 | 107 | // Get connected nodes 108 | for node in nodesConnectedTo(path.head) where !visited.contains(node) { 109 | visited.insert(node) 110 | let next = Path( 111 | head: node, 112 | tail: path, 113 | stepDistance: stepDistance(from: path.head, to: node), 114 | remaining: 0 115 | ) 116 | // Skip this node if max distance exceeded 117 | if next.totalDistance > maxDistance { 118 | break 119 | } 120 | // Insert new paths at start 121 | paths.insert(next, at: 0) 122 | } 123 | } 124 | 125 | // Unreachable 126 | return [] 127 | } 128 | 129 | func floodFill(from start: Node, threshold: Double) -> Set { 130 | var visited = Set([start]) 131 | var unvisited = [start] 132 | while let next = unvisited.popLast() { 133 | visited.insert(next) 134 | for node in nodesConnectedTo(next) where !visited.contains(node) { 135 | if stepDistance(from: next, to: node) < threshold { 136 | unvisited.append(node) 137 | } 138 | } 139 | } 140 | return visited 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /Swune/Engine/Projectile.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Projectile.swift 3 | // Swune 4 | // 5 | // Created by Nick Lockwood on 15/02/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | class Projectile { 11 | var x, y: Double 12 | var tx, ty: Double 13 | var speed: Double = Projectile.speed 14 | var damage: Double = 0.25 15 | var lastSmoked: Double = -.greatestFiniteMagnitude 16 | 17 | // TODO: refactor 18 | fileprivate static let speed: Double = 5 19 | 20 | init(x: Double, y: Double, target: TileCoord) { 21 | self.x = x 22 | self.y = y 23 | self.tx = target.center.x + .random(in: -0.5 ... 0.5) 24 | self.ty = target.center.y + .random(in: -0.5 ... 0.5) 25 | } 26 | 27 | func update(timeStep: Double, in world: World) { 28 | let dx = tx - x, dy = ty - y 29 | let distance = (dx * dx + dy * dy).squareRoot() 30 | let step = timeStep * speed 31 | if distance < step { 32 | world.emitSmallExplosion(at: x, y) 33 | if let entity = world.pickEntity(at: TileCoord(x: Int(x), y: Int(y))) { 34 | entity.health -= damage 35 | if entity.team == playerTeam, entity is Building { 36 | world.postMessage("Base is under attack.") 37 | } 38 | } 39 | if let index = world.projectiles.firstIndex(where: { $0 === self }) { 40 | world.projectiles.remove(at: index) 41 | } 42 | } else { 43 | if world.elapsedTime > lastSmoked + 0.1 { 44 | let x = x + .random(in: -0.05 ... 0.05) 45 | let y = y + .random(in: -0.05 ... 0.05) 46 | let smoke = world.emitSmoke(from: (x, y)) 47 | smoke.dx = .random(in: 0 ... 0.5) 48 | smoke.dy = .random(in: -0.5 ... 0) 49 | lastSmoked = world.elapsedTime 50 | } 51 | x += (dx / distance) * step 52 | y += (dy / distance) * step 53 | } 54 | } 55 | 56 | // MARK: Serialization 57 | 58 | struct State: Codable { 59 | var x, y: Double 60 | var tx, ty: Double 61 | } 62 | 63 | var state: State { 64 | .init(x: x, y: y, tx: tx, ty: ty) 65 | } 66 | 67 | init(state: State) { 68 | self.x = state.x 69 | self.y = state.y 70 | self.tx = state.tx 71 | self.ty = state.ty 72 | } 73 | } 74 | 75 | extension World { 76 | func fireProjectile(from start: TileCoord, at entity: Entity) { 77 | if let unit = entity as? Unit, let next = unit.path.first { 78 | // Try to fire at where unit is going 79 | var target = unit.coord 80 | let distance = start.distance(from: next) 81 | let estimatedTime = distance / Projectile.speed 82 | let estimatedUnitDistance = min(estimatedTime * unit.type.speed, 1) 83 | let dx = Double(next.x) - unit.x 84 | let dy = Double(next.y) - unit.y 85 | let norm = (dx * dx + dy * dy).squareRoot() 86 | if norm > 0 { 87 | target = TileCoord( 88 | x: Int(unit.x + dx / norm * estimatedUnitDistance), 89 | y: Int(unit.y + dy / norm * estimatedUnitDistance) 90 | ) 91 | } 92 | fireProjectile(from: start, at: target) 93 | } else { 94 | fireProjectile(from: start, at: entity.nearestCoord(to: start)) 95 | } 96 | } 97 | 98 | func fireProjectile(from start: TileCoord, at target: TileCoord) { 99 | let projectile = Projectile( 100 | x: start.center.x, 101 | y: start.center.y, 102 | target: target 103 | ) 104 | projectiles.append(projectile) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /Swune/Engine/Tilemap.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Tilemap.swift 3 | // Swune 4 | // 5 | // Created by Nick Lockwood on 13/02/2022. 6 | // 7 | 8 | import UIKit 9 | 10 | struct TileCoord: Hashable, Codable { 11 | var x, y: Int 12 | 13 | var center: Point { 14 | (Double(x) + 0.5, Double(y) + 0.5) 15 | } 16 | 17 | func distance(from coord: TileCoord) -> Double { 18 | let dx = Double(coord.x) - Double(x), dy = Double(coord.y) - Double(y) 19 | return (dx * dx + dy * dy).squareRoot() 20 | } 21 | } 22 | 23 | enum Tile: Character, Codable { 24 | case sand = " " 25 | case spice = "2" 26 | case heavySpice = "5" 27 | case stone = "1" 28 | case boulder = "3" 29 | case slab = "4" 30 | case crater = "6" 31 | 32 | var isPassable: Bool { 33 | return self != .boulder 34 | } 35 | } 36 | 37 | struct Tilemap: Codable { 38 | private(set) var width, height: Int 39 | private(set) var tiles: [Tile] = [] 40 | 41 | init(level: Level) { 42 | // Set tiles 43 | let rows = level.tiles 44 | height = rows.count 45 | width = rows.reduce(.max) { min($0, $1.count) } 46 | tiles = rows.flatMap { $0.map { 47 | Tile(rawValue: $0) ?? .sand 48 | }} 49 | } 50 | 51 | func tile(at coord: TileCoord) -> Tile { 52 | return tiles[ 53 | min(height - 1, max(0, coord.y)) * width + 54 | min(width - 1, max(0, coord.x)) 55 | ] 56 | } 57 | 58 | func coord(at index: Int) -> TileCoord { 59 | return TileCoord(x: index % width, y: index / width) 60 | } 61 | 62 | mutating func setTile(_ tile: Tile, at coord: TileCoord) { 63 | tiles[coord.y * width + coord.x] = tile 64 | } 65 | } 66 | 67 | -------------------------------------------------------------------------------- /Swune/Engine/Unit.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Unit.swift 3 | // Swune 4 | // 5 | // Created by Nick Lockwood on 14/02/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | enum UnitRole: String, Decodable { 11 | case `default` 12 | case harvester 13 | } 14 | 15 | struct UnitType: EntityType, Decodable { 16 | var id: EntityTypeID 17 | var name: String 18 | var speed: Double 19 | var turnSpeed: Double 20 | var health: Double 21 | var cost: Int 22 | var buildTime: Double 23 | var role: UnitRole? 24 | var idle: Animation 25 | var harvestingTime: Double? 26 | var spiceCapacity: Int? 27 | var attackCooldown: Double? 28 | 29 | var avatarName: String? { 30 | idle.frame(angle: .init(radians: .pi), time: 0) 31 | } 32 | } 33 | 34 | class Unit { 35 | let id: EntityID 36 | var type: UnitType 37 | var x, y: Double 38 | var angle: Angle 39 | var team: Int 40 | var attackRange: Double = 5 41 | var health: Double 42 | var elapsedTime: Double 43 | var spice: Int 44 | var isHarvesting: Bool 45 | var lastFired: Double 46 | var lastSmoked: Double 47 | var path: [TileCoord] = [] 48 | var target: EntityID? 49 | var onAssignment: Bool = false 50 | 51 | var coord: TileCoord { 52 | TileCoord(x: Int(x + 0.5), y: Int(y + 0.5)) 53 | } 54 | 55 | var role: UnitRole { 56 | type.role ?? .default 57 | } 58 | 59 | var spiceCapacity: Int { 60 | type.spiceCapacity ?? (role == .harvester ? 500 : 0) 61 | } 62 | 63 | init(id: EntityID, type: UnitType, team: Int, coord: TileCoord?) { 64 | self.id = id 65 | self.type = type 66 | self.team = team 67 | self.x = Double(coord?.x ?? 0) 68 | self.y = Double(coord?.y ?? 0) 69 | self.angle = .zero 70 | self.health = type.health 71 | self.elapsedTime = 0 72 | self.spice = 0 73 | self.isHarvesting = false 74 | self.lastFired = -.greatestFiniteMagnitude 75 | self.lastSmoked = -.greatestFiniteMagnitude 76 | } 77 | 78 | func canEnter(_ building: Building) -> Bool { 79 | switch building.role { 80 | case .refinery: 81 | return role == .harvester && building.team == team 82 | case .slab: 83 | return true 84 | case .default: 85 | return false 86 | } 87 | } 88 | 89 | func findPath(to end: TileCoord, in world: World) -> [TileCoord] { 90 | var end = end 91 | while !world.unit(self, canMoveTo: end) { 92 | guard let next = world.nearestCoord(from: end, to: coord) else { 93 | return [] 94 | } 95 | end = next 96 | } 97 | switch role { 98 | case .harvester: 99 | return world.findPath( 100 | from: coord, 101 | to: end, 102 | maxDistance: .infinity 103 | ) { coord in 104 | world.unit(self, canMoveTo: coord) 105 | } 106 | case .default: 107 | return world.findPath(from: coord, to: end, maxDistance: .infinity) 108 | } 109 | } 110 | 111 | // MARK: Serialization 112 | 113 | struct State: Codable { 114 | var id: EntityID 115 | var type: EntityTypeID 116 | var team: Int 117 | var x, y: Double 118 | var angle: Angle 119 | var health: Double 120 | var target: EntityID? 121 | var onAssignment: Bool 122 | var elapsedTime: Double 123 | var spice: Int 124 | var isHarvesting: Bool 125 | var lastFired: Double 126 | var lastSmoked: Double 127 | } 128 | 129 | var state: State { 130 | .init( 131 | id: id, 132 | type: type.id, 133 | team: team, 134 | x: x, 135 | y: y, 136 | angle: angle, 137 | health: health, 138 | target: target, 139 | onAssignment: onAssignment, 140 | elapsedTime: elapsedTime, 141 | spice: spice, 142 | isHarvesting: isHarvesting, 143 | lastFired: lastFired, 144 | lastSmoked: lastSmoked 145 | ) 146 | } 147 | 148 | init(state: State, assets: Assets) throws { 149 | guard let type = assets.unitTypes[state.type] else { 150 | throw AssetError.unknownUnitType(state.type) 151 | } 152 | self.id = state.id 153 | self.type = type 154 | self.team = state.team 155 | self.x = state.x 156 | self.y = state.y 157 | self.angle = state.angle 158 | self.health = state.health 159 | self.target = state.target 160 | self.onAssignment = state.onAssignment 161 | self.elapsedTime = state.elapsedTime 162 | self.spice = state.spice 163 | self.isHarvesting = state.isHarvesting 164 | self.lastFired = state.lastFired 165 | self.lastSmoked = state.lastSmoked 166 | } 167 | } 168 | 169 | extension Unit: Entity { 170 | var bounds: Bounds { 171 | .init(x: x, y: y, width: 1, height: 1) 172 | } 173 | 174 | var imageName: String? { 175 | type.idle.frame(angle: angle, time: 0) 176 | } 177 | 178 | var avatarName: String? { 179 | type.avatarName 180 | } 181 | 182 | var maxHealth: Double { 183 | type.health 184 | } 185 | 186 | func canAttack(_ target: Entity) -> Bool { 187 | role != .harvester && target.team != team 188 | } 189 | 190 | func direction(of coord: TileCoord) -> Angle? { 191 | Angle(x: Double(coord.x) - x, y: Double(coord.y) - y) 192 | } 193 | 194 | func update(timeStep: Double, in world: World) { 195 | elapsedTime += timeStep 196 | // Handle damage 197 | if health <= 0 { 198 | world.remove(self) 199 | world.emitExplosion(at: (x + 0.5, y + 0.5)) 200 | world.postMessage( 201 | team == playerTeam ? "Unit destroyed." : "Enemy unit destroyed." 202 | ) 203 | return 204 | } else if health < 0.5 * maxHealth { 205 | let cooldown = health / maxHealth / 2 206 | if world.elapsedTime - lastSmoked > cooldown { 207 | let x = x + 0.5 + .random(in: -0.25 ... 0.25) 208 | let y = y + 0.5 + .random(in: -0.25 ... 0) 209 | world.emitSmoke(from: (x, y)) 210 | lastSmoked = world.elapsedTime 211 | } 212 | } 213 | // Attack target 214 | var targetDirection: Angle? 215 | attack: if target != nil { 216 | guard let target = world.get(target), target.health > 0 else { 217 | self.target = nil 218 | self.onAssignment = false 219 | break attack 220 | } 221 | if canAttack(target), distance(from: target) < attackRange { 222 | path = path.first.map { [$0] } ?? [] 223 | targetDirection = direction(of: target.nearestCoord(to: coord)) 224 | // Attack 225 | if world.elapsedTime - lastFired > type.attackCooldown ?? 3, 226 | let direction = targetDirection, 227 | angle.delta(from: direction) < 0.1 228 | { 229 | world.fireProjectile(from: coord, at: target) 230 | lastFired = world.elapsedTime 231 | } 232 | } else if onAssignment { 233 | if path.isEmpty, let destination = world.nearestCoord( 234 | in: target.bounds, 235 | to: coord 236 | ) { 237 | // Recalculate path 238 | path = findPath(to: destination, in: world) 239 | } 240 | } else { 241 | self.target = nil 242 | } 243 | } 244 | // Follow path 245 | if let next = path.first { 246 | guard world.unit(self, canMoveTo: next) else { 247 | if let unit = world.pickUnit(at: next), 248 | unit.team == team, 249 | world.moveUnitAside(unit) 250 | { 251 | return 252 | } else { 253 | path = [coord] 254 | } 255 | return 256 | } 257 | 258 | let dx = Double(next.x) - x, dy = Double(next.y) - y 259 | let distance = (dx * dx + dy * dy).squareRoot() 260 | let step = timeStep * type.speed 261 | if distance < step { 262 | path.removeFirst() 263 | x = Double(next.x) 264 | y = Double(next.y) 265 | } else { 266 | targetDirection = Angle(x: dx, y: dy) 267 | if let direction = targetDirection, 268 | angle.delta(from: direction) < 0.001 269 | { 270 | x += (dx / distance) * step 271 | y += (dy / distance) * step 272 | } 273 | } 274 | } 275 | // Turn towards target 276 | if let direction = targetDirection { 277 | let da = angle.delta(from: direction) 278 | guard abs(da) < 0.001 else { 279 | let astep = timeStep * type.turnSpeed * 2 * .pi 280 | if abs(da) < astep { 281 | angle = direction 282 | } else { 283 | angle.radians += astep * (da < 0 ? -1 : 1) 284 | } 285 | return 286 | } 287 | } 288 | // Role-specific logic 289 | switch role { 290 | case .harvester: 291 | // Enter refinery 292 | if let building = world.get(target) as? Building, 293 | building.bounds.contains(coord) 294 | { 295 | assert(building.team == team) 296 | assert(building.unit == nil) 297 | building.unit = self 298 | target = nil 299 | onAssignment = false 300 | world.remove(self) 301 | } 302 | guard path.isEmpty else { 303 | return 304 | } 305 | if spice < spiceCapacity { 306 | if world.map.tile(at: coord).isSpice { 307 | // Harvest 308 | if !isHarvesting { 309 | elapsedTime = 0 310 | isHarvesting = true 311 | } 312 | if elapsedTime >= type.harvestingTime ?? 5 { 313 | elapsedTime = 0 314 | var tile = world.map.tile(at: coord) 315 | spice += tile.harvest() 316 | world.map.setTile(tile, at: coord) 317 | } 318 | } else { 319 | isHarvesting = false 320 | // Seek spice 321 | if world.map.tiles.enumerated().contains(where: { i, tile in 322 | tile.isSpice && world.pickUnit(at: world.map.coord(at: i)) == nil 323 | }) { 324 | path = world.findPath(from: coord, to: { coord in 325 | world.map.tile(at: coord).isSpice && 326 | world.unit(self, canMoveTo: coord) 327 | }, maxDistance: .infinity) 328 | } 329 | } 330 | } else { 331 | isHarvesting = false 332 | } 333 | if path.isEmpty, !isHarvesting, spice > 0 { 334 | // Return to refinery 335 | isHarvesting = false 336 | spice = spiceCapacity 337 | target = world.nearestEntity(to: coord, matching: { 338 | $0.team == team && ($0 as? Building)?.role == .refinery 339 | })?.id 340 | onAssignment = (target != nil) 341 | } 342 | case .default: 343 | guard !onAssignment, path.isEmpty else { 344 | break 345 | } 346 | // Attack nearest unit in range 347 | target = world.nearestEntity(to: coord, matching: { 348 | $0.team != team && 349 | ($0.team == playerTeam || team == playerTeam) && 350 | distance(from: $0) < attackRange 351 | })?.id 352 | } 353 | } 354 | } 355 | 356 | extension World { 357 | var units: [Unit] { 358 | entities.compactMap { $0 as? Unit } 359 | } 360 | 361 | var selectedUnit: Unit? { 362 | selectedEntity as? Unit 363 | } 364 | 365 | func pickUnit(at coord: TileCoord) -> Unit? { 366 | for case let unit as Unit in entities 367 | where unit.bounds.contains(coord) 368 | { 369 | return unit 370 | } 371 | return nil 372 | } 373 | 374 | func nearestEntity( 375 | to coord: TileCoord, 376 | matching: (Entity) -> Bool 377 | ) -> Entity? { 378 | var nearest: Entity? 379 | var distance = Double.infinity 380 | for entity in entities.compactMap({ $0 }) where matching(entity) { 381 | let d = entity.distance(from: coord) 382 | if d < distance { 383 | nearest = entity 384 | distance = d 385 | } 386 | } 387 | return nearest 388 | } 389 | 390 | func moveUnit(_ unit: Unit, to coord: TileCoord) { 391 | let path = unit.findPath(to: coord, in: self) 392 | unit.path = (unit.path.first.map { [$0] } ?? []) + path 393 | } 394 | 395 | func moveUnitAside(_ unit: Unit) -> Bool { 396 | if unit.isHarvesting { 397 | return false 398 | } 399 | guard let target = nodesConnectedTo(unit.coord).first(where: { node in 400 | self.unit(unit, canMoveTo: node) && !units.contains(where: { 401 | $0 !== unit && ($0.path.first == node || $0.path.last == node) 402 | }) 403 | }) else { 404 | return false 405 | } 406 | guard let current = unit.path.last else { 407 | unit.path = [target] 408 | return true 409 | } 410 | let path = findPath( 411 | from: target, 412 | to: current, 413 | maxDistance: .infinity 414 | ) 415 | guard !path.isEmpty else { 416 | return false 417 | } 418 | unit.path = [target] + path 419 | return true 420 | } 421 | 422 | func unit(_ unit: Unit, canMoveTo coord: TileCoord) -> Bool { 423 | guard map.tile(at: coord).isPassable else { 424 | return false 425 | } 426 | guard let building = pickBuilding(at: coord) else { 427 | if let hit = pickUnit(at: coord), hit !== unit { 428 | return false 429 | } 430 | return true 431 | } 432 | return unit.canEnter(building) && building.unit == nil 433 | } 434 | } 435 | 436 | extension Tile { 437 | var isSpice: Bool { 438 | switch self { 439 | case .spice, .heavySpice: 440 | return true 441 | case .slab, .crater, .boulder, .sand, .stone: 442 | return false 443 | } 444 | } 445 | 446 | mutating func harvest() -> Int { 447 | switch self { 448 | case .heavySpice: 449 | self = .spice 450 | return 100 451 | case .spice: 452 | self = .sand 453 | return 100 454 | case .slab, .crater, .boulder, .sand, .stone: 455 | assertionFailure() 456 | return 0 457 | } 458 | } 459 | } 460 | -------------------------------------------------------------------------------- /Swune/Engine/World.swift: -------------------------------------------------------------------------------- 1 | // 2 | // World.swift 3 | // Swune 4 | // 5 | // Created by Nick Lockwood on 13/02/2022. 6 | // 7 | 8 | let playerTeam = 1 9 | 10 | struct TeamState: Codable { 11 | var team: Int 12 | var spice: Int = 1000 13 | var hasFoundPlayer: Bool = false 14 | } 15 | 16 | class World { 17 | private(set) var version: Int 18 | private(set) var entities: [Entity?] = [] 19 | private var indexByID: [EntityID: Int] = [:] 20 | private var nextID: Int = 0 21 | private(set) var assets: Assets 22 | private(set) var message: String? 23 | var map: Tilemap 24 | var goal: Goal? 25 | var elapsedTime: Double 26 | var screenShake: Double 27 | var scrollX: Double 28 | var scrollY: Double 29 | var teams: [Int: TeamState] 30 | var particles: [Particle] 31 | var projectiles: [Projectile] 32 | 33 | private(set) lazy var tileIsPassable: (TileCoord) -> Bool = 34 | { [weak self] coord in 35 | guard let self = self else { return false } 36 | return self.map.tile(at: coord).isPassable && 37 | self.pickBuilding(at: coord) == nil 38 | } 39 | 40 | private var selectedEntityID: EntityID? 41 | var selectedEntity: Entity? { 42 | get { get(selectedEntityID) } 43 | set { 44 | if newValue != nil { 45 | message = nil 46 | } 47 | selectedEntityID = newValue?.id 48 | } 49 | } 50 | 51 | init(level: Level, assets: Assets) { 52 | self.assets = assets 53 | self.version = level.version 54 | self.map = Tilemap(level: level) 55 | self.goal = level.goal 56 | self.elapsedTime = 0 57 | self.screenShake = 0 58 | self.scrollX = 0 59 | self.scrollY = 0 60 | self.particles = [] 61 | self.projectiles = [] 62 | self.teams = [:] 63 | level.buildings.forEach { 64 | guard let type = assets.buildingTypes[$0.type] else { 65 | assertionFailure() 66 | return 67 | } 68 | let team = $0.team 69 | if teams[team] == nil { 70 | teams[team] = TeamState(team: team) 71 | } 72 | let coord = TileCoord(x: $0.x, y: $0.y) 73 | add(create { id in 74 | Building(id: id, type: type, team: team, coord: coord) 75 | }) 76 | } 77 | level.units.forEach { 78 | guard let type = assets.unitTypes[$0.type] else { 79 | assertionFailure() 80 | return 81 | } 82 | let team = $0.team 83 | if teams[team] == nil { 84 | teams[team] = TeamState(team: team) 85 | } 86 | let coord = TileCoord(x: $0.x, y: $0.y) 87 | add(create { id in 88 | Unit(id: id, type: type, team: team, coord: coord) 89 | }) 90 | } 91 | } 92 | 93 | func nearestCoord(in bounds: Bounds, to coord: TileCoord) -> TileCoord? { 94 | bounds.coords.min(by: { 95 | $0.distance(from: coord) < $1.distance(from: coord) 96 | }) 97 | } 98 | 99 | func nearestCoord(from bounds: Bounds, to coord: TileCoord) -> TileCoord? { 100 | nodesAdjacentTo(bounds).min(by: { 101 | $0.distance(from: coord) < $1.distance(from: coord) 102 | }) 103 | } 104 | 105 | func nearestCoord(from start: TileCoord, to end: TileCoord) -> TileCoord? { 106 | nodesAdjacentTo(start).min(by: { 107 | $0.distance(from: end) < $1.distance(from: end) 108 | }) 109 | } 110 | 111 | func postMessage(_ message: String?) { 112 | self.message = message 113 | } 114 | 115 | func update(timeStep: Double) { 116 | elapsedTime += timeStep 117 | // Update entities 118 | for entity in entities { 119 | entity?.update(timeStep: timeStep, in: self) 120 | } 121 | // Update projectiles 122 | for projectile in projectiles { 123 | projectile.update(timeStep: timeStep, in: self) 124 | } 125 | // Update particles 126 | for particle in particles { 127 | particle.update(timeStep: timeStep, in: self) 128 | } 129 | // Update shake 130 | screenShake *= (1 - timeStep) 131 | if screenShake < 0.3 { 132 | screenShake = 0 133 | } 134 | // Update AI 135 | let sightRange: Double = 10 136 | for (team, state) in teams where team != playerTeam { 137 | var state = state 138 | // Check for attack 139 | let buildingUnderAttack = buildings.first(where: { building in 140 | building.team == team && 141 | building.health < building.maxHealth && 142 | units.contains(where: { 143 | $0.team != team && $0.target == building.id 144 | }) 145 | }) 146 | // Update units 147 | for unit in units where unit.team == team { 148 | // Find player 149 | if !state.hasFoundPlayer, buildings.contains(where: { 150 | $0.team == playerTeam && 151 | unit.distance(from: $0) < sightRange 152 | }) { 153 | state.hasFoundPlayer = true 154 | } 155 | // Run away to nearest building if nearly dead 156 | if unit.health <= 0.25 * unit.maxHealth, units.contains(where: { 157 | $0.team == playerTeam && $0.target == unit.id 158 | }), unit.path.isEmpty, let building = nearestEntity( 159 | to: unit.coord, 160 | matching: { $0.team == team && $0 is Building } 161 | ), let destination = nearestCoord( 162 | from: building.bounds, 163 | to: unit.coord 164 | ) { 165 | moveUnit(unit, to: destination) 166 | unit.target = nil 167 | unit.onAssignment = false 168 | } 169 | // Ignore if busy 170 | guard unit.role != .harvester, !unit.onAssignment, 171 | unit.path.isEmpty 172 | else { 173 | continue 174 | } 175 | // Protect base 176 | if let building = buildingUnderAttack, let coord = nearestCoord( 177 | from: building.bounds, 178 | to: unit.coord 179 | ) { 180 | moveUnit(unit, to: coord) 181 | unit.onAssignment = false 182 | continue 183 | } 184 | // Attack enemy base 185 | if state.hasFoundPlayer, let nearestBuilding = nearestEntity( 186 | to: unit.coord, 187 | matching: { $0.team == playerTeam && $0 is Building }) 188 | { 189 | unit.target = nearestBuilding.id 190 | unit.onAssignment = true 191 | } 192 | // Attack nearest unit in range 193 | if !unit.onAssignment, let target = nearestEntity( 194 | to: unit.coord, matching: { 195 | $0.team != team && 196 | ($0.team == playerTeam || team == playerTeam) && 197 | unit.distance(from: $0) < sightRange 198 | } 199 | ) { 200 | unit.target = target.id 201 | unit.onAssignment = true 202 | } 203 | } 204 | // Build buildings 205 | if let yard = buildings.first(where: { 206 | $0.team == team && constructionTypes(for: $0.type) 207 | .contains(where: { $0 is BuildingType }) 208 | }) { 209 | if let building = yard.building { 210 | // Deploy building 211 | _ = placeBuilding(building) 212 | yard.construction = nil 213 | yard.building = nil 214 | } 215 | if yard.construction == nil { 216 | let buildingTypes = constructionTypes(for: yard.type) 217 | .compactMap({ $0 as? BuildingType }) 218 | // Refinery 219 | if !buildings.contains(where: { 220 | $0.team == team && $0.role == .refinery 221 | }), let refineryType = buildingTypes.first(where: { 222 | $0.role == .refinery 223 | }) { 224 | yard.construction = Construction(type: refineryType) 225 | } 226 | // Factory 227 | if !buildings.contains(where: { 228 | $0.team == team && constructionTypes(for: $0.type) 229 | .contains(where: { $0 is UnitType }) 230 | }), let factoryType = buildingTypes.first(where: { 231 | constructionTypes(for: $0) 232 | .compactMap({ $0 as? UnitType }) 233 | .contains(where: { $0.role != .harvester }) 234 | }) { 235 | yard.construction = Construction(type: factoryType) 236 | } 237 | } 238 | } 239 | // Build vehicles 240 | var harvesterCount = 0 241 | var vehicleCount = 0 242 | for unit in units where unit.team == team { 243 | if unit.role == .harvester { 244 | harvesterCount += 1 245 | } else { 246 | vehicleCount += 1 247 | } 248 | } 249 | for factory in buildings.filter({ 250 | $0.team == team && constructionTypes(for: $0.type) 251 | .contains(where: { $0 is UnitType }) 252 | }) { 253 | if let construction = factory.construction { 254 | if let unitType = construction.type as? UnitType { 255 | if unitType.role == .harvester { 256 | harvesterCount += 1 257 | } else { 258 | vehicleCount += 1 259 | } 260 | } 261 | continue 262 | } 263 | let unitTypes = constructionTypes(for: factory.type) 264 | .compactMap({ $0 as? UnitType }) 265 | if harvesterCount < 1, 266 | let harvesterType = unitTypes.first(where: { 267 | $0.role == .harvester 268 | }) 269 | { 270 | // Harvester 271 | factory.construction = Construction(type: harvesterType) 272 | } else if vehicleCount < 5, 273 | let vehicleType = unitTypes.first(where: { 274 | $0.role != .harvester 275 | }) 276 | { 277 | // Combat vehicle 278 | factory.construction = Construction(type: vehicleType) 279 | } 280 | } 281 | // Update state 282 | teams[team] = state 283 | } 284 | } 285 | 286 | // MARK: Serialization 287 | 288 | struct State: Codable { 289 | var map: Tilemap 290 | var version: Int 291 | var goal: Goal? 292 | var elapsedTime: Double 293 | var screenShake: Double 294 | var scrollX: Double 295 | var scrollY: Double 296 | var selectedEntity: EntityID? 297 | var teams: [TeamState] 298 | var buildings: [Building.State] 299 | var units: [Unit.State] = [] 300 | var particles: [Particle.State] 301 | var projectiles: [Projectile.State] 302 | } 303 | 304 | var state: State { 305 | .init( 306 | map: map, 307 | version: version, 308 | goal: goal, 309 | elapsedTime: elapsedTime, 310 | screenShake: screenShake, 311 | scrollX: scrollX, 312 | scrollY: scrollY, 313 | selectedEntity: selectedEntityID, 314 | teams: Array(teams.values), 315 | buildings: buildings.map { $0.state }, 316 | units: units.map { $0.state }, 317 | particles: particles.map { $0.state }, 318 | projectiles: projectiles.map { $0.state } 319 | ) 320 | } 321 | 322 | init(state: State, assets: Assets) throws { 323 | self.assets = assets 324 | self.version = state.version 325 | self.goal = state.goal 326 | self.map = state.map 327 | self.elapsedTime = state.elapsedTime 328 | self.screenShake = state.screenShake 329 | self.scrollX = state.scrollX 330 | self.scrollY = state.scrollY 331 | self.selectedEntityID = state.selectedEntity 332 | self.teams = Dictionary(uniqueKeysWithValues: state.teams.map { 333 | ($0.team, $0) 334 | }) 335 | self.particles = try state.particles.map { 336 | try Particle(state: $0, assets: assets) 337 | } 338 | self.projectiles = state.projectiles.map { 339 | Projectile(state: $0) 340 | } 341 | try state.buildings.forEach { 342 | try add(Building(state: $0, assets: assets)) 343 | } 344 | try state.units.forEach { 345 | try add(Unit(state: $0, assets: assets)) 346 | } 347 | } 348 | } 349 | 350 | extension World { 351 | func create(_ constructor: (EntityID) throws -> T) rethrows -> T { 352 | nextID += 1 // Do this first to avoid problems with reentrancy 353 | let entity = try constructor(EntityID(rawValue: nextID)) 354 | return entity 355 | } 356 | 357 | func get(_ id: EntityID?) -> Entity? { 358 | id.flatMap { indexByID[$0] }.flatMap { entities[$0] } 359 | } 360 | 361 | func add(_ entity: Entity) { 362 | assert(indexByID[entity.id] == nil) 363 | nextID = max(nextID, entity.id.rawValue + 1) 364 | if let index = entities.firstIndex(where: { $0 == nil }) { 365 | indexByID[entity.id] = index 366 | entities[index] = entity 367 | } else { 368 | indexByID[entity.id] = entities.count 369 | entities.append(entity) 370 | } 371 | } 372 | 373 | func remove(_ entity: Entity) { 374 | guard let index = indexByID[entity.id] else { 375 | assertionFailure() 376 | return 377 | } 378 | entities[index] = nil 379 | indexByID[entity.id] = nil 380 | } 381 | 382 | func pickEntity(at coord: TileCoord) -> Entity? { 383 | for case let entity? in entities where entity.bounds.contains(coord) { 384 | return entity 385 | } 386 | return nil 387 | } 388 | } 389 | 390 | extension World: Graph { 391 | typealias Node = TileCoord 392 | 393 | func findPath( 394 | from start: Node, 395 | to end: Node, 396 | maxDistance: Double, 397 | canPass: @escaping (TileCoord) -> Bool 398 | ) -> [Node] { 399 | let oldFn = tileIsPassable 400 | tileIsPassable = canPass 401 | defer { tileIsPassable = oldFn } 402 | return findPath(from: start, to: end, maxDistance: maxDistance) 403 | } 404 | 405 | func nodesAdjacentTo(_ bounds: Bounds) -> Set { 406 | let coords = bounds.coords 407 | var visited = Set(coords) 408 | for coord in coords { 409 | for node in nodesAdjacentTo(coord) where !coords.contains(node) { 410 | visited.insert(node) 411 | } 412 | } 413 | return visited 414 | } 415 | 416 | func nodesAdjacentTo(_ node: TileCoord) -> [TileCoord] { 417 | return [ 418 | Node(x: node.x - 1, y: node.y - 1), 419 | Node(x: node.x - 1, y: node.y), 420 | Node(x: node.x - 1, y: node.y + 1), 421 | Node(x: node.x, y: node.y + 1), 422 | Node(x: node.x + 1, y: node.y + 1), 423 | Node(x: node.x + 1, y: node.y), 424 | Node(x: node.x + 1, y: node.y - 1), 425 | Node(x: node.x, y: node.y - 1), 426 | ].filter { 427 | $0.x >= 0 && $0.x < map.width && $0.y >= 0 && $0.y < map.height 428 | } 429 | } 430 | 431 | func nodesConnectedTo(_ node: TileCoord) -> [TileCoord] { 432 | nodesAdjacentTo(node).filter { 433 | tileIsPassable($0) && 434 | tileIsPassable(Node(x: $0.x, y: node.y)) && 435 | tileIsPassable(Node(x: node.x, y: $0.y)) 436 | } 437 | } 438 | 439 | func estimatedDistance(from a: Node, to b: Node) -> Double { 440 | return abs(Double(b.x - a.x)) + abs(Double(b.y - a.y)) 441 | } 442 | 443 | func stepDistance(from a: Node, to b: Node) -> Double { 444 | return 1 445 | } 446 | } 447 | 448 | extension World { 449 | var spiceGoal: Int { 450 | goal?.spice ?? 0 451 | } 452 | 453 | var selectedEntityDescription: String? { 454 | selectedEntity.flatMap(description(for:)) 455 | } 456 | 457 | var destroyAllBuildings: Bool { 458 | goal?.destroyAllBuildings ?? (spiceGoal == 0) 459 | } 460 | 461 | var destroyAllUnits: Bool { 462 | goal?.destroyAllUnits ?? false 463 | } 464 | 465 | var isLevelComplete: Bool { 466 | return (!destroyAllBuildings || !buildings.contains(where: { 467 | $0.team != playerTeam 468 | })) && (!destroyAllUnits || !units.contains(where: { 469 | $0.team != playerTeam 470 | })) && teams[playerTeam]?.spice ?? 0 >= spiceGoal 471 | } 472 | 473 | func spiceCapacity(for team: Int) -> Int { 474 | return buildings.reduce(1000) { 475 | $0 + ($1.type.spiceCapacity ?? 0) 476 | } 477 | } 478 | } 479 | -------------------------------------------------------------------------------- /Swune/GameViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.swift 3 | // Swune 4 | // 5 | // Created by Nick Lockwood on 13/02/2022. 6 | // 7 | 8 | import UIKit 9 | 10 | private let tileSize = CGSize(width: 48, height: 48) 11 | private let maximumTimeStep: Double = 1 / 20 12 | private let worldTimeStep: Double = 1 / 120 13 | private let levelEndDelay: Double = 2 14 | 15 | class GameViewController: UIViewController { 16 | private var displayLink: CADisplayLink? 17 | private var lastFrameTime = CACurrentMediaTime() 18 | private var scrollView = UIScrollView() 19 | private var tileViews = [UIImageView]() 20 | private var spriteViews = [UIImageView]() 21 | private var projectileViews = [UIView]() 22 | private let selectionView = UIImageView() 23 | private let placeholderView = UIView() 24 | private let avatarView = AvatarView() 25 | private let constructionView = AvatarView() 26 | private let spiceLabel = UILabel() 27 | private let messageLabel = UILabel() 28 | private let pauseButton = UIButton() 29 | private var isPaused = true 30 | private var levelEnded: TimeInterval? 31 | private var world: World 32 | 33 | init(world: World) { 34 | self.world = world 35 | super.init(nibName: nil, bundle: nil) 36 | } 37 | 38 | required init?(coder: NSCoder) { 39 | fatalError("init(coder:) has not been implemented") 40 | } 41 | 42 | override func viewDidLoad() { 43 | super.viewDidLoad() 44 | 45 | NotificationCenter.default.addObserver( 46 | forName: UIApplication.willResignActiveNotification, 47 | object: nil, 48 | queue: .main 49 | ) { [weak self] _ in 50 | saveState(self?.world.state) 51 | } 52 | 53 | view.backgroundColor = .black 54 | 55 | scrollView.frame = view.bounds 56 | scrollView.showsHorizontalScrollIndicator = false 57 | scrollView.showsVerticalScrollIndicator = false 58 | scrollView.autoresizingMask = [.flexibleWidth, .flexibleHeight] 59 | scrollView.delegate = self 60 | view.addSubview(scrollView) 61 | 62 | let gesture = UITapGestureRecognizer( 63 | target: self, 64 | action: #selector(didTap) 65 | ) 66 | scrollView.addGestureRecognizer(gesture) 67 | 68 | let scrollPosition = CGPoint(x: world.scrollX, y: world.scrollY) 69 | scrollView.setContentOffset(scrollPosition, animated: true) 70 | 71 | let imageView = UIImageView() 72 | imageView.image = UIImage(sprite: "pause", team: nil) 73 | imageView.transform = CGAffineTransform(scaleX: 8, y: 8) 74 | imageView.contentMode = .scaleToFill 75 | imageView.frame = CGRect(x: 0, y: 0, width: 32, height: 32) 76 | imageView.layer.magnificationFilter = .nearest 77 | imageView.layer.shadowOffset = .zero 78 | imageView.layer.shadowOpacity = 0.25 79 | imageView.layer.shadowRadius = 0.32 80 | pauseButton.addSubview(imageView) 81 | pauseButton.sizeToFit() 82 | pauseButton.addAction(UIAction { [weak self] _ in 83 | self?.isPaused = true 84 | let alert = UIAlertController( 85 | title: "Paused", 86 | message: "", 87 | preferredStyle: .alert 88 | ) 89 | alert.addAction(UIAlertAction( 90 | title: "Resume", 91 | style: .default 92 | ) { _ in 93 | self?.isPaused = false 94 | }) 95 | alert.addAction(UIAlertAction( 96 | title: "Quit", 97 | style: .default 98 | ) { _ in 99 | self?.presentingViewController?.dismiss(animated: true) 100 | }) 101 | self?.present(alert, animated: true) 102 | }, for: .touchUpInside) 103 | view.addSubview(pauseButton) 104 | 105 | spiceLabel.configure(withSize: 6) 106 | view.addSubview(spiceLabel) 107 | 108 | messageLabel.configure(withSize: 4) 109 | messageLabel.numberOfLines = 0 110 | view.addSubview(messageLabel) 111 | 112 | loadWorld(world) 113 | 114 | displayLink?.invalidate() 115 | displayLink = CADisplayLink(target: self, selector: #selector(update)) 116 | displayLink?.add(to: .main, forMode: .common) 117 | } 118 | 119 | override func viewDidAppear(_ animated: Bool) { 120 | super.viewDidAppear(animated) 121 | var objectives = [String]() 122 | if world.destroyAllBuildings { 123 | objectives.append("Destroy all enemy buildings") 124 | } 125 | if world.destroyAllUnits { 126 | objectives.append("Destroy all enemy units") 127 | } 128 | if world.spiceGoal > 0 { 129 | objectives.append("Gather \(world.spiceGoal) units of spice") 130 | } 131 | let alert = UIAlertController( 132 | title: "Mission:", 133 | message: objectives.joined(separator: "\n"), 134 | preferredStyle: .alert 135 | ) 136 | alert.addAction(UIAlertAction(title: "OK", style: .default) { [weak self] _ in 137 | self?.isPaused = false 138 | }) 139 | present(alert, animated: true) 140 | } 141 | 142 | override func viewWillDisappear(_ animated: Bool) { 143 | super.viewWillDisappear(animated) 144 | isPaused = true 145 | } 146 | 147 | func loadTilemap(_ tilemap: Tilemap) { 148 | scrollView.contentSize = CGSize( 149 | width: tileSize.width * CGFloat(tilemap.width), 150 | height: tileSize.height * CGFloat(tilemap.height) 151 | ) 152 | // Draw map 153 | for y in 0 ..< tilemap.height { 154 | for x in 0 ..< tilemap.width { 155 | let tileView = UIImageView(frame: CGRect( 156 | x: tileSize.width * CGFloat(x), 157 | y: tileSize.height * CGFloat(y), 158 | width: tileSize.width, 159 | height: tileSize.height 160 | )) 161 | let coord = TileCoord(x: x, y: y) 162 | let tile = tilemap.tile(at: coord) 163 | tileView.image = tile.image 164 | tileView.contentMode = .scaleToFill 165 | tileView.layer.magnificationFilter = .nearest 166 | tileViews.append(tileView) 167 | scrollView.addSubview(tileView) 168 | } 169 | } 170 | } 171 | 172 | func loadWorld(_ world: World) { 173 | loadTilemap(world.map) 174 | 175 | // Draw reticle 176 | if let reticleImage = UIImage(sprite: "reticle") { 177 | let scale = tileSize.width / reticleImage.size.width 178 | selectionView.transform = CGAffineTransform(scaleX: scale, y: scale) 179 | selectionView.image = reticleImage.stretchableImage( 180 | withLeftCapWidth: Int(reticleImage.size.width / 2), 181 | topCapHeight: Int(reticleImage.size.height / 2) 182 | ) 183 | } 184 | selectionView.contentMode = .scaleToFill 185 | selectionView.layer.magnificationFilter = .nearest 186 | selectionView.isHidden = true 187 | scrollView.addSubview(selectionView) 188 | 189 | // Draw placeholder 190 | placeholderView.isHidden = true 191 | placeholderView.layer.borderWidth = 4 192 | placeholderView.layer.borderColor = UIColor.white.cgColor 193 | let gesture = UIPanGestureRecognizer(target: self, action: #selector(didDrag)) 194 | placeholderView.addGestureRecognizer(gesture) 195 | scrollView.addSubview(placeholderView) 196 | 197 | // Draw avatar 198 | avatarView.isHidden = true 199 | view.addSubview(avatarView) 200 | 201 | // Draw construction 202 | constructionView.isHidden = true 203 | view.addSubview(constructionView) 204 | } 205 | 206 | @objc func update(_ displayLink: CADisplayLink) { 207 | if isPaused { 208 | updateViews() 209 | return 210 | } 211 | 212 | let timeStep = min(maximumTimeStep, displayLink.timestamp - lastFrameTime) 213 | lastFrameTime = displayLink.timestamp 214 | 215 | let worldSteps = (timeStep / worldTimeStep).rounded(.up) 216 | for _ in 0 ..< Int(worldSteps) { 217 | world.update(timeStep: timeStep / worldSteps) 218 | } 219 | 220 | let intensity = world.screenShake 221 | if intensity > 0 { 222 | let shakeX = Double.random(in: -intensity ... intensity) 223 | let shakeY = Double.random(in: -intensity ... intensity) 224 | view.window?.transform = CGAffineTransform(translationX: shakeX, y: shakeY) 225 | } else if view.window?.transform != .identity { 226 | view.window?.transform = .identity 227 | } 228 | 229 | if let levelEnded = levelEnded { 230 | if lastFrameTime - levelEnded >= levelEndDelay { 231 | isPaused = true 232 | let alert = UIAlertController( 233 | title: "Mission Complete!", 234 | message: nil, 235 | preferredStyle: .alert 236 | ) 237 | present(alert, animated: true) 238 | alert.addAction(UIAlertAction(title: "OK", style: .default) { [weak self] _ in 239 | guard let self = self else { return } 240 | let level = loadLevel() 241 | self.world = .init(level: level, assets: self.world.assets) 242 | self.levelEnded = nil 243 | self.isPaused = false 244 | }) 245 | } 246 | } else if world.isLevelComplete { 247 | levelEnded = lastFrameTime 248 | } 249 | 250 | updateViews() 251 | } 252 | 253 | func addSprite(_ name: String?, team: Int?, frame: CGRect, index: Int) { 254 | let spriteView: UIImageView 255 | if index >= spriteViews.count { 256 | spriteView = UIImageView(frame: frame) 257 | spriteView.isUserInteractionEnabled = false 258 | spriteView.contentMode = .scaleToFill 259 | spriteView.layer.magnificationFilter = .nearest 260 | spriteViews.append(spriteView) 261 | scrollView.insertSubview(spriteView, belowSubview: selectionView) 262 | } else { 263 | spriteView = spriteViews[index] 264 | spriteView.frame = frame 265 | spriteView.isHidden = false 266 | } 267 | spriteView.image = name.flatMap { UIImage(sprite: $0, team: team) } 268 | } 269 | 270 | func updateViews() { 271 | var i = 0 272 | 273 | // Draw map 274 | let tilemap = world.map 275 | for y in 0 ..< tilemap.height { 276 | for x in 0 ..< tilemap.width { 277 | let tileView = tileViews[y * tilemap.width + x] 278 | let tile = tilemap.tile(at: TileCoord(x: x, y: y)) 279 | tileView.image = tile.image 280 | } 281 | } 282 | 283 | // Draw buildings 284 | for building in world.buildings { 285 | addSprite( 286 | building.imageName, 287 | team: building.team, 288 | frame: CGRect(building.bounds), 289 | index: i 290 | ) 291 | i += 1 292 | } 293 | 294 | // Draw units 295 | for unit in world.units { 296 | addSprite( 297 | unit.imageName, 298 | team: unit.team, 299 | frame: CGRect(unit.bounds), 300 | index: i 301 | ) 302 | i += 1 303 | } 304 | 305 | // Draw projectiles 306 | // for (i, projectile) in world.projectiles.enumerated() { 307 | // let projectileView: UIView 308 | // if i >= projectileViews.count { 309 | // projectileView = UIView(frame: .zero) 310 | // projectileView.backgroundColor = .yellow 311 | // projectileViews.append(projectileView) 312 | // scrollView.addSubview(projectileView) 313 | // } else { 314 | // projectileView = projectileViews[i] 315 | // } 316 | // projectileView.frame = CGRect( 317 | // x: tileSize.width * CGFloat(projectile.x), 318 | // y: tileSize.height * CGFloat(projectile.y), 319 | // width: tileSize.width, 320 | // height: tileSize.height 321 | // ) 322 | // } 323 | // while projectileViews.count > world.projectiles.count { 324 | // projectileViews.last?.removeFromSuperview() 325 | // projectileViews.removeLast() 326 | // } 327 | 328 | // Draw particles 329 | for particle in world.particles { 330 | addSprite( 331 | particle.imageName, 332 | team: nil, 333 | frame: CGRect(particle.bounds), 334 | index: i 335 | ) 336 | i += 1 337 | } 338 | 339 | // Draw target 340 | if let entity = world.selectedEntity { 341 | var center: Point? 342 | if entity.team == playerTeam, let unit = entity as? Unit { 343 | if let target = world.get(unit.target) { 344 | center = target.bounds.center 345 | } else if let coord = unit.path.last { 346 | center = coord.center 347 | } 348 | } else if world.units.contains(where: { 349 | $0.team == playerTeam && $0.target == entity.id 350 | }) { 351 | center = entity.bounds.center 352 | } 353 | if let center = center { 354 | let bounds = Bounds( 355 | x: center.x - 0.5, 356 | y: center.y - 0.5, 357 | width: 1, 358 | height: 1 359 | ) 360 | addSprite("target", team: nil, frame: CGRect(bounds), index: i) 361 | i += 1 362 | } 363 | } 364 | 365 | // Clear unused sprites 366 | for j in i ..< spriteViews.count { 367 | spriteViews[j].isHidden = true 368 | } 369 | 370 | // Draw reticle 371 | if let entity = world.selectedEntity { 372 | selectionView.frame = CGRect(entity.bounds).inset(by: UIEdgeInsets( 373 | top: -8, 374 | left: -8, 375 | bottom: -8, 376 | right: -8 377 | )) 378 | selectionView.isHidden = false 379 | } else { 380 | selectionView.isHidden = true 381 | } 382 | 383 | // Draw placeholder 384 | if let building = world.selectedBuilding?.building { 385 | var bounds = building.bounds 386 | bounds.x += placeholderDelta.dx 387 | bounds.y += placeholderDelta.dy 388 | placeholderView.frame = CGRect(bounds) 389 | if world.canPlaceBuilding(building, at: bounds) { 390 | placeholderView.backgroundColor = .green.withAlphaComponent(0.5) 391 | } else { 392 | placeholderView.backgroundColor = .red.withAlphaComponent(0.5) 393 | } 394 | placeholderView.isHidden = false 395 | } else { 396 | placeholderView.isHidden = true 397 | } 398 | 399 | // Draw avatar 400 | if let selectedEntity = world.selectedEntity { 401 | avatarView.image = selectedEntity.avatarName.flatMap { 402 | UIImage(sprite: $0, team: selectedEntity.team) 403 | } 404 | let health = selectedEntity.health / selectedEntity.maxHealth 405 | avatarView.progress = health 406 | switch health { 407 | case 0 ..< 0.3: 408 | avatarView.barColor = .red 409 | case 0.3 ..< 0.6: 410 | avatarView.barColor = .yellow 411 | default: 412 | avatarView.barColor = .green 413 | } 414 | avatarView.isHidden = false 415 | } else { 416 | avatarView.menu = nil 417 | avatarView.isHidden = true 418 | } 419 | 420 | // Draw build progress 421 | if let building = world.selectedBuilding, 422 | let construction = building.construction 423 | { 424 | constructionView.image = construction.type.avatarName.flatMap { 425 | UIImage(sprite: $0, team: building.team) 426 | } 427 | constructionView.progress = construction.progress 428 | constructionView.barColor = .cyan 429 | constructionView.isHidden = false 430 | } else { 431 | constructionView.isHidden = true 432 | } 433 | 434 | // Draw credits 435 | if let state = world.teams[playerTeam] { 436 | spiceLabel.text = "$\(state.spice)" 437 | spiceLabel.sizeToFit() 438 | } 439 | 440 | // Draw message 441 | 442 | messageLabel.text = world.message ?? world.selectedEntityDescription 443 | if !avatarView.isHidden { 444 | messageLabel.frame.size = CGSize( 445 | width: avatarView.frame.minX - messageLabel.frame.minX - 16, 446 | height: 100 447 | ) 448 | } else { 449 | messageLabel.frame.size = CGSize( 450 | width: view.bounds.width - messageLabel.frame.minX - 16, 451 | height: 100 452 | ) 453 | } 454 | messageLabel.sizeToFit() 455 | 456 | // Update menu 457 | if let building = world.selectedBuilding { 458 | if building.team != playerTeam { 459 | avatarView.menu = nil 460 | } else if building.building != nil || building.construction != nil { 461 | avatarView.menu = UIMenu(children: [ 462 | UIAction(title: "Cancel") { [weak building] _ in 463 | building?.construction = nil 464 | building?.building = nil 465 | } 466 | ]) 467 | } else { 468 | let assets = world.assets 469 | var buildActions: [UIAction] = [] 470 | for typeID in building.type.constructions ?? [] { 471 | guard let type = assets.entityType(for: typeID) else { 472 | assertionFailure() 473 | continue 474 | } 475 | buildActions.append(UIAction( 476 | title: "Build \(type.name) ($\(type.cost))", 477 | image: type.avatarName.flatMap { 478 | UIImage(sprite: $0, team: building.team) 479 | } 480 | ) { [weak building] _ in 481 | building?.construction = Construction(type: type) 482 | }) 483 | } 484 | if buildActions.isEmpty { 485 | avatarView.menu = nil 486 | } else { 487 | avatarView.menu = UIMenu(children: buildActions) 488 | } 489 | } 490 | } 491 | } 492 | 493 | func tileCoordinate(at location: CGPoint) -> TileCoord { 494 | TileCoord( 495 | x: Int(location.x / tileSize.width), 496 | y: Int(location.y / tileSize.height) 497 | ) 498 | } 499 | 500 | @objc private func didTap(_ gesture: UITapGestureRecognizer) { 501 | let location = gesture.location(in: scrollView) 502 | let coord = tileCoordinate(at: location) 503 | if let building = world.selectedBuilding?.building, 504 | building.bounds.contains(coord) 505 | { 506 | _ = world.placeBuilding(building) 507 | } else if let unit = world.pickUnit(at: coord) { 508 | if unit === world.selectedEntity { 509 | world.selectedEntity = nil 510 | } else { 511 | if let current = world.selectedEntity as? Unit, 512 | current.team == playerTeam, 513 | current.canAttack(unit) 514 | { 515 | current.target = unit.id 516 | current.onAssignment = true 517 | } 518 | world.selectedEntity = unit 519 | } 520 | updateViews() 521 | } else if let building = world.pickBuilding(at: coord) { 522 | if building === world.selectedEntity { 523 | world.selectedEntity = nil 524 | } else { 525 | if let current = world.selectedEntity as? Unit, 526 | current.team == playerTeam, 527 | current.canAttack(building) || current.canEnter(building) 528 | { 529 | current.target = building.id 530 | current.onAssignment = true 531 | } 532 | world.selectedEntity = building 533 | } 534 | updateViews() 535 | } else if let unit = world.selectedEntity as? Unit, unit.team == playerTeam { 536 | world.moveUnit(unit, to: coord) 537 | unit.target = nil 538 | unit.onAssignment = false 539 | } else { 540 | world.selectedEntity = nil 541 | updateViews() 542 | } 543 | } 544 | 545 | private var lastDragLocation: CGPoint = .zero 546 | private var placeholderDelta: CGVector = .zero 547 | @objc private func didDrag(_ gesture: UIPanGestureRecognizer) { 548 | guard let building = world.selectedBuilding?.building else { return } 549 | let location = gesture.location(in: scrollView) 550 | switch gesture.state { 551 | case .began: 552 | lastDragLocation = location 553 | case .changed: 554 | placeholderDelta.dx = (location.x - lastDragLocation.x) / tileSize.width 555 | placeholderDelta.dy = (location.y - lastDragLocation.y) / tileSize.height 556 | case .ended: 557 | building.x += Int(round((location.x - lastDragLocation.x) / tileSize.width)) 558 | building.y += Int(round((location.y - lastDragLocation.y) / tileSize.height)) 559 | placeholderDelta = .zero 560 | case .cancelled, .failed, .possible: 561 | break 562 | @unknown default: 563 | break 564 | } 565 | } 566 | 567 | override func viewDidLayoutSubviews() { 568 | super.viewDidLayoutSubviews() 569 | avatarView.frame.origin = CGPoint( 570 | x: view.frame.width - avatarView.frame.width - view.safeAreaInsets.right - 16, 571 | y: view.safeAreaInsets.top + 16 572 | ) 573 | constructionView.frame.origin = CGPoint( 574 | x: avatarView.frame.minX, 575 | y: avatarView.frame.maxY + 16 576 | ) 577 | pauseButton.frame.origin = CGPoint( 578 | x: view.safeAreaInsets.left + 16, 579 | y: avatarView.frame.minY 580 | ) 581 | spiceLabel.frame.origin = CGPoint( 582 | x: pauseButton.frame.maxX + 16, 583 | y: avatarView.frame.minY - 10 584 | ) 585 | messageLabel.frame.origin = CGPoint( 586 | x: pauseButton.frame.minX, 587 | y: spiceLabel.frame.maxY + 4 588 | ) 589 | } 590 | } 591 | 592 | extension GameViewController: UIScrollViewDelegate { 593 | func scrollViewDidScroll(_ scrollView: UIScrollView) { 594 | world.scrollX = scrollView.contentOffset.x 595 | world.scrollY = scrollView.contentOffset.y 596 | } 597 | } 598 | 599 | extension CGRect { 600 | init(_ bounds: Bounds) { 601 | self.init( 602 | x: bounds.x * tileSize.width, 603 | y: bounds.y * tileSize.height, 604 | width: bounds.width * tileSize.width, 605 | height: bounds.height * tileSize.width 606 | ) 607 | } 608 | } 609 | 610 | extension Tile { 611 | var imageName: String? { 612 | switch self { 613 | case .sand: return "sand" 614 | case .slab: return "slab" 615 | case .stone: return "stone" 616 | case .crater: return "crater" 617 | case .spice: return "spice" 618 | case .heavySpice: return "heavy-spice" 619 | case .boulder: return nil 620 | } 621 | } 622 | 623 | var image: UIImage? { 624 | imageName.flatMap { UIImage(sprite: $0) } 625 | } 626 | } 627 | -------------------------------------------------------------------------------- /Swune/Graphics/construction-yard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/construction-yard.png -------------------------------------------------------------------------------- /Swune/Graphics/crater.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/crater.png -------------------------------------------------------------------------------- /Swune/Graphics/explosion1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/explosion1.png -------------------------------------------------------------------------------- /Swune/Graphics/explosion2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/explosion2.png -------------------------------------------------------------------------------- /Swune/Graphics/explosion3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/explosion3.png -------------------------------------------------------------------------------- /Swune/Graphics/explosion4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/explosion4.png -------------------------------------------------------------------------------- /Swune/Graphics/explosion5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/explosion5.png -------------------------------------------------------------------------------- /Swune/Graphics/explosion6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/explosion6.png -------------------------------------------------------------------------------- /Swune/Graphics/factory.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/factory.png -------------------------------------------------------------------------------- /Swune/Graphics/harvester-e-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/harvester-e-blue.png -------------------------------------------------------------------------------- /Swune/Graphics/harvester-e-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/harvester-e-red.png -------------------------------------------------------------------------------- /Swune/Graphics/harvester-n-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/harvester-n-blue.png -------------------------------------------------------------------------------- /Swune/Graphics/harvester-n-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/harvester-n-red.png -------------------------------------------------------------------------------- /Swune/Graphics/harvester-ne-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/harvester-ne-blue.png -------------------------------------------------------------------------------- /Swune/Graphics/harvester-ne-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/harvester-ne-red.png -------------------------------------------------------------------------------- /Swune/Graphics/harvester-nw-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/harvester-nw-blue.png -------------------------------------------------------------------------------- /Swune/Graphics/harvester-nw-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/harvester-nw-red.png -------------------------------------------------------------------------------- /Swune/Graphics/harvester-s-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/harvester-s-blue.png -------------------------------------------------------------------------------- /Swune/Graphics/harvester-s-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/harvester-s-red.png -------------------------------------------------------------------------------- /Swune/Graphics/harvester-se-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/harvester-se-blue.png -------------------------------------------------------------------------------- /Swune/Graphics/harvester-se-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/harvester-se-red.png -------------------------------------------------------------------------------- /Swune/Graphics/harvester-sw-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/harvester-sw-blue.png -------------------------------------------------------------------------------- /Swune/Graphics/harvester-sw-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/harvester-sw-red.png -------------------------------------------------------------------------------- /Swune/Graphics/harvester-w-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/harvester-w-blue.png -------------------------------------------------------------------------------- /Swune/Graphics/harvester-w-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/harvester-w-red.png -------------------------------------------------------------------------------- /Swune/Graphics/heavy-spice.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/heavy-spice.png -------------------------------------------------------------------------------- /Swune/Graphics/pause.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/pause.png -------------------------------------------------------------------------------- /Swune/Graphics/refinery-active1-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/refinery-active1-blue.png -------------------------------------------------------------------------------- /Swune/Graphics/refinery-active1-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/refinery-active1-red.png -------------------------------------------------------------------------------- /Swune/Graphics/refinery-active2-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/refinery-active2-blue.png -------------------------------------------------------------------------------- /Swune/Graphics/refinery-active2-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/refinery-active2-red.png -------------------------------------------------------------------------------- /Swune/Graphics/refinery-active3-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/refinery-active3-blue.png -------------------------------------------------------------------------------- /Swune/Graphics/refinery-active3-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/refinery-active3-red.png -------------------------------------------------------------------------------- /Swune/Graphics/refinery-active4-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/refinery-active4-blue.png -------------------------------------------------------------------------------- /Swune/Graphics/refinery-active4-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/refinery-active4-red.png -------------------------------------------------------------------------------- /Swune/Graphics/refinery-active5-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/refinery-active5-blue.png -------------------------------------------------------------------------------- /Swune/Graphics/refinery-active5-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/refinery-active5-red.png -------------------------------------------------------------------------------- /Swune/Graphics/refinery-active6-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/refinery-active6-blue.png -------------------------------------------------------------------------------- /Swune/Graphics/refinery-active6-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/refinery-active6-red.png -------------------------------------------------------------------------------- /Swune/Graphics/refinery.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/refinery.png -------------------------------------------------------------------------------- /Swune/Graphics/refinery1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/refinery1.png -------------------------------------------------------------------------------- /Swune/Graphics/refinery2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/refinery2.png -------------------------------------------------------------------------------- /Swune/Graphics/refinery3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/refinery3.png -------------------------------------------------------------------------------- /Swune/Graphics/refinery4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/refinery4.png -------------------------------------------------------------------------------- /Swune/Graphics/refinery5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/refinery5.png -------------------------------------------------------------------------------- /Swune/Graphics/refinery6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/refinery6.png -------------------------------------------------------------------------------- /Swune/Graphics/reticle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/reticle.png -------------------------------------------------------------------------------- /Swune/Graphics/sand.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/sand.png -------------------------------------------------------------------------------- /Swune/Graphics/silo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/silo.png -------------------------------------------------------------------------------- /Swune/Graphics/slab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/slab.png -------------------------------------------------------------------------------- /Swune/Graphics/small-explosion1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/small-explosion1.png -------------------------------------------------------------------------------- /Swune/Graphics/small-explosion2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/small-explosion2.png -------------------------------------------------------------------------------- /Swune/Graphics/small-explosion3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/small-explosion3.png -------------------------------------------------------------------------------- /Swune/Graphics/small-explosion4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/small-explosion4.png -------------------------------------------------------------------------------- /Swune/Graphics/small-explosion5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/small-explosion5.png -------------------------------------------------------------------------------- /Swune/Graphics/small-explosion6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/small-explosion6.png -------------------------------------------------------------------------------- /Swune/Graphics/smoke1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/smoke1.png -------------------------------------------------------------------------------- /Swune/Graphics/smoke2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/smoke2.png -------------------------------------------------------------------------------- /Swune/Graphics/smoke3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/smoke3.png -------------------------------------------------------------------------------- /Swune/Graphics/smoke4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/smoke4.png -------------------------------------------------------------------------------- /Swune/Graphics/smoke5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/smoke5.png -------------------------------------------------------------------------------- /Swune/Graphics/smoke6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/smoke6.png -------------------------------------------------------------------------------- /Swune/Graphics/smoke7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/smoke7.png -------------------------------------------------------------------------------- /Swune/Graphics/spice.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/spice.png -------------------------------------------------------------------------------- /Swune/Graphics/stone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/stone.png -------------------------------------------------------------------------------- /Swune/Graphics/target.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/target.png -------------------------------------------------------------------------------- /Swune/Graphics/title.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/title.png -------------------------------------------------------------------------------- /Swune/Graphics/trike-e-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/trike-e-blue.png -------------------------------------------------------------------------------- /Swune/Graphics/trike-e-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/trike-e-red.png -------------------------------------------------------------------------------- /Swune/Graphics/trike-n-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/trike-n-blue.png -------------------------------------------------------------------------------- /Swune/Graphics/trike-n-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/trike-n-red.png -------------------------------------------------------------------------------- /Swune/Graphics/trike-ne-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/trike-ne-blue.png -------------------------------------------------------------------------------- /Swune/Graphics/trike-ne-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/trike-ne-red.png -------------------------------------------------------------------------------- /Swune/Graphics/trike-nw-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/trike-nw-blue.png -------------------------------------------------------------------------------- /Swune/Graphics/trike-nw-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/trike-nw-red.png -------------------------------------------------------------------------------- /Swune/Graphics/trike-s-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/trike-s-blue.png -------------------------------------------------------------------------------- /Swune/Graphics/trike-s-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/trike-s-red.png -------------------------------------------------------------------------------- /Swune/Graphics/trike-se-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/trike-se-blue.png -------------------------------------------------------------------------------- /Swune/Graphics/trike-se-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/trike-se-red.png -------------------------------------------------------------------------------- /Swune/Graphics/trike-sw-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/trike-sw-blue.png -------------------------------------------------------------------------------- /Swune/Graphics/trike-sw-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/trike-sw-red.png -------------------------------------------------------------------------------- /Swune/Graphics/trike-w-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/trike-w-blue.png -------------------------------------------------------------------------------- /Swune/Graphics/trike-w-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklockwood/Swune/d5c900f84e22a2d5d63e65a33be836bad13392d1/Swune/Graphics/trike-w-red.png -------------------------------------------------------------------------------- /Swune/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | UIApplicationSceneManifest 6 | 7 | UIApplicationSupportsMultipleScenes 8 | 9 | UISceneConfigurations 10 | 11 | UIWindowSceneSessionRoleApplication 12 | 13 | 14 | UISceneConfigurationName 15 | Default Configuration 16 | UISceneDelegateClassName 17 | $(PRODUCT_MODULE_NAME).SceneDelegate 18 | UISceneStoryboardFile 19 | Main 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Swune/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegate.swift 3 | // Swune 4 | // 5 | // Created by Nick Lockwood on 13/02/2022. 6 | // 7 | 8 | import UIKit 9 | 10 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 11 | 12 | var window: UIWindow? 13 | 14 | 15 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 16 | // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. 17 | // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. 18 | // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). 19 | guard let _ = (scene as? UIWindowScene) else { return } 20 | } 21 | 22 | func sceneDidDisconnect(_ scene: UIScene) { 23 | // Called as the scene is being released by the system. 24 | // This occurs shortly after the scene enters the background, or when its session is discarded. 25 | // Release any resources associated with this scene that can be re-created the next time the scene connects. 26 | // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). 27 | } 28 | 29 | func sceneDidBecomeActive(_ scene: UIScene) { 30 | // Called when the scene has moved from an inactive state to an active state. 31 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. 32 | } 33 | 34 | func sceneWillResignActive(_ scene: UIScene) { 35 | // Called when the scene will move from an active state to an inactive state. 36 | // This may occur due to temporary interruptions (ex. an incoming phone call). 37 | } 38 | 39 | func sceneWillEnterForeground(_ scene: UIScene) { 40 | // Called as the scene transitions from the background to the foreground. 41 | // Use this method to undo the changes made on entering the background. 42 | } 43 | 44 | func sceneDidEnterBackground(_ scene: UIScene) { 45 | // Called as the scene transitions from the foreground to the background. 46 | // Use this method to save data, release shared resources, and store enough scene-specific state information 47 | // to restore the scene back to its current state. 48 | } 49 | 50 | 51 | } 52 | 53 | -------------------------------------------------------------------------------- /Swune/TitleViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TitleViewController.swift 3 | // Swune 4 | // 5 | // Created by Nick Lockwood on 13/03/2022. 6 | // 7 | 8 | import UIKit 9 | 10 | class TitleViewController: UIViewController { 11 | private let imageView = UIImageView() 12 | private let titleLabel = UILabel() 13 | private var buttons = [UIButton]() 14 | 15 | override func viewDidLoad() { 16 | super.viewDidLoad() 17 | 18 | let imageURL = Bundle.main.url( 19 | forResource: "title", 20 | withExtension: "png", 21 | subdirectory: "Graphics" 22 | ) 23 | imageView.image = (imageURL?.path).map(UIImage.init) 24 | imageView.contentMode = .scaleAspectFill 25 | imageView.center = view.center 26 | imageView.layer.magnificationFilter = .nearest 27 | imageView.frame = view.bounds 28 | imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight] 29 | view.addSubview(imageView) 30 | 31 | titleLabel.configure(withSize: 10) 32 | titleLabel.text = "Swune II" 33 | titleLabel.sizeToFit() 34 | view.addSubview(titleLabel) 35 | 36 | let assets = loadAssets() 37 | 38 | if let state = loadState() { 39 | addButton("Continue") { [weak self] in 40 | guard let world = restoreState(state, with: assets) else { 41 | let alert = UIAlertController( 42 | title: "Error", 43 | message: "Unable to restore saved games", 44 | preferredStyle: .alert 45 | ) 46 | alert.addAction(UIAlertAction( 47 | title: "OK", 48 | style: .default 49 | ) { _ in }) 50 | self?.present(alert, animated: true) 51 | return 52 | } 53 | let gameController = GameViewController(world: world) 54 | gameController.modalTransitionStyle = .crossDissolve 55 | gameController.modalPresentationStyle = .fullScreen 56 | self?.present(gameController, animated: true) 57 | } 58 | } 59 | 60 | addButton("New Game") { [weak self] in 61 | let level = loadLevel() 62 | let world = World(level: level, assets: assets) 63 | let gameController = GameViewController(world: world) 64 | gameController.modalTransitionStyle = .crossDissolve 65 | gameController.modalPresentationStyle = .fullScreen 66 | self?.present(gameController, animated: true) 67 | } 68 | } 69 | 70 | override func viewDidLayoutSubviews() { 71 | super.viewDidLayoutSubviews() 72 | 73 | titleLabel.center = CGPoint( 74 | x: view.bounds.midX, 75 | y: view.bounds.height * 0.2) 76 | 77 | var start = CGPoint( 78 | x: titleLabel.center.x, 79 | y: view.bounds.height - view.bounds.height * 0.6 80 | ) 81 | for button in buttons { 82 | start.y += 64 83 | button.center = start 84 | } 85 | } 86 | } 87 | 88 | private extension TitleViewController { 89 | func addButton(_ text: String, action: @escaping () -> Void) { 90 | let label = UILabel() 91 | label.configure(withSize: 6) 92 | label.text = text 93 | label.sizeToFit() 94 | let inset: CGFloat = 12 95 | let button = UIButton(frame: CGRect( 96 | origin: .zero, 97 | size: label.frame.size 98 | ).inset(by: .init( 99 | top: -inset, 100 | left: -inset, 101 | bottom: -inset, 102 | right: -inset 103 | ))) 104 | label.frame.origin = CGPoint(x: inset, y: inset) 105 | button.addSubview(label) 106 | button.addAction(UIAction(handler: { _ in 107 | action() 108 | }), for: .touchUpInside) 109 | view.addSubview(button) 110 | buttons.append(button) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /Swune/UIImage+Swune.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIImage+Swune.swift 3 | // Swune 4 | // 5 | // Created by Nick Lockwood on 20/02/2022. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UIImage { 11 | private static var cache: [Sprite: CGImage] = [:] 12 | 13 | convenience init?(sprite: Sprite, team: Int? = nil) { 14 | var name = sprite 15 | switch team { 16 | case 1: 17 | name += "-blue" 18 | case 2: 19 | name += "-red" 20 | default: 21 | break 22 | } 23 | if let cgImage = Self.cache[sprite] { 24 | self.init(cgImage: cgImage) 25 | return 26 | } 27 | guard let url = Bundle.main.url( 28 | forResource: name, 29 | withExtension: "png", 30 | subdirectory: "Graphics" 31 | ) else { 32 | guard team != nil else { 33 | return nil 34 | } 35 | self.init(sprite: sprite) 36 | return 37 | } 38 | self.init(contentsOfFile: url.path) 39 | Self.cache[name] = cgImage 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Swune/UILabel+Swune.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UILable+Swune.swift 3 | // Swune 4 | // 5 | // Created by Nick Lockwood on 13/03/2022. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UILabel { 11 | func configure(withSize size: Int) { 12 | font = .init(name: "Copperplate", size: CGFloat(size)) 13 | textColor = .white 14 | let offset = size < 8 ? 0.32 : 0.64 15 | layer.shadowOffset = CGSize(width: offset, height: offset) 16 | layer.shadowOpacity = 1 17 | layer.shadowRadius = 0 18 | layer.magnificationFilter = .nearest 19 | transform = CGAffineTransform(scaleX: 8, y: 8) 20 | } 21 | } 22 | --------------------------------------------------------------------------------