Exercise 01

PONG

Projekt erstellen

  • Godot Öffnen
  • Create
  • Projektnamen und -pfad eingeben
  • Create

(Optional:) Externer Editor

Bearbeiten der Skripte in vscode oder anderen Editoren statt in Godot direkt.

Notiz: Den internen Editor auszuprobieren lohnt sich durchaus, da er sehr nette Features hat.

(Beispiel an vscode hier schauen für andere)

  • EditorEditor Settings...Text Editor/External
    • Exec Path: [vscode path] (kann in appdata liegen)
    • Exec Flags: {project} --goto {file}:{line}:{col}
    • Use External Editor: True
  • Script
    • (In Script Fenster:) Debug
    • Debug with External Editor

Reload Hotkey einrichten

Um das Projekt schnell neu zu laden

  • EditorEditor Settings...Shortcuts
  • Suchen nach reload
  • Reload Current Project→ ALT+R
  • (Zum testen: ALT+R drücken)

Neue Szene anlegen

  • In FileSystem
    • Rechstklick → new Folder
    • scenes nennen
    • Rechstklick auf scenes
    • Create NewScene...
    • Rechtsklick auf die Scene → Set as Main Scene

Projekt Viewport einstellen

Legt die Größe des Spielfensters fest

  • ProjectProject Settings...General
  • DisplayWindow
    • Viewport Width: 1280
    • Viewport Heigth: 720

Schwarzer Hintergrund hinzufügen

  • In Scene
    • auf "Scene root" (PongGame) Rechtsklick
    • Add Child Node
    • nach sprite suchen und Sprite2D hinzufügen
    • Doppelklick oder F2 zum umbenennen in Background
  • Background auswählen
  • In Inspector
    • Bei Texture Linksklick → GradientTexture2D
    • Auf die Textur klicken
    • Auf Gradient klicken
    • Rechtsklick auf die rechte Farbe (weiß) damit der Gradient nur Schwarz ist
    • In der Textur die Größe setzen
      • Width: 1280
      • Height: 720
  • Immer noch in den Settings von Background
  • Unten in Node2DTransformPosition
    • x: 1280/2
    • y: 720/2

Paddel Szene erstellen

  • Ordner anlegen /scenes/player
  • In Ordner Create newScene...
    • Root Type: CharacterBody2D
    • Scene Name: paddle
  • Zu Paddle Szene hinzufügen:
  • CollisionShape2D
    • Shape: RectangleShape2D
    • Size von Shape auf (30,120)
  • Sprite2D
    • Texture: GradientTexture2D
    • Größe von Textur (30,120)
    • Gradient von Textur auf pur Weiß stellen
  • Bei Paddle Node im Inspector unten bei Node:
  • Script: New Script...
    • im Pfad /scenes/player/paddle.gd
    • Typ CharacterBody2D

Skript Inhalt:

              
class_name Padddle
extends CharacterBody2D
              
            

Beide Padddles als Szenen Instantiieren

  • Szene /scenes/player/paddle.tscn via drag&drop in Szene ziehen
  • Erste Instanz: PaddleA
  • Transform
    • x: 30
    • y: 720/2 (360)
  • Zweite Instanz: PaddleB
  • Transform
    • x: 1280-30 (1250)
    • y: 720/2 (360)

Paddle Script

Code unten erklärt

    class_name Paddle
extends CharacterBody2D
#-- Die Geschwindigkeit des Paddles. Kann man dank @export pro Instanz einstellen.
## speed of paddle movement
@export var speed: float = 400.0
#-- Normalerweise würde man die Größe direkt auslesen aus CollisionShape2D oder Sprite2D
#-- Hier zu vereinfachung von späteren Code hardcoded
## size of paddle (used for ball calculation later)
const size: float = 120.0
#-- Kann man sich vorstellen wie ein Joystick
#-- Nach unten -1 und nach oben 1
## movement direction: -1.0 down and 1.0 up
var movement: float = 0.0

func _physics_process(delta):
	# If Movement is not zero
	if movement != 0.0:
		velocity.x = 0
		#-- Hier wird zur Sicherheit die movement Variable clamped (min -1 und max 1)
		#-- falls die variable falsch von außen gesetzt wird.
		#-- das wird noch mit _speed_ multipliziert für die richtige Geschwindigkeit.
		# set vertical velocity and clamp movement in case it's set incorrectly
		velocity.y = clampf(movement, -1.0, 1.0) * speed
		#-- move_and_collide hat einen Rückgabewert bei Kollision; wird hier verworfen
		#-- Wir nutzen hier nicht move_and_slide, da wir an der Wand stehen bleiben wollen.
		# move until hitting an obstacle
		move_and_collide(velocity * delta)


    
  

