bgen/demo/scripts/arcade/enemy.gd

175 lines
4.6 KiB
GDScript3
Raw Normal View History

2026-04-16 21:04:50 -04:00
class_name ArcadeEnemy
extends CharacterBody3D
# Three-state FSM:
# WANDER: pick a random passable cell in this enemy's room, walk there,
# occasionally idle.
# CHASE: move toward last-known player grid cell via BFS.
# ATTACK: within melee range — swing, tick cooldown.
#
# Transitions:
# WANDER -> CHASE when player has LOS AND distance ≤ sight_radius
# CHASE -> WANDER when player has been out of sight for lose_sight_turns
# CHASE -> ATTACK when close enough to hit
# ATTACK -> CHASE when out of range
signal died
enum State { WANDER, CHASE, ATTACK }
@export var hp := 8
@export var move_speed := 3.0
@export var sight_radius := 10.0
@export var melee_range := 1.4
@export var melee_damage := 2
@export var melee_cooldown := 0.9
@export var lose_sight_seconds := 3.0
# Set by spawner on instantiate.
var grid_ref: Dictionary = {}
var room_cells: Array = [] # Array[Vector2i]
var player_ref: Node3D = null
var rng := RandomNumberGenerator.new()
var _state := State.WANDER
var _path_target: Vector2i = Vector2i(-1, -1)
var _repath_timer := 0.0
var _attack_timer := 0.0
var _lose_sight_timer := 0.0
var _alive := true
func _ready() -> void:
rng.randomize()
_pick_wander_target()
func set_room(cells: Array) -> void:
room_cells = cells
_pick_wander_target()
func _physics_process(delta: float) -> void:
if not _alive:
return
if _attack_timer > 0.0:
_attack_timer -= delta
_update_state(delta)
match _state:
State.WANDER: _do_wander(delta)
State.CHASE: _do_chase(delta)
State.ATTACK: _do_attack(delta)
# Gravity always.
if not is_on_floor():
velocity.y -= 22.0 * delta
else:
velocity.y = max(velocity.y, 0.0)
move_and_slide()
# --- state machine ---
func _update_state(delta: float) -> void:
if player_ref == null:
return
var my_grid := GridUtil.world_to_grid(global_position)
var p_grid := GridUtil.world_to_grid(player_ref.global_position)
var dist := float(my_grid.distance_to(p_grid))
var can_see := dist <= sight_radius and GridUtil.has_los(grid_ref, my_grid, p_grid)
match _state:
State.WANDER:
if can_see:
_state = State.CHASE
_path_target = p_grid
_repath_timer = 0.0
State.CHASE:
if can_see:
_lose_sight_timer = 0.0
_path_target = p_grid
else:
_lose_sight_timer += delta
if _lose_sight_timer >= lose_sight_seconds:
_state = State.WANDER
_pick_wander_target()
return
# Close enough to swing?
var world_dist := global_position.distance_to(player_ref.global_position)
if world_dist <= melee_range:
_state = State.ATTACK
State.ATTACK:
var world_dist2 := global_position.distance_to(player_ref.global_position)
if world_dist2 > melee_range * 1.3:
_state = State.CHASE
if not can_see:
_lose_sight_timer += delta
if _lose_sight_timer >= lose_sight_seconds:
_state = State.WANDER
_pick_wander_target()
# --- behaviors ---
func _do_wander(delta: float) -> void:
_repath_timer -= delta
if _path_target == Vector2i(-1, -1) or _at_target():
if _repath_timer <= 0.0:
_repath_timer = rng.randf_range(0.8, 2.0)
_pick_wander_target()
_step_toward(_path_target, move_speed * 0.5)
func _do_chase(delta: float) -> void:
_repath_timer -= delta
if _repath_timer <= 0.0:
_repath_timer = 0.25 # repath 4×/sec
_step_toward(_path_target, move_speed)
func _do_attack(_delta: float) -> void:
velocity.x = 0.0
velocity.z = 0.0
if _attack_timer <= 0.0:
_attack_timer = melee_cooldown
if player_ref and player_ref.has_method("take_damage"):
player_ref.take_damage(melee_damage, self)
# --- helpers ---
func _pick_wander_target() -> void:
if room_cells.is_empty():
_path_target = GridUtil.world_to_grid(global_position)
return
_path_target = GridUtil.random_passable(grid_ref, room_cells, rng)
func _at_target() -> bool:
var my_grid := GridUtil.world_to_grid(global_position)
return my_grid == _path_target
func _step_toward(target: Vector2i, speed: float) -> void:
if target == Vector2i(-1, -1):
velocity.x = 0.0
velocity.z = 0.0
return
var my_grid := GridUtil.world_to_grid(global_position)
var nxt := GridUtil.next_step_toward(grid_ref, my_grid, target)
if nxt == my_grid:
velocity.x = 0.0
velocity.z = 0.0
return
var target_world := GridUtil.grid_to_world(nxt, global_position.y)
var dir := (target_world - global_position)
dir.y = 0
dir = dir.normalized()
velocity.x = dir.x * speed
velocity.z = dir.z * speed
# Face movement direction so we look right visually.
if dir.length() > 0.1:
look_at(global_position + dir, Vector3.UP)
func take_damage(amount: int, _source: Node) -> void:
if not _alive:
return
hp -= amount
if hp <= 0:
_alive = false
died.emit()
queue_free()