Creating the application model

Until now, we have been concerned with modeling specific components that make up tetromino blocks. Now it is time to concern ourselves with defining application logic. We will create an application model to implement the necessary Tetris gameplay logic, as well as to serve as an intermediary interface between views and the block components we have created.

A view will send a request for the performance to the application model, and the model will execute the action if it is valid and send feedback to the view. Similar to the models we have created so far, we need a separate class file for the application model. Go ahead and create a new Kotlin file named AppModel.kt and add a class named AppModel to the file with imports for Point, FieldConstants, array2dOfByte, and AppPreferences:

package com.mydomain.tetris.models

import android.graphics.Point
import com.mydomain.tetris.constants.FieldConstants
import com.mydomain.tetris.helpers.array2dOfByte
import com.mydomain.tetris.storage.AppPreferences

class AppModel

Some functions of AppModel are to keep track of the current score, the tetris gameplay field state, the current block, the current state of the game, the current status of the game, and the motions being experienced by the current block. AppModel must also have direct access to values stored within the application's SharedPreferences file via the AppPreferences class we created. Catering to these different demands may seem daunting at first, but is easy as pie.

The first thing we must do is add the necessary constants that will be utilized by AppModel. We will need to create constants for the possible game statuses and the possible motions that can occur during gameplay. These constants are created with ease with the use of enum classes:

class AppModel {
enum class Statuses {
AWAITING_START, ACTIVE, INACTIVE, OVER
}

enum class Motions {
LEFT, RIGHT, DOWN, ROTATE
}
}

We created four status constants earlier. AWAITING_START is the status of the game before the game has been started. ACTIVE is the status in which the game exists when gameplay is currently in progress. OVER is the status that the game takes when the game ends.

Earlier in this chapter, it was stated that four distinct motions can occur on a block. Blocks can be moved to the right, to the left, up, down, and rotated. LEFT, RIGHT, UP, DOWN, and ROTATE are defined in the Motions enum class to represent these distinct motions.

Having added the constants required, we can proceed by adding the necessary class properties of AppModel, which are as follows:

package com.mydomain.tetris.models

import android.graphics.Point
import com.mydomain.tetris.constants.FieldConstants
import com.mydomain.tetris.helpers.array2dOfByte
import com.mydomain.tetris.storage.AppPreferences

class AppModel {
var score: Int = 0
private var preferences: AppPreferences? = null

var currentBlock: Block? = null
var currentState: String = Statuses.AWAITING_START.name

private var field: Array<ByteArray> = array2dOfByte(
FieldConstants.ROW_COUNT.value,
FieldConstants.COLUMN_COUNT.value
)

enum class Statuses {
AWAITING_START, ACTIVE, INACTIVE, OVER
}

enum class Motions {
LEFT, RIGHT, DOWN, ROTATE
}
}

score is an integer property that will be used to hold the current score of the player within a gaming session. preferences is a private property that will hold an AppPreferences object to provide direct access to the application's SharedPreferences file. currentBlock is a property that will hold the current block translating across the play field. currentState holds the state of the game. Statuses.AWAITING_START.name returns the name of Statuses.AWAITING_START in the form of an AWAITING_START string. The current state of the game is initialized to AWAITING_START immediately because this is the first state that GameActivity must transition into upon launch. Lastly, field is a two-dimensional array that will be used as the playing field for the game.

Next we must add a few setter and getter functions. These functions are setPreferences(), setCellStatus(), and getCellStatus(). Add the following functions to AppModel:

fun setPreferences(preferences: AppPreferences?) {
this.preferences = preferences
}

fun getCellStatus(row: Int, column: Int): Byte? {
return field[row][column]
}

private fun setCellStatus(row: Int, column: Int, status: Byte?) {
if (status != null) {
field[row][column] = status
}
}

The setPreferences() method sets the preferences property of AppModel to the AppPreferences instance passed as an argument to the function. The getCellStatus() method returns the status of a cell existing in a specified row-column position within the field's two-dimensional array. The setCellStatus() method sets the status of a cell existing in the field to a specified byte.

Functions for checking state are necessary in the model as well. These will serve as a medium to assert the state that the game is currently in. As we have three possible game statuses corresponding to three possible game states, three functions are required for each individual game state. These methods are isGameAwaitingStart(), isGameActive(), and isGameOver():

class AppModel {

var score: Int = 0
private var preferences: AppPreferences? = null

var currentBlock: Block? = null
var currentState: String = Statuses.AWAITING_START.name

private var field: Array<ByteArray> = array2dOfByte(
FieldConstants.ROW_COUNT.value,
FieldConstants.COLUMN_COUNT.value
)

fun setPreferences(preferences: AppPreferences?) {
this.preferences = preferences
}

fun getCellStatus(row: Int, column: Int): Byte? {
return field[row][column]
}

private fun setCellStatus(row: Int, column: Int, status: Byte?) {
if (status != null) {
field[row][column] = status
}
}

fun isGameOver(): Boolean {
return currentState == Statuses.OVER.name
}

fun isGameActive(): Boolean {
return currentState == Statuses.ACTIVE.name
}

fun isGameAwaitingStart(): Boolean {
return currentState == Statuses.AWAITING_START.name
}

enum class Statuses {
AWAITING_START, ACTIVE, INACTIVE, OVER
}

enum class Motions {
LEFT, RIGHT, DOWN, ROTATE
}
}

