#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
Dieses Skript ist eine einfache Implementierung (d. h. insb. ohne Klassen) des Spiels
"Pong" mithilfe des Tk-Wrappers TkInter. Tk ist nicht für die Spieleprogrammierung
optimiert, stellt jedoch eine umfangreiche Möglichkeit für die Erstellung von GUIs dar.
In diesem Beispiel wird der Einfachheit wegen lediglich von dem Canvas-Widget Gebrauch
gemacht.

Es wird darauf hingewiesen, dass in diesem Beispiel einige globale Variablen verwendet
werden. Das global-Statement zu Beginn einer Funktion legt Variablennamen fest, die
sich im Kontext der jeweiligen Funktion auf globale Variablen beziehen, welche u. U.
verändert werden. Während die Verwendung globaler Variablen auch ohne das global-
Statement möglich ist, wird dieses für Zuweisungen benötigt. Das Verändern globaler
Variablen innerhalb von Funktionen gilt allerdings als unschön, weshalb in der
fortgeschritteneren Programmierung Klassen verwendet werden und auf veränderliche
globale Variablen verzichtet werden sollte.

Steuerung:
 Spieler L       -> Tastatur W/S
 Spieler R       -> Pfeiltaste hoch/runter
 Ball freigeben  -> Leertaste
 Beide Spieler   -> Maus ("Einzelspieler"-Modus)
