Optimisation

Étape 6
Étape terminée ?

Optimisation du rendu graphique et accélération de l'affichage.

Optimisation graphique

La modélisation qu'on a adoptée pour le serpent est déjà optimisée par notre système de listes en anneaux pour mémoriser les positions successives occupées par la tête du serpent sur la grille. Donc il n'y a pas grand chose à optimiser de ce côté là.

Par contre, on peut apporter une optimisation substantielle sur le rendu graphique de la scène de jeu... En effet, si tu parviens à maintenir le serpent en vie assez longtemps pour que sa queue atteigne une longueur suffisante, tu t'apercevras que le jeu va commencer à ramer un peu... (après une vingtaine de pommes avalées, tu devrais remarquer un ralentissement perceptible).

Contrairement au C++, notre script Python est interprété par CircuitPython, qui à son tour exécute des routines précompilées en langage machine. La couche intermédiaire induite par l'interpréteur implique déjà un ralentissement intrinsèque de ton code. Tu as donc tout intérêt à appliquer des procédures peu coûteuses en temps CPU pour exécuter tes propres routines.

Concernant le rendu graphique de la scène de jeu, souviens-toi comment on s'y prend pour dessiner le serpent :

# ----------------------------------------------------------
# Graphic display
# ----------------------------------------------------------

def drawSnake():
    display.setColor(COLOR_SNAKE)
    n = snake['len']
    for i in range(n):
        x = snake['x'][i]
        y = snake['y'][i]
        display.fillRect(OX + x * SNAKE_SIZE, OY + y * SNAKE_SIZE, SNAKE_SIZE, SNAKE_SIZE)

Chaque tronçon est redessiné à chaque cycle de la boucle principale. Or, si tu réfléchis bien, tu remarqueras qu'il n'est pas nécessaire de redessiner le serpent entièrement. Et oui, puisque d'un cycle à l'autre, qu'est-ce qui change exactement ?

Gamebuino META

Seules les extrémités du serpent nécessitent réellement un rafraîchissement graphique. Par ailleurs, avant de redessiner la scène de jeu, on commence par tout effacer :

# ----------------------------------------------------------
# Graphic display
# ----------------------------------------------------------

def draw():
    clearScreen()
    drawWalls()
    drawSnake()
    drawScore()
    drawApple()

Ces opérations sont coûteuses, alors qu'elles ne sont pas nécessaires...

Donc l'idée consiste à ne pas tout effacer quand ça n'est pas nécessaire, et à ne redessiner que ce qui a changé par-rapport au cycle prédécent.

La première chose à faire est de définir une propriété supplémentaire au niveau du moteur de jeu qui lui indique si, au prochain cycle, il doit ou non effacer totalement la scène de jeu avant de la redessiner :

# ----------------------------------------------------------
# Initialization
# ----------------------------------------------------------

game  = {
    'mode':    MODE_START,
    'score':   0,
    'refresh': True
}

On affectera la valeur True à game['refresh'] lorsqu'on souhaite un raffraîchissement complet de la scène de jeu, et False sinon.

On peut donc modifier la fonction draw() pour qu'elle prenne en compte cette nouvelle donnée :

# ----------------------------------------------------------
# Graphic display
# ----------------------------------------------------------

def draw():
    if game['refresh']:
        clearScreen()
        drawWalls()
        drawSnake()
    else:
        drawSnakeHead()
    drawScore()
    drawApple()

Ici, la fonction drawSnake() est toujours celle qui sera chargée de redessiner le serpent en entier. Par contre, on introduit une nouvelle fonction d'affichage drawSnakeHead() pour le serpent, qui ne redessine que sa tête. On va définir cette fonction dans un instant. Mais auparavant, essayons un peu de factoriser notre code...

Pour dessiner un tronçon du serpent, on utilise la fonction display.fillRect() :

