Kapitel 1 · Es schlägt 12

Eine analoge Uhr in Python

Das erste Grafikprogramm aus dem Buch: eine Uhr, die nichts weiter tut, als die aktuelle Zeit anzuzeigen. Dahinter steckt ein schöner Trick – aus Minuten werden Winkel, aus Winkeln mit Sinus und Kosinus die Spitzen der Zeiger.

Aus dem Buch · Kapitel 1 Einsteiger Grafik & AnimationMathe & Fraktale Python 3 · läuft im Browser
Bereit · Python startet beim ersten Klick
# Analoge Uhr – Kapitel 1 »Es schlägt 12«
# aus »Coding for Fun mit Python«, portiert auf Python 3 und c4f
import math
import datetime
from c4f import Screen

windowMargin         = 30
windowWidth          = 600
windowHeight         = windowWidth
windowCenter         = windowWidth / 2, windowHeight / 2
clockMarginWidth     = 20
secondColor          = (255, 0, 0)
minuteColor          = (100, 200, 0)
hourColor            = (100, 200, 0)
clockMarginColor     = (130, 130, 0)
clockBackgroundColor = (20, 40, 30)
backgroundColor      = (255, 255, 255)
hourCursorLength     = windowWidth / 2.0 - windowMargin - 140
minuteCursorLength   = windowWidth / 2.0 - windowMargin - 40
secondCursorLength   = windowWidth / 2.0 - windowMargin - 10

virtualSpeed    = 1
useVirtualTimer = False


def getCirclePoint(position, scale, cursorLength):
    degrees = getCursorPositionDegrees(position, scale)
    bogenmass = gradToBogenmass(degrees)
    xPos = round(math.cos(bogenmass) * cursorLength + windowCenter[0])
    yPos = round(math.sin(bogenmass) * cursorLength + windowCenter[1])
    return (xPos, yPos)


def gradToBogenmass(degrees):
    # Python bietet auch math.radians(degrees), das genau so rechnet –
    # so wird der Sachverhalt aber deutlicher
    return degrees / 180.0 * math.pi


def getCursorPositionDegrees(position, scale):
    cursorOffset = -90  # 12 Uhr liegt bei -90 Grad
    degrees = 360 / scale * position + cursorOffset
    return degrees


def drawCursor(color, width, length, position, scale):
    end = getCirclePoint(position, scale, length)
    screen.line(windowCenter, end, color, width)


def drawBackground():
    screen.fill(backgroundColor)
    size = windowWidth - 2 * windowMargin
    screen.ellipse(windowMargin, windowMargin, size, size, clockMarginColor)
    inner = windowMargin + clockMarginWidth / 2
    screen.ellipse(inner, inner, windowWidth - inner * 2, windowWidth - inner * 2, clockBackgroundColor)


def drawForeground():
    screen.ellipse(windowWidth / 2.0 - 9, windowHeight / 2.0 - 9, 18, 18, clockMarginColor)


hour   = 0
minute = 0
second = 0
micro  = 0


def timeGoesOn():
    global hour, minute, second, micro
    micro += virtualSpeed
    if micro >= 2:  # halbe Sekunden – keine Mikrosekunden
        second += 1
        micro %= 2
    if second > 60:
        minute += 1
        second %= 60
    if minute > 60:
        hour += 1
        minute %= 60
    if hour > 12:
        hour %= 12


def drawCurrentTime():
    global hour, minute, second, micro
    if useVirtualTimer:
        timeGoesOn()
    else:
        now    = datetime.datetime.now()
        micro  = now.microsecond
        hour   = now.hour
        minute = now.minute
        second = now.second

    drawCursor(hourColor,   15, hourCursorLength,   hour + minute / 60.0, 12)
    drawCursor(minuteColor,  8, minuteCursorLength, minute + second / 60.0, 60)
    drawCursor(secondColor,  3, secondCursorLength, second + micro / 1000000.0, 60)


