Kapitel 11 · SOS – Save our Screens

Ein Bildschirmschoner in Python: die Farbbalken

Drei Balken in Blau, Grün und Rot gleiten über den Bildschirm und prallen an den Rändern ab. Ihr Farbverlauf lässt sie plastisch wirken. Ein kleines Programm, an dem sich zeigen lässt, wie aus Funktionen eine aufgeräumte Klasse wird.

Aus dem Buch · Kapitel 11 Einsteiger Grafik & Animation Python 3 · läuft im Browser
Bereit · Python startet beim ersten Klick
# Farbbalken – Kapitel 11 »SOS – Save our Screens«, Version mit Klasse
# aus »Coding for Fun mit Python«, portiert auf Python 3 und c4f
import math
from c4f import Screen, time, KEYDOWN, MOUSEDOWN

start = time()  # Millisekunden seit dem Start, wie pygame.time.get_ticks()


class Bar(object):
    def __init__(self, yStartPos, barHeight, barWidth, windowHeight, colorChoice):
        self.__yPos         = yStartPos
        self.__height       = barHeight
        self.__width        = barWidth
        self.__windowHeight = windowHeight
        self.__color        = colorChoice
        self.__direction    = 1

    # berechnet einen schönen Farbverlauf für die angegebenen Farbwerte
    # jede Farbkomponente wird hierbei proportional zum Farbwert eingerechnet
    def __drawBar(self):
        factor = int(math.floor(255.0 / self.__height))
        for i in range(0, self.__height):
            if i < self.__height / 2.0:
                # hier wird die Helligkeit erhöht
                brightness = i * factor
            else:
                # hier wird die Helligkeit reduziert
                brightness = 255 - i * factor

            color = tuple(map(lambda x: x * brightness, self.__color))
            screen.line((0, self.__yPos + i), (self.__width, self.__yPos + i), color)

    # prüft, ob ein Rand erreicht worden ist, und passt die Bewegungsrichtung an
    def move(self):
        self.__yPos += self.__direction
        if self.__yPos + barHeight > self.__windowHeight or self.__yPos < 0:
            self.__direction *= -1

        self.__drawBar()


def handleEvents(running):
    for event in screen.events():
        # Eingaben erst nach einer Sekunde beachten (der Trick aus dem Buch)
        if time() - start > 1000:
            if event.type == KEYDOWN:
                running = False
            elif event.type == MOUSEDOWN:
                running = False

    return running


# hier sind die Farbbalken sowie die Fenstergröße definiert
barHeight = 50
winHeight = 600
winWidth  = 800

bar1 = Bar(  0, barHeight, winWidth, winHeight, (0, 0, 1))
bar2 = Bar(200, barHeight, winWidth, winHeight, (0, 1, 0))
bar3 = Bar(400, barHeight, winWidth, winHeight, (1, 0, 0))

running = True
screen  = Screen(winWidth, winHeight, background=(0, 0, 0), title="Farbbalken")
maxfps  = 100
while running:
    running = handleEvents(running)

    screen.fill((0, 0, 0))
    bar1.move()
    bar2.move()
    bar3.move()
    await screen.frame(maxfps)  # statt clock.tick(maxfps) und pygame.display.flip()

print("Bildschirmschoner beendet – »Ausführen« startet ihn neu.")
Drei waagerechte Balken mit Farbverlauf in Blau, Grün und Rot auf schwarzem Grund

Wie ein echter Bildschirmschoner endet das Programm nach der ersten Sekunde bei jedem Tastendruck oder Klick.

Konsole

    

Was du hier lernst

  • Farbverläufe berechnen
  • map und lambda
  • Klassen mit privaten Attributen
  • Bewegung und Abprallen
  • Refactoring: von Funktionen zur Klasse
  • Animationsschleife

So funktioniert der Bildschirmschoner

