Python implements 2048 games

2048 is a digital game developed by Gabriele Cirulli, 20, which was once popular. In this experiment, we use 200 lines of Python code to realize a small game with 2048 rules in the terminal environment.

2048 courses in the lab building:

GO language development 2048
Web version 2048
C language production 2048
Game playing methods can be experienced here:)

2048 original game address: http://gabrielecirulli.github.io/2048
In this experiment, we will learn and practice the following knowledge points:

Python Basics
curses terminal graphics programming library
Random random number module
collections container data type library
Concept of state machine
This course implements a 2048 game running on the terminal through python, which reflects the simplicity and power of Python syntax. It is suitable for students who already have Python foundation and want to further improve their Python coding ability through challenges.
The complete code of this experiment can be obtained through the following commands:

wget https://labfile.oss.aliyuncs.com/courses/368/2048.py

Experimental steps
Create the game file 2048 in the / home/shiyanlou / directory py

First import the required package:

curses is used to display the graphical interface on the terminal

import curses

The random module is used to generate random numbers

from random import randrange, choice

collections provides a subclass of the dictionary defaultdict. You can specify the default value of value when the key value does not exist.

from collections import defaultdict

Python implements 2048 games
Main logic
User behavior
All valid inputs can be converted into six behaviors: up, down, left, right, game reset and exit, which are represented by actions

actions = ['Up', 'Left', 'Down', 'Right', 'Restart', 'Exit']

Valid input keys are the most common W (up), A (left), S (down), D (right), R (reset) and Q (exit). Here, the list of valid key values should be obtained considering the opening of uppercase keys:

The ord() function takes a character as a parameter and returns the ASCII value corresponding to the parameter, which is easy to associate with the key captured later

letter_codes = [ord(ch) for ch in 'WASDRQwasdrq']
copy
Associate input with behavior:

actions_dict = dict(zip(letter_codes, actions * 2))

actions_ The output of dict is

{87: 'Up', 65: 'Left', 83: 'Down', 68: 'Right', 82: 'Restart', 81: 'Exit', 119: 'Up', 97: 'Left', 115: 'Down', 100: 'Right', 114: 'Restart', 113: 'Exit'}
copy
State machine
When dealing with the main logic of the game, we will use a very common technology: state machine, or more accurately, finite state machine (FSM)

You will find that 2048 games can be easily broken down into several state transitions.

2.1-1

State stores the current state_actions is a dictionary variable as the rule of state transition. Its key is the state and value is the function that returns the next state:

Init: init()
Game
Game: game()
Game
Win
GameOver
Exit
Win: lambda: not_game('Win')
Init
Exit
Gameover: lambda: not_game('Gameover')
Init
Exit
Exit: exit loop
The status opportunity continues to cycle until the Exit end state is reached to end the program.

Let's clarify the code of the main logic: (the incomplete code will be completed later)

init function is used to initialize our game board and make the game become the initial state.

Initialize chessboard

def init():
    ''' Initialize game board '''
    return 'Game'

copy
not_ The game function represents the state at the end of the game. At the end of the game, there are only two results: victory and defeat. While showing these two results, we also need to provide players with "Restart" and "Exit" functions.

def not_game(state):
    '''Show the game end interface.
    Read user input action,Decide whether to restart the game or end the game
    '''
    # The defaultdict parameter is of type callable, so you need to pass a function
    responses = defaultdict(lambda: state)
    # Create two new key value pairs in the dictionary
    responses['Restart'], responses['Exit'] = 'Init', 'Exit'
    return responses[action]