async def main():
    global screen
    screen = Screen(windowWidth, windowHeight, background=backgroundColor, title="Analog Clock")

    # Hauptschleife: zeichnen, anzeigen, kurz warten – immer wieder
    while True:
        drawBackground()
        drawCurrentTime()
        drawForeground()
        await screen.frame(60)  # statt pygame.display.flip() und pygame.time.delay(10)


await main()
Analoge Uhr mit grünem Stunden- und Minutenzeiger und rotem Sekundenzeiger auf dunklem Zifferblatt

Die Uhr zeigt die Zeit deines Geräts. »Stopp« beendet das Programm.

Konsole

    

Was du hier lernst

  • Sinus und Kosinus
  • Grad und Bogenmaß
  • Koordinaten auf dem Bildschirm
  • Animationsschleife
  • Modul datetime
  • globale Variablen

So funktioniert die Uhr

Eine Uhr zu zeichnen klingt banal – ein Kreis und drei Striche. Die eigentliche Frage ist: Wo genau endet ein Zeiger? Um 15 Uhr zeigt der Stundenzeiger nach rechts, um 18 Uhr nach unten. Das Programm braucht also eine Regel, die aus einer Uhrzeit einen Punkt auf dem Bildschirm macht. Diese Regel ist der Kern des ganzen Programms, alles andere ist Drumherum.

1. Maße und Farben als Konstanten

Ganz oben stehen alle Größen, die das Aussehen bestimmen: Fenstergröße, Rand, Farben und die Längen der drei Zeiger. Die Zeigerlängen werden aus der Fenstergröße berechnet – machst du das Fenster größer, wachsen die Zeiger automatisch mit.

windowCenter       = windowWidth / 2, windowHeight / 2
hourCursorLength   = windowWidth / 2.0 - windowMargin - 140
secondCursorLength = windowWidth / 2.0 - windowMargin - 10

windowCenter ist ein Tupel aus zwei Zahlen, dem x- und dem y-Wert der Mitte. Farben sind ebenfalls Tupel: (255, 0, 0) bedeutet volles Rot, kein Grün, kein Blau.

2. Von der Uhrzeit zum Winkel

Ein voller Kreis hat 360 Grad. Das Zifferblatt teilt ihn für Minuten und Sekunden in 60 Teile, für Stunden in 12. Die Funktion getCursorPositionDegrees rechnet eine Position auf dieser Skala in Grad um:

def getCursorPositionDegrees(position, scale):
    cursorOffset = -90  # 12 Uhr liegt bei -90 Grad
    degrees = 360 / scale * position + cursorOffset
    return degrees

15 Minuten ergeben 360 / 60 * 15 = 90 Grad. Warum dann noch -90? In der Mathematik liegt 0 Grad bei »3 Uhr«, auf der Uhr soll die Zählung aber oben bei der 12 beginnen. Die Verschiebung um −90 Grad dreht die Skala an die richtige Stelle.

Warum läuft die Uhr im Uhrzeigersinn?

In der Schule zeigt die y-Achse nach oben, auf dem Bildschirm zeigt sie nach unten. Dadurch drehen wachsende Winkel hier im Uhrzeigersinn – genau so, wie eine Uhr es braucht. Ein glücklicher Zufall, der das Programm kurz hält.

3. Vom Winkel zum Punkt: Sinus und Kosinus

Jetzt kommt die Trigonometrie. Ein Punkt auf einem Kreis mit Radius r um die Mitte (cx, cy) liegt bei

  • x = cx + cos(Winkel) · r
  • y = cy + sin(Winkel) · r

Pythons math.cos und math.sin erwarten den Winkel allerdings im Bogenmaß, nicht in Grad. Ein voller Kreis sind im Bogenmaß 2π, also gilt Bogenmaß = Grad / 180 · π. Genau das rechnet gradToBogenmass, und getCirclePoint setzt alles zusammen:

def getCirclePoint(position, scale, cursorLength):
    degrees = getCursorPositionDegrees(position, scale)
    bogenmass = gradToBogenmass(degrees)
    xPos = round(math.cos(bogenmass) * cursorLength + windowCenter[0])
    yPos = round(math.sin(bogenmass) * cursorLength + windowCenter[1])
    return (xPos, yPos)

4. Drei Zeiger mit einer Funktion

drawCursor zeichnet eine Linie von der Mitte zu dem Punkt, den getCirclePoint liefert. Alle drei Zeiger nutzen dieselbe Funktion, nur Farbe, Dicke, Länge und Skala unterscheiden sich:

drawCursor(hourColor,   15, hourCursorLength,   hour + minute / 60.0, 12)
drawCursor(minuteColor,  8, minuteCursorLength, minute + second / 60.0, 60)
drawCursor(secondColor,  3, secondCursorLength, second + micro / 1000000.0, 60)

Der Trick steckt im vierten Argument. Der Stundenzeiger bekommt nicht nur die Stunde, sondern hour + minute / 60.0. Um 14:30 Uhr steht er deshalb genau zwischen der 2 und der 3 – wie bei einer echten Uhr. Beim Sekundenzeiger sorgen die Mikrosekunden dafür, dass er gleitet statt springt.

5. Die Hauptschleife

Eine Animation ist nichts anderes als sehr viele Einzelbilder. Die Schleife in main() zeichnet jedes Mal das komplette Bild neu: erst den Hintergrund mit Rand und Zifferblatt, dann die Zeiger, zuletzt den kleinen Knopf in der Mitte, der die Zeigerenden verdeckt.

while True:
    drawBackground()
    drawCurrentTime()
    drawForeground()
    await screen.frame(60)

await screen.frame(60) zeigt das fertige Bild an und wartet, bis das nächste an der Reihe ist – höchstens 60 Bilder pro Sekunde. Die Reihenfolge der Zeichenbefehle ist wichtig: Was später gezeichnet wird, liegt oben.

Genau hingeschaut

Im Zeitraffer-Modus rechnet timeGoesOn() mit if second > 60. Dadurch zählt die virtuelle Uhr bis 60, bevor sie umspringt – jede virtuelle Minute hat 61 Sekunden. Mit >= 60 wäre es korrekt. Bei der echten Uhrzeit spielt das keine Rolle, denn die liefert datetime fertig.

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. 01Zeitraffer: Die Uhr rast

    Statt der echten Zeit zählt jetzt timeGoesOn() eine eigene, virtuelle Zeit hoch – eine halbe Sekunde pro Bild. Bei 60 Bildern pro Sekunde läuft die Uhr also dreißigmal so schnell. Mit virtualSpeed kannst du noch einen Gang höher schalten.

    - useVirtualTimer = False
    + useVirtualTimer = True
  2. 02Ein Zifferblatt mit Strichen

    Die Uhr bekommt 60 Markierungen, jede fünfte etwas länger und dicker. Das Schöne: Du brauchst keine neue Mathematik. getCirclePoint liefert die Punkte für die Striche genauso wie für die Zeigerspitzen – einmal mit kleinerem, einmal mit größerem Radius.

    -     screen.ellipse(inner, inner, windowWidth - inner * 2, windowWidth - inner * 2, clockBackgroundColor)
    +     screen.ellipse(inner, inner, windowWidth - inner * 2, windowWidth - inner * 2, clockBackgroundColor)
    +     for i in range(60):
    +         start = getCirclePoint(i, 60, 228 if i % 5 == 0 else 240)
    +         end   = getCirclePoint(i, 60, 252)
    +         screen.line(start, end, (200, 200, 170), 5 if i % 5 == 0 else 1)
  3. 03Tick, tick statt Gleiten

    Im Original gleitet der Sekundenzeiger, weil die Mikrosekunden mit eingerechnet werden. Lass sie weg, und der Zeiger springt wie bei einer Bahnhofsuhr im Sekundentakt.

    - second + micro / 1000000.0
    + second
  4. 04Digitalanzeige dazu

    Eine zweite Anzeige schreibt die Uhrzeit als Text ins Zifferblatt. strftime formatiert die Zeit, screen.text zeichnet sie mittig.

    - def drawForeground():
    -     screen.ellipse(windowWidth / 2.0 - 9, windowHeight / 2.0 - 9, 18, 18, clockMarginColor)
    + def drawForeground():
    +     screen.ellipse(windowWidth / 2.0 - 9, windowHeight / 2.0 - 9, 18, 18, clockMarginColor)
    +     text = datetime.datetime.now().strftime("%H:%M:%S")
    +     screen.text(text, (windowCenter[0], windowCenter[1] + 80), (200, 200, 170), size=34, align="center", font="mono")
  5. 05Neue Farben

    Alle Farben stehen als Konstanten ganz oben – ein Tupel aus Rot, Grün und Blau, jeweils von 0 bis 255. Hier eine Variante in Nachtblau und Orange. Probiere danach eigene Werte.

    - secondColor          = (255, 0, 0)
    + secondColor          = (255, 140, 0)
    - minuteColor          = (100, 200, 0)
    + minuteColor          = (230, 230, 255)
    - hourColor            = (100, 200, 0)
    + hourColor            = (230, 230, 255)
    - clockMarginColor     = (130, 130, 0)
    + clockMarginColor     = (70, 90, 160)
    - clockBackgroundColor = (20, 40, 30)
    + clockBackgroundColor = (15, 20, 45)