Input Actions einrichten

  • In ProjectProject Settings...Input Map
  • Input actions einrichten (Namen eingeben und hinzufügen, dann Key festlegen)
    • PaddleAUp: W
    • PaddleADown: S
    • PaddleBUp:
    • PaddleBDown:

Human Controller erstellen und einrichten

neues Skript /scenes/player/human_controller.gd anlegen

              class_name HumanController
extends Node

@export var action_up: String = "PaddleAUp"
@export var action_down: String = "PaddleADown"

@onready var paddle: Paddle = get_parent()

func _physics_process(_delta):
	paddle.movement = Input.get_axis(action_up, action_down)
              
            

Die exportierten Action Strings geben die Möglichkeit die beiden Spieler abzubilden ohne zwei Skripte zu schreiben.

Seitenwände

  • StaticBody2D hinzufügen und Walls benennen
  • Darunter eine CollisionShape2D hinzufügen
    • Darin eine RectangleShape2D definieren
    • Größe (1280, 20)
    • Position (1280/2, -10)
  • Die CollisionShape2D duplizieren mit CTRL+D
    • Position (1280/2, 730)

Goal Areas

  • Unter der PongGame node eine Area2D hinzufügen
    • Name: GoalLeft
  • Darunter eine CollisionShape2D hinzufügen
    • Größe: (20, 720+40)
    • Position: (-10, 720/2)
    • Debug Farbe: ca. Rot (nur zur Unterscheidung im Editor)
  • Das Area2D mit der CollisionShape2D Gruppieren
    • Beide mit Shift anklicken
    • CTRL+G zum Gruppieren
  • Das Area Duplizieren mit CTRL+D
    • Area umbenennen auf GoalRight
    • Position von Area auf (1280+10, 720/2)

Pong Ball erstellen

  • Neuen CharacterBody2D unter PongGame erstellen
    • Name: PongBall
    • Im Inspector unter Node Gruppe "ball" hinzufügen
  • Unter dem Ball eine Sprite2D node hinzufügen
    • Texture → GradientTexture2D
    • Gradient auf pur Weiß stellen
    • größe der Textur auf (40, 40)
  • Unter dem Ball eine CollisionShape2D hinzufügen
    • Shape: RectangleShape2D
    • Größe: (40, 40)
  • Das Ganze mit CTRL+G Gruppieren

Pong Ball Code

Code erklärung unten

              class_name PongBall
extends CharacterBody2D
const BOUNCE_ANGLE: float = 45

#-- Signal bei Paddle-Ball Kollision worauf Effekte oder KIs reagieren können
signal ball_collision(collision: KinematicCollision2D)

#-- Start Geschwindigkeit geben (primär zum testen am Anfang)
func _ready():
	velocity = Vector2(300, 0)

func _physics_process(delta):
#-- Solange gerade aus bewegen bis eine Kollision auftritt
	var collision: KinematicCollision2D = move_and_collide(velocity * delta)
#-- Unterscheidung zwischen Paddle und anderem damit die Paddle auch Zielen können
	if collision:
		# check if collided with a paddle
		var collider: Node = collision.get_collider()
		if collider.is_in_group("paddles"):
			# make special bounce to make aiming possible
			_handle_paddle_collision(collision)
			ball_collision.emit(collision)
		else:
			# bounce normally
			velocity = velocity.bounce(collision.get_normal())

func _handle_paddle_collision(collision: KinematicCollision2D) -> void:
#-- extra Fehlerbehandlung falls die Gruppe "Paddle" fälschlich gesetzt wird
	if collision.get_collider() is not Paddle:
		push_warning("Collision with non-Paddle object: %s" % collision.get_collider().name)
		return
#-- Lustige Mathe um das Bounce verhalten ähnlich zu Breakout zu machen
#-- Nur bedingt das originale PONG verhalten, aber macht es interessanter
#-- Großteil des Codes kann weggelassen werden ohne dieses Sonderverhalten
	# if the ball hits a paddle, adjust the angle based on where it hit
	var paddle: Paddle = collision.get_collider()
	var paddle_center: Vector2 = paddle.global_position
	var hit_position: Vector2 = collision.get_position()
	# calculate how far up or down the paddle the ball collided
	var diff = hit_position.y - paddle_center.y
	diff = diff / (paddle.size / 2.0)
	var magnitude: float = velocity.length()
	velocity = collision.get_normal() * (magnitude + abs(diff) * 100.0)
#-- Der Vektor wird in eine Richtung rotiert um den gesteuerten Bounce zu machen
	if velocity.x > 0:
		# when going left
		velocity = velocity.rotated(deg_to_rad(BOUNCE_ANGLE * diff))
	else:
		# when going right
		velocity = velocity.rotated(deg_to_rad(-BOUNCE_ANGLE * diff))
              
            

