-
Notifications
You must be signed in to change notification settings - Fork 6
Kunafa tutorial XO game
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
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.
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
}
}
}
}
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.
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
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.
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.
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.
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.
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
}
}