Showing posts with label AI. Show all posts
Showing posts with label AI. Show all posts

Thursday, April 4, 2024

Sprite Overlap

 Today, we are going to create a new scene(node) to handle enemy sprites overlapping on screen:
- Area2D root node
- and a collisionShape

Then attach a script to your Area2D. In this script, we're going to check if there are two sprites in the same position (overlapping), and if they are, one of them is going to receive a little push to separate them.

```
extends Area2D

func is_colliding():
    var areas = get_overlapping_areas()
    return areas.size() > 0

func get_push_vector():
    var areas = get_overlapping_areas()
    var push_vector = Vector2.ZERO
    if is_colliding():
        var area = areas[0]
        push_vector = area.global_position.direction_to(global_position)
        push_vector = push_vector.normalized()
    return push_vector
```


For this to work, we also need to create a layer for this collision.

And then set it up to be used

Once the new Area2D scene is set up, instance it to your enemy nodes, and add their collision shapes

> I bet you think that's all, but you need to use those functions in your bat script for them to work

`onready var spriteOverlap = $SpriteOverlap`

and at the end of your physic_process:
```
    if spriteOverlap.is_colliding():
        velocity += spriteOverlap.get_push_vector() * delta * 400
    velocity = move_and_slide(velocity)
```


In other words, your enemy script should be eventually something like this:
```
extends KinematicBody2D

const EnemyDeathEffect = preload("res://Effects/EnemyDeathEffect.tscn")

export var ACCELERATION = 300
export var MAX_SPEED = 50
export var FRICTION = 200
export var WANDER_TARGET_RANGE = 4

enum {
    IDLE,
    WANDER,
    CHASE
}

var velocity = Vector2.ZERO
var knockback = Vector2.ZERO

var state = CHASE

onready var stats = $Stats
onready var playerDetectionZone = $PlayerDetectionZone
onready var spriteOverlap = $SpriteOverlap

func _physics_process(delta):
    knockback = knockback.move_toward(Vector2.ZERO, FRICTION * delta)
    knockback = move_and_slide(knockback)
    
    match state:
        IDLE:
            velocity = velocity.move_toward(Vector2.ZERO, FRICTION * delta)
            seek_player()
        WANDER:
            pass
        CHASE:
            var player = playerDetectionZone.player
            if player != null:
                var direction = (player.global_position - global_position).normalized()
                velocity = velocity.move_toward(direction * MAX_SPEED, ACCELERATION * delta)
            else:
                state = IDLE
                
    if spriteOverlap.is_colliding():
        velocity += spriteOverlap.get_push_vector() * delta * 400
    velocity = move_and_slide(velocity)

func seek_player():
    if playerDetectionZone.can_see_player():
        state = CHASE

func _on_Hurtbox_area_entered(area):
    stats.health -= area.damage
    knockback = area.knockback_vector * 150

func _on_Stats_no_health():
    queue_free()
    var enemyDeathEffect = EnemyDeathEffect.instance()
    get_parent().add_child(enemyDeathEffect)
    enemyDeathEffect.global_position = global_position

```

And that's all for this entry

Wednesday, April 3, 2024

Enemy AI part 2

For the enemy to attack, the player needs to have a hurtbox and some stats. That's the first thing we are going to do.

- Add a hurtbox and a stats child node to your player

- As we did with the enemy, we need to connect the no_health signal from the player stats too

- Yes, we also need another signal connected to the player for the hurtbox Area entered too

* After connecting this signal, you should have two, one for the hitbox to the area (for the animation effect), and one for the hitbox to the player (for the damage).


- After that, add the variable and the function for the player to update their stats
`onready var stats = $Stats` and

```
func _on_Hurtbox_area_entered(area):
    stats.health -= area.damage
```


- So, the player can be hurt, but the enemy doesn't have a hitbox, it's time to change that. Add a hitbox child node to your enemy

* Don't forget its shape, for now we will just attach a circle shape (will update this according to enemy's attacks later)


* It also needs the mask set up properly, 3 = player hurtbox:


With all of that, our enemy can kill the player now. However, the player never exits the area hitbox and the enemy can't repeat the attack until that.


>>> Things to fix: The enemy attack animation and hitbox, and the enemy exiting the area. Both should be fixable at the same time when we do the attack sprite animation part.

>>> Things to fix 2: enemy Sprite Overlap. If there is more than one enemy following you they overlap and you only see one sprite which also affect to colliders.

Tuesday, April 2, 2024

Enemy AI part 1

This entry has quite a few similarities with the ones on "animating sprites". However, it has key extra functions and creating some new entries for the explanations is worth it. Mind you, we are still mostly following https://youtu.be/R0XvL3_t840 if you want to watch it in video.

Let's start with the base script for the NPC to find and chase the player. It won't deal damage, and it will use some already created nodes from the animated sprites entries.

As you can see below:
- We already have created the export variables that manage our enemy movement stats.
- A basic state machine diagram for the enemy's possible actions is defined through an enum.
- The physics_process deals with the engine's physics and has a knockback function that moves the enemy when it receives a hit.
- And we have functions for the enemy dying and receiving damage

