Uncategorized

game – Command line 2048 in Python (using numpy)


Good evening. I am studying mathematics at the moment, so I have little to no formal education in actual computer engineering. However, I am trying my hands at learning Python because I will need lots of it for my future career. I hope you can point out any bad practices or mistakes on my part, so that I can fix them early on in my journey. I thank all of you in advance.

I have coded a command line implementation of the “2048” game. I have written a “game engine”, implemented as the Game class of the two048.py python module. This module is imported by main.py and an instance of Game is then used to actually play the game.

The Game class is implemented to be as general as possible: it can be used to play the “2048” game on a nxn game table with the goal of going over any positive m. The game table is stored as a numpy array to make the code as efficient as possible.

Here comes the code:

file1 – main.py

"""Play a command line "2048" game."""
from two048 import *

# Create a new "2048" game, set a random entry and display it.
game = Game(4, 2048)
game.display()

# As long as the game is not won or lost, allow the player to make moves by inputting characters.
# Illegal moves or meaningless strings are ignored.
while game.status == Status.IN_PROGRESS:
    c = input()
    if c == "w":
        game.shift_up()
    elif c == "d":
        game.shift_right()
    elif c == "s":
        game.shift_down()
    elif c == "a":
        game.shift_left()
    else:
        continue

    game.insert_random_2()
    game.update_status()
    game.display()


if game.status == Status.LOST:
    print("You lost!")
elif game.status == Status.WON:
    print("You won!")

file2 – two048.py

"""Game engine for "2048".

This module defines a class Game whose instances are 2048 games.
"""

# Import useful libraries:
#   - numpy: the "2048" game is played on a numpy array
#   - random: random handles
#   - enum: the status of the "2048" game is memorized as an Enum
#   - re: we use the sub function to print the game table

import numpy as np
import random
from enum import Enum
from re import sub

random.seed()

Status = Enum("Status", ["WON", "LOST", "IN_PROGRESS"])


