DEV Community

Cover image for Een beat-em-up game maken met Godot - hurtboxes en hitboxes
Bgie
Bgie

Posted on • Updated on

Een beat-em-up game maken met Godot - hurtboxes en hitboxes

Terug naar deel 6

Het vecht-systeem is gebaseerd op 'hitboxes' en 'hurtboxes'.

Het slachtoffer van een aanval heeft een hitbox. Het wapen van de aanvaller heeft een hurtbox. Als de hurtbox van de aanvaller de hitbox van het slachtoffer raakt, krijgt het slachtoffer 'damage'.

Beiden gaan we maken met CollisionShape2D objecten, waar we ook onze botsingen mee maakten.

We hadden al een CollisionShape2D op de voeten geplaatst van onze personages. Dit kunnen we ook gebruiken voor als hitbox. Om dit duidelijk te maken, veranderen we naam van deze nodes, zowel voor de 'Player' als voor de 'Minotaur':

Rename to hitbox

Vervolgens gaan we de hurtbox voor het wapen maken. Bij de speler voegen we 2 nodes toe, een Area2D en daaronder een CollisionShape2D:

Add player area2d

Om de hurtbox op de juiste plaats te kunnen zetten, gaan we tijdelijk onze AnimatedSprite2D verzetten naar de juiste frame (5) voor de "attack" animatie. Zo zien we waar het wapen neerkomt.

Attack animation

Zoals we al eerder deden, moeten we nu een Shape toevoegen aan onze CollisionShape2D. Hiervoor gebruiken we een CapsuleShape2D.

Add shape to hurtbox

We zetten enkele waardes van onze CapsuleShape2D juist: de Radius wordt 130 px, de Height wordt 400 px en onze Position krijgt een x van 300. Het is ook mogelijk de kleur (Debug Color) aan te passen, maar dit zie je niet in de game zelf, enkel in de editor tijdens het ontwerpen.

Set hurtbox properties

Onze hurtbox noemt nog Area2D, de standaard naam. Het is duidelijker als we deze van naam te veranderen:

Rename hurtbox

Onze code hangen we aan het signaal body_entered(body: Node2D) dat hoort bij onze hurtbox. (In de screenshot heet de hurtbox nog Area2D)

Connect body_entered

De functie die we nu krijgen van Godot heeft een parameter body. Dit is een variabele waarin het object zit waar we mee botsen. Allerlei objecten kunnen botsen, en niet elk object kunnen we aanvallen.

Onze vijand, die kan aangevallen worden, gaan we dadelijk een "take_damage" functie geven. In onze _on_hurt_box_body_entered van de speler kunnen we aan Godot vragen of de body waar we mee botsen zo een functie "take_damage" heeft.
Indien ja, dan roepen we die aan.

We passen onze _on_hurt_box_body_entered functie aan:

func _on_hurt_box_body_entered(body):
    if body.has_method("take_damage"):
        body.take_damage()
Enter fullscreen mode Exit fullscreen mode

Het "take_damage" gedeelte komt in het script van de vijand. We voegen daar deze nieuwe functie toe. self.queue_free() is de instructie om de vijand te laten verdwijnen. We zorgen straks voor een 'sterf' animatie.

In het script van de vijand:

func take_damage():
    self.queue_free()
Enter fullscreen mode Exit fullscreen mode

Als we dit testen, merken we dat er nog een probleem is. De vijand verdwijnt wel degelijk, maar onze speler moet niet eens aanvallen! Om dit correct te doen, moeten we de hurtbox enkel actief maken wanneer het wapen neerkomt.

We zorgen dat onze hurtbox in het begin UIT staat, en geen botsingen gaat detecteren. We zetten Monitoring en Monitorable allebei uit.
We zetten ook Layer 1 uit bij het collision gedeelte. Dit zorgt ervoor dat onze hurbox niet meetelt als 'hard' object voor botsingen. Andere objecten in laag 1 kunnen gewoon door onze hurtbox heen bewegen. Enkel Mask staat op, wat wil zeggen dat onze hurbox wel gaat kijken naar andere objecten.

Set hurtbox properties

Nu willen we onze hurtbox AAN zetten wanneer het wapen neerkomt. Als we gaan kijken in onze AnimatedSprite2D bij de "attack" animatie, zien we alle frames en hun nummers. In frame 4 tot 7 komt het wapen neer. We verbinden het signaal frame_changed() aan ons script:

Frames with attack

Vanboven in de code van de speler maken we een tweede @onready variabele om aan onze hurtbox te kunnen:

@onready var sprite: AnimatedSprite2D = $AnimatedSprite2D
@onready var hurtbox: Area2D = $HurtBox
Enter fullscreen mode Exit fullscreen mode

