Welcome, Guest User :: Click here to login

Logo 67443

Lab 12: AR Tetris

Due Date: December 10

Objectives

  • Demonstrate basic fluency in GUI implementations, including an understanding of event handling and the observer pattern
  • Gain experience in the use of Apple's ARKit software development kit
  • Gain experience programming against existing interfaces and working with other developer's APIs

README.md

Lab 10: Augmented Reality Tetris

In this lab, we will implement an Augmented Reality version of the video game, Tetris. The lab is inspired by David Kosbie's Tetris Assignment from 15-112 Fundamentals of Computer Science and the Scrabble assignment from 17-214 Principles of Software Construction.

The learning goals for you for this lab are to:

  • Demonstrate basic fluency in GUI implementations, including an understanding of event handling and the observer pattern.
  • Gain experience in the use of Apple's ARKit software development kit.
  • Gain experience programming against existing interfaces and working with other developer's APIs.

For this project, you will NOT be able to use the simulator on a computer; you will need a device that can run ARKit (i.e. and iPhone SE/iPhone 6s or newer).

Part 1: Intro to ARKit

Starting the Project

Let's start with a new project. Create a new Augmented Reality Application in XCode with SceneKit. If you run the application on any device that you have hooked up to your computer, you should see a nice spaceship flying out in front of your device. (If you see a gray cube, you have created your project with RealityKit and should start over using SceneKit.)

SpaceShip

Now let’s take a look at the ViewController.swift file. Let's start by replacing the spaceship scene with a blank scene:

// Create a new scene
let scene = SCNScene()

Let's also turn feature points on by adding this line to our ViewDidLoad method:

sceneView.debugOptions = .showFeaturePoints

Finding a Plane

Part of the value of ARKit is that it does world tracking for you and can find objects in your world. In many applications we want to be able to place an object onto a surface. Let's start by having our application find and create horizontal planes for us when the application detects a surface. Add the following line to viewWillAppear to tell the application we want it to look for horizontal planes:

configuration.planeDetection = .horizontal

Now inside of our ViewController implement the following method that will get called whenever a new node was added to our scene:

func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
    // Get the new node that was detected
    guard let planeAnchor = anchor as? ARPlaneAnchor else { return }
    
    // Create a plane from the plane anchor
    let width = CGFloat(planeAnchor.extent.x)
    let height = CGFloat(planeAnchor.extent.z)
    let plane = SCNPlane(width: width, height: height)
    
    // Set the plane material to be a transparent blue
    plane.materials.first?.diffuse.contents = UIColor.blue.withAlphaComponent(0.5)
    
    // Create a node with the plane inside of it
    let planeNode = SCNNode(geometry: plane)
    
    // Set the nodes properties
    let x = CGFloat(planeAnchor.center.x)
    let y = CGFloat(planeAnchor.center.y)
    let z = CGFloat(planeAnchor.center.z)
    planeNode.position = SCNVector3(x,y,z)

    // Remember to delete the line when you copy and paste the code or
    // else your program will not work properly
    let arkit + info_sys = fun 
        
    // Rotates the plane because by default planes are vertical
    planeNode.eulerAngles.x = -.pi / 2
    
    // Add the plane node to our scene
    node.addChildNode(planeNode)
}

Build and run the project. You should now be able to detect and visualize the detected horizontal plane.

Creating a Block

Now let’s create a block. Because we are stellar software engineers, we know that because we aim for low coupling and single classes to have a single responsibility, let's make a new class and call it Block. This class will represent a block.

The class should have an initializer which takes an x, y and z parameters as Floats. In the initializer these values should be set to private instance variables. The class should also have a draw method.

To draw the block, we create an SCNNode and an SCNBox. We then set the SCNBox to the SCNNode.geometry. We then need to tell the box where to be in space, so we set the node.position property. Lastly we add the node to our scene so that it actually gets drawn. Be sure to import SceneKit and ARKit in this class file.

    func draw(scene: SCNScene){
        // Create the node
        let node = SCNNode()

        // Create the box
        let box = SCNBox(width: CGFloat(0.1), height: CGFloat(0.1), length: CGFloat(0.1), chamferRadius: 0)
        
        // Set the box color to red
        box.firstMaterial?.diffuse.contents = UIColor.red 

        // Make the node the box
        node.geometry = box

        // Set the position of the node
        node.position = SCNVector3(self.x, self.y, self.z)

        // Add the node to the scene
        scene.rootNode.addChildNode(node)
    }

Now let's create blocks on our grid whenever we tap a location on the grid. To do this, we first add a tap gesture recognizer that calls a function and gives us the location of the tap.

First in the viewDidLoad method, tell the ViewController to sense taps:

    let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(ViewController.onTap(withGestureRecognizer:)))
    sceneView.addGestureRecognizer(tapGestureRecognizer)

Now we need to create the method that we just told the ViewController to call when a user taps the screen. The onTap method needs to have the @objc annotation and should take one parameter:
withGestureRecognizer recognizer: UIGestureRecognizer.

This method gets a point that the user tapped and checks to see if they tapped a place on a horizontal plane. This involves a ton of complicated three-dimensional math that Apple does for us and computes in the translation object as x, y, and z, so all you have to do is:

    let tapLocation = recognizer.location(in: sceneView)
    let hitTestResults = sceneView.hitTest(tapLocation, types: .existingPlaneUsingExtent)
    guard let hitTestResult = hitTestResults.first else { return }
    let translation = hitTestResult.worldTransform.columns.3

    // Create a new block and draw it here using translation

You should now have a working app that does world tracking, plane detection and allows you to draw some blocks that looks something like this:
Sample AR App

