Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions config/death_hints.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"death_hints": {
"_description": "Contextual hints shown on defeat screen based on death cause. Each hint unlocks permanently once triggered.",
"hints": [
{
"id": "hint_elevation",
"trigger": "elevation_damage",
"text": "Elevation affects both damage dealt and received. High ground is worth fighting for.",
"localization_key": "HINT_ELEVATION"
},
{
"id": "hint_cover",
"trigger": "cover_ignored",
"text": "Cover tiles reduce incoming damage. Positioning matters as much as attacking.",
"localization_key": "HINT_COVER"
},
{
"id": "hint_ap_management",
"trigger": "out_of_ap",
"text": "AP regenerates each turn, but spending it all leaves you defenseless. Plan your actions.",
"localization_key": "HINT_AP_MANAGEMENT"
},
{
"id": "hint_desperation",
"trigger": "died_without_desperation",
"text": "When HP drops below 25%, a Desperation Strike becomes available. It costs all AP but deals devastating damage.",
"localization_key": "HINT_DESPERATION"
},
{
"id": "hint_elite_priority",
"trigger": "elite_kill",
"text": "Elite enemies amplify nearby foes. Consider eliminating them first to reduce pressure.",
"localization_key": "HINT_ELITE_PRIORITY"
},
{
"id": "hint_combo_unlock",
"trigger": "combo_unavailable",
"text": "Elemental combos multiply damage. Unlock new abilities in the Sanctum to discover powerful synergies.",
"localization_key": "HINT_COMBO_UNLOCK"
}
]
}
}
7 changes: 7 additions & 0 deletions localization/ui_strings.csv
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ HUD_SUMMARY_TURNS,Turns taken: %d,Gespielte Züge: %d,Turnos tomados: %d,Tours j
HUD_SUMMARY_KILLS,Enemies defeated: %d,Besiegte Gegner: %d,Enemigos derrotados: %d,Ennemis vaincus : %d
HUD_SUMMARY_SHARDS,Echo shards earned: %d,Verdiente Echosplitter: %d,Fragmentos de eco ganados: %d,Éclats d'écho gagnés : %d
HUD_SUMMARY_ROOMS,Rooms cleared: %d,Gereinigte Räume: %d,Salas despejadas: %d,Salles nettoyées : %d
HUD_SUMMARY_DAMAGE,Damage taken: %d,Erlittener Schaden: %d,Daño recibido: %d,Dégâts subis : %d
HINT_ELEVATION,"Elevation affects both damage dealt and received. High ground is worth fighting for.","Die Höhe beeinflusst sowohl ausgeteilten als auch erhaltenen Schaden. Höheres Gelände lohnt sich.","La elevación afecta tanto el daño infligido como el recibido. Vale la pena luchar por terreno alto.","L'altitude affecte les dégâts infligés et subis. Il vaut la peine de se battre pour les hauteurs."
HINT_COVER,"Cover tiles reduce incoming damage. Positioning matters as much as attacking.","Deckung reduziert eingehenden Schaden. Positionierung ist genauso wichtig wie Angriff.","Las casillas de cobertura reducen el daño entrante. El posicionamiento importa tanto como atacar.","Les tuiles de couverture réduisent les dégâts entrants. Le positionnement compte autant que l'attaque."
HINT_DESPERATION,"When HP drops below 25%, a Desperation Strike becomes available. It costs all AP but deals devastating damage.","Bei weniger als 25% LP wird ein Verzweiflungsschlag verfügbar. Er kostet alle AP, verursacht aber verheerenden Schaden.","Cuando la salud cae por debajo del 25%, un Golpe de Desesperación se vuelve disponible. Cuesta todo el PA pero causa daño devastador.","Quand les PV descendent sous 25%, une Frappe de Désespoir devient disponible. Elle coûte tous les PA mais inflige des dégâts dévastateurs."
HINT_ELITE_PRIORITY,"Elite enemies amplify nearby foes. Consider eliminating them first to reduce pressure.","Elitegegner verstärken nahe Gegner. Erwäge, sie zuerst zu eliminieren.","Los enemigos de élite amplifican a los cercanos. Considera eliminarlos primero.","Les ennemis d'élite amplifient les ennemis proches. Envisagez de les éliminer en premier."
HINT_AP_MANAGEMENT,"AP regenerates each turn, but spending it all leaves you defenseless. Plan your actions.","AP regeneriert sich jede Runde, aber alles auszugeben lässt dich wehrlos. Plane deine Aktionen.","Los PA se regeneran cada turno, pero gastarlos todos te deja indefenso. Planifica tus acciones.","Les PA se régénèrent chaque tour, mais les dépenser tous vous laisse sans défense. Planifiez vos actions."
HINT_COMBO_UNLOCK,"Elemental combos multiply damage. Unlock new abilities in the Sanctum to discover powerful synergies.","Elementarkombos vervielfachen Schaden. Schalte neue Fähigkeiten im Sanktuarium frei.","Los combos elementales multiplican el daño. Desbloquea nuevas habilidades en el Sagrario.","Les combos élémentaires multiplient les dégâts. Débloquez de nouvelles capacités dans le Sanctuaire."
HUD_PLAYER_TURN,Your Turn,Dein Zug,Tu Turno,Votre Tour
HUD_ENEMY_TURN,Enemy Turn: %s,Gegnerzug: %s,Turno del Enemigo: %s,Tour de l'Ennemi : %s
HUD_ROUND_LABEL,Round %d,Runde %d,Ronda %d,Round %d
Expand Down
142 changes: 142 additions & 0 deletions scripts/core/combat_room.gd
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ var _enemies_node: Node2D
var _combat_input: CombatInput
var _turn_manager: TurnManager
var _room_kills: int = 0
var _room_damage_taken: int = 0
var _desperation_available: bool = true
var _reflection_text: String = ""
var _current_room_data: Dictionary = {}
var _boss_entity: Entity = null
Expand Down Expand Up @@ -93,6 +95,11 @@ func _exit_tree() -> void:
lifecycle.mwt_reached.disconnect(_on_mwt_reached)
lifecycle.clear_timers()

