Skip to content

Commit

Permalink
Issue-35: Death handling and combat end (Saplings-Projects#60)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
Tomzkk and Tomzkk authored Feb 11, 2024
1 parent 4b3f58f commit f5d25a4
Show file tree
Hide file tree
Showing 9 changed files with 281 additions and 14 deletions.
2 changes: 1 addition & 1 deletion Cards/Effects/EffectApplyStatus.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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)
target.get_status_component().add_status(status_to_apply, caster)
56 changes: 44 additions & 12 deletions Core/Battler.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand All @@ -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]

Expand All @@ -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()
7 changes: 7 additions & 0 deletions Core/Enums.gd
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ enum Phase
PLAYER_ATTACKING,
PLAYER_FINISHING,
ENEMY_ATTACKING,
SCENE_END,
}

enum Team
Expand Down Expand Up @@ -46,3 +47,9 @@ enum CardMovementState
HOVERED,
QUEUED,
}

enum CombatResult
{
VICTORY,
DEFEAT
}
55 changes: 55 additions & 0 deletions Core/SceneController.gd
Original file line number Diff line number Diff line change
@@ -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")
2 changes: 1 addition & 1 deletion Entity/Components/PartyComponent.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
29 changes: 29 additions & 0 deletions Global/EnemyAction.gd
Original file line number Diff line number Diff line change
@@ -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)
6 changes: 6 additions & 0 deletions Managers/PhaseManager.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
137 changes: 137 additions & 0 deletions Tests/test_death_handling.gd
Original file line number Diff line number Diff line change
@@ -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))
1 change: 1 addition & 0 deletions project.godot
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down

0 comments on commit f5d25a4

Please sign in to comment.