"""

from tkinter import *
import math  # Umrechnung von Bewegungswinkel (in Grad) zu Bewegungvektor (x,y)
import random
import functools  # um parametrisierte Funktionen per widget.bind() zu binden

__author__ = "Anne Brüggemann-Klein, Stefan Berktold"
__version__ = "1.0"
__maintainer__ = "Stefan Berktold"
__email__ = "s.berktold@tum.de"


# Konstanten (existieren in Python grundsätzlich nicht und sind daher in diesem
# Beispiel eigentlich einfache globale Variablen; werden jedoch nicht verändert):
GAME_WIDTH = 800          # Breite des Spielfelds
GAME_HEIGHT = 400         # Höhe des Spielfelds
FPS = 50                  # Bilder pro Sekunde (Frames per Second)
BALL_SIZE = 14            # Größe des Balls
BALL_SPEED = 350          # Geschwindigkeit des Ball (in Pixeln pro Sekunde)
BALL_ANGLE_VARIATION = 30 # Maximale Richtungsschwankung bei Abwehr (in Grad) (MAX_ANGLE gilt trotzdem)
BALL_MAX_ANGLE = 60       # Maximaler Bewegungswinkel des Balls in Grad (0-90)
RACKET_WIDTH = 70         # Breite eines Schlägers (= Vertikale)
RACKET_HEIGHT = 10        # Höhe eines Schlägers (= Horizontale)
RACKET_BORDER_DIST = 15   # Abstand zwischen Schläger und "Aus"/Rand (..._distance)
RACKET_BALL_DIST = 5      # Abstand zwischen Schläger und Ball bei der Angabe
RACKET_KEY_SPEED = 25     # Anzahl Pixel, die ein Schläger mit einmal Drücken bewegt wird
COLOR_BG = "black"        # Hintergrundfarbe
COLOR_FG = "white"        # Vordergrundfarbe
FONT = ("Courier", 20)    # Schriftart & Größe
SLIME_MODE = False        # Schwerer Schleim-Modus: Der Ball weckelt hin und her ;-)

# Globale Variablen (sind für alle Funktionen zugreifbar):
#  Mit None initialisierte Variablen werden sozusagen nur "deklariert", was nicht
#  nötig ist, aber eine bessere Übersicht über alle globalen Variablen bietet.
#  Deklaration = "Anlegen" einer Variable (None-Initialisierung).
window = None             # Fenster (Tk-Instanz)
canvas = None             # Zeichenfläche/Spielfeld (Tk-Widget Canvas)
p_score = [None, None]    # Spielstand Spieler 0/1 (L/R) (Canvas-Text-Objekte)
p_racket = [None, None]   # Schläger Spieler 0/1 (L/R) (Canvas-Line-Objekte)
p_racket_y = [(GAME_HEIGHT-RACKET_WIDTH)/2,  # Position des Schlägers 0/1 (L/R)
              (GAME_HEIGHT-RACKET_WIDTH)/2]
ball = None               # Spielball (Canvas-Oval-Objekt)
ball_angle = 0.0          # aktueller Bewegungswinkel des Balls (in Grad)
ball_pos = [RACKET_BORDER_DIST,  # Position des Balls
            (GAME_HEIGHT-BALL_SIZE)/2]
current_player = 0        # 0 = links, 1 = rechts (hier Beginner festlegen)
playing = False           # aktueller Status, ob sich der Ball bewegt



def convert_angle_to_vector(angle: float) -> (float, float):
    """
    Wandelt die Bewegungsrichtung des Balls - einen Winkel im Gradmaß - in einen Vektor
    der Form (x, y) um und gibt diesen zurück. x bzw. y ist dabei die Anzahl der Pixel,
    um die der Ball horizontal bzw. vertikal bewegt werden muss, um eine Bewegung in
    dem gegebenen Winkel mit der Bewegungsgeschwindigkeit des Balls durchzuführen.

    Veranschaulichung (Gradzahl zu Bewegungsrichtung):
       225  270 315        -135  -90  -45
       180   +    0        -180   +    0
       135   90  45        -225 -270 -315

    :param angle: die Bewegungsrichtung des Balls als Winkel in Grad
    :return: die Bewegungsrichtung in Pixeln (x, y) mit Beachtung der Geschwindigkeit
    """

    angle = math.radians(angle)  # Winkel im Bogenmaß
    move_pixel_x = (BALL_SPEED/FPS * math.cos(angle))  # Horizontale Bewegung (in Pixeln)
    move_pixel_y = (BALL_SPEED/FPS * math.sin(angle))  # Vertikale Bewegung (in Pixeln)
    return move_pixel_x, move_pixel_y


def flip_angle(angle: float, axis: str) -> float:
    """
    Spiegelt einen beliebigen Winkel (im Gradmaß) horizontal und/oder vertikal und gibt
    die gespiegelte Gradzahl zurück. Als Axen können die h(orizontale), v(ertikale) oder
    beide (hv bzw. vh) angegeben werden.

    :param angle: ein Winkel im Gradmaß (in Grad)
    :param axis: die Axen, um die gespiegelt werden soll (h, v, hv)
    :return: der gespiegelte Winkel (in positiven Grad)
    """

    if 'h' in axis:
        angle = 360 - angle
    if 'v' in axis:
        angle = 180 - angle

    # Falls nötig positiv machen
    if angle < 0:
        angle += 360

    return angle


def move() -> None:
    """
    Diese Funktion enthält alle Formeln bzgl. der Bewegung der Spielschläger und
    des Balls. Je nach Spielstatus wird der Ball am Schläger des aktuellen Spielers
    oder auf dem Spielfeld in seiner aktuellen Richtung bewegt und bei Kollision
    mit einer Wand oder einem Schläger entsprechende Richtungsänderungen durchgeführt.
    Diese Richtungsänderungen enthalten des Weiteren eine geringe Zufallsabweichung,
    um ein "Einklemmen" oder Langeweile zu vermeiden (vgl. BALL_ANGLE_VARIATION).
    Punktet ein Spieler, so wird dessen Score erhöht. Die tatsächliche (sichtbare)
    Bewegung der Schläger wird durch die Änderung der Position ebenfalls in dieser
    Funktion durchgeführt.

    Diese Funktion ruft sich anschließend immer wieder selbst auf (in einem Zeitabstand,
    sodass die angegebenen Frames per Second erreicht werden).
    """

    global ball_pos, ball_angle
    global current_player, playing

    # x- und y-Position festlegen:
    if playing:  # Spiel läuft -> Ball soll sich weiter bewegen
        ball_move = convert_angle_to_vector(ball_angle + (0 if not SLIME_MODE else random.randint(-60, 60)))
        ball_pos[0] += ball_move[0]
        ball_pos[1] += ball_move[1]
    else:  # Spiel läuft nicht -> Ball am aktuellen Schläger orientieren
        # Start-Winkel wird zufällig festgelegt (im gültigen Bereich)
        if current_player == 0:  # am linken Schläger orientieren
            ball_pos = [RACKET_BORDER_DIST + 1 + RACKET_HEIGHT + RACKET_BALL_DIST,
                        p_racket_y[0] + (RACKET_WIDTH - BALL_SIZE) / 2]
            ball_angle = random.randint(-BALL_MAX_ANGLE, BALL_MAX_ANGLE)
        else:  # am rechten Schläger orientieren
            ball_pos = [GAME_WIDTH - (RACKET_BORDER_DIST - 1) - RACKET_HEIGHT - RACKET_BALL_DIST - BALL_SIZE,
                        p_racket_y[1] + (RACKET_WIDTH - BALL_SIZE) / 2]
            ball_angle = random.randint(180 - BALL_MAX_ANGLE, 180 + BALL_MAX_ANGLE)

    # Horizontaler Richtungswechsel falls Grenzen (oben/unten) sonst überschritten:
    if ball_pos[1] < 0:
        ball_pos[1] = 0
        ball_angle = flip_angle(ball_angle, "h")
    elif ball_pos[1] > (GAME_HEIGHT-BALL_SIZE):
        ball_pos[1] = GAME_HEIGHT-BALL_SIZE
        ball_angle = flip_angle(ball_angle, "h")

    # Prüfen, ob ein Spieler punktet (Ball überschreitet Linie um __% seiner Größe):
    # ggf. Score erhöhen (ohne Score explizit zwischenzuspeichern)
    required = 0.75  # Ball muss die hintere Linie mit mind. __ % seiner Breite überschreiten
    if ball_pos[0] > (GAME_WIDTH-(1-required)*BALL_SIZE):  # L punktet
        canvas.itemconfigure(p_score[0],
                             text=str(int(canvas.itemcget(p_score[0], 'text'))+1))
        current_player = 1 # R bekommt Ball
        playing = False  # Ball nicht mehr bewegen
    elif ball_pos[0] < -required*BALL_SIZE:  # R punktet
        canvas.itemconfigure(p_score[1],
                             text=str(int(canvas.itemcget(p_score[1], 'text'))+1))
        current_player = 0  # L bekommt Ball
        playing = False  # Ball nicht mehr bewegen

    # Prüfen, ob eine Abwehr stattfindet und diese durchführen:
    # Der Ball gilt als abgewehrt, wenn der Schläger diesen vertikal zu mindestens __ %
    # berührt. In der Horizontalen muss der Ball weitestgehend von vorne berührt werden.
    # Dabei wird die neu ermittelte (reflektierte) Bewegungsrichtung um einen zufälligen
    # Winkel ergänzt, um das Spiel etwas spannender zu machen (= Richtungsschwankung).
    covered = 0.28  # Ball muss vertikal an mind. __ % seiner Höhe berührt werden
    if (RACKET_BORDER_DIST+RACKET_HEIGHT/2 < ball_pos[0]+BALL_SIZE and ball_pos[0] <= RACKET_BORDER_DIST+RACKET_HEIGHT
        and p_racket_y[0] <= ball_pos[1]+(1-covered)*BALL_SIZE
        and ball_pos[1]+covered*BALL_SIZE <= (p_racket_y[0]+RACKET_WIDTH)):
        # Spieler L schlägt zurück:
        ball_angle = flip_angle(ball_angle, "v")  # gespiegelter Winkel im Bereich [0-90; 270-360]
        ball_angle += random.randint(-BALL_ANGLE_VARIATION, BALL_ANGLE_VARIATION)  # Zufallswinkel hinzumischen
        # Neue Winkelwerte prüfen:
        ball_angle = max(ball_angle, -BALL_MAX_ANGLE)  # mindestens -BALL_MAX_ANGLE
        ball_angle = min(ball_angle, 360+BALL_MAX_ANGLE)  # höchstens 360+BALL_MAX_ANGLE
        if 180 > ball_angle > BALL_MAX_ANGLE:
            ball_angle = BALL_MAX_ANGLE
        elif 180 < ball_angle < 360-BALL_MAX_ANGLE:
            ball_angle = 360-BALL_MAX_ANGLE
    elif (ball_pos[0] < GAME_WIDTH-RACKET_BORDER_DIST and ball_pos[0]+BALL_SIZE >= GAME_WIDTH-RACKET_BORDER_DIST-RACKET_HEIGHT
        and p_racket_y[1] <= ball_pos[1]+(1-covered)*BALL_SIZE
        and ball_pos[1]+covered*BALL_SIZE <= (p_racket_y[1]+RACKET_WIDTH)):
        # Spieler L schlägt zurück:
        ball_angle = flip_angle(ball_angle, "v")  # gespiegelter Winkel im Bereich [90-270
        ball_angle += random.randint(-BALL_ANGLE_VARIATION, BALL_ANGLE_VARIATION)  # Zufallswinkel hinzumischen
        # Neue Winkelwerte prüfen:
        ball_angle = max(ball_angle, 180-BALL_MAX_ANGLE)  # mindestens 180-BALL_MAX_ANGLE
        ball_angle = min(ball_angle, 180+BALL_MAX_ANGLE)  # höchstens 180+BALL_MAX_ANGLE

    # Positionen setzen (Objekte sichtbar an die berechneten Positionen bewegen):
    canvas.coords(ball, ball_pos[0], ball_pos[1], ball_pos[0]+BALL_SIZE, ball_pos[1]+BALL_SIZE)  # Ball Position setzen
    canvas.coords(p_racket[0], RACKET_BORDER_DIST, p_racket_y[0],
                               RACKET_BORDER_DIST+RACKET_HEIGHT, p_racket_y[0]+RACKET_WIDTH)
    canvas.coords(p_racket[1], GAME_WIDTH-RACKET_BORDER_DIST-RACKET_HEIGHT, p_racket_y[1],
                               GAME_WIDTH-RACKET_BORDER_DIST, p_racket_y[1]+RACKET_WIDTH)

    window.after(int(1000/FPS), move)  # diese Methode nach ... ms erneut ausführen


def start_round(event: Event) -> None:
    """
    Startet eine Spielrunde (gibt den Ball frei).
    :param event: das Event, durch welches diese Funktion aufgerufen wurde.
    """

    global playing
    playing = True


def move_racket_up(event: Event, racketID: int) -> None:
    """
    Ändert die Koordinaten des Schlägers racketID für eine Bewegung nach oben.
    :param event: das Event, durch welches diese Funktion aufgerufen wurde.
    :param racketID: der zu bewegende Schläger (0=links, 1=rechts)
    """

    global p_racket_y

    if p_racket_y[racketID] <= RACKET_KEY_SPEED:
        p_racket_y[racketID] = 0  # ganz oben
    else:
        p_racket_y[racketID] -= RACKET_KEY_SPEED


def move_racket_down(event: Event, racketID: int) -> None:
    """
    Ändert die Koordinaten des Schlägers racketID für eine Bewegung nach unten.
    :param event: das Event, durch welches diese Funktion aufgerufen wurde.
    :param racketID: der zu bewegende Schläger (0=links, 1=rechts)
    """

    global p_racket_y

    if p_racket_y[racketID] >= (GAME_HEIGHT-RACKET_WIDTH-RACKET_KEY_SPEED):
        p_racket_y[racketID] = GAME_HEIGHT-RACKET_WIDTH
    else:
        p_racket_y[racketID] += RACKET_KEY_SPEED


def move_rackets(event: Event) -> None:
    """
    Ändert die Koordinaten beider Schläger für eine Bewegung mit der Maus.
    :param event: das Event, durch welches diese Funktion aufgerufen wurde.
    """

    global p_racket_y

    # event.y = aktuelle y-Koordinate des Mauszeigers
    if event.y >= RACKET_WIDTH/2 and event.y <= (GAME_HEIGHT-RACKET_WIDTH+RACKET_WIDTH/2):
        p_racket_y[0] = event.y-RACKET_WIDTH/2
        p_racket_y[1] = event.y-RACKET_WIDTH/2


def main() -> None:
    """
    main()

    Hauptfunktion (auszuführenden Programmcode). Erstellt das Spielfenster- und feld inkl. aller
    Objekte, bindet Nutzereingaben an Funktionen und startet den Event-Loop.
    """

    global window, canvas
    global ball, p_racket, p_score

    # Fenster und Spielfeld:
    window = Tk()  # Fenster erstellen
    window.title("TkInter Pong")  # Fenstertitel festlegen
    window.resizable(width=False, height=False)  # Fenstergröße fixieren (normalerweise anpassbar)
    canvas = Canvas(window, bg=COLOR_BG, height=GAME_HEIGHT, width=GAME_WIDTH)  # Zeichenoberfläche/Spielfeld erstellen
    canvas.pack()  # Zeichenoberfläche/Spielfeld im Fenster platzieren (per pack/grid/place)

    # Mittellinie, Ball, Schläger und Spielstände der beiden Spieler
    # (je später etwas erstellt wird, desto weiter vorne ist es auf der Zeichenoberfläche):
    canvas.create_line(GAME_WIDTH/2, 0, GAME_WIDTH/2, GAME_HEIGHT, width=4, fill=COLOR_FG, dash=(10,))  # (gestrichelt)
    ball = canvas.create_oval(ball_pos[0], ball_pos[1], ball_pos[0]+BALL_SIZE, ball_pos[1]+BALL_SIZE, width=0, fill=COLOR_FG)  # (width = Linienstärke)
    p_racket[0] = canvas.create_rectangle(RACKET_BORDER_DIST, p_racket_y[0],
                                          RACKET_BORDER_DIST+RACKET_HEIGHT, p_racket_y[0]+RACKET_WIDTH,
                                          width=0, fill=COLOR_FG)
    p_racket[1] = canvas.create_rectangle(GAME_WIDTH-RACKET_BORDER_DIST-RACKET_HEIGHT, p_racket_y[1],
                                          GAME_WIDTH-RACKET_BORDER_DIST, p_racket_y[1]+RACKET_WIDTH,
                                          width=0, fill=COLOR_FG)
    p_score[0] = canvas.create_text(GAME_WIDTH/4, 20, text=0, font=FONT, fill=COLOR_FG)
    p_score[1] = canvas.create_text(GAME_WIDTH*3/4, 20, text=0, font=FONT, fill=COLOR_FG)

    # Steuerungstasten an bestimmte Funktionen binden (wenn [Taste] gedrückt, dann führe [Funktion] aus)
    # (Man könnte bspw. die linke Maustaste <Button-1> an eine Funktion binden, die dann die Koordinaten verwendet):
    # Standardmäßig: window.bind('w', move_L_up)
    # Da wir die Bewege-Funktion sowohl für links (w/s) als auch für rechts (up/down) implementieren wollen, verwenden
    # wir einen Trick, um die beiden Funktionen nicht doppelt zu implementieren (was natürlich wie oberhalb beschrieben
    # ebenfalls möglich wäre):
    window.bind("w", functools.partial(move_racket_up, racketID=0))
    window.bind("s", functools.partial(move_racket_down, racketID=0))
    window.bind("<Up>", functools.partial(move_racket_up, racketID=1))
    window.bind("<Down>", functools.partial(move_racket_down, racketID=1))
    # Normale Binding:
    window.bind("<B1-Motion>", move_rackets)  # beide gleichzeitig mit der Maus steuern (Singleplayer ohne KI)
    window.bind("<space>", start_round)

    window.after_idle(move)
    window.mainloop()  # Event Schleife starten (wartet auf Events wie z. B. "Schließen" und führt Sie aus)


if __name__ == '__main__':
    main()  # ruft die Hauptfunktion auf
