From f5d25a45c5d1278a0de72310575ecfe12e495f35 Mon Sep 17 00:00:00 2001 From: Tomzkk Date: Sun, 11 Feb 2024 17:04:05 +0100 Subject: [PATCH] Issue-35: Death handling and combat end (#60) * Handle player killing enemies * Refactor enemy attack loop - Splits enemy attack loop into two, where first actions are generated in the first loop and then executed in the second one - Introduced EnemyAction class to use as structure holding action details and necessary checks * Emit signal on combat end * Restart combat when battle ends - Added signal for end of battle into phase manager - Battler emits signal when it detects end of battle - Added scene controller to switch to new testingscene after combat - Changed EnemyAction target checking to account for the possibility of it being dead but not freed yet * Remove call of death handling from enemy actions - removed explicit call of handling enemy deaths as it is already being called from inside because of the signal from on_card_play * Scene mapping dictionary * Add death handling tests * Assert enemy deletions * Refactor phase changing in SceneController --------- Co-authored-by: Tomzkk --- Cards/Effects/EffectApplyStatus.gd | 2 +- Core/Battler.gd | 56 +++++++++--- Core/Enums.gd | 7 ++ Core/SceneController.gd | 55 +++++++++++ Entity/Components/PartyComponent.gd | 2 +- Global/EnemyAction.gd | 29 ++++++ Managers/PhaseManager.gd | 6 ++ Tests/test_death_handling.gd | 137 ++++++++++++++++++++++++++++ project.godot | 1 + 9 files changed, 281 insertions(+), 14 deletions(-) create mode 100644 Core/SceneController.gd create mode 100644 Global/EnemyAction.gd create mode 100644 Tests/test_death_handling.gd diff --git a/Cards/Effects/EffectApplyStatus.gd b/Cards/Effects/EffectApplyStatus.gd index 0bc8d546..ec75b021 100644 --- a/Cards/Effects/EffectApplyStatus.gd +++ b/Cards/Effects/EffectApplyStatus.gd @@ -5,4 +5,4 @@ class_name EffectApplyStatus extends EffectBase # @Override @warning_ignore("unused_parameter") func apply_effect(caster: Entity, target: Entity, value: int) -> void: - target.get_status_component().add_status(status_to_apply, caster) \ No newline at end of file + target.get_status_component().add_status(status_to_apply, caster) diff --git a/Core/Battler.gd b/Core/Battler.gd index 14152a47..5832d664 100644 --- a/Core/Battler.gd +++ b/Core/Battler.gd @@ -5,7 +5,6 @@ class_name Battler ## This class holds a list of all the enemies, so it's a good central place to dispatch ## battle actions (player clicking on enemies, enemy attacks, applying status). - @export var enemies_to_summon: Array[PackedScene] @export var enemy_spacing: float = 50.0 @export var enemy_attack_time: float = 1.0 @@ -24,6 +23,7 @@ func _ready() -> void: PhaseManager.on_phase_changed.connect(_on_phase_changed) CardManager.on_card_container_initialized.connect(_on_card_container_initialized) + CardManager.on_card_action_finished.connect(_handle_deaths.unbind(1)) func _summon_enemies() -> void: @@ -51,13 +51,16 @@ func _on_phase_changed(new_phase: Enums.Phase, _old_phase: Enums.Phase) -> void: if new_phase == Enums.Phase.ENEMY_ATTACKING: _on_enemy_start_turn() + func _on_card_container_initialized() -> void: if (!CardManager.is_discard_hand_signal_connected(_on_player_hand_discarded)): CardManager.connect_discard_hand_signal(_on_player_hand_discarded) + func _on_player_hand_discarded() -> void: PhaseManager.set_phase(Enums.Phase.ENEMY_ATTACKING) + # player start phase: apply status func _on_player_start_turn() -> void: PlayerManager.player.get_status_component().apply_turn_start_status() @@ -70,16 +73,20 @@ func _on_enemy_start_turn() -> void: # apply status for enemy: Entity in _enemy_list: enemy.get_status_component().apply_turn_start_status() + + _handle_deaths() - # enemy attack - for enemy: Entity in _enemy_list: + # generate list of enemy actions + var enemy_action_list: Array[EnemyAction] = [] + + for enemy: Enemy in _enemy_list: var enemy_attack: CardBase = enemy.get_behavior_component().attack - var can_attack: bool = enemy_attack.can_play_card(enemy, PlayerManager.player) - - assert(can_attack == true, "Enemy failed to attack.") + var enemy_action = EnemyAction.new(enemy, enemy_attack, [PlayerManager.player]) + enemy_action_list.append(enemy_action) - if can_attack: - enemy_attack.on_card_play(enemy, [PlayerManager.player]) + # execute enemy actions + for enemy_action: EnemyAction in enemy_action_list: + enemy_action.execute() # TODO: temporary delay so we can see the draw pile and discard pile working await get_tree().create_timer(enemy_attack_time).timeout @@ -104,7 +111,8 @@ func _try_player_play_card_on_entity(entity: Entity) -> void: if can_play: CardManager.card_container.play_card([entity]) - + + func get_all_targets(application_type : Enums.ApplicationType) -> Array[Entity]: var all_target : Array[Entity] @@ -118,8 +126,32 @@ func get_all_targets(application_type : Enums.ApplicationType) -> Array[Entity]: all_target = [PlayerManager.player] return all_target + -# TODO condition check for killing enemies and removing them from the combat -# TODO condition check for killing player and ending the combat -# TODO condition check for killing all enemies and ending the combat +func _handle_enemy_deaths() -> void: + var enemies_to_remove : Array[Entity] = [] + for enemy: Enemy in _enemy_list: + if enemy.get_health_component().current_health == 0: + enemies_to_remove.append(enemy) + + for enemy: Enemy in enemies_to_remove: + _enemy_list.erase(enemy) + enemy.queue_free() + + for enemy: Enemy in _enemy_list: + enemy.get_party_component().set_party(_enemy_list) + + +func _check_and_handle_battle_end() -> void: + if PlayerManager.player.get_health_component().current_health == 0: + PhaseManager.on_combat_end.emit(Enums.CombatResult.DEFEAT) + if _enemy_list.is_empty(): + PhaseManager.on_combat_end.emit(Enums.CombatResult.VICTORY) + + +func _handle_deaths() -> void: + _handle_enemy_deaths() + _check_and_handle_battle_end() + + # TODO reset temporary stats at the end of the combat using EntityStats.reset_modifier_dict_temp_to_default() diff --git a/Core/Enums.gd b/Core/Enums.gd index 3271771b..852cf3b3 100644 --- a/Core/Enums.gd +++ b/Core/Enums.gd @@ -10,6 +10,7 @@ enum Phase PLAYER_ATTACKING, PLAYER_FINISHING, ENEMY_ATTACKING, + SCENE_END, } enum Team @@ -46,3 +47,9 @@ enum CardMovementState HOVERED, QUEUED, } + +enum CombatResult +{ + VICTORY, + DEFEAT +} diff --git a/Core/SceneController.gd b/Core/SceneController.gd new file mode 100644 index 00000000..3ef77e35 --- /dev/null +++ b/Core/SceneController.gd @@ -0,0 +1,55 @@ +extends Node + + +var current_scene: Node = null + +var SCENE_MAPPING: Dictionary = { + Enums.CombatResult.VICTORY: "res://#Scenes/TestingScene.tscn", + Enums.CombatResult.DEFEAT: "res://#Scenes/TestingScene.tscn", +} + +func _ready(): + var root: Window = get_tree().root + current_scene = root.get_child(root.get_child_count() - 1) + PhaseManager.on_combat_end.connect(_combat_end_change_scene) + + +func goto_scene(path: String) -> void: + # This function will usually be called from a signal callback, + # or some other function in the current scene. + # Deleting the current scene at this point is + # a bad idea, because it may still be executing code. + # This will result in a crash or unexpected behavior. + + # The solution is to defer the load to a later time, when + # we can be sure that no code from the current scene is running: + + call_deferred("_deferred_goto_scene", path) + + +func _deferred_goto_scene(path: String) -> void: + # It is now safe to remove the current scene + current_scene.free() + + # Load the new scene. + var new_scene: Resource = ResourceLoader.load(path) + + # Instance the new scene. + current_scene = new_scene.instantiate() + + # Add it to the active scene, as child of root. + get_tree().root.add_child(current_scene) + + # Optionally, to make it compatible with the SceneTree.change_scene_to_file() API. + get_tree().current_scene = current_scene + + +func _combat_end_change_scene(combat_result: Enums.CombatResult) -> void: + PhaseManager.call_deferred("set_phase", Enums.Phase.SCENE_END) + if combat_result == Enums.CombatResult.DEFEAT: + print('Defeat') + goto_scene(SCENE_MAPPING[Enums.CombatResult.DEFEAT]) + elif combat_result == Enums.CombatResult.VICTORY: + print("Victory") + goto_scene(SCENE_MAPPING[Enums.CombatResult.VICTORY]) + PhaseManager.call_deferred("initialize_game") diff --git a/Entity/Components/PartyComponent.gd b/Entity/Components/PartyComponent.gd index c483ecb0..29b7db7b 100644 --- a/Entity/Components/PartyComponent.gd +++ b/Entity/Components/PartyComponent.gd @@ -28,7 +28,7 @@ func can_play_on_entity(application_type: Enums.ApplicationType, target: Entity) func set_party(in_party: Array[Entity]) -> void: - party += in_party + party = in_party func add_party_member(party_member: Entity) -> void: diff --git a/Global/EnemyAction.gd b/Global/EnemyAction.gd new file mode 100644 index 00000000..7ae837ce --- /dev/null +++ b/Global/EnemyAction.gd @@ -0,0 +1,29 @@ +extends RefCounted + +class_name EnemyAction + +var caster: Entity +var action: CardBase +var target_list: Array[Entity] + +func _init(_caster: Entity, _action: CardBase, _target_list: Array[Entity]): + self.caster = _caster + self.action = _action + self.target_list = _target_list + +# function to attack that plays card +func execute() -> void: + if not is_instance_valid(caster): + print("Caster died, skipping action") + return + + # Simplified for now, will need refactor once we have advanced enemy actions + if not is_instance_valid(target_list[0]) or target_list[0].get_health_component().current_health == 0: + print("Target died, skipping action") + return + + var can_execute: bool = action.can_play_card(caster, target_list[0]) + assert(can_execute == true, "Enemy failed to attack.") + + if can_execute: + action.on_card_play(caster, target_list) diff --git a/Managers/PhaseManager.gd b/Managers/PhaseManager.gd index 7d74ac5e..5e0e49a2 100644 --- a/Managers/PhaseManager.gd +++ b/Managers/PhaseManager.gd @@ -8,10 +8,16 @@ extends Node signal on_game_start signal on_phase_changed(new_phase: Enums.Phase, old_phase: Enums.Phase) +signal on_combat_end(result: Enums.CombatResult) + var current_phase: Enums.Phase = Enums.Phase.NONE func _ready() -> void: + initialize_game() + + +func initialize_game(): set_phase(Enums.Phase.GAME_STARTING) # TODO give all objects some time to initialize. Kinda hacky diff --git a/Tests/test_death_handling.gd b/Tests/test_death_handling.gd new file mode 100644 index 00000000..014adf2c --- /dev/null +++ b/Tests/test_death_handling.gd @@ -0,0 +1,137 @@ +extends TestBase +## Tests for things relating to death handling things + + +# @Override +func before_each(): + super() + # disconnecting signal as _combat_end_change_scene causes scene to be (re)loaded + # which if called from test actually starts the game and the test doesnt end + watch_signals(PhaseManager) + if PhaseManager.is_connected("on_combat_end", SceneController._combat_end_change_scene): + PhaseManager.disconnect("on_combat_end", SceneController._combat_end_change_scene) + + +# No typing for argument as if it's already been freed it doesn't have one +func _free_if_valid(node): + if is_instance_valid(node): + node.free() + +# @Override +func after_each(): + _free_if_valid(_player) + _free_if_valid(_enemy) + _free_if_valid(_enemy_2) + _battler.free() + assert_no_new_orphans("Orphans still exist, please free up test resources.") + + +func test_player_death_during_enemy_turn(): + _player.get_health_component()._set_health(1.0) + _battler._on_enemy_start_turn() + assert_eq(_player.get_health_component().current_health, 0.) + assert_signal_emitted_with_parameters(PhaseManager, "on_combat_end", [Enums.CombatResult.DEFEAT]) + + +func test_check_and_handle_battle_end_player_death(): + _player_health_component._set_health(0.) + + _battler._check_and_handle_battle_end() + + assert_signal_emitted_with_parameters(PhaseManager, "on_combat_end", [Enums.CombatResult.DEFEAT]) + + +func test_check_and_handle_battle_end_enemy_death(): + _battler._enemy_list = [] + + _battler._check_and_handle_battle_end() + + assert_signal_emitted_with_parameters(PhaseManager, "on_combat_end", [Enums.CombatResult.VICTORY]) + + +func test_handle_enemy_deaths_none(): + assert_eq(_enemy_list.size(), 2) + _battler._handle_enemy_deaths() + assert_eq(_enemy_list.size(), 2) + + +func test_handle_enemy_deaths_single(): + _enemy_health_component._set_health(0.) + + assert_eq(_enemy_list.size(), 2) + _battler._handle_enemy_deaths() + assert_eq(_enemy_list.size(), 1) + assert_true(_enemy.is_queued_for_deletion()) + await get_tree().process_frame + assert_false(is_instance_valid(_enemy)) + + +func test_handle_enemy_deaths_all(): + _enemy_health_component._set_health(0.) + _enemy_2_health_component._set_health(0.) + + assert_eq(_enemy_list.size(), 2) + _battler._handle_enemy_deaths() + assert_eq(_enemy_list.size(), 0) + assert_true(_enemy.is_queued_for_deletion()) + assert_true(_enemy_2.is_queued_for_deletion()) + await get_tree().process_frame + assert_false(is_instance_valid(_enemy)) + assert_false(is_instance_valid(_enemy_2)) + + +func test_enemy_death_to_player_attack(): + _enemy_health_component._set_health(1.0) + var card_damage: CardBase = load("res://Cards/Resource/Card_Damage.tres") + + assert_eq(_enemy_list.size(), 2) + card_damage.on_card_play(_player, [_enemy]) + assert_eq(_enemy_list.size(), 1) + assert_true(_enemy.is_queued_for_deletion()) + await get_tree().process_frame + assert_false(is_instance_valid(_enemy)) + + +func test_all_enemy_death_to_player_attack_all(): + _enemy_health_component._set_health(1.0) + _enemy_2_health_component._set_health(1.0) + var card_damage_all: CardBase = load("res://Cards/Resource/Card_DamageAll.tres") + + assert_eq(_enemy_list.size(), 2) + card_damage_all.on_card_play(_player, []) + assert_eq(_enemy_list.size(), 0) + assert_true(_enemy.is_queued_for_deletion()) + assert_true(_enemy_2.is_queued_for_deletion()) + await get_tree().process_frame + assert_false(is_instance_valid(_enemy)) + assert_false(is_instance_valid(_enemy_2)) + + + +func test_enemy_death_to_poison(): + _enemy_health_component._set_health(1.0) + var card_poison: CardBase = load("res://Cards/Resource/Card_Poison.tres") + card_poison.on_card_play(_player, [_enemy]) + + assert_eq(_enemy_list.size(), 2) + _battler._on_enemy_start_turn() + assert_eq(_enemy_list.size(), 1) + assert_true(_enemy.is_queued_for_deletion()) + await get_tree().process_frame + assert_false(is_instance_valid(_enemy)) + + +func test_enemy_death_to_expiring_poison(): + _enemy_health_component._set_health(1.0) + var card_poison: CardBase = load("res://Cards/Resource/Card_Poison.tres") + card_poison.on_card_play(_player, [_enemy]) + _enemy.get_status_component().current_status[0].status_turn_duration = 1 + + assert_eq(_enemy_list.size(), 2) + assert_eq(_enemy.get_status_component().current_status.size(), 1) + _battler._on_enemy_start_turn() + assert_eq(_enemy.get_status_component().current_status.size(), 0) + assert_eq(_enemy_list.size(), 1) + assert_true(_enemy.is_queued_for_deletion()) + await get_tree().process_frame + assert_false(is_instance_valid(_enemy)) diff --git a/project.godot b/project.godot index fd7f4bf2..8f5ca948 100644 --- a/project.godot +++ b/project.godot @@ -22,6 +22,7 @@ CardManager="*res://Managers/CardManager.gd" PhaseManager="*res://Managers/PhaseManager.gd" RandomGenerator="*res://Managers/RandomGenerator.gd" GlobalVar="*res://Global/GLOBAL_VAR.gd" +SceneController="*res://Core/SceneController.gd" [display]