```
extends KinematicBody2D

const EnemyDeathEffect = preload("res://Effects/EnemyDeathEffect.tscn")

export var ACCELERATION = 300
export var MAX_SPEED = 50
export var FRICTION = 200
export var WANDER_TARGET_RANGE = 4

enum {
    IDLE,
    WANDER,
    CHASE
}

var velocity = Vector2.ZERO
var knockback = Vector2.ZERO

onready var stats = $Stats

func _physics_process(delta):
    knockback = knockback.move_toward(Vector2.ZERO, FRICTION * delta)
    knockback = move_and_slide(knockback)


func _on_Hurtbox_area_entered(area):
    stats.health -= area.damage
    knockback = area.knockback_vector * 100

func _on_Stats_no_health():
    queue_free()
    var enemyDeathEffect = EnemyDeathEffect.instance()
    get_parent().add_child(enemyDeathEffect)
    enemyDeathEffect.global_position = global_position

```


For our enemy scene, we currently have:
- The KinematicBody2D's scene root node, so it can move around.
- A sprite node, to display the enemy on screen.
- A CollisionShape2D to collide with the world environment.
- A hurtbox composed node, for the enemy to be able to receive hits
- And a stats composed node, to deal with enemy stats updates.


First, we need to add the code for the states diagram to be active: 

A new var `var state = IDLE` and the match state in the physics_process:

```
func _physics_process(delta):
    knockback = knockback.move_toward(Vector2.ZERO, FRICTION * delta)
    knockback = move_and_slide(knockback)
    
    match state:
        IDLE:
            pass
        WANDER:
            pass
        CHASE:
            pass
```


Now let's work with our states. The easiest one is to let the enemy be idle until the player enters in its vision range:

```
        IDLE:
            velocity = velocity.move_toward(Vector2.ZERO, FRICTION * delta)
            seek_player()
```


To check if the player is in its vision range, we will use a function:

```
func seek_player():
    if playerDetectionZone.can_see_player():
        state = CHASE
```


And a correlated composed node (scene), connected to your enemy script by a new var:
`onready var playerDetectionZone = $PlayerDetectionZone`
in the enemy's script.

- This composed scene will be an Area2D with a CollisionShape2D

- Attach a script to your CollisionShape2D, so it can manage the colliders
```
extends Area2D

var player = null

func can_see_player():
    return player != null

func _on_PlayerDetectionZone_body_entered(body):
    player = body

func _on_PlayerDetectionZone_body_exited(_body):
    player = null
```


And make sure your `body_entered` and `body_exited` signals are connected:

* Don't forget to set your collision mask properly, so the Area is only activated when the player enters and not any other object:

- Once you have your custom node, add it to your enemy and check its editable children property

- Now you can add the collision detection shape:

After that, it's time to update our CHASE state (in the enemy's script) so the enemy can chase the player
```
        CHASE:
            var player = playerDetectionZone.player
            if player != null:
                var direction = (player.global_position - global_position).normalized()
                velocity = velocity.move_toward(direction * MAX_SPEED, ACCELERATION * delta)
            else:
                state = IDLE
```


* Don't forget to update your velocity in the script right after, outside the 'match':
`velocity = move_and_slide(velocity)`

>>> Since the enemy's script code updates are scattered around the entry to be properly explained, I'll copy the current full script here, so you can double check:

```
extends KinematicBody2D

const EnemyDeathEffect = preload("res://Effects/EnemyDeathEffect.tscn")

export var ACCELERATION = 300
export var MAX_SPEED = 50
export var FRICTION = 200
export var WANDER_TARGET_RANGE = 4

enum {
    IDLE,
    WANDER,
    CHASE
}

var velocity = Vector2.ZERO
var knockback = Vector2.ZERO

var state = CHASE

onready var stats = $Stats
onready var playerDetectionZone = $PlayerDetectionZone

func _physics_process(delta):
    knockback = knockback.move_toward(Vector2.ZERO, FRICTION * delta)
    knockback = move_and_slide(knockback)
    
    match state:
        IDLE:
            velocity = velocity.move_toward(Vector2.ZERO, FRICTION * delta)
            seek_player()
        WANDER:
            pass
        CHASE:
            var player = playerDetectionZone.player
            if player != null:
                var direction = (player.global_position - global_position).normalized()
                velocity = velocity.move_toward(direction * MAX_SPEED, ACCELERATION * delta)
            else:
                state = IDLE
                
    velocity = move_and_slide(velocity)

func seek_player():
    if playerDetectionZone.can_see_player():
        state = CHASE

func _on_Hurtbox_area_entered(area):
    stats.health -= area.damage
    knockback = area.knockback_vector * 100

func _on_Stats_no_health():
    queue_free()
    var enemyDeathEffect = EnemyDeathEffect.instance()
    get_parent().add_child(enemyDeathEffect)
    enemyDeathEffect.global_position = global_position
```


Extra: For the player or the enemy to be in front properly, when they are one over the other, you need to use ysort ( https://docs.godotengine.org/en/3.5/classes/class_ysort.html )

And that's all for this entry, will update this enemy so at least it can attack in a future one.