copy
Here, the function of defaultdict is to generate a special dictionary responses.
In ordinary dictionaries, we all know that if we use a key that does not exist in the dictionary to get value, the program will report an error.
However, in the special dictionary generated by defaultdict, if the key to be retrieved does not exist, the program will not report an error, but also get a default value set by us.
In other words, in the special dictionary of responses, responses[action] corresponds to 'Init' and 'Exit' respectively when the action is' Restart 'and' Exit '.
When keyaction is other actions in actions = ['Up', 'Left', 'Down', 'Right', 'Restart', 'Exit], the corresponding value is the default valuestate.

In this way, at the end of the game interface, the player's input of keys other than r and q can not affect the game interface.

(if you are still confused about the function of defaultdict, you can try creating a new py file by yourself)

The game function represents the state of the game. Without restarting or quitting, the game will always be in the game state as long as the game does not win or fail.

Game status

def game():
    '''Draw the current chessboard state
    Read user input action
    '''
    if action == 'Restart':
        return 'Init'
    if action == 'Exit':
        return 'Exit'
    # if successfully moved one step:
        if The game won:
            return 'Win'
        if The game failed:
            return 'Gameover'
    return 'Game'

copy
Here you will also get the user input and get the action.
When the action is "Restart" or "Exit", the "Restart" or "Exit" functions will be executed.
The difference is that when the action is' Up ',' Left ',' Down 'and' Right ', the chessboard will move once accordingly, and then judge whether the Game is over. If it is finished, it will return to the corresponding end state. If it is not finished, it will return to the state 'Game', indicating that the Game is still in progress.

State machine cycle

state_actions = {
        'Init': init,
        'Win': lambda: not_game('Win'),
        'Gameover': lambda: not_game('Gameover'),
        'Game': game
}

state = 'Init'

# The state machine starts cycling
while state != 'Exit':
    state = state_actions[state]()

copy
Here we first define a dictionary state_actions, let the four states of Init, Win, Gameover and Game act as key s, and the corresponding four functions act as value s.
Because dictionary state_ The return value of the function in actionsvalue is one of Init, Win, Gameover, Game and Exit.
Therefore, the status opportunity cycles until state equals Exit.

These are the main codes of the main logic:

def main(stdscr):

def init():
    # Initialize game board
    return 'Game'

def not_game(state):
    '''Draw GameOver perhaps Win Interface of
    Read user input action,Decide whether to restart the game or end the game
    '''
    # The default is the current state. If there is no 'Restart' or 'Exit' behavior, the current state will always be maintained
    responses = defaultdict(lambda: state)
    # Create a new key value pair to correspond the behavior to the status
    responses['Restart'], responses['Exit'] = 'Init', 'Exit'
    return responses[action]

def game():
    # Draw the current chessboard state
    # Read user input to get action
    if action == 'Restart':
        return 'Init'
    if action == 'Exit':
        return 'Exit'
    # if successfully moved one step:
        if The game won:
            return 'Win'
        if The game failed:
            return 'Gameover'
    return 'Game'


state_actions = {
        'Init': init,
        'Win': lambda: not_game('Win'),
        'Gameover': lambda: not_game('Gameover'),
        'Game': game
}

state = 'Init'

# The state machine starts cycling
while state != 'Exit':
    state = state_actions[state]()

User input processing
Block + cycle, and the corresponding behavior is not returned until the user's valid input is obtained:

def get_user_action(keyboard):
char = "N"
while char not in actions_dict:
#Returns the ASCII value of the pressed key
char = keyboard.getch()
#Returns the behavior corresponding to the input key
return actions_dict[char]

Create chessboard
Initialize the parameters of the chessboard. You can specify the height and width of the chessboard and the winning conditions of the game. The default is the most classic 4x4 ~ 2048.

class GameField(object):
def init(self, height=4, width=4, win=2048):
self.height = height # height
self.width = width # width
self.win_value = 2048 # pass score
self.score = 0 # current score
self.highscore = 0 # highest score
self.reset() # chessboard reset

Chessboard operation
Randomly generate a 2 or 4
def spawn(self):
#Take a random number from 100. If the random number is greater than 89, new_element is equal to 4, otherwise it is equal to 2
new_element = 4 if randrange(100) > 89 else 2
#Get the tuple coordinates of a random blank position
(i,j) = choice([(i,j) for i in range(self.width) for j in range(self.height) if self.field[i][j] == 0])
self.field[i][j] = new_element
copy
According to the rules of the game, you need to randomly find a blank position on the chessboard and randomly generate a 2 or 4 at this position.
Therefore, we use the random and choice methods of the random library. For the usage of range, refer to the comments in the code block above.
The choice method randomly returns an element from a non empty sequence (list, str, tuple, etc.). However, we need to know the i and j of the two-dimensional array symbolizing the chessboard to determine the position on the chessboard.
Therefore, in the above code, we pass a list into the choice method, and change the two-dimensional array into a list with (i,j) as the element in the list generation formula, while excluding non-zero positions.

Reset chessboard
def reset(self):
#Update score
if self.score > self.highscore:
self.highscore = self.score
self.score = 0
#Initialize the game start interface
self.field = [[0 for i in range(self.width)] for j in range(self.height)]
self.spawn()
self.spawn()
copy
The reset method is called when the chessboard is initialized. Its main function is to restore all position elements of the chessboard to 0, and then generate the initial value of the game in a random position.

Merge one row to the left
(Note: this operation is defined in move. It is disassembled to facilitate reading)

def move_row_left(row):
def tighten(row):
'squeeze the scattered non-zero units together '"
#First, take out all non-zero elements and add them to the new list
new_row = [i for i in row if i != 0]
#Fill the new list with zeros according to the size of the original list
new_row += [0 for i in range(len(row) - len(new_row))]
return new_row

def merge(row):
    '''Merge adjacent elements'''
    pair = False
    new_row = []
    for i in range(len(row)):
        if pair:
            # After merging, the element multiplied by 2 is added after the 0 element
            new_row.append(2 * row[i])
            # Update score
            self.score += 2 * row[i]
            pair = False
        else:
            # Judge whether adjacent elements can be merged
            if i + 1 < len(row) and row[i] == row[i + 1]:
                pair = True
                # When you can merge, add element 0 to the new list
                new_row.append(0)
            else:
                # Cannot merge. Add this element to the new list
                new_row.append(row[i])
    # Assert that the row and column size will not be changed after merging, otherwise an error will be reported
    assert len(new_row) == len(row)
    return new_row
# First squeeze to one piece, then merge, and then squeeze to one piece
return tighten(merge(tighten(row)))

copy
Matrix transpose and matrix reversal
Adding these two operations can greatly save our code and reduce repetitive work.

Matrix transpose:

Matrix transpose

For like our chessboard, 4 × 4, we can directly use Python's built-in zip(*) method to transpose the matrix.

def transpose(field):
return [list(row) for row in zip(*field)]
copy
Matrix reversal (not inverse):

Here we just reverse every row of the matrix, which has nothing to do with the concept of the inverse matrix.

def invert(field):
return [row[::-1] for row in field]
copy
One step on the chessboard
By transposing and reversing the matrix, the movement operations in the other three directions can be obtained directly from the left

(Note: some codes in the move function are omitted here)

def move(self, direction):
#Create a moves dictionary and take different chessboard operations as different key s, corresponding to different method functions
moves = {}
moves['Left'] = lambda field: [move_row_left(row) for row in field]
moves['Right'] = lambda field: invert(moves'Left')
moves['Up'] = lambda field: transpose(moves'Left')
moves['Down'] = lambda field: transpose(moves'Right')
#Judge whether the chessboard operation exists and is feasible
if direction in moves:
if self.move_is_possible(direction):
self.field = movesdirection
self.spawn()
return True
else:
return False
copy
In the moves dictionary, there are four key s: Left, Right, Up and Down, which correspond to four kinds of chessboard operations.
We first judge whether the direction operation passed in as a key exists in the move dictionary. If it exists, we can use move again_ is_ The possible method determines whether the operation can be performed on the chessboard.
After these two judgments are passed, the chessboard will be moved accordingly.
The difficulty here is to understand the relationship between the transposed and reversed matrix and the original matrix. If you don't understand, you can draw the matrix comparison before and after the change on the paper.

Judge whether to win or lose
def is_win(self):
#When the number of any position is greater than the set win value, the game wins
return any(any(i >= self.win_value for i in row) for row in self.field)

def is_gameover(self):
#When the game fails to merge and
return not any(self.move_is_possible(move) for move in actions)
copy
In is_ In the win function method, we use Python's built-in any function. Any receives an iteratable object as a parameter (iterable) and returns the bool value.
There is another any nested in any here. Any in the inner layer passes in the elements of each row and compares each element of this row with self win_ Value, if any element is greater than self win_ Value, return True, otherwise return False; Any in the outer layer is the bool value returned after each row of matrix elements are processed in the inner layer any. If any bool value is True, any in the outer layer returns True.

is_ The gameover function is used to determine whether the game is over. When you can't move up, down, left and right, the game ends.

Judge whether it can move
def move_is_possible(self, direction):
'pass in the direction you want to move
Determine whether you can move in this direction
'''
def row_is_left_movable(row):
Judge whether there is a row inside or a row inside
'''
def change(i):
#When there is a space (0) on the left and a number on the right, you can move to the left
if row[i] == 0 and row[i + 1] != 0:
return True
#When a number on the left is equal to the number on the right, it can be merged to the left
if row[i] != 0 and row[i + 1] == row[i]:
return True
return False
return any(change(i) for i in range(len(row) - 1))

# Check whether it can be moved (merging can also be regarded as moving)
check = {}
# Determine whether each row of the matrix has elements that can be moved to the left
check['Left']  = lambda field: any(row_is_left_movable(row) for row in field)
# Judge whether each row of the matrix has elements that can be moved to the right. Only judgment is used here, so there is no need to transform and restore after matrix transformation
check['Right'] = lambda field: check['Left'](invert(field))

check['Up']    = lambda field: check['Left'](transpose(field))

check['Down']  = lambda field: check['Right'](transpose(field))

# If the direction is "left, right, up and down", that is, the operation existing in the dictionary check, execute its corresponding function
if direction in check:
    # Pass in the matrix and execute the corresponding function
    return check[direction](self.field)
else:
    return False

copy
In move_ is_ In the possible function, we only use the code to judge whether it can move to the left, and then use the transpose and reversal of the matrix to convert the matrix to judge whether it can move to other directions.

Draw game interface
(Note: this step is defined in the chessboard class)

def draw(self, screen):
help_string1 = '(W)Up (S)Down (A)Left (D)Right'
help_string2 = ' ®Restart (Q)Exit'
gameover_string = ' GAME OVER'
win_string = ' YOU WIN!'

# Draw function
def cast(string):
    # The addstr() method displays the incoming content to the terminal
    screen.addstr(string + '\n')

# Function for drawing horizontal split lines
def draw_hor_separator():
    line = '+' + ('+------' * self.width + '+')[1:]
    cast(line)

# Function to draw a vertical split line
def draw_row(row):
    cast(''.join('|{: ^5} '.format(num) if num > 0 else '|      ' for num in row) + '|')

# Clear screen
screen.clear()
# Draw scores and maximum scores
cast('SCORE: ' + str(self.score))
if 0 != self.highscore:
    cast('HIGHSCORE: ' + str(self.highscore))

# Draw row / column border divider
for row in self.field:
    draw_hor_separator()
    draw_row(row)
draw_hor_separator()

# Draw prompt text
if self.is_win():
    cast(win_string)
else:
    if self.is_gameover():
        cast(gameover_string)
    else:
        cast(help_string1)
cast(help_string2)

copy
The key to this part of the code is the cast function. The screen parameter passed in the draw function represents the painted form object. Here, we first remember the screen Addstr() is used to draw characters, screen Clear() is used to clear the screen and refresh it. After the next part of the main logic, we will combine the two parts to understand the usage of curses library.

Complete main logic
After completing the above work, we can complete the main logic!

def main(stdscr):
def init():
#Reset game board
game_field.reset()
return 'Game'

def not_game(state):
    # Draw the interface of the game according to the status
    game_field.draw(stdscr)
    # Read the user input to get the action, and judge whether to restart the game or end the game
    action = get_user_action(stdscr)
    # If there are no action s of 'Restart' and 'Exit', the existing state will always be maintained
    responses = defaultdict(lambda: state)
    responses['Restart'], responses['Exit'] = 'Init', 'Exit'
    return responses[action]

def game():
    # Draw the interface of the game according to the status
    game_field.draw(stdscr)
    # Read user input to get action
    action = get_user_action(stdscr)

    if action == 'Restart':
        return 'Init'
    if action == 'Exit':
        return 'Exit'
    if game_field.move(action):  # move successful
        if game_field.is_win():
            return 'Win'
        if game_field.is_gameover():
            return 'Gameover'
    return 'Game'


state_actions = {
        'Init': init,
        'Win': lambda: not_game('Win'),
        'Gameover': lambda: not_game('Gameover'),
        'Game': game
    }
# Configure defaults using colors
curses.use_default_colors()

# Instantiate the game interface object and set the game winning condition to 2048
game_field = GameField(win=2048)


state = 'Init'

# The state machine starts cycling
while state != 'Exit':
    state = state_actions[state]()

curses.wrapper(main)
copy
The main content here was analyzed at the beginning of the experiment.
So let's combine the screen in the previous part Addstr() and screen Clear () understands the usage of curses library.

First, curses The wrapper function will activate and initialize the terminal to enter 'curses mode'.
In this mode, the input characters are prohibited from being displayed on the terminal and the line buffering of the terminal program is prohibited, that is, the characters can be used during input without encountering line feed or carriage return.

Next, curses The wrapper function needs to pass a function as a parameter. The function passed in must meet the first parameter of main window stdscr.
In the previous code, you can see that we give curses An stdscr is passed into the main function of wrapper (main).

Finally, stdscr is used as window addstr(str),window. The call of the clear () method requires a window object, which is displayed in the game_field.draw(stdscr) is passed into the draw method.

function
Finally, run at the terminal:

$ python3 2048.py

2.8-1

(Note: if you encounter problems in the experiment, you can check the errors by comparing the reference code given at the beginning of the experiment.)

Experimental summary
In this experiment, we implemented a 2048 rule graphical interface game on the terminal with Python curses library. Learned how to use the defaultdict special dictionary in random library functions random, choice and collections.

During the experiment, we only partially used the object-oriented development method. The following provides you with the code after using object-oriented to reconstruct the experimental content, as a supplement and reflection to the experiment.

Refactoring code with object-oriented method
Author: protream

-- coding: utf-8 --

import random
import curses
from itertools import chain

class Action(object):

UP = 'up'
LEFT = 'left'
DOWN = 'down'
RIGHT = 'right'
RESTART = 'restart'
EXIT = 'exit'

letter_codes = [ord(ch) for ch in 'WASDRQwasdrq']
actions = [UP, LEFT, DOWN, RIGHT, RESTART, EXIT]
actions_dict = dict(zip(letter_codes, actions * 2))

def __init__(self, stdscr):
    self.stdscr = stdscr

def get(self):
    char = "N"
    while char not in self.actions_dict:
        char = self.stdscr.getch()
    return self.actions_dict[char]

class Grid(object):

def __init__(self, size):
    self.size = size
    self.cells = None
    self.reset()

def reset(self):
    self.cells = [[0 for i in range(self.size)] for j in range(self.size)]
    self.add_random_item()
    self.add_random_item()

def add_random_item(self):
    empty_cells = [(i, j) for i in range(self.size) for j in range(self.size) if self.cells[i][j] == 0]
    (i, j) = random.choice(empty_cells)
    self.cells[i][j] = 4 if random.randrange(100) >= 90 else 2

def transpose(self):
    self.cells = [list(row) for row in zip(*self.cells)]

def invert(self):
    self.cells = [row[::-1] for row in self.cells]

@staticmethod
def move_row_left(row):
    def tighten(row):
        new_row = [i for i in row if i != 0]
        new_row += [0 for i in range(len(row) - len(new_row))]
        return new_row

    def merge(row):
        pair = False
        new_row = []
        for i in range(len(row)):
            if pair:
                new_row.append(2 * row[i])
                # self.score += 2 * row[i]
                pair = False
            else:
                if i + 1 < len(row) and row[i] == row[i + 1]:
                    pair = True
                    new_row.append(0)
                else:
                    new_row.append(row[i])
        assert len(new_row) == len(row)
        return new_row
    return tighten(merge(tighten(row)))

def move_left(self):
    self.cells = [self.move_row_left(row) for row in self.cells]

def move_right(self):
    self.invert()
    self.move_left()
    self.invert()

def move_up(self):
    self.transpose()
    self.move_left()
    self.transpose()

def move_down(self):
    self.transpose()
    self.move_right()
    self.transpose()

@staticmethod
def row_can_move_left(row):
    def change(i):
        if row[i] == 0 and row[i + 1] != 0:
            return True
        if row[i] != 0 and row[i + 1] == row[i]:
            return True
        return False
    return any(change(i) for i in range(len(row) - 1))

def can_move_left(self):
    return any(self.row_can_move_left(row) for row in self.cells)

def can_move_right(self):
    self.invert()
    can = self.can_move_left()
    self.invert()
    return can

def can_move_up(self):
    self.transpose()
    can = self.can_move_left()
    self.transpose()
    return can

def can_move_down(self):
    self.transpose()
    can = self.can_move_right()
    self.transpose()
    return can

class Screen(object):

help_string1 = '(W)up (S)down (A)left (D)right'
help_string2 = '     (R)Restart (Q)Exit'
over_string = '           GAME OVER'
win_string = '          YOU WIN!'

def __init__(self, screen=None, grid=None, score=0, best_score=0, over=False, win=False):
    self.grid = grid
    self.score = score
    self.over = over
    self.win = win
    self.screen = screen
    self.counter = 0

def cast(self, string):
    self.screen.addstr(string + '\n')

def draw_row(self, row):
    self.cast(''.join('|{: ^5}'.format(num) if num > 0 else '|     ' for num in row) + '|')

def draw(self):
    self.screen.clear()
    self.cast('SCORE: ' + str(self.score))
    for row in self.grid.cells:
        self.cast('+-----' * self.grid.size + '+')
        self.draw_row(row)
    self.cast('+-----' * self.grid.size + '+')

    if self.win:
        self.cast(self.win_string)
    else:
        if self.over:
            self.cast(self.over_string)
        else:
            self.cast(self.help_string1)

    self.cast(self.help_string2)

class GameManager(object):

def __init__(self, size=4, win_num=2048):
    self.size = size
    self.win_num = win_num
    self.reset()

def reset(self):
    self.state = 'init'
    self.win = False
    self.over = False
    self.score = 0
    self.grid = Grid(self.size)
    self.grid.reset()

@property
def screen(self):
    return Screen(screen=self.stdscr, score=self.score, grid=self.grid, win=self.win, over=self.over)

def move(self, direction):
    if self.can_move(direction):
        getattr(self.grid, 'move_' + direction)()
        self.grid.add_random_item()
        return True
    else:
        return False

@property
def is_win(self):
    self.win = max(chain(*self.grid.cells)) >= self.win_num
    return self.win

@property
def is_over(self):
    self.over = not any(self.can_move(move) for move in self.action.actions)
    return self.over

def can_move(self, direction):
    return getattr(self.grid, 'can_move_' + direction)()

def state_init(self):
    self.reset()
    return 'game'

def state_game(self):
    self.screen.draw()
    action = self.action.get()

    if action == Action.RESTART:
        return 'init'
    if action == Action.EXIT:
        return 'exit'
    if self.move(action):
        if self.is_win:
            return 'win'
        if self.is_over:
            return 'over'
    return 'game'

def _restart_or_exit(self):
    self.screen.draw()
    return 'init' if self.action.get() == Action.RESTART else 'exit'

def state_win(self):
    return self._restart_or_exit()

def state_over(self):
    return self._restart_or_exit()

def __call__(self, stdscr):
    curses.use_default_colors()
    self.stdscr = stdscr
    self.action = Action(stdscr)
    while self.state != 'exit':
        self.state = getattr(self, 'state_' + self.state)()

if name == 'main':
curses.wrapper(GameManager())
copy
After using the object-oriented method to realize the code, the SCORE is always 0 when the game is running. You can think about how to modify the code to achieve the correct scoring.

License
This work is on gfdl1 2. Use authorized under the agreement.

Added by hatching on Tue, 08 Mar 2022 10:09:09 +0200