Exercise 02

Bot Brawl

Projekt herunterladen

Download bot-brawl-exercise.zip

Main scene und Hintergrund (Parallax)

  • scene res://scenes/main_scene.tscn erstellen
  • eine Node2D
  • Namen: MainScene
  • als MainScene festlegen
  • eine Parallax2D node hinzufügen
    • Repeat Size: (360,360)
    • Repeat Times: 17
  • darunter eine Sprite2D
    • Texture: darkPurple.png
    • Scale: 2.0

Playership und Collision Polygon

  • RigidBody2D hinzufügen
  • Darunter ein Sprite2D
    • Texture: spaceShips_005.png
    • Rotation: 180
    • Scale: 0.34
  • Beim Sprite oben Create CollisionPolygon2D sibling
  • CollisionPolygon2D vereinfachen
  • Camera2D hinzufügen
    • Process Callback: Physics
    • Position Smoothing: enabled
    • position_smoothing_speed: 15

Teamfarben Shader

              shader_type canvas_item;

const float max_red_value = 190.0 / 255.0;
uniform vec4 target_color : source_color = vec4(0.7, 0.0, 0.0, 1.0);

void fragment() {
	vec4 current_pixel = texture(TEXTURE, UV);
	// check if pixel is primarily red coloured
	if(current_pixel.r > (current_pixel.b + current_pixel.g) * 0.5)
	{
		// divide by 190 (converted from 255 to 1.0 value), ca highest red value of the original images
		float mult = current_pixel.r / max_red_value;
		// check how close the colors are to approximate blending between colors
		float diff_g = current_pixel.g / current_pixel.r;
		// get min value of current color to adjust blending target
		float avg_val = (current_pixel.r + current_pixel.g + current_pixel.b) / 3.0;
		// adapt target color to actual pixel color
		current_pixel.rgb = target_color.rgb * mult + (vec3(avg_val) - target_color.rgb * mult) * diff_g;
	}
	COLOR = current_pixel;
}

            
  • shader anlegen: res://components/shaders/team_color_shader.gdshader
  • Shadermaterial anlegen: res://components/materials/team_color_shadermat.tres
  • Das ist ein "Color Swap Shader"
  • Damit kann man später einfacher die Space Ships auseinander halten

Health Component und Health Bar

            @icon("res://assets/icons/heart.svg")
class_name HealthComponent
extends Node2D

@export var health: float = 100.0:
	set(val):
		health = val
		health_changed.emit()
		if health <= 0 and not was_zero:
			health_zero.emit()
var health_percentage: float:
	get():
		return health / initial_health
var was_zero: bool = false
var initial_health: float
signal health_changed
signal health_zero

func _ready():
	health_changed.connect(on_health_changed)
	health_zero.connect(on_health_zero)
	get_parent().add_to_group("has_health")
	get_parent().set_meta("health_path", get_path())
	initial_health = health

func on_health_zero():
	was_zero = true
	print("ded")

func on_health_changed():
	if health >= 0 and was_zero:
		was_zero = false

func damage(value: float):
	health = clampf(health - value, 0.0, initial_health)
          
              class_name HealthBar
extends ProgressBar

var hp_comp_parent: Node2D
var hp_comp: HealthComponent
var hp_offset: Vector2

func _ready():
	hp_comp = get_parent()
	hp_comp_parent = hp_comp.get_parent()
	hp_offset = hp_comp.position
	hp_comp.top_level = true
	hp_comp.health_changed.connect(_on_health_changed)
	_on_health_changed()

func _process(_delta):
	hp_comp.global_position = hp_comp_parent.global_position + hp_offset

func _on_health_changed():
	value = hp_comp.health_percentage * 100
	if value >= 100:
		hide()
	elif not visible:
		show()
            
  • res://components/health_component.gd anlegen
  • res://components/health_bar.gd anlegen
  • res://components/health_bar.tscn anlegne
    • res://components/health_bar.tscn: false
    • size: (70,10)
    • position: (-35,-5)
    • Theme override
      • Background → graue "flat" box (~100 alpha)
      • Fill → grüne "flat" box (~100 alpha)
  • HealthComponent unter dem Spieler instantiieren und etwas nach oben schieben
  • HealthBar unter der HealthComponent instantiieren