Vom Buch in den Browser

Im Buch zeichnet die Uhr mit pygame in ein eigenes Fenster, geschrieben für Python 2.6. Im Browser gibt es kein Fenster und kein pygame – deshalb zeichnet die Uhr hier mit c4f, einer kleinen Zeichen-API dieser Website, auf eine Leinwand. Die Logik ist unverändert: dieselben Funktionen, dieselben Namen, dieselbe Rechnung.

Im Buch (pygame) Hier (c4f)
pygame.display.set_mode((600, 600)) screen = Screen(600, 600)
pygame.draw.ellipse(screen, farbe, (x, y, b, h)) screen.ellipse(x, y, b, h, farbe)
pygame.draw.line(screen, farbe, start, ende, dicke) screen.line(start, ende, farbe, dicke)
pygame.display.flip() + pygame.time.delay(10) await screen.frame(60)
Beenden per Taste oder Mausklick der Knopf »Stopp«

Die wichtigste Änderung ist das await. Ein Browser zeichnet nur, wenn ihm das Programm kurz die Kontrolle zurückgibt. Deshalb ist main() jetzt eine async-Funktion, und await screen.frame(60) macht die Pause, in der das Bild erscheint. Am Ende startet await main() das Programm – im Browser ist await auch außerhalb von Funktionen erlaubt. Auf dem eigenen Rechner würdest du dafür asyncio.run(main()) schreiben.

Außerdem:

  • global screen steht jetzt in main(). Im Buch fehlte die Zeile im abgedruckten Listing (das ist ein Eintrag der damaligen Errata), in der Datei auf der Buch-CD war sie enthalten.
  • global steht in drawCurrentTime() am Anfang der Funktion statt mitten im if-Zweig. Python 3 akzeptiert beides, am Anfang ist es aber übersichtlicher.
  • Die Ereignisbehandlung (handleEvents) entfällt, weil sie nur dazu diente, das Fenster zu schließen.
Original aus dem Buch ansehen clock.py · Python 2
import pygame, sys, math, datetime

