From 6bba22a562faffa8685fc4e4227c55fdd91d51ec Mon Sep 17 00:00:00 2001 From: Wickedness Date: Thu, 18 Jun 2026 03:17:25 +0900 Subject: [PATCH] Add shop sell-back --- README.md | 2 +- docs/ARCHITECTURE.md | 2 +- docs/DATA_MODEL.md | 4 +- docs/ROADMAP.md | 2 +- scripts/core/campaign_state.gd | 19 +++++ scripts/scenes/battle_scene.gd | 127 ++++++++++++++++++++++++++++++++- 6 files changed, 148 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 621ae61..023e90a 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr - MP tactics with low/high damage options, healing options, a first support buff, a skill list menu, previews, and target overlays. - Campaign inventory consumables with an item menu, HP/MP recovery preview, and target overlay. - Weapon, armor, and accessory rewards can be stored, displayed, and equipped from the battle HUD. -- Scenario-specific pre-battle shop purchases spend campaign gold, save immediately, and sync into the upcoming battle inventory. +- Scenario-specific pre-battle shop purchases 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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7aa552c..0717dd7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -56,7 +56,7 @@ 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. Pre-battle shop stock comes from the loaded scenario's `shop.items` list plus matching `shop.conditional_items` blocks. Purchases are campaign transactions: they spend saved gold 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 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, and reinforcements from saved campaign choices. Consumable use can restore HP or MP, and in-battle item use plus equipment swaps mutate only the battle copy until victory; defeats and restarts do not spend saved 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. Already completed scenarios can be replayed without granting duplicate rewards, choices, or replay inventory consumption; pre-battle Shop, Armory, Roster, and Formation are disabled on completed-scenario replays to avoid side effects. +Inventory and campaign flags are copied from `CampaignState` into `BattleState` when a scenario starts. Pre-battle shop stock comes from the loaded scenario's `shop.items` list plus matching `shop.conditional_items` blocks. Shop purchases and 50% sell-back are campaign transactions: they update saved gold and unequipped inventory 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 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, and reinforcements from saved campaign choices. Consumable use can restore HP or MP, and in-battle item use plus equipment swaps mutate only the battle copy until victory; defeats and restarts do not spend saved 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. Already completed scenarios can be replayed without granting duplicate rewards, choices, or replay inventory consumption; pre-battle Shop, Armory, Roster, and Formation are disabled on completed-scenario replays to avoid side effects. The battle scene owns presentation audio: briefing and result states use menu BGM, active battles use battle BGM, and log/result hooks trigger placeholder SFX without changing battle rules. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 152783d..8d539a5 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -277,7 +277,7 @@ Weapon `range` also uses `[min, max]`. When class and equipment both provide ran Consumables use `kind: "consumable"` with effect records. `heal_hp` restores HP and `heal_mp` restores MP to a valid target; `Bean` and `Wine` are the first examples. Item use consumes the acting unit's action and only commits to the save file on victory. Weapons, armor, and accessories can be granted through `rewards.items`; they are shown in inventory, can be equipped through the Equip menu before the unit moves or acts, and are not usable from the battle item menu. -Scenario `shop.items` controls which positive-price items appear in the current pre-battle shop. `shop.conditional_items` can append extra stock when saved campaign flags match. Purchases spend campaign gold, increment the campaign inventory count, save immediately, and then refresh the battle inventory copy if a scenario briefing is already loaded. Later campaign data should add finite stock counts, unlock flags, and sell-back rules. +Scenario `shop.items` controls which positive-price items appear in the current pre-battle shop. `shop.conditional_items` can append extra stock when saved campaign flags match. Purchases spend campaign gold, increment the campaign inventory count, save immediately, and then refresh the battle inventory copy if a scenario briefing is already loaded. The shop can also sell unequipped inventory stock back for 50% of base `price`, saving immediately and refreshing the loaded battle inventory copy. Later campaign data should add finite stock counts and unlock flags. The pre-battle Armory operates on the currently loaded player deployments and the copied battle inventory, then saves the changed roster entries and inventory counts back into `CampaignState`. Unlike in-battle equipment swaps, Armory changes persist immediately and survive defeat or restart. Completed-scenario replays keep Shop and Armory disabled so replay setup cannot alter the campaign save. @@ -475,7 +475,7 @@ During a battle, item consumption and equipment swaps affect `BattleState.battle Physical attacks grant normal attack/counter EXP only on hit after the attack/counter exchange resolves. A missed physical attack or counter grants a small miss EXP value, while defeat bonuses still require the attack to hit and defeat the target. -Before a battle starts, `BattleScene` can buy priced items through `CampaignState.try_buy_item()`. A successful purchase updates campaign gold and inventory in the save file immediately, then calls `BattleState.set_inventory_snapshot()` so the upcoming battle sees the new stock. Pre-battle Armory changes call `CampaignState.try_save_prebattle_loadout()`, which merges the currently deployed roster snapshot into the saved roster and saves the adjusted inventory in the same operation. Pre-battle Roster calls `BattleState.try_set_unit_deployed()` to toggle optional player units while enforcing required officers and sortie limits. Pre-battle Formation calls `BattleState.try_set_prebattle_formation()`, which can move a player unit to an open formation cell or swap two player units inside the formation area. Matching `briefing.conditional_lines` and `shop.conditional_items` are merged while the scenario loads, before the briefing panel opens. `post_battle_dialogue` is rendered by the battle scene after victory and before rewards/choices are shown in the result panel. Post-battle choices call `CampaignState.try_apply_post_battle_choice()`, which saves selected flags and rolls back flags, gold, and inventory if the save write fails. +Before a battle starts, `BattleScene` can buy priced items through `CampaignState.try_buy_item()` and sell unequipped inventory through `CampaignState.try_sell_item()`. Successful shop transactions update campaign gold and inventory in the save file immediately, then call `BattleState.set_inventory_snapshot()` so the upcoming battle sees the new stock. Pre-battle Armory changes call `CampaignState.try_save_prebattle_loadout()`, which merges the currently deployed roster snapshot into the saved roster and saves the adjusted inventory in the same operation. Pre-battle Roster calls `BattleState.try_set_unit_deployed()` to toggle optional player units while enforcing required officers and sortie limits. Pre-battle Formation calls `BattleState.try_set_prebattle_formation()`, which can move a player unit to an open formation cell or swap two player units inside the formation area. Matching `briefing.conditional_lines` and `shop.conditional_items` are merged while the scenario loads, before the briefing panel opens. `post_battle_dialogue` is rendered by the battle scene after victory and before rewards/choices are shown in the result panel. Post-battle choices call `CampaignState.try_apply_post_battle_choice()`, which saves selected flags and rolls back flags, gold, and inventory if the save write fails. When every campaign scenario id is present in `completed_scenarios`, the battle scene shows a campaign completion panel. Starting a new campaign clears the save file and resets `current_scenario_id` to `start_scenario`. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 532b69b..f804771 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -55,7 +55,7 @@ - Linear sixty-seven-scenario campaign order exists. - Save reset exists. - Victory rewards, consumable counts, equipment stock, and equipped gear persist. -- A basic pre-battle shop with scenario-specific stock exists. Sell-back and richer item management are still planned. +- A basic pre-battle shop with scenario-specific stock and 50% sell-back exists. Richer item management is still planned. - A basic pre-battle Armory exists for saved equipment changes before combat. - A basic pre-battle Roster exists for required officers, required scenario units, optional officers, and sortie limits. - A basic pre-battle Formation screen exists for scenario-defined starting cells. diff --git a/scripts/core/campaign_state.gd b/scripts/core/campaign_state.gd index 45fd095..7b817b9 100644 --- a/scripts/core/campaign_state.gd +++ b/scripts/core/campaign_state.gd @@ -256,6 +256,25 @@ func try_buy_item(item_id: String, price: int) -> bool: return false +func try_sell_item(item_id: String, sale_price: int) -> bool: + if item_id.is_empty() or sale_price <= 0: + return false + var previous_count := int(inventory.get(item_id, 0)) + if previous_count <= 0: + return false + var previous_gold := gold + gold += sale_price + if previous_count > 1: + inventory[item_id] = previous_count - 1 + else: + inventory.erase(item_id) + if save_game(): + return true + gold = previous_gold + inventory[item_id] = previous_count + return false + + func try_save_prebattle_loadout(roster_snapshot: Dictionary, inventory_snapshot: Dictionary) -> bool: var previous_roster := roster.duplicate(true) var previous_inventory := inventory.duplicate(true) diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index bece41a..c0ded61 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -59,8 +59,11 @@ var briefing_panel: PanelContainer var briefing_label: Label var shop_button: Button var shop_menu: VBoxContainer +var shop_buy_button: Button +var shop_sell_button: Button var shop_list: VBoxContainer var shop_status_label: Label +var shop_sell_mode := false var armory_button: Button var armory_menu: VBoxContainer var armory_list: VBoxContainer @@ -342,6 +345,20 @@ func _create_hud() -> void: shop_status_label.custom_minimum_size = Vector2(600, 22) shop_menu.add_child(shop_status_label) + var shop_mode_row := HBoxContainer.new() + shop_mode_row.add_theme_constant_override("separation", 8) + shop_menu.add_child(shop_mode_row) + + shop_buy_button = Button.new() + shop_buy_button.text = "Buy" + shop_buy_button.pressed.connect(_on_shop_buy_mode_pressed) + shop_mode_row.add_child(shop_buy_button) + + shop_sell_button = Button.new() + shop_sell_button.text = "Sell" + shop_sell_button.pressed.connect(_on_shop_sell_mode_pressed) + shop_mode_row.add_child(shop_sell_button) + var shop_scroll := ScrollContainer.new() shop_scroll.custom_minimum_size = Vector2(600, 190) shop_menu.add_child(shop_scroll) @@ -573,7 +590,7 @@ func _play_log_sfx(message: String) -> void: _play_sfx("fire_skill") elif message.contains(" moved to ") or message.contains(" advances to ") or message.contains(" takes formation at ") or message.contains(" arrives at "): _play_sfx("footstep") - elif message.contains(" uses ") or message.contains(" equips ") or message.begins_with("Bought "): + elif message.contains(" uses ") or message.contains(" equips ") or message.begins_with("Bought ") or message.begins_with("Sold "): _play_sfx("item_pickup") elif message.contains(" reaches Lv.") or message.contains(" promotes to "): _play_sfx("menu_confirm") @@ -1350,6 +1367,7 @@ func _on_restart_pressed() -> void: battle_result_applied = false battle_result_summary.clear() post_battle_dialogue_started = false + shop_sell_mode = false selected_skill_id = "" selected_item_id = "" armory_unit_id = "" @@ -1438,7 +1456,7 @@ func _show_briefing() -> void: _hide_formation_menu() var prep_locked := _is_prebattle_prep_locked() if shop_button != null: - shop_button.disabled = prep_locked or state.get_shop_item_ids().is_empty() + shop_button.disabled = prep_locked or (state.get_shop_item_ids().is_empty() and _get_sellable_item_ids().is_empty()) if armory_button != null: armory_button.disabled = prep_locked or state.get_controllable_player_units().is_empty() if roster_button != null: @@ -1473,6 +1491,7 @@ func _on_shop_pressed() -> void: if shop_menu != null and shop_menu.visible: _hide_shop_menu() else: + shop_sell_mode = state.get_shop_item_ids().is_empty() and not _get_sellable_item_ids().is_empty() _hide_armory_menu() _hide_roster_menu() _hide_formation_menu() @@ -1817,11 +1836,24 @@ func _rebuild_shop_menu() -> void: return _clear_shop_list() if shop_status_label != null: - shop_status_label.text = "Gold %d | %s" % [ + var mode_text := "Sell" if shop_sell_mode else "Buy" + shop_status_label.text = "%s | Gold %d | %s" % [ + mode_text, campaign_state.gold, _format_inventory_status_text() ] + if shop_buy_button != null: + shop_buy_button.disabled = not shop_sell_mode + if shop_sell_button != null: + shop_sell_button.disabled = shop_sell_mode or _get_sellable_item_ids().is_empty() + if shop_sell_mode: + _rebuild_shop_sell_list() + else: + _rebuild_shop_buy_list() + + +func _rebuild_shop_buy_list() -> void: var item_ids := state.get_shop_item_ids() if item_ids.is_empty(): var empty_label := Label.new() @@ -1842,6 +1874,26 @@ func _rebuild_shop_menu() -> void: shop_list.add_child(buy_button) +func _rebuild_shop_sell_list() -> void: + var item_ids := _get_sellable_item_ids() + if item_ids.is_empty(): + var empty_label := Label.new() + empty_label.text = "No sellable stock" + shop_list.add_child(empty_label) + return + + var inventory := campaign_state.get_inventory_snapshot() + for item_id in item_ids: + var normalized_id := str(item_id) + var item := state.get_item_def(normalized_id) + var sale_price := _sale_price_for_item(item) + var owned := int(inventory.get(normalized_id, 0)) + var sell_button := Button.new() + sell_button.text = _format_shop_sell_item_button_text(normalized_id, item, sale_price, owned) + sell_button.pressed.connect(_on_shop_sell_item_pressed.bind(normalized_id)) + shop_list.add_child(sell_button) + + func _clear_shop_list() -> void: for child in shop_list.get_children(): shop_list.remove_child(child) @@ -1861,6 +1913,15 @@ func _format_shop_item_button_text(item_id: String, item: Dictionary, price: int ] +func _format_shop_sell_item_button_text(item_id: String, item: Dictionary, sale_price: int, owned: int) -> String: + return "%s Sell %dG %s Owned x%d" % [ + _item_display_name(item_id), + sale_price, + _format_shop_item_effect_text(item), + owned + ] + + func _format_shop_item_effect_text(item: Dictionary) -> String: var kind := str(item.get("kind", "item")) if kind == "consumable": @@ -1897,6 +1958,66 @@ func _on_shop_item_pressed(item_id: String) -> void: _update_hud() +func _on_shop_sell_item_pressed(item_id: String) -> void: + if battle_started or _is_prebattle_prep_locked(): + return + var item := state.get_item_def(item_id) + var sale_price := _sale_price_for_item(item) + var item_name := _item_display_name(item_id) + if item.is_empty() or sale_price <= 0: + return + if campaign_state.try_sell_item(item_id, sale_price): + state.set_inventory_snapshot(campaign_state.get_inventory_snapshot()) + _on_log_added("Sold %s for %d gold." % [item_name, sale_price]) + else: + _play_ui_cancel() + _on_log_added("Could not sell %s." % item_name) + if _get_sellable_item_ids().is_empty(): + shop_sell_mode = false + _rebuild_shop_menu() + _update_hud() + + +func _on_shop_buy_mode_pressed() -> void: + if not shop_sell_mode: + return + _play_ui_click() + shop_sell_mode = false + _rebuild_shop_menu() + _update_hud() + + +func _on_shop_sell_mode_pressed() -> void: + if shop_sell_mode or _get_sellable_item_ids().is_empty(): + return + _play_ui_click() + shop_sell_mode = true + _rebuild_shop_menu() + _update_hud() + + +func _get_sellable_item_ids() -> Array: + var item_ids := [] + var inventory := campaign_state.get_inventory_snapshot() + for item_id in inventory.keys(): + var normalized_id := str(item_id) + if normalized_id.is_empty() or int(inventory.get(normalized_id, 0)) <= 0: + continue + var item := state.get_item_def(normalized_id) + if _sale_price_for_item(item) <= 0: + continue + item_ids.append(normalized_id) + item_ids.sort() + return item_ids + + +func _sale_price_for_item(item: Dictionary) -> int: + var price := int(item.get("price", 0)) + if price <= 0: + return 0 + return max(1, int(floor(float(price) * 0.5))) + + func _is_input_locked() -> bool: return campaign_complete_screen or _is_dialogue_visible() or not battle_started or state.battle_status != BattleState.STATUS_ACTIVE