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.

No comments:

Post a Comment