Skip to content

Kunafa tutorial XO game

Kabbura edited this page Mar 15, 2019 · 2 revisions

Kunafa tutorial

Deprecation notice: This tutorial uses Kunafa 0.1.x. Use the new updated tutorials

Here we will dive a little deeper into kunafa to create an XO game. The end result is a game with 9 squares for the game where "X" player and "O" player turns will alternate after each play (clicking on of the empty squares). Also, we will have an auto generated buttons to keep track of the game history and can restore the game state when clicked. So let's start. If you don't want to follow along, you can get the final version of the code from this repository

The UI

To create the game UI, setup a new Kotlin-Js project and add Kunafa as a dependency. If you haven't done it before, follow the Getting statred guide.

The game board

To seperate the ui from the logic, let's create a view class. Under the main function, create the class AppView and add function setup() that will create the game ui. Then inside the main function, instaniate an instance and call the setup function. Your code should look like this.

fun main(args: Array<String>) {
    AppView().setup()
}

class AppView {
    fun setup() {
        
    }
}

Our function does not do anything yet, so let's change that now. The top level component of the page should always be page. So add that first.

    fun setup() {
        page {
            
        }
    }

The game consist of the board (to the left) and the control UI to right. The controll UI will have a status text and the buttons to reset the game. To layout the ui component on the screen we will use a layout manager. Add a horizontalLayout inside the page component. Give a width of matchParent, margin of 16.px. We want the game components to be centered on the screen, so we will set the justifyContent of the layout to center. Note that you'll need to add import statments as well. Here's how the code should look.

        page {
            horizontalLayout {
                width = matchParent
                margin = 16.px
                justifyContent = JustifyContent.Center
            }
        }

To create the game cells, we will need to vertical layout containing 3 horizontal layouts representing the rows, and each row containing 3 game cells. Let's add the layouts before the cells.

            horizontalLayout {
                width = matchParent
                margin = 16.px
                justifyContent = JustifyContent.Center
                verticalLayout {
                    horizontalLayout {

                    }
                    horizontalLayout {

                    }
                    horizontalLayout {
                        
                    }
                }
            }

Each cell will be a view containing a text view. Since layouts in Kunafa are actually HTML div elements, we will make eact cell a verticallayout containing a textView. Add one cell with the following params.

        page {
            horizontalLayout {
                width = matchParent
                margin = 16.px
                justifyContent = JustifyContent.Center
                verticalLayout { // The game board
                    horizontalLayout { // Row
                        verticalLayout { // The cell
                            width = 30.px
                            height = 30.px
                            background = Color.rgb(220, 220, 220)
                            justifyContent = JustifyContent.Center
                            textView {
                                width = matchParent
                                textSize = 18.px
                                textAlign = TextView.TextAlign.Center
                            }
                        }

                    }
                    horizontalLayout { // Row

                    }
                    horizontalLayout { // Row

                    }
                }
            }
        }

We gave the cell a fixed width and height of 30 pixels. The backgraound is light grey and made the text view centered vertically. The text view alignent is also center. Instead of duplicating the cell code to create the rest of the cells, lets extract it out. Create an extension function to LinearLayout called addCell. Let the function receive an integer index. We will use it to set the background color for now. If the index is even, the cell color will be light grey, otherwise, it'll be white.

    private fun LinearLayout.addCell(index: Int) {
        verticalLayout {
            width = 30.px
            height = 30.px
            background = if (index.rem(2) == 0) Color.rgb(220, 220, 220) else Color.white
            justifyContent = JustifyContent.Center
            textView {
                width = matchParent
                textSize = 18.px
                textAlign = TextView.TextAlign.Center
            }
        }
    }

Now add the rest of the cells.

                verticalLayout {
                    horizontalLayout {
                        addCell(0)
                        addCell(1)
                        addCell(2)

                    }
                    horizontalLayout {
                        addCell(3)
                        addCell(4)
                        addCell(5)

                    }
                    horizontalLayout {
                        addCell(6)
                        addCell(7)
                        addCell(8)

                    }
                }