# FIX #600: Disconnect player hp tracking.
var player_ent := CombatEntity.get_entity(_player)
if player_ent and player_ent.hp_changed.is_connected(_on_player_hp_changed):
player_ent.hp_changed.disconnect(_on_player_hp_changed)

# FIX #596: Disconnect undo signal.
if is_instance_valid(_turn_manager):
if _turn_manager.turn_started.is_connected(_on_turn_started_undo):
Expand All @@ -115,6 +122,8 @@ func _on_room_entered(p_room_index: int, room_data: Dictionary) -> void:

_current_room_data = room_data
_room_kills = 0
_room_damage_taken = 0
_desperation_available = true

# Clear existing entities if any
for child: Node in entity_container.get_children():
Expand Down Expand Up @@ -142,6 +151,13 @@ func _on_room_entered(p_room_index: int, room_data: Dictionary) -> void:
_boss_entity.hp_changed.connect(_on_boss_hp_changed)
break

# FIX #600: Track player damage taken.
var player_ent := CombatEntity.get_entity(_player)
if player_ent and player_ent.hp_changed.is_connected(_on_player_hp_changed):
player_ent.hp_changed.disconnect(_on_player_hp_changed)
if player_ent:
player_ent.hp_changed.connect(_on_player_hp_changed)

# Show internal monologue on first room entry (room_index 0)
if p_room_index == 0:
var dm: _DialogueManager = AutoloadHelper.dialogue_manager()
Expand Down Expand Up @@ -183,6 +199,9 @@ func _setup_hud() -> void:
combat_hud.setup(player_entity, _turn_manager, _combat_input)
if not combat_hud.move_pressed.is_connected(_on_hud_move_pressed):
combat_hud.move_pressed.connect(_on_hud_move_pressed)
# FIX #600: Wire desperation strike signal.
if not combat_hud.desperation_pressed.is_connected(_on_desperation_pressed):
combat_hud.desperation_pressed.connect(_on_desperation_pressed)

var turn_banner_scene := load(TURN_BANNER_SCENE_PATH) as PackedScene
if turn_banner_scene:
Expand Down Expand Up @@ -626,6 +645,76 @@ func _on_hud_move_pressed() -> void:
_combat_input._stop_targeting()


## FIX #600: Desperation Strike — costs all AP, deals 2x damage to nearest enemy.
func _on_desperation_pressed() -> void:
if not _desperation_available:
return
if _turn_manager == null or _turn_manager.current_state != TurnManager.CombatState.PLAYER_TURN:
return

var player_ent: Entity = CombatEntity.get_entity(_player)
if player_ent == null or player_ent.ap <= 0:
return

# Find nearest alive enemy.
var nearest_enemy: Node2D = null
var nearest_dist: int = GameConstants.GRID_W * GameConstants.GRID_H
var player_pos: Vector2i = player_ent.grid_position()
for child: Node in _enemies_node.get_children():
if not child is Node2D:
continue
var enemy_ent: Entity = CombatEntity.get_entity(child)
if enemy_ent == null or enemy_ent.hp <= 0:
continue
var dist: int = maxi(absi(enemy_ent.x - player_pos.x), absi(enemy_ent.y - player_pos.y))
if dist < nearest_dist:
nearest_dist = dist
nearest_enemy = child

if nearest_enemy == null:
return

var target_ent: Entity = CombatEntity.get_entity(nearest_enemy)
if target_ent == null:
return