windowMargin            = 30
windowWidth             = 600
windowHeight            = windowWidth
windowCenter            = windowWidth/2, windowHeight/2
clockMarginWidth        = 20
secondColor             = (255, 0, 0)
minuteColor             = (100, 200, 0)
hourColor               = (100, 200, 0)
clockMarginColor        = (130, 130, 0)
clockBackgroundColor    = (20, 40, 30)
backgroundColor         = (255, 255, 255)
hourCursorLength        = windowWidth/2.0-windowMargin-140
minuteCursorLength      = windowWidth/2.0-windowMargin-40
secondCursorLength      = windowWidth/2.0-windowMargin-10

virtualSpeed    = 1
useVirtualTimer = False

def handleEvents():
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            sys.exit(0)
        elif event.type == pygame.KEYDOWN:
            sys.exit(0)
        elif event.type == pygame.MOUSEBUTTONDOWN:
            sys.exit(0)
            
def getCirclePoint(position, scale, cursorLength):
    degrees = getCursorPositionDegrees(position, scale)
    bogenmass = gradToBogenmass(degrees)
    xPos = round(math.cos(bogenmass)*cursorLength+windowCenter[0])
    yPos = round(math.sin(bogenmass)*cursorLength+windowCenter[1])
    return (xPos, yPos)

def gradToBogenmass(degrees):
    # python bietet auch die Funktion math.radians(degrees),
    # welche die Umrechnung genauso ausfuehrt, aber so wird 
    # der Sachverhalt deutlicher
    return degrees/180.0*math.pi

def getCursorPositionDegrees(position, scale):
    cursorOffset = -90 # 12 o'Clock is -90 degrees
    degrees = 360 / scale * position + cursorOffset
    return degrees

def drawCursor(color, width, length, position, scale):
    end = getCirclePoint(position, scale, length);
    pygame.draw.line(screen, color, windowCenter, end, width)
    
def drawBackground():
    screen.fill(backgroundColor)
    pygame.draw.ellipse(screen, clockMarginColor, (windowMargin, windowMargin, windowWidth-2*windowMargin, windowWidth-2*windowMargin))
    pygame.draw.ellipse(screen, clockBackgroundColor, (windowMargin+clockMarginWidth/2, windowMargin+clockMarginWidth/2, windowWidth-(windowMargin+clockMarginWidth/2)*2, windowWidth-(windowMargin+clockMarginWidth/2)*2))
    
def drawForeground():
    pygame.draw.ellipse(screen, clockMarginColor, (windowWidth/2.0-9, windowHeight/2.0-9, 18, 18))    

hour    = 0
minute  = 0
second  = 0
micro   = 0
def timeGoesOn():
    global hour, minute, second, micro
    micro += virtualSpeed
    if micro >= 2: # halve seconds - not micro seconds
        second += 1
        micro %= 2
    if second > 60:
        minute += 1
        second %= 60
    if minute > 60:
        hour += 1
        minute %= 60
    if hour > 12:
        hour %= 12

def drawCurrentTime():
    if useVirtualTimer:
        global hour, minute, second, micro
        timeGoesOn()
    else:
        now     = datetime.datetime.now()
        micro   = now.microsecond
        hour    = now.hour
        minute  = now.minute
        second  = now.second
        
    drawCursor(  hourColor, 15, hourCursorLength,   hour+minute/60.0, 12)
    drawCursor(minuteColor,  8, minuteCursorLength, minute+second/60.0, 60)
    drawCursor(secondColor,  3, secondCursorLength, second+micro/1000000.0, 60)


def main():
    # Initialise screen
    global screen
    pygame.init()
    screen = pygame.display.set_mode((windowWidth, windowHeight), pygame.HWSURFACE | pygame.DOUBLEBUF);
    pygame.display.set_caption('Analog Clock');

    # Event loop
    while True:
        handleEvents()
        drawBackground()
        drawCurrentTime() 
        drawForeground()
        pygame.display.flip()
        pygame.time.delay(10)

if __name__ == '__main__':
    main()