All three methods return Boolean values of either true or false depending on whether the game is existing in their respective states. So far, we have not made use of the score in AppModel. Let's add a function that can be used to increase the score value held by the score. We will name the function boostScore().

private fun boostScore() {
score += 10
if (score > preferences?.getHighScore() as Int)
preferences?.saveHighScore(score)
}

When called, boostScore() increases the current score of the player by 10 points, after which it checks whether the current score of the player is greater than the high score recorded in the preferences file. If the current score is greater than the saved high score, the high score is overwritten with the current score.

Having gotten the basic functions and fields up and running, we can progress to creating slightly more complicated functions. The first of these functions is generateNextBlock():

private fun generateNextBlock() {
currentBlock = Block.createBlock()
}

The generateNextBlock() function creates a new block instance and sets currentBlock to the newly created instance. 

Before going any further with method definitions, let's create one more enum class to hold constant cell values. Create a CellConstants.kt file in the constants package and add the following source code to it:

package com.mydomain.tetris.constants

enum class CellConstants(val value: Byte) {
EMPTY(0), EPHEMERAL(1)
}

You may be wondering what these constants are for. Recall when we created the Frame class to model a blocks frame, we defined addRow(), which took a string of 1s and 0s as its argument—with 1 representing cells that made up the frame and 0 representing cells excluded from the frame—and converted these 1s and 0s to byte representations. We are going to be manipulating these bytes in upcoming functions and we need to have corresponding constants for them.

Import the newly created enum class into AppModel. We will make use of it in the upcoming function:

private fun validTranslation(position: Point, shape: Array<ByteArray>): Boolean {
return if (position.y < 0 || position.x < 0) {
false
} else if (position.y + shape.size > FieldConstants.ROW_COUNT.value) {
false
} else if (position.x + shape[0].size > FieldConstants
.COLUMN_COUNT.value) {
false
} else {
for (i in 0 until shape.size) {
for (j in 0 until shape[i].size) {
val y = position.y + i
val x = position.x + j

if (CellConstants.EMPTY.value != shape[i][j] &&
CellConstants.EMPTY.value != field[y][x]) {
return false
}
}
}
true
}
}

Add the preceding validTranslation() method to AppModel. As the name implies, this function is used to check whether a translational motion of a tetromino in the playing field is valid based on a set of conditions. It returns a true  Boolean value if the translation is valid, and false otherwise. The first three conditionals test whether the position the tetromino is being translated in the field to is a valid one. The else block checks whether the cells the tetromino is attempting to translate into are empty. If they are not, false is returned.

We need a calling function for validTranslation(). We will declare moveValid() to serve this purpose. Add the following function to AppModel:

private fun moveValid(position: Point, frameNumber: Int?): Boolean {
val shape: Array<ByteArray>? = currentBlock?
.getShape(frameNumber as Int)
return validTranslation(position, shape as Array<ByteArray>)
}

moveValid() utilizes validTranslation() to check whether a move performed by the player is permitted. If the move is permitted, it returns true, otherwise false is returned. We need to create a few other important methods. These are generateField(), resetField(), persistCellData(), assessField(), translateBlock(), blockAdditionPossible(), shiftRows(), startGame(), restartGame(), endGame(), and resetModel().

We will firstly work on the generateField() method. Add the code shown below to AppModel.

fun generateField(action: String) {
if (isGameActive()) {
resetField()
var frameNumber: Int? = currentBlock?.frameNumber
val coordinate: Point? = Point()
coordinate?.x = currentBlock?.position?.x
coordinate?.y = currentBlock?.position?.y

when (action) {
Motions.LEFT.name -> {
coordinate?.x = currentBlock?.position?.x?.minus(1)
}
Motions.RIGHT.name -> {
coordinate?.x = currentBlock?.position?.x?.plus(1)
}
Motions.DOWN.name -> {
coordinate?.y = currentBlock?.position?.y?.plus(1)
}
Motions.ROTATE.name -> {
frameNumber = frameNumber?.plus(1)

if (frameNumber != null) {
if (frameNumber >= currentBlock?.frameCount as Int) {
frameNumber = 0
}
}
}
}

if (!moveValid(coordinate as Point, frameNumber)) {
translateBlock(currentBlock?.position as Point,
currentBlock?.frameNumber as Int)
if (Motions.DOWN.name == action) {
boostScore()
persistCellData()
assessField()
generateNextBlock()

if (!blockAdditionPossible()) {
currentState = Statuses.OVER.name;
currentBlock = null;
resetField(false);
}
}
} else {
if (frameNumber != null) {
translateBlock(coordinate, frameNumber)
currentBlock?.setState(frameNumber, coordinate)
}
}
}
}