De code voor onze _on_animated_sprite_2d_frame_changed bestaat uit enkele eenvoudige if instructies. Als de "attack" animatie aan frame 4 komt, zetten we monitoring van de hurtbox aan. Bij frame 7 terug uit.

func _on_animated_sprite_2d_frame_changed():
    if sprite.animation == "attack":
        if sprite.frame == 4:
            hurtbox.monitoring = true
        elif sprite.frame == 7:
            hurtbox.monitoring = false
Enter fullscreen mode Exit fullscreen mode

De frame_changed() functie wordt altijd opgeroepen, zelfs als de grafische kaart te traag is om het beeld feitelijk te tonen (lage FPS).

We hebben nu dit:

Attack enemy gone

De vijand verdwijnt gewoon, terwijl we een sterf animatie voor onze vijand hebben. Die willen we natuurlijk gebruiken!

Het is eenvoudig om dit aan onze state machine toe te voegen. In het script van de vijand voegen we een extra state toe, DYING.

enum State {READY, ATTACK, DYING}
Enter fullscreen mode Exit fullscreen mode

In de take_damage() functie gaan we niet onmiddellijk de vijand laten verdwijnen, maar gaan we naar onze nieuwe DYING state switchen en de juiste animatie starten.

func take_damage():
    if state != State.DYING:
        sprite.play("die")
        state = State.DYING
Enter fullscreen mode Exit fullscreen mode

Dit kan je testen, het werkt, maar na de dramatische val komt onze vijand gewoon terug recht. Dat komt door onze animation_finished code, die we hebben geschreven voor het aanvallen.

We passen de _on_animated_sprite_2d_animation_finished functie dus ook aan:

func _on_animated_sprite_2d_animation_finished():
    if state == State.DYING:
        self.queue_free()
    else:
        state = State.READY
Enter fullscreen mode Exit fullscreen mode

Nu verdwijnt onze vijand na het omvallen.

Het is pas helemaal af met een fade-out animatie. Een manier om een eenvoudige animatie te starten vanuit code is met een tween. We maken een tween, en voegen een aantal animatie stappen toe, die de tween dan erna gaat uitvoeren. De animatie is als volgt: eerst 3 seconden wachten, dan de kleur van onze sprite geleidelijk over laten gaan naar transparant, gedurende 1 seconde. Dan pas volgt de queue_free() waarmee onze vijand verdwijnt. De animatie wordt samengesteld in onze code, maar start pas nadat onze functie _on_animated_sprite_2d_animation_finished afgelopen is.

func _on_animated_sprite_2d_animation_finished():
    if state == State.DYING:
        var tween = create_tween()
        tween.tween_interval(3)
        tween.tween_property(sprite, "modulate", Color.TRANSPARENT, 1)
        tween.tween_callback(self.queue_free)
    else:
        state = State.READY
Enter fullscreen mode Exit fullscreen mode

Attack with fade out

Het is niet erg fair dat onze vijand zich niet kan verdedigen. We gaan dezelfde stappen herhalen voor de vijand, zodat de vijand onze speler ook pijn kan doen.

In het Scene paneel van de minotaurus voegen we ook een Area2D toe met daaronder een CollisionShape2D. De Area2D geven we een ander naam: HurtBox.
De CollisionShape2D krijgt een CapsuleShape2D als vorm, met een Radius van 150 px, een Height van 500 px en een Position met x = 400 px. De Debug Color kleur mag je ook aanpassen als je wil.

Enemy hurtbox properties

We verbinden ook het body_entered(body: Node2D) signaal.

Connect body entered

We kunnen hier dezelfde code gebruiken als voor onze speler:

func _on_hurt_box_body_entered(body):
    if body.has_method("take_damage"):
        body.take_damage()
Enter fullscreen mode Exit fullscreen mode

We moeten straks gewoon zorgen dat onze speler ook een "take_damage" functie heeft.

Eerst gaan we zorgen dat de onze hurtbox enkel actief is wanneer de knuppel neerkomt.

De hurtbox van onze minotaurus passen we aan: Monitoring en Monitorable moeten UIT, en Layer 1 moet ook UIT.

Disable enemy hurtbox monitoring

Dan verbinden we frame_changed() van de AnimatedSprite2D met ons script.

Connect enemy frame_changed

Net als bij de speler maken we een nieuwe @onready variabele om aan de hurtbox te kunnen vanuit de Enemy.gd code:

@onready var hurtbox: Area2D = $HurtBox
Enter fullscreen mode Exit fullscreen mode