def drawSnake():
    display.setColor(COLOR_SNAKE)
    n = snake['len']
    for i in range(n):
        x = snake['x'][i]
        y = snake['y'][i]
        display.fillRect(OX + x * SNAKE_SIZE, OY + y * SNAKE_SIZE, SNAKE_SIZE, SNAKE_SIZE)

Ça ressemble beaucoup à la manière dont on dessine la pomme :

def drawApple():
    display.setColor(COLOR_APPLE)
    x = apple['x']
    y = apple['y']
    display.fillRect(OX + x * SNAKE_SIZE, OY + y * SNAKE_SIZE, SNAKE_SIZE, SNAKE_SIZE)

Seule la couleur du rectangle change finalement... On peut donc factoriser le code en définissant une routine commune, drawDot(), sur laquelle les fonctions drawSnake() et drawApple() pourront s'appuyer :

# ----------------------------------------------------------
# Graphic display
# ----------------------------------------------------------

def drawSnake():
    n = snake['len']
    for i in range(n):
        drawDot(snake['x'][i], snake['y'][i], COLOR_SNAKE)

def drawApple():
    drawDot(apple['x'], apple['y'], COLOR_APPLE)

def drawDot(x, y, color):
    display.setColor(color)
    display.fillRect(OX + x * SNAKE_SIZE, OY + y * SNAKE_SIZE, SNAKE_SIZE, SNAKE_SIZE)

La réutilisation des routines de ton code est une bonne pratique de programmation que je t'encourage à appliquer systématiquement quand c'est possible.

On peut donc maintenant définir la fonction drawSnakeHead() en s'appuyant également sur la fonction drawDot() :

# ----------------------------------------------------------
# Graphic display
# ----------------------------------------------------------

def drawSnakeHead():
    h = snake['head']
    drawDot(snake['x'][h], snake['y'][h], COLOR_SNAKE)

Pour effacer l'extrémité de la queue, il suffit de la redessiner avec la couleur de fond, et le tour est joué ! Ajoutons la fonction clearSnakeTail() qui se chargera de ça :

# ----------------------------------------------------------
# Graphic display
# ----------------------------------------------------------

def clearSnakeTail():
    h = snake['head']
    n = snake['len']
    t = (h + 1) % n
    drawDot(snake['x'][t], snake['y'][t], COLOR_BG)

Reste à déterminer maintenant à quel moment fixer la valeur de game['refresh'] à True ou à False, et à quel moment exécuter clearSnakeTail().

Revenons à notre ordonnanceur. À chaque cycle de la boucle principale, on entre dans l'ordonnanceur pour mettre à jour la situation du jeu. Autrement dit, si le serpent est en mouvement, il va forcément changer de position avec un appel à la fonction moveSnake(). Cette fonction aura pour effet de modifier la position de sa tête et d'écraser la dernière position connue de l'extrémité de sa queue. Il faut donc appeler la fonction clearSnakeTail() avant que ça ne se produise !

# ----------------------------------------------------------
# Game management
# ----------------------------------------------------------

def tick():
    if not game['refresh']:
        clearSnakeTail()
    
    if game['mode'] == MODE_START:
        resetSnake()
        spawnApple()
        game['mode'] = MODE_READY
        game['score'] = 0
    elif game['mode'] == MODE_READY:
        game['refresh'] = False
        handleButtons()
        moveSnake()
        if snakeHasMoved():
            game['mode'] = MODE_PLAY
    elif game['mode'] == MODE_PLAY:
        handleButtons()
        moveSnake()
        if game['refresh']:
            game['refresh'] = False
        if didSnakeEatApple():
            game['score'] += 1
            game['refresh'] = True
            extendSnakeTail()
            spawnApple()
        if didSnakeBiteItsTail() or didSnakeHitTheWall():
            game['mode'] = MODE_START
            game['refresh'] = True

    draw()

C'est la raison pour laquelle on commence tout de suite par analyser la valeur de game['refresh']. Et si sa valeur est False on peut en déduire que le serpent est déjà en mouvement. Donc on efface l'extrémite de la queue à ce moment là, avant de ne plus pouvoir le faire car sa position aura été écrasée par moveSnake() :