Gruppe paddles zu den Paddles hinzufügen

  • In der Szene /scenes/player/paddle.tscn
    • Dem Paddle die Gruppe paddles hinzufügen
  • Das sollte automatisch in der PongGame Szene aktualisiert werden

Pong Game Skript

              class_name PongGame
extends Node2D

@export var ball: PongBall
@export var paddle_left: Paddle
@export var paddle_right: Paddle

@export var label_a: Label
@export var label_b: Label
var score_a: int = 0
var score_b: int = 0

signal kickoff
signal goal(goal_on_right: bool)

func _ready():
	reset_ball(true)

#-- Diese beiden Funktionen werden auf Tore reagieren
#-- Noch sind die nicht verbunden und werden nicht aufgerufen
func _on_goal_left_body_entered(body: Node2D) -> void:
	if body.is_in_group("ball"):
		score_b += 1
		label_b.text = str("%02d" % score_b)
		reset_ball(true)
		print("Goal for Player B! Score: ", score_b)
		goal.emit(true)

func _on_goal_right_body_entered(body: Node2D) -> void:
	if body.is_in_group("ball"):
		score_a += 1
		label_a.text = str("%02d" % score_a)
		reset_ball(false)
		print("Goal for Player A! Score: ", score_a)
		goal.emit(false)

func reset_paddle_pos() -> void:
	paddle_left.position.y = 720 / 2.0
	paddle_right.position.y = 720 / 2.0

#-- Ball wird in die Mitte gesetzt mit leichten offset von position und Richtung
#-- Damit ist das Spiel ein bisschen interessanter
func reset_ball(towards_left: bool = true) -> void:
	ball.position = Vector2(1280.0/2.0, 720.0/2.0) + Vector2.UP * (randf() - 0.5) * 100
	ball.velocity = Vector2.ZERO
	await get_tree().create_timer(1.0).timeout
	if towards_left:
		ball.velocity = Vector2(-300, 0)
	else:
		ball.velocity = Vector2(300, 0)
	# rotate by 10% of 180 degrees for random kickoff
	ball.velocity = ball.velocity.rotated(randf() * PI * 0.1)
	kickoff.emit()
              
            

Pong Game Signale verbinden

  • Signale der beiden Tore zu PongGame verbinden
    • GoalLeft→Signal body_entered
    • PongGame_on_goal_left_body_entered
    • GoalRight→Signal body_entered
    • PongGame_on_goal_right_body_entered

Simple KI

              class_name SimpleAI
extends Node

@export var ball: PongBall
@onready var paddle: Paddle = get_parent()
@export var avoid_vibration: bool = true

func _physics_process(delta):
	if avoid_vibration:
		_chase_ball_no_vibration(delta)
	else:
		_chase_ball_simple()

#-- Wir müssen hier bei der Movement nicht auf -1 bis 1 begrenzen
#-- da wir das bereits im Ball Skript gemacht haben.
#-- Hier gibt es zwei Versionen der Bewegungen, eine sehr einfache
## just move towards the ball
func _chase_ball_simple() -> void: 
	paddle.movement = ball.position.y - paddle.position.y

#-- und eine die die gebrauchte Distanz Ausrechnet und macht
## try to not overshoot
func _chase_ball_no_vibration(delta: float) -> void:
	paddle.movement = (ball.position.y - paddle.position.y) / paddle.speed / delta
              
            

Vorbereitung für Predictive KI

  • Unter PongGame node einen StaticBody2D erstellen
    • Name: DetectionWallLeft
    • CollisionShape2D: RectangleShape2D
    • CollisionShape2D Größe: (30, 720/2)
    • Farbe der Shape auf Grün setzen
  • StaticBody und CollisionShape mit CTRL+G Gruppieren
  • Gruppe detectors auf den StaticBody hinzufügen
  • CollisionLayer setzen auf
    • Layer: 2
    • Detection: -
  • CollisionLayer 2 umbenennen auf "DetectionLeft"
  • CollisionLayer 3 umbenennen auf "DetectionRight"
  • Shape Duplizieren mit CTRL+D mit Name DetectionWallRight
  • Position Wand Links: (30, 720/2)
  • Position Wand Rechts: (1280-30, 720/2)

Predictive KI hinzufügen

              class_name PredictiveAI
extends Node2D

@export var ball: PongBall
#-- detector_layer ist für den Raycast als Bitmaske für die Layer.
#-- Eine Seite braucht Layer 1 und 2, die andere 1 und 3
@export var detector_layer: int = 0b0011
@onready var paddle: Paddle = get_parent()
signal target_predicted(target_height: float)

var target_y: float = 720.0 / 2.0 # Default to center of the screen
var raycast_path: Array = []

func _physics_process(delta):
	# go towards the target Y position
	paddle.movement = (target_y - paddle.position.y) / paddle.speed / delta