Project settings und Controls

  • Project Settings
    • Gravity: (0,0)
    • Viewport Width/Height: (1280, 720)
  • in Input Map:
    • move_up: W
    • move_down: S
    • move_left: A
    • move_right: D
    • boost: SPACE
  • res://scenes/character/manual_control.gd anlegen
  • ManualControl Node unter Spieler anlegen
  • res://scenes/character/player_ship.gdanlegen
              @icon("res://assets/icons/node_steering.svg")
class_name ManualControl
extends Node

@export var is_enabled: bool = true
@onready var player: SpaceShip = get_parent()
var camera: Camera2D
var camera_zoom_tween: Tween
var camera_zoom = 1

const ZOOM_FACTOR = 0.1
const MAX_ZOOM = 3.5
const MIN_ZOOM = 0.3

func _ready():
	camera = get_node_or_null("../Camera2D")
	if not camera:
		push_error("No Camera found to control, disabling camera controls...")

func _physics_process(_delta):
	if not is_enabled:
		return
	player.targeting_point = player.get_global_mouse_position()

func _input(event):
	if event is InputEventMouseButton:
		var mouse_event: InputEventMouseButton = event
		if mouse_event.button_index == MOUSE_BUTTON_LEFT and mouse_event.pressed:
			player.shoot_primary()
		if mouse_event.button_index == MOUSE_BUTTON_RIGHT and mouse_event.pressed:
			player.shoot_secondary()
		if camera:
			if mouse_event.button_index == MOUSE_BUTTON_WHEEL_UP and mouse_event.pressed:
				var factor = clamp(camera_zoom * (1 + ZOOM_FACTOR), MIN_ZOOM, MAX_ZOOM)
				_zoom_camera_to(factor)
			elif mouse_event.button_index == MOUSE_BUTTON_WHEEL_DOWN and mouse_event.pressed:
				var factor = clamp(camera_zoom * (1 - ZOOM_FACTOR), MIN_ZOOM, MAX_ZOOM)
				_zoom_camera_to(factor)
			elif mouse_event.button_index == MOUSE_BUTTON_MIDDLE and mouse_event.pressed:
				_zoom_camera_to(1)
	elif event is InputEventKey:
		player.acceleration_input = Input.get_vector("move_left", "move_right", "move_up", "move_down")
		if event.is_action("boost"):
			player.is_boosting = event.pressed


func _zoom_camera_to(factor: float):
	if not camera:
		push_warning("Zoom camera called without camera, aborting...")
	if camera_zoom_tween:
		camera_zoom_tween.kill()
	camera_zoom_tween = camera.create_tween()
	camera_zoom_tween.tween_property(camera, "zoom", Vector2(factor, factor), 0.05)
	camera_zoom = factor
            
              class_name SpaceShip
extends RigidBody2D

@export var speed: float = 500
@export var acceleration: float = 1000

var is_thrusting : bool = false:
	set(val):
		is_thrusting = val
		thrusting_changed.emit(is_thrusting)
var is_boosting : bool = false:
	set(val):
		is_boosting = val
		boosting_changed.emit(is_boosting)

var targeting_point: Vector2 = Vector2.ZERO
var acceleration_input: Vector2 = Vector2.ZERO

signal thrusting_changed(is_thrusting: bool)
signal boosting_changed(is_boosting: bool)
signal primary_fire_triggered()
signal secondary_fire_triggered()
signal damaged(damage: float)

func _ready():
	add_to_group("ship")

func _physics_process(_delta):
	# handle acceleration input
	apply_central_force(acceleration_input * acceleration)
	# handle aiming
	look_at(targeting_point)
	rotation_degrees += 90

	check_for_thrusting_state()

func check_for_thrusting_state() -> void:
	if acceleration_input.is_zero_approx() != not is_thrusting:
		is_thrusting = !is_thrusting
		thrusting_changed.emit(is_thrusting)

func shoot_primary():
	primary_fire_triggered.emit()

func shoot_secondary():
	secondary_fire_triggered.emit()

func take_damage(damage: float):
	damaged.emit(damage)
            

Polish für den "Auspuff"

  • Scene ExhaustFire anlegen
  • Darin gibt es eine Sprite2D und ein CPUParticles2D
  • Script für ExhaustFire anlegen
  • Partikeleffekte anpassen
              extends Sprite2D

var tween: Tween
@onready var smoke_particles: CPUParticles2D = $ExhaustSmoke