If you build the project and opened the index.html in the browser, you should see the 9 cells on the screen. Before adding the board logic, let's first add a status text view that will show whose turn is it, and who is the winner. Inside the page component, we have already added a horizontal layout, containg one vertical layout (the game board). So let's add another vertical layout that will hold the control ui. Create a verticalLayout with fixed width of 200 pixels, padding at the start of 16 pixels and center items alignment. Inside it, add a textView for the status.

        page {
            horizontalLayout {
                width = matchParent
                margin = 16.px
                justifyContent = JustifyContent.Center
                verticalLayout { 
                /* The game rows and cells */
                }
                verticalLayout {
                    paddingStart = 16.px
                    width = 200.px
                    alignItems = Alignment.Center
                    textView {
                        text = "Turn: X player"
                    }
                }
            }
        }

This is enough for the UI. We will need to add the logic for the game now. Your file should look something like this.

package com.narbase.game

import com.narbase.kunafa.core.components.*
import com.narbase.kunafa.core.components.layout.Alignment
import com.narbase.kunafa.core.components.layout.JustifyContent
import com.narbase.kunafa.core.components.layout.LinearLayout
import com.narbase.kunafa.core.dimensions.dependent.matchParent
import com.narbase.kunafa.core.dimensions.independent.px
import com.narbase.kunafa.core.drawable.Color

fun main(args: Array<String>) {
    AppView().setup()
}

class AppView {
    fun setup() {
        page {
            horizontalLayout {
                width = matchParent
                margin = 16.px
                justifyContent = JustifyContent.Center
                verticalLayout {
                    horizontalLayout {
                        addCell(0)
                        addCell(1)
                        addCell(2)

                    }
                    horizontalLayout {
                        addCell(3)
                        addCell(4)
                        addCell(5)

                    }
                    horizontalLayout {
                        addCell(6)
                        addCell(7)
                        addCell(8)

                    }
                }
                verticalLayout {
                    paddingStart = 16.px
                    width = 200.px
                    alignItems = Alignment.Center
                    textView {
                        text = "Turn: X player"
                    }
                }
            }
        }
    }

    private fun LinearLayout.addCell(index: Int) {
        verticalLayout {
            width = 30.px
            height = 30.px
            background = if (index.rem(2) == 0) Color.rgb(220, 220, 220) else Color.white
            justifyContent = JustifyContent.Center
            textView {
                width = matchParent
                textSize = 18.px
                textAlign = TextView.TextAlign.Center
            }
        }
    }
}

The game logic

Kunafa does not put any restrictions on where the business logic code should go, but it makes it easier to separate the business logic from the UI. You can create a normal class that contains the business and that's fine. However, if you want to get notified with lifecycle events (e.g. when the view is created) you can extend the Presnter class and implement its functions. So, let's do that.

The presenter

Create a new file containing a class named AppPresenter. Let it extned the Presnter class and add an empty onViewCreated function.

class AppPresenter : Presenter() {
    override fun onViewCreated(view: View) {
        
    }
}

Instantiate the preseter at the beginning of the main function, and then pass to the presenter as a constructor paramenter.

fun main(args: Array<String>) {
    val presenter = AppPresenter()
    AppView(presenter).setup()
}

class AppView(val appPresenter: AppPresenter) {
    /*
    ....
    */
}

To receive the ui lifecycle events, we'll need to assign the presenter to a view presenter preperty. Let me explain this a bit. Each Kunafa view has a nullable presnter property. If it is set, it will be called during the view lifecycle. Since we want our presenter to listen to the page lifecycle, we will assign the the page presetner the appPresenter.