# 2x damage via deterministic formula.
var cover_tiles: Array[Vector2i] = []
if _grid_system:
for tile: TacTileData in _grid_system.all_tiles():
if tile.has_cover():
cover_tiles.append(tile.coords)
var base_damage: int = CombatFormula.compute_damage_from_entities(
player_ent, target_ent, cover_tiles
)
var desperation_damage: int = DeterministicMath.damage_floor(float(base_damage) * 2.0)

var lifecycle: _EntityLifecycle = AutoloadHelper.entity_lifecycle()
if lifecycle:
lifecycle.apply_damage(player_ent, target_ent, desperation_damage)
else:
target_ent.apply_damage(desperation_damage)

# Cost all AP.
player_ent.ap = 0

# Mark as used.
_desperation_available = false
var combat_hud: _CombatHUD = ui_overlay.get_node_or_null("CombatHUD") as _CombatHUD
if combat_hud != null and combat_hud.bottom_console != null:
combat_hud.bottom_console.mark_desperation_used()

# Visual feedback.
trigger_camera_shake(CAMERA_SHAKE_MAX_OFFSET * 2.0)
var eb: _EventBus = AutoloadHelper.event_bus()
if eb:
eb.floating_text_requested.emit(
str(desperation_damage) + "!",
nearest_enemy.get_global_transform_with_canvas().origin + Vector2(0, -40),
Color(0.9, 0.1, 0.1)
)


func _on_combat_ended(victory: bool) -> void:
if victory:
if _boss_entity and _boss_entity.archetype_id == "overgrown_guardian":
Expand Down Expand Up @@ -828,6 +917,12 @@ func _on_boss_hp_changed(new_hp: int, _old_hp: int) -> void:
_spawn_reinforcements()


## FIX #600: Track total damage taken this room.
func _on_player_hp_changed(new_hp: int, old_hp: int) -> void:
if old_hp > new_hp:
_room_damage_taken += (old_hp - new_hp)


func _spawn_reinforcements() -> void:
if not _current_room_data.has("reinforcements"):
return
Expand Down Expand Up @@ -935,5 +1030,52 @@ func _show_defeat_modal() -> void:
var summary: Dictionary = {
"turns": _turn_manager.round_number,
"rooms": rm.room_index if rm else 0,
"kills": _room_kills,
"damage_taken": _room_damage_taken,
"hint": _select_death_hint(),
}
modal.setup(summary)


## FIX #600: Select a contextual death hint based on room state.
func _select_death_hint() -> String:
var cfg: _ConfigLoader = AutoloadHelper.config_loader()
if cfg == null:
return ""
var hints_data: Dictionary = cfg.getValue("death_hints", "", {})
var hints: Array = hints_data.get("hints", []) as Array
if hints.is_empty():
return ""

# Determine which hint to show based on room state.
var player_ent: Entity = CombatEntity.get_entity(_player)
var trigger: String = ""
if player_ent != null:
var hp_pct: float = float(player_ent.hp_max - player_ent.hp) / float(player_ent.hp_max)
if _room_damage_taken >= player_ent.hp_max / 2 and hp_pct >= 0.75:
trigger = "cover_ignored"
elif _room_damage_taken >= player_ent.hp_max / 2:
trigger = "elevation_damage"
elif _desperation_available:
trigger = "died_without_desperation"

# Find a hint matching the trigger.
for hint_item: Variant in hints:
if hint_item is Dictionary:
var hint: Dictionary = hint_item as Dictionary
if hint.get("trigger", "") == trigger:
var key: String = hint.get("localization_key", "")
if not key.is_empty():
var text: String = tr(key)
if text != key:
return text
return hint.get("text", "")

# Fallback: first hint.
var first: Dictionary = hints[0] as Dictionary
var key: String = first.get("localization_key", "")
if not key.is_empty():
var text: String = tr(key)
if text != key:
return text
return first.get("text", "")
66 changes: 66 additions & 0 deletions scripts/ui/bottom_console.gd
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ extends Control
const MOVE_ACCENT: Color = Color(0.2, 0.6, 0.95, 1.0) # Blue
const ATTACK_ACCENT: Color = Color(0.95, 0.35, 0.15, 1.0) # Red-orange
const END_TURN_ACCENT: Color = Color(0.95, 0.8, 0.15, 1.0) # Gold
## FIX #600: Desperation Strike accent color (crimson pulse).
const DESPERATION_ACCENT: Color = Color(0.9, 0.1, 0.1, 1.0) # Deep red

@onready
var move_button: Button = $MarginContainer/HBoxContainer/CenterConsole/ActionButtons/MoveButton
Expand All @@ -18,7 +20,11 @@ var attack_button: Button = $MarginContainer/HBoxContainer/CenterConsole/ActionB
var end_turn_button: Button = $MarginContainer/HBoxContainer/CenterConsole/ActionButtons/EndTurnButton
@onready var burden_label: Label = $MarginContainer/HBoxContainer/RightWing/BurdenLabel

