diff --git a/.project-management/current-prd/tasks-prd-ai-behavior-improvements.md b/.project-management/current-prd/tasks-prd-ai-behavior-improvements.md index c498ea01..2765e71e 100644 --- a/.project-management/current-prd/tasks-prd-ai-behavior-improvements.md +++ b/.project-management/current-prd/tasks-prd-ai-behavior-improvements.md @@ -45,6 +45,9 @@ ### Existing Files Modified - `Scripts/Mob/StateMachine.gd` - Validate transitions and add fallbacks. +- `Scripts/Mob/MobFollow.gd` - Validate target references and handle missing targets. +- `Scripts/Mob/MobAttack.gd` - Add target validation and fallback to follow. +- `Scripts/Mob/MobRangedAttack.gd` - Ensure target validity and fallback. ### Files To Remove - *(none)* @@ -54,9 +57,9 @@ ## Tasks -- [ ] 4.0 Harden state-machine transitions to gracefully recover from lost or invalid targets - - [ ] 4.1 Map current transitions in `StateMachine.gd` and identify failure points. - - [ ] 4.2 Add validation checks for target references before state changes. - - [ ] 4.3 Implement fallback logic when targets disappear or become unreachable. + - [x] 4.0 Harden state-machine transitions to gracefully recover from lost or invalid targets + - [x] 4.1 Map current transitions in `StateMachine.gd` and identify failure points. + - [x] 4.2 Add validation checks for target references before state changes. + - [x] 4.3 Implement fallback logic when targets disappear or become unreachable. *End of document* diff --git a/Scripts/Mob/MobAttack.gd b/Scripts/Mob/MobAttack.gd index 2901f3d4..9fcb9a31 100644 --- a/Scripts/Mob/MobAttack.gd +++ b/Scripts/Mob/MobAttack.gd @@ -34,11 +34,15 @@ func exit(): func physics_update(_delta: float): if mob.terminated: Transitioned.emit(self, "mobterminate") - # Rotation towards target using look_at - if spotted_target: - var target_position = spotted_target.global_position - target_position.y = mob.meshInstance.global_position.y # Align y-axis to avoid tilting - mob.meshInstance.look_at(target_position, Vector3.UP) + + # Rotation towards target using look_at + if not spotted_target or !is_instance_valid(spotted_target): + stop_attacking() + return + # Rotation towards target using look_at + var target_position = spotted_target.global_position + target_position.y = mob.meshInstance.global_position.y # Align y-axis to avoid tilting + mob.meshInstance.look_at(target_position, Vector3.UP) var space_state = get_world_3d().direct_space_state var query = PhysicsRayQueryParameters3D.create( @@ -89,7 +93,11 @@ func attack(): # Helper function to send attack data to the entity's get_hit method func _apply_attack_to_entity(chosen_attack: Dictionary) -> void: - if spotted_target and spotted_target.has_method("get_hit"): + if ( + spotted_target + and is_instance_valid(spotted_target) + and spotted_target.has_method("get_hit") + ): var attack_data: Dictionary = { "attack": chosen_attack, "mobposition": mob.global_position, diff --git a/Scripts/Mob/MobFollow.gd b/Scripts/Mob/MobFollow.gd index 59e4f929..c856b561 100644 --- a/Scripts/Mob/MobFollow.gd +++ b/Scripts/Mob/MobFollow.gd @@ -94,9 +94,14 @@ func orient_toward_target(): # Performs raycasting to check if the targeted entity is within sight and melee range func check_for_target_in_range(): - if not spotted_target: + if not spotted_target or !is_instance_valid(spotted_target): + spotted_target = null + Transitioned.emit(self, "mobidle") return + # If the ray hits a wall first, the mob cannot see the player + + # If the ray hits a valid target before hitting a wall, continue checking attack range var space_state = get_world_3d().direct_space_state var query = PhysicsRayQueryParameters3D.create( mobCol.global_position, @@ -110,9 +115,10 @@ func check_for_target_in_range(): # If the ray hits a wall first, the mob cannot see the player if result.collider.collision_layer == 1 << 2: # Check if the collider is on layer 3 spotted_target = null # Reset target if the wall blocks vision + Transitioned.emit(self, "mobidle") return - # If the ray hits a valid target before hitting a wall, continue checking attack range + # If the ray hits a valid target before hitting a wall, continue checking attack range var is_valid_target = ( result.collider.is_in_group("Players") or result.collider.is_in_group("mobs") ) diff --git a/Scripts/Mob/MobRangedAttack.gd b/Scripts/Mob/MobRangedAttack.gd index 6b093ea2..7671130d 100644 --- a/Scripts/Mob/MobRangedAttack.gd +++ b/Scripts/Mob/MobRangedAttack.gd @@ -27,6 +27,9 @@ func physics_update(_delta: float): if mob.terminated: Transitioned.emit(self, "mobterminate") return + if not spotted_target or !is_instance_valid(spotted_target): + Transitioned.emit(self, "mobfollow") + return var ranged_range: int = mob.get_ranged_range() diff --git a/Scripts/Mob/StateMachine.gd b/Scripts/Mob/StateMachine.gd index 995844d4..7d88dd11 100644 --- a/Scripts/Mob/StateMachine.gd +++ b/Scripts/Mob/StateMachine.gd @@ -6,6 +6,14 @@ var current_state: State var states: Dictionary = {} var mob: CharacterBody3D # The mob that we are enabling the behaviour for +# State transitions: +# mobidle -> mobfollow +# mobfollow -> mobrangedattack | mobattack | mobidle | mobterminate +# mobattack -> mobfollow | mobterminate +# mobrangedattack -> mobfollow | mobterminate +# any state -> mobterminate +# Failure points: transitions that rely on target references may fail if the target is lost. + # Initialize the StateMachine func _ready(): @@ -51,10 +59,17 @@ func _physics_process(delta): func on_child_transition(state, new_state_name): if state != current_state: return + # Validate target references before changing states + if state is MobFollow or state is MobAttack or state is MobRangedAttack: + var target = state.spotted_target + if !target or !is_instance_valid(target): + new_state_name = "mobidle" var new_state = states.get(new_state_name.to_lower()) if !new_state: - return + # Fallback to initial state when requested state is missing + new_state = initial_state + if current_state: current_state.exit()