Forum Archive

Optimising the UI for a 2048 Widget Game

Buzzerb

I’m attempting to make a 2048 game that will run as a widget. The game does work, however it will crash if run in the widget. I can also get it to work by removing any of my colour changing code, however I wish to avoid this. To my (untrained) eye, when looking in the object inspector, the vast majority of variables appear to be UI variables. Therefore, I'm wondering if there is any way to drastically trim the included data attributes and methods. Also, I have tried to modify my code to reduce memory usage but any further suggestions are welcome.

from random import randint
from console import alert
from appex import set_widget_view
import ui

"""These variables need to be global"""
score = 0 #what it says
moves = 0
fails = set() #needs to be global despite only being in one module to exist between calls
failed = False #Records whether a move was sucessful and therefore whether to add randoms
playGrid = [[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]]  #The current grid
tileUI = [[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]] #The corresponding text fields


def printGrid():
    """Outputs the current grid onto the UI, along with score, and number of moves"""
    for y in range(4):
        for x in range(4):
            tile = screen["tile" + str(y) + str(x)]
            #Reset normal font colour and size
            tile.text_color = "#000000"
            tile.font = ("<system-bold>", 50)
            if playGrid[y][x] != 0: #Write non-zeros to grid pos
                tile.text = str(playGrid[y][x])
                #Set colours
                if playGrid[y][x] == 2: 
                    tile.background_color = "#fbffaa"
                elif playGrid[y][x] == 4:
                    tile.background_color = "#fff1aa"
                elif playGrid[y][x] == 8:
                    tile.background_color = "#ffb86a"
                elif playGrid[y][x] == 16:
                    tile.background_color = "#ff703e"
                elif playGrid[y][x] == 32:
                    tile.background_color = "#ff4f4f"
                elif playGrid[y][x] == 64:
                    tile.background_color = "#f20000"
                elif playGrid[y][x] == 128:
                    tile.background_color = "#edf800"
                elif playGrid[y][x] == 256:
                    tile.background_color = "#7cff61"
                elif playGrid[y][x] == 512:
                    tile.background_color = "#00f315"
                elif playGrid[y][x] == 1024:
                    tile.background_color = "#23ffe2"
                elif playGrid[y][x] == 2048:
                    tile.background_color = "#47b7ff"
                elif playGrid[y][x] == 4096:
                    tile.background_color = "#0b20ff"
                    tile.text_color = "#ffffff"
                elif playGrid[y][x] == 8192:
                    tile.background_color = "#9400db"
                    tile.text_color = "#ffffff"
                else:
                    tile.background_color = "#000000"
                    tile.text_color = "#ffffff"
                    tile.font = ("<system-bold>", 42)
            else:
                tile.text = "" #Zeros shown as blank
                tile.background_color = "#ffffff"    
            del tile         
    screen["scoreLabel"].text = "Score: " + str(score)
    screen["movesLabel"].text = "Moves: " + str(moves)


def generateNewNumbers():
    """Places a random 2 or 4 onto a random grid position"""
    randomXLocation = randint(0, 3) #Generate random coordinates
    randomYLocation = randint(0, 3)
    while playGrid[randomYLocation][randomXLocation] != 0: #Keep doing it till the location is empty
        randomXLocation = randint(0, 3)
        randomYLocation = randint(0, 3)
    playGrid[randomYLocation][randomXLocation] = randint(1, 2) * 2 #Set that location to 2 or 4
    del randomXLocation, randomYLocation

def moveTilesDirection(direction):
    """Moves the tiles in playGrid in a specified direction.

    Usage: moveTilesDirection(direction)
    0 is up, 1 is right, 2 is down, 3 is left
    """
    #failed, fails and moves need to be global, playgrid is a list and does not
    global failed
    global fails
    global moves
    def combineTiles():
        """Combines current tile with previous tile, and add non-zero values to a stack"""
        #score must be global
        global score
        #Non-Local needed to ensure that values are retrived and modified correctly
        nonlocal Ycord
        nonlocal Xcord
        nonlocal lastTile
        nonlocal tileStack

        if playGrid[Ycord][Xcord] > 0: #First check there is a value here
            if lastTile == playGrid[Ycord][Xcord]: #If the same as last, combine
                tileStack.pop() #Remove the last value from stack
                tileStack.append(lastTile * 2) #and add combined
                score += lastTile * 2 #Add to score
                lastTile = -1 #And reset to prevent double combines
            else: #If not the same as last
                lastTile = playGrid[Ycord][Xcord] #update last tile
                tileStack.append(lastTile) #and add to row stack

    #empty the save of previous grid and update to current (surface copy)
    previousPlayGrid = [playGrid[i][:] for i in range(4)]

    #check for directions
    if direction == 0:
        for Xcord in range(4): #for each row/column
            tileStack = [] #reset the stack and last tile
            lastTile = -1
            for Ycord in range(4): #step through row/column
                combineTiles() #with combine tiles
            while len(tileStack) < 4: #append as many 0's as needed
                tileStack.append(0)
            for i, tile in enumerate(tileStack): #write to column
                playGrid[i][Xcord] = tile
    elif direction == 2:
        for Xcord in range(4):
            tileStack = []
            lastTile = -1
            for Ycord in range(3, -1, -1):
                combineTiles()
            tileStack.reverse() #reverse the complete stack
            while len(tileStack) < 4:
                tileStack.insert(0, 0) #need to put 0's at start so have to insert at beginning
            for i, tile in enumerate(tileStack):
                playGrid[i][Xcord] = tile
    elif direction == 1:
        for Ycord in range(4):
            tileStack = []
            lastTile = -1
            for Xcord in range(3, -1, -1):
                combineTiles()
            tileStack.reverse()
            while len(tileStack) < 4:
                tileStack.insert(0, 0)
            playGrid[Ycord] = tileStack #easier to write to rows
    elif direction == 3:
        for Ycord in range(4):
            tileStack = []
            lastTile = -1
            for Xcord in range(4):
                combineTiles()
            while len(tileStack) < 4:
                tileStack.append(0)
            playGrid[Ycord] = tileStack

    #Check this was a valid move
    if previousPlayGrid == playGrid: #if nothing moved
        fails.add(direction) #add this direction to failed set (set ensures no duplicates)
        if len(fails) == 4: #if all 4 directions tried, you lost
            alert("YOU LOST!")
        else: #if not retry without adding random tiles or printing
            failed = True
    else: #if the moved worked, reset fail counters
        fails = set()
        failed = False
        moves += 1