Bildschirmschoner stammen aus der Zeit der Röhrenmonitore: Ein Bild, das stundenlang unverändert blieb, konnte sich dauerhaft in die Leuchtschicht einbrennen. Bewegte Bilder verhinderten das. Heute braucht kein Bildschirm mehr diesen Schutz – schön anzusehen sind die Animationen trotzdem. Dieser Klassiker lässt drei farbige Balken auf und ab gleiten.

1. Ein Balken aus vielen Linien

Ein Balken ist 50 Pixel hoch und besteht aus 50 waagerechten Linien, jede einen Pixel dick. Jede Linie bekommt eine etwas andere Helligkeit – von oben nach unten erst heller, dann wieder dunkler. Genau dieser Verlauf lässt den Balken rund wirken, wie eine Röhre, auf die Licht fällt.

factor = int(math.floor(255.0 / self.__height))
for i in range(0, self.__height):
    if i < self.__height / 2.0:
        brightness = i * factor
    else:
        brightness = 255 - i * factor

factor ist die Schrittweite: 255 geteilt durch 50 ergibt abgerundet 5. In der oberen Hälfte wächst die Helligkeit also um 5 pro Linie, in der unteren Hälfte sinkt sie wieder.

2. Farbe mal Helligkeit

Welche Farbe ein Balken hat, legt ein Tupel mit drei Anteilen fest: (0, 0, 1) heißt nur Blau, (0, 1, 0) nur Grün, (1, 0, 0) nur Rot. Für jede Linie wird jeder Anteil mit der Helligkeit multipliziert:

color = tuple(map(lambda x: x * brightness, self.__color))

lambda x: x * brightness ist eine kleine Funktion ohne Namen. map wendet sie auf jedes Element des Tupels an, tuple(...) sammelt die Ergebnisse. Aus (0, 0, 1) wird bei Helligkeit 100 also (0, 0, 100) – ein mittleres Blau. Mischfarben entstehen von allein: (1, 1, 0) wird zu Gelb.

Warum die Balken nie ganz hell werden

Rechne nach: Die obere Hälfte endet bei Linie 24 mit 24 × 5 = 120. Die untere Hälfte beginnt bei Linie 25 mit 255 − 25 × 5 = 130. Heller als 130 von 255 wird ein Balken also nie, und in der Mitte springt die Helligkeit von 120 auf 130. Das fällt kaum auf, erklärt aber, warum die Balken eher gedämpft leuchten. Unter »Probier mal« findest du eine Rechnung, die bis 255 geht.

3. Hin und her

Jeder Balken merkt sich seine Position yPos und seine Richtung direction: +1 heißt nach unten, −1 nach oben. Bei jedem Bild wandert der Balken um einen Pixel. Stößt er an den Rand, kehrt sich die Richtung um:

def move(self):
    self.__yPos += self.__direction
    if self.__yPos + barHeight > self.__windowHeight or self.__yPos < 0:
        self.__direction *= -1
    self.__drawBar()

Die Multiplikation mit −1 ist ein beliebter Trick: Aus +1 wird −1 und umgekehrt, ohne dass das Programm fragen muss, in welche Richtung der Balken gerade fährt.

4. Aufräumen mit einer Klasse

Im Buch entsteht das Programm in zwei Schritten. Die erste Fassung kommt ohne Klasse aus: Für jeden Balken gibt es zwei Variablen (y1, dir1, y2, dir2 …), und die Hauptschleife ruft für jeden Balken Zeichnen und Bewegen einzeln auf. Das funktioniert, wird aber mit jedem weiteren Balken unübersichtlicher.

Die zweite Fassung bündelt alles, was zu einem Balken gehört, in der Klasse Bar: Position, Höhe, Breite, Farbe und Richtung. Nach außen bietet sie nur move() an, das Zeichnen erledigt die private Methode __drawBar(). Die Hauptschleife schrumpft auf drei Zeilen:

bar1.move()
bar2.move()
bar3.move()

Diesen Umbau nennt man Refactoring: Das Programm tut danach genau dasselbe, ist aber leichter zu lesen und zu erweitern. Ein vierter Balken ist jetzt eine einzige Zeile.