if not game['refresh']:
    clearSnakeTail()

Ensuite, lorsqu'on entre dans la phase de jeu MODE_READY, c'est que le serpent vient juste d'être mis en mouvement. Il faut donc fixer la valeur de game['refresh'] à False pour désactiver le rafraîchissement total de la scène de jeu :

elif game['mode'] == MODE_READY:
    game['refresh'] = False
    handleButtons()
    moveSnake()
    if snakeHasMoved():
        game['mode'] = MODE_PLAY

Puis on bascule dans la phase MODE_PLAY, et là les choses sont susceptibles de changer :

  • soit lorsque le serpent avale une pomme,
  • soit lorsque le serpent se mord la queue ou heurte un mur.

Dans ces deux cas, la scène de jeu doit être redessinée entièrement, donc il faut fixer la valeur de game['refresh'] à True. Mais lors du prochain passage dans cette routine, il faudra immédiatement désactiver à nouveau le raffraîchissement total (qui aura déjà eu lieu) :

elif game['mode'] == MODE_PLAY:
        handleButtons()
        moveSnake()
        if game['refresh']:
            game['refresh'] = False
        if didSnakeEatApple():
            game['score'] += 1
            game['refresh'] = True
            extendSnakeTail()
            spawnApple()
        if didSnakeBiteItsTail() or didSnakeHitTheWall():
            game['mode'] = MODE_START
            game['refresh'] = True

Et voilà ! Tu peux maintenant tester le résultat... Tu verras que dorénavant le jeu ne rame plus, même après que le serpent ait avalé plus d'une cinquantaine de pommes !

Démo

Voilà le scipt code.py au complet, qui intègre tout ce que nous avons fait jusqu'à maintenant :

from gamebuino_meta import waitForUpdate, display, color, buttons
from random import randint

# ----------------------------------------------------------
# Global variables
# ----------------------------------------------------------

SCREEN_WIDTH  = 80
SCREEN_HEIGHT = 64
SNAKE_SIZE    = 2
SNAKE_LENGTH  = 4
SNAKE_EXTENT  = 2
COLS          = (SCREEN_WIDTH  - 4) // SNAKE_SIZE
ROWS          = (SCREEN_HEIGHT - 4) // SNAKE_SIZE
OX            = (SCREEN_WIDTH  - COLS * SNAKE_SIZE) // 2
OY            = (SCREEN_HEIGHT - ROWS * SNAKE_SIZE) // 2
COLOR_BG      = 0x69c0
COLOR_WALL    = 0xed40
COLOR_SNAKE   = 0xfd40
COLOR_APPLE   = 0x07f0
COLOR_SCORE   = 0xffff
MODE_START    = 0
MODE_READY    = 1
MODE_PLAY     = 2

# ----------------------------------------------------------
# Game management
# ----------------------------------------------------------

def tick():
    if not game['refresh']:
        clearSnakeTail()
    
    if game['mode'] == MODE_START:
        resetSnake()
        spawnApple()
        game['mode'] = MODE_READY
        game['score'] = 0
    elif game['mode'] == MODE_READY:
        game['refresh'] = False
        handleButtons()
        moveSnake()
        if snakeHasMoved():
            game['mode'] = MODE_PLAY
    elif game['mode'] == MODE_PLAY:
        handleButtons()
        moveSnake()
        if game['refresh']:
            game['refresh'] = False
        if didSnakeEatApple():
            game['score'] += 1
            game['refresh'] = True
            extendSnakeTail()
            spawnApple()
        if didSnakeBiteItsTail() or didSnakeHitTheWall():
            game['mode'] = MODE_START
            game['refresh'] = True

    draw()

def spawnApple():
    apple['x'] = randint(0, COLS - 1)
    apple['y'] = randint(0, ROWS - 1)