func _ready():
	get_parent().thrusting_changed.connect(on_is_boosting_changed)
	modulate = Color.TRANSPARENT
	smoke_particles.scale_amount_min *= scale.x
	smoke_particles.scale_amount_max *= scale.x
	smoke_particles.emitting = false
	

func on_is_boosting_changed(is_boosting: bool) -> void:
	if tween:
		tween.kill()
	if is_boosting:
		tween = create_tween()
		tween.set_loops()
		tween.tween_property(self, "modulate", Color.WHITE, 0.3)
		tween.tween_property(self, "modulate", Color(1, 1, 1, 0.5), 0.3)
		smoke_particles.emitting = true
	else:
		tween = create_tween()
		tween.tween_property(self, "modulate", Color.TRANSPARENT, 0.5)
		smoke_particles.emitting = false
            

Laser shots

  • res://scenes/weapons/projectile_settings.gd anlegen
  • eine laser_shot.tres anlegen (ProjectileSettings)
  • die Scene res://scenes/weapons/laser_shot.tscn anlegen mit Script
    • LaserShot ist eine Area2D
    • CollisionShape2D in Form eines Capsule
    • Sprite2D zB als "laserRed05.png"
    • sprite und collisionshape 90° kippen
              class_name ProjectileSetting
extends Resource

@export var projectile_scene: PackedScene = null

@export_group("Missile Settings")
@export_custom(PROPERTY_HINT_GROUP_ENABLE,"") var is_missile: bool = false
## fuel duration of missile until it runs out
@export var fuel_duration: float = 5
## Turning speed in degree/seconds
@export var turning_speed: float = 30
@export var turning_gain: float = 100
@export var turning_damp: float = 10
@export var velocity_alignment_rate: float = 1.0
@export var boosting_strength: float = 50
## Distance of detector polygon (FOV 45deg) in px
@export var detector_distance: float = 180
## Explosion radius
@export var explosion_radius: float = 50
@export var explosion_damage: float = 15
@export var impact_damage: float = 10

@export_group("Laser Settings")
@export_custom(PROPERTY_HINT_GROUP_ENABLE, "") var is_laser: bool = false
@export var laser_velocity: float = 50
@export var laser_damage: float = 10
@export var laser_energy_cost: float = 5
@export var laser_lifetime: float = 3.0
            
              class_name LaserShot
extends Area2D

var settings: ProjectileSetting
@export var ray: RayCast2D
var lifetime: float
var base_velocity: Vector2
var source_body: Node2D

func init_laser(projectile_settings: ProjectileSetting, shooter: Node2D , initial_velocity: Vector2 = Vector2.ZERO):
	settings = projectile_settings
	base_velocity = initial_velocity
	source_body = shooter

func _ready():
	add_to_group("laser")
	if settings == null:
		push_warning("No settings given on spawn. Self destructing...")
		# queue_free()
		settings = ProjectileSetting.new()
		return
	ray.target_position = Vector2.RIGHT * settings.laser_velocity
	lifetime = settings.laser_lifetime

func _physics_process(delta):
	lifetime -= delta
	if lifetime <= 0:
		queue_free()
		return
	# move laser by laser_velocity/seconds
	position += transform.x.normalized() * settings.laser_velocity * delta + base_velocity * delta

func _on_body_entered(body: Node2D):
	# to prevent multiple collisions after moving
	if is_queued_for_deletion():
		return
	# exclude shooter
	if body == source_body:
		return
	# check if body can be damaged
	if body.is_in_group("has_health"):
		# get health component of body and damage it
		body.get_node(body.get_meta("health_path")).damage(settings.laser_damage)
#-- wird später gebraucht, noch auskommmentiert da die missile noch fehlt (danach wieder reinmachen!)
#	if body.is_in_group("missile"):
#		var missile = body as Missile
#		missile.explode()
	# self destruct
	queue_free()
            

Ship guns (und laser fix)

  @icon("res://assets/icons/cannon.svg")
class_name ShipGun
extends Node2D

@export var loaded_primary: ProjectileSetting
@export var loaded_secondary: ProjectileSetting
@export var available_projectiles: Array[ProjectileSetting]

@onready var ship: SpaceShip = get_parent()

func _ready():
	ship.primary_fire_triggered.connect(on_primary_fire)
	ship.secondary_fire_triggered.connect(on_secondary_fire)

