class_name GrunkBeast extends CharacterBody3D ## Grunk beast controller #region Constants const GROUP := "GrunkBeast" const ANGER_KEY := "anger_level" const POI_KEY := "point_of_interest" #endregion #region Exported Properties @export var spawn_on_load := false @export var speed_curve: Curve @export var anger_level: float: set = _set_anger_level, get = _get_anger_level @export var point_of_interest: Vector3: set = _set_poi, get = _get_poi #@export var base_speed := 80.0 #@export var pursuit_speed := 360.0 @export var debug_destroy: bool: set(value): queue_free() #endregion #region Member Variables var gravity: Vector3 = ( ProjectSettings.get_setting("physics/3d/default_gravity") * ProjectSettings.get_setting("physics/3d/default_gravity_vector") ) var pathfinding := true var traversing_link := false @onready var model: BeastModel = %Shambler @onready var nav_agent: NavigationAgent3D = %NavAgent @onready var nav_probe: NavigationAgent3D = %NavProbe @onready var spawn_zone_finder: Area3D = %SpawnZoneFinder @onready var sound_detection_cooldown: Timer = %SoundDetectionCooldown @onready var touch_cooldown: Timer = %TouchCooldown @onready var blackboard: Blackboard = %Blackboard @onready var behavior: BeehaveTree = %GrunkBeastBehavior @onready var root_block: AwaitSignal = %RootBlock @onready var debug_canvas_layer: CanvasLayer = %DebugCanvasLayer @onready var beast_behavior_label: Label = %BeastBehaviorLabel @onready var beast_anger_meter: ProgressBar = %BeastAngerMeter #endregion #region Static Variables static var anger_min: float = ProjectSettings.get_setting("game/gameplay/beast/anger_min") static var anger_max: float = ProjectSettings.get_setting("game/gameplay/beast/anger_max") static var anger_decay_rate: float = ProjectSettings.get_setting( "game/gameplay/beast/anger_decay_rate" ) static var anger_noise: float = ProjectSettings.get_setting("game/gameplay/beast/anger_noise") static var anger_noise_near: float = ProjectSettings.get_setting( "game/gameplay/beast/anger_noise_near" ) static var anger_alarm: float = ProjectSettings.get_setting("game/gameplay/beast/anger_alarm") static var anger_alarm_extra: float = ProjectSettings.get_setting( "game/gameplay/beast/anger_alarm_extra" ) static var anger_touch: float = ProjectSettings.get_setting("game/gameplay/beast/anger_touch") static var anger_attack: float = ProjectSettings.get_setting("game/gameplay/beast/anger_attack") static var anger_sticker: float = ProjectSettings.get_setting("game/gameplay/beast/anger_sticker") static var provocation_range: float = ProjectSettings.get_setting( "game/gameplay/beast/provocation_range" ) static var anger_extra_alert_level: float = ProjectSettings.get_setting( "game/gameplay/beast/anger_extra_alert_level" ) #endregion #region Member Access func _get_anger_level() -> float: return blackboard.get_value(ANGER_KEY, 0.0) func _set_anger_level(value: float) -> void: blackboard.set_value(ANGER_KEY, clampf(value, GrunkBeast.anger_min, GrunkBeast.anger_max)) func _get_poi() -> Vector3: return blackboard.get_value(POI_KEY, Vector3.ZERO) func _set_poi(value: Vector3) -> void: blackboard.set_value(POI_KEY, value) #endregion #region Character Controller func _ready() -> void: nav_agent.velocity_computed.connect(_on_velocity_computed) if World.instance and World.instance.manager: World.instance.manager.alarm_triggered.connect(_on_alarm_triggered) if spawn_on_load: start_spawn() func start_spawn() -> void: model.play_spawn_animation() root_block.await_signal(model.spawn_animation_finished) # Set point of interest to spawn point point_of_interest = global_position func get_spawn_zones() -> Array[BeastSpawnZone]: var zones: Array[BeastSpawnZone] = [] zones.assign(spawn_zone_finder.get_overlapping_areas()) return zones func get_speed() -> float: return speed_curve.sample(anger_level) func path_shorter_than(target: Vector3, limit: float) -> bool: var length := 0.0 var last_pos := global_position nav_probe.target_position = target # Fail early if the target is unreachable. # NOTE: this call also forces a navigation path refresh! Do not remove! if not nav_probe.is_target_reachable(): return false var path := nav_probe.get_current_navigation_path().slice( nav_probe.get_current_navigation_path_index() ) if not path: # Shouldn't be possible, but if it is it would cause problems if we didn't fail here print_debug("Target is reachable but has no path (tell the developer!)") return false # Integrate along path for waypoint: Vector3 in path: length += last_pos.distance_to(waypoint) if length > limit: return false last_pos = waypoint return true func _physics_process(_delta: float) -> void: # Update debug info if debug_canvas_layer.visible: beast_behavior_label.text = str(blackboard.get_value("current_mode")) beast_anger_meter.value = anger_level # Don't use nav agent before map has synchronized if NavigationServer3D.map_get_iteration_id(nav_agent.get_navigation_map()) == 0: return var motion := Vector3.ZERO if pathfinding and (traversing_link or not nav_agent.is_navigation_finished()): motion = global_position.direction_to(nav_agent.get_next_path_position()) * get_speed() motion.y = velocity.y if not is_on_floor(): motion += gravity if motion.x or motion.z: model.set_target_rotation(atan2(motion.x, motion.z)) if nav_agent.avoidance_enabled: nav_agent.set_velocity(motion) else: _on_velocity_computed(motion) func _on_velocity_computed(safe_velocity: Vector3) -> void: model.set_move_speed(safe_velocity.length()) velocity = safe_velocity move_and_slide() func on_sound_detected(source: Vector3) -> void: if root_block.is_blocked(): return point_of_interest = source if sound_detection_cooldown.is_stopped(): print_debug("Beast heard something from ", source) anger_level += GrunkBeast.anger_noise if source.distance_to(self.global_position) <= GrunkBeast.provocation_range: print_debug("... And it was close, too!") anger_level += GrunkBeast.anger_noise_near sound_detection_cooldown.start() # TODO animation? #endregion func _on_link_reached(_details: Dictionary) -> void: traversing_link = true func _on_waypoint_reached(_details: Dictionary) -> void: traversing_link = false func _anger_decay() -> void: if not root_block.is_blocked(): anger_level -= GrunkBeast.anger_decay_rate func _on_alarm_triggered(source: GunkAlarm) -> void: if root_block.is_blocked(): return print_debug("The beast was angered by the alarm!") point_of_interest = source.global_position anger_level += GrunkBeast.anger_alarm if World.instance.manager.alert_level >= GrunkBeast.anger_extra_alert_level: print_debug("The beast got extra-angry!") anger_level += GrunkBeast.anger_alarm_extra func _on_touch(_body: Node3D) -> void: if root_block.is_blocked(): return if touch_cooldown.is_stopped(): print_debug("Touched the beast!") anger_level += GrunkBeast.anger_touch touch_cooldown.start()