Eine kleine Unsauberkeit

move() prüft den unteren Rand mit der globalen Variable barHeight statt mit der eigenen Höhe self.__height. Das geht gut, solange alle Balken 50 Pixel hoch sind. Ein Balken mit anderer Höhe würde falsch abprallen – die Variante »Unterschiedlich dicke Balken« zeigt, wie es richtig geht.

5. Die Hauptschleife und der Ein-Sekunden-Trick

Ein Bildschirmschoner soll bei der ersten Berührung verschwinden. handleEvents beendet die Schleife deshalb bei jedem Tastendruck oder Klick – aber erst nach der ersten Sekunde:

if time() - start > 1000:
    if event.type == KEYDOWN:
        running = False

Der Grund: Wer den Schoner von Hand startet, bewegt dabei meist noch die Maus oder hat gerade eine Taste gedrückt. Ohne die Wartezeit würde das Programm sofort wieder enden. await screen.frame(maxfps) zeigt jedes Bild an und begrenzt die Schleife auf 100 Durchläufe pro Sekunde.

Probier mal

Jede Variante ändert den Code oben. Ein Klick auf »Ausprobieren« übernimmt die Änderung in den Editor und startet das Programm. »Zurücksetzen« holt das Original zurück.

  1. 01Volle Leuchtkraft

    Im Original werden die Balken nie richtig hell: Die Helligkeit steigt nur bis 120, springt in der Mitte auf 130 und fällt dann wieder. Diese Rechnung misst stattdessen den Abstand zur Balkenmitte – 0 in der Mitte, 1 am Rand – und macht daraus einen gleichmäßigen Verlauf von 0 bis 255.

    -             if i < self.__height / 2.0:
    -                 # hier wird die Helligkeit erhöht
    -                 brightness = i * factor
    -             else:
    -                 # hier wird die Helligkeit reduziert
    -                 brightness = 255 - i * factor
    +             # Abstand zur Balkenmitte: 0 in der Mitte, 1 am Rand
    +             distance = abs(i - self.__height / 2.0) / (self.__height / 2.0)
    +             brightness = int(255 * (1 - distance))
  2. 02Sechs Balken in Mischfarben

    Eine Farbe ist ein Tupel aus drei Anteilen für Rot, Grün und Blau. (1, 1, 0) mischt Rot und Grün zu Gelb, (0, 1, 1) ergibt Türkis, (1, 0, 1) Magenta. Dank der Klasse sind drei neue Balken je eine Zeile – und ein weiterer Aufruf von move().

    - bar3 = Bar(400, barHeight, winWidth, winHeight, (1, 0, 0))
    + bar3 = Bar(400, barHeight, winWidth, winHeight, (1, 0, 0))
    + bar4 = Bar(100, barHeight, winWidth, winHeight, (1, 1, 0))
    + bar5 = Bar(300, barHeight, winWidth, winHeight, (0, 1, 1))
    + bar6 = Bar(500, barHeight, winWidth, winHeight, (1, 0, 1))
    -     bar3.move()
    +     bar3.move()
    +     bar4.move()
    +     bar5.move()
    +     bar6.move()
  3. 03Unterschiedlich dicke Balken

    Hier bekommt jeder Balken eine eigene Höhe. Damit das funktioniert, muss move() die Höhe des Balkens selbst verwenden statt der globalen Variable barHeight – sonst würde der dicke grüne Balken 70 Pixel tief unter den unteren Rand rutschen, bevor er umkehrt.

    -         if self.__yPos + barHeight > self.__windowHeight or self.__yPos < 0:
    +         if self.__yPos + self.__height > self.__windowHeight or self.__yPos < 0:
    - bar2 = Bar(200, barHeight, winWidth, winHeight, (0, 1, 0))
    + bar2 = Bar(200, 120, winWidth, winHeight, (0, 1, 0))
    - bar3 = Bar(400, barHeight, winWidth, winHeight, (1, 0, 0))
    + bar3 = Bar(400, 20, winWidth, winHeight, (1, 0, 0))
  4. 04Durchscheinende Balken

    Ein viertes Element im Farbtupel ist die Deckkraft von 0 (unsichtbar) bis 255 (deckend). Mit 160 werden die Balken halb durchsichtig: Wo sie sich überkreuzen, mischen sich ihre Farben.

    -             color = tuple(map(lambda x: x * brightness, self.__color))
    +             color = tuple(map(lambda x: x * brightness, self.__color)) + (160,)
  5. 05Mehr Tempo

    Jeder Balken wandert pro Bild um direction Pixel. Mit 3 statt 1 sind die Balken dreimal so schnell. Weil das Abprallen nur prüft, ob der Rand schon überschritten ist, ragen sie dabei für ein Bild ein kleines Stück über den Rand hinaus.

    -         self.__direction    = 1
    +         self.__direction    = 3

