From 1041fc917d94270c639939683392940f9e8b8f13 Mon Sep 17 00:00:00 2001 From: Michele Rossi Date: Sat, 14 Mar 2026 11:12:05 +0100 Subject: [PATCH] Add goals --- big_number.gd | 37 ++- game_state.gd | 121 +++++++- generator-unlock-goals/TECH_SPEC.md | 197 +++++++++++++ .../generator_unlock_goals.json | 25 ++ .../generator_unlock_system.gd | 211 ++++++++++++++ .../generator_unlock_system.gd.uid | 1 + generator-unlock-goals/goals_debug_ui.gd | 273 ++++++++++++++++++ generator-unlock-goals/goals_debug_ui.gd.uid | 1 + generator-unlock-goals/goals_debug_ui.tscn | 45 +++ generator/primary_generator.tres | 1 - generator/secondary_generator.tres | 2 + generator_museum.tscn | 11 + project.godot | 1 + 13 files changed, 897 insertions(+), 29 deletions(-) create mode 100644 generator-unlock-goals/TECH_SPEC.md create mode 100644 generator-unlock-goals/generator_unlock_goals.json create mode 100644 generator-unlock-goals/generator_unlock_system.gd create mode 100644 generator-unlock-goals/generator_unlock_system.gd.uid create mode 100644 generator-unlock-goals/goals_debug_ui.gd create mode 100644 generator-unlock-goals/goals_debug_ui.gd.uid create mode 100644 generator-unlock-goals/goals_debug_ui.tscn diff --git a/big_number.gd b/big_number.gd index 499bb48..92dda83 100644 --- a/big_number.gd +++ b/big_number.gd @@ -123,22 +123,35 @@ func divide(other: BigNumber) -> BigNumber: ## Returns 1 if this > other, -1 if this < other, 0 if equal func compare_to(other: BigNumber) -> int: - if mantissa == 0.0 and other.mantissa == 0.0: return 0 - + if mantissa == 0.0 and other.mantissa == 0.0: + return 0 + + # Handle zero explicitly before sign/exponent checks. + if mantissa == 0.0: + return -1 if other.mantissa > 0.0 else 1 + if other.mantissa == 0.0: + return 1 if mantissa > 0.0 else -1 + # Handle signs - if mantissa > 0 and other.mantissa <= 0: return 1 - if mantissa < 0 and other.mantissa >= 0: return -1 - + if mantissa > 0 and other.mantissa < 0: + return 1 + if mantissa < 0 and other.mantissa > 0: + return -1 + # Both are same sign. Compare exponents first. var sign_mult: int = 1 if mantissa > 0 else -1 - - if exponent > other.exponent: return sign_mult - if exponent < other.exponent: return -sign_mult - + + if exponent > other.exponent: + return sign_mult + if exponent < other.exponent: + return -sign_mult + # Exponents are equal, compare mantissas - if mantissa > other.mantissa: return 1 - if mantissa < other.mantissa: return -1 - + if mantissa > other.mantissa: + return 1 + if mantissa < other.mantissa: + return -1 + return 0 func is_greater_than(other: BigNumber) -> bool: diff --git a/game_state.gd b/game_state.gd index 479dd4c..0382a25 100644 --- a/game_state.gd +++ b/game_state.gd @@ -12,8 +12,33 @@ enum CurrencyType { gold, gems } # ========================================== # STATE VARIABLES # ========================================== -var gold: BigNumber = BigNumber.from_float(0.0) -var gems: BigNumber = BigNumber.from_float(0.0) +var _current_currency: Dictionary = {} +var _total_currency_acquired: Dictionary = {} + +var gold: BigNumber: + get: + return _get_current_currency_ref(CurrencyType.gold) + set(value): + _set_current_currency(CurrencyType.gold, value) + +var gems: BigNumber: + get: + return _get_current_currency_ref(CurrencyType.gems) + set(value): + _set_current_currency(CurrencyType.gems, value) + +var total_gold_acquired: BigNumber: + get: + return _get_total_currency_ref(CurrencyType.gold) + set(value): + _set_total_currency(CurrencyType.gold, value) + +var total_gems_acquired: BigNumber: + get: + return _get_total_currency_ref(CurrencyType.gems) + set(value): + _set_total_currency(CurrencyType.gems, value) + var generator_states: Dictionary = {} var last_save_time: int = 0 # Unix timestamp @@ -26,35 +51,47 @@ const GENERATOR_UNLOCKED_KEY: String = "unlocked" const GENERATOR_AVAILABLE_KEY: String = "available" func _ready() -> void: + _initialize_currency_maps() load_game() # ========================================== # STATE MODIFIERS # ========================================== func add_gold(amount: BigNumber) -> void: - gold.add_in_place(amount) - currency_changed.emit(CurrencyType.gold, gold) + add_currency(CurrencyType.gold, amount) func spend_gold(cost: BigNumber) -> bool: - if gold.is_greater_than(cost) or gold.is_equal_to(cost): - var negative_cost = BigNumber.new(-cost.mantissa, cost.exponent) - gold.add_in_place(negative_cost) - currency_changed.emit(CurrencyType.gold, gold) - return true - return false + return spend_currency(CurrencyType.gold, cost) func add_gems(amount: BigNumber) -> void: - gems.add_in_place(amount) - currency_changed.emit(CurrencyType.gems, gems) + add_currency(CurrencyType.gems, amount) func spend_gems(cost: BigNumber) -> bool: - if gems.is_greater_than(cost) or gems.is_equal_to(cost): - var negative_cost = BigNumber.new(-cost.mantissa, cost.exponent) - gems.add_in_place(negative_cost) - currency_changed.emit(CurrencyType.gems, gems) + return spend_currency(CurrencyType.gems, cost) + +func add_currency(currency: CurrencyType, amount: BigNumber) -> void: + if amount.mantissa > 0.0: + _get_total_currency_ref(currency).add_in_place(amount) + + var current: BigNumber = _get_current_currency_ref(currency) + current.add_in_place(amount) + currency_changed.emit(currency, current) + +func spend_currency(currency: CurrencyType, cost: BigNumber) -> bool: + var current: BigNumber = _get_current_currency_ref(currency) + if current.is_greater_than(cost) or current.is_equal_to(cost): + var negative_cost: BigNumber = BigNumber.new(-cost.mantissa, cost.exponent) + current.add_in_place(negative_cost) + currency_changed.emit(currency, current) return true return false +func get_currency_amount(currency: CurrencyType) -> BigNumber: + return _get_current_currency_ref(currency) + +func get_total_currency_acquired(currency: CurrencyType) -> BigNumber: + return _get_total_currency_ref(currency) + func register_generator(generator_id: StringName, unlocked: bool = false, available: bool = false) -> void: var key: String = String(generator_id) if key.is_empty(): @@ -132,6 +169,8 @@ func save_game() -> void: var save_data = { "gold": gold.serialize(), "gems": gems.serialize(), + "total_gold_acquired": total_gold_acquired.serialize(), + "total_gems_acquired": total_gems_acquired.serialize(), "generator_states": _serialize_generator_states(), "last_save_time": Time.get_unix_time_from_system() } @@ -150,9 +189,59 @@ func load_game() -> void: if parsed is Dictionary: gold = BigNumber.deserialize(parsed.get("gold", {})) gems = BigNumber.deserialize(parsed.get("gems", {})) + total_gold_acquired = _deserialize_total_currency(parsed.get("total_gold_acquired", null), gold) + total_gems_acquired = _deserialize_total_currency(parsed.get("total_gems_acquired", null), gems) generator_states = _deserialize_generator_states(parsed.get("generator_states", {})) last_save_time = parsed.get("last_save_time", Time.get_unix_time_from_system()) +func _deserialize_total_currency(raw_total: Variant, current_amount: BigNumber) -> BigNumber: + var minimum_total: BigNumber = _copy_big_number(current_amount) + if minimum_total.mantissa < 0.0: + minimum_total = BigNumber.from_float(0.0) + + if not (raw_total is Dictionary): + return minimum_total + + var parsed_total: BigNumber = BigNumber.deserialize(raw_total) + if parsed_total.mantissa < 0.0: + return minimum_total + if parsed_total.is_less_than(minimum_total): + return minimum_total + return parsed_total + +func _copy_big_number(value: BigNumber) -> BigNumber: + return BigNumber.new(value.mantissa, value.exponent) + +func _initialize_currency_maps() -> void: + _set_current_currency(CurrencyType.gold, BigNumber.from_float(0.0)) + _set_current_currency(CurrencyType.gems, BigNumber.from_float(0.0)) + _set_total_currency(CurrencyType.gold, BigNumber.from_float(0.0)) + _set_total_currency(CurrencyType.gems, BigNumber.from_float(0.0)) + +func _get_current_currency_ref(currency: CurrencyType) -> BigNumber: + var value: Variant = _current_currency.get(currency) + if value is BigNumber: + return value + + var fallback: BigNumber = BigNumber.from_float(0.0) + _current_currency[currency] = fallback + return fallback + +func _get_total_currency_ref(currency: CurrencyType) -> BigNumber: + var value: Variant = _total_currency_acquired.get(currency) + if value is BigNumber: + return value + + var fallback: BigNumber = BigNumber.from_float(0.0) + _total_currency_acquired[currency] = fallback + return fallback + +func _set_current_currency(currency: CurrencyType, value: BigNumber) -> void: + _current_currency[currency] = _copy_big_number(value) + +func _set_total_currency(currency: CurrencyType, value: BigNumber) -> void: + _total_currency_acquired[currency] = _copy_big_number(value) + func _default_generator_state(unlocked: bool, available: bool) -> Dictionary: return { GENERATOR_OWNED_KEY: 0, diff --git a/generator-unlock-goals/TECH_SPEC.md b/generator-unlock-goals/TECH_SPEC.md new file mode 100644 index 0000000..d1b2e69 --- /dev/null +++ b/generator-unlock-goals/TECH_SPEC.md @@ -0,0 +1,197 @@ +# Goal-Based Generator Unlocks - Technical Specification + +## Document Status +- Version: 1.0 +- Date: 2026-03-14 +- Scope: Unlock generators when goal thresholds are reached using one or more currencies. +- Context sources: Amp threads `T-019ce93a-d410-70ad-aaa0-4697dafb0148` and `T-019c846f-2556-75bc-a1aa-fb26cd9ff52a`. + +## Problem Statement +The project already supports generator state (`unlocked` + `available`) in `GameState`, but unlock +transitions are currently configured only by static defaults (`starts_unlocked`, `starts_available`). + +We need progression goals so that a locked generator becomes usable only after the player reaches +specific currency quantities. Goals must support one or more currencies per objective. + +## Current Baseline +1. Generator state is persisted in `GameState.generator_states` with keys `owned`, `purchased_count`, +`unlocked`, `available`. +2. `CurrencyGenerator` already exposes `is_unlocked`, `is_available`, and `is_available_to_player()` +and uses them to gate click, auto-production, and purchases. +3. `GeneratorContainer` listens to `GameState.generator_state_changed` and updates button enabled/disabled state. +4. Save/load sanitization already handles generator state schema evolution safely. + +This means the unlock feature can be added without changing core purchase/production mechanics. + +## Goals And Non-Goals + +## Goals + +1. Define unlock goals that depend on one or multiple currency thresholds. +2. Evaluate goals automatically when relevant currency values change. +3. Unlock and make target generator available exactly once when a goal is met. +4. Keep behavior deterministic after save/load. + +## Non-Goals + +1. Adding quests, timed objectives, or repeatable missions. +2. Adding reward types other than generator unlock/availability. +3. Rebalancing generator economy values in this feature. + +## Functional Requirements + +1. A goal targets exactly one generator (`target_generator_id`). +2. A goal contains one or more currency requirements (`requirements[]`). +3. Goal completion rule is logical AND across requirements (all thresholds must be met). +4. Supported currencies must map to existing `GameState.CurrencyType` values (`gold`, `gems`). +5. When completed, a goal sets both: + - `GameState.set_generator_unlocked(target, true)` + - `GameState.set_generator_available(target, true)` +6. Goal completion must be idempotent (re-evaluating does not re-apply side effects). +7. Goal checks must run: + - at runtime on `currency_changed` + - once at startup after loading save data +8. Invalid goal entries (unknown currency, missing target id, malformed amount) must be ignored with a warning, +not a crash. + +## Data Model + +## New Config File + +Add a root JSON file: +- `res://generator_unlock_goals.json` + +Schema (v1): + +```json +{ + "version": 1, + "goals": [ + { + "id": "unlock_gems_generator", + "target_generator_id": "Gems", + "requirements": [ + { + "currency": "gold", + "amount": { "m": 1.0, "e": 3 } + } + ] + } + ] +} +``` + +Notes: +1. `amount` uses existing `BigNumber` serialization (`m`, `e`) to avoid float precision/overflow issues. +2. `id` must be unique and stable. +3. `requirements` must contain at least one entry. + +## Runtime Structures (GDScript) + +Recommended internal structs/classes: + +1. `UnlockGoalRequirement` + - `currency: GameState.CurrencyType` + - `amount: BigNumber` +2. `UnlockGoal` + - `id: StringName` + - `target_generator_id: StringName` + - `requirements: Array[UnlockGoalRequirement]` + +No persistent `completed_goals` storage is required for v1 because completion is derived from generator unlock state. + +## System Design + +## New Runtime Component + +Add a scene-level controller script (recommended name: `generator_unlock_system.gd`) responsible for: + +1. Loading + validating `generator_unlock_goals.json`. +2. Subscribing to currency change signals. +3. Evaluating pending goals. +4. Triggering generator unlock transitions. + +Placement options: + +1. Attach to the main gameplay scene root (preferred for prototype). +2. Promote to autoload later if multiple gameplay scenes require shared unlock logic. + +## Evaluation Flow + +1. On `_ready()`: + - load goals + - connect to `GameState.currency_changed` + - run `evaluate_all_goals()` once +2. On currency change: + - run `evaluate_all_goals()` (or an optimized subset) +3. For each goal: + - skip if target generator already unlocked and available + - verify all requirement thresholds + - if satisfied, unlock target generator and emit debug/info log + +## Pseudocode + +```gdscript +func _evaluate_goal(goal: UnlockGoal) -> bool: + if GameState.is_generator_unlocked(goal.target_generator_id) and GameState.is_generator_available(goal.target_generator_id): + return false + + for requirement in goal.requirements: + var current: BigNumber = _get_currency_amount(requirement.currency) + if current.is_less_than(requirement.amount): + return false + + GameState.set_generator_unlocked(goal.target_generator_id, true) + GameState.set_generator_available(goal.target_generator_id, true) + return true +``` + +## UI/UX Behavior + +1. Existing `GeneratorContainer` behavior already prevents interaction while locked. +2. Optional v1.1 enhancement: show a compact "Unlock requirement" line for locked generators. +3. On unlock, UI updates automatically through existing `generator_state_changed` signal path. + +## Error Handling And Validation +1. File open failure: log warning and disable unlock processing for that session. +2. JSON parse failure or wrong root type: log warning and disable unlock processing. +3. Invalid goal entries: skip only invalid entries, continue loading valid ones. +4. Duplicate goal IDs: keep first entry, ignore duplicates with warning. +5. Unknown `target_generator_id`: allow config load, but warn when evaluation cannot find/resolve state. + +## Save/Load Behavior + +1. Unlock result persists naturally via existing generator state persistence. +2. On load, if a goal was completed previously, no duplicate effect occurs because state is already unlocked. +3. If config thresholds are reduced in future versions, startup evaluation can unlock additional generators from existing balances. + +## Performance Considerations + +1. v1 can safely evaluate all goals on every currency change (small goal count). +2. If goals scale up, add currency-to-goal index to evaluate only affected goals. +3. BigNumber comparisons are lightweight for this usage profile. + +## Implementation Plan + +1. Add `generator_unlock_goals.json` with initial goals. +2. Implement `generator_unlock_system.gd` loader/validator/evaluator. +3. Attach unlock system to gameplay scene (`generator_museum.tscn` or current active scene). +4. Configure at least one generator as initially locked (`starts_unlocked = false`, `starts_available = false`) in its `.tres` data. +5. Add runtime logs for unlock events and invalid config entries. +6. Run headless project parse smoke check. + +## Acceptance Criteria + +1. A locked generator becomes usable immediately after all configured currency thresholds are reached. +2. Goals with multiple requirements unlock only when every requirement is satisfied. +3. Unlock state persists after save/load and app restart. +4. No crashes occur when goals file is missing or malformed. +5. Existing generator purchase/production behavior is unchanged for already unlocked generators. + +## Manual Test Cases + +1. Start with a generator configured as locked; verify buy buttons are disabled. +2. Grant required currency via debug controls; verify automatic unlock and enabled buttons. +3. Save/restart; verify generator remains unlocked. +4. Use a multi-currency goal; verify partial progress does not unlock. +5. Corrupt one goal entry in JSON; verify warning is logged and other valid goals still work. diff --git a/generator-unlock-goals/generator_unlock_goals.json b/generator-unlock-goals/generator_unlock_goals.json new file mode 100644 index 0000000..60d8895 --- /dev/null +++ b/generator-unlock-goals/generator_unlock_goals.json @@ -0,0 +1,25 @@ +{ + "version": 1, + "goals": [ + { + "id": "first_goal", + "target_generator_id": "Gems", + "requirements": [ + { + "currency": "gold", + "amount": { "m": 30, "e": 0 } + } + ] + }, + { + "id": "second_goal", + "target_generator_id": "Gems", + "requirements": [ + { + "currency": "gold", + "amount": { "m": 1.3, "e": 3 } + } + ] + } + ] +} diff --git a/generator-unlock-goals/generator_unlock_system.gd b/generator-unlock-goals/generator_unlock_system.gd new file mode 100644 index 0000000..b9c650e --- /dev/null +++ b/generator-unlock-goals/generator_unlock_system.gd @@ -0,0 +1,211 @@ +extends Node + +class UnlockGoalRequirement extends RefCounted: + var currency: GameState.CurrencyType + var amount: BigNumber + + func _init(req_currency: GameState.CurrencyType, req_amount: BigNumber) -> void: + currency = req_currency + amount = req_amount + +class UnlockGoal extends RefCounted: + var id: StringName + var target_generator_id: StringName + var requirements: Array[UnlockGoalRequirement] + + func _init(goal_id: StringName, target_id: StringName, goal_requirements: Array[UnlockGoalRequirement]) -> void: + id = goal_id + target_generator_id = target_id + requirements = goal_requirements + +const GOALS_FILE_PATH: String = "res://generator-unlock-goals/generator_unlock_goals.json" +const SCHEMA_VERSION: int = 1 + +var _goals: Array[UnlockGoal] = [] +var _known_generator_ids: Dictionary = {} +var _warned_unknown_target_ids: Dictionary = {} + +func _ready() -> void: + _collect_known_generator_ids() + _load_goals() + GameState.currency_changed.connect(_on_currency_changed) + _evaluate_all_goals() + +func _on_currency_changed(_changed_currency: GameState.CurrencyType, _new_amount: BigNumber) -> void: + _evaluate_all_goals() + +func _evaluate_all_goals() -> void: + if _goals.is_empty(): + return + + for goal in _goals: + _evaluate_goal(goal) + +func _evaluate_goal(goal: UnlockGoal) -> bool: + if _is_goal_completed(goal): + return false + if not _can_resolve_target(goal.target_generator_id): + return false + + for requirement in goal.requirements: + var total_amount: BigNumber = _get_total_currency_amount(requirement.currency) + if total_amount.is_less_than(requirement.amount): + return false + + GameState.set_generator_unlocked(goal.target_generator_id, true) + GameState.set_generator_available(goal.target_generator_id, true) + print("[UnlockSystem] Goal completed: %s -> %s" % [String(goal.id), String(goal.target_generator_id)]) + return true + +func _is_goal_completed(goal: UnlockGoal) -> bool: + return ( + GameState.is_generator_unlocked(goal.target_generator_id) + and GameState.is_generator_available(goal.target_generator_id) + ) + +func _can_resolve_target(target_generator_id: StringName) -> bool: + var target_key: String = String(target_generator_id) + if target_key.is_empty(): + return false + + if _known_generator_ids.has(target_key) or GameState.generator_states.has(target_key): + return true + + if not _warned_unknown_target_ids.has(target_key): + _warned_unknown_target_ids[target_key] = true + push_warning("Unlock goal target generator not found in scene/state: '%s'" % target_key) + return false + +func _load_goals() -> void: + _goals.clear() + + if not FileAccess.file_exists(GOALS_FILE_PATH): + push_warning("Unlock goals file not found: %s" % GOALS_FILE_PATH) + return + + var file: FileAccess = FileAccess.open(GOALS_FILE_PATH, FileAccess.READ) + if file == null: + push_warning("Unable to open unlock goals file: %s" % GOALS_FILE_PATH) + return + + var parsed: Variant = JSON.parse_string(file.get_as_text()) + if not (parsed is Dictionary): + push_warning("Unlock goals root must be a Dictionary: %s" % GOALS_FILE_PATH) + return + + var root: Dictionary = parsed + var version: int = int(root.get("version", SCHEMA_VERSION)) + if version != SCHEMA_VERSION: + push_warning("Unlock goals schema version %d is different from expected %d" % [version, SCHEMA_VERSION]) + + var raw_goals: Variant = root.get("goals", []) + if not (raw_goals is Array): + push_warning("Unlock goals file has invalid 'goals' field (expected Array)") + return + + var seen_goal_ids: Dictionary = {} + for raw_goal in raw_goals: + var goal: UnlockGoal = _parse_goal(raw_goal) + if goal == null: + continue + + var goal_key: String = String(goal.id) + if seen_goal_ids.has(goal_key): + push_warning("Duplicate unlock goal id skipped: '%s'" % goal_key) + continue + + seen_goal_ids[goal_key] = true + _goals.append(goal) + + if _goals.is_empty(): + push_warning("Unlock goals loaded, but no valid entries were found.") + +func _parse_goal(raw_goal: Variant) -> UnlockGoal: + if not (raw_goal is Dictionary): + push_warning("Skipping unlock goal: entry is not a Dictionary") + return null + + var goal_dict: Dictionary = raw_goal + var goal_id: String = String(goal_dict.get("id", "")).strip_edges() + var target_id: String = String(goal_dict.get("target_generator_id", "")).strip_edges() + + if goal_id.is_empty(): + push_warning("Skipping unlock goal: missing 'id'") + return null + if target_id.is_empty(): + push_warning("Skipping unlock goal '%s': missing 'target_generator_id'" % goal_id) + return null + + var raw_requirements: Variant = goal_dict.get("requirements", []) + if not (raw_requirements is Array): + push_warning("Skipping unlock goal '%s': 'requirements' must be an Array" % goal_id) + return null + + var parsed_requirements: Array[UnlockGoalRequirement] = [] + for raw_requirement in raw_requirements: + var requirement: UnlockGoalRequirement = _parse_requirement(raw_requirement, goal_id) + if requirement != null: + parsed_requirements.append(requirement) + + if parsed_requirements.is_empty(): + push_warning("Skipping unlock goal '%s': no valid requirements" % goal_id) + return null + + return UnlockGoal.new(StringName(goal_id), StringName(target_id), parsed_requirements) + +func _parse_requirement(raw_requirement: Variant, goal_id: String) -> UnlockGoalRequirement: + if not (raw_requirement is Dictionary): + push_warning("Skipping requirement in goal '%s': entry is not a Dictionary" % goal_id) + return null + + var requirement_dict: Dictionary = raw_requirement + var currency_value: int = _parse_currency(requirement_dict.get("currency", "")) + if currency_value < 0: + push_warning("Skipping requirement in goal '%s': unknown currency '%s'" % [goal_id, String(requirement_dict.get("currency", ""))]) + return null + + var raw_amount: Variant = requirement_dict.get("amount", {}) + if not (raw_amount is Dictionary): + push_warning("Skipping requirement in goal '%s': 'amount' must be a Dictionary" % goal_id) + return null + + var amount: BigNumber = BigNumber.deserialize(raw_amount) + if amount.mantissa < 0.0: + push_warning("Skipping requirement in goal '%s': amount must be non-negative" % goal_id) + return null + + return UnlockGoalRequirement.new(currency_value, amount) + +func _parse_currency(raw_currency: Variant) -> int: + if raw_currency is int: + var currency_int: int = raw_currency + if currency_int == GameState.CurrencyType.gold or currency_int == GameState.CurrencyType.gems: + return currency_int + return -1 + + var currency_text: String = String(raw_currency).to_lower().strip_edges() + match currency_text: + "gold": + return GameState.CurrencyType.gold + "gems", "gem": + return GameState.CurrencyType.gems + _: + return -1 + +func _get_total_currency_amount(currency: GameState.CurrencyType) -> BigNumber: + return GameState.get_total_currency_acquired(currency) + +func _collect_known_generator_ids() -> void: + _known_generator_ids.clear() + + var generator_nodes: Array[Node] = find_children("*", "CurrencyGenerator", true, false) + for generator_node in generator_nodes: + var generator: CurrencyGenerator = generator_node as CurrencyGenerator + if generator == null: + continue + + var generator_key: String = String(generator.get_generator_id()) + if generator_key.is_empty(): + continue + + _known_generator_ids[generator_key] = true diff --git a/generator-unlock-goals/generator_unlock_system.gd.uid b/generator-unlock-goals/generator_unlock_system.gd.uid new file mode 100644 index 0000000..1961b94 --- /dev/null +++ b/generator-unlock-goals/generator_unlock_system.gd.uid @@ -0,0 +1 @@ +uid://1sykgtg24a7g diff --git a/generator-unlock-goals/goals_debug_ui.gd b/generator-unlock-goals/goals_debug_ui.gd new file mode 100644 index 0000000..8c78b23 --- /dev/null +++ b/generator-unlock-goals/goals_debug_ui.gd @@ -0,0 +1,273 @@ +extends PanelContainer + +class GoalRequirement extends RefCounted: + var currency: GameState.CurrencyType + var amount: BigNumber + + func _init(req_currency: GameState.CurrencyType, req_amount: BigNumber) -> void: + currency = req_currency + amount = req_amount + +class GoalDefinition extends RefCounted: + var id: StringName + var target_generator_id: StringName + var requirements: Array[GoalRequirement] + + func _init(goal_id: StringName, target_id: StringName, goal_requirements: Array[GoalRequirement]) -> void: + id = goal_id + target_generator_id = target_id + requirements = goal_requirements + +class GoalRow extends RefCounted: + var goal: GoalDefinition + var status_label: Label + var requirements_label: Label + var button: Button + + func _init(goal_definition: GoalDefinition, status: Label, requirements: Label, unlock_button: Button) -> void: + goal = goal_definition + status_label = status + requirements_label = requirements + button = unlock_button + +const GOALS_FILE_PATH: String = "res://generator-unlock-goals/generator_unlock_goals.json" + +@onready var _summary_label: Label = $MarginContainer/VBoxContainer/SummaryLabel +@onready var _goals_list: VBoxContainer = $MarginContainer/VBoxContainer/ScrollContainer/GoalsList + +var _goals: Array[GoalDefinition] = [] +var _rows_by_goal_id: Dictionary = {} +var _completed_goal_ids: Dictionary = {} + +func _ready() -> void: + _load_goals() + _build_goal_rows() + GameState.currency_changed.connect(_on_currency_changed) + GameState.generator_state_changed.connect(_on_generator_state_changed) + _refresh_ui() + _refresh_ui.call_deferred() + +func _on_currency_changed(_currency_type: GameState.CurrencyType, _new_amount: BigNumber) -> void: + _refresh_ui() + +func _on_generator_state_changed(_generator_id: StringName, _state: Dictionary) -> void: + _refresh_ui() + +func _load_goals() -> void: + _goals.clear() + + if not FileAccess.file_exists(GOALS_FILE_PATH): + push_warning("Goals debug UI: goals file not found at %s" % GOALS_FILE_PATH) + return + + var file: FileAccess = FileAccess.open(GOALS_FILE_PATH, FileAccess.READ) + if file == null: + push_warning("Goals debug UI: could not open goals file at %s" % GOALS_FILE_PATH) + return + + var parsed: Variant = JSON.parse_string(file.get_as_text()) + if not (parsed is Dictionary): + push_warning("Goals debug UI: goals root must be a Dictionary") + return + + var goals_raw: Variant = parsed.get("goals", []) + if not (goals_raw is Array): + push_warning("Goals debug UI: goals must be an Array") + return + + var seen_goal_ids: Dictionary = {} + for raw_goal in goals_raw: + var goal: GoalDefinition = _parse_goal(raw_goal) + if goal == null: + continue + + var goal_key: String = String(goal.id) + if seen_goal_ids.has(goal_key): + push_warning("Goals debug UI: duplicate goal id '%s' skipped" % goal_key) + continue + + seen_goal_ids[goal_key] = true + _goals.append(goal) + +func _parse_goal(raw_goal: Variant) -> GoalDefinition: + if not (raw_goal is Dictionary): + return null + + var goal_dict: Dictionary = raw_goal + var goal_id: String = String(goal_dict.get("id", "")).strip_edges() + var target_id: String = String(goal_dict.get("target_generator_id", "")).strip_edges() + if goal_id.is_empty() or target_id.is_empty(): + return null + + var raw_requirements: Variant = goal_dict.get("requirements", []) + if not (raw_requirements is Array): + return null + + var requirements: Array[GoalRequirement] = [] + for raw_requirement in raw_requirements: + var requirement: GoalRequirement = _parse_requirement(raw_requirement) + if requirement != null: + requirements.append(requirement) + + if requirements.is_empty(): + return null + + return GoalDefinition.new(StringName(goal_id), StringName(target_id), requirements) + +func _parse_requirement(raw_requirement: Variant) -> GoalRequirement: + if not (raw_requirement is Dictionary): + return null + + var requirement_dict: Dictionary = raw_requirement + var currency_value: int = _parse_currency(requirement_dict.get("currency", "")) + if currency_value < 0: + return null + + var raw_amount: Variant = requirement_dict.get("amount", {}) + if not (raw_amount is Dictionary): + return null + + var amount: BigNumber = BigNumber.deserialize(raw_amount) + if amount.mantissa < 0.0: + return null + + return GoalRequirement.new(currency_value, amount) + +func _parse_currency(raw_currency: Variant) -> int: + if raw_currency is int: + var currency_int: int = raw_currency + if currency_int == GameState.CurrencyType.gold or currency_int == GameState.CurrencyType.gems: + return currency_int + return -1 + + var currency_text: String = String(raw_currency).to_lower().strip_edges() + match currency_text: + "gold": + return GameState.CurrencyType.gold + "gems", "gem": + return GameState.CurrencyType.gems + _: + return -1 + +func _build_goal_rows() -> void: + _rows_by_goal_id.clear() + for child in _goals_list.get_children(): + child.queue_free() + + for goal in _goals: + var row_container: HBoxContainer = HBoxContainer.new() + row_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row_container.add_theme_constant_override("separation", 12) + + var title_label: Label = Label.new() + title_label.custom_minimum_size = Vector2(220.0, 0.0) + title_label.text = "%s -> %s" % [String(goal.id), String(goal.target_generator_id)] + + var status_label: Label = Label.new() + status_label.custom_minimum_size = Vector2(95.0, 0.0) + + var requirements_label: Label = Label.new() + requirements_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + + var unlock_button: Button = Button.new() + unlock_button.custom_minimum_size = Vector2(90.0, 0.0) + unlock_button.text = "Unlock" + unlock_button.disabled = true + unlock_button.pressed.connect(_on_unlock_pressed.bind(goal.id)) + + row_container.add_child(title_label) + row_container.add_child(status_label) + row_container.add_child(requirements_label) + row_container.add_child(unlock_button) + _goals_list.add_child(row_container) + + _rows_by_goal_id[String(goal.id)] = GoalRow.new(goal, status_label, requirements_label, unlock_button) + +func _refresh_ui() -> void: + if _goals.is_empty(): + _summary_label.text = "No valid goals loaded" + return + + var ready_to_unlock: int = 0 + var unlocked: int = 0 + for goal in _goals: + var row: GoalRow = _rows_by_goal_id.get(String(goal.id)) + if row == null: + continue + _refresh_goal_row(row) + + if _is_goal_completed(goal.id): + unlocked += 1 + elif _is_goal_met(goal): + ready_to_unlock += 1 + + _summary_label.text = "Goals: %d | Ready: %d | Unlocked: %d" % [_goals.size(), ready_to_unlock, unlocked] + +func _refresh_goal_row(row: GoalRow) -> void: + var goal: GoalDefinition = row.goal + var already_unlocked: bool = _is_goal_completed(goal.id) + var met: bool = _is_goal_met(goal) + var can_unlock: bool = met and not already_unlocked + + row.requirements_label.text = _get_requirements_text(goal) + row.button.disabled = not can_unlock + row.button.text = "Done" if already_unlocked else "Unlock" + + if already_unlocked: + row.status_label.text = "Unlocked" + return + + if met: + row.status_label.text = "Ready" + return + + row.status_label.text = "Locked" + +func _is_goal_met(goal: GoalDefinition) -> bool: + for requirement in goal.requirements: + var total_amount: BigNumber = GameState.get_total_currency_acquired(requirement.currency) + if total_amount.is_less_than(requirement.amount): + return false + return true + +func _is_goal_completed(goal_id: StringName) -> bool: + return _completed_goal_ids.get(String(goal_id), false) + +func _on_unlock_pressed(goal_id: StringName) -> void: + var row: GoalRow = _rows_by_goal_id.get(String(goal_id)) + if row == null: + return + + var goal: GoalDefinition = row.goal + if _is_goal_completed(goal.id): + return + if not _is_goal_met(goal): + return + + _completed_goal_ids[String(goal.id)] = true + GameState.set_generator_unlocked(goal.target_generator_id, true) + GameState.set_generator_available(goal.target_generator_id, true) + print("[GoalsDebugUI] Unlocked goal '%s' -> %s" % [String(goal.id), String(goal.target_generator_id)]) + _refresh_ui() + +func _get_requirements_text(goal: GoalDefinition) -> String: + var parts: Array[String] = [] + for requirement in goal.requirements: + var total_amount: BigNumber = GameState.get_total_currency_acquired(requirement.currency) + parts.append( + "%s %s / %s" % [ + _currency_label(requirement.currency), + total_amount.to_string_suffix(2), + requirement.amount.to_string_suffix(2) + ] + ) + return " | ".join(parts) + +func _currency_label(currency: GameState.CurrencyType) -> String: + match currency: + GameState.CurrencyType.gold: + return "Gold" + GameState.CurrencyType.gems: + return "Gems" + _: + return "Unknown" diff --git a/generator-unlock-goals/goals_debug_ui.gd.uid b/generator-unlock-goals/goals_debug_ui.gd.uid new file mode 100644 index 0000000..59b6319 --- /dev/null +++ b/generator-unlock-goals/goals_debug_ui.gd.uid @@ -0,0 +1 @@ +uid://bmrbaulftvvwm diff --git a/generator-unlock-goals/goals_debug_ui.tscn b/generator-unlock-goals/goals_debug_ui.tscn new file mode 100644 index 0000000..dfe28fe --- /dev/null +++ b/generator-unlock-goals/goals_debug_ui.tscn @@ -0,0 +1,45 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://generator-unlock-goals/goals_debug_ui.gd" id="1_s7s8v"] + +[node name="GoalsDebugUI" type="PanelContainer"] +offset_right = 640.0 +offset_bottom = 260.0 +script = ExtResource("1_s7s8v") + +[node name="MarginContainer" type="MarginContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/margin_left = 8 +theme_override_constants/margin_top = 8 +theme_override_constants/margin_right = 8 +theme_override_constants/margin_bottom = 8 + +[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +theme_override_constants/separation = 6 + +[node name="TitleLabel" type="Label" parent="MarginContainer/VBoxContainer"] +layout_mode = 2 +text = "Goals Debug" + +[node name="SummaryLabel" type="Label" parent="MarginContainer/VBoxContainer"] +layout_mode = 2 +text = "Goals: 0 | Ready: 0 | Unlocked: 0" + +[node name="ScrollContainer" type="ScrollContainer" parent="MarginContainer/VBoxContainer"] +custom_minimum_size = Vector2(0, 170) +layout_mode = 2 +size_flags_vertical = 3 + +[node name="GoalsList" type="VBoxContainer" parent="MarginContainer/VBoxContainer/ScrollContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +theme_override_constants/separation = 6 diff --git a/generator/primary_generator.tres b/generator/primary_generator.tres index 95a8eeb..e96e49b 100644 --- a/generator/primary_generator.tres +++ b/generator/primary_generator.tres @@ -8,5 +8,4 @@ id = &"Gold" name = "Gold Mine" initial_time = 0.6 initial_productivity = 1.67 -grants_click_while_hovering = true metadata/_custom_type_script = "uid://b00tqsuhxdy0d" diff --git a/generator/secondary_generator.tres b/generator/secondary_generator.tres index f4c7a05..b2da738 100644 --- a/generator/secondary_generator.tres +++ b/generator/secondary_generator.tres @@ -6,6 +6,8 @@ script = ExtResource("1_s3g28") id = &"Gems" name = "Gems Mine" +starts_unlocked = false +starts_available = false initial_cost = 60.0 initial_time = 3.0 initial_revenue = 60.0 diff --git a/generator_museum.tscn b/generator_museum.tscn index 79c4695..f400018 100644 --- a/generator_museum.tscn +++ b/generator_museum.tscn @@ -6,6 +6,7 @@ [ext_resource type="Texture2D" uid="uid://bgtt3wu43tajh" path="res://icon.svg" id="3_mhw8n"] [ext_resource type="PackedScene" uid="uid://ckos7f22pnmyh" path="res://generator_container.tscn" id="3_pw2a0"] [ext_resource type="PackedScene" uid="uid://btkxru2gdjsgc" path="res://currency_tile.tscn" id="3_x5lt1"] +[ext_resource type="PackedScene" path="res://generator-unlock-goals/goals_debug_ui.tscn" id="4_63gjq"] [sub_resource type="RectangleShape2D" id="RectangleShape2D_y5m1q"] size = Vector2(128, 128) @@ -16,6 +17,7 @@ size = Vector2(128, 128) position = Vector2(566, 455) script = ExtResource("1_pl17p") data = ExtResource("2_x5lt1") +grants_click_while_hovering = true [node name="Sprite2D" type="Sprite2D" parent="GoldGenerator" unique_id=381586216] texture = ExtResource("3_mhw8n") @@ -29,6 +31,8 @@ shape = SubResource("RectangleShape2D_y5m1q") script = ExtResource("1_pl17p") currency = 1 data = ExtResource("3_dc82g") +starts_unlocked = false +starts_available = false [node name="UI" type="Control" parent="." unique_id=452530906] layout_mode = 3 @@ -66,5 +70,12 @@ offset_right = 247.0 offset_bottom = 380.0 _generator = NodePath("../../GemsGenerator") +[node name="GoalsDebugUI" parent="UI" unique_id=4710697 instance=ExtResource("4_63gjq")] +layout_mode = 1 +offset_left = 577.0 +offset_top = 3.0 +offset_right = 1150.0 +offset_bottom = 262.0 + [connection signal="mouse_entered" from="GoldGenerator/Area2D" to="GoldGenerator" method="_on_area_2d_mouse_entered"] [connection signal="mouse_exited" from="GoldGenerator/Area2D" to="GoldGenerator" method="_on_area_2d_mouse_exited"] diff --git a/project.godot b/project.godot index 08d72b0..6427075 100644 --- a/project.godot +++ b/project.godot @@ -11,6 +11,7 @@ config_version=5 [application] config/name="Idles" +config/version="0.0.1" config/features=PackedStringArray("4.6", "Forward Plus") config/icon="res://icon.svg"