diff --git a/config/death_hints.json b/config/death_hints.json new file mode 100644 index 00000000..9e64f35b --- /dev/null +++ b/config/death_hints.json @@ -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" + } + ] + } +} diff --git a/localization/ui_strings.csv b/localization/ui_strings.csv index 2887358b..b990fd1a 100644 --- a/localization/ui_strings.csv +++ b/localization/ui_strings.csv @@ -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 diff --git a/scripts/core/combat_room.gd b/scripts/core/combat_room.gd index 20869375..fdb074b1 100644 --- a/scripts/core/combat_room.gd +++ b/scripts/core/combat_room.gd @@ -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 @@ -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): @@ -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(): @@ -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() @@ -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: @@ -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": @@ -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 @@ -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", "") diff --git a/scripts/ui/bottom_console.gd b/scripts/ui/bottom_console.gd index c26ceef6..ed6d5166 100644 --- a/scripts/ui/bottom_console.gd +++ b/scripts/ui/bottom_console.gd @@ -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 @@ -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: @@ -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. @@ -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 @@ -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) diff --git a/scripts/ui/combat_hud.gd b/scripts/ui/combat_hud.gd index dcb501c9..d2f048d0 100644 --- a/scripts/ui/combat_hud.gd +++ b/scripts/ui/combat_hud.gd @@ -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 @@ -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() @@ -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() diff --git a/scripts/ui/defeat_modal.gd b/scripts/ui/defeat_modal.gd index 28e10a9a..e2f2523d 100644 --- a/scripts/ui/defeat_modal.gd +++ b/scripts/ui/defeat_modal.gd @@ -53,10 +53,12 @@ func _update_summary_display() -> void: var defeat_config: Dictionary = config.getValue("end_screens", "defeat", {}) var summary_keys: Array = defeat_config.get("summary_keys", []) - # Mapping of keys to summary data values + # FIX #600: Expanded key mapping with kills and damage taken. var key_map: Dictionary = { "HUD_SUMMARY_TURNS": _summary_data.get("turns", 0), - "HUD_SUMMARY_ROOMS": _summary_data.get("rooms", 0) + "HUD_SUMMARY_ROOMS": _summary_data.get("rooms", 0), + "HUD_SUMMARY_KILLS": _summary_data.get("kills", 0), + "HUD_SUMMARY_DAMAGE": _summary_data.get("damage_taken", 0), } for key: String in summary_keys: @@ -79,6 +81,20 @@ func _update_summary_display() -> void: narr_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART summary_container.add_child(narr_lbl) + # FIX #600: Append contextual death hint if available. + var hint_text: String = _summary_data.get("hint", "") + if not hint_text.is_empty(): + var spacer: Control = Control.new() + spacer.custom_minimum_size = Vector2(0, 8) + summary_container.add_child(spacer) + var hint_lbl: Label = Label.new() + hint_lbl.text = "💡 " + hint_text + hint_lbl.add_theme_color_override("font_color", Color(0.95, 0.8, 0.3)) + hint_lbl.add_theme_font_size_override("font_size", 14) + hint_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + hint_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + summary_container.add_child(hint_lbl) + func _select_defeat_narrative() -> String: """Select a defeat narrative variant deterministically from room_index + MWT.""" diff --git a/tests/test_desperation_move.gd b/tests/test_desperation_move.gd new file mode 100644 index 00000000..deec9aa1 --- /dev/null +++ b/tests/test_desperation_move.gd @@ -0,0 +1,37 @@ +extends GdUnitTestSuite + + +func test_desperation_button_hidden_by_default() -> void: + var bc: _BottomConsole = _BottomConsole.new() + add_child(bc) + var player: Entity = Entity.new("Player", 0, 0, 100, 10, 5) + bc.setup(player) + # Before visibility update, button may not exist yet. + if bc.desperation_button != null: + assert_bool(bc.desperation_button.visible).is_false() + bc.queue_free() + + +func test_desperation_button_shows_at_low_hp() -> void: + var bc: _BottomConsole = _BottomConsole.new() + add_child(bc) + var player: Entity = Entity.new("Player", 0, 0, 100, 10, 5) + player.hp = 20 # 20% HP + bc.setup(player) + bc.update_desperation_visibility() + if bc.desperation_button != null: + assert_bool(bc.desperation_button.visible).is_true() + bc.queue_free() + + +func test_desperation_button_hidden_after_used() -> void: + var bc: _BottomConsole = _BottomConsole.new() + add_child(bc) + var player: Entity = Entity.new("Player", 0, 0, 100, 10, 5) + player.hp = 20 + bc.setup(player) + bc.mark_desperation_used() + bc.update_desperation_visibility() + if bc.desperation_button != null: + assert_bool(bc.desperation_button.visible).is_false() + bc.queue_free()