class Game:
    """Each instance represents a 2048 game.

    The game table is represented as a nxn numpy array (n is provided by the user an is usually set = 4).
    Use the shift_left, shift right, shift_up, shift_down methods to
    shift the numbers on the table. These methods represent moves the player can make.
    A move is legal if it results in at least one number shifting or changing.
    Every time the player makes a legal move, a 2 is randomly inserted on the table.
    If the player cannot make any legal move, they lose.
    If the player manages to obtain a number >= winning number (winning number is provided by the user and is usually
    set = 2048), they win.
    The status of the game (WON - LOST - IN_PROGRESS) is memorized as a Status Enum.
    """

    def __init__(self, n, winning_number):
        """Initialize a 2048 game and set up all the instance variables.

        Arguments:
            self
            n -- integer representing the size of the game table
            winning_number -- integer representing the number that the user should exceed to win
        """
        self.n = n
        self.table = np.zeros((n, n), dtype=int)
        self.winning_number = winning_number
        self.status = Status.IN_PROGRESS

        self.insert_random_2()

    def shift_left(self):
        """Shift the table left.

        The same algorithm is applied to every row:
            - compact the row to the left by removing empty space
            - replace consecutive equal integers with a single integer equal to their sum
        """
        # i -- row index
        # j -- column index
        for i in range(self.n):
            # We need to keep track of the number eliminated elements to avoid getting stuck in a loop.
            num_eliminated_elements = 0
            # Iterate over the i-th row to compact the row by eliminating null entries.
            j = 0
            while j < self.n - num_eliminated_elements - 1:
                if self.table[i, j] == 0:
                    # Compact the array by "eliminating" self.table[i, j]. The concatenate function is used for the sake
                    # of efficiency.
                    self.table[i, j:] = np.concatenate((self.table[i, j + 1:], np.zeros(1)))
                    num_eliminated_elements += 1
                else:
                    j += 1
            # Iterate over the i-th row again to sum up consecutive equal entries.
            j = 0
            while j < self.n - num_eliminated_elements - 1:
                if self.table[i, j] == self.table[i, j + 1]:
                    self.table[i, j + 1] = 2*self.table[i, j + 1]
                    self.table[i, j:] = np.concatenate((self.table[i, j + 1:], np.zeros(1)))
                    num_eliminated_elements += 1
                else:
                    j += 1

    def shift_right(self):
        """Shift the table right.

        The same algorithm is applied to every row:
            - compact the row to the right by removing empty space
            - replace consecutive equal integers with a single integer equal to their sum
        """
        # i -- row index
        # j -- column index
        for i in range(self.n):
            # We need to keep track of the number eliminated elements to avoid getting stuck in a loop.
            num_eliminated_elements = 0
            # Iterate through the i-th row in reverse order to compact the row by eliminating null entries.
            j = self.n - 1
            while j > num_eliminated_elements:
                if self.table[i, j] == 0:
                    # Compact the array by "eliminating" self.table[i, j]. The concatenate function is used for the sake
                    # of efficiency.
                    self.table[i, :j + 1] = np.concatenate((np.zeros(1), self.table[i, :j]))
                    num_eliminated_elements += 1
                else:
                    j -= 1
            # Iterate over the i-th row again to sum up consecutive equal entries.
            j = self.n - 1
            while j > num_eliminated_elements:
                if self.table[i, j] == self.table[i, j - 1]:
                    self.table[i, j - 1] = 2 * self.table[i, j - 1]
                    self.table[i, :j + 1] = np.concatenate((np.zeros(1), self.table[i, :j]))
                    num_eliminated_elements += 1
                else:
                    j -= 1

    def shift_up(self):
        """Shift the table up.

        The same algorithm is applied to every column:
            - compact the column above by removing empty space
            - replace consecutive equal integers with a single integer equal to their sum
        """
        # i -- column index
        # j -- row index
        for i in range(self.n):
            # We need to keep track of the number eliminated elements to avoid getting stuck in a loop.
            num_eliminated_elements = 0
            # Iterate through the i-th column in reverse order to compact the row by eliminating null entries.
            j = 0
            while j < self.n - num_eliminated_elements - 1:
                if self.table[j, i] == 0:
                    # Compact the array by "eliminating" self.table[i, j]. The concatenate function is used for the sake
                    # of efficiency.
                    self.table[j:, i] = np.concatenate((self.table[j + 1:, i], np.zeros(1)))
                    num_eliminated_elements += 1
                else:
                    j += 1
            # Iterate over the i-th column again to sum up consecutive equal entries.
            j = 0
            while j < self.n - num_eliminated_elements - 1:
                if self.table[j, i] == self.table[j + 1, i]:
                    self.table[j + 1, i] = 2 * self.table[j + 1, i]
                    self.table[j:, i] = np.concatenate((self.table[j + 1:, i], np.zeros(1)))
                    num_eliminated_elements += 1
                else:
                    j += 1

    def shift_down(self):
        """Shift the table down.

        The same algorithm is applied to every column:
            - compact the column below by removing empty space
            - replace consecutive equal integers with a single integer equal to their sum
        """
        # i -- column index
        # j -- row index
        for i in range(self.n):
            # We need to keep track of the number eliminated elements to avoid getting stuck in a loop.
            num_eliminated_elements = 0
            # Iterate through the i-th column in reverse order to compact the row by eliminating null entries.
            j = self.n - 1
            while j > num_eliminated_elements:
                if self.table[j, i] == 0:
                    # Compact the array by "eliminating" self.table[i, j]. The concatenate function is used for the sake
                    # of efficiency.
                    self.table[:j + 1, i] = np.concatenate((np.zeros(1), self.table[:j, i]))
                    num_eliminated_elements += 1
                else:
                    j -= 1
            # Iterate over the i-th column again to sum up consecutive equal entries.
            j = self.n - 1
            while j > num_eliminated_elements:
                if self.table[j, i] == self.table[j - 1, i]:
                    self.table[j - 1, i] = 2 * self.table[j - 1, i]
                    self.table[:j + 1, i] = np.concatenate((np.zeros(1), self.table[:j, i]))
                    num_eliminated_elements += 1
                else:
                    j -= 1

    def update_status(self):
        """Check whether the game has been won or lost and update the self.status instance variable if necessary."""
        # If there is at least one element in self.table >= self.winning_number, update the game status to WON.
        if np.any(self.table >= self.winning_number):
            self.status = Status.WON
            return
        # If any entry of the table is null, the game is certainly not lost. Since at this point we know that the game
        # is not lost, any subsequent check becomes unnecessary.
        if np.any(self.table == 0):
            return
        # At this point, we know that the game is not won and that no entry of self.table is null. Check if the player
        # can make any legal move, i.e. if there are two consecutive equal elements of self.table along either axis.
        # If this is the case, no further check is needed and we can return. If the player can make no legal move,
        # the game is lost.
        for i in range(self.n):
            for j in range(self.n):
                # The indexing may fail due to out of bounds errors and thus a try block is required. If the indexing
                # fail, just try a different pair of indeces.
                try:
                    if self.table[i,j] == self.table[i+1,j]:
                        return
                except:
                    pass
                try:
                    if self.table[i,j] == self.table[i,j+1]:
                        return
                except:
                    pass
        self.status = Status.LOST

    def display(self):
        """Display the game table in a legible format."""
        # We use the array_str numpy method to convert the table to a string, and then we use a regular expression to
        # remove the brackets. The first bracket needs to be replaced by a space to preserve indentation.
        # A blank line is printed before and after the table.
        print()
        print(sub("[\[\]]", "", " " + np.array_str(self.table)))
        print()

    def insert_random_2(self):
        """Replace a random null entry of self.table with the number 2. Nothing is done if there is no null entry."""
        if np.any(self.table == 0):
            # Select random indices i,j until self.table[i, j] == 0. Then set self.table[i, j] = 2.
            i = random.randrange(self.n)
            j = random.randrange(self.n)
            while self.table[i, j] != 0:
                i = random.randrange(self.n)
                j = random.randrange(self.n)
            self.table[i, j] = 2

The following aspects of the code concern me the most:

  1. The four methods that shift the numbers on the table are all fairly similar and feel quite repetitive. Nevertheless, any other solution I can come up with would make the code much more complicated and difficult to understand. Is this amount of repetition normal in professional code?
  2. I’m not 100% sure if making main.py handle the game loop is a good idea. On one hand, the game loop feels like part of the game logic, and thus I think should be in two048.py. On the other hand, I would like the Game class to be quite generic. For instance, I wouldn’t mind reusing it as is to implement a GUI version of the game in the future, and the game loop wood look very different there.
  3. This is the first Python project I have taken seriously enough to document everything properly. How did I do? Are the docstrings and comments explicative enough? Is my code understandable?

Any feedback and advice is welcome, regardless of how harsh. Thanks everyone and have nice day.



Source link

Leave a Reply

Your email address will not be published. Required fields are marked *