"""UI SETUP"""
def upButton(sender):
    moveTilesDirection(0)
    if failed == False:
        generateNewNumbers()
        printGrid()

def rightButton(sender):
    moveTilesDirection(1)
    if failed == False:
        generateNewNumbers()
        printGrid()

def downButton(sender):
    moveTilesDirection(2)
    if failed == False:
        generateNewNumbers()
        printGrid()

def leftButton(sender):
    moveTilesDirection(3)
    if failed == False:
        generateNewNumbers()
        printGrid()

#finally do something (load the view)
screen = ui.load_view('ScreenWidget')
set_widget_view(screen)

#generate starting numbers and print to screen
generateNewNumbers()
printGrid()
JonB

@Buzzerb one approach might be to use a single custom ui.View class, using a draw() method to fill rects, and draw_strings. You would then implement touch_moved to get swipe direction.

Buzzerb

@JonB I have a working implementation of swiping to move the game, however widgets do not support swipes as far as I can tell (they simply move the list of widgets up and down). Would it be possible to manually implement buttons through the method you listed? I presume begin_touch would work but I don’t know.
I’m also a little unsure as to how this would allow me to reduce memory use, are you suggesting that one ui.view object displays all of the boxes and text? Would there be any way to reduce the size of the scene, as it also appears to generate a lot of unnecessary variables.

Buzzerb

@JonB I managed to get it working simply by adding del ui after loading my view. All the necessary objects were contained in screen as far as I can tell, so this removed a significant quantity of unnecessary data.

Edit: Somewhat working- occasional crashes occur

cvp

@Buzzerb It is always possible to make the code shorter, but I don't know of that helps
Assume your buttons are named up,right,down and left, all with the same action:

def MoveButton(sender):
    n = ['up','right','down','left']
    moveTilesDirection(n.index(sender.name))
    if failed == False:
        generateNewNumbers()
        printGrid()
JonB

oh wait, this is a scene game? Im surprised that works at all in the the widget. I though you were just using views?

All of those dels should be unnecessary, since unreferenced items should automatically be deleted at the end of the scope where it is declared. but gc.collect() might help?

ccc

Optimizing in a different place... A dict will speed up printGrid().

bg_colors = {2: "#fbffaa",
             4: "#fff1aa",
             8: "#ffb86a",
             16: "#ff703e",
             32: "#ff4f4f",
             64: "#f20000",
             128: "#edf800",
             256: "#7cff61",
             512: "#00f315",
             1024: "#23ffe2",
             2048: "#47b7ff",
             4096: "#0b20ff",
             8192: "#9400db"}


def printGrid():
    """Outputs the current grid onto the UI, along with score, and number of moves"""
    for y in range(4):
        for x in range(4):
            value = playGrid[y][x]
            tile = screen["tile{}{}".format(y, x)]
            tile.text = "" if value == 0 else str(value)
            tile.font = ("<system-bold>", 42 if value == 0 else 50)
            tile.background_color = bg_colors.get(value, "#000000")
            tile.text_color = "#000000" if 0 < value < 4096 else "#ffffff"
            del tile         
    screen["scoreLabel"].text = "Score: " + str(score)
    screen["movesLabel"].text = "Moves: " + str(moves)
ccc

You could also make all four buttons share a single action...

def button_pressed(sender)
    moveTilesDirection('up right down left'.split().index(sender.text))
    if not failed:
        generateNewNumbers()
        printGrid()
Buzzerb

@ccc Speed isn’t really the problem as far as I know, but it’s possible that your method helps, I’ll test it. And thank you for the all buttons sharing a single action script.

Buzzerb

@JonB I’m not sure what would classify this as a scene game, could you explain?

Edit: @ccc In that case I'm not using a scene game

ccc

Also tryout f”strings” like:

    screen[f"tile{y}{x}"]         
    screen["scoreLabel"].text = f"Score: {score}"
    screen["movesLabel"].text = f"Moves: {moves}"
ccc

Scene games are built using http://omz-software.com/pythonista/docs/ios/scene.html

Buzzerb

@ccc Not sure how legitimate it is but I read that '+' string concatenation is faster when only combining two strings, and f string is faster with more. Also, is using dict.get with a default significantly slower or faster than just fetching a value because my game is unlikely to get above 8192, and I could always add more pairs to the dictionary.

ccc

https://docs.python.org/3/library/timeit.html#timeit.timeit will give you a good sense of performance differences. My bet is that timeit.timeit() will reveal that the dict will be faster then the repeated if/else. However the reason that I suggested the dict approach for a widget is code size.

Also try timeit() on the string question but remember that the current code is not concatenating two strings. That innocent little call to str() effects performance as well. Let timeit() guide choices that really matter to you.

https://goo.gl/images/BNdGdm

Buzzerb

@ccc I looked into using timeit to determine performance of all these changes but ultimately decided that performance was almost definitely not the problem my widget was encountering.

Thank you for the dictionary approach anyway, it’s much more concise than I would’ve managed