How to make 2048 with Felgo - Tiles and Game Logic
Time to bring some life to our game. According to the 2048 rules there are a few activities related to a tile life. A tile should be able to:
In addition, each of these activities should also have an animation. However, just for now we make a simplified version of the tile class without colors and animation, but don't worry we add them at the very end.
Create a new .qml class with a name Tile and add the following code.
Tile.qml
import QtQuick 2.2 import Felgo 4.0 EntityBase{ id: tile entityType: "Tile" width: gridWidth / gridSizeGame // ”300” / ”4” height: width // square so height=width property int tileIndex // is responsible for the position of the tile property int tileValue // tileValue is what gets incremented every time 2 tiles get merged property string tileText // tileFontSize is deduced from the tile width, so it fits into any grid size property int tileFontSize: width/3 // tile rectangle Rectangle { id: innerRect anchors.centerIn: parent // center this object in the invisible "EntityBase" width: parent.width - 2 // -2 is the width offset, set it to 0 if no offset is needed height: width // square so height=width radius: 4 // radius of tile corners color: "#88B605" // tile text Text { id: innerRectText anchors.centerIn: parent // center this object in the "innerRect" color: "white" font.pixelSize: tileFontSize text: Math.pow(2, tileValue) // tileValue gets squared according to the 2048 rules (1,2,3) ->(2,4,6) } } // startup position calculation Component.onCompleted: { x = (width) * (tileIndex % gridSizeGame) // we get the current row and multiply with the width to get the current position y = (height) * Math.floor(tileIndex / gridSizeGame) // we get the current column and multiply with the width to get the current position tileValue = Math.random() < 0.9 ? 1 : 2 // a new tile has 10% = 4 and 90% = 2 } // move function function moveTile(newTileIndex) { tileIndex = newTileIndex x = ((width) * (tileIndex % gridSizeGame)) y = ((height) * Math.floor(tileIndex / gridSizeGame)) } // destroy function function destroyTile() { removeEntity() } }
Since EntityManager works only with entities, we create an EntityBase component that makes up our tile class. We proceed by setting the
width
and the height
of the EntityBase. The visible Rectangle is going to inherit the
width
and height
. This way we can set a nice little margin offset without interfering with our move and position method.
Component.onCompleted()
is a special component method that gets called only once when a component is being created. In fact that's exactly where we going to keep our initial position calculation
and tileValue
initialization.
Our next function moveTile()
is responsible for the tile movement. You may have noticed that it looks quite similar to the onCompleted()
function but with a minor difference. Before calculating the
new x
and y
position, we update the tileIndex
to the new position in the grid.
The last function destroyTile()
is responsible for destroying a tile when two tiles are merged and one of them gets consumed.
Now go back to your Main.qml
and add these two new functions before the very last closing } of the GameWindow.
import Felgo 4.0 import QtQuick 2.2 GameWindow { // ... Scene { // ... } // extract and save emptyCells from tileItems function updateEmptyCells() { emptyCells = [] for (var i = 0; i < gridSizeGameSquared; i++) { if(tileItems[i] === null) emptyCells.push(i) } } // creates new tile at random index(0-15) // positioning and value setting happens inside the tile class! function createNewTile() { var randomCellId = emptyCells[Math.floor(Math.random() * emptyCells.length)] // get random emptyCells var tileId = entityManager.createEntityFromUrlWithProperties(Qt.resolvedUrl("Tile.qml"), {"tileIndex": randomCellId}) // create new Tile with a referenceID tileItems[randomCellId] = entityManager.getEntityById(tileId) // paste new Tile to the array emptyCells.splice(emptyCells.indexOf(randomCellId), 1) // remove the taken cell from emptyCell array } }
The first function updateEmptyCells()
keeps track of all empty tile-spots in the game, by storing the index of every empty cell in an array.We will use this array to randomly select an empty spot for
generating new tiles.
The second function createNewTile()
is a bit more complex.
First, it gets an empty tile-spot from the emptyCells
array. Then it creates an entity Tile.qml
and sets its tileIndex
to the empty tile-spot index. After that the new tile reference is
being sent to our tileItems
(which is an array that holds all our active tiles). Finally we remove the taken tile-spot from the emptyCells
array.
Ok, time to add our new functions into the game. Update your gameScene
with our new friend Component.onCompleted
and add the following code.
import Felgo 4.0 import QtQuick 2.2 GameWindow { // ... Scene { id: gameScene width: 480 height: 320 Component.onCompleted: { // fill the main array with empty spaces for(var i = 0; i < gridSizeGameSquared; i++) tileItems[i] = null // collect empty cells positions updateEmptyCells() // create 2 random tiles createNewTile() createNewTile() } // ... } // ... }
Now if you run the game you will notice two green tiles appearing at random positions. The next step is to add movement and merging of the tiles.
Before writing our core game logic, we are should try to perfectly understand whats happening in the game. We can then split up the logic into more simple tasks.
First of all, let's look at the original 2048 game. The main component is the game grid, which consists of 4 rows and 4 columns. Whenever a user makes a swipe - all the tiles of the grid move in the direction of the swipe. If two tiles with an equal number align next two each other after the movement, they merge.
…Hold on a second, if all rows and columns move at the same time and in one direction, why not work only with one line of tiles at a time? For simplicity let's also consider LEFT as our swipe direction and make all movement calculations based on that
Ok, this looks simplified enough. Now it should be easier to create the logic for our game (or at least this row).
As you remember, only neighboring tiles can merge after the movement. If we assume the movement is complete, we can get the same result by simply ignoring empty spots. Hey, excellent idea! Why not make a new row with all empty spots already excluded? One less problem to worry about!
Great. Next we check if two neighboring elements have the same tile-number. In case they do, we create a new incremented element, push it to a new array and go onto the next element. If we reach the last element of the row -we can just push the element to our array, since there are no more tiles in that row to merge it with. Finally, the remaining space gets treated as empty spots. Watch the animation for better understanding.
Looking good so far... But now back to the code. Paste this function before the very last closing } of the GameWindow.
import Felgo 4.0 import QtQuick 2.2 GameWindow { // ... Scene { // ... } // ... function merge(sourceRow) { var i, j var nonEmptyTiles = [] // sourceRow without empty tiles // remove zero/empty elements for(i = 0; i < sourceRow.length; i++) { if(sourceRow[i] > 0) nonEmptyTiles.push(sourceRow[i]) } var mergedRow = [] // sourceRow after it was merged for(i = 0; i < nonEmptyTiles.length; i++) { // after all elements were pushed we push the last element because there is no element after to be merged with if(i === nonEmptyTiles.length - 1) mergedRow.push(nonEmptyTiles[i]) else // comparing if values are mergeable if (nonEmptyTiles[i] === nonEmptyTiles[i+1]) { // elements got merged so a new element appears and gets incremented // skip one element(i++) because it got merged mergedRow.push(nonEmptyTiles[i] + 1) i++ } else { // no merge, so follow normal order mergedRow.push(nonEmptyTiles[i]) } } // fill empty spots with zeroes for( i = mergedRow.length; i < sourceRow.length; i++) mergedRow[i] = 0 // now we create an object with the merged row array inside and return it return {mergedRow : mergedRow} } }
The steps described before are all included in this function. The reason we are not returning the array directly, but instead create an object with the array inside, is that we will return a 2nd array later. We then can access the arrays in the object by their identifier (mergedRow in this case). The syntax of such an object is {identifier: data}, you will see how to use it in the move functions soon.
Now we add a function that can take any row from our main array tileItems
and convert it into an array of numbers. Add this function after the last function.
import Felgo 4.0 import QtQuick 2.2 GameWindow { // ... Scene { // ... } // ... function getRowAt(index) { var row = [] for(var j = 0; j < gridSizeGame; j++) { // if there are no tileItems at this spot we push(0) to the row, else push the tileIndex value if(tileItems[j + index * gridSizeGame] === null) row.push(0) else row.push(tileItems[j + index * gridSizeGame].tileValue) } return row } }
The function is pretty straightforward. We have our main array tileItems
with a length of 16. In case we just start the game the array gets filled with nulls and then with two random tiles.
Basically getRowAt(index)
checks each element in the given row whether it's a null or an existing object. In case of null we push 0 and in case of object we push an extracted tileValue
of the current object. So our return row is a simple one dimensional array - e.g. [1,0,0,0].
The benefit of our getRowAt(index)
function will be more obvious in our next function moveLeft()
.
Add this after the last function.
import Felgo 4.0 import QtQuick 2.2 GameWindow { // ... Scene { // ... } // ... function moveLeft() { var sourceRow, mergedRow, merger for(var i = 0; i < gridSizeGame; i++) { sourceRow = getRowAt(i) merger = merge(sourceRow) mergedRow = merger.mergedRow console.log(mergedRow) } } }
Hey, our new functions are all here! This function merges each row of tileItems
and writes the result in the console. Put this function in your left swipe method like this.
// ... else if (deltax < -30 && Math.abs(deltay) < 30 && moveRelease.running === false) { console.log("move Left") moveLeft() moveRelease.start() } // ...
Now run the game and swipe left.
Don't forget in the game all numbers are squared but in our code we work with raw numbers. On the right are our initial values and on the left is our console result after moving the tiles.
In case your output is correct I have good news for you. You have 85% of your game logic already done!
The only thing that's left now is the remaining swipe directions, because we can't always swipe left only. Or can we? The truth is, no matter in what direction we swipe, we always perform exactly the same chain of checks and
events. Therefore we can reuse our merge(sourceRow)
function for all four directions.
All we need to do, is reverse our sourceRow
so it gets treated like a row that is moving/merging left. After all the merge/move calculations were done we just reverse the row back. Let's make our
moveRight()
exactly in that manner.
import Felgo 4.0 import QtQuick 2.2 GameWindow { // ... Scene { // ... } // ... function moveRight() { var sourceRow, mergedRow, merger for(var i = 0; i < gridSizeGame; i++) { sourceRow = getRowAt(i).reverse() merger = merge(sourceRow) mergedRow = merger.mergedRow mergedRow.reverse() console.log(mergedRow) } } }
Done! As you can see this function is almost an exact copy of our moveLeft()
function but with some reverses. Our sourceRow
gets reversed for the merge calculation and mergedRow
gets
reversed back for output demonstration. If you call this function in your swipe right method you see that all the values in the console move to the right.
We will add the moveUp()
and moveDown()
functions in the next section.
However, we don't want to play in the console the entire time! Yes, I'm talking about swiping real tiles now! In order to do that we need to update our old code and add some new functions.
Let's start by adding new functions, they all are pretty simple.
import Felgo 4.0 import QtQuick 2.2 GameWindow { // ... Scene { // ... } // ... function getColumnAt(index) { var column = [] for(var j = 0; j < gridSizeGame; j++) { // if there are no tileItems at this spot we push(0) to the column, else push the tileIndex value if(tileItems[index + j * gridSizeGame] === null) column.push(0) else column.push(tileItems[index + j * gridSizeGame].tileValue) } return column } function arraysIdentical(a, b) { var i = a.length if (i !== b.length) return false while (i--) { if (a[i] !== b[i]) return false } return true } }
getColumnAt(index)
is a brother to our getRowAt(index)
function but for columns. The arraysIdentical(a, b)
function checks if two arrays consist of equal values.
This is it with new functions for now. Now let's update our old ones.
This is our new merge(sourceRow)
function
function merge(soureRow) { var i, j var nonEmptyTiles = [] // sourceRow without empty tiles var indices = [] // remove zero elements for(i = 0; i < soureRow.length; i++) { indices[i] = nonEmptyTiles.length if(soureRow[i] > 0) nonEmptyTiles.push(soureRow[i]) } var mergedRow = [] // sourceRow after it was merged for(i = 0; i < nonEmptyTiles.length; i++) { // push last element because there is no element after to be merged with if(i === nonEmptyTiles.length - 1) mergedRow.push(nonEmptyTiles[i]) else { // comparing if values are mergeable if (nonEmptyTiles[i] === nonEmptyTiles[i+1]) { for(j = 0; j < soureRow.length; j++) { if(indices[j] > mergedRow.length) indices[j] -= 1 } // elements got merged a new element appears and gets incremented // skip one element(i++) because it got merged mergedRow.push(nonEmptyTiles[i] + 1) i++ } else { // no merge, so follow normal order mergedRow.push(nonEmptyTiles[i]) } } } // fill empty spots with zeroes for( i = mergedRow.length; i < soureRow.length; i++) mergedRow[i] = 0 // now we create an object with the arrays inside and return it, the syntax is {identifier: data, identifier: data} return {mergedRow : mergedRow, indices: indices} }
This function got a new array - indices
. Indices
is a way to keep track of all position changes and merges. Indices
get initialized at the point where we push non-zero elements to our
nonEmptyTiles
array. They also might get edited in case some of the elements got merged. Finally they get returned as a second array of the function. You will know more about them in our next functions.
Our next method is moveLeft()
function moveLeft() { var isMoved = false // move happens not for a single cell but for a whole row var sourceRow, mergedRow, merger, indices var i, j for(i = 0; i < gridSizeGame; i++) { // gridSizeGame is 4 sourceRow = getRowAt(i) merger = merge(sourceRow) mergedRow = merger.mergedRow indices = merger.indices // checks if the given row is not the same as before if (!arraysIdentical(sourceRow, mergedRow)) { isMoved = true // merges and moves tileItems elements for(j = 0; j < sourceRow.length; j++) { // checks if an element is not empty if (sourceRow[j] > 0 && indices[j] !== j) { // checks if a merge has happened and at what position if (mergedRow[indices[j]] > sourceRow[j] && tileItems[gridSizeGame * i + indices[j]] !== null) { // Move, merge and increment value of the merged element tileItems[gridSizeGame * i + indices[j]].tileValue++ // incrementing the value of the tile that got merged tileItems[gridSizeGame * i + j].moveTile(gridSizeGame * i + indices[j]) // move second tile in the merge direction(will be visible only when all animations are set up) tileItems[gridSizeGame * i + j].destroyTile() // and destroy it } else { // Move only tileItems[gridSizeGame * i + j].moveTile(gridSizeGame * i + indices[j]) // move to the new position tileItems[gridSizeGame * i + indices[j]] = tileItems[gridSizeGame * i + j] // update the element inside the array } tileItems[gridSizeGame * i + j] = null // set to empty an old position of the moved tile } } } } if (isMoved) { // update empty cells updateEmptyCells() // create new random position tile createNewTile() } }
Well, this function clearly gained some weight but let's not blame it, because it has pretty hard work to do.
The first part of function should already be familiar to you. We fill our sourceRow
, make calculation with our merge methods and give it all to mergedRow
. However, now our
merge(sourceRow)
function also return indices, so we provide a separate array for them.
After that we go through an enormous amount of if statements to get to our old tile functions. You should remember them all: moveTile
– moves tile to a specified position, destroyTile
– destroys a
tile.
The part where tiles get merged is pretty simple. One tile doesn't move but gets incremented. Another tile is moving onto the first tile and gets destroyed (fade out) in the motion. The fading out part is not visible yet
because the animations are not set up yet. Another nice thing is that, since the only tile that changed its index was the one that got destroyed we don't need to update our tileItems
array.
However, when only movement happens we do need update its position inside the tileItems
array and move the tile.
Here we go. Our moveLeft()
function is complete.
Next are our moveRight()
, moveUp()
, moveDown()
functions. They all behave in the exact same manner, just with some reversing. Also, for moveUp()
and moveDown()
we going to use our new getColumnAt(index)
function and not getRowAt(index)
.
moveRight()
function moveRight() { var isMoved = false var sourceRow, mergedRow, merger, indices var i, j, k // k used for reversing for(i = 0; i < gridSizeGame; i++) { // reverse sourceRow sourceRow = getRowAt(i).reverse() merger = merge(sourceRow) mergedRow = merger.mergedRow indices = merger.indices if (!arraysIdentical(sourceRow,mergedRow)) { isMoved = true // reverse all other arrays as well sourceRow.reverse() mergedRow.reverse() indices.reverse() // recalculate the indices from the end to the start for (j = 0; j < indices.length; j++) indices[j] = gridSizeGame - 1 - indices[j] for(j = 0; j < sourceRow.length; j++) { k = sourceRow.length -1 - j if (sourceRow[k] > 0 && indices[k] !== k) { if (mergedRow[indices[k]] > sourceRow[k] && tileItems[gridSizeGame * i + indices[k]] !== null) { // Move and merge tileItems[gridSizeGame * i + indices[k]].tileValue++ tileItems[gridSizeGame * i + k].moveTile(gridSizeGame * i + indices[k]) tileItems[gridSizeGame * i + k].destroyTile() } else { // Move only tileItems[gridSizeGame * i + k].moveTile(gridSizeGame * i + indices[k]) tileItems[gridSizeGame * i + indices[k]] = tileItems[gridSizeGame * i + k] } tileItems[gridSizeGame * i + k] = null } } } } if (isMoved) { // update empty cells updateEmptyCells() // create new random position tile createNewTile() } }
moveUp()
function moveUp() { var isMoved = false var sourceRow, mergedRow, merger, indices var i, j for (i = 0; i < gridSizeGame; i++) { sourceRow = getColumnAt(i) merger = merge(sourceRow) mergedRow = merger.mergedRow indices = merger.indices if (! arraysIdentical(sourceRow,mergedRow)) { isMoved = true for (j = 0; j < sourceRow.length; j++) { if (sourceRow[j] > 0 && indices[j] !== j) { // keep in mind now we are working with COLUMNS NOT ROWS! // i and j are swapped when arranging tileItems if (mergedRow[indices[j]] > sourceRow[j] && tileItems[gridSizeGame * indices[j] + i] !== null) { // Move and merge tileItems[gridSizeGame * indices[j] + i].tileValue++ tileItems[gridSizeGame * j + i].moveTile(gridSizeGame * indices[j] + i) tileItems[gridSizeGame * j + i].destroyTile() } else { // just move tileItems[gridSizeGame * j + i].moveTile(gridSizeGame * indices[j] + i) tileItems[gridSizeGame * indices[j] + i] = tileItems[gridSizeGame * j + i] } tileItems[gridSizeGame * j + i] = null } } } } if (isMoved) { // update empty cells updateEmptyCells() // create new random position tile createNewTile() } }
Keep in mind j and i are swapped since we working with columns.
moveDown()
function moveDown() { var isMoved = false var sourceRow, mergedRow, merger, indices var j, k for (var i = 0; i < gridSizeGame; i++) { sourceRow = getColumnAt(i).reverse() merger = merge(sourceRow) mergedRow = merger.mergedRow indices = merger.indices if (! arraysIdentical(sourceRow,mergedRow)) { isMoved = true sourceRow.reverse() mergedRow.reverse() indices.reverse() for (j = 0; j < gridSizeGame; j++) indices[j] = gridSizeGame - 1 - indices[j] for (j = 0; j < sourceRow.length; j++) { k = sourceRow.length -1 - j if (sourceRow[k] > 0 && indices[k] !== k) { // keep in mind now we are working with COLUMNS NOT ROWS! // i and k will be swapped when arranging tileItems if (mergedRow[indices[k]] > sourceRow[k] && tileItems[gridSizeGame * indices[k] + i] !== null) { // Move and merge tileItems[gridSizeGame * indices[k] + i].tileValue++ tileItems[gridSizeGame * k + i].moveTile(gridSizeGame * indices[k] + i) tileItems[gridSizeGame * k + i].destroyTile() } else { // Move only tileItems[gridSizeGame * k + i].moveTile(gridSizeGame * indices[k] + i) tileItems[gridSizeGame * indices[k] + i] = tileItems[gridSizeGame * k + i] } tileItems[gridSizeGame * k + i] = null } } } } if (isMoved) { // update empty cells updateEmptyCells() // create new random position tile createNewTile() } }
Just like previous one but with reversed arrays.
Now make sure your swipe controls and keyboard controls are all set and have corresponding functions.
import Felgo 4.0 import QtQuick 2.2 GameWindow { // ... Scene { // ... // KEYBOARD CONTROLS Item { id: keyboardController Keys.onPressed: { if(!system.desktopPlatform) return if (event.key === Qt.Key_Left && moveRelease.running === false) { event.accepted = true moveLeft() moveRelease.start() console.log("move Left") } else if (event.key === Qt.Key_Right && moveRelease.running === false) { event.accepted = true moveRight() moveRelease.start() console.log("move Right") } else if (event.key === Qt.Key_Up && moveRelease.running === false) { event.accepted = true moveUp() moveRelease.start() console.log("move Up") } else if (event.key === Qt.Key_Down && moveRelease.running === false) { event.accepted = true moveDown() moveRelease.start() console.log("move Down") } } } // SWIPE CONTROLS MouseArea { id:mouseArea anchors.fill: gameScene.gameWindowAnchorItem property int startX // initial position X property int startY // initial position Y property string direction // direction of the swipe property bool moving: false //3 Methods below check swiping direction //and call an appropriate method accordingly onPressed: { startX = mouse.x //save initial position X startY = mouse.y //save initial position Y moving = false } onReleased: { moving = false } onPositionChanged: mouse => { var deltax = mouse.x - startX var deltay = mouse.y - startY if (moving === false) { if (Math.abs(deltax) > 40 || Math.abs(deltay) > 40) { moving = true if (deltax > 30 && Math.abs(deltay) < 30 && moveRelease.running === false) { console.log("move Right") moveRight() moveRelease.start() } else if (deltax < -30 && Math.abs(deltay) < 30 && moveRelease.running === false) { console.log("move Left") moveLeft() moveRelease.start() } else if (Math.abs(deltax) < 30 && deltay > 30 && moveRelease.running === false) { console.log("move Down") moveDown() moveRelease.start() } else if (Math.abs(deltax) < 30 && deltay < 30 && moveRelease.running === false) { console.log("move Up") moveUp() moveRelease.start() } } } } } } // ... }
Note that we also added a check for system.desktopPlatform
to our onKeyPressed()
handler. We only want to allow keyboard input if the game is run on a desktop computer.
Try to start the game. Everything should work fine now.