Vom Buch in den Browser

Im Buch läuft der Bildschirmschoner mit pygame im Vollbildmodus. Hier zeichnet er auf die Leinwand im Browser; mit dem Vollbild-Knopf in der Werkzeugleiste bekommst du trotzdem den ganzen Bildschirm. Die Klasse Bar und die Hauptschleife sind aus der zweiten Fassung (colorBars_V2.py) übernommen.

Im Buch (pygame) Hier (c4f)
pygame.display.set_mode((800, 600), pygame.FULLSCREEN) Screen(800, 600) und der Vollbild-Knopf
pygame.draw.line(screen, farbe, start, ende) screen.line(start, ende, farbe)
clock.tick(maxfps) + pygame.display.flip() await screen.frame(maxfps)
pygame.time.get_ticks() time() aus c4f
pygame.event.poll() – ein Ereignis pro Durchlauf screen.events() – alle Ereignisse seit dem letzten Bild

map in Python 3. In Python 2 lieferte map direkt eine Liste. In Python 3 liefert es einen sogenannten Iterator, der die Werte erst berechnet, wenn jemand sie abruft. Deshalb steht tuple(...) darum herum: So entsteht wie früher eine fertige Farbe aus drei Zahlen.

Beenden. Das Original endete auch bei jeder Mausbewegung. Im Browser würde dann schon das Überfahren der Seite mit dem Mauszeiger den Schoner beenden. Deshalb reagiert er hier nur auf Tastendrücke und Klicks – nach der ersten Sekunde, wie im Buch. Außerdem gibt es den Knopf »Stopp«.

Die Klasse verwendet – wie im Original – die globalen Variablen screen und barHeight. Für ein kleines Programm ist das in Ordnung, sauberer wäre es, beides beim Anlegen eines Balkens zu übergeben. Die erste Fassung ohne Klasse (colorBars_V1.py) kannst du unten ebenfalls aufklappen.

Original aus dem Buch ansehen colorBars_V2.py · Python 2
import pygame, math

start = pygame.time.get_ticks()


class Bar(object):
    def __init__(self, yStartPos, barHeight, barWidth, windowHeight, colorChoice):
        self.__yPos         = yStartPos
        self.__height       = barHeight
        self.__width        = barWidth
        self.__windowHeight = windowHeight
        self.__color        = colorChoice
        self.__direction    = 1
        
    # berechnet einen schoenen Farbverlauf fuer die angegebenen Farbwerte
    # jede Farbkomponente wird hierbei proportional zum Farbwert eingerechnet
    def __drawBar(self):
        factor = int(math.floor(255.0 / self.__height))
        for i in range(0, self.__height):
            if i < self.__height/2.0:
                # hier wird die Helligkeit erhoeht
                brightness  = i*factor
            else:
                # hier wird die Helligkeit reduziert
                brightness  = 255-i*factor
    
            color = map(lambda x: x*brightness, self.__color)
            pygame.draw.line(screen, color, (0, self.__yPos+i), (self.__width, self.__yPos+i))

    # prueft ob ein Rand erreicht worden ist und passt die Bewegungsrichtung an
    def move(self):
        self.__yPos += self.__direction
        if self.__yPos + barHeight > self.__windowHeight or self.__yPos < 0:
            self.__direction *= -1

        self.__drawBar()
    