class AppView(val appPresenter: AppPresenter) {
    fun setup() {
        page {
            presenter = appPresenter

Adding an on click listener

We will need to update the game when a cell is clicked. Let's add a function that will handle the cells click. Inside the presenter, add a function called onCellClicked and let it receive the index of the cell.

  fun onCellClicked(index: Int) {
  
  }

Now go to the AppView class, and inside the function addCell add an onClick property to the vertical layout. Inside the onClick closure, call appPresenter.onCellClicked function.

        verticalLayout {
            width = 30.px
            height = 30.px
            background = if (index.rem(2) == 0) Color.rgb(220, 220, 220) else Color.white
            justifyContent = JustifyContent.Center
            textView {
                width = matchParent
                textSize = 18.px
                textAlign = TextView.TextAlign.Center
            }
            onClick = {
                appPresenter.onCellClicked(index)
            }
        }

Make sure that the onClick is outside the textView. Otherwise, it will be for the textView not for the whole layout.

Holding references to the UI elements

When a cell is clicked, we need to determine if it should text be "X" or "O" or if it should not change. We will need to hold references of the 9 cells text views. Inside the AppPresenter class, add cells property as follows.

    val cells = arrayOfNulls<TextView>(9)

Next, in AppView adjust the addCell function to return the cell text view.

    private fun LinearLayout.addCell(index: Int): TextView? {
        var cell: TextView? = null
        verticalLayout {
            width = 30.px
            height = 30.px
            background = if (index.rem(2) == 0) Color.rgb(220, 220, 220) else Color.white
            justifyContent = JustifyContent.Center
            cell = textView {
                width = matchParent
                textSize = 18.px
                textAlign = TextView.TextAlign.Center
            }
            onClick = {
                appPresenter.onCellClicked(index)
            }
        }
        return cell
    }

As you can see, the textView { } DSL function return the newly created TextView. We simply assign this value to a variable, i.e. cell and return it. Then, change the setup function and assign the return values of addCell functions to the appPresenter cell array.

                verticalLayout {
                    horizontalLayout {
                        appPresenter.cells[0] = addCell(0)
                        appPresenter.cells[1] = addCell(1)
                        appPresenter.cells[2] = addCell(2)
                    }
                    horizontalLayout {
                        appPresenter.cells[3] = addCell(3)
                        appPresenter.cells[4] = addCell(4)
                        appPresenter.cells[5] = addCell(5)
                    }
                    horizontalLayout {
                        appPresenter.cells[6] = addCell(6)
                        appPresenter.cells[7] = addCell(7)
                        appPresenter.cells[8] = addCell(8)
                    }
                }

To store the game state, we will add two properties inside AppPresenter class.

    private var turn = "X"
    private var gameEnded = false

The turn determines if the current turn is player X turn or player O, while the gameEnded determines if there is a winner or not hence no more plays are allowed. Create the following function inside AppPresenter.

    private fun flipTurn() {
        turn = if (turn == "X") "O" else "X"
    }

We will use this function to switch turns between the two players. Next, add the following code to onCellClicked function.

    fun onCellClicked(index: Int) {
        val cell = cells[index]
        if (cell?.text?.isNotEmpty() == true || gameEnded) return
        cell?.text = turn
        flipTurn()
    }

You can build and test the game untill now. You should be able to click on the game cells and their text will change. You will notice however that the status text to the right does not change. So let's fix that.

Add statusTextView property to the AppPresenter class.

    var statusTextView: TextView? = null

Then go to the setup function in AppView class, and assign the return value of textView { text = "Turn: X player" } to the appPresenter.statusTextView.

                verticalLayout {
                    paddingStart = 16.px
                    width = 200.px
                    alignItems = Alignment.Center
                    appPresenter.statusTextView = textView {
                        text = "Turn: X player"
                    }
                }

Now we can delete the text = "Turn: X player" from the view and set it inside the onViewCreated since it depends on the UI state.

                verticalLayout {
                    paddingStart = 16.px
                    width = 200.px
                    alignItems = Alignment.Center
                    appPresenter.statusTextView = textView {
                    }
                }

and then inside onViewCreated

    override fun onViewCreated(view: View) {
        statusTextView?.text = "Turn: $turn player"

    }

Now adjust the flipTurn function as follows.

    private fun flipTurn() {
        turn = if (turn == "X") "O" else "X"
        statusTextView?.text = "Turn: $turn player"
    }

Build the project, and you will be able to see the status text view change when the turn changes.

Check winning

After each turn, we want to see if the game is won. To do that, add these winning combinations to the AppPresenter class.

    private val winningCombinations = arrayListOf(
            arrayListOf(0, 1, 2),
            arrayListOf(3, 4, 5),
            arrayListOf(6, 7, 8),
            arrayListOf(0, 3, 6),
            arrayListOf(1, 4, 7),
            arrayListOf(2, 5, 8),
            arrayListOf(0, 4, 8),
            arrayListOf(2, 4, 6)
    )

Next, create the function getWinner ass follows.

    private fun getWinner(): String? {
        winningCombinations.forEach {
            if (cells[it[0]]?.text?.isNotEmpty() == true &&
                    cells[it[0]]?.text == cells[it[1]]?.text &&
                    cells[it[0]]?.text == cells[it[2]]?.text )
                return cells[it[0]]?.text
        }
        return null
    }

Finally, at the end of onCellClicked, add these lines.

        getWinner()?.let {
            statusTextView?.text = "$it is the winner!"
            gameEnded = true
        }

If everything is going fine, you should be able to play the game now and the game will stop when the game is won. The status text view will also change to reflect who the winner is. There isn't, however, yet a way to restart the game other than refreshing the page.

Restoring the game UI

After each turn, we want a button to be created under the status text view that can restore the game state when clicked. Hence, we will need to store what cells were checked each turn. Create historyEntries inside AppPresenter.

    private val historyEntries = arrayListOf<Int>()

This will be a choronological list of the indices of the checked cells. Thus, add the following line to onCellClicked function right before calling flipTurn.

        historyEntries.add(index)

The AppView needs to know how to add a button and remove the last added button. To do that, first we will need to get a reference to the parent layout of the buttons. Add this to AppView

    private var buttonsLayout: LinearLayout? = null

and assign it the return value of the control panel vertical layout

                buttonsLayout = verticalLayout {
                    paddingStart = 16.px
                    width = 200.px
                    alignItems = Alignment.Center
                    appPresenter.statusTextView = textView {
                    }
                }

Then create the function addHistoryButton

    fun addHistoryButton(text: String, index: Int) {
        buttonsLayout?.apply {
            button {
                button.textContent = text
                paddingTop = 8.px
            }
        }
    }

The buttons do not have onClick listener yet, but we will add it soon. The presenter does not have a reference of the view, so add

    var view: AppView? = null

inside the AppPresenter and then go to the AppView setup function and add this line first thing even beofe creating the page

        appPresenter.view = this

Now go to the presenter and inside onCellClicked function, after historyEntries.add(index) add

        view?.addHistoryButton("Reset: $turn at cell: $index", index)

Great! Now, After each turn, a new button will be created. However, it does nothing when clicked. Let's see what can we do about that.

Add the following function to AppView class.

    fun deleteLastButton() {
        if (buttonsLayout?.children?.isNotEmpty() != true) return
        buttonsLayout?.children?.last()?.let {
            buttonsLayout?.removeChild(it)
        }
    }

Here, we remove the last child of the layout. Then, inside the the AppPresenter, add these two functions.

    fun onHistoryButtonClicked(index: Int) {
        if (historyEntries.isEmpty()) return
        while (true) {
            if (historyEntries.last() == index || historyEntries.isEmpty()) {
                resetLastTurn()
                return
            }
            resetLastTurn()
        }
    }

    private fun resetLastTurn() {
        if (historyEntries.isEmpty()) return
        val lastTurn = historyEntries.last()
        cells[lastTurn]?.let {
            it.text = ""
        }
        historyEntries.remove(lastTurn)
        view?.deleteLastButton()
        flipTurn()
        gameEnded = false
    }

And finally, inside the AppView.addHistoryButton function, add an onClick property to the button as follows.

                onClick = {
                    appPresenter.onHistoryButtonClicked(index)
                }

And that's it. Now we are done.

Final classes

If you followed this tutorial to this point, your files should look like this. App.kt

package com.narbase.game

import com.narbase.kunafa.core.components.*
import com.narbase.kunafa.core.components.layout.Alignment
import com.narbase.kunafa.core.components.layout.JustifyContent
import com.narbase.kunafa.core.components.layout.LinearLayout
import com.narbase.kunafa.core.dimensions.dependent.matchParent
import com.narbase.kunafa.core.dimensions.independent.px
import com.narbase.kunafa.core.drawable.Color

fun main(args: Array<String>) {
    val presenter = AppPresenter()
    AppView(presenter).setup()
}

class AppView(private val appPresenter: AppPresenter) {
    private var buttonsLayout: LinearLayout? = null
    fun setup() {
        appPresenter.view = this
        page {
            presenter = appPresenter
            horizontalLayout {
                width = matchParent
                margin = 16.px
                justifyContent = JustifyContent.Center
                verticalLayout {
                    horizontalLayout {
                        appPresenter.cells[0] = addCell(0)
                        appPresenter.cells[1] = addCell(1)
                        appPresenter.cells[2] = addCell(2)
                    }
                    horizontalLayout {
                        appPresenter.cells[3] = addCell(3)
                        appPresenter.cells[4] = addCell(4)
                        appPresenter.cells[5] = addCell(5)
                    }
                    horizontalLayout {
                        appPresenter.cells[6] = addCell(6)
                        appPresenter.cells[7] = addCell(7)
                        appPresenter.cells[8] = addCell(8)
                    }
                }
                buttonsLayout = verticalLayout {
                    paddingStart = 16.px
                    width = 200.px
                    alignItems = Alignment.Center
                    appPresenter.statusTextView = textView {
                    }
                }
            }
        }
    }

    private fun LinearLayout.addCell(index: Int): TextView? {
        var cell: TextView? = null
        verticalLayout {
            width = 30.px
            height = 30.px
            background = if (index.rem(2) == 0) Color.rgb(220, 220, 220) else Color.white
            justifyContent = JustifyContent.Center
            cell = textView {
                width = matchParent
                textSize = 18.px
                textAlign = TextView.TextAlign.Center
            }
            onClick = {
                appPresenter.onCellClicked(index)
            }
        }
        return cell
    }

    fun addHistoryButton(text: String, index: Int) {
        buttonsLayout?.apply {
            button {
                button.textContent = text
                paddingTop = 8.px
                onClick = {
                    appPresenter.onHistoryButtonClicked(index)
                }
            }
        }
    }

    fun deleteLastButton() {
        if (buttonsLayout?.children?.isNotEmpty() != true) return
        buttonsLayout?.children?.last()?.let {
            buttonsLayout?.removeChild(it)
        }
    }
}

And AppPresenter.kt

package com.narbase.game

import com.narbase.kunafa.core.components.TextView
import com.narbase.kunafa.core.components.View
import com.narbase.kunafa.core.presenter.Presenter

class AppPresenter : Presenter() {
    var view: AppView? = null
    val cells = arrayOfNulls<TextView>(9)
    var statusTextView: TextView? = null
    private val historyEntries = arrayListOf<Int>()
    private var turn = "X"
    private var gameEnded = false

    override fun onViewCreated(view: View) {
        statusTextView?.text = "Turn: $turn player"
    }

    fun onCellClicked(index: Int) {
        val cell = cells[index]
        if (cell?.text?.isNotEmpty() == true || gameEnded) return
        cell?.text = turn
        historyEntries.add(index)
        view?.addHistoryButton("Reset: $turn at cell: $index", index)
        flipTurn()
        getWinner()?.let {
            statusTextView?.text = "$it is the winner!"
            gameEnded = true
        }
    }

    private fun flipTurn() {
        turn = if (turn == "X") "O" else "X"
        statusTextView?.text = "Turn: $turn player"
    }

    private val winningCombinations = arrayListOf(
            arrayListOf(0, 1, 2),
            arrayListOf(3, 4, 5),
            arrayListOf(6, 7, 8),
            arrayListOf(0, 3, 6),
            arrayListOf(1, 4, 7),
            arrayListOf(2, 5, 8),
            arrayListOf(0, 4, 8),
            arrayListOf(2, 4, 6)
    )

    private fun getWinner(): String? {
        winningCombinations.forEach {
            if (cells[it[0]]?.text?.isNotEmpty() == true &&
                    cells[it[0]]?.text == cells[it[1]]?.text &&
                    cells[it[0]]?.text == cells[it[2]]?.text)
                return cells[it[0]]?.text
        }
        return null
    }

    fun onHistoryButtonClicked(index: Int) {
        if (historyEntries.isEmpty()) return
        while (true) {
            if (historyEntries.last() == index || historyEntries.isEmpty()) {
                resetLastTurn()
                return
            }
            resetLastTurn()
        }
    }

    private fun resetLastTurn() {
        if (historyEntries.isEmpty()) return
        val lastTurn = historyEntries.last()
        cells[lastTurn]?.let {
            it.text = ""
        }
        historyEntries.remove(lastTurn)
        view?.deleteLastButton()
        flipTurn()
        gameEnded = false
    }
}