De functie voor frame_changed is identiek aan die van de speler, enkel de frame nummers zijn verschillend:

func _on_animated_sprite_2d_frame_changed():
    if sprite.animation == "attack":
        if sprite.frame == 8:
            hurtbox.monitoring = true
        elif sprite.frame == 11:
            hurtbox.monitoring = false
Enter fullscreen mode Exit fullscreen mode

Om dit stuk al te testen, maken we tijdelijk een "take_damage" functie in het Player.gd script:

func take_damage():
    self.queue_free()
Enter fullscreen mode Exit fullscreen mode

Als we dit testen, dan lijkt het niet te werken. Er is een fout in de code geslopen, blijkbaar werkt de hurtbox alleen maar aan de rechterzijde van de vijand!

Hurtbox bug

Dit probleem had ik zelf niet voorzien, en ik wil de vorige screenshots niet opnieuw maken :(

De oplossing is gelukkig niet moeilijk. Het probleem zit in het spiegelen van de personages als ze naar links lopen, met de code sprite.scale.x = -1. Daar spiegelen we enkel de AnimatedSprite2D. Maar de hurtbox blijft natuurlijk op dezelfde plaats staan, aan de rechterzijde van de sprite.

We lossen dit op door de hurtbox van onze minotaurus te verslepen met de muis, en een kind te maken van de AnimatedSprite2D. Als we dan spiegelen, worden de kind nodes ook mee gespiegeld. De hurtbox komt dan ook aan de linkerzijde te liggen.

Move hurtbox node

Er is een kleine wijziging nodig in onze Enemy.gd code, omdat we de hurtbox gebruiken in onze code. De plaats waar de hurtbox staat is nu anders, hij is een kind geworden van de AnimatedSprite2D. De regel:

@onready var hurtbox: Area2D = $HurtBox
Enter fullscreen mode Exit fullscreen mode

... wordt nu ...

@onready var hurtbox: Area2D = $AnimatedSprite2D/HurtBox
Enter fullscreen mode Exit fullscreen mode

De bug zit ook in onze player code. Ook daar moeten we de hurtbox node verslepen in de Scene en de code aanpassen.

Attack by enemy

Er schuilt echter nog een subtiele bug in ons spel. Slaat de speler naar links met zijn staf, dan verdwijnt hij zelf!

Attack with suicide

Dit is een goede gelegenheid om een debug optie van Godot te demonstreren. In het debug menu kan je Visible Collision Shapes opzetten.

Enable visible collision shapes

Dan krijg je de botsingen te zien in de game, en dan zien we dit:

Hurtbox touching own hitbox

Omdat de tekening van de engel niet helemaal in het midden staat, hebben we een probleem als we spiegelen. De hurtbox van de speler raakt zijn eigen hitbox!

Er is een manier om dit op te lossen door verschillende lagen te gebruiken voor de botsingen, maar daar gaan we nu niet op ingaan.

De eenvoudigste manier is, om in de CollisionShape2D van de speler de CapsuleShape2D wat smaller te maken. We veranderen de Radius naar 120 px.

Reduce capsule radius

Alles lijkt nu te werken!

Attack with collision shapes

In de plaats van een instant-death voor onze speler, willen we misschien liever meerdere levens. Krijgt de speler een mep, dan spelen we de "hurt" animatie in plaats van te verdwijnen.

We voegen een nieuwe state toe in Player.gd:

enum State {READY, ATTACK, HURT}
Enter fullscreen mode Exit fullscreen mode

Onze take_damage maken we minder fataal:

func take_damage():
    if state == State.READY:
        state = State.HURT
        sprite.play("hurt")
Enter fullscreen mode Exit fullscreen mode

Een andere kleine wijziging is ook nodig: de vijand mept veel te snel, en de speler krijgt geen kans om te ontsnappen.

Open de 2D view van de minotaurus, en open de SpriteFrames van de AnimatedSprite2D. In de "attack" animatie gaan we de laatste frame (11) langer laten duren. We zetten de Frame Duration op 20.

Add delay to minotaur attack

Hetzelfde kunnen we doen voor de speler, maar hier gebruiken we 10 frames als vertraging. De staf van de speler is wat sneller dan de knuppel van de minotaurus.

Add delay to player attack

Dankzij de vertraging kan de speler nog weglopen wanneer de minotaurus begint te meppen, maar hij krijgt toch een stevig pak rammel!

Taking a beating

In de volgende delen gaan we nog toevoegen:

  • nieuwe vijanden die spawnen
  • een beperkt aantal levens voor de speler
  • game over en een high-score systeem

In het volgende deel implementeren we een basis 'health' systeem voor onze player.

Top comments (0)