## FIX #600: Desperation Strike button (added programmatically).
var desperation_button: Button = null

var _player_entity: Entity
var _desperation_used_this_run: bool = false


func setup(player_entity: Entity) -> void:
Expand All @@ -28,6 +34,7 @@ func setup(player_entity: Entity) -> void:
eb.entity_state_changed.connect(_on_entity_state_changed)
_update_burden_label()
_setup_button_colors()
_setup_desperation_button()


## FIX #601: Apply accent colors to action buttons.
Expand All @@ -48,6 +55,58 @@ func _setup_button_colors() -> void:
end_turn_button.tooltip_text = "End Turn (Space)"


## FIX #600: Create and wire the Desperation Strike button.
func _setup_desperation_button() -> void:
if desperation_button != null:
return
var action_buttons: HBoxContainer = (
$MarginContainer/HBoxContainer/CenterConsole/ActionButtons as HBoxContainer
)
if action_buttons == null:
return

desperation_button = Button.new()
desperation_button.name = "DesperationButton"
desperation_button.text = "Desperation"
desperation_button.custom_minimum_size = Vector2(110, 36)
desperation_button.visible = false
desperation_button.add_theme_color_override("font_color", DESPERATION_ACCENT)
desperation_button.tooltip_text = "Devastating strike — costs all AP"
action_buttons.add_child(desperation_button)

if not desperation_button.pressed.is_connected(_on_desperation_pressed):
desperation_button.pressed.connect(_on_desperation_pressed)


## FIX #600: Update desperation button visibility based on player HP.
func update_desperation_visibility() -> void:
if desperation_button == null or _player_entity == null:
return
if _desperation_used_this_run:
desperation_button.visible = false
return

var hp_pct: float = float(_player_entity.hp) / float(_player_entity.hp_max)
var should_show: bool = hp_pct <= 0.25 and _player_entity.hp > 0
desperation_button.visible = should_show


## FIX #600: Called by CombatRoom when desperation is used.
func mark_desperation_used() -> void:
_desperation_used_this_run = true
if desperation_button != null:
desperation_button.visible = false


func _on_desperation_pressed() -> void:
## Signal emitted for CombatRoom to handle the actual strike.
desperation_pressed.emit()


## FIX #600: Signal for CombatRoom to handle desperation strike.
signal desperation_pressed


func _update_burden_label() -> void:
if burden_label == null:
return
Expand All @@ -61,9 +120,16 @@ func _update_burden_label() -> void:
func _on_entity_state_changed(entity: Entity, _old: Entity.State, _new: Entity.State) -> void:
if entity == _player_entity:
_update_burden_label()
## FIX #600: Re-check desperation visibility on any player state change.
update_desperation_visibility()


func _exit_tree() -> void:
var eb := AutoloadHelper.event_bus()
if eb and eb.entity_state_changed.is_connected(_on_entity_state_changed):
eb.entity_state_changed.disconnect(_on_entity_state_changed)
if (
desperation_button != null
and desperation_button.pressed.is_connected(_on_desperation_pressed)
):
desperation_button.pressed.disconnect(_on_desperation_pressed)
11 changes: 11 additions & 0 deletions scripts/ui/combat_hud.gd
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ extends Control
## Manages HUD layout and bottom chrome reflow.

signal move_pressed
## FIX #600: Emitted when Desperation Strike button is pressed.
signal desperation_pressed

var _player_entity: Entity
var _turn_manager: TurnManager
Expand Down Expand Up @@ -165,6 +167,10 @@ func setup(player_entity: Entity, turn_manager: TurnManager, combat_input: Comba
bottom_console.move_button.pressed.connect(_on_move_pressed)
bottom_console.attack_button.pressed.connect(_on_attack_pressed)
bottom_console.end_turn_button.pressed.connect(_on_end_turn_pressed)
# FIX #600: Wire desperation button signal.
if bottom_console.desperation_pressed.is_connected(_on_desperation_pressed):
bottom_console.desperation_pressed.disconnect(_on_desperation_pressed)
bottom_console.desperation_pressed.connect(_on_desperation_pressed)

# Apply button micro-animations
var animator: _ButtonAnimator = _ButtonAnimator.new()
Expand Down Expand Up @@ -244,6 +250,11 @@ func _on_move_pressed() -> void:
_log_from_config("move_hint")


## FIX #600: Forward desperation signal to CombatRoom.
func _on_desperation_pressed() -> void:
desperation_pressed.emit()


func _on_attack_pressed() -> void:
if _combat_input:
_combat_input.enter_targeting_mode()
Expand Down
Loading
Loading