func _on_ball_collision(collision: KinematicCollision2D) -> void:
	var collider: Node = collision.get_collider()
#-- Wenn der Ball das andere Paddle trifft, rechne einen neue Prediction aus
	# check if the ball just collided with the other paddle
	if collider.is_in_group("paddles") and collider != paddle:
		raycast_path.clear()
		# raycast and bounce until hit a detection layer
		var curr_pos: Vector2 = collision.get_position()
		raycast_path.append(curr_pos)
		var direction: Vector2 = ball.velocity.normalized()
		calc_new_target_y(curr_pos, direction, collider)
		queue_redraw()
#-- Wenn der Ball das eigene Paddle trifft, lösche die Visualisierung
#-- und zurück auf neutrale Position
	elif collider == paddle:
		# if the ball hit itself, just set the target to the center
		target_y = 720.0 / 2.0
		raycast_path.clear()
		# print("Ball hit itself, resetting target to center.")
		queue_redraw()

#-- Bei Kickoff muss getestet werden ob der Ball auf das eigene Paddle zugeht
#-- dafür wird verglichen ob der Ball links vom Paddle ist und nach rechts geht
#-- und umgekehrt
func _on_kickoff() -> void:
	var ball_left_of_paddle: bool = ball.global_position.x < paddle.global_position.x
	if ((ball_left_of_paddle and ball.velocity.x > 0.0)
		or
		(not ball_left_of_paddle and ball.velocity.x < 0.0)):
		calc_new_target_y(
			ball.global_position,
			ball.velocity.normalized(),
			null
		)

#-- Um eine Prediction zu machen werden raycasts geschossen bis eine Wand getroffen wird 
#-- und reflektiert. Das wirdd wiederholt bis ein detector getroffen wird.
func calc_new_target_y(start_pos: Vector2, start_dir: Vector2, prev_collider: Node) -> void:
	var curr_pos: Vector2 = start_pos
	var direction: Vector2 = start_dir
	var emergency_counter: int = 0
	var ray_length: float = 2000.0 # Arbitrary long distance
	var is_finished: bool = false
	var ray_end: Vector2 = start_pos + start_dir * ray_length
	var space_state = paddle.get_world_2d().direct_space_state
	# fallback to paddle if no previous collider is provided
	var last_collider: Node = prev_collider if prev_collider else paddle
	while not is_finished:
			emergency_counter += 1
			if emergency_counter > 50:
				push_error("Emergency counter exceeded 100. Stopping raycast to prevent infinite loop.")
				return
			# print("dir: ", direction)
			var query = PhysicsRayQueryParameters2D.create(
				curr_pos,
				ray_end,
				detector_layer,
				[last_collider, paddle, ball],
				
			)
			query.hit_from_inside = false
			var result = space_state.intersect_ray(query)
			if result.is_empty():
				push_error("Raycast did not hit anything. Aborting...")
				return
			if result["collider"].is_in_group("detectors"):
				is_finished = true
				target_y = result["position"].y
				target_y = paddle.get_parent().to_local(Vector2(0, target_y)).y
				target_predicted.emit(result["position"].y)
				raycast_path.append(result["position"])
				target_y += (randf() - 0.5) * 40 # Add some randomness to the target Y position
				# print("target_y set to: ", target_y)
			else:
				if result["normal"] == Vector2.ZERO:
					# print("pos: ", result["position"])
					push_error("Raycast hit a collider with no normal. Aborting...")
					return
				curr_pos = result["position"]
				direction = direction.bounce(result["normal"])
				ray_end = curr_pos + direction * ray_length
				# print("bounce at: ", curr_pos)
				raycast_path.append(curr_pos)
				last_collider = result["collider"]

#-- Mit der Draw funktion wird direkt auf das malen der Node2D zugegriffen
#-- hier wird der zuvor gespeicherte predictive path gemalt
#-- außerdem werden Start und Ende markiert.
func _draw():
	if raycast_path.size() > 0:
		var draw_arr: Array = []
		for point in raycast_path:
			draw_arr.append(point)
		# print(raycast_path)
		for i in range(raycast_path.size() - 1):
			draw_line(draw_arr[i], draw_arr[i + 1], Color(1, 0, 0), 3)
		draw_circle(draw_arr[0], 5, Color(0, 1, 0)) # Start point
		draw_circle(draw_arr[-1], 5, Color(0, 0, 1)) # End point
              
            
  • PredictiveAI node unter PadddleLeft hinzufügen
  • AI Node Top Level machen (CanvasItem/Visibility/TopLevel)
  • PongBall in export variable setzen
  • Signal kickoff von PongGame verbinden zu _on_kickoff()
  • Signal ball_collision von PongBall verbinden zu _on_ball_collision()
  • (Wenn diese KI rechts hinzugefügt wird die DetectorLayer von 3 auf 5 setzen)