diff --git a/README.md b/README.md index 25ccb0a..188cc30 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ The focus below is on behavior-bearing files and how they interact. 2. Two autoload services power the runtime: `CurrencyDatabase` and `GameState`. 3. `BigNumber` is the numeric backbone used by currency amounts, costs, goals, production, and persistence. 4. Generator behavior is runtime-driven by `CurrencyGenerator`, parameterized by `CurrencyGeneratorData` and `GeneratorBuffData` resources. -5. Goal-based progression uses reusable goal primitives (`GoalData`, `GoalRequirementData`) plus unlock bindings (`GeneratorUnlockGoalData`) and runtime evaluator (`generator_unlock_system.gd`). +5. Goal-based progression uses reusable goal primitives (`GoalData`, `GoalRequirementData`) directly on generator data (`CurrencyGeneratorData.unlock_goal`). 6. UI scripts are thin adapters listening to `GameState` signals. ## Core Systems @@ -102,6 +102,7 @@ Files: `core/generator/currency_generator.gd`, `core/generator/currency_generato 5. Purchased-count multiplier. 6. Production/cycle and production/second helpers. 7. ROI-like helpers (`payback_seconds`, `income_to_cost_ratio`). +8. Optional generator unlock goal (`unlock_goal`). ### 5) Buff System @@ -126,16 +127,16 @@ Files: 1. `core/goals/goal_requirement_data.gd` 2. `core/goals/goal_data.gd` -3. `core/generator-unlock-goals/generator_unlock_goal_data.gd` -4. `core/generator-unlock-goals/generator_unlock_system.gd` +3. `core/generator/currency_generator_data.gd` +4. `core/generator/currency_generator.gd` 5. `idles/goals/*.tres` Key behavior: 1. Goal requirements validate currency + target amount. 2. Requirement completion checks **total acquired currency**, not current wallet. -3. Unlock system evaluates goals on currency change and generator state changes. -4. If a goal is met and target generator is resolvable, it sets both `unlocked = true` and `available = true`. +3. Each `CurrencyGenerator` evaluates its own `data.unlock_goal` on startup and on `currency_changed`. +4. If the goal is met, it sets both `unlocked = true` and `available = true`, then emits `goal_achieved(generator_id, goal_id)`. ### 7) UI Adapter Layer @@ -167,7 +168,6 @@ It composes: 2. `KnowledgeGenerator` instance (initially hidden and starts locked via data). 3. Currency tiles for `magic` and `knowledge`. 4. Goals debug UI panel. -5. Runtime `GeneratorUnlockSystem` node with configured unlock goals. ### Legacy/Secondary Museum @@ -180,7 +180,7 @@ This appears to be an older/placeholder scene and not the current gameplay focus ### Generators 1. `primary_generator.tres`: `Magic Orb`, active from start, includes 3 buffs. -2. `secondary_generator.tres`: `Library`, starts locked/unavailable. +2. `secondary_generator.tres`: `Library`, starts locked/unavailable and unlocks via `magic_total_30` goal. ### Buffs (Magic) @@ -192,8 +192,6 @@ This appears to be an older/placeholder scene and not the current gameplay focus 1. `magic_total_30.tres`: reach total magic 30. 2. `magic_total_1300.tres`: reach total magic 1300. -3. `generator_unlock_knowledge.tres`: maps `magic_total_30` to generator `Knowledge`. -4. `generator_unlock_wood.tres`: maps `magic_total_1300` to generator `Wood`. ## End-to-End Runtime Flow @@ -212,8 +210,8 @@ This appears to be an older/placeholder scene and not the current gameplay focus | Signal | Declared In | Emitted By | Emitted When | Listeners | | --- | --- | --- | --- | --- | -| `currency_changed(currency_id, new_amount)` | `core/game_state.gd` | `GameState.add_currency_by_id`, `GameState.spend_currency_by_id` | Any currency balance increases/decreases | `CurrencyGenerator._on_currency_changed`, `GeneratorPanel._on_currency_changed`, `CurrencyTile._on_currency_changed`, `BigNumberProgressBar._on_currency_changed`, `GeneratorUnlockSystem._on_currency_changed`, `GoalsDebugUI._on_currency_changed` | -| `generator_state_changed(generator_id, state)` | `core/game_state.gd` | `GameState.register_generator`, `GameState._set_generator_state` | Generator state created or changed (`owned`, `purchased_count`, `unlocked`, `available`) | `CurrencyGenerator._on_generated_state_changed`, `GeneratorPanel._on_generator_state_changed`, `GeneratorUnlockSystem._on_generator_state_changed`, `GoalsDebugUI._on_generator_state_changed` | +| `currency_changed(currency_id, new_amount)` | `core/game_state.gd` | `GameState.add_currency_by_id`, `GameState.spend_currency_by_id` | Any currency balance increases/decreases | `CurrencyGenerator._on_currency_changed`, `GeneratorPanel._on_currency_changed`, `CurrencyTile._on_currency_changed`, `BigNumberProgressBar._on_currency_changed`, `GoalsDebugUI._on_currency_changed` | +| `generator_state_changed(generator_id, state)` | `core/game_state.gd` | `GameState.register_generator`, `GameState._set_generator_state` | Generator state created or changed (`owned`, `purchased_count`, `unlocked`, `available`) | `CurrencyGenerator._on_generated_state_changed`, `GeneratorPanel._on_generator_state_changed`, `GoalsDebugUI._on_generator_state_changed` | | `generator_buff_level_changed(generator_id, buff_id, new_level)` | `core/game_state.gd` | `GameState.register_generator_buff`, `GameState.set_generator_buff_level` | Buff level created/sanitized/updated | `GeneratorPanel._on_generator_buff_level_changed` | | `generator_buff_unlocked_changed(generator_id, buff_id, unlocked)` | `core/game_state.gd` | `GameState.register_generator_buff_unlocked`, `GameState.set_generator_buff_unlocked` | Buff unlock state created/sanitized/updated | `GeneratorPanel._on_generator_buff_unlocked_changed` | | `purchase_completed(amount, total_owned, total_purchased, total_cost)` | `core/generator/currency_generator.gd` | `CurrencyGenerator.buy` | Generator purchase succeeds | `GeneratorPanel._on_generator_updated` | @@ -221,7 +219,7 @@ This appears to be an older/placeholder scene and not the current gameplay focus | `production_tick(amount, cycle_count, total_owned)` | `core/generator/currency_generator.gd` | `CurrencyGenerator._grant_cycle_income` | One or more automatic production cycles complete | `GeneratorPanel._on_generator_updated` | | `buff_purchased(buff_id, new_level, cost, cost_currency_id)` | `core/generator/currency_generator.gd` | `CurrencyGenerator.buy_buff` | Buff purchase succeeds | `GeneratorPanel._on_generator_updated` | | `buff_purchase_failed(buff_id, required_cost, cost_currency_id)` | `core/generator/currency_generator.gd` | `CurrencyGenerator.buy_buff` | Buff purchase fails for insufficient funds | `GeneratorPanel._on_generator_updated` | -| `goal_achieved(goal_id, target_generator_id)` | `core/generator-unlock-goals/generator_unlock_system.gd` | `GeneratorUnlockSystem._evaluate_goal` | Unlock goal transitions target generator to unlocked+available | None currently (available for gameplay/UI hooks) | +| `goal_achieved(generator_id, goal_id)` | `core/generator/currency_generator.gd` | `CurrencyGenerator._evaluate_generator_unlock_goal` | Generator unlock goal transitions that generator to unlocked+available | None currently (available for gameplay/UI hooks) | | `buy_pressed(buff_id)` | `generator_buff_tile.gd` | `GeneratorBuffTile._on_buy_button_pressed` | User presses buff buy button in a buff row | `GeneratorPanel._on_buy_buff_pressed` | ### Built-In Godot Signals Wired In This Project @@ -243,7 +241,7 @@ This appears to be an older/placeholder scene and not the current gameplay focus 1. Currency gain/spend chain: generator or debug action mutates `GameState` currency, `GameState` emits `currency_changed`, UI widgets and unlock systems recompute and redraw. 2. Generator purchase chain: panel button triggers `CurrencyGenerator.buy`, generator updates `GameState` state, generator emits purchase signal, panel refreshes stats and buttons. 3. Buff purchase chain: buff tile emits `buy_pressed`, panel calls `CurrencyGenerator.buy_buff`, generator updates buff level/unlock and optionally grants currency, `GameState` emits buff/currency/state signals, panel and other listeners refresh. -4. Goal unlock chain: `currency_changed` triggers unlock evaluator, evaluator sets generator `unlocked/available`, emits `goal_achieved(goal_id, target_generator_id)`, `GameState` emits `generator_state_changed`, generator and UI become interactable/visible. +4. Goal unlock chain: `currency_changed` triggers generator-local unlock evaluator, evaluator sets generator `unlocked/available`, emits `goal_achieved(generator_id, goal_id)`, `GameState` emits `generator_state_changed`, generator and UI become interactable/visible. ## Key Findings and Risks @@ -251,7 +249,7 @@ This appears to be an older/placeholder scene and not the current gameplay focus 2. Save/load is only partially wired: `load_game()` runs at startup, but `save_game()` is not invoked anywhere else in the repository. 3. `currency_label.gd` and `big_number_progress_bar.gd` reference `GameState.GOLD_CURRENCY_ID`, but that constant does not exist in `core/game_state.gd`. 4. `generator_container.tscn` connects `mouse_entered`/`mouse_exited` to methods that do not exist in `generator_container.gd`. -5. `generator_unlock_wood.tres` targets generator ID `Wood`, but no generator with that ID appears in current runtime scene/content, so this goal is currently non-functional. +5. Generator unlock goals now assume the target generator exists in-scene and owns its own `unlock_goal`. 6. `KnowledgeGenerator` visibility logic may be inconsistent because `currency_generator.gd` sets `visible = true` whenever its generator state changes. 7. Two buffs are configured locked with no unlock-goal path, so they remain permanently inaccessible under current logic. 8. `core/generator-unlock-goals/TECH_SPEC.md` contains some outdated assumptions relative to current `.tres`-based implementation. diff --git a/core/generator-unlock-goals/TECH_SPEC.md b/core/generator-unlock-goals/TECH_SPEC.md index 4db3469..ee8d4d8 100644 --- a/core/generator-unlock-goals/TECH_SPEC.md +++ b/core/generator-unlock-goals/TECH_SPEC.md @@ -1,186 +1,58 @@ -# Goal-Based Generator Unlocks - Technical Specification +# Generator-Embedded Unlock Goals - 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`. +- Version: 2.0 +- Date: 2026-03-21 +- Scope: Unlock generators from `CurrencyGeneratorData.unlock_goal` when goal thresholds are reached. ## 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. +Generator unlock progression should be authored where generator behavior lives: `CurrencyGeneratorData`. +The previous scene-level target mapping layer increased setup complexity and created mismatch risk between goal targets and scene content. ## 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. +1. Generator state persists in `GameState.generator_states` (`owned`, `purchased_count`, `unlocked`, `available`). +2. `CurrencyGenerator` already gates interactions using `is_available_to_player()`. +3. Goal primitives remain reusable and data-driven: + - `GoalRequirementData` + - `GoalData` +4. Generator data now owns unlock definition via `unlock_goal: GoalData`. ## Functional Requirements +1. A generator may define one optional unlock goal (`CurrencyGeneratorData.unlock_goal`). +2. Goal completion uses logical AND across requirements. +3. Requirement checks use total acquired currency (`GameState.get_total_currency_acquired_by_id`). +4. Unlock evaluation runs: + - once on generator `_ready()` + - on every `GameState.currency_changed` +5. When a goal is met, runtime sets: + - `GameState.set_generator_unlocked(generator_id, true)` + - `GameState.set_generator_available(generator_id, true)` +6. Unlock transitions are idempotent. +7. Unlock completion emits `CurrencyGenerator.goal_achieved(generator_id, goal_id)` for hooks/UI. -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. +## Runtime Design +1. `CurrencyGeneratorData` exposes: + - `has_unlock_goal()` + - `is_unlock_goal_met()` + - `get_unlock_goal_id()` +2. `CurrencyGenerator` executes `_evaluate_generator_unlock_goal()`. +3. No scene-level unlock controller is required. +4. Goals debug UI discovers goals from scene generators instead of external target-mapping resources. -## Data Model - -## New Config Resources - -Goals are now authored as typed resources instead of JSON: -1. `res://core/goals/goal_requirement_data.gd` (`GoalRequirementData`) -2. `res://core/goals/goal_data.gd` (`GoalData`) -3. `res://core/generator-unlock-goals/generator_unlock_goal_data.gd` (`GeneratorUnlockGoalData`) - -Example assets: -1. `res://idles/goals/magic_total_30.tres` (goal definition) -2. `res://idles/goals/generator_unlock_knowledge.tres` (goal target binding) - -Notes: -1. `GoalData.id` must be unique and stable. -2. `GoalData.requirements` must contain at least one valid `GoalRequirementData`. -3. `GoalRequirementData` stores amount as mantissa/exponent to preserve `BigNumber` semantics. - -## Runtime Structures (GDScript) - -Runtime data comes from resource classes: - -1. `GoalRequirementData` - - `currency: Resource` - - `amount_mantissa: float` - - `amount_exponent: int` -2. `GoalData` - - `id: StringName` - - `requirements: Array[Resource]` (validated as `GoalRequirementData`) -3. `GeneratorUnlockGoalData` - - `target_generator_id: StringName` - - `goal: Resource` (validated as `GoalData`) - -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 exported `GeneratorUnlockGoalData` resources. -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. +## Data Authoring Rules +1. Configure locked generators with `starts_unlocked = false` and `starts_available = false`. +2. Set `unlock_goal` directly on that generator `.tres` resource. +3. Keep `GoalData.id` unique and stable. +4. Goal data can still be shared across systems (generator unlocks, buff unlocks). ## Save/Load Behavior +1. Unlock persistence remains unchanged because `GameState` stores `unlocked/available`. +2. Re-evaluation after load is safe due to idempotent state writes. -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 `GoalData` and `GeneratorUnlockGoalData` resources 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. +## Validation +1. Run headless parse: `"$GODOT_BIN" --headless --path . --quit` +2. Manual smoke test in `generator_museum`: + - verify locked generator starts unavailable + - grant required currency + - verify generator unlocks automatically + - verify state persists after restart diff --git a/core/generator-unlock-goals/generator_unlock_goal_data.gd b/core/generator-unlock-goals/generator_unlock_goal_data.gd deleted file mode 100644 index 1fbcfcc..0000000 --- a/core/generator-unlock-goals/generator_unlock_goal_data.gd +++ /dev/null @@ -1,11 +0,0 @@ -class_name GeneratorUnlockGoalData -extends Resource - -@export var target_generator_id: StringName = &"" -@export var goal: GoalData - -func get_goal_data() -> GoalData: - if goal == null: - return null - - return goal diff --git a/core/generator-unlock-goals/generator_unlock_goal_data.gd.uid b/core/generator-unlock-goals/generator_unlock_goal_data.gd.uid deleted file mode 100644 index ac4d505..0000000 --- a/core/generator-unlock-goals/generator_unlock_goal_data.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dt4df3gwr6n5m diff --git a/core/generator-unlock-goals/generator_unlock_system.gd b/core/generator-unlock-goals/generator_unlock_system.gd deleted file mode 100644 index 813c975..0000000 --- a/core/generator-unlock-goals/generator_unlock_system.gd +++ /dev/null @@ -1,151 +0,0 @@ -extends Node - -signal goal_achieved(goal_id: StringName, target_generator_id: StringName) - -class GeneratorUnlockGoal extends RefCounted: - var target_generator_id: StringName - var goal: GoalData - - func _init(target_id: StringName, goal_data: GoalData) -> void: - target_generator_id = target_id - goal = goal_data - -@export var goals: Array[GeneratorUnlockGoalData] = [] - -var _runtime_goals: Array[GeneratorUnlockGoal] = [] -var _known_generator_ids: Dictionary = {} -var _warned_unknown_target_ids: Dictionary = {} -var _is_evaluating_goals: bool = false -var _pending_goal_evaluation: bool = false - -func _ready() -> void: - _collect_known_generator_ids() - _load_goals() - GameState.currency_changed.connect(_on_currency_changed) - GameState.generator_state_changed.connect(_on_generator_state_changed) - _evaluate_all_goals() - _evaluate_all_goals.call_deferred() - -func _on_currency_changed(_changed_currency_id: StringName, _new_amount: BigNumber) -> void: - _evaluate_all_goals() - -func _on_generator_state_changed(generator_id: StringName, _state: Dictionary) -> void: - var generator_key: String = String(generator_id) - if not generator_key.is_empty(): - _known_generator_ids[generator_key] = true - _evaluate_all_goals() - -func _evaluate_all_goals() -> void: - if _runtime_goals.is_empty(): - return - if _is_evaluating_goals: - _pending_goal_evaluation = true - return - - _is_evaluating_goals = true - - for runtime_goal in _runtime_goals: - _evaluate_goal(runtime_goal) - - _is_evaluating_goals = false - if _pending_goal_evaluation: - _pending_goal_evaluation = false - _evaluate_all_goals.call_deferred() - -func _evaluate_goal(runtime_goal: GeneratorUnlockGoal) -> bool: - if runtime_goal == null: - return false - if runtime_goal.goal == null: - return false - if _is_goal_completed(runtime_goal): - return false - if not _can_resolve_target(runtime_goal.target_generator_id): - return false - if not bool(runtime_goal.goal.is_met()): - return false - - var goal_id: StringName = _extract_goal_id(runtime_goal.goal) - GameState.set_generator_unlocked(runtime_goal.target_generator_id, true) - GameState.set_generator_available(runtime_goal.target_generator_id, true) - goal_achieved.emit(goal_id, runtime_goal.target_generator_id) - print("[UnlockSystem] Goal completed: %s -> %s" % [String(goal_id), String(runtime_goal.target_generator_id)]) - return true - -func _extract_goal_id(goal: GoalData) -> StringName: - if goal == null: - return &"" - - var goal_id_text: String = String(goal.id).strip_edges() - if goal_id_text.is_empty(): - return &"" - - return StringName(goal_id_text) - -func _is_goal_completed(runtime_goal: GeneratorUnlockGoal) -> bool: - return ( - GameState.is_generator_unlocked(runtime_goal.target_generator_id) - and GameState.is_generator_available(runtime_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: - _runtime_goals.clear() - - var seen_goal_ids: Dictionary = {} - for generator_unlock_goal_data in goals: - if generator_unlock_goal_data == null: - continue - var goal_data: GeneratorUnlockGoalData = generator_unlock_goal_data - if goal_data == null: - continue - var target_generator_id: String = String(goal_data.target_generator_id).strip_edges() - if target_generator_id.is_empty(): - push_warning("Skipping unlock goal resource: missing target_generator_id") - continue - var resolved_goal: GoalData = generator_unlock_goal_data.get_goal_data() - if resolved_goal == null: - push_warning("Skipping unlock goal for target '%s': goal is null" % target_generator_id) - continue - if not bool(resolved_goal.is_valid()): - push_warning("Skipping unlock goal for target '%s': goal is invalid" % target_generator_id) - continue - - var goal_key: String = String(resolved_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 - _runtime_goals.append(GeneratorUnlockGoal.new(StringName(target_generator_id), resolved_goal)) - - if _runtime_goals.is_empty(): - push_warning("Unlock goals loaded, but no valid entries were found.") - -func _collect_known_generator_ids() -> void: - _known_generator_ids.clear() - - var scene_root: Node = get_tree().current_scene - var search_root: Node = scene_root if scene_root != null else self - var generator_nodes: Array[Node] = search_root.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/core/generator-unlock-goals/generator_unlock_system.gd.uid b/core/generator-unlock-goals/generator_unlock_system.gd.uid deleted file mode 100644 index 1961b94..0000000 --- a/core/generator-unlock-goals/generator_unlock_system.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://1sykgtg24a7g diff --git a/core/generator/currency_generator.gd b/core/generator/currency_generator.gd index 2d2124d..9ca971f 100644 --- a/core/generator/currency_generator.gd +++ b/core/generator/currency_generator.gd @@ -17,6 +17,8 @@ signal production_tick(amount: BigNumber, cycle_count: int, total_owned: int) signal buff_purchased(buff_id: StringName, new_level: int, cost: BigNumber, cost_currency_id: StringName) ## Emitted when a buff purchase cannot be paid. signal buff_purchase_failed(buff_id: StringName, required_cost: BigNumber, cost_currency_id: StringName) +## Emitted when this generator unlock goal transitions to completed. +signal goal_achieved(generator_id: StringName, goal_id: StringName) ## Sentinel exponent used when cost math overflows float range. const HUGE_COST_EXPONENT: int = 1000000 @@ -97,6 +99,7 @@ func _ready() -> void: GameState.currency_changed.connect(_on_currency_changed) GameState.generator_state_changed.connect(_on_generated_state_changed) + _evaluate_generator_unlock_goal(false) ## Updates click cooldown/click grants and runs automatic production cycles. func _process(delta: float) -> void: @@ -542,8 +545,29 @@ func _try_unlock_buff_from_goal(buff: GeneratorBuffData) -> void: GameState.set_generator_buff_unlocked(_generator_id, buff_id, true) func _on_currency_changed(_changed_currency_id: StringName, _new_amount: BigNumber) -> void: + _evaluate_generator_unlock_goal(false) _evaluate_buff_unlock_goals() +func try_unlock_from_goal() -> bool: + return _evaluate_generator_unlock_goal(true) + +func _evaluate_generator_unlock_goal(allow_manual_unlock: bool) -> bool: + if data == null: + return false + if is_available_to_player(): + return false + if not data.has_unlock_goal(): + return false + if not data.unlocks_automatically_from_goal() and not allow_manual_unlock: + return false + if not data.is_unlock_goal_met(): + return false + + GameState.set_generator_unlocked(_generator_id, true) + GameState.set_generator_available(_generator_id, true) + goal_achieved.emit(_generator_id, data.get_unlock_goal_id()) + return true + ## Default unlocked state loaded from generator data. func _default_unlocked_state() -> bool: if data == null: diff --git a/core/generator/currency_generator_data.gd b/core/generator/currency_generator_data.gd index 14549b9..aa67f3d 100644 --- a/core/generator/currency_generator_data.gd +++ b/core/generator/currency_generator_data.gd @@ -1,10 +1,17 @@ class_name CurrencyGeneratorData extends Resource +enum UnlockGoalBehavior { + AUTOMATIC, + MANUAL_BUTTON, +} + @export var id: StringName = &"" @export var name: String = "" @export var starts_unlocked: bool = true @export var starts_available: bool = true +@export var unlock_goal: GoalData +@export var unlock_goal_behavior: UnlockGoalBehavior = UnlockGoalBehavior.AUTOMATIC ## Base cost and exponential growth (part I): cost_next = b * r^owned @export var initial_cost: float = 10.0 @@ -31,6 +38,31 @@ extends Resource ## Data-driven buffs purchasable for this generator. @export var buffs: Array[GeneratorBuffData] = [] +func has_unlock_goal() -> bool: + if unlock_goal == null: + return false + + return bool(unlock_goal.is_valid()) + +func is_unlock_goal_met() -> bool: + if not has_unlock_goal(): + return false + + return bool(unlock_goal.is_met()) + +func get_unlock_goal_id() -> StringName: + if not has_unlock_goal(): + return &"" + + var goal_id_text: String = String(unlock_goal.id).strip_edges() + if goal_id_text.is_empty(): + return &"" + + return StringName(goal_id_text) + +func unlocks_automatically_from_goal() -> bool: + return unlock_goal_behavior == UnlockGoalBehavior.AUTOMATIC + func find_buff(buff_id: StringName) -> GeneratorBuffData: var expected_id: String = String(buff_id).strip_edges() if expected_id.is_empty(): diff --git a/docs/museums/generator_museum.tscn b/docs/museums/generator_museum.tscn index efcbcba..5dd7674 100644 --- a/docs/museums/generator_museum.tscn +++ b/docs/museums/generator_museum.tscn @@ -5,11 +5,7 @@ [ext_resource type="Resource" uid="uid://04pmc034qupd" path="res://idles/generators/secondary_generator.tres" id="3_4ly0e"] [ext_resource type="PackedScene" uid="uid://btkxru2gdjsgc" path="res://currency_tile.tscn" id="4_6ri4a"] [ext_resource type="Resource" uid="uid://brqaojindcxa5" path="res://idles/currencies/magic.tres" id="5_dl3gy"] -[ext_resource type="PackedScene" uid="uid://dirvi76rkoowf" path="res://core/generator-unlock-goals/goals_debug_ui.tscn" id="7_7i63j"] -[ext_resource type="Script" uid="uid://dt4df3gwr6n5m" path="res://core/generator-unlock-goals/generator_unlock_goal_data.gd" id="8_6ri4a"] -[ext_resource type="Script" uid="uid://1sykgtg24a7g" path="res://core/generator-unlock-goals/generator_unlock_system.gd" id="8_bhig6"] -[ext_resource type="Resource" uid="uid://mnuihnwb70ba" path="res://idles/goals/generator_unlock_knowledge.tres" id="9_l765j"] -[ext_resource type="Resource" uid="uid://8wrqdjewfdq5" path="res://idles/goals/generator_unlock_wood.tres" id="10_x0wp6"] +[ext_resource type="PackedScene" uid="uid://dirvi76rkoowf" path="res://goals_debug_ui.tscn" id="7_7i63j"] [node name="GeneratorMuseum" type="Node2D" unique_id=1219373683] @@ -47,7 +43,3 @@ offset_left = 1253.0 offset_top = 817.0 offset_right = 1913.0 offset_bottom = 1076.0 - -[node name="GeneratorUnlockSystem" type="Node" parent="." unique_id=1909967402] -script = ExtResource("8_bhig6") -goals = Array[ExtResource("8_6ri4a")]([ExtResource("9_l765j"), ExtResource("10_x0wp6")]) diff --git a/core/generator-unlock-goals/goals_debug_ui.gd b/goals_debug_ui.gd similarity index 69% rename from core/generator-unlock-goals/goals_debug_ui.gd rename to goals_debug_ui.gd index bd6ea09..6cfa94e 100644 --- a/core/generator-unlock-goals/goals_debug_ui.gd +++ b/goals_debug_ui.gd @@ -2,15 +2,14 @@ extends PanelContainer class GoalDefinition extends RefCounted: var target_generator_id: StringName - var goal: Resource + var goal: GoalData - func _init(target_id: StringName, goal_data: Resource) -> void: + func _init(target_id: StringName, goal_data: GoalData) -> void: target_generator_id = target_id goal = goal_data const GOAL_DATA_SCRIPT: Script = preload("res://core/goals/goal_data.gd") const GOAL_REQUIREMENT_SCRIPT: Script = preload("res://core/goals/goal_requirement_data.gd") -const GENERATOR_UNLOCK_GOAL_DATA_SCRIPT: Script = preload("res://core/generator-unlock-goals/generator_unlock_goal_data.gd") class GoalRow extends RefCounted: var goal: GoalDefinition @@ -24,8 +23,6 @@ class GoalRow extends RefCounted: requirements_label = requirements button = unlock_button -@export var goals: Array[GeneratorUnlockGoalData] = [] - @onready var _summary_label: Label = $MarginContainer/VBoxContainer/SummaryLabel @onready var _goals_list: VBoxContainer = $MarginContainer/VBoxContainer/ScrollContainer/GoalsList @@ -33,6 +30,7 @@ var _loaded_goals: Array[GoalDefinition] = [] var _rows_by_goal_id: Dictionary = {} var _known_generator_ids: Dictionary = {} var _warned_unknown_target_ids: Dictionary = {} +var _generators_by_id: Dictionary = {} func _ready() -> void: _collect_known_generator_ids() @@ -50,36 +48,44 @@ func _on_generator_state_changed(generator_id: StringName, _state: Dictionary) - var generator_key: String = String(generator_id) if not generator_key.is_empty(): _known_generator_ids[generator_key] = true + _refresh_generator_lookup(generator_id) _refresh_ui() func _load_goals() -> void: _loaded_goals.clear() + _generators_by_id.clear() + var scene_root: Node = get_tree().current_scene + var search_root: Node = scene_root if scene_root != null else self + var generator_nodes: Array[Node] = search_root.find_children("*", "CurrencyGenerator", true, false) var seen_goal_ids: Dictionary = {} - for goal_resource in goals: - if goal_resource == null: + for generator_node in generator_nodes: + var generator: CurrencyGenerator = generator_node as CurrencyGenerator + if generator == null: continue - if goal_resource.get_script() != GENERATOR_UNLOCK_GOAL_DATA_SCRIPT: + + var generator_id: StringName = generator.get_generator_id() + if generator_id == &"": continue - var goal_data: Resource = goal_resource - if goal_data == null: + _generators_by_id[String(generator_id)] = generator + + if generator.data == null: continue - var target_generator_id: String = String(goal_data.get("target_generator_id")).strip_edges() - if target_generator_id.is_empty(): + if not generator.data.has_unlock_goal(): continue - var resolved_goal: Resource = goal_data.call("get_goal_data") + + var resolved_goal: GoalData = generator.data.unlock_goal if resolved_goal == null: continue - if not bool(resolved_goal.call("is_valid")): + if not bool(resolved_goal.is_valid()): continue - var goal_key: String = String(resolved_goal.get("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 - _loaded_goals.append(GoalDefinition.new(StringName(target_generator_id), resolved_goal)) + _loaded_goals.append(GoalDefinition.new(generator_id, resolved_goal)) func _build_goal_rows() -> void: _rows_by_goal_id.clear() @@ -89,8 +95,6 @@ func _build_goal_rows() -> void: for goal in _loaded_goals: if goal.goal == null: continue - if goal.goal.get_script() != GOAL_DATA_SCRIPT: - continue var row_container: HBoxContainer = HBoxContainer.new() row_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL @@ -98,7 +102,7 @@ func _build_goal_rows() -> void: var title_label: Label = Label.new() title_label.custom_minimum_size = Vector2(220.0, 0.0) - title_label.text = "%s -> %s" % [String(goal.goal.get("id")), String(goal.target_generator_id)] + title_label.text = "%s -> %s" % [String(goal.goal.id), String(goal.target_generator_id)] var status_label: Label = Label.new() status_label.custom_minimum_size = Vector2(95.0, 0.0) @@ -110,7 +114,7 @@ func _build_goal_rows() -> void: 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.goal.get("id"))) + unlock_button.pressed.connect(_on_unlock_pressed.bind(goal.goal.id)) row_container.add_child(title_label) row_container.add_child(status_label) @@ -118,7 +122,7 @@ func _build_goal_rows() -> void: row_container.add_child(unlock_button) _goals_list.add_child(row_container) - _rows_by_goal_id[String(goal.goal.get("id"))] = GoalRow.new(goal, status_label, requirements_label, unlock_button) + _rows_by_goal_id[String(goal.goal.id)] = GoalRow.new(goal, status_label, requirements_label, unlock_button) func _refresh_ui() -> void: if _loaded_goals.is_empty(): @@ -130,10 +134,8 @@ func _refresh_ui() -> void: for goal in _loaded_goals: if goal.goal == null: continue - if goal.goal.get_script() != GOAL_DATA_SCRIPT: - continue - var row: GoalRow = _rows_by_goal_id.get(String(goal.goal.get("id"))) + var row: GoalRow = _rows_by_goal_id.get(String(goal.goal.id)) if row == null: continue _refresh_goal_row(row) @@ -150,11 +152,18 @@ func _refresh_goal_row(row: GoalRow) -> void: var already_unlocked: bool = _is_goal_completed(goal) var can_resolve_target: bool = _can_resolve_target(goal.target_generator_id) var met: bool = _is_goal_met(goal) - var can_unlock: bool = can_resolve_target and met and not already_unlocked + var generator: CurrencyGenerator = _get_generator_for_goal(goal) + var is_manual_goal: bool = _is_manual_goal_generator(generator) + var can_unlock: bool = can_resolve_target and met and not already_unlocked and is_manual_goal 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.button.text = "Done" + elif is_manual_goal: + row.button.text = "Unlock" + else: + row.button.text = "Auto" if already_unlocked: row.status_label.text = "Unlocked" @@ -175,10 +184,8 @@ func _is_goal_met(goal: GoalDefinition) -> bool: return false if goal.goal == null: return false - if goal.goal.get_script() != GOAL_DATA_SCRIPT: - return false - return bool(goal.goal.call("is_met")) + return bool(goal.goal.is_met()) func _is_goal_completed(goal: GoalDefinition) -> bool: return ( @@ -199,9 +206,14 @@ func _on_unlock_pressed(goal_id: StringName) -> void: if not _is_goal_met(goal): return - 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.goal.get("id")), String(goal.target_generator_id)]) + var generator: CurrencyGenerator = _get_generator_for_goal(goal) + if generator == null: + return + + if not generator.try_unlock_from_goal(): + return + + print("[GoalsDebugUI] Unlocked goal '%s' -> %s" % [String(goal.goal.id), String(goal.target_generator_id)]) _refresh_ui() func _can_resolve_target(target_generator_id: StringName) -> bool: @@ -219,6 +231,7 @@ func _can_resolve_target(target_generator_id: StringName) -> bool: func _collect_known_generator_ids() -> void: _known_generator_ids.clear() + _generators_by_id.clear() var scene_root: Node = get_tree().current_scene var search_root: Node = scene_root if scene_root != null else self @@ -233,6 +246,45 @@ func _collect_known_generator_ids() -> void: continue _known_generator_ids[generator_key] = true + _generators_by_id[generator_key] = generator + +func _refresh_generator_lookup(generator_id: StringName) -> void: + var generator_key: String = String(generator_id) + if generator_key.is_empty(): + return + + var scene_root: Node = get_tree().current_scene + var search_root: Node = scene_root if scene_root != null else self + var generator_nodes: Array[Node] = search_root.find_children("*", "CurrencyGenerator", true, false) + for generator_node in generator_nodes: + var generator: CurrencyGenerator = generator_node as CurrencyGenerator + if generator == null: + continue + if generator.get_generator_id() != generator_id: + continue + + _generators_by_id[generator_key] = generator + return + +func _get_generator_for_goal(goal: GoalDefinition) -> CurrencyGenerator: + if goal == null: + return null + + var key: String = String(goal.target_generator_id) + if key.is_empty(): + return null + + return _generators_by_id.get(key) as CurrencyGenerator + +func _is_manual_goal_generator(generator: CurrencyGenerator) -> bool: + if generator == null: + return false + if generator.data == null: + return false + if not generator.data.has_unlock_goal(): + return false + + return not generator.data.unlocks_automatically_from_goal() func _get_requirements_text(goal: GoalDefinition) -> String: if goal == null: @@ -243,15 +295,15 @@ func _get_requirements_text(goal: GoalDefinition) -> String: return "" var parts: Array[String] = [] - for requirement_resource in goal.goal.call("get_valid_requirements"): + for requirement_resource in goal.goal.get_valid_requirements(): if requirement_resource == null: continue if requirement_resource.get_script() != GOAL_REQUIREMENT_SCRIPT: continue - var requirement_currency_id: StringName = requirement_resource.call("get_currency_id") + var requirement_currency_id: StringName = requirement_resource.get_currency_id() var total_amount: BigNumber = GameState.get_total_currency_acquired_by_id(requirement_currency_id) - var required_amount: BigNumber = requirement_resource.call("get_amount") + var required_amount: BigNumber = requirement_resource.get_amount() parts.append( "%s %s / %s" % [ _currency_label(requirement_currency_id), diff --git a/core/generator-unlock-goals/goals_debug_ui.gd.uid b/goals_debug_ui.gd.uid similarity index 100% rename from core/generator-unlock-goals/goals_debug_ui.gd.uid rename to goals_debug_ui.gd.uid diff --git a/core/generator-unlock-goals/goals_debug_ui.tscn b/goals_debug_ui.tscn similarity index 79% rename from core/generator-unlock-goals/goals_debug_ui.tscn rename to goals_debug_ui.tscn index 454be8d..23306f7 100644 --- a/core/generator-unlock-goals/goals_debug_ui.tscn +++ b/goals_debug_ui.tscn @@ -1,14 +1,11 @@ [gd_scene format=3 uid="uid://dirvi76rkoowf"] -[ext_resource type="Script" uid="uid://bmrbaulftvvwm" path="res://core/generator-unlock-goals/goals_debug_ui.gd" id="1_s7s8v"] -[ext_resource type="Resource" uid="uid://mnuihnwb70ba" path="res://idles/goals/generator_unlock_knowledge.tres" id="2_jyi2u"] -[ext_resource type="Resource" uid="uid://8wrqdjewfdq5" path="res://idles/goals/generator_unlock_wood.tres" id="3_0nfd8"] +[ext_resource type="Script" uid="uid://bmrbaulftvvwm" path="res://goals_debug_ui.gd" id="1_s7s8v"] [node name="GoalsDebugUI" type="PanelContainer" unique_id=1494700185] offset_right = 640.0 offset_bottom = 260.0 script = ExtResource("1_s7s8v") -goals = [ExtResource("2_jyi2u"), ExtResource("3_0nfd8")] [node name="MarginContainer" type="MarginContainer" parent="." unique_id=1582529872] layout_mode = 2 diff --git a/idles/generators/secondary_generator.tres b/idles/generators/secondary_generator.tres index c8e3ed4..e67c8a3 100644 --- a/idles/generators/secondary_generator.tres +++ b/idles/generators/secondary_generator.tres @@ -2,6 +2,7 @@ [ext_resource type="Script" uid="uid://dnhocfsuvmeyb" path="res://core/generator/generator_buff_data.gd" id="1_d86db"] [ext_resource type="Script" uid="uid://b00tqsuhxdy0d" path="res://core/generator/currency_generator_data.gd" id="1_s3g28"] +[ext_resource type="Resource" uid="uid://qyxct5gbrxwa" path="res://idles/goals/magic_total_30.tres" id="2_r2b8m"] [resource] script = ExtResource("1_s3g28") @@ -9,6 +10,8 @@ id = &"Knowledge" name = "Library" starts_unlocked = false starts_available = false +unlock_goal = ExtResource("2_r2b8m") +unlock_goal_behavior = 1 initial_cost = 60.0 initial_time = 3.0 initial_revenue = 60.0 diff --git a/idles/goals/generator_unlock_knowledge.tres b/idles/goals/generator_unlock_knowledge.tres deleted file mode 100644 index 1f3e3f5..0000000 --- a/idles/goals/generator_unlock_knowledge.tres +++ /dev/null @@ -1,9 +0,0 @@ -[gd_resource type="Resource" script_class="GeneratorUnlockGoalData" format=3 uid="uid://mnuihnwb70ba"] - -[ext_resource type="Script" uid="uid://dt4df3gwr6n5m" path="res://core/generator-unlock-goals/generator_unlock_goal_data.gd" id="1_hv2u1"] -[ext_resource type="Resource" uid="uid://qyxct5gbrxwa" path="res://idles/goals/magic_total_30.tres" id="2_7r0qe"] - -[resource] -script = ExtResource("1_hv2u1") -target_generator_id = &"Knowledge" -goal = ExtResource("2_7r0qe") diff --git a/idles/goals/generator_unlock_wood.tres b/idles/goals/generator_unlock_wood.tres deleted file mode 100644 index d172a0f..0000000 --- a/idles/goals/generator_unlock_wood.tres +++ /dev/null @@ -1,9 +0,0 @@ -[gd_resource type="Resource" script_class="GeneratorUnlockGoalData" format=3 uid="uid://8wrqdjewfdq5"] - -[ext_resource type="Script" uid="uid://dt4df3gwr6n5m" path="res://core/generator-unlock-goals/generator_unlock_goal_data.gd" id="1_phux3"] -[ext_resource type="Resource" uid="uid://bmlhoeasl7xor" path="res://idles/goals/magic_total_1300.tres" id="2_w06cq"] - -[resource] -script = ExtResource("1_phux3") -target_generator_id = &"Wood" -goal = ExtResource("2_w06cq")