def handleEvents(running):
    event   = pygame.event.poll()
    
    if pygame.time.get_ticks() - start > 1000:
        if event.type == pygame.QUIT:
            running = False
        elif event.type == pygame.KEYDOWN:
            running = False
        elif event.type == pygame.MOUSEMOTION:
            running = False
        elif event.type == pygame.MOUSEBUTTONDOWN:
            running = False    
    
    return running
    
# here are the start positions and directions for each bar
barHeight   = 50
winHeight   = 600
winWidth    = 800

bar1 = Bar(  0, barHeight, winWidth, winHeight, (0, 0, 1))
bar2 = Bar(200, barHeight, winWidth, winHeight, (0, 1, 0))
bar3 = Bar(400, barHeight, winWidth, winHeight, (1, 0, 0))

running     = True
screen      = pygame.display.set_mode((winWidth, winHeight), pygame.FULLSCREEN);
clock       = pygame.time.Clock()
maxfps      = 100
while running:
    clock.tick(maxfps)
    running = handleEvents(running)
    
    screen.fill((0, 0, 0))
    bar1.move()    
    bar2.move()
    bar3.move()
    pygame.display.flip()
Original aus dem Buch ansehen colorBars_V1.py · Python 2
import pygame, math

start = pygame.time.get_ticks()

# berechnet einen schoenen Farbverlauf fuer die angegebenen Farbwerte
# jede Farbkomponente wird hierbei proportional zum Farbwert eingerechnet
def drawBar(yStartPos, barHeight, barWidth, colorChoice):
    factor = int(math.floor(255.0 / barHeight))
    for i in range(0, barHeight):
        if i < barHeight/2.0:
            # hier wird die Helligkeit erhoeht
            brightness  = i*factor
        else:
            # hier wird die Helligkeit reduziert
            brightness  = 255-i*factor

        color = map(lambda x: x*brightness, colorChoice)
        pygame.draw.line(screen, color, (0, yStartPos+i), (barWidth, yStartPos+i))

# this checks if the border is reached and changes the direction if neccessary
def moveAndCheckDirection(direction, yPosition, barHeight, windowHeight):
    yPosition += direction
    if yPosition + barHeight > windowHeight or yPosition < 0:
        direction *= -1
    
    return direction, yPosition
    
def handleEvents(running):
    event   = pygame.event.poll()
    
    if pygame.time.get_ticks() - start > 1000:
        print start
        if event.type == pygame.QUIT:
            running = False
        elif event.type == pygame.KEYDOWN:
            running = False
        elif event.type == pygame.MOUSEMOTION:
            running = False
        elif event.type == pygame.MOUSEBUTTONDOWN:
            running = False    
    
    return running
    
# here are the start positions and directions for each bar
y1          = 0
dir1        = 1
y2          = 200
dir2        = 1
y3          = 400
dir3        = 1

running     = True
barHeight   = 50
winHeight   = 600
winWidth    = 800
screen      = pygame.display.set_mode((winWidth, winHeight), pygame.FULLSCREEN);
clock       = pygame.time.Clock()
maxfps      = 100
while running:
    clock.tick(maxfps)
    running = handleEvents(running)
    
    screen.fill((0, 0, 0))
    drawBar(y1, barHeight, winWidth, (0, 0, 1))
    drawBar(y2, barHeight, winWidth, (0, 1, 0))
    drawBar(y3, barHeight, winWidth, (1, 0, 0))
    
    dir1, y1 = moveAndCheckDirection(dir1, y1, barHeight, winHeight)
    dir2, y2 = moveAndCheckDirection(dir2, y2, barHeight, winHeight)
    dir3, y3 = moveAndCheckDirection(dir3, y3, barHeight, winHeight)
    
    pygame.display.flip()