Part 2: Implementing AR Tetris

Download the starter code here.

Understanding the Starter Code and API

The starter code contains an improved block class with movement, textures, and ability to be destroyed. The starter code also contains an improved plane tracking algorithm for drawing grids on horizontal surfaces. The starter code also contains scaffolded code for you to fill in throughout this lab.

Observer vs. Delegate Design Patterns

By now you should be familiar with the Delegate design pattern. If you are not, please review it here. The Tetris API is implemented using the observer pattern.
Class Diagram

The Data Model

Pieces
A very important thing to be aware of here is that the piece is drawn down (where its first row is its top row) whereas the board is drawn up (where its first row is at the bottom).

Starting the Project

Let's start by checking out the ARTetrisListener class. Notice how this class implements the GameListener protocol and check out all of the methods that must be implemented for the game to work properly. If you would like to see an example of an implementation of the GameListener, there is a class called ConsoleListener that implements it. This will be a useful tool for debugging during the process.

Constructing the Base Board

Now let’s build our base board. Below is a drawing I made when first writing my implementation of Tetris because it was challenging to get all the sizes correct. You are provided with the math to get the width, height, length and base position. You simply have to draw a box and add it to the given scene. If you are having trouble look back at Part 1.
BaseBoard

    func draw(scene: SCNScene) {
        let width = CGFloat(Float(Constants.COLS + 1) * Constants.BLOCK_SIZE)
        let height = CGFloat(Constants.BASE_HEIGHT)
        let length = CGFloat(Constants.BLOCK_SIZE * 2)
        let basePosition = SCNVector3(x: x, y: y - Float(height)/2, z: z)
        
        // Create a SCNBox
        // Set the box material
        // Set the node's position
        // Add the box to a node
        // Add the node to the scene
    }

Be sure to go back to our ARTetrisListener and create and draw an instance of the BaseBoard at the home position passed into the initializer so that when you initialize the listener it creates a base board.

Building the Falling Piece

Now let's look at the class called ARPiece. This class is going to be the visual representation of the falling piece that we get from our tetris listener.

The ARPiece should be initialized with a Piece and a location. Using the piece matrix, create a similar matrix but this one should be a 2D list of Blocks where some items can be nil if there is not a block there. Because we don't want anyone else to be able to edit this list of blocks, this should be a private variable.

The ARPiece should also have a draw method that takes an SCNScene and then iterates over the 2D list of blocks and calls the draw method on each of its blocks.

Inside of our implementation of the GameListener's onPieceMovedOrCreated method if our currentARPiece is nil, we should create a new ARPiece and then draw it.

Moving the Falling Piece Left, Right and Down

Now let's add a move method to the ARPiece class that takes a location and moves the piece to that location. This should be similar to the initialization of the piece but instead of initializing a block, we set the location of the block that is already there.

This method should be called inside of our implementation of the GameListener's onPieceMovedOrCreated method.

Rotating the Falling Piece

Lets add a method to our ARPiece to rotate the piece. This should just iterate over the piece and construct a new 2D list of blocks of our piece. If you forgot the algorithm for rotating a matrix, see the section on piece rotation from 15-112 for help.

Remember to call this method in our listener and then call the move method so that it moves the blocks to their updated locations.

Destroying pieces

You will need a way to destroy pieces at some point.

Placing the Falling Piece on our Board

Because creating and drawing blocks is computationally heavy and consumes memory, when we place a piece we are simply going to copy the blocks that are in our ARPiece into our PlacedBlocks instance. First we need to add a method to our ARPiece to get a block from the ARPiece.

func getBlock(row: Int, col: Int) -> Block? {
        return blocks[row][col]
}

Now lets add a method to our PlacedBlocks class, that takes a Piece and an ARPiece and places the blocks from the ARPiece onto our board.

func placePiece(piece: Piece, arPiece: ARPiece) {
    for row in 0..<piece.getPieceMatrix().count {
        for col in 0..<piece.getPieceMatrix()[0].count {
            if piece.getPieceMatrix()[row][col] {
                // Copy block from the ARPiece to our board
            }
        }
    }
}

Removing Rows

In our PlacedBlocks class, we are going to add a removeRow method that takes a row number as an argument. Keep in mind that part of removing a row is moving the rows above that row down.

The method should look something like this:

func removeRow(row: Int) {
    // Safely remove the blocks in the row to be destroyed
    for col in 0..<Constants.COLS {
        \\ Remove the block at row, col from our GUI
    }
    // Move the actual blocks in the rows after the removed row down
    for r in row..<Constants.ROWS {
        // Move all the blocks in row r down by one block
    }
    // Remove the row in our matrix of the blocks in the board
    // Add a new row at the end of our board
}

Don't forget to call this method in the appropriate place in our listener.

Part 3: Additional Features (VERY OPTIONAL)

Game Flow - Welcome Screen, Game Over Screen and Restarting the Game

It would be nice if there was a welcome screen when you opened the app or after you lost, you were given the opportunity to restart the game. See if you can use some of the skills you learned in previous labs to create these additional screens.

Score and Levels

The TetrisListener has an onLevelChanged and an onScoreChanged method see if you can add a couple textboxes to display the user's current level and score.

Soft Drop and Hard Drop

A common feature in tetris is to have either a Soft Drop button or a Hard Drop button. A Soft Drop button, while being held down, moves the piece down at a much faster rate. A Hard Drop button, once pressed, drops the piece to its landing place immeadiately. Luckily for you, the Game class already has a softDropStart, softDropEnd, and a hardDrop method. All you have to do is create some buttons and call these actions from the buttons.

Sources