diff --git a/README.md b/README.md index 53ab144..1ebcbd1 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr - Floating combat text for damage, recovery, misses, support effects, poison ticks, action locks, level-ups, and promotions. - Short unit slide animation for board movement. - Hover target preview badges for attacks, tactics, and items. -- Low-HP board unit warning rings, HP bar color states, and board status pips for support, debuff, poison, seal, snare, and disarm effects. +- Low-HP board unit warning rings, HP bar color states, and board status pips plus HUD duration summaries for support, debuff, poison, seal, snare, and disarm effects. - Data-driven scenario setup through JSON definitions and deployments. ## Run diff --git a/docs/GODOT_4_6_MIGRATION.md b/docs/GODOT_4_6_MIGRATION.md index e161383..cf8f58c 100644 --- a/docs/GODOT_4_6_MIGRATION.md +++ b/docs/GODOT_4_6_MIGRATION.md @@ -63,7 +63,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File tools\check_godot46_readines - Seal Tactic applies seal, prevents the affected unit from using tactics during its next action phase, and disables the tactic button with a sealed forecast/badge. - Snare Tactic applies snare, prevents the affected unit from moving during its next action phase, and still leaves attacks, tactics, items, and wait available. - Disarm Tactic applies disarm, prevents physical attacks and counterattacks, and still leaves movement, tactics, items, and wait available. -- Support, debuff, poison, seal, snare, and disarm status pips appear on affected board units and disappear when those effects expire or are cured. +- Support, debuff, poison, seal, snare, and disarm status pips appear on affected board units, HUD summaries show remaining phases, and both disappear when those effects expire or are cured. - Antidote can cure poison, Panacea can cure poison, seal, snare, or disarm, and neither consumes stock when used on a target without a matching status. - Enemy turn advances and AI acts, including damage-aware physical target selection. - Victory and defeat panels still appear. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3782b5c..0ad32ee 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -134,7 +134,7 @@ ## Milestone 5: Presentation - Portrait pipeline. Officer definitions can provide default portrait paths, dialogue lines can override them, battle HUD thumbnails reuse them for selected or hovered units, missing art falls back to speaker initials, and the first seven AI-generated photorealistic officer portraits are in `art/portraits`. -- Unit sprites and animations. Board movement now has a short slide animation, and board units show low-HP warning rings plus small status pips for active support, debuff, poison, seal, snare, and disarm effects. +- Unit sprites and animations. Board movement now has a short slide animation, and board units show low-HP warning rings plus small status pips for active support, debuff, poison, seal, snare, and disarm effects, with HUD summaries showing remaining phases. - Battle effects. First floating combat text now covers damage, recovery, misses, support effects, poison ticks, item pickups, AI target-focus changes, level-ups, and promotions. Hover target preview badges summarize attacks, tactics, and items, and the board can overlay enemy threat ranges with physical damage and hostile tactic hints. - Music and sound. Basic placeholder BGM and SFX now play for menus, battle flow, unit actions, tactics, items, and result states. - Chapter UI, objective presentation, and visual novel scenes. Pre-battle briefing now uses campaign chapter ranges for chapter-aware numbering, has a chapter progress/replay overview plus objective/progress/risk tracker, active battle HUD shows the same live objective panel, and dialogue lines can choose left or right portrait placement in an expanded portrait dialogue panel with progress and previous-line controls. diff --git a/scripts/core/battle_state.gd b/scripts/core/battle_state.gd index f161b3f..62f3f59 100644 --- a/scripts/core/battle_state.gd +++ b/scripts/core/battle_state.gd @@ -1114,24 +1114,36 @@ func _format_active_status_effects(unit: Dictionary) -> String: for effect in _active_status_effects(unit): var effect_type := str(effect.get("type", "stat_bonus")) var amount := int(effect.get("amount", 0)) + var duration_text := _format_status_remaining_phases(effect) if effect_type == "damage_over_time": if amount > 0: - parts.append("%s -%d HP" % [_status_display_name(str(effect.get("status", "poison"))), amount]) + parts.append("%s -%d HP %s" % [ + _status_display_name(str(effect.get("status", "poison"))), + amount, + duration_text + ]) continue if effect_type == "action_lock": - parts.append("%s %s" % [ + parts.append("%s %s %s" % [ _status_display_name(str(effect.get("status", "status"))), - _action_lock_display_name(str(effect.get("action", ""))) + _action_lock_display_name(str(effect.get("action", ""))), + duration_text ]) continue var stat := str(effect.get("stat", "")) if not _is_supported_status_stat(stat) or amount == 0: continue var sign := "+" if amount > 0 else "" - parts.append("%s %s%d" % [stat.to_upper(), sign, amount]) + parts.append("%s %s%d %s" % [stat.to_upper(), sign, amount, duration_text]) return _join_strings(parts, ", ") +func _format_status_remaining_phases(effect: Dictionary) -> String: + var remaining: int = maxi(1, int(effect.get("remaining_phases", 1))) + var label := "phase" if remaining == 1 else "phases" + return "(%d %s)" % [remaining, label] + + func _status_display_name(status: String) -> String: var normalized := status.strip_edges() if normalized.is_empty(): @@ -1155,12 +1167,12 @@ func _unit_has_action_lock(unit: Dictionary, action: String) -> bool: func _action_lock_display_name(action: String) -> String: if action == "skill": - return "tactics" + return "no tactics" if action == "move": - return "movement" + return "no movement" if action == "attack": - return "attacks" - return "action" + return "no attacks" + return "no action" func try_wait_selected() -> bool: diff --git a/tools/smoke_equipment_campaign_persistence.gd b/tools/smoke_equipment_campaign_persistence.gd new file mode 100644 index 0000000..fb3215e --- /dev/null +++ b/tools/smoke_equipment_campaign_persistence.gd @@ -0,0 +1,115 @@ +extends SceneTree + +const BattleStateScript := preload("res://scripts/core/battle_state.gd") +const CampaignStateScript := preload("res://scripts/core/campaign_state.gd") + +const CAMPAIGN_PATH := "res://data/campaign/campaign.json" +const SAVE_PATH := "user://campaign_save.json" + + +func _init() -> void: + var failures: Array[String] = [] + var save_existed := FileAccess.file_exists(SAVE_PATH) + var save_text := FileAccess.get_file_as_string(SAVE_PATH) if save_existed else "" + + _check_unequipped_loadout_persists_after_victory(failures) + _restore_save(save_existed, save_text, failures) + + if failures.is_empty(): + print("equipment campaign persistence smoke ok") + quit(0) + return + + for failure in failures: + push_error(failure) + quit(1) + + +func _check_unequipped_loadout_persists_after_victory(failures: Array[String]) -> void: + var campaign_state = CampaignStateScript.new() + if not campaign_state.load_campaign(CAMPAIGN_PATH): + failures.append("could not load campaign data") + return + campaign_state.start_new(campaign_state.get_start_scenario_id()) + + var state = BattleStateScript.new() + if not state.load_battle( + campaign_state.get_scenario_path("001_yellow_turbans"), + campaign_state.get_roster_overrides(), + campaign_state.get_inventory_snapshot(), + campaign_state.get_flags_snapshot(), + campaign_state.get_joined_officers_snapshot() + ): + failures.append("could not load opening battle") + return + + if not state.try_unequip_item("cao_cao", "weapon"): + failures.append("Cao Cao should be able to unequip Bronze Sword before victory") + return + var battle_inventory: Dictionary = state.get_inventory_snapshot() + if int(battle_inventory.get("bronze_sword", 0)) != 1: + failures.append("battle inventory should receive unequipped Bronze Sword") + var battle_equipment: Dictionary = state.get_equipment_snapshot("cao_cao") + if battle_equipment.has("weapon"): + failures.append("battle equipment should keep Cao Cao weapon empty before save") + + state.battle_status = "victory" + var result: Dictionary = campaign_state.apply_battle_result(state) + if not bool(result.get("saved", false)): + failures.append("campaign save should succeed after opening victory") + if not campaign_state.completed_scenarios.has("001_yellow_turbans"): + failures.append("opening scenario should be completed after victory") + if campaign_state.current_scenario_id != "002_sishui_gate": + failures.append("campaign should advance to Sishui Gate, got %s" % campaign_state.current_scenario_id) + + var campaign_roster: Dictionary = campaign_state.get_roster_overrides() + var cao_cao_roster: Dictionary = campaign_roster.get("cao_cao", {}) + var saved_equipment: Dictionary = cao_cao_roster.get("equipment", {}) + if saved_equipment.has("weapon"): + failures.append("campaign roster should persist empty Cao Cao weapon slot: %s" % str(saved_equipment)) + var campaign_inventory: Dictionary = campaign_state.get_inventory_snapshot() + if int(campaign_inventory.get("bronze_sword", 0)) != 1: + failures.append("campaign inventory should keep returned Bronze Sword") + if int(campaign_inventory.get("bean", 0)) != 1: + failures.append("campaign inventory should include opening victory Bean reward") + + var next_state = BattleStateScript.new() + if not next_state.load_battle( + campaign_state.get_scenario_path("002_sishui_gate"), + campaign_state.get_roster_overrides(), + campaign_state.get_inventory_snapshot(), + campaign_state.get_flags_snapshot(), + campaign_state.get_joined_officers_snapshot() + ): + failures.append("could not load next battle from saved campaign state") + return + var next_equipment: Dictionary = next_state.get_equipment_snapshot("cao_cao_ch2") + if next_equipment.has("weapon"): + failures.append("next scenario should rehydrate Cao Cao without weapon: %s" % str(next_equipment)) + if int(next_state.get_inventory_snapshot().get("bronze_sword", 0)) != 1: + failures.append("next scenario battle inventory should include returned Bronze Sword") + + var before_replay_inventory: Dictionary = campaign_state.get_inventory_snapshot() + var before_replay_roster: Dictionary = campaign_state.get_roster_overrides() + var replay_result: Dictionary = campaign_state.apply_battle_result(state) + if not bool(replay_result.get("already_completed", false)): + failures.append("second apply should be treated as completed replay") + if campaign_state.get_inventory_snapshot() != before_replay_inventory: + failures.append("completed replay should not mutate campaign inventory") + if campaign_state.get_roster_overrides() != before_replay_roster: + failures.append("completed replay should not mutate campaign roster") + + +func _restore_save(save_existed: bool, save_text: String, failures: Array[String]) -> void: + if save_existed: + var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE) + if file == null: + failures.append("could not restore previous campaign save") + return + file.store_string(save_text) + return + + if FileAccess.file_exists(SAVE_PATH): + var error := DirAccess.remove_absolute(ProjectSettings.globalize_path(SAVE_PATH)) + if error != OK: + failures.append("could not remove temporary campaign save") diff --git a/tools/smoke_status_effect_summary.gd b/tools/smoke_status_effect_summary.gd new file mode 100644 index 0000000..e87ce76 --- /dev/null +++ b/tools/smoke_status_effect_summary.gd @@ -0,0 +1,85 @@ +extends SceneTree + +const BattleStateScript := preload("res://scripts/core/battle_state.gd") +const BattleSceneScript := preload("res://scripts/scenes/battle_scene.gd") + + +func _init() -> void: + var failures: Array[String] = [] + _check_status_summary_duration_text(failures) + _check_hud_reuses_status_summary(failures) + + if failures.is_empty(): + print("status effect summary smoke ok") + quit(0) + return + + for failure in failures: + push_error(failure) + quit(1) + + +func _check_status_summary_duration_text(failures: Array[String]) -> void: + var state = _loaded_state(failures, "status summary") + if state == null: + return + var cao_cao: Dictionary = state.get_unit("cao_cao") + cao_cao["status_effects"] = [ + { + "type": "stat_bonus", + "stat": "atk", + "amount": 3, + "remaining_phases": 2 + }, + { + "type": "damage_over_time", + "status": "poison", + "amount": 4, + "remaining_phases": 1 + }, + { + "type": "action_lock", + "status": "seal", + "action": "skill", + "remaining_phases": 3 + } + ] + var summary: String = state.get_status_effect_summary("cao_cao") + _check_contains(failures, "stat duration", summary, "ATK +3 (2 phases)") + _check_contains(failures, "poison duration", summary, "Poison -4 HP (1 phase)") + _check_contains(failures, "seal duration", summary, "Seal no tactics (3 phases)") + + +func _check_hud_reuses_status_summary(failures: Array[String]) -> void: + var scene = BattleSceneScript.new() + scene._create_hud() + scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json") + var cao_cao: Dictionary = scene.state.get_unit("cao_cao") + cao_cao["status_effects"] = [ + { + "type": "stat_bonus", + "stat": "def", + "amount": -2, + "remaining_phases": 2 + } + ] + scene.state.selected_unit_id = "cao_cao" + scene.hover_cell = Vector2i(1, 3) + scene._update_hud() + _check_contains(failures, "selected HUD status", scene.selected_label.text, "Effects: DEF -2 (2 phases)") + _check_contains(failures, "hover tile status", scene.cell_info_label.text, "Effects: DEF -2 (2 phases)") + scene.free() + + +func _loaded_state(failures: Array[String], label: String): + var state = BattleStateScript.new() + if not state.load_battle("res://data/scenarios/001_yellow_turbans.json"): + failures.append("%s could not load opening scenario" % label) + return null + return state + + +func _check_contains(failures: Array[String], label: String, text: String, expected: String) -> void: + if text.contains(expected): + return + failures.append("%s expected `%s` in `%s`" % [label, expected, text])