In the last post, I went over how to build an engine that will move the Tetrominos down. Now, let’s make it so users can move the Tetrominos.

Moving a Tetromino Down

As part of our engine, we already implemented moving a Tetromino down. However, this code all lives in our engine so we can’t really leverage it right now. If we pull that code out, we can use it for both our engine and for moving the Tetrominos. To pull this code out, we’ll make 2 functions.

The first function will be general moving function. This will try to move a Tetromino in any direction. The process of moving a Tetromino can be described as follows:

Make a new Tetromino in the spot we want to move it to See if that new Tetromino is valid If it’s valid, update our Tetromino to that new Tetrmino

The second function will be a wrapper around this general moving function that moves the Tetromino down.

class TetrisGameModel: ObservableObject { func runEngine(timer: Timer) { // Spawn a new block if we need to ... // See about moving block down if moveTetrominoDown() { print("Moving Tetromino down") return } // see if we need to place the block ... } func moveTetrominoDown() -> Bool { return moveTetromino(rowOffset: -1, columnOffset: 0) } func moveTetromino(rowOffset: Int, columnOffset: Int) -> Bool { guard let currentTetromino = tetromino else { return false } let newTetromino = currentTetromino.moveBy(row: rowOffset, column: columnOffset) if isValidTetromino(testTetromino: newTetromino) { tetromino = newTetromino return true } return false } }

Left, Right, and Drop Movements

The next three movements that Tetris players normally get are Left/Right movements and a Drop movement that drops the Tetromino all the way down.

The Left and Right movements are going to be very similar to our Down movement. We’ll call our moveTetromino function, but use the column offsets instead of row offsets.

The Drop movement is a little more complicated. We need to move the Tetromino down until we can’t move it down any further. The most elegant way I can think of for dropping a piece is a while loop with no code being executed in the body.

class TetrisGameModel: ObservableObject { ... func moveTetrominoLeft() -> Bool { return moveTetromino(rowOffset: 0, columnOffset: -1) } func moveTetrominoRight() -> Bool { return moveTetromino(rowOffset: 0, columnOffset: 1) } func dropTetromino() { while(moveTetrominoDown()) { } } }

Hooking This Up

On our front end, I’d like users to be able to swipe up/down/left/right to move Tetrominos. DragGestures are good for detecting these kinds of movements, so let’s try using this. If you’d like to learn more about Gestures, feel free to check out my Simple Gestures in SwiftUI post.

DragGestures, when activated, can call a function and pass in a DragGesture Value struct. This struct will let you know where the user first touched the screen (startLocation), how far they moved (translation), and where the user’s finger currently is (location).

My first thought is to use the translation to move the pieces. Whenever the translation’s magnitude in any direction is more than 10, when can call our moveTetromino[Direction] function. However, this wont work if a user continues to drag their finger after a movement happens.

Instead, we’ll save the first location that we get in the ViewModel. Whenever we notice that the current location is 10 points away from our original location, we’ll call the corresponding moveTetromino[Direction] function. After calling this, we’ll update the first location to be this latest current location so it all resets. When a user picks their finger up, we’ll set the first location to nil.

We’ll encapsulate all this logic inside of our ViewModel, and our View will just need to get the Gesture from the ViewModel.

class TetrisGameViewModel: ObservableObject { ... var lastMoveLocation: CGPoint? ... func getMoveGesture() -> some Gesture { return DragGesture() .onChanged(onMoveChanged(value:)) .onEnded(onMoveEnded(_:)) } func onMoveChanged(value: DragGesture.Value) { guard let start = lastMoveLocation else { lastMoveLocation = value.location return } let xDiff = value.location.x - start.x if xDiff > 10 { print("Moving right") let _ = tetrisGameModel.moveTetrominoRight() lastMoveLocation = value.location return } if xDiff < -10 { print("Moving left") let _ = tetrisGameModel.moveTetrominoLeft() lastMoveLocation = value.location return } let yDiff = value.location.y - start.y if yDiff > 10 { print("Moving Down") let _ = tetrisGameModel.moveTetrominoDown() lastMoveLocation = value.location return } if yDiff < -10 { print("Dropping") tetrisGameModel.dropTetromino() lastMoveLocation = value.location return } } func onMoveEnded(_: DragGesture.Value) { lastMoveLocation = nil } } struct TetrisGameView: View { ... var body: some View { GeometryReader { (geometry: GeometryProxy) in self.drawBoard(boundingRect: geometry.size) } .gesture(tetrisGame.getMoveGesture()) } }

Row Clearing

Not gonna lie, testing movement was a little unsatisfying without rows clearing when full. I know I didn’t implement the logic yet, but it was still sad to see it not happen. Before I do rotations, I have to fix this.

There are a few different algorithms to clearing a row. While I’m not extremely concerned with performance, I still want to avoid doing a ton of extra work if I don’t have to. I also don’t want to trigger UI updates if I don’t need to, so I don’t want to modify the board unless we need to clear a row. The best balance I could think of is this:

Create a new board

Starting at the bottom of the current board, for each row: If the current board’s row doesn’t need to get cleared, copy it over to the new board

If any rows were cleared, set the current board to this new board

Doing this should also only trigger our objectWIllChange.send() once if and only if we need to clear a row. We’ll also cross this board twice if we need to copy elements over, and once if we don’t.

We’ll plug this code into our engine before we try to spawn a Tetromino, and exit out of our game loop if we do clear a row. This will give the user a short pause whenever the row is cleared to see what happened.

class TetrisGameModel: ObservableObject { ... func runEngine(timer: Timer) { // Check if we need to clear a line if clearLines() { print("Line Cleared") return } // Spawn a new block if we need to ... // See about moving block down ... // see if we need to place the block ... } func clearLines() -> Bool { var newBoard: [[TetrisGameBlock?]] = Array(repeating: Array(repeating: nil, count: numRows), count: numColumns) var boardUpdated = false var nextRowToCopy = 0 for row in 0...numRows-1 { var clearLine = true for column in 0...numColumns-1 { clearLine = clearLine && gameBoard[column][row] != nil } if !clearLine { for column in 0...numColumns-1 { newBoard[column][nextRowToCopy] = gameBoard[column][row] } nextRowToCopy += 1 } boardUpdated = boardUpdated || clearLine } if boardUpdated { gameBoard = newBoard } return boardUpdated } }

Shadow

Wohoo! Row Clearing is working alright! However, it’s hard to know where the Tetromino is going to land. To make this easier, I really want to add a Shadow / Ghost piece.

For a Shadow piece, we’ll essentially need to Drop our Tetromino and draw it there. Since this is completely reliant on our Tetromino, I’ll make the Shadow a computed property.

class TetrisGameModel: ObservableObject { ... var shadow: Tetromino? { guard var lastShadow = tetromino else { return nil } var testShadow = lastShadow while(isValidTetromino(testTetromino: testShadow)) { lastShadow = testShadow testShadow = lastShadow.moveBy(row: -1, column: 0) } return lastShadow } }

When it comes time to draw this shadow, we can do this in our ViewModel’s gameBoard property. We can duplicate our code for drawing our Tetromino, except we’ll get the substitute in our Shadow. We’ll draw the Shadow first so our Tetromino will always be drawn on top. I’ll also need to add a bunch of lighter colors so that our Shadow doesn’t look exactly like our Tetromino.

class TetrisGameViewModel: ObservableObject { ... var gameBoard: [[TetrisGameSquare]] { var board = tetrisGameModel.gameBoard.map { $0.map(convertToSquare) } if let shadow = tetrisGameModel.shadow { for blockLocation in shadow.blocks { board[blockLocation.column + shadow.origin.column][blockLocation.row + shadow.origin.row] = TetrisGameSquare(color: getShadowColor(blockType: shadow.blockType)) } } if let tetromino = tetrisGameModel.tetromino { for blockLocation in tetromino.blocks { board[blockLocation.column + tetromino.origin.column][blockLocation.row + tetromino.origin.row] = TetrisGameSquare(color: getColor(blockType: tetromino.blockType)) } } return board } func getShadowColor(blockType: BlockType) -> Color { switch blockType { case .i: return .tetrisLightBlueShadow case .j: return .tetrisDarkBlueShadow case .l: return .tetrisOrangeShadow case .o: return .tetrisYellowShadow case .s: return .tetrisGreenShadow case .t: return .tetrisPurpleShadow case .z: return .tetrisRedShadow } } }

What Next?

We have the basic movements for down, so I think the next thing up is going to be Tetromino rotations and wall kicks.

Feel free to check out my Github Repo, or watch my tutorial about this on Youtube:

Thanks for reading!

– SchwiftyUI