func on_primary_fire():
	if not loaded_primary or not loaded_primary.projectile_scene:
		push_warning("Loaded projectile missing or incorrect, not firing!")
		return
	shoot_shot(loaded_primary)

func on_secondary_fire():
	if not loaded_secondary or not loaded_secondary.projectile_scene:
		push_warning("Loaded projectile missing or incorrect, not firing!")
		return
	shoot_shot(loaded_secondary)

func shoot_shot(settings: ProjectileSetting):
	var new_shot = settings.projectile_scene.instantiate()
	new_shot.global_position = global_position
	new_shot.global_rotation = global_rotation
	if settings.is_laser:
		new_shot.init_laser(settings, ship, ship.linear_velocity)
	else:
		new_shot.init_missile(settings, ship, ship.linear_velocity)
	ship.add_sibling(new_shot)
        
  • res://components/ship_gun.gd anlegen
  • zwei ShipGun nodes unter den Spieler auf die Guns legen und rotieren dass X richtung vorne Zeigt
  • bei beiden Guns das "loaded_primary_projectile" als die laser_shot.tres setzen die vorhin angelegt wurde

Laser finaler fix und Missile anfang

  • Bei Lasershot noch das Signal verbinden
  • _on_body_entered./_on_body_entered()
  • danach die Missile Szene anfangen
    • RigidBody2D namens Missile
    • ´
      • Solver → Contact Monitor: true
      • Solver → Max Contacts: 1
      • CollisionLayer: 2
      • CollisionMask: 1
    • Sprite2D mit spaceMissiles_001.png, 90° rotation und scale 0.4
    • CollisionShape2D als capsule und auch rotieren und anpassen
    • ein ExhaustFire spawnen, rotieren und auch auf 0.4 skalieren und dann positionieren
  class_name Missile
extends RigidBody2D

@export var draw_target_debug: bool = true
signal thrusting_changed(is_thrusting)
var settings: ProjectileSetting
var lifetime: float
var base_velocity: Vector2
var source_body: Node2D
var fuel: float
var visible_targets: Array[Node2D]
@onready var detector: Area2D = $ShipDetector
@onready var explosion_particles: CPUParticles2D = $ExplosionParticles
var is_burnt_out: bool = false
var is_boosting: bool = false
var current_target: Node2D:
	set(val):
		current_target = val
		queue_redraw()

func init_missile(projectile_settings: ProjectileSetting, shooter: Node2D, initial_velocity: Vector2 = Vector2.ZERO):
	await ready
	settings = projectile_settings
	base_velocity = initial_velocity
	source_body = shooter
	# shrink/grow detection polygon according to settings
	var detector_polygon = detector.get_child(0) as CollisionPolygon2D
	var detector_factor = settings.detector_distance / 60.0
	detector_polygon.polygon = PackedVector2Array([
		Vector2.ZERO,
		Vector2(-60, -60) * detector_factor,
		Vector2(60, -60) * detector_factor,
		]
	)
	# set initial setting values
	fuel = settings.fuel_duration

func _ready():
	add_to_group("missile")
	var col_layer = collision_layer
	var col_mask = collision_mask
	collision_layer = 0
	collision_mask = 0
	get_tree().create_timer(1).timeout.connect(func():
		collision_layer = col_layer
		collision_mask = col_mask
		is_boosting = true
		thrusting_changed.emit(true)
	)
	apply_impulse(global_transform.x * 300)
	pass

func explode(deal_damage = true):
	if is_queued_for_deletion():
		return

	# change explosion particles to be independent from missile
	remove_child(explosion_particles)
	get_parent().add_child(explosion_particles)
	explosion_particles.global_position = global_position
	# handle explosion
	if deal_damage:
		var space_state = get_world_2d().direct_space_state
		var shape = CircleShape2D.new()
		shape.radius = settings.explosion_radius
		var query = PhysicsShapeQueryParameters2D.new()
		query.shape = shape
		query.transform = query.transform.translated(global_position)
		query.collide_with_bodies = true
		query.collide_with_areas = false
		query.exclude = [self]
		var results = space_state.intersect_shape(query)
		for result in results:
			var node: Node = result.collider
			if node.is_in_group("has_health"):
				var health_comp: HealthComponent = node.get_node(node.get_meta("health_path"))
				health_comp.damage(settings.explosion_damage)
	# make the one shot particles emit
	explosion_particles.emitting = true
	# adjust visuals to explosion radius
	explosion_particles.emission_sphere_radius = settings.explosion_radius
	explosion_particles.scale_amount_min *= settings.explosion_radius / 50
	explosion_particles.scale_amount_max *= settings.explosion_radius / 50
	# make explosion particles delete themselves after emission finished
	explosion_particles.finished.connect(explosion_particles.queue_free.bind())
	queue_free()