def handleButtons():
    if buttons.pressed(buttons.LEFT):
        dirSnake(-1, 0)
    elif buttons.pressed(buttons.RIGHT):
        dirSnake(1, 0)
    elif buttons.pressed(buttons.UP):
        dirSnake(0, -1)
    elif buttons.pressed(buttons.DOWN):
        dirSnake(0, 1)

# ----------------------------------------------------------
# Snake management
# ----------------------------------------------------------

def resetSnake():
    x = COLS // 2
    y = ROWS // 2
    snake['x'] = []
    snake['y'] = []
    for _ in range(SNAKE_LENGTH):
        snake['x'].append(x)
        snake['y'].append(y)
    snake['head'] = SNAKE_LENGTH - 1
    snake['len']  = SNAKE_LENGTH
    snake['vx'] = 0
    snake['vy'] = 0

def dirSnake(dx, dy):
    snake['vx'] = dx
    snake['vy'] = dy

def moveSnake():
    h = snake['head']
    x = snake['x'][h]
    y = snake['y'][h]
    h = (h + 1) % snake['len']
    snake['x'][h] = x + snake['vx']
    snake['y'][h] = y + snake['vy']
    snake['head'] = h

def snakeHasMoved():
    return snake['vx'] or snake['vy']

def didSnakeEatApple():
    h = snake['head']
    return snake['x'][h] == apple['x'] and snake['y'][h] == apple['y']

def extendSnakeTail():
    i = snake['head']
    n = snake['len']
    i = (i + 1) % n
    x = snake['x'][i]
    y = snake['y'][i]
    for _ in range(SNAKE_EXTENT):
        snake['x'].insert(i, x)
        snake['y'].insert(i, y)
    snake['len'] += SNAKE_EXTENT

def didSnakeBiteItsTail():
    h = snake['head']
    n = snake['len']
    x = snake['x'][h]
    y = snake['y'][h]
    i = (h + 1) % n
    for _ in range(n-1):
        if snake['x'][i] == x and snake['y'][i] == y:
            return True
        i = (i + 1) % n
    return False

def didSnakeHitTheWall():
    h = snake['head']
    x = snake['x'][h]
    y = snake['y'][h]
    return x < 0 or x == COLS or y < 0 or y == ROWS

# ----------------------------------------------------------
# Graphic display
# ----------------------------------------------------------

def draw():
    if game['refresh']:
        clearScreen()
        drawWalls()
        drawSnake()
    else:
        drawSnakeHead()
    drawScore()
    drawApple()

def clearScreen():
    display.clear(COLOR_BG)

def drawWalls():
    display.setColor(COLOR_WALL)
    display.drawRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT)

def drawSnake():
    n = snake['len']
    for i in range(n):
        drawDot(snake['x'][i], snake['y'][i], COLOR_SNAKE)

def drawSnakeHead():
    h = snake['head']
    drawDot(snake['x'][h], snake['y'][h], COLOR_SNAKE)

def clearSnakeTail():
    h = snake['head']
    n = snake['len']
    t = (h + 1) % n
    drawDot(snake['x'][t], snake['y'][t], COLOR_BG)

def drawScore():
    display.setColor(COLOR_SCORE)
    display.print(2, 2, game['score'])

def drawApple():
    drawDot(apple['x'], apple['y'], COLOR_APPLE)

def drawDot(x, y, color):
    display.setColor(color)
    display.fillRect(OX + x * SNAKE_SIZE, OY + y * SNAKE_SIZE, SNAKE_SIZE, SNAKE_SIZE)

# ----------------------------------------------------------
# Initialization
# ----------------------------------------------------------

game = {
    'mode':    MODE_START,
    'score':   0,
    'refresh': True
}

snake = {
    'x':    [],
    'y':    [],
    'head': 0,
    'len':  0,
    'vx':   0,
    'vy':   0
}

apple = { 'x': 0, 'y': 0 }

# ----------------------------------------------------------
# Main loop
# ----------------------------------------------------------

while True:
    waitForUpdate()
    tick()

Tu peux désormais passer à la prochaine et dernière étape, dans laquelle on va peaufiner la fin de la partie.

Étapes