generateField() generates a refresh of the field. This field refresh is determined by the action that is passed as the argument of generateField().

First, generateField() checks whether the game is currently in its active state when called. If the game is active, the frame number and coordinates of the block are retrieved. The action requested is then determined via the when expression. Having determined the requested action, the coordinates of the block are changed appropriately if the action requested is a leftward, rightward, or downward motion. If a rotational motion is requested, frameNumber is changed to an appropriate number of a frame that represents the tetromino in the rotation exerted.

The generateField() method then checks whether the motion requested is a valid motion via moveValid(). If the move is not valid, the current block is fixed in the field to its current position with the use of translateBlock().

The resetField(), persistCellData() and assessField() methods invoked by generateField() are given below. Add them to AppModel:

private fun resetField(ephemeralCellsOnly: Boolean = true) {
for (i in 0 until FieldConstants.ROW_COUNT.value) {
(0 until FieldConstants.COLUMN_COUNT.value)
.filter { !ephemeralCellsOnly || field[i][it] ==
CellConstants.EPHEMERAL.value }
.forEach { field[i][it] = CellConstants.EMPTY.value }
}
}

private fun persistCellData() {
for (i in 0 until field.size) {
for (j in 0 until field[i].size) {
var status = getCellStatus(i, j)

if (status == CellConstants.EPHEMERAL.value) {
status = currentBlock?.staticValue
setCellStatus(i, j, status)
}
}
}
}

private fun assessField() {
for (i in 0 until field.size) {
var emptyCells = 0;

for (j in 0 until field[i].size) {
val status = getCellStatus(i, j)
val isEmpty = CellConstants.EMPTY.value == status
if (isEmpty)
emptyCells++
}
if (emptyCells == 0)
shiftRows(i)
}
}

As you may have noticed, translateBlock() has not been implemented. Go ahead and add this method along with blockAdditionPossible(), shiftRows(), startGame(), restartGame(), endGame(), and resetModel() to AppModel is as follows: 

private fun translateBlock(position: Point, frameNumber: Int) {
synchronized(field) {
val shape: Array<ByteArray>? = currentBlock?.getShape(frameNumber)

if (shape != null) {
for (i in shape.indices) {
for (j in 0 until shape[i].size) {
val y = position.y + i
val x = position.x + j

if (CellConstants.EMPTY.value != shape[i][j]) {
field[y][x] = shape[i][j]
}
}
}
}
}
}

private fun blockAdditionPossible(): Boolean {
if (!moveValid(currentBlock?.position as Point,
currentBlock?.frameNumber)) {

return false
}
return true
}

private fun shiftRows(nToRow: Int) {
if (nToRow > 0) {
for (j in nToRow - 1 downTo 0) {
for (m in 0 until field[j].size) {
setCellStatus(j + 1, m, getCellStatus(j, m))
}
}
}

for (j in 0 until field[0].size) {
setCellStatus(0, j, CellConstants.EMPTY.value)
}
}

fun startGame() {
if (!isGameActive()) {
currentState = Statuses.ACTIVE.name
generateNextBlock()
}
}

fun restartGame() {
resetModel()
startGame()
}

fun endGame() {
score = 0
currentState = AppModel.Statuses.OVER.name
}

private fun resetModel() {
resetField(false)
currentState = Statuses.AWAITING_START.name
score = 0
}

In a scenario where the requested move is a downward motion and the move is not valid, it implies that the block has reached the bottom of the field. In this case, the player's score is boosted via boostScore() and the states of all cells in the field are persisted via persistCellData(). The assessField() method is then called to scan through the field row by row and check whether all cells in a row have been filled up:

private fun assessField() {
for (i in 0 until field.size) {
var emptyCells = 0;

for (j in 0 until field[i].size) {
val status = getCellStatus(i, j)
val isEmpty = CellConstants.EMPTY.value == status

if (isEmpty)
emptyCells++
}

if (emptyCells == 0)
shiftRows(i)
}
}

In the case where all cells in a row are filled up, the row is cleared and shifted by shiftRow(). After the assessment of the field is complete, a new block is generated with generateNextBlock():

private fun generateNextBlock() {
currentBlock = Block.createBlock()
}

Before the newly generated block can be pushed to the field, AppModel makes sure that the field is not already filled up and the block can be moved into the field with blockAdditionPossible():

private fun blockAdditionPossible(): Boolean {
if (!moveValid(currentBlock?.position as Point,
currentBlock?.frameNumber)) {
return false
}
return true
}

If block addition is not possible, that means all blocks have been stacked to the top edge of the field. This results in a game over. As a result, the current state of the game is set to Statuses.OVER and the currentBlock is set to null. Lastly, the field is cleared.

On the other hand, if the move was valid from the start, the block is translated to its new coordinates via translateBlock() and the state of the current block is set to its new coordinates and frameNumber.

With those additions in place, we have been able to successfully create the application model to handle the gameplay logic. Now we have to create a view that exploits AppModel.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.191.189.186