func _physics_process(delta):
	if is_burnt_out:
		return
	if not is_boosting:
		return
	fuel -= delta
	# check if rocket is out of fuel
	if fuel <= 0.0:
		is_burnt_out = true
		thrusting_changed.emit(false)
		current_target = null
		queue_redraw()
		# explode in 2 seconds if it didn't already happen
		get_tree().create_timer(2).timeout.connect(
		func():
			if self:
				self.explode()
		)
		return
	# get closest target to aim at
	for body: Node2D in visible_targets:
		if not current_target or current_target.global_position.distance_squared_to(global_position) < body.global_position.distance_squared_to(global_position):
			current_target = body
	if visible_targets.is_empty():
		current_target = null
	# aim towards target
	if current_target:
		queue_redraw()
		## PID like aiming
		var target_dir = (current_target.global_position - global_position).normalized()
		var target_error = target_dir.angle() - global_rotation
		target_error = wrapf(target_error, -PI, PI)
		var proportional_torque = target_error * settings.turning_gain
		var damping_torque = - angular_velocity * settings.turning_damp
		apply_torque(proportional_torque + damping_torque)

		var missile_angle = global_transform.x.angle()
		var vel_angle = linear_velocity.angle()
		var angle_diff = wrapf(missile_angle - vel_angle, -PI, PI)
		var rotation_amount = clampf(angle_diff, -settings.velocity_alignment_rate, settings.velocity_alignment_rate)
		linear_velocity = linear_velocity.rotated(rotation_amount)
	else:
		apply_torque(-angular_velocity * 0.8)
	# accelerate
	if current_target:
		apply_central_force(global_transform.x * settings.boosting_strength)
	else:
		# only apply half acceleration without a target (be "idle")
		apply_central_force(global_transform.x * settings.boosting_strength * 0.5)

func _on_ship_detector_body_entered(body: Node2D):
	# ignore self
	if body == self:
		return
	if body.is_in_group("ship"):
		visible_targets.append(body)

func _on_ship_detector_body_exited(body: Node2D):
	if body in visible_targets:
		visible_targets.erase(body)

func _draw():
	if current_target and draw_target_debug:
		draw_line(Vector2.ZERO, to_local(current_target.global_position), Color(0,1,0,0.5), 5)

func _on_body_entered(_body: Node):
	explode()

Missile Detector und Explosions Effekt

  • Area2D mit CollisionPolygon2D erstellen mit Namen "ShipDetector"
    • CollisionLayer: 0
    • CollisionMask: 1
    • Rotieren um 90° (passiert im übernächsten clip)
    • Polygon mit werten (nach rotation) versehen: (0,0), (-60,-60), (60,-60)
    • CPUParticles2D erstellen mit Namen ExplosionParticles und visuell anpassen

Missile Einstellungen

  • Signal body_entered() und body_exited mit Missile Script verbinden
  • Signal von Missile selbst auf sich selbst connecten der body_entered
  • MissileSettings ProjectileSettings anlegen
  • bei den ShipGuns die MissileSettings hinterlegen

Missile Finaler Fix

  • hier wird die rotation durchgeführt und damit das Area2D gefixt
  • außerdem werden noch die missile settings eingestellt

Gegner Drone (fast nur Kompenten zusammenstecken!)

  • hier werden die Schritte vom Spieler größtenteils wiederholt um einen Gegner zu bauen
  • beliebiges Schiff kann hier ausgewählt werden (im Video spaceShips_008.png)
  • hier nur drauf achten, ohne die manual control darf der gegner nicht genau auf (0,0) positioniert sein, das ist ein Bug im Script

Mögliche Erweiterungen:

Wenn man noch Zeit hat zum experimentieren

  • Explodieren der Schiffe bei 0HP
  • RigidBody2D Asteroiden die als Hindernisse fungieren könenn
  • Simple Gegner KI die den Spieler angreift (immer zielen und drauf schießen, vilt sogar eine FSM, ...)