diff --git a/README.md b/README.md index 659ce73..d3c20e3 100644 --- a/README.md +++ b/README.md @@ -17,13 +17,13 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr - Min/max attack ranges and counterattack forecasts. - MP tactics with low/high damage options, area damage/healing/status options, ally support buffs, enemy debuffs, movement and accuracy/evasion modifiers, poison damage-over-time, seal/snare/disarm action locks, a skill list menu, previews, and target overlays. - Campaign inventory consumables with an item menu, HP/MP recovery, poison/seal/snare/disarm cure previews, and target overlay. -- Weapon, armor, and accessory rewards can be stored, displayed, and equipped from the battle HUD; some weapons deal bonus physical damage against matching move types. +- Weapon, armor, and accessory rewards can be stored, displayed, compared, and equipped from the battle HUD; some weapons deal bonus physical damage against matching move types. - First named equipment rewards exist in the northern campaign arc and show a `[Named]` or `Named` tag in reward, inventory, shop, Armory, and equipment menus. - Scenario-specific pre-battle shop purchases, optional finite shop stock, and 50% sell-back save immediately and sync into the upcoming battle inventory. - Pre-battle Armory equipment changes save immediately and sync roster equipment plus inventory stock. - Pre-battle Roster can move optional officers between sortie and reserve within scenario limits. - Pre-battle Formation lets deployed officers swap starting positions inside scenario-defined cells. -- Pre-battle briefing shows the campaign chapter, chapter battle number, location, and victory/defeat summary. +- Pre-battle briefing shows the campaign chapter, chapter battle number, location, victory/defeat summary, and first-clear reward preview. - Pre-battle Chapters overview shows story-arc progress and can open completed or current battles. - Pre-battle Save menu can write and load one manual campaign checkpoint separate from the automatic save. - Escort scenarios can include required, non-controllable protected units. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fb61d86..d49a3d2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -56,8 +56,8 @@ Joined officers gate whether `requires_joined` player deployments are loaded at all. Rewards and post-battle choices can add or remove joined officers without deleting their saved roster snapshot, so a later rejoin can preserve progression. When a save loads, `CampaignState` scans completed scenarios in campaign order and reapplies only officer join/leave transitions from rewards and the saved `applied_post_battle_choices` ledger, allowing added recruitment rewards to reach older saves without replaying gold or items. Old saves without the ledger can backfill a choice id only when exactly one completed scenario choice matches saved flags. Roster entries are keyed by `officer_id` when available, so future scenarios can deploy the same joined officer under different scenario-specific `unit_id` values. Saved progression overlays final battle stats for now; a later equipment refactor should split base stats from equipment bonuses before adding item leveling or stat recalculation. New battles currently start player units healed to their saved maximum HP/MP. Skill access is carried in roster snapshots, with class defaults still applied for old saves that do not include skills. Promoted class ids are saved in the roster, and the next battle rehydrates class-derived movement, growth, skills, and range from the saved class id before equipment range is recalculated. -Inventory and campaign flags are copied from `CampaignState` into `BattleState` when a scenario starts. The pre-battle Chapters overview reads `CampaignState` chapter progress and can load completed or current battles without mutating the save. The pre-battle Save menu can write the current campaign state to `user://campaign_manual_save.json`, and loading that checkpoint restores it into `user://campaign_save.json` before re-entering the same pending-choice, completion, or current-briefing branch as startup. Pre-battle shop stock comes from the loaded scenario's `shop.items` list plus matching `shop.conditional_items` blocks, with optional finite limits from item entries or `stock` maps. Shop purchases and 50% sell-back are campaign transactions: they update saved gold, unequipped inventory, and finite-stock purchase counts immediately, write the save file, and refresh the already-loaded battle inventory before the player begins the battle. Pre-battle Armory equipment changes use the same equipment rules as battle HUD equipment changes, then immediately save merged roster equipment/stat snapshots and inventory stock. Pre-battle Roster uses scenario `roster.max_units`, `roster.required_officers`, and `roster.required_units` to mark optional player deployments as sortie or reserve; reserve units remain in the candidate list but are excluded from board occupancy, drawing, selection, AI targeting, and living-unit victory checks. Non-controllable player units can act as protected escort targets: enemies can attack them and conditions can reference them, but they are skipped by player selection, Armory, Formation, counterattacks, and campaign roster progression snapshots when `persist_progression` is false. Scenario `ai_target_priority` nudges enemy movement and damage-skill scoring toward important targets such as envoys without overriding defeat bonuses or range checks. Pre-battle Formation uses the loaded scenario's `formation.cells` and only mutates current battle unit positions before battle-begin events fire. Destination victory cells from `unit_reaches_tile` conditions are exposed to the scene for objective overlays and tile info. Movement-triggered `unit_reaches_tile` events receive the unit that just moved, so ambushes and pickup events fire on entry rather than from units already standing on a marker. Briefing data can append matching `briefing.conditional_lines`, and scenario events can use `when.campaign_flags` to branch dialogue, objective changes, item pickups, and reinforcements from saved campaign choices. Consumable use can restore HP or MP or cure matching poison/seal/snare/disarm statuses, and in-battle item use, event pickup grants, plus equipment swaps mutate only the battle copy until victory; defeats and restarts do not spend saved items, keep picked-up items, or preserve in-battle gear changes. Post-battle choices write campaign `flags` only after the result-panel button is pressed; if the player reloads after rewards are saved but before selecting, the pending scenario id restores the victory choice panel without replaying rewards. The next battle button stays locked until the campaign choice save succeeds. Equipment rewards share the same `item_id -> count` inventory stock, cover weapon, armor, and accessory slots, can be equipped from the side HUD before the selected unit moves or acts, and do not appear in the consumable action menu. Named equipment currently uses the same stock model with a presentation-only rarity tag rather than unique item instances. Already completed scenarios can be replayed without granting duplicate rewards, choices, replay inventory consumption, save writes, current-scenario advancement, or finite-stock purchases; pre-battle Shop, Armory, Roster, Formation, and Save are disabled on completed-scenario replays to avoid side effects. +Inventory and campaign flags are copied from `CampaignState` into `BattleState` when a scenario starts. The pre-battle Chapters overview reads `CampaignState` chapter progress and can load completed or current battles without mutating the save. Briefings preview first-clear rewards and show completed replay rewards as already claimed. The pre-battle Save menu can write the current campaign state to `user://campaign_manual_save.json`, and loading that checkpoint restores it into `user://campaign_save.json` before re-entering the same pending-choice, completion, or current-briefing branch as startup. Pre-battle shop stock comes from the loaded scenario's `shop.items` list plus matching `shop.conditional_items` blocks, with optional finite limits from item entries or `stock` maps. Shop purchases and 50% sell-back are campaign transactions: they update saved gold, unequipped inventory, and finite-stock purchase counts immediately, write the save file, and refresh the already-loaded battle inventory before the player begins the battle. Pre-battle Armory equipment changes use the same equipment rules as battle HUD equipment changes, then immediately save merged roster equipment/stat snapshots and inventory stock. Pre-battle Roster uses scenario `roster.max_units`, `roster.required_officers`, and `roster.required_units` to mark optional player deployments as sortie or reserve; reserve units remain in the candidate list but are excluded from board occupancy, drawing, selection, AI targeting, and living-unit victory checks. Non-controllable player units can act as protected escort targets: enemies can attack them and conditions can reference them, but they are skipped by player selection, Armory, Formation, counterattacks, and campaign roster progression snapshots when `persist_progression` is false. Scenario `ai_target_priority` nudges enemy movement and damage-skill scoring toward important targets such as envoys without overriding defeat bonuses or range checks. Pre-battle Formation uses the loaded scenario's `formation.cells` and only mutates current battle unit positions before battle-begin events fire. Destination victory cells from `unit_reaches_tile` conditions are exposed to the scene for objective overlays and tile info. Movement-triggered `unit_reaches_tile` events receive the unit that just moved, so ambushes and pickup events fire on entry rather than from units already standing on a marker. Briefing data can append matching `briefing.conditional_lines`, and scenario events can use `when.campaign_flags` to branch dialogue, objective changes, item pickups, and reinforcements from saved campaign choices. Consumable use can restore HP or MP or cure matching poison/seal/snare/disarm statuses, and in-battle item use, event pickup grants, plus equipment swaps mutate only the battle copy until victory; defeats and restarts do not spend saved items, keep picked-up items, or preserve in-battle gear changes. Post-battle choices write campaign `flags` only after the result-panel button is pressed; if the player reloads after rewards are saved but before selecting, the pending scenario id restores the victory choice panel without replaying rewards. The next battle button stays locked until the campaign choice save succeeds. Equipment rewards share the same `item_id -> count` inventory stock, cover weapon, armor, and accessory slots, can be equipped from the side HUD before the selected unit moves or acts, and do not appear in the consumable action menu. Named equipment currently uses the same stock model with a presentation-only rarity tag rather than unique item instances. Already completed scenarios can be replayed without granting duplicate rewards, choices, replay inventory consumption, save writes, current-scenario advancement, or finite-stock purchases; pre-battle Shop, Armory, Roster, Formation, and Save are disabled on completed-scenario replays to avoid side effects. -The battle scene owns presentation feedback: briefing and result states use menu BGM, briefing headers combine `CampaignState` chapter ranges, campaign order, and title data with `BattleState` briefing and objective text, active battles use battle BGM, dialogue lines can show optional portrait textures or speaker-initial fallback panels and can place the portrait on the left or right side of the dialogue panel, board units render low-HP warning rings, HP bar color states, and active support/debuff/poison/seal/snare/disarm status pips from battle state fields, hover previews are rendered as target badges from existing `BattleState` forecast APIs, hover unit info shows class, movement type, AGI, movement, range, and equipped gear, result and inventory summaries show named equipment with compact rarity tags, selected area tactics highlight their affected cells, the Threat toggle overlays enemy physical and hostile tactic reach while tile info names threat sources, estimates physical damage, and summarizes hostile tactic damage or status effects against occupied cells, log/result hooks trigger placeholder SFX, support, debuff, poison, seal, snare, disarm, and item pickup effects use distinct floating feedback colors, `BattleState.unit_motion_requested` asks the scene to interpolate a unit's draw position during movement, and `BattleState.combat_feedback_requested` asks the scene to draw transient floating combat text without changing battle rules. +The battle scene owns presentation feedback: briefing and result states use menu BGM, briefing headers combine `CampaignState` chapter ranges, campaign order, and title data with `BattleState` briefing and objective text, active battles use battle BGM, dialogue lines can show optional portrait textures or speaker-initial fallback panels and can place the portrait on the left or right side of the dialogue panel, board units render low-HP warning rings, HP bar color states, and active support/debuff/poison/seal/snare/disarm status pips from battle state fields, hover previews are rendered as target badges from existing `BattleState` forecast APIs, hover unit info shows class, movement type, AGI, movement, range, and equipped gear, equipment option menus show stat/range/effect deltas, result and inventory summaries show named equipment with compact rarity tags, selected area tactics highlight their affected cells, the Threat toggle overlays enemy physical and hostile tactic reach while tile info names threat sources, estimates physical damage, and summarizes hostile tactic damage or status effects against occupied cells, log/result hooks trigger placeholder SFX, support, debuff, poison, seal, snare, disarm, and item pickup effects use distinct floating feedback colors, `BattleState.unit_motion_requested` asks the scene to interpolate a unit's draw position during movement, and `BattleState.combat_feedback_requested` asks the scene to draw transient floating combat text without changing battle rules. The `New Campaign` button clears `user://campaign_save.json`, resets campaign state to the first scenario, and reloads the opening briefing. diff --git a/docs/GODOT_4_6_MIGRATION.md b/docs/GODOT_4_6_MIGRATION.md index af4f3c5..651afc4 100644 --- a/docs/GODOT_4_6_MIGRATION.md +++ b/docs/GODOT_4_6_MIGRATION.md @@ -45,7 +45,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File tools\check_godot46_readines - Chapters, Shop, Armory, Roster, Formation, and Save open and close; completed or current Chapter entries can open a battle briefing. - Finite-stock Shop entries show remaining counts and disable once sold out. - Manual checkpoint save/load restores the expected current briefing without script errors. -- A player unit can move, attack, wait, use a tactic, use an item, and equip compatible gear; weapon effectiveness appears in forecasts and equipment/shop text when applicable. +- A player unit can move, attack, wait, use a tactic, use an item, and equip compatible gear; weapon effectiveness appears in forecasts and equipment/shop text when applicable, and equipment options show stat/range change hints. - Named equipment rewards such as Yitian Sword and Dragon Spear validate, appear with compact rarity tags in reward/inventory/equipment text, and remain ordinary stackable inventory stock until unique item instances are implemented. - Hovering an occupied tile shows the unit's class, movement type, AGI, movement, range, and equipped weapon/armor/accessory, with empty slots shown as `-`. - Movement-triggered supply cache events grant battle inventory items with pickup feedback, and the items persist only after victory. @@ -61,7 +61,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File tools\check_godot46_readines - 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. -- Post-battle dialogue and post-battle choices still block progression until a choice is saved. +- Briefings show victory/defeat text plus first-clear reward previews, while post-battle dialogue and post-battle choices still block progression until a choice is saved. - Next battle loads with saved roster, inventory, joined officers, and campaign flags. - New Campaign clears the save and returns to the opening scenario. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 551fa30..0447da9 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -9,7 +9,7 @@ ## Milestone 1: Playable Battle Prototype -- Scenario briefing before battle with a campaign chapter header, battle header, and objective summary. +- Scenario briefing before battle with a campaign chapter header, battle header, objective summary, and reward preview. - Select player units. - Show move and attack ranges. - Show hover tile and unit information, including class, movement type, AGI, movement, range, and equipped gear. @@ -31,7 +31,7 @@ - Unit classes and class growth tables. Basic version exists. - Automatic class promotion routes. Basic level-threshold promotion exists for core first-tier battle classes. - Weapon, armor, and accessory slots. Basic stat bonuses and first weapon-vs-move-type physical damage bonuses exist. -- Equipment rewards can be stored, displayed, and swapped from the battle HUD. +- Equipment rewards can be stored, displayed, compared, and swapped from the battle HUD. - Terrain movement, defense, and avoid modifiers affect combat. - Skills, tactics, and MP. Multiple damage/heal tactics, first area damage/healing/status tactics, ally support buffs, enemy debuffs, movement and accuracy/evasion modifiers, poison damage-over-time, seal/snare/disarm action locks, a skill list menu with range/effect hints, and enemy tactic AI exist. - Consumable item use. Basic global inventory, Bean HP recovery, Wine MP recovery, Antidote poison cure, Panacea poison/seal/snare/disarm cure, item menu, preview, and target overlay exist. diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index ea1cc5f..dc9122a 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -2286,9 +2286,36 @@ func _format_briefing_objectives() -> String: if not text.is_empty(): text += "\n" text += "Defeat: %s" % defeat + var rewards_text := _format_briefing_rewards() + if not rewards_text.is_empty(): + if not text.is_empty(): + text += "\n" + text += "Rewards: %s" % rewards_text return text +func _format_briefing_rewards() -> String: + if campaign_state.is_scenario_completed(active_scenario_id): + return "already claimed" + var rewards := state.get_rewards() + var parts := [] + var gold: int = maxi(0, int(rewards.get("gold", 0))) + if gold > 0: + parts.append("%dG" % gold) + var items = rewards.get("items", []) + if typeof(items) == TYPE_ARRAY and not items.is_empty(): + var item_text := _format_reward_items(items) + if item_text != "none": + parts.append(item_text) + var joined_text := _format_officer_id_list(rewards.get("join_officers", [])) + if not joined_text.is_empty(): + parts.append("Join %s" % joined_text) + var left_text := _format_officer_id_list(rewards.get("leave_officers", [])) + if not left_text.is_empty(): + parts.append("Leave %s" % left_text) + return _join_strings(parts, ", ") + + func _show_briefing() -> void: _play_bgm("menu") var briefing := state.get_briefing() @@ -2877,11 +2904,12 @@ func _rebuild_armory_menu() -> void: var normalized_id := str(item_id) var item := state.get_item_def(normalized_id) var armory_equip_button := Button.new() - armory_equip_button.text = "%s x%d %s" % [ - _item_display_name(normalized_id), - int(inventory.get(normalized_id, 0)), - _format_equipment_bonus_text(item) - ] + armory_equip_button.text = _format_equipment_option_text( + selected, + normalized_id, + item, + int(inventory.get(normalized_id, 0)) + ) armory_equip_button.pressed.connect(_on_armory_equip_pressed.bind(normalized_id)) armory_list.add_child(armory_equip_button) @@ -3289,11 +3317,11 @@ func _format_battle_result_summary() -> String: var joined_text := "" var joined_officers: Array = battle_result_summary.get("joined_officers", []) if not joined_officers.is_empty(): - joined_text = "\nJoined: %s" % _join_strings(joined_officers, ", ") + joined_text = "\nJoined: %s" % _format_officer_id_list(joined_officers) var left_text := "" var left_officers: Array = battle_result_summary.get("left_officers", []) if not left_officers.is_empty(): - left_text = "\nLeft: %s" % _join_strings(left_officers, ", ") + left_text = "\nLeft: %s" % _format_officer_id_list(left_officers) var progression_text := _format_progression_events(battle_result_summary.get("progression_events", [])) var saved_text := "Campaign saved." if battle_result_summary.get("saved", false) else "Campaign save failed." @@ -3408,6 +3436,22 @@ func _format_inventory_by_category(inventory: Dictionary) -> String: return _join_strings(sections, " | ") +func _format_officer_id_list(values) -> String: + if typeof(values) != TYPE_ARRAY: + return "" + var labels := [] + for value in values: + var label := _format_identifier_label(str(value)) + if label.is_empty(): + continue + labels.append(label) + return _join_strings(labels, ", ") + + +func _format_identifier_label(value: String) -> String: + return value.strip_edges().replace("_", " ").capitalize() + + func _item_display_name(item_id: String) -> String: var item := state.get_item_def(item_id) if item.is_empty(): @@ -3854,11 +3898,12 @@ func _rebuild_equip_menu(selected: Dictionary) -> void: var normalized_id := str(item_id) var item := state.get_item_def(normalized_id) var equip_item_button := Button.new() - equip_item_button.text = "%s x%d %s" % [ - _item_display_name(normalized_id), - int(inventory.get(normalized_id, 0)), - _format_equipment_bonus_text(item) - ] + equip_item_button.text = _format_equipment_option_text( + selected, + normalized_id, + item, + int(inventory.get(normalized_id, 0)) + ) equip_item_button.disabled = _is_input_locked() equip_item_button.pressed.connect(_on_equip_item_pressed.bind(normalized_id)) equip_list.add_child(equip_item_button) @@ -3906,6 +3951,103 @@ func _equipment_item_id(equipment: Dictionary, slot: String) -> String: return str(item_id) +func _format_equipment_option_text(unit: Dictionary, item_id: String, item: Dictionary, count: int) -> String: + var parts := [ + _item_display_name(item_id), + "x%d" % count, + _format_equipment_bonus_text(item) + ] + var change_text := _format_equipment_change_text(unit, item) + if not change_text.is_empty(): + parts.append(change_text) + return _join_strings(parts, " ") + + +func _format_equipment_change_text(unit: Dictionary, item: Dictionary) -> String: + if unit.is_empty() or item.is_empty(): + return "" + var slot := _equipment_slot_for_display(item) + if slot.is_empty(): + return "" + + var equipment := state.get_equipment_snapshot(str(unit.get("id", ""))) + var current_item_id := _equipment_item_id(equipment, slot) + var current_item := state.get_item_def(current_item_id) + var parts := [] + var candidate_bonuses := _equipment_bonus_map(item) + var current_bonuses := _equipment_bonus_map(current_item) + for stat in ["hp", "mp", "atk", "def", "int", "agi"]: + var delta := int(candidate_bonuses.get(stat, 0)) - int(current_bonuses.get(stat, 0)) + if delta == 0: + continue + var sign := "+" if delta > 0 else "" + parts.append("%s %s%d" % [stat.to_upper(), sign, delta]) + + var range_text := _format_equipment_range_change_text(current_item, item) + if not range_text.is_empty(): + parts.append(range_text) + var effectiveness_text := _format_equipment_effectiveness_change_text(current_item, item) + if not effectiveness_text.is_empty(): + parts.append(effectiveness_text) + if parts.is_empty(): + return "Change none" + return "Change %s" % _join_strings(parts, ", ") + + +func _equipment_slot_for_display(item: Dictionary) -> String: + var kind := str(item.get("kind", "")) + if kind == "weapon" or kind == "armor" or kind == "accessory": + return kind + return "" + + +func _equipment_bonus_map(item: Dictionary) -> Dictionary: + if item.is_empty() or typeof(item.get("bonuses", {})) != TYPE_DICTIONARY: + return {} + return item.get("bonuses", {}) + + +func _format_equipment_range_change_text(current_item: Dictionary, candidate_item: Dictionary) -> String: + if str(candidate_item.get("kind", "")) != "weapon": + return "" + var candidate_range := _format_equipment_item_range_text(candidate_item) + var current_range := _format_equipment_item_range_text(current_item) + if candidate_range.is_empty() or candidate_range == current_range: + return "" + if current_range.is_empty(): + current_range = "-" + return "RNG %s->%s" % [current_range, candidate_range] + + +func _format_equipment_item_range_text(item: Dictionary) -> String: + if item.is_empty() or not item.has("range"): + return "" + var value = item.get("range", 1) + if typeof(value) == TYPE_ARRAY: + if value.size() >= 2: + return "%d-%d" % [int(value[0]), int(value[1])] + if value.size() == 1: + return "%d" % int(value[0]) + return "" + if typeof(value) == TYPE_INT or typeof(value) == TYPE_FLOAT: + return "%d" % int(value) + return "" + + +func _format_equipment_effectiveness_change_text(current_item: Dictionary, candidate_item: Dictionary) -> String: + if str(candidate_item.get("kind", "")) != "weapon": + return "" + var candidate_text := _format_equipment_effectiveness_text(candidate_item) + var current_text := _format_equipment_effectiveness_text(current_item) + if candidate_text == current_text: + return "" + if current_text.is_empty(): + return "Gains %s" % candidate_text + if candidate_text.is_empty(): + return "Loses %s" % current_text + return "Effect changes" + + func _format_equipment_bonus_text(item: Dictionary) -> String: var parts := [] var rarity_text := _format_equipment_rarity_text(item)