diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..c55d306 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,7 @@ +language: swift +osx_image: xcode10.1 + +script: + - cd Source + - xcodebuild clean build -scheme Rampage -destination 'platform=iOS Simulator,name=iPhone XR,OS=12.1' + - xcodebuild clean test -scheme Rampage -destination 'platform=iOS Simulator,name=iPhone XR,OS=12.1' diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e5a131d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,80 @@ +## Change Log + +Occasionally bugs happen, and given the episodic nature of this tutorial, it is difficult to address these retrospectively without changing the Git commit history. + +This file is a record of bugs that have been found and fixed since the tutorial started. The dates next to each bug indicate when the fix was merged. If you completed the relevant tutorial(s) after the date listed for a given bug, you can safely ignore it. + +### Flipped Floor and Ceiling (2019/09/27) + +The original logic in [Part 9](Tutorial/Part9.md) for rotating the textures to compensate for switching to column-first pixel order had the side-effect of flipping the Z-axis. This resulted in the floor texture being drawn on the ceiling, and vice-versa (thanks to [Adam McNight](https://twitter.com/adamcnight/status/1174323711710781442?s=20) for reporting). + +The fix for this was to change two lines in `UIImage+Bitmap.swift`. First, in `UIImage.init()` change: + +```swift +self.init(cgImage: cgImage, scale: 1, orientation: .left) +``` + +to: + +```swift +self.init(cgImage: cgImage, scale: 1, orientation: .leftMirrored) +``` + +Then in `Bitmap.init()` change: + +```swift +UIImage(cgImage: cgImage, scale: 1, orientation: .rightMirrored).draw(at: .zero) +``` + +to: + +```swift +UIImage(cgImage: cgImage, scale: 1, orientation: .left).draw(at: .zero) +``` + +### Wall Collisions (2019/08/19) + +The original wall collision detection code described in [Part 2](Tutorial/Part2.md) had a bug that could cause the player to stick when sliding along a wall (thanks to [José Ibañez](https://twitter.com/jose_ibanez/status/1163225777401401344?s=20) for reporting). + +The fix for this was to return the largest intersection detected between any wall segment, rather than just the first intersection detected. The necessary code changes are in `Actor.intersection(with map:)`, which should now look like this: + +```swift +func intersection(with map: Tilemap) -> Vector? { + let minX = Int(rect.min.x), maxX = Int(rect.max.x) + let minY = Int(rect.min.y), maxY = Int(rect.max.y) + var largestIntersection: Vector? + for y in minY ... maxY { + for x in minX ... maxX where map[x, y].isWall { + let wallRect = Rect( + min: Vector(x: Double(x), y: Double(y)), + max: Vector(x: Double(x + 1), y: Double(y + 1)) + ) + if let intersection = rect.intersection(with: wallRect), + intersection.length > largestIntersection?.length ?? 0 { + largestIntersection = intersection + } + } + } + return largestIntersection +} +``` + +### Sprite Rendering (2019/08/02) + +In the original version of [Part 5](Tutorial/Part5.md) there were a couple of bugs in the sprite texture coordinate calculation. In your own project, check if the `// Draw sprites` section in `Renderer.swift` contains the following two lines: + +```swift +let textureX = Int(spriteX * Double(wallTexture.width)) +let spriteTexture = textures[sprite.texture] +``` + +If so, replace them with: + +```swift +let spriteTexture = textures[sprite.texture] +let textureX = min(Int(spriteX * Double(spriteTexture.width)), spriteTexture.width - 1) +``` + +### Texture Capitalization (2019/07/28) + +This isn't exactly a bug, but the capitalization of texture image keys changed from `lowercase` to `camelCase` after [Part 5](Tutorial/Part5.md) was released. If you've been using the tutorial textures in your own project, watch out for this when updating as some of the asset file names may not match up with the keys in `Textures.swift`. diff --git a/README.md b/README.md index 236374a..2285d4b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,111 @@ ## Retro Rampage -This repository contains a code snapshot for the [Retro Rampage tutorial series](https://github.com/nicklockwood/RetroRampage) by Nick Lockwood. +[![PayPal](https://img.shields.io/badge/paypal-donate-blue.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=CR6YX6DLRNJTY&source=url) +[![Travis](https://travis-ci.org/nicklockwood/RetroRampage.svg)](https://travis-ci.org/nicklockwood/RetroRampage) +[![Swift 4.2](https://img.shields.io/badge/swift-4.2-red.svg?style=flat)](https://developer.apple.com/swift) +[![License](https://img.shields.io/badge/license-MIT-lightgrey.svg)](https://opensource.org/licenses/MIT) +[![Twitter](https://img.shields.io/badge/twitter-@nicklockwood-blue.svg)](http://twitter.com/nicklockwood) + +![Screenshot](Tutorial/Images/FunctioningDoor.png) + +### About + +Retro Rampage is a tutorial series in which you will learn how to build a Wolfenstein-like game from scratch, in Swift. Initially the game will be targeting iPhone and iPad, but the engine should work on any platform that can run Swift code. + +Modern shooters have moved on a bit from Wolfenstein's grid-based 2.5D world, but we're going to stick with that template for a few reasons: + +* It's feasible to build Wolfenstein's 3D engine from scratch, without a lot of complicated math and without needing to know anything about GPUs or shaders. + +* It's simple to create and visualize maps that are constructed on a 2D grid, avoiding the complexities of 3D modeling and animation tools. + +* Tile grids are an excellent way to prototype techniques such as procedural map generation, pathfinding and line-of-sight calculations, which can then be applied to more complex worlds. + +### Background + +Ever since I first played Wolfenstein 3D on a friend's battered old 386 back in 1993, I was hooked on the *First-Person Shooter*. + +As an aspiring programmer, I wanted to recreate what I had seen. But armed only with 7th grade math and a rudimentary knowledge of BASIC, recreating the state-of-the-art in modern PC 3D graphics was hopelessly beyond my reach. + +More than two decades later, a few things have changed: + +We have the iPhone - a mobile computer many hundreds of times more powerful than a DOS-era desktop PC; We have Swift - a simple, powerful programming language with which to write apps and games; Finally - and most importantly - we have the Wolfenstein source code, and the wizardry behind it has been thoroughly demystified. + +I guess now is as good a time as any to scratch that quarter-century itch and build an FPS! + +### Tutorials + +The tutorials below are designed to be completed in order, and each step builds on the code from the previous one. If you decide to skip ahead, project snapshots for each step are available [here](https://github.com/nicklockwood/RetroRampage/releases). + +The tutorials are written with the assumption that you are already familiar with Xcode and are comfortable setting up an iOS project and adding new files to it. No knowledge of advanced Swift features is required, so it's fine if you've only used Objective-C or other C-like languages. + +[Part 1 - Separation of Concerns](Tutorial/Part1.md) + +Unlike most apps, games are typically designed to be independent of any given device or OS. Swift has already been ported to many platforms outside of the Apple ecosystem, including Android, Ubuntu, Windows and even Raspberry Pi. In this first part, we'll set up our project to minimize dependencies with iOS and provide a solid foundation for writing a fully portable game engine. + +[Part 2 - Mazes and Motion](Tutorial/Part2.md) + +Wolfenstein 3D is really a 2D game projected into the third dimension. The game mechanics work exactly the same as for a top-down 2D shooter, and to prove that we'll begin by building the game from a top-down 2D perspective before we make the shift to first-person 3D. + +[Part 3 - Ray Casting](Tutorial/Part3.md) + +Long before hardware accelerated 3D graphics, some of the greatest game programmers of our generation were creating incredible 3D worlds armed only with a 16-bit processor. We'll follow in their footsteps and bring our game into the third dimension with an old-school graphics hack called *ray casting*. + +[Part 4 - Texture Mapping](Tutorial/Part4.md) + +In this chapter we'll spruce up the bare walls and floor with *texture mapping*. Texture mapping is the process of painting or *wall-papering* a 3D object with a 2D image, helping to provide the appearance of intricate detail in an otherwise featureless surface. + +[Part 5 - Sprites](Tutorial/Part5.md) + +It's time to introduce some monsters to keep our player company. We'll display these using *sprites* - a popular technique used to add engaging content to 3D games in the days before it was possible to render textured polygonal models in real-time with sufficient detail. + +[Part 6 - Enemy Action](Tutorial/Part6.md) + +Right now the monsters in the maze are little more than gruesome scenery. We'll bring those passive monsters to life with collision detection, animations, and artificial intelligence so they can hunt and attack the player. + +[Part 7 - Death and Pixels](Tutorial/Part7.md) + +In this part we'll implement player damage, giving the monsters the ability to hurt and eventually kill the game's protagonist. We'll explore a variety of damage effects and techniques, including a cool Wolfenstein transition called *fizzlefade*. + +[Part 8 - Target Practice](Tutorial/Part8.md) + +We'll now give the player a weapon so they can fight back against the ravenous monsters. This chapter will demonstrate how to extend our drawing logic to handle screen-space sprites, add a bunch of new animations, and figure out how to implement reliable collision detection for fast-moving projectiles. + +[Part 9 - Performance Tuning](Tutorial/Part9.md) + +The new effects we've added are starting to take a toll on the game's frame rate, especially on older devices. Let's take a break from adding new features and spend some time on improving the rendering speed. In this chapter we'll find out how to diagnose and fix performance bottlenecks, while avoiding the kind of micro-optimizations that will make it harder to add new features later on. + +[Part 10 - Sliding Doors](Tutorial/Part10.md) + +In this chapter we add another iconic feature from Wolfenstein - the sliding metal doors between rooms. These add some interesting challenges as the first non-static, non-grid-aligned scenery in the game. + +### Bugs + +I've occasionally made retrospective fixes after a tutorial chapter was published. This will normally be called out in a later tutorial if it directly impacts any new code, but it's a good idea to periodically check the [CHANGELOG](CHANGELOG.md) for fixes. + +### Acknowledgments + +I'd like to thank [Nat Brown](https://github.com/natbro) and [PJ Cook](https://github.com/pjcook) for their invaluable feedback on the first draft of these tutorials. + +Thanks also to [Lode Vandevenne](https://github.com/lvandeve) and [Fabien Sanglard](https://github.com/fabiensanglard/), whom I've never actually spoken to, but whose brilliant explanations of ray casting and the Wolfenstein engine formed both the basis and inspiration for this tutorial series. + +### Experiments + +If you're up to date with the tutorials, and can't wait for the next chapter, you might like to check out some of the [experimental PRs](https://github.com/nicklockwood/RetroRampage/pulls) on Github. + +These experiments demonstrate advanced features that we aren't quite ready to explore in the tutorials yet. + +### Further Reading + +If you've exhausted the tutorials and experiments and are still eager to learn more, here are some resources you might find useful: + +* [Lode's Raycasting Tutorial](https://lodev.org/cgtutor/raycasting.html#Introduction) - A great tutorial on ray casting, implemented in C++. +* [Game Engine Black Book: Wolfenstein 3D](https://www.amazon.co.uk/gp/product/1727646703/ref=as_li_tl?ie=UTF8&camp=1634&creative=6738&creativeASIN=1727646703&linkCode=as2&tag=charcoaldesig-21&linkId=aab5d43499c96f7417b7aa0a7b3e587d) - Fabien Sanglard's excellent book about the Wolfenstein 3D game engine. +* [Swiftenstein](https://github.com/nicklockwood/Swiftenstein) - A more complete but less polished implementation of the ideas covered in this tutorial. +* [Handmade Hero](https://handmadehero.org) - A video series in which games industry veteran [Casey Muratori](https://github.com/cmuratori) builds a game from scratch in C. + +### Tip Jar + +I started this tutorial series thinking it would take just a few days. Many months later, with no end in sight, I realize I may have been a bit naive. If you've found it interesting, please consider donating to my caffeine fund. + +[![Donate via PayPal](https://www.paypalobjects.com/en_GB/i/btn/btn_donate_LG.gif)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=CR6YX6DLRNJTY&source=url) + diff --git a/Tutorial/Images/AddUnitTestingBundle.png b/Tutorial/Images/AddUnitTestingBundle.png new file mode 100644 index 0000000..af3480b Binary files /dev/null and b/Tutorial/Images/AddUnitTestingBundle.png differ diff --git a/Tutorial/Images/Architecture.png b/Tutorial/Images/Architecture.png new file mode 100644 index 0000000..e0f0324 Binary files /dev/null and b/Tutorial/Images/Architecture.png differ diff --git a/Tutorial/Images/AttackAnimation.png b/Tutorial/Images/AttackAnimation.png new file mode 100644 index 0000000..c3a83e8 Binary files /dev/null and b/Tutorial/Images/AttackAnimation.png differ diff --git a/Tutorial/Images/BigPixelFizzle.png b/Tutorial/Images/BigPixelFizzle.png new file mode 100644 index 0000000..79c511d Binary files /dev/null and b/Tutorial/Images/BigPixelFizzle.png differ diff --git a/Tutorial/Images/BitmapOpacityTest.png b/Tutorial/Images/BitmapOpacityTest.png new file mode 100644 index 0000000..0f563ae Binary files /dev/null and b/Tutorial/Images/BitmapOpacityTest.png differ diff --git a/Tutorial/Images/BlackSpriteBackground.png b/Tutorial/Images/BlackSpriteBackground.png new file mode 100644 index 0000000..ee4e68e Binary files /dev/null and b/Tutorial/Images/BlackSpriteBackground.png differ diff --git a/Tutorial/Images/BlendColorCrash.png b/Tutorial/Images/BlendColorCrash.png new file mode 100644 index 0000000..f34618b Binary files /dev/null and b/Tutorial/Images/BlendColorCrash.png differ diff --git a/Tutorial/Images/BlurryBluePixel.png b/Tutorial/Images/BlurryBluePixel.png new file mode 100644 index 0000000..4f952d4 Binary files /dev/null and b/Tutorial/Images/BlurryBluePixel.png differ diff --git a/Tutorial/Images/ClearSpriteBackground.png b/Tutorial/Images/ClearSpriteBackground.png new file mode 100644 index 0000000..e14821b Binary files /dev/null and b/Tutorial/Images/ClearSpriteBackground.png differ diff --git a/Tutorial/Images/CollisionLoop.png b/Tutorial/Images/CollisionLoop.png new file mode 100644 index 0000000..47e9044 Binary files /dev/null and b/Tutorial/Images/CollisionLoop.png differ diff --git a/Tutorial/Images/CollisionResponse.png b/Tutorial/Images/CollisionResponse.png new file mode 100644 index 0000000..7d707fb Binary files /dev/null and b/Tutorial/Images/CollisionResponse.png differ diff --git a/Tutorial/Images/ColumnOrderTest.png b/Tutorial/Images/ColumnOrderTest.png new file mode 100644 index 0000000..288f964 Binary files /dev/null and b/Tutorial/Images/ColumnOrderTest.png differ diff --git a/Tutorial/Images/CurvedWalls.png b/Tutorial/Images/CurvedWalls.png new file mode 100644 index 0000000..4b4b7b6 Binary files /dev/null and b/Tutorial/Images/CurvedWalls.png differ diff --git a/Tutorial/Images/DeathEffectTimeline.png b/Tutorial/Images/DeathEffectTimeline.png new file mode 100644 index 0000000..e5d8d4a Binary files /dev/null and b/Tutorial/Images/DeathEffectTimeline.png differ diff --git a/Tutorial/Images/DisableSafetyChecks.png b/Tutorial/Images/DisableSafetyChecks.png new file mode 100644 index 0000000..fb813f8 Binary files /dev/null and b/Tutorial/Images/DisableSafetyChecks.png differ diff --git a/Tutorial/Images/DoorInCorner.png b/Tutorial/Images/DoorInCorner.png new file mode 100644 index 0000000..d253635 Binary files /dev/null and b/Tutorial/Images/DoorInCorner.png differ diff --git a/Tutorial/Images/DoorRays.png b/Tutorial/Images/DoorRays.png new file mode 100644 index 0000000..9dfe8ae Binary files /dev/null and b/Tutorial/Images/DoorRays.png differ diff --git a/Tutorial/Images/DoorTextures.png b/Tutorial/Images/DoorTextures.png new file mode 100644 index 0000000..f837f97 Binary files /dev/null and b/Tutorial/Images/DoorTextures.png differ diff --git a/Tutorial/Images/Doorjamb.png b/Tutorial/Images/Doorjamb.png new file mode 100644 index 0000000..32dd5e0 Binary files /dev/null and b/Tutorial/Images/Doorjamb.png differ diff --git a/Tutorial/Images/DoorjambTextures.png b/Tutorial/Images/DoorjambTextures.png new file mode 100644 index 0000000..68d4e2c Binary files /dev/null and b/Tutorial/Images/DoorjambTextures.png differ diff --git a/Tutorial/Images/DraggableJoystick.png b/Tutorial/Images/DraggableJoystick.png new file mode 100644 index 0000000..fb5b530 Binary files /dev/null and b/Tutorial/Images/DraggableJoystick.png differ diff --git a/Tutorial/Images/EasingCurves.png b/Tutorial/Images/EasingCurves.png new file mode 100644 index 0000000..4d02ee3 Binary files /dev/null and b/Tutorial/Images/EasingCurves.png differ diff --git a/Tutorial/Images/FieldOfView.png b/Tutorial/Images/FieldOfView.png new file mode 100644 index 0000000..7d2a793 Binary files /dev/null and b/Tutorial/Images/FieldOfView.png differ diff --git a/Tutorial/Images/FizzleFade.png b/Tutorial/Images/FizzleFade.png new file mode 100644 index 0000000..0146ff1 Binary files /dev/null and b/Tutorial/Images/FizzleFade.png differ diff --git a/Tutorial/Images/FloatingJoystick.png b/Tutorial/Images/FloatingJoystick.png new file mode 100644 index 0000000..4261fb0 Binary files /dev/null and b/Tutorial/Images/FloatingJoystick.png differ diff --git a/Tutorial/Images/FunctioningDoor.png b/Tutorial/Images/FunctioningDoor.png new file mode 100644 index 0000000..9e7ccb2 Binary files /dev/null and b/Tutorial/Images/FunctioningDoor.png differ diff --git a/Tutorial/Images/HurtThroughDoor.png b/Tutorial/Images/HurtThroughDoor.png new file mode 100644 index 0000000..4a4f130 Binary files /dev/null and b/Tutorial/Images/HurtThroughDoor.png differ diff --git a/Tutorial/Images/ImprovedTextureTrace.png b/Tutorial/Images/ImprovedTextureTrace.png new file mode 100644 index 0000000..0e903f6 Binary files /dev/null and b/Tutorial/Images/ImprovedTextureTrace.png differ diff --git a/Tutorial/Images/InlineSetterTest.png b/Tutorial/Images/InlineSetterTest.png new file mode 100644 index 0000000..808de3a Binary files /dev/null and b/Tutorial/Images/InlineSetterTest.png differ diff --git a/Tutorial/Images/Lighting.png b/Tutorial/Images/Lighting.png new file mode 100644 index 0000000..a07a152 Binary files /dev/null and b/Tutorial/Images/Lighting.png differ diff --git a/Tutorial/Images/LineDrawing.png b/Tutorial/Images/LineDrawing.png new file mode 100644 index 0000000..cc50337 Binary files /dev/null and b/Tutorial/Images/LineDrawing.png differ diff --git a/Tutorial/Images/LineOfSight.png b/Tutorial/Images/LineOfSight.png new file mode 100644 index 0000000..bf6f667 Binary files /dev/null and b/Tutorial/Images/LineOfSight.png differ diff --git a/Tutorial/Images/LinearAccessTest.png b/Tutorial/Images/LinearAccessTest.png new file mode 100644 index 0000000..c23e2f8 Binary files /dev/null and b/Tutorial/Images/LinearAccessTest.png differ diff --git a/Tutorial/Images/MemoryHierarchy.png b/Tutorial/Images/MemoryHierarchy.png new file mode 100644 index 0000000..1b78c50 Binary files /dev/null and b/Tutorial/Images/MemoryHierarchy.png differ diff --git a/Tutorial/Images/MonsterDeath.png b/Tutorial/Images/MonsterDeath.png new file mode 100644 index 0000000..0e9f219 Binary files /dev/null and b/Tutorial/Images/MonsterDeath.png differ diff --git a/Tutorial/Images/MonsterInWall.png b/Tutorial/Images/MonsterInWall.png new file mode 100644 index 0000000..b97773c Binary files /dev/null and b/Tutorial/Images/MonsterInWall.png differ diff --git a/Tutorial/Images/MonsterInWall2.png b/Tutorial/Images/MonsterInWall2.png new file mode 100644 index 0000000..5f8a9d2 Binary files /dev/null and b/Tutorial/Images/MonsterInWall2.png differ diff --git a/Tutorial/Images/MonsterMob.png b/Tutorial/Images/MonsterMob.png new file mode 100644 index 0000000..ec5ab3b Binary files /dev/null and b/Tutorial/Images/MonsterMob.png differ diff --git a/Tutorial/Images/MonsterSprite.png b/Tutorial/Images/MonsterSprite.png new file mode 100644 index 0000000..0f2a385 Binary files /dev/null and b/Tutorial/Images/MonsterSprite.png differ diff --git a/Tutorial/Images/MonsterSurprise.png b/Tutorial/Images/MonsterSurprise.png new file mode 100644 index 0000000..90f3b17 Binary files /dev/null and b/Tutorial/Images/MonsterSurprise.png differ diff --git a/Tutorial/Images/MonstersAttacking.png b/Tutorial/Images/MonstersAttacking.png new file mode 100644 index 0000000..98ae383 Binary files /dev/null and b/Tutorial/Images/MonstersAttacking.png differ diff --git a/Tutorial/Images/MuzzleFlash.png b/Tutorial/Images/MuzzleFlash.png new file mode 100644 index 0000000..6d86da9 Binary files /dev/null and b/Tutorial/Images/MuzzleFlash.png differ diff --git a/Tutorial/Images/NoInliningSubscript.png b/Tutorial/Images/NoInliningSubscript.png new file mode 100644 index 0000000..e8e5a67 Binary files /dev/null and b/Tutorial/Images/NoInliningSubscript.png differ diff --git a/Tutorial/Images/OptimizedBlendTrace.png b/Tutorial/Images/OptimizedBlendTrace.png new file mode 100644 index 0000000..a834738 Binary files /dev/null and b/Tutorial/Images/OptimizedBlendTrace.png differ diff --git a/Tutorial/Images/OptimizedOrderTrace.png b/Tutorial/Images/OptimizedOrderTrace.png new file mode 100644 index 0000000..5ba39cb Binary files /dev/null and b/Tutorial/Images/OptimizedOrderTrace.png differ diff --git a/Tutorial/Images/OverlappingAccess.png b/Tutorial/Images/OverlappingAccess.png new file mode 100644 index 0000000..6f87c8d Binary files /dev/null and b/Tutorial/Images/OverlappingAccess.png differ diff --git a/Tutorial/Images/PerspectiveView.png b/Tutorial/Images/PerspectiveView.png new file mode 100644 index 0000000..d17925b Binary files /dev/null and b/Tutorial/Images/PerspectiveView.png differ diff --git a/Tutorial/Images/PistolFiring.png b/Tutorial/Images/PistolFiring.png new file mode 100644 index 0000000..b7b8852 Binary files /dev/null and b/Tutorial/Images/PistolFiring.png differ diff --git a/Tutorial/Images/PistolOverlay.png b/Tutorial/Images/PistolOverlay.png new file mode 100644 index 0000000..6ce7229 Binary files /dev/null and b/Tutorial/Images/PistolOverlay.png differ diff --git a/Tutorial/Images/PistolPosition.png b/Tutorial/Images/PistolPosition.png new file mode 100644 index 0000000..b097eb3 Binary files /dev/null and b/Tutorial/Images/PistolPosition.png differ diff --git a/Tutorial/Images/PistolSprite.png b/Tutorial/Images/PistolSprite.png new file mode 100644 index 0000000..9cc606a Binary files /dev/null and b/Tutorial/Images/PistolSprite.png differ diff --git a/Tutorial/Images/PixelOpacityTest.png b/Tutorial/Images/PixelOpacityTest.png new file mode 100644 index 0000000..307bb00 Binary files /dev/null and b/Tutorial/Images/PixelOpacityTest.png differ diff --git a/Tutorial/Images/ProjectStructure.png b/Tutorial/Images/ProjectStructure.png new file mode 100644 index 0000000..0b43ce0 Binary files /dev/null and b/Tutorial/Images/ProjectStructure.png differ diff --git a/Tutorial/Images/Pythagoras.png b/Tutorial/Images/Pythagoras.png new file mode 100644 index 0000000..73626e6 Binary files /dev/null and b/Tutorial/Images/Pythagoras.png differ diff --git a/Tutorial/Images/RayFan.png b/Tutorial/Images/RayFan.png new file mode 100644 index 0000000..b5456f0 Binary files /dev/null and b/Tutorial/Images/RayFan.png differ diff --git a/Tutorial/Images/RayLengths.png b/Tutorial/Images/RayLengths.png new file mode 100644 index 0000000..1369b89 Binary files /dev/null and b/Tutorial/Images/RayLengths.png differ diff --git a/Tutorial/Images/RayTileIntersection.png b/Tutorial/Images/RayTileIntersection.png new file mode 100644 index 0000000..e30de83 Binary files /dev/null and b/Tutorial/Images/RayTileIntersection.png differ diff --git a/Tutorial/Images/RedFlashEffect.png b/Tutorial/Images/RedFlashEffect.png new file mode 100644 index 0000000..0376e11 Binary files /dev/null and b/Tutorial/Images/RedFlashEffect.png differ diff --git a/Tutorial/Images/RedSpriteBackground.png b/Tutorial/Images/RedSpriteBackground.png new file mode 100644 index 0000000..658bc05 Binary files /dev/null and b/Tutorial/Images/RedSpriteBackground.png differ diff --git a/Tutorial/Images/ReleaseMode.png b/Tutorial/Images/ReleaseMode.png new file mode 100644 index 0000000..17ed91e Binary files /dev/null and b/Tutorial/Images/ReleaseMode.png differ diff --git a/Tutorial/Images/ReleaseModeTests.png b/Tutorial/Images/ReleaseModeTests.png new file mode 100644 index 0000000..56c361f Binary files /dev/null and b/Tutorial/Images/ReleaseModeTests.png differ diff --git a/Tutorial/Images/RemoveMultiplicationTest.png b/Tutorial/Images/RemoveMultiplicationTest.png new file mode 100644 index 0000000..1355f97 Binary files /dev/null and b/Tutorial/Images/RemoveMultiplicationTest.png differ diff --git a/Tutorial/Images/RotatedOutput.png b/Tutorial/Images/RotatedOutput.png new file mode 100644 index 0000000..192a04c Binary files /dev/null and b/Tutorial/Images/RotatedOutput.png differ diff --git a/Tutorial/Images/RotatedSprites.png b/Tutorial/Images/RotatedSprites.png new file mode 100644 index 0000000..f877626 Binary files /dev/null and b/Tutorial/Images/RotatedSprites.png differ diff --git a/Tutorial/Images/RoundingErrors.png b/Tutorial/Images/RoundingErrors.png new file mode 100644 index 0000000..e7e5f17 Binary files /dev/null and b/Tutorial/Images/RoundingErrors.png differ diff --git a/Tutorial/Images/SafetyChecksOffTest.png b/Tutorial/Images/SafetyChecksOffTest.png new file mode 100644 index 0000000..7ed6046 Binary files /dev/null and b/Tutorial/Images/SafetyChecksOffTest.png differ diff --git a/Tutorial/Images/ScrambledOutput.png b/Tutorial/Images/ScrambledOutput.png new file mode 100644 index 0000000..cb0382f Binary files /dev/null and b/Tutorial/Images/ScrambledOutput.png differ diff --git a/Tutorial/Images/SetBaseline.png b/Tutorial/Images/SetBaseline.png new file mode 100644 index 0000000..00ea000 Binary files /dev/null and b/Tutorial/Images/SetBaseline.png differ diff --git a/Tutorial/Images/SharpBluePixel.png b/Tutorial/Images/SharpBluePixel.png new file mode 100644 index 0000000..1396e9b Binary files /dev/null and b/Tutorial/Images/SharpBluePixel.png differ diff --git a/Tutorial/Images/SlopeIntercept.png b/Tutorial/Images/SlopeIntercept.png new file mode 100644 index 0000000..54ba242 Binary files /dev/null and b/Tutorial/Images/SlopeIntercept.png differ diff --git a/Tutorial/Images/SmoothedDeathEffect.png b/Tutorial/Images/SmoothedDeathEffect.png new file mode 100644 index 0000000..1e5f681 Binary files /dev/null and b/Tutorial/Images/SmoothedDeathEffect.png differ diff --git a/Tutorial/Images/SolidFloorColor.png b/Tutorial/Images/SolidFloorColor.png new file mode 100644 index 0000000..4aaa09d Binary files /dev/null and b/Tutorial/Images/SolidFloorColor.png differ diff --git a/Tutorial/Images/SortedSprites.png b/Tutorial/Images/SortedSprites.png new file mode 100644 index 0000000..32d7a7b Binary files /dev/null and b/Tutorial/Images/SortedSprites.png differ diff --git a/Tutorial/Images/SpriteBehindPlayer.png b/Tutorial/Images/SpriteBehindPlayer.png new file mode 100644 index 0000000..d902e0c Binary files /dev/null and b/Tutorial/Images/SpriteBehindPlayer.png differ diff --git a/Tutorial/Images/SpriteLines.png b/Tutorial/Images/SpriteLines.png new file mode 100644 index 0000000..a2d2bbb Binary files /dev/null and b/Tutorial/Images/SpriteLines.png differ diff --git a/Tutorial/Images/SpriteOrderBug.png b/Tutorial/Images/SpriteOrderBug.png new file mode 100644 index 0000000..b941b27 Binary files /dev/null and b/Tutorial/Images/SpriteOrderBug.png differ diff --git a/Tutorial/Images/SpriteRadius.png b/Tutorial/Images/SpriteRadius.png new file mode 100644 index 0000000..f897e81 Binary files /dev/null and b/Tutorial/Images/SpriteRadius.png differ diff --git a/Tutorial/Images/SpriteRayBug.png b/Tutorial/Images/SpriteRayBug.png new file mode 100644 index 0000000..bb9b4ca Binary files /dev/null and b/Tutorial/Images/SpriteRayBug.png differ diff --git a/Tutorial/Images/SpriteRayIntersection.png b/Tutorial/Images/SpriteRayIntersection.png new file mode 100644 index 0000000..d7efec5 Binary files /dev/null and b/Tutorial/Images/SpriteRayIntersection.png differ diff --git a/Tutorial/Images/StagedResponse.png b/Tutorial/Images/StagedResponse.png new file mode 100644 index 0000000..8d60eee Binary files /dev/null and b/Tutorial/Images/StagedResponse.png differ diff --git a/Tutorial/Images/StateMachine.png b/Tutorial/Images/StateMachine.png new file mode 100644 index 0000000..1d7016b Binary files /dev/null and b/Tutorial/Images/StateMachine.png differ diff --git a/Tutorial/Images/StoredWidthTest.png b/Tutorial/Images/StoredWidthTest.png new file mode 100644 index 0000000..ed29c3b Binary files /dev/null and b/Tutorial/Images/StoredWidthTest.png differ diff --git a/Tutorial/Images/StraightWalls.png b/Tutorial/Images/StraightWalls.png new file mode 100644 index 0000000..bf52d82 Binary files /dev/null and b/Tutorial/Images/StraightWalls.png differ diff --git a/Tutorial/Images/StretchedWalls.png b/Tutorial/Images/StretchedWalls.png new file mode 100644 index 0000000..b1214ed Binary files /dev/null and b/Tutorial/Images/StretchedWalls.png differ diff --git a/Tutorial/Images/TextureLookupTrace.png b/Tutorial/Images/TextureLookupTrace.png new file mode 100644 index 0000000..9be5cb9 Binary files /dev/null and b/Tutorial/Images/TextureLookupTrace.png differ diff --git a/Tutorial/Images/TextureMapping.png b/Tutorial/Images/TextureMapping.png new file mode 100644 index 0000000..a25ada6 Binary files /dev/null and b/Tutorial/Images/TextureMapping.png differ diff --git a/Tutorial/Images/TextureSmearing.png b/Tutorial/Images/TextureSmearing.png new file mode 100644 index 0000000..eb08db5 Binary files /dev/null and b/Tutorial/Images/TextureSmearing.png differ diff --git a/Tutorial/Images/TextureVariants.png b/Tutorial/Images/TextureVariants.png new file mode 100644 index 0000000..9f934a1 Binary files /dev/null and b/Tutorial/Images/TextureVariants.png differ diff --git a/Tutorial/Images/TexturedCeiling.png b/Tutorial/Images/TexturedCeiling.png new file mode 100644 index 0000000..55f464d Binary files /dev/null and b/Tutorial/Images/TexturedCeiling.png differ diff --git a/Tutorial/Images/TexturedFloor.png b/Tutorial/Images/TexturedFloor.png new file mode 100644 index 0000000..cc60a06 Binary files /dev/null and b/Tutorial/Images/TexturedFloor.png differ diff --git a/Tutorial/Images/TexturesAndLighting.png b/Tutorial/Images/TexturesAndLighting.png new file mode 100644 index 0000000..b53a537 Binary files /dev/null and b/Tutorial/Images/TexturesAndLighting.png differ diff --git a/Tutorial/Images/TileEdgeHit.png b/Tutorial/Images/TileEdgeHit.png new file mode 100644 index 0000000..8b607aa Binary files /dev/null and b/Tutorial/Images/TileEdgeHit.png differ diff --git a/Tutorial/Images/TileSteps.png b/Tutorial/Images/TileSteps.png new file mode 100644 index 0000000..d51380d Binary files /dev/null and b/Tutorial/Images/TileSteps.png differ diff --git a/Tutorial/Images/Tilemap.png b/Tutorial/Images/Tilemap.png new file mode 100644 index 0000000..06b3f02 Binary files /dev/null and b/Tutorial/Images/Tilemap.png differ diff --git a/Tutorial/Images/UnoptimizedTest.png b/Tutorial/Images/UnoptimizedTest.png new file mode 100644 index 0000000..1924d1c Binary files /dev/null and b/Tutorial/Images/UnoptimizedTest.png differ diff --git a/Tutorial/Images/UnoptimizedTrace.png b/Tutorial/Images/UnoptimizedTrace.png new file mode 100644 index 0000000..6b0c139 Binary files /dev/null and b/Tutorial/Images/UnoptimizedTrace.png differ diff --git a/Tutorial/Images/VariedTextures.png b/Tutorial/Images/VariedTextures.png new file mode 100644 index 0000000..8891eae Binary files /dev/null and b/Tutorial/Images/VariedTextures.png differ diff --git a/Tutorial/Images/ViewFrustum.png b/Tutorial/Images/ViewFrustum.png new file mode 100644 index 0000000..af5e4f0 Binary files /dev/null and b/Tutorial/Images/ViewFrustum.png differ diff --git a/Tutorial/Images/ViewPlane.png b/Tutorial/Images/ViewPlane.png new file mode 100644 index 0000000..48f4750 Binary files /dev/null and b/Tutorial/Images/ViewPlane.png differ diff --git a/Tutorial/Images/WalkingFrames.png b/Tutorial/Images/WalkingFrames.png new file mode 100644 index 0000000..c82b99f Binary files /dev/null and b/Tutorial/Images/WalkingFrames.png differ diff --git a/Tutorial/Images/WallCollisions.png b/Tutorial/Images/WallCollisions.png new file mode 100644 index 0000000..791f548 Binary files /dev/null and b/Tutorial/Images/WallCollisions.png differ diff --git a/Tutorial/Images/WallDistance.png b/Tutorial/Images/WallDistance.png new file mode 100644 index 0000000..7f6911b Binary files /dev/null and b/Tutorial/Images/WallDistance.png differ diff --git a/Tutorial/Images/WallHit.png b/Tutorial/Images/WallHit.png new file mode 100644 index 0000000..afb897c Binary files /dev/null and b/Tutorial/Images/WallHit.png differ diff --git a/Tutorial/Images/WallTexture.png b/Tutorial/Images/WallTexture.png new file mode 100644 index 0000000..40fefd7 Binary files /dev/null and b/Tutorial/Images/WallTexture.png differ diff --git a/Tutorial/Images/WolfensteinSprites.png b/Tutorial/Images/WolfensteinSprites.png new file mode 100644 index 0000000..ec1ae04 Binary files /dev/null and b/Tutorial/Images/WolfensteinSprites.png differ diff --git a/Tutorial/Part1.md b/Tutorial/Part1.md new file mode 100644 index 0000000..e0a7cd2 --- /dev/null +++ b/Tutorial/Part1.md @@ -0,0 +1,744 @@ +## Part 1: Separation of Concerns + +In a traditional app, it is common practice to split the logic into three layers - the Model, View and Controller[[1]](#footnote1). Games can also be divided into three similar layers: + +* Platform Layer - Platform-specific code to integrate with the host operating system. +* Engine - Platform-independent game functions like asset loaders, rendering, physics, pathfinding AI, etc. +* Game Logic - Gameplay and graphics specific to this game. Tightly coupled to a particular engine. + +Traditional app architecture vs game architecture + +Apple would ideally like you to build your game or engine using frameworks like GameKit, SpriteKit and SceneKit. These are powerful and easy-to-use, but doing so shackles your game completely to Apple's platforms. + +In practice, few game developers do this because Apple alone doesn't represent a large enough share of the games market. A popular alternative is to use a 3rd party framework like Unity or Unreal that provide a cross-platform engine, and have platform layers for many different systems. This frees you from Apple exclusivity, but ties you into a 3rd party ecosystem instead. + +In this tutorial we are choosing a third path: We'll create a very minimal platform layer for iOS using UIKit, then build both the game and engine from scratch using pure Swift, with no external dependencies. + +### Project Setup + +Modern games require a fairly complex interface with their host operating system in order to implement hardware accelerated graphics, sound and user input. But fortunately, *we aren't building a modern game*. + +In the era of Wolfenstein 3D, computers were mainly intended as business machines, and did almost nothing for you as a game developer. We're going to build this game using a similar approach to what they used in the MSDOS days (while taking advantage of modern advancements such as fast floating-point math and 32-bit color). + +Start by creating a new *Single View iOS App* project in Xcode. To keep ourselves honest, we're going to put the engine code in a separate module, creating an "air gap" between the game code and the code that interacts with iOS. + +To create the Engine module, go to the Targets tab and add a new target of type *Cocoa Touch Framework* called "Engine". Xcode will automatically link this in to your main app. It will also create a C header file in the Engine folder called `Engine.h` which imports UIKit. We aren't going to be using UIKit in the engine, so you can go ahead and delete `Engine.h`. + +If you aren't confident creating a project with a nested framework (or if you just don't feel like setting up a project from scratch) you can download the base project from [here](https://github.com/nicklockwood/RetroRampage/archive/Start.zip). + +Here is how the project structure should look: + +Initial project structure + +### View Setup + +Open `ViewController.swift` and replace its contents with the following: + +```swift +import UIKit + +class ViewController: UIViewController { + private let imageView = UIImageView() + + override func viewDidLoad() { + super.viewDidLoad() + setUpImageView() + } + + func setUpImageView() { + view.addSubview(imageView) + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true + imageView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true + imageView.widthAnchor.constraint(equalTo: view.widthAnchor).isActive = true + imageView.heightAnchor.constraint(equalTo: view.heightAnchor).isActive = true + imageView.contentMode = .scaleAspectFit + imageView.backgroundColor = .black + } +} +``` + +Now run the app. If all has gone well you should see the black background of the `UIImageView` filling the whole screen. + +### Pixel Pushing + +Apps display views. Games typically deal with sprites or polygons. We are going to be working directly with *pixels*. Everything you see on screen is made up of pixels, and on a modern system, pixels are usually stored as 4 bytes[[2]](#footnote2) - one byte for each of the red, green, blue and alpha channels. + +UIKit provides the `UIColor` class, which treats those channels as floating-point values in the range 0.0 to 1.0. This is a convenient abstraction, but it's an expensive way to store colors and it couples us to UIKit, so we'll create our own `Color` type instead. Create a new file called `Color.swift` in the Engine module with the following contents[[3]](#footnote3): + +```swift +public struct Color { + public var r, g, b, a: UInt8 + + public init(r: UInt8, g: UInt8, b: UInt8, a: UInt8 = 255) { + self.r = r + self.g = g + self.b = b + self.a = a + } +} +``` + +Since we're mostly going to be dealing with opaque colors, we've defaulted the alpha value to 255 (no transparency) in the initializer. In the same file, add some common color constants, which will be useful later[[4]](#footnote4): + +```swift +public extension Color { + static let clear = Color(r: 0, g: 0, b: 0, a: 0) + static let black = Color(r: 0, g: 0, b: 0) + static let white = Color(r: 255, g: 255, b: 255) + static let gray = Color(r: 192, g: 192, b: 192) + static let red = Color(r: 255, g: 0, b: 0) + static let green = Color(r: 0, g: 255, b: 0) + static let blue = Color(r: 0, g: 0, b: 255) +} +``` + +Now that the engine has a way to represent a pixel color, we need a type to represent an entire image. We'll call this `Bitmap` to avoid confusion with `UIImage`. Create a new file called `Bitmap.swift` in the Engine module with the following contents: + +```swift +public struct Bitmap { + public private(set) var pixels: [Color] + public let width: Int + + public init(width: Int, pixels: [Color]) { + self.width = width + self.pixels = pixels + } +} +``` + +You'll notice that `Bitmap` stores its pixels in a flat array instead of a 2D matrix as you might expect. This is mainly for efficiency - allocating a flat array like this means that the pixels will be stored contiguously in a single block of memory instead of each row or column being allocated separately on the heap. + +In performance-critical code, consideration of memory layout is important to avoid cache-misses, which occur when the program is forced to page in new data from main memory instead of the much-faster [processor cache](https://en.wikipedia.org/wiki/CPU_cache). + +Accessing a 2D image using a one-dimensional index (and having to compute the row offsets each time) isn't very ergonomic though, so lets add a 2D subscript. While we're at it, let's also add a computed property for the image height, and a convenience initializer for creating an empty image: + +```swift +public extension Bitmap { + var height: Int { + return pixels.count / width + } + + subscript(x: Int, y: Int) -> Color { + get { return pixels[y * width + x] } + set { pixels[y * width + x] = newValue } + } + + init(width: Int, height: Int, color: Color) { + self.pixels = Array(repeating: color, count: width * height) + self.width = width + } +} +``` + +The subscript takes an X and Y coordinate within the image and does some math to compute the offset into the flat `pixels` array. The code assumes that the pixels are stored in row order rather than column order (which is the convention on most platforms, including iOS), but this assumption is not exposed in the public interface, so we can change it later if necessary. + +Add a new file to the main app target called `UIImage+Bitmap.swift` with the following contents: + +```swift +import UIKit +import Engine + +extension UIImage { + convenience init?(bitmap: Bitmap) { + let alphaInfo = CGImageAlphaInfo.premultipliedLast + let bytesPerPixel = MemoryLayout.size + let bytesPerRow = bitmap.width * bytesPerPixel + + guard let providerRef = CGDataProvider(data: Data( + bytes: bitmap.pixels, count: bitmap.height * bytesPerRow + ) as CFData) else { + return nil + } + + guard let cgImage = CGImage( + width: bitmap.width, + height: bitmap.height, + bitsPerComponent: 8, + bitsPerPixel: bytesPerPixel * 8, + bytesPerRow: bytesPerRow, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGBitmapInfo(rawValue: alphaInfo.rawValue), + provider: providerRef, + decode: nil, + shouldInterpolate: true, + intent: .defaultIntent + ) else { + return nil + } + + self.init(cgImage: cgImage) + } +} +``` + +Notice that the file imports both UIKit and the Engine module - that's a good clue as to its purpose, which is to act as a bridge between UIKit and the game engine. Specifically, it provides a way to take a `Bitmap` and turn it into a `UIImage` for display on screen. + +We want this conversion process to be as cheap as possible since we're repeating it every frame. The `pixels` array already has the correct memory layout to be consumed directly by `CGImage`, so it's a simple copy without any need for per-pixel manipulation. + +Pay particular attention to this line: + +```swift +let alphaInfo = CGImageAlphaInfo.premultipliedLast +``` + +Usually in Swift, the order in which properties are declared in a struct doesn't matter very much. In this case though, we are relying on the order and alignment of the `r`, `g`, `b` and `a` properties of `Color` to match the format we specified for `alphaInfo`. + +The constant `premultipliedLast` specifies that the alpha value is stored *after* the RGB channels. It is common practice on iOS to use `premultipliedFirst` instead, and if we used that we would have to change the property order in our `Color` struct to `a, r, g, b` or our colors would be messed up. + +Now we have all the pieces we need to display a `Bitmap` on the screen. Add the following code to `viewDidLoad()` in `ViewController.swift` (note that you need to add `import Engine` at the top of the file as well since the `Bitmap` type is defined in the Engine module): + +```swift +import Engine + +... + +override func viewDidLoad() { + super.viewDidLoad() + setUpImageView() + + var bitmap = Bitmap(width: 8, height: 8, color: .white) + bitmap[0, 0] = .blue + + imageView.image = UIImage(bitmap: bitmap) +} +``` + +This code creates a new 8x8 `Bitmap` filled with white, and then sets the pixel at 0,0 (the top-left corner) to blue. The result looks like this: + +![A blurry blue pixel](Images/BlurryBluePixel.png) + +Now I know what you're thinking: *Why is it all blurry?* + +What you are seeing here is the modern miracle of [bilinear filtering](https://en.wikipedia.org/wiki/Bilinear_filtering). In the good old days, when computers were slow, when we expanded images on the screen we saw big square pixels[[5]](#footnote5) *and we liked it*. But in the mid-to-late '90s, as 3D graphics cards became popular and math became cheaper, bilinear filtering replaced the so-called [nearest-neighbor](https://en.wikipedia.org/wiki/Nearest-neighbor_interpolation) algorithm used previously. + +Bilinear filtering combines nearby pixel values, creating results that are interpolated rather than just sampled directly from the original image. When downscaling images this produces dramatically better results, because instead of pixels just disappearing as the output shrinks, their color still contributes to the final image. Unfortunately, when *upscaling* images - especially chunky pixel-art - the results tend to look like blurry garbage. + +To solve this, add the following line to the end of `ViewController.setUpImageView()`: + +```swift +imageView.layer.magnificationFilter = .nearest +``` + +That tells the `UIImageView` to use the nearest-neighbor algorithm when magnifying an image instead of bilinear filtering (which is the default for both upscaling and downscaling). Run the app again and the blurriness should be gone. + +![A sharp blue pixel](Images/SharpBluePixel.png) + +### Inversion of Control + +Right now our drawing logic lives in the platform layer, which is kind of backwards. The game engine should be responsible for the drawing, not the platform. Create a new file called `Renderer.swift` in the Engine module with the following contents: + +```swift +public struct Renderer { + public private(set) var bitmap: Bitmap + + public init(width: Int, height: Int) { + self.bitmap = Bitmap(width: width, height: height, color: .white) + } +} + +public extension Renderer { + mutating func draw() { + bitmap[0, 0] = .blue + } +} + +``` + +Then in `ViewController.viewDidLoad()` replace the lines: + +```swift +var bitmap = Bitmap(width: 8, height: 8, color: .white) +bitmap[0, 0] = .blue + +imageView.image = UIImage(bitmap: bitmap) +``` + +with: + +```swift +var renderer = Renderer(width: 8, height: 8) +renderer.draw() + +imageView.image = UIImage(bitmap: renderer.bitmap) +``` + +Run it again, and the result should look the same as before. + +### Loop-the-Loop + +That's all very well, but one static pixel is hardly a game. In a game, things change over time, even when the player isn't doing anything. This is different from a typical GUI application where the app spends most of its time sitting idle, waiting for user input. + +Right now we just draw one screen and then stop. In a real game, everything happens in a loop called the *game loop*. Game loops are usually driven in sync with the display's [refresh rate](https://en.wikipedia.org/wiki/Refresh_rate). + +In the early days of home computing, computer monitors used a scanning electron beam to stimulate phosphorescent paint on the inside of the glass screen. The rate at which that beam could scan across the entire screen was called the *refresh rate* - typically 50 Hz (PAL) or 60 Hz (NTSC). + +Early graphics cards did little more than expose a pixel buffer into which apps and games could draw their interface. If you timed your drawing at the wrong point in the beam cycle, the image would flicker, which is why it was important for game loops to be synchronized to the refresh rate. + +Modern LCD or OLED screens no longer have a scanning beam, but they still have a finite rate at which data can be streamed into and out of video memory. Thanks to [double buffering](https://en.wikipedia.org/wiki/Multiple_buffering#Double_buffering_in_computer_graphics) we no longer have to worry about flickering and tearing, but we still need to synchronize our drawing to the refresh rate in order to achieve smooth animations. + +If we redraw the interface too frequently then we'll waste cycles on drawing that will never be seen. If we redraw too slowly then the screen will appear frozen. If we redraw slightly out of step with the refresh, objects will appear to stutter as they move across the screen. Most iOS developers have experienced the *jerky scrolling* that arises when a `UITableView` takes too long to redraw its cells. + +In single-threaded, single-process operating systems like DOS, the game loop was an actual `while` loop, repeatedly scanning for user input, running the game logic, then redrawing the screen. Modern operating systems like iOS have a lot of things going on in parallel, and blocking the main thread is a big no-no. So instead of a loop, we use a *timer*, which calls our update logic at a specified frequency, handing back control to the OS while it's waiting. + +In iOS, the recommended way to synchronize code execution to the screen refresh is to use `CADisplayLink`. `CADisplayLink` is a special type of timer whose period is always an exact multiple of the refresh rate (typically 60 Hz, although some modern iOS devices can manage 120 Hz). + +Unlike `Timer`, `CADisplayLink` doesn't have a Swift-friendly, closure-based API, so we need to use the target/selector pattern. Add the following method to `ViewController` (Note the `@objc` annotation, which is needed for the selector binding to work): + +```swift +@objc func update(_ displayLink: CADisplayLink) { + var renderer = Renderer(width: 8, height: 8) + renderer.draw() + + imageView.image = UIImage(bitmap: renderer.bitmap) +} +``` + +Then in `viewDidLoad()`, replace the lines: + +```swift +var renderer = Renderer(width: 8, height: 8) +renderer.draw() + +imageView.image = UIImage(bitmap: renderer.bitmap) +``` + +with: + +```swift +let displayLink = CADisplayLink(target: self, selector: #selector(update)) +displayLink.add(to: .main, forMode: .common) +``` + +This code creates a `CADisplayLink` with the default frequency (60 Hz) and adds it to the main `RunLoop`. A `RunLoop` is essentially the same concept as a game loop - it's a loop that runs continuously on a given thread, monitoring for user-triggered events like touches or key presses, and firing scheduled events like timers. By adding our display link to the `RunLoop` we are telling the system to keep track of it and call our `update()` method as needed until further notice. + +Note the `forMode:` argument. The `Runloop.Mode` is a sort of priority system that iOS uses to determine which events need to be updated under which circumstances. The documentation for `CADisplayLink` suggests using `default` for the mode, but events bound to the `default` mode won't fire during *tracking* (e.g. when the user is scrolling, or manipulating a UI control). By using the `common` mode we ensure that our `CADisplayLink` will be given top priority. + +A word of caution: `CADisplayLink` is retained by the `RunLoop`, and also retains a strong reference to its `target` (the view controller in this case) while running. We are not keeping a reference to the display link anywhere, so we have no means to stop it, which means it will run forever until the app terminates, and will never release the `ViewController`, even if it moves off-screen. + +In this case that doesn't matter because `ViewController` is the root view controller and will never be dismissed, but if you were to build a game that used multiple view controllers for different screens it would be something to watch out for. A simple solution would be to store a reference to the `CADisplayLink` and call its `invalidate()` method when the view controller is dismissed. + +### Setting Things in Motion + +If everything worked correctly, we should now be redrawing our screen at 60 FPS (Frames Per Second). We're still always drawing the same thing though, so lets add some animation. + +Motion is a change of position over time. Position in a 2D world is typically defined as a vector with X (for horizontal position) and Y (for vertical position). We already touched on the idea of X and Y coordinates in the context of accessing pixels in a `Bitmap`, and world coordinates are similar. But unlike pixels, entities in the world won't necessarily be aligned exactly on a grid, so we need to represent them using floating point values. + +Let's create a new type to represent a position in the world. Add a new file to the Engine module called `Vector.swift` with the following contents: + +```swift +public struct Vector { + public var x, y: Double + + public init(x: Double, y: Double) { + self.x = x + self.y = y + } +} +``` + +Vectors are a very flexible type - they can represent position, velocity, distance, direction and more. We will be making a lot of use of this type throughout the game engine. Our vector type could easily be extended into the 3rd dimension by adding a Z component, but because our game mechanics are primarily 2D, we'll stick with just X and Y as it makes the math much simpler. + +While we're here, let's add a few operator overloads[[6]](#footnote6) to make `Vector` easier to work with: + +```swift +public extension Vector { + static func + (lhs: Vector, rhs: Vector) -> Vector { + return Vector(x: lhs.x + rhs.x, y: lhs.y + rhs.y) + } + + static func - (lhs: Vector, rhs: Vector) -> Vector { + return Vector(x: lhs.x - rhs.x, y: lhs.y - rhs.y) + } + + static func * (lhs: Vector, rhs: Double) -> Vector { + return Vector(x: lhs.x * rhs, y: lhs.y * rhs) + } + + static func / (lhs: Vector, rhs: Double) -> Vector { + return Vector(x: lhs.x / rhs, y: lhs.y / rhs) + } + + static func * (lhs: Double, rhs: Vector) -> Vector { + return Vector(x: lhs * rhs.x, y: lhs * rhs.y) + } + + static func / (lhs: Double, rhs: Vector) -> Vector { + return Vector(x: lhs / rhs.x, y: lhs / rhs.y) + } + + static func += (lhs: inout Vector, rhs: Vector) { + lhs.x += rhs.x + lhs.y += rhs.y + } + + static func -= (lhs: inout Vector, rhs: Vector) { + lhs.x -= rhs.x + lhs.y -= rhs.y + } + + static func *= (lhs: inout Vector, rhs: Double) { + lhs.x *= rhs + lhs.y *= rhs + } + + static func /= (lhs: inout Vector, rhs: Double) { + lhs.x /= rhs + lhs.y /= rhs + } + + static prefix func - (rhs: Vector) -> Vector { + return Vector(x: -rhs.x, y: -rhs.y) + } +} +``` + +### Ready Player One + +We'll need a *player* object to act as our avatar in the game. Create a new file called `Player.swift` in the Engine module, with the following contents: + +```swift +public struct Player { + public var position: Vector + + public init(position: Vector) { + self.position = position + } +} +``` + +The player's position should persist between frames, so it can't be a local variable. Add a `player` property to the `ViewController` class: + +```swift +class ViewController: UIViewController { + private let imageView = UIImageView() + private var player = Player(position: Vector(x: 4, y: 4)) + + ... +} +``` + +The player is positioned at 4,4 by default which would be the center of a world that had a size of 8x8 units square. We haven't really thought about what those units are or what any of that means yet, but for now let's assume that the world's dimensions match the bitmap that we've been drawing into - i.e. that the units are in pixels. + +Now we have a player instance, the renderer will need access to it in order to draw it. In `Renderer.swift` change the `draw()` method to: + +```swift +mutating func draw(_ player: Player) { + bitmap[Int(player.position.x), Int(player.position.y)] = .blue +} +``` + +Then in `ViewController.swift` update the line: + +```swift +renderer.draw() +``` + +to: + +```swift +renderer.draw(player) +``` + +Run the app and and you'll see that we now draw a pixel at the player's position - the middle of the bitmap - instead of the top corner. Next, we'll make that position change over time. + +### Need for Speed + +A change of position over time is called [velocity](https://en.wikipedia.org/wiki/Velocity). Velocity is a vector that combines both the [speed](https://en.wikipedia.org/wiki/Speed) at which an object is moving, and the direction of movement. Let's add a velocity property to `Player`: + +```swift +public struct Player { + public var position: Vector + public var velocity: Vector + + public init(position: Vector) { + self.position = position + self.velocity = Vector(x: 1, y: 1) + } +} +``` + +Now that the player has a velocity, we can implement movement by adding the player `velocity` to the player `position` every frame. We don't really want to add movement logic inside the `draw()` method because that violates the [separation of concerns](https://en.wikipedia.org/wiki/Separation_of_concerns) principle, so let's add a new method to `Player` called `update()`: + +```swift +public extension Player { + mutating func update() { + position += velocity + } +} +``` + +In `ViewController`'s `update()` method, call `player.update()` before drawing: + +```swift +@objc func update(_ displayLink: CADisplayLink) { + player.update() + + var renderer = Renderer(width: 8, height: 8) + ... +} +``` + +If we run the app now it crashes almost instantly. The problem is that the player quickly moves outside of visible area, and when we try to draw outside the bitmap we get an out-of-bounds error for the `pixels` array. + +This is likely to be a common problem. Rather than having to add tedious bounds checking to every drawing function we write, let's just add a guard inside the subscript to prevent us from accidentally drawing outside the array. In `Bitmap.swift`, replace the line: + +```swift +set { pixels[y * width + x] = newValue } +``` + +with: + +```swift +set { + guard x >= 0, y >= 0, x < width, y < height else { return } + pixels[y * width + x] = newValue +} +``` + +With that protection in place the app won't crash anymore, but the player avatar still vanishes from view almost immediately. To solve that, we can wrap the player position within the bitmap, using the `formTruncatingRemainder()` function from the Swift standard library: + +```swift +mutating func update() { + position += velocity + position.x.formTruncatingRemainder(dividingBy: 8) + position.y.formTruncatingRemainder(dividingBy: 8) +} +``` + +Run the app now and you should see the blue player pixel streaking diagonally across the screen, wrapping back around to the top-left corner whenever it moves off the screen. It seems to be moving much too fast - but how fast is it exactly? + +For the player velocity we used `Vector(x: 1, y: 1)`, which means that the player will move one pixel to the right and down every frame. Since our frame rate is 60 FPS, that means it's moving 60 pixels per second, and since the world is only 8 pixels wide that means it flashes across the entire screen 7.5 times per second! + +If we want it to move at one unit per *second* instead of one unit per frame, we need to divide the velocity by the frame-rate: + +```swift +func update() { + position += velocity / 60 + ... +} +``` + +That magic number 60 is pretty nasty though because it ties us to 60 FPS, when we might decide that we actually want to run at 30 FPS on older devices (or 120 FPS on newer ones) without it affecting the speed at which objects move. + +Instead of hard-coding the time factor as 1/60, let's pass it as a parameter to the update function: + +```swift +mutating func update(timeStep: Double) { + position += velocity * timeStep + ... +} +``` +The value for `timeStep` will need to be provided by the platform layer. Although `CADisplayLink` has a number of time-related properties, it doesn't have exactly the value we need, so we'll need to compute it. To do that we'll add a `lastFrameTime` property to `ViewController`: + +```swift +class ViewController: UIViewController { + private let imageView = UIImageView() + private var player = Player(position: Vector(x: 4, y: 4)) + private var lastFrameTime = CACurrentMediaTime() + + ... +} +``` + +Then, in the `update()` method, replace the line: + +```swift +player.update() +``` + +with: + +```swift +let timeStep = displayLink.timestamp - lastFrameTime +player.update(timeStep: timeStep) +lastFrameTime = displayLink.timestamp +``` + +Run the game again and you'll see that the player avatar now moves much more slowly. The frame rate appears much lower too, but that's an illusion - the player avatar *really is* being moved and redrawn ~60 times per second, but its position is rounded down to the nearest whole pixel in the bitmap (which only has 8x8 resolution), and the rounded value only changes once per second. + +We can make the movement smoother by increasing the resolution of the bitmap from 8x8 to something much higher, but that will also make the player rectangle smaller and slower because both its size and speed are proportional to the bitmap resolution. Besides, we've hard-coded those 8x8 pixel dimensions in a couple of places already and all these magic numbers are starting to get a bit unwieldy. It's time for a refactor. + +### Brave New World + +We don't want the dimensions of the world to be coupled to the dimensions of the bitmap that we are drawing into, so let's make a new type to represent the world itself. Create a new file in the Engine module called `World.swift` with the following contents: + +```swift +public struct World { + public let size: Vector + public var player: Player + + public init() { + self.size = Vector(x: 8, y: 8) + self.player = Player(position: Vector(x: 4, y: 4)) + } +} + +public extension World { + mutating func update(timeStep: Double) { + player.position += player.velocity * timeStep + player.position.x.formTruncatingRemainder(dividingBy: size.x) + player.position.y.formTruncatingRemainder(dividingBy: size.y) + } +} +``` + +And delete the `update()` method from `Player.swift`, as we won't be needing it anymore. + +In `ViewController.swift` replace the line: + +```swift +private var player = Player(position: Vector(x: 4, y: 4)) +``` + +with: + +```swift +private var world = World() +``` + +Then in `ViewController.update()` replace: + +```swift +player.update(timeStep: timeStep) +``` + +with: + +```swift +world.update(timeStep: timeStep) +``` + +And replace: + +```swift +renderer.draw(player) +``` + +with: + +```swift +renderer.draw(world.player) +``` + +Currently, the world is still the same size as the bitmap (8x8 units), but because the world now has its own coordinate system independent of the bitmap, we are free to make the bitmap resolution higher without affecting the player speed. + +If we want to be able to draw the player avatar at different scales, we can no longer just represent it as a single pixel. Let's introduce a rectangle type to represent the player's size on screen. Create a new file in the Engine module called `Rect.swift` with the following contents: + +```swift +public struct Rect { + var min, max: Vector + + public init(min: Vector, max: Vector) { + self.min = min + self.max = max + } +} +``` + +We'll also add a convenience method to `Bitmap` to draw a `Rect`: + +```swift +public extension Bitmap { + ... + + mutating func fill(rect: Rect, color: Color) { + for y in Int(rect.min.y) ..< Int(rect.max.y) { + for x in Int(rect.min.x) ..< Int(rect.max.x) { + self[x, y] = color + } + } + } +} +``` + +Now that the player avatar is a rectangle rather than a single pixel, we should make the size configurable. Add a `radius` property to `Player`: + +```swift +public struct Player { + public let radius: Double = 0.5 + ... +} +``` + +Radius might seem an odd way to specify the size of a square, and you may be wondering why we don't use a width and height, but a radius value will be easier to work with (as we'll see in just a second). Add a computed property to `Player` to get the bounding `Rect` (in world units): + +```swift +public extension Player { + var rect: Rect { + let halfSize = Vector(x: radius, y: radius) + return Rect(min: position - halfSize, max: position + halfSize) + } +} +``` + +Here is why the radius is useful. We want the player rectangle to be *centered* on their position, which means that it needs to extend by half their width/height in every direction. We can use the `radius` value for this directly instead of dividing by two every time. + +Finally, we can update `Renderer.draw()` to display the player as a filled rectangle instead of just a single pixel. Since the method now needs to know the world size in order to compute the scale at which to draw, we'll update the method signature to accept the whole world rather than just the player: + +```swift +mutating func draw(_ world: World) { + let scale = Double(bitmap.height) / world.size.y + + // Draw player + var rect = world.player.rect + rect.min *= scale + rect.max *= scale + bitmap.fill(rect: rect, color: .blue) +} +``` + +Then, in `ViewController`, update the line: + +```swift +renderer.draw(world.player) +``` + +to just: + +```swift +renderer.draw(world) +``` + +By dividing the bitmap height by the world height[[7]](#footnote7) we get the relative scale between world units and pixels. That value is then used to scale the player rectangle so that its size on screen is independent of the pixel resolution of the bitmap. + +The bitmap size is set inside the platform layer, in `ViewController`. This makes sense because the resolution at which we draw the output should be chosen to suit the device we are displaying it on. But instead of matching the world size, let's derive it from the screen size. In `ViewController.swift` replace the line: + +```swift +var renderer = Renderer(width: 8, height: 8) +``` + +with: + +```swift +let size = Int(min(imageView.bounds.width, imageView.bounds.height)) +var renderer = Renderer(width: size, height: size) +``` + +Note that the view size is in *points*, which on a Retina display is only a half or a third of the actual pixel resolution. This isn't a mistake - drawing a full-screen image with the CPU is expensive, and I don't recommend trying to do it at full pixel resolution. Besides, chunky pixels are good for the authentic retro look! + +Run the app again and you should now at last see the blue player rectangle moving smoothly across the screen (don't worry if it's a bit jerky on the simulator - it will run silky smooth in release mode on a real device). + +And that's a wrap for Part 1. To recap, in this part we... + +* Created a platform-independent software renderer +* Created a simple game engine with a moving player avatar +* Created an iOS platform layer to display the output of the engine + +In [Part 2](Part2.md) we'll make the world a bit more interesting, and implement user input so the player can travel in any direction, not just diagonally. + +
+ +[[1]](#reference1) MVC is not the only architecture used for apps, but patterns such as MVP, MVVM, Clean, VIPER, etc. still share the same basic concepts of *view*, *model* and *everything else*, even if they give them different names or divide their responsibilities across additional layers. + +[[2]](#reference2) On older systems, when RAM was at much more of a premium and memory bandwidth was lower, it was common practice to use an [indexed color palette](https://en.wikipedia.org/wiki/Indexed_color). Since the number of colors in the palette was smaller than the total number of possible colors, the space required to store a bitmap made up of color indexes was much smaller than the 3 or 4 bytes needed to store each color directly. + +[[3]](#reference3) Note that there is no `import Foundation`. Nothing in the Engine module relies on any code outside the Swift standard library, even Foundation. + +[[4]](#reference4) Note that the constants are added via a public extension rather than directly in the `Color` struct. Members added this way will be `public` by default instead of `internal`, which saves some typing. + +[[5]](#reference5) Or possibly *rectangles*, because in the popular [Mode 13h](https://en.wikipedia.org/wiki/Mode_13h) VGA display resolution used by games like Wolfenstein and Doom, the pixels weren't actually square. + +[[6]](#reference6) I'm not a big fan of operator overloading as a rule, but extending regular math operators to support vector operands like this can make code a lot more concise and easier to follow. + +[[7]](#reference7) We could compute X scale and Y scale separately, but since both our world and bitmap are square we know they'll be the same. Later that will change, but we'll be replacing this code before that happens anyway. diff --git a/Tutorial/Part10.md b/Tutorial/Part10.md new file mode 100644 index 0000000..6f4f6e6 --- /dev/null +++ b/Tutorial/Part10.md @@ -0,0 +1,765 @@ +## Part 10: Sliding Doors + +In [Part 9](Part9.md) we optimized the drawing logic to improve frame rate on older devices and provide some headroom for adding new features. The code for Part 9 can be found [here](https://github.com/nicklockwood/RetroRampage/archive/Part9.zip). + +The game world so far consists of a simple maze containing monsters. It's time to expand that environment a bit with some interactive elements. + +### Room Service + +Wolfenstein 3D breaks up its endless corridors by splitting areas into separate rooms, separated by doors. These aren't just a graphical flourish, they also affect gameplay - rooms can be set up with surprise ambushes that only trigger when the player opens the door, and doors can also be locked with a key that must be located before the door will open. + +But how exactly *did* Wolfenstein's doors work? After all, the ray casting system is specifically designed to work with a grid, and the doors don't actually conform to this grid. Like all other level geometry in Wolfenstein, doors are grid-aligned, but they have zero thickness and don't line up with the other wall surfaces. + +Viewed from above, doors are a line segment bridging the gap between two wall sections. The Wolfenstein engine handled these as a special case in the ray caster[[1]](#footnote1). If the ray encountered a door tile it would know that it needed to make a half-step to reach the door, and then, depending on how open the door was, it would either stop at the door or continue through the gap. + +Rays hitting or passing door depending on how far it has opened + +It wouldn't be too hard to extend the ray caster to handle doors in this way, but we aren't going to do that, because we already have another mechanism we can use that is more flexible. + +### Write Once, Use Twice + +I have a confession to make, dear reader. In [Part 4](Part4.md), when we added the logic for drawing sprites, I made you work a bit harder than you really needed to. + +If you just want to draw scalable sprites that always face the screen, there's really no need to compute the intersection point between arbitrary lines. The `Billboard` type we added is significantly over-specced for its ostensible purpose of drawing sprites. + +And that's because it has *another purpose*. We can create a door by placing a `Billboard` in the gap between two walls. But unlike sprites, this billboard will be aligned with the map grid rather than with the view plane. + +We'll need a texture for the door. Doors have two sides, but we'll just let the reverse side be a mirror of the front (this falls naturally out of the way billboard texturing works, so we don't need to do anything extra). We'll want the lighting of the doors to match the environment though, so we'll actually add *two* textures - a light and dark variant - for the vertical and horizontal door orientations. + +Here are the textures I've used. You will find them in the [project assets folder](https://github.com/nicklockwood/RetroRampage/tree/Part10/Source/Rampage/Assets.xcassets/), but feel free to make your own. The door graphic can be anything you like - it can even include transparency. + +Door textures + +Add the door textures to XCAssets, then add the new cases to `Textures.swift`: + +```swift +public enum Texture: String, CaseIterable { + ... + case door, door2 + ... +} +``` + +Next up, we need to add a type to represent the door. Create a new file called `Door.swift` in the Engine module, with the following contents: + +```swift +public struct Door { + public let position: Vector + public let direction: Vector + public let texture: Texture + + public init(position: Vector, direction: Vector, texture: Texture) { + self.position = position + self.direction = direction + self.texture = texture + } +} +``` + +The structure of the door is pretty similar to `Billboard` - not a coincidence since that's how we intend to present the door to the renderer. Let's go ahead and add a computed property for the door's billboard representation. Add the following code to the bottom of the file: + +```swift +public extension Door { + var billboard: Billboard { + return Billboard( + start: position - direction * 0.5, + direction: direction, + length: 1, + texture: texture + ) + } +} +``` + +We'll need to place the doors in the map. The `Tilemap` has two categories of data in it, *tiles* and *things*, and it's not immediately obvious which of these a door would fall under. + +On the one hand, a door is part of the level geometry. It's not sentient, it can't move freely around the level, it can't occupy the same space as a wall. So it makes sense for it to be a type of tile, right? + +But on the other hand, doors are not static. At any given time a door can be open or closed (or somewhere in between), so it requires runtime state. It's animated, it can interact with the player, it can't occupy the same starting tile as a monster or other *thing*. + +I don't think there's an obvious right answer here, but in the end I opted to make doors a *thing*, for the following reasons: + +* It means we can specify the floor/ceiling tile for the doorway independently of the door itself. +* It requires fewer code changes to the `Renderer` and `World` initialization code. + +In `Thing.swift`, go ahead and add a `door` case to the enum: + +```swift +public enum Thing: Int, Decodable { + case nothing + case player + case monster + case door +} +``` + +Then in `Map.json`, in the main project, add two doors to the `things` array (doors are represented by a `3`, matching their index in the `Thing` enum): + +```swift +{ + "width": 8, + "tiles": [ + ... + ], + "things": [ + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 2, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 2, 0, + 0, 0, 0, 3, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 3, 0, + 0, 0, 2, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 1, 0, 2, 0, + 0, 0, 0, 0, 0, 0, 0, 0 + ] +} +``` + +Back in Engine, in `World.swift` update the `World` struct to include a new `doors` property as follows: + +```swift +public struct World { + public let map: Tilemap + public private(set) var doors: [Door] + public private(set) var monsters: [Monster] + public private(set) var player: Player! + public private(set) var effects: [Effect] + + public init(map: Tilemap) { + self.map = map + self.doors = [] + self.monsters = [] + self.effects = [] + reset() + } +} +``` + +Still in `World.swift`, add the following to the top of the `reset()` method, just below the `self.monsters = []` line: + +```swift +self.doors = [] +``` + +We'll also need to add a case to the switch statement to handle the new `door` type: + +```swift +case .door: + doors.append(Door( + position: position, + direction: Vector(x: 0, y: 1), + texture: .door + )) +``` + +This adds a vertical door, but what do we do about horizontal doors? We could add a second door type to `Thing` but that seems rather inelegant. It's also not great that `World` needs to know quite so much about the door internals here. + +In `Door.swift`, replace the `init` method with the following: + +```swift +public init(position: Vector, isVertical: Bool) { + self.position = position + if isVertical { + self.direction = Vector(x: 0, y: 1) + self.texture = .door + } else { + self.direction = Vector(x: 1, y: 0) + self.texture = .door2 + } +} +``` + +That's better - the `Door` initializer now only allows valid door configurations, and the caller can easily specify if a vertical or horizontal door is needed. So how do we work out which type we need? + +The design of the doors means that they have to be straddled on either side by two wall tiles. They can't have a wall segment in front and behind them, otherwise it wouldn't be possible to walk through. + +On that basis, we can infer that if there is a wall tile above and below a door then it must be vertical, and if there isn't, it must be horizontal. Back in `World.reset()`, replace the `case .door:` block with: + +```swift +case .door: + let isVertical = map[x, y - 1].isWall && map[x, y + 1].isWall + doors.append(Door(position: position, isVertical: isVertical)) +``` + +You may have noticed that this code will crash if the door is placed on a border tile - we could add bounds checks, but the renderer will freeze up anyway if the border of the map isn't a wall, so we can consider this a programmer error. + +But since we disabled safety checks in [Part 9](Part9.md), it's possible an out-of-bounds error *won't* actually crash here but will return a garbage value and probably crash at some later point. It would be good code hygiene to add a `precondition()` call to ensure we crash early with a meaningful error. + +Add the following line just before the `let isVertical ...`: + +```swift +precondition(y > 0 && y < map.height, "Door cannot be placed on map edge") +``` + +### Drawing Room + +That's all the code we need in order to place the doors in the world. Now we just need to actually draw them, and turn our corridors into rooms. + +This part is actually fairly trivial, since we already did all the setup work. In the `World.sprites` computed property, replace the line: + +```swift +return monsters.map { $0.billboard(for: ray) } +``` + +with: + +```swift +return monsters.map { $0.billboard(for: ray) } + doors.map { $0.billboard } +``` + +And that's it! Run the game and there's the door in the corner[[2]](#footnote2). + +![The door, visible in the corner of the room](Images/DoorInCorner.png) + +If you walk over to admire the door up close, you might be surprised by this fellow making a sudden appearance! + +![Monster walking through the door](Images/MonsterSurprise.png) + +The problem is, although we've placed the door in the level and drawn it, it has no physical presence. None of the collision detection mechanisms are aware of it, so monsters can walk right through it - as can the player. + +### You Shall Not Pass + +To prevent the monster and player from passing through the door, we need to extend the collision logic. All of our collisions so far are based on rectangle intersections, and fortunately that will work for doors as well. In `Door.swift`, add a computed `rect` property, as follows: + +```swift +public extension Door { + var rect: Rect { + let position = self.position - direction * 0.5 + return Rect(min: position, max: position + direction) + } + + ... +} +``` + +This `Rect` actually has zero area - it's really just a line - but it will work perfectly well for collision calculations. The original Wolfenstein 3D actually modeled the collision area for doors as a full tile-sized square, so you couldn't step into the alcove in front of them, but on a mobile device that makes it tricky to line yourself up with the door, as John Carmack described in a [blog post about the original Wolfenstein 3D iPhone port](https://www.eurogamer.net/articles/id-releases-open-source-wolf-3d-iphone): + +> In watching the early testers, the biggest issue I saw was people sliding off doors before they opened, and having to maneuver back around to go through. In Wolf, as far as collision detection was concerned, everything was just a 64x64 tile map that was either solid or passable. + +> It turned out to be pretty easy to make the door tiles only have a solid central core against the player, so players would slide into the "notch" with the door until it opened. This made a huge improvement in playability. + +Collisions between a monster or player and the walls are handled by the `Actor` protocol. In `Actor.swift`, we already have an `intersection(with map:)` method, but we can't simply extend that to handle doors as well because the `Tilemap` only contains the initial state of the doors, and we'll need to vary the collision behavior when the doors are opened or closed. + +Instead, add two new methods just below `intersection(with map:)`: + +```swift +func intersection(with door: Door) -> Vector? { + return rect.intersection(with: door.rect) +} + +func intersection(with world: World) -> Vector? { + if let intersection = intersection(with: world.map) { + return intersection + } + for door in world.doors { + if let intersection = intersection(with: door) { + return intersection + } + } + return nil +} +``` + +The `intersection(with door:)` method detects the intersection with a single door, and the `intersection(with world:)` checks for intersections with any wall or door in the world. + +In `World.update`, in the `// Handle collisions` section, replace the line: + +``` +while let intersection = monster.intersection(with: map) { +``` + +with: + +``` +while let intersection = monster.intersection(with: self) { +``` + +Then replace: + +``` +while let intersection = player.intersection(with: map) { +``` + +with: + +``` +while let intersection = player.intersection(with: self) { +``` + +Try running the game again and you should find that the monster can no longer walk through the door to get you. If you walk right up to the door though, you'll find that the screen starts flashing red, and you eventually die. What's up with that? + +![Being hurt through the door](Images/HurtThroughDoor.png) + +### X-Ray Vision + +Even though the door is closed, the monster behind it can still see you because we didn't update their AI to take doors into account. As soon as you walk in front of the door they'll walk towards you. They can't walk through the door, but since the door has zero thickness, if you stand right up against it then you're inside their attack radius and they can hurt you. + +Your pistol also "sees" the monster using a ray cast, so right now there's nothing to stop you shooting them through the door. Both of these bugs have the same root cause: + +The monster detects the player via the `Monster.canSeePlayer()` method, which projects a ray towards the player and checks if it hits a wall before it reaches them. To detect wall collisions, it uses the `Tilemap.hitTest()` method internally, which isn't aware of doors (hence the "ability" to see through them). + +The player's pistol finds its target by calling the `World.hitTest()` method, which projects a ray and returns the index of the first monster it hits. This method also calls `Tilemap.hitTest()`, and so also ignores doors. + +In retrospect, `World.hitTest()` was poorly named - partly because it deviates from the pattern of other `hitTest()` methods (which return a coordinate rather than an object index), and partly because it implies generality, whereas actually this method is specifically checking for *monster* hits - we can't use it to detect if the ray hits a wall, or a door, or the player. + +In `World.swift`, rename the `hitTest()` method to `pickMonster()`, as follows: + +```swift +func pickMonster(_ ray: Ray) -> Int? { +``` + +Then, in `Player.update()`, change the line: + +```swift +if let index = world.hitTest(ray) { +``` + +to: + +```swift +if let index = world.pickMonster(ray) { +``` + +That frees up the name `World.hitTest()` to use for its more natural purpose. Back in `World.swift`, add a new `hitTest()` method above the renamed `pickMonster()`: + +```swift +func hitTest(_ ray: Ray) -> Vector { + var wallHit = map.hitTest(ray) + + return wallHit +} +``` + +Currently, this just wraps the `Tilemap.hitTest()` method, and will only detect wall hits, but let's expand it to handle doors as well. In `Door.swift` add the following method: + +```swift +func hitTest(_ ray: Ray) -> Vector? { + return billboard.hitTest(ray) +} +``` + +This may seem a little redundant, since the `Door.billboard` property is public and we could `hitTest()` it directly, but it gives us the flexibility to change the behavior. For example, we might later want doors with holes that can be seen and fired through, or we could add the ability to destroy doors, etc. + +Back in `World.hitTest()`, add the following lines before the return statement: + +```swift +var distance = (wallHit - ray.origin).length +for door in doors { + guard let hit = door.hitTest(ray) else { + continue + } + let hitDistance = (hit - ray.origin).length + guard hitDistance < distance else { + continue + } + wallHit = hit + distance = hitDistance +} +``` + +Here we loop through the doors in the level and check if the ray hits them. If so, the distance of the hit is compared to the original wall hit distance, and the shortest distance is taken as the new threshold. + +Now we have a method to detect ray collisions with both doors and walls, we can refactor the methods that use `Tilemap.hitTest()` to use `World.hitTest()` instead. In `World.pickMonster()`, replace the line: + +```swift +let wallHit = map.hitTest(ray) +``` + +with just: + +```swift +let wallHit = hitTest(ray) +``` + +Then, in `Monster.canSeePlayer()`, replace: + +```swift +let wallHit = world.map.hitTest(ray) +``` + +with just: + +```swift +let wallHit = world.hitTest(ray) +``` + +With that done, we finally have a door that is impervious both to movement and vision. We have created *a wall*. Um... I guess we should probably work out how to make a door that actually opens? + +### Open Sesame + +To allow doors to be opened and closed, we will need to give them some internal state. By now you should be familiar with the concept of creating a state machine by switching over an enum - we'll use the same technique again here. + +In `Door.swift`, add the following enum to the top of the file: + +```swift +public enum DoorState { + case closed + case opening + case open + case closing +} +``` + +The door will start out in the closed state, and like all animated objects in the game it will need to keep track of time, so go ahead and add the following properties to the `Door` struct: + +```swift +public var state: DoorState = .closed +public var time: Double = 0 +``` + +Since doors are interactive, they will need their own `update()` method. Add the following method to the extension block: + +```swift +mutating func update(in world: inout World) { + switch state { + case .closed: + + case .opening: + + case .open: + + case .closing: + + } +} +``` + +This is just a shell for now - we'll work out the implementation in a second. But first, go to `World.update` and add this block of code after the `// Update monsters` section: + +```swift +// Update doors +for i in 0 ..< doors.count { + var door = doors[i] + door.time += timeStep + door.update(in: &self) + doors[i] = door +} +``` + +This should be a fairly familiar pattern - for each door we make a copy, increment the time, and then call `update()`, before replacing the original door instance in the array with the modified one. + +Door opening will be triggered by the player, but having an explicit "open door" button isn't great UX for a mobile game - as Carmack explains in the [aforementioned blog post](https://www.eurogamer.net/articles/id-releases-open-source-wolf-3d-iphone): + +> I started out with an explicit "open door" button like the original game, but I quickly decided to just make that automatic. Wolf and Doom had explicit "use" buttons, but we did away with them on Quake with contact or proximity activation on everything. Modern games have generally brought explicit activation back by situationally overriding attack, but hunting for push walls in Wolf by shooting every tile wouldn't work out. There were some combat tactics involving explicitly shutting doors that are gone with automatic-use, and some secret push walls are trivially found when you pick up an item in front of them now, but this was definitely the right decision. + +If and when we port the game to other platforms, we might want to consider adding an explicit door control, but for now we'll make it automatic. That means we don't need to extend the `Input` or `Player` types at all - we can implement the door opening logic entirely within `Door.update()`. Replace the empty `case .closed:` with the following: + +```swift +case .closed: + if world.player.intersection(with: self) != nil { + state = .opening + time = 0 + } +``` + +So now if the player is touching the door when it's in the `closed` state, it will transition to the `opening` state automatically. What's next? + +The transition from `opening` to `open` is time-based. We'll need to add a `duration` constant to control how long that process should take, so go ahead and add the following property at the top of the `Door` struct: + +```swift +public let duration: Double = 0.5 +``` + +Then in `update()`, replace the empty `case .opening:` with: + +```swift +case .opening: + if time >= duration { + state = .open + time = 0 + } +``` + +We'll use the same duration for the closing animation too, so replace `case .closing:` with: + +```swift +case .closing: + if time >= duration { + state = .closed + time = 0 + } +``` + +That just leaves the `open` case. We need some logic to transition from the open state to the closing sequence. The opening sequence is triggered when the player touches the door, but there's no equivalent action for closing the door again - so how should this work? + +We could do something based on distance - if the player moves outside a certain radius then the door closes automatically - but a simpler option is to have the door close after a fixed delay. Add a second constant called `closeDelay` to `Door`, just below the `duration` property we added earlier: + +```swift +public let closeDelay: Double = 3 +``` + +Then in `update()`, replace `case .open:` with: + +```swift +case .open: + if time >= closeDelay { + state = .closing + time = 0 + } +``` + +That takes care of the door's state machine - now we just need to implement the animation. + +In keeping with the original Wolfenstein 3D, our doors will slide open sideways[[3]](#footnote3). In earlier chapters we implemented animations based on image sequences and opacity - this time we are going to animate a position, but really the principle is the same. + +The door already has a `position` property. We could manipulate this directly, but that would make it difficult to track where we are at in the animation, and could also lead to a gradual buildup of floating point rounding errors over time. Instead, we'll add a separate `offset` property which we can combine with the `position` to get the current door placement. + +Add the following computed property to the extension block in `Door.swift`, just above the `billboard` property: + +```swift +var offset: Double { + let t = min(1, time / duration) + switch state { + case .closed: + return 0 + case .opening: + return Easing.easeInEaseOut(t) + case .open: + return 1 + case .closing: + return 1 - Easing.easeInEaseOut(t) + } +} +``` + +This `offset` property works in a very similar way to the `progress` property we added to the `Effect` type in [Part 7](Part7.md). Like the `fizzleOut` effect, we've used the `easeInEaseOut()` easing function so that the door will gently accelerate and decelerate as it opens and closes. + +In the `billboard` property code, replace the line: + +```swift +start: position - direction * 0.5, +``` + +with: + +```swift +start: position + direction * (offset - 0.5), +``` + +So now the `Billboard` for the door will slide along its axis in proportion to the current offset. That will take care of rendering, but we also need to handle player and monster collisions, which are based on the door's `rect` property. + +There are a couple of ways we could handle this - for example, we could update `Player.intersection(with door:)` to return nil if the door is in the `open` or `opening state`. That's actually how the original Wolfenstein engine worked. + +But the way we have constructed the door means it's really not much trouble to implement collisions properly. In the computed `Door.rect` property, replace the following line: + +```swift +let position = self.position - direction * 0.5 +``` + +with: + +```swift +let position = self.position + direction * (offset - 0.5) +``` + +With this in place, the position of the collision rect for the door will match up exactly with the billboard position, allowing the player to pass through the gap when the door is open. Try running the game now and you should find that the door is fully functional. + +![Functioning door](Images/FunctioningDoor.png) + +### Door Jam + +The decision to implement the door collisions faithfully does introduce a slight complication. After opening the door (and dispatching any prowling monsters), try standing in the doorway and waiting for it to close on you. + +Assuming you've lined yourself up correctly, you'll find that the game freezes when the door hits you. Not good! + +If you recall, in `World.update()` we wrote some collision logic for the player that looked like this: + +```swift +while let intersection = player.intersection(with: map) { + player.position -= intersection +} +``` + +This well-intentioned loop was supposed to ensure that the player never got stuck inside walls, but we failed to account for the scenario of a wall (or in this case, a door) getting stuck *inside the player*. As the door closes on the player, the collision handling tries to move the player out of the way, but there is nowhere to move to, as the gap between the door and wall is now too narrow for the player to fit. + +The solution is to make the collision handling a bit more tolerant. But since this code is duplicated between the player and monsters, we'll also take this opportunity to extract it. In `Actor.swift`, add the following method to the extension block: + +```swift +mutating func avoidWalls(in world: World) { + var attempts = 10 + while attempts > 0, let intersection = intersection(with: world) { + position -= intersection + attempts -= 1 + } +} +``` + +Like the existing collision loops, this repeatedly tries to move the actor away from any scenery they are intersecting. Unlike those loops, however, it attempts this only 10 times before giving up, on the basis that it's better to allow some object interpenetration than to freeze the game. + +Back in `World.update()`, in the `// Handle collisions` block, replace the lines: + +```swift +while let intersection = player.intersection(with: map) { + player.position -= intersection +} +``` + +with: + +```swift +player.avoidWalls(in: self) +``` + +Then also replace: + +```swift +while let intersection = monster.intersection(with: map) { + monster.position -= intersection +} +``` + +with: + +```swift +monster.avoidWalls(in: self) +``` + +Try running the game again and you should find that it's no longer possible to get stuck in the door. If you position yourself so it closes on you, you just end up getting nudged out again as the `avoidWalls()` method gives up and the door keeps moving. + +### Doorjamb + +It looks a little odd having the doors slide out of a featureless wall. Wolfenstein had special [doorjamb](https://en.wikipedia.org/wiki/Jamb) textures that were applied to the neighboring wall tiles. + +Go ahead and add a pair of doorjamb textures to XCAssets. + +Doorjamb textures + +Then add the new cases to `Textures.swift`: + +```swift +public enum Texture: String, CaseIterable { + ... + case doorjamb, doorjamb2 + ... +} +``` + +We could implement the doorjamb as a special kind of wall tile, but our texturing scheme currently only allows us to vary the texture for vertical or horizontal walls, which would lead to an awkward limitation on how doors can be placed to avoid the jamb texture appearing on walls we didn't intend it to. + +Instead of trying to force this into our existing wall model, let's extend the renderer to support doorjambs as a special case. Whenever a wall is adjacent to a door, we'll use the jamb texture instead of whatever texture is specified by the tile. + +To check if a given tile contains a door, we could either use the `things` array in the `Tilemap` or the `doors` array in `World`. The `things` array is already ordered for lookup by tile coordinate, whereas `doors` would require a linear search to find the element with the matching position, so `things` seems like the better choice. + +In `World.swift`, add the following method: + +```swift +func isDoor(at x: Int, _ y: Int) -> Bool { + return map.things[y * map.width + x] == .door +} +``` + +Now let's take a look at the wall-rendering code. In `Renderer.draw()`, in the `// Draw wall` section, we look up the current tile using: + +```swift +let tile = world.map.tile(at: end, from: ray.direction) +``` + +The `TileMap.tile(at:from:)` method uses the ray intersection point and direction to compute the tile that's been hit. This method returns the `Tile` directly, but in order to determine if the neighboring tile contains a door, it would be more helpful if we had the tile *coordinates* instead. + +In `Tilemap.swift`, find the following method: + +```swift +func tile(at position: Vector, from direction: Vector) -> Tile { +``` + +and change its signature to: + +```swift +func tileCoords(at position: Vector, from direction: Vector) -> (x: Int, y: Int) { +``` + +Then, in the same method, replace the last line: + +```swift +return self[Int(position.x) + offsetX, Int(position.y) + offsetY] +``` + +with: + +```swift +return (x: Int(position.x) + offsetX, y: Int(position.y) + offsetY) +``` + +Next, to avoid breaking all the call sites that were using `tile(at:from:)`, add a new method with the same signature just below the `tileCoords()` method: + +```swift +func tile(at position: Vector, from direction: Vector) -> Tile { + let (x, y) = tileCoords(at: position, from: direction) + return self[x, y] +} +``` + +Back in the `// Draw wall` section of `Renderer.draw()`, replace the line: + +```swift +let tile = world.map.tile(at: end, from: ray.direction) +``` + +with: + +```swift +let (tileX, tileY) = world.map.tileCoords(at: end, from: ray.direction) +let tile = world.map[tileX, tileY] +``` + +The new code is functionally equivalent, but now that we have the actual tile coordinates available, we can derive the neighboring tile and determine if it contains a door. + +The code immediately after those lines looks like this: + +```swift +if end.x.rounded(.down) == end.x { + wallTexture = textures[tile.textures[0]] + wallX = end.y - end.y.rounded(.down) +} else { + wallTexture = textures[tile.textures[1]] + wallX = end.x - end.x.rounded(.down) +} +``` + +This code checks if the wall is horizontal or vertical, then applies the appropriate wall texture. It is here that we need to add the check for a neighboring door and (if applicable) override the texture. + +For a vertical wall, the tile we need to check will be either to the left or right of `tileX`, depending on the direction of the ray. Replace the line: + +```swift +wallTexture = textures[tile.textures[0]] +``` + +with: + +```swift +let neighborX = tileX + (ray.direction.x > 0 ? -1 : 1) +let isDoor = world.isDoor(at: neighborX, tileY) +wallTexture = textures[isDoor ? .doorjamb : tile.textures[0]] +``` + +That code uses the ray direction to determine which neighbor to check, then if the neighbor contains a door, swaps the wall texture for the doorjamb. Now we'll do the same for the `else` (horizontal) branch. Replace: + +```swift +wallTexture = textures[tile.textures[1]] +``` + +with: + +```swift +let neighborY = tileY + (ray.direction.y > 0 ? -1 : 1) +let isDoor = world.isDoor(at: tileX, neighborY) +wallTexture = textures[isDoor ? .doorjamb2 : tile.textures[1]] +``` + +Run the game again and you should see the doorjamb in position by the door. + +![Doorjamb texture on wall next to door](Images/Doorjamb.png) + +And that's it for Part 10! In this part we: + +* Added sliding doors by repurposing the billboard renderer we used for sprites +* Implemented doorjamb textures without awkward map layout restrictions + +More to come in Part 11 (TBD). + +
+ +[[1]](#reference1) Sliding doors are a rare example of something the primitive Wolfenstein engine could do that later, more sophisticated engines like Doom could not replicate. In Doom's sector-based world, sectors can only move up and down, not sideways. Doors in Doom were effectively mini elevators - with a small section of floor moving up to meet the ceiling, or vice-versa. + +[[2]](#reference2) Before examining the door, you might want to kill the zombie first so you can appreciate it properly without being mauled. + +[[3]](#reference3) Note that we don't *have* to implement them this way. Although vertical offsets aren't current supported for billboards, we could create a door opening animation using a sequence of image frames and then animate it using the same technique we use for sprites. \ No newline at end of file diff --git a/Tutorial/Part2.md b/Tutorial/Part2.md new file mode 100644 index 0000000..86d915a --- /dev/null +++ b/Tutorial/Part2.md @@ -0,0 +1,690 @@ +## Part 2: Mazes and Motion + +In [Part 1](Part1.md) we laid the groundwork for our game, creating a cross-platform graphics engine, a rectangular world with a moving player avatar, and a platform layer for iOS. + +We will now build upon the code we created in Part 1, adding a 2D maze, user input and collision handling. If you haven't already done so, I strongly suggest you work through Part 1 and write the code yourself, but if you prefer you can just download the project from [here](https://github.com/nicklockwood/RetroRampage/archive/Part1.zip). + +### Amaze Me + +Wolfenstein, at its heart, is a 2D maze game built on a 64x64 tile grid. A grid of fixed-size tiles is a little too crude to create realistic architecture, but it's great for making twisting corridors, hidden alcoves and secret chambers. It's also really easy to define in code. + +Create a new file in the Engine module called `Tile.swift` with the following contents: + +```swift +public enum Tile: Int, Decodable { + case floor + case wall +} +``` + +Then, create another file called `Tilemap.swift`: + +```swift +public struct Tilemap: Decodable { + private let tiles: [Tile] + public let width: Int +} + +public extension Tilemap { + var height: Int { + return tiles.count / width + } + + var size: Vector { + return Vector(x: Double(width), y: Double(height)) + } + + subscript(x: Int, y: Int) -> Tile { + return tiles[y * width + x] + } +} +``` + +If you were thinking this looks familiar, you'd be right. `Tilemap` is pretty similar to the `Bitmap` struct we defined in the first part (they even both have "map" in the name). The only difference is that instead of colors, `Tilemap` contains tiles[[1]](#footnote1). + +In the `World` struct, replace the stored `size` property with: + +```swift +public let map: Tilemap +``` + +Then modify the `World.init()` method to look like this: + +```swift +public init(map: Tilemap) { + self.map = map + self.player = Player(position: map.size / 2) +} +``` + +Since `size` is now a sub-property of `map`, rather than being of a property of the world itself, let's add a computed property for the size so we don't break all the existing references: + +```swift +public extension World { + var size: Vector { + return map.size + } + + ... +} +``` + +To draw the map we will need to know if each tile is a wall or not. We could just check if the tile type is equal to `.wall`, but later if we add other kinds of wall tile, such code would break silently. + +Instead, add the following code to `Tile.swift`: + +``` +public extension Tile { + var isWall: Bool { + switch self { + case .wall: + return true + case .floor: + return false + } + } +} +``` + +The `switch` statement in the `isWall` property is *exhaustive*, meaning it doesn't include a `default:` clause, so it will fail to compile if we add another case without handling it. That means we can't accidentally introduce a bug by forgetting to update it when we add new wall types. + +Repeating that `switch` everywhere we access the tiles would be a nuisance, but by using the `isWall` property instead, we can ensure the code is robust without being verbose. + +In `Renderer.draw()` add the following block of code before `// Draw player`: + +```swift +// Draw map +for y in 0 ..< world.map.height { + for x in 0 ..< world.map.width where world.map[x, y].isWall { + let rect = Rect( + min: Vector(x: Double(x), y: Double(y)) * scale, + max: Vector(x: Double(x + 1), y: Double(y + 1)) * scale + ) + bitmap.fill(rect: rect, color: .white) + } +} +``` + +That takes care of drawing the wall tiles. But so that we can actually see the white walls against the background, in `Renderer.init()` change the line: + +```swift +self.bitmap = Bitmap(width: width, height: height, color: .white) +``` + +to: + +```swift +self.bitmap = Bitmap(width: width, height: height, color: .black) +``` + +Now that we have code to draw the map, *we need a map to draw*. + +Here we hit upon another natural division between architectural layers. So far we have only really dealt with two layers: the game engine and the platform layer. Gameplay specifics such as particular map layouts do not really belong in the engine, but they are also not tied to a particular platform. So where do we put them? + +### The Best Code is No Code + +We could create a whole new module for game-specific code, but even better would be if we could treat our game as *data* to be consumed by the engine, rather than code at all. + +You may have noticed that the `Tile` enum has an `Int` type, and conforms to `Decodable` (as does `Tilemap` itself). The reason for this is to facilitate defining maps in JSON rather than having to hard-code them. + +Create a new empty file in the main project called `Map.json` and add the following contents: + +```swift +{ + "width": 8, + "tiles": [ + 1, 1, 1, 1, 1, 1, 1, 1, + 1, 0, 0, 1, 0, 0, 0, 1, + 1, 0, 0, 1, 0, 0, 0, 1, + 1, 0, 0, 0, 0, 0, 0, 1, + 1, 0, 0, 1, 1, 1, 0, 1, + 1, 0, 0, 1, 0, 0, 0, 1, + 1, 0, 0, 1, 0, 0, 0, 1, + 1, 1, 1, 1, 1, 1, 1, 1 + ] +} +``` + +The numbers match the case values in the `Tile` enum, so `0` is a floor tile and `1` is a wall. This is a tiny and trivial map, but it will serve to test the engine. You can replace it with a different map size or layout if you prefer - it won't affect the rest of the tutorial. + +In `ViewController.swift`, add the following free function outside the `ViewController` class[[2]](#footnote2): + +```swift +private func loadMap() -> Tilemap { + let jsonURL = Bundle.main.url(forResource: "Map", withExtension: "json")! + let jsonData = try! Data(contentsOf: jsonURL) + return try! JSONDecoder().decode(Tilemap.self, from: jsonData) +} +``` + +Then replace the line: + +```swift +private var world = World() +``` + +with: + +```swift +private var world = World(map: loadMap()) +``` + +Run the app again and you should see the map rendered in all its glory: + +![The tile map displayed in white](Images/Tilemap.png) + +The player looks a bit silly now though, drifting diagonally across the map without regard for the placement of walls. Let's fix that. + +### Position is Everything + +For maximum flexibility in the map design, the player's starting position should be defined in the JSON file rather than hard-coded. + +We could add a new case called `player` to the `Tile` enum, and then place the player in the map array the same way we place walls, but if we later add different types of floor tile we'd have no way to specify the type of floor underneath the player if they both occupied the same element in the `tiles` array. + +Instead, let's add a new type to represent non-static objects in the map. Create a new file called `Thing.swift` in the Engine module, with the following contents: + +```swift +public enum Thing: Int, Decodable { + case nothing + case player +} +``` + +Then add a `things` property to `Tilemap`: + +```swift +public struct Tilemap: Decodable { + private let tiles: [Tile] + public let things: [Thing] + public let width: Int +} +``` + +Finally, add an array of `things` to the `Map.json` file: + +``` +{ + "width": 8, + "tiles": [ + 1, 1, 1, 1, 1, 1, 1, 1, + 1, 0, 0, 1, 0, 0, 0, 1, + 1, 0, 0, 1, 0, 0, 0, 1, + 1, 0, 0, 0, 0, 0, 0, 1, + 1, 0, 0, 1, 1, 1, 0, 1, + 1, 0, 0, 1, 0, 0, 0, 1, + 1, 0, 0, 1, 0, 0, 0, 1, + 1, 1, 1, 1, 1, 1, 1, 1 + ], + "things": [ + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 1, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0 + ] +} +``` + +Unlike the map tiles, we can't just look up the player position from the `things` array each time we need it, because once the player starts moving their position will no longer align to the tile grid. + +Instead we'll use `things` to get the *initial* player position, after which the `Player` object will keep track of its own position. In `World.init()`, replace the line: + +```swift +self.player = Player(position: map.size / 2) +``` + +with: + +```swift +for y in 0 ..< map.height { + for x in 0 ..< map.width { + let position = Vector(x: Double(x) + 0.5, y: Double(y) + 0.5) + let thing = map.things[y * map.width + x] + switch thing { + case .nothing: + break + case .player: + self.player = Player(position: position) + } + } +} +``` + +This code scans through the things array and adds any non-zero things (currently just the player) to the world. Note that we add `0.5` to the map X and Y coordinate when we set the player's starting position so that the player will be placed in the center of the tile instead of at the top-left corner. + +If we try to run the app now the compiler will complain that we are trying to return from the initializer without initializing all properties. Although we can see that there is a `1` in the `things` array, the Swift compiler isn't able to detect that statically. + +To fix that, add a `!` to the `player` property (that means the app will crash if we forget to include the player in `things`, but the game can't work without a player anyway): + +```swift +public var player: Player! +``` + +With the player avatar in the correct starting position, we now need to fix the player's movement. First, let's stop the hard-coded diagonal motion. In `Player.swift`, change the line: + +```swift +self.velocity = Vector(x: 1, y: 1) +``` + +To: + +```swift +self.velocity = Vector(x: 0, y: 0) +``` + +Now, to make the player move properly we need to implement *user input*. User input is handled by the platform layer, as it will vary significantly between different target devices. On a Mac you'd probably use arrow keys for movement. On tvOS you'd use a joypad. For iOS, we'll need to implement touch-based movement. + +### Pan Handling + +The most effective form of touch-based interface is direct manipulation, but that doesn't translate well to games where player movement often extends beyond a single screen. Traditional game control schemes also don't work well on a touch screen - some early ports of classic games to iOS had *truly horrible* controls because they tried to replicate physical buttons and joysticks with on-screen icons[[3]](#footnote3). + +The best general-purpose movement control I've found for action games is a *floating joystick*, where the stick position is measured relative to where the finger first touched down instead of a fixed point. The benefit of this approach is that it's much less susceptible to finger-drift than a fixed joystick, meaning that the player can keep their eyes on the action without worrying about their finger slipping off the controls. + +There are a couple of different ways to implement a joystick on iOS, but the most straightforward is to use a `UIPanGestureRecognizer`. Go ahead and add a `panGesture` property to the `ViewController` class: + +```swift +class ViewController: UIViewController { + private let imageView = UIImageView() + private let panGesture = UIPanGestureRecognizer() + + ... +} +``` + +Then add the following line to `viewDidLoad()`: + +```swift +view.addGestureRecognizer(panGesture) +``` + +Conveniently, `UIPanGestureRecognizer` already computes the relative finger position from the point at which the gesture started, so we don't need to store any state information beyond what the gesture recognizer already tracks for us. + +In a normal app we'd use the target/action pattern to bind a method that would be called whenever the gesture was recognized, but that's not really helpful in this case because we don't want to be informed about pan gestures *as they happen*, we want to sample them once per frame. Instead of an action method, we'll use a computed property for the input vector. + +Add the following code to `ViewController`: + +```swift +private var inputVector: Vector { + switch panGesture.state { + case .began, .changed: + let translation = panGesture.translation(in: view) + return Vector(x: Double(translation.x), y: Double(translation.y)) + default: + return Vector(x: 0, y: 0) + } +} +``` + +This input needs to be passed to the engine. We could directly pass the vector, but we'll be adding other inputs in future (e.g. a fire button), so let's package the input up inside a new type. Create a new file in the Engine module called `Input.swift` with the following contents: + +```swift +public struct Input { + public var velocity: Vector + + public init(velocity: Vector) { + self.velocity = velocity + } +} +``` + +Then replace the following line in `ViewController.update()`: + +```swift +world.update(timeStep: timeStep) +``` + +with: + +```swift +let input = Input(velocity: inputVector) +world.update(timeStep: timeStep, input: input) +``` + +And in the `World.swift` file, change the `update()` method to: + +```swift +mutating func update(timeStep: Double, input: Input) { + player.velocity = input.velocity + player.position += player.velocity * timeStep + player.position.x.formTruncatingRemainder(dividingBy: size.x) + player.position.y.formTruncatingRemainder(dividingBy: size.y) +} +``` + +If you try running the app now, and drag your finger around the screen, you'll see that the player moves way too fast. The problem here is that we are measuring the input vector in screen points, but player movement in the game is supposed to be specified in world units - a finger swipe across the screen covers far more points than tiles. + +The solution is to divide the input vector by some value - but what value? We could use the relative scale factor of the screen to the world, but we don't really want the player speed to depend on the size of the world and/or screen. + +What we need to do is [normalize](https://en.wikipedia.org/wiki/Normalization_(statistics)) the input velocity, so that its value is independent of the input method. Regardless of the raw input values produced by the platform layer the game should always receive a value in the range 0 to 1. + +Floating joystick behavior + +In order to normalize the input, we first need to pick a maximum value. If you think about a real joystick on a gamepad, they typically have a travel distance of about half an inch. On an iPhone that's roughly 80 screen points, so a sensible joystick radius would be around 40 points (equivalent to 80 points diameter). + +Add a new constant to the top of the `ViewController.swift` file (not inside the `ViewController` class itself): + +``` +private let joystickRadius: Double = 40 +``` + +Now we have defined the maximum, we can divide the input by that value to get the normalized result. In the `inputVector` getter, replace the line: + +```swift +return Vector(x: Double(translation.x), y: Double(translation.y)) +``` + +with: + +```swift +var vector = Vector(x: Double(translation.x), y: Double(translation.y)) +vector /= joystickRadius +return vector +``` + +That's *almost* the solution, but there's still a problem. If the user drags their finger further than 40 points, the resultant vector magnitude will be > 1, so we need to clamp it. In order to do that, we need to calculate the actual length of the vector. + +[Pythagoras's theorem](https://en.wikipedia.org/wiki/Pythagorean_theorem#Other_forms_of_the_theorem) states that the length of the hypotenuse of a right-angle triangle is equal to the square root of the sum of the squares of the other two sides. This relationship allows us to derive the length of a vector from its X and Y components. + +Pythagoras's theorem + +In `Vector.swift`, add a computed property for `length`: + +```swift +public extension Vector { + var length: Double { + return (x * x + y * y).squareRoot() + } + + ... +} +``` + +Then in `ViewController.inputVector`, replace the line: + +```swift +vector /= joystickRadius +``` + +with: + +```swift +vector /= max(joystickRadius, vector.length) +``` + +With that extra check, we can be confident that the resultant magnitude will never be greater than one. Run the app again and you should find that the player now moves at reasonable speed. If anything, one world unit per second is a bit *slow* for the maximum speed. Add a `speed` constant to `Player` so we can configure the maximum rate of movement: + +```swift +public struct Player { + public let speed: Double = 2 + ... +} +``` + +Then, in `World.update()`, multiply the input velocity by the player's speed to get the final velocity: + +```swift +func update(timeStep: Double, input: Vector) { + player.velocity = input.velocity * player.speed + ... +} +``` + +The speed is reasonable now, but movement feels a bit *laggy*. The problem is, if you drag more than 40 points and then try to change direction, you have to move your finger back inside the joystick radius before it will register movement again. This was exactly the problem we wanted to mitigate by having a floating joystick, but although the joystick re-centers itself whenever you touch down, its position still remains fixed until you raise your finger. + +Ideally, it should never be possible to move your finger off the joystick. Attempting to move your finger outside the joystick radius should just drag the joystick to a new location. + +Joystick follows player's finger + +In the same way that we clamped the vector to prevent input values > 1, we can also clamp the cumulative translation value of the gesture itself. `UIPanGestureRecognizer` has a `setTranslation()` method that lets you update the distance travelled, so in the `inputVector` computed property, just before the `return vector` line, add the following: + +```swift +panGesture.setTranslation(CGPoint( + x: vector.x * joystickRadius, + y: vector.y * joystickRadius +), in: view) +``` + +This uses the clamped `vector` value multiplied by the `joystickRadius` to get the clamped translation distance in screen points, then reassigns that to the gesture recognizer, effectively changing its record of where the gesture started. + +### Barrier to Entry + +Next, we need to solve the problem of the player moving through walls. To prevent this, we first need a way to detect if the player is intersecting a wall. + +Because the map is a grid, we can identify the tiles that overlap the player rectangle by taking the integer parts of the `min` and `max` coordinates of the rectangle to get the equivalent tile coordinates, then looping through the tiles in that range to see if any of them is a wall. + +Detecting collision between player and walls + +We'll implement that as an `isIntersecting()` method on `Player`: + +```swift +public extension Player { + ... + + func isIntersecting(map: Tilemap) -> Bool { + let minX = Int(rect.min.x), maxX = Int(rect.max.x) + let minY = Int(rect.min.y), maxY = Int(rect.max.y) + for y in minY ... maxY { + for x in minX ... maxX { + if map[x, y].isWall { + return true + } + } + } + return false + } +} +``` + +That gives us a way to detect if the player is colliding with a wall. Now we need a way to *stop* them from doing that. A simple approach is to wait for the collision to happen, then *undo* it. Replace the code in `World.update()` with the following: + +```swift +mutating func update(timeStep: Double, input: Input) { + let oldPosition = player.position + player.velocity = input.velocity * player.speed + player.position += player.velocity * timeStep + if player.isIntersecting(map: map) { + player.position = oldPosition + } +} +``` + +If you run the game again, you'll see that it's now not possible to walk through walls anymore. The only problem is that now if you walk into a wall, you get *stuck*, unless you walk directly away from the wall again. + +Once you touch the wall, any attempt at lateral movement is likely to lead to interpenetration and be rejected by the update handler. In fact, it's not really possible to move down the one-unit-wide corridor because of this. + +We can mitigate the problem a bit by reducing the player's `radius`. The current value of `0.5`, means the player's diameter is one whole world unit - the same width as the corridor. Reducing it to `0.25` will give us a bit more freedom to move around. In `Player.swift` replace the line: + +```swift +public let radius: Double = 0.5 +``` + +with: + +```swift +public let radius: Double = 0.25 +``` + +That helps with the narrow corridors, but we still stick to the walls when we hit them. It would be much nicer if you could slide along the wall when you walk into it, instead of stopping dead like you'd walked into flypaper. + +To fix this, we need to go beyond collision *detection*, and implement collision *response*. Instead of just working out if the player is intersecting a wall, we'll calculate *how far* into the wall they have moved, and then push them out by exactly that amount. + +To simplify the problem, we'll start by computing the intersection vector between two rectangles, then we can extend it to work with player/map collisions. + +To get the intersection, we measure the overlaps between all four edges of the two rectangles and create an intersection vector for each. If any of these overlaps is zero or negative, it means the rectangles are not intersecting, so we return `nil`. Then we sort the vectors to find the shortest. + +Measuring intersection vector between two rectangles + +Add the following code in `Rect.swift`: + +```swift +public extension Rect { + func intersection(with rect: Rect) -> Vector? { + let left = Vector(x: max.x - rect.min.x, y: 0) + if left.x <= 0 { + return nil + } + let right = Vector(x: min.x - rect.max.x, y: 0) + if right.x >= 0 { + return nil + } + let up = Vector(x: 0, y: max.y - rect.min.y) + if up.y <= 0 { + return nil + } + let down = Vector(x: 0, y: min.y - rect.max.y) + if down.y >= 0 { + return nil + } + return [left, right, up, down] + .sorted(by: { $0.length < $1.length }).first + } +} +``` + +In `Player.swift`, replace the `isIntersecting(map:)` method with the following: + +``` +func intersection(with map: Tilemap) -> Vector? { + let minX = Int(rect.min.x), maxX = Int(rect.max.x) + let minY = Int(rect.min.y), maxY = Int(rect.max.y) + var largestIntersection: Vector? + for y in minY ... maxY { + for x in minX ... maxX where map[x, y].isWall { + let wallRect = Rect( + min: Vector(x: Double(x), y: Double(y)), + max: Vector(x: Double(x + 1), y: Double(y + 1)) + ) + if let intersection = rect.intersection(with: wallRect), + intersection.length > largestIntersection?.length ?? 0 { + largestIntersection = intersection + } + } + } + return largestIntersection +} +``` + +The basic structure of the new method is the same as before: identifying the potentially overlapping tiles and looping through them. But now we actually compute the intersection vectors - rather than just a boolean to indicate that an intersection has occurred - and return the largest intersection detected. + +In `World.swift`, change the `update()` method to use the new intersection method: + +```swift +mutating func update(timeStep: Double, input: Input) { + player.velocity = input.velocity * player.speed + player.position += player.velocity * timeStep + if let intersection = player.intersection(with: map) { + player.position -= intersection + } +} +``` + +If an intersection was detected, we subtract the vector from the player's current position. Unlike the previous solution, this won't move the player back to where they were before, but instead it will move them the shortest distance necessary to push them out of the wall. If they approach the wall at an angle, their lateral movement will be preserved and they will appear to slide along the wall instead of stopping dead. + +If you run the game now and try sliding against a wall you may find that the player occasionally sinks into it, or even passes right through it to the other side. + +The problem is that if the player is at an intersection between two walls, pushing them out of one wall tile can potentially push them right into another one, since we only handle the first intersection we detect. + +That would probably be corrected by a subsequent collision response, but that wouldn't happen until one or more frames later, and in the meantime the player may have moved further into the wall. + +Multiple collision response steps needed to move player out of neighboring walls + +Instead of waiting until the next frame to apply further collision response steps, we can replace the `if` statement in the update logic with a `while` loop. + +In `World.update()` replace the following line: + +```swift +if let intersection = player.intersection(with: map) { +``` + +with: + +```swift +while let intersection = player.intersection(with: map) { +``` + +With that change in place, the collision logic will keep nudging the player until there are no further intersections detected. + +This collision response mechanism is still a bit fragile however, because it assumes that a player can never walk more than their own diameter's depth through a wall in a single frame. If they did somehow manage to do that, they could get trapped - stuck in an infinite loop of being nudged out of one wall into another and then back again. If they managed to go even faster they might pass the center point of the wall and end up getting pushed out the other side. + +Player gets stuck in an infinite collision response loop + +So what would it take for that to happen? At the current player movement speed of 2 units-per-second, and a player diameter of 0.5 units, they will travel their own diameter's distance in 0.25 seconds (one quarter of a second). In order to prevent the player potentially getting stuck, we need to *guarantee* that the size of each time step remains well below that limit. + +### Baby Steps + +Of course, the game would be completely unplayable at 4 frames per second, and we would never ship it in that state, but there are plenty of scenarios where a game might suffer a single-frame stutter. For example, if the player answers a phone call in the middle of a game, the game will move into the background and the frame timer will be paused. When the player resumes 10 minutes later, we don't want their avatar to suddenly jump forward several thousand pixels as if they had been moving that whole time. + +A brute-force solution is to cap the update `timeStep` at some sensible maximum like 1/20 (i.e. a minimum of 20 FPS). If the frame period ever goes over that, we'll just pass 1/20 as the `timeStep` anyway. + +Add a new constant to the top of the `ViewController.swift` file: + +```swift +private let maximumTimeStep: Double = 1 / 20 +``` + +Then in `update()`, replace the line: + +```swift +let timeStep = displayLink.timestamp - lastFrameTime +``` + +with: + +```swift +let timeStep = min(maximumTimeStep, displayLink.timestamp - lastFrameTime) +``` + +That gives us a little bit of breathing room, but what happens later if we want to add projectiles with a radius of 0.1 units that travel 100 units per second? Well, we can't very well set a minimum frame rate of 1000 FPS, so we need a way to make the time steps as small as possible without increasing the frame rate. + +The solution is to perform multiple world updates per frame. There is no rule that says world updates have to happen in lock-step with drawing[[4]](#footnote4). For a game like this, where the frame rate will likely be limited by rendering rather than physics or AI, it makes sense to perform multiple world updates for every frame drawn. + +If the actual frame time is 1/20th of a second, we could update the world twice, passing a `timeStep` of 1/40th each time. The player won't see the effect of those intermediate steps on screen, so we won't bother drawing them, but by reducing the time between updates we can improve the accuracy of collisions and avoid any weirdness due to lag. + +Since the frame rate will be 60 FPS on most systems, a time step of 1/120 (120 FPS) seems reasonable for now (we can always increase it later if we need to handle tiny and/or fast-moving objects). Add the following constant to the top of `ViewController.swift`: + +```swift +private let worldTimeStep: Double = 1 / 120 +``` + +Then, in the `update()` method, replace the line: + +```swift +world.update(timeStep: timeStep, input: input) +``` + +with: + +```swift +let worldSteps = (timeStep / worldTimeStep).rounded(.up) +for _ in 0 ..< Int(worldSteps) { + world.update(timeStep: timeStep / worldSteps, input: input) +} +``` + +Regardless of the frame rate, the world will now always be updated at a minimum of 120 FPS, ensuring consistent and reliable collision handling. + +You might be thinking that with the `worldTimeStep` logic, we no longer need the `maximumTimeStep` check, but actually this saves us from another problem - the so-called *spiral of death* that can occur when your world updates take longer to execute than your frame step. + +By limiting the maximum frame time, we also limit the number of iterations of the world update. 1/20 divided by 1/120 is 6, so there will never be more than six world updates performed for a single frame drawn. That means that if a frame happens to take a long time (e.g. because the game was backgrounded or paused), then when the game wakes up it won't try to execute hundreds of world updates in order to catch up with the frame time, blocking the main thread and delaying the next frame - and so on - leading to the death spiral. + +That's it for Part 2. In this part we... + +* Loaded and displayed a simple maze +* Added touch input for moving the player +* Added collision detection and response so the player doesn't pass through walls +* Refined the game loop and decoupled world updates from the frame rate + +In [Part 3](Part3.md) we'll take the jump into 3D and learn how to view a 2D maze from a first person perspective. + +
+ +[[1]](#reference1) We could replace both of these with a single `Map` type, perhaps using generics or a protocol in place of `Color`/`Tile`, but despite the superficial similarity, these two structs serve significantly different roles, and are likely to diverge as we add more code, rather than benefitting from a shared implementation. + +[[2]](#reference2) You may wonder why we haven't bothered with any error handling in the `loadMap()` function? Since the `Map.json` file is bundled with the app at build time, any runtime errors when loading it would really be symptomatic of a programmer error, so there is no point in trying to recover gracefully. + +[[3]](#reference3) I'd name and shame some examples, but due to the 32-bit *Appocalypse* of iOS 11, most of them don't exist anymore. + +[[4]](#reference4) Modern games often run their game logic on a different thread from graphics rendering. This has some nice advantages - such as improved input responsiveness - but it adds a lot of complexity because you need to ensure that all the data structures shared by the game logic and the renderer are thread-safe. \ No newline at end of file diff --git a/Tutorial/Part3.md b/Tutorial/Part3.md new file mode 100644 index 0000000..e5d8090 --- /dev/null +++ b/Tutorial/Part3.md @@ -0,0 +1,743 @@ +## Part 3: Ray Casting + +In [Part 2](Part2.md) we created a 2D tile-based maze, implemented a player avatar with simple physics and collision handling, and added a touch-based joystick for movement. + +Starting with [that code](https://github.com/nicklockwood/RetroRampage/archive/Part2.zip) we will now make the jump into 3D using a technique called ray casting. But first, we need to lay some groundwork. + +### Sense of Direction + +The player avatar currently has an implicit direction of movement, but in a first person game we will need to have an *explicit* direction that the player is facing, even when stationary. Go ahead and add a `direction` property to the player: + +```swift +public struct Player { + public let speed: Double = 2 + public let radius: Double = 0.25 + public var position: Vector + public var velocity: Vector + public var direction: Vector + + public init(position: Vector) { + self.position = position + self.velocity = Vector(x: 0, y: 0) + self.direction = Vector(x: 1, y: 0) + } +} +``` + +Note that we've used a vector for the direction, rather than an angle. Vectors are a better representation for directions as they avoid ambiguities such as where the angle is measured from, and whether it's measured clockwise or counter-clockwise. They're also easier to work with mathematically. + +A direction vector should ideally always be *normalized* (have a length of 1), which is why we've initialized the direction with 1,0 instead of 0,0. + +For now, we'll derive the direction from the input velocity vector. The input vector may have a length less than 1, so we can't use it directly, but we can get the normalized direction by dividing the vector by its own length. + +Insert the following code at the beginning of the `World.update()` method to set the player's direction: + +```swift +let length = input.velocity.length +if length > 0 { + player.direction = input.velocity / length +} +``` + +### Plotting a Course + +The player's direction should now always face whichever way they last moved. But the player's avatar is just a square - how can we see which way it's facing? We need a way to visualize the player's line of sight. + +Let's draw a line to illustrate the direction vector. Add a new method to `Bitmap`: + +```swift +public extension Bitmap { + ... + + mutating func drawLine(from: Vector, to: Vector, color: Color) { + + } +} +``` + +To draw the line, we will need to fill each pixel in the bitmap that it touches. We'll start by working out the vector between the start and end of the line, which we'll call `difference`: + +```swift +mutating func drawLine(from: Vector, to: Vector, color: Color) { + let difference = to - from + +} +``` + +To fill every pixel along the line, without leaving gaps or filling the same pixel twice, we need to step along its length exactly one pixel at a time. Each step should be a vector that, when added to the current position, moves to the next pixel along the line. + +To ensure the correct step length, the number of steps should be equal to the larger of the X or Y components of the total length. That way the step vector will be exactly one pixel in the longer axis and <= to one pixel in the other axis, so there will be no gaps when we drawn the line. + +Add the following code to `drawLine()` to compute the step count: + +```swift +let stepCount: Int +if abs(difference.x) > abs(difference.y) { + stepCount = Int(abs(difference.x).rounded(.up)) + +} else { + stepCount = Int(abs(difference.y).rounded(.up)) + +} +``` + +The longer component of that step vector will have a value of one (because we're moving one pixel at a time). If the longer component is the X component, the Y component will have a length of `difference.y / difference.x`. + +Drawing a line with pixels + +In other words it will be equal to the height of the line divided by the width, so that the slope of the step vector is the same as the slope of the line itself. If the longer component is Y, the X component will have a length of `difference.x / difference.y`, for the same reason. + +Update the `drawLine()` code to compute the step vector as follows: + +```swift +mutating func drawLine(from: Vector, to: Vector, color: Color) { + let difference = to - from + let stepCount: Int + let step: Vector + if abs(difference.x) > abs(difference.y) { + stepCount = Int(abs(difference.x).rounded(.up)) + let sign = difference.x > 0 ? 1.0 : -1.0 + step = Vector(x: 1, y: difference.y / difference.x) * sign + } else { + stepCount = Int(abs(difference.y).rounded(.up)) + let sign = difference.y > 0 ? 1.0 : -1.0 + step = Vector(x: difference.x / difference.y, y: 1) * sign + } + +} +``` + +Complete the `drawLine()` method by adding a loop from 0 to `stepCount`, advancing the step each time and filling the specified pixel: + +```swift +var point = from +for _ in 0 ..< stepCount { + self[Int(point.x), Int(point.y)] = color + point += step +} +``` + +This is a very basic line-drawing routine - it makes no attempt at [antialiasing](https://en.wikipedia.org/wiki/Spatial_anti-aliasing), or even to correct for the floating point offset of the start and end points within each pixel. It will suffice for visualization purposes, however. + +Add the following code to the end of the `Renderer.draw()` method: + +```swift +// Draw line of sight +let end = world.player.position + world.player.direction * 100 +bitmap.drawLine(from: world.player.position * scale, to: end * scale, color: .green) +``` + +This draws a green line extending out from the center of the player avatar, along their line of sight. The line of sight is technically infinite, but since we can't draw an infinitely long line, we've simply used an arbitrary large number for the length (100 world units). + +Run the app and move around a bit. You should see something like this: + +![Green line of sight extending from player](Images/LineOfSight.png) + +### Ray, Interrupted + +The line we have drawn is known as a *ray* - a line that extends out indefinitely from a fixed point until it hits something. Right now, we don't actually detect if it hits anything though, so let's fix that. + +First, let's create a data structure to represent the ray. A ray has an *origin* (the starting point) and a direction (a normalized vector). It *doesn't* have an end-point because it continues indefinitely. Create a new file called `Ray.swift` in the Engine module with the following contents: + +```swift +public struct Ray { + public var origin, direction: Vector + + public init(origin: Vector, direction: Vector) { + self.origin = origin + self.direction = direction + } +} +``` + +To detect where the ray is interrupted by a wall, we need to perform a *hit test*. The hit test method will take a ray as an argument and return a vector indicating the point at which the ray should terminate. + +The problem of detecting the hit point inside the map is in some ways quite similar to the `drawLine()` function we wrote earlier. In both cases we need to step along a line inside a grid (in this case a grid of tiles rather than pixels) and find the intersection points. + +For the map hit test though, we'll need to be much more precise. It is not enough to know which tile we hit - we also need to know exactly *where* on the tile the collision occurred. + +We'll start by simplifying the problem to a single tile. Given that we are standing inside a map tile, where is the point on the tile's boundary where our line of sight exits the tile? + +Although the ray origin and direction are vectors, it's actually simpler in this case if we take the X and Y components and handle them individually. + +Since the tiles lie on a 1x1 grid, we can get the horizontal and vertical positions of the current tile's edges by rounding the X and Y components of ray's origin up or down, depending on the direction of the ray. From those we can subtract the un-rounded X and Y values to get the distance of the origin from those edges. + +Ray intersection with edge of its originating tile + +Add the following method to `TileMap`: + +```swift +public extension Tilemap { + ... + + func hitTest(_ ray: Ray) -> Vector { + var position = ray.origin + let edgeDistanceX, edgeDistanceY: Double + if ray.direction.x > 0 { + edgeDistanceX = position.x.rounded(.down) + 1 - position.x + } else { + edgeDistanceX = position.x.rounded(.up) - 1 - position.x + } + if ray.direction.y > 0 { + edgeDistanceY = position.y.rounded(.down) + 1 - position.y + } else { + edgeDistanceY = position.y.rounded(.up) - 1 - position.y + } + + } +} +``` + +You might wonder why the code to compute `edgeDistanceX` and `edgeDistanceY` is using `rounded(.down) + 1` instead of `rounded(.up)` and `rounded(.up) - 1` instead of `rounded(.down)` to get the tile boundaries? This is to handle the edge case (*literally*) where the player is already standing at the edge of the tile. + +For example, if the player is standing at the exact leftmost edge of a tile looking right, `rounded(.up)` will give the coordinate of the leftmost edge (not what we want), whereas `rounded(.down) + 1` will give the position of the rightmost edge. The same applies if they are standing at the rightmost edge looking left (and so on for up and down). + +Now that we know the X and Y distances from the edges of our current tile, we can compute the step vectors we would need to take along the ray to reach either of those edges. We can use the slope of the ray (the X component of the direction divided by the Y component) to derive the missing components of the step vectors, which we'll call `step1` and `step2`. + +Two possible steps for the ray to take to reach the next tile + +Append the following code to `hitTest()` to compute `step1` and `step2`: + +```swift +let slope = ray.direction.x / ray.direction.y +let step1 = Vector(x: edgeDistanceX, y: edgeDistanceX / slope) +let step2 = Vector(x: edgeDistanceY * slope, y: edgeDistanceY) +``` + +Adding the shortest of these two steps to our current position gives us the position at which the ray would exit the tile we are current standing in. Complete the `hitTest()` method by adding this last block of code: + +```swift +if step1.length < step2.length { + position += step1 +} else { + position += step2 +} +return position +``` + +This is only the first piece of the puzzle, but it's a good point at which to check our work. In `Renderer.draw()`, replace the line: + +```swift +let end = world.player.position + world.player.direction * 100 +``` + +With: + +```swift +let ray = Ray(origin: world.player.position, direction: world.player.direction) +let end = world.map.hitTest(ray) +``` + +Now run the app and you should see something like this: + +![Ray stopping at edge of current tile](Images/TileEdgeHit.png) + +The line length may at first seem arbitrary, but if you move around it should become clear that the line always stops at the nearest edge of whatever tile we are currently standing in (which isn't necessarily a wall). + +Now that we have the code to calculate where the ray hits the next tile, we can put that code inside a loop to extend the ray until the edge we have hit is a wall. Update `TileMap.hitTest()` as follows: + +```swift +func hitTest(_ ray: Ray) -> Vector { + var position = ray.origin + let slope = ray.direction.x / ray.direction.y + repeat { + let edgeDistanceX, edgeDistanceY: Double + if ray.direction.x > 0 { + edgeDistanceX = position.x.rounded(.down) + 1 - position.x + } else { + edgeDistanceX = position.x.rounded(.up) - 1 - position.x + } + if ray.direction.y > 0 { + edgeDistanceY = position.y.rounded(.down) + 1 - position.y + } else { + edgeDistanceY = position.y.rounded(.up) - 1 - position.y + } + let step1 = Vector(x: edgeDistanceX, y: edgeDistanceX / slope) + let step2 = Vector(x: edgeDistanceY * slope, y: edgeDistanceY) + if step1.length < step2.length { + position += step1 + } else { + position += step2 + } + } while // TODO: Check if we hit a wall + return position +} +``` + +Checking the type of tile we have just hit is not quite as simple as rounding the coordinates of `position` down to the nearest whole tile. All the collision points will be on the boundary between two (or even four) tiles, and which of those tiles we need to check depends on the direction the ray is pointing. + +Since this is a nontrivial piece of logic (and it may be useful later), let's extract it into its own method. Add a the following method to `TileMap`, just before the `hitTest()` method: + +```swift +func tile(at position: Vector, from direction: Vector) -> Tile { + var offsetX = 0, offsetY = 0 + if position.x.rounded(.down) == position.x { + offsetX = direction.x > 0 ? 0 : -1 + } + if position.y.rounded(.down) == position.y { + offsetY = direction.y > 0 ? 0 : -1 + } + return self[Int(position.x) + offsetX, Int(position.y) + offsetY] +} +``` + +As before, we've simplified the problem by handling the X and Y components separately. If either component is a whole number (i.e. it lies on a tile boundary), we use the ray direction to determine which side should be checked. + +We can now use `tile(at:from:)` to complete the `hitTest()` method. Replace the line: + +```swift +} while // TODO: Check if we hit a wall +``` + +with: + +```swift +} while tile(at: position, from: ray.direction).isWall == false +``` + +Run the app again and you'll see that the ray now terminates at the first wall it hits, instead of the first tile boundary: + +![Ray stopping at first wall that it hits](Images/WallHit.png) + +This may not look like much (it certainly doesn't look much like Wolfenstein!) but the ray intersection logic we just wrote is the key to the ray casting engine. + +### Custom Frustum + +To render the whole scene using ray casting (instead of casting just a single ray in the direction the player is facing) we need to cast a *fan* of rays - one for each column of pixels on the screen. In a true 3D engine we would need to cast a ray for *every pixel* on the screen, but because our map is 2D we don't need to worry about the vertical axis. + +The area described by the fan represents a horizontal cross-section of the [view frustum](https://en.wikipedia.org/wiki/Viewing_frustum). In most 3D projections, the frustum has so-called *near* and *far* clipping planes that define the nearest and farthest things that can be seen. The 3D scene is projected onto the near plane to form the image that appears on the screen. + +We don't have a far plane, as the rays we are projecting go on indefinitely until they hit something. For this reason we'll refer to the near plane as the *view plane* instead. + +The view frustum, as seen from above + +Since our world is two-dimensional, we can think of the view plane as a line rather than a rectangle. The direction of this line is always orthogonal to the direction that the camera is facing. The orthogonal vector can be computed as (-Y, X). This will be useful, so let's add another computed property to `Vector`: + +```swift +public extension Vector { + var orthogonal: Vector { + return Vector(x: -y, y: x) + } + + ... +} +``` + +The length of the line represents the *view width* in world units. This has no direct relationship to how many pixels wide the view is on-screen, it's more about how big we want the world to appear from the player's viewpoint. + +Since the player's `direction` vector is normalized (has a length of 1) the orthogonal vector will be as well. That means we can just multiply the orthogonal vector by the view width to get a line of the correct length to represent the view plane. + +```swift +let viewPlane = world.player.direction.orthogonal * viewWidth +``` + +The distance of the view plane from the player is the *focal length*. This affects how near things appear to be. Together, the view width and focal length define the *Field of View* (FoV), which determines how much of the world the player can see at once. + +The relationship between focal length, view width and the field of view + +We'll set both the view width and the focal length to `1.0` for now. This gives a FoV angle of ~53 degrees[[1]](#footnote1), which is a little narrow, but we'll fix that later. Add the following code to the end of the `Renderer.draw()` method: + +```swift +// Draw view plane +let focalLength = 1.0 +let viewWidth = 1.0 +let viewPlane = world.player.direction.orthogonal * viewWidth +let viewCenter = world.player.position + world.player.direction * focalLength +let viewStart = viewCenter - viewPlane / 2 +let viewEnd = viewStart + viewPlane +bitmap.drawLine(from: viewStart * scale, to: viewEnd * scale, color: .red) +``` + +Run the app and you will the view plane drawn as a red line in front of the player. + +![Red view plane line in front of player](Images/ViewPlane.png) + +### Fan Service + +Now we have defined the view plane, we can replace the single line-of-sight ray with a fan of rays spanning the player's view. Delete the following lines from `Renderer.draw()`, as we won't need them anymore: + +```swift +// Draw line of sight +let ray = Ray(origin: world.player.position, direction: world.player.direction) +let end = world.map.hitTest(ray) +bitmap.drawLine(from: ray.origin * scale, to: end * scale, color: .green) +``` + +To get the direction of the first ray in the fan, we subtract the player position from the starting point of the view plane: + +```swift +let rayDirection = columnPosition - world.player.position +``` + +The length of `rayDirection` is the diagonal distance from the player to the view plane. We mentioned earlier that direction vectors should always be normalized, and while it doesn't matter right now, it will avoid some weird bugs later. To normalize the ray direction, we divide it by its length: + +```swift +let viewPlaneDistance = rayDirection.length +let ray = Ray( + origin: world.player.position, + direction: rayDirection / viewPlaneDistance +) +``` + +Eventually we'll need one ray for every horizontal pixel in the bitmap, but for now lets just draw 10 rays to test the principle. Add the following code to end of the `Renderer.draw()` method: + +```swift +// Cast rays +let columns = 10 +let step = viewPlane / Double(columns) +var columnPosition = viewStart +for _ in 0 ..< columns { + let rayDirection = columnPosition - world.player.position + let viewPlaneDistance = rayDirection.length + let ray = Ray( + origin: world.player.position, + direction: rayDirection / viewPlaneDistance + ) + let end = world.map.hitTest(ray) + bitmap.drawLine(from: ray.origin * scale, to: end * scale, color: .green) + columnPosition += step +} +``` + +Run the app again and you should see a fan of ten green rays spanning the red view plane line we drew earlier. + +![A fan of 10 rays spanning the view plane](Images/RayFan.png) + +From the length of those rays, we can determine the distance of the walls of the maze at every pixel along the screen. Just one last thing to do before we enter the third dimension... + +### A Quick Turnaround + +The touch-based joystick we created in Part 2 works well for navigating a maze from a top-down perspective, but it's not set up quite right for a first-person game. Currently, pushing the joystick up always moves the player avatar towards the top of the screen, but in 3D we'll want it to move us *forward* relative to whatever direction the avatar is facing. + +The joystick code itself is fine, but we need to change the input model to something more appropriate. Remove the `velocity` property of the `Input` struct and replace it with the following: + +```swift +public struct Input { + public var speed: Double + public var rotation: Rotation + + public init(speed: Double, rotation: Rotation) { + self.speed = speed + self.rotation = rotation + } +} +``` + +Instead of a velocity vector, our input will now be a scalar speed (positive for forwards and negative for backwards) and a left/right rotation. But what is that `Rotation` type? + +We used a vector previously to represent the player's direction, but that won't work for rotation. The obvious way to represent rotation is with an angle, but that introduces a slight problem. We committed to building the engine of the game without any external dependencies - even Foundation - but the Swift standard library (as of version 5.0) does not contain any trigonometric functions, which means working with angles is going to be a bit awkward. + +### Enter The Matrix + +Fortunately there is another, *even better* way to represent a rotation - a 2x2 [rotation matrix](https://en.wikipedia.org/wiki/Rotation_matrix). This matrix encapsulates a rotation in a form that can be efficiently applied to a vector using only ordinary multiplication and addition. + +Create a new file in the Engine module called `Rotation.swift` with the following contents: + +```swift +public struct Rotation { + var m1, m2, m3, m4: Double +} +``` + +A 2x2 matrix contains 4 numbers, hence the four parameters. The `m[x]` naming is conventional, but unless you are well-versed with linear algebra those parameters won't mean a whole lot. Let's add an initializer with slightly more ergonomic parameters: + +```swift +public extension Rotation { + init(sine: Double, cosine: Double) { + self.init(m1: cosine, m2: -sine, m3: sine, m4: cosine) + } +} +``` + +This initializer takes the sine and cosine of a given angle and produces a matrix representing a rotation by that angle. We already said that we can't (easily) use the `sin` and `cos` functions inside the engine itself, but that's OK because we'll we be doing that part in the platform layer. + +Finally, we'll add a function to apply the rotation to a vector. This feels most natural to write as an extension method on `Vector` itself, but we'll put that extension in the `Rotation.swift` file because it makes more sense from a grouping perspective. Add the following code in `Rotation.swift`: + +```swift +public extension Vector { + func rotated(by rotation: Rotation) -> Vector { + return Vector( + x: x * rotation.m1 + y * rotation.m2, + y: x * rotation.m3 + y * rotation.m4 + ) + } +} +``` + +Next, we need to update the code in `ViewController.swift` to send the new input model. We'll pass the Y component of the joystick `inputVector` as the speed, and use the X component to calculate the rotation. + +As you may recall from Part 2, the input velocity's magnitude ranges from 0 to 1, measured in world-units per second. On the engine side we multiply this by the player's maximum speed and the `timeStep` value before adding it to the position each frame. + +If we treat the X component as a rotational velocity value, it becomes *radians* per second rather than world units. This will also need to be multiplied by the time-step and a maximum turning speed, so let's add a `turningSpeed` property to `Player`: + +```swift +public struct Player { + public let speed: Double = 3 + public let turningSpeed: Double = .pi + ... +} +``` + +We've chosen a value of `pi` radians (180 degrees) for the turning speed, which means the player will be able to turn a full revolution in two seconds. + +Because we are fudging things a little by doing the trigonometry in the platform layer, we'll need to multiply the rotation by the `timeStep` and `turningSpeed` on the platform layer side instead of in `World.update()` as we did for the velocity. This is a bit inelegant, but still preferable to writing our own `sin` and `cos` functions. + +In `ViewController.update()`, replace the line: + +```swift +let input = Input(velocity: inputVector) +``` + +with: + +```swift +let inputVector = self.inputVector +let rotation = inputVector.x * world.player.turningSpeed * worldTimeStep +let input = Input( + speed: -inputVector.y, + rotation: Rotation(sine: sin(rotation), cosine: cos(rotation)) +) +``` + +Finally, in `World.update()`, replace the lines: + +```swift +let length = input.velocity.length +if length > 0 { + player.direction = input.velocity / length +} +player.velocity = input.velocity * player.speed +``` + +with: + +```swift +player.direction = player.direction.rotated(by: input.rotation) +player.velocity = player.direction * input.speed * player.speed +``` + +If you run the app again now, it should *look* exactly the same, but movement works differently. Swiping the joystick right or left will rotate the player clockwise/counter-clockwise respectively, and swiping up/down will move the player forwards or backwards relative to the direction they are currently facing. + +This is a considerably more awkward way to move the player when viewing from a fixed, top-down perspective. But that's OK, because *we won't be using that perspective from now on*. + +### A New Dimension + +The top-down 2D drawing code we've written so far won't be needed for the first person view, so you can go ahead and delete most of the code in the `Renderer.draw()` function, leaving only the following: + +```swift +mutating func draw(_ world: World) { + let focalLength = 1.0 + let viewWidth = 1.0 + let viewPlane = world.player.direction.orthogonal * viewWidth + let viewCenter = world.player.position + world.player.direction * focalLength + let viewStart = viewCenter - viewPlane / 2 + + // Cast rays + let columns = 10 + let step = viewPlane / Double(columns) + var columnPosition = viewStart + for _ in 0 ..< columns { + let rayDirection = columnPosition - world.player.position + let viewPlaneDistance = rayDirection.length + let ray = Ray( + origin: world.player.position, + direction: rayDirection / viewPlaneDistance + ) + let end = world.map.hitTest(ray) + + columnPosition += step + } +} +``` + +Ten rays won't be enough to render a complete first-person view - we'll need one ray for every horizontal pixel of the bitmap, so replace the line: + +```swift +let columns = 10 +``` + +with: + +```swift +let columns = bitmap.width +``` + +Inside the loop we need to compute the distance from the start of the ray to where it hits the wall: + +```swift +let wallDistance = (end - ray.origin).length +``` + +From this we can use the [perspective projection equation](https://en.wikipedia.org/wiki/3D_projection#Weak_perspective_projection) to compute the height at which to draw the walls. The walls have a height of one world unit, so the code to compute the final wall height in pixels is: + +```swift +let wallHeight = 1.0 +let height = wallHeight * focalLength / wallDistance * Double(bitmap.height) +``` + +Then it's just a matter of drawing that line in the bitmap, for which we can use the `drawLine()` method we wrote earlier. But instead of drawing *along* the ray, this time we'll draw a vertical line from the top to the bottom of the wall. The complete code for the draw function is below, replace Renderer.draw() with the following: + +```swift +mutating func draw(_ world: World) { + let focalLength = 1.0 + let viewWidth = 1.0 + let viewPlane = world.player.direction.orthogonal * viewWidth + let viewCenter = world.player.position + world.player.direction * focalLength + let viewStart = viewCenter - viewPlane / 2 + + // Cast rays + let columns = bitmap.width + let step = viewPlane / Double(columns) + var columnPosition = viewStart + for x in 0 ..< columns { + let rayDirection = columnPosition - world.player.position + let viewPlaneDistance = rayDirection.length + let ray = Ray( + origin: world.player.position, + direction: rayDirection / viewPlaneDistance + ) + let end = world.map.hitTest(ray) + let wallDistance = (end - ray.origin).length + + // Draw wall + let wallHeight = 1.0 + let height = wallHeight * focalLength / wallDistance * Double(bitmap.height) + let wallColor = Color.white + bitmap.drawLine( + from: Vector(x: Double(x), y: (Double(bitmap.height) - height) / 2), + to: Vector(x: Double(x), y: (Double(bitmap.height) + height) / 2), + color: wallColor + ) + + columnPosition += step + } +} +``` + +If you run the app now you'll see that we are standing in a 3D world. + +![The world viewed from a first person perspective](Images/PerspectiveView.png) + +Try moving around - you should be able to walk around naturally by dragging the joystick up/down to move forwards/backwards, and left/right to turn. You'll notice that there's something a bit odd going on with the walls, but it's hard to tell exactly what it is when the view is so small and every surface is pure white. + +### Let There Be Light(ing) + +Eventually we'll be adding wall textures which will help with the contrast, but in the meantime, maybe we could do something to add some contrast? + +Early 3D games tended to use very simple lighting systems, as true, dynamic lights were too expensive. Wolfenstein actually had no lighting *at all*, it just used darker wall textures for North/South facing walls to add contrast. + +We don't have textures yet, but we can replicate Wolfenstein's approach by simply using two color tones. We know that walls are aligned on a 1x1 grid, so a wall coordinate with an exact integer Y value must be a North/South facing. In `Renderer.draw()`, replace the line + +```swift +let wallColor = Color.white +``` + +with: + +```swift +let wallColor: Color +if end.x.rounded(.down) == end.x { + wallColor = .white +} else { + wallColor = .gray +} +``` + +Run the app again and you'll be able to see more clearly now where one wall ends and the next begins. + +![Walls lit according to distance from the player](Images/Lighting.png) + +### A Wider Perspective + +It would also help if we could see a bit more of the scene at once. We originally used a square aspect ratio for the bitmap because that matched up with the dimensions of the world, but now we've switched to a first-person perspective it seems a shame not to take advantage of the iPhone's glorious widescreen. + +Let's extend the bitmap to fill the full screen width. In `ViewController.update()` replace the lines: + +```swift +let size = Int(min(imageView.bounds.width, imageView.bounds.height)) +var renderer = Renderer(width: size, height: size) +``` + +with: + +```swift +let width = Int(imageView.bounds.width), height = Int(imageView.bounds.height) +var renderer = Renderer(width: width, height: height) +``` + +Now run the app again. The view now fills the screen, but it looks stretched. Each wall tile ought to be square, but if you look at that doorway in front of us, it's clearly wider than it is tall. + +![In widescreen view the walls appear stretched](Images/StretchedWalls.png) + +The stretching effect is because we changed the aspect ratio of the bitmap without updating the width of the view plane accordingly. As far as the code is concerned, the view plane is still square, but we're now stretching it to fill a rectangular bitmap. To fix that, in `Renderer.draw()`, replace the line: + +```swift +let viewWidth = 1.0 +``` + +with: + +```swift +let viewWidth = Double(bitmap.width) / Double(bitmap.height) +``` + +Running the app again, you can see that we've fixed the stretching effect. Since the focal length is the same but the view width is wider, we've also increased the *field of view*[[2]](#footnote2). + +![Walls appear curved](Images/CurvedWalls.png) + +It's now also a bit easier now to see what's going on with the walls. They're... curved? *How are the walls curved?! Literally every line we've drawn is straight!* + +### Fishy Business + +When the fan of rays hits a wall perpendicular to the player, the further the ray is from the center of the view, the longer it is. Since we are using the length of the ray to compute the distance from the wall, that means that parts of the wall further from the center appear further away, causing a *fisheye* effect as the wall bends away from the camera. + +Ray length increases with distance from the center + +What we really need to use is not the *length* of the ray but the perpendicular distance from the end of the ray to the view plane. We could use Pythagoras's Theorem[[3]](#footnote3) to derive the perpendicular length from the ray length, but we would need to know the length of the opposite side of the triangle, which we currently don't. + +Fortunately, we can make use of the handy fact that the ratio between the lengths of the sides doesn't change when you scale a triangle up or down[[4]](#footnote4). + +Wherever the ray hits a wall, the triangle it forms perpendicular to the view plane will have the same proportions as the triangle that the ray forms with the view plane itself, which we already know all the sides of. So we can calculate the perpendicular length as follows: + +1. Compute the vector from the player to the view plane along the ray (which happens to be the vector we already used for the ray direction). +2. Divide the length of that vector by the focal length to get the ratio between distance and perpendicular distance. +3. Divide the distance from the wall by the ratio to get the perpendicular distance to the wall. + +Calculating perpendicular distance from wall/ray intersection + +To implement that in code, in `Renderer.draw()` replace the line: + +```swift +let height = wallHeight * focalLength / wallDistance * Double(bitmap.height) +``` + +with: + +```swift +let distanceRatio = viewPlaneDistance / focalLength +let perpendicular = wallDistance / distanceRatio +let height = wallHeight * focalLength / perpendicular * Double(bitmap.height) +``` + +Run the app again and you should see that all trace of curvature has disappeared. + +![The walls are now straight](Images/StraightWalls.png) + +And that will do for now. To recap, in this part we... + +* Implemented ray casting to detect walls in the player's field of view +* Changed from a directional to a rotational control scheme +* Switched to a first-person perspective +* Added simple lighting + +In [Part 4](Part4.md) we'll redecorate a bit and add some *texture* to our world. + +
+ +[[1]](#reference1) If you're curious how the field of view angle was derived, the point where the line of sight meets the view plane forms a right-angled triangle. Using [SOHCAHTOA](http://mathworld.wolfram.com/SOHCAHTOA.html), with the `focalLength` as the *adjacent* side and half the `viewWidth` as the *opposite* side, the FoV (in radians) can be computed using the formula `fieldOfView = 2 * atan(viewWidth / 2 / focalLength)`. Multiply the result by `180 / .pi` to get the angle in degrees. + +[[2]](#reference2) If you recall, I mentioned before that the earlier value of 53 degrees for the field of view was a bit narrow? Well now it's ~90 degrees (assuming a screen aspect ratio of ~2:1 as found on X-series iPhones). + +[[3]](#reference3) The squared length of the diagonal of a right-angle triangle is equal to the sum of the squares of the other two sides. We used this before in [Part 2](Part2.md) to calculate the length of a vector length from its X and Y components. + +[[4]](#reference4) You can [thank Pythagoras](https://en.wikipedia.org/wiki/Pythagorean_theorem#Proof_using_similar_triangles) for that little factoid as well. diff --git a/Tutorial/Part4.md b/Tutorial/Part4.md new file mode 100644 index 0000000..58aafff --- /dev/null +++ b/Tutorial/Part4.md @@ -0,0 +1,679 @@ +## Part 4: Texture Mapping + +In [Part 3](Part3.md) we used ray casting to render our simple 2D maze in glorious 3D. The complete code from Part 3 can be found [here](https://github.com/nicklockwood/RetroRampage/archive/Part3.zip). + +Walking around a 3D world of our own creation is pretty neat, but the novelty wears off pretty quick with only boring white walls to look at. Let's improve the decor a bit. + +### Sweet Release + +A quick note before we begin: Graphics code of the sort we are writing is extremely sensitive to compiler optimizations. You may find that the game is almost unplayably slow when running in *Debug* mode, but should be much smoother if you run in *Release* mode. + +To enable Release mode, go to Edit Scheme > Run > Build Configuration (see below). + +Enabling release mode when building + +Note that when running in Release mode, debugging tools may not work as expected, so you may need to toggle back to Debug when diagnosing errors. + +### Surface Detail + +The traditional way to make walls more interesting in a 3D game is to use *texture mapping*. This is a technique where a 2D image is used to decorate a 3D surface, a bit like wallpaper. The term "texture" implies some sort of roughness, but the most common form of texture map is a *color* map, which only affects the color of the surface, not its shape[[1]](#footnote1). + +We'll need an image to use for the wall texture. This image should be square, and needs to wrap horizontally (i.e. the rightmost column of pixels should match up seamlessly with the leftmost column). + +I'm going to use a 16x16 image because big pixels make it easier to see what's going on[[2]](#footnote2), but you should feel free to use any size you like[[3]](#footnote3). Drawing performance is mainly constrained by the resolution of the *output* bitmap, not the size of individual input textures. + +Wall texture blown up on left and at actual size on right + +You are welcome to use this image if you don't feel like drawing your own - you can find it [here](https://github.com/nicklockwood/RetroRampage/tree/Part4/Source/Rampage/Assets.xcassets), along with all other textures used in this tutorial. + +We'll need some way to get the image into the game. Since the Engine module knows nothing about the filesystem, it makes sense for the platform layer to load the images and pass them into the engine. Although in theory we could make `Bitmap` conform to `Decodable` and load it directly using our own file format, iOS already has optimized mechanisms for handling images using XCAssets and `.car` files, and we shouldn't fight the host operating system. + +In the main project, add your wall texture to `Assets.xcassets` and name it `wall` (all lower-case). + +We're going to need more than one texture eventually, so rather than just passing in a single `Bitmap`, let's create a wrapper we can modify later once we better understand what our requirements are. Create a new file called `Textures.swift` in the Engine module with the following contents: + +```swift +public enum Texture: String, CaseIterable { + case wall +} + +public struct Textures { + private let textures: [Texture: Bitmap] +} + +public extension Textures { + init(loader: (String) -> Bitmap) { + var textures = [Texture: Bitmap]() + for texture in Texture.allCases { + textures[texture] = loader(texture.rawValue) + } + self.init(textures: textures) + } + + subscript(_ texture: Texture) -> Bitmap { + return textures[texture]! + } +} +``` + +The `Texture` enum is backed by a `String` and conforms to `CaseIterable`. This allows for a very elegant implementation of the `Textures` initializer, which requests bitmaps for textures automatically based on their case names. + +The `Textures` struct is basically just a wrapper around a `Dictionary`, but because the initializer implementation makes it impossible to request an image that doesn't exist, the `subscript` can safely return a non-`Optional` value. + +In `Renderer.swift` add a `textures` property and change the initializer to accept a `textures` parameter: + +```swift +public struct Renderer { + public private(set) var bitmap: Bitmap + private let textures: Textures + + public init(width: Int, height: Int, textures: Textures) { + self.bitmap = Bitmap(width: width, height: height, color: .black) + self.textures = textures + } +} +``` + +Images in XCAssets are typically loaded using `UIImage(named:)`. We already have code to convert a `Bitmap` to a `UIImage`, so let's add the *inverse* conversion. Open `UIImage+Bitmap.swift` and add the following code: + +```swift +extension Bitmap { + init?(image: UIImage) { + guard let cgImage = image.cgImage else { + return nil + } + + let alphaInfo = CGImageAlphaInfo.premultipliedLast + let bytesPerPixel = MemoryLayout.size + let bytesPerRow = cgImage.width * bytesPerPixel + + var pixels = [Color](repeating: .clear, count: cgImage.width * cgImage.height) + guard let context = CGContext( + data: &pixels, + width: cgImage.width, + height: cgImage.height, + bitsPerComponent: 8, + bytesPerRow: bytesPerRow, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: alphaInfo.rawValue + ) else { + return nil + } + + context.draw(cgImage, in: CGRect(origin: .zero, size: image.size)) + self.init(width: cgImage.width, pixels: pixels) + } +} +``` + +This method initializes a `Bitmap` from a `UIImage`. I works by creating a new `CGContext` backed by an array of `Color` pixels, then drawing the `UIImage` into that context, thereby filling the `Color` array with the image contents. + +In `ViewController.swift`, add the following free function at the top of the file[[4]](#footnote4): + +```swift +private func loadTextures() -> Textures { + return Textures(loader: { name in + Bitmap(image: UIImage(named: name)!)! + }) +} +``` + +Then add the following property to the `ViewController` class: + +```swift +private let textures = loadTextures() +``` + +And finally, in the `update()` method change the line: + +```swift +var renderer = Renderer(width: width, height: height) +``` + +to: + +```swift +var renderer = Renderer(width: width, height: height, textures: textures) +``` + +Now that the wall texture is available to the engine, we actually have to use it to draw the walls. The way that texture mapping works is that for each pixel of the 3D surface shown on screen, we find the equivalent coordinate on the texture, and then draw that color. + +This normally involves a lot of matrix math to convert between the two coordinate systems, but fortunately because our wall surfaces are squares that exactly match the texture dimensions, and are only rotated in one plane, the mapping in this case is fairly straightforward. + +We currently draw the walls one column of pixels at a time, with the height of each column determined by its distance from the camera. We know the top and bottom of the wall match up with the top and bottom of the texture, so the only tricky part will be mapping from the X position of the column on screen to the X position of the column within the texture. + +Mapping of screen coordinates to wall texture + +Again, the grid-based nature of the map really helps here. The `Tilemap.hitTest()` method provides the world coordinate at which each ray hits the wall. Because the walls are arranged on a 1x1 world-unit grid, the fractional part of the coordinate value represents how far along the wall the ray landed, in the range 0 - 1. We can take this value and multiply it by the texture width to get the correct X offset within the texture. + +We'll need to replace the `drawLine()` call with a new method that copies a column of the wall texture to the output bitmap. Add the following code to `Bitmap.swift`: + +```swift +public extension Bitmap { + ... + + mutating func drawColumn(_ sourceX: Int, of source: Bitmap, at point: Vector, height: Double) { + let start = Int(point.y), end = Int(point.y + height) + 1 + let stepY = Double(source.height) / height + for y in max(0, start) ..< min(self.height, end) { + let sourceY = (Double(y) - point.y) * stepY + let sourceColor = source[sourceX, Int(sourceY)] + self[Int(point.x), y] = sourceColor + } + } +} +``` + +This method is similar to `drawLine()`, but simpler because the line it draws is always vertical, so we are only stepping along the Y axis. The `sourceX` parameter is the X coordinate of the column of pixels within the source bitmap that we need to copy. The `point` parameter specifies the starting position in the destination bitmap to begin drawing, and `height` indicates the output height of the column of pixels to be drawn. + +The code first computes the `start` and `end` pixel positions in the destination bitmap, then loops through them. Because the actual size of the source bitmap may be larger or smaller than the size at which it is being drawn, we need to calculate the Y position of each pixel in the source for the current output Y position in the destination - that's what the `stepY` value is for. + +Now we need to update `Renderer.draw()` to use this method. Replace the lines: + +```swift +let wallColor: Color +if end.x.rounded(.down) == end.x { + wallColor = .white +} else { + wallColor = .gray +} +bitmap.drawLine( + from: Vector(x: Double(x), y: (Double(bitmap.height) - height) / 2), + to: Vector(x: Double(x), y: (Double(bitmap.height) + height) / 2), + color: wallColor +) +``` + +with: + +```swift +let wallTexture = textures[.wall] +let wallX = end.x - end.x.rounded(.down) +let textureX = Int(wallX * Double(wallTexture.width)) +let wallStart = Vector(x: Double(x), y: (Double(bitmap.height) - height) / 2) +bitmap.drawColumn(textureX, of: wallTexture, at: wallStart, height: height) +``` + +The first line takes the `end` position vector (the point where the ray intersects the wall) and extracts just the fractional part of the X component (`wallX`). This is then used to determine which column of the texture to draw (`textureX`). Try running the app: + +![Texture smearing on rear wall](Images/TextureSmearing.png) + +Hmm... well that's *almost* right. The problem is that we're only using the X component of the wall position for the texture offset, but we need to use either the X *or* Y parts, depending on whether it's a vertical (North/South) or horizontal (West/East) wall. + +We actually just deleted some code that checked if the wall was horizontal or vertical in order to select the correct wall color, so let's bring that back and use it to select either the X or Y component of the `end` position to use for the texture coordinate. Still in `Renderer.draw()`, replace the line: + +```swift +let wallX = end.x - end.x.rounded(.down) +``` + +with: + +```swift +let wallX: Double +if end.x.rounded(.down) == end.x { + wallX = end.y - end.y.rounded(.down) +} else { + wallX = end.x - end.x.rounded(.down) +} +``` + +Run the app again and you'll see that the smeared rear wall is now textured correctly. If you look *closely* however, you may notice a small glitch running horizontally along the middle row of pixels on the screen. + +![Rounding errors in middle row of pixels](Images/RoundingErrors.png) + +This glitch is caused by floating point rounding errors where the edge of the texture pixels are too close to the pixel boundary on screen. The computed texture coordinate is almost exactly on the boundary between two texture pixels, so it flips between the two as alternate columns are drawn. + +A simple (if somewhat hack-y) solution is just to add a tiny offset to the rendering position so the values no longer clash. In `Renderer.draw()` replace the line: + +```swift +let wallStart = Vector(x: Double(x), y: (Double(bitmap.height) - height) / 2) +``` + +with: + +```swift +let wallStart = Vector(x: Double(x), y: (Double(bitmap.height) - height) / 2 + 0.001) +``` + +### Watch Your Tone + +The walls look a lot better with textures applied, but we've lost the nice two-tone lighting effect. + +Wolfenstein achieved this effect by using two sets of textures. The fixed 256-color palette meant it was no simple feat to adjust brightness programmatically. Darker versions of a given color didn't necessarily exist in the palette, and in those cases a suitable substitute would have to be chosen by hand by the texture artist. + +Since we're working with 32-bit color, it's actually a lot simpler for us to darken a given image at runtime, but for now we'll just do as Wolfenstein did and create a second copy of the texture. + +Add a second, darker copy of `wall` called `wall2` to XCAssets, and add a matching case to the `Texture` enum: + +```swift +public enum Texture: String, CaseIterable { + case wall, wall2 +} +``` + +The same `if` statement we originally used to select the wall color (and then repurposed to help calculate the texture coordinate) can be used to select the correct wall texture. In `Renderer.draw()` replace the following code: + +```swift +let wallTexture = textures[.wall] +let wallX: Double +if end.x.rounded(.down) == end.x { + wallX = end.y - end.y.rounded(.down) +} else { + wallX = end.x - end.x.rounded(.down) +} +``` + +with: + +```swift +let wallTexture: Bitmap +let wallX: Double +if end.x.rounded(.down) == end.x { + wallTexture = textures[.wall] + wallX = end.y - end.y.rounded(.down) +} else { + wallTexture = textures[.wall2] + wallX = end.x - end.x.rounded(.down) +} +``` + +Run the app again and you'll see that the two-tone lighting is back, helping to make the world look more solid. + +![Two-tone wall lighting](Images/TexturesAndLighting.png) + +### The Floor in the Plan + +That's the wallpaper sorted, but what about the floor and ceiling? You may be surprised to hear (if you haven't played it in the last 25 years) that Wolfenstein actually didn't bother with floor or ceiling textures, instead opting for a simple solid color. + +The process for drawing floor and ceiling textures is more computationally expensive than for walls, requiring a depth calculation for every pixel rather than just one for each column. But while the machines of Wolfenstein's era may have struggled with it, it's not really a problem for a modern CPU. + +Let's depart from strict adherence to the limitations of the Wolfenstein engine, and draw ourselves a textured floor. + +To draw the floor we need to fill the column of pixels below each column of the wall up to the edge of the screen. Let's begin by just filling the floor with a solid color, one column at a time. In the `Renderer.draw()` method, just before `columnPosition += step`, add the following: + +```swift +// Draw floor +let floorStart = Int(wallStart.y + height) + 1 +for y in min(floorStart, bitmap.height) ..< bitmap.height { + bitmap[x, y] = .red +} +``` + +The `floorStart` value is basically the same as the upper bound we used for the `drawColumn()` method - in other words the floor starts exactly where the wall ends. The `min()` function is needed because if the wall is too close to the player, `floorStart` would actually be below the bottom edge of the screen, resulting in an invalid range. Run the app and you should see a solid red floor. + +![Solid floor color](Images/SolidFloorColor.png) + +Now that we know which pixels we are going to fill, we need a texture to fill them with. Go ahead and add two new texture called `floor` and `ceiling` to both the XCAssets and the `Texture` enum in `Textures.swift`. Like the walls, these textures should be square, but this time they will need to tile both horizontally *and* vertically to avoid ugly seams on the floor. + +```swift +public enum Texture: String, CaseIterable { + case wall, wall2 + case floor, ceiling +} +``` + +Because the pixels in each column of the floor are not all at the same distance from the camera, we cannot use the `drawColumn()` function we wrote earlier. We'll have to compute the correct color for each pixel individually. + +When drawing the walls, we first computed the position of the wall in world units, and then used the [perspective projection equation](https://en.wikipedia.org/wiki/3D_projection#Weak_perspective_projection) to transform that into screen coordinates. This time we're going to go the *other way* and derive the world coordinate from the screen position. + +The map position at `floorStart` is the same distance away as the wall itself - in other words it will be `wallDistance` world units from the camera. Each successive pixel we step over in the loop moves the position closer to the camera. The distance of that position from the camera can be derived by applying the perspective equation in reverse. + +The first step is to convert the Y position (in pixels) to a normalized position relative to the view plane. We do that by dividing by the output bitmap height: + +```swift +let normalizedY = Double(y) / Double(bitmap.height) +``` + +That gives us a value for `normalizedY` where zero is at the top of the view plane and one is at the bottom. The vanishing point (the point at which the floor or ceiling would be infinitely away) is in the middle of the screen, so we actually want the zero value of `normalizedY` to be the *center* of the screen, not the top. We can achieve that by multiplying the value by two and then subtracting one: + +```swift +let normalizedY = (Double(y) / Double(bitmap.height)) * 2 - 1 +``` + +From the normalized Y value, we can use the inverse perspective equation to derive the perpendicular distance of that pixel from the camera: + +```swift +let perpendicular = wallHeight * focalLength / normalizedY +``` + +Next, by dividing by the `distanceRatio` we calculated earlier we can determine the actual distance, and from that we can compute the map position: + +```swift +let distance = perpendicular * distanceRatio +let mapPosition = ray.origin + ray.direction * distance +``` + +Because the tiles are on a 1x1 grid, the integer parts of the `mapPosition` X and Y components give us the tile coordinate, and the fractional parts give us the precise position within the tile itself, which we can use to derive the texture coordinate: + +```swift +let tileX = mapPosition.x.rounded(.down), tileY = mapPosition.y.rounded(.down) +let textureX = Int((mapPosition.x - tileX) * Double(floorTexture.width)) +let textureY = Int((mapPosition.y - tileY) * Double(floorTexture.height)) +``` + +That's everything we need to texture the floor. In `Renderer.draw()` replace the following lines: + +```swift +// Draw floor +let floorStart = Int(wallStart.y + height) + 1 +for y in min(floorStart, bitmap.height) ..< bitmap.height { + bitmap[x, y] = .red +} +``` + +with: + +```swift +// Draw floor +let floorTexture = textures[.floor] +let floorStart = Int(wallStart.y + height) + 1 +for y in min(floorStart, bitmap.height) ..< bitmap.height { + let normalizedY = (Double(y) / Double(bitmap.height)) * 2 - 1 + let perpendicular = wallHeight * focalLength / normalizedY + let distance = perpendicular * distanceRatio + let mapPosition = ray.origin + ray.direction * distance + let tileX = mapPosition.x.rounded(.down), tileY = mapPosition.y.rounded(.down) + let textureX = Int((mapPosition.x - tileX) * Double(floorTexture.width)) + let textureY = Int((mapPosition.y - tileY) * Double(floorTexture.height)) + bitmap[x, y] = floorTexture[textureX, textureY] +} +``` + +Run the game and you should see that the floor is now textured. + +![Textured floor](Images/TexturedFloor.png) + +To draw the ceiling we could duplicate this code, with a slight change to the `normalizedY` calculation. But there's actually an even simpler way - because the ceiling's coordinates are a mirror of the floor's, we can draw them both with the same loop by just inverting the output Y coordinate. Replace the lines: + +```swift +// Draw floor +let floorTexture = textures[.floor] +``` + +with: + +```swift +// Draw floor and ceiling +let floorTexture = textures[.floor], ceilingTexture = textures[.ceiling] +``` + +And then just below the line that sets the floor pixel color: + +```swift +bitmap[x, y] = floorTexture[textureX, textureY] +``` + +add the following line to fill the equivalent ceiling pixel: + +```swift +bitmap[x, bitmap.height - y] = ceilingTexture[textureX, textureY] +``` + +Run the game again and you'll see that the textured floor now has a matching ceiling. + +![Textured floor and ceiling](Images/TexturedCeiling.png) + +### Not Normal + +We cheated a little by re-using the floor texture coordinates for the ceiling - not because they don't match up (they do) - but because the ceiling texture isn't *necessarily* the same size as the floor texture. If the floor and ceiling textures had different resolutions then this logic wouldn't work. + +We could solve this by duplicating the `textureX` and `textureY` variables and calculating them separately for each texture, but that's nasty. What we really want is to be able to look up texture pixels using *normalized* coordinates, so we don't have to worry about their actual pixel dimensions. + +Let's add a way to do that. In `Bitmap.swift` add the following code just below the existing `subscript`: + +```swift +subscript(normalized x: Double, y: Double) -> Color { + return self[Int(x * Double(width)), Int(y * Double(height))] +} +``` + +Now, back in the `Renderer.draw()` method, a little way below the `// Draw floor and ceiling` comment, replace the lines: + +```swift +let textureX = Int((mapPosition.x - tileX) * Double(floorTexture.width)) +let textureY = Int((mapPosition.y - tileY) * Double(floorTexture.height)) +bitmap[x, y] = floorTexture[textureX, textureY] +bitmap[x, bitmap.height - y] = ceilingTexture[textureX, textureY] +``` + +with: + +```swift +let textureX = mapPosition.x - tileX, textureY = mapPosition.y - tileY +bitmap[x, y] = floorTexture[normalized: textureX, textureY] +bitmap[x, bitmap.height - y] = ceilingTexture[normalized: textureX, textureY] +``` + +The new code is more concise, but more importantly it's *correct*, regardless of the relative texture resolutions. + +### Variety Show + +Even with textures, the world looks a bit monotonous when every wall is the same. Currently the wall and floor textures are always the same because they're hard-coded in the renderer, but we really want these to be specified by the *map*. + +We'll start by adding two new wall types and a new floor type to the XCAssets file. For the walls, we need two textures for each new type (light and dark). + +Additional wall and floor textures + +In `Textures.swift` extend the `Texture` enum with these five additional cases: + +```swift +public enum Texture: String, CaseIterable { + case wall, wall2 + case crackWall, crackWall2 + case slimeWall, slimeWall2 + case floor + case crackFloor + case ceiling +} +``` + +Note that the order and grouping of the cases in the enum isn't important, as the values are keyed by name rather than index. + +Next, we need to extend the `Tile` enum with new cases as well: + +```swift +public enum Tile: Int, Decodable { + case floor + case wall + case crackWall + case slimeWall + case crackFloor +} +``` + +In this case the order *is* important. Because we are decoding these values by index from the `Map.json` file, if we change the order then it will break the map. If you'd prefer to have the freedom to group them logically, you can preserve compatibility with the JSON by setting the indices explicitly: + +```swift +public enum Tile: Int, Decodable { + // Floors + case floor = 0 + case crackFloor = 4 + + // Walls + case wall = 1 + case crackWall = 2 + case slimeWall = 3 +} +``` + +Now that we have more wall types, we'll need to update the `Tile.isWall` helper property we wrote earlier. Add the extra cases as follows: + +```swift +var isWall: Bool { + switch self { + case .wall, .crackWall, .slimeWall: + return true + case .floor, .crackFloor: + return false + } +} +``` + +Since there are two textures for each wall tile (and for the floor tiles too, if you count the ceiling texture) we'll need some form of mapping between them. For now we'll just hard-code the textures for each case using a `switch`. Add a new computed property to `Tile` as follows:: + +```swift +public extension Tile { + ... + + var textures: [Texture] { + switch self { + case .floor: + return [.floor, .ceiling] + case .crackFloor: + return [.crackFloor, .ceiling] + case .wall: + return [.wall, .wall2] + case .crackWall: + return [.crackWall, .crackWall2] + case .slimeWall: + return [.slimeWall, .slimeWall2] + } + } +} +``` + +The map layout itself is specified in the `Map.json` file in the main project. Edit the `tiles` array in the JSON to include some of our new wall and floor tiles (the exact layout doesn't matter for the tutorial, so feel free to be creative): + +```swift +"tiles": [ + 1, 3, 1, 1, 3, 1, 1, 1, + 1, 0, 0, 2, 0, 0, 0, 1, + 1, 4, 0, 3, 4, 0, 0, 3, + 2, 0, 0, 0, 0, 0, 4, 3, + 1, 4, 0, 1, 1, 1, 0, 1, + 1, 0, 4, 2, 0, 0, 0, 1, + 1, 0, 0, 1, 0, 4, 4, 1, + 1, 3, 3, 1, 1, 3, 1, 1 +], +``` + +That's all of the updates to the game model - now we just need to update the renderer to use the textures specified by the `Tilemap` instead of hard-coded values. + +We'll start with the walls. In order to select the correct texture, we first need to work out which wall we hit. You may recall that in Part 3 we added a method `tile(at:from:)` to `Tilemap` that was used to determine which tile a given ray was hitting given a hit position and direction. We can call that method again with the output of `hitTest()` to tell us which wall tile we hit, and from that we can select the correct texture. + +In `Renderer.draw()`, a few lines below the `// Draw wall` comment, replace the lines: + +```swift +if end.x.rounded(.down) == end.x { + wallTexture = textures[.wall] + wallX = end.y - end.y.rounded(.down) +} else { + wallTexture = textures[.wall2] + wallX = end.x - end.x.rounded(.down) +} +``` + +with: + +```swift +let tile = world.map.tile(at: end, from: ray.direction) +if end.x.rounded(.down) == end.x { + wallTexture = textures[tile.textures[0]] + wallX = end.y - end.y.rounded(.down) +} else { + wallTexture = textures[tile.textures[1]] + wallX = end.x - end.x.rounded(.down) +} +``` + +We'll use a similar approach for the floor and ceiling. We can't look up the textures in advance anymore, so delete the following line from just below the `// Draw floor and ceiling` comment: + +```swift +let floorTexture = textures[.floor], ceilingTexture = textures[.ceiling] +``` + +Now we'll use the `wallX` and `wallY` values we already computed to look up the tile, which will give us the textures. Still in the `// Draw floor and ceiling` section, find the line: + +```swift +let tileX = mapPosition.x.rounded(.down), tileY = mapPosition.y.rounded(.down) +``` + +then insert the following code just below it: + +```swift +let tile = world.map[Int(tileX), Int(tileY)] +let floorTexture = textures[tile.textures[0]] +let ceilingTexture = textures[tile.textures[1]] +``` + +And that should do it! Run the app again to see the new wall and floor tiles. + +![Varied wall and floor tile textures](Images/VariedTextures.png) + +### Be Wise, Optimize + +You may notice the frame rate dropping a bit as you move around, even when running in release mode. + +We've mostly avoided talking about optimization until now. It's generally a bad idea to optimize the code before you've finished implementing all the functionality, because optimized code can be harder to modify[[5]](#footnote5). + +In this case however, the performance *just* took an unexpected nose dive, which tells us that we probably did something dumb. Now is the best time to figure out what that might be, before we pile on more code and lose the context. + +You may already have an intuition what the problem is, but it's a good idea to verify your assumptions before you start performance tuning. Open up the *Time Profiler* tool in Instruments (to open Instruments, select `Product > Profile` from the menu bar in Xcode, or press `Cmd-I`), and run the game through it. Ideally this should be done on a device, but for major issues the Simulator will give a reasonable picture of what's happening as well. + +You should see something like the screenshot below. Pay close attention to the `Call Tree` settings - inverting the call tree and hiding system libraries makes the output easer to interpret. + +![Time Profiler trace showing relative time spent in various calls](Images/TextureLookupTrace.png) + +The top function here is what we'd expect - the game spends most of its time inside the `Renderer.draw()` method. The second line item is a bit of a surprise though - `Textures.subscript.getter`. The most expensive single call inside `draw()` is not anything to do with vector math, or writing or reading pixels from a bitmap - it's just looking up the bitmaps for the textures. + +It doesn't make a lot of sense that this would be an expensive operation. We aren't doing any loading or decoding - we loaded all the bitmaps for our textures in advance, and stored them in a `Dictionary`, keyed by the texture name. A hash lookup from a dictionary in Swift is by no means an expensive operation, so we'd have to be calling it *a lot* for it to dominate the trace. + +*And it seems that we are.* + +When drawing the walls, we have to do a texture lookup once for each ray, which is not too bad. Previously we were doing the same for the floor and ceiling textures (which never changed), but now that we are getting the texture from the map tile, we are looking it up again for *every single pixel* of the visible floor surface. + +Most of the time, the texture doesn't change between consecutive pixels, so we shouldn't have to look it up again, we can just cache the most recently used texture and use it again. The problem (as always) with caching is knowing when to expire the cache. + +As we draw the floor (and ceiling), we need to fetch a new texture every time the tile type changes, but *only* when it changes. The `Tile` type is just an `enum` backed by an `Int`, so it's very cheap to compare. Let's use the current `Tile` as a cache key, and re-fetch the textures whenever it changes. + +When you hear the word "cache", you might be thinking of something like a dictionary of `Tile` to `Bitmap`, but remember that the objective here was to *avoid* the cost of a hash lookup. We only need to store a single cache entry (the last pair of texture bitmaps we used), so let's just use a few local variables outside the floor-drawing loop. + +In `Renderer.draw()` just below the comment `// Draw floor and ceiling`, add the following lines: + +```swift +var floorTile: Tile! +var floorTexture, ceilingTexture: Bitmap! +``` + +That's our cache storage. Next, replace the lines: + +```swift +let floorTexture = textures[tile.textures[0]] +let ceilingTexture = textures[tile.textures[1]] +``` + +with: + +```swift +if tile != floorTile { + floorTexture = textures[tile.textures[0]] + ceilingTexture = textures[tile.textures[1]] + floorTile = tile +} +``` + +And that's the cache implementation. If the tile has changed since the last loop iteration, we fetch the new floor and ceiling textures and update the `floorTile` value used as the cache key. If it hasn't changed, we do nothing. + +Run the game again and you should see that the frame drops have been fixed. If you compare the new Time Profiler trace, you'll also see that `Textures.subscript.getter` has been pushed way down the list and now represents only a small percentage of the total frame time. + +![Time Profiler trace showing reduced texture lookup time](Images/ImprovedTextureTrace.png) + +That's it for part 4. In this part we... + +* Added wall, floor and ceiling textures +* Added texture variants, configurable by the map JSON +* Encountered and fixed our first performance blip + +In [Part 5](Part5.md) we'll see about adding some other maze inhabitants to keep the player company. + +
+ +[[1]](#reference1) There are other types of texture map, such as [displacement maps](https://en.wikipedia.org/wiki/Displacement_mapping) that actually *can* affect the surface geometry, but such textures weren't seen in games until around 2005, coinciding with the mainstream availability of programmable GPUs capable of running complex shaders. + +[[2]](#reference2) OK, it's also because I'm not much of an artist and fewer pixels are easier to draw. + +[[3]](#reference3) Wolfenstein used 64x64 images for the wall textures, in case you were wondering. Doom used 128x128. + +[[4]](#reference4) As with the `loadMap()` function, we haven't bothered with error handling in `loadTextures()`. An error here would be fatal anyway, and it's better to crash as early as possible. + +[[5]](#reference5) As Kent Beck famously said, *"Make it work, make it right, make it fast."* (in that order). diff --git a/Tutorial/Part5.md b/Tutorial/Part5.md new file mode 100644 index 0000000..96431e1 --- /dev/null +++ b/Tutorial/Part5.md @@ -0,0 +1,733 @@ +## Part 5: Sprites + +In [Part 4](Part4.md) we added wall and floor textures to make our 3D environment more interesting. If you're just jumping in at this point, the code for Part 4 can be found [here](https://github.com/nicklockwood/RetroRampage/archive/Part4.zip). + +So far we have succeeded in building a fairly compelling *wallpaper simulator*, however to make this into a game we'll need more than textured walls. It's time to add some other inhabitants to our world. + +### Monsters, Inc. + +Add a new file called `Monster.swift` to the Engine module with the following contents: + +```swift +public struct Monster { + public var position: Vector + + public init(position: Vector) { + self.position = position + } +} +``` + +In `Thing.swift`, add a `monster` case to the `Thing` enum: + +```swift +public enum Thing: Int, Decodable { + case nothing + case player + case monster +} +``` + +This new monster has an index of 2, so go ahead and add a bunch of `2`s to the `things` array in `Map.json`: + +```swift +"things": [ + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 2, 0, 0, 0, 2, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 2, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 1, 0, 2, 0, + 0, 0, 0, 0, 0, 0, 0, 0 +] +``` + +Finally, in `World.swift` add a `monsters` property to the `World` struct, and update the `switch` inside `init()` to handle the `monster` case: + +```swift +public struct World { + ... + public var monsters: [Monster] + + public init(map: Tilemap) { + self.map = map + self.monsters = [] + for y in 0 ..< map.height { + for x in 0 ..< map.width { + let position = Vector(x: Double(x) + 0.5, y: Double(y) + 0.5) + let thing = map.things[y * map.width + x] + switch thing { + case .nothing: + break + case .player: + self.player = Player(position: position) + case .monster: + self.monsters.append(Monster(position: position)) + } + } + } + } +} +``` + +That takes care of placing the monsters in the world, but how do we *draw* them? + +### Spritely Creatures + +Modern games use 3D models made from textured polygons. While this approach produces incomparable visual and animation detail, it requires a complex toolchain and engine to work with 3D assets. Back in the early '90s, the technology to render detailed 3D models in real time simply didn't exist, so a different solution had to be found. That solution was [sprites](https://en.wikipedia.org/wiki/Sprite_%28computer_graphics%29). + +A *sprite* is just a fancy term for an image that moves around on the screen independently of the background. Sprites were employed in 2D games from the very earliest days of computing, and many arcade machines and consoles had dedicated hardware for handling sprites. + +By scaling a sprite in proportion to its distance from the viewer, it can be used to simulate a 3D object[[1]](#footnote1). Flat, 2D images might seem like a poor way to represent something solid, but from a first-person perspective, a flat image that always rotates to face the viewer can be pretty convincing. + +Because sprites must always face the player, they can't rotate freely. Rotating the plane on which the sprite is drawn would just reveal its lack of depth and break the illusion. In order for a sprite-based character to appear to face away from the player, the sprite has to be swapped out for another image drawn from a different angle. + +Soldier sprite from the original Wolfenstein, drawn from eight angles + +The original DOS version of Wolfenstein used characters drawn from eight different orientations to simulate rotation, but this meant eight copies of every animation frame and pose - a huge amount of work for the artists, as each frame had to be drawn by hand. Later ports of the game such as the [SNES and Mac versions](https://wolfenstein.fandom.com/wiki/Mac_Family) saved memory by doing away with the multiple orientations and simply drawing every enemy facing the player at all times[[2]](#footnote2). + +We'll keep things simple and just use a single image for the monster for now. The monster sprite will be one world unit in size - the same width and height as a wall tile. As with the walls, I've used a 16x16 image for the sprite image, but feel free to use whatever resolution you like as long as it's square. + +Monster sprite blown up on left and at actual size on right + +The monster sprite does not need to completely fill the bounds of the image, but its feet should touch the bottom edge (unless you want it to hover). The background behind the monster should be transparent (i.e. the pixels should have an alpha component of zero). If you would like to use my terrible artwork, you can download it [here](https://github.com/nicklockwood/RetroRampage/tree/Part5/Source/Rampage/Assets.xcassets/monster.imageset). + + +Add an XCAsset image called `monster`, then add a `monster` case to the `Texture` enum in `Textures.swift`: + +```swift +public enum Texture: String, CaseIterable { + case wall, wall2 + case crackWall, crackWall2 + ... + case monster +} +``` + +To draw each monster we need to create a [billboard](https://en.wikipedia.org/wiki/2.5D#Billboarding) - a textured rectangle that always faces the player. As with the view plane, we can model this rectangle as a line because only its X and Y coordinates ever change. + +This next part will be much easier to visualize in 2D. In `Renderer.swift`, add a new method called `draw2D` containing the top-down drawing code we we used back in [Part 3](Part3.md): + +```swift +mutating func draw2D(_ world: World) { + let scale = Double(bitmap.height) / world.size.y + + // Draw map + for y in 0 ..< world.map.height { + for x in 0 ..< world.map.width where world.map[x, y].isWall { + let rect = Rect( + min: Vector(x: Double(x), y: Double(y)) * scale, + max: Vector(x: Double(x + 1), y: Double(y + 1)) * scale + ) + bitmap.fill(rect: rect, color: .white) + } + } + + // Draw player + var rect = world.player.rect + rect.min *= scale + rect.max *= scale + bitmap.fill(rect: rect, color: .blue) + + // Draw view plane + let focalLength = 1.0 + let viewWidth = 1.0 + let viewPlane = world.player.direction.orthogonal * viewWidth + let viewCenter = world.player.position + world.player.direction * focalLength + let viewStart = viewCenter - viewPlane / 2 + let viewEnd = viewStart + viewPlane + bitmap.drawLine(from: viewStart * scale, to: viewEnd * scale, color: .red) + + // Cast rays + let columns = 10 + let step = viewPlane / Double(columns) + var columnPosition = viewStart + for _ in 0 ..< columns { + let rayDirection = columnPosition - world.player.position + let viewPlaneDistance = rayDirection.length + let ray = Ray( + origin: world.player.position, + direction: rayDirection / viewPlaneDistance + ) + let end = world.map.hitTest(ray) + bitmap.drawLine(from: ray.origin * scale, to: end * scale, color: .green) + columnPosition += step + } +} +``` + +Then in `ViewController.update()`, replace the line: + +```swift +renderer.draw(world) +``` + +with: + +```swift +renderer.draw2D(world) +``` + +Although we have a method for drawing a line, we don't currently have a way to *model* one as a self-contained value. You might be thinking that `Ray` does this job, but `Ray` only has a start point and direction so its length is infinite/unspecified, whereas a line has a start *and* end point, so its length is part of the definition. + +Create a new file in the Engine module called `Billboard.swift` with the following contents: + +```swift +public struct Billboard { + public var start: Vector + public var direction: Vector + public var length: Double + + public init(start: Vector, direction: Vector, length: Double) { + self.start = start + self.direction = direction + self.length = length + } +} + +public extension Billboard { + var end: Vector { + return start + direction * length + } +} +``` + +Now, for every monster in the level, we need to create a `Billboard` that represents its sprite. Since the sprites all face the player, the plane of every sprite will be parallel to the view plane, which is itself orthogonal to the player's direction: + +```swift +let spritePlane = player.direction.orthogonal +``` + +The width of the sprites will always be one world unit, so the length of the plane is already correct. We just need to subtract half of that length from each monster's position to get the starting points of their respective sprite billboards. Let's add a computed property to `World` that returns the billboards for each monster sprite. Add the following code in `World.swift`: + +```swift +public extension World { + ... + + var sprites: [Billboard] { + let spritePlane = player.direction.orthogonal + return monsters.map { monster in + Billboard( + start: monster.position - spritePlane / 2, + direction: spritePlane, + length: 1 + ) + } + } +} +``` + +Now to draw those lines. Append the following code to the end of the `draw2D()` method in `Renderer.swift`: + +```swift +// Draw sprites +for line in world.sprites { + bitmap.drawLine(from: line.start * scale, to: line.end * scale, color: .green) +} +``` + +Now run the game again. If you move around you'll see that the green lines representing the monster sprites always rotate to face the direction of the player[[3]](#footnote3). + +![Lines representing the player-facing monster sprites](Images/SpriteLines.png) + +The view rays currently pass right through the sprites to the wall behind. In order to draw the sprites in 3D we will need to find where the rays intersect them. In `Billboard.swift` add the following placeholder method: + +```swift +public extension Billboard { + ... + + func hitTest(_ ray: Ray) -> Vector? { + + } +} +``` + +This should look familiar - we wrote a similar method on `Tilemap` previously. In that case we were looking for the intersection point between a `Ray` and a grid of map tiles. So how do we find the intersection point between a `Ray` and a `Billboard`? + +The first step is to convert the lines of the `Ray` and `Billboard` to [slope-intercept form](https://en.wikipedia.org/wiki/Linear_equation#Slope–intercept_form). The slope intercept equation defines the relationship between the X and Y coordinates along a line as: + +```swift +y = m * x + c +``` + +In this equation, `m` is the slope of the line and `c` is the Y value at the point where the line crosses the Y axis (i.e. where X is zero). + +An arbitrary line defined in terms of slope and intercept + +Mathematicians love to use single-letter variable names for some reason, but in the programming world that's generally considered an anti-pattern, so let's rewrite that more explicitly: + +```swift +y = slope * x + intercept +``` + +Slope-intercept form discards the length information, so there is no difference between the slope for a line segment or a ray. For that reason, we might as well convert the `Billboard` to a `Ray`, then we can avoid having to duplicate the logic for multiple types. Add the following line to `Billboard.hitTest()`: + +```swift +let lhs = ray, rhs = Ray(origin: start, direction: direction) +``` + +This code assigns the incoming ray to a local variable (`lhs` - short for *left-hand side*) and creates a `Ray` instance from the billboard itself, which we assign to the `rhs` variable. + +Now that we have two rays, we need to compute their slope intercept parameters. To get the slope of a ray, we need to take the `direction` vector and divide it's height by its width: + +``` +let slope = direction.y / direction.x +``` + +If we rearrange the slope intercept equation by subtracting `slope * x` from both sides, we get `y - slope * x = intercept`. We already have a known position on the ray - its `origin` - so if we plug in the `origin` values for `x` and `y` that will give us the intercept value: + +``` +let intercept = origin.y - slope * origin.x +``` + +Because the intercept depends on the slope, instead of having two separate properties, we'll combine slope and intercept into a single *tuple*. Add the following computed property to `Ray.swift`: + +```swift +public extension Ray { + ... + + var slopeIntercept: (slope: Double, intercept: Double) { + let slope = direction.y / direction.x + let intercept = origin.y - slope * origin.x + return (slope, intercept) + } +} +``` + +Back in the `Billboard.swift` file, add the following code to `hitTest()`: + +```swift +// Calculate slopes and intercepts +let (slope1, intercept1) = lhs.slopeIntercept +let (slope2, intercept2) = rhs.slopeIntercept +``` + +You may have noticed that unlike the `Tilemap.hitTest()` method, `Billboard.hitTest()` returns an *optional* `Vector`. That's because its possible that the ray doesn't intersect the billboard at all. One case where this could happen is if the ray and billboard are parallel, which would mean their slope values would be equal. Let's add a check for that: + +```swift +// Check if slopes are parallel +if slope1 == slope2 { + return nil +} +``` + +What's next? Well, we have our slope intercept equations for the two lines: + +```swift +y = slope1 * x + intercept1 +y = slope2 * x + intercept2 +``` + +These are [simultaneous equations](https://en.wikipedia.org/wiki/System_of_linear_equations) - two separate equations that have two variables in common. In this case the variables are the X and Y coordinates of the point where the two lines cross. + +You solve a pair of simultaneous equations by defining one in terms of the other. We can replace `y` in the second equation with the body of the first equation, eliminating the `y` variable so we only need to find `x`: + +```swift +slope1 * x + intercept1 = slope2 * x + intercept2 +``` + +Rearranging this equation then gives us the formula for `x` in terms of values we already know: + +```swift +let x = (intercept1 - intercept2) / (slope2 - slope1) +``` + +We now have the X coordinate of the intersection point between the ray and billboard. Given X, we can use the `y = m * x + c` equation that we started with to find Y: + +```swift +let y = slope1 * x + intercept1 +``` + +Putting it all together, the code for the `Billboard.hitTest()` function is: + +```swift +func hitTest(_ ray: Ray) -> Vector? { + let lhs = ray, rhs = Ray(origin: start, direction: direction) + + // Calculate slopes and intercepts + let (slope1, intercept1) = lhs.slopeIntercept + let (slope2, intercept2) = rhs.slopeIntercept + + // Check if slopes are parallel + if slope1 == slope2 { + return nil + } + + // Find intersection point + let x = (intercept1 - intercept2) / (slope2 - slope1) + let y = slope1 * x + intercept1 + + return Vector(x: x, y: y) +} +``` + +Unfortunately there's an edge case (isn't there always?). For a perfectly vertical ray, the value of `direction.x` will be zero, which means that `direction.y / direction.x` will be infinite, and pretty soon we're going to end up with [NaNs](https://en.wikipedia.org/wiki/NaN) everywhere. + +We could add a bunch of logic to handle vertical rays as a special case, but instead we're going to use a time-honored tradition when working with floating point numbers - we're going to *fudge* it. When `direction.x` is zero (or very close to zero), we'll just add a small offset to the value (you may recall that we already used this trick once before when we added `0.001` to the texture coordinate in Part 4 to fix a rounding error). + +In `Billboard.hitTest()`, replace the following: + +```swift +let lhs = ray, rhs = Ray(origin: start, direction: direction) +``` + +with: + +```swift +var lhs = ray, rhs = Ray(origin: start, direction: direction) + +// Ensure rays are never exactly vertical +let epsilon = 0.00001 +if abs(lhs.direction.x) < epsilon { + lhs.direction.x = epsilon +} +if abs(rhs.direction.x) < epsilon { + rhs.direction.x = epsilon +} +``` + +The `epsilon` constant is the offset in world units. There's no great science to choosing this value - it just needs to be small enough that it won't be noticeable if an object is misplaced by this amount in the world, but large enough not to cause precision issues in the subsequent calculations[[4]](#footnote4). + +Now we have calculated the intersection point between the lines, let's update the drawing code so that the rays stop when they hit a sprite. In `Renderer.draw2D()`, in the `// Cast rays` section, replace the line: + +```swift +let end = world.map.hitTest(ray) +``` + +with: + +```swift +var end = world.map.hitTest(ray) +for sprite in world.sprites { + guard let hit = sprite.hitTest(ray) else { + continue + } + let spriteDistance = (hit - ray.origin).length + if spriteDistance > (end - ray.origin).length { + continue + } + end = hit +} +``` + +This logic loops through each sprite and checks if the ray hits it. If it does, it compares the distance from the player to the hit position with the distance to the current `end`, and updates `end` if the new position is closer. Run the game again and see how it looks. + +![Rays pointing the wrong way](Images/SpriteRayBug.png) + +Hmm... not quite the effect we were hoping for. Several of the rays seem to be going the wrong way and/or stopping in mid-air. + +The problem is, our ray-to-billboard intersection code doesn't currently take the `origin` point of the ray into account. We are treating the ray as if it extends indefinitely in *both* directions. The rays in the top-down view are going the wrong way because the sprite behind us is closer than the one in front, and the slopes formed by the ray and that sprite *do* in fact intersect, even though they do so behind the player. + +Nearest ray/sprite intersection is behind player + +We need to update the `hitTest()` method to return `nil` if the intersection point between the lines is outside the range we want to test. We can do that by checking if the vector between the ray origin and the intersection point lies in the same direction as the ray itself. + +Subtracting the ray origin from the intersection point gives us the direction vector. We can check if that direction matches the ray direction by comparing the signs of the X and Y components. + +```swift +guard (x - lhs.origin.x).sign == lhs.direction.x.sign && + (y - lhs.origin.y).sign == lhs.direction.y.sign else { + return nil +} +``` + +But actually, since the X and Y components of a vector are both proportional to its length, and because of our hack that guarantees that `direction.x` will never be zero, we can figure this out using just the X components instead of the whole vector. + +If we take the X component of the vector from the ray origin to the intersection point, and divide it by the X component of the direction, the result will be proportional to the distance of the intersection point along the ray. If that result is less than zero it means that the intersection happens before the origin point of the ray (i.e. that the sprite is behind the player), so we can ignore it. + +In `Billboard.hitTest()` add the following code just before the line `return Vector(x: x, y: y)`: + +```swift +// Check intersection point is in range +let distanceAlongRay = (x - lhs.origin.x) / lhs.direction.x +if distanceAlongRay < 0 { + return nil +} +``` + +We also need to check that the intersection point lies between the start and end points of the sprite's billboard, otherwise the rays will stop in mid-air every time they cross any sprite plane. Add the following lines below the code we just wrote: + +```swift +let distanceAlongBillboard = (x - rhs.origin.x) / rhs.direction.x +if distanceAlongBillboard < 0 || distanceAlongBillboard > length { + return nil +} +``` + +Run the game again and you should see that the rays all travel in the right direction, and no longer stop in mid-air. + +![Rays stopping at the correct sprite](Images/SpriteRayIntersection.png) + +Now that we've got the intersection logic figured out, we can switch back to first-person perspective and draw the sprites in 3D. + +### Erase and Rewind + +In `ViewController.update` restore the line: + +```swift +renderer.draw2D(world) +``` + +back to: + +```swift +renderer.draw(world) +``` + +Then, back in `Renderer.swift`, delete the `draw2D()` method, as we won't be needing it anymore. + +Next, add the following code to the `Renderer.draw()` method, just before the line `columnPosition += step`: + +```swift +// Draw sprites +for sprite in world.sprites { + guard let hit = sprite.hitTest(ray) else { + continue + } + let spriteDistance = (hit - ray.origin).length + +} +``` + +As in the `draw2D` method, we need to compare the distance from the sprite to the player with the distance from the wall to the player. But in this case, we already have the distance from the player to the wall stored in the `wallDistance` variable so we don't need to calculate it. Add the following code to the loop: + +```swift +if spriteDistance > wallDistance { + continue +} +``` + +From the `spriteDistance` we can derive the height at which to draw the sprite - it's the same exact logic we used to draw the wall previously: + +```swift +let perpendicular = spriteDistance / distanceRatio +let height = wallHeight / perpendicular * Double(bitmap.height) +``` + +Now we need to derive the X coordinate to use for the sprite texture. This is proportional to the distance from the start of the sprite billboard to the hit position, divided by the billboard's length: + +```swift +let spriteX = (hit - sprite.start).length / sprite.length +``` + +We can then divide `spriteX` by the texture width to get the actual texture coordinate: + +```swift +let spriteTexture = textures[.monster] +let textureX = min(Int(spriteX * Double(spriteTexture.width)), spriteTexture.width - 1) +``` + +The rest of the code is the same as for drawing the walls. Putting it all together we have: + +```swift +// Draw sprites +for sprite in world.sprites { + guard let hit = sprite.hitTest(ray) else { + continue + } + let spriteDistance = (hit - ray.origin).length + if spriteDistance > wallDistance { + continue + } + let perpendicular = spriteDistance / distanceRatio + let height = wallHeight / perpendicular * Double(bitmap.height) + let spriteX = (hit - sprite.start).length / sprite.length + let spriteTexture = textures[.monster] + let textureX = min(Int(spriteX * Double(spriteTexture.width)), spriteTexture.width - 1) + let start = Vector(x: Double(x), y: (Double(bitmap.height) - height) / 2 + 0.001) + bitmap.drawColumn(textureX, of: spriteTexture, at: start, height: height) +} +``` + +Run the app again and we should see... *Why, hello friend!* + +![Monster sprite with black background](Images/BlackSpriteBackground.png) + +Why is the wall behind the monster black? You might be thinking it's because we aren't drawing the wall if the ray hits the sprite first, but that isn't it - we draw the wall first and then draw the sprite on top. The problem is actually to do with *blending*. + +### Will it Blend? + +As you may recall, every pixel in the bitmap is represented by a `Color` struct with four components. The `r`, `g` and `b` properties determine the color, and the `a` (*alpha*) component controls the transparency. + +Pixels with zero alpha should not be drawn to the screen. Ones with partial alpha values should be *blended* into the existing background color. At least, that's how it works normally on iOS. But we aren't using iOS to draw our pixels - we wrote our own drawing code. + +Our sprite has a transparent background, but at the moment, when the sprite is drawn into the bitmap, its pixels just replace the ones that are already there. The transparent pixels in the sprite aren't blended with the background, *they make the background transparent*. So why does it appear black? Because the `UIImageView` has a black background, and we're seeing right through to that. + +If we change the `UIImageView` background color to red, the background of the sprite will be red instead. + +![Monster sprite with red background](Images/RedSpriteBackground.png) + +To fix this, we'll need to implement alpha blending in `Bitmap.drawColumn()`. So how do we do that? + +In the era of indexed color, it wasn't practical to implement variable transparency (at least not without extremely clever palette management). Instead, games like Wolfenstein chose a single color in the palette to represent the transparency, and pixels that used that color were simply not drawn. + +The sprite we are using at the moment only has either fully transparent or opaque pixels, but since we are using full 32-bit color for our sprites, we may as well add proper support for alpha blending. In pseudo-code, the equation for alpha blending is: + +```swift +result = backgroundColor * (1 - alpha) + foregroundColor * alpha +``` + +In this equation `alpha` is a value in the range 0 to 1, with 0 being fully transparent and 1 being fully opaque. This operation is actually quite expensive, requiring two multiplications for every component (a total of six for the red, green and blue components) per pixel. + +For this reason it is common practice to use an optimization called *premultiplied alpha* to eliminate half of those multiplications. With premultiplied alpha, the red, green and blue components in the image are premultiplied by the alpha component - i.e. every pixel in the image has already been multiplied by its alpha value. With premultiplied alpha, the blending equation is simplified to: + +```swift +result = backgroundColor * (1 - alpha) + foregroundColor +``` + +So how do we know if our images are using premultiplied alpha or not? When we wrote the code to initialize a `Bitmap` from a `UIImage` we had to specify a `CGImageAlphaInfo` value, and the value we used was `premultipliedLast` which means that the color values in the bitmap *are* already premultiplied, so we can use the more efficient form of the blending equation. + +In `Bitmap.swift`, add the following new method: + +```swift +public extension Bitmap { + ... + + mutating func blendPixel(at x: Int, _ y: Int, with newColor: Color) { + let oldColor = self[x, y] + let inverseAlpha = 1 - Double(newColor.a) / 255 + self[x, y] = Color( + r: UInt8(Double(oldColor.r) * inverseAlpha) + newColor.r, + g: UInt8(Double(oldColor.g) * inverseAlpha) + newColor.g, + b: UInt8(Double(oldColor.b) * inverseAlpha) + newColor.b + ) + } +} +``` + +Then in the `drawColumn()` method, replace the line: + +```swift +self[Int(point.x), y] = sourceColor +``` + +with: + +```swift +blendPixel(at: Int(point.x), y, with: sourceColor) +``` + +Run the app again and you'll see that the wall now shows through the background of the sprite. + +![Monster sprite with transparent background](Images/ClearSpriteBackground.png) + +### We Don't Like Your Sort + +If you walk to the top-right corner of the map and stand behind the monster in that corner, looking down towards the monster in the bottom-right corner, you'll see something like this (that's assuming you're using the same map layout as we have in the tutorial - if not, you can just take my word for it). + +![Monster sprites drawn in the wrong order](Images/SpriteOrderBug.png) + +What's going on there? Well, basically we're seeing the far-away monster *through* the nearer one. This doesn't happen with the walls because the ray casting algorithm always stops at the wall nearest the camera, but because sprites have transparent areas we can't just draw the nearest sprite or we wouldn't be able to see other sprites showing through the gaps. + +The order in which we draw the sprites is determined by the order in which they are defined in the `things` array, so sprites that are further toward the bottom/right of the map will be drawn after (and therefore *on top of*) ones that are in the top/left, even if they are further away from the player. + +This problem still plagues modern graphics engines, and a common solution is to use the [Painter's algorithm](https://en.wikipedia.org/wiki/Painter's_algorithm) - a fancy name for a simple idea: When a painter wants to draw a person in front of a mountain, they draw the mountain first, and then the person. So likewise, when we want to draw one sprite in front of another, we need to make sure we draw the more distant sprite first. + +In other words, we need to sort the sprites according to their distance from the player before we draw them. In `Renderer.draw()`, add the following code just above the `// Draw sprites` comment: + +```swift +// Sort sprites by distance +var spritesByDistance: [(distance: Double, sprite: Billboard)] = [] +for sprite in world.sprites { + guard let hit = sprite.hitTest(ray) else { + continue + } + let spriteDistance = (hit - ray.origin).length + spritesByDistance.append( + (distance: spriteDistance, sprite: sprite) + ) +} +``` + +The first part of this loop looks a lot like the sprite drawing loop we already wrote. It iterates over the sprites, performs a hit test, then computes the distance. But instead of drawing anything, it appends a tuple of the distance and sprite itself to an array. + +Just below the for loop, add the following line: + +```swift +spritesByDistance.sort(by: { $0.distance > $1.distance }) +``` + +This sorts the tuples in the array in reverse order of the `distance`, so the sprite with the greatest distance appears first in the array. Finally, in the `// Draw sprites` section below, replace the line: + +```swift +for sprite in world.sprites { +``` + +with: + +```swift +for (_, sprite) in spritesByDistance { +``` + +This means we are now looping through the pre-sorted tuples instead of the unsorted sprites. We aren't using the distance value from the tuple, so we discard it using the `_` syntax and just keep the sprite. Try running the game again and you should find that the problem with sprite order has been resolved. + +![Monster sprites drawn in correct order](Images/SortedSprites.png) + +### Measure Twice, Cut Once + +It seems inefficient that we are performing the hit test twice for each sprite, discarding the result in between. It's tempting to either include the hit position in the sorted tuples so we can reuse it in the drawing loop, or compute extra values like `textureX` in the first loop and store the results so they need not be recalculated in the second. + +This is the dangerous allure of *premature optimization*. It is easy to be distracted by small optimization opportunities, but applying a small optimization can often make it harder to recognize a larger one. + +The hit test is certainly an expensive calculation to be doing twice, but we are actually doing something *far more* wasteful. The sorting loop creates an array of billboards for every sprite in the level, then copies them into another array, then sorts them by distance from the player, and it does this every frame, for every ray. There is one ray cast for every horizontal pixel on the screen, so that's ~1000 times per frame. + +But because the sprites all face the player, their order doesn't change depending on their horizontal position on the screen. The (non-perpendicular) distance of each sprite varies depending on the angle of the ray, but the relative *order* of the sprites does not. That means we can hoist the sorting loop outside of the ray casting loop, and only do it once per frame instead of 1000 times. + +In `Renderer.draw()`, move the following block of code from its current position to just above the `//Cast rays` comment: + +```swift +// Sort sprites by distance +var spritesByDistance: [(distance: Double, sprite: Billboard)] = [] +for sprite in world.sprites { + guard let hit = sprite.hitTest(ray) else { + continue + } + let spriteDistance = (hit - ray.origin).length + spritesByDistance.append( + (distance: spriteDistance, sprite: sprite) + ) +} +spritesByDistance.sort(by: { $0.distance > $1.distance }) +``` + +At this point in the code we don't have a `ray` to use for the intersection test anymore, but we can use the distance from the player position to the starting point of the sprite billboard as an approximation. This won't always work correctly if two sprites are very close together, but the collision detection we'll add later should prevent that from happening anyway. + +In the `// Sort sprites by distance` block, replace the lines: + +```swift +guard let hit = sprite.hitTest(ray) else { + continue +} +let spriteDistance = (hit - ray.origin).length +``` + +with: + +```swift +let spriteDistance = (sprite.start - world.player.position).length +``` + +The `spriteDistance` value we are now using to sort the sprites is completely different from the one used inside the `//Draw sprites` loop. If we had "optimized" the rendering by combining those values, this (much more significant) optimization would not have been possible. + +In fact, the optimization we have done here is *also* a bit premature, and we may have cause to regret it later, but it's a good illustration of the principle that small optimizations can impede larger ones. + +That's it for Part 5. In this part we: + +* Added monsters to the map +* Displayed the monsters in 3D using sprites +* Implemented alpha blending +* Learned a valuable lesson about premature optimization + +In [Part 6](Part6.md) we will bring the monsters to life with collision detection, animation, and some rudimentary AI. + +
+ +[[1]](#reference1) Long before modern GPU technology, consoles such as the [SNES](https://en.wikipedia.org/wiki/Super_Nintendo_Entertainment_System) included [built-in support](https://en.wikipedia.org/wiki/Mode_7) for scaling and rotating sprites instead of just moving them around. + +[[2]](#reference2) It's a testament to the immersiveness of Wolfenstein that I never noticed this fact when playing the Mac version as a teenager (although I did notice that the Mac graphics, despite being twice the resolution of the PC version, were significantly inferior artistically). + +[[3]](#reference3) If you're wondering why the map is left-aligned rather than centered as it was before, it's because we changed the aspect ratio of the output bitmap after we switched to a first-person view. The bitmap is now wider than it is tall, but the map is still square, so it only occupies the left-hand side. + +[[4]](#reference4) You might be tempted to use some *official* value for `epsilon` like `.ulpOfOne`, but don't do that. Dividing by `.ulpOfOne` will produce a gigantic number right at the limit of `Double` precision, and will certainly cause errors in the subsequent calculations. + diff --git a/Tutorial/Part6.md b/Tutorial/Part6.md new file mode 100644 index 0000000..e8c95e4 --- /dev/null +++ b/Tutorial/Part6.md @@ -0,0 +1,716 @@ +## Part 6: Enemy Action + +In [Part 5](Part5.md) we added some monsters to the level, and displayed them using *sprites* - textured rectangles that scale with distance but always face the camera. The complete code for Part 5 can be found [here](https://github.com/nicklockwood/RetroRampage/archive/Part5.zip). + +The monster sprites *look* good[[1]](#footnote1), but they don't *do* very much. In fact we can walk right through them as if they weren't even there. + +### Don't Ghost Me + +The reason you can walk through the sprites is that they are ghosts. I know they look more like zombies, but they're actually ghosts. It's a feature, not a bug. + +OK, *fine* - I suppose we can make them solid if you insist. To make the zombies less ghostly and more zombie-ie, we'll need to implement collision handling. Fortunately we already did almost all the work for this when we implemented player-wall collisions back in [Part 2](Part2.md). + +To detect if the player was colliding with a wall, we modeled the player as a `Rect` and then did a `Rect`/`Rect` intersection test with the walls. We can use the same approach for `Player`/`Monster` collisions. + +Start by copying the computed `rect` property from `Player.swift` and adding it to `Monster.swift` as follows: + +```swift +public extension Monster { + var rect: Rect { + let halfSize = Vector(x: radius, y: radius) + return Rect(min: position - halfSize, max: position + halfSize) + } +} +``` + +That code depends on a `radius` property, so add a `radius` to the `Monster` as well: + +```swift +public struct Monster { + public let radius: Double = 0.25 + + ... +} +``` + +Now, in`Player.swift`, add the following method to the end of the `Player` extension: + +```swift +func intersection(with monster: Monster) -> Vector? { + return rect.intersection(with: monster.rect) +} +``` + +This code is just a proxy to the `Rect.insersection()` method, which returns a vector representing the overlap between the rectangles (or `nil` if they don't intersect). + +This gives us everything we need to determine if the player is colliding with a monster, and if so by how much we need to move them so that they won't be anymore. In `World.update()`, add the following block of code just above the `while` loop: + +```swift +// Handle collisions +for monster in monsters { + if let intersection = player.intersection(with: monster) { + player.position -= intersection + } +} +``` + +This code loops through every monster in the map, checks if the player is intersecting them, and if so moves the player away so that they aren't. This solution isn't necessarily perfect - it's possible that if you bumped into a group of monsters, you'd end up being buffeted from one into another, but it should be good enough since you expect a certain amount of squishiness if you walk into a monster, so it's not a big problem if the collision response has some looseness to it. + +It's because of this looseness that we perform the monster collision loop *before* the wall collision loop - we don't mind if the player gets bounced from one monster into another, but we *definitely* don't want them to end up embedded in a wall, so we make sure that wall collisions are processed last. + +Run the game now and you should find that you are no longer able to walk through the monsters + +### You've Got to Give a Little + +Something feels a bit... *off* about the collision handling right now. Walking into those monsters is like walking into a brick wall because they don't have any *give*. Since they are roughly the same size as the player, and assuming they aren't glued to the floor, you'd expect to be able to push them around. + +Instead of applying the collision response solely to the player, what if we split it evenly between the player and monster, so that a collision pushes both player and monster in opposite directions? + +In order to move the monsters, we'll need to change the loop a bit. Because the monsters are structs (value types) we can't modify the monster variable in the loop - we'll need to make a copy of the monster, modify that, and then assign it back to the correct position in the `monsters` array. Replace the `// Handle collisions` block we just wrote with the following: + +```swift +// Handle collisions +for i in monsters.indices { + var monster = monsters[i] + if let intersection = player.intersection(with: monster) { + player.position -= intersection + } + monsters[i] = monster +} +``` + +Instead of looping over the monsters directly, we are now looping over the *indices* of the array, which allows us to replace the original monster (that's what the `monsters[i] = monster` line is doing at the end of the loop[[2]](#footnote2). Still in the same block of code, replace the line: + +```swift +player.position -= intersection +``` + +with: + +```swift +player.position -= intersection / 2 +monster.position += intersection / 2 +``` + +Now, when we bump into a monster, it will be pushed back. Because we're also being pushed back, we will move more slowly when pushing, making the interaction feel more realistic. + +Try running the game now and pushing some monsters around. Pretty soon, you'll notice we've introduced a new problem. + +![Monster stuck inside the wall](Images/MonsterInWall.png) + +### They're in the Walls! + +Now that we can push the monsters, we can push them *through walls*. So far, all our collision handling has been player-centric, but now that monsters can move (even if it's not of their own volition), they need their own collision logic. + +We already have a method to detect collisions between the player and the walls, and since the player and monster are both represented by rectangles, we can re-use that collision logic. The problem is that currently the `intersection(with:)` method we need is a member of the `Player` type, and we don't really want to copy and paste it into `Monster`. It's time for a refactor. + +The `Monster` and `Player` types have a bunch of properties and behavior in common, so it makes sense to allow them to share these via a common abstraction. In a traditional object-oriented language like Objective-C or Java, we might have done this by making them inherit from a common superclass, but in our game these are structs rather than classes, so they don't support inheritance. + +We could make them into classes, but this has all sorts of downsides, such as losing the ability to magically add serializion via the `Codable` protocol, or easily adding multithreading due to the data structures all being immutable, so let's not do that. Fortunately, Swift has a nice mechanism for sharing logic between value types, in the form of *protocol extensions*. + +Create a new file in the Engine module called `Actor.swift`, with the following contents: + +```swift +public protocol Actor { + var radius: Double { get } + var position: Vector { get set } +} +``` + +"Actor" is a generic term for entities such as the player, non-player-characters, scenery, or anything else that potentially moves or has agency within the game, as per this description from [Game Coding Complete](https://www.amazon.co.uk/gp/product/1133776574/ref=as_li_tl?ie=UTF8&camp=1634&creative=6738&creativeASIN=1133776574&linkCode=as2&tag=charcoaldesig-21&linkId=5b73f74ee5a9de652e35f8c305266802): + +> A game actor is an object that represents a single entity in your game world. It could be an ammo pickup, a tank, a couch, an NPC, or anything you can think of. In some cases, the world itself might even be an actor. It’s important to define the parameters of game actors and to ensure that they are as flexible and reusable as possible. + +The `Actor` protocol we've defined makes the guarantee that any object conforming to it will have a read-only `radius` property, and a read-write `position`, which is everything we need to implement collision handling. + +In `Player.swift`, replace the following line: + +```swift +public struct Player { +``` + +with: + +```swift +public struct Player: Actor { +``` + +That says that `Player` conforms to the `Actor` protocol. We don't have to do anything else to conform to the protocol because `Player` already has a `radius` and `position` property. + +Next, cut the entire `public extension Player { ... }` block from `Player.swift`, and paste it into the `Actor.swift` file. Then replace all references to `Player` or `Monster` with `Actor`. The result should look like this: + +```swift +public extension Actor { + var rect: Rect { + let halfSize = Vector(x: radius, y: radius) + return Rect(min: position - halfSize, max: position + halfSize) + } + + func intersection(with map: Tilemap) -> Vector? { + let minX = Int(rect.min.x), maxX = Int(rect.max.x) + let minY = Int(rect.min.y), maxY = Int(rect.max.y) + var largestIntersection: Vector? + for y in minY ... maxY { + for x in minX ... maxX where map[x, y].isWall { + let wallRect = Rect( + min: Vector(x: Double(x), y: Double(y)), + max: Vector(x: Double(x + 1), y: Double(y + 1)) + ) + if let intersection = rect.intersection(with: wallRect), + intersection.length > largestIntersection?.length ?? 0 { + largestIntersection = intersection + } + } + } + return largestIntersection + } + + func intersection(with actor: Actor) -> Vector? { + return rect.intersection(with: actor.rect) + } +} +``` + +Now we'll do the same for `Monster`. In `Monster.swift` replace: + +```swift +public struct Monster { +``` + +with: + +```swift +public struct Monster: Actor { +``` + +Then you can delete the `public extension Monster { ... }` block completely, since the computed `rect` property is now inherited from the `Actor` protocol. + +That's the beauty of Swift's protocol extensions. In Objective-C it was possible for classes to conform to a common protocol, but they couldn't inherit behavior from it. In Swift however, we can extend the protocol with functionality that is then available to all types that conform to it. That even works for value types like struct that aren't polymorphic and don't support traditional inheritance. + +Anyway, enough about how awesome Swift is - let's get on with the task at hand. In `World.update()`, add the following code inside the `for` loop, just before the line `monsters[i] = monster`: + +```swift +while let intersection = monster.intersection(with: map) { + monster.position -= intersection +} +``` + +This applies the same wall collision logic we used for `Player` to every `Monster` in the map. Collision with walls is handled *after* collisions with the player, so that it overrides previous collision response handling - the monster may end up being pushed back into the player, but they shouldn't get pushed through a wall. + +If you run the game again though, you'll see that's not quite the case. + +![Monster sprite slightly intersecting the wall](Images/MonsterInWall2.png) + +It's no longer possible to push the monster right through the wall as before, but they still seem to be able to get stuck a little way into it - why is that? + +In short, it's because we've used the wrong *radius*. We set the monster's radius to 0.25, meaning that it occupies half a tile's width. But the sprite graphic we are using for the monster is 14 pixels wide inside a 16-pixel texture. Since the texture is one tile wide, that means the monster is actually 14/16ths of a tile wide - equivalent to 0.875 tiles. + +Actual radius of sprite image relative to collision rectangle + +If the collision rectangle we use for the monsters is smaller than the sprite, the sprite will clip into the walls when the monster bumps up agains them. If we don't want that to happen, the correct radius to use for the monsters is 0.4375 (half of 0.875). In `Monster.swift`, change the line: + +```swift +public let radius: Double = 0.25 +``` + +to: + +```swift +public let radius: Double = 0.4375 +``` + +The monsters should no longer intersect the walls, however much you push them. But what about *other monsters*? It shouldn't be possible to push one monster right through another one, but right now there's nothing to prevent that. The collision handling should be able to prevent any monster from intersecting another. + +In order to implement monster-monster collisions we'll need to do a pairwise collision test between each monster and every other monster. For that we'll need to add a second loop through all of the monsters, *inside* the first loop. + +Back inside the `World.update()` method, find the line: + +```swift +while let intersection = monster.intersection(with: map) { +``` + +Just above that line, add the following: + +```swift +for j in monsters.indices where i != j { + if let intersection = monster.intersection(with: monsters[j]) { + monster.position -= intersection / 2 + monsters[j].position += intersection / 2 + } +} +``` + +This inner loop[[3]](#footnote3) runs through the monsters again, checking for an intersection between the current monster from the outer loop (`monsters[i]`) and the current monster in the inner loop (`monsters[j]`). The `where i != j` ensures we don't check if a monster is colliding with itself (which would always be true, leading to some interesting bugs). + +If you're familiar with [Big O notation](https://en.wikipedia.org/wiki/Big_O_notation) for describing algorithmic complexity, this algorithm has a a *Big O* of O(n2), where *n* is the total number of monsters. + +An algorithm with O(n2) complexity slows down rapidly with the number of elements, and is generally considered a *bad thing*. Modern physics engines use a variety of tricks to cut down the number of collision tests they need to perform, such as subdividing objects into buckets and only checking for collisions between objects in the same or neighboring buckets. + +With such a small map and so few monsters, n2 will never get large enough to be a problem, so we won't worry about it for now. There is, however, a trivial optimization we can make which will halve the work we are doing. + +We're currently comparing every pair of monsters twice because we always compare `monsters[i]` with `monsters[j]` for every value of `i` and `j`. But if we've already compared `monsters[1]` with `monsters[2]` we don't *also* need to compare `monsters[2]` with `monsters[1]` because they're equivalent. + +So it follows that for the inner `for` loop, we only need to loop over monsters that we have not *already* covered in the outer loop, so instead of looping through every index apart from `i`, we can just loop through every index *after* `i`. In `World.update()` replace the line: + +```swift +for j in monsters.indices where i != j { +``` + +with: + +```swift +for j in i + 1 ..< monsters.count { +``` + +That should take care of monster-monster collisions. Run the app again and you should find that you now can't push monsters through walls *or* each other. + +The `// Handle collisions` code is maybe not the most beautiful we've written, but I'll leave refactoring it as an exercise for the reader because we have more interesting fish to fry. + +### Enemy State + +The monsters have had enough of being pushed around - it's time they got their revenge. The only problem is, they don't actually have the power of independent thought. We need to give them some *artificial intelligence*. + +There's an old joke in tech circles that when companies talk about their new software using "advanced AI", they probably mean it has a giant bunch of `if`/`else` statements. And it's funny because it's true - if you are trying to build a program that can "reason" about a situation and decide on a course of action, the simplest approach in most cases is to figure out all of the possible scenarios and then build a big `if` or `switch` statement with the appropriate responses to each of them. + +The AI for non-player characters (including enemies) in a game can be built around a [state machine](https://en.wikipedia.org/wiki/Finite-state_machine). At any given point in time, the character is in a particular state. While in that state they have a certain behavior. Certain events or *triggers* will cause them to transition to a different state, with different behavior. + +In Swift, the state machine can be implemented as a `switch` over an enum, with a bunch of `if`/`else` cases for handling state transitions. For now, we'll just define two states for the monsters - *idle* and *chasing*. + +Each monster will start out in the *idle* state. When they see the player they will switch to the *chasing* state. In the chasing state, the monster will pursue the player until it can't see them anymore, at which point it will revert back to idle. + +State machine diagram for the monster's AI + +In `Monster.swift`, add the following code to the top of the file: + +```swift +public enum MonsterState { + case idle + case chasing +} +``` + +Then add a `state` property to the `Monster` itself: + +```swift +public struct Monster: Actor { + ... + public var state: MonsterState = .idle + + ... +} +``` + +In `World.update()`, insert the following block of code above the `// Handle collisions` section: + +```swift +// Update monsters +for i in 0 ..< monsters.count { + var monster = monsters[i] + monster.update(in: self) + monsters[i] = monster +} +``` + +Then, back in `Monster.swift` add the following block of code to the bottom of the file: + +```swift +public extension Monster { + mutating func update(in world: World) { + switch state { + case .idle: + + case .chasing: + + } + } +} +``` + +Here we have the outline for the AI routine - now we need to write the implementations for the two states. In `idle` mode, all the monster needs to do is wait until it sees the player. That means we need a method to detect if the monster can see the player. + +Since the monsters don't currently have a direction (they always face the player), we don't need to worry about their field of view. The only reason a monster *wouldn't* be able to see the player is if there was a wall between them. + +Still in `Monster.swift` add a `canSeePlayer()` method: + +```swift +public extension Monster { + ... + + func canSeePlayer(in world: World) -> Bool { + + } +} +``` + +The first thing we'll need to do is create a `Ray` from the monster to the `Player`. We've done this a few times now, so the code should require no explanation. Add the following lines to the start of the `canSeePlayer()` method: + +```swift +let direction = world.player.position - position +let playerDistance = direction.length +let ray = Ray(origin: position, direction: direction / playerDistance) +``` + +We need to check if the view of the player is obstructed by a wall. We already have a way to check if a ray hits a wall, using the `Tilemap.hitTest()` method we wrote in [Part 3](Part3.md). Add the following line to the `canSeePlayer()` method: + +```swift +let wallHit = world.map.hitTest(ray) +``` + +Now we have the point at which the ray intersects the map, we just need to check if the distance at which that occurs is closer or farther than the player. Add the following lines to complete the `canSeePlayer()` method: + +```swift +let wallDistance = (wallHit - position).length +return wallDistance > playerDistance +``` + +Using that method, we can now implement the monster's AI. Replace the empty switch cases in `Monster.update()` with the following: + +```swift +switch state { +case .idle: + if canSeePlayer(in: world) { + state = .chasing + } +case .chasing: + guard canSeePlayer(in: world) else { + state = .idle + break + } +} +``` + +So now if the monster sees the player it will enter the `chasing` state, and if it it can't see the player anymore it will return to the `idle` state. Just one last thing to do - we need to make it actually chase the player! + +### The Chase is On + +The `Player` has a `speed` property to control their maximum speed. Let's add one to `Monster` too: + +```swift +public struct Monster: Actor { + public let speed: Double = 0.5 + ... +} +``` + +The player has a maximum speed of `2`, but we've set the monster's maximum speed to `0.5`. They're supposed to be zombies, so we don't really want them sprinting around. We may as well add a `velocity` property too while we're here: + +```swift +public struct Monster: Actor { + ... + public var position: Vector + public var velocity: Vector = Vector(x: 0, y: 0) + public var state: MonsterState = .idle + + ... +} +``` + +In the `Monster.update()` method, add the following code inside `case .idle:`, after the `if` statement: + +```swift +velocity = Vector(x: 0, y: 0) +``` + +Then, inside `case .chasing:`, after the `guard` statement, add the following: + +```swift +let direction = world.player.position - position +velocity = direction * (speed / direction.length) +``` + +Finally, back in `World.upate()` in the `// Update monsters` section, just before the line `monsters[i] = monster`, add the following: + +```swift +monster.position += monster.velocity * timeStep +``` + +With these additions, each monster will move towards the player whenever it can see them. Run the game again to check everything is working as expected (prepare to be mobbed!) + +![Monsters crowding the player](Images/MonsterMob.png) + +### An Animated Performance + +It's pretty eery seeing the monsters float towards you like ghosts, but they are supposed to be zombies and zombies *stagger*, they don't float. It would help to improve the realism if the monsters had some animation. + +Start by adding some walking frames for the monster. + +Walking animation frames + +The walking animation has four frames, but two of them are the same as the standing image we already have. Feel free to use as many or as few frames as needed for your own animation (you can also just use [these ones](https://github.com/nicklockwood/RetroRampage/tree/Part6/Source/Rampage/Assets.xcassets/) if you want). + +Add the images for the walking animation to XCAssets, then in `Textures.swift` extend the `Texture` enum with these two additional cases, matching the names of the image assets: + +```swift +public enum Texture: String, CaseIterable { + ... + case monster + case monsterWalk1, monsterWalk2 +} +``` + +We'll need a new type to represent the animation itself. In the Engine module, create a new file called `Animation.swift` with the following contents: + +```swift +public struct Animation { + public let frames: [Texture] + public let duration: Double + + public init(frames: [Texture], duration: Double) { + self.frames = frames + self.duration = duration + } +} +``` + +Then in `Monster.swift` add an `animation` property to `Monster`: + +```swift +public struct Monster: Actor { + ... + public var state: MonsterState = .idle + public var animation: Animation = .monsterIdle + + ... +} +``` + +It may seem like the monster only has one animation, but it really has two - it's just that the idle/standing animation only has a single frame. Still in `Monster.swift`, add the following code to the bottom of the file: + +```swift +public extension Animation { + static let monsterIdle = Animation(frames: [ + .monster + ], duration: 0) + static let monsterWalk = Animation(frames: [ + .monsterWalk1, + .monster, + .monsterWalk2, + .monster + ], duration: 0.5) +} +``` + +We've added the animations as static constants on the `Animation` type because it means we can conveniently reference them using dot syntax, thanks to Swift's type inference. But since these animations are specific to the monster sprite, it makes sense to keep them together in with the other `Monster`-specific code rather than in the `Animation.swift` file. + +Now, in the `Monster.update()` method, modify the state machine again to swap the animations: + +```swift +switch monster.state { +case .idle: + if monster.canSeePlayer(in: self) { + state = .chasing + animation = .monsterWalk + } +case .chasing: + guard monster.canSeePlayer(in: self) else { + state = .idle + animation = .monsterIdle + break + } + ... +} +``` + +That's the data model side of animations taken care of, but what about the *rendering* side? Right now the renderer is just hard-coded to show the monster's standing image for each sprite. The renderer is going to need to know which animation frame to draw. + +Open `Billboard.swift` and add a `texture` property and initializer argument: + +```swift +public struct Billboard { + public var start: Vector + public var direction: Vector + public var length: Double + public var texture: Texture + + public init(start: Vector, direction: Vector, length: Double, texture: Texture) { + self.start = start + self.direction = direction + self.length = length + self.texture = texture + } +} +``` + +Then, in `Renderer.draw()`, in the `// Draw sprites` section, replace the line: + +```swift +let spriteTexture = textures[.monster] +``` + +with: + +```swift +let spriteTexture = textures[sprite.texture] +``` + +It's now up to the `World` to supply the correct frame texture for each sprite billboard at a given point in time, but which frame should it select? We need to introduce a concept of the *current frame* for an animation. + +Back in `Animation.swift`, add a `time` property to the `Animation` struct: + +```swift +public struct Animation { + public let frames: [Texture] + public let duration: Double + public var time: Double = 0 + + ... +} +``` + +The `time` defaults to zero (the start of the animation), but it's a `var`, so we can advance the time of any given `Animation` instance in order to scrub through the frames. Still in `Animation.swift`, add the following extension method: + +```swift +public extension Animation { + var texture: Texture { + guard duration > 0 else { + return frames[0] + } + let t = time.truncatingRemainder(dividingBy: duration) / duration + return frames[Int(Double(frames.count) * t)] + } +} +``` + +This fetches the current frame texture for `time`. It calculates this by dividing `time` by the animation's `duration`, and then multiplying by the `frames` count. The `truncatingRemainder(dividingBy: duration)` means that if `time` is greater than `duration`, the animation will loop back around. + +In `World.swift`. update the computed `sprites` var, replacing the lines: + +```swift +Billboard( + start: monster.position - spritePlane / 2, + direction: spritePlane, + length: 1 +) +``` + +with: + +```swift +Billboard( + start: monster.position - spritePlane / 2, + direction: spritePlane, + length: 1, + texture: monster.animation.texture +) +``` + +Each sprite billboard will now include the current animation frame for that monster as its texture. All that's left now is to actually advance the animation times as the game is playing. Still in `World.swift`, in the `// Update monsters` block inside the `update()` method, add the following line just before `monsters[i] = monster`: + +```swift +monster.animation.time += timeStep +``` + +And that's it! Run the game now and you'll see the monsters striding towards you menacingly (it's actually a pretty creepy). + +### Space Invaders + +As terrifying as it is to be crowded by a bunch of zombies, it's a bit anticlimactic if all they do is try to stand uncomfortably close to you. Let's add a new state to the monster AI. First, we'll need a new animation: + +Attack animation frames + +Add the images to XCAssets, then add the new cases to the `Texture` enum in `Textures.swift`: + +```swift +public enum Texture: String, CaseIterable { + ... + case monsterScratch1, monsterScratch2, monsterScratch3, monsterScratch4 + case monsterScratch5, monsterScratch6, monsterScratch7, monsterScratch8 +} +``` + +Next, at the bottom of the `Monster.swift` file, add a new static property for the `monsterScratch` animation: + +```swift +public extension Animation { + ... + + static let monsterScratch = Animation(frames: [ + .monsterScratch1, + .monsterScratch2, + .monsterScratch3, + .monsterScratch4, + .monsterScratch5, + .monsterScratch6, + .monsterScratch7, + .monsterScratch8, + ], duration: 0.8) +} +``` + +That's the animation taken care of - now we need to upgrade the monster's AI. At the top of `Monster.swift`, add a `scratching` case to the `MonsterState` enum: + +```swift +public enum MonsterState { + case idle + case chasing + case scratching +} +``` + +The monster can only scratch the player if they're in range, so we'll need some logic to determine that. This doesn't need to be anything fancy, we can just check if the distance between the player and monster is below a certain threshold. + +Still in `Monster.swift` add the following method just below the `canSeePlayer()` method we added earlier: + +```swift +func canReachPlayer(in world: World) -> Bool { + let reach = 0.25 + let playerDistance = (world.player.position - position).length + return playerDistance - radius - world.player.radius < reach +} +``` + +This computes the distance from the monster to the player (we don't care about the direction) and then compares it with a `reach` constant that determines how far the monster can reach out when attacking. + +We subtract the player and monster radii from the `playerDistance` before comparing with `reach`. The player and monster cannot actually stand in exactly the same spot because of collision handling, so `reach` is measured relative to their minimum separation distance, which is the sum of their radii. + +In `Monster.update()`, inside `case .chasing:`, find the following block of code: + +```swift +guard canSeePlayer(in: world) else { + state = .idle + animation = .monsterIdle + break +} +``` + +Just below it, add the following: + +```switch +if canReachPlayer(in: world) { + state = .scratching + animation = .monsterScratch +} +``` + +Finally, add this extra case to the end of the `switch` block to complete the monster attack logic: + +```swift +case .scratching: + guard canReachPlayer(in: world) else { + state = .chasing + animation = .monsterWalk + break + } +``` + +The monsters can now both chase and attack the player (albeit without actually doing any damage). Run the game again, but prepare for a scare! + +![Monsters attacking](Images/MonstersAttacking.png) + +That's it for Part 6. In this part we: + +* Added collision handling for the monsters +* Extracted common code between the `Player` and `Monster` classes +* Created a state machine to act as the monster's brain +* Gave the monsters the ability to chase and attack the player +* Added walking and attack animations for the monsters + +In [Part 7](Part7.md) we'll raise the stakes a bit by giving the monsters the ability to hurt (and eventually kill) the player. + +
+ +[[1]](#reference1) Beauty is in the eye of the beholder, OK? + +[[2]](#reference2) The Swift experts among you will probably be turning up your noses at this unapologetically imperative code and wondering why I don't just use `Array.map()` like a civilized person. This will be explained, so try to contain your disgust for now and read on. + +[[3]](#reference3) The inner loop is why I didn't use `map()`. There may still be a way to solve this using functional programming, but I didn't want to write it and I *definitely* didn't want to have to explain it. diff --git a/Tutorial/Part7.md b/Tutorial/Part7.md new file mode 100644 index 0000000..43d9d90 --- /dev/null +++ b/Tutorial/Part7.md @@ -0,0 +1,846 @@ +## Part 7: Death and Pixels + +In [Part 6](Part6.md) we added animations to the monsters inhabiting the maze, and gave them rudimentary intelligence so they could hunt and attack the player. The complete code for Part 6 can be found [here](https://github.com/nicklockwood/RetroRampage/archive/Part6.zip). + +Although the monsters appear to attack, their blows don't actually have any effect on the player. Time to fix that. + +### Healthy Living + +In order for the player to be hurt (and eventually die), they need to have some sort of health meter. In `Player.swift`, add a `health` property to the `Player` and in the initializer set its starting value to `100`: + +```swift +public struct Player: Actor { + public let speed: Double = 2 + public let turningSpeed: Double = .pi + public let radius: Double = 0.25 + public var position: Vector + public var velocity: Vector + public var direction: Vector + public var health: Double + + public init(position: Vector) { + self.position = position + self.velocity = Vector(x: 0, y: 0) + self.direction = Vector(x: 1, y: 0) + self.health = 100 + } +} +``` + +In the same file, add the following convenience extension: + +```swift +public extension Player { + var isDead: Bool { + return health <= 0 + } +} +``` + +### Hurt Me Plenty + +Now that the player has health, the monster needs a way to deplete it. + +Changes to the player are currently handled inline within the `World.update()` method. This is a *mutating* method, so it has the ability to make changes to the world and its contents. + +Monster behavior is delegated out to the `Monster.update()` method. This method is also marked as mutating - which means that it can make changes to the monster itself - but the `world` parameter it receives is immutable, which means it cannot make changes to the world. + +In order for the monster to be able to hurt the player, we need some way for its behavior to affect objects outside of itself. We could just inline the monster update logic inside `World.update()`, but that method is already doing more than it should so we don't really want to overload it any further. + +Instead, we can pass the world to `Monster.update()` as an `inout` parameter, thereby making it mutable. In `Monster.swift`, replace the line: + +```swift +mutating func update(in world: World) { +``` + +with: + +```swift +mutating func update(in world: inout World) { +``` + +Then, in `World.update()`, in the `// Update monsters` section, change the line: + +```swift +monster.update(in: self) +``` + +to: + +```swift +monster.update(in: &self) +``` + +This means that we are passing the world *by reference*, giving `Monster.update()` the ability to make changes to the world, along with anything in it (such as the player). + +We don't really want the monster to just manipulate the player's health directly though, because harming the player will potentially have side-effects in the game logic that the monster shouldn't need to know about. + +Let's lock the interface down a bit so that we don't accidentally break encapsulation. Find the following property declarations at the top of the `World` struct: + +```swift +public var monsters: [Monster] +public var player: Player! +``` + +And change them to: + +```swift +public private(set) var monsters: [Monster] +public private(set) var player: Player! +``` + +Then, still in `World.swift`, add the following method to the bottom of the extension: + +```swift +mutating func hurtPlayer(_ damage: Double) { + player.health -= damage +} +``` + +That gives the monster a way to hurt the player in a structured way without directly manipulating properties. Next, in `Monster.update()`, add the following code to the end of the `case .scratching:` block, just after the guard statement: + +```swift +world.hurtPlayer(10) +``` + +### Game Over Man, Game Over! + +I mentioned earlier that reducing player health might have side-effects. One such side-effect is that if the player's health drops to zero, the game should end. This is a change that affects the entire world, which is why `hurtPlayer()` is a method of the world itself and not the `Player` type. + +Eventually, the player dying will have effects that extend even beyond the game world, such as displaying a "Game Over" screen, and presenting a menu for the player to quit or try again, etc. We haven't built any of the infrastructure for that yet, so for now we'll just reset the world to its initial conditions. + +The world is currently created by `ViewController` (in the platform layer) which might seem like it would cause a headache if we need to trigger re-initialization from *inside* the world itself. Fortunately, the initial conditions for the world are all encoded within the `TileMap`, which is never actually modified. + +Since the world already contains all the information needed to reset itself, we can go ahead and add a method to do that. Near the top of `World.swift`, find the `init()` method and copy the following lines: + +```swift +self.monsters = [] +for y in 0 ..< map.height { + for x in 0 ..< map.width { + let position = Vector(x: Double(x) + 0.5, y: Double(y) + 0.5) + let thing = map.things[y * map.width + x] + switch thing { + case .nothing: + break + case .player: + self.player = Player(position: position) + case .monster: + monsters.append(Monster(position: position)) + } + } +} +``` + +Create a new mutating method in the extension block called `reset()` and paste the copied code into it: + +```swift +mutating func reset() { + ... +} +``` + +Then, still in `World.swift`, replace the `init()` implementation with the following: + +```swift +public init(map: Tilemap) { + self.map = map + self.monsters = [] + reset() +} +``` + +That gives us the means to reinitialize the world in its starting state when the player dies. Find the `World.hurtPlayer()` function we created earlier and append the following code inside it: + +```swift +if player.isDead { + reset() +} +``` + +Now go ahead and run the game. + +*Hmm, that's odd...* as soon the the monster touches the player, everything starts flickering and the player can't move. What's going on? + +There are actually *two* problems here. The first is that the game is being reset every time the monster touches the player. The second is that the monster itself is unaffected by the reset, and continues to attack the player immediately after player re-spawns. + +### One Punch Man + +The monster only does 10 damage per swipe, so how come the player dies instantly? Let's look at the monster attack logic again: + +```swift +case .scratching: + guard canReachPlayer(in: world) else { + state = .chasing + animation = .monsterWalk + break + } + world.hurtPlayer(10) +} +``` + +Here is the problem - as long as the monster is in `scratching` mode and in range of the player, `hurtPlayer()` will be called on every update. Since `update()` is called ~120 times per second, it's no wonder that the player drops dead almost instantly. + +We will need to keep track of when the monster last struck the player, and only apply damage again if a minimum time has passed - we'll call this the *cooldown* period. + +The scratching animation is 0.8 seconds long, and the monster swipes at the player twice during each cycle of the attack animation, so a cooldown period of 0.4 seconds would make sense. + +In `Monster.swift`, add the following two properties to the `Monster` struct: + +```swift +public let attackCooldown: Double = 0.4 +public private(set) var lastAttackTime: Double = 0 +``` + +Then in `Monster.update()`, in the `case .scratching:` block, replace the line: + +```swift +world.hurtPlayer(10) +``` + +with: + +```swift +if animation.time - lastAttackTime >= attackCooldown { + lastAttackTime = animation.time + world.hurtPlayer(10) +} +``` + +Then, just above in the `case .chasing:` block, find the code: + +```swift +if canReachPlayer(in: world) { + state = .scratching + animation = .monsterScratch +} +``` + +And add the following line inside the `if` block: + +```swift +lastAttackTime = -attackCooldown +``` + +We set the `lastAttackTime` to `-attackCooldown` when the `scratching` state begins to ensure that the `animation.time - lastAttackTime >= attackCooldown` condition is initially true, otherwise the monster's first swipe wouldn't do any damage. + +If you run the game again, you should find that the player can take a few punches before the game resets. Now for the second problem - why isn't the monster being reset with the rest of the world? + +### Left Behind + +Take a look at the `// Update monsters` loop in the `World.update()` method: + +```swift +for i in 0 ..< monsters.count { + var monster = monsters[i] + monster.update(in: &self) + monster.position += monster.velocity * timeStep + monster.animation.time += timeStep + monsters[i] = monster +} +``` + +Inside the loop, we copy the monster at the current index into a local variable, then call its `update()` method, then modify its `position` and `animation` before assigning it back to the `monsters` array. + +If the player is killed during `Monster.update()` the world will be reset immediately, but the local `monster` var inside the `// Update monsters` loop will be unaffected by the reset, and will then be written back into the `monsters` array. That's why the monster is still in the same position after the reset occurs. + +Maybe it's not such a good idea for the `hurtPlayer()` method to reset the world immediately? Remove the following lines from the `World.hurtPlayer()` method: + +```swift +if player.isDead { + reset() +} +``` + +Then, at the very top of the `World.update()` method, add the following: + +```swift +// Update player +if player.isDead { + reset() + return +} +``` + +In this way, the reset is deferred until the next update cycle after the player is killed, and performed before any other updates so it can't introduce inconsistencies in the world model. + +Run the game again and you should find that it now resets correctly when the player dies. + +### Cause and Effect + +The visual impact of being hacked to death by a ravenous zombie is currently rather underwhelming. Eventually we'll add an on-screen health meter, but in the meantime it would be good to at least have *some* visual indication of player damage. + +Modern games use a variety of effects to indicate the player being hurt - camera shake, blood spatter on the screen, blurred vision, etc. Wolfenstein 3D used a brief red flash of the screen, and so will we. + +Create a new file in the Engine module called `Effect.swift` with the following contents: + +```swift +public enum EffectType { + case fadeIn +} + +public struct Effect { + public let type: EffectType + public let color: Color + public let duration: Double + public var time: Double = 0 + + public init(type: EffectType, color: Color, duration: Double) { + self.type = type + self.color = color + self.duration = duration + } +} +``` + +The `Effect` type is quite similar to the `Animation` type we added in [Part 6](Part6.md), but instead of an array of image frames it has a `type` and `color`. Just below the `Effect` struct, add the following extension: + +```swift +public extension Effect { + var isCompleted: Bool { + return time >= duration + } + + var progress: Double { + return min(1, time / duration) + } +} +``` + +These convenience properties allow us to easily get the *progress* of the effect (a normalized representation of how far it is from completion) and whether it has already completed. + +Effects will span multiple frames, so we'll need to store them somewhere. It's debatable whether they really *exist* as physical objects within the world, but it seems like the logical place to put them for now. In `World.swift`, add an `effects` property to the `World` struct, and initialize it inside `World.init()`: + +```swift +public struct World { + public let map: Tilemap + public private(set) var monsters: [Monster] + public private(set) var player: Player! + public private(set) var effects: [Effect] + + public init(map: Tilemap) { + self.map = map + self.monsters = [] + self.effects = [] + reset() + } +} +``` + +Like other objects in the world, effects will need to be updated over time. It seems like a good idea to update effects first, before any other updates because we don't want events like a world reset (which returns from the `update()` method early) to cause any ongoing effects to skip frames. + +Insert the following code at the very top of the `World.update()` method, above the `// Update player` section: + +```swift +// Update effects +effects = effects.map { effect in + var effect = effect + effect.time += timeStep + return effect +} +``` + +This code is a little different from the other update loops. As with animations, we're incrementing the elapsed `time` property of each effect, but this time we're using `map()` to replace the original `effects` array, instead of a `for` loop. + +Functional programming is cool and all, but *why now*? + +Well, there's something else we need to do in this loop in addition to updating the effects - once an effect is complete we want to remove it from the array. That's awkward and error-prone to do with a traditional `for` loop because removing an item messes up the index (unless you resort to tricks like iterating the loop backwards). + +But with Swift's functional programming extensions, we can do this very cleanly using `compactMap()`. The `compactMap()` function works like a combination of `map()` (to replace the values in a collection) and `filter()` (to conditionally remove them). In the `// Update effects` block we just added, replace the line: + +```swift +effects = effects.map { effect in +``` + +with: + +```swift +effects = effects.compactMap { effect in + if effect.isCompleted { + return nil + } +``` + +So now we return `nil` for any effect that has completed, thereby removing it from the `effects` array. This check is performed *before* incrementing the time, so that the final state of the effect is displayed at least once before it is removed[[1]](#footnote1). + +The red flash effect should be triggered whenever the player is hurt. Still in `World.swift`, add the following line to the `hurtPlayer()` method: + +```swift +effects.append(Effect(type: .fadeIn, color: .red, duration: 0.2)) +``` + +Finally, we need to actually draw the effect. + +Wolfenstein implemented the red screen flash using a trick called [color cycling](https://en.wikipedia.org/wiki/Color_cycling) (also known as *palette animation*), made possible by the game's use of an indexed color palette. It would have been expensive to tint every pixel on-screen, and also impractical to craft a palette that had multiple red-shifted shades of each color, but color cycling neatly solves both of these problems. + +The palette introduces a layer of indirection between the 8-bit color indexes drawn into the output buffer and the colors that actually appeared on screen. By gradually red-shifting all the colors in the palette over time, the image drawn on the screen automatically tinted to red without the game needing to actually needing to redraw anything. And shifting the palette only required changing the 256 values in the palette itself, not the 64,000 pixels on screen[[2]](#footnote2). + +Pretty clever, right? Unfortunately, since we're using 32-bit "true" color instead of an indexed palette, we can't use the same trick[[3]](#footnote3). *Fortunately*, computers are immensely fast now so we can just use a brute-force approach and blend every pixel of the output bitmap. + +In `Renderer.swift` add the following code to the bottom of the `draw()` function (be careful to put it *outside* the `for` loop, as the nested closing braces can be confusing): + +```swift +// Effects +for effect in world.effects { + let color = effect.color + for y in 0 ..< bitmap.height { + for x in 0 ..< bitmap.width { + bitmap.blendPixel(at: x, y, with: color) + } + } +} +``` + +Run the game and you should see the screen blink red every time the monster swipes at the player. The effect isn't very pretty yet though - we need to add the fading effect. In the `// Effects` code we just added, replace the line: + +```swift +let color = effect.color +``` + +with: + +```swift +var color = effect.color +color.a = UInt8((1 - effect.progress) * 255) +``` + +If you recall from earlier, the `progress` property is the normalized effect time (in the range 0 - 1). We can use that to calculate the `a` (alpha) value for the color by subtracting it from 1 and multiplying by 255, so that the value falls from 255 to 0 over the duration of the effect. + +Run the game and... *crash*. Oh. + +Crash inside blendColor() function + +The game crashed inside the `blendPixel()` function. A bit of investigation reveals that the cause of the crash is that we overflowed the range of `UInt8` - we tried to put a number bigger than 255 into it. How did that happen? + +If you recall, in [Part 5](Part5.md), when we originally added the `blendPixel()` method, we designed it to work with *premultiplied alpha* values, because it's better for performance. Premultiplied alpha is common when dealing with images, but it's a bit of a weird requirement for a public API. + +We can fix the crash by manually premultiplying the color components by the alpha value. In `Renderer.draw()`, inside the `// Effects` update loop, replace the lines: + +```swift +var color = effect.color +color.a = UInt8((1 - effect.progress) * 255) +``` + +with: + +```swift +let opacity = 1 - effect.progress +let color = Color( + r: UInt8(opacity * Double(effect.color.r)), + g: UInt8(opacity * Double(effect.color.g)), + b: UInt8(opacity * Double(effect.color.b)), + a: UInt8(opacity * 255) +) +``` + +Now run the game again, and you should see the fading effect working correctly. + +![Screen flashing red as player is hurt](Images/RedFlashEffect.png) + +### It's a Trap + +We've fixed the bug, but it seems a bit dangerous to have such a sharp edge exposed in the `Bitmap` API. The behavior of `blendPixel()` is pretty unintuitive. There are three possible ways I can think of to improve it: + +* Change the name to more accurately reflect its purpose +* Add bounds checking so it won't crash for out-of-range values +* Make it private + +This is a performance-critical method (it's called at least once for every pixel on screen), so adding bounds checking is probably not desirable. It's also not clear that would be the right solution anyway since a bounds error here is indicative of a programming mistake - it should't happen with valid input. + +For now, we'll just make the `blendPixel()` method private. In `Bitmap.swift`, replace the line: + +```swift +mutating func blendPixel(at x: Int, _ y: Int, with newColor: Color) { +``` + +with: + +```swift +private mutating func blendPixel(at x: Int, _ y: Int, with newColor: Color) { +``` + +If the method is private, we can't call it from outside of `Bitmap`, but applying a translucent tint seems like a general enough operation that we can just make a method for it on `Bitmap` itself. + +Still in `Bitmap.swift`, add the following method to the bottom of the extension: + +```swift +mutating func tint(with color: Color, opacity: Double) { + let color = Color( + r: UInt8(opacity * Double(color.r)), + g: UInt8(opacity * Double(color.g)), + b: UInt8(opacity * Double(color.b)), + a: UInt8(opacity * 255) + ) + for y in 0 ..< height { + for x in 0 ..< width { + blendPixel(at: x, y, with: color) + } + } +} +``` + +This is pretty much the same logic we used in the effects loop. One extra enhancement we can make while we're here though is to take the original alpha value from the `color` into account, so that the maximum opacity of the overlay can be adjusted by changing the alpha of the effect color. + +Insert the following line at the beginning of the `Bitmap.tint()` function we just added: + +```swift +let opacity = min(1, max(0, Double(color.a) / 255 * opacity)) +``` + +Now, back in `Renderer.swift`, replace the whole `// Effects` block at the end of the `draw()` function with the following code: + +```swift +// Effects +for effect in world.effects { + bitmap.tint(with: effect.color, opacity: 1 - effect.progress) +} +``` + +As you can see, this is a much nicer (and safer) API to work with. + +We mentioned above that the new `tint()` method supports using a partially transparent base color. In `World.hurtPlayer()`, replace the line: + +```swift +effects.append(Effect(type: .fadeIn, color: .red, duration: 0.2)) +``` + +with: + +```swift +let color = Color(r: 255, g: 0, b: 0, a: 191) +effects.append(Effect(type: .fadeIn, color: color, duration: 0.2)) +``` + +An alpha value of `191` is equivalent to 75% opacity (255 * 0.75), so now the flash of red when the monster strikes will start at 75% opacity instead of 100%. This isn't just a cosmetic improvement - it also means the red flash won't completely obscure your vision if you are attacked repeatedly. + +### Fade to Black Red + +The red flash effect works well for player damage, but the re-spawn when the player eventually dies is much too sudden. We need a longer, more pronounced effect for the player's death to give the them time to react to what has happened, and prepare for the next attempt. + +Instead of a short fade *from* red, when the player dies let's show a longer fade *to* red. Since this is basically just the same effect in reverse, we can re-use a lot of the logic we've already written. + +In `Effect.swift`, add a `fadeOut` case to the `EffectType` enum: + +```swift +public enum EffectType { + case fadeIn + case fadeOut +} +``` + +Then in `Renderer.draw()`, modify the `// Effects` block again as follows: + +```swift +// Effects +for effect in world.effects { + switch effect.type { + case .fadeIn: + bitmap.tint(with: effect.color, opacity: 1 - effect.progress) + case .fadeOut: + bitmap.tint(with: effect.color, opacity: effect.progress) + } +} +``` + +As you can see, the code for `fadeOut` is nearly identical to the original `fadeIn` effect, except that we are no longer subtracting the `progress` value from 1, so the opacity goes up from 0 to 1 instead of down from 1 to 0. + +In `World.swift`, append the following code to the `hurtPlayer()` method: + +```swift +if player.isDead { + effects.append(Effect(type: .fadeOut, color: .red, duration: 2)) +} +``` + +This triggers a two-second fade to red. If you run the game again, you'll find this doesn't quite do what we wanted however. The fade works, but the game still resets instantly when the player dies, so the fade begins much too late. + +We need to defer the game reset until after the effect has completed. Let's take a look at the code responsible for resetting the game, which is located near the top of the `World.update()` method: + +```swift +// Update player +if player.isDead { + reset() + return +} +``` + +In this block, replace the line: + +```swift +if player.isDead { +``` + +with: + +```swift +if player.isDead, effects.isEmpty { +``` + +Then try running the game again. When the player dies, the game now keeps running until the fade is complete. But now the fade never goes away - what's going on? + +### Rest in Peace + +Because the game now keeps running after the player is dead, the monster is going to keep hitting the player, causing more and more fade effects to trigger. Because of this, the `effects` array is never empty, so the game never actually resets. + +We could modify the monster AI so that it stops attacking the player once they're dead, but seeing the zombies continue to hack away at your corpse as you pass away is kind of cool[[4]](#footnote4), so let's solve it a different way. Add the following code to the top of the `World.hurtPlayer()` method: + +```swift +if player.isDead { + return +} +``` + +Now, once the player is dead, the monsters can't hurt them anymore (even though they'll keep trying). That means no further effects will be spawned, and the reset should happen on schedule. + +The last slightly weird thing about the way the death sequence works currently is that the player can keep moving after death. We need to keep the update loop running if we want the effects to keep animating and the monsters to keep attacking, but we don't necessarily need to keep accepting player input. + +To prevent the player wandering around after death (there are enough zombies in this game already), in `World.update()`, rearrange the `// Update player` code section as follows: + +```swift +// Update player +if player.isDead == false { + player.direction = player.direction.rotated(by: input.rotation) + player.velocity = player.direction * input.speed * player.speed + player.position += player.velocity * timeStep +} else if effects.isEmpty { + reset() + return +} +``` + +Now the player can finally die with bit of dignity. With, um, zombies hacking at their lifeless corpse. + +### Easy Does It + +The game-over fade looks pretty good, but it ends rather abruptly. + +We can mitigate this by adding a short fade-in after the reset. Still in `World.update()`, in the `// Update player` section, add the following just after the `reset()` line (or immediately before it - it doesn't really matter since effects aren't cleared by the reset): + +```swift +effects.append(Effect(type: .fadeIn, color: .red, duration: 0.5)) +``` + +You can play with the duration value a bit [[5]](#footnote5), but it still doesn't completely fix the problem. The issue is that the human eye is quite sensitive to discontinuities in animation timing. This is much more apparent with moving objects, but it also applies to fading effects. + +If we plot a graph of the change in opacity of the fade effect over time, we get the following: + +Timeline of the death fade effect + +There is a sharp discontinuity in the middle where we transition from the `fadeOut` to `fadeIn` effects, which is why it doesn't quite feel right. We can smooth it out a bit by using a technique called *easing*. + +Easing (also known as *tweening*) is a (typically non-linear) mapping of time to an animated property such as position or scale (or in our case, opacity). If you were a Flash programmer in the early 2000s, the term *easing* may be almost synonymous with the name [Robert Penner](http://robertpenner.com/easing/), whose open-source easing functions have been ported to almost every language and platform you can think of. + +There are an infinite number of possible easing functions, but the most common variants are: *linear*, *ease-in*, *ease-out* and *ease-in-ease-out*. + +Example easing curves + +Real-life objects do not start and stop moving instantly, they have to accelerate up to speed and then decelerate back to a stop. The purpose of easing is to create realistic motion without all the complexities of a true physics engine, with simulated mass, friction, torque, etc. + +The best all-round easing function for general use is *ease-in-ease-out*, because it starts slow, reaches maximum speed at the half-way point, and then slows down again before stopping, just like a real object. That doesn't mean it's necessarily the best choice for every situation though. + +For the fade to red as the player dies, we want to start quickly at first and then go slower so that the effect seems more drawn out towards the end. For that, we can use the *ease-out* curve. And for the subsequent fade in we want the reverse effect (*ease-in*) so that the overall curve is smooth. + +Easing functions take a normalized input time and produce an output time that has been delayed or sped up according to the type of easing applied. We could implement these as free functions, but grouping them under a common namespace helps with autocompletion and prevents naming collisions. + +Create a new file in the Engine module called `Easing.swift` with the following contents: + +```swift +public enum Easing {} + +public extension Easing { + static func linear(_ t: Double) -> Double { + return t + } + + static func easeIn(_ t: Double) -> Double { + return t * t + } +} +``` + +The first line here may seem a little baffling. An enum with no cases?! + +This is actually a neat little pattern in Swift for when you want to create a namespace. There is no `namespace` keyword in Swift, so to group functions together you have to make them members of a type. We could use a class or struct, but an empty enum has the nice property that you can't accidentally create an instance of it. It's what's known as an *uninhabited type* - a type with no possible values. + +After the empty enum we have an extension with some static methods. The first is the `linear()` easing function - this just returns the same time you pass in. + +Next is the `easeIn()` function. There are actually many ways to implement `easeIn()` - for example, UIKit and Core Animation use Bezier curves to implement easing. But you can also use sine/cosine, or various orders of [polynomial](https://en.wikipedia.org/wiki/Polynomial). + +In this case we have used a *quadratic* `easeIn()` implementation, meaning that it's proportional to the input time *squared*. If we had used `t * t * t`, it would have been a *cubic* `easeIn()` instead. There's no right or wrong type to use, so feel free to experiment and see what feels right. + +Let's add the equivalent quadratic implementations for ease-out and ease-in-ease-out: + +```swift +public extension Easing { + ... + + static func easeOut(_ t: Double) -> Double { + return 1 - easeIn(1 - t) + } + + static func easeInEaseOut(_ t: Double) -> Double { + if t < 0.5 { + return 2 * easeIn(t) + } else { + return 4 * t - 2 * easeIn(t) - 1 + } + } +} +``` + +The `easeOut()` function is just the opposite of ease-in, so we can implement it by calling `easeIn()` with inverted input. The `easeInEaseOut()` function is a little more complex but essentially it behaves like ease-in up to the half-way point, then it behaves like ease-out for the rest (shifted, so it aligns seamlessly with the first half). + +In `Effect.swift`, find the code for the computed `progress` property and update it as follows: + +```swift +var progress: Double { + let t = min(1, time / duration) + switch type { + case .fadeIn: + return Easing.easeIn(t) + case .fadeOut: + return Easing.easeOut(t) + } +} +``` + +We're still using `time / duration` to compute the normalized time, but now we pass it through either `easeIn()` or `easeOut()` depending on the effect type. The game-over fade effect timing is now a smooth curve. + +The death fade effect after applying easing + +Try running the game again and you should notice a subtle improvement. + +### Not with a Bang, but with a Fizzle + +Wolfenstein 3D didn't actually use a palette fade for the player death. Instead, John Carmack created a rather special custom effect he called *fizzlefade*. Fizzlefade is a sort of stippling transition where the pixels flip to red at random. + +The naive way to implement this would be to repeatedly set pixels to red at random until the screen is filled, but this doesn't work well in practice because as the screen fills up, the chances of randomly stumbling on an unfilled pixel tends towards zero. As a result, the effect runs slower and slower over time, and the total duration is non-deterministic. + +Wolfenstein used an ingenious solution to this problem which Fabien Sanglard [wrote about here](http://fabiensanglard.net/fizzlefade/). I'm not *sure* I fully understand how it works (let alone to a level where I can explain it), but fortunately we aren't as resource-constrained as Wolfenstein was, and can use a much simpler approach. + +To restate the problem: We need to visit every pixel on the screen only once, but in a random order. If we put the indexes of all the pixels into an array and then shuffle it before we begin, we can step through the shuffled array in order to ensure we only touch each pixel once. + +In `Effect.swift`, add a new case to the `EffectType` enum called `fizzleOut`: + +```swift +public enum EffectType { + case fadeIn + case fadeOut + case fizzleOut +} +``` + +Then add the following extra case to the switch statement in the computed `progress` property below: + +```swift +case .fizzleOut: + return Easing.easeInEaseOut(t) +``` + +Although it's similar to fade-out, I found that `easeOut()` didn't work as well for the fizzle effect, so I've used `easeInEaseOut()` instead (it also looks pretty good with just `linear()` easing, which is what Wolfenstein used). + +Next, in `World.hurtPlayer()` replace the line: + +```swift +effects.append(Effect(type: .fadeOut, color: .red, duration: 2)) +``` + +with: + +```swift +effects.append(Effect(type: .fizzleOut, color: .red, duration: 2)) +``` + +To implement the fizzle effect itself, we need to pre-generate a buffer of pixel indexes. It's not immediately obvious where we should store that. We could put it in the renderer itself if it was persistent, but we are currently creating a new `Renderer` instance each frame so we can't store anything in it long-term. Another reasonable option would be to store it in the `Effect` itself, but since it's only needed for fizzle-type effects that's not ideal either. + +For now we'll store the fizzle buffer in a global constant. It's not a very elegant solution because it introduces potential issues with thread safety, but since the renderer is currently single-threaded anyway it will do as a stop-gap measure. + +At the top of the `Renderer.swift` file, add the following line: + +```swift +private let fizzle = (0 ..< 10000).shuffled() +``` + +This populates the fizzle buffer with the values 0 to 9999, shuffled in a random order. A typical iPhone screen has a lot more than 10,000 pixels but we don't need a buffer entry for every single pixel, just enough so that any repetition isn't obvious. + +At the bottom of the `Renderer.draw()` method, add the missing `.fizzleOut` case to the `// Effects` block: + +```swift +case .fizzleOut: + let threshold = Int(effect.progress * Double(fizzle.count)) + +} +``` + +The threshold value is the number of pixels in the `fizzle` buffer that should be filled in a given frame. For example, if `effect.progress` is 0.5, we want to fill 50% of the pixels on screen, so any pixel that maps to the lower half of the buffer should be filled, and any in the upper half won't be. + +Next, we need to loop through every pixel in the image and determine if we should fill the pixel or not. Add the following code just after the `let threshold` line: + +```swift +for y in 0 ..< bitmap.height { + for x in 0 ..< bitmap.width { + let index = y * bitmap.width + x + + } +} +``` + +The `let index...` line converts the X and Y pixel coordinates into a linear buffer index. Just below that line, add the following: + +```swift +let fizzledIndex = fizzle[index % fizzle.count] +``` + +This looks up the fizzled index from the buffer. Since there are more pixels in the bitmap than entries in the `fizzle` buffer, we use `%` (the modulo operator) to wrap the index value within the buffer's range. + +Finally, we need to compare the fizzled index value to the threshold. If it's below the threshold we'll fill the pixel, otherwise we do nothing. Add the following code below the line we just added: + +```swift +if fizzledIndex <= threshold { + bitmap[x, y] = effect.color +} +``` + +Run the game and get yourself killed. You should see the original fade to red has now been replaced by the fizzle effect. + +![Fizzle fade](Images/FizzleFade.png) + +This effect is pretty faithful to the original Wolfenstein 3D death sequence, but it doesn't really suit the lo-fi look of Retro Rampage because the resolution of a modern iPhone (even the virtual 1x resolution that we are using) is a bit too dense. + +We can improve the effect by artificially lowering the resolution for a more pixelated fizzle. In the code we just wrote, replace the line: + +```swift +let index = y * bitmap.width + x +``` + +with: + +```swift +let granularity = 4 +let index = y / granularity * bitmap.width + x / granularity +``` + +The `granularity` constant controls the sampling size. By dividing the real X and Y coordinates by the desired granularity, we increase the apparent size of each sampled pixel on screen. + +![Fizzle fade with larger pixels](Images/BigPixelFizzle.png) + +And with that, we'll bring this (rather morbid) chapter to a close. In this part we: + +* Added player health and damage +* Handled player death and re-spawn +* Added fullscreen fade effects for when the player is hurt or killed +* Implemented animation easing for smoother fades +* Recreated the old-school fizzlefade effect from Wolfenstein + +In [Part 8](Part8.md) we'll give the player the means to fight back! + +
+ +[[1]](#reference1) This isn't actually guaranteed to work since there are multiple world updates per frame, but it doesn't seem to be a problem in practice. + +[[2]](#reference2) The original Wolfenstein 3D screen resolution was 320x200 (320 * 200 = 64000). + +[[3]](#reference3) There are actually similar tricks that you can do on modern machines by altering the [display gamma](https://en.wikipedia.org/wiki/Gamma_correction), but that's highly platform-specific and outside the scope of what we're trying to do here. + +[[4]](#reference4) It's, uh... completely normal to think like this, right? + +[[5]](#reference5) Don't make it too long, or the monster will be on top of you by the time your vision has cleared! diff --git a/Tutorial/Part8.md b/Tutorial/Part8.md new file mode 100644 index 0000000..1e3a341 --- /dev/null +++ b/Tutorial/Part8.md @@ -0,0 +1,865 @@ +## Part 8: Target Practice + +In [Part 7](Part7.md) we added the ability for monsters to hurt and even kill the player, causing the game to reset. The complete code for Part 7 can be found [here](https://github.com/nicklockwood/RetroRampage/archive/Part7.zip). + +Now it's time to give the player the means to fight back! + +### Pistols at Dawn + +I don't know about you, but I've had about enough of being mauled to death by zombies. It's *payback time*. + +We're going to need a weapon. In keeping with FPS tradition, we'll start with a humble pistol. + +Pistol sprite blown up on left and at actual size on right + +Like the monster sprite, the pistol has a transparent background. Even though the sprite only occupies the bottom-right corner of the screen, to keep the math simple we're going to scale the sprite image to fit the whole screen height. + +I've used a 32x32 image for the pistol instead of the 16x16 images used previously, so that the apparent resolution is more similar to the monster sprites and textures, which are normally seen from further away[[1]](#footnote1). + +If you'd like to use the artwork from the tutorial, you can find it [here](https://github.com/nicklockwood/RetroRampage/tree/Part8/Source/Rampage/Assets.xcassets/), but feel free to use any weapon graphics you like for your own project. The logic for handling sprites is resolution-independent, and the image size has no significant impact on performance. + +Once you've added the "pistol" image to XCAssets, open `Textures.swift` and extend the `Texture` enum with the new case: + +```swift +public enum Texture: String, CaseIterable { + ... + case pistol +} +``` + +### Weapon Drawn + +So how do we go about actually drawing the pistol on-screen? + +In terms of screen area, the sprite that we want to draw looks like this: + +Position of pistol sprite on screen + +We already have a mechanism for drawing sprites using *billboards* (flat, textured rectangles that always face the player), so it might seem reasonable to implement the pistol as a billboard positioned a constant distance in front of the player. + +This wouldn't work well in practice however, because if we try to position the pistol sprite in 3D we'll introduce problems like the sprite being clipped by walls, or nearby monster sprites being drawn in front of it. + +We always want to draw the pistol in front of everything else. The height needs to match the screen height, but there is no perspective distortion, nor do we want any part of the image to be clipped or occluded by other objects in the scene. This doesn't really seem like a 3D problem at all - we just want to draw a scaled 2D image. + +We already have logic to draw a vertical slice of an image at any scale, so let's extend that to draw an entire image. In `Bitmap.swift` find the `drawColumn()` method. Add a new method underneath it called `drawImage()`, as follows: + +```swift +mutating func drawImage(_ source: Bitmap, at point: Vector, size: Vector) { + +} +``` + +The method signature is quite similar to `drawColumn()`, but we don't need the `x` parameter, and instead of a scalar output `height`, we have a vector `size`. The bodies of the methods will also be similar, but instead of stepping vertically through the pixels of a single column, we're going to step horizontally through the columns of the whole image. + +Add this code inside `drawImage()`: + +```swift +let start = Int(point.x), end = Int(point.x + size.x) +let stepX = Double(source.width) / size.x +for x in max(0, start) ..< min(width, end) { + let sourceX = (Double(x) - point.x) * stepX + +} +``` + +Inside the loop, `x` is the position at which the column will be displayed on-screen, and `sourceX` is the position in the source image from which the column will be sampled. We can now use the `drawColumn()` function to actually draw each column of pixels. Add the following two lines inside the loop to complete the `drawImage()` method: + +```swift +let outputPosition = Vector(x: Double(x), y: point.y) +drawColumn(Int(sourceX), of: source, at: outputPosition, height: size.y) +``` + +In `Renderer.draw()`, add this block of code just before the `// Effects` section: + +```swift +// Player weapon +let screenHeight = Double(bitmap.height) +bitmap.drawImage( + textures[.pistol], + at: Vector(x: Double(bitmap.width) / 2 - screenHeight / 2, y: 0), + size: Vector(x: screenHeight, y: screenHeight) +) +``` + +This ensures the pistol will be drawn in front of all 3D objects in the scene, but underneath the effects overlays. Run the game again and you should see the pistol displayed over the background. + +![Pistol sprite drawn over background](Images/PistolOverlay.png) + +### Fired Up + +We've *drawn* a pistol, but currently there's no way to actually fire it. To do that, we need to add a new type of input. + +Many FPS games on the iPhone use an on-screen button for firing weapons. I find that this suffers from the same problem as fixed-position joysticks - it's too easy for your finger position to slip, and with no tactile feedback it's hard to feel your way back to the button without taking your eyes off the action. + +Instead, we're going to turn the *entire screen* into a fire button. In `ViewController.swift`, add the following new property just below `panGesture`: + +```swift +private let tapGesture = UITapGestureRecognizer() +``` + +For `panGesture` we didn't bother adding an action handler, and just sampled the current value each frame. That approach won't work for `tapGesture` though, because we only want to send one fire event to the engine each time the user taps, and there's not any obvious way to do that without storing additional state outside the `UITapGestureRecognizer` itself. + +Add the following property to `ViewController`: + +```swift +private var lastFiredTime = 0.0 +``` + +Then the following method: + +```swift +@objc func fire(_ gestureRecognizer: UITapGestureRecognizer) { + lastFiredTime = CACurrentMediaTime() +} +``` + +Finally, in `viewDidLoad()` add the lines: + +```swift +view.addGestureRecognizer(tapGesture) +tapGesture.addTarget(self, action: #selector(fire)) +``` + +In `Input.swift`, add a new `isFiring` property and update `init()` to set the new property: + +```swift +public struct Input { + public var speed: Double + public var rotation: Rotation + public var isFiring: Bool + + public init(speed: Double, rotation: Rotation, isFiring: Bool) { + self.speed = speed + self.rotation = rotation + self.isFiring = isFiring + } +} +``` + +Then, in `ViewController.update()`, replace the lines: + +```swift +let input = Input( + speed: -inputVector.y, + rotation: Rotation(sine: sin(rotation), cosine: cos(rotation)) +) +``` + +with: + +```swift +let input = Input( + speed: -inputVector.y, + rotation: Rotation(sine: sin(rotation), cosine: cos(rotation)), + isFiring: lastFiredTime > lastFrameTime +) +``` + +That takes care of the platform layer changes - now to implement the firing logic in the engine itself. + +### Flash Forward + +We'll start by implementing the muzzle flash. This will give us a clear visual indication of whether the tap handling is working as intended. First, we'll need some animation frames: + +Pistol firing frames + +The pistol firing sequence has five frames, but the last is the same as the idle state, so there's only four new images to add to XCAssets. Do that, then add the new cases to `Textures.swift`: + +```swift +public enum Texture: String, CaseIterable { + ... + case pistol + case pistolFire1, pistolFire2, pistolFire3, pistolFire4 +} +``` + +We'll need to create some `Animation` instances for the pistol, but where to put them? The pistol is the closest thing we have to a player avatar, so it seems like it makes sense to put them in `Player.swift`. Add the following code to the bottom of the `Player.swift` file: + +```swift +public extension Animation { + static let pistolIdle = Animation(frames: [ + .pistol + ], duration: 0) + static let pistolFire = Animation(frames: [ + .pistolFire1, + .pistolFire2, + .pistolFire3, + .pistolFire4, + .pistol + ], duration: 0.5) +} +``` + +Now we need some code to manage weapon state. Add the following enum to the top of `Player.swift`: + +```swift +public enum PlayerState { + case idle + case firing +} +``` + +Then add these properties to the `Player` struct[[2]](#footnote2): + +```swift +public var state: PlayerState = .idle +public var animation: Animation = .pistolIdle +public let attackCooldown: Double = 0.4 +public private(set) var lastAttackTime: Double = 0 +``` + +The `Monster` attack logic is handled inside `Monster.update()`, but the player doesn't currently have their own `update()` method. Let's change that. + +In `World.update()`, find the following two lines in the `// Update player` section: + +```swift +player.direction = player.direction.rotated(by: input.rotation) +player.velocity = player.direction * input.speed * player.speed +``` + +and replace them with: + +```swift +player.animation.time += timeStep +player.update(with: input) +``` + +Then, back in `Player.swift`, add an `update()` method containing the logic we just removed: + +```swift +extension Player { + ... + + mutating func update(with input: Input) { + direction = direction.rotated(by: input.rotation) + velocity = direction * input.speed * speed + + } +} +``` + +Now that we've moved the player's update logic into the `Player` type itself, we'll add the new code for managing weapon state. Append the following lines to the `Player.update()` method: + +```swift +switch state { +case .idle: + if input.isFiring { + state = .firing + animation = .pistolFire + } +case .firing: + if animation.time >= attackCooldown { + state = .idle + animation = .pistolIdle + } +} +``` + +The state logic here is as follows: + +* If we're in the `idle` state, and the fire button was pressed during the frame, switch to the `firing` state and trigger the `pistolFire` animation. +* Once in the `firing` state, wait until the `attackCooldown` period has passed, then return to the `idle` state so the weapon can be fired again. + +In `Renderer.draw()`, in the `// Player weapon` section, replace the line: + +```swift +textures[.pistol], +``` + +with: + +```swift +textures[world.player.animation.texture], +``` + +This ensures that we draw the current animation frame of the pistol instead of just its idle state. + +Run the game again and try tapping to fire the pistol. You should see a satisfying muzzle flash. + +![Pistol sprite rendered as a clipboard](Images/MuzzleFlash.png) + +At least, you should see it for about a second, before a zombie starts chomping on your brain! + +*It's time we put this guy down.* + +### All Flash, No Substance + +A weapon isn't much good if it doesn't inflict any damage on your attacker. But before we can hurt the monster, we need to detect which (if any) monster we've hit. + +The monster's own attack is just based on distance from the player - it isn't directional. By contrast, a projectile weapon like a pistol requires a very different implementation. + +As we discussed in [Part 2](Part.md), a small and/or fast-moving object (like a pistol bullet) poses a potential problem for our collision handling, since it operates in discrete time steps. A sufficiently fast projectile might appear to teleport through a wall unless we increase the update frequency to compensate. + +We currently update the world at ~120 FPS. Is that sufficient to reliably simulate a pistol bullet? + +[According to Wikipedia](https://en.wikipedia.org/wiki/Muzzle_velocity), muzzle velocity for a bullet ranges from around 120 m/s (meters-per-second) for an old-fashioned musket, to around 1200 m/s for a modern high-powered rifle. That actually makes the math super-simple, because it means that at 120 FPS, the bullet would travel anywhere between 1 and 10 meters per frame. + +One world unit is roughly equivalent to a meter, so we can say that a realistic bullet would travel between one and ten world units per frame. The size of a bullet is pretty close to zero (about 0.02 units) so we can discount that. The implication then is that 120 FPS is just on the borderline if we want to simulate a bullet as a moving projectile. + +We are free to increase our world simulation rate since we decoupled it from the frame rate. Pushing it up to 1000 FPS would solve any collision accuracy problems. Alternatively, we could compromise by reducing the bullet speed a bit - after all the player is unlikely to really notice if a bullet travels at 60 m/s instead of 120. + +But there is a third option which requires neither a reduction in bullet speed nor an increase in simulation speed - we can model the bullet as a *ray* instead of a projectile. + +### Ray Gun + +Instead of moving a fixed distance each frame, a ray instantly traverses the entire map like a laser beam, stopping at the first object it intersects. This isn't really realistic of course, but the difference between a bullet moving 10 units vs the entire map diameter is not really perceptible[[3]](#footnote3), and it greatly simplifies the implementation. + +We've already written the logic for ray/wall and ray/sprite intersections in the `Renderer`, but the use-cases for drawing and bullet collisions are slightly different, so we don't really want to couple them together. Instead, we'll add a new `hitTest()` method to `Monster`. + +To implement that method we'll need the monster's sprite `Billboard`, but that's currently generated directly inside `World.sprites` and isn't available here[[4]](#footnote4), so we'll need to fix that first. + +In `Monster.swift` add the following new method near the bottom of the extension: + +```swift +func billboard(for ray: Ray) -> Billboard { + let plane = ray.direction.orthogonal + return Billboard( + start: position - plane / 2, + direction: plane, + length: 1, + texture: animation.texture + ) +} +``` + +Then, in `World.swift`, find the computed `sprites` var and replace it with this much-simplified version so that we aren't duplicating the billboard-generation logic: + +```swift +var sprites: [Billboard] { + let ray = Ray(origin: player.position, direction: player.direction) + return monsters.map { $0.billboard(for: ray) } +} +``` + +With that refactoring out the the way, we can get back to the task at hand. Back in `Monster.swift`, just below the `billboard()` method we just added, insert following: + +```swift +func hitTest(_ ray: Ray) -> Vector? { + guard let hit = billboard(for: ray).hitTest(ray) else { + return nil + } + return hit +} +``` + +This method checks whether the ray intercepts the monster's billboard, and returns the hit location if so, or `nil` otherwise. We're not quite done with the `hitTest()` implementation yet though. + +As you may recall from [Part 6](Part6.md), the monster sprite image doesn't extend right to the edges of the bitmap. We don't want bullets that should pass by the monster to be treated as hits, so we need to do a further check to ensure that the distance of the hit position along the billboard is within the monster's radius. + +Add the following code to the `hitTest()` method, just before the `return hit` line: + +```swift +guard (hit - position).length < radius else { + return nil +} +``` + +Now that we have a way to test a ray against an individual monster, we need to write the logic to test each monster in turn to see a) if they've been hit, and b) which was hit first (a process known as *picking*). + +In `World.swift`, add the following placeholder method to the bottom of the extension: + +```swift +func hitTest(_ ray: Ray) -> Int? { + +} +``` + +You may have noticed that the signature of this `hitTest()` method is different from all other similarly-named methods in the game. In most cases we are interested in the *location* at which the ray intersects the object, but in this case we just need to know *which* object was hit, so we return an integer index instead of a vector position. + +The method will start by performing a hit-test against the map itself, and recording the distance. This is useful because any monster hit that is further away than this can be discarded (the bullet would have stopped at the wall first). + +Add these lines inside `hitTest()`: + +```swift +let wallHit = map.hitTest(ray) +var distance = (wallHit - ray.origin).length +``` + +Next, we need to loop through all the monsters and check if the ray hits them. Add the following code to complete the method: + +```swift +var result: Int? = nil +for i in monsters.indices { + guard let hit = monsters[i].hitTest(ray) else { + continue + } + let hitDistance = (hit - ray.origin).length + guard hitDistance < distance else { + continue + } + result = i + distance = hitDistance +} +return result +``` + +For each monster, if a hit is detected we compute the distance and then compare it with the previous nearest hit. At the end, the index of the closest sprite is returned (or `nil`, if no sprites were hit). + +### Health Hazard + +Once we have isolated the monster that was hit, we'll need a way to inflict damage. In [Part 7](Part7.md) we made the player mortal by giving them a `health` property - we'll now do the same for the monsters. In `Monster.swift` add the following property to `Monster`: + +```swift +public var health: Double = 50 +``` + +Then add a computed `isDead` property to the extension: + +```swift +public extension Monster { + var isDead: Bool { + return health <= 0 + } + + ... +} +``` + +Since we've made the `World.monsters` array setter private, it's not possible for the player to directly manipulate the monster's health. For player damage, we solved this problem by adding a `hurtPlayer()` method to `World`. We'll use the same solution here. + +In `World.swift`, add the following method to the extension: + +```swift +mutating func hurtMonster(at index: Int, damage: Double) { + monsters[index].health -= damage +} +``` + +The `Player.update()` method is going to need some way to call this, which means it needs access to the `World` instance. As with `Monster.update()`, we'll pass the world to `Player.update()` via an `inout` parameter. In `Player.swift`, replace the method signature: + +```swift +mutating func update(with input: Input) { +``` + +with: + +```swift +mutating func update(with input: Input, in world: inout World) { +``` + +Then, in `World.update()`, in the `// Update player` section, replace the line: + +```swift +player.update(with: input) +``` + +with: + +```swift +player.update(with: input, in: &self) +``` + +Uh-oh, what's this? + +Overlapping access error + +It seems that we can't pass a mutable reference to `World` to `Player.update()` because this would permit simultaneous modifications to be made to the `player` property via two different references. + +This isn't a problem for `Monster.update()` because we copy the monster into a local variable before calling `update()`. We'll use the same solution for the player. Still in `World.update()`, replace the lines: + +```swift +player.animation.time += timeStep +player.update(with: input, in: &self) +player.position += player.velocity * timeStep +``` + +with: + +```swift +var player = self.player! +player.animation.time += timeStep +player.update(with: input, in: &self) +player.position += player.velocity * timeStep +self.player = player +``` + +Back in `Player.update()`, just after the line `animation = .pistolFire`, add the following: + +```swift +let ray = Ray(origin: position, direction: direction) +if let index = world.hitTest(ray) { + world.hurtMonster(at: index, damage: 10) +} +``` + +OK, it seems like that should be everything we need. If you run the game though, it seems like the monster still won't die, no matter how much you shoot it. + +### Life After Death + +Although *we* know that you are supposed to die when your health reaches zero, we forgot to tell the monster. Rather ironically for a zombie, the monster is *quite literally* a member of the walking dead. + +The monster's behavior is guided by a state machine. We could just bypass that logic when the monster is dead, but it makes more sense to extend the state machine rather than work around it. + +In `Monster.swift`, add two new cases to the `MonsterState` enum: + +```swift +public enum MonsterState { + ... + case hurt + case dead +} +``` + +We'll need some new monster animation frames to represent these new states. + +Monster death animation frames + +Add these images to XCAssets, then add the new cases in `Textures.swift`: + +```swift +case monsterHurt, monsterDeath1, monsterDeath2, monsterDead +``` + +Next, at the bottom of `Monster.swift`, add three new animations to the extension: + +```swift +public extension Animation { + ... + static let monsterHurt = Animation(frames: [ + .monsterHurt + ], duration: 0.2) + static let monsterDeath = Animation(frames: [ + .monsterHurt, + .monsterDeath1, + .monsterDeath2 + ], duration: 0.5) + static let monsterDead = Animation(frames: [ + .monsterDead + ], duration: 0) +} +``` + +For the `monsterHurt` animation we've just re-used the first frame of the death animation, but you could do something more interesting in your own project, like having the monster shake its head or clutch its chest. Even though the animation is only one frame, we've made it last for 0.2 seconds because we want the monster to pause for a while each time it is shot. + +When hurt, the monster will pause its attack on the player, then resume again after the animation has finished. When dead, it should play the death animation once and then switch to the single-frame `monsterDead` animation so it doesn't appear to keep dying over and over. + +In `Animation.swift`, add the following code to the extension: + +```swift +public extension Animation { + var isCompleted: Bool { + return time >= duration + } + + ... +} +``` + +Then back in `Monster.swift`, add the following cases to the `switch` statement inside the `update()` method: + +```swift +case .hurt: + if animation.isCompleted { + state = .idle + animation = .monsterIdle + } +case .dead: + if animation.isCompleted { + animation = .monsterDead + } +``` + +### Rogue States + +That takes care of how the monster should behave in the `hurt` and `dead` states, but how does it actually get into those states in the first place? Unlike the other cases in the `MonsterState` enum, being hurt or killed isn't something that the monster AI *decides* to do, it's something that is *done to* the monster. + +Let's look at the `World.hurtPlayer()` method for inspiration. If we ignore anything specific to effects we are left with: + +```swift +mutating func hurtPlayer(_ damage: Double) { + if player.isDead { + return + } + player.health -= damage + ... + if player.isDead { + ... + } +} +``` + +We can use more-or-less the same code for the monster. In `World.hurtMonster()`, replace the line: + +```swift +monsters[index].health -= damage +``` + +with: + +```swift +var monster = monsters[index] +if monster.isDead { + return +} +monster.health -= damage +if monster.isDead { + monster.state = .dead + monster.animation = .monsterDeath +} else { + monster.state = .hurt + monster.animation = .monsterHurt +} +monsters[index] = monster +``` + +Run the game and you should find that your bullets now hurt and kill the monsters. There are a few weird glitches though: + +* The monster's corpse keeps sliding towards the player after it dies. +* We sometimes see a flash of the monster standing up again just at the point of death. + +### Death Slide + +The posthumous slide is happening because the monster's velocity isn't reset when it dies. + +The velocity of the monster is controlled by its state machine. In the `chasing` state it always maneuvers towards the player. The velocity gets reset to zero in the `idle` state, but we never reset it when the monster is hurt or killed, so it just retains whatever value it had previously. + +Since it's only the `chasing` state that should be actively changing the velocity, it makes more sense for the velocity to be set at the point of transition to another state, instead of constantly while in that state. + +In `Monster.update()` remove the following line from inside `case .idle:`: + +```swift +velocity = Vector(x: 0, y: 0) +``` + +Then update the body of `case .chasing:` to look like this: + +```swift +guard canSeePlayer(in: world) else { + state = .idle + animation = .monsterIdle + velocity = Vector(x: 0, y: 0) + break +} +if canReachPlayer(in: world) { + state = .scratching + animation = .monsterScratch + lastAttackTime = -attackCooldown + velocity = Vector(x: 0, y: 0) + break +} +let direction = world.player.position - position +velocity = direction * (speed / direction.length) +``` + +So that the velocity is reset to zero each time we switch state. + +Finally, in `World.hurtMonster()`, find the line: + +```swift +monster.health -= damage +``` + +and immediately after it, insert: + +```swift +monster.velocity = Vector(x: 0, y: 0) +``` + +That should solve the issue with monsters sliding around after death. + +We don't currently experience this bug with the player, but only because we decided to stop updating player position when they are dead. We might change that later and accidentally introduce the same bug, so it's better to code defensively. + +In `World.hurtPlayer()`, find the line: + +```swift +player.health -= damage +``` + +and immediately after it, insert: + +```swift +player.velocity = Vector(x: 0, y: 0) +``` + +With that done, we're ready to move on to the second bug. + +### Strange Loop + +The death animation glitch is due to a race condition. In `World.update()`, in the `// Update monsters` section, we update each monster *before* we update the playback time for its animation. That means that the monster may be redrawn in between when the animation has ended and when the monster switches to the `dead` state, by which point the animation will have wrapped around to its first frame again. + +In `World.update()`, in the `// Update monsters` section, move the line: + +```swift +monster.animation.time += timeStep +``` + +from its current position to just above the line: + +```swift +monster.update(in: &self) +``` + +That will ensure that `animation.isCompleted` gets checked and handled before the animation wraps around. + +### Dead Weight + +There's just one last issue with post-death monster behavior - dead monsters still behave like solid objects. They can be pushed around the level, and they absorb bullets. + +This isn't exactly a bug, but it's not optimal from a gameplay perspective. Let's disable collision detection when the monster is dead. + +In `Monster.swift`, in the `hitTest()` method, replace the line: + +```swift +guard let hit = billboard(for: ray).hitTest(ray) else { +``` + +with: + +```swift +guard isDead == false, let hit = billboard(for: ray).hitTest(ray) else { +``` + +That means the monster corpse will no longer absorb bullets, so you can now shoot over the top of one corpse to hit the monster behind. + +It's a little more complex to disable collisions between the player and monster, because that logic has been abstracted out into `Actor`. Fortunately, since both `Monster` and `Player` already have an `isDead` property, we can easily abstract that as well. + +In `Actor.swift`, add the following property to the `Actor` protocol: + +```swift +var isDead: Bool { get } +``` + +Then insert the following code at the start of the `intersection(with actor:)` method: + +```swift +if isDead || actor.isDead { + return nil +} +``` + +If you run the game now, you should see that all the post-death monster glitches are solved. But it's also apparent that the game is actually pretty *hard*. + +There are a couple of reasons for this: + +* It's not possible to fire and turn at the same time. +* Even when locked on target, it's still difficult to land enough hits to bring down a monster before it attacks. + +### Point and Shoot + +The first problem is due to the way that `UIGestureRecognizer` works. By default, only one gesture can be recognized at a time, so if iOS thinks you're making a pan gesture, it won't also recognize a tap gesture, and vice-versa. + +This is probably a sensible default as it helps to prevent false positives, but in the context of our game it's annoying. We can override this behavior using `UIGestureRecognizerDelegate`. In `ViewController.swift`, add the following code to the bottom of the file: + +```swift +extension ViewController: UIGestureRecognizerDelegate { + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + return true + } +} +``` + +Next, in the `viewDidLoad()` method, just before the line: + +```swift +view.addGestureRecognizer(panGesture) +``` + +add: + +```swift +panGesture.delegate = self +``` + +Finally, in the same method, just after the line: + +```swift +tapGesture.addTarget(self, action: #selector(fire)) +``` + +add: + +```swift +tapGesture.delegate = self +``` + +With the delegates hooked up, the gestures can now be recognized concurrently, so we no longer have to choose between aiming and firing. Let's take a look at the second problem. + +### Too Cool for Comfort + +Although we set the monster health to 50 (half of the player's), they approach fast and we can only fire relatively slowly. + +We can tip the odds a bit further in the player's favor by reducing their cooldown period (the minimum time between shots). In `Player.swift`, change the line: + +```swift +public let attackCooldown: Double = 0.4 +``` + +to: + +```swift +public let attackCooldown: Double = 0.25 +``` + +If you run the game again, you'll see you can now fire much faster, making it easier to take down a monster before they get close enough to start draining your health. + +But an unintended side-effect of this is that the pistol firing animation no longer plays to completion. Because the player state resets to `idle` as soon as the cooldown period of 0.25 seconds has elapsed, the pistol animation (which is supposed to last 0.5 seconds) is never able to finish. + +We didn't notice this problem earlier because the cooldown period of 0.4, combined with the fact that the `pistolFire` animation's final frame is the same as the `pistolIdle` frame anyway, meant that the animation wasn't clipped. + +Interrupting the animation in order to fire more frequently than the full duration makes sense, but when we *don't* interrupt the animation, it should play to completion. + +In `Player.swift`, add the following code just below the `var isDead` property: + +```swift +var canFire: Bool { + switch state { + case .idle: + return true + case .firing: + return animation.time >= attackCooldown + } +} +``` + +This computed property allows us to hoist the firing logic outside of the `idle` state handler. Next, in `Player.update()`, insert this code just before the `switch` statement: + +```swift +if input.isFiring, canFire { + state = .firing + animation = .pistolFire + let ray = Ray(origin: position, direction: direction) + if let index = world.hitTest(ray) { + world.hurtMonster(at: index, damage: 10) + } +} +``` + +then replace the body of `case .idle:` with just a `break` statement, since we no longer need to be in the `idle` state to fire. + +Finally, in `case .firing:`, replace the line: + +```swift +if animation.time >= attackCooldown { +``` + +with: + +```swift +if animation.isCompleted { +``` + +So now when the `pistolFire` animation is playing, if the player *doesn't* tap to fire, the state will only transition back to `idle` once the animation has actually completed. + +That's all for Part 8. In this part we: + +* Added a player weapon sprite and figured out how best to draw it +* Implemented tap-to-fire and added a muzzle flash animation +* Used ray casting to simulate bullets hitting enemies +* Added the ability to hurt and kill the monsters in the game +* Discovered and fixed a bunch of bugs + +We've added a bunch of new effects over the last few chapters, and the frame rate is starting to suffer, especially on older devices. In [Part 9](Part9.md) we'll take a short break from pixelated mayhem to tune the performance a bit before we pile on any more features. + +
+ +[[1]](#reference1) It's also pretty difficult to draw a pistol any smaller than this. + +[[2]](#reference2) You may have noticed that these properties closely mirror the ones we used for the `Monster` attack sequence. There's probably an opportunity to consolidate some of this shared code, but let's follow the [rule of three](https://en.wikipedia.org/wiki/Rule_of_three_(computer_programming)) and hold off on refactoring until we've completed more of the functionality. + +[[3]](#reference3) Especially given that our test map isn't even 10 units wide. + +[[4]](#reference4) What idiot made *that* decision? + diff --git a/Tutorial/Part9.md b/Tutorial/Part9.md new file mode 100644 index 0000000..0148fc8 --- /dev/null +++ b/Tutorial/Part9.md @@ -0,0 +1,824 @@ +## Part 9: Performance Tuning + +In [Part 8](Part8.md) we gave the player a pistol to kill the monsters so the game isn't quite so one-sided. The complete code for Part 8 can be found [here](https://github.com/nicklockwood/RetroRampage/archive/Part8.zip). + +We've held off on doing any optimization work so far because optimized code tends to be harder to read and harder to modify. It's also just generally an inefficient use of our time to optimize code that might be changed or even deleted. + +But now that we've reached a point of relative stability with the renderer, let's take a break from adding new features to do some much-needed performance tuning. + +### Standardized Testing + +The cardinal rule of performance tuning is *measure first*. Before we do anything, we need to give ourselves a way to measure the progress we are making. We need a [test harness](https://en.wikipedia.org/wiki/Test_harness). + +It's no secret that the games industry hasn't embraced automated testing to quite the same extent as app developers, preferring to rely on teams of manual testers. + +Videogames are an inherently visual medium, and while some aspects of our code base could benefit from automated testing, the kind of bugs we've encountered so far - such as fisheye rendering distortion, or monster corpses absorbing bullets - would take hours to write tests for, and can be reproduced in seconds by just *playing the game*. + +Performance testing is an altogether different matter. A human can tell if a game seems smooth or choppy, but it's very hard for them to make objective comparisons between the effects of small optimizations. For that we need a more scientific approach, and this is where automated tests really shine. + +We're going to start by adding a test target to the project. Xcode usually adds this by default, so you may already have a folder called *RampageTests* in your project, in which case you can skip the next step. + +If you *don't* already have a test target, go to `File > New > Target...` and select *iOS Unit Testing Bundle*. + +Adding a new iOS Unit Testing Bundle target + +Once you've added the test target, edit the Rampage scheme and change the *Build Configuration* in the *Test* tab to "Release". + +We already set the *Run* mode to Release in [Part 4](Part4.md), but we need to do it for the Test target as well otherwise the tests won't be representative of real-world performance conditions. + +Changing testing build configuration to Release + +### Public Service + +It's common practice to use the `@testable` attribute when importing modules in a test file. `@testable` makes `internal` (module-scoped) code accessible to the test. Unfortunately `@testable` only works in Debug mode, so we can't use it here. That means we're going to have to make a few things in the Rampage module public. + +In `ViewController.swift`, make `loadMap()` public by replacing the line: + +```swift +private func loadMap() -> Tilemap { +``` + +with: + +```swift +public func loadMap() -> Tilemap { +``` + +Then do the same for the `loadTextures()` method. + +### Purity Test + +In general it's much easier to write tests for *pure* functions than stateful ones. A [pure function](https://en.wikipedia.org/wiki/Pure_function) is one that takes an input argument and returns an output value without accessing any external state. + +Modern rendering engines are multi-threaded and highly stateful, as they need to interact with GPU hardware (which is basically a giant state machine). The software renderer that we've written for Retro Rampage however is *completely stateless*, and should be super-simple to test. + +In the RampageTests folder in your project, find the file called `RampageTests.swift` and edit the contents to look as follows: + +```swift +import XCTest +import Engine +import Rampage + +class RampageTests: XCTestCase { + let world = World(map: loadMap()) + let textures = loadTextures() + + func testRenderFrame() { + self.measure { + var renderer = Renderer(width: 1000, height: 1000, textures: textures) + renderer.draw(world) + } + } +} +``` + +That's all the code needed to render a single frame of the game under test conditions. The `measure {}` block wrapped around the drawing code is a built-in method of `XCTestCase` that automatically executes and benchmarks the code inside it. + +Normally a test function would include one or more `XCAssert()` statements to test the output of the code under test, but in this case we aren't interested in the *output* of the test, only how long it takes to run. + +Go ahead and run the tests (select the `Product > Test` menu, or press `Cmd-U` on the keyboard). + +You might be surprised to see that the iPhone simulator boots up and runs the game for a few seconds. That's normal, but we don't really want it to happen in this case because we're trying to measure performance in an isolated test, and having the game running in the background is likely to peg the CPU, impacting the result. + +In `ViewController.swift` add the following code at the top of the `viewDidLoad()` method, just below the `super.viewDidLoad()` line: + +```swift +guard NSClassFromString("XCTestCase") == nil else { + return +} +``` + +This (slightly eccentric) code performs a runtime check for the presence of the `XCTestCase` class. If found, it indicates that the app is running in a test environment, so we bail out early before the game has been initialized. + +Run the tests again and you should see something like this: + +First run of the performance test + +Each time the tests are run, the code inside `measure {}` is evaluated multiple times and the average time is compared against the previously recorded value. Because this is the first run, we don't have a baseline time for the performance test yet, so we need to set one. + +Click the small grey diamond icon on the left-hand side of the `self.measure {` line and you'll see the following popup that will allow you to save the last recorded time as the baseline: + +Popup for setting baseline running time for test + +Once set, the baseline value is saved inside the project. Future runs of the test will be compared against this value, and the test will fail if the new time is significantly worse. + +Successive performance test values are only meaningful when the tests are run on the same hardware - in this case a Mid 2014 MacBook Pro[[1]](#footnote1). Even on the same hardware, test results can be distorted by background processes or other external factors affecting the test machine. For this reason I usually run the tests a few times and then take the best reproducible result. + +The baseline time we've recorded is 0.11 seconds, so we're only getting ~9 FPS. That means we've got plenty of room for improvement. + +### Low Hanging Fruit + +You can spend an almost unlimited amount of time making small improvements to performance, but the return on investment tends to diminish exponentially. So how do we decide where best to focus our efforts? + +Instincts about what makes code slow are often wildly misguided, but fortunately Xcode has some great tools that we can use to disabuse us of our misconceptions. In [Part 4](Part4.md) we briefly used the Time Profiler Instrument to diagnose an unexpected performance dip. We'll use it again now (use `Cmd-I` to launch Instruments and select the Time Profiler tool). + +Here is the trace for the game as it currently stands. + +![Time Profiler trace showing relative time spent in various calls](Images/UnoptimizedTrace.png) + +The Time Profiler is not especially good for measuring absolute performance (for that, we have the performance test we just wrote), but it's great for identifying the bottlenecks in your code. + +From the screenshot you can see that the top function in the list is `Bitmap.blendPixel()` with a whole 24% of the total frame time spent in just that single function. It's clearly a prime candidate for optimization. + +Let's take a look at `blendPixel()`: + +```swift +private mutating func blendPixel(at x: Int, _ y: Int, with newColor: Color) { + let oldColor = self[x, y] + let inverseAlpha = 1 - Double(newColor.a) / 255 + self[x, y] = Color( + r: UInt8(Double(oldColor.r) * inverseAlpha) + newColor.r, + g: UInt8(Double(oldColor.g) * inverseAlpha) + newColor.g, + b: UInt8(Double(oldColor.b) * inverseAlpha) + newColor.b + ) +} +``` + +At first glance there isn't much opportunity for improvement here. We're already using premultiplied alpha, and common code like `inverseAlpha` has already been extracted. But if you can't make code faster, what about *not calling it in the first place*? + +The `blendPixel()` method is called from inside both `drawColumn()` and `tint()`. Looking at the Time Profiler trace, `Bitmap.drawColumn()` is higher in the list, which makes sense since we only use `tint()` for `Effect` overlays when the player is hurt, whereas `drawColumn()` is called many times each frame for both walls and sprites. + +So let's concentrate on `drawColumn()` for now. The `drawColumn()` method calls `blendPixel()` because it's used for sprites, which have a transparent background. But it's also used for walls, which don't. We pay a significant price for that flexibility. + +We could fork the `drawColumn()` method into opaque and non-opaque variants, or add an `isOpaque` parameter to the function, but there's a more elegant solution. + +### Clear as Mud + +In `Color.swift`, add the following computed property to the extension: + +```swift +public extension Color { + var isOpaque: Bool { + return a == 255 + } + + ... +} +``` + +Then in `Bitmap.swift`, update the `Bitmap` struct to include an `isOpaque` property, as follows: + +```swift +public struct Bitmap { + public private(set) var pixels: [Color] + public let width: Int + public let isOpaque: Bool + + public init(width: Int, pixels: [Color]) { + self.width = width + self.pixels = pixels + self.isOpaque = pixels.allSatisfy { $0.isOpaque } + } +} +``` + +The `allSatisfy` method returns true *only* if its closure argument returns true for every element in the array, so this is a handy way for us to determine up-front if every pixel in the bitmap is opaque, and then store that value so we can efficiently query it later. + +The `Bitmap` type also has a second convenience initializer which creates an image filled with a solid color. Find the following method in the extension block below: + +```swift +init(width: Int, height: Int, color: Color) { + self.pixels = Array(repeating: color, count: width * height) + self.width = width +} +``` + +and add this line to it: + +```swift +self.isOpaque = color.isOpaque +``` + +Now, in `Bitmap.drawColumn()`, replace the loop: + +```swift +for y in max(0, start) ..< min(self.height, end) { + let sourceY = (Double(y) - point.y) * stepY + let sourceColor = source[sourceX, Int(sourceY)] + blendPixel(at: Int(point.x), y, with: sourceColor) +} +``` + +with: + +```swift +if source.isOpaque { + for y in max(0, start) ..< min(self.height, end) { + let sourceY = (Double(y) - point.y) * stepY + let sourceColor = source[sourceX, Int(sourceY)] + self[Int(point.x), y] = sourceColor + } +} else { + for y in max(0, start) ..< min(self.height, end) { + let sourceY = (Double(y) - point.y) * stepY + let sourceColor = source[sourceX, Int(sourceY)] + blendPixel(at: Int(point.x), y, with: sourceColor) + } +} +``` + +So now we completely avoid the cost of blending for opaque surfaces such as walls. If we run the performance test again we can see a 16% improvement - not bad! + +**Disclaimer:** It's unlikely you will see *exactly* the same result on your own machine. You may see a smaller improvement, or the performance may even appear to get *worse*. If so, run the tests a few times to make sure it wasn't a random dip. If the result is consistently worse, try reverting the `isOpaque` optimization and re-recording the baseline before trying again, as your original baseline value may have been a fluke. + +Performance after adding bitmap opacity check + +There's no point applying the same optimization to `Bitmap.tint()` because the way it's used means that overlays will almost always be translucent. But given that `isOpaque` worked so well, maybe there's a way we can optimize `blendPixel()` after all? + +### Clear as Crystal + +The `isOpaque` trick works because wall images are completely opaque, however it doesn't help with sprites, where some pixels are opaque and others aren't. + +But even with sprites, many of the pixels *are* opaque, and others are completely transparent. Transparent pixels can be handled even more efficiently than opaque ones since we can skip them completely. But of course no image is *completely* transparent, so to take advantage of this we'll have to check the alpha for each pixel as we draw it. + +In `Bitmap.swift`, update the `blendPixel()` method as follows: + +```swift +private mutating func blendPixel(at x: Int, _ y: Int, with newColor: Color) { + switch newColor.a { + case 0: + break + case 255: + self[x, y] = newColor + default: + let oldColor = self[x, y] + let inverseAlpha = 1 - Double(newColor.a) / 255 + self[x, y] = Color( + r: UInt8(Double(oldColor.r) * inverseAlpha) + newColor.r, + g: UInt8(Double(oldColor.g) * inverseAlpha) + newColor.g, + b: UInt8(Double(oldColor.b) * inverseAlpha) + newColor.b + ) + } +} +``` + +This does add a slight overhead for each pixel, but in return we avoid the cost of blending entirely for fully opaque or fully transparent pixels. Run the tests again and let's see if it helped. + +Performance after adding pixel opacity check + +Whoa... A whopping *45% improvement* - we've almost doubled our frame rate! + +We didn't update the baseline after the last set of changes, so that 45% includes the 16% we got from `Bitmap.isOpaque`, but even so it's pretty huge. + +### Now You See Me... + +Now that we've made a significant improvement to performance, let's run the Time Profiler again. + +![Time Profiler trace after optimizing blendPixels()](Images/OptimizedBlendTrace.png) + +Thanks to our optimizations, `blendPixel()` is now taking up only 6.2% of the frame time (down from 24%) and has been bumped down to number five in the list. The new highest priority is `Bitmap.drawColumn()`. + +Wait a moment though - shouldn't the changes we just made have improved `drawColumn()` as well? Why has it gotten *slower*? Have we just shifted the performance overhead from one method to another? + +Let's take a look at the `drawColumn()` implementation again. + +```swift +mutating func drawColumn(_ sourceX: Int, of source: Bitmap, at point: Vector, height: Double) { + let start = Int(point.y), end = Int(point.y + height) + 1 + let stepY = Double(source.height) / height + if source.isOpaque { + for y in max(0, start) ..< min(self.height, end) { + let sourceY = (Double(y) - point.y) * stepY + let sourceColor = source[sourceX, Int(sourceY)] + self[Int(point.x), y] = sourceColor + } + } else { + for y in max(0, start) ..< min(self.height, end) { + let sourceY = (Double(y) - point.y) * stepY + let sourceColor = source[sourceX, Int(sourceY)] + blendPixel(at: Int(point.x), y, with: sourceColor) + } + } +} +``` + +Previously this method called `blendPixel()` in a tight loop. Now, the loop *either* calls `blendPixel()` *or* it sets the pixel color directly via `Bitmap.subscript`. + +In the first trace we ran, `Bitmap.subscript.setter` was the second highest line item after `Bitmap.blendPixel()`. You would expect that `Bitmap.subscript.setter` would now be the highest item in the list, but it seems to have vanished completely from the trace. What's going on? + +When you run your app in the Profiler it uses an *optimized* build (equivalent to Release mode). This makes sense because we are interested in how it will perform in the App Store, not how it performs in Debug mode. But the optimizer has a broad remit to make changes to the code structure, and one of the changes it makes is [method inlining](https://en.wikipedia.org/wiki/Inline_expansion). + +Due to inlining, the method you need to optimize *may not appear in traces at all*. The subscripting code is still there, but as far as the profiler is concerned that code is now just part of `drawColumn()`. + +Optimization is a bit like detective work. When examining a trace, it's not enough to look at which methods appear - you also need to keep an eye out for methods that are conspicuous by their absence. We have some pretty good circumstantial evidence that `Bitmap.setter()` is our performance bottleneck, but how can we *prove* it? + +Swift has an `@inline()` attribute that can be used to override the optimizer. In general, I don't recommend using this unless you are extremely confident that you know better than the compiler, but I see no harm in using it to test our theory. + +In `Bitmap.subscript(x:y:)` replace the line: + +```swift +set { +``` + +with: + +```swift +@inline(never) set { +``` + +This tells Swift *never* to inline the subscript setter. Run the time profiler again and you'll see that our suspicions have been confirmed. + +![Time Profiler trace after disabling inlining of subscript setter](Images/NoInliningSubscript.png) + +### Set Cost + +Before we do anything else, remove the `@inline(never)` again so it doesn't negatively affect our performance. + +Next, let's take a closer look at the `Bitmap.subscript` setter: + +```swift +set { + guard x >= 0, y >= 0, x < width, y < height else { return } + pixels[y * width + x] = newValue +} +``` + +There are 4 range comparisons and some math happening for each pixel we write to the screen. The comparisons are a safety check which we added in [Part 1](Part1.md) to prevent crashes if we try to draw outside the bitmap. We can skip those checks in places like `drawColumn()` where we already constrain the loop bounds to the bitmap height. + +In `Bitmap.drawColumn()`, replace the line: + +```swift +self[Int(point.x), y] = sourceColor +``` + +with: + +```swift +pixels[y * width + Int(point.x)] = sourceColor +``` + +Run the tests again (`Cmd-U`) and see if that helps the performance at all. + +Performance after inlining setter + +That's almost another 10% percent faster - not massive, but certainly worth doing. + +We've bypassed the range checks by manually inlining the pixel index calculation, but not the multiplication and addition, which are needed to convert between a 2D pixel coordinate and the linear array index. Since we are stepping through a vertical column of pixels, each pixel should be exactly `width` pixels offset from the previous one, so if we keep a running index we can get rid of the multiplication. + +Add the following line just before `if source.isOpaque {`: + +```swift +var index = max(0, start) * width + Int(point.x) +``` + +Then replace the line: + +```swift +pixels[y * width + Int(point.x)] = sourceColor +``` + +with: + +```swift +pixels[index] = sourceColor +index += width +``` + +And if we run the tests again... oh. + +Performance after removing multiplication + +After eliminating the multiplication, the performance is no better - maybe even a little worse. So what happened? + +It's important to remember that the compiler is pretty smart. The chances are, if we can spot something simple like a multiplication that can be replaced by addition, the compiler can too. My guess is that it was already performing this optimization for us, so doing it manually ourselves gains us nothing. + +Instead of wasting our time on fruitless micro-optimizations, let's see if we can find some bigger wins elsewhere. + +### Out of Order + +We know that most of the drawing code goes through the `drawColumn()` method. It's pretty fundamental to the design of the ray-casting engine that everything in the world is drawn using vertical strips. + +When we created the `Bitmap` type in [Part 1](Part1.md) we decided to lay out the pixels in row order. This made a lot of sense at the time because it's how native iOS image data is arranged, which makes it easy to convert to and from `UIImage` without needing to swizzle the pixels. + +But we spend a lot more of our frame time reading and writing pixels from bitmaps inside the Engine module than we do converting to and from `UIImage`, and in most cases we are accessing the pixels column-by-column rather than row-by-row. + +Switching `Bitmap.pixels` to column-order would simplify the math for stepping through columns, but based on what we've just seen it's possible that wouldn't improve performance much beyond what the compiler can do for us. There's a much more important reason why it's worth aligning our pixel data with the order we access it though, and that's [cache locality](https://en.wikipedia.org/wiki/Locality_of_reference). + +Computer memory is arranged in a [hierarchy](https://en.wikipedia.org/wiki/Memory_hierarchy). At the top of the hierarchy are the CPU registers. There are only a handful of these, but they are super-fast to access. At the bottom of the hierarchy is main memory[[2]](#footnote2). This is normally numbered in gigabytes, and dwarfs the register capacity, but it is hundreds or even thousands of times slower to access. + +In order to make up the performance gap between registers and main memory, modern CPUs include several layers of [cache](https://en.wikipedia.org/wiki/CPU_cache), known as L1 cache, L2 cache, etc. Each level of cache is progressively larger and slower. + +The memory hierarchy + +The key to good performance is to try to keep the data you are working on as near to the top of the hierarchy as possible. + +The first time you try to access some data in main memory, the processor copies a 64-byte chunk of that data into the cache. That chunk is known as a *cache line*. Subsequent accesses to data inside the same chunk are essentially free, but accessing data outside the cache line will force another line to be fetched from memory, which takes much longer. + +The most efficient way to access data is therefore to do it *sequentially*, because this maximizes the chances that subsequent data accesses will land in the same cache line. Predictable data access patterns also allow the CPU to preemptively fetch cache lines before they are needed, reducing the access latency even further. + +So how does this relate to `Bitmap`? Well, each pixel in the bitmap is 4 bytes in size, so we can only fit around 16,000 pixels in a single cache line. The screen is about 1000 pixels wide, so that means the CPU can only fetch ~16 rows at a time. If we are looping through a vertical column of pixels, every 16th pixel we access will be in a separate cache line. But if the pixels were stored in column order, the entire column would fit in a single line. + +In practice, the processor can fetch and store multiple cache lines from different parts of main memory at once, and given the regularity of the access pattern we are using, it's likely that the processor is already making up most of the cost of our out-of-order accesses. But it's worth at least *trying* to structure our data to match our usage patterns. + +To switch the pixel layout, we're going to have to make a few changes in `Bitmap.swift`. First, we'll replace the stored `width` property with `height`, and update `init()`: + +```swift +public struct Bitmap { + public private(set) var pixels: [Color] + public let height: Int + public let isOpaque: Bool + + public init(height: Int, pixels: [Color]) { + self.height = height + self.pixels = pixels + self.isOpaque = pixels.allSatisfy { $0.isOpaque } + } +} +``` + +Next, in the extension, replace the computed `height` var with: + +```swift +var width: Int { + return pixels.count / height +} +``` + +The `subscript` will also need to be updated as follows: + +```swift +subscript(x: Int, y: Int) -> Color { + get { return pixels[x * height + y] } + set { + guard x >= 0, y >= 0, x < width, y < height else { return } + pixels[x * height + y] = newValue + } +} +``` + +And in `init(width:height:color:)`, replace the line: + +```swift +self.width = width +``` + +with: + +```swift +self.height = height +``` + +The `drawColumn()` function will now be broken because the optimizations we made previously were row-order specific. To fix it, replace: + +```swift +var index = max(0, start) * width + Int(point.x) +``` + +with: + +```swift +let offset = Int(point.x) * self.height +``` + +Then replace the lines: + +```swift +pixels[index] = sourceColor +index += width +``` + +with: + +```swift +pixels[offset + y] = sourceColor +``` + +Finally, in `UIImage+Bitmap.swift`, in the `Bitmap` extension block, replace: + +```swift +self.init(width: cgImage.width, pixels: pixels) +``` + +with: + +```swift +self.init(height: cgImage.height, pixels: pixels) +``` + +Let's try running the tests again and see what difference we've made. + +Performance after switching to column order + +That's about another 10% improvement. A bit disappointing, but we're not done yet. + +Before we make any further changes, let's run the game quickly and check everything still works after the switch to column order. + +![Scrambled output](Images/ScrambledOutput.png) + +Ha ha, whoops! It seems like the change we made in `UIImage+Bitmap.swift` wasn't quite sufficient to adapt to the new pixel layout. We'll need to swap over all the references to `width` and `height` in the `UIImage` extension so that it looks like this: + +```swift +public extension UIImage { + convenience init?(bitmap: Bitmap) { + let alphaInfo = CGImageAlphaInfo.premultipliedLast + let bytesPerPixel = MemoryLayout.size + let bytesPerRow = bitmap.height * bytesPerPixel + + guard let providerRef = CGDataProvider(data: Data( + bytes: bitmap.pixels, count: bitmap.width * bytesPerRow + ) as CFData) else { + return nil + } + + guard let cgImage = CGImage( + width: bitmap.height, + height: bitmap.width, + bitsPerComponent: 8, + bitsPerPixel: bytesPerPixel * 8, + bytesPerRow: bytesPerRow, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGBitmapInfo(rawValue: alphaInfo.rawValue), + provider: providerRef, + decode: nil, + shouldInterpolate: true, + intent: .defaultIntent + ) else { + return nil + } + + self.init(cgImage: cgImage) + } +} +``` + +Let's try that again... + +![Rotated output](Images/RotatedOutput.png) + +Nope! I guess we also need to rotate the output image since we flipped the rows and columns? Fortunately iOS makes it quite easy to do this without having to actually swizzle the pixels. In `UIImage.init()` change the line: + +```swift +self.init(cgImage: cgImage) +``` + +to: + +```swift +self.init(cgImage: cgImage, scale: 1, orientation: .leftMirrored) +``` + +That tells iOS that the image data is stored left-side-up, and that it should rotate the image at display time to compensate. Let's see if it helps. + +![Rotated output](Images/RotatedSprites.png) + +LOL! Well, the world is now the right way up, but the textures are all rotated 90 degrees and flipped, and it looks like the monsters are walking on the walls[[3]](#footnote3). + +This isn't a problem with `UIImage.init()`, it's actually the `Bitmap` initializer that we need to fix this time. We could cheat and just pre-rotate all our texture images before importing them[[4]](#footnote4), but it would be nicer if we could keep the pixel layout as an internal implementation detail as much as possible. + +Still in `UIImage+Bitmap.swift`, go ahead and flip all the `height` and `width` references inside `Bitmap.init()`: + +```swift +public extension Bitmap { + init?(image: UIImage) { + guard let cgImage = image.cgImage else { + return nil + } + + let alphaInfo = CGImageAlphaInfo.premultipliedLast + let bytesPerPixel = MemoryLayout.size + let bytesPerRow = cgImage.height * bytesPerPixel + + var pixels = [Color](repeating: .clear, count: cgImage.width * cgImage.height) + guard let context = CGContext( + data: &pixels, + width: cgImage.height, + height: cgImage.width, + bitsPerComponent: 8, + bytesPerRow: bytesPerRow, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: alphaInfo.rawValue + ) else { + return nil + } + + context.draw(cgImage, in: CGRect(origin: .zero, size: image.size)) + self.init(height: cgImage.width, pixels: pixels) + } +} +``` + +Finally, to actually rotate the image, we need to do the inverse of the transform we performed in `UIImage.init()`, and replace the line: + +```swift +context.draw(cgImage, in: CGRect(origin: .zero, size: image.size)) +``` + +with: + +```swift +UIGraphicsPushContext(context) +UIImage(cgImage: cgImage, scale: 1, orientation: .left).draw(at: .zero) +UIGraphicsPopContext() +``` + +### Loose Ends + +There are still a handful of places where we either aren't taking full advantage of the column-order optimization, or where it may actually be making things *slower*, because we are still enumerating pixels in row-first order. + +We'll start with the `Bitmap.fill()` function. We aren't using it in the game currently, but we don't want to forget to update it. In `fill()`, swap the lines: + +```swift +for y in Int(rect.min.y) ..< Int(rect.max.y) { + for x in Int(rect.min.x) ..< Int(rect.max.x) +``` + +so they read: + + +```swift +for x in Int(rect.min.x) ..< Int(rect.max.x) { + for y in Int(rect.min.y) ..< Int(rect.max.y) { +``` + +That ensures that the pixels are being accessed in column-first order, giving the CPU cache an easier time. + +Next, let's take a look at the `tint()` function: + +```swift +mutating func tint(with color: Color, opacity: Double) { + let alpha = min(1, max(0, Double(color.a) / 255 * opacity)) + let color = Color( + r: UInt8(Double(color.r) * alpha), + g: UInt8(Double(color.g) * alpha), + b: UInt8(Double(color.b) * alpha), + a: UInt8(255 * alpha) + ) + for y in 0 ..< height { + for x in 0 ..< width { + blendPixel(at: x, y, with: color) + } + } +} +``` + +Here we are also processing pixels in row-first order, but there's actually a more significant optimization to be made here than just swapping the loops like we did in `fill()`. + +Because `tint()` is always applied to every pixel in the bitmap, we don't need to use X, Y indexing, we can just process the pixels in order. To do that, we'll first need to update `blendPixel()` to use linear indexing, as follows: + +```swift +private mutating func blendPixel(at index: Int, with newColor: Color) { + switch newColor.a { + case 0: + break + case 255: + pixels[index] = newColor + default: + let oldColor = pixels[index] + let inverseAlpha = 1 - Double(newColor.a) / 255 + pixels[index] = Color( + r: UInt8(Double(oldColor.r) * inverseAlpha) + newColor.r, + g: UInt8(Double(oldColor.g) * inverseAlpha) + newColor.g, + b: UInt8(Double(oldColor.b) * inverseAlpha) + newColor.b + ) + } +} +``` + +Then in `tint()` replace the lines: + +```swift +for y in 0 ..< height { + for x in 0 ..< width { + blendPixel(at: x, y, with: color) + } +} +``` + +with: + +```swift +for i in pixels.indices { + blendPixel(at: i, with: color) +} +``` + +And finally, in `drawColumn()` replace the line: + +```swift +blendPixel(at: Int(point.x), y, with: sourceColor) +``` + +with: + +```swift +blendPixel(at: offset + y, with: sourceColor) +``` + +That's it for the `Bitmap` changes. There's just one other place where our code assumes we're using row-order pixels. + +In `Renderer.swift` in the `// Effects` block, swap the lines: + +```swift +for y in 0 ..< bitmap.height { + for x in 0 ..< bitmap.width { +``` + +to: + +```swift +for x in 0 ..< bitmap.width { + for y in 0 ..< bitmap.height { +``` + +Now run the tests again and see how we're doing. + +Performance after applying column-order fixes everywhere + +We've gained another couple of %, but I think it's time to accept that we may have reached the point of diminishing returns, at least for bitmap-related optimizations. + +Before we down tools though, there is one last improvement that we can get, essentially for free. + +### Safeties Off + +By default, Swift performs safety checks to prevent array bounds violations and integer overflows. These checks aren't designed to prevent crashes - on the contrary, they are designed to *force* a crash, instead of allowing more insidious problems such as silent memory corruption or undefined output. But there is a runtime cost to these checks, and we'd rather not pay it. + +We can bypass the numeric overflow checks by replacing integer operators like `+`, `-`, `*` and `/` with their unchecked variants `&+`, `&-`, `&* and `&/`, but that's pretty tedious and it won't help with array bounds checks. Instead, let's just disable safety checks completely for the Engine module. + +In the Build Settings for the Engine target, find the *Disable Safety Checks* option, and set it to *Yes* for Release builds. + +Disabling Safety Checks for the Engine target + +Let's see how that affects the performance: + +Performance after disabling safety checks + +Great! That brings our frame time down by another 7%, for a total saving of 73%. + +So after all those optimizations, what are now the top functions on the Time Profiler trace? + +![Time Profiler trace after applying column-order optimizations](Images/OptimizedOrderTrace.png) + +At the very top is `Renderer.draw()`, which is not surprising since it's the primary call site for all the code we've been working on. + +There are undoubtedly many optimization opportunities inside `Renderer.draw()` itself, but we aren't done adding features to the game yet, and making optimizations to the rendering logic at this stage is likely to make that code harder to change, which is why we've mostly restricted our changes to the code inside `Bitmap`[[5]](#footnote5). + +The function that shows up a couple of lines *below* `Renderer.draw()` is a bit more surprising - it seems that almost 11% of our frame time is now being spent in the computed getter for `Bitmap.width`. + +### Does Not Compute + +It's hard to believe such a trivial function could be a bottleneck, but - as we mentioned earlier - performance is often counter-intuitive. In any case, switching `width` to a stored property instead of a computed property should be a trivial fix. + +In `Bitmap.swift`, in the `Bitmap` struct itself, replace the line: + +```swift +public let height: Int +``` + +with: + +```swift +public let width, height: Int +``` + +Then in `init(height:pixels:)`, add the line: + +```swift +self.width = pixels.count / height +``` + +A little further down, in the extension block, delete the computed property: + +```swift +var width: Int { + return pixels.count / height +} +``` + +And finally, in `init(width:height:color:)` add the line: + +```swift +self.width = width +``` + +If we run the tests again, we should expect to see around another 10% performance boost. And we do! + +Performance after switching to stored width + +All in all, we've reduced rendering time by 83% - an almost *6X improvement* in frame rate - and we've done it without significantly complicating the code, or hampering our ability to add new features. + +Performance testing can be frustrating, counterintuitive and full of dead ends. But hopefully I've convinced you that with the right tools, and a rigorous approach, it can also be quite rewarding. + +If you are interested in learning more about optimizing Swift code, [Apple has published some tips](https://github.com/apple/swift/blob/master/docs/OptimizationTips.rst) on the Swift Github page, including several we didn't cover here. + +That's all for Part 9. In this part we: + +* Created a performance test harness for making accurate frame time measurements +* Used the Time Profiler to identify bottlenecks in the code +* Switched our bitmaps to column-order +* Disabled safety checks +* Made many other minor improvements, adding up to a huge overall performance win + +With these optimizations in place, we're in good shape to add more graphics and gameplay features in [Part 10](Part10.md). + +
+ +[[1]](#reference1) A very good vintage. + +[[2]](#reference2) You can actually extend the hierarchy to include the disk drive, and then network storage, etc. In a sense these are forms of memory, each layer exponentially larger in capacity and proportionally slower to access. + +[[3]](#reference3) The game is actually fully playable like this, and I highly recommend you try it because it's hilarious. + +[[4]](#reference4) Which is exactly how Wolfenstein 3D handled it - Wolfenstein's sprites were originally pre-rotated by 90 degrees so they could be efficiently rendered column-by-column. + +[[5]](#reference5) Although that's also where most of the bottlenecks have been anyway. +