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()