class_name GrunkBeast extends CharacterBody3D ## Grunk beast controller #region Constants signal state_changed(new_state: State) enum State { UNKNOWN, LURKING, STALKING, PURSUIT, } const LURK_POINT_GROUP := "LurkPoint" const STALKING_SOUND_LIMIT := 14.0 const IDLE_EPSILON := 0.001 #endregion #region Exported Properties @export var lurk_spontaneity := 0.02 @export var base_speed := 60.0 @export var pursuit_speed := 180.0 @export var initial_state := State.LURKING #endregion #region Member Variables var gravity: Vector3 = ( ProjectSettings.get_setting("physics/3d/default_gravity") * ProjectSettings.get_setting("physics/3d/default_gravity_vector") ) var state: State: set(value): if state != value: _on_state_change(value) state = value var pathfinding := true @onready var nav_agent: NavigationAgent3D = %NavAgent @onready var stalking_timeout: Timer = %StalkingTimeout @onready var idle_timer: Timer = %IdleTimer #endregion #region Character Controller func get_speed() -> float: match state: State.PURSUIT: return pursuit_speed return base_speed func _physics_process(delta: float) -> void: var motion := Vector3.ZERO if pathfinding: var path_pos := nav_agent.get_next_path_position() var relative_pos := path_pos - global_position motion = relative_pos.normalized() * get_speed() * delta velocity.x = motion.x velocity.z = motion.z if not is_on_floor(): velocity += gravity * delta if velocity.length_squared() > IDLE_EPSILON: idle_timer.start() move_and_slide() #endregion #region State Machine func _ready() -> void: state = initial_state func _current_path_shorter_than(limit: float) -> bool: var limit_sq := limit * limit var length_sq := 0.0 var last_pos := global_position for waypoint: Vector3 in nav_agent.get_current_navigation_path().slice( nav_agent.get_current_navigation_path_index() ): # Using distance squared to save a sqrt instruction length_sq += last_pos.distance_squared_to(waypoint) if length_sq > limit_sq: return false last_pos = waypoint return true func _set_new_lurk_point() -> void: @warning_ignore("unsafe_cast") var next_lurk_point := get_tree().get_nodes_in_group(LURK_POINT_GROUP).pick_random() as Node3D if next_lurk_point: nav_agent.target_position = next_lurk_point.global_position else: push_warning(self, " tried to lurk, but couldn't find a lurk point!") ## Lurking routine func lurk() -> void: if nav_agent.is_target_reached() or randf() < lurk_spontaneity: _set_new_lurk_point() ## Stalking routine func stalk() -> void: pass ## Pursuit routine func pursue() -> void: if Player.instance: nav_agent.target_position = Player.instance.global_position func _on_state_change(new_state: State) -> void: match new_state: State.LURKING: _set_new_lurk_point() State.STALKING: stalking_timeout.start() state_changed.emit(new_state) func update_behavior() -> void: match state: State.UNKNOWN: state = State.LURKING update_behavior() State.LURKING: lurk() State.STALKING: stalk() State.PURSUIT: pursue() func on_sound_detected(source: Vector3) -> void: var prev_target := nav_agent.target_position nav_agent.target_position = source # Check that the source isn't too far away, e.g. a sound from another room if _current_path_shorter_than(STALKING_SOUND_LIMIT): # First detection switches to stalking, second switches to pursuit if state == State.STALKING: state = State.PURSUIT else: state = State.STALKING else: nav_agent.target_position = prev_target func on_player_entered_grabbing_range(_body: Node3D) -> void: print_debug("GET GRABBED, IDIOT!") func on_player_left_pursuit_range(_body: Node3D) -> void: state = State.STALKING func on_stalking_timeout_timeout() -> void: state = State.LURKING func on_stalking_idle_timer_timeout() -> void: pass # Replace with function body. #endregion