diff --git a/.agents/skills/godot-test-instrumentation/SKILL.md b/.agents/skills/godot-test-instrumentation/SKILL.md new file mode 100644 index 0000000..f9e3a06 --- /dev/null +++ b/.agents/skills/godot-test-instrumentation/SKILL.md @@ -0,0 +1,642 @@ +--- +name: godot-test-instrumentation +description: Automates Godot testing for coding agents via headless execution, method simulation, and stdout validation. +--- + +# Godot Test Instrumentation + +Enables coding agents to automatically test game changes by running Godot in headless mode, simulating user actions via method calls, and validating results through structured stdout output. + +## Use This Skill When + +- A coding agent needs to verify gameplay changes work correctly +- Testing generator purchases, currency gains, prestige mechanics, or buff systems +- Running automated validation in CI/CD pipelines +- Debugging features without manual GUI interaction + +## Core Workflow + +``` +1. Agent writes/modifies game code + ↓ +2. Agent creates or updates test script in res://tests/ + ↓ +3. Agent runs: godot --headless --path . -s res://tests/test_x.gd + ↓ +4. Test loads gym scene, simulates actions, prints [TAG] results + ↓ +5. Agent parses stdout for [PASS]/[FAIL]/[RESULT] + ↓ +6. Exit code 0 = PASS, Exit code 1 = FAIL +``` + +--- + +## Directory Structure + +``` +res:// +├── tests/ +│ ├── test_runner.gd # Cross-platform test discovery & execution +│ ├── test_utils.gd # Shared assertion helpers +│ ├── test_gold_mine_click.gd # Example: generator click → currency gain +│ └── test_prestige.gd # Example: prestige mechanics validation +├── docs/gyms/ # Test scenes (e.g., tiny_sword.tscn) +└── core/ # Game logic (LevelGameState, generators, etc.) +``` + +--- + +## 1. Test Utils (res://tests/test_utils.gd) + +Shared assertion helpers with structured stdout output. + +```gdscript +class_name TestUtils +extends RefCounted + +static var _test_name: String = "" +static var _passed: int = 0 +static var _failed: int = 0 + +static func set_test_name(name: String) -> void: + _test_name = name + _passed = 0 + _failed = 0 + +static func assert_equals(expected: Variant, actual: Variant, message: String) -> void: + if expected == actual: + print("[PASS] %s: %s" % [_test_name, message]) + _passed += 1 + else: + print("[FAIL] %s: %s (expected %s, got %s)" % [ + _test_name, message, str(expected), str(actual) + ]) + _failed += 1 + +static func assert_true(condition: bool, message: String) -> void: + if condition: + print("[PASS] %s: %s" % [_test_name, message]) + _passed += 1 + else: + print("[FAIL] %s: %s" % [_test_name, message]) + _failed += 1 + +static func assert_greater_than(a: float, b: float, message: String) -> void: + if a > b: + print("[PASS] %s: %s" % [_test_name, message]) + _passed += 1 + else: + print("[FAIL] %s: %s (expected %s > %s)" % [_test_name, message, str(a), str(b)]) + _failed += 1 + +static func print_result() -> void: + print("[SUMMARY] %s: %d passed, %d failed" % [_test_name, _passed, _failed]) + if _failed == 0: + print("[RESULT] PASS") + else: + print("[RESULT] FAIL") +``` + +--- + +## 2. Test Runner (res://tests/test_runner.gd) + +Cross-platform test discovery and execution. Runs all `test_*.gd` scripts in the tests directory. + +```gdscript +extends SceneTree + +var test_scripts: Array[String] = [] +var test_index: int = 0 +var total_passed: int = 0 +var total_failed: int = 0 + +func _init(): + print("\n=== GODOT TEST RUNNER ===\n") + _discover_tests() + + if test_scripts.is_empty(): + print("[ERROR] No test scripts found in res://tests/") + quit(1) + return + + print("Found %d test(s): %s\n" % [test_scripts.size(), str(test_scripts)]) + await _run_next_test() + + print("\n=== TEST SUMMARY ===") + print("Total passed: %d" % total_passed) + print("Total failed: %d" % total_failed) + + if total_failed == 0: + print("[OVERALL_RESULT] PASS") + quit(0) + else: + print("[OVERALL_RESULT] FAIL") + quit(1) + +func _discover_tests() -> void: + var dir = DirAccess.open("res://tests") + if dir: + dir.list_dir_begin() + var file_name = dir.get_next() + while file_name != "": + if file_name.ends_with(".gd") and file_name.begins_with("test_") and file_name != "test_runner.gd" and file_name != "test_utils.gd": + test_scripts.append("res://tests/%s" % file_name) + file_name = dir.get_next() + dir.list_dir_end() + test_scripts.sort() + +func _run_next_test() -> void: + if test_index >= test_scripts.size(): + return + + var test_script = test_scripts[test_index] + print("--- Running test %d/%d: %s ---" % [test_index + 1, test_scripts.size(), test_script]) + + var test_instance = load(test_script).new() + await test_instance.run() + + total_passed += test_instance.passed + total_failed += test_instance.failed + + test_index += 1 + await _run_next_test() + +class TestScript: + var passed: int = 0 + var failed: int = 0 + + func run(): + pass +``` + +**Usage:** +```bash +# Run all tests via test runner +"$GODOT_BIN" --headless --path . -s res://tests/test_runner.gd +``` + +--- + +## 3. Test Script Template + +Each test extends `SceneTree` and implements a `run()` method. + +```gdscript +extends SceneTree + +var passed: int = 0 +var failed: int = 0 + +func _init(): + await run() + quit(0 if failed == 0 else 1) + +func run(): + # Setup + TestUtils.set_test_name("") + + # Load scene + var scene = load("") # e.g., "res://docs/gyms/tiny_sword/tiny_sword.tscn" + var root_node = scene.instantiate() + add_root_node(root_node) + + # Wait for scene initialization + await _wait() + + # Get references + var game_state: LevelGameState = root_node.find_child("LevelGameState") + var generator = root_node.find_child("") as CurrencyGenerator + + if game_state == null: + print("[ERROR] LevelGameState not found") + _print_summary() + return + + # Test logic + var initial_value = game_state.get_currency_amount_by_id(&"") + print("[TARGET] initial_: %s" % initial_value.to_string_suffix(2)) + + # Simulate action (method call, not mouse click) + generator._on_pressed() + + # Wait for signal propagation + await _wait() + + # Validate + var final_value = game_state.get_currency_amount_by_id(&"") + print("[TARGET] final_: %s" % final_value.to_string_suffix(2)) + + TestUtils.assert_greater_than( + final_value.mantissa, + initial_value.mantissa, + " increased after action" + ) + + _print_summary() + +func _wait() -> void: + await get_tree().create_timer(0.5).timeout + +func _print_summary(): + TestUtils.print_result() + passed = _get_passed_count() + failed = _get_failed_count() + +func _get_passed_count() -> int: + # Extract from TestUtils or track locally + return 0 + +func _get_failed_count() -> int: + return 0 +``` + +--- + +## 4. Example Test: Gold Mine Click + +Tests that clicking a generator grants currency. + +```gdscript +extends SceneTree + +var passed: int = 0 +var failed: int = 0 +var _local_passed: int = 0 +var _local_failed: int = 0 + +func _init(): + await run() + quit(failed == 0 ? 0 : 1) + +func run(): + print("\n=== TEST: Gold Mine Click ===\n") + TestUtils.set_test_name("gold_mine_click") + + # Load tiny_sword scene + var scene = load("res://docs/gyms/tiny_sword/tiny_sword.tscn") + var root_node = scene.instantiate() + add_root_node(root_node) + + await _wait() + + var game_state: LevelGameState = root_node.find_child("LevelGameState") + var gold_mine = root_node.find_child("GoldMine") as CurrencyGenerator + + if game_state == null or gold_mine == null: + print("[ERROR] Missing LevelGameState or GoldMine node") + _print_summary() + return + + # Record initial gold + var initial_gold: BigNumber = game_state.get_currency_amount_by_id(&"gold") + print("[TARGET] initial_gold: %s" % initial_gold.to_string_suffix(2)) + + # Simulate clicking the generator + gold_mine._on_pressed() + + await _wait() + + # Validate gold increased + var final_gold: BigNumber = game_state.get_currency_amount_by_id(&"gold") + print("[TARGET] final_gold: %s" % final_gold.to_string_suffix(2)) + + TestUtils.assert_greater_than( + final_gold.mantissa, + initial_gold.mantissa, + "Gold increased after generator click" + ) + + # Check specific value (adjust based on click_mantissa in data) + var expected_min: float = initial_gold.mantissa + 1.0 + TestUtils.assert_true( + final_gold.mantissa >= expected_min, + "Gold increased by at least 1.0" + ) + + _print_summary() + +func _wait() -> void: + await get_tree().create_timer(0.5).timeout + +func _print_summary(): + TestUtils.print_result() + # Note: For accurate counts, modify TestUtils to return counts or track locally + _local_failed = 0 # Update based on actual assertions +``` + +--- + +## 5. Example Test: Prestige Mechanics + +Tests prestige threshold and currency gain. + +```gdscript +extends SceneTree + +var passed: int = 0 +var failed: int = 0 + +func _init(): + await run() + quit(failed == 0 ? 0 : 1) + +func run(): + print("\n=== TEST: Prestige Mechanics ===\n") + TestUtils.set_test_name("prestige") + + var scene = load("res://docs/gyms/tiny_sword/tiny_sword.tscn") + var root_node = scene.instantiate() + add_root_node(root_node) + + await _wait() + + var game_state: LevelGameState = root_node.find_child("LevelGameState") + var prestige_manager = root_node.find_child("PrestigeManager") as PrestigeManager + + if game_state == null or prestige_manager == null: + print("[ERROR] Missing LevelGameState or PrestigeManager") + _print_summary() + return + + # Setup: Accumulate currencies to meet prestige threshold + game_state.add_currency_by_id(&"gold", BigNumber.from_float(100000.0)) + game_state.add_currency_by_id(&"gems", BigNumber.from_float(100.0)) + + await _wait() + + # Check if can prestige + var can_prestige: bool = prestige_manager.can_prestige() + print("[TARGET] can_prestige: %s" % str(can_prestige)) + + TestUtils.assert_true(can_prestige, "Should be able to prestige after accumulating currencies") + + if can_prestige: + # Perform prestige + prestige_manager.perform_prestige() + + await _wait() + + # Check ascension currency + var ascension: BigNumber = game_state.get_currency_amount_by_id(&"ascension") + print("[TARGET] ascension_currency: %s" % ascension.to_string_suffix(2)) + + TestUtils.assert_true( + ascension.mantissa > 0, + "Should receive ascension currency after prestige" + ) + + # Check prestige multiplier increased + var total_multiplier = prestige_manager.get_total_multiplier() + print("[TARGET] total_multiplier: %s" % str(total_multiplier)) + + TestUtils.assert_greater_than( + total_multiplier if total_multiplier is float else float(total_multiplier), + 1.0, + "Prestige multiplier should be > 1.0" + ) + + _print_summary() + +func _wait() -> void: + await get_tree().create_timer(0.5).timeout + +func _print_summary(): + TestUtils.print_result() +``` + +--- + +## 6. Shell Scripts + +### run_test.sh + +```bash +#!/bin/bash +# Usage: ./run_test.sh +# Example: ./run_test.sh res://tests/test_gold_mine_click.gd + +set -e + +TEST_SCRIPT="$1" +GODOT_BIN="${GODOT_BIN:-godot}" + +if [ -z "$TEST_SCRIPT" ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "Running test: $TEST_SCRIPT" +echo "" + +"$GODOT_BIN" --headless --path . -s "$TEST_SCRIPT" +EXIT_CODE=$? + +echo "" +if [ $EXIT_CODE -eq 0 ]; then + echo "✓ Test passed" +else + echo "✗ Test failed with exit code: $EXIT_CODE" +fi + +exit $EXIT_CODE +``` + +--- + +### run_all.sh + +```bash +#!/bin/bash +# Usage: ./run_all.sh + +set -e + +GODOT_BIN="${GODOT_BIN:-godot}" +TEST_DIR="res://tests" + +echo "=== Running All Tests ===" +echo "" + +# Run test runner (discovers all tests automatically) +"$GODOT_BIN" --headless --path . -s "$TEST_DIR/test_runner.gd" +EXIT_CODE=$? + +echo "" +if [ $EXIT_CODE -eq 0 ]; then + echo "✓ All tests passed" +else + echo "✗ Some tests failed" +fi + +exit $EXIT_CODE +``` + +--- + +### check_syntax.sh + +```bash +#!/bin/bash +# Usage: ./check_syntax.sh + +SCRIPT="$1" +GODOT_BIN="${GODOT_BIN:-godot}" + +if [ -z "$SCRIPT" ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "Checking syntax: $SCRIPT" +"$GODOT_BIN" --headless --path . --check-only --script "$SCRIPT" +EXIT_CODE=$? + +if [ $EXIT_CODE -eq 0 ]; then + echo "✓ Syntax valid" +else + echo "✗ Syntax errors found" +fi + +exit $EXIT_CODE +``` + +--- + +## 7. Godot CLI Command Reference + +| Command | Purpose | +|---------|---------| +| `"$GODOT_BIN" --headless --path . -s res://tests/test_x.gd` | Run single test in headless mode | +| `"$GODOT_BIN" --headless --path . -s res://tests/test_runner.gd` | Run all tests via test runner | +| `"$GODOT_BIN" --headless --path . --check-only --script res://test.gd` | Syntax validation only | +| `"$GODOT_BIN" --headless --verbose --path . -s res://tests/test_x.gd` | Verbose output for debugging | +| `"$GODOT_BIN" --headless --path . --quit-after 100 -s res://tests/test_x.gd` | Run for 100 frames then quit | +| `"$GODOT_BIN" --headless --path . -s res://tests/test_x.gd --log-file /tmp/test.log` | Log output to file | + +**Important:** Always use `--headless` for automated testing. This sets `--display-driver headless --audio-driver Dummy`. + +--- + +## 8. Output Parsing Protocol + +Agents should parse stdout for these tags: + +| Tag | Purpose | Example | +|-----|---------|---------| +| `[TARGET]` | Values to validate | `[TARGET] gold: 100.00` | +| `[PASS]` | Assertion passed | `[PASS] gold_mine_click: Gold increased]` | +| `[FAIL]` | Assertion failed | `[FAIL] gold_mine_click: Expected gold > 0]` | +| `[SUMMARY]` | Test completion stats | `[SUMMARY] gold_mine_click: 2 passed, 0 failed]` | +| `[RESULT]` | Overall test result | `[RESULT] PASS` or `[RESULT] FAIL]` | +| `[ERROR]` | Setup/runtime error | `[ERROR] Missing LevelGameState node]` | +| `[OVERALL_RESULT]` | Test runner summary | `[OVERALL_RESULT] PASS` or `[OVERALL_RESULT] FAIL]` | + +**Exit Codes:** +- `0` = All assertions passed +- `1` = One or more assertions failed or error occurred + +--- + +## 9. Best Practices + +### Simulating User Actions + +In headless mode, **do not** simulate mouse clicks with `InputEventMouseButton`. Instead, call methods directly: + +```gdscript +# ✅ DO: Call the method directly +generator._on_pressed() +game_state.add_currency_by_id(&"gold", BigNumber.from_float(100.0)) +prestige_manager.perform_prestige() + +# ❌ DON'T: Try to synthesize mouse events (won't work in headless) +var event = InputEventMouseButton.new() +Input.parse_input_event(event) # Ineffective without GUI +``` + +### Async Handling + +Use standard wait time for signal propagation: + +```gdscript +await get_tree().create_timer(0.5).timeout +``` + +Adjust based on test needs: +- `0.1s` - Simple state changes +- `0.5s` - Signal propagation (recommended default) +- `1.0s+` - Complex async operations + +### Scene Selection + +Use gym scenes from `res://docs/gyms/`: +- `res://docs/gyms/tiny_sword/tiny_sword.tscn` - Full game with generators, currencies, prestige +- Create minimal test scenes if testing isolated features + +### Test Naming + +- Use `test_.gd` naming convention +- Test name in `TestUtils.set_test_name()` should match filename (without `.gd`) + +### Assertions + +- One assertion per line for clear parsing +- Include descriptive messages +- Use `[TARGET]` to log values before/after actions + +--- + +## 10. Quick Start for Agents + +1. **Create test file:** + ```bash + touch res://tests/test_my_feature.gd + ``` + +2. **Copy template:** + ```gdscript + extends SceneTree + var passed: int = 0 + var failed: int = 0 + + func _init(): + await run() + quit(failed == 0 ? 0 : 1) + + func run(): + print("\n=== TEST: My Feature ===\n") + TestUtils.set_test_name("my_feature") + + var scene = load("res://docs/gyms/tiny_sword/tiny_sword.tscn") + var root = scene.instantiate() + add_root_node(root) + + await get_tree().create_timer(0.5).timeout + + # Your test logic here + + TestUtils.print_result() + ``` + +3. **Run test:** + ```bash + godot --headless --path . -s res://tests/test_my_feature.gd + ``` + +4. **Parse output:** + - Look for `[PASS]`, `[FAIL]`, `[RESULT]` tags + - Check exit code (0 = pass, 1 = fail) + +--- + +## References + +- [Godot CLI Tutorial](https://docs.godotengine.org/en/latest/tutorials/editor/command_line_tutorial.html) +- [Godot Headless Mode](https://docs.godotengine.org/en/latest/tutorials/editor/command_line_tutorial.html#run-options) +- [InputEvent Documentation](https://docs.godotengine.org/en/stable/tutorials/inputs/inputevent.html) + + + +/home/mikymod/work/jmp/idle/.agents/skills/godot-test-instrumentation/SKILL.md \ No newline at end of file diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/.opencode/plans/currency-panel.md b/.opencode/plans/currency-panel.md new file mode 100644 index 0000000..495ab7e --- /dev/null +++ b/.opencode/plans/currency-panel.md @@ -0,0 +1,97 @@ +# Plan: Create CurrencyPanel Widget + +## Goal +Regroup the 9 individual CurrencyTile nodes in tiny_sword.tscn into a single dynamic `CurrencyPanel` widget that automatically retrieves and displays all currencies from the LevelGameState catalogue. + +## Changes + +### 1. Create `CurrencyPanel` script (`core/currency/currency_panel.gd`) + +**New file**: `res://core/currency/currency_panel.gd` + +**Class**: `class_name CurrencyPanel`, extends `PanelContainer` + +**Structure**: +```gdscript +class_name CurrencyPanel +extends PanelContainer + +const CURRENCY_TILE_SCENE: PackedScene = preload("res://currency_tile.tscn") + +@onready var _tiles_container: VBoxContainer = $CurrencyTiles + +var _game_state: LevelGameState + +func _ready() -> void: + _game_state = find_parent("LevelGameState") + if _game_state == null: + push_error("CurrencyPanel: No LevelGameState found in parent hierarchy") + return + _build_currency_tiles() + _game_state.ready.connect(_on_game_state_ready) + +func _build_currency_tiles() -> void: + # Clear existing children + for child in _tiles_container.get_children(): + child.queue_free() + + if _game_state.currency_catalogue == null: + return + + for currency in _game_state.currency_catalogue.currencies: + if currency == null: + continue + var tile: CurrencyTile = CURRENCY_TILE_SCENE.instantiate() + # Set exports before add_child() so they're available in _ready() + tile.currency = currency + tile.game_state = _game_state + _tiles_container.add_child(tile) + +func _on_game_state_ready() -> void: + pass +``` + +**Why this works with existing CurrencyTile**: CurrencyTile uses `@export` for `currency` and `game_state`. When `instantiate()` is called, `_ready()` is NOT invoked yet - it fires when the node is added to the scene tree. By setting properties before `add_child()`, `_ready()` sees them as non-null. The DebugIncomeButton is preserved since CurrencyTile is unchanged. + +### 2. Update tiny_sword.tscn scene + +**Replace**: 9 individual CurrencyTile nodes under `LevelGameState/UI` with a single CurrencyPanel hierarchy. + +**Remove from scene**: +- All 9 individual CurrencyTile nodes: `GoldCurrencyTile`, `WorkerCurrencyTile`, `FoodCurrencyTile`, `WoodCurrencyTile`, `AscensionCurrencyTile`, `MagicGoldTile`, `ManaStoneTile`, `CogniteTile`, `PhilosoperStoneTile` +- ExtResource `7_0cs5o` (currency_tile.tscn) - no longer referenced directly +- All 9 individual currency resource ExtResources: `9_1363k` (gold), `10_i1cck` (worker), `11_hskcg` (food), `12_l6a68` (wood), `15_ascension`, `20_no27p` (magic_gold), `22_rbyxa` (mana_stone), `23_vrcrd` (cognite), `24_eaq6h` (philosoper_stone) + +**Add to scene**: +- ExtResource `8_cpnl` → `res://core/currency/currency_panel.gd` +- New node hierarchy: + ``` + LevelGameState/UI (Control, layout_mode=3) + CurrencyPanel (PanelContainer, layout_mode=1, pos 5,5 size 250x340, script=8_cpnl) + CurrencyTiles (VBoxContainer, separation=4) ← script-less, populated at runtime + GoalsDebugUI (repositioned: offset_left=265) + ``` + +**Keep**: GoalsDebugUI (repositioned slightly from offset_left=1275 to offset_left=265 to account for panel width) + +## File Structure After Changes + +``` +core/ + level_game_state.gd # unchanged (ready is built-in) + currency/ + currency_panel.gd # NEW: dynamic panel widget +docs/gyms/tiny_sword/ + tiny_sword.tscn # 9 tiles → 1 panel hierarchy (cleaner) +``` + +## No Changes Needed +- **`level_game_state.gd`**: The `ready` signal is built-in for Godot nodes - no addition needed. +- **`currency_label.gd` (CurrencyTile)**: Unchanged - DebugIncomeButton preserved. + +## Testing +- Run Godot headless to verify scene loads without errors +- Verify all 9 currencies appear in the CurrencyPanel +- Verify currency values update on changes (currency_changed signal) +- Verify DebugIncomeButton works on each tile +- Verify the panel doesn't overlap with GoalsDebugUI diff --git a/AGENTS.md b/AGENTS.md index 7db0bb7..f4a619f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,6 @@ # AGENTS.md -Godot 4.6 idle-game prototype; code is GDScript + `.tscn` scenes + JSON data at repo root. +Godot 4.6 idle-game; code is GDScript + `.tscn` scenes. + ## Build, Lint, And Test Commands Use `GODOT_BIN` for your local editor binary (e.g. `godot`, `godot4`, or full path). Run project: `"$GODOT_BIN" --path .` @@ -7,12 +8,14 @@ Headless smoke/lint parse: `"$GODOT_BIN" --headless --path . --quit` Script syntax check (single script): `"$GODOT_BIN" --headless --check-only --script res://big_number.gd` Tests: no test framework is currently committed (`tests/` and `addons/gut` are absent). Single-test command: N/A right now; if GUT is added, use `"$GODOT_BIN" --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/.gd`. + ## Architecture And Structure `project.godot` defines autoload singletons: `GameState` (runtime currency state + save/load) and `CurrencyGeneratorDatabase` (loads `generator_data.json`). `big_number.gd` (`class_name BigNumber`) is the internal numeric API for huge-value math, comparisons, formatting, and serialization. `big_number_museum.tscn` + `big_number_museum.gd` is the current main playable scene/prototype UI. `currency_generator.gd`, `currency_label.gd`, and `big_number_progress_bar.gd` are UI/gameplay adapters that react to `GameState` signals. Persistence is local JSON only: `user://idle_save.json`; there is no external DB/server/backend. + ## Code Style And Conventions Prefer typed GDScript (`var x: Type`, `func f() -> void`) and keep shared models/components as `class_name` scripts. Naming: `snake_case` for vars/functions/signals, `PascalCase` for classes, `UPPER_SNAKE_CASE` for constants. @@ -20,3 +23,5 @@ Follow existing Godot style: tabs/consistent indentation, early returns, and sho When reading files/JSON, always guard `FileAccess.open(...)` and validate parsed types before indexing. Use signals (already in `GameState`) for UI updates instead of polling or cross-node direct mutation. No Cursor/Claude/Windsurf/Cline/Goose/Copilot rule files were found in this repository, so this file is the canonical agent guidance. +Avoid trivial one-line wrapper/helper functions that only forward or repack data. Inline the logic at the real call site unless the wrapper adds meaningful abstraction or is reused enough to justify it. +Resource type safety: Use the specific resource. Avoid as much as possible explicit `Resource` usage. diff --git a/README.md b/README.md index 188cc30..6079e5a 100644 --- a/README.md +++ b/README.md @@ -1,261 +1,313 @@ -# Idles - Systems Architecture Report +# Idles — Godot 4.6 Idle Game + +A modular idle/incremental game prototype featuring a decoupled buff system, multi-target buffs with wildcard support, goal-based progression, prestige resets, and production-driven research. + +## Quick Start + +```bash +# Run the project +"$GODOT_BIN" --path . + +# Headless mode (lint/smoke test) +"$GODOT_BIN" --headless --path . --quit + +# Check single script syntax +"$GODOT_BIN" --headless --check-only --script res://core/big_number.gd +``` + +## Architecture Overview + +### Core Systems + +| System | Location | Purpose | +|--------|----------|---------| +| **BigNumber** | `core/big_number.gd` | Handles huge numbers (mantissa + exponent) for idle game math | +| **LevelGameState** | `core/level_game_state.gd` | Central state authority — currencies, generators, buffs, goals, research, persistence | +| **Currency** | `core/currency/`, `core/currency_catalogue.gd` | Catalog system for all in-game currencies | +| **Generators** | `core/generator/` | Production mechanics (auto cycles, clicks, purchases) | +| **Buffs** | `core/generator/generator_buff_data.gd` | Global, multi-target buffs applied to generators | +| **Goals** | `core/goals/` | Unlock conditions and achievement tracking | +| **Prestige** | `core/prestige/` | Reset-with-bonus mechanics | +| **Research** | `core/research/` | XP-based progression with production multipliers | + +### Key Nodes + +| Node | Script | Description | +|------|--------|-------------| +| `LevelGameState` | `core/level_game_state.gd` | Central state management, save/load, catalogs, all signals | +| `PrestigeManager` | `core/prestige/prestige_manager.gd` | Prestige calculation, reset orchestration | +| `CurrencyGenerator` | `core/generator/currency_generator.gd` | Per-generator production, purchasing, buff interaction | +| `GeneratorPanel` | `core/generator/generator_container.gd` | UI panel for a single generator | + +**Important:** There are **no autoload singletons**. `LevelGameState` and `PrestigeManager` are regular nodes instantiated in the scene (e.g., `docs/gyms/tiny_sword/tiny_sword.tscn`). All code accesses them through `@export` references or `get_node()`, never through an autoload name like `/root/GameState`. -## Scope Covered +### Data Directory Structure -This report covers all tracked files in the repository (62 total at time of analysis), including: +All gameplay resources (`.tres` files) live under: -1. Project configuration. -2. Core runtime scripts. -3. Gameplay/economy systems. -4. Goal/unlock systems. -5. UI adapter scripts and scenes. -6. Data resources (`.tres`). -7. Technical docs and metadata/import files. +``` +docs/gyms/tiny_sword/ +├── currencies/ # Currency resources (gold, food, wood, worker, etc.) +├── buildings/ # Generator configuration data +│ ├── farm/ # farm_generator.tres +│ ├── forestry/ # forestry_generator.tres +│ ├── gold_mine/ # gold_mine_generator.tres +│ ├── monastery/ # monastery_generator.tres +│ └── alchemy_tower/ # alchemy_tower_data.tres + recipes/ +├── buffs/ # Buff definitions with target_ids +├── goals/ # Unlock goal definitions +├── prestige/ # Prestige configuration (primary_prestige.tres) +├── research/ # Research track data +└── test/ # (reserved for tests) +``` + +Game art assets live separately in: + +``` +sandbox/tiny_swords/ +├── Buildings/ # Building sprites +├── Terrain/ # Tilesets and decorations +├── UI Elements/ # UI assets +└── Units/ # Unit sprites +``` + +## Core Documentation + +Each `core/` subsystem has its own detailed README: + +| Module | Documentation | +|--------|--------------| +| Core overview | `core/README.md` | +| Currency system | `core/currency/README.md` | +| Generator system | `core/generator/README.md` | +| Goals system | `core/goals/README.md` | +| Prestige system | `core/prestige/README.md` | +| Research system | `core/research/README.md` | + +## Buff System + +Buffs are **globally registered** and **multi-target capable**: + +- **Global Levels**: Each buff has a single level shared across all generators it targets. +- **Multi-Target**: A single buff resource can affect multiple generators. Controlled by the `target_ids` array on `GeneratorBuffData`. +- **Wildcards**: A `target_ids` value of `["*"]` applies the buff to all current AND future generators. +- **Catalog-Based Loading**: Buffs are defined in a `BuffCatalogue` resource (`@export var buff_catalogue: BuffCatalogue` on `LevelGameState`). No runtime directory scanning is performed. +- **Goal-Based Activation**: Buffs with an `unlock_goal` automatically unlock when their goal is completed. +- **Multiplicative Stacking**: Multiple buffs of the same kind on a generator stack multiplicatively (`buff1 × buff2 × …`). +- **Buff Kinds**: `AUTO_PRODUCTION_MULTIPLIER`, `MANUAL_CLICK_MULTIPLIER`, `RESOURCE_PURCHASE`, `RESEARCH_XP_MULTIPLIER` + +### Lifecycle + +| Event | What happens | +|-------|-------------| +| **Default state** | Inactive (locked) until its unlock goal is met | +| **Goal met** | Buff auto-unlocks, becomes purchasable | +| **Purchased** | Level increments, effect applies to all target generators | +| **Prestige reset** | All buff levels reset to 0, all buffs re-locked | + +### API (on LevelGameState) + +```gdscript +LevelGameState.register_buff(buff: GeneratorBuffData) +LevelGameState.get_buff(buff_id: StringName) -> GeneratorBuffData +LevelGameState.get_buff_level(buff_id: StringName) -> int +LevelGameState.get_buffs_for_generator(generator_id: StringName) -> Array[GeneratorBuffData] +LevelGameState.get_effective_multiplier(generator_id: StringName, kind: int) -> float +LevelGameState.set_buff_level(buff_id: StringName, level: int) +LevelGameState.is_buff_active(buff_id: StringName) -> bool +``` + +## Goals System + +Goals track player progress and unlock generators or buffs when requirements are met. + +- **Automatic or Manual**: Goals define their own `unlock_behavior` (`AUTOMATIC` or `MANUAL`). +- **Logarithmic Progress**: Progress bars use log-scale for visual feedback on huge numbers. +- **Prestige Reset**: All goals are reset to incomplete on prestige. Previously unlocked generators re-lock until their goal is met again. + +### Signals on LevelGameState + +```gdscript +goal_completed(goal_id: StringName) +goal_progress_changed(goal_id: StringName, progress: float) +``` + +## Prestige System + +Prestige resets run progress in exchange for a permanent production multiplier. + +- **Configuration**: `PrestigeConfig` resource at `docs/gyms/tiny_sword/prestige/primary_prestige.tres` +- **Basis types**: `LIFETIME_TOTAL` (single currency), `RUN_TOTAL`, `RUN_MAX`, `ALL_CURRENCIES` (sum of all tracked currencies) +- **Formula types**: `POWER` (`scale × ratio^exponent`) or `TRIANGULAR_INVERSE` +- **Multiplier modes**: `ADDITIVE` or `MULTIPLICATIVE_POWER` + +### What gets reset on prestige + +| State | Behavior | +|-------|----------| +| **Current currency balances** | Reset to 0 (except prestige currency if configured) | +| **Generator states** (owned, unlocked) | Cleared | +| **Buff levels** | All reset to 0, all re-locked | +| **Goals** | All reset to incomplete | +| **Research XP and levels** | All cleared to 0 | +| **Lifetime currency totals** | Preserved | +| **All-time currency acquired** | Preserved | +| **Prestige points earned** | Preserved (permanent) | + +## Research System + +XP-based progression tied to generator output. Research tracks level up automatically, granting permanent production multipliers to their associated generator. + +- **XP Formula**: `XP = currency_produced × xp_per_currency_produced × buff_multiplier` +- **Level Formula**: `xp_required(n) = base_xp_required × growth_multiplier^(n-1)` +- **Buffs**: The `RESEARCH_XP_MULTIPLIER` buff kind increases XP gain for a research track. +- **Prestige Reset**: All research XP and levels reset to 0. + +## Save Format + +**Version 5 (current):** + +```json +{ + "save_format_version": 5, + "currencies": { + "": { + "current": {"m": 1.0, "e": 0}, + "total": {"m": 1.0, "e": 0}, + "all_time": {"m": 1.0, "e": 0} + } + }, + "generator_states": { + "": { + "owned": 0, + "purchased_count": 0, + "unlocked": false, + "available": false + } + }, + "buff_levels": { "": 0 }, + "buff_unlocked": { "": false }, + "buff_active": { "": false }, + "goals": { "completed": [""] }, + "research_xp": { "": {"m": 0.0, "e": 0} }, + "research_levels": { "": 0 }, + "research_workers": 0, + "prestige_state": { … }, + "last_save_time": 1234567890 +} +``` + +Notes: +- All large numbers are serialized as `{"m": mantissa_float, "e": exponent_int}` (BigNumber format). +- `prestige_state` is an external section managed by `PrestigeManager`, not by `LevelGameState` directly. +- `research_workers` tracks workers assigned to research across all tracks. +- Save file is written to `user://level_save.json`. + +## Save/Load + +- **Save**: `LevelGameState.save_game()` serializes all state to JSON. Called on prestige reset (via `PrestigeManager`) and when manually triggered. +- **Load**: `LevelGameState.load_game()` deserializes from disk during `_ready()`. Handles version migration from v2+. +- **External sections**: Other systems (e.g. `PrestigeManager`) can attach their data via `set_external_save_data()` / `get_external_save_data()`. + +## Development + +### Adding New Content + +All gameplay resources go under `docs/gyms/tiny_sword/` (not `sandbox/` — that's for art assets only). + +**New Currency:** +1. Create `res://docs/gyms/tiny_sword/currencies/.tres` (extend `Currency`) +2. Set `id`, `display_name`, `icon` +3. Add to the `CurrencyCatalogue` resource (`ts_currency_catalogue.tres`) + +**New Generator:** +1. Create `res://docs/gyms/tiny_sword/buildings//_generator.tres` (extend `CurrencyGeneratorData`) +2. Configure production stats, costs, purchase currency, optional `unlock_goal` +3. Add a `CurrencyGenerator` node to the scene referencing this data + +**New Buff:** +1. Create `res://docs/gyms/tiny_sword/buffs/.tres` (extend `GeneratorBuffData`) +2. Set `target_ids` (e.g., `["farm", "forestry"]` or `["*"]` for all generators) +3. Configure `kind`, `effect_increment`, cost, and optional `unlock_goal` +4. Add to the `BuffCatalogue` resource (`ts_buff_catalogue.tres`) + +**New Research:** +1. Create `res://docs/gyms/tiny_sword/research/.tres` (extend `ResearchData`) +2. Link to a generator via `generator_id` +3. Configure `xp_per_currency_produced`, `base_xp_required`, `xp_growth_multiplier`, `multiplier_per_level` +4. Add to the `ResearchCatalogue` resource (`ts_research_catalogue.tres`) + +### BigNumber System + +- **Representation**: `mantissa: float` × `10^exponent: int` +- **Range**: Values up to ~10^308 before float precision loss +- **Operations**: `add()`, `subtract()`, `multiply()`, `divide()`, `compare_to()`, `add_in_place()` +- **Serialization**: `.serialize()` → `{"m": float, "e": int}`; `BigNumber.deserialize(dict)` → `BigNumber` +- **Display**: `.to_string_suffix(decimals: int)` → `"1.50K"`, `"3.21M"`, etc. + +### Production Formulas + +``` +Cost for N items: base × coefficient^owned × (coefficient^n − 1) / (coefficient − 1) +Max affordable: floor(log(currency × (coefficient − 1) / (base × coefficient^owned) + 1) / log(coefficient)) +Production/cycle: initial_productivity × owned × run_mult × milestone_mult × purchased_mult +Production/second: production/cycle / initial_time +``` + +## Project Structure + +``` +core/ # Engine code (all .gd scripts) +├── big_number.gd # BigNumber class +├── level_game_state.gd # Central state manager (LevelGameState node) +├── buff_catalogue.gd # Buff catalogue resource +├── currency_catalogue.gd # Currency catalogue resource +├── goal_catalogue.gd # Goal catalogue resource +├── currency/ # Currency resource definition +├── generator/ # Generator, buff data, UI panel, research buff calculator +├── goals/ # Goal/requirement resources +├── prestige/ # Prestige manager, config, panel +└── research/ # Research catalogue, panel, row UI + +docs/gyms/tiny_sword/ # Playable game content (.tres resources) +├── currencies/ # Currency definitions +├── buildings/ # Generator configs +├── buffs/ # Buff definitions +├── goals/ # Goal definitions +├── prestige/ # Prestige config +├── research/ # Research configs +└── test/ # (reserved for tests) + +sandbox/tiny_swords/ # Art assets (sprites, tilesets, UI textures) +└── Buildings/, Terrain/, UI Elements/, Units/ + +docs/museums/ # Prototype/demo scenes (big_number_museum) +``` + +## Known Issues / TODOs -The focus below is on behavior-bearing files and how they interact. +- [ ] Periodic auto-save not implemented (saves only on prestige and manual trigger) +- [ ] No automated test suite +- [ ] Some buffs may be configured with no unlock path -## Architecture Summary +## Technical Details -1. Global startup and singleton wiring is defined in `project.godot`. -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`) directly on generator data (`CurrencyGeneratorData.unlock_goal`). -6. UI scripts are thin adapters listening to `GameState` signals. +### Buff Multiplier Stacking -## Core Systems +Multiple buffs of the same kind on the same generator stack **multiplicatively**: -### 1) Numeric System (`BigNumber`) +``` +total_multiplier = buff1_effect(level) × buff2_effect(level) × buff3_effect(level) × … +``` -File: `core/big_number.gd` +### Prestige Multiplier Application -`BigNumber` stores values as mantissa + exponent (scientific notation style), normalizes them, and provides: +Generators apply prestige multipliers automatically. The prestige multiplier is fetched from `PrestigeManager.get_total_multiplier()` and multiplied into the effective production rate alongside research and buff multipliers. -1. Arithmetic operations (`add`, `subtract`, `multiply`, `divide`). -2. In-place fast accumulation (`add_in_place`) for frame-loop performance. -3. Safe comparisons (`compare_to`, `is_greater_than`, etc.). -4. Ratio conversion for progress UI (`get_ratio`). -5. Display formatting (`to_string_sci`, `to_string_suffix`). -6. JSON-friendly serialization/deserialization. +## License -This class is used everywhere currency values move. +Internal prototype — no external license applied. -### 2) Currency Catalog System +--- -Files: `core/currency/currency.gd`, `core/currency_database.gd`, `idles/currencies/*.tres` - -`CurrencyDatabase` scans `res://idles/currencies`, loads typed `Currency` resources, normalizes IDs, and resolves names/icons. - -Current data assets: - -1. `idles/currencies/magic.tres` (`id = magic`). -2. `idles/currencies/knowledge.tres` (`id = knowledge`). - -This creates a data-driven currency catalog that `GameState`, goals, and UI can query consistently. - -### 3) Global State + Persistence (`GameState`) - -File: `core/game_state.gd` - -`GameState` is the central state authority and signal bus. - -It owns: - -1. Current currency map by currency ID. -2. Total acquired currency map by currency ID. -3. Generator states (`owned`, `purchased_count`, `unlocked`, `available`). -4. Generator buff levels. -5. Generator buff unlocked flags. -6. `last_save_time`. - -Important signals: - -1. `currency_changed`. -2. `generator_state_changed`. -3. `generator_buff_level_changed`. -4. `generator_buff_unlocked_changed`. - -Persistence: - -1. Save path: `user://idle_save.json`. -2. Save payload includes currencies, generator states, buff levels, buff unlocked map, and timestamp. -3. Load path includes defensive sanitization and backward-compatible defaulting. - -### 4) Generator Economy Runtime - -Files: `core/generator/currency_generator.gd`, `core/generator/currency_generator_data.gd`, `core/generator/currency_generator.tscn` - -`CurrencyGenerator` implements active gameplay behavior: - -1. Auto production cycles (`_process`, cycle accumulation). -2. Click/hover grants with cooldown. -3. Buy-one and buy-max generator purchases. -4. Buff purchase logic and buff effect application. -5. State registration and lookup through `GameState`. - -`CurrencyGeneratorData` provides formulas and tuning knobs: - -1. Exponential cost growth. -2. Bulk-buy geometric cost. -3. Max-affordable estimate. -4. Milestone multipliers. -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 - -Files: `core/generator/generator_buff_data.gd`, `idles/buffs/*.tres` - -Buff kinds: - -1. Auto production multiplier. -2. Manual click multiplier. -3. Resource purchase (instant grant on buff buy). - -Each buff resource defines: - -1. Unlock rules (`unlocked` and optional `unlock_goal`). -2. Effect scaling (`base_effect`, `effect_increment`). -3. Cost scaling (`base_cost`, `cost_multiplier`). -4. Optional target currency and resource grant scaling. - -### 6) Goals and Unlock Progression - -Files: - -1. `core/goals/goal_requirement_data.gd` -2. `core/goals/goal_data.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. 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 - -Files: - -1. `generator_container.gd` + `generator_container.tscn`. -2. `generator_buff_tile.gd` + `generator_buff_tile.tscn`. -3. `currency_label.gd` + `currency_tile.tscn`. -4. `big_number_progress_bar.gd` + `big_number_progress_bar.tscn`. -5. `core/generator-unlock-goals/goals_debug_ui.gd` + `.tscn`. - -These scripts primarily: - -1. Subscribe to `GameState` and generator signals. -2. Convert runtime values to UI strings/states. -3. Trigger user actions (buy generator, buy buff, debug unlock). - -The architecture is mostly event-driven and avoids polling for state refresh. - -## Scene Composition - -### Active Gameplay Prototype - -File: `docs/museums/generator_museum.tscn` - -It composes: - -1. `MagicGenerator` instance. -2. `KnowledgeGenerator` instance (initially hidden and starts locked via data). -3. Currency tiles for `magic` and `knowledge`. -4. Goals debug UI panel. - -### Legacy/Secondary Museum - -Files: `docs/museums/big_number_museum.tscn`, `docs/museums/big_number_museum.gd` - -This appears to be an older/placeholder scene and not the current gameplay focus. - -## Data-Driven Content Snapshot - -### Generators - -1. `primary_generator.tres`: `Magic Orb`, active from start, includes 3 buffs. -2. `secondary_generator.tres`: `Library`, starts locked/unavailable and unlocks via `magic_total_30` goal. - -### Buffs (Magic) - -1. `magic_auto_flux.tres`: auto-production buff, unlocked by default. -2. `magic_click_focus.tres`: manual-click buff. -3. `magic_supply_cache.tres`: resource-purchase buff. - -### Goals - -1. `magic_total_30.tres`: reach total magic 30. -2. `magic_total_1300.tres`: reach total magic 1300. - -## End-to-End Runtime Flow - -1. `project.godot` autoloads `CurrencyDatabase` and `GameState`. -2. `CurrencyDatabase` builds currency catalog from `idles/currencies`. -3. `GameState` initializes per-currency maps and loads save data. -4. Scene nodes initialize and connect to `GameState` and local gameplay signals. -5. Generators produce currency automatically and via click/hover paths. -6. Currency updates emit `currency_changed` and drive UI refresh + unlock checks. -7. Goal unlock evaluator may transition generator state to unlocked/available. -8. Generator and buff purchases route all balance/state mutations through `GameState`. - -## Signal Guide (Emitters and Listeners) - -### Custom Gameplay Signals - -| 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`, `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` | -| `purchase_failed(requested_amount, required_cost, available_currency)` | `core/generator/currency_generator.gd` | `CurrencyGenerator.buy` | Generator purchase fails for insufficient funds | `GeneratorPanel._on_generator_updated` | -| `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(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 - -| Source Signal | Connected In | Listener Method | Purpose | -| --- | --- | --- | --- | -| `Area2D.mouse_entered` | `core/generator/currency_generator.tscn` | `CurrencyGenerator._on_area_2d_mouse_entered` | Enter generator hover zone; mark hover true and show generator panel | -| `Area2D.mouse_exited` | `core/generator/currency_generator.tscn` | `CurrencyGenerator._on_area_2d_mouse_exited` | Exit generator hover zone; mark hover false and hide generator panel | -| `BuyOneButton.pressed` | `generator_container.tscn` | `GeneratorPanel._on_buy_pressed` | Buy one generator | -| `BuyMaxButton.pressed` | `generator_container.tscn` | `GeneratorPanel._on_buy_max_pressed` | Buy max affordable generators | -| `GeneratorContainer.mouse_entered` | `generator_container.tscn` | `GeneratorPanel._on_mouse_entered` | Intended hover hook on panel container (method currently missing) | -| `GeneratorContainer.mouse_exited` | `generator_container.tscn` | `GeneratorPanel._on_mouse_exited` | Intended hover hook on panel container (method currently missing) | -| `BuyButton.pressed` | `generator_buff_tile.tscn` | `GeneratorBuffTile._on_buy_button_pressed` | Emit tile-level `buy_pressed` custom signal | -| `DebugIncomeButton.pressed` | `currency_tile.tscn` | `CurrencyTile._on_debug_income_button_pressed` | Add debug income to the tile currency | -| `Button.pressed` (runtime-created unlock button) | `core/generator-unlock-goals/goals_debug_ui.gd` | `GoalsDebugUI._on_unlock_pressed` (bound with goal id) | Manual debug unlock when a goal is ready | - -### Common Signal Chains - -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 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 - -1. `project.godot` has no explicit `run/main_scene` entry, so startup scene is not pinned in tracked config. -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 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. - -## Additional Notes - -1. `.uid` files are identity metadata used by Godot and contain no runtime logic. -2. `icon.svg` and `icon.svg.import` are standard icon/import metadata. -3. Root config files (`.editorconfig`, `.gitattributes`, `.gitignore`) are minimal and conventional. +*Godot version: 4.6* diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..3b1b8c2 --- /dev/null +++ b/TODO.md @@ -0,0 +1,283 @@ +# TODO + +## Ascension Buff Graph — Needs Review Before Implementation + +### Goal + +Add a permanent buff graph system: +- **Separate** from existing `GeneratorBuffData` (which resets on prestige). +- **Permanent** — prestige does NOT reset ascension buff state. +- **Unlocked** by spending ascension currency (earned during prestige). +- **Graph/DAG-structured** — nodes have prerequisites (parent_ids) that must be met before a node can be purchased. +- **One-shot unlocks** — each node is either locked or unlocked. No repeatable levels. + +### Data Structure + +#### `AscensionBuffNode` (Resource) +Single node in the graph. + +| Field | Type | Notes | +|-------|------|-------| +| `id` | `StringName` | Unique node ID | +| `display_name` | `String` | UI label | +| `description` | `String` (multiline) | Tooltip | +| `icon` | `Texture2D` | Visual | +| `parent_ids` | `Array[StringName]` | Prerequisites (DAG — multiple parents allowed) | +| `effect_type` | `enum EffectType` | What this buff does | +| `effect_value` | `float` | Effect magnitude | +| `target_id` | `StringName` | Target generator/currency for type-specific effects (empty = global) | +| `cost_mantissa` | `float` | Ascension currency cost | +| `cost_exponent` | `int` | | +| `tier` | `int` | Row in graph UI (visual grouping) | +| `x_position` | `float` | Column in graph UI | + +**Effect types** (extensible enum): + +``` +GLOBAL_PRODUCTION_MULTIPLIER → multiply all generator output +GENERATOR_PRODUCTION_MULTIPLIER → multiply one generator's output +CURRENCY_PER_RUN → start each prestige with this currency +COST_REDUCTION → reduce all generator purchase costs +PRESTIGE_GAIN_MULTIPLIER → earn more ascension currency per prestige +GENERATOR_COUNT_MULTIPLIER → scaling based on owned generator count +RESEARCH_XP_MULTIPLIER → faster research leveling +``` + +#### `AscensionBuffCatalogue` (Resource) +Flat list of nodes. The graph emerges from `parent_ids`. Follows existing catalogue pattern (`BuffCatalogue`, `GoalCatalogue`, etc.). + +| Field | Type | +|-------|------| +| `nodes` | `Array[AscensionBuffNode]` | + +#### State in `LevelGameState` + +```gdscript +@export var ascension_buff_catalogue: AscensionBuffCatalogue +var _ascension_buff_unlocked: Dictionary = {} # {StringName: bool} — node_id → unlocked +``` + +- Never cleared by `reset_for_prestige()` (unlike `_buff_levels` for generator buffs, which IS cleared). +- Normalized/sanitized on load (like generator states). + +### Save Format + +- Bump `CURRENT_SAVE_FORMAT_VERSION` from 7 → 8. +- New key: `"ascension_buff_unlocked"` → array of unlocked node_id strings: `["strong_start", "efficiency"]`. + +### API on LevelGameState + +| Method | Purpose | +|--------|---------| +| `purchase_ascension_buff(buff_id) → bool` | Spend ascension currency, unlock node, emit signal, save | +| `can_purchase_ascension_buff(buff_id) → bool` | All parent_ids unlocked? Not already unlocked? Enough currency? | +| `is_ascension_buff_unlocked(buff_id) → bool` | Check if node is unlocked | +| `get_ascension_buff_cost(buff_id) → BigNumber` | Fixed cost for the node | +| `get_ascension_buff_multiplier(effect_type, target_id) → float` | Walk graph, compute combined effect from unlocked nodes | +| `get_available_ascension_buffs() → Array[AscensionBuffNode]` | Nodes whose prerequisites are all met | + +### Signals + +```gdscript +signal ascension_buff_unlocked(buff_id: StringName) +``` + +Ascension currency balance changes are covered by the existing `currency_changed` signal — no separate signal needed. + +### Prestige Integration + +No changes needed to `PrestigeManager` — `_reset_all_buff_levels()` only touches `_buff_levels` (generator buffs), not `_ascension_buff_unlocked`. Ascension currency earning already emits `currency_changed` via `add_currency_by_id()`. The new dictionary naturally survives the reset. + +### Purchase Flow + +``` +Player clicks unlock node + → can_purchase_ascension_buff(node_id) + → All parent_ids unlocked? + → Not already unlocked? + → Enough ascension currency? + → YES: spend_currency_by_id(ascension_currency, cost) + → _ascension_buff_unlocked[node_id] = true + → emit ascension_buff_unlocked + → save_game() +``` + +### Example Graph Content (Illustrative) + +``` +Tier 0 (roots): + strong_start → +10 gold/run cost: 1 parents: [] + efficiency → +10% global prod cost: 1 parents: [] + thrifty → -5% gen costs cost: 2 parents: [] + +Tier 1: + better_start → +100 gold/run cost: 3 parents: [strong_start] + enhanced_efficiency → +25% global prod cost: 3 parents: [efficiency] + double_dip → +50% gold from forestry cost: 5 parents: [strong_start, efficiency] + +Tier 2: + power_surge → +100% global prod cost: 10 parents: [better_start, enhanced_efficiency] +``` + +### Files to Create/Modify + +| File | Action | Purpose | +|------|--------|---------| +| `core/ascension/ascension_buff_node.gd` | Create | Node resource | +| `core/ascension/ascension_buff_catalogue.gd` | Create | Catalogue resource | +| `core/ascension/ascension_buff_graph_panel.gd` | Create | Separate-screen UI panel | +| `core/level_game_state.gd` | Modify | Add catalogue ref, `_ascension_buff_unlocked`, purchase/query methods, save/load, signal | +| `docs/gyms/tiny_sword/ascension/*.tres` | Create | `.tres` resources for actual graph content | + +--- + +## Implementation Plan + +### Step 1: `core/ascension/ascension_buff_node.gd` + +Create new `Resource` class. + +- `class_name AscensionBuffNode extends Resource` +- `enum EffectType` with all seven types from the data structure table +- `@export` fields: `id`, `display_name`, `description` (multiline), `icon`, `parent_ids`, `effect_type`, `effect_value`, `target_id`, `cost_mantissa`, `cost_exponent`, `tier`, `x_position` +- Helper methods: + - `get_cost() → BigNumber` — returns `BigNumber.new(cost_mantissa, cost_exponent)` + - `has_parents() → bool` — returns `not parent_ids.is_empty()` + - `all_parents_unlocked(unlocked: Dictionary) → bool` — checks every id in `parent_ids` is in the dict as `true` + - `is_valid() → bool` — id not empty, cost_mantissa > 0 + +### Step 2: `core/ascension/ascension_buff_catalogue.gd` + +Create new `Resource` class. Follows `BuffCatalogue` pattern exactly. + +- `class_name AscensionBuffCatalogue extends Resource` +- `@export var nodes: Array[AscensionBuffNode] = []` +- `get_node_by_id(id: StringName) → AscensionBuffNode` — linear search +- `get_all_ids() → Array[StringName]` — collect non-empty ids +- `get_root_nodes() → Array[AscensionBuffNode]` — nodes with empty `parent_ids` +- `get_children_of(parent_id: StringName) → Array[AscensionBuffNode]` — nodes that list this id in `parent_ids` + +### Step 3: Modify `core/level_game_state.gd` + +**3a. Add export, state, signal, constants** + +- Add `@export var ascension_buff_catalogue: AscensionBuffCatalogue` in the export section +- Add `var _ascension_buff_unlocked: Dictionary = {}` in state variables +- Add signal: `signal ascension_buff_unlocked(buff_id: StringName)` in signals section +- Add constant: `const ASCENSION_BUFF_UNLOCKED_KEY: String = "ascension_buff_unlocked"` in save/load constants +- Bump `CURRENT_SAVE_FORMAT_VERSION` from 7 to 8 + +**3b. Add initialization in `_ready()`** + +After `_initialize_catalogues()`, call a new `_initialize_ascension_buffs()` method that: +- Iterates `ascension_buff_catalogue.nodes` (if catalogue is set) +- For each node id not already in `_ascension_buff_unlocked`, sets it to `false` + +**3c. Add save serialization** + +In `save_game()`, add: +```gdscript +ASCENSION_BUFF_UNLOCKED_KEY: _serialize_ascension_buff_unlocked(), +``` + +New private method `_serialize_ascension_buff_unlocked() → Array`: +- Returns an array of node id strings where `_ascension_buff_unlocked[id] == true` + +**3d. Add load deserialization** + +In `load_game()`, under the save format version check, add: +```gdscript +if parsed_data.has(ASCENSION_BUFF_UNLOCKED_KEY): + _deserialize_ascension_buff_unlocked(parsed_data.get(ASCENSION_BUFF_UNLOCKED_KEY)) +``` + +New private method `_deserialize_ascension_buff_unlocked(raw: Variant) → void`: +- If raw is Array, for each string element set `_ascension_buff_unlocked[StringName(s)] = true` +- Then call `_initialize_ascension_buffs()` to fill in any missing node ids as `false` + +**3e. Add API methods** + +In a new `#region ascension_buff` section: + +- `purchase_ascension_buff(buff_id: StringName) → bool` + - Guard: if not `can_purchase_ascension_buff(buff_id)`, return false + - Get node from catalogue, get cost via `BigNumber` from node's mantissa/exponent + - Call `spend_currency_by_id(ascension_currency_id, cost)` — use `get_ascension_currency_id()` helper + - Set `_ascension_buff_unlocked[buff_id] = true` + - Emit `ascension_buff_unlocked.emit(buff_id)` + - `save_game()` + - Return true + +- `can_purchase_ascension_buff(buff_id: StringName) → bool` + - Node must exist in catalogue + - Not already unlocked (`_ascension_buff_unlocked.get(buff_id, false) == false`) + - All parent_ids unlocked (use node's helper or iterate) + - Enough ascension currency (check via `get_currency_amount_by_id(ascension_currency_id)`) + +- `is_ascension_buff_unlocked(buff_id: StringName) → bool` + - `return _ascension_buff_unlocked.get(buff_id, false)` + +- `get_ascension_buff_cost(buff_id: StringName) → BigNumber` + - Get node, return `BigNumber.new(node.cost_mantissa, node.cost_exponent)` + +- `get_ascension_buff_multiplier(effect_type: int, target_id: StringName = &"") → float` + - Iterate all unlocked nodes matching the effect type + - Multiply their `effect_value` together + - If `target_id` is non-empty, filter nodes whose `target_id` matches or is empty (global) — TBD based on type semantics + - Return the product (starting from 1.0) + +- `get_available_ascension_buffs() → Array[AscensionBuffNode]` + - Iterate all catalogue nodes not yet unlocked where all parent_ids are unlocked + +- `get_ascension_currency_id() → StringName` (private helper or public) + - Look up the ascension currency ID from PrestigeManager's config, or use a well-known ID like `&"ascension"`. Prefer using the catalogue to determine it. For now, return `&"ascension"` and validate it exists in the currency catalogue. + +**3f. Ensure prestige does NOT reset ascension buffs** + +Verify `reset_for_prestige()` does not touch `_ascension_buff_unlocked`. It currently clears `_buff_levels` (generator buffs), `generator_states`, `research_xp`, `research_levels`, goals, etc. No change needed — `_ascension_buff_unlocked` is a separate dictionary that is not listed in the reset logic. + +### Step 4: Create `core/ascension/ascension_buff_graph_panel.gd` + +Create a new `PanelContainer`-based UI. This is a separate screen — it can be toggled visible/hidden (e.g., via a button in the main UI). + +- `class_name AscensionBuffGraphPanel extends PanelContainer` +- `@onready` references to: + - Title label + - `ScrollContainer`/`VBoxContainer` for tier rows + - Ascension currency balance label +- `_game_state: LevelGameState` — found via `find_parent("LevelGameState")` +- In `_ready()`: + - Connect `game_state.ascension_buff_unlocked` → refresh + - Connect `game_state.currency_changed` → refresh ascension currency label + - Call `_build_graph()` +- `_build_graph()`: + - Clear existing rows + - Group all catalogue nodes by `tier`, sort tiers ascending + - For each tier, create an `HBoxContainer` row + - For each node in the tier (sorted by `x_position`), instantiate a tile scene +- **Tile scene** (`ascension_buff_graph_tile.tscn` + `.gd`): + - A small `Button`/`Panel` showing: + - Node icon + - Node display_name + - Effect description (e.g., "+10% global production") + - Cost in ascension currency + - Visual state: locked (greyed out), available (highlighted, purchasable), unlocked (green check) + - On press: call `_game_state.purchase_ascension_buff(node_id)` + - Connect to `ascension_buff_unlocked` and `currency_changed` to re-evaluate state +- Ascension currency label at the top showing current balance + +### Step 5: Create content `.tres` files (deferred) + +Content is not defined yet per resolved question 4. When ready: +- Create `docs/gyms/tiny_sword/ascension/` directory +- Create `ascension_buff_catalogue.tres` resource with nodes +- Wire it into the `LevelGameState` node in `tiny_sword.tscn` via editor + +--- + +### Resolved Questions + +1. **Effect stacking**: Multiply — consistent with the existing generator buff multiplier pattern. +2. **UI placement**: A separate screen. +3. **Ascension currency spending**: Spend through `LevelGameState.spend_currency_by_id()` — ascension currency is already in the currency catalogue. +4. **Content**: None at the moment — define later. diff --git a/big_number_progress_bar.gd b/big_number_progress_bar.gd index 773a2e0..7480214 100644 --- a/big_number_progress_bar.gd +++ b/big_number_progress_bar.gd @@ -1,6 +1,7 @@ extends HBoxContainer -@export var currency: Resource +@export var currency: Currency +@export var game_state: LevelGameState @onready var _label_start = $LabelStart @onready var _label_end = $LabelEnd @@ -22,15 +23,16 @@ func set_limits(start: BigNumber, end: BigNumber) -> void: _progress_bar.max_value = _end.get_ratio(_start) func _ready() -> void: - if currency == null: - currency = CurrencyDatabase.get_currency_resource(GameState.GOLD_CURRENCY_ID) if currency == null: push_warning("BigNumberProgressBar '%s' has no currency configured." % String(name)) return - _currency_id = GameState.get_currency_id(currency) + if game_state == null: + push_warning("BigNumberProgressBar '%s' has no game_state configured." % String(name)) + return + _currency_id = game_state.get_currency_id(currency) - GameState.currency_changed.connect(_on_currency_changed) - _current = GameState.get_currency_amount_by_id(_currency_id) + game_state.currency_changed.connect(_on_currency_changed) + _current = game_state.get_currency_amount_by_id(_currency_id) set_limits(_current, _current.add(BigNumber.new(1, 1))) diff --git a/core/README.md b/core/README.md new file mode 100644 index 0000000..71ce681 --- /dev/null +++ b/core/README.md @@ -0,0 +1,266 @@ +# Core Module Documentation + +## Overview + +The `core/` directory contains the fundamental systems for the idle game. It provides: + +- **Numeric Foundation**: `BigNumber` for handling huge idle game values +- **Game State Management**: `LevelGameState` for all persistent state +- **Currency System**: `CurrencyCatalogue` and tracking for multiple currency types +- **Generator System**: Automated resource production with scaling costs +- **Buff System**: Upgrades that modify generator behavior +- **Goal System**: Achievement tracking and unlock triggers +- **Prestige System**: Rebirth mechanics with permanent multipliers +- **Research System**: XP-based progression with production multipliers + +## Architecture + +``` +core/ +├── big_number.gd # Core numeric API +├── level_game_state.gd # Main state management (Node) +├── currency/ # Currency data structures + CurrencyCatalogue +├── currency_catalogue.gd # Currency catalogue resource +├── buff_catalogue.gd # Buff catalogue resource +├── goal_catalogue.gd # Goal catalogue resource +├── generator/ # Generator logic and data +├── goals/ # Goal/achievement system +├── prestige/ # Prestige/rebirth system +└── research/ # Research XP and progression +``` + +## Data Flow + +1. **Game Start**: `LevelGameState._ready()` initializes currencies, loads save +2. **Player Action**: Generator bought → `LevelGameState` updated → signals emitted +3. **UI Reaction**: Listeners respond to signals → update display +4. **Goal Check**: Currency changed → goals evaluated → buffs unlocked +5. **Save**: `LevelGameState.save_game()` → JSON to `user://level_save.json` + +## Production Multiplier Chain + +All multipliers compose multiplicatively from callee to caller. The entry point is +`_grant_cycle_income()` in `CurrencyGenerator`. + +### Entry: `CurrencyGenerator._grant_cycle_income()` (`currency_generator.gd:134`) + +``` +var effective = get_effective_auto_run_multiplier() +var per_cycle = data.production_per_cycle(owned, effective, purchased_count) +var produced = per_cycle × cycle_count +``` + +### Layer 1 — `CurrencyGeneratorData.production_per_cycle()` (`currency_generator_data.gd:117`) + +``` +production_per_cycle = initial_productivity × owned × run_multiplier × milestone_mult × purchased_mult +``` + +| Sub-multiplier | Method | Formula | +|---|---|---| +| **milestone_mult** | `milestone_multiplier_total(owned)` | `milestone_multiplier ^ floor(owned / milestone_step)` | +| **purchased_mult** | `purchased_multiplier(purchased)` | `1.0 + purchased × (purchased_boost_percent / 100)` | + +### Layer 2 — `CurrencyGenerator.get_effective_auto_run_multiplier()` (`currency_generator.gd:357`) + +This is the `run_multiplier` value passed into Layer 1 above: + +``` +effective_run_multiplier = run_multiplier × research_multiplier × auto_production_buffs × prestige_multiplier +``` + +#### 2a. `run_multiplier` + +Exported `float` on the `CurrencyGenerator` node. Default `1.0`. A scene-level tweak. + +#### 2b. `research_multiplier` → `LevelGameState.get_research_multiplier(id)` (`level_game_state.gd:488`) → `ResearchData.get_multiplier_for_level(level)` (`research/research_data.gd:52`) + +``` +research_multiplier = base_multiplier + (multiplier_per_level × research_level) +``` + +Level is auto-calculated from accumulated BigNumber XP. Both constants are exports on `ResearchData`. + +#### 2c. `auto_production_buffs` → `LevelGameState.get_effective_multiplier(generator_id, AUTO_PRODUCTION_MULTIPLIER)` (`level_game_state.gd:441`) + +```gdscript +# For every active buff of kind AUTO_PRODUCTION_MULTIPLIER targeting this generator: +mult = 1.0 +for buff in buffs: + if buff.active and buff.kind == AUTO_PRODUCTION_MULTIPLIER: + mult *= buff.get_effect_multiplier(level) +``` + +`GeneratorBuffData.get_effect_multiplier(level)` (`generator_buff_data.gd:97`): + +``` +buff_effect = base_effect + (effect_increment × level) +``` + +Multiple buffs of the same kind stack **multiplicatively**: `∏ (base_effect_i + increment_i × level_i)`. + +#### 2d. `prestige_multiplier` → `PrestigeManager.get_total_multiplier()` (`prestige_manager.gd:107`) + +Configured via `PrestigeConfig.multiplier_mode`: + +| Mode | Formula | +|---|---| +| `ADDITIVE` | `base + prestige_points × multiplier_per_prestige` | +| `MULTIPLICATIVE_POWER` | `base × (1 + multiplier_per_prestige) ^ (prestige_points ^ exponent)` | + +### Layer 3 — Production per second + +``` +production_per_second = production_per_cycle / maxf(initial_time, 0.0001) +``` + +(`currency_generator_data.gd:128`) + +### Full Formula (one production cycle) + +``` +produced = initial_productivity × owned + + # --- effective_run_multiplier --- + × run_multiplier + × (base_multiplier + multiplier_per_level × research_level) + × ∏(base_effect_i + effect_increment_i × buff_level_i) [auto-production buffs] + × prestige_multiplier [from PrestigeManager] + + # --- milestone & purchased --- + × milestone_multiplier ^ floor(owned / milestone_step) + × (1.0 + purchased_count × purchased_boost_percent / 100) +``` + +### Manual Click Formula (separate path) + +`_try_grant_click_currency()` (`currency_generator.gd:460`): + +``` +click_reward = (click_mantissa × 10^click_exponent) × ∏ buff_effect_i [MANUAL_CLICK_MULTIPLIER buffs] +``` + +Uses the same `get_effective_multiplier()` loop filtered to `BuffKind.MANUAL_CLICK_MULTIPLIER`. +Research, prestige, milestone, and purchased multipliers are **not** applied to clicks. + +### Research XP Awarded (production side-effect) + +Awarded inside `_grant_cycle_income()` when the generator has an associated `ResearchData`: + +``` +base_xp = produced_currency × xp_per_currency_produced +scaled_xp = base_xp × (1.0 + research_workers × worker_scaling_factor) +actual_xp = scaled_xp × ∏ buff_effect_i [RESEARCH_XP_MULTIPLIER buffs] +``` + +Buff multiplier for XP is applied later in `LevelGameState.add_research_xp()` +via `ResearchBuffCalculator.apply_buffs()` (`core/generator/research_buff_calculator.gd`). + +### Cascade Diagram + +``` + ┌─────────────────────────────────┐ + │ run_multiplier (node export) │ + │ research_multiplier (XP level) │─── effective_run_multiplier ──┐ + │ auto-production buffs (∏) │ │ + │ prestige_multiplier │ │ + └─────────────────────────────────┘ │ + ▼ +production_per_cycle = initial_productivity × owned × effective_run_multiplier × milestone × purchased + │ + ▼ +production_per_second = production_per_cycle / initial_time +``` + +## Save Format + +**Version**: 5 + +```json +{ + "save_format_version": 5, + "currencies": { + "": { + "current": {"m": float, "e": int}, + "total": {"m": float, "e": int}, + "all_time": {"m": float, "e": int} + } + }, + "generator_states": { + "": { + "owned": int, + "purchased_count": int, + "unlocked": bool, + "available": bool + } + }, + "buff_levels": { + "": int + }, + "buff_unlocked": { + "": true + }, + "buff_active": { + "": true + }, + "goals": { + "completed": ["", ...] + }, + "research_xp": { + "": {"m": float, "e": int} + }, + "research_levels": { + "": int + }, + "last_save_time": unix_timestamp +} +``` + +## Key Signals + +| Signal | Emitted When | +|--------|--------------| +| `currency_changed(currency_id, new_amount)` | Any currency balance changes | +| `generator_state_changed(generator_id, state)` | Generator owned/unlocked changes | +| `generator_buff_level_changed(gen_id, buff_id, level)` | Buff level increased | +| `buff_unlocked_changed(buff_id, unlocked)` | Buff unlock state changed | +| `goal_completed(goal_id)` | Goal criteria met | + +## BigNumber Serialization + +All large numbers use scientific notation internally: +```gdscript +# Value = mantissa * 10^exponent +var num = BigNumber.new(1.5, 24) # 1.5e24 +var dict = num.serialize() # {"m": 1.5, "e": 24} +var restored = BigNumber.deserialize(dict) +``` + +## Extending the Core + +### Adding a New Currency +1. Create `res://sandbox/currencies/.tres` extending `Currency` +2. Set `id`, `display_name`, and `icon` +3. Add to `CurrencyCatalogue` resource assigned to `LevelGameState` + +### Adding a New Generator +1. Create `res://sandbox/generators/.tres` extending `CurrencyGeneratorData` +2. Configure costs, production, and unlock goals +3. Instance `CurrencyGenerator` scene with this data + +### Adding a New Buff +1. Create `res://sandbox/buffs/.tres` extending `GeneratorBuffData` +2. Set `target_ids`, `kind`, `effect_increment`, and `cost` +3. Add to `BuffCatalogue` resource assigned to `LevelGameState` + +### Adding New Research +1. Create `res://sandbox/research/.tres` extending `ResearchData` +2. Link to a generator via `generator_id` +3. Add to `ResearchCatalogue` resource assigned to `LevelGameState` + +## See Also + +- `currency/` - Currency data structures +- `generator/` - Generator production system +- `goals/` - Achievement system +- `prestige/` - Rebirth mechanics diff --git a/core/big_number.gd b/core/big_number.gd index 92dda83..de29fc3 100644 --- a/core/big_number.gd +++ b/core/big_number.gd @@ -195,6 +195,16 @@ func get_ratio(target: BigNumber) -> float: static func from_float(val: float) -> BigNumber: return BigNumber.new(val, 0) +## Converts this BigNumber to a standard float. Returns INF or 0.0 for extreme values. +func to_float() -> float: + if mantissa <= 0.0: + return 0.0 + if exponent > 308: + return INF + if exponent < -308: + return 0.0 + return mantissa * pow(10.0, float(exponent)) + ## Outputs a UI-friendly string (e.g., "1.50e12") func to_string_sci(decimals: int = 2) -> String: if exponent < 3: diff --git a/core/buff_catalogue.gd b/core/buff_catalogue.gd new file mode 100644 index 0000000..63ecb2f --- /dev/null +++ b/core/buff_catalogue.gd @@ -0,0 +1,24 @@ +class_name BuffCatalogue +extends Resource + +@export var buffs: Array[GeneratorBuffData] = [] + +func get_buff_by_id(id: StringName) -> GeneratorBuffData: + for buff in buffs: + if buff.id == id: + return buff + return null + +func get_all_ids() -> Array[StringName]: + var ids: Array[StringName] = [] + for buff in buffs: + if buff.id: + ids.append(buff.id) + return ids + +func get_buffs_for_generator(generator_id: StringName) -> Array[GeneratorBuffData]: + var result: Array[GeneratorBuffData] = [] + for buff in buffs: + if buff.targets_generator(generator_id): + result.append(buff) + return result diff --git a/core/buff_catalogue.gd.uid b/core/buff_catalogue.gd.uid new file mode 100644 index 0000000..7021592 --- /dev/null +++ b/core/buff_catalogue.gd.uid @@ -0,0 +1 @@ +uid://ctc5yjlnvi0ok diff --git a/core/currency/README.md b/core/currency/README.md new file mode 100644 index 0000000..58e58ab --- /dev/null +++ b/core/currency/README.md @@ -0,0 +1,111 @@ +# Currency Module Documentation + +## Overview + +The `currency/` subfolder contains the data structure for defining in-game currencies. + +## Files + +### `currency.gd` + +Defines the `Currency` class - a Resource for currency metadata. + +## Currency Class + +```gdscript +class_name Currency +extends Resource + +@export var id: StringName = &"" # Unique identifier (e.g., &"gold") +@export var display_name: String = "" # UI-friendly name +@export var icon: Texture2D # Display icon +``` + +## Usage + +### Defining a Currency + +Create a `.tres` resource file with: +- **id**: Lowercase unique key (used internally) +- **display_name**: What players see ("Gold", "Gems") +- **icon**: Texture for UI display + +Example `res://sandbox/currencies/gold.tres`: +``` +[gd_resource type="Resource" script_class="Currency"] +[resource] +id = &"gold" +display_name = "Gold" +icon = ExtResource("uid://...") +``` + +### Accessing Currencies + +Currencies are defined in a `CurrencyCatalogue` resource that holds an array of `Currency` resources. + +```gdscript +# Get all known currency IDs from the catalogue +var ids: Array[StringName] = currency_catalogue.get_all_ids() + +# Get currency resource by ID +var gold: Currency = currency_catalogue.get_currency_by_id(&"gold") +``` + +## Integration with LevelGameState + +Currencies are tracked in three ways: +1. **current**: Current balance (resets on prestige) +2. **total**: Total acquired this run (resets on prestige) +3. **all_time**: Lifetime total (never resets) + +```gdscript +# Add currency +level_game_state.add_currency(gold_resource, BigNumber.new(100.0, 0)) + +# Add by ID +level_game_state.add_currency_by_id(&"gold", BigNumber.new(100.0, 0)) + +# Get balance +var balance: BigNumber = level_game_state.get_currency_amount_by_id(&"gold") + +# Spend currency (returns false if insufficient) +var success: bool = level_game_state.spend_currency_by_id(&"gold", BigNumber.new(50.0, 0)) + +# Check if currency exists +var is_valid: bool = level_game_state.is_known_currency_id(&"gems") + +# Get currency resource +var gold: Currency = level_game_state.get_currency_resource(&"gold") + +# Get display name +var name: String = level_game_state.get_currency_name(&"gold") +``` + +## CurrencyCatalogue Class + +```gdscript +class_name CurrencyCatalogue +extends Resource + +@export var currencies: Array[Currency] = [] + +func get_currency_by_id(id: StringName) -> Currency +func get_all_ids() -> Array[StringName] +``` + +The `CurrencyCatalogue` is a resource that holds all defined currencies and is assigned to `LevelGameState` via the `currency_catalogue` export variable. + +## Signals + +`LevelGameState` emits `currency_changed(currency_id: StringName, new_amount: BigNumber)` whenever a currency balance changes. + +## See Also + +- `core/level_game_state.gd` - Currency state management +- `core/big_number.gd` - Large number handling +- `core/currency/currency_catalogue.gd` - Currency catalogue resource +- `core/generator/currency_generator_data.gd` - Generator purchase currency + + + +/home/mikymod/work/jmp/idle/core/currency/README.md diff --git a/core/currency/currency.gd b/core/currency/currency.gd index 31f81e8..bb8da2f 100644 --- a/core/currency/currency.gd +++ b/core/currency/currency.gd @@ -4,3 +4,4 @@ extends Resource @export var id: StringName = &"" @export var display_name: String = "" @export var icon: Texture2D +@export var always_visible: bool = false diff --git a/core/currency/currency_catalogue.gd b/core/currency/currency_catalogue.gd new file mode 100644 index 0000000..7a2d0c0 --- /dev/null +++ b/core/currency/currency_catalogue.gd @@ -0,0 +1,17 @@ +class_name CurrencyCatalogue +extends Resource + +@export var currencies: Array[Currency] = [] + +func get_currency_by_id(id: StringName) -> Currency: + for currency in currencies: + if currency.id == id: + return currency + return null + +func get_all_ids() -> Array[StringName]: + var ids: Array[StringName] = [] + for currency in currencies: + if currency.id != &"": + ids.append(currency.id) + return ids diff --git a/core/currency/currency_catalogue.gd.uid b/core/currency/currency_catalogue.gd.uid new file mode 100644 index 0000000..4eaf29c --- /dev/null +++ b/core/currency/currency_catalogue.gd.uid @@ -0,0 +1 @@ +uid://621tus0uvbrd diff --git a/core/currency/currency_panel.gd b/core/currency/currency_panel.gd new file mode 100644 index 0000000..c7cb9b0 --- /dev/null +++ b/core/currency/currency_panel.gd @@ -0,0 +1,35 @@ +## Dynamically displays all currencies from the LevelGameState catalogue as CurrencyTile widgets. +class_name CurrencyPanel +extends PanelContainer + +const CURRENCY_TILE_SCENE: PackedScene = preload("res://currency_tile.tscn") + +@onready var _tiles_container: VBoxContainer = $CurrencyTiles + +var _game_state: LevelGameState + +func _ready() -> void: + _game_state = find_parent("LevelGameState") + if _game_state == null: + push_error("CurrencyPanel: No LevelGameState found in parent hierarchy") + return + _build_currency_tiles() + _game_state.ready.connect(_on_game_state_ready) + +func _build_currency_tiles() -> void: + for child in _tiles_container.get_children(): + child.queue_free() + + if _game_state.currency_catalogue == null: + return + + for currency in _game_state.currency_catalogue.currencies: + if currency == null: + continue + var tile: CurrencyTile = CURRENCY_TILE_SCENE.instantiate() + tile.currency = currency + tile.game_state = _game_state + _tiles_container.add_child(tile) + +func _on_game_state_ready() -> void: + pass diff --git a/core/currency/currency_panel.gd.uid b/core/currency/currency_panel.gd.uid new file mode 100644 index 0000000..f5e762d --- /dev/null +++ b/core/currency/currency_panel.gd.uid @@ -0,0 +1 @@ +uid://blfou5xiynxqf diff --git a/core/currency_database.gd b/core/currency_database.gd deleted file mode 100644 index cb0921a..0000000 --- a/core/currency_database.gd +++ /dev/null @@ -1,164 +0,0 @@ -extends Node - -#const GOLD_CURRENCY_ID: StringName = &"gold" -#const GEMS_CURRENCY_ID: StringName = &"gems" -#const LEGACY_CURRENCY_INDEX_TO_ID: Dictionary = { - #0: GOLD_CURRENCY_ID, - #1: GEMS_CURRENCY_ID, -#} - -const CURRENCY_DIRECTORY_PATH: String = "res://idles/currencies" -const CURRENCY_RESOURCE_EXTENSIONS: PackedStringArray = ["tres", "res"] - -var _currency_by_id: Dictionary = {} -var _catalog_initialized: bool = false - -func _ready() -> void: - _initialize_currency_catalog() - -func get_known_currencies() -> Array[Currency]: - _initialize_currency_catalog_if_needed() - - var result: Array[Currency] = [] - for raw_currency in _currency_by_id.values(): - var currency: Currency = raw_currency as Currency - if currency != null: - result.append(currency) - return result - -func get_known_currency_ids() -> Array[StringName]: - _initialize_currency_catalog_if_needed() - - var ids: Array[StringName] = [] - for raw_currency_id in _currency_by_id.keys(): - var currency_id: StringName = _normalize_currency_id(StringName(String(raw_currency_id))) - if currency_id == &"": - continue - ids.append(currency_id) - return ids - -func get_currency_id(currency: Currency) -> StringName: - if currency == null: - return &"" - - var raw_id: Variant = currency.id - if raw_id is StringName: - return _normalize_currency_id(raw_id) - return _normalize_currency_id(StringName(String(raw_id))) - -func parse_currency_id(raw_currency: Variant) -> StringName: - #if raw_currency is int: - #return currency_id_from_legacy_index(raw_currency) - - var currency_text: String = String(raw_currency).to_lower().strip_edges() - if currency_text == "gem": - currency_text = "gems" - return _normalize_currency_id(StringName(currency_text)) - -#func currency_id_from_legacy_index(currency_index: int) -> StringName: - #return LEGACY_CURRENCY_INDEX_TO_ID.get(currency_index, &"") - -func is_known_currency_id(currency_id: StringName) -> bool: - _initialize_currency_catalog_if_needed() - - var normalized_currency_id: StringName = _normalize_currency_id(currency_id) - if normalized_currency_id == &"": - return false - return _currency_by_id.has(normalized_currency_id) - -func get_currency_resource(currency_id: StringName) -> Currency: - _initialize_currency_catalog_if_needed() - - var normalized_currency_id: StringName = _normalize_currency_id(currency_id) - if normalized_currency_id == &"": - return null - - var raw_currency: Variant = _currency_by_id.get(normalized_currency_id) - if raw_currency is Currency: - return raw_currency - return null - -func get_currency_name(currency_id: StringName) -> String: - var currency: Currency = get_currency_resource(currency_id) - if currency != null: - var display_name: String = String(currency.display_name).strip_edges() - if not display_name.is_empty(): - return display_name - return _humanize_currency_id(currency_id) - -func get_currency_icon(currency_id: StringName) -> Texture2D: - var currency: Resource = get_currency_resource(currency_id) - if currency == null: - return null - - var raw_icon: Variant = currency.icon - if raw_icon is Texture2D: - return raw_icon - return null - -func _initialize_currency_catalog_if_needed() -> void: - if _catalog_initialized: - return - _initialize_currency_catalog() - -func _initialize_currency_catalog() -> void: - _currency_by_id.clear() - _catalog_initialized = true - - var currency_resource_paths: PackedStringArray = _discover_currency_resource_paths() - for resource_path in currency_resource_paths: - var loaded_resource: Resource = load(resource_path) - if loaded_resource == null: - push_warning("Failed to load currency resource at '%s'." % resource_path) - continue - if not (loaded_resource is Currency): - continue - - var currency_id: StringName = get_currency_id(loaded_resource) - if currency_id == &"": - push_warning("Skipping currency with missing id: %s" % resource_path) - continue - if _currency_by_id.has(currency_id): - push_warning("Duplicate currency id '%s' found at %s. Keeping first occurrence." % [String(currency_id), resource_path]) - continue - - _currency_by_id[currency_id] = loaded_resource - - if _currency_by_id.is_empty(): - push_warning("No currency resources discovered under %s. Ensure currency .tres files exist and are included in export presets." % CURRENCY_DIRECTORY_PATH) - -func _discover_currency_resource_paths() -> PackedStringArray: - var result: PackedStringArray = [] - var directory: DirAccess = DirAccess.open(CURRENCY_DIRECTORY_PATH) - if directory == null: - push_warning("Unable to open currency directory: %s" % CURRENCY_DIRECTORY_PATH) - return result - - directory.list_dir_begin() - var entry_name: String = directory.get_next() - while not entry_name.is_empty(): - if directory.current_is_dir() or entry_name.begins_with("."): - entry_name = directory.get_next() - continue - - var extension: String = entry_name.get_extension().to_lower() - if CURRENCY_RESOURCE_EXTENSIONS.has(extension): - result.append("%s/%s" % [CURRENCY_DIRECTORY_PATH, entry_name]) - - entry_name = directory.get_next() - directory.list_dir_end() - - result.sort() - return result - -func _normalize_currency_id(currency_id: StringName) -> StringName: - var normalized: String = String(currency_id).to_lower().strip_edges() - if normalized.is_empty(): - return &"" - return StringName(normalized) - -func _humanize_currency_id(currency_id: StringName) -> String: - var normalized: String = String(_normalize_currency_id(currency_id)) - if normalized.is_empty(): - return "Unknown" - return normalized.replace("_", " ").capitalize() diff --git a/core/currency_database.gd.uid b/core/currency_database.gd.uid deleted file mode 100644 index 27c1cd2..0000000 --- a/core/currency_database.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dmv83fnq26xao diff --git a/core/edge_scroll_camera.gd b/core/edge_scroll_camera.gd new file mode 100644 index 0000000..00ad1fb --- /dev/null +++ b/core/edge_scroll_camera.gd @@ -0,0 +1,118 @@ +## Scrolls the camera when the mouse cursor reaches the screen edge. +## Attach to a Camera2D node. Requires an OrthographicCamera2D or ViewportContainer +## to determine screen bounds at runtime. +@tool +extends Camera2D + +## Speed at which the camera scrolls (pixels per second). +@export var scroll_speed: float = 400.0 + +## Distance from the edge (in pixels) that triggers scrolling. +@export var edge_margin: float = 40.0 + +## Whether to clamp the camera position to a specific region. +@export var clamp_enabled: bool = false + +## Minimum bounds when clamping (in world space). +@export var clamp_min: Vector2 = Vector2.ZERO + +## Maximum bounds when clamping (in world space). +@export var clamp_max: Vector2 = Vector2.ZERO + +## Zoom sensitivity per mouse wheel tick. +@export var zoom_sensitivity: float = 0.1 + +## Minimum zoom level. +@export var zoom_min: float = 0.25 + +## Maximum zoom level. +@export var zoom_max: float = 3.0 + +var _screen_rect: Rect2 + + +func _ready() -> void: + _update_screen_rect() + var viewport: Viewport = get_viewport() + if viewport != null: + viewport.size_changed.connect(_update_screen_rect) + + +func _process(delta: float) -> void: + if Engine.is_editor_hint(): + queue_redraw() + return + + if is_zero_approx(scroll_speed): + return + + var mouse_pos: Vector2 = get_viewport().get_mouse_position() + var scroll_dir: Vector2 = _get_scroll_direction(mouse_pos) + + if scroll_dir != Vector2.ZERO: + position += scroll_dir * scroll_speed * delta + + if clamp_enabled: + _clamp_position() + + +func _get_scroll_direction(mouse_pos: Vector2) -> Vector2: + var direction: Vector2 = Vector2.ZERO + + if mouse_pos.x < _screen_rect.position.x + edge_margin: + direction.x -= 1.0 + elif mouse_pos.x > _screen_rect.position.x + _screen_rect.size.x - edge_margin: + direction.x += 1.0 + + if mouse_pos.y < _screen_rect.position.y + edge_margin: + direction.y -= 1.0 + elif mouse_pos.y > _screen_rect.position.y + _screen_rect.size.y - edge_margin: + direction.y += 1.0 + + return direction.normalized() + + +func _draw() -> void: + if not Engine.is_editor_hint(): + return + if not clamp_enabled: + return + + var rect_size: Vector2 = clamp_max - clamp_min + draw_rect(Rect2(clamp_min, rect_size), Color(1, 0.4, 0, 0.35)) + draw_rect(Rect2(clamp_min, rect_size), Color(1, 0.6, 0.2, 0.8), false, 2.0) + + +func _clamp_position() -> void: + position.x = clampf(position.x, clamp_min.x, clamp_max.x) + position.y = clampf(position.y, clamp_min.y, clamp_max.y) + + +func _unhandled_input(event: InputEvent) -> void: + if Engine.is_editor_hint(): + return + + if event is InputEventMouseButton: + var mouse_event: InputEventMouseButton = event + if mouse_event.button_index == MOUSE_BUTTON_WHEEL_UP: + _zoom(zoom_sensitivity, mouse_event.position) + elif mouse_event.button_index == MOUSE_BUTTON_WHEEL_DOWN: + _zoom(-zoom_sensitivity, mouse_event.position) + + +func _zoom(change: float, mouse_pos: Vector2) -> void: + var new_zoom_value: float = clampf(zoom.x + change, zoom_min, zoom_max) + if is_equal_approx(new_zoom_value, zoom.x): + return + + var viewport_center: Vector2 = get_viewport_rect().size / 2.0 + var offset: Vector2 = mouse_pos - viewport_center + var world_point: Vector2 = position + offset / zoom + zoom = Vector2(new_zoom_value, new_zoom_value) + position = world_point - offset / zoom + + +func _update_screen_rect() -> void: + var viewport: Viewport = get_viewport() + if viewport != null: + _screen_rect = viewport.get_visible_rect() diff --git a/core/edge_scroll_camera.gd.uid b/core/edge_scroll_camera.gd.uid new file mode 100644 index 0000000..08087d9 --- /dev/null +++ b/core/edge_scroll_camera.gd.uid @@ -0,0 +1 @@ +uid://b6systw05frh0 diff --git a/core/game_state.gd b/core/game_state.gd deleted file mode 100644 index d4bf03d..0000000 --- a/core/game_state.gd +++ /dev/null @@ -1,810 +0,0 @@ -extends Node -# This script is added as an Autoload named 'GameState' - -# ========================================== -# SIGNALS -# ========================================== -signal currency_changed(currency_id: StringName, new_amount: BigNumber) -signal generator_state_changed(generator_id: StringName, state: Dictionary) -signal generator_buff_level_changed(generator_id: StringName, buff_id: StringName, new_level: int) -signal generator_buff_unlocked_changed(generator_id: StringName, buff_id: StringName, unlocked: bool) - -# ========================================== -# STATE VARIABLES -# ========================================== -var _current_currency: Dictionary = {} -var _total_currency_acquired: Dictionary = {} -var _all_time_currency_acquired: Dictionary = {} - -var generator_states: Dictionary = {} -var generator_buff_levels: Dictionary = {} -var generator_buff_unlocked: Dictionary = {} -var _external_save_sections: Dictionary = {} - -var last_save_time: int = 0 # Unix timestamp - -# ========================================== -# Save / Load -# ========================================== -static var file_name: String = "user://idle_save.json" -const GENERATOR_OWNED_KEY: String = "owned" -const GENERATOR_PURCHASED_COUNT_KEY: String = "purchased_count" -const GENERATOR_UNLOCKED_KEY: String = "unlocked" -const GENERATOR_AVAILABLE_KEY: String = "available" -const GENERATOR_STATES_KEY: String = "generator_states" -const GENERATOR_BUFF_LEVELS_KEY: String = "generator_buff_levels" -const GENERATOR_BUFF_UNLOCKED_KEY: String = "generator_buff_unlocked" -const CURRENCIES_KEY: String = "currencies" -const CURRENT_KEY: String = "current" -const TOTAL_KEY: String = "total" -const ALL_TIME_KEY: String = "all_time" -const LAST_SAVE_TIME_KEY: String = "last_save_time" - -func _ready() -> void: - _initialize_currency_maps() - load_game() - -# ========================================== -# STATE MODIFIERS -# ========================================== -#func add_gold(amount: BigNumber) -> void: - #add_currency_by_id(GOLD_CURRENCY_ID, amount) -# -#func spend_gold(cost: BigNumber) -> bool: - #return spend_currency_by_id(GOLD_CURRENCY_ID, cost) -# -#func add_gems(amount: BigNumber) -> void: - #add_currency_by_id(GEMS_CURRENCY_ID, amount) -# -#func spend_gems(cost: BigNumber) -> bool: - #return spend_currency_by_id(GEMS_CURRENCY_ID, cost) - -func get_known_currencies() -> Array[Currency]: - return CurrencyDatabase.get_known_currencies() - -func get_currency_id(currency: Resource) -> StringName: - return CurrencyDatabase.get_currency_id(currency) - -func parse_currency_id(raw_currency: Variant) -> StringName: - return CurrencyDatabase.parse_currency_id(raw_currency) - -func is_known_currency_id(currency_id: StringName) -> bool: - return CurrencyDatabase.is_known_currency_id(currency_id) - -func get_currency_resource(currency_id: StringName) -> Resource: - return CurrencyDatabase.get_currency_resource(currency_id) - -func get_currency_name(currency_id: StringName) -> String: - return CurrencyDatabase.get_currency_name(currency_id) - -func get_currency_icon(currency_id: StringName) -> Texture2D: - return CurrencyDatabase.get_currency_icon(currency_id) - -func add_currency(currency: Resource, amount: BigNumber) -> void: - var currency_id: StringName = get_currency_id(currency) - if currency_id == &"": - push_warning("Attempted to add currency with an invalid Currency resource.") - return - - add_currency_by_id(currency_id, amount) - -func spend_currency(currency: Resource, cost: BigNumber) -> bool: - var currency_id: StringName = get_currency_id(currency) - if currency_id == &"": - push_warning("Attempted to spend currency with an invalid Currency resource.") - return false - - return spend_currency_by_id(currency_id, cost) - -func add_currency_by_id(currency_id: StringName, amount: BigNumber) -> void: - var normalized_currency_id: StringName = _normalize_currency_id(currency_id) - if normalized_currency_id == &"": - return - - if amount.mantissa > 0.0: - _get_total_currency_ref(normalized_currency_id).add_in_place(amount) - _get_all_time_currency_ref(normalized_currency_id).add_in_place(amount) - - var current: BigNumber = _get_current_currency_ref(normalized_currency_id) - current.add_in_place(amount) - currency_changed.emit(normalized_currency_id, current) - -func spend_currency_by_id(currency_id: StringName, cost: BigNumber) -> bool: - var normalized_currency_id: StringName = _normalize_currency_id(currency_id) - if normalized_currency_id == &"": - return false - - var current: BigNumber = _get_current_currency_ref(normalized_currency_id) - 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(normalized_currency_id, current) - return true - return false - -func get_currency_amount(currency: Resource) -> BigNumber: - return get_currency_amount_by_id(get_currency_id(currency)) - -func get_currency_amount_by_id(currency_id: StringName) -> BigNumber: - return _get_current_currency_ref(currency_id) - -func get_total_currency_acquired(currency: Resource) -> BigNumber: - return get_total_currency_acquired_by_id(get_currency_id(currency)) - -func get_total_currency_acquired_by_id(currency_id: StringName) -> BigNumber: - return _get_total_currency_ref(currency_id) - -func get_all_time_currency_acquired(currency: Resource) -> BigNumber: - return get_all_time_currency_acquired_by_id(get_currency_id(currency)) - -func get_all_time_currency_acquired_by_id(currency_id: StringName) -> BigNumber: - return _get_all_time_currency_ref(currency_id) - -func register_generator(generator_id: StringName, unlocked: bool = false, available: bool = false, owned: int = 0) -> void: - var key: String = String(generator_id) - if key.is_empty(): - return - var default_owned: int = _to_non_negative_int(owned, 0) - - if generator_states.has(key): - var existing_raw: Variant = generator_states.get(key) - if existing_raw is Dictionary: - generator_states[key] = _sanitize_generator_state(existing_raw, unlocked, available, default_owned) - return - - var new_state: Dictionary = _default_generator_state(unlocked, available, default_owned) - generator_states[key] = new_state - generator_state_changed.emit(StringName(key), new_state.duplicate(true)) - -func get_generator_state(generator_id: StringName) -> Dictionary: - return _get_generator_state(generator_id).duplicate(true) - -func get_generator_owned(generator_id: StringName) -> int: - var state: Dictionary = _get_generator_state(generator_id) - return int(state.get(GENERATOR_OWNED_KEY, 0)) - -func set_generator_owned(generator_id: StringName, value: int) -> void: - var state: Dictionary = _get_generator_state(generator_id) - var next_owned: int = maxi(value, 0) - if int(state.get(GENERATOR_OWNED_KEY, 0)) == next_owned: - return - - state[GENERATOR_OWNED_KEY] = next_owned - state[GENERATOR_PURCHASED_COUNT_KEY] = maxi(int(state.get(GENERATOR_PURCHASED_COUNT_KEY, 0)), next_owned) - _set_generator_state(generator_id, state) - -func get_generator_purchased_count(generator_id: StringName) -> int: - var state: Dictionary = _get_generator_state(generator_id) - return int(state.get(GENERATOR_PURCHASED_COUNT_KEY, 0)) - -func set_generator_purchased_count(generator_id: StringName, value: int) -> void: - var state: Dictionary = _get_generator_state(generator_id) - var min_purchased: int = int(state.get(GENERATOR_OWNED_KEY, 0)) - var next_purchased: int = maxi(value, min_purchased) - if int(state.get(GENERATOR_PURCHASED_COUNT_KEY, 0)) == next_purchased: - return - - state[GENERATOR_PURCHASED_COUNT_KEY] = next_purchased - _set_generator_state(generator_id, state) - -func is_generator_unlocked(generator_id: StringName) -> bool: - var state: Dictionary = _get_generator_state(generator_id) - return bool(state.get(GENERATOR_UNLOCKED_KEY, false)) - -func set_generator_unlocked(generator_id: StringName, value: bool) -> void: - var state: Dictionary = _get_generator_state(generator_id) - if bool(state.get(GENERATOR_UNLOCKED_KEY, false)) == value: - return - - state[GENERATOR_UNLOCKED_KEY] = value - _set_generator_state(generator_id, state) - -func is_generator_available(generator_id: StringName) -> bool: - var state: Dictionary = _get_generator_state(generator_id) - return bool(state.get(GENERATOR_AVAILABLE_KEY, false)) - -func set_generator_available(generator_id: StringName, value: bool) -> void: - var state: Dictionary = _get_generator_state(generator_id) - if bool(state.get(GENERATOR_AVAILABLE_KEY, false)) == value: - return - - state[GENERATOR_AVAILABLE_KEY] = value - _set_generator_state(generator_id, state) - -func register_generator_buff(generator_id: StringName, buff_id: StringName, initial_level: int = 0) -> void: - var generator_key: String = String(generator_id) - var buff_key: String = String(buff_id) - if generator_key.is_empty() or buff_key.is_empty(): - return - - var levels: Dictionary = _get_generator_buff_levels_ref(generator_id) - if levels.has(buff_key): - var existing_level: int = _to_non_negative_int(levels.get(buff_key, initial_level), initial_level) - if int(levels.get(buff_key, initial_level)) != existing_level: - levels[buff_key] = existing_level - generator_buff_levels[generator_key] = levels - generator_buff_level_changed.emit(StringName(generator_key), StringName(buff_key), existing_level) - return - - var sanitized_level: int = _to_non_negative_int(initial_level, 0) - levels[buff_key] = sanitized_level - generator_buff_levels[generator_key] = levels - generator_buff_level_changed.emit(StringName(generator_key), StringName(buff_key), sanitized_level) - -func register_generator_buff_unlocked(generator_id: StringName, buff_id: StringName, initially_unlocked: bool = false) -> void: - var generator_key: String = String(generator_id) - var buff_key: String = String(buff_id) - if generator_key.is_empty() or buff_key.is_empty(): - return - - var unlocked_map: Dictionary = _get_generator_buff_unlocked_ref(generator_id) - if unlocked_map.has(buff_key): - var existing_unlocked: bool = _to_bool(unlocked_map.get(buff_key, initially_unlocked), initially_unlocked) - if bool(unlocked_map.get(buff_key, initially_unlocked)) != existing_unlocked: - unlocked_map[buff_key] = existing_unlocked - generator_buff_unlocked[generator_key] = unlocked_map - generator_buff_unlocked_changed.emit(StringName(generator_key), StringName(buff_key), existing_unlocked) - return - - var sanitized_unlocked: bool = _to_bool(initially_unlocked, false) - unlocked_map[buff_key] = sanitized_unlocked - generator_buff_unlocked[generator_key] = unlocked_map - generator_buff_unlocked_changed.emit(StringName(generator_key), StringName(buff_key), sanitized_unlocked) - -func get_generator_buff_level(generator_id: StringName, buff_id: StringName) -> int: - var buff_key: String = String(buff_id) - if buff_key.is_empty(): - return 0 - - var levels: Dictionary = _get_generator_buff_levels_ref(generator_id) - return _to_non_negative_int(levels.get(buff_key, 0), 0) - -func set_generator_buff_level(generator_id: StringName, buff_id: StringName, value: int) -> void: - var generator_key: String = String(generator_id) - var buff_key: String = String(buff_id) - if generator_key.is_empty() or buff_key.is_empty(): - return - - var levels: Dictionary = _get_generator_buff_levels_ref(generator_id) - var next_level: int = _to_non_negative_int(value, 0) - if int(levels.get(buff_key, -1)) == next_level: - return - - levels[buff_key] = next_level - generator_buff_levels[generator_key] = levels - generator_buff_level_changed.emit(StringName(generator_key), StringName(buff_key), next_level) - -func is_generator_buff_unlocked(generator_id: StringName, buff_id: StringName) -> bool: - var buff_key: String = String(buff_id) - if buff_key.is_empty(): - return false - - var unlocked_map: Dictionary = _get_generator_buff_unlocked_ref(generator_id) - return _to_bool(unlocked_map.get(buff_key, false), false) - -func set_generator_buff_unlocked(generator_id: StringName, buff_id: StringName, value: bool) -> void: - var generator_key: String = String(generator_id) - var buff_key: String = String(buff_id) - if generator_key.is_empty() or buff_key.is_empty(): - return - - var unlocked_map: Dictionary = _get_generator_buff_unlocked_ref(generator_id) - var next_unlocked: bool = _to_bool(value, false) - if _to_bool(unlocked_map.get(buff_key, false), false) == next_unlocked: - return - - unlocked_map[buff_key] = next_unlocked - generator_buff_unlocked[generator_key] = unlocked_map - generator_buff_unlocked_changed.emit(StringName(generator_key), StringName(buff_key), next_unlocked) - -func get_generator_buff_unlocked(generator_id: StringName) -> Dictionary: - return _get_generator_buff_unlocked_ref(generator_id).duplicate(true) - -func get_generator_buff_levels(generator_id: StringName) -> Dictionary: - return _get_generator_buff_levels_ref(generator_id).duplicate(true) - -func set_external_save_data(section_key: StringName, payload: Dictionary) -> void: - var key: String = String(section_key).strip_edges() - if key.is_empty(): - return - - _external_save_sections[key] = payload.duplicate(true) - -func get_external_save_data(section_key: StringName) -> Dictionary: - var key: String = String(section_key).strip_edges() - if key.is_empty(): - return {} - - var raw_payload: Variant = _external_save_sections.get(key, {}) - if raw_payload is Dictionary: - return raw_payload.duplicate(true) - - return {} - -func reset_for_prestige(reset_total_currency: bool = false, emit_currency_signals: bool = true) -> void: - var currency_ids: Array[StringName] = _collect_currency_ids_for_save() - for currency_id in currency_ids: - _set_current_currency(currency_id, BigNumber.from_float(0.0)) - if reset_total_currency: - _set_total_currency(currency_id, BigNumber.from_float(0.0)) - - generator_states.clear() - generator_buff_levels.clear() - generator_buff_unlocked.clear() - - if not emit_currency_signals: - return - - for currency_id in currency_ids: - currency_changed.emit(currency_id, _get_current_currency_ref(currency_id)) - -func emit_currency_changed_for_all() -> void: - for currency_id in _collect_currency_ids_for_save(): - currency_changed.emit(currency_id, _get_current_currency_ref(currency_id)) - -# ========================================== -# PERSISTENCE (Save/Load) -# ========================================== -func save_game() -> void: - var save_data = { - CURRENCIES_KEY: _serialize_currency_balances(), - GENERATOR_STATES_KEY: _serialize_generator_states(), - GENERATOR_BUFF_LEVELS_KEY: _serialize_generator_buff_levels(), - GENERATOR_BUFF_UNLOCKED_KEY: _serialize_generator_buff_unlocked(), - LAST_SAVE_TIME_KEY: Time.get_unix_time_from_system() - } - - for section_key in _external_save_sections.keys(): - var key: String = String(section_key) - if key.is_empty(): - continue - - var payload: Variant = _external_save_sections.get(section_key) - if not (payload is Dictionary): - continue - - save_data[key] = payload - - var file: FileAccess = FileAccess.open(file_name, FileAccess.WRITE) - if file: - file.store_string(JSON.stringify(save_data)) - -func load_game() -> void: - if not FileAccess.file_exists(file_name): - return - - var file: FileAccess = FileAccess.open(file_name, FileAccess.READ) - if file: - var parsed: Variant = JSON.parse_string(file.get_as_text()) - if parsed is Dictionary: - var parsed_data: Dictionary = parsed - _external_save_sections.clear() - if parsed_data.has(CURRENCIES_KEY): - _load_currency_balances(parsed_data.get(CURRENCIES_KEY, {})) - - generator_states = _deserialize_generator_states(parsed_data.get(GENERATOR_STATES_KEY, {})) - generator_buff_levels = _deserialize_generator_buff_levels(parsed_data.get(GENERATOR_BUFF_LEVELS_KEY, {})) - generator_buff_unlocked = _deserialize_generator_buff_unlocked(parsed_data.get(GENERATOR_BUFF_UNLOCKED_KEY, {})) - last_save_time = parsed_data.get(LAST_SAVE_TIME_KEY, Time.get_unix_time_from_system()) - for save_key_variant in parsed_data.keys(): - var save_key: String = String(save_key_variant) - if save_key.is_empty(): - continue - if save_key in [CURRENCIES_KEY, GENERATOR_STATES_KEY, GENERATOR_BUFF_LEVELS_KEY, GENERATOR_BUFF_UNLOCKED_KEY, LAST_SAVE_TIME_KEY]: - continue - - var section_payload: Variant = parsed_data.get(save_key_variant) - if section_payload is Dictionary: - _external_save_sections[save_key] = section_payload.duplicate(true) - -func _load_currency_balances(raw_currencies: Variant) -> void: - if not (raw_currencies is Dictionary): - return - - var currencies: Dictionary = raw_currencies - for raw_currency_id in currencies.keys(): - var currency_id: StringName = _normalize_currency_id(StringName(String(raw_currency_id))) - if currency_id == &"": - continue - - var raw_entry: Variant = currencies.get(raw_currency_id) - if not (raw_entry is Dictionary): - continue - - var entry: Dictionary = raw_entry - var current_amount: BigNumber = BigNumber.deserialize(entry.get(CURRENT_KEY, {})) - var total_amount: BigNumber = _deserialize_total_currency(entry.get(TOTAL_KEY, null), current_amount) - var all_time_amount: BigNumber = _deserialize_all_time_currency(entry.get(ALL_TIME_KEY, null), total_amount) - _set_current_currency(currency_id, current_amount) - _set_total_currency(currency_id, total_amount) - _set_all_time_currency(currency_id, all_time_amount) - -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 _deserialize_all_time_currency(raw_all_time: Variant, total_amount: BigNumber) -> BigNumber: - var minimum_all_time: BigNumber = _copy_big_number(total_amount) - if minimum_all_time.mantissa < 0.0: - minimum_all_time = BigNumber.from_float(0.0) - - if not (raw_all_time is Dictionary): - return minimum_all_time - - var parsed_all_time: BigNumber = BigNumber.deserialize(raw_all_time) - if parsed_all_time.mantissa < 0.0: - return minimum_all_time - if parsed_all_time.is_less_than(minimum_all_time): - return minimum_all_time - return parsed_all_time - -func _copy_big_number(value: BigNumber) -> BigNumber: - return BigNumber.new(value.mantissa, value.exponent) - -func _serialize_currency_balances() -> Dictionary: - var result: Dictionary = {} - - for currency_id in _collect_currency_ids_for_save(): - var key: String = String(currency_id) - if key.is_empty(): - continue - - result[key] = { - CURRENT_KEY: _get_current_currency_ref(currency_id).serialize(), - TOTAL_KEY: _get_total_currency_ref(currency_id).serialize(), - ALL_TIME_KEY: _get_all_time_currency_ref(currency_id).serialize(), - } - - return result - -func _collect_currency_ids_for_save() -> Array[StringName]: - var ids: Array[StringName] = [] - - for raw_id in _current_currency.keys(): - var normalized_currency_id: StringName = _normalize_currency_id(StringName(String(raw_id))) - if normalized_currency_id == &"" or ids.has(normalized_currency_id): - continue - ids.append(normalized_currency_id) - - for raw_id in _all_time_currency_acquired.keys(): - var normalized_currency_id: StringName = _normalize_currency_id(StringName(String(raw_id))) - if normalized_currency_id == &"" or ids.has(normalized_currency_id): - continue - ids.append(normalized_currency_id) - - for known_currency_id in CurrencyDatabase.get_known_currency_ids(): - var normalized_currency_id: StringName = _normalize_currency_id(known_currency_id) - if normalized_currency_id == &"" or ids.has(normalized_currency_id): - continue - ids.append(normalized_currency_id) - - return ids - -func _initialize_currency_maps() -> void: - for currency_id in CurrencyDatabase.get_known_currency_ids(): - if currency_id == &"": - continue - _set_current_currency(currency_id, BigNumber.from_float(0.0)) - _set_total_currency(currency_id, BigNumber.from_float(0.0)) - _set_all_time_currency(currency_id, BigNumber.from_float(0.0)) - -func _normalize_currency_id(currency_id: StringName) -> StringName: - var normalized: String = String(currency_id).to_lower().strip_edges() - if normalized.is_empty(): - return &"" - return StringName(normalized) - -func _get_current_currency_ref(currency_id: StringName) -> BigNumber: - var normalized_currency_id: StringName = _normalize_currency_id(currency_id) - if normalized_currency_id == &"": - return BigNumber.from_float(0.0) - - var value: Variant = _current_currency.get(normalized_currency_id) - if value is BigNumber: - return value - - var fallback: BigNumber = BigNumber.from_float(0.0) - _current_currency[normalized_currency_id] = fallback - return fallback - -func _get_total_currency_ref(currency_id: StringName) -> BigNumber: - var normalized_currency_id: StringName = _normalize_currency_id(currency_id) - if normalized_currency_id == &"": - return BigNumber.from_float(0.0) - - var value: Variant = _total_currency_acquired.get(normalized_currency_id) - if value is BigNumber: - return value - - var fallback: BigNumber = BigNumber.from_float(0.0) - _total_currency_acquired[normalized_currency_id] = fallback - return fallback - -func _get_all_time_currency_ref(currency_id: StringName) -> BigNumber: - var normalized_currency_id: StringName = _normalize_currency_id(currency_id) - if normalized_currency_id == &"": - return BigNumber.from_float(0.0) - - var value: Variant = _all_time_currency_acquired.get(normalized_currency_id) - if value is BigNumber: - return value - - var fallback: BigNumber = BigNumber.from_float(0.0) - _all_time_currency_acquired[normalized_currency_id] = fallback - return fallback - -func _set_current_currency(currency_id: StringName, value: BigNumber) -> void: - var normalized_currency_id: StringName = _normalize_currency_id(currency_id) - if normalized_currency_id == &"": - return - _current_currency[normalized_currency_id] = _copy_big_number(value) - -func _set_total_currency(currency_id: StringName, value: BigNumber) -> void: - var normalized_currency_id: StringName = _normalize_currency_id(currency_id) - if normalized_currency_id == &"": - return - _total_currency_acquired[normalized_currency_id] = _copy_big_number(value) - -func _set_all_time_currency(currency_id: StringName, value: BigNumber) -> void: - var normalized_currency_id: StringName = _normalize_currency_id(currency_id) - if normalized_currency_id == &"": - return - _all_time_currency_acquired[normalized_currency_id] = _copy_big_number(value) - -func _default_generator_state(unlocked: bool, available: bool, owned: int = 0) -> Dictionary: - var owned_value: int = _to_non_negative_int(owned, 0) - return { - GENERATOR_OWNED_KEY: owned_value, - GENERATOR_PURCHASED_COUNT_KEY: owned_value, - GENERATOR_UNLOCKED_KEY: unlocked, - GENERATOR_AVAILABLE_KEY: available, - } - -func _get_generator_state(generator_id: StringName) -> Dictionary: - var key: String = String(generator_id) - if key.is_empty(): - return _default_generator_state(false, false, 0) - - if not generator_states.has(key): - register_generator(generator_id) - - var existing_raw: Variant = generator_states.get(key, _default_generator_state(false, false, 0)) - if existing_raw is Dictionary: - var state: Dictionary = existing_raw - var sanitized: Dictionary = _sanitize_generator_state(state, false, false, 0) - generator_states[key] = sanitized - return sanitized - - var fallback: Dictionary = _default_generator_state(false, false, 0) - generator_states[key] = fallback - return fallback - -func _set_generator_state(generator_id: StringName, state: Dictionary) -> void: - var key: String = String(generator_id) - if key.is_empty(): - return - - var sanitized: Dictionary = _sanitize_generator_state(state, false, false, 0) - generator_states[key] = sanitized - generator_state_changed.emit(StringName(key), sanitized.duplicate(true)) - -func _get_generator_buff_levels_ref(generator_id: StringName) -> Dictionary: - var key: String = String(generator_id) - if key.is_empty(): - return {} - - var existing_raw: Variant = generator_buff_levels.get(key, {}) - if existing_raw is Dictionary: - var sanitized: Dictionary = _sanitize_generator_buff_levels(existing_raw) - generator_buff_levels[key] = sanitized - return sanitized - - var fallback: Dictionary = {} - generator_buff_levels[key] = fallback - return fallback - -func _get_generator_buff_unlocked_ref(generator_id: StringName) -> Dictionary: - var key: String = String(generator_id) - if key.is_empty(): - return {} - - var existing_raw: Variant = generator_buff_unlocked.get(key, {}) - if existing_raw is Dictionary: - var sanitized: Dictionary = _sanitize_generator_buff_unlocked(existing_raw) - generator_buff_unlocked[key] = sanitized - return sanitized - - var fallback: Dictionary = {} - generator_buff_unlocked[key] = fallback - return fallback - -func _sanitize_generator_buff_levels(raw_levels: Dictionary) -> Dictionary: - var sanitized_levels: Dictionary = {} - for buff_key_variant in raw_levels.keys(): - var buff_key: String = String(buff_key_variant) - if buff_key.is_empty(): - continue - - sanitized_levels[buff_key] = _to_non_negative_int(raw_levels.get(buff_key_variant, 0), 0) - - return sanitized_levels - -func _sanitize_generator_buff_unlocked(raw_unlocked: Dictionary) -> Dictionary: - var sanitized_unlocked: Dictionary = {} - for buff_key_variant in raw_unlocked.keys(): - var buff_key: String = String(buff_key_variant) - if buff_key.is_empty(): - continue - - # Save format only stores true values, but keep parser tolerant. - var is_unlocked: bool = _to_bool(raw_unlocked.get(buff_key_variant, false), false) - if not is_unlocked: - continue - - sanitized_unlocked[buff_key] = true - - return sanitized_unlocked - -func _serialize_generator_states() -> Dictionary: - var result: Dictionary = {} - for key_variant in generator_states.keys(): - var key: String = String(key_variant) - if key.is_empty(): - continue - - var value: Variant = generator_states.get(key_variant) - if value is Dictionary: - result[key] = _serialize_generator_state_entry(value) - return result - -func _deserialize_generator_states(raw_value: Variant) -> Dictionary: - var result: Dictionary = {} - if not (raw_value is Dictionary): - return result - - for key_variant in raw_value.keys(): - var key: String = String(key_variant) - if key.is_empty(): - continue - - var entry: Variant = raw_value.get(key_variant) - if entry is Dictionary: - result[key] = _deserialize_generator_state_entry(entry) - return result - -func _serialize_generator_buff_levels() -> Dictionary: - var result: Dictionary = {} - for generator_key_variant in generator_buff_levels.keys(): - var generator_key: String = String(generator_key_variant) - if generator_key.is_empty(): - continue - - var raw_levels: Variant = generator_buff_levels.get(generator_key_variant) - if raw_levels is Dictionary: - result[generator_key] = _sanitize_generator_buff_levels(raw_levels) - - return result - -func _deserialize_generator_buff_levels(raw_value: Variant) -> Dictionary: - var result: Dictionary = {} - if not (raw_value is Dictionary): - return result - - for generator_key_variant in raw_value.keys(): - var generator_key: String = String(generator_key_variant) - if generator_key.is_empty(): - continue - - var raw_levels: Variant = raw_value.get(generator_key_variant) - if raw_levels is Dictionary: - result[generator_key] = _sanitize_generator_buff_levels(raw_levels) - - return result - -func _serialize_generator_buff_unlocked() -> Dictionary: - var result: Dictionary = {} - for generator_key_variant in generator_buff_unlocked.keys(): - var generator_key: String = String(generator_key_variant) - if generator_key.is_empty(): - continue - - var raw_unlocked: Variant = generator_buff_unlocked.get(generator_key_variant) - if not (raw_unlocked is Dictionary): - continue - - var sanitized_unlocked: Dictionary = _sanitize_generator_buff_unlocked(raw_unlocked) - if sanitized_unlocked.is_empty(): - continue - - result[generator_key] = sanitized_unlocked - - return result - -func _deserialize_generator_buff_unlocked(raw_value: Variant) -> Dictionary: - var result: Dictionary = {} - if not (raw_value is Dictionary): - return result - - for generator_key_variant in raw_value.keys(): - var generator_key: String = String(generator_key_variant) - if generator_key.is_empty(): - continue - - var raw_unlocked: Variant = raw_value.get(generator_key_variant) - if not (raw_unlocked is Dictionary): - continue - - var sanitized_unlocked: Dictionary = _sanitize_generator_buff_unlocked(raw_unlocked) - if sanitized_unlocked.is_empty(): - continue - - result[generator_key] = sanitized_unlocked - - return result - -func _serialize_generator_state_entry(raw_state: Dictionary) -> Dictionary: - var owned_value: int = _to_non_negative_int(raw_state.get(GENERATOR_OWNED_KEY, 0), 0) - var purchased_value: int = _to_non_negative_int(raw_state.get(GENERATOR_PURCHASED_COUNT_KEY, owned_value), owned_value) - - var serialized_state: Dictionary = { - GENERATOR_OWNED_KEY: owned_value, - GENERATOR_PURCHASED_COUNT_KEY: maxi(purchased_value, owned_value), - } - - if raw_state.has(GENERATOR_UNLOCKED_KEY) and raw_state.get(GENERATOR_UNLOCKED_KEY) is bool: - serialized_state[GENERATOR_UNLOCKED_KEY] = raw_state.get(GENERATOR_UNLOCKED_KEY) - if raw_state.has(GENERATOR_AVAILABLE_KEY) and raw_state.get(GENERATOR_AVAILABLE_KEY) is bool: - serialized_state[GENERATOR_AVAILABLE_KEY] = raw_state.get(GENERATOR_AVAILABLE_KEY) - - return serialized_state - -func _deserialize_generator_state_entry(raw_state: Dictionary) -> Dictionary: - var owned_value: int = _to_non_negative_int(raw_state.get(GENERATOR_OWNED_KEY, 0), 0) - var purchased_value: int = _to_non_negative_int(raw_state.get(GENERATOR_PURCHASED_COUNT_KEY, owned_value), owned_value) - - var parsed_state: Dictionary = { - GENERATOR_OWNED_KEY: owned_value, - GENERATOR_PURCHASED_COUNT_KEY: maxi(purchased_value, owned_value), - } - - if raw_state.has(GENERATOR_UNLOCKED_KEY) and raw_state.get(GENERATOR_UNLOCKED_KEY) is bool: - parsed_state[GENERATOR_UNLOCKED_KEY] = raw_state.get(GENERATOR_UNLOCKED_KEY) - if raw_state.has(GENERATOR_AVAILABLE_KEY) and raw_state.get(GENERATOR_AVAILABLE_KEY) is bool: - parsed_state[GENERATOR_AVAILABLE_KEY] = raw_state.get(GENERATOR_AVAILABLE_KEY) - - return parsed_state - -func _sanitize_generator_state(raw_state: Dictionary, default_unlocked: bool, default_available: bool, default_owned: int = 0) -> Dictionary: - var normalized_default_owned: int = _to_non_negative_int(default_owned, 0) - var owned_value: int = _to_non_negative_int(raw_state.get(GENERATOR_OWNED_KEY, normalized_default_owned), normalized_default_owned) - var purchased_value: int = _to_non_negative_int(raw_state.get(GENERATOR_PURCHASED_COUNT_KEY, owned_value), owned_value) - - return { - GENERATOR_OWNED_KEY: owned_value, - GENERATOR_PURCHASED_COUNT_KEY: maxi(purchased_value, owned_value), - GENERATOR_UNLOCKED_KEY: _to_bool(raw_state.get(GENERATOR_UNLOCKED_KEY, default_unlocked), default_unlocked), - GENERATOR_AVAILABLE_KEY: _to_bool(raw_state.get(GENERATOR_AVAILABLE_KEY, default_available), default_available), - } - -func _to_non_negative_int(value: Variant, fallback: int = 0) -> int: - if value is int: - return maxi(value, 0) - if value is float: - return maxi(int(value), 0) - return maxi(fallback, 0) - -func _to_bool(value: Variant, fallback: bool) -> bool: - if value is bool: - return value - return fallback diff --git a/core/game_state.gd.uid b/core/game_state.gd.uid deleted file mode 100644 index fdee5b0..0000000 --- a/core/game_state.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d2j7tvlgxr2jp diff --git a/core/generator-unlock-goals/TECH_SPEC.md b/core/generator-unlock-goals/TECH_SPEC.md deleted file mode 100644 index ee8d4d8..0000000 --- a/core/generator-unlock-goals/TECH_SPEC.md +++ /dev/null @@ -1,58 +0,0 @@ -# Generator-Embedded Unlock Goals - Technical Specification - -## Document Status -- Version: 2.0 -- Date: 2026-03-21 -- Scope: Unlock generators from `CurrencyGeneratorData.unlock_goal` when goal thresholds are reached. - -## Problem Statement -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 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. - -## 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 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. - -## 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/README.md b/core/generator/README.md new file mode 100644 index 0000000..10b3409 --- /dev/null +++ b/core/generator/README.md @@ -0,0 +1,225 @@ +# Generator Module Documentation + +## Overview + +The `generator/` subfolder contains the core idle game mechanics: automated resource production with scaling costs, upgrades (buffs), and manual interaction. + +## Files + +| File | Purpose | +|------|---------| +| `currency_generator.gd` | Runtime generator logic (Node2D) | +| `currency_generator_data.gd` | Balancing data resource | +| `generator_buff_data.gd` | Buff/upgrade definitions | +| `generator_container.gd` | UI panel for generator stats | + +## Architecture + +``` +CurrencyGenerator (Node2D) +├── Handles production cycles +├── Manages purchases +├── Processes clicks/hover +└── GeneratorPanel (UI) + ├── Shows stats/costs + └── Buff upgrade buttons +``` + +## CurrencyGeneratorData + +Resource class for generator balancing data. + +### Key Properties + +```gdscript +@export var id: StringName = &"" # Unique identifier +@export var name: String = "" # Display name +@export var starts_unlocked: bool = true # Visible at game start +@export var purchase_currency: Currency # What to spend +@export var unlock_goal: GoalData # Goal to unlock this (behavior defined in GoalData) + +# Cost formula: cost = base * coefficient^owned +@export var initial_cost: float = 10.0 # Base cost +@export var coefficient: float = 1.15 # Growth factor + +# Production +@export var initial_time: float = 1.0 # Seconds per cycle +@export var initial_productivity: float = 1.0 # Resources per cycle + +# Milestones +@export var milestone_step: int = 25 # Every N units +@export var milestone_multiplier: float = 2.0 # x multiplier + +# Manual click +@export var click_mantissa: float = 1.0 +@export var click_exponent: int = 0 +@export var click_cooldown_seconds: float = 0.2 +``` + +### Cost Formula + +**Bulk Buy Cost** (geometric series): +``` +total_cost = base * r^owned * ((r^n - 1) / (r - 1)) +``` +Where: +- `base` = initial_cost +- `r` = coefficient +- `owned` = current count +- `n` = amount to buy + +**Max Buy** (inverse formula): +``` +max = floor(log((currency * (r-1) / (base * r^owned)) + 1) / log(r)) +``` + +### Production Formula + +```gdscript +production_per_cycle = initial_productivity * owned * run_multiplier * milestone_mult * purchased_mult +production_per_second = production_per_cycle / initial_time +``` + +## CurrencyGenerator + +Runtime node instance attached to generator scenes. + +### Signals + +```gdscript +signal purchase_completed(amount, total_owned, total_purchased, total_cost) +signal production_tick(amount, cycle_count, total_owned) +signal buff_purchased(buff_id, new_level, cost, cost_currency_id) +signal goal_achieved(generator_id, goal_id) # Emitted when generator unlocks from goal +``` + +### Key Methods + +```gdscript +# Purchase +func buy(amount: int = 1) -> bool +func buy_max() -> int +func get_cost_for_amount(amount: int) -> BigNumber +func can_buy(amount: int) -> bool + +# Goal-based unlock (automatic when goal completes) +# Generator listens to GameState.goal_completed signal +``` + +# Production +func grant_currency(amount: BigNumber) +func get_effective_auto_run_multiplier() -> float + +# Buffs +func buy_buff(buff_id: StringName) -> bool +func get_buff_level(buff_id: StringName) -> int +func is_buff_unlocked(buff_id: StringName) -> bool +``` + +### Production Cycle + +1. Accumulate `cycle_progress_seconds` in `_process()` +2. When `>= initial_time`, grant production +3. Emit `production_tick` signal +4. Multiply by: + - Owned count + - Run multiplier (export) + - Buff multipliers (auto_production) + - Milestone bonuses + - Purchase boost + +## GeneratorBuffData + +Defines upgradeable modifiers for generators. + +### Buff Kinds + +```gdscript +enum BuffKind { + AUTO_PRODUCTION_MULTIPLIER, # x output per cycle + MANUAL_CLICK_MULTIPLIER, # x click reward + RESOURCE_PURCHASE # Instant currency grant +} +``` + +### Key Properties + +```gdscript +@export var id: StringName = &"" +@export var target_ids: Array[StringName] = [] # Generators affected +@export var kind: BuffKind +@export var max_level: int = -1 # -1 = unlimited +@export var base_effect: float = 1.0 # Starting multiplier +@export var effect_increment: float = 0.1 # +per level + +@export var base_cost_mantissa: float = 10.0 +@export var base_cost_exponent: int = 0 +@export var cost_multiplier: float = 1.5 # Cost growth + +# Effect: base_effect + (effect_increment * level) +func get_effect_multiplier(level: int) -> float +``` + +### Cost Formula + +```gdscript +cost_for_level = base_cost * cost_multiplier^level +``` + +### Resource Purchase Buff + +Special buff type that grants instant currency: +```gdscript +resource_purchase_amount = base_amount * (resource_purchase_increment_multiplier^level) +``` + +## GeneratorPanel + +UI component showing generator information. + +### Displays +- Generator name and owned count +- Next purchase cost +- Production per second +- Buff upgrade buttons with costs + +### Signals Connected +- `purchase_completed` - Refresh UI +- `production_tick` - Update production display +- `generator_buff_level_changed` - Refresh buff rows + +## Goal-Based Unlock + +Generators with `unlock_goal` automatically listen to `LevelGameState.goal_completed` signal: +- When goal completes, generator unlocks automatically +- Goal's `unlock_behavior` (AUTOMATIC/MANUAL) controls when goal completes +- Manual goals require player to click "Unlock" button first + +```gdscript +# In CurrencyGenerator._ready(): +level_game_state.goal_completed.connect(_on_goal_completed) + +func _on_goal_completed(goal_id: StringName) -> void: + if data.has_unlock_goal() and data.get_unlock_goal_id() == goal_id: + unlock_generator() +``` + +## Prestige Integration + +Generators automatically apply prestige multipliers: +```gdscript +effective_multiplier = run_multiplier * buff_multiplier * prestige_multiplier +``` + +On prestige via `LevelGameState.reset_for_prestige()`: +1. Current currencies reset to 0 +2. `cycle_progress_seconds` reset +3. Buff levels reset to 0 +4. Goal completion state cleared and re-initialized +5. Research XP and levels reset to 0 + +## See Also + +- `core/level_game_state.gd` - Generator state storage +- `core/goals/goal_data.gd` - Unlock conditions +- `core/prestige/prestige_manager.gd` - Multiplier application diff --git a/core/generator/currency_generator.gd b/core/generator/currency_generator.gd index 83472c8..d65432b 100644 --- a/core/generator/currency_generator.gd +++ b/core/generator/currency_generator.gd @@ -24,7 +24,7 @@ signal goal_achieved(generator_id: StringName, goal_id: StringName) const HUGE_COST_EXPONENT: int = 1000000 ## Currency target updated by this generator. -@export var currency: Resource +@export var currency: Currency ## Data resource containing balancing values and defaults (required). @export var data: CurrencyGeneratorData ## Enables per-cycle automatic production when true. @@ -37,49 +37,52 @@ const HUGE_COST_EXPONENT: int = 1000000 @export var run_multiplier: float = 1.0 ## Reference to generator UI -@onready var info_generator_container: GeneratorPanel = $GeneratorPanel +@export var info_generator_container: GeneratorPanel ## True while the pointer is inside the generator Area2D. var _mouse_entered: bool = false ## Time left until next click/hover grant is allowed. var _remaining_click_cooldown_seconds: float = 0.0 +## Reference to LevelGameState instance. +@onready var game_state: LevelGameState = find_parent("LevelGameState") + ## Number of currently owned generators. -## Backed by GameState via this generator's resolved id. +## Backed by LevelGameState via this generator's resolved id. var owned: int: get: _ensure_registered() - return GameState.get_generator_owned(_generator_id) + return game_state.get_generator_owned(_generator_id) set(value): _ensure_registered() - GameState.set_generator_owned(_generator_id, value) + game_state.set_generator_owned(_generator_id, value) ## Lifetime purchased generators (does not decrease if ownership drops). var purchased_count: int: get: _ensure_registered() - return GameState.get_generator_purchased_count(_generator_id) + return game_state.get_generator_purchased_count(_generator_id) set(value): _ensure_registered() - GameState.set_generator_purchased_count(_generator_id, value) + game_state.set_generator_purchased_count(_generator_id, value) ## Whether this generator is unlocked for the player. var is_unlocked: bool: get: _ensure_registered() - return GameState.is_generator_unlocked(_generator_id) + return game_state.is_generator_unlocked(_generator_id) set(value): _ensure_registered() - GameState.set_generator_unlocked(_generator_id, value) + game_state.set_generator_unlocked(_generator_id, value) ## Whether this unlocked generator is currently visible/usable to the player. var is_available: bool: get: _ensure_registered() - return GameState.is_generator_available(_generator_id) + return game_state.is_generator_available(_generator_id) set(value): _ensure_registered() - GameState.set_generator_available(_generator_id, value) + game_state.set_generator_available(_generator_id, value) ## Accumulated elapsed time toward the next automatic production cycle. var cycle_progress_seconds: float = 0.0 @@ -96,12 +99,15 @@ func _ready() -> void: assert(data != null, "Data cannot be null") _generator_id = _resolve_generator_id() - #_ensure_registered() cycle_progress_seconds = 0.0 - GameState.currency_changed.connect(_on_currency_changed) - GameState.generator_state_changed.connect(_on_generated_state_changed) - _evaluate_generator_unlock_goal(false) + if game_state: + game_state.currency_changed.connect(_on_currency_changed) + game_state.generator_state_changed.connect(_on_generated_state_changed) + game_state.goal_completed.connect(_on_goal_completed) + # Research XP is awarded via game_state.add_research_xp() - buff calculation handled statically + + _ensure_registered() ## Updates click cooldown/click grants and runs automatic production cycles. func _process(delta: float) -> void: @@ -137,6 +143,23 @@ func _grant_cycle_income(cycle_count: int) -> void: var produced: BigNumber = BigNumber.from_float(per_cycle).multiply(BigNumber.from_float(float(cycle_count))) _add_currency(produced) production_tick.emit(produced, cycle_count, owned) + + if data != null and data.research_data != null: + if not _is_research_active(data.research_data.id): + return + + var research_workers: int = game_state.get_research_workers() + if research_workers < data.research_data.min_workers_for_xp: + return + + var amount_float: float = produced.mantissa * pow(10.0, float(produced.exponent)) + var base_xp: BigNumber = BigNumber.from_float(data.research_data.xp_per_currency_produced * amount_float) + + var worker_multiplier: float = 1.0 + (float(research_workers) * data.research_data.worker_scaling_factor) + var scaled_xp: BigNumber = base_xp.multiply(BigNumber.from_float(worker_multiplier)) + + if game_state != null and scaled_xp.mantissa > 0.0: + game_state.add_research_xp(data.research_data.id, scaled_xp) ## Convenience helper for the next single-unit purchase cost. func get_next_cost() -> BigNumber: @@ -195,7 +218,7 @@ func buy_max() -> int: if purchase_currency_id == &"": return 0 - var available_currency: float = _big_number_to_float(_get_currency_amount_by_id(purchase_currency_id)) + var available_currency: float = _get_currency_amount_by_id(purchase_currency_id).to_float() var estimated_max: int = data.max_affordable(owned, available_currency) if estimated_max <= 0: return 0 @@ -219,18 +242,27 @@ func buy_max() -> int: func get_buffs() -> Array[GeneratorBuffData]: if data == null: return [] - return data.buffs + if game_state == null: + return [] + return game_state.get_buffs_for_generator(_generator_id) func get_buff_level(buff_id: StringName) -> int: _ensure_registered() - return GameState.get_generator_buff_level(_generator_id, buff_id) + if game_state == null: + return 0 + return game_state.get_buff_level(buff_id) func is_buff_unlocked(buff_id: StringName) -> bool: _ensure_registered() - return GameState.is_generator_buff_unlocked(_generator_id, buff_id) + if game_state == null: + return false + return game_state.is_buff_unlocked(buff_id) func get_buff_cost(buff_id: StringName) -> BigNumber: - var buff: GeneratorBuffData = _find_buff(buff_id) + if game_state == null: + return BigNumber.from_float(0.0) + + var buff: GeneratorBuffData = game_state.get_buff(buff_id) if buff == null: return BigNumber.from_float(0.0) if not is_buff_unlocked(buff.id): @@ -240,7 +272,10 @@ func get_buff_cost(buff_id: StringName) -> BigNumber: return buff.get_cost_for_level(level) func get_buff_cost_currency_id(buff_id: StringName) -> StringName: - var buff: GeneratorBuffData = _find_buff(buff_id) + if game_state == null: + return &"" + + var buff: GeneratorBuffData = game_state.get_buff(buff_id) if buff == null: return &"" return _resolve_buff_cost_currency_id(buff) @@ -249,7 +284,10 @@ func can_buy_buff(buff_id: StringName) -> bool: if not is_available_to_player(): return false - var buff: GeneratorBuffData = _find_buff(buff_id) + if game_state == null: + return false + + var buff: GeneratorBuffData = game_state.get_buff(buff_id) if buff == null: return false @@ -265,21 +303,24 @@ func can_buy_buff(buff_id: StringName) -> bool: return false var cost: BigNumber = buff.get_cost_for_level(current_level) - var current_amount: BigNumber = GameState.get_currency_amount_by_id(cost_currency_id) + var current_amount: BigNumber = game_state.get_currency_amount_by_id(cost_currency_id) return current_amount.is_greater_than(cost) or current_amount.is_equal_to(cost) func buy_buff(buff_id: StringName) -> bool: if not is_available_to_player(): return false - var buff: GeneratorBuffData = _find_buff(buff_id) + if game_state == null: + return false + + var buff: GeneratorBuffData = game_state.get_buff(buff_id) if buff == null: return false - if not is_buff_unlocked(buff.id): + if not game_state.is_buff_unlocked(buff.id): return false - var current_level: int = get_buff_level(buff.id) + var current_level: int = game_state.get_buff_level(buff.id) if not buff.can_purchase_next_level(current_level): return false @@ -293,25 +334,58 @@ func buy_buff(buff_id: StringName) -> bool: return false var next_level: int = current_level + 1 - GameState.set_generator_buff_level(_generator_id, buff.id, next_level) + game_state.set_buff_level(buff.id, next_level) _apply_resource_purchase_buff(buff, next_level) buff_purchased.emit(buff.id, next_level, cost, cost_currency_id) return true func is_buff_maxed(buff_id: StringName) -> bool: - var buff: GeneratorBuffData = _find_buff(buff_id) + if game_state == null: + return true + + var buff: GeneratorBuffData = game_state.get_buff(buff_id) if buff == null: return true - if not is_buff_unlocked(buff.id): + if not game_state.is_buff_unlocked(buff.id): return false - return not buff.can_purchase_next_level(get_buff_level(buff.id)) + return not buff.can_purchase_next_level(game_state.get_buff_level(buff.id)) func get_automatic_production_multiplier() -> float: return _get_multiplier_for_buff_kind(GeneratorBuffData.BuffKind.AUTO_PRODUCTION_MULTIPLIER) func get_effective_auto_run_multiplier() -> float: - return run_multiplier * get_automatic_production_multiplier() * _get_prestige_multiplier() + return run_multiplier * get_research_multiplier() * get_automatic_production_multiplier() * _get_prestige_buff_production_multiplier() + +func get_research_multiplier() -> float: + if data == null or data.research_data == null: + return 1.0 + if game_state == null: + return 1.0 + return game_state.get_research_multiplier(data.research_data.id) + +func _is_research_active(research_id: StringName) -> bool: + if game_state == null: + return true + + var research_panel: Node = null + var parent: Node = get_parent() + while parent != null and parent != game_state.get_tree().root: + if parent.has_method("is_research_active"): + research_panel = parent + break + parent = parent.get_parent() + + if research_panel == null: + research_panel = game_state.find_child("ResearchPanel", true, false) + + if research_panel == null: + return true + + if research_panel.has_method("is_research_active"): + return research_panel.is_research_active(research_id) + + return true func reset_runtime_state_for_prestige() -> void: _is_registered = false @@ -319,8 +393,6 @@ func reset_runtime_state_for_prestige() -> void: cycle_progress_seconds = 0.0 _remaining_click_cooldown_seconds = 0.0 _ensure_registered() - _evaluate_generator_unlock_goal(false) - _evaluate_buff_unlock_goals() visible = is_available_to_player() func get_manual_click_multiplier() -> float: @@ -359,9 +431,30 @@ func _try_grant_click_currency() -> void: if _remaining_click_cooldown_seconds > 0.0: return - _add_currency(_get_click_value()) + var click_value: BigNumber = _get_click_value() + _add_currency(click_value) + _remaining_click_cooldown_seconds = _get_click_cooldown_seconds() + # Grant research XP for manual clicks + if data.research_data != null and click_value.mantissa > 0.0: + if not _is_research_active(data.research_data.id): + return + + var research_workers: int = game_state.get_research_workers() + if research_workers < data.research_data.min_workers_for_xp: + return + + var amount_float: float = click_value.mantissa * pow(10.0, float(click_value.exponent)) + var base_xp: BigNumber = BigNumber.from_float(data.research_data.xp_per_currency_produced * amount_float) + + var worker_multiplier: float = 1.0 + (float(research_workers) * data.research_data.worker_scaling_factor) + var scaled_xp: BigNumber = base_xp.multiply(BigNumber.from_float(worker_multiplier)) + + if game_state != null and scaled_xp.mantissa > 0.0: + game_state.add_research_xp(data.research_data.id, scaled_xp) + + ## Resolves click reward from data. func _get_click_value() -> BigNumber: if data == null: @@ -384,20 +477,10 @@ func _get_click_cooldown_seconds() -> float: func _grants_click_while_hovering() -> bool: return grants_click_while_hovering -func _get_prestige_multiplier() -> float: - var manager: PrestigeManager = get_node_or_null("/root/PrestigeManager") - if manager == null: +func _get_prestige_buff_production_multiplier() -> float: + if game_state == null: return 1.0 - if not manager.get_total_multiplier(): - return 1.0 - - var raw_multiplier: Variant = manager.get_total_multiplier() - if raw_multiplier is float: - return maxf(raw_multiplier, 0.0) - if raw_multiplier is int: - return maxf(float(raw_multiplier), 0.0) - - return 1.0 + return game_state.get_prestige_buff_multiplier(PrestigeBuffNode.EffectType.CURRENCY_PRODUCTION_MULTIPLIER, _generator_id) ## Returns this generator's resolved state id. func get_generator_id() -> StringName: @@ -406,14 +489,16 @@ func get_generator_id() -> StringName: ## Returns the configured currency id. func get_currency_id() -> StringName: - return GameState.get_currency_id(currency) + if game_state == null: + return &"" + return game_state.get_currency_id(currency) func get_purchase_currency_id() -> StringName: var purchase_currency: Currency = _resolve_purchase_currency() if purchase_currency == null: return &"" - return GameState.get_currency_id(purchase_currency) + return game_state.get_currency_id(purchase_currency) ## True when the generator is both unlocked and available. func is_available_to_player() -> bool: @@ -425,13 +510,17 @@ func _add_currency(amount: BigNumber) -> void: push_error("CurrencyGenerator '%s' cannot add currency: currency resource is null." % String(name)) return - GameState.add_currency(currency, amount) + if game_state == null: + return + game_state.add_currency(currency, amount) func _add_currency_by_id(currency_id: StringName, amount: BigNumber) -> void: if currency_id == &"": return - GameState.add_currency_by_id(currency_id, amount) + if game_state == null: + return + game_state.add_currency_by_id(currency_id, amount) ## Attempts to spend target currency; returns false when insufficient. func _spend_currency(cost: BigNumber) -> bool: @@ -439,25 +528,33 @@ func _spend_currency(cost: BigNumber) -> bool: push_error("CurrencyGenerator '%s' cannot spend currency: currency resource is null." % String(name)) return false - return GameState.spend_currency(currency, cost) + if game_state == null: + return false + return game_state.spend_currency(currency, cost) func _spend_currency_by_id(currency_id: StringName, cost: BigNumber) -> bool: if currency_id == &"": return false - return GameState.spend_currency_by_id(currency_id, cost) + if game_state == null: + return false + return game_state.spend_currency_by_id(currency_id, cost) ## Returns the current amount for the configured currency type. func _get_currency_amount() -> BigNumber: if currency == null: return BigNumber.from_float(0.0) - return GameState.get_currency_amount(currency) + if game_state == null: + return BigNumber.from_float(0.0) + return game_state.get_currency_amount(currency) func _get_currency_amount_by_id(currency_id: StringName) -> BigNumber: if currency_id == &"": return BigNumber.from_float(0.0) - return GameState.get_currency_amount_by_id(currency_id) + if game_state == null: + return BigNumber.from_float(0.0) + return game_state.get_currency_amount_by_id(currency_id) ## Safely converts finite float values into BigNumber costs. func _float_to_big_number(value: float) -> BigNumber: @@ -468,29 +565,19 @@ func _float_to_big_number(value: float) -> BigNumber: return BigNumber.from_float(value) ## Converts BigNumber to float for approximate estimate math. -func _big_number_to_float(value: BigNumber) -> float: - if value.mantissa <= 0.0: - return 0.0 - if value.exponent > 308: - return INF - if value.exponent < -308: - return 0.0 - return value.mantissa * pow(10.0, float(value.exponent)) -func _find_buff(buff_id: StringName) -> GeneratorBuffData: - if data == null: - return null - return data.find_buff(buff_id) func _resolve_buff_cost_currency_id(buff: GeneratorBuffData) -> StringName: if buff == null: return &"" - var cost_currency: Resource = buff.cost_currency if buff.cost_currency != null else currency + var cost_currency: Currency = buff.cost_currency if buff.cost_currency != null else currency if cost_currency == null: return &"" - return GameState.get_currency_id(cost_currency) + if game_state == null: + return &"" + return game_state.get_currency_id(cost_currency) func _resolve_purchase_currency() -> Currency: if data == null: @@ -502,25 +589,18 @@ func _resolve_buff_target_currency_id(buff: GeneratorBuffData) -> StringName: if buff == null: return &"" - var target_currency: Resource = buff.resource_target_currency if buff.resource_target_currency != null else currency + var target_currency: Currency = buff.resource_target_currency if buff.resource_target_currency != null else currency if target_currency == null: return &"" - return GameState.get_currency_id(target_currency) + if game_state == null: + return &"" + return game_state.get_currency_id(target_currency) func _get_multiplier_for_buff_kind(kind: GeneratorBuffData.BuffKind) -> float: - var multiplier: float = 1.0 - for buff in get_buffs(): - if buff == null: - continue - if buff.kind != kind: - continue - if not is_buff_unlocked(buff.id): - continue - - multiplier *= buff.get_effect_multiplier(get_buff_level(buff.id)) - - return maxf(multiplier, 0.0) + if game_state == null: + return 1.0 + return game_state.get_effective_multiplier(_generator_id, kind) func _apply_resource_purchase_buff(buff: GeneratorBuffData, level: int) -> void: if buff == null: @@ -550,7 +630,7 @@ func _resolve_generator_id() -> StringName: return &"generator" -## Ensures this generator has a state entry in GameState. +## Ensures this generator has a state entry in LevelGameState. func _ensure_registered() -> void: if _is_registered or _is_registering: return @@ -560,8 +640,11 @@ func _ensure_registered() -> void: if _generator_id == &"": return + if game_state == null: + return + _is_registering = true - GameState.register_generator( + game_state.register_generator( _generator_id, _default_unlocked_state(), _default_available_state(), @@ -572,6 +655,9 @@ func _ensure_registered() -> void: _is_registering = false func _register_buffs() -> void: + if game_state == null: + return + for buff in get_buffs(): if buff == null: continue @@ -581,58 +667,34 @@ func _register_buffs() -> void: continue var buff_id: StringName = StringName(buff_id_text) - GameState.register_generator_buff(_generator_id, buff_id, 0) - var persisted_level: int = GameState.get_generator_buff_level(_generator_id, buff_id) - var starts_unlocked: bool = buff.unlocked or persisted_level > 0 - GameState.register_generator_buff_unlocked(_generator_id, buff_id, starts_unlocked) + game_state.register_buff(buff) + + if not game_state.is_buff_unlocked(buff_id): + var persisted_level: int = game_state.get_buff_level(buff_id) + var starts_unlocked: bool = buff.unlocked or persisted_level > 0 + game_state.set_buff_unlocked(buff_id, starts_unlocked) - _evaluate_buff_unlock_goals() - -func _evaluate_buff_unlock_goals() -> void: - for buff in get_buffs(): - _try_unlock_buff_from_goal(buff) - -func _try_unlock_buff_from_goal(buff: GeneratorBuffData) -> void: - if buff == null: - return - - var buff_id_text: String = String(buff.id).strip_edges() - if buff_id_text.is_empty(): - return - - var buff_id: StringName = StringName(buff_id_text) - if GameState.is_generator_buff_unlocked(_generator_id, buff_id): - return - if not buff.has_unlock_goal(): - return - if not buff.is_unlock_goal_met(): - return - - GameState.set_generator_buff_unlocked(_generator_id, buff_id, true) + game_state.try_unlock_buff_from_goal(buff) func _on_currency_changed(_changed_currency_id: StringName, _new_amount: BigNumber) -> void: - _evaluate_generator_unlock_goal(false) - _evaluate_buff_unlock_goals() + if game_state == null: + return + game_state.evaluate_all_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 +func _on_goal_completed(goal_id: StringName) -> void: + if data == null or not data.has_unlock_goal(): + return + if data.get_unlock_goal_id() != goal_id: + return 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 + return - 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 + if game_state == null: + return + game_state.set_generator_unlocked(_generator_id, true) + game_state.set_generator_available(_generator_id, true) + goal_achieved.emit(_generator_id, goal_id) + _on_generated_state_changed(_generator_id, game_state._get_generator_state(_generator_id)) ## Default unlocked state loaded from generator data. func _default_unlocked_state() -> bool: @@ -665,3 +727,5 @@ func _on_area_2d_mouse_exited() -> void: func _on_generated_state_changed(generator_id: StringName, _state: Dictionary) -> void: if generator_id == _generator_id: visible = is_available_to_player() + if info_generator_container != null: + info_generator_container._refresh_generator_ui() diff --git a/core/generator/currency_generator.tscn b/core/generator/currency_generator.tscn index 94f74dd..c34cb64 100644 --- a/core/generator/currency_generator.tscn +++ b/core/generator/currency_generator.tscn @@ -1,34 +1,6 @@ [gd_scene format=3 uid="uid://jeoiinukrrsp"] [ext_resource type="Script" uid="uid://dtbxopw6ulhl8" path="res://core/generator/currency_generator.gd" id="1_4n4ca"] -[ext_resource type="Resource" uid="uid://brqaojindcxa5" path="res://idles/currencies/magic.tres" id="2_5tmvy"] -[ext_resource type="Resource" uid="uid://co0mcc2kvcpo5" path="res://idles/generators/orb.tres" id="3_wx13b"] -[ext_resource type="Texture2D" uid="uid://bgtt3wu43tajh" path="res://icon.svg" id="4_ruf1h"] -[ext_resource type="PackedScene" uid="uid://ckos7f22pnmyh" path="res://generator_container.tscn" id="5_bb14m"] - -[sub_resource type="RectangleShape2D" id="RectangleShape2D_y5m1q"] -size = Vector2(128, 128) [node name="CurrencyGenerator" type="Node2D" unique_id=967969064] script = ExtResource("1_4n4ca") -currency = ExtResource("2_5tmvy") -data = ExtResource("3_wx13b") - -[node name="Sprite2D" type="Sprite2D" parent="." unique_id=807584513] -texture = ExtResource("4_ruf1h") - -[node name="Area2D" type="Area2D" parent="." unique_id=707349247] - -[node name="CollisionShape2D" type="CollisionShape2D" parent="Area2D" unique_id=1572620025] -shape = SubResource("RectangleShape2D_y5m1q") - -[node name="GeneratorPanel" parent="." unique_id=1129190041 node_paths=PackedStringArray("_generator") instance=ExtResource("5_bb14m")] -visible = false -offset_left = 33.0 -offset_top = -65.0 -offset_right = 474.0 -offset_bottom = 200.5 -_generator = NodePath("..") - -[connection signal="mouse_entered" from="Area2D" to="." method="_on_area_2d_mouse_entered"] -[connection signal="mouse_exited" from="Area2D" to="." method="_on_area_2d_mouse_exited"] diff --git a/core/generator/currency_generator_data.gd b/core/generator/currency_generator_data.gd index 3586206..6d89250 100644 --- a/core/generator/currency_generator_data.gd +++ b/core/generator/currency_generator_data.gd @@ -1,11 +1,6 @@ class_name CurrencyGeneratorData extends Resource -enum UnlockGoalBehavior { - AUTOMATIC, - MANUAL_BUTTON, -} - @export var id: StringName = &"" @export var name: String = "" @export var starts_unlocked: bool = true @@ -13,7 +8,6 @@ enum UnlockGoalBehavior { @export var initial_owned: int = 0 @export var purchase_currency: Currency @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 @@ -37,8 +31,7 @@ enum UnlockGoalBehavior { @export var click_exponent: int = 0 @export var click_cooldown_seconds: float = 0.2 -## Data-driven buffs purchasable for this generator. -@export var buffs: Array[GeneratorBuffData] = [] +@export var research_data: ResearchData func has_unlock_goal() -> bool: if unlock_goal == null: @@ -46,12 +39,6 @@ func has_unlock_goal() -> bool: 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 &"" @@ -62,22 +49,6 @@ func get_unlock_goal_id() -> StringName: 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(): - return null - - for buff in buffs: - if buff == null: - continue - if String(buff.id) == expected_id: - return buff - - return null - ## Returns cost of next generator func cost_next(owned: int) -> float: return cost_for_amount(owned, 1) diff --git a/core/generator/generator_buff_data.gd b/core/generator/generator_buff_data.gd index 916b94c..00d0275 100644 --- a/core/generator/generator_buff_data.gd +++ b/core/generator/generator_buff_data.gd @@ -5,11 +5,13 @@ enum BuffKind { AUTO_PRODUCTION_MULTIPLIER, MANUAL_CLICK_MULTIPLIER, RESOURCE_PURCHASE, + RESEARCH_XP_MULTIPLIER, } const HUGE_EXPONENT: int = 1000000 @export var id: StringName = &"" +@export var target_ids: Array[StringName] = [] @export var kind: BuffKind = BuffKind.AUTO_PRODUCTION_MULTIPLIER @export_multiline var text: String = "" @export var icon: Texture2D @@ -23,6 +25,10 @@ const HUGE_EXPONENT: int = 1000000 @export var base_effect: float = 1.0 @export var effect_increment: float = 0.1 +@export_group("Research XP Multiplier") +@export var research_target_id: StringName = &"" +@export var xp_multiplier_per_level: float = 0.1 + @export_group("Cost") @export var cost_currency: Currency @export var base_cost_mantissa: float = 10.0 @@ -35,6 +41,18 @@ const HUGE_EXPONENT: int = 1000000 @export var resource_purchase_base_exponent: int = 0 @export var resource_purchase_increment_multiplier: float = 1.2 +func targets_generator(generator_id: StringName) -> bool: + if target_ids.is_empty(): + return false + + if target_ids.has("*"): + return true + + return target_ids.has(generator_id) + +func get_target_generators() -> Array[StringName]: + return target_ids.duplicate() + func has_unlock_goal() -> bool: if unlock_goal == null: return false @@ -45,10 +63,10 @@ func get_unlock_goal_amount() -> BigNumber: if unlock_goal == null: return BigNumber.from_float(0.0) - var valid_requirements: Array[GoalRequirementData] = unlock_goal.get_valid_requirements() - if valid_requirements.is_empty(): + var requirements: Array[GoalRequirementData] = unlock_goal.get_requirements() + if requirements.is_empty(): return BigNumber.from_float(0.0) - var requirement_resource: GoalRequirementData = valid_requirements[0] + var requirement_resource: GoalRequirementData = requirements[0] if requirement_resource == null: return BigNumber.from_float(0.0) @@ -62,21 +80,15 @@ func get_unlock_goal_currency() -> Currency: if unlock_goal == null: return null - var valid_requirements: Array[GoalRequirementData] = unlock_goal.get_valid_requirements() - if valid_requirements.is_empty(): + var requirements: Array[GoalRequirementData] = unlock_goal.get_requirements() + if requirements.is_empty(): return null - var requirement_resource: GoalRequirementData = valid_requirements[0] + var requirement_resource: GoalRequirementData = requirements[0] if requirement_resource == null: return null return requirement_resource.currency -func is_unlock_goal_met() -> bool: - if unlock_goal == null: - return false - - return bool(unlock_goal.is_met()) - func can_purchase_next_level(current_level: int) -> bool: if max_level < 0: return true @@ -126,7 +138,9 @@ func get_effect_description(level: int) -> String: BuffKind.AUTO_PRODUCTION_MULTIPLIER, BuffKind.MANUAL_CLICK_MULTIPLIER: return "x%.2f" % get_effect_multiplier(safe_level) BuffKind.RESOURCE_PURCHASE: - return "+%s" % get_resource_purchase_amount_for_level(safe_level).to_string_suffix(2) + return "+%s" % get_resource_purchase_amount_for_level(level).to_string_suffix(2) + BuffKind.RESEARCH_XP_MULTIPLIER: + return "+%.0f%% XP" % ((get_effect_multiplier(safe_level) - 1.0) * 100) _: return "-" diff --git a/generator_container.gd b/core/generator/generator_container.gd similarity index 83% rename from generator_container.gd rename to core/generator/generator_container.gd index f2e1d4d..e6e66b7 100644 --- a/generator_container.gd +++ b/core/generator/generator_container.gd @@ -37,11 +37,10 @@ func _ready() -> void: _generator.purchase_failed.connect(_on_generator_updated) _generator.buff_purchased.connect(_on_generator_updated) _generator.buff_purchase_failed.connect(_on_generator_updated) - GameState.currency_changed.connect(_on_currency_changed) - - GameState.generator_state_changed.connect(_on_generator_state_changed) - GameState.generator_buff_level_changed.connect(_on_generator_buff_level_changed) - GameState.generator_buff_unlocked_changed.connect(_on_generator_buff_unlocked_changed) + + if _generator.game_state: + _generator.game_state.currency_changed.connect(_on_currency_changed) + _generator.game_state.generator_state_changed.connect(_on_generator_state_changed) _name_label.text = _generator.data.name if _generator.data != null else "Generator" _build_buff_rows() @@ -70,18 +69,6 @@ func _on_generator_state_changed(generator_id: StringName, _state: Dictionary) - visible = true _refresh_generator_ui() -func _on_generator_buff_level_changed(generator_id: StringName, _buff_id: StringName, _new_level: int) -> void: - if generator_id != _generator_id: - return - - _refresh_generator_ui() - -func _on_generator_buff_unlocked_changed(generator_id: StringName, _buff_id: StringName, _unlocked: bool) -> void: - if generator_id != _generator_id: - return - - _refresh_generator_ui() - func _on_buy_buff_pressed(buff_id: StringName) -> void: _generator.buy_buff(buff_id) _refresh_generator_ui() @@ -122,10 +109,10 @@ func _refresh_buff_rows(can_interact: bool) -> void: var buff_unlocked: bool = _generator.is_buff_unlocked(buff.id) if not buff_unlocked: var locked_hint: String = "Locked" - var unlock_goal_currency: Resource = buff.get_unlock_goal_currency() - if buff.has_unlock_goal() and unlock_goal_currency != null: - var goal_currency_id: StringName = GameState.get_currency_id(unlock_goal_currency) - var currency_name: String = GameState.get_currency_name(goal_currency_id) + var unlock_goal_currency: Currency = buff.get_unlock_goal_currency() + if buff.has_unlock_goal() and unlock_goal_currency != null and _generator.game_state: + var goal_currency_id: StringName = _generator.game_state.get_currency_id(unlock_goal_currency) + var currency_name: String = _generator.game_state.get_currency_name(goal_currency_id) var required_text: String = buff.get_unlock_goal_amount().to_string_suffix(2) locked_hint = "Unlock at %s %s" % [required_text, currency_name] @@ -141,20 +128,27 @@ func _refresh_buff_rows(can_interact: bool) -> void: var cost: BigNumber = _generator.get_buff_cost(buff.id) var cost_currency_id: StringName = _generator.get_buff_cost_currency_id(buff.id) - var currency_label: String = GameState.get_currency_name(cost_currency_id) + var currency_label: String = _generator.game_state.get_currency_name(cost_currency_id) if _generator.game_state else "Unknown" var cost_text: String = "%s %s" % [cost.to_string_suffix(2), currency_label] var can_buy: bool = can_interact and _generator.can_buy_buff(buff.id) row.tile.set_runtime(level, effect_text, cost_text, can_buy, false) func _refresh_generator_ui() -> void: + if _buy_button == null or _buy_max_button == null: + return + + if _generator == null: + return + var can_interact: bool = _generator.is_available_to_player() + _buy_button.disabled = not can_interact _buy_max_button.disabled = not can_interact _owned_value.text = str(_generator.owned) if can_interact: var purchase_currency_id: StringName = _generator.get_purchase_currency_id() - var purchase_currency_name: String = GameState.get_currency_name(purchase_currency_id) + var purchase_currency_name: String = _generator.game_state.get_currency_name(purchase_currency_id) if _generator.game_state else "Unknown" if purchase_currency_name.is_empty(): purchase_currency_name = "Unconfigured" _next_cost_value.text = "%s %s" % [_generator.get_next_cost().to_string_suffix(2), purchase_currency_name] diff --git a/generator_container.gd.uid b/core/generator/generator_container.gd.uid similarity index 100% rename from generator_container.gd.uid rename to core/generator/generator_container.gd.uid diff --git a/generator_container.tscn b/core/generator/generator_container.tscn similarity index 98% rename from generator_container.tscn rename to core/generator/generator_container.tscn index 90a9c47..76f89c7 100644 --- a/generator_container.tscn +++ b/core/generator/generator_container.tscn @@ -1,6 +1,6 @@ [gd_scene format=3 uid="uid://ckos7f22pnmyh"] -[ext_resource type="Script" uid="uid://es14nqk6vrrk" path="res://generator_container.gd" id="1_8wouw"] +[ext_resource type="Script" uid="uid://es14nqk6vrrk" path="res://core/generator/generator_container.gd" id="1_8wouw"] [node name="GeneratorContainer" type="PanelContainer" unique_id=1451609580] anchors_preset = 8 diff --git a/core/generator/research_buff_calculator.gd b/core/generator/research_buff_calculator.gd new file mode 100644 index 0000000..7ddad96 --- /dev/null +++ b/core/generator/research_buff_calculator.gd @@ -0,0 +1,22 @@ +## Static utility class for research XP buff calculations +class_name ResearchBuffCalculator + +static func apply_buffs(game_state: LevelGameState, research_id: StringName, base_xp: BigNumber) -> BigNumber: + if game_state == null: + return base_xp + + var multiplier: float = _calculate_buff_multiplier(game_state, research_id) + var multiplier_bn: BigNumber = BigNumber.from_float(multiplier) + return base_xp.multiply(multiplier_bn) + +static func _calculate_buff_multiplier(game_state: LevelGameState, research_id: StringName) -> float: + var research: ResearchData = game_state.research_catalogue.get_research_by_id(research_id) + if research == null or research.associated_buff_id.is_empty(): + return 1.0 + + var buff: GeneratorBuffData = game_state.get_buff(research.associated_buff_id) + if buff == null or not game_state.is_buff_active(buff.id): + return 1.0 + + var level: int = game_state.get_buff_level(buff.id) + return buff.get_effect_multiplier(level) diff --git a/core/generator/research_buff_calculator.gd.uid b/core/generator/research_buff_calculator.gd.uid new file mode 100644 index 0000000..61136dd --- /dev/null +++ b/core/generator/research_buff_calculator.gd.uid @@ -0,0 +1 @@ +uid://bhrcgp2dna0mb diff --git a/core/goals/README.md b/core/goals/README.md new file mode 100644 index 0000000..ae5a0fe --- /dev/null +++ b/core/goals/README.md @@ -0,0 +1,192 @@ +# Goals Module Documentation + +## Overview + +The `goals/` subfolder contains the achievement system. Goals track player progress and can unlock generators or buffs when conditions are met. + +## Files + +| File | Purpose | +|------|---------| +| `goal_data.gd` | Goal definition resource | +| `goal_requirement_data.gd` | Individual requirement criteria | + +## GoalData + +Resource class defining an achievement. + +### Properties + +```gdscript +class_name GoalData +extends Resource + +@export var id: StringName = &"" # Unique identifier +@export var requirements: Array[GoalRequirementData] # Conditions to complete +@export var unlock_behavior: UnlockBehavior = MANUAL # AUTOMATIC or MANUAL +``` + +### Enum + +```gdscript +enum UnlockBehavior { + AUTOMATIC, # Goal completes immediately when met + MANUAL, # Goal requires player to click unlock button +} +``` + +### Methods + +```gdscript +func has_id() -> bool # Has non-empty id +func is_valid() -> bool # Has valid id and requirements +func get_requirements() -> Array[GoalRequirementData] # Access requirements +``` + +**Note:** State-dependent methods (`is_goal_met()`, `get_goal_progress()`, `get_goal_requirement_summary()`) are implemented in `LevelGameState` for proper decoupling. + +## GoalRequirementData + +Single condition within a goal. + +### Properties + +```gdscript +class_name GoalRequirementData +extends Resource + +@export var currency: Currency # Currency to track +@export var amount_mantissa: float = 0.0 # Required amount (scientific notation) +@export var amount_exponent: int = 0 +``` + +### Methods + +```gdscript +func get_currency() -> Currency # Currency to track +func get_amount() -> BigNumber # Required amount +func has_valid_amount() -> bool # Amount is non-negative +``` + +**Note:** State-dependent methods (`is_met()`, `get_progress()`, `get_summary_text()`) are implemented in `LevelGameState` for proper decoupling. + +### Progress Calculation + +Uses **logarithmic scaling** for better visual feedback on huge numbers: + +```gdscript +# Convert to log space +current_log = log(current_mantissa) / log(10) + current_exponent +required_log = log(required_mantissa) / log(10) + required_exponent + +# Progress ratio +progress = clampf(current_log / required_log, 0.0, 1.0) +``` + +This means reaching 50% progress on a 1e100 goal requires ~1e50 currency. + +## Goal Evaluation + +### Automatic Checking + +`LevelGameState` evaluates goals whenever currency changes: + +```gdscript +func _on_currency_changed(currency_id, new_amount): + evaluate_all_goals() +``` + +### Completion Flow + +1. Currency updated → signal emitted +2. `LevelGameState.evaluate_all_goals()` called +3. Each uncompleted goal checked via `is_goal_met(goal)` +4. If met → `goal_completed` signal emitted +5. Buffs with unlock goals checked → unlocked if goal complete + +### Manual Unlock vs Automatic + +Goals control their own unlock behavior via `unlock_behavior`: +- **AUTOMATIC**: Goal completes immediately when requirements are met +- **MANUAL**: Goal stays incomplete until player clicks unlock button + +Generators with `unlock_goal` automatically unlock when the goal completes. + +### Goal Methods in LevelGameState + +```gdscript +func is_goal_requirement_met(requirement: GoalRequirementData) -> bool +func get_goal_requirement_progress(requirement: GoalRequirementData) -> float +func get_goal_requirement_summary(requirement: GoalRequirementData) -> String +func is_goal_met(goal: GoalData) -> bool +func get_goal_progress(goal: GoalData) -> float +``` + +## Usage Example + +### Defining a Goal + +Create `res://sandbox/goals/first_million.tres`: + +``` +[gd_resource type="Resource" script_class="GoalData"] +[resource] +id = &"first_million" +requirements = [ + preload("res://sandbox/goals/first_million_req.tres") +] +``` + +### Defining a Requirement + +Create `res://sandbox/goals/first_million_req.tres`: + +``` +[gd_resource type="Resource" script_class="GoalRequirementData"] +[resource] +currency = preload("res://sandbox/currencies/gold.tres") +amount_mantissa = 1.0 +amount_exponent = 6 # 1,000,000 +``` + +### Connecting to Generator + +In `CurrencyGeneratorData`: +```gdscript +@export var unlock_goal: GoalData # Assign goal resource +``` + +The goal's `unlock_behavior` determines if the generator unlocks automatically or requires manual button click. + +## Signals + +```gdscript +signal goal_completed(goal_id: StringName) +signal goal_progress_changed(goal_id: StringName, progress: float) +``` + +## Integration Points + +### With LevelGameState +- Goals registered from `GoalCatalogue` assigned to `LevelGameState` +- Completion state saved to JSON +- Buff unlock goals checked automatically + +### With Buffs +```gdscript +# In GeneratorBuffData +@export var unlock_goal: GoalData + +# When goal completes, buff auto-unlocks: +level_game_state._try_unlock_buff_from_goal(buff) +``` + +### With Prestige +- Goal completion state persists through prestige +- Reset via `LevelGameState.reset_for_prestige()` + +## See Also + +- `core/level_game_state.gd` - Goal state management +- `core/generator/currency_generator_data.gd` - Generator unlock goals +- `core/generator/generator_buff_data.gd` - Buff unlock goals diff --git a/core/goals/current_goal_panel.gd b/core/goals/current_goal_panel.gd new file mode 100644 index 0000000..4d6080f --- /dev/null +++ b/core/goals/current_goal_panel.gd @@ -0,0 +1,153 @@ +## Single-goal tile showing the current (first uncompleted) goal from GoalCatalogue. +## Mirrors a single row from goals_debug_ui: goal name, requirements text, and a resolve button. +extends PanelContainer + +@onready var _game_state: LevelGameState = find_parent("LevelGameState") +@onready var _goal_label: Label = $VBoxContainer/GoalLabel +@onready var _requirements_label: Label = $VBoxContainer/RequirementsLabel +@onready var _resolve_button: Button = $VBoxContainer/ResolveButton + +var _current_goal: GoalData + + +func _ready() -> void: + _resolve_button.pressed.connect(_on_resolve_pressed) + if _game_state: + _game_state.currency_changed.connect(_on_currency_changed) + _game_state.generator_state_changed.connect(_on_generator_state_changed) + _game_state.goal_completed.connect(_on_goal_completed) + if not _game_state.goal_catalogue or _game_state.goal_catalogue.goals.is_empty(): + _game_state.ready.connect(_on_level_state_ready) + + _refresh_current_goal() + _refresh_ui() + _refresh_ui.call_deferred() + + +func _get_current_goal() -> GoalData: + if _game_state == null: + return null + var catalogue: GoalCatalogue = _game_state.goal_catalogue + if catalogue == null: + return null + + for goal in catalogue.goals: + if goal == null: + continue + if not goal.has_id(): + continue + if _game_state.is_goal_completed(goal.id): + continue + return goal + + return null + + +func _refresh_current_goal() -> void: + _current_goal = _get_current_goal() + + +func _refresh_ui() -> void: + if _current_goal == null: + _goal_label.text = "All goals complete!" + _requirements_label.text = "" + _resolve_button.disabled = true + _resolve_button.text = "Done" + return + + _goal_label.text = String(_current_goal.id) + + if _game_state == null: + return + + var already_completed: bool = _game_state.is_goal_completed(_current_goal.id) + + _requirements_label.text = _get_requirements_text(_current_goal) + + var met: bool = _game_state.is_goal_met(_current_goal) + var can_click: bool = met and not already_completed + + _resolve_button.disabled = not can_click + _resolve_button.text = "Done" if already_completed else "Resolve" + + +func _get_requirements_text(goal: GoalData) -> String: + if goal == null or _game_state == null: + return "" + + var parts: Array[String] = [] + for req in goal.get_requirements(): + if req == null: + continue + + var currency: Currency = req.get_currency() + if currency == null: + continue + + var currency_id: StringName = _game_state.get_currency_id(currency) + var total_amount: BigNumber = _game_state.get_total_currency_acquired_by_id(currency_id) + var required_amount: BigNumber = req.get_amount() + var currency_name: String = _game_state.get_currency_name(currency_id) + + parts.append( + "%s %s / %s" % [ + currency_name, + total_amount.to_string_suffix(2), + required_amount.to_string_suffix(2), + ] + ) + + if parts.is_empty(): + return "No requirements" + return " | ".join(parts) + + +func _on_resolve_pressed() -> void: + if _current_goal == null: + return + if _game_state == null: + return + if _game_state.is_goal_completed(_current_goal.id): + return + if not _game_state.is_goal_met(_current_goal): + return + + if _current_goal.unlock_behavior == GoalData.UnlockBehavior.MANUAL: + _game_state._complete_goal_manually(_current_goal.id) + + _refresh_current_goal() + _refresh_ui() + + +func _on_currency_changed(_currency_id: StringName, _new_amount: BigNumber) -> void: + _refresh_current_goal() + _refresh_ui() + + +func _on_generator_state_changed(_generator_id: StringName, _state: Dictionary) -> void: + _refresh_current_goal() + _refresh_ui() + + +func _on_goal_completed(_goal_id: StringName) -> void: + _refresh_current_goal() + _refresh_ui() + + +func _on_level_state_ready() -> void: + if _game_state: + _game_state.ready.disconnect(_on_level_state_ready) + _refresh_current_goal() + _refresh_ui() + + +func _exit_tree() -> void: + if _game_state: + if _game_state.currency_changed.is_connected(_on_currency_changed): + _game_state.currency_changed.disconnect(_on_currency_changed) + if _game_state.generator_state_changed.is_connected(_on_generator_state_changed): + _game_state.generator_state_changed.disconnect(_on_generator_state_changed) + if _game_state.goal_completed.is_connected(_on_goal_completed): + _game_state.goal_completed.disconnect(_on_goal_completed) + if _game_state.ready.is_connected(_on_level_state_ready): + _game_state.ready.disconnect(_on_level_state_ready) diff --git a/core/goals/current_goal_panel.gd.uid b/core/goals/current_goal_panel.gd.uid new file mode 100644 index 0000000..5bbbdc8 --- /dev/null +++ b/core/goals/current_goal_panel.gd.uid @@ -0,0 +1 @@ +uid://dfn06haxb2yhr diff --git a/core/goals/current_goal_panel.tscn b/core/goals/current_goal_panel.tscn new file mode 100644 index 0000000..b51ea35 --- /dev/null +++ b/core/goals/current_goal_panel.tscn @@ -0,0 +1,26 @@ +[gd_scene format=3 uid="uid://dudfvxilbydas"] + +[ext_resource type="Script" uid="uid://dfn06haxb2yhr" path="res://core/goals/current_goal_panel.gd" id="1_70l2h"] + +[node name="CurrentGoalPanel" type="PanelContainer" unique_id=1791509101] +offset_left = 155.0 +offset_top = 210.0 +offset_right = 500.0 +offset_bottom = 320.0 +script = ExtResource("1_70l2h") + +[node name="VBoxContainer" type="VBoxContainer" parent="." unique_id=933762288] +layout_mode = 2 +theme_override_constants/separation = 4 + +[node name="GoalLabel" type="Label" parent="VBoxContainer" unique_id=1859068341] +layout_mode = 2 +text = "Goal" + +[node name="RequirementsLabel" type="Label" parent="VBoxContainer" unique_id=2092922827] +layout_mode = 2 +text = "Requirements" + +[node name="ResolveButton" type="Button" parent="VBoxContainer" unique_id=760904344] +layout_mode = 2 +text = "Resolve" diff --git a/core/goals/goal_catalogue.gd b/core/goals/goal_catalogue.gd new file mode 100644 index 0000000..f736c9f --- /dev/null +++ b/core/goals/goal_catalogue.gd @@ -0,0 +1,17 @@ +class_name GoalCatalogue +extends Resource + +@export var goals: Array[GoalData] = [] + +func get_goal_by_id(id: StringName) -> GoalData: + for goal in goals: + if goal.id == id: + return goal + return null + +func get_all_ids() -> Array[StringName]: + var ids: Array[StringName] = [] + for goal in goals: + if goal.has_id(): + ids.append(goal.id) + return ids diff --git a/core/goals/goal_catalogue.gd.uid b/core/goals/goal_catalogue.gd.uid new file mode 100644 index 0000000..a798104 --- /dev/null +++ b/core/goals/goal_catalogue.gd.uid @@ -0,0 +1 @@ +uid://bfbp4mo8ys5p8 diff --git a/core/goals/goal_data.gd b/core/goals/goal_data.gd index 2ffa672..eef69f2 100644 --- a/core/goals/goal_data.gd +++ b/core/goals/goal_data.gd @@ -1,43 +1,34 @@ class_name GoalData extends Resource +enum UnlockBehavior { + AUTOMATIC, + MANUAL, +} + @export var id: StringName = &"" @export var requirements: Array[GoalRequirementData] = [] +@export var unlock_behavior: UnlockBehavior = UnlockBehavior.MANUAL func has_id() -> bool: return not String(id).strip_edges().is_empty() -func get_valid_requirements() -> Array[GoalRequirementData]: - var result: Array[GoalRequirementData] = [] - for requirement_resource in requirements: - if requirement_resource == null: - continue - if not bool(requirement_resource.is_valid()): - continue - - result.append(requirement_resource) - - return result +func get_requirements() -> Array[GoalRequirementData]: + return requirements.duplicate() func is_valid() -> bool: if not has_id(): return false - - return not get_valid_requirements().is_empty() - -func is_met() -> bool: - var valid_requirements: Array[GoalRequirementData] = get_valid_requirements() - if valid_requirements.is_empty(): + + if requirements.is_empty(): return false - - for requirement_resource in valid_requirements: - if not bool(requirement_resource.is_met()): - return false - + + for req in requirements: + if req == null: + continue + if req.get_currency() == null: + continue + if not req.has_valid_amount(): + continue + return true - -func get_first_requirement_summary() -> String: - var valid_requirements: Array[GoalRequirementData] = get_valid_requirements() - if valid_requirements.is_empty(): - return "" - return String(valid_requirements[0].get_summary_text()) diff --git a/core/goals/goal_requirement_data.gd b/core/goals/goal_requirement_data.gd index eca58ec..cb95bc6 100644 --- a/core/goals/goal_requirement_data.gd +++ b/core/goals/goal_requirement_data.gd @@ -5,38 +5,11 @@ extends Resource @export var amount_mantissa: float = 0.0 @export var amount_exponent: int = 0 -func get_currency_id() -> StringName: - if currency == null: - return &"" - - return GameState.get_currency_id(currency) +func get_currency() -> Currency: + return currency func get_amount() -> BigNumber: return BigNumber.new(amount_mantissa, amount_exponent) -func has_valid_currency() -> bool: - var currency_id: StringName = get_currency_id() - if currency_id == &"": - return false - - return GameState.is_known_currency_id(currency_id) - func has_valid_amount() -> bool: return get_amount().mantissa >= 0.0 - -func is_valid() -> bool: - return has_valid_currency() and has_valid_amount() - -func is_met() -> bool: - if not is_valid(): - return false - - var total_amount: BigNumber = GameState.get_total_currency_acquired_by_id(get_currency_id()) - return not total_amount.is_less_than(get_amount()) - -func get_summary_text() -> String: - if not is_valid(): - return "" - - var currency_name: String = GameState.get_currency_name(get_currency_id()) - return "%s %s" % [get_amount().to_string_suffix(2), currency_name] diff --git a/core/level_game_state.gd b/core/level_game_state.gd new file mode 100644 index 0000000..672ad87 --- /dev/null +++ b/core/level_game_state.gd @@ -0,0 +1,1534 @@ +## Central state authority for the idle game. Manages currencies, generators, buffs, goals, research, and persistence. +## Acts as the single source of truth for all gameplay state, emitting signals for UI and other systems to react. +class_name LevelGameState +extends Node + +# ============================================================================== +# SIGNALS +# ============================================================================== +## Emitted when any currency balance changes. [param currency_id] identifies the currency, [param new_amount] is the updated balance. +signal currency_changed(currency_id: StringName, new_amount: BigNumber) +## Emitted when a generator's state changes (owned count, unlocked status, etc.). +signal generator_state_changed(generator_id: StringName, state: Dictionary) + +## Emitted when a global buff level changes. +signal buff_level_changed(buff_id: StringName, new_level: int) +## Emitted when a global buff unlock state changes. +signal buff_unlocked_changed(buff_id: StringName, unlocked: bool) +## Emitted when a goal is completed. +signal goal_completed(goal_id: StringName) +## Emitted when goal progress changes (for UI progress bars). +signal goal_progress_changed(goal_id: StringName, progress: float) +## Emitted when research XP changes. +signal research_xp_changed(research_id: StringName, new_xp: BigNumber) +## Emitted when a research track levels up. +signal research_level_up(research_id: StringName, old_level: int, new_level: int) +## Emitted when worker count changes (derived from currency balance). +signal worker_count_changed(currency_id: StringName, new_count: int) +## Emitted when research workers change. +signal research_workers_changed(new_count: int) +## Emitted when an prestige buff node is unlocked. +signal prestige_buff_unlocked(buff_id: StringName) + +# ============================================================================== +# EXPORTED REFERENCES +# ============================================================================== +## Catalog of all defined currencies in the game. +@export var currency_catalogue: CurrencyCatalogue +## Catalog of all defined buffs in the game. +@export var buff_catalogue: BuffCatalogue +## Catalog of all defined goals in the game. +@export var goal_catalogue: GoalCatalogue +## Catalog of all defined research tracks in the game. +@export var research_catalogue: ResearchCatalogue +## Catalog of all prestige buff nodes in the graph. +@export var prestige_buff_catalogue: PrestigeBuffCatalogue +## File path for save/load persistence. Defaults to user directory. +@export var save_file_path: String = "user://level_save.json" + +# ============================================================================== +# STATE VARIABLES +# ============================================================================== +## Current balance for each currency (resets on prestige). +var _current_currency: Dictionary = {} +## Total currency acquired this run (resets on prestige). +var _total_currency_acquired: Dictionary = {} +## All-time currency acquired (never resets, persists across prestige). +var _all_time_currency_acquired: Dictionary = {} + +## Generator states (owned count, unlocked status, availability). +var generator_states: Dictionary = {} + +## External save data sections for other systems (e.g., PrestigeManager). +var _external_save_sections: Dictionary = {} + +## Global buff definitions by ID. +var _buff_definitions: Dictionary = {} +## Global buff levels (shared across all targets). +var _buff_levels: Dictionary = {} +## Global buff unlock states. +var _buff_unlocked: Dictionary = {} +## Global buff active states (true when unlocked and active). +var _buff_active: Dictionary = {} + +## Goal definitions by ID. +var _goal_definitions: Dictionary = {} +## Goal completion state (true if completed). +var _goal_completed: Dictionary = {} + +## Research XP by research ID (BigNumber). +var research_xp: Dictionary = {} +## Research levels by research ID. +var research_levels: Dictionary = {} +## Workers assigned to research (total across all tracks). +var research_workers: int = 0 +## Ascension buff unlock state: maps node_id (StringName) → bool (true if unlocked). Persists across prestige. +var _prestige_buff_unlocked: Dictionary = {} + +## Unix timestamp of last save. +var last_save_time: int = 0 + +# ============================================================================== +# SAVE / LOAD CONSTANTS +# ============================================================================== +## JSON key for save format version. +const SAVE_FORMAT_VERSION_KEY: String = "save_format_version" +## Current save format version (7 includes research workers tracking). +const CURRENT_SAVE_FORMAT_VERSION: int = 8 +## JSON key for generator owned count. +const GENERATOR_OWNED_KEY: String = "owned" +## JSON key for generator purchased count. +const GENERATOR_PURCHASED_COUNT_KEY: String = "purchased_count" +## JSON key for generator unlocked status. +const GENERATOR_UNLOCKED_KEY: String = "unlocked" +## JSON key for generator availability. +const GENERATOR_AVAILABLE_KEY: String = "available" +## JSON key for generator states section. +const GENERATOR_STATES_KEY: String = "generator_states" + +## JSON key for buff definitions section. +const BUFF_DEFINITIONS_KEY: String = "buff_definitions" +## JSON key for buff levels section. +const BUFF_LEVELS_KEY: String = "buff_levels" +## JSON key for buff unlocked section. +const BUFF_UNLOCKED_KEY: String = "buff_unlocked" +## JSON key for buff active section. +const BUFF_ACTIVE_KEY: String = "buff_active" +## JSON key for goals section. +const GOALS_KEY: String = "goals" +## JSON key for goal completed array. +const GOAL_COMPLETED_KEY: String = "completed" +## JSON key for currencies section. +const CURRENCIES_KEY: String = "currencies" +## JSON key for current currency balance. +const CURRENT_KEY: String = "current" +## JSON key for total currency acquired. +const TOTAL_KEY: String = "total" +## JSON key for all-time currency acquired. +const ALL_TIME_KEY: String = "all_time" +## JSON key for last save timestamp. +const LAST_SAVE_TIME_KEY: String = "last_save_time" +## JSON key for research XP section. +const RESEARCH_XP_KEY: String = "research_xp" +## JSON key for research levels section. +const RESEARCH_LEVELS_KEY: String = "research_levels" +## JSON key for research workers section. +const RESEARCH_WORKERS_KEY: String = "research_workers" +## JSON key for prestige buff unlocked array. +const PRESTIGE_BUFF_UNLOCKED_KEY: String = "prestige_buff_unlocked" + +## Reference to the PrestigeManager autoload node (set in _ready). +var prestige_manager: PrestigeManager + +## Called when the node enters the scene tree. Initializes all game systems, loads saved data, and restores state. +func _ready() -> void: + prestige_manager = find_child("PrestigeManager") + assert(prestige_manager != null) + + _initialize_currency_maps() + _initialize_catalogues() + _initialize_prestige_buffs() + _load_catalogue_goals() + load_game() + _try_unlock_all_buffs_from_goals() + _initialize_research() + +#region currency + +# ============================================================================== +# CURRENCY API +# ============================================================================== +## Returns the normalized ID from a Currency resource. +func get_currency_id(currency: Currency) -> StringName: + return _normalize_currency_id(currency.id if currency else &"") + +## Parses and normalizes a currency ID from raw input (handles special cases like "gem" -> "gems"). +func parse_currency_id(raw_currency: Variant) -> StringName: + var currency_text: String = String(raw_currency).to_lower().strip_edges() + if currency_text == "gem": + currency_text = "gems" + return _normalize_currency_id(StringName(currency_text)) + +## Checks if a currency ID is known in the catalogue. +func is_known_currency_id(currency_id: StringName) -> bool: + if not currency_catalogue: + return false + var ids: Array[StringName] = currency_catalogue.get_all_ids() + return ids.has(_normalize_currency_id(currency_id)) + +## Returns the Currency resource for a given ID, or null if not found. +func get_currency_resource(currency_id: StringName) -> Currency: + if not currency_catalogue: + return null + return currency_catalogue.get_currency_by_id(_normalize_currency_id(currency_id)) + +## Returns the display name for a currency ID, falling back to a humanized ID if not found. +func get_currency_name(currency_id: StringName) -> String: + var currency: Currency = get_currency_resource(currency_id) + if currency != null: + var display_name: String = String(currency.display_name).strip_edges() + if not display_name.is_empty(): + return display_name + return _humanize_currency_id(currency_id) + +## Returns the icon texture for a currency ID, or null if not found. +func get_currency_icon(currency_id: StringName) -> Texture2D: + var currency: Currency = get_currency_resource(currency_id) + if currency == null: + return null + var raw_icon: Variant = currency.icon + if raw_icon is Texture2D: + return raw_icon + return null + +## Adds currency from a Currency resource. Emits [signal currency_changed] on success. +func add_currency(currency: Currency, amount: BigNumber) -> void: + var currency_id: StringName = get_currency_id(currency) + if currency_id == &"": + push_warning("Attempted to add currency with an invalid Currency resource.") + return + add_currency_by_id(currency_id, amount) + +## Spends currency from a Currency resource. Returns false if insufficient funds. +func spend_currency(currency: Currency, cost: BigNumber) -> bool: + var currency_id: StringName = get_currency_id(currency) + if currency_id == &"": + push_warning("Attempted to spend currency with an invalid Currency resource.") + return false + return spend_currency_by_id(currency_id, cost) + +## Adds currency by ID. Updates current, total, and all-time tracking. Emits [signal currency_changed]. +func add_currency_by_id(currency_id: StringName, amount: BigNumber) -> void: + var normalized_currency_id: StringName = _normalize_currency_id(currency_id) + if normalized_currency_id == &"": + return + + if amount.mantissa > 0.0: + _get_total_currency_ref(normalized_currency_id).add_in_place(amount) + _get_all_time_currency_ref(normalized_currency_id).add_in_place(amount) + + var current: BigNumber = _get_current_currency_ref(normalized_currency_id) + current.add_in_place(amount) + currency_changed.emit(normalized_currency_id, current) + +## Spends currency by ID. Returns true if successful, false if insufficient funds. Emits [signal currency_changed]. +func spend_currency_by_id(currency_id: StringName, cost: BigNumber) -> bool: + var normalized_currency_id: StringName = _normalize_currency_id(currency_id) + if normalized_currency_id == &"": + return false + + var current: BigNumber = _get_current_currency_ref(normalized_currency_id) + 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(normalized_currency_id, current) + return true + return false + +## Returns the current balance for a Currency resource. +func get_currency_amount(currency: Currency) -> BigNumber: + return get_currency_amount_by_id(get_currency_id(currency)) + +## Returns the current balance for a currency ID. +func get_currency_amount_by_id(currency_id: StringName) -> BigNumber: + return _get_current_currency_ref(currency_id) + +## Returns the total acquired this run for a Currency resource. +func get_total_currency_acquired(currency: Currency) -> BigNumber: + return get_total_currency_acquired_by_id(get_currency_id(currency)) + +## Returns the total acquired this run for a currency ID (resets on prestige). +func get_total_currency_acquired_by_id(currency_id: StringName) -> BigNumber: + return _get_total_currency_ref(currency_id) + +## Returns the all-time total for a Currency resource. +func get_all_time_currency_acquired(currency: Currency) -> BigNumber: + return get_all_time_currency_acquired_by_id(get_currency_id(currency)) + +## Returns the all-time total for a currency ID (never resets). +func get_all_time_currency_acquired_by_id(currency_id: StringName) -> BigNumber: + return _get_all_time_currency_ref(currency_id) + +#endregion + +#region generator +# ============================================================================== +# GENERATOR STATE API +# ============================================================================== +## Registers a generator with the game state. Creates default state if not exists, sanitizes existing state otherwise. +## Emits [signal generator_state_changed] on registration or update. +func register_generator(generator_id: StringName, unlocked: bool = false, available: bool = false, owned: int = 0) -> void: + var key: String = String(generator_id) + if key.is_empty(): + return + var default_owned: int = _to_non_negative_int(owned, 0) + + if generator_states.has(key): + var existing_raw: Variant = generator_states.get(key) + if existing_raw is Dictionary: + generator_states[key] = _sanitize_generator_state(existing_raw, unlocked, available, default_owned) + return + + var new_state: Dictionary = _default_generator_state(unlocked, available, default_owned) + generator_states[key] = new_state + generator_state_changed.emit(StringName(key), new_state.duplicate(true)) + +## Returns a copy of the full state dictionary for a generator. +func get_generator_state(generator_id: StringName) -> Dictionary: + return _get_generator_state(generator_id).duplicate(true) + +## Returns the owned count for a generator. +func get_generator_owned(generator_id: StringName) -> int: + var state: Dictionary = _get_generator_state(generator_id) + return int(state.get(GENERATOR_OWNED_KEY, 0)) + +## Sets the owned count for a generator. Also updates purchased_count to be at least the new owned value. +func set_generator_owned(generator_id: StringName, value: int) -> void: + var state: Dictionary = _get_generator_state(generator_id) + var next_owned: int = maxi(value, 0) + if int(state.get(GENERATOR_OWNED_KEY, 0)) == next_owned: + return + + state[GENERATOR_OWNED_KEY] = next_owned + state[GENERATOR_PURCHASED_COUNT_KEY] = maxi(int(state.get(GENERATOR_PURCHASED_COUNT_KEY, 0)), next_owned) + _set_generator_state(generator_id, state) + +## Returns the total purchased count for a generator (lifetime purchases, never decreases). +func get_generator_purchased_count(generator_id: StringName) -> int: + var state: Dictionary = _get_generator_state(generator_id) + return int(state.get(GENERATOR_PURCHASED_COUNT_KEY, 0)) + +## Sets the purchased count for a generator. Ensures it never drops below owned count. +func set_generator_purchased_count(generator_id: StringName, value: int) -> void: + var state: Dictionary = _get_generator_state(generator_id) + var min_purchased: int = int(state.get(GENERATOR_OWNED_KEY, 0)) + var next_purchased: int = maxi(value, min_purchased) + if int(state.get(GENERATOR_PURCHASED_COUNT_KEY, 0)) == next_purchased: + return + + state[GENERATOR_PURCHASED_COUNT_KEY] = next_purchased + _set_generator_state(generator_id, state) + +## Returns true if a generator is unlocked (visible and accessible to player). +func is_generator_unlocked(generator_id: StringName) -> bool: + var state: Dictionary = _get_generator_state(generator_id) + return bool(state.get(GENERATOR_UNLOCKED_KEY, false)) + +## Sets the unlocked state for a generator. Emits [signal generator_state_changed]. +func set_generator_unlocked(generator_id: StringName, value: bool) -> void: + var state: Dictionary = _get_generator_state(generator_id) + if bool(state.get(GENERATOR_UNLOCKED_KEY, false)) == value: + return + + state[GENERATOR_UNLOCKED_KEY] = value + _set_generator_state(generator_id, state) + +## Returns true if a generator is available (may differ from unlocked for goal-locked generators). +func is_generator_available(generator_id: StringName) -> bool: + var state: Dictionary = _get_generator_state(generator_id) + return bool(state.get(GENERATOR_AVAILABLE_KEY, false)) + +## Sets the availability state for a generator. Emits [signal generator_state_changed]. +func set_generator_available(generator_id: StringName, value: bool) -> void: + var state: Dictionary = _get_generator_state(generator_id) + if bool(state.get(GENERATOR_AVAILABLE_KEY, false)) == value: + return + + state[GENERATOR_AVAILABLE_KEY] = value + _set_generator_state(generator_id, state) + +#endregion + + +#region buff +# ============================================================================== +# BUFF API +# ============================================================================== +## Registers a global buff definition for runtime lookup. Initializes level, unlock, and active state. +func register_buff(buff: GeneratorBuffData) -> void: + if buff == null: + return + var buff_id: StringName = buff.id + if buff_id == &"": + push_warning("Attempted to register buff with invalid id") + return + + _buff_definitions[buff_id] = buff + if not _buff_levels.has(buff_id): + _buff_levels[buff_id] = 0 + if not _buff_unlocked.has(buff_id): + _buff_unlocked[buff_id] = false + if not _buff_active.has(buff_id): + _buff_active[buff_id] = false + +## Returns the buff definition for a given ID, or null if not registered. +func get_buff(buff_id: StringName) -> GeneratorBuffData: + return _buff_definitions.get(buff_id, null) + +## Returns all registered global buffs as an array. +func get_all_buffs() -> Array[GeneratorBuffData]: + var result: Array[GeneratorBuffData] = [] + for buff in _buff_definitions.values(): + if buff is GeneratorBuffData: + result.append(buff) + return result + +## Returns the current level of a global buff (0 if not registered). +func get_buff_level(buff_id: StringName) -> int: + return _buff_levels.get(buff_id, 0) + +## Sets the level of a global buff. Emits [signal buff_level_changed]. +func set_buff_level(buff_id: StringName, level: int) -> void: + if level < 0: + level = 0 + + var current_level: int = _buff_levels.get(buff_id, 0) + if current_level == level: + return + + _buff_levels[buff_id] = level + buff_level_changed.emit(buff_id, level) + +## Returns true if a global buff is unlocked. +func is_buff_unlocked(buff_id: StringName) -> bool: + return _buff_unlocked.get(buff_id, false) + +## Sets the unlock state for a global buff. Emits [signal buff_unlocked_changed] and activates the buff if unlocked. +func set_buff_unlocked(buff_id: StringName, unlocked: bool) -> void: + if bool(_buff_unlocked.get(buff_id, false)) == unlocked: + return + + _buff_unlocked[buff_id] = unlocked + buff_unlocked_changed.emit(buff_id, unlocked) + + if unlocked and not _buff_active.get(buff_id, false): + _buff_active[buff_id] = true + +## Returns true if a global buff is currently active (unlocked and applied). +func is_buff_active(buff_id: StringName) -> bool: + return _buff_active.get(buff_id, false) + +## Returns the list of generators targeted by a global buff. +func get_generators_targeted_by(buff_id: StringName) -> Array[StringName]: + var buff: GeneratorBuffData = get_buff(buff_id) + if buff == null: + return [] + return buff.get_target_generators() + +## Returns all buffs that affect a specific generator. +func get_buffs_for_generator(generator_id: StringName) -> Array[GeneratorBuffData]: + if buff_catalogue: + return buff_catalogue.get_buffs_for_generator(generator_id) + + var result: Array[GeneratorBuffData] = [] + for buff in _buff_definitions.values(): + if buff is GeneratorBuffData and buff.targets_generator(generator_id): + result.append(buff) + return result + +## Calculates the effective production multiplier for a generator by combining all active buffs of the specified kind. +func get_effective_multiplier(generator_id: StringName, kind: int) -> float: + var multiplier: float = 1.0 + for buff in get_buffs_for_generator(generator_id): + if buff == null: + continue + if not is_buff_active(buff.id): + continue + if buff.kind != kind: + continue + + var level: int = get_buff_level(buff.id) + if level <= 0: + continue + + var buff_multiplier: float = buff.get_effect_multiplier(level) + multiplier *= buff_multiplier + + return multiplier +#endregion + +#region research +# ============================================================================== +# RESEARCH API +# ============================================================================== +## Initializes all research tracks from the catalogue on game start. +func _initialize_research() -> void: + if research_catalogue: + for research in research_catalogue.get_all_research(): + register_research(research.id) + +## Registers a research track, initializing XP and level storage. +func register_research(research_id: StringName) -> void: + if not research_xp.has(research_id): + research_xp[research_id] = BigNumber.new(0.0, 0) + if not research_levels.has(research_id): + research_levels[research_id] = 0 + +## Returns the current XP for a research track. +func get_research_xp(research_id: StringName) -> BigNumber: + var val: Variant = research_xp.get(research_id, BigNumber.new(0.0, 0)) + return val as BigNumber + +## Returns the current level for a research track. +func get_research_level(research_id: StringName) -> int: + return research_levels.get(research_id, 0) + +## Returns the production multiplier provided by a research track at its current level. +func get_research_multiplier(research_id: StringName) -> float: + var level: int = get_research_level(research_id) + var research: ResearchData = _get_research_data(research_id) + if research == null: + return 1.0 + return research.get_multiplier_for_level(level) + +## Returns the total number of workers assigned to research. +func get_research_workers() -> int: + return research_workers + +## Assigns a worker to research (called when spending worker currency on research). +func assign_worker_to_research() -> bool: + var worker_currency: BigNumber = get_currency_amount_by_id("worker") + if worker_currency.mantissa < 1.0: + return false + + spend_currency_by_id("worker", BigNumber.from_float(1.0)) + research_workers += 1 + research_workers_changed.emit(research_workers) + return true + +## Removes a worker from research (returns to worker currency). +func remove_worker_from_research() -> bool: + if research_workers <= 0: + return false + + research_workers -= 1 + add_currency_by_id("worker", BigNumber.from_float(1.0)) + research_workers_changed.emit(research_workers) + return true + +## Adds XP to a research track, applying buff modifiers and emitting signals on level up. +func add_research_xp(research_id: StringName, xp: BigNumber) -> void: + var actual_xp: BigNumber = ResearchBuffCalculator.apply_buffs(self, research_id, xp) + + var old_level: int = get_research_level(research_id) + var current_xp: BigNumber = get_research_xp(research_id) + research_xp[research_id] = current_xp.add(actual_xp) + + var research: ResearchData = _get_research_data(research_id) + if research != null: + var new_level: int = research.get_level_for_xp(research_xp[research_id]) + + if new_level > old_level: + research_levels[research_id] = new_level + research_level_up.emit(research_id, old_level, new_level) + + research_xp_changed.emit(research_id, research_xp[research_id]) + +## Retrieves the ResearchData resource for a given ID from the catalogue. +func _get_research_data(research_id: StringName) -> ResearchData: + if research_catalogue == null: + return null + return research_catalogue.get_research_by_id(research_id) + +## Returns the current worker count (derived from "worker" currency balance). +func get_worker_count() -> int: + var worker_amount: BigNumber = get_currency_amount_by_id("worker") + return int(worker_amount.mantissa) if worker_amount.mantissa >= 0 else 0 + +#endregion + +# ============================================================================== +# EXTERNAL SAVE DATA API +# ============================================================================== +## Stores external save data from other systems (e.g., PrestigeManager). Creates or overwrites the section. +func set_external_save_data(section_key: StringName, payload: Dictionary) -> void: + var key: String = String(section_key).strip_edges() + if key.is_empty(): + return + + _external_save_sections[key] = payload.duplicate(true) + +## Retrieves a saved data section by key. Returns empty dict if not found. +func get_external_save_data(section_key: StringName) -> Dictionary: + var key: String = String(section_key).strip_edges() + if key.is_empty(): + return {} + + var raw_payload: Variant = _external_save_sections.get(key, {}) + if raw_payload is Dictionary: + return raw_payload.duplicate(true) + + return {} + +# ============================================================================== +# PRESTIGE RESET API +# ============================================================================== +## Resets all progress for a prestige reset. Optionally resets total currency, preserves specified currencies, and controls signal emission. +func reset_for_prestige(reset_total_currency: bool = false, emit_currency_signals: bool = true, preserve_currency_ids: Array[StringName] = []) -> void: + var currency_ids: Array[StringName] = _collect_currency_ids_for_save() + for currency_id in currency_ids: + if currency_id in preserve_currency_ids: + continue + _set_current_currency(currency_id, BigNumber.from_float(0.0)) + if reset_total_currency: + _set_total_currency(currency_id, BigNumber.from_float(0.0)) + + generator_states.clear() + + for goal_id in _goal_definitions.keys(): + _goal_completed[goal_id] = false + + var research_ids: Array = research_xp.keys() + research_xp.clear() + research_levels.clear() + + for research_id in research_ids: + research_xp_changed.emit(research_id, BigNumber.from_float(0.0)) + + if not emit_currency_signals: + return + + for currency_id in currency_ids: + currency_changed.emit(currency_id, _get_current_currency_ref(currency_id)) + +## Internal helper: Emits currency_changed signals for all currencies to refresh UI after a prestige load. +func emit_currency_changed_for_all() -> void: + for currency_id in _collect_currency_ids_for_save(): + currency_changed.emit(currency_id, _get_current_currency_ref(currency_id)) + +# ========================================== +# PERSISTENCE (Save/Load) +# ========================================== +## Saves all game state to disk as JSON. +func save_game() -> void: + if save_file_path.is_empty(): + push_warning("LevelGameState: save_file_path is empty; skipping save.") + return + + var save_data = { + SAVE_FORMAT_VERSION_KEY: CURRENT_SAVE_FORMAT_VERSION, + CURRENCIES_KEY: _serialize_currency_balances(), + GENERATOR_STATES_KEY: _serialize_generator_states(), + BUFF_LEVELS_KEY: _serialize_buff_levels(), + BUFF_UNLOCKED_KEY: _serialize_buff_unlocked(), + BUFF_ACTIVE_KEY: _serialize_buff_active(), + GOALS_KEY: _serialize_goals(), + RESEARCH_XP_KEY: _serialize_research_xp(), + RESEARCH_LEVELS_KEY: research_levels.duplicate(), + RESEARCH_WORKERS_KEY: research_workers, + PRESTIGE_BUFF_UNLOCKED_KEY: _serialize_prestige_buff_unlocked(), + LAST_SAVE_TIME_KEY: Time.get_unix_time_from_system() + } + + for section_key in _external_save_sections.keys(): + var key: String = String(section_key) + if key.is_empty(): + continue + + var payload: Variant = _external_save_sections.get(section_key) + if not (payload is Dictionary): + continue + + save_data[key] = payload + + var file: FileAccess = FileAccess.open(save_file_path, FileAccess.WRITE) + if file: + file.store_string(JSON.stringify(save_data)) + else: + push_error("LevelGameState: Failed to open save file: %s" % save_file_path) + +## Loads all game state from disk JSON. +func load_game() -> void: + if not FileAccess.file_exists(save_file_path): + return + + var file: FileAccess = FileAccess.open(save_file_path, FileAccess.READ) + if file: + var parsed: Variant = JSON.parse_string(file.get_as_text()) + if parsed is Dictionary: + var parsed_data: Dictionary = parsed + + var version: int = int(parsed_data.get(SAVE_FORMAT_VERSION_KEY, 1)) + if version < 2: + push_error("Save file version %d too old. Please start fresh." % version) + return + + if version < 3: + _migrate_goals_from_version_2(parsed_data) + + _external_save_sections.clear() + if parsed_data.has(CURRENCIES_KEY): + _load_currency_balances(parsed_data.get(CURRENCIES_KEY, {})) + + generator_states = _deserialize_generator_states(parsed_data.get(GENERATOR_STATES_KEY, {})) + _deserialize_buff_states(parsed_data) + _deserialize_goals(parsed_data.get(GOALS_KEY, {})) + + if parsed_data.has(RESEARCH_XP_KEY): + research_xp = _deserialize_research_xp(parsed_data.get(RESEARCH_XP_KEY, {})) + if parsed_data.has(RESEARCH_LEVELS_KEY): + research_levels = parsed_data.get(RESEARCH_LEVELS_KEY, {}) + if parsed_data.has(RESEARCH_WORKERS_KEY): + research_workers = int(parsed_data.get(RESEARCH_WORKERS_KEY, 0)) + + last_save_time = parsed_data.get(LAST_SAVE_TIME_KEY, Time.get_unix_time_from_system()) + if parsed_data.has(PRESTIGE_BUFF_UNLOCKED_KEY): + _deserialize_prestige_buff_unlocked(parsed_data.get(PRESTIGE_BUFF_UNLOCKED_KEY)) + for save_key_variant in parsed_data.keys(): + var save_key: String = String(save_key_variant) + if save_key.is_empty(): + continue + if save_key in [SAVE_FORMAT_VERSION_KEY, CURRENCIES_KEY, GENERATOR_STATES_KEY, BUFF_LEVELS_KEY, BUFF_UNLOCKED_KEY, BUFF_ACTIVE_KEY, GOALS_KEY, RESEARCH_XP_KEY, RESEARCH_LEVELS_KEY, RESEARCH_WORKERS_KEY, PRESTIGE_BUFF_UNLOCKED_KEY, LAST_SAVE_TIME_KEY]: + continue + + var section_payload: Variant = parsed_data.get(save_key_variant) + if section_payload is Dictionary: + _external_save_sections[save_key] = section_payload.duplicate(true) + +## Internal helper: Loads currency balances from deserialized save data. +func _load_currency_balances(raw_currencies: Variant) -> void: + if not (raw_currencies is Dictionary): + return + + var currencies: Dictionary = raw_currencies + for raw_currency_id in currencies.keys(): + var currency_id: StringName = _normalize_currency_id(StringName(String(raw_currency_id))) + if currency_id == &"": + continue + + var raw_entry: Variant = currencies.get(raw_currency_id) + if not (raw_entry is Dictionary): + continue + + var entry: Dictionary = raw_entry + var current_amount: BigNumber = BigNumber.deserialize(entry.get(CURRENT_KEY, {})) + var total_amount: BigNumber = _deserialize_total_currency(entry.get(TOTAL_KEY, null), current_amount) + var all_time_amount: BigNumber = _deserialize_all_time_currency(entry.get(ALL_TIME_KEY, null), total_amount) + _set_current_currency(currency_id, current_amount) + _set_total_currency(currency_id, total_amount) + _set_all_time_currency(currency_id, all_time_amount) + +## Internal helper: Deserializes total currency from save data with validation. +func _deserialize_total_currency(raw_total: Variant, current_amount: BigNumber) -> BigNumber: + var minimum_total: BigNumber = BigNumber.new(current_amount.mantissa, current_amount.exponent) + 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 + +## Internal helper: Deserializes all-time currency from save data with validation. +func _deserialize_all_time_currency(raw_all_time: Variant, total_amount: BigNumber) -> BigNumber: + var minimum_all_time: BigNumber = BigNumber.new(total_amount.mantissa, total_amount.exponent) + if minimum_all_time.mantissa < 0.0: + minimum_all_time = BigNumber.from_float(0.0) + + if not (raw_all_time is Dictionary): + return minimum_all_time + + var parsed_all_time: BigNumber = BigNumber.deserialize(raw_all_time) + if parsed_all_time.mantissa < 0.0: + return minimum_all_time + if parsed_all_time.is_less_than(minimum_all_time): + return minimum_all_time + return parsed_all_time + +## Internal helper: Serializes all currency balances for saving. +func _serialize_currency_balances() -> Dictionary: + var result: Dictionary = {} + + for currency_id in _collect_currency_ids_for_save(): + var key: String = String(currency_id) + if key.is_empty(): + continue + + result[key] = { + CURRENT_KEY: _get_current_currency_ref(currency_id).serialize(), + TOTAL_KEY: _get_total_currency_ref(currency_id).serialize(), + ALL_TIME_KEY: _get_all_time_currency_ref(currency_id).serialize(), + } + + return result + +## Internal helper: Collects all currency IDs for serialization. +func _collect_currency_ids_for_save() -> Array[StringName]: + var ids: Array[StringName] = [] + + for raw_id in _current_currency.keys(): + var normalized_currency_id: StringName = _normalize_currency_id(StringName(String(raw_id))) + if normalized_currency_id == &"" or ids.has(normalized_currency_id): + continue + ids.append(normalized_currency_id) + + for raw_id in _all_time_currency_acquired.keys(): + var normalized_currency_id: StringName = _normalize_currency_id(StringName(String(raw_id))) + if normalized_currency_id == &"" or ids.has(normalized_currency_id): + continue + ids.append(normalized_currency_id) + + if currency_catalogue: + for known_currency_id in currency_catalogue.get_all_ids(): + var normalized_currency_id: StringName = _normalize_currency_id(known_currency_id) + if normalized_currency_id == &"" or ids.has(normalized_currency_id): + continue + ids.append(normalized_currency_id) + + return ids + +## Internal helper: Initializes all currency maps from the catalogue. +func _initialize_currency_maps() -> void: + if currency_catalogue: + for currency_id in currency_catalogue.get_all_ids(): + if currency_id == &"": + continue + _set_current_currency(currency_id, BigNumber.from_float(0.0)) + _set_total_currency(currency_id, BigNumber.from_float(0.0)) + _set_all_time_currency(currency_id, BigNumber.from_float(0.0)) + +## Internal helper: Initializes all catalogues (buffs, goals). +func _initialize_catalogues() -> void: + if currency_catalogue: + for currency in currency_catalogue.currencies: + if currency == null: + continue + + if buff_catalogue: + for buff in buff_catalogue.buffs: + if buff == null: + continue + register_buff(buff) + + if goal_catalogue: + for goal in goal_catalogue.goals: + if goal == null: + continue + register_goal(goal) + +## Internal helper: Initializes prestige buff unlock state from the catalogue, filling missing node IDs as false. +func _initialize_prestige_buffs() -> void: + if prestige_buff_catalogue == null: + return + for node in prestige_buff_catalogue.nodes: + if node == null or node.id == &"": + continue + if not _prestige_buff_unlocked.has(node.id): + _prestige_buff_unlocked[node.id] = false + +## Internal helper: Loads goal definitions from the catalogue. +func _load_catalogue_goals() -> void: + if goal_catalogue: + for goal in goal_catalogue.goals: + if goal == null: + continue + if not goal.has_id(): + continue + if _goal_definitions.has(goal.id): + continue + _goal_definitions[goal.id] = goal + _goal_completed[goal.id] = false + +# ============================================================================== +# GOAL EVALUATION HELPERS +# ============================================================================== +## Internal helper: Checks if a goal requirement's currency amount has been reached. +func is_goal_requirement_met(requirement: GoalRequirementData) -> bool: + if requirement == null: + return false + + var currency: Currency = requirement.get_currency() + if currency == null: + return false + + var currency_id: StringName = get_currency_id(currency) + if currency_id == &"": + return false + + var total_amount: BigNumber = get_total_currency_acquired_by_id(currency_id) + var required_amount: BigNumber = requirement.get_amount() + + return not total_amount.is_less_than(required_amount) + +## Internal helper: Returns progress (0.0-1.0) for a goal requirement using logarithmic scaling for large numbers. +func get_goal_requirement_progress(requirement: GoalRequirementData) -> float: + if requirement == null: + return 0.0 + + var currency: Currency = requirement.get_currency() + if currency == null: + return 0.0 + + var currency_id: StringName = get_currency_id(currency) + if currency_id == &"": + return 0.0 + + var current_amount: BigNumber = get_total_currency_acquired_by_id(currency_id) + var required_amount: BigNumber = requirement.get_amount() + + if required_amount.mantissa <= 0.0: + return 1.0 + + if current_amount.is_greater_than(required_amount) or current_amount.is_equal_to(required_amount): + return 1.0 + + if current_amount.mantissa <= 0.0: + return 0.0 + + var current_log: float = log(maxf(current_amount.mantissa, 0.0001)) / log(10) + float(current_amount.exponent) + var required_log: float = log(maxf(required_amount.mantissa, 0.0001)) / log(10) + float(required_amount.exponent) + + if required_log <= 0.0: + return 1.0 + + var progress: float = current_log / required_log + return clampf(progress, 0.0, 1.0) + +## Internal helper: Returns a human-readable summary string for a goal requirement (amount + currency name). +func get_goal_requirement_summary(requirement: GoalRequirementData) -> String: + if requirement == null: + return "" + + var currency: Currency = requirement.get_currency() + if currency == null: + return "" + + var currency_id: StringName = get_currency_id(currency) + if currency_id == &"": + return "" + + var currency_name: String = get_currency_name(currency_id) + return "%s %s" % [requirement.get_amount().to_string_suffix(2), currency_name] + +## Internal helper: Returns true if all requirements for a goal are met. +func is_goal_met(goal: GoalData) -> bool: + if goal == null: + return false + + for req in goal.get_requirements(): + if not is_goal_requirement_met(req): + return false + + return true + +## Internal helper: Returns the overall progress (0.0-1.0) for a goal based on its slowest requirement. +func get_goal_progress(goal: GoalData) -> float: + if goal == null: + return 0.0 + + var requirements: Array[GoalRequirementData] = goal.get_requirements() + if requirements.is_empty(): + return 0.0 + + var min_progress: float = 1.0 + for req in requirements: + if req == null: + continue + + var req_progress: float = get_goal_requirement_progress(req) + if req_progress < min_progress: + min_progress = req_progress + + return min_progress + +## Internal helper: Normalizes a currency ID (lowercase, trimmed). +func _normalize_currency_id(currency_id: StringName) -> StringName: + var normalized: String = String(currency_id).to_lower().strip_edges() + if normalized.is_empty(): + return &"" + return StringName(normalized) + +## Internal helper: Gets or creates a reference to the current currency balance. +func _get_current_currency_ref(currency_id: StringName) -> BigNumber: + var normalized_currency_id: StringName = _normalize_currency_id(currency_id) + if normalized_currency_id == &"": + return BigNumber.from_float(0.0) + + var value: Variant = _current_currency.get(normalized_currency_id) + if value is BigNumber: + return value + + var fallback: BigNumber = BigNumber.from_float(0.0) + _current_currency[normalized_currency_id] = fallback + return fallback + +## Internal helper: Gets or creates a reference to the total currency acquired this run. +func _get_total_currency_ref(currency_id: StringName) -> BigNumber: + var normalized_currency_id: StringName = _normalize_currency_id(currency_id) + if normalized_currency_id == &"": + return BigNumber.from_float(0.0) + + var value: Variant = _total_currency_acquired.get(normalized_currency_id) + if value is BigNumber: + return value + + var fallback: BigNumber = BigNumber.from_float(0.0) + _total_currency_acquired[normalized_currency_id] = fallback + return fallback + +## Internal helper: Gets or creates a reference to the all-time currency acquired. +func _get_all_time_currency_ref(currency_id: StringName) -> BigNumber: + var normalized_currency_id: StringName = _normalize_currency_id(currency_id) + if normalized_currency_id == &"": + return BigNumber.from_float(0.0) + + var value: Variant = _all_time_currency_acquired.get(normalized_currency_id) + if value is BigNumber: + return value + + var fallback: BigNumber = BigNumber.from_float(0.0) + _all_time_currency_acquired[normalized_currency_id] = fallback + return fallback + +## Internal helper: Sets the current currency balance. +func _set_current_currency(currency_id: StringName, value: BigNumber) -> void: + var normalized_currency_id: StringName = _normalize_currency_id(currency_id) + if normalized_currency_id == &"": + return + _current_currency[normalized_currency_id] = BigNumber.new(value.mantissa, value.exponent) + +## Internal helper: Sets the total currency acquired this run. +func _set_total_currency(currency_id: StringName, value: BigNumber) -> void: + var normalized_currency_id: StringName = _normalize_currency_id(currency_id) + if normalized_currency_id == &"": + return + _total_currency_acquired[normalized_currency_id] = BigNumber.new(value.mantissa, value.exponent) + +## Internal helper: Sets the all-time currency acquired. +func _set_all_time_currency(currency_id: StringName, value: BigNumber) -> void: + var normalized_currency_id: StringName = _normalize_currency_id(currency_id) + if normalized_currency_id == &"": + return + _all_time_currency_acquired[normalized_currency_id] = BigNumber.new(value.mantissa, value.exponent) + +# ============================================================================== +# GENERATOR STATE HELPERS +# ============================================================================== +## Internal helper: Creates a default generator state dictionary with the specified values. +func _default_generator_state(unlocked: bool, available: bool, owned: int = 0) -> Dictionary: + var owned_value: int = _to_non_negative_int(owned, 0) + return { + GENERATOR_OWNED_KEY: owned_value, + GENERATOR_PURCHASED_COUNT_KEY: owned_value, + GENERATOR_UNLOCKED_KEY: unlocked, + GENERATOR_AVAILABLE_KEY: available, + } + +## Internal helper: Gets or creates the state dictionary for a generator, ensuring it exists and is valid. +func _get_generator_state(generator_id: StringName) -> Dictionary: + var key: String = String(generator_id) + if key.is_empty(): + return _default_generator_state(false, false, 0) + + if not generator_states.has(key): + register_generator(generator_id) + + var existing_raw: Variant = generator_states.get(key, _default_generator_state(false, false, 0)) + if existing_raw is Dictionary: + var state: Dictionary = existing_raw + var current_unlocked: bool = state.get(GENERATOR_UNLOCKED_KEY, false) + var current_available: bool = state.get(GENERATOR_AVAILABLE_KEY, false) + var current_owned: int = state.get(GENERATOR_OWNED_KEY, 0) + var sanitized: Dictionary = _sanitize_generator_state(state, current_unlocked, current_available, current_owned) + generator_states[key] = sanitized + return sanitized + + var fallback: Dictionary = _default_generator_state(false, false, 0) + generator_states[key] = fallback + return fallback + +## Internal helper: Updates a generator's state dictionary and emits the change signal. +func _set_generator_state(generator_id: StringName, state: Dictionary) -> void: + var key: String = String(generator_id) + if key.is_empty(): + return + + var current_unlocked: bool = state.get(GENERATOR_UNLOCKED_KEY, false) + var current_available: bool = state.get(GENERATOR_AVAILABLE_KEY, false) + var current_owned: int = state.get(GENERATOR_OWNED_KEY, 0) + var sanitized: Dictionary = _sanitize_generator_state(state, current_unlocked, current_available, current_owned) + generator_states[key] = sanitized + generator_state_changed.emit(StringName(key), sanitized.duplicate(true)) + +# ============================================================================== +# GENERATOR SERIALIZATION HELPERS +# ============================================================================== +## Internal helper: Serializes all generator states for saving. +func _serialize_generator_states() -> Dictionary: + var result: Dictionary = {} + for key_variant in generator_states.keys(): + var key: String = String(key_variant) + if key.is_empty(): + continue + + var value: Variant = generator_states.get(key_variant) + if value is Dictionary: + result[key] = _serialize_generator_state_entry(value) + return result + +## Internal helper: Deserializes generator states from save data. +func _deserialize_generator_states(raw_value: Variant) -> Dictionary: + var result: Dictionary = {} + if not (raw_value is Dictionary): + return result + + for key_variant in raw_value.keys(): + var key: String = String(key_variant) + if key.is_empty(): + continue + + var entry: Variant = raw_value.get(key_variant) + if entry is Dictionary: + result[key] = _deserialize_generator_state_entry(entry) + return result + + +## Internal helper: Serializes a single generator state entry for saving. +func _serialize_generator_state_entry(raw_state: Dictionary) -> Dictionary: + var owned_value: int = _to_non_negative_int(raw_state.get(GENERATOR_OWNED_KEY, 0), 0) + var purchased_value: int = _to_non_negative_int(raw_state.get(GENERATOR_PURCHASED_COUNT_KEY, owned_value), owned_value) + + var serialized_state: Dictionary = { + GENERATOR_OWNED_KEY: owned_value, + GENERATOR_PURCHASED_COUNT_KEY: maxi(purchased_value, owned_value), + } + + if raw_state.has(GENERATOR_UNLOCKED_KEY) and raw_state.get(GENERATOR_UNLOCKED_KEY) is bool: + serialized_state[GENERATOR_UNLOCKED_KEY] = raw_state.get(GENERATOR_UNLOCKED_KEY) + if raw_state.has(GENERATOR_AVAILABLE_KEY) and raw_state.get(GENERATOR_AVAILABLE_KEY) is bool: + serialized_state[GENERATOR_AVAILABLE_KEY] = raw_state.get(GENERATOR_AVAILABLE_KEY) + + return serialized_state + +## Internal helper: Deserializes a single generator state entry from save data. +func _deserialize_generator_state_entry(raw_state: Dictionary) -> Dictionary: + var owned_value: int = _to_non_negative_int(raw_state.get(GENERATOR_OWNED_KEY, 0), 0) + var purchased_value: int = _to_non_negative_int(raw_state.get(GENERATOR_PURCHASED_COUNT_KEY, owned_value), owned_value) + + var parsed_state: Dictionary = { + GENERATOR_OWNED_KEY: owned_value, + GENERATOR_PURCHASED_COUNT_KEY: maxi(purchased_value, owned_value), + } + + if raw_state.has(GENERATOR_UNLOCKED_KEY) and raw_state.get(GENERATOR_UNLOCKED_KEY) is bool: + parsed_state[GENERATOR_UNLOCKED_KEY] = raw_state.get(GENERATOR_UNLOCKED_KEY) + if raw_state.has(GENERATOR_AVAILABLE_KEY) and raw_state.get(GENERATOR_AVAILABLE_KEY) is bool: + parsed_state[GENERATOR_AVAILABLE_KEY] = raw_state.get(GENERATOR_AVAILABLE_KEY) + + return parsed_state + +## Internal helper: Sanitizes a generator state dictionary with default values. +func _sanitize_generator_state(raw_state: Dictionary, default_unlocked: bool, default_available: bool, default_owned: int = 0) -> Dictionary: + var normalized_default_owned: int = _to_non_negative_int(default_owned, 0) + var owned_value: int = _to_non_negative_int(raw_state.get(GENERATOR_OWNED_KEY, normalized_default_owned), normalized_default_owned) + var purchased_value: int = _to_non_negative_int(raw_state.get(GENERATOR_PURCHASED_COUNT_KEY, owned_value), owned_value) + + return { + GENERATOR_OWNED_KEY: owned_value, + GENERATOR_PURCHASED_COUNT_KEY: maxi(purchased_value, owned_value), + GENERATOR_UNLOCKED_KEY: _to_bool(raw_state.get(GENERATOR_UNLOCKED_KEY, default_unlocked), default_unlocked), + GENERATOR_AVAILABLE_KEY: _to_bool(raw_state.get(GENERATOR_AVAILABLE_KEY, default_available), default_available), + } + +## Internal helper: Converts a value to a non-negative integer. +func _to_non_negative_int(value: Variant, fallback: int = 0) -> int: + if value is int: + return maxi(value, 0) + if value is float: + return maxi(int(value), 0) + return maxi(fallback, 0) + +## Internal helper: Converts a value to a boolean with a fallback. +func _to_bool(value: Variant, fallback: bool) -> bool: + if value is bool: + return value + return fallback + +## Internal helper: Serializes all global buff levels for saving. +func _serialize_buff_levels() -> Dictionary: + var result: Dictionary = {} + for buff_id in _buff_levels.keys(): + result[buff_id] = _buff_levels[buff_id] + return result + +## Internal helper: Serializes all global buff unlock states for saving. +func _serialize_buff_unlocked() -> Dictionary: + var result: Dictionary = {} + for buff_id in _buff_unlocked.keys(): + if _buff_unlocked[buff_id]: + result[buff_id] = true + return result + +## Internal helper: Serializes all global buff active states for saving. +func _serialize_buff_active() -> Dictionary: + var result: Dictionary = {} + for buff_id in _buff_active.keys(): + if _buff_active[buff_id]: + result[buff_id] = true + return result + +## Internal helper: Deserializes all global buff states from save data. +func _deserialize_buff_states(parsed_data: Dictionary) -> void: + _buff_levels.clear() + _buff_unlocked.clear() + _buff_active.clear() + + if parsed_data.has(BUFF_LEVELS_KEY): + var raw_levels: Variant = parsed_data.get(BUFF_LEVELS_KEY) + if raw_levels is Dictionary: + for key_variant in raw_levels.keys(): + var key: String = String(key_variant) + if not key.is_empty(): + var value: Variant = raw_levels.get(key_variant) + if value is int: + _buff_levels[key] = value + + if parsed_data.has(BUFF_UNLOCKED_KEY): + var raw_unlocked: Variant = parsed_data.get(BUFF_UNLOCKED_KEY) + if raw_unlocked is Dictionary: + for key_variant in raw_unlocked.keys(): + var key: String = String(key_variant) + if not key.is_empty(): + _buff_unlocked[key] = true + + if parsed_data.has(BUFF_ACTIVE_KEY): + var raw_active: Variant = parsed_data.get(BUFF_ACTIVE_KEY) + if raw_active is Dictionary: + for key_variant in raw_active.keys(): + var key: String = String(key_variant) + if not key.is_empty(): + _buff_active[key] = true + +# ============================================================================== +# GOALS API +# ============================================================================== +## Registers a goal definition and initializes its completion state. +func register_goal(goal: GoalData) -> void: + if goal == null: + return + var goal_id: StringName = goal.id + if goal_id == &"": + push_warning("Attempted to register goal with invalid id") + return + if _goal_definitions.has(goal_id): + return + _goal_definitions[goal_id] = goal + _goal_completed[goal_id] = false + +## Returns the goal definition for a given ID, or null if not registered. +func get_goal(goal_id: StringName) -> GoalData: + return _goal_definitions.get(goal_id, null) + +## Returns all registered goals as an array. +func get_all_goals() -> Array[GoalData]: + var result: Array[GoalData] = [] + for goal in _goal_definitions.values(): + if goal is GoalData: + result.append(goal) + return result + +## Returns true if a goal has been completed. +func is_goal_completed(goal_id: StringName) -> bool: + return _goal_completed.get(goal_id, false) + +## Returns the progress (0.0-1.0) for a goal by ID. +func get_goal_progress_by_id(goal_id: StringName) -> float: + var goal: GoalData = get_goal(goal_id) + if goal == null: + return 0.0 + return get_goal_progress(goal) + +## Unlocks a single buff if its unlock goal is completed. +func try_unlock_buff_from_goal(buff: GeneratorBuffData) -> void: + if buff == null: + return + if not buff.has_unlock_goal(): + return + if is_buff_unlocked(buff.id): + return + var goal_id: StringName = buff.unlock_goal.id + if not goal_id.is_empty() and is_goal_completed(goal_id): + set_buff_unlocked(buff.id, true) + +## Evaluates all goals for completion and unlocks buffs from completed goals. Emits [signal goal_progress_changed]. +func evaluate_all_goals() -> void: + for goal_id in _goal_completed.keys(): + if not _goal_completed[goal_id]: + _try_complete_goal(goal_id) + + _try_unlock_all_buffs_from_goals() + + goal_progress_changed.emit(&"", 0.0) + +## Internal helper: Attempts to complete a goal if its requirements are met and unlock behavior allows auto-completion. +func _try_complete_goal(goal_id: StringName) -> void: + var goal: GoalData = _goal_definitions.get(goal_id, null) + if goal == null: + return + if _goal_completed[goal_id]: + return + if is_goal_met(goal): + if goal.unlock_behavior == GoalData.UnlockBehavior.MANUAL: + return + _goal_completed[goal_id] = true + goal_completed.emit(goal_id) + +## Internal helper: Manually completes a goal (for MANUAL unlock behavior). Emits [signal goal_completed]. +func _complete_goal_manually(goal_id: StringName) -> void: + var goal: GoalData = _goal_definitions.get(goal_id, null) + if goal == null: + return + if _goal_completed.get(goal_id, false): + return + if not is_goal_met(goal): + return + _goal_completed[goal_id] = true + goal_completed.emit(goal_id) + +## Internal helper: Unlocks all buffs from completed goals. +func _try_unlock_all_buffs_from_goals() -> void: + for buff in get_all_buffs(): + if buff == null: + continue + try_unlock_buff_from_goal(buff) + + + +# ============================================================================== +# PRESTIGE BUFF API +# ============================================================================== + +## Returns the ascension currency ID used for purchasing buff nodes. +func get_prestige_currency_id() -> StringName: + return &"ascension" + +## Attempts to purchase and unlock an prestige buff node. Returns true on success. +func purchase_prestige_buff(buff_id: StringName) -> bool: + if not can_purchase_prestige_buff(buff_id): + return false + + var node: PrestigeBuffNode = _get_prestige_buff_node(buff_id) + if node == null: + return false + + var cost: BigNumber = node.get_cost() + var currency_id: StringName = get_prestige_currency_id() + if not spend_currency_by_id(currency_id, cost): + return false + + _prestige_buff_unlocked[buff_id] = true + prestige_buff_unlocked.emit(buff_id) + save_game() + return true + +## Checks whether an prestige buff node can be purchased (all parents unlocked, not already unlocked, enough currency). +func can_purchase_prestige_buff(buff_id: StringName) -> bool: + var node: PrestigeBuffNode = _get_prestige_buff_node(buff_id) + if node == null: + return false + + if is_prestige_buff_unlocked(buff_id): + return false + + if not node.all_parents_unlocked(_prestige_buff_unlocked): + return false + + var cost: BigNumber = node.get_cost() + var currency_id: StringName = get_prestige_currency_id() + var balance: BigNumber = get_currency_amount_by_id(currency_id) + return balance.is_greater_than(cost) or balance.is_equal_to(cost) + +## Returns true if the given prestige buff node is unlocked. +func is_prestige_buff_unlocked(buff_id: StringName) -> bool: + return bool(_prestige_buff_unlocked.get(buff_id, false)) + +## Returns the fixed BigNumber cost for an prestige buff node. +func get_prestige_buff_cost(buff_id: StringName) -> BigNumber: + var node: PrestigeBuffNode = _get_prestige_buff_node(buff_id) + if node == null: + return BigNumber.from_float(0.0) + return node.get_cost() + +## Computes the combined multiplier from all unlocked prestige buff nodes matching the given effect type. +## [param target_id] filters to nodes targeting this ID (or global nodes when empty). +func get_prestige_buff_multiplier(effect_type: int, target_id: StringName = &"") -> float: + var multiplier: float = 1.0 + for node_id in _prestige_buff_unlocked.keys(): + if not bool(_prestige_buff_unlocked.get(node_id, false)): + continue + var node: PrestigeBuffNode = _get_prestige_buff_node(node_id) + if node == null: + continue + if node.effect_type != effect_type: + continue + if target_id != &"" and node.target_id != &"" and node.target_id != target_id: + continue + multiplier *= node.effect_value + return multiplier + +## Returns all prestige buff nodes whose prerequisites are met but are not yet unlocked. +func get_available_prestige_buffs() -> Array[PrestigeBuffNode]: + if prestige_buff_catalogue == null: + return [] + + var available: Array[PrestigeBuffNode] = [] + for node in prestige_buff_catalogue.nodes: + if node == null or node.id == &"": + continue + if is_prestige_buff_unlocked(node.id): + continue + if node.all_parents_unlocked(_prestige_buff_unlocked): + available.append(node) + return available + +## Returns the PrestigeBuffNode resource for a given ID, or null if not found. +func _get_prestige_buff_node(buff_id: StringName) -> PrestigeBuffNode: + if prestige_buff_catalogue == null: + return null + return prestige_buff_catalogue.get_node_by_id(buff_id) + +# ============================================================================== +# PERSISTENCE HELPERS +# ============================================================================== +## Serializes goal completion state for saving. +func _serialize_goals() -> Dictionary: + var result: Dictionary = {} + var completed_goals: Array[StringName] = [] + for goal_id in _goal_completed.keys(): + if _goal_completed[goal_id]: + completed_goals.append(goal_id) + result[GOAL_COMPLETED_KEY] = completed_goals + return result + +## Deserializes goal completion state from save data. +func _deserialize_goals(raw_value: Variant) -> void: + _goal_completed.clear() + + if not (raw_value is Dictionary): + return + + var raw_completed: Variant = raw_value.get(GOAL_COMPLETED_KEY, []) + if raw_completed is Array: + for goal_id_variant in raw_completed: + var goal_id: String = String(goal_id_variant) + if not goal_id.is_empty(): + _goal_completed[goal_id] = true + + for goal_id in _goal_definitions.keys(): + if not _goal_completed.has(goal_id): + _goal_completed[goal_id] = false + +## Internal helper: Migrates goal completion state from save format version 2 (generator unlock-based) to current format. +func _migrate_goals_from_version_2(parsed_data: Dictionary) -> void: + _goal_completed.clear() + + var generator_states_data: Dictionary = parsed_data.get(GENERATOR_STATES_KEY, {}) + for gen_id_variant in generator_states_data.keys(): + var gen_id: String = String(gen_id_variant) + if gen_id.is_empty(): + continue + + var state: Dictionary = generator_states_data.get(gen_id_variant, {}) + if not state.get(GENERATOR_UNLOCKED_KEY, false): + continue + + var goal_id: StringName = _get_unlock_goal_id_for_generator(gen_id) + if not goal_id.is_empty(): + _goal_completed[goal_id] = true + + for goal_id in _goal_definitions.keys(): + if not _goal_completed.has(goal_id): + _goal_completed[goal_id] = false + +## Internal helper: Finds the goal ID that unlocks a specific generator (used in save migration). +func _get_unlock_goal_id_for_generator(generator_id: String) -> StringName: + if goal_catalogue == null: + return &"" + + for goal in goal_catalogue.goals: + if goal == null or not goal.has_id(): + continue + + var goal_id: StringName = goal.id + var requirements: Array[GoalRequirementData] = goal.get_requirements() + for req in requirements: + if req == null or req.get_currency() == null: + continue + + if String(req.get_currency().id) == generator_id: + return goal_id + + return &"" + +## Internal helper: Converts a currency ID to a human-readable display name. +func _humanize_currency_id(currency_id: StringName) -> String: + var normalized: String = String(_normalize_currency_id(currency_id)) + if normalized.is_empty(): + return "Unknown" + return normalized.replace("_", " ").capitalize() + +## Serializes research XP data for saving (includes BigNumber mantissa/exponent). +func _serialize_research_xp() -> Dictionary: + var result: Dictionary = {} + for research_id in research_xp.keys(): + var xp: BigNumber = research_xp[research_id] + result[research_id] = xp.serialize() + return result + +## Deserializes research XP data from save data. +func _deserialize_research_xp(raw: Variant) -> Dictionary: + var result: Dictionary = {} + if raw is Dictionary: + for research_id in raw.keys(): + var serialized: Variant = raw[research_id] + if serialized is Dictionary: + result[research_id] = BigNumber.deserialize(serialized) + return result + +## Serializes prestige buff unlock state as an array of unlocked node IDs. +func _serialize_prestige_buff_unlocked() -> Array: + var unlocked: Array = [] + for node_id in _prestige_buff_unlocked.keys(): + if _prestige_buff_unlocked.get(node_id, false): + unlocked.append(node_id) + return unlocked + +## Deserializes prestige buff unlock state from an array of unlocked node ID strings, then fills missing catalogue entries. +func _deserialize_prestige_buff_unlocked(raw: Variant) -> void: + _prestige_buff_unlocked.clear() + if raw is Array: + for element in raw: + var node_id: StringName = StringName(String(element)) + if node_id != &"": + _prestige_buff_unlocked[node_id] = true + _initialize_prestige_buffs() + + # Notify tiles that were waiting for state to load + for node_id in _prestige_buff_unlocked.keys(): + if _prestige_buff_unlocked.get(node_id, false): + prestige_buff_unlocked.emit(node_id) diff --git a/core/level_game_state.gd.uid b/core/level_game_state.gd.uid new file mode 100644 index 0000000..a106ca6 --- /dev/null +++ b/core/level_game_state.gd.uid @@ -0,0 +1 @@ +uid://cv2132o4hi7q3 diff --git a/core/prestige/README.md b/core/prestige/README.md new file mode 100644 index 0000000..9e7b19e --- /dev/null +++ b/core/prestige/README.md @@ -0,0 +1,467 @@ +# Prestige Module Documentation + +## Overview + +The `prestige/` subfolder implements the rebirth/reset system and the prestige buff graph — permanent upgrades purchasable with prestige currency that persist across resets. + +## Files + +| File | Purpose | +|------|---------| +| `prestige_config.gd` | Configuration resource for prestige rules (`PrestigeConfig`) | +| `prestige_manager.gd` | Core prestige state, gain calculation, and reset orchestration (`PrestigeManager`) | +| `prestige_buff_node.gd` | Single node in the prestige buff graph (`PrestigeBuffNode`) | +| `prestige_buff_catalogue.gd` | Flat list of all prestige buff nodes (`PrestigeBuffCatalogue`) | +| `prestige_buff_graph_tile.gd` | Individual tile UI for a buff node (`PrestigeBuffGraphTile`) | +| `prestige_buff_graph_tile.tscn` | Tile scene (icon, name, effect, cost, state) | +| `prestige_buff_graph_panel.gd` | Full-screen panel displaying the buff graph (`PrestigeBuffGraphPanel`) | +| `prestige_panel.gd` | UI panel for prestige reset interaction (`PrestigePanel`) | +| `prestige_panel.tscn` | Prestige panel scene | +| `prestige_progress_bar.gd` | Progress bar toward next prestige threshold | +| `prestige_progress_bar.tscn` | Progress bar scene | +| `TECH_SPEC.md` | Detailed technical specification (historical design doc) | + +## Architecture + +``` +LevelGameState (Node) +├── PrestigeManager (Node) +│ ├── Tracks prestige currency & reset count +│ ├── Calculates pending gain from config +│ └── Orchestrates reset via GameState +├── PrestigePanel (UI) +│ ├── Shows current/pending prestige & multiplier +│ └── Reset button with confirmation dialog +├── PrestigeProgressBar (UI) +│ └── Visual progress toward next threshold +├── PrestigeBuffGraphPanel (UI) +│ ├── Ascension currency balance +│ └── Tiered rows of PrestigeBuffGraphTile nodes +└── prestige_buff_catalogue (PrestigeBuffCatalogue) + └── Array of PrestigeBuffNode resources +``` + +PrestigeManager is **not** an autoload — it is a child node of `LevelGameState`. The game state finds it via `find_child("PrestigeManager")` in `_ready()`. + +--- + +# Part 1 — Prestige Reset System + +## PrestigeConfig + +Resource defining prestige rules. + +### Enums + +```gdscript +enum BasisType { + LIFETIME_TOTAL, # All-time currency acquired (single currency) + RUN_TOTAL, # Currency this run (single currency) + RUN_MAX, # Peak currency this run (single currency) + ALL_CURRENCIES # Sum of all currencies (multi-currency tracking) +} + +enum FormulaType { + POWER, # gain = scale * (ratio^exponent) + TRIANGULAR_INVERSE # gain based on triangular number inverse +} + +enum RoundingMode { + FLOOR, ROUND, CEIL +} + +enum MultiplierMode { + ADDITIVE, # +X per prestige + MULTIPLICATIVE_POWER # x^(multiplier_per_prestige) +} +``` + +### Key Properties + +```gdscript +@export var id: StringName = &"primary" +@export var display_name: String = "Ascension" +@export var prestige_currency_id: StringName = &"prestige" +@export var source_currency_id: StringName = &"magic" # What to measure (ignored for ALL_CURRENCIES) +@export var basis: BasisType = LIFETIME_TOTAL +@export var formula: FormulaType = POWER + +# Currency tracking +@export var include_currency_ids: Array[StringName] = [] # Empty = all currencies (for ALL_CURRENCIES basis) + +# Threshold: minimum source currency needed +@export var threshold_mantissa: float = 1.0 +@export var threshold_exponent: int = 4 # 1e4 = 10,000 + +# Formula parameters +@export var scale: float = 1.0 +@export var exponent: float = 0.5 # Square root + +# Multiplier growth +@export var multiplier_per_prestige: float = 0.05 # +5% per prestige +@export var multiplier_mode: MultiplierMode = ADDITIVE +``` + +## PrestigeManager + +Child node of `LevelGameState` managing prestige state. + +### Signals + +```gdscript +signal prestige_state_changed(total_prestige, pending_gain) +signal prestige_performed(gain, total_prestige) +signal prestige_threshold_changed(next_threshold) +``` + +### State Variables + +```gdscript +var total_prestige_earned: BigNumber # Lifetime prestige +var current_prestige_unspent: BigNumber # Available to spend +var prestige_resets_count: int # How many resets +var run_start_total_source_currency: BigNumber # Track run start +var run_peak_source_currency: BigNumber # Peak this run +``` + +### Key Methods + +```gdscript +# Query state +func get_total_prestige() -> BigNumber +func get_current_prestige_unspent() -> BigNumber +func calculate_pending_gain() -> BigNumber +func can_prestige() -> bool +func get_total_multiplier() -> float +func get_next_prestige_threshold() -> BigNumber + +# Perform prestige +func perform_prestige() -> bool +``` + +### Gain Calculation + +**Power Formula** (default): +```gdscript +ratio = basis_value / threshold +raw_gain = scale * (ratio^exponent) + flat_bonus + +# Cumulative basis subtracts already earned +if basis == LIFETIME_TOTAL: + gain = raw_gain - total_prestige_earned +``` + +**Triangular Inverse Formula**: +```gdscript +# Based on inverse triangular number: n = (sqrt(1 + 8k) - 1) / 2 +k = basis_value / threshold +target_total = (sqrt(1 + 8k) - 1) * 0.5 + +if basis == LIFETIME_TOTAL: + gain = target_total - total_prestige_earned +``` + +**Basis Value Calculation**: +```gdscript +if basis == ALL_CURRENCIES: + # Sum of all tracked currencies + basis_value = sum(get_total_currency_acquired(currency_id) for currency_id in tracked_currencies) +elif basis == RUN_TOTAL: + # Currency earned since last reset + basis_value = lifetime_total - run_start_total +elif basis == RUN_MAX: + # Peak currency reached this run + basis_value = run_peak_currency +else: # LIFETIME_TOTAL + # All-time currency acquired + basis_value = lifetime_total +``` + +### Multiplier Application + +**Additive Mode**: +```gdscript +multiplier = base_multiplier + (total_prestige * multiplier_per_prestige) +# e.g., 1.0 + (5 * 0.05) = 1.25 (25% bonus) +``` + +**Multiplicative Power Mode**: +```gdscript +growth_base = 1.0 + multiplier_per_prestige +multiplier = base_multiplier * (growth_base ^ total_prestige) +# e.g., 1.0 * (1.05 ^ 5) = 1.276 (27.6% bonus) +``` + +### Prestige Reset Flow + +1. Player clicks "Prestige" button +2. `perform_prestige()` called +3. Gain calculated and added to total +4. Prestige currency granted via `game_state.add_currency_by_id()` +5. `game_state.reset_for_prestige()` called (preserves prestige currency, resets generators, currencies, buff levels, goals) +6. `_reset_all_buff_levels()` — clears generator buff levels (NOT prestige buff unlocks) +7. Generator runtime state reinitialized +8. Run tracking reinitialized +9. `game_state.emit_currency_changed_for_all()` — triggers UI refresh +10. Save triggered + +## PrestigePanel + +UI component for prestige interaction. + +### Displays + +```gdscript +_total_value.text # Total prestige earned (e.g. "123.45") +_pending_value.text # Gain available now +_multiplier_value.text # Current multiplier (e.g. "x2.34") +_basis_label.text # Basis + value (e.g. "Total Currency (1.50e6)") +``` + +### Buttons + +- **Reset**: Triggers `perform_prestige()` after confirmation dialog +- **Save**: Manually saves game state + +## Generator Integration + +Generators apply prestige multipliers via `PrestigeManager.get_total_multiplier()`: + +```gdscript +func _get_prestige_multiplier() -> float: + var parent: Node = get_parent() + var prestige_manager: PrestigeManager = parent.find_child("PrestigeManager", true, false) + if prestige_manager: + return prestige_manager.get_total_multiplier() + return 1.0 +``` + +This is called from `get_effective_auto_run_multiplier()` alongside research and buff multipliers. In the future, prestige buff multipliers from the graph system will replace or augment this. + +## Save Integration + +Prestige state stored in `LevelGameState` external save: + +```json +{ + "prestige_state": { + "total_prestige_earned": {"m": 10.0, "e": 0}, + "current_prestige_unspent": {"m": 5.0, "e": 0}, + "prestige_resets_count": 3, + "run_start_total_source_currency": {"m": 1000.0, "e": 0}, + "run_peak_source_currency": {"m": 5000.0, "e": 0}, + "last_reset_time": 1234567890 + } +} +``` + +## Configuration Example + +For a typical idle game with single currency: + +```gdscript +id = &"primary" +display_name = "Ascension" +source_currency_id = &"magic" +basis = LIFETIME_TOTAL +threshold_mantissa = 1.0 +threshold_exponent = 6 # 1e6 +formula = POWER +exponent = 0.5 # Square root +multiplier_mode = ADDITIVE +multiplier_per_prestige = 0.10 +``` + +For multi-currency tracking (`ALL_CURRENCIES`): + +```gdscript +id = &"ascension" +display_name = "Ascension" +prestige_currency_id = &"ascension" +basis = ALL_CURRENCIES +include_currency_ids = [] # Empty = all currencies from catalogue +threshold_mantissa = 1.0 +threshold_exponent = 4 # 1e4 +formula = POWER +exponent = 0.5 +multiplier_mode = ADDITIVE +multiplier_per_prestige = 0.05 +``` + +--- + +# Part 2 — Prestige Buff Graph + +## Overview + +The prestige buff graph is a permanent upgrade system where players spend prestige currency to unlock nodes in a DAG-structured graph. Unlike generator buffs (which reset on prestige), prestige buff unlocks are **permanent** and survive resets. + +## PrestigeBuffNode + +Single node in the graph. Each node is a one-shot unlock — either locked or unlocked, no repeatable levels. + +### EffectType Enum + +```gdscript +enum EffectType { + CURRENCY_PRODUCTION_MULTIPLIER, # Multiply currency output by x% + BUILDING_CREATION_BONUS, # When a building is created, increase currency gain by x% + WORKER_THRESHOLD_MULTIPLIER, # When 10+ workers are assigned to a building, increase currency gain by x% + CARRYOVER_BUILDINGS, # After prestige, start with some buildings and workers already assigned + UNLOCK_AUTO_BUY_WORKER, # Unlock option to auto-buy workers + HOVER_SPEED_BOOST, # Hovering over a place speeds up workers instead of clicking + UNLOCK_BUILDING, # Unlock a new building (generator) + WORKER_PRODUCTIVITY, # Increase worker productivity by x% + RESEARCH_XP_MULTIPLIER, # Increase research XP gain by x% +} +``` + +### Exported Fields + +| Field | Type | Notes | +|-------|------|-------| +| `id` | `StringName` | Unique node identifier | +| `display_name` | `String` | UI label | +| `description` | `String` (multiline) | Tooltip | +| `icon` | `Texture2D` | Icon in graph UI | +| `parent_ids` | `Array[StringName]` | Prerequisites (DAG — multiple parents allowed) | +| `effect_type` | `EffectType` | What this buff does | +| `effect_value` | `float` | Effect magnitude (multiplier or count depending on type) | +| `target_id` | `StringName` | Target building/currency for type-specific effects (empty = global) | +| `cost_mantissa` | `float` | Prestige currency cost mantissa | +| `cost_exponent` | `int` | Prestige currency cost exponent | +| `tier` | `int` | Row in graph UI (visual grouping) | +| `x_position` | `float` | Column in graph UI | + +### Key Methods + +```gdscript +func get_cost() -> BigNumber # BigNumber from mantissa/exponent +func has_parents() -> bool # True if parent_ids is non-empty +func all_parents_unlocked(unlocked: Dictionary) -> bool # All prerequisites met? +func is_valid() -> bool # Non-empty id and positive cost +``` + +## PrestigeBuffCatalogue + +Flat resource containing all buff nodes. The graph structure emerges from `parent_ids`. + +| Field | Type | +|-------|------| +| `nodes` | `Array[PrestigeBuffNode]` | + +### Key Methods + +```gdscript +func get_node_by_id(id: StringName) -> PrestigeBuffNode +func get_all_ids() -> Array[StringName] +func get_root_nodes() -> Array[PrestigeBuffNode] # Nodes with empty parent_ids +func get_children_of(parent_id: StringName) -> Array[PrestigeBuffNode] +``` + +## State in LevelGameState + +```gdscript +@export var prestige_buff_catalogue: PrestigeBuffCatalogue +var _prestige_buff_unlocked: Dictionary = {} # {StringName: bool} — node_id → unlocked +``` + +- Never cleared by `reset_for_prestige()` (unlike `_buff_levels` which IS cleared) +- Normalized on load: missing catalogue node IDs are filled as `false` +- On initialization, iterates the catalogue and sets any missing entries to `false` + +## LevelGameState API + +```gdscript +# Purchase and query +func purchase_prestige_buff(buff_id: StringName) -> bool +func can_purchase_prestige_buff(buff_id: StringName) -> bool +func is_prestige_buff_unlocked(buff_id: StringName) -> bool +func get_prestige_buff_cost(buff_id: StringName) -> BigNumber +func get_prestige_currency_id() -> StringName # Returns &"ascension" + +# Multiplier computation — walks graph, returns combined product from unlocked nodes +func get_prestige_buff_multiplier(effect_type: int, target_id: StringName = &"") -> float + +# Available nodes (prerequisites met, not yet unlocked) +func get_available_prestige_buffs() -> Array[PrestigeBuffNode] +``` + +### Signal + +```gdscript +signal prestige_buff_unlocked(buff_id: StringName) +``` + +Prestige currency balance changes use the existing `currency_changed` signal — no separate signal needed. + +## Purchase Flow + +``` +Player clicks unlock node + → can_purchase_prestige_buff(node_id) + → Node exists in catalogue? + → Not already unlocked? + → All parent_ids unlocked? + → Enough prestige currency? + → YES: spend_currency_by_id("ascension", cost) + → _prestige_buff_unlocked[node_id] = true + → emit prestige_buff_unlocked + → save_game() +``` + +## Multiplier Computation + +`get_prestige_buff_multiplier(effect_type, target_id)` iterates all unlocked nodes matching the given effect type and multiplies their `effect_value` together. Results start at `1.0`. + +- If `target_id` is empty: all matching nodes are included (global effect) +- If `target_id` is set: only nodes with empty or matching `target_id` are included + +## Multiplier Usage In Generators + +Generators should call `get_prestige_buff_multiplier()` with their generator ID: + +```gdscript +var asc_mult: float = game_state.get_prestige_buff_multiplier( + PrestigeBuffNode.EffectType.CURRENCY_PRODUCTION_MULTIPLIER, + get_generator_id() +) +``` + +This replaces the old `_get_prestige_multiplier()` call for prestige buff-driven production bonuses. + +## Save Format + +- Save format version bumped to **8** when prestige buffs were introduced. +- Key: `"prestige_buff_unlocked"` → array of unlocked node ID strings: `["gold_boost", "farm_boost"]` +- Load deserialization reads the array, sets matching entries to `true`, then calls `_initialize_prestige_buffs()` to fill in missing entries as `false`. + +## UI — PrestigeBuffGraphPanel + +A `PanelContainer`-based separate screen. + +- **Layout**: Tiered rows (`HBoxContainer` per tier), sorted by `tier` then `x_position` +- **Tiles** (`PrestigeBuffGraphTile`): show icon, name, effect description, cost, state +- **States**: + - **Locked** (greyed out): prerequisites not met + - **Available** (highlighted, button enabled): all prerequisites met, can purchase + - **Unlocked** (green check, button disabled): already owned +- **Balance**: Shows current prestige currency balance at top, refreshes on `currency_changed` +- **Graph rebuild**: Rebuilds entire graph on `prestige_buff_unlocked` to reflect newly available children + +## Content Files + +Prestige buff content lives in `docs/gyms/tiny_sword/prestige/`: + +| File | Purpose | +|------|---------| +| `prestige_buff_catalogue.tres` | Catalogue resource referencing all buff nodes | +| `prestige_buff_gold_boost.tres` | Example: +50% gold mine production | +| `prestige_buff_farm_boost.tres` | Example: +50% farm food production | + +Each `.tres` is a `PrestigeBuffNode` resource with exported fields set in the inspector. + +## Prestige Reset And Buffs + +- **Generator buffs** (`_buff_levels`, `_buff_unlocked`, `_buff_active`): reset to 0/false on prestige +- **Prestige buff unlocks** (`_prestige_buff_unlocked`): **never reset** — permanent across all prestige resets diff --git a/core/prestige/prestige_buff_catalogue.gd b/core/prestige/prestige_buff_catalogue.gd new file mode 100644 index 0000000..013ecb7 --- /dev/null +++ b/core/prestige/prestige_buff_catalogue.gd @@ -0,0 +1,40 @@ +## Flat list of all ascension buff nodes. The graph structure emerges from each node's parent_ids field. +class_name PrestigeBuffCatalogue +extends Resource + +@export var nodes: Array[PrestigeBuffNode] = [] + + +## Finds a node by its unique ID. Returns null if not found. +func get_node_by_id(id: StringName) -> PrestigeBuffNode: + for node in nodes: + if node.id == id: + return node + return null + + +## Returns all non-empty node IDs in the catalogue. +func get_all_ids() -> Array[StringName]: + var ids: Array[StringName] = [] + for node in nodes: + if node.id: + ids.append(node.id) + return ids + + +## Returns all root nodes (nodes with no prerequisites). +func get_root_nodes() -> Array[PrestigeBuffNode]: + var roots: Array[PrestigeBuffNode] = [] + for node in nodes: + if node.parent_ids.is_empty(): + roots.append(node) + return roots + + +## Returns all nodes that list the given parent_id as a prerequisite. +func get_children_of(parent_id: StringName) -> Array[PrestigeBuffNode]: + var children: Array[PrestigeBuffNode] = [] + for node in nodes: + if node.parent_ids.has(parent_id): + children.append(node) + return children diff --git a/core/prestige/prestige_buff_catalogue.gd.uid b/core/prestige/prestige_buff_catalogue.gd.uid new file mode 100644 index 0000000..354a599 --- /dev/null +++ b/core/prestige/prestige_buff_catalogue.gd.uid @@ -0,0 +1 @@ +uid://bjcvnyh2bc6l5 diff --git a/core/prestige/prestige_buff_connection_overlay.gd b/core/prestige/prestige_buff_connection_overlay.gd new file mode 100644 index 0000000..b7428ec --- /dev/null +++ b/core/prestige/prestige_buff_connection_overlay.gd @@ -0,0 +1,43 @@ +## Draws bezier curves between connected PrestigeBuffGraphTile nodes in the graph panel. +## Store pairs of tile references before calling queue_redraw(). +class_name PrestigeBuffConnectionOverlay +extends Control + +## Pairs of [parent_tile, child_tile] to connect. +var _connections: Array = [] + + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_IGNORE + +## Replace all connections and redraw. +func set_connections(connections: Array) -> void: + _connections = connections + queue_redraw() + + +func _draw() -> void: + if _connections.is_empty(): + return + + for pair in _connections: + var parent_tile: Control = pair[0] as Control + var child_tile: Control = pair[1] as Control + if parent_tile == null or child_tile == null: + continue + + var from_pos: Vector2 = _tile_bottom_center(parent_tile) + var to_pos: Vector2 = _tile_top_center(child_tile) + draw_line(from_pos, to_pos, Color(0.6, 0.6, 0.7, 0.6), 2.0) + + +func _tile_bottom_center(tile: Control) -> Vector2: + var rect: Rect2 = tile.get_global_rect() + var global_pos: Vector2 = Vector2(rect.position.x + rect.size.x * 0.5, rect.position.y + rect.size.y) + return get_global_transform().affine_inverse() * global_pos + + +func _tile_top_center(tile: Control) -> Vector2: + var rect: Rect2 = tile.get_global_rect() + var global_pos: Vector2 = Vector2(rect.position.x + rect.size.x * 0.5, rect.position.y) + return get_global_transform().affine_inverse() * global_pos diff --git a/core/prestige/prestige_buff_connection_overlay.gd.uid b/core/prestige/prestige_buff_connection_overlay.gd.uid new file mode 100644 index 0000000..a326169 --- /dev/null +++ b/core/prestige/prestige_buff_connection_overlay.gd.uid @@ -0,0 +1 @@ +uid://uv5lxj6hmpk diff --git a/core/prestige/prestige_buff_graph_panel.gd b/core/prestige/prestige_buff_graph_panel.gd new file mode 100644 index 0000000..af93cde --- /dev/null +++ b/core/prestige/prestige_buff_graph_panel.gd @@ -0,0 +1,85 @@ +## Separate-screen panel displaying the prestige buff graph. +## Tiles are placed manually in the scene editor — each PrestigeBuffGraphTile has its PrestigeBuffNode +## resource assigned directly. The panel manages the prestige currency balance display and +## delegates connection-line drawing to a PrestigeBuffConnectionOverlay child. +class_name PrestigeBuffGraphPanel +extends PanelContainer + +@onready var _balance_label: Label = $MarginContainer/VBoxContainer/BalanceLabel +@onready var _tiles_area: Control = $MarginContainer/VBoxContainer/TilesArea +@onready var _close_button: Button = $MarginContainer/VBoxContainer/Header/CloseButton +@onready var _connection_overlay: PrestigeBuffConnectionOverlay = $MarginContainer/VBoxContainer/TilesArea/ConnectionOverlay + +var _game_state: LevelGameState + + +func _ready() -> void: + _game_state = find_parent("LevelGameState") + if _game_state == null: + push_error("PrestigeBuffGraphPanel: Could not find LevelGameState parent") + return + + _game_state.currency_changed.connect(_on_currency_changed) + _close_button.pressed.connect(_on_close_pressed) + + visibility_changed.connect(_on_visibility_changed) + _refresh_balance() + + +## Rebuilds the connection overlay by scanning all PrestigeBuffGraphTile children and matching parent_ids. +func _refresh_connections() -> void: + if _connection_overlay == null: + return + + var tiles_by_id: Dictionary = {} + var all_tiles: Array[PrestigeBuffGraphTile] = [] + _collect_tiles(_tiles_area, all_tiles) + + for tile in all_tiles: + var node_id: StringName = tile.get_node_id() + if node_id != &"": + tiles_by_id[node_id] = tile + + var connections: Array = [] + for tile in all_tiles: + if tile.prestige_buff == null: + continue + for parent_id in tile.prestige_buff.parent_ids: + var parent_tile: PrestigeBuffGraphTile = tiles_by_id.get(parent_id, null) + if parent_tile: + connections.append([parent_tile, tile]) + + _connection_overlay.set_connections(connections) + + +## Collects all PrestigeBuffGraphTile nodes rooted at `from`, recursing into every child. +func _collect_tiles(from: Node, out_tiles: Array[PrestigeBuffGraphTile]) -> void: + var found: Array[Node] = from.find_children("*", "PrestigeBuffGraphTile", true, false) + for node in found: + var tile := node as PrestigeBuffGraphTile + if tile: + out_tiles.append(tile) + +func _refresh_balance() -> void: + if _game_state == null: + _balance_label.text = "Prestige Currency: 0" + return + + var currency_id: StringName = _game_state.get_prestige_currency_id() + var balance: BigNumber = _game_state.get_currency_amount_by_id(currency_id) + _balance_label.text = "Prestige Currency: %s" % balance.to_string_suffix(2) + +func _on_currency_changed(currency_id: StringName, _new_amount: BigNumber) -> void: + if currency_id == _game_state.get_prestige_currency_id(): + _refresh_balance() + +func _on_close_pressed() -> void: + hide() + +func _on_visibility_changed() -> void: + if visible: + _refresh_balance() + _refresh_connections() + +func _on_prestige_buff_toggle_pressed() -> void: + show() diff --git a/core/prestige/prestige_buff_graph_panel.gd.uid b/core/prestige/prestige_buff_graph_panel.gd.uid new file mode 100644 index 0000000..1cc1237 --- /dev/null +++ b/core/prestige/prestige_buff_graph_panel.gd.uid @@ -0,0 +1 @@ +uid://n01pkakxllnj diff --git a/core/prestige/prestige_buff_graph_tile.gd b/core/prestige/prestige_buff_graph_tile.gd new file mode 100644 index 0000000..63f62f6 --- /dev/null +++ b/core/prestige/prestige_buff_graph_tile.gd @@ -0,0 +1,122 @@ +## Individual tile in the prestige buff graph UI. +## Visually represents a single PrestigeBuffNode with locked/available/unlocked states. +## The PrestigeBuffNode resource is assigned directly via the editor export. +class_name PrestigeBuffGraphTile +extends PanelContainer + +## The prestige buff node this tile represents. Set in the scene editor. +@export var prestige_buff: PrestigeBuffNode + +@onready var _button: Button = $Button +@onready var _icon: TextureRect = $Button/HBoxContainer/Icon +@onready var _name_label: Label = $Button/HBoxContainer/VBoxContainer/NameLabel +@onready var _effect_label: Label = $Button/HBoxContainer/VBoxContainer/EffectLabel +@onready var _cost_label: Label = $Button/HBoxContainer/VBoxContainer/CostLabel +@onready var _state_label: Label = $Button/HBoxContainer/StateLabel + +var _game_state: LevelGameState + + +func _ready() -> void: + _game_state = find_parent("LevelGameState") + _button.pressed.connect(_on_button_pressed) + + if _game_state: + _game_state.prestige_buff_unlocked.connect(_on_prestige_buff_unlocked) + _game_state.currency_changed.connect(_on_currency_changed) + + if prestige_buff == null: + push_warning("PrestigeBuffGraphTile: prestige_buff is not set") + return + + _refresh_visuals() + _refresh_state() + + +func get_node_id() -> StringName: + if prestige_buff: + return prestige_buff.id + return &"" + + +func _refresh_visuals() -> void: + if prestige_buff == null: + return + + _name_label.text = prestige_buff.display_name + _cost_label.text = "Cost: %s" % prestige_buff.get_cost().to_string_suffix(2) + + if prestige_buff.icon: + _icon.texture = prestige_buff.icon + + var effect_text: String = _format_effect_description() + _effect_label.text = effect_text + _effect_label.visible = not effect_text.is_empty() + + +func _refresh_state() -> void: + if prestige_buff == null or _game_state == null: + return + + var unlocked: bool = _game_state.is_prestige_buff_unlocked(prestige_buff.id) + var available: bool = _game_state.can_purchase_prestige_buff(prestige_buff.id) + + if unlocked: + _state_label.text = "✓ Unlocked" + _button.disabled = true + modulate = Color.GREEN + elif available: + _state_label.text = "Available" + _button.disabled = false + modulate = Color.WHITE + else: + _state_label.text = "Locked" + _button.disabled = true + modulate = Color(0.5, 0.5, 0.5, 1.0) + + +func _format_effect_description() -> String: + if prestige_buff == null: + return "" + + match prestige_buff.effect_type: + PrestigeBuffNode.EffectType.CURRENCY_PRODUCTION_MULTIPLIER: + if prestige_buff.target_id != &"": + return "+%.0f%% %s production" % [((prestige_buff.effect_value - 1.0) * 100.0), prestige_buff.target_id] + return "+%.0f%% all production" % ((prestige_buff.effect_value - 1.0) * 100.0) + PrestigeBuffNode.EffectType.BUILDING_CREATION_BONUS: + return "+%.0f%% gain on build" % ((prestige_buff.effect_value - 1.0) * 100.0) + PrestigeBuffNode.EffectType.WORKER_THRESHOLD_MULTIPLIER: + return "+%.0f%% with 10+ workers" % ((prestige_buff.effect_value - 1.0) * 100.0) + PrestigeBuffNode.EffectType.CARRYOVER_BUILDINGS: + return "Keep %d buildings on prestige" % int(prestige_buff.effect_value) + PrestigeBuffNode.EffectType.UNLOCK_AUTO_BUY_WORKER: + return "Unlock auto-buy worker" + PrestigeBuffNode.EffectType.HOVER_SPEED_BOOST: + return "Hover to speed up workers" + PrestigeBuffNode.EffectType.UNLOCK_BUILDING: + return "Unlock %s building" % prestige_buff.target_id + PrestigeBuffNode.EffectType.WORKER_PRODUCTIVITY: + return "+%.0f%% worker productivity" % ((prestige_buff.effect_value - 1.0) * 100.0) + PrestigeBuffNode.EffectType.RESEARCH_XP_MULTIPLIER: + return "+%.0f%% research XP" % ((prestige_buff.effect_value - 1.0) * 100.0) + _: + return "" + + +func _on_button_pressed() -> void: + if _game_state and prestige_buff: + _game_state.purchase_prestige_buff(prestige_buff.id) + + +func _on_prestige_buff_unlocked(buff_id: StringName) -> void: + if prestige_buff and buff_id == prestige_buff.id: + _refresh_state() + + +func _on_currency_changed(currency_id: StringName, _new_amount: BigNumber) -> void: + if _game_state == null or prestige_buff == null: + return + var asc_id: StringName = _game_state.get_prestige_currency_id() + if currency_id == asc_id: + _refresh_state() diff --git a/core/prestige/prestige_buff_graph_tile.gd.uid b/core/prestige/prestige_buff_graph_tile.gd.uid new file mode 100644 index 0000000..cf49391 --- /dev/null +++ b/core/prestige/prestige_buff_graph_tile.gd.uid @@ -0,0 +1 @@ +uid://w8ogrp1s6qsf diff --git a/core/prestige/prestige_buff_graph_tile.tscn b/core/prestige/prestige_buff_graph_tile.tscn new file mode 100644 index 0000000..b662b9f --- /dev/null +++ b/core/prestige/prestige_buff_graph_tile.tscn @@ -0,0 +1,40 @@ +[gd_scene format=3 uid="uid://bqk8rwia7lpbg"] + +[ext_resource type="Script" uid="uid://w8ogrp1s6qsf" path="res://core/prestige/prestige_buff_graph_tile.gd" id="1_script"] + +[node name="AscensionBuffGraphTile" type="PanelContainer" unique_id=582396224] +custom_minimum_size = Vector2(220, 100) +script = ExtResource("1_script") + +[node name="Button" type="Button" parent="." unique_id=34096996] +layout_mode = 2 + +[node name="HBoxContainer" type="HBoxContainer" parent="Button" unique_id=760838153] +layout_mode = 0 + +[node name="Icon" type="TextureRect" parent="Button/HBoxContainer" unique_id=2086768939] +custom_minimum_size = Vector2(48, 48) +layout_mode = 2 +size_flags_horizontal = 0 + +[node name="VBoxContainer" type="VBoxContainer" parent="Button/HBoxContainer" unique_id=1701436193] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="NameLabel" type="Label" parent="Button/HBoxContainer/VBoxContainer" unique_id=334883451] +layout_mode = 2 +text = "Buff Name" + +[node name="EffectLabel" type="Label" parent="Button/HBoxContainer/VBoxContainer" unique_id=1416390723] +layout_mode = 2 +text = "+10% effect" + +[node name="CostLabel" type="Label" parent="Button/HBoxContainer/VBoxContainer" unique_id=983468527] +layout_mode = 2 +text = "Cost: 0" + +[node name="StateLabel" type="Label" parent="Button/HBoxContainer" unique_id=629071282] +layout_mode = 2 +size_flags_horizontal = 0 +text = "Locked" +horizontal_alignment = 2 diff --git a/core/prestige/prestige_buff_node.gd b/core/prestige/prestige_buff_node.gd new file mode 100644 index 0000000..6e06aa5 --- /dev/null +++ b/core/prestige/prestige_buff_node.gd @@ -0,0 +1,69 @@ +## A single node in the prestige buff graph. +## Each node represents a permanent upgrade that persists across prestige resets and is purchased with prestige currency. +class_name PrestigeBuffNode +extends Resource + +enum EffectType { + CURRENCY_PRODUCTION_MULTIPLIER, ## Multiply currency output by x% + BUILDING_CREATION_BONUS, ## When a building is created, increase currency gain by x% + WORKER_THRESHOLD_MULTIPLIER, ## When 10+ workers are assigned to a building, increase currency gain by x% + CARRYOVER_BUILDINGS, ## After prestige, start with some buildings and workers already assigned + UNLOCK_AUTO_BUY_WORKER, ## Unlock option to auto-buy workers + HOVER_SPEED_BOOST, ## Hovering over a place speeds up workers instead of clicking + UNLOCK_BUILDING, ## Unlock a new building (generator) + WORKER_PRODUCTIVITY, ## Increase worker productivity by x% + RESEARCH_XP_MULTIPLIER, ## Increase research XP gain by x% +} + +## Unique node identifier. +@export var id: StringName = &"" +## UI display label. +@export var display_name: String = "" +## Tooltip / description text. +@export_multiline var description: String = "" +## Icon shown in the graph UI. +@export var icon: Texture2D +## Prerequisite node IDs that must be unlocked before this node becomes available. +@export var parent_ids: Array[StringName] = [] +## What kind of effect this buff provides. +@export var effect_type: EffectType = EffectType.CURRENCY_PRODUCTION_MULTIPLIER +## Magnitude of the effect (interpreted per effect type). +@export var effect_value: float = 1.0 +## Target generator, building, or currency ID for type-specific effects. Empty means global. +@export var target_id: StringName = &"" +## Prestige currency cost mantissa (combined with cost_exponent for BigNumber). +@export var cost_mantissa: float = 1.0 +## Prestige currency cost exponent (combined with cost_mantissa for BigNumber). +@export var cost_exponent: int = 0 +## Visual tier / row in the graph UI. +@export var tier: int = 0 +## Visual column / horizontal position in the graph UI. +@export var x_position: float = 0.0 + + +## Builds and returns the fixed prestige currency cost as a BigNumber. +func get_cost() -> BigNumber: + return BigNumber.new(cost_mantissa, cost_exponent) + + +## Returns true if this node has any prerequisite parent nodes. +func has_parents() -> bool: + return not parent_ids.is_empty() + + +## Checks whether all prerequisites are unlocked in the provided dictionary. +## [param unlocked] maps node_id (StringName) → bool (true if unlocked). +func all_parents_unlocked(unlocked: Dictionary) -> bool: + for parent_id in parent_ids: + if not bool(unlocked.get(parent_id, false)): + return false + return true + + +## Returns true if this node has valid required data (non-empty id, positive cost). +func is_valid() -> bool: + if id == &"": + return false + if cost_mantissa <= 0.0: + return false + return true diff --git a/core/prestige/prestige_buff_node.gd.uid b/core/prestige/prestige_buff_node.gd.uid new file mode 100644 index 0000000..728262c --- /dev/null +++ b/core/prestige/prestige_buff_node.gd.uid @@ -0,0 +1 @@ +uid://mjyig8xlgki0 diff --git a/core/prestige/prestige_config.gd b/core/prestige/prestige_config.gd index b7f3915..7d1aa78 100644 --- a/core/prestige/prestige_config.gd +++ b/core/prestige/prestige_config.gd @@ -5,6 +5,7 @@ enum BasisType { LIFETIME_TOTAL, RUN_TOTAL, RUN_MAX, + ALL_CURRENCIES, } enum FormulaType { @@ -41,6 +42,7 @@ enum MultiplierMode { @export var base_multiplier: float = 1.0 @export var multiplier_per_prestige: float = 0.05 @export var multiplier_exponent: float = 1.0 +@export var include_currency_ids: Array[StringName] = [] func get_threshold() -> BigNumber: return BigNumber.new(threshold_mantissa, threshold_exponent) @@ -48,7 +50,7 @@ func get_threshold() -> BigNumber: func is_valid() -> bool: if String(id).strip_edges().is_empty(): return false - if String(source_currency_id).strip_edges().is_empty(): + if String(source_currency_id).strip_edges().is_empty() and basis != BasisType.ALL_CURRENCIES: return false if threshold_mantissa <= 0.0: return false diff --git a/core/prestige/prestige_manager.gd b/core/prestige/prestige_manager.gd index e5a77fb..9b04968 100644 --- a/core/prestige/prestige_manager.gd +++ b/core/prestige/prestige_manager.gd @@ -1,7 +1,9 @@ +class_name PrestigeManager extends Node signal prestige_state_changed(total_prestige: BigNumber, pending_gain: BigNumber) signal prestige_performed(gain: BigNumber, total_prestige: BigNumber) +signal prestige_threshold_changed(next_threshold: BigNumber) const SAVE_KEY: String = "prestige_state" const TOTAL_PRESTIGE_KEY: String = "total_prestige_earned" @@ -14,6 +16,7 @@ const LAST_RESET_TIME_KEY: String = "last_reset_time" const BASIS_LIFETIME_TOTAL: int = 0 const BASIS_RUN_TOTAL: int = 1 const BASIS_RUN_MAX: int = 2 +const BASIS_ALL_CURRENCIES: int = 3 const FORMULA_POWER: int = 0 const FORMULA_TRIANGULAR_INVERSE: int = 1 @@ -21,7 +24,8 @@ const FORMULA_TRIANGULAR_INVERSE: int = 1 const MULTIPLIER_MODE_ADDITIVE: int = 0 const MULTIPLIER_MODE_MULTIPLICATIVE_POWER: int = 1 -@export var config: Resource +@export var config: PrestigeConfig +@export var game_state: LevelGameState var total_prestige_earned: BigNumber = BigNumber.from_float(0.0) var current_prestige_unspent: BigNumber = BigNumber.from_float(0.0) @@ -32,13 +36,17 @@ var last_reset_time: int = 0 func _ready() -> void: if config == null: - config = load("res://idles/prestige/primary_prestige.tres") as Resource + config = load("res://docs/gyms/tiny_sword/prestige/primary_prestige.tres") as PrestigeConfig if not _is_config_valid(): push_warning("PrestigeManager has invalid or missing config; prestige is disabled.") return - var loaded_state: Dictionary = GameState.get_external_save_data(SAVE_KEY) + if game_state == null: + push_warning("PrestigeManager: game_state reference is not set.") + return + + var loaded_state: Dictionary = game_state.get_external_save_data(SAVE_KEY) if loaded_state.is_empty(): _initialize_run_tracking_from_current_state() else: @@ -46,8 +54,12 @@ func _ready() -> void: _validate_loaded_run_tracking() _save_state_to_game_state() - GameState.currency_changed.connect(_on_currency_changed) + game_state.currency_changed.connect(_on_currency_changed) prestige_state_changed.emit(get_total_prestige(), calculate_pending_gain()) + prestige_threshold_changed.emit(get_next_prestige_threshold()) + +func get_config() -> PrestigeConfig: + return config func get_source_currency_id() -> StringName: if not _is_config_valid(): @@ -55,6 +67,38 @@ func get_source_currency_id() -> StringName: return _normalize_currency_id(_get_config_string_name("source_currency_id", &"")) +func get_prestige_currency_id() -> StringName: + if not _is_config_valid(): + return &"" + + return _normalize_currency_id(_get_config_string_name("prestige_currency_id", &"")) + +func get_tracked_currency_ids() -> Array[StringName]: + if not _is_config_valid(): + return [] + + var basis_type: int = _get_config_int("basis", BASIS_LIFETIME_TOTAL) + + if basis_type == BASIS_ALL_CURRENCIES: + var include_ids: Array[StringName] = _get_config_array("include_currency_ids", []) + if not include_ids.is_empty(): + return include_ids + if game_state and game_state.currency_catalogue: + return game_state.currency_catalogue.get_all_ids() + return [] + + return [get_source_currency_id()] + +func _get_config_array(property_name: String, fallback: Array = []) -> Array: + if config == null: + return fallback + + var value: Variant = config.get(property_name) + if value is Array: + return value + + return fallback + func get_total_prestige() -> BigNumber: return _copy_big_number(total_prestige_earned) @@ -66,7 +110,7 @@ func get_total_multiplier() -> float: return 1.0 var base: float = maxf(_get_config_float("base_multiplier", 1.0), 0.0) - var prestige_value: float = _big_number_to_float(total_prestige_earned) + var prestige_value: float = total_prestige_earned.to_float() if prestige_value <= 0.0: return base @@ -125,52 +169,119 @@ func perform_prestige() -> bool: if gain.mantissa > 0.0: total_prestige_earned.add_in_place(gain) current_prestige_unspent.add_in_place(gain) + + var prestige_currency_id: StringName = get_prestige_currency_id() + if prestige_currency_id != &"" and game_state: + game_state.add_currency_by_id(prestige_currency_id, gain) prestige_resets_count += 1 last_reset_time = Time.get_unix_time_from_system() - GameState.reset_for_prestige(true, false) + if game_state: + var preserve_currencies: Array[StringName] = [] + var prestige_currency_id: StringName = get_prestige_currency_id() + if prestige_currency_id != &"": + preserve_currencies.append(prestige_currency_id) + game_state.reset_for_prestige(true, false, preserve_currencies) + _reset_all_buff_levels() _reinitialize_generators_after_prestige_reset() - GameState.emit_currency_changed_for_all() + if game_state: + game_state.emit_currency_changed_for_all() _initialize_run_tracking_from_current_state() _save_state_to_game_state() - GameState.save_game() + if game_state: + game_state.save_game() var total: BigNumber = get_total_prestige() + var next_threshold: BigNumber = get_next_prestige_threshold() prestige_performed.emit(gain, total) prestige_state_changed.emit(total, calculate_pending_gain()) + prestige_threshold_changed.emit(next_threshold) return true +func get_next_prestige_threshold() -> BigNumber: + if not _is_config_valid(): + return BigNumber.from_float(0.0) + + var basis_type: int = _get_config_int("basis", BASIS_LIFETIME_TOTAL) + + match basis_type: + BASIS_RUN_TOTAL, BASIS_ALL_CURRENCIES: + return _get_config_threshold() + _: + var current_total: float = total_prestige_earned.to_float() + var next_target: float = _calculate_target_for_prestige(current_total + 1.0) + return BigNumber.from_float(next_target) + func get_basis_value_for_display() -> BigNumber: return _get_basis_value() func get_basis_label() -> String: if not _is_config_valid(): return "-" - - match _get_config_int("basis", BASIS_LIFETIME_TOTAL): + + var basis_type: int = _get_config_int("basis", BASIS_LIFETIME_TOTAL) + + match basis_type: BASIS_RUN_TOTAL: return "Run Total" BASIS_RUN_MAX: return "Run Max" + BASIS_ALL_CURRENCIES: + return "Total Currency" _: return "Lifetime Total" func _on_currency_changed(changed_currency_id: StringName, new_amount: BigNumber) -> void: if config == null: return - - var source_currency_id: StringName = get_source_currency_id() - if source_currency_id == &"": - return - if changed_currency_id != source_currency_id: - return - - if new_amount.is_greater_than(run_peak_source_currency): - run_peak_source_currency = _copy_big_number(new_amount) - + + var basis_type: int = _get_config_int("basis", BASIS_LIFETIME_TOTAL) + + if basis_type == BASIS_ALL_CURRENCIES: + if new_amount.is_greater_than(run_peak_source_currency): + run_peak_source_currency = _copy_big_number(new_amount) + else: + var source_currency_id: StringName = get_source_currency_id() + if source_currency_id == &"": + return + if changed_currency_id != source_currency_id: + return + + if new_amount.is_greater_than(run_peak_source_currency): + run_peak_source_currency = _copy_big_number(new_amount) + _save_state_to_game_state() + var next_threshold: BigNumber = get_next_prestige_threshold() prestige_state_changed.emit(get_total_prestige(), calculate_pending_gain()) + prestige_threshold_changed.emit(next_threshold) + +func _calculate_target_for_prestige(prestige_level: float) -> float: + if config == null: + return 0.0 + + var threshold_value: float = _get_config_threshold().to_float() + if threshold_value <= 0.0: + return 0.0 + + match _get_config_int("formula", FORMULA_POWER): + FORMULA_TRIANGULAR_INVERSE: + var target_total: float = prestige_level + var k: float = target_total * (target_total + 1.0) * 0.5 + return k * threshold_value + _: + var scale: float = maxf(_get_config_float("scale", 1.0), 0.0001) + var exponent: float = maxf(_get_config_float("exponent", 1.0), 0.0001) + var flat_bonus: float = _get_config_float("flat_bonus", 0.0) + if scale <= 0.0: + scale = 0.0001 + if exponent <= 0.0: + exponent = 0.0001 + + var ratio: float = pow((prestige_level - flat_bonus) / scale, 1.0 / exponent) + if ratio <= 0.0: + return threshold_value + return ratio * threshold_value func _calculate_raw_gain_float(basis_value: BigNumber) -> float: if config == null: @@ -178,11 +289,11 @@ func _calculate_raw_gain_float(basis_value: BigNumber) -> float: match _get_config_int("formula", FORMULA_POWER): FORMULA_TRIANGULAR_INVERSE: - var threshold_value: float = _big_number_to_float(_get_config_threshold()) + var threshold_value: float = _get_config_threshold().to_float() if threshold_value <= 0.0: return 0.0 - var basis_float: float = _big_number_to_float(basis_value) + var basis_float: float = basis_value.to_float() if basis_float <= 0.0: return 0.0 @@ -192,7 +303,7 @@ func _calculate_raw_gain_float(basis_value: BigNumber) -> float: var target_total: float = (sqrt(1.0 + 8.0 * k) - 1.0) * 0.5 if _is_cumulative_basis(): - return target_total - _big_number_to_float(total_prestige_earned) + return target_total - total_prestige_earned.to_float() return target_total _: var ratio: float = _big_number_ratio_to_float(basis_value, _get_config_threshold()) @@ -201,20 +312,26 @@ func _calculate_raw_gain_float(basis_value: BigNumber) -> float: var value: float = maxf(_get_config_float("scale", 0.0), 0.0) * pow(ratio, maxf(_get_config_float("exponent", 0.0001), 0.0001)) + _get_config_float("flat_bonus", 0.0) if _is_cumulative_basis(): - return value - _big_number_to_float(total_prestige_earned) + return value - total_prestige_earned.to_float() return value func _get_basis_value() -> BigNumber: if config == null: return BigNumber.from_float(0.0) - var source_currency_id: StringName = get_source_currency_id() - if source_currency_id == &"": + if game_state == null: return BigNumber.from_float(0.0) + + var basis_type: int = _get_config_int("basis", BASIS_LIFETIME_TOTAL) - match _get_config_int("basis", BASIS_LIFETIME_TOTAL): + match basis_type: + BASIS_ALL_CURRENCIES: + return _get_total_all_currencies() BASIS_RUN_TOTAL: - var lifetime_now: BigNumber = GameState.get_total_currency_acquired_by_id(source_currency_id) + var source_currency_id: StringName = get_source_currency_id() + if source_currency_id == &"": + return BigNumber.from_float(0.0) + var lifetime_now: BigNumber = game_state.get_total_currency_acquired_by_id(source_currency_id) var run_total: BigNumber = lifetime_now.subtract(run_start_total_source_currency) if run_total.mantissa < 0.0: return BigNumber.from_float(0.0) @@ -222,11 +339,37 @@ func _get_basis_value() -> BigNumber: BASIS_RUN_MAX: return _copy_big_number(run_peak_source_currency) _: - return _copy_big_number(GameState.get_total_currency_acquired_by_id(source_currency_id)) + var source_currency_id: StringName = get_source_currency_id() + if source_currency_id == &"": + return BigNumber.from_float(0.0) + return _copy_big_number(game_state.get_total_currency_acquired_by_id(source_currency_id)) + +func _get_total_all_currencies() -> BigNumber: + var total: BigNumber = BigNumber.from_float(0.0) + var currency_ids: Array[StringName] = get_tracked_currency_ids() + + for currency_id in currency_ids: + var currency_total: BigNumber = game_state.get_total_currency_acquired_by_id(currency_id) + total.add_in_place(currency_total) + + return total func _initialize_run_tracking_from_current_state() -> void: if config == null: return + + var basis_type: int = _get_config_int("basis", BASIS_LIFETIME_TOTAL) + + if basis_type == BASIS_ALL_CURRENCIES: + run_start_total_source_currency = _get_total_all_currencies() + run_peak_source_currency = BigNumber.from_float(0.0) + var currency_ids: Array[StringName] = get_tracked_currency_ids() + for currency_id in currency_ids: + var current: BigNumber = game_state.get_currency_amount_by_id(currency_id) + if current.is_greater_than(run_peak_source_currency): + run_peak_source_currency = _copy_big_number(current) + _save_state_to_game_state() + return var source_currency_id: StringName = get_source_currency_id() if source_currency_id == &"": @@ -234,13 +377,31 @@ func _initialize_run_tracking_from_current_state() -> void: run_peak_source_currency = BigNumber.from_float(0.0) return - run_start_total_source_currency = _copy_big_number(GameState.get_total_currency_acquired_by_id(source_currency_id)) - run_peak_source_currency = _copy_big_number(GameState.get_currency_amount_by_id(source_currency_id)) + if game_state == null: + run_start_total_source_currency = BigNumber.from_float(0.0) + run_peak_source_currency = BigNumber.from_float(0.0) + return + + run_start_total_source_currency = _copy_big_number(game_state.get_total_currency_acquired_by_id(source_currency_id)) + run_peak_source_currency = _copy_big_number(game_state.get_currency_amount_by_id(source_currency_id)) _save_state_to_game_state() func _validate_loaded_run_tracking() -> void: if config == null: return + + var basis_type: int = _get_config_int("basis", BASIS_LIFETIME_TOTAL) + + if basis_type == BASIS_ALL_CURRENCIES: + var currency_ids: Array[StringName] = get_tracked_currency_ids() + var peak: BigNumber = BigNumber.from_float(0.0) + for currency_id in currency_ids: + var current: BigNumber = game_state.get_currency_amount_by_id(currency_id) + if current.is_greater_than(peak): + peak = _copy_big_number(current) + if peak.is_greater_than(run_peak_source_currency): + run_peak_source_currency = _copy_big_number(peak) + return var source_currency_id: StringName = get_source_currency_id() if source_currency_id == &"": @@ -248,11 +409,14 @@ func _validate_loaded_run_tracking() -> void: run_peak_source_currency = BigNumber.from_float(0.0) return - var current_currency: BigNumber = GameState.get_currency_amount_by_id(source_currency_id) + if game_state == null: + return + + var current_currency: BigNumber = game_state.get_currency_amount_by_id(source_currency_id) if current_currency.is_greater_than(run_peak_source_currency): run_peak_source_currency = _copy_big_number(current_currency) - var lifetime_now: BigNumber = GameState.get_total_currency_acquired_by_id(source_currency_id) + var lifetime_now: BigNumber = game_state.get_total_currency_acquired_by_id(source_currency_id) if run_start_total_source_currency.is_greater_than(lifetime_now): run_start_total_source_currency = _copy_big_number(lifetime_now) @@ -278,7 +442,18 @@ func _deserialize_state(raw_state: Dictionary) -> void: current_prestige_unspent = _copy_big_number(total_prestige_earned) func _save_state_to_game_state() -> void: - GameState.set_external_save_data(SAVE_KEY, _serialize_state()) + if game_state: + game_state.set_external_save_data(SAVE_KEY, _serialize_state()) + +func _reset_all_buff_levels() -> void: + if game_state == null: + return + + for buff in game_state.get_all_buffs(): + if buff == null: + continue + game_state.set_buff_level(buff.id, 0) + game_state.set_buff_unlocked(buff.id, false) func _reinitialize_generators_after_prestige_reset() -> void: var scene_root: Node = get_tree().current_scene @@ -316,14 +491,7 @@ func _big_number_ratio_to_float(a: BigNumber, b: BigNumber) -> float: return (a.mantissa / b.mantissa) * pow(10.0, float(exponent_delta)) -func _big_number_to_float(value: BigNumber) -> float: - if value.mantissa <= 0.0: - return 0.0 - if value.exponent > 308: - return INF - if value.exponent < -308: - return 0.0 - return value.mantissa * pow(10.0, float(value.exponent)) + func _normalize_currency_id(currency_id: StringName) -> StringName: var normalized: String = String(currency_id).to_lower().strip_edges() @@ -341,33 +509,18 @@ func _to_non_negative_int(value: Variant, fallback: int = 0) -> int: func _is_config_valid() -> bool: if config == null: return false - if not config.has_method("is_valid"): - return false - - return bool(config.call("is_valid")) + return config.is_valid() func _is_cumulative_basis() -> bool: - if config != null and config.has_method("is_cumulative_basis"): - return bool(config.call("is_cumulative_basis")) - - return _get_config_int("basis", BASIS_LIFETIME_TOTAL) != BASIS_RUN_TOTAL + var basis_type: int = _get_config_int("basis", BASIS_LIFETIME_TOTAL) + return basis_type != BASIS_RUN_TOTAL and basis_type != BASIS_ALL_CURRENCIES func _round_gain(value: float) -> float: - if config != null and config.has_method("round_value"): - var rounded: Variant = config.call("round_value", value) - if rounded is float: - return rounded - if rounded is int: - return float(rounded) - - return floorf(value) + return config.round_value(value) func _get_config_threshold() -> BigNumber: - if config != null and config.has_method("get_threshold"): - var threshold: Variant = config.call("get_threshold") - if threshold is BigNumber: - return threshold - + if config != null: + return config.get_threshold() return BigNumber.from_float(1.0) func _get_config_string_name(property_name: String, fallback: StringName = &"") -> StringName: diff --git a/core/prestige/prestige_panel.gd b/core/prestige/prestige_panel.gd index 2994d04..5530a60 100644 --- a/core/prestige/prestige_panel.gd +++ b/core/prestige/prestige_panel.gd @@ -5,44 +5,44 @@ extends PanelContainer @onready var _pending_value: Label = $MarginContainer/VBoxContainer/StatsGrid/PendingValue @onready var _multiplier_value: Label = $MarginContainer/VBoxContainer/StatsGrid/MultiplierValue @onready var _basis_label: Label = $MarginContainer/VBoxContainer/StatsGrid/BasisValue +@onready var _progress_bar: Node = $MarginContainer/VBoxContainer/PrestigeProgressBar @onready var _reset_button: Button = $MarginContainer/VBoxContainer/Buttons/ResetButton @onready var _save_button: Button = $MarginContainer/VBoxContainer/Buttons/SaveButton @onready var _confirm_dialog: ConfirmationDialog = $ConfirmDialog +@onready var game_state: LevelGameState = find_parent("LevelGameState") var _is_waiting_for_confirm: bool = false func _ready() -> void: - var manager: Node = _get_prestige_manager() - if manager != null and manager.has_signal("prestige_state_changed"): - manager.prestige_state_changed.connect(_on_prestige_state_changed) - if manager != null and manager.has_signal("prestige_performed"): - manager.prestige_performed.connect(_on_prestige_performed) - if not GameState.currency_changed.is_connected(_on_currency_changed): - GameState.currency_changed.connect(_on_currency_changed) + game_state.ready.connect(_on_game_state_ready) +func _on_game_state_ready() -> void: + var manager: PrestigeManager = _get_prestige_manager() + manager.prestige_state_changed.connect(_on_prestige_state_changed) + manager.prestige_performed.connect(_on_prestige_performed) + game_state.currency_changed.connect(_on_currency_changed) _refresh_ui() + func _on_reset_button_pressed() -> void: - var manager: Node = _get_prestige_manager() - if manager == null or not manager.has_method("can_prestige"): + var manager: PrestigeManager = _get_prestige_manager() + + if not bool(manager.can_prestige()): return - if not bool(manager.call("can_prestige")): - return - - var pending: BigNumber = manager.call("calculate_pending_gain") - var source_currency_id: StringName = manager.call("get_source_currency_id") - var source_currency_name: String = GameState.get_currency_name(source_currency_id) + var pending: BigNumber = manager.calculate_pending_gain() + var basis_label: String = String(manager.get_basis_label()) + var basis_value: BigNumber = manager.get_basis_value_for_display() var confirm_message: String = "Reset this run for +%s prestige?\n\nThis resets currencies, owned generators, and buff levels. Lifetime totals and prestige are kept." % pending.to_string_suffix(2) - if not source_currency_name.is_empty(): - confirm_message = "%s\n\nBased on %s progression." % [confirm_message, source_currency_name] + confirm_message = "%s\n\nBased on %s: %s" % [confirm_message, basis_label, basis_value.to_string_suffix(2)] _confirm_dialog.dialog_text = confirm_message _is_waiting_for_confirm = true _confirm_dialog.popup_centered_ratio(0.4) func _on_save_button_pressed() -> void: - GameState.save_game() + if game_state: + game_state.save_game() func _on_prestige_state_changed(_total: BigNumber, _pending: BigNumber) -> void: _refresh_ui() @@ -58,33 +58,33 @@ func _on_confirm_dialog_confirmed() -> void: return _is_waiting_for_confirm = false - var manager: Node = _get_prestige_manager() + var manager: PrestigeManager = _get_prestige_manager() if manager == null: return - manager.call("perform_prestige") + manager.perform_prestige() func _on_confirm_dialog_canceled() -> void: _is_waiting_for_confirm = false func _refresh_ui() -> void: - var manager: Node = _get_prestige_manager() + var manager: PrestigeManager = _get_prestige_manager() if manager == null: visible = false return visible = true - var total: BigNumber = manager.call("get_total_prestige") - var pending: BigNumber = manager.call("calculate_pending_gain") - var multiplier: float = float(manager.call("get_total_multiplier")) - var basis_text: String = String(manager.call("get_basis_label")) - var basis_value: BigNumber = manager.call("get_basis_value_for_display") + var total: BigNumber = manager.get_total_prestige() + var pending: BigNumber = manager.calculate_pending_gain() + var multiplier: float = float(manager.get_total_multiplier()) + var basis_text: String = String(manager.get_basis_label()) + var basis_value: BigNumber = manager.get_basis_value_for_display() _total_value.text = total.to_string_suffix(2) _pending_value.text = pending.to_string_suffix(2) _multiplier_value.text = "x%.2f" % multiplier _basis_label.text = "%s (%s)" % [basis_text, basis_value.to_string_suffix(2)] - _reset_button.disabled = not bool(manager.call("can_prestige")) + _reset_button.disabled = not bool(manager.can_prestige()) -func _get_prestige_manager() -> Node: - return get_node_or_null("/root/PrestigeManager") +func _get_prestige_manager() -> PrestigeManager: + return game_state.prestige_manager diff --git a/core/prestige/prestige_panel.tscn b/core/prestige/prestige_panel.tscn index 57aecd1..a7ce3e5 100644 --- a/core/prestige/prestige_panel.tscn +++ b/core/prestige/prestige_panel.tscn @@ -1,76 +1,83 @@ -[gd_scene load_steps=2 format=3] +[gd_scene format=3 uid="uid://dlidx2x0otpjg"] -[ext_resource type="Script" path="res://core/prestige/prestige_panel.gd" id="1_7fdwq"] +[ext_resource type="Script" uid="uid://dmsmmgtbrul1t" path="res://core/prestige/prestige_panel.gd" id="1_panel"] +[ext_resource type="PackedScene" path="res://core/prestige/prestige_progress_bar.tscn" id="2_7b6yg"] +[ext_resource type="Resource" uid="uid://dwmfvmusfskk6" path="res://docs/gyms/tiny_sword/prestige/primary_prestige.tres" id="3_r8h5p"] -[node name="PrestigePanel" type="PanelContainer"] +[node name="PrestigePanel" type="PanelContainer" unique_id=789062217] offset_right = 420.0 offset_bottom = 206.0 -script = ExtResource("1_7fdwq") +script = ExtResource("1_panel") -[node name="MarginContainer" type="MarginContainer" parent="."] +[node name="MarginContainer" type="MarginContainer" parent="." unique_id=1133221945] layout_mode = 2 theme_override_constants/margin_left = 10 theme_override_constants/margin_top = 10 theme_override_constants/margin_right = 10 theme_override_constants/margin_bottom = 10 -[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer"] +[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer" unique_id=1014101412] layout_mode = 2 theme_override_constants/separation = 8 -[node name="Title" type="Label" parent="MarginContainer/VBoxContainer"] +[node name="Title" type="Label" parent="MarginContainer/VBoxContainer" unique_id=738033951] layout_mode = 2 text = "Prestige" -[node name="StatsGrid" type="GridContainer" parent="MarginContainer/VBoxContainer"] +[node name="StatsGrid" type="GridContainer" parent="MarginContainer/VBoxContainer" unique_id=1121463897] layout_mode = 2 columns = 2 -[node name="TotalTitle" type="Label" parent="MarginContainer/VBoxContainer/StatsGrid"] +[node name="TotalTitle" type="Label" parent="MarginContainer/VBoxContainer/StatsGrid" unique_id=1462148351] layout_mode = 2 text = "Total" -[node name="TotalValue" type="Label" parent="MarginContainer/VBoxContainer/StatsGrid"] +[node name="TotalValue" type="Label" parent="MarginContainer/VBoxContainer/StatsGrid" unique_id=1607797034] layout_mode = 2 text = "0" -[node name="PendingTitle" type="Label" parent="MarginContainer/VBoxContainer/StatsGrid"] +[node name="PendingTitle" type="Label" parent="MarginContainer/VBoxContainer/StatsGrid" unique_id=1999999111] layout_mode = 2 text = "Pending" -[node name="PendingValue" type="Label" parent="MarginContainer/VBoxContainer/StatsGrid"] +[node name="PendingValue" type="Label" parent="MarginContainer/VBoxContainer/StatsGrid" unique_id=1731674897] layout_mode = 2 text = "0" -[node name="MultiplierTitle" type="Label" parent="MarginContainer/VBoxContainer/StatsGrid"] +[node name="MultiplierTitle" type="Label" parent="MarginContainer/VBoxContainer/StatsGrid" unique_id=1338227256] layout_mode = 2 text = "Multiplier" -[node name="MultiplierValue" type="Label" parent="MarginContainer/VBoxContainer/StatsGrid"] +[node name="MultiplierValue" type="Label" parent="MarginContainer/VBoxContainer/StatsGrid" unique_id=1407185183] layout_mode = 2 text = "x1.00" -[node name="BasisTitle" type="Label" parent="MarginContainer/VBoxContainer/StatsGrid"] +[node name="BasisTitle" type="Label" parent="MarginContainer/VBoxContainer/StatsGrid" unique_id=1092652344] layout_mode = 2 text = "Basis" -[node name="BasisValue" type="Label" parent="MarginContainer/VBoxContainer/StatsGrid"] +[node name="BasisValue" type="Label" parent="MarginContainer/VBoxContainer/StatsGrid" unique_id=1421212102] layout_mode = 2 text = "-" -[node name="Buttons" type="HBoxContainer" parent="MarginContainer/VBoxContainer"] +[node name="PrestigeProgressBar" parent="MarginContainer/VBoxContainer" unique_id=619882824 instance=ExtResource("2_7b6yg")] +layout_mode = 2 +config = ExtResource("3_r8h5p") + +[node name="Buttons" type="HBoxContainer" parent="MarginContainer/VBoxContainer" unique_id=634533362] layout_mode = 2 theme_override_constants/separation = 8 -[node name="ResetButton" type="Button" parent="MarginContainer/VBoxContainer/Buttons"] +[node name="ResetButton" type="Button" parent="MarginContainer/VBoxContainer/Buttons" unique_id=379577642] layout_mode = 2 text = "Prestige Reset" -[node name="SaveButton" type="Button" parent="MarginContainer/VBoxContainer/Buttons"] +[node name="SaveButton" type="Button" parent="MarginContainer/VBoxContainer/Buttons" unique_id=1151017798] layout_mode = 2 text = "Save" -[node name="ConfirmDialog" type="ConfirmationDialog" parent="."] +[node name="ConfirmDialog" type="ConfirmationDialog" parent="." unique_id=1893210048] +oversampling_override = 1.0 title = "Prestige Reset" ok_button_text = "Prestige" dialog_text = "Reset this run?" diff --git a/core/prestige/prestige_progress_bar.gd b/core/prestige/prestige_progress_bar.gd new file mode 100644 index 0000000..1ce3463 --- /dev/null +++ b/core/prestige/prestige_progress_bar.gd @@ -0,0 +1,72 @@ +extends HBoxContainer + +@export var config: PrestigeConfig + +@onready var _label_start = $LabelStart +@onready var _label_end = $LabelEnd +@onready var _progress_bar = $ProgressBar + +var _basis_value: BigNumber +var _next_threshold: BigNumber +var _connected: bool = false +var _game_state: LevelGameState +var _manager: PrestigeManager + +func _ready() -> void: + _game_state = find_parent("LevelGameState") + _game_state.ready.connect(_on_game_state_ready) + +func _on_game_state_ready() -> void: + _manager = _game_state.prestige_manager + + if _manager == null: + push_warning("PrestigeProgressBar '%s' cannot find PrestigeManager; progress bar disabled." % String(name)) + visible = false + return + + if not _manager.has_signal("prestige_state_changed"): + push_warning("PrestigeManager does not have 'prestige_state_changed' signal; progress bar will not update.") + visible = false + return + + if config == null: + config = _manager.get_config() + + if config == null: + push_warning("PrestigeProgressBar '%s' has no config; progress bar disabled." % String(name)) + visible = false + return + + if _manager.has_signal("prestige_threshold_changed"): + _manager.prestige_threshold_changed.connect(_on_prestige_threshold_changed) + _manager.prestige_state_changed.connect(_on_prestige_state_changed) + _connected = true + _update_progress() + +func _on_prestige_state_changed(_total: BigNumber, _pending: BigNumber) -> void: + _update_progress() + +func _on_prestige_threshold_changed(_threshold: BigNumber) -> void: + _update_progress() + +func _update_progress() -> void: + if not _connected: + return + + if _manager == null or config == null: + return + + _basis_value = _manager.get_basis_value_for_display() + _next_threshold = _manager.get_next_prestige_threshold() + + var basis_float: float = _basis_value.to_float() + var threshold_float: float = _next_threshold.to_float() + + if threshold_float > 0.0 and basis_float >= 0.0: + var ratio: float = minf(basis_float / threshold_float, 1.0) + _progress_bar.value = ratio * 100.0 + else: + _progress_bar.value = 0.0 + + _label_start.text = _basis_value.to_string_suffix(2) + _label_end.text = _next_threshold.to_string_suffix(2) diff --git a/core/prestige/prestige_progress_bar.gd.uid b/core/prestige/prestige_progress_bar.gd.uid new file mode 100644 index 0000000..0b9e974 --- /dev/null +++ b/core/prestige/prestige_progress_bar.gd.uid @@ -0,0 +1 @@ +uid://dsq8yix7kkyaj diff --git a/core/prestige/prestige_progress_bar.tscn b/core/prestige/prestige_progress_bar.tscn new file mode 100644 index 0000000..f10dfd3 --- /dev/null +++ b/core/prestige/prestige_progress_bar.tscn @@ -0,0 +1,22 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://core/prestige/prestige_progress_bar.gd" id="1_pbg"] + +[node name="PrestigeProgressBar" type="HBoxContainer"] +offset_right = 298.0 +offset_bottom = 49.0 +alignment = 1 +script = ExtResource("1_pbg") + +[node name="LabelStart" type="Label" parent="."] +layout_mode = 2 +text = "0" + +[node name="ProgressBar" type="ProgressBar" parent="."] +custom_minimum_size = Vector2(200, 0) +layout_mode = 2 +show_percentage = true + +[node name="LabelEnd" type="Label" parent="."] +layout_mode = 2 +text = "0" diff --git a/core/research/README.md b/core/research/README.md new file mode 100644 index 0000000..44737cc --- /dev/null +++ b/core/research/README.md @@ -0,0 +1,488 @@ +# Research Module Documentation + +## Overview + +The `research/` subfolder implements a production-based research system where generator output earns +research XP, automatically leveling up to provide production multipliers. Research tracks are tied to +specific generators and can be enhanced with purchasable buffs that increase XP gain. + +## Files + +| File | Purpose | +|------|---------| +| `research_catalogue.gd` | Resource holding all research configurations | +| `research_panel.gd` | UI panel showing all research tracks | +| `research_row.gd` | UI row component for individual research display | +| `../generator/research_buff_calculator.gd` | Static utility for buff multiplier calculations | + +## Architecture + +``` +LevelGameState (Node2D) +├── research_xp: Dictionary +├── research_levels: Dictionary +├── research_catalogue: ResearchCatalogue +│ └── ResearchPanel (UI, always visible) +│ ├── ResearchRow (for each research track) +│ │ ├── Name & icon +│ │ ├── Level display +│ │ ├── Progress bar (percentage) +│ │ ├── Multiplier indicator +│ │ └── Active buff display +└── (buff calculation delegated to ResearchBuffCalculator static methods) +``` + +## Data Flow + +``` +Generator Production (currency_per_cycle) + │ + ▼ +LevelGameState.add_research_xp() + 1. base_xp = currency_produced * xp_per_currency_produced + 2. buff_multiplier = ResearchBuffCalculator.apply_buffs(research, buffs) + 3. actual_xp = base_xp * buff_multiplier + 4. research_xp += actual_xp + 5. Check level threshold → auto-level + 6. Emit research_level_up signal + │ + ▼ +ResearchState {xp: 150.5, level: 3} + │ + ▼ +CurrencyGenerator.get_research_multiplier() → 1.3 (30% bonus) + │ + ▼ +Production *= 1.3 +``` + +## ResearchData + +Resource class defining research configuration for a generator (defined in `core/generator/research_data.gd`). + +### Key Properties + +```gdscript +@export var id: StringName # Unique research identifier +@export var generator_id: StringName # Which generator this affects +@export var name: String # Display name +@export_multiline var description: String = "" # Description text +@export var icon: Texture2D # Icon for UI + +# XP Configuration +@export var xp_per_currency_produced: float = 0.01 # XP per 1 currency unit +@export var base_xp_required: float = 100.0 # XP needed for level 1 +@export var xp_growth_multiplier: float = 1.5 # XP requirement growth per level + +# Multiplier Configuration +@export var base_multiplier: float = 1.0 # Production multiplier at level 0 +@export var multiplier_per_level: float = 0.1 # +10% production per level + +# Associated Buff (single buff per research track) +@export var associated_buff_id: StringName # Buff that increases XP gain +``` + +### Key Methods + +```gdscript +func get_xp_required_for_level(level: int) -> float: + """XP needed to reach this level from previous""" + return base_xp_required * pow(xp_growth_multiplier, float(maxi(level - 1, 0))) + +func get_xp_required_for_level_big(level: int) -> BigNumber: + """XP needed to reach this level from previous (BigNumber)""" + return BigNumber.from_float(base_xp_required * pow(xp_growth_multiplier, float(maxi(level - 1, 0)))) + +func get_total_xp_for_level(level: int) -> BigNumber: + """Total cumulative XP needed to reach this level from level 0 (BigNumber)""" + if level <= 0: return BigNumber.new(0.0, 0) + var total: BigNumber = BigNumber.new(0.0, 0) + for l in range(1, level + 1): + total = total.add(get_xp_required_for_level_big(l)) + return total + +func get_level_for_xp(xp: BigNumber) -> int: + """Calculate level from total accumulated BigNumber XP (loop-based)""" + if xp.mantissa == 0.0: return 0 + var level: int = 0 + while true: + var next_xp: BigNumber = get_total_xp_for_level(level + 1) + if xp.compare_to(next_xp) < 0: break + level += 1 + return level + +func get_multiplier_for_level(level: int) -> float: + """Production multiplier at given level""" + return base_multiplier + (multiplier_per_level * float(maxi(level, 0))) + +func get_xp_progress(xp: float) -> float: + """Returns 0.0-1.0 progress to next level""" +``` + +## ResearchCatalogue + +Resource class holding all research configurations. + +### Structure + +```gdscript +class_name ResearchCatalogue +extends Resource + +@export var research_entries: Array[ResearchData] = [] + +func get_research_by_id(research_id: StringName) -> ResearchData +func get_research_by_generator_id(generator_id: StringName) -> ResearchData +func get_all_research() -> Array[ResearchData] +``` + +### Usage + +Referenced directly by `LevelGameState` via `@export var research_catalogue: ResearchCatalogue`. + +## ResearchBuffCalculator + +Static utility class for calculating research XP buff multipliers. + +### Location + +`res://core/generator/research_buff_calculator.gd` + +### Key Methods + +```gdscript +static func apply_buffs(research: ResearchData, game_state: LevelGameState) -> float: + """Calculate total buff multiplier for research XP gain. Returns multiplicative factor.""" + var multiplier: float = 1.0 + if research.associated_buff_id.is_empty(): + return multiplier + + var buff: GeneratorBuffData = game_state.get_buff(research.associated_buff_id) + if buff != null and game_state.is_buff_active(buff.id): + var buff_level: int = game_state.get_buff_level(buff.id) + multiplier *= ResearchBuffCalculator._calculate_buff_multiplier(buff, buff_level) + + return multiplier + +static func _calculate_buff_multiplier(buff: GeneratorBuffData, level: int) -> float: + """Calculate buff effect multiplier at given level.""" + return buff.get_effect_multiplier(level) +``` + +### Usage + +Called from `LevelGameState.add_research_xp()`: + +```gdscript +func add_research_xp(research_id: StringName, xp: BigNumber) -> void: + var research: ResearchData = research_catalogue.get_research_by_id(research_id) + if research == null: + return + + # Apply buff multipliers + var buff_multiplier: float = ResearchBuffCalculator.apply_buffs(research, self) + var multiplier_bn: BigNumber = BigNumber.from_float(buff_multiplier) + var actual_xp: BigNumber = xp.multiply(multiplier_bn) + + # Store XP and check for level-up + var old_level: int = get_research_level(research_id) + research_xp[research_id] = research_xp.get(research_id, BigNumber.new(0.0, 0)).add(actual_xp) + var new_level: int = research.get_level_for_xp(research_xp[research_id]) + + if new_level > old_level: + research_levels[research_id] = new_level + research_level_up.emit(research_id, old_level, new_level) + + research_xp_changed.emit(research_id, research_xp[research_id]) +``` + +## LevelGameState Extensions + +### State Variables +```gdscript +var research_xp: Dictionary = {} # {research_id: BigNumber} +var research_levels: Dictionary = {} # {research_id: level_int} +# research_tracker removed - buff calculation moved to static utility +``` + +### Signals +```gdscript +signal research_xp_changed(research_id: StringName, new_xp: BigNumber) +signal research_level_up(research_id: StringName, old_level: int, new_level: int) +``` + +### Key Methods +```gdscript +func register_research(research_id: StringName) -> void: + """Initialize research state from catalogue (BigNumber XP)""" + if not research_xp.has(research_id): + research_xp[research_id] = BigNumber.new(0.0, 0) + +func get_research_xp(research_id: StringName) -> BigNumber + +func get_research_level(research_id: StringName) -> int + +func get_research_multiplier(research_id: StringName) -> float: + """Returns production multiplier from research level""" + var level: int = get_research_level(research_id) + return research.get_multiplier_for_level(level) + +func add_research_xp(research_id: StringName, xp: BigNumber) -> void: + """Add XP (BigNumber) with buff multipliers applied, auto-level if threshold reached""" + var research: ResearchData = research_catalogue.get_research_by_id(research_id) + if research == null: + return + + var old_level: int = get_research_level(research_id) + var current_xp: BigNumber = get_research_xp(research_id) + + # Apply buff multipliers via static utility + var buff_multiplier: float = ResearchBuffCalculator.apply_buffs(research, self) + var multiplier_bn: BigNumber = BigNumber.from_float(buff_multiplier) + var actual_xp: BigNumber = xp.multiply(multiplier_bn) + + research_xp[research_id] = current_xp.add(actual_xp) + var new_level: int = research.get_level_for_xp(research_xp[research_id]) + + if new_level > old_level: + research_levels[research_id] = new_level + research_level_up.emit(research_id, old_level, new_level) + + research_xp_changed.emit(research_id, research_xp[research_id]) +``` + +### Save/Load Integration +```gdscript +const RESEARCH_XP_KEY = "research_xp" +const RESEARCH_LEVELS_KEY = "research_levels" +const CURRENT_SAVE_FORMAT_VERSION = 5 # Bumped from 4 to support BigNumber + +# In save_game(): +var save_data = { + # ... existing fields ... + RESEARCH_XP_KEY: _serialize_research_xp(), # Serialize BigNumber as {m, e} + RESEARCH_LEVELS_KEY: research_levels.duplicate(), +} + +func _serialize_research_xp() -> Dictionary: + var result: Dictionary = {} + for research_id in research_xp.keys(): + var xp: BigNumber = research_xp[research_id] + result[research_id] = xp.serialize() + return result + +# In load_game(): +research_xp = _deserialize_research_xp(parsed_data.get(RESEARCH_XP_KEY, {})) +research_levels = parsed_data.get(RESEARCH_LEVELS_KEY, {}) + +func _deserialize_research_xp(raw: Variant) -> Dictionary: + var result: Dictionary = {} + if raw is Dictionary: + for research_id in raw.keys(): + var serialized: Variant = raw[research_id] + if serialized is Dictionary: + result[research_id] = BigNumber.deserialize(serialized) + return result + +# In reset_for_prestige(): +research_xp.clear() +research_levels.clear() +``` + +## CurrencyGenerator Extensions + +### Production Tick Integration +```gdscript +func _grant_cycle_income(cycle_count: int) -> void: + var produced: BigNumber = ... + _add_currency(produced) + production_tick.emit(produced, cycle_count, owned) + + # Award research XP (BigNumber) - buff multipliers applied in LevelGameState + if data.research_data != null: + var amount_float: float = produced.mantissa * pow(10.0, float(produced.exponent)) + var xp: BigNumber = BigNumber.from_float(data.research_data.xp_per_currency_produced * amount_float) + game_state.add_research_xp(data.research_data.id, xp) +``` + +### Research Multiplier + +```gdscript +func get_research_multiplier() -> float: + if data.research_data == null: + return 1.0 + return game_state.get_research_multiplier(data.research_data.id) + +func get_effective_auto_run_multiplier() -> float: + return run_multiplier * get_research_multiplier() * get_automatic_production_multiplier() * _get_prestige_multiplier() +``` + +## GeneratorBuffData Extensions + +### New Buff Kind + +```gdscript +enum BuffKind { + AUTO_PRODUCTION_MULTIPLIER, + MANUAL_CLICK_MULTIPLIER, + RESOURCE_PURCHASE, + RESEARCH_XP_MULTIPLIER # Increases research XP gain +} +``` + +### New Properties (for RESEARCH_XP_MULTIPLIER buffs) + +```gdscript +@export var research_target_id: StringName # Which research track this buff affects +@export var xp_multiplier_per_level: float = 0.1 # +10% XP per level +``` + +## ResearchPanel + +UI component showing all research tracks (always visible, like GeneratorPanel). + +### Layout + +``` +ResearchPanel (ScrollContainer) +└── VBoxContainer + ├── TitleLabel ("Research") + └── ResearchRows (VBoxContainer) + └── ResearchRow (for each research track) +``` + +### Key Methods + +```gdscript +func _build_research_rows() -> void: + for research in research_catalogue.get_all_research(): + var row = RESEARCH_ROW_SCENE.instantiate() + row.setup(research) + _research_rows[research.id] = row + +func _refresh_row(row) -> void: + var research_id: StringName = row.get_research_id() + var xp: BigNumber = game_state.get_research_xp(research_id) + var level: int = game_state.get_research_level(research_id) + var progress: float = research.get_xp_progress(xp) + var multiplier: float = research.get_multiplier_for_level(level) + var xp_current: BigNumber = xp.subtract(research.get_total_xp_for_level(level)) + var xp_needed: BigNumber = research.get_xp_required_for_level_big(level + 1) + row.update_display(level, progress, multiplier, xp_current, xp_needed) +``` + +### Signals Connected + +- `research_xp_changed` - Refresh progress bars +- `research_level_up` - Refresh level and multiplier displays + +## ResearchRow + +UI row component for individual research display. + +### UI Layout + +``` +ResearchRow (HBoxContainer) +├── Icon (TextureRect) +├── VBoxContainer +│ ├── NameLabel ("Gold Mine Research") +│ └── LevelLabel ("Level 3") +├── ProgressBar (with percentage label "75%") +├── MultiplierLabel ("+30%") +└── BuffLabel ("Mining Expert: +20% XP") +``` + +### Key Methods + +```gdscript +func setup(research: ResearchData) -> void: + _name_label.text = research.name + _icon.texture = research.icon + +func update_display(level: int, progress: float, multiplier: float, xp_current: BigNumber, xp_needed: BigNumber) -> void: + _level_label.text = "Level %d" % level + _progress_bar.value = progress * 100 + _progress_label.text = "%d%%" % int(progress * 100) + _multiplier_label.text = "+%d%%" % int((multiplier - 1.0) * 100) + + # Update buff label if buff is active + if _research.associated_buff_id != &"": + var buff: GeneratorBuffData = game_state.get_buff(_research.associated_buff_id) + if is_buff_active(buff.id): + _buff_label.text = "%s: +%d%% XP" % [buff.text, int(effect * 100)] +``` + +## Formulas + +### XP Calculation + +``` +base_xp = currency_produced * xp_per_currency_produced +actual_xp = base_xp * buff_multiplier +``` + +### Level Progression + +``` +xp_required_for_level(n) = base_xp_required * (xp_growth_multiplier ^ (n - 1)) +total_xp_for_level(n) = sum(xp_required_for_level(i) for i in 1..n) +level = get_level_for_xp(total_xp) +``` + +### Production Multiplier + +``` +multiplier = base_multiplier + (multiplier_per_level * level) +effective_production = base_production * multiplier +``` + +### Buff XP Multiplier + +``` +xp_multiplier = 1.0 +for each active buff targeting this research: + xp_multiplier *= buff.get_effect_multiplier(level) +actual_xp = base_xp * xp_multiplier +``` + +## Design Decisions + +| Decision | Value | +|----------|-------| +| ResearchCatalogue reference | Direct `@export var research_catalogue: ResearchCatalogue` in LevelGameState | +| Buff calculation | Static utility (`ResearchBuffCalculator`) - no dedicated tracker node | +| Progress bar display | Percentage format (e.g., "75%") | +| Buff display | Shows buff name and effect (e.g., "Mining Expert: +20% XP") | +| Research unlock | Starts unlocked | +| XP formula | Production-based: `XP = currency_produced * xp_per_currency_produced` | +| Level-up | Auto-level when XP threshold reached | +| Buffs | Single buff per research track, multiplicative XP bonus | +| Prestige | Research levels and XP reset to 0 | +| ResearchPanel visibility | Always visible (like GeneratorPanel) | + +## System Behavior + +1. **Generator produces currency** → `production_tick` signal emitted +2. **CurrencyGenerator calls** → `game_state.add_research_xp()` with base XP +3. **LevelGameState applies buffs** → `ResearchBuffCalculator.apply_buffs()` calculates multiplier +4. **XP awarded** → stored in `research_xp` dictionary +5. **Level check** → if XP exceeds threshold, auto-level and emit `research_level_up` +6. **Multiplier applied** → `get_research_multiplier()` returns updated production bonus +7. **UI refresh** → `ResearchPanel` updates progress bars and level displays + +## Prestige Integration + +On prestige reset: +1. `research_xp.clear()` - All XP lost +2. `research_levels.clear()` - All levels reset to 0 +3. Buff purchases persist (separate state) +4. Research tracks remain unlocked + +## See Also + +- `core/level_game_state.gd` - Research state storage and management +- `core/generator/currency_generator_data.gd` - Links to ResearchData +- `core/generator/generator_buff_data.gd` - RESEARCH_XP_MULTIPLIER buff type +- `core/generator/research_buff_calculator.gd` - Static buff calculation utility +- `core/research/research_catalogue.gd` - Research configuration resource diff --git a/core/research/TODO.md b/core/research/TODO.md new file mode 100644 index 0000000..5b441cd --- /dev/null +++ b/core/research/TODO.md @@ -0,0 +1,616 @@ +# Research Feature Implementation Plan + +## Overview + +A production-based research system where: +- **Generator production → Research XP → Auto-level → Production multiplier** +- **Research buffs** (purchased with currency) increase XP gain multiplicatively +- **One research track per generator** with a single associated buff +- **Progress bar shows percentage** to next level +- **Buff display shows name and effect** +- **Research tracks start unlocked** +- **Resets on prestige** (levels and XP lost) + +--- + +## System Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ LevelGameState │ +│ - research_xp: Dictionary │ +│ - research_levels: Dictionary │ +│ - research_catalogue: ResearchCatalogue │ +│ - add_research_xp() applies buffs via ResearchBuffCalculator │ +└─────────────────────────────────────────────────────────────────┘ + │ + ┌───────────────────┼───────────────────┐ + ▼ ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ CurrencyGen │ │ ResearchData │ │ ResearchPanel │ +│ (Gold Mine) │──│ (Resource) │──│ (Always visible)│ +│ - produces gold │ │ - XP config │ │ - Shows all │ +│ - emits signal │ │ - Multiplier │ │ research │ +└─────────────────┘ └─────────────────┘ │ - Progress bars │ + └─────────────────┘ + │ + ▼ +┌───────────────────────────────────────────────────────────────┐ +│ ResearchBuffCalculator (static utility) │ +│ - apply_buffs() calculates XP multiplier from active buffs │ +└───────────────────────────────────────────────────────────────┘ +``` + +--- + +## Data Flow + +``` +Generator Production (currency_per_cycle) + │ + ▼ +┌──────────────────────┐ +│ CurrencyGen │ +│ emits production_tick │ +└──────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────┐ +│ LevelGameState.add_research_xp() │ +│ 1. base_xp = currency_produced * rate │ +│ 2. buff_mult = ResearchBuffCalculator.apply_buffs() +│ 3. actual_xp = base_xp * buff_mult │ +│ 4. research_xp += actual_xp │ +│ 5. Check level threshold → auto-level │ +│ 6. Emit research_level_up signal │ +└─────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────┐ +│ ResearchState │ +│ {xp: 150.5, level: 3}│ +└──────────────────────┘ + │ + ▼ +┌──────────────────────┐ +│ CurrencyGen │ +│ get_research_multiplier() → 1.3 (30% bonus) +└──────────────────────┘ + │ + ▼ +┌──────────────────────┐ +│ Production *= 1.3 │ +└──────────────────────┘ +``` + +--- + +## Component Specifications + +### 1. ResearchData (core/generator/research_data.gd) + +**Purpose:** Resource defining research configuration for a generator + +**Location:** `res://core/generator/research_data.gd` + +**Key Properties:** +```gdscript +@export var id: StringName # Unique research identifier +@export var generator_id: StringName # Which generator this research affects +@export var name: String # Display name +@export_multiline var description: String = "" # Description text +@export var icon: Texture2D # Icon for UI + +# XP Configuration +@export var xp_per_currency_produced: float = 0.01 # XP per 1 currency unit produced +@export var base_xp_required: float = 100.0 # XP needed for level 1 +@export var xp_growth_multiplier: float = 1.5 # XP requirement growth per level + +# Multiplier Configuration +@export var base_multiplier: float = 1.0 # Production multiplier at level 0 +@export var multiplier_per_level: float = 0.1 # +10% production per level + +# Associated Buff (single buff per research track) +@export var associated_buff_id: StringName # Buff that increases XP gain +``` + +**Key Methods:** +```gdscript +func get_xp_required_for_level(level: int) -> float: + """XP needed to reach this level from previous""" + return base_xp_required * pow(xp_growth_multiplier, float(maxi(level - 1, 0))) + +func get_total_xp_for_level(level: int) -> float: + """Total cumulative XP needed to reach this level from level 0""" + if level <= 0: + return 0.0 + var total: float = 0.0 + for l in range(1, level + 1): + total += get_xp_required_for_level(l) + return total + +func get_level_for_xp(xp: float) -> int: + """Calculate level from total accumulated XP""" + var level: int = 0 + var cumulative_xp: float = 0.0 + while true: + var next_xp: float = cumulative_xp + get_xp_required_for_level(level + 1) + if xp < next_xp: + break + level += 1 + cumulative_xp = next_xp + return level + +func get_multiplier_for_level(level: int) -> float: + """Production multiplier at given level""" + return base_multiplier + (multiplier_per_level * float(maxi(level, 0))) + +func get_xp_progress(xp: float) -> float: + """Returns 0.0-1.0 progress to next level""" + if xp <= 0.0: + return 0.0 + var current_level: int = get_level_for_xp(xp) + if current_level < 0: + return 0.0 + + var xp_at_current_level: float = get_total_xp_for_level(current_level) + var xp_needed_for_next: float = get_xp_required_for_level(current_level + 1) + var xp_in_current_level: float = xp - xp_at_current_level + + return clampf(xp_in_current_level / xp_needed_for_next, 0.0, 1.0) +``` + +--- + +### 2. ResearchCatalogue (core/research/research_catalogue.gd) + +**Purpose:** Resource holding all research configurations + +**Location:** `res://core/research/research_catalogue.gd` + +**Structure:** +```gdscript +class_name ResearchCatalogue +extends Resource + +@export var research_entries: Array[ResearchData] = [] + +func get_research_by_id(research_id: StringName) -> ResearchData: + for entry in research_entries: + if entry.id == research_id: + return entry + return null + +func get_research_by_generator_id(generator_id: StringName) -> ResearchData: + for entry in research_entries: + if entry.generator_id == generator_id: + return entry + return null + +func get_all_research() -> Array[ResearchData]: + return research_entries.duplicate() +``` + +**Usage:** Referenced directly by LevelGameState via `@export var research_catalogue: ResearchCatalogue` + +--- + +### 3. ResearchBuffCalculator (core/generator/research_buff_calculator.gd) + +**Purpose:** Static utility class for calculating research XP buff multipliers + +**Location:** `res://core/generator/research_buff_calculator.gd` + +**Key Methods:** +```gdscript +static func apply_buffs(research: ResearchData, game_state: LevelGameState) -> float: + """Calculate total buff multiplier for research XP gain. Returns multiplicative factor.""" + var multiplier: float = 1.0 + if research.associated_buff_id.is_empty(): + return multiplier + + var buff: GeneratorBuffData = game_state.get_buff(research.associated_buff_id) + if buff != null and game_state.is_buff_active(buff.id): + var buff_level: int = game_state.get_buff_level(buff.id) + multiplier *= _calculate_buff_multiplier(buff, buff_level) + + return multiplier + +static func _calculate_buff_multiplier(buff: GeneratorBuffData, level: int) -> float: + """Calculate buff effect multiplier at given level.""" + return buff.get_effect_multiplier(level) +``` + +**Usage:** Called from `LevelGameState.add_research_xp()` to apply buffs before storing XP + +--- + +### 4. LevelGameState Extensions (core/level_game_state.gd) + +**New State Variables:** +```gdscript +var research_xp: Dictionary = {} # {research_id: BigNumber} +var research_levels: Dictionary = {} # {research_id: level_int} +# research_tracker removed - buff calculation moved to static utility +``` + +**New Signals:** +```gdscript +signal research_xp_changed(research_id: StringName, new_xp: BigNumber) +signal research_level_up(research_id: StringName, old_level: int, new_level: int) +``` + +**New Properties:** +```gdscript +@export var research_catalogue: ResearchCatalogue +``` + +**New Methods:** +```gdscript +func register_research(research_id: StringName) -> void: + if not research_xp.has(research_id): + research_xp[research_id] = BigNumber.new(0.0, 0) + if not research_levels.has(research_id): + research_levels[research_id] = 0 + +func get_research_xp(research_id: StringName) -> BigNumber: + return research_xp.get(research_id, BigNumber.new(0.0, 0)) + +func get_research_level(research_id: StringName) -> int: + return research_levels.get(research_id, 0) + +func get_research_multiplier(research_id: StringName) -> float: + var level: int = get_research_level(research_id) + var research: ResearchData = _get_research_data(research_id) + if research == null: + return 1.0 + return research.get_multiplier_for_level(level) + +func add_research_xp(research_id: StringName, xp: BigNumber) -> void: + """Add XP with buff multipliers applied, auto-level if threshold reached""" + var research: ResearchData = research_catalogue.get_research_by_id(research_id) + if research == null: + return + + var old_level: int = get_research_level(research_id) + var current_xp: BigNumber = get_research_xp(research_id) + + # Apply buff multipliers via static utility + var buff_multiplier: float = ResearchBuffCalculator.apply_buffs(research, self) + var multiplier_bn: BigNumber = BigNumber.from_float(buff_multiplier) + var actual_xp: BigNumber = xp.multiply(multiplier_bn) + + research_xp[research_id] = current_xp.add(actual_xp) + var new_level: int = research.get_level_for_xp(research_xp[research_id]) + + if new_level > old_level: + research_levels[research_id] = new_level + research_level_up.emit(research_id, old_level, new_level) + + research_xp_changed.emit(research_id, research_xp[research_id]) + +func _get_research_data(research_id: StringName) -> ResearchData: + if research_catalogue == null: + return null + return research_catalogue.get_research_by_id(research_id) +``` + +**Save/Load Integration:** +```gdscript +const RESEARCH_XP_KEY = "research_xp" +const RESEARCH_LEVELS_KEY = "research_levels" + +# In save_game(): +var save_data = { + # ... existing fields ... + RESEARCH_XP_KEY: research_xp.duplicate(), + RESEARCH_LEVELS_KEY: research_levels.duplicate(), +} + +# In load_game(): +if parsed_data.has(RESEARCH_XP_KEY): + research_xp = parsed_data.get(RESEARCH_XP_KEY, {}) +if parsed_data.has(RESEARCH_LEVELS_KEY): + research_levels = parsed_data.get(RESEARCH_LEVELS_KEY, {}) + +# In reset_for_prestige(): +research_xp.clear() +research_levels.clear() +``` + +**Initialization in _ready():** +```gdscript +# Initialize research state from catalogue +if research_catalogue: + for research in research_catalogue.get_all_research(): + register_research(research.id) +``` + +--- + +### 5. CurrencyGeneratorData Extensions (core/generator/currency_generator_data.gd) + +**New Property:** +```gdscript +@export var research_data: ResearchData # Link to research configuration +``` + +--- + +### 6. CurrencyGenerator Extensions (core/generator/currency_generator.gd) + +**In `_grant_cycle_income()`:** +```gdscript +func _grant_cycle_income(cycle_count: int) -> void: + if data == null or cycle_count <= 0: + return + + var effective_run_multiplier: float = get_effective_auto_run_multiplier() + var per_cycle: float = data.production_per_cycle(owned, effective_run_multiplier, purchased_count) + if per_cycle <= 0.0: + return + + var produced: BigNumber = BigNumber.from_float(per_cycle).multiply(BigNumber.from_float(float(cycle_count))) + _add_currency(produced) + production_tick.emit(produced, cycle_count, owned) + + # Award research XP - buff multipliers applied in LevelGameState + if data.research_data != null: + var xp: float = data.research_data.xp_per_currency_produced * produced.to_float() + game_state.add_research_xp(data.research_data.id, BigNumber.from_float(xp)) +``` + +**New Method:** +```gdscript +func get_research_multiplier() -> float: + if data == null or data.research_data == null: + return 1.0 + if game_state == null: + return 1.0 + return game_state.get_research_multiplier(data.research_data.id) +``` + +**Modify `get_effective_auto_run_multiplier()`:** +```gdscript +func get_effective_auto_run_multiplier() -> float: + return run_multiplier * get_research_multiplier() * get_automatic_production_multiplier() * _get_prestige_multiplier() +``` + +--- + +### 7. GeneratorBuffData Extensions (core/generator/generator_buff_data.gd) + +**New Enum Value:** +```gdscript +enum BuffKind { + AUTO_PRODUCTION_MULTIPLIER, + MANUAL_CLICK_MULTIPLIER, + RESOURCE_PURCHASE, + RESEARCH_XP_MULTIPLIER # NEW: Increases research XP gain +} +``` + +**New Properties (for RESEARCH_XP_MULTIPLIER buffs):** +```gdscript +@export var research_target_id: StringName # Which research track this buff affects +@export var xp_multiplier_per_level: float = 0.1 # +10% XP per level (uses get_effect_multiplier) +``` + +**Note:** Buff names are configured in the `text` field of each `GeneratorBuffData` instance + +--- + +### 8. ResearchRow (core/research/research_row.tscn + .gd) + +**Location:** +- Script: `res://core/research/research_row.gd` +- Scene: `res://core/research/research_row.tscn` + +**UI Layout:** +``` +ResearchRow (HBoxContainer) +├── Icon (TextureRect) +├── VBoxContainer +│ ├── NameLabel ("Gold Mine Research") +│ └── LevelLabel ("Level 3") +├── ProgressBar (with percentage label "75%") +├── MultiplierLabel ("+30%") +└── BuffLabel ("Mining Expert: +20% XP") +``` + +**Script Methods:** +```gdscript +func setup(research: ResearchData) -> void: + """Initialize row with research data""" + _research = research + _name_label.text = research.name + _icon.texture = research.icon + +func update_display(level: int, progress: float, multiplier: float, xp_current: float, xp_needed: float) -> void: + """Update UI elements""" + _level_label.text = "Level %d" % level + _progress_bar.value = progress * 100 + _progress_label.text = "%d%%" % int(progress * 100) + _multiplier_label.text = "+%d%%" % int((multiplier - 1.0) * 100) + + # Update buff label if buff is active + if _research.associated_buff_id != &"": + var buff: GeneratorBuffData = game_state.get_buff(_research.associated_buff_id) + if buff != null and game_state.is_buff_active(buff.id): + var level: int = game_state.get_buff_level(buff.id) + var effect: float = buff.get_effect_multiplier(level) - 1.0 + _buff_label.text = "%s: +%d%% XP" % [buff.text, int(effect * 100)] +``` + +--- + +### 9. ResearchPanel (core/research/research_panel.tscn + .gd) + +**Location:** +- Script: `res://core/research/research_panel.gd` +- Scene: `res://core/research/research_panel.tscn` + +**Layout:** +``` +ResearchPanel (ScrollContainer) +├── VBoxContainer +│ ├── TitleLabel ("Research") +│ └── ResearchRows (VBoxContainer) +│ └── ResearchRow (for each research track) +``` + +**Script Methods:** +```gdscript +func _ready() -> void: + _game_state = find_parent("LevelGameState") + if _game_state == null: + return + + _game_state.research_xp_changed.connect(_on_research_xp_changed) + _game_state.research_level_up.connect(_on_research_level_up) + + _build_research_rows() + _refresh_all() + +func _build_research_rows() -> void: + for child in _research_rows_container.get_children(): + child.queue_free() + _research_rows.clear() + + if _game_state.research_catalogue: + for research in _game_state.research_catalogue.get_all_research(): + var row = RESEARCH_ROW_SCENE.instantiate() + _research_rows_container.add_child(row) + row.setup(research) + _research_rows[research.id] = row + +func _refresh_all() -> void: + for row in _research_rows.values(): + _refresh_row(row) + +func _refresh_row(row) -> void: + var research_id: StringName = row.get_research_id() + var xp: float = _game_state.get_research_xp(research_id) + var level: int = _game_state.get_research_level(research_id) + var research: ResearchData = _game_state.research_catalogue.get_research_by_id(research_id) + + if research != null: + var progress: float = research.get_xp_progress(xp) + var multiplier: float = research.get_multiplier_for_level(level) + var xp_current: float = xp - research.get_total_xp_for_level(level) + var xp_needed: float = research.get_xp_required_for_level(level + 1) + + row.update_display(level, progress, multiplier, xp_current, xp_needed) + +func _on_research_xp_changed(research_id: StringName, _new_xp: float) -> void: + if _research_rows.has(research_id): + _refresh_row(_research_rows[research_id]) + +func _on_research_level_up(research_id: StringName, _old_level: int, _new_level: int) -> void: + if _research_rows.has(research_id): + _refresh_row(_research_rows[research_id]) +``` + +--- + +## Implementation Sequence + +| Step | Task | Files | Priority | Status | +|------|------|-------|----------|--------| +| 1 | Create `ResearchData` resource class | `core/generator/research_data.gd` | High | ⬜ | +| 2 | Create `ResearchCatalogue` resource class | `core/research/research_catalogue.gd` | High | ⬜ | +| 3 | Create `ResearchBuffCalculator` static utility | `core/generator/research_buff_calculator.gd` | High | ✅ Done | +| 4 | Add research state to `LevelGameState` | `core/level_game_state.gd` | High | ✅ Done | +| 5 | Add `RESEARCH_XP_MULTIPLIER` buff kind | `core/generator/generator_buff_data.gd` | High | ⬜ | +| 6 | Add `research_data` export to `CurrencyGeneratorData` | `core/generator/currency_generator_data.gd` | High | ⬜ | +| 7 | Connect production to research XP in `CurrencyGenerator` | `core/generator/currency_generator.gd` | High | ✅ Done | +| 8 | Create `ResearchRow` scene & script | `core/research/research_row.tscn`, `.gd` | Medium | ⬜ | +| 9 | Create `ResearchPanel` scene & script | `core/research/research_panel.tscn`, `.gd` | Medium | ⬜ | +| 10 | Add save/load support for research state | `core/level_game_state.gd` | Medium | ⬜ | +| 11 | Add prestige reset for research | `core/level_game_state.gd` | Medium | ⬜ | +| 12 | Create sample `ResearchData` resource | `doc/gyms/tiny_sword/resources/` | Low | ⬜ | +| 13 | Test integration | Manual testing | Low | ⬜ | + +--- + +## Design Decisions (Confirmed) + +| Decision | Value | +|----------|-------| +| ResearchCatalogue reference | Direct `@export var research_catalogue: ResearchCatalogue` in LevelGameState | +| ResearchCatalogue location | `core/research/research_catalogue.gd` (resource, referenced directly) | +| Buff calculation | Static utility (`ResearchBuffCalculator`) - no dedicated tracker node | +| Progress bar display | Percentage format (e.g., "75%") | +| Buff display | Shows buff name and effect (e.g., "Mining Expert: +20% XP") | +| Research unlock | Starts unlocked | +| XP formula | Production-based: `XP = currency_produced * xp_per_currency_produced` | +| Level-up | Auto-level when XP threshold reached | +| Buffs | Single buff per research track, multiplicative XP bonus | +| Prestige | Research levels and XP reset to 0 | +| ResearchData location | `res://doc/gyms/tiny_sword/resources/` | +| ResearchPanel visibility | Always visible (like GeneratorPanel) | + +--- + +## Formulas + +### XP Calculation +``` +base_xp = currency_produced * xp_per_currency_produced +actual_xp = base_xp * buff_multiplier +``` + +### Level Progression +``` +xp_required_for_level(n) = base_xp_required * (xp_growth_multiplier ^ (n - 1)) +total_xp_for_level(n) = sum(xp_required_for_level(i) for i in 1..n) +level = get_level_for_xp(total_xp) +``` + +### Production Multiplier +``` +multiplier = base_multiplier + (multiplier_per_level * level) +effective_production = base_production * multiplier +``` + +### Buff XP Multiplier +``` +xp_multiplier = 1.0 +for each active buff targeting this research: + xp_multiplier *= buff.get_effect_multiplier(level) +actual_xp = base_xp * xp_multiplier +``` + +--- + +## Notes + +- **ResearchData instances** should be created as `.tres` resources in `doc/gyms/tiny_sword/resources/` +- **Associated buffs** should be created as `GeneratorBuffData` with `BuffKind.RESEARCH_XP_MULTIPLIER` +- **Buff names** are configured in the `text` field of each `GeneratorBuffData` +- **ResearchPanel** should be added as a child node in the main game scene (always visible) +- **Save format version** should be incremented to 5 when adding research support (BigNumber XP) +- **ResearchXPTracker removed** - buff calculation moved to static `ResearchBuffCalculator` utility + +--- + +## Testing Checklist + +- [ ] Generator produces currency → research XP increases +- [ ] Buff multipliers correctly applied to XP gain +- [ ] Auto-level triggers at correct XP thresholds +- [ ] Production multiplier updates after level-up +- [ ] ResearchPanel displays correct progress and level +- [ ] Save/load preserves research state +- [ ] Prestige resets research levels and XP +- [ ] Multiple research tracks work independently +- [ ] Buff purchase correctly affects XP gain + +--- + +## Dependencies + +- `CurrencyGenerator` must emit `production_tick` signal +- `GeneratorBuffData` must support `RESEARCH_XP_MULTIPLIER` kind +- `LevelGameState` must support buff tracking and signals +- Save format version must be updated diff --git a/core/research/research_catalogue.gd b/core/research/research_catalogue.gd new file mode 100644 index 0000000..65f89b8 --- /dev/null +++ b/core/research/research_catalogue.gd @@ -0,0 +1,19 @@ +class_name ResearchCatalogue +extends Resource + +@export var research_entries: Array[ResearchData] = [] + +func get_research_by_id(research_id: StringName) -> ResearchData: + for entry in research_entries: + if entry.id == research_id: + return entry + return null + +func get_research_by_generator_id(generator_id: StringName) -> ResearchData: + for entry in research_entries: + if entry.generator_id == generator_id: + return entry + return null + +func get_all_research() -> Array[ResearchData]: + return research_entries.duplicate() diff --git a/core/research/research_catalogue.gd.uid b/core/research/research_catalogue.gd.uid new file mode 100644 index 0000000..dc92b19 --- /dev/null +++ b/core/research/research_catalogue.gd.uid @@ -0,0 +1 @@ +uid://d2v6t6w2todfy diff --git a/core/research/research_data.gd b/core/research/research_data.gd new file mode 100644 index 0000000..1b5f179 --- /dev/null +++ b/core/research/research_data.gd @@ -0,0 +1,70 @@ +class_name ResearchData +extends Resource + +@export var id: StringName = &"" +@export var generator_id: StringName = &"" +@export var name: String = "" +@export_multiline var description: String = "" +@export var icon: Texture2D + +## XP Configuration +@export var xp_per_currency_produced: float = 0.01 +@export var base_xp_required: float = 100.0 +@export var xp_growth_multiplier: float = 1.5 + +## Multiplier Configuration +@export var base_multiplier: float = 1.0 +@export var multiplier_per_level: float = 0.1 + +## Associated Buff (single buff per research track) +@export var associated_buff_id: StringName = &"" + +## Worker Scaling Configuration +@export var worker_scaling_factor: float = 0.01 +@export var min_workers_for_xp: int = 1 + +func get_xp_required_for_level(level: int) -> float: + return base_xp_required * pow(xp_growth_multiplier, float(maxi(level - 1, 0))) + +func get_xp_required_for_level_big(level: int) -> BigNumber: + var float_val: float = base_xp_required * pow(xp_growth_multiplier, float(maxi(level - 1, 0))) + return BigNumber.from_float(float_val) + +func get_total_xp_for_level(level: int) -> BigNumber: + if level <= 0: + return BigNumber.new(0.0, 0) + var total: BigNumber = BigNumber.new(0.0, 0) + for l in range(1, level + 1): + total = total.add(get_xp_required_for_level_big(l)) + return total + +func get_level_for_xp(xp: BigNumber) -> int: + if xp.mantissa == 0.0: + return 0 + var level: int = 0 + while true: + var next_xp: BigNumber = get_total_xp_for_level(level + 1) + if xp.compare_to(next_xp) < 0: + break + level += 1 + return level + +func get_multiplier_for_level(level: int) -> float: + return base_multiplier + (multiplier_per_level * float(maxi(level, 0))) + +func get_xp_progress(xp: BigNumber) -> float: + if xp.mantissa == 0.0: + return 0.0 + var current_level: int = get_level_for_xp(xp) + if current_level < 0: + return 0.0 + + var xp_at_current_level: BigNumber = get_total_xp_for_level(current_level) + var xp_needed_for_next: BigNumber = get_xp_required_for_level_big(current_level + 1) + var xp_in_current_level: BigNumber = xp.subtract(xp_at_current_level) + + if xp_needed_for_next.mantissa == 0.0: + return 1.0 + + var ratio: float = (xp_in_current_level.mantissa / xp_needed_for_next.mantissa) * pow(10.0, float(xp_in_current_level.exponent - xp_needed_for_next.exponent)) + return clampf(ratio, 0.0, 1.0) diff --git a/core/research/research_data.gd.uid b/core/research/research_data.gd.uid new file mode 100644 index 0000000..664f5b6 --- /dev/null +++ b/core/research/research_data.gd.uid @@ -0,0 +1 @@ +uid://m7baywrfnpn0 diff --git a/core/research/research_panel.gd b/core/research/research_panel.gd new file mode 100644 index 0000000..81e14cd --- /dev/null +++ b/core/research/research_panel.gd @@ -0,0 +1,155 @@ +class_name ResearchPanel +extends PanelContainer + +const RESEARCH_ROW_SCENE: PackedScene = preload("res://core/research/research_row.tscn") + +@onready var _title_label: Label = $ScrollContainer/VBoxContainer/TitleLabel +@onready var _research_rows_container: VBoxContainer = $ScrollContainer/VBoxContainer/ResearchRows +@onready var _buy_worker_button: Button = $ScrollContainer/VBoxContainer/HBoxContainer/BuyWorkerButton + +var _game_state: LevelGameState +var _research_rows: Dictionary = {} +var _active_research_id: StringName = &"" + +func _ready() -> void: + _game_state = find_parent("LevelGameState") + if _game_state == null: + push_error("ResearchPanel: Could not find LevelGameState parent") + return + + _game_state.research_xp_changed.connect(_on_research_xp_changed) + _game_state.research_level_up.connect(_on_research_level_up) + _game_state.currency_changed.connect(_on_currency_changed) + _game_state.research_workers_changed.connect(_on_research_workers_changed) + + _build_research_rows() + _refresh_all() + _refresh_worker_button() + +func _build_research_rows() -> void: + for child in _research_rows_container.get_children(): + child.queue_free() + _research_rows.clear() + _active_research_id = &"" + + if _game_state.research_catalogue: + for research in _game_state.research_catalogue.get_all_research(): + var row: ResearchRow = RESEARCH_ROW_SCENE.instantiate() + _research_rows_container.add_child(row) + row.setup(research) + row.active_changed.connect(_on_research_active_changed.bind(research.id)) + _research_rows[research.id] = row + + _load_active_research_state() + +func _refresh_all() -> void: + for row in _research_rows.values(): + _refresh_row(row) + +func _refresh_row(row: ResearchRow) -> void: + var research_id: StringName = row.get_research_id() + var xp: BigNumber = _game_state.get_research_xp(research_id) + var level: int = _game_state.get_research_level(research_id) + var research: ResearchData = _game_state.research_catalogue.get_research_by_id(research_id) + + if research != null: + var progress: float = research.get_xp_progress(xp) + var multiplier: float = research.get_multiplier_for_level(level) + var xp_current: BigNumber = xp.subtract(research.get_total_xp_for_level(level)) + var xp_needed: BigNumber = research.get_xp_required_for_level_big(level + 1) + + row.update_display(level, progress, multiplier, xp_current, xp_needed) + +func _on_research_xp_changed(research_id: StringName, _new_xp: BigNumber) -> void: + if _research_rows.has(research_id): + _refresh_row(_research_rows[research_id]) + +func _on_research_level_up(research_id: StringName, _old_level: int, _new_level: int) -> void: + if _research_rows.has(research_id): + _refresh_row(_research_rows[research_id]) + +func _on_research_active_changed(is_active: bool, research_id: StringName) -> void: + if is_active: + if _active_research_id != &"" and _active_research_id != research_id: + var existing_row: ResearchRow = _research_rows.get(_active_research_id) + if existing_row != null: + existing_row.set_active(false, false) + _active_research_id = research_id + _save_active_research_state() + else: + if _active_research_id == research_id: + _active_research_id = &"" + _save_active_research_state() + +func _load_active_research_state() -> void: + if _game_state == null: + return + + var save_data: Dictionary = _game_state.get_external_save_data("research_active") + if save_data.has("active_research_id"): + var loaded_id: String = String(save_data.get("active_research_id", "")) + if not loaded_id.is_empty(): + _active_research_id = StringName(loaded_id) + var row: ResearchRow = _research_rows.get(_active_research_id) + if row != null: + row.set_active(true, false) + +func _save_active_research_state() -> void: + if _game_state == null: + return + + var save_data: Dictionary = {} + if _active_research_id != &"": + save_data["active_research_id"] = String(_active_research_id) + _game_state.set_external_save_data("research_active", save_data) + +func get_active_research_id() -> StringName: + return _active_research_id + +func is_research_active(research_id: StringName) -> bool: + return _active_research_id == research_id + +func activate_research(research_id: StringName) -> void: + if _research_rows.has(research_id): + var row: ResearchRow = _research_rows[research_id] + row.set_active(true) + +func _on_currency_changed(currency_id: StringName, _new_amount: BigNumber) -> void: + if currency_id == &"worker": + _refresh_worker_button() + +func _on_research_workers_changed(_new_count: int) -> void: + _refresh_worker_button() + +func _can_buy_worker() -> bool: + if _game_state == null: + return false + + var worker_currency: BigNumber = _game_state.get_currency_amount_by_id(&"worker") + + return worker_currency.mantissa >= 1.0 + +func _buy_worker() -> void: + if _game_state == null: + return + + _game_state.assign_worker_to_research() + +func _on_buy_worker_pressed() -> void: + _buy_worker() + _refresh_worker_button() + +func _refresh_worker_button() -> void: + if _buy_worker_button == null: + return + + if _game_state == null: + _buy_worker_button.disabled = true + return + + var can_buy: bool = _can_buy_worker() + _buy_worker_button.disabled = not can_buy + + var worker_currency: BigNumber = _game_state.get_currency_amount_by_id(&"worker") + var research_workers: int = _game_state.get_research_workers() + _buy_worker_button.text = "Assign Worker to Research (%d available, %d assigned)" % [int(worker_currency.mantissa), research_workers] diff --git a/core/research/research_panel.gd.uid b/core/research/research_panel.gd.uid new file mode 100644 index 0000000..400ee9b --- /dev/null +++ b/core/research/research_panel.gd.uid @@ -0,0 +1 @@ +uid://fdl7ftxw8w1e diff --git a/core/research/research_panel.tscn b/core/research/research_panel.tscn new file mode 100644 index 0000000..0ee16e8 --- /dev/null +++ b/core/research/research_panel.tscn @@ -0,0 +1,34 @@ +[gd_scene format=3 uid="uid://6d101h70mcx"] + +[ext_resource type="Script" uid="uid://fdl7ftxw8w1e" path="res://core/research/research_panel.gd" id="1_script"] +[ext_resource type="PackedScene" uid="uid://ckr805xqy6h4w" path="res://core/research/worker_summary_label.tscn" id="3_worker"] + +[node name="ResearchPanel" type="PanelContainer" unique_id=600021293] +custom_minimum_size = Vector2(400, 100) +offset_right = 400.0 +offset_bottom = 100.0 +script = ExtResource("1_script") + +[node name="ScrollContainer" type="ScrollContainer" parent="." unique_id=973132184] +layout_mode = 2 + +[node name="VBoxContainer" type="VBoxContainer" parent="ScrollContainer" unique_id=1679714014] +layout_mode = 2 + +[node name="TitleLabel" type="Label" parent="ScrollContainer/VBoxContainer" unique_id=990297493] +layout_mode = 2 +text = "Research" + +[node name="HBoxContainer" type="HBoxContainer" parent="ScrollContainer/VBoxContainer" unique_id=1419318708] +layout_mode = 2 + +[node name="WorkerSummaryLabel" parent="ScrollContainer/VBoxContainer/HBoxContainer" unique_id=13607753 instance=ExtResource("3_worker")] + +[node name="BuyWorkerButton" type="Button" parent="ScrollContainer/VBoxContainer/HBoxContainer" unique_id=1083899286] +layout_mode = 2 +text = "Invest Worker (0 available)" + +[node name="ResearchRows" type="VBoxContainer" parent="ScrollContainer/VBoxContainer" unique_id=2066050169] +layout_mode = 2 + +[connection signal="pressed" from="ScrollContainer/VBoxContainer/HBoxContainer/BuyWorkerButton" to="." method="_buy_worker"] diff --git a/core/research/research_row.gd b/core/research/research_row.gd new file mode 100644 index 0000000..3519e41 --- /dev/null +++ b/core/research/research_row.gd @@ -0,0 +1,98 @@ +class_name ResearchRow +extends HBoxContainer + +signal active_changed(is_active: bool) + +@onready var _name_label: Label = $VBoxContainer/NameLabel +@onready var _level_label: Label = $VBoxContainer/HBoxContainer/LevelLabel +@onready var _progress_bar: ProgressBar = $VBoxContainer/HBoxContainer/ProgressBar +@onready var _multiplier_label: Label = $VBoxContainer/HBoxContainer/MultiplierLabel +@onready var _buff_label: Label = $VBoxContainer/HBoxContainer/BuffLabel +@onready var _check_button: CheckButton = $CheckButton + +var _research: ResearchData +var _game_state: LevelGameState +var _is_active: bool = false + +func _ready() -> void: + _game_state = find_parent("LevelGameState") + if _game_state == null: + push_error("ResearchRow: Could not find LevelGameState parent") + return + + _game_state.research_xp_changed.connect(_on_research_xp_changed) + _game_state.research_level_up.connect(_on_research_level_up) + _check_button.toggled.connect(_on_check_button_toggled) + +func setup(research: ResearchData) -> void: + _research = research + if _name_label: + _name_label.text = research.name + +func get_research_id() -> StringName: + if _research == null: + return &"" + return _research.id + +func update_display(level: int, progress: float, multiplier: float, xp_current: BigNumber, xp_needed: BigNumber) -> void: + if _level_label: + _level_label.text = "Level %d" % level + + if _progress_bar: + _progress_bar.value = progress * 100 + + if _multiplier_label: + _multiplier_label.text = "+%d%%" % int((multiplier - 1.0) * 100) + + if _buff_label and _research != null: + if not _research.associated_buff_id.is_empty(): + var buff: GeneratorBuffData = _game_state.get_buff(_research.associated_buff_id) + if buff != null and _game_state.is_buff_active(buff.id): + var buff_level: int = _game_state.get_buff_level(buff.id) + var effect: float = buff.get_effect_multiplier(buff_level) - 1.0 + _buff_label.text = "%s: +%d%% XP" % [buff.text, int(effect * 100)] + else: + _buff_label.text = "" + else: + _buff_label.text = "" + +func _on_research_xp_changed(research_id: StringName, _new_xp: BigNumber) -> void: + if _research != null and research_id == _research.id: + _update_from_state() + +func _on_research_level_up(research_id: StringName, _old_level: int, _new_level: int) -> void: + if _research != null and research_id == _research.id: + _update_from_state() + +func _update_from_state() -> void: + if _research == null or _game_state == null: + return + + var xp: BigNumber = _game_state.get_research_xp(_research.id) + var level: int = _game_state.get_research_level(_research.id) + + var progress: float = _research.get_xp_progress(xp) + var multiplier: float = _research.get_multiplier_for_level(level) + var xp_current: BigNumber = xp.subtract(_research.get_total_xp_for_level(level)) + var xp_needed: BigNumber = _research.get_xp_required_for_level_big(level + 1) + + update_display(level, progress, multiplier, xp_current, xp_needed) + +func _on_check_button_toggled(toggled_on: bool) -> void: + if toggled_on != _is_active: + _is_active = toggled_on + active_changed.emit(_is_active) + +func set_active(is_active: bool, emit_signal: bool = true) -> void: + _is_active = is_active + _check_button.button_pressed = is_active + if emit_signal: + active_changed.emit(is_active) + +func is_research_active() -> bool: + return _is_active + +func get_research_id_public() -> StringName: + if _research == null: + return &"" + return _research.id diff --git a/core/research/research_row.gd.uid b/core/research/research_row.gd.uid new file mode 100644 index 0000000..108beb5 --- /dev/null +++ b/core/research/research_row.gd.uid @@ -0,0 +1 @@ +uid://gk3mr3k5yvp7 diff --git a/core/research/research_row.tscn b/core/research/research_row.tscn new file mode 100644 index 0000000..78da542 --- /dev/null +++ b/core/research/research_row.tscn @@ -0,0 +1,42 @@ +[gd_scene format=3 uid="uid://bpo8pl5tipav"] + +[ext_resource type="Script" uid="uid://gk3mr3k5yvp7" path="res://core/research/research_row.gd" id="1_script"] + +[node name="ResearchRow" type="HBoxContainer" unique_id=810714290] +mouse_filter = 2 +script = ExtResource("1_script") + +[node name="Icon" type="TextureRect" parent="." unique_id=1376308659] +layout_mode = 2 +size_flags_horizontal = 0 +size_flags_vertical = 0 + +[node name="VBoxContainer" type="VBoxContainer" parent="." unique_id=468422769] +layout_mode = 2 + +[node name="NameLabel" type="Label" parent="VBoxContainer" unique_id=1742937834] +layout_mode = 2 +text = "Research Name" + +[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer" unique_id=819270678] +layout_mode = 2 +alignment = 2 + +[node name="LevelLabel" type="Label" parent="VBoxContainer/HBoxContainer" unique_id=1108328348] +layout_mode = 2 +text = "Level 0" + +[node name="ProgressBar" type="ProgressBar" parent="VBoxContainer/HBoxContainer" unique_id=1855503166] +custom_minimum_size = Vector2(200, 0) +layout_mode = 2 + +[node name="MultiplierLabel" type="Label" parent="VBoxContainer/HBoxContainer" unique_id=135310321] +layout_mode = 2 +text = "+0%" + +[node name="BuffLabel" type="Label" parent="VBoxContainer/HBoxContainer" unique_id=2124734446] +layout_mode = 2 + +[node name="CheckButton" type="CheckButton" parent="." unique_id=3456789012] +layout_mode = 2 +text = "Active" diff --git a/core/research/worker_summary_label.gd b/core/research/worker_summary_label.gd new file mode 100644 index 0000000..26ce4f2 --- /dev/null +++ b/core/research/worker_summary_label.gd @@ -0,0 +1,27 @@ +class_name WorkerSummaryLabel +extends Label + +@onready var _game_state: LevelGameState = find_parent("LevelGameState") + +signal research_workers_changed(new_count: int) + +func _ready() -> void: + if _game_state == null: + push_error("WorkerSummaryLabel: Could not find LevelGameState parent") + return + + _game_state.currency_changed.connect(_on_currency_changed) + _game_state.research_workers_changed.connect(_on_research_workers_changed) + _update_worker_display() + +func _on_currency_changed(currency_id: StringName, _new_amount: BigNumber) -> void: + if currency_id == "worker": + _update_worker_display() + +func _on_research_workers_changed(new_count: int) -> void: + _update_worker_display() + +func _update_worker_display() -> void: + var worker_currency: int = int(_game_state.get_currency_amount_by_id("worker").mantissa) + var research_workers: int = _game_state.get_research_workers() + text = "Workers: %d available, %d assigned" % [worker_currency, research_workers] diff --git a/core/research/worker_summary_label.gd.uid b/core/research/worker_summary_label.gd.uid new file mode 100644 index 0000000..3b68f1d --- /dev/null +++ b/core/research/worker_summary_label.gd.uid @@ -0,0 +1 @@ +uid://brvoy607b3nft diff --git a/core/research/worker_summary_label.tscn b/core/research/worker_summary_label.tscn new file mode 100644 index 0000000..1a36862 --- /dev/null +++ b/core/research/worker_summary_label.tscn @@ -0,0 +1,8 @@ +[gd_scene format=3 uid="uid://ckr8z5xqy6h4w"] + +[ext_resource type="Script" path="res://core/research/worker_summary_label.gd" id="1_script"] + +[node name="WorkerSummaryLabel" type="Label"] +layout_mode = 2 +script = ExtResource("1_script") +text = "Workers: 0" diff --git a/currency_label.gd b/currency_label.gd index efe32fa..dd50cf0 100644 --- a/currency_label.gd +++ b/currency_label.gd @@ -1,7 +1,8 @@ class_name CurrencyTile extends HBoxContainer -@export var currency: Resource +@export var currency: Currency +@export var game_state: LevelGameState @onready var _icon: TextureRect = $CurrencyIcon @onready var _label: Label = $Label @@ -11,26 +12,36 @@ extends HBoxContainer var _currency_id: StringName = &"" func _ready() -> void: - if currency == null: - currency = CurrencyDatabase.get_currency_resource(GameState.GOLD_CURRENCY_ID) if currency == null: push_warning("CurrencyTile '%s' has no currency configured." % String(name)) return - _currency_id = GameState.get_currency_id(currency) - _label.text = "%s:" % GameState.get_currency_name(_currency_id) - _icon.texture = GameState.get_currency_icon(_currency_id) - _debug_button.text = "+100 %s" % GameState.get_currency_name(_currency_id) + if game_state == null: + push_error("CurrencyTile '%s' missing game_state reference" % String(name)) + return - GameState.currency_changed.connect(_on_currency_changed) - _on_currency_changed(_currency_id, GameState.get_currency_amount_by_id(_currency_id)) + game_state.ready.connect(_on_level_game_state_ready) + +func _on_level_game_state_ready() -> void: + _currency_id = game_state.get_currency_id(currency) + + var label_string = game_state.get_currency_name(_currency_id) + _label.text = "%s:" % label_string + _icon.texture = game_state.get_currency_icon(_currency_id) + _debug_button.text = "+100 %s" % label_string + + game_state.currency_changed.connect(_on_currency_changed) + _on_currency_changed(_currency_id, game_state.get_currency_amount_by_id(_currency_id)) func _on_currency_changed(changed_currency_id: StringName, amount: BigNumber) -> void: if changed_currency_id != _currency_id: return _currency_label.text = amount.to_string_suffix(2) + visible = currency.always_visible or not amount.is_equal_to(BigNumber.from_float(0.0)) func _on_debug_income_button_pressed() -> void: if currency == null: return - GameState.add_currency(currency, BigNumber.from_float(100)) + if game_state == null: + return + game_state.add_currency(currency, BigNumber.from_float(100)) diff --git a/currency_tile.tscn b/currency_tile.tscn index 6cc8b54..0006666 100644 --- a/currency_tile.tscn +++ b/currency_tile.tscn @@ -1,15 +1,17 @@ [gd_scene format=3 uid="uid://btkxru2gdjsgc"] [ext_resource type="Script" uid="uid://sp67wcb6s5nw" path="res://currency_label.gd" id="1_0hv07"] +[ext_resource type="Texture2D" uid="uid://d3nymghus6x3" path="res://sandbox/tiny_swords/UI Elements/UI Elements/Icons/Icon_03.png" id="2_otq0d"] [node name="CurrencyTile" type="HBoxContainer" unique_id=1440583137] theme_override_constants/separation = 12 script = ExtResource("1_0hv07") [node name="CurrencyIcon" type="TextureRect" parent="." unique_id=935299369] -visible = false custom_minimum_size = Vector2(20, 20) layout_mode = 2 +texture = ExtResource("2_otq0d") +expand_mode = 2 stretch_mode = 5 [node name="Label" type="Label" parent="." unique_id=89234840] diff --git a/docs/gyms/generator_gym.tscn b/docs/gyms/generator_gym.tscn deleted file mode 100644 index 5354038..0000000 --- a/docs/gyms/generator_gym.tscn +++ /dev/null @@ -1,78 +0,0 @@ -[gd_scene format=3 uid="uid://cfryecxmcg8hw"] - -[ext_resource type="PackedScene" uid="uid://jeoiinukrrsp" path="res://core/generator/currency_generator.tscn" id="1_6pne4"] -[ext_resource type="Resource" uid="uid://bnhqk8b31mm4e" path="res://idles/currencies/knowledge.tres" id="2_8qilt"] -[ext_resource type="Resource" uid="uid://l0pn6mlcer7t" path="res://idles/currencies/spirit.tres" id="2_jlqd0"] -[ext_resource type="Resource" uid="uid://04pmc034qupd" path="res://idles/generators/library.tres" id="3_4ly0e"] -[ext_resource type="Resource" uid="uid://cythfovqgqlyh" path="res://idles/currencies/wood.tres" id="4_2xpf5"] -[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="Resource" uid="uid://df5k58yu1g6rf" path="res://idles/generators/forestry.tres" id="5_u3cug"] -[ext_resource type="PackedScene" uid="uid://dirvi76rkoowf" path="res://goals_debug_ui.tscn" id="7_7i63j"] -[ext_resource type="PackedScene" path="res://core/prestige/prestige_panel.tscn" id="9_q5vce"] - -[node name="GeneratorGym" type="Node2D" unique_id=1219373683] - -[node name="MagicGenerator" parent="." unique_id=967969064 instance=ExtResource("1_6pne4")] -position = Vector2(262, 301) - -[node name="KnowledgeGenerator" parent="." unique_id=2139088546 instance=ExtResource("1_6pne4")] -visible = false -position = Vector2(262, 626) -currency = ExtResource("2_8qilt") -data = ExtResource("3_4ly0e") -press_buys_generator = false - -[node name="WoodGenerator" parent="." unique_id=29858558 instance=ExtResource("1_6pne4")] -visible = false -position = Vector2(1046, 626) -currency = ExtResource("4_2xpf5") -data = ExtResource("5_u3cug") -press_buys_generator = false - -[node name="UI" type="Control" parent="." unique_id=452530906] -layout_mode = 3 -anchors_preset = 0 -offset_right = 40.0 -offset_bottom = 40.0 - -[node name="MagicCurrencyTile" parent="UI" unique_id=1440583137 instance=ExtResource("4_6ri4a")] -layout_mode = 0 -offset_right = 160.0 -offset_bottom = 31.0 -currency = ExtResource("5_dl3gy") - -[node name="SpiritCurrencyTile" parent="UI" unique_id=797237056 instance=ExtResource("4_6ri4a")] -layout_mode = 0 -offset_top = 32.0 -offset_right = 160.0 -offset_bottom = 63.0 -currency = ExtResource("2_jlqd0") - -[node name="KnowledgeCurrencyTile" parent="UI" unique_id=1977342362 instance=ExtResource("4_6ri4a")] -layout_mode = 0 -offset_top = 63.0 -offset_right = 160.0 -offset_bottom = 94.0 -currency = ExtResource("2_8qilt") - -[node name="WoodCurrencyTile" parent="UI" unique_id=1210103101 instance=ExtResource("4_6ri4a")] -layout_mode = 0 -offset_top = 94.0 -offset_right = 160.0 -offset_bottom = 125.0 -currency = ExtResource("4_2xpf5") - -[node name="GoalsDebugUI" parent="UI" unique_id=4710697 instance=ExtResource("7_7i63j")] -layout_mode = 1 -offset_left = 1253.0 -offset_top = 817.0 -offset_right = 1913.0 -offset_bottom = 1076.0 - -[node name="PrestigePanel" parent="UI" unique_id=401213142 instance=ExtResource("9_q5vce")] -layout_mode = 0 -offset_left = 12.0 -offset_top = 822.0 -offset_right = 432.0 -offset_bottom = 1028.0 diff --git a/docs/gyms/tiny_sword/buffs/farm_auto_flux_buff.tres b/docs/gyms/tiny_sword/buffs/farm_auto_flux_buff.tres new file mode 100644 index 0000000..bffab84 --- /dev/null +++ b/docs/gyms/tiny_sword/buffs/farm_auto_flux_buff.tres @@ -0,0 +1,19 @@ +[gd_resource type="Resource" script_class="GeneratorBuffData" format=3 uid="uid://cg7os1rfknw05"] + +[ext_resource type="Resource" uid="uid://bxg2au0ijp242" path="res://docs/gyms/tiny_sword/currencies/food.tres" id="1_qy8mo"] +[ext_resource type="Resource" uid="uid://w4u1hddplb4e" path="res://docs/gyms/tiny_sword/currencies/gold.tres" id="2_pac24"] +[ext_resource type="Script" uid="uid://dnhocfsuvmeyb" path="res://core/generator/generator_buff_data.gd" id="3_t5xb7"] +[ext_resource type="Resource" uid="uid://bo463s6jt0ep7" path="res://docs/gyms/tiny_sword/goals/gold_total_13k_goal.tres" id="4_r8eth"] + +[resource] +script = ExtResource("3_t5xb7") +id = &"farm_auto_flux" +target_ids = Array[StringName]([&"farm"]) +text = "Farm Dynamo" +max_level = 10 +unlock_goal = ExtResource("4_r8eth") +effect_increment = 1.0 +cost_currency = ExtResource("1_qy8mo") +base_cost_mantissa = 100.0 +cost_multiplier = 1.7 +resource_target_currency = ExtResource("2_pac24") diff --git a/docs/gyms/tiny_sword/buffs/forestry_auto_flux_buff.tres b/docs/gyms/tiny_sword/buffs/forestry_auto_flux_buff.tres new file mode 100644 index 0000000..544d97a --- /dev/null +++ b/docs/gyms/tiny_sword/buffs/forestry_auto_flux_buff.tres @@ -0,0 +1,20 @@ +[gd_resource type="Resource" script_class="GeneratorBuffData" format=3 uid="uid://kamgujbqqhg3"] + +[ext_resource type="Resource" uid="uid://bxg2au0ijp242" path="res://docs/gyms/tiny_sword/currencies/food.tres" id="1_pdm0x"] +[ext_resource type="Resource" uid="uid://w4u1hddplb4e" path="res://docs/gyms/tiny_sword/currencies/gold.tres" id="2_rnlmu"] +[ext_resource type="Script" uid="uid://dnhocfsuvmeyb" path="res://core/generator/generator_buff_data.gd" id="3_y6252"] +[ext_resource type="Resource" uid="uid://bo463s6jt0ep7" path="res://docs/gyms/tiny_sword/goals/gold_total_13k_goal.tres" id="4_y6252"] + +[resource] +script = ExtResource("3_y6252") +id = &"forestry_auto_flux" +target_ids = Array[StringName]([&"forestry"]) +text = "Forest Dynamo +" +max_level = 10 +unlock_goal = ExtResource("4_y6252") +effect_increment = 1.0 +cost_currency = ExtResource("1_pdm0x") +base_cost_mantissa = 250.0 +cost_multiplier = 1.7 +resource_target_currency = ExtResource("2_rnlmu") diff --git a/docs/gyms/tiny_sword/buffs/gold_auto_flux_buff.tres b/docs/gyms/tiny_sword/buffs/gold_auto_flux_buff.tres new file mode 100644 index 0000000..7edec80 --- /dev/null +++ b/docs/gyms/tiny_sword/buffs/gold_auto_flux_buff.tres @@ -0,0 +1,19 @@ +[gd_resource type="Resource" script_class="GeneratorBuffData" format=3 uid="uid://c3op2mqy02sow"] + +[ext_resource type="Resource" uid="uid://bxg2au0ijp242" path="res://docs/gyms/tiny_sword/currencies/food.tres" id="1_j3saa"] +[ext_resource type="Resource" uid="uid://w4u1hddplb4e" path="res://docs/gyms/tiny_sword/currencies/gold.tres" id="2_hvfo8"] +[ext_resource type="Script" uid="uid://dnhocfsuvmeyb" path="res://core/generator/generator_buff_data.gd" id="3_bwcdh"] +[ext_resource type="Resource" uid="uid://ik7t0r3su633" path="res://docs/gyms/tiny_sword/goals/gold_total_30_goal.tres" id="4_f52il"] + +[resource] +script = ExtResource("3_bwcdh") +id = &"gold_auto_flux" +target_ids = Array[StringName]([&"goldmine"]) +text = "Gold Dynamo" +max_level = 10 +unlock_goal = ExtResource("4_f52il") +effect_increment = 1.0 +cost_currency = ExtResource("1_j3saa") +base_cost_mantissa = 25.0 +cost_multiplier = 1.7 +resource_target_currency = ExtResource("2_hvfo8") diff --git a/docs/gyms/tiny_sword/buffs/gold_click_buff.tres b/docs/gyms/tiny_sword/buffs/gold_click_buff.tres new file mode 100644 index 0000000..e896dd9 --- /dev/null +++ b/docs/gyms/tiny_sword/buffs/gold_click_buff.tres @@ -0,0 +1,20 @@ +[gd_resource type="Resource" script_class="GeneratorBuffData" format=3 uid="uid://dlq2ke6i2pfob"] + +[ext_resource type="Resource" uid="uid://w4u1hddplb4e" path="res://docs/gyms/tiny_sword/currencies/gold.tres" id="1_8h747"] +[ext_resource type="Script" uid="uid://dnhocfsuvmeyb" path="res://core/generator/generator_buff_data.gd" id="2_aejsu"] +[ext_resource type="Resource" uid="uid://ik7t0r3su633" path="res://docs/gyms/tiny_sword/goals/gold_total_30_goal.tres" id="3_aejsu"] + +[resource] +script = ExtResource("2_aejsu") +id = &"gold_click_focus" +target_ids = Array[StringName]([&"goldmine"]) +kind = 1 +text = "Gold Clicker!" +max_level = 4 +unlock_goal = ExtResource("3_aejsu") +effect_increment = 1.0 +cost_currency = ExtResource("1_8h747") +base_cost_mantissa = 2.5 +base_cost_exponent = 1 +cost_multiplier = 2.0 +resource_target_currency = ExtResource("1_8h747") diff --git a/docs/gyms/tiny_sword/buffs/spawn_worker_buff.tres b/docs/gyms/tiny_sword/buffs/spawn_worker_buff.tres new file mode 100644 index 0000000..c95e7cd --- /dev/null +++ b/docs/gyms/tiny_sword/buffs/spawn_worker_buff.tres @@ -0,0 +1,21 @@ +[gd_resource type="Resource" script_class="GeneratorBuffData" format=3 uid="uid://bjc6qmvr7pe12"] + +[ext_resource type="Resource" uid="uid://w4u1hddplb4e" path="res://docs/gyms/tiny_sword/currencies/gold.tres" id="1_we7j4"] +[ext_resource type="Resource" uid="uid://dfxk30o34qe2s" path="res://docs/gyms/tiny_sword/currencies/worker.tres" id="2_s45cj"] +[ext_resource type="Script" uid="uid://dnhocfsuvmeyb" path="res://core/generator/generator_buff_data.gd" id="3_tgtcn"] +[ext_resource type="Resource" uid="uid://ik7t0r3su633" path="res://docs/gyms/tiny_sword/goals/gold_total_30_goal.tres" id="4_kqmci"] + +[resource] +script = ExtResource("3_tgtcn") +id = &"summon_worker" +kind = 2 +text = "Summon Worker +" +unlock_goal = ExtResource("4_kqmci") +effect_increment = 1.0 +cost_currency = ExtResource("1_we7j4") +base_cost_mantissa = 200.0 +cost_multiplier = 1.15 +resource_target_currency = ExtResource("2_s45cj") +resource_purchase_base_mantissa = 1.0 +resource_purchase_increment_multiplier = 1.0 diff --git a/docs/gyms/tiny_sword/buffs/ts_buff_catalogue.tres b/docs/gyms/tiny_sword/buffs/ts_buff_catalogue.tres new file mode 100644 index 0000000..00ba8fb --- /dev/null +++ b/docs/gyms/tiny_sword/buffs/ts_buff_catalogue.tres @@ -0,0 +1,14 @@ +[gd_resource type="Resource" script_class="BuffCatalogue" format=3 uid="uid://bgmj3lep0wmtf"] + +[ext_resource type="Script" uid="uid://dnhocfsuvmeyb" path="res://core/generator/generator_buff_data.gd" id="1_up80l"] +[ext_resource type="Resource" uid="uid://dlq2ke6i2pfob" path="res://docs/gyms/tiny_sword/buffs/gold_click_buff.tres" id="2_dtvfl"] +[ext_resource type="Script" uid="uid://ctc5yjlnvi0ok" path="res://core/buff_catalogue.gd" id="2_iuxc1"] +[ext_resource type="Resource" uid="uid://c3op2mqy02sow" path="res://docs/gyms/tiny_sword/buffs/gold_auto_flux_buff.tres" id="3_0bb41"] +[ext_resource type="Resource" uid="uid://bjc6qmvr7pe12" path="res://docs/gyms/tiny_sword/buffs/spawn_worker_buff.tres" id="4_hnq3m"] +[ext_resource type="Resource" uid="uid://cg7os1rfknw05" path="res://docs/gyms/tiny_sword/buffs/farm_auto_flux_buff.tres" id="5_mii30"] +[ext_resource type="Resource" uid="uid://kamgujbqqhg3" path="res://docs/gyms/tiny_sword/buffs/forestry_auto_flux_buff.tres" id="6_k5yi4"] + +[resource] +script = ExtResource("2_iuxc1") +buffs = Array[ExtResource("1_up80l")]([ExtResource("2_dtvfl"), ExtResource("3_0bb41"), ExtResource("4_hnq3m"), ExtResource("5_mii30"), ExtResource("6_k5yi4")]) +metadata/_custom_type_script = "uid://ctc5yjlnvi0ok" diff --git a/docs/gyms/tiny_sword/buildings/alchemy_tower/README.md b/docs/gyms/tiny_sword/buildings/alchemy_tower/README.md new file mode 100644 index 0000000..d32f141 --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/alchemy_tower/README.md @@ -0,0 +1,55 @@ +# Alchemy Tower + +The Alchemy Tower is a building that passively produces **magic gold** and allows **crafting** other currencies through alchemical recipes. + +## Passive Production + +The tower runs a continuous production cycle. Once a cycle completes, magic gold is added to your balance and the next cycle begins with a slightly longer duration. + +| Data field | Default | Description | +|---|---|---| +| `base_production_time_seconds` | 5.0 | Duration of the first cycle (seconds) | +| `production_time_growth_multiplier` | 1.2 | Each completed cycle multiplies the next cycle's duration by this amount | +| `magic_gold_per_cycle` | 1.0 | Base magic gold produced per cycle | + +Production is only active when at least one worker is assigned and the tower is available in the game state. + +## Workers + +Workers are assigned by spending 1 **worker** currency per worker. Each assigned worker provides a **+10% speed bonus** to production (configurable via `base_worker_speed_bonus`). + +- Max workers: `max_workers` (default 10) +- The speed multiplier formula: `1.0 + (worker_count * base_worker_speed_bonus)` +- This multiplier affects both the rate time advances and the final cycle output + +The UI panel includes a worker counter and an "Assign Worker" button. The button is disabled when you have no workers available or have reached the max. + +## Crafting + +The crafting panel shows recipes from an `AlchemyCraftCatalogue` resource. Each recipe (`AlchemyCraftRecipe`) defines: + +- `id` — unique recipe identifier +- `output_currency` / `output_amount` — what you get +- `cost_entries` — array of `CurrencyCostEntry` resources, each specifying a currency and an amount + +To craft, click the recipe's button. The tower deducts all costs from your balance via `game_state.spend_currency()`. If any cost can't be covered, the craft fails silently and nothing is deducted. On success, the output currency is added to your balance. + +## Signals + +| Signal | Emitted when | +|---|---| +| `production_progress_updated(progress: float)` | Every frame during production; 0.0–1.0 | +| `production_completed(amount: BigNumber)` | A production cycle finishes | +| `magic_gold_balance_updated(amount: BigNumber)` | Magic gold balance changes | + +## Key Files + +| File | Purpose | +|---|---| +| `alchemy_tower.gd` | Main tower logic: production loop, worker management, crafting | +| `alchemy_tower_data.gd` | Configuration resource (`AlchemyTowerData`) | +| `alchemy_craftable_panel.gd` | UI panel (`AlchemyCurrenciesPanel`) for progress bar, balance, workers, and recipe list | +| `alchemy_craftable_panel_tile.gd` | Individual recipe tile in the crafting list | +| `recipes/alchemy_craft_catalogue.gd` | Catalogue resource holding all available recipes | +| `recipes/alchemy_craft_recipe.gd` | Single recipe resource | +| `recipes/currency_cost_entry.gd` | Cost entry for a single currency in a recipe | diff --git a/docs/gyms/tiny_sword/buildings/alchemy_tower/Tower.png b/docs/gyms/tiny_sword/buildings/alchemy_tower/Tower.png new file mode 100644 index 0000000..cf740f0 Binary files /dev/null and b/docs/gyms/tiny_sword/buildings/alchemy_tower/Tower.png differ diff --git a/docs/gyms/tiny_sword/buildings/alchemy_tower/Tower.png.import b/docs/gyms/tiny_sword/buildings/alchemy_tower/Tower.png.import new file mode 100644 index 0000000..5cc370e --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/alchemy_tower/Tower.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cieg8i3c8hca6" +path="res://.godot/imported/Tower.png-89ae300c234cff5100d7ac69a9b9290c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/gyms/tiny_sword/buildings/alchemy_tower/Tower.png" +dest_files=["res://.godot/imported/Tower.png-89ae300c234cff5100d7ac69a9b9290c.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_craftable_panel.gd b/docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_craftable_panel.gd new file mode 100644 index 0000000..6586c3f --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_craftable_panel.gd @@ -0,0 +1,161 @@ +class_name AlchemyCurrenciesPanel +extends PanelContainer + +signal craft_requested(recipe: Variant) +signal alchemy_workers_changed(count: int) + +# Currency used to pay crafts (magic gold) +@export var currency: Currency +# The list of crafts available to alchemy tower +@export var craft_recipes: AlchemyCraftCatalogue + +@onready var _tile_scene: PackedScene = preload("res://docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_craftable_panel_tile.tscn") +@onready var _craft_list: VBoxContainer = $VBoxContainer/CraftList +@onready var _currency_name_label: Label = $VBoxContainer/HBoxCurrency/NameLabel +@onready var _progress_bar: ProgressBar = $VBoxContainer/HBoxCurrency/ProgressBar +@onready var _balance_label: Label = $VBoxContainer/HBoxCurrency/AmountLabel +@onready var _worker_label: Label = $VBoxContainer/HBoxWorker/WorkerLabel +@onready var _assign_button: Button = $VBoxContainer/HBoxWorker/AssignButton + +var _alchemy_tower: AlchemyTower +var _game_state: LevelGameState +var _alchemy_worker_count: int = 0 + +func _ready() -> void: + assert(craft_recipes != null, "Craft recipes cannot be null") + + _game_state = find_parent("LevelGameState") + if _game_state == null: + push_error("AlchemyCurrenciesPanel: Could not find LevelGameState") + return + + # Find AlchemyTower in parent + _alchemy_tower = _find_alchemy_tower() + + # Set up UI labels + _currency_name_label.text = currency.display_name if currency else "Magic Gold" + + # Connect to tower signals + if _alchemy_tower: + _alchemy_tower.production_progress_updated.connect(_on_production_progress) + _alchemy_tower.magic_gold_balance_updated.connect(_on_magic_gold_changed) + _alchemy_tower.production_completed.connect(_on_production_completed) + + # Connect worker button + if _assign_button: + _assign_button.pressed.connect(_on_assign_button_pressed) + + # Initialize displays + _update_magic_gold_display() + _update_production_progress(0.0) + _update_worker_display() + + # Connect to worker currency changes + _game_state.currency_changed.connect(_on_currency_changed) + + # Clear and populate craft tiles + for child in _craft_list.get_children(): + child.queue_free() + + for recipe in craft_recipes.recipes: + var instance: AlchemyCraftableTile = _tile_scene.instantiate() as AlchemyCraftableTile + instance.setup(recipe) + instance.craft_button_pressed.connect(_on_craft_button_pressed) + _craft_list.add_child(instance) + +func _find_alchemy_tower() -> AlchemyTower: + var parent: Node = get_parent() + while parent != null: + if parent is AlchemyTower: + return parent as AlchemyTower + parent = parent.get_parent() + return null + +func _update_magic_gold_display() -> void: + if _alchemy_tower == null or _game_state == null: + return + + var magic_gold_currency: Currency = _get_magic_gold_currency() + if magic_gold_currency == null: + _balance_label.text = "0" + return + + var balance: BigNumber = _game_state.get_currency_amount(magic_gold_currency) + _balance_label.text = balance.to_string_suffix(2) + +func _update_production_progress(progress: float) -> void: + if _progress_bar == null: + return + _progress_bar.value = progress * 100.0 + +func _update_worker_display() -> void: + if _worker_label == null: + return + + _worker_label.text = "Alchemy Workers: %d" % _alchemy_worker_count + + if _assign_button != null: + var worker_currency: Currency = _get_worker_currency() + var can_assign: bool = false + + if worker_currency != null and _alchemy_worker_count < 10: + var worker_balance: BigNumber = _game_state.get_currency_amount(worker_currency) + can_assign = worker_balance.mantissa >= 1.0 + + _assign_button.disabled = not can_assign + _assign_button.text = "Assign Worker" if can_assign else "No Workers Available" + +func _get_magic_gold_currency() -> Currency: + if _game_state == null or _game_state.currency_catalogue == null: + return null + + for currency in _game_state.currency_catalogue.currencies: + if currency == null: + continue + if currency.id == &"magic_gold": + return currency + + return null + +func _get_worker_currency() -> Currency: + if _game_state == null or _game_state.currency_catalogue == null: + return null + + for currency in _game_state.currency_catalogue.currencies: + if currency == null: + continue + if currency.id == &"worker": + return currency + + return null + +func _on_production_progress(progress: float) -> void: + _update_production_progress(progress) + +func _on_magic_gold_changed(amount: BigNumber) -> void: + _update_magic_gold_display() + _update_worker_display() + +func _on_production_completed(_amount: BigNumber) -> void: + _update_magic_gold_display() + +func _on_currency_changed(currency_id: StringName, _amount: BigNumber) -> void: + if currency_id == &"worker": + _update_worker_display() + +func _on_assign_button_pressed() -> void: + if _alchemy_tower == null or _game_state == null: + return + + var worker_currency: Currency = _get_worker_currency() + if worker_currency == null: + return + + # Spend a worker + if _game_state.spend_currency(worker_currency, BigNumber.from_float(1.0)): + _alchemy_worker_count += 1 + _update_worker_display() + alchemy_workers_changed.emit(_alchemy_worker_count) + +func _on_craft_button_pressed(recipe: Variant) -> void: + craft_requested.emit(recipe) diff --git a/docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_craftable_panel.gd.uid b/docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_craftable_panel.gd.uid new file mode 100644 index 0000000..27e1f16 --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_craftable_panel.gd.uid @@ -0,0 +1 @@ +uid://do1i3hrd0ueb6 diff --git a/docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_craftable_panel.tscn b/docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_craftable_panel.tscn new file mode 100644 index 0000000..1cb2988 --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_craftable_panel.tscn @@ -0,0 +1,40 @@ +[gd_scene format=3 uid="uid://cbp6vpth8x4rw"] + +[ext_resource type="Script" uid="uid://do1i3hrd0ueb6" path="res://docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_craftable_panel.gd" id="1_rg8ty"] + +[node name="AlchemyCraftablePanel" type="PanelContainer" unique_id=1000001] +custom_minimum_size = Vector2(400, 300) +script = ExtResource("1_rg8ty") + +[node name="VBoxContainer" type="VBoxContainer" parent="." unique_id=1115194594] +layout_mode = 2 + +[node name="HBoxWorker" type="HBoxContainer" parent="VBoxContainer" unique_id=1415354976] +layout_mode = 2 + +[node name="WorkerLabel" type="Label" parent="VBoxContainer/HBoxWorker" unique_id=4000008] +layout_mode = 2 +text = "Alchemy Workers: 0" + +[node name="AssignButton" type="Button" parent="VBoxContainer/HBoxWorker" unique_id=4000009] +layout_mode = 2 +text = "Assign Worker" + +[node name="HBoxCurrency" type="HBoxContainer" parent="VBoxContainer" unique_id=1815331819] +layout_mode = 2 + +[node name="NameLabel" type="Label" parent="VBoxContainer/HBoxCurrency" unique_id=4000005] +layout_mode = 2 +text = "Magic Gold" + +[node name="ProgressBar" type="ProgressBar" parent="VBoxContainer/HBoxCurrency" unique_id=4000006] +custom_minimum_size = Vector2(200, 0) +layout_mode = 2 + +[node name="AmountLabel" type="Label" parent="VBoxContainer/HBoxCurrency" unique_id=4000007] +layout_mode = 2 +text = "0" + +[node name="CraftList" type="VBoxContainer" parent="VBoxContainer" unique_id=3000005] +layout_mode = 2 +size_flags_vertical = 8 diff --git a/docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_craftable_panel_tile.gd b/docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_craftable_panel_tile.gd new file mode 100644 index 0000000..a9a4f14 --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_craftable_panel_tile.gd @@ -0,0 +1,81 @@ +class_name AlchemyCraftableTile +extends HBoxContainer + +signal craft_button_pressed(recipe: Variant) + +@onready var _name_label: Label = $NameLabel +@onready var _balance_label: Label = $BalanceLabel +@onready var _cost_label: Label = $CostLabel +@onready var _button: Button = $Button + +var _recipe: Variant +var _game_state: LevelGameState +var _initialized: bool = false + +func _ready() -> void: + _game_state = find_parent("LevelGameState") + _game_state.ready.connect(_on_game_state_ready) + + if not _button.pressed.is_connected(_on_button_pressed): + _button.pressed.connect(_on_button_pressed) + + _initialized = true + +func setup(recipe: AlchemyCraftRecipe) -> void: + _recipe = recipe + +func _on_game_state_ready() -> void: + # Validate labels before use + if _name_label == null: + push_error("AlchemyCraftableTile: _name_label is null in setup") + return + + # Set up display + _name_label.text = str(_recipe.output_currency.display_name) if _recipe.output_currency else "Unknown" + _update_cost_display() + + # Connect to currency changes for balance updates + if _game_state != null: + _game_state.currency_changed.connect(_on_currency_changed) + + # Initial balance update + _update_balance() + +func _update_cost_display() -> void: + if _cost_label == null or _recipe == null: + return + + if _recipe.cost_entries.is_empty(): + _cost_label.text = "No cost" + return + + var cost_text: String = "" + for entry in _recipe.cost_entries: + if entry == null: + continue + if not cost_text.is_empty(): + cost_text += " + " + cost_text += "%d %s" % [entry.amount, entry.currency.display_name] + + _cost_label.text = cost_text + +func _update_balance() -> void: + if _balance_label == null or _recipe == null or _game_state == null: + return + + var balance: BigNumber = _game_state.get_currency_amount(_recipe.output_currency) + _balance_label.text = balance.to_string_suffix(0) + +func _on_button_pressed() -> void: + if _recipe == null: + push_warning("AlchemyCraftableTile: No recipe configured") + return + + craft_button_pressed.emit(_recipe) + +func _on_currency_changed(currency_id: StringName, _amount: BigNumber) -> void: + # Update balance if this currency changed + if _recipe != null: + var output_currency_id: StringName = _recipe.output_currency.id if _recipe.output_currency else &"" + if currency_id == output_currency_id: + _update_balance() diff --git a/docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_craftable_panel_tile.gd.uid b/docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_craftable_panel_tile.gd.uid new file mode 100644 index 0000000..2ca7984 --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_craftable_panel_tile.gd.uid @@ -0,0 +1 @@ +uid://c63g772y4kxwm diff --git a/docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_craftable_panel_tile.tscn b/docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_craftable_panel_tile.tscn new file mode 100644 index 0000000..78c63f8 --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_craftable_panel_tile.tscn @@ -0,0 +1,31 @@ +[gd_scene format=3 uid="uid://cxusq0tunvjlb"] + +[ext_resource type="Script" uid="uid://c63g772y4kxwm" path="res://docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_craftable_panel_tile.gd" id="1_hw1b0"] + +[node name="AlchemyCraftableTile" type="HBoxContainer" unique_id=1715275035] +offset_left = 10.0 +offset_top = 10.0 +offset_right = 280.0 +offset_bottom = 41.0 +script = ExtResource("1_hw1b0") + +[node name="NameLabel" type="Label" parent="." unique_id=2031343677] +layout_mode = 2 +size_flags_horizontal = 8 +text = "Currency" + +[node name="BalanceLabel" type="Label" parent="." unique_id=1367089480] +layout_mode = 2 +size_flags_horizontal = 8 +text = "0" + +[node name="CostLabel" type="Label" parent="." unique_id=1649255694] +layout_mode = 2 +size_flags_horizontal = 8 +text = "10 Magic Gold" + +[node name="Button" type="Button" parent="." unique_id=1649255693] +layout_mode = 2 +text = "Craft" + +[connection signal="pressed" from="Button" to="." method="_on_button_pressed"] diff --git a/docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_currencies_panel_old.gd.uid b/docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_currencies_panel_old.gd.uid new file mode 100644 index 0000000..3aedd89 --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_currencies_panel_old.gd.uid @@ -0,0 +1 @@ +uid://bsfgq4apkidak diff --git a/docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_tower.gd b/docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_tower.gd new file mode 100644 index 0000000..fddca0d --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_tower.gd @@ -0,0 +1,202 @@ +class_name AlchemyTower +extends Node2D + +## Emitted when production progress changes (0.0 to 1.0) +signal production_progress_updated(progress: float) +## Emitted when magic gold balance changes +signal magic_gold_balance_updated(amount: BigNumber) +## Emitted when a production cycle completes +signal production_completed(amount: BigNumber) + +## Configuration data for this tower +@export var data: AlchemyTowerData +## Available craft recipes +@export var craft_recipes: AlchemyCraftCatalogue + +## Reference to game state +@onready var game_state: LevelGameState = find_parent("LevelGameState") +@onready var _alchemy_currencies_panel: AlchemyCurrenciesPanel = $AlchemyCurrenciesPanel + +## Current production state +var production_time_elapsed: float = 0.0 +var current_production_time: float +var cycle_count: int = 0 + +## Number of workers assigned to this tower +var _worker_count: int = 0 + +## UI panel reference +@onready var alchemy_panel: AlchemyCurrenciesPanel = $AlchemyCurrenciesPanel + +func _ready() -> void: + assert(data != null, "AlchemyTowerData is required") + assert(craft_recipes != null, "Craft recipes catalogue is required") + assert(game_state != null, "LevelGameState is required") + + # Initialize production time + current_production_time = data.base_production_time_seconds + + # Register this tower as an available generator + game_state.set_generator_available(get_generator_id(), true) + + # Connect to magic gold balance changes + game_state.currency_changed.connect(_on_currency_changed) + + # Connect panel signals if available + if alchemy_panel: + alchemy_panel.craft_requested.connect(_on_craft_requested) + alchemy_panel.alchemy_workers_changed.connect(_on_alchemy_workers_changed) + + # Initial progress update + production_progress_updated.emit(0.0) + +func _process(delta: float) -> void: + if not _is_production_active(): + return + + var speed_multiplier: float = _get_worker_speed_multiplier() + + # Add elapsed time scaled by worker count + production_time_elapsed += delta * speed_multiplier + + # Update progress bar every frame + production_progress_updated.emit(_get_production_progress()) + + # Check if production is complete + if production_time_elapsed >= current_production_time: + _try_complete_production() + +func _is_production_active() -> bool: + if not game_state.is_generator_available(get_generator_id()): + return false + return _get_worker_count() > 0 + +func _try_complete_production() -> void: + # Calculate how many cycles completed + var completed_cycles: int = floori(production_time_elapsed / current_production_time) + + if completed_cycles <= 0: + return + + # Reset elapsed time + production_time_elapsed -= current_production_time * float(completed_cycles) + + # Calculate output + var output_per_cycle: BigNumber = BigNumber.from_float(data.magic_gold_per_cycle) + var total_output: BigNumber = output_per_cycle.multiply(BigNumber.from_float(float(completed_cycles))) + + # Apply worker speed bonus (affects output, not time) + var worker_multiplier: float = _get_worker_speed_multiplier() + if worker_multiplier > 1.0: + total_output = total_output.multiply(BigNumber.from_float(worker_multiplier)) + + # Add magic gold + _add_magic_gold(total_output) + + # Increment cycle count + cycle_count += completed_cycles + + # Increase production time (mild exponential growth) + current_production_time *= pow(data.production_time_growth_multiplier, float(completed_cycles)) + + # Emit signals + production_completed.emit(total_output) + production_progress_updated.emit(_get_production_progress()) + +func _add_magic_gold(amount: BigNumber) -> void: + if data == null or game_state == null: + return + + var magic_gold_currency: Currency = _get_magic_gold_currency() + if magic_gold_currency == null: + push_error("AlchemyTower: Could not find magic gold currency") + return + + game_state.add_currency(magic_gold_currency, amount) + +func _get_magic_gold_currency() -> Currency: + # Find magic gold in the currency catalogue + if game_state == null or game_state.currency_catalogue == null: + return null + + for currency in game_state.currency_catalogue.currencies: + if currency == null: + continue + if currency.id == &"magic_gold": + return currency + + return null + +func _get_worker_count() -> int: + return mini(_worker_count, data.max_workers) + +func _get_worker_speed_multiplier() -> float: + if game_state == null: + return 1.0 + + var worker_count: int = _get_worker_count() + var multiplier: float = 1.0 + (float(worker_count) * data.base_worker_speed_bonus) + + return multiplier + +func _get_worker_currency() -> Currency: + if game_state == null or game_state.currency_catalogue == null: + return null + + for currency in game_state.currency_catalogue.currencies: + if currency == null: + continue + if currency.id == &"worker": + return currency + + return null + +func _get_production_progress() -> float: + if current_production_time <= 0.0: + return 1.0 + + var progress: float = production_time_elapsed / current_production_time + return clampf(progress, 0.0, 1.0) + +func get_generator_id() -> StringName: + return data.id + +func craft_item(recipe: Variant) -> bool: + if recipe == null: + return false + + # Check and pay all costs + for entry in recipe.cost_entries: + if entry == null or entry.currency == null: + continue + if not game_state.spend_currency(entry.currency, BigNumber.from_float(float(entry.amount))): + return false + + # Success - add output + game_state.add_currency(recipe.output_currency, BigNumber.from_float(float(recipe.output_amount))) + + return true + +func _on_currency_changed(currency_id: StringName, _amount: BigNumber) -> void: + if currency_id == &"magic_gold": + magic_gold_balance_updated.emit(_amount) + +func _on_alchemy_workers_changed(count: int) -> void: + _worker_count = count + production_progress_updated.emit(_get_production_progress()) + +func _on_craft_requested(recipe: Variant) -> void: + if recipe == null: + return + + var success: bool = craft_item(recipe) + if not success: + push_warning("AlchemyTower: Failed to craft %s - insufficient funds" % recipe.output_currency.display_name) + + +func _on_area_2d_mouse_entered() -> void: + _alchemy_currencies_panel.visible = true + + +func _on_area_2d_mouse_exited() -> void: + _alchemy_currencies_panel.visible = false diff --git a/docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_tower.gd.uid b/docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_tower.gd.uid new file mode 100644 index 0000000..ae7c32e --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_tower.gd.uid @@ -0,0 +1 @@ +uid://bddaaj76msmvj diff --git a/docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_tower.tscn b/docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_tower.tscn new file mode 100644 index 0000000..7f00c41 --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_tower.tscn @@ -0,0 +1,37 @@ +[gd_scene format=3 uid="uid://bp5ng4vu4ot4a"] + +[ext_resource type="Texture2D" uid="uid://cieg8i3c8hca6" path="res://docs/gyms/tiny_sword/buildings/alchemy_tower/Tower.png" id="1_vbhae"] +[ext_resource type="PackedScene" uid="uid://cbp6vpth8x4rw" path="res://docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_craftable_panel.tscn" id="2_8pntr"] +[ext_resource type="Script" uid="uid://bddaaj76msmvj" path="res://docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_tower.gd" id="3_tower"] +[ext_resource type="Resource" uid="uid://cedxqlirp3imi" path="res://docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_tower_data.tres" id="4_data"] +[ext_resource type="Resource" uid="uid://diboykfbbxpfs" path="res://docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/alchemy_craft_catalogue.tres" id="5_recipes"] +[ext_resource type="Resource" uid="uid://dpbndqxvsffa0" path="res://docs/gyms/tiny_sword/currencies/magic_gold.tres" id="7_b8p5n"] + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_8pntr"] +size = Vector2(109, 182) + +[node name="AlchemyTower" type="Node2D" unique_id=51852160] +script = ExtResource("3_tower") +data = ExtResource("4_data") +craft_recipes = ExtResource("5_recipes") + +[node name="Sprite2D" type="Sprite2D" parent="." unique_id=1160485854] +texture = ExtResource("1_vbhae") + +[node name="Area2D" type="Area2D" parent="." unique_id=911128362] + +[node name="CollisionShape2D" type="CollisionShape2D" parent="Area2D" unique_id=1059910507] +position = Vector2(0.5, 11) +shape = SubResource("RectangleShape2D_8pntr") + +[node name="AlchemyCurrenciesPanel" parent="." unique_id=731368154 instance=ExtResource("2_8pntr")] +visible = false +offset_left = 65.0 +offset_top = -75.0 +offset_right = 65.0 +offset_bottom = -75.0 +currency = ExtResource("7_b8p5n") +craft_recipes = ExtResource("5_recipes") + +[connection signal="mouse_entered" from="Area2D" to="." method="_on_area_2d_mouse_entered"] +[connection signal="mouse_exited" from="Area2D" to="." method="_on_area_2d_mouse_exited"] diff --git a/docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_tower_data.gd b/docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_tower_data.gd new file mode 100644 index 0000000..67172b1 --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_tower_data.gd @@ -0,0 +1,23 @@ +class_name AlchemyTowerData +extends Resource + +@export_group("Identification") +@export var id: StringName = &"alchemy_tower" +@export var name: String = "Alchemy Tower" + +@export_group("Production") +## Base time to produce one magic gold (first production) +@export var base_production_time_seconds: float = 5.0 + +## Multiplier applied to production time each cycle (1.2 = 20% increase) +@export var production_time_growth_multiplier: float = 1.2 + +## Amount of magic gold produced per cycle +@export var magic_gold_per_cycle: float = 1.0 + +@export_group("Workers") +## Speed bonus per alchemy worker (0.1 = +10% speed per worker) +@export var base_worker_speed_bonus: float = 0.1 + +## Maximum workers that can be assigned +@export var max_workers: int = 10 diff --git a/docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_tower_data.gd.uid b/docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_tower_data.gd.uid new file mode 100644 index 0000000..bc38cec --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_tower_data.gd.uid @@ -0,0 +1 @@ +uid://dxgpic67x08c6 diff --git a/docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_tower_data.tres b/docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_tower_data.tres new file mode 100644 index 0000000..deb69f2 --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_tower_data.tres @@ -0,0 +1,6 @@ +[gd_resource type="Resource" script_class="AlchemyTowerData" format=3 uid="uid://cedxqlirp3imi"] + +[ext_resource type="Script" uid="uid://dxgpic67x08c6" path="res://docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_tower_data.gd" id="1_data"] + +[resource] +script = ExtResource("1_data") diff --git a/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/alchemy_craft_catalogue.gd b/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/alchemy_craft_catalogue.gd new file mode 100644 index 0000000..d520a6c --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/alchemy_craft_catalogue.gd @@ -0,0 +1,18 @@ +class_name AlchemyCraftCatalogue +extends Resource + +## Catalogue of alchemy craft recipes +@export var recipes: Array[AlchemyCraftRecipe] = [] + +func get_recipe_by_id(id: StringName) -> Variant: + for recipe in recipes: + if recipe != null and recipe.id == id: + return recipe + return null + +func get_all_ids() -> Array[StringName]: + var ids: Array[StringName] = [] + for recipe in recipes: + if recipe != null and recipe.id != &"": + ids.append(recipe.id) + return ids diff --git a/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/alchemy_craft_catalogue.gd.uid b/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/alchemy_craft_catalogue.gd.uid new file mode 100644 index 0000000..c1f39e4 --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/alchemy_craft_catalogue.gd.uid @@ -0,0 +1 @@ +uid://biljlhsxr3rsc diff --git a/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/alchemy_craft_catalogue.tres b/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/alchemy_craft_catalogue.tres new file mode 100644 index 0000000..e3c2cca --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/alchemy_craft_catalogue.tres @@ -0,0 +1,10 @@ +[gd_resource type="Resource" script_class="AlchemyCraftCatalogue" format=3 uid="uid://diboykfbbxpfs"] + +[ext_resource type="Script" uid="uid://biljlhsxr3rsc" path="res://docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/alchemy_craft_catalogue.gd" id="1_cat"] +[ext_resource type="Resource" uid="uid://by8qmuhvo38nn" path="res://docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/mana_stone_recipe.tres" id="2_mana_recipe"] +[ext_resource type="Resource" uid="uid://dliibkgb2mom0" path="res://docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/cognite_recipe.tres" id="3_cognite_recipe"] +[ext_resource type="Resource" uid="uid://nfpkv6a2u0wf" path="res://docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/philosoper_stone_recipe.tres" id="3_pbygp"] + +[resource] +script = ExtResource("1_cat") +recipes = [ExtResource("2_mana_recipe"), ExtResource("3_cognite_recipe"), ExtResource("3_pbygp")] diff --git a/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/alchemy_craft_recipe.gd b/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/alchemy_craft_recipe.gd new file mode 100644 index 0000000..e2109f3 --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/alchemy_craft_recipe.gd @@ -0,0 +1,10 @@ +class_name AlchemyCraftRecipe +extends Resource + +## Recipe for crafting currencies at the Alchemy Tower +@export var id: StringName = &"" +@export var output_currency: Currency +@export var output_amount: int = 1 + +## Multi-currency cost to perform this craft +@export var cost_entries: Array[CurrencyCostEntry] = [] diff --git a/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/alchemy_craft_recipe.gd.uid b/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/alchemy_craft_recipe.gd.uid new file mode 100644 index 0000000..6d03f6d --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/alchemy_craft_recipe.gd.uid @@ -0,0 +1 @@ +uid://ddlnlpb0p750q diff --git a/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/cognite_cost_entry.tres b/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/cognite_cost_entry.tres new file mode 100644 index 0000000..a4506cb --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/cognite_cost_entry.tres @@ -0,0 +1,10 @@ +[gd_resource type="Resource" script_class="CurrencyCostEntry" format=3] + +[ext_resource type="Script" uid="uid://ba8e403mb7hp0" path="res://docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/currency_cost_entry.gd" id="1_cost"] +[ext_resource type="Resource" uid="uid://dpbndqxvsffa0" path="res://docs/gyms/tiny_sword/currencies/magic_gold.tres" id="2_magic_gold"] + +[resource] +script = ExtResource("1_cost") +currency = ExtResource("2_magic_gold") +amount = 25 +metadata/_custom_type_script = "res://docs/gyms/tiny_sword/buildings/alchemy_tower/currency_cost_entry.gd" diff --git a/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/cognite_recipe.tres b/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/cognite_recipe.tres new file mode 100644 index 0000000..82083d1 --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/cognite_recipe.tres @@ -0,0 +1,11 @@ +[gd_resource type="Resource" script_class="AlchemyCraftRecipe" format=3 uid="uid://dliibkgb2mom0"] + +[ext_resource type="Script" uid="uid://ddlnlpb0p750q" path="res://docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/alchemy_craft_recipe.gd" id="1_recipe"] +[ext_resource type="Resource" uid="uid://t6du7gm2ywbi" path="res://docs/gyms/tiny_sword/currencies/cognite.tres" id="2_xltlj"] +[ext_resource type="Resource" path="res://docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/cognite_cost_entry.tres" id="3_cost"] + +[resource] +script = ExtResource("1_recipe") +id = &"cognite_recipe" +output_currency = ExtResource("2_xltlj") +cost_entries = [ExtResource("3_cost")] diff --git a/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/currency_cost_entry.gd b/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/currency_cost_entry.gd new file mode 100644 index 0000000..c5aedd5 --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/currency_cost_entry.gd @@ -0,0 +1,6 @@ +class_name CurrencyCostEntry +extends Resource + +## Entry for multi-currency cost in alchemy recipes +@export var currency: Currency +@export var amount: int = 1 diff --git a/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/currency_cost_entry.gd.uid b/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/currency_cost_entry.gd.uid new file mode 100644 index 0000000..7158d86 --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/currency_cost_entry.gd.uid @@ -0,0 +1 @@ +uid://ba8e403mb7hp0 diff --git a/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/mana_stone_cost_entry.tres b/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/mana_stone_cost_entry.tres new file mode 100644 index 0000000..6e7cc21 --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/mana_stone_cost_entry.tres @@ -0,0 +1,10 @@ +[gd_resource type="Resource" script_class="CurrencyCostEntry" format=3] + +[ext_resource type="Script" uid="uid://ba8e403mb7hp0" path="res://docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/currency_cost_entry.gd" id="1_cost"] +[ext_resource type="Resource" uid="uid://dpbndqxvsffa0" path="res://docs/gyms/tiny_sword/currencies/magic_gold.tres" id="2_magic_gold"] + +[resource] +script = ExtResource("1_cost") +currency = ExtResource("2_magic_gold") +amount = 10 +metadata/_custom_type_script = "res://docs/gyms/tiny_sword/buildings/alchemy_tower/currency_cost_entry.gd" diff --git a/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/mana_stone_recipe.tres b/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/mana_stone_recipe.tres new file mode 100644 index 0000000..9acf62c --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/mana_stone_recipe.tres @@ -0,0 +1,11 @@ +[gd_resource type="Resource" script_class="AlchemyCraftRecipe" format=3 uid="uid://by8qmuhvo38nn"] + +[ext_resource type="Script" uid="uid://ddlnlpb0p750q" path="res://docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/alchemy_craft_recipe.gd" id="1_recipe"] +[ext_resource type="Resource" uid="uid://brctmnpmhjas6" path="res://docs/gyms/tiny_sword/currencies/mana_stone.tres" id="2_mana_stone"] +[ext_resource type="Resource" path="res://docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/mana_stone_cost_entry.tres" id="3_cost"] + +[resource] +script = ExtResource("1_recipe") +id = &"mana_stone_recipe" +output_currency = ExtResource("2_mana_stone") +cost_entries = [ExtResource("3_cost")] diff --git a/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/philosoper_stone_cost_entry_0.tres b/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/philosoper_stone_cost_entry_0.tres new file mode 100644 index 0000000..2178897 --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/philosoper_stone_cost_entry_0.tres @@ -0,0 +1,9 @@ +[gd_resource type="Resource" script_class="CurrencyCostEntry" format=3 uid="uid://cheww15gwet47"] + +[ext_resource type="Resource" uid="uid://brctmnpmhjas6" path="res://docs/gyms/tiny_sword/currencies/mana_stone.tres" id="1_8mjcf"] +[ext_resource type="Script" uid="uid://ba8e403mb7hp0" path="res://docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/currency_cost_entry.gd" id="1_cost"] + +[resource] +script = ExtResource("1_cost") +currency = ExtResource("1_8mjcf") +amount = 10 diff --git a/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/philosoper_stone_cost_entry_1.tres b/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/philosoper_stone_cost_entry_1.tres new file mode 100644 index 0000000..f9858f3 --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/philosoper_stone_cost_entry_1.tres @@ -0,0 +1,9 @@ +[gd_resource type="Resource" script_class="CurrencyCostEntry" format=3 uid="uid://bprij85vf66at"] + +[ext_resource type="Resource" uid="uid://t6du7gm2ywbi" path="res://docs/gyms/tiny_sword/currencies/cognite.tres" id="1_3oqnk"] +[ext_resource type="Script" uid="uid://ba8e403mb7hp0" path="res://docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/currency_cost_entry.gd" id="2_q6dwm"] + +[resource] +script = ExtResource("2_q6dwm") +currency = ExtResource("1_3oqnk") +amount = 10 diff --git a/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/philosoper_stone_recipe.tres b/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/philosoper_stone_recipe.tres new file mode 100644 index 0000000..fbf026c --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/philosoper_stone_recipe.tres @@ -0,0 +1,12 @@ +[gd_resource type="Resource" script_class="AlchemyCraftRecipe" format=3 uid="uid://nfpkv6a2u0wf"] + +[ext_resource type="Resource" uid="uid://cheww15gwet47" path="res://docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/philosoper_stone_cost_entry_0.tres" id="1_jbac4"] +[ext_resource type="Resource" uid="uid://co4fiqgluwit0" path="res://docs/gyms/tiny_sword/currencies/philosoper_stone.tres" id="1_o0mnq"] +[ext_resource type="Script" uid="uid://ddlnlpb0p750q" path="res://docs/gyms/tiny_sword/buildings/alchemy_tower/recipes/alchemy_craft_recipe.gd" id="1_pxgqn"] + +[resource] +script = ExtResource("1_pxgqn") +id = &"philosoper_stone_recipe" +output_currency = ExtResource("1_o0mnq") +cost_entries = [ExtResource("1_jbac4")] +metadata/_custom_type_script = "uid://ddlnlpb0p750q" diff --git a/docs/gyms/tiny_sword/buildings/alchemy_tower/test_panel.gd.uid b/docs/gyms/tiny_sword/buildings/alchemy_tower/test_panel.gd.uid new file mode 100644 index 0000000..77c0785 --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/alchemy_tower/test_panel.gd.uid @@ -0,0 +1 @@ +uid://d5wkgk7kfrma diff --git a/docs/gyms/tiny_sword/buildings/castle/Castle.png b/docs/gyms/tiny_sword/buildings/castle/Castle.png new file mode 100644 index 0000000..dcb93f5 Binary files /dev/null and b/docs/gyms/tiny_sword/buildings/castle/Castle.png differ diff --git a/docs/gyms/tiny_sword/buildings/castle/Castle.png.import b/docs/gyms/tiny_sword/buildings/castle/Castle.png.import new file mode 100644 index 0000000..d413ced --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/castle/Castle.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cry4arn00kgrt" +path="res://.godot/imported/Castle.png-e8b352108f4d5f1f213446b1f212024a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/gyms/tiny_sword/buildings/castle/Castle.png" +dest_files=["res://.godot/imported/Castle.png-e8b352108f4d5f1f213446b1f212024a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/docs/gyms/tiny_sword/buildings/castle/castle.gd b/docs/gyms/tiny_sword/buildings/castle/castle.gd new file mode 100644 index 0000000..f68c4cc --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/castle/castle.gd @@ -0,0 +1,20 @@ +extends Node2D + +@onready var _prestige_panel: PrestigePanel = $PrestigePanel + +# Called when the node enters the scene tree for the first time. +func _ready() -> void: + pass # Replace with function body. + + +# Called every frame. 'delta' is the elapsed time since the previous frame. +func _process(delta: float) -> void: + pass + +func _on_area_2d_mouse_entered() -> void: + ## TODO: remove this comment to enable prestige panel + #_prestige_panel.visible = true + pass + +func _on_area_2d_mouse_exited() -> void: + _prestige_panel.visible = false diff --git a/docs/gyms/tiny_sword/buildings/castle/castle.gd.uid b/docs/gyms/tiny_sword/buildings/castle/castle.gd.uid new file mode 100644 index 0000000..393df4e --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/castle/castle.gd.uid @@ -0,0 +1 @@ +uid://cjc0l13b07802 diff --git a/docs/gyms/tiny_sword/buildings/castle/castle.tscn b/docs/gyms/tiny_sword/buildings/castle/castle.tscn new file mode 100644 index 0000000..11a0274 --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/castle/castle.tscn @@ -0,0 +1,72 @@ +[gd_scene format=3 uid="uid://cl05bdri38mxf"] + +[ext_resource type="Script" uid="uid://cjc0l13b07802" path="res://docs/gyms/tiny_sword/buildings/castle/castle.gd" id="1_cjtps"] +[ext_resource type="Texture2D" uid="uid://cry4arn00kgrt" path="res://docs/gyms/tiny_sword/buildings/castle/Castle.png" id="1_tgvch"] +[ext_resource type="PackedScene" uid="uid://dlidx2x0otpjg" path="res://core/prestige/prestige_panel.tscn" id="3_l7gct"] +[ext_resource type="Script" uid="uid://cpfmctd4xsfho" path="res://docs/gyms/tiny_sword/buildings/castle/test_buff.gd" id="4_l7gct"] +[ext_resource type="PackedScene" uid="uid://dudfvxilbydas" path="res://core/goals/current_goal_panel.tscn" id="4_r36om"] +[ext_resource type="Resource" uid="uid://bjc6qmvr7pe12" path="res://docs/gyms/tiny_sword/buffs/spawn_worker_buff.tres" id="5_s3a5k"] + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_tgvch"] +size = Vector2(312, 198) + +[node name="Castle" type="Node2D" unique_id=1966557957] +script = ExtResource("1_cjtps") + +[node name="Sprite2D" type="Sprite2D" parent="." unique_id=1365573096] +texture = ExtResource("1_tgvch") + +[node name="Area2D" type="Area2D" parent="." unique_id=66203052] + +[node name="CollisionShape2D" type="CollisionShape2D" parent="Area2D" unique_id=1342657556] +position = Vector2(1, 22) +shape = SubResource("RectangleShape2D_tgvch") + +[node name="PrestigePanel" parent="." unique_id=245519778 instance=ExtResource("3_l7gct")] +offset_left = 161.0 +offset_top = -232.0 +offset_right = 581.0 +offset_bottom = -3.0 + +[node name="PanelContainer" type="PanelContainer" parent="." unique_id=549346899] +offset_left = 160.0 +offset_top = 5.0 +offset_right = 575.0 +offset_bottom = 201.0 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="MarginContainer" type="MarginContainer" parent="PanelContainer" unique_id=321038386] +layout_mode = 2 +theme_override_constants/margin_left = 20 +theme_override_constants/margin_top = 20 +theme_override_constants/margin_right = 20 +theme_override_constants/margin_bottom = 20 + +[node name="VBoxContainer" type="VBoxContainer" parent="PanelContainer/MarginContainer" unique_id=450633732] +layout_mode = 2 +size_flags_vertical = 0 +theme_override_constants/separation = 4 +alignment = 1 + +[node name="TestBuff" type="PanelContainer" parent="PanelContainer/MarginContainer/VBoxContainer" unique_id=1906280979] +layout_mode = 2 +script = ExtResource("4_l7gct") +buff_data = ExtResource("5_s3a5k") + +[node name="HBoxContainer" type="HBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer/TestBuff" unique_id=1293262110] +layout_mode = 2 + +[node name="Label" type="Label" parent="PanelContainer/MarginContainer/VBoxContainer/TestBuff/HBoxContainer" unique_id=1519392687] +layout_mode = 2 +text = "Label" + +[node name="Button" type="Button" parent="PanelContainer/MarginContainer/VBoxContainer/TestBuff/HBoxContainer" unique_id=1254643833] +layout_mode = 2 +text = "Button" + +[node name="CurrentGoalPanel" parent="PanelContainer/MarginContainer/VBoxContainer" unique_id=1627561697 instance=ExtResource("4_r36om")] +layout_mode = 2 + +[connection signal="mouse_entered" from="Area2D" to="." method="_on_area_2d_mouse_entered"] +[connection signal="mouse_exited" from="Area2D" to="." method="_on_area_2d_mouse_exited"] diff --git a/docs/gyms/tiny_sword/buildings/castle/test_buff.gd b/docs/gyms/tiny_sword/buildings/castle/test_buff.gd new file mode 100644 index 0000000..a83233a --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/castle/test_buff.gd @@ -0,0 +1,118 @@ +extends PanelContainer + +@export var buff_data: GeneratorBuffData + +@onready var _game_state: LevelGameState = find_parent("LevelGameState") +@onready var _label: Label = $HBoxContainer/Label +@onready var _button: Button = $HBoxContainer/Button + +var _current_level: int = 0 + + +func _ready() -> void: + if buff_data != null: + _label.text = buff_data.text if not buff_data.text.is_empty() else str(buff_data.id) + _button.text = "Buy" + _button.disabled = true + else: + _label.text = "No Buff" + _button.text = "Disabled" + _button.disabled = true + + _button.pressed.connect(_on_button_pressed) + if _game_state: + _game_state.buff_level_changed.connect(_on_buff_level_changed) + _game_state.buff_unlocked_changed.connect(_on_buff_unlocked_changed) + _game_state.currency_changed.connect(_on_currency_changed) + _update_ui() + + +func _on_button_pressed() -> void: + if buff_data == null: + return + + var cost_currency_id: StringName = _resolve_buff_cost_currency_id(buff_data) + if cost_currency_id == &"": + push_warning("test_buff: Invalid cost currency") + return + + var cost: BigNumber = buff_data.get_cost_for_level(_current_level) + if _game_state and _game_state.spend_currency_by_id(cost_currency_id, cost): + var next_level: int = _current_level + 1 + _game_state.set_buff_level(buff_data.id, next_level) + _apply_buff_effect(buff_data, next_level) + _current_level = next_level + _update_ui() + else: + push_warning("test_buff: Not enough currency to buy buff") + + +func _apply_buff_effect(buff: GeneratorBuffData, level: int) -> void: + if buff.kind != GeneratorBuffData.BuffKind.RESOURCE_PURCHASE: + return + + var target_currency_id: StringName = _resolve_buff_target_currency_id(buff) + if target_currency_id == &"": + return + + var purchased_amount: BigNumber = buff.get_resource_purchase_amount_for_level(level) + if purchased_amount.mantissa > 0.0 and _game_state: + _game_state.add_currency_by_id(target_currency_id, purchased_amount) + + +func _resolve_buff_target_currency_id(buff: GeneratorBuffData) -> StringName: + if buff.resource_target_currency == null: + return &"" + + if _game_state == null: + return &"" + return _game_state.get_currency_id(buff.resource_target_currency) + + +func _on_buff_level_changed(buff_id: StringName, new_level: int) -> void: + if buff_data != null and buff_id == buff_data.id: + _current_level = new_level + _update_ui() + + +func _on_buff_unlocked_changed(buff_id: StringName, unlocked: bool) -> void: + if buff_data != null and buff_id == buff_data.id: + _update_ui() + +func _on_currency_changed(_changed_currency_id: StringName, _new_value: BigNumber) -> void: + _update_ui() + +func _update_ui() -> void: + if buff_data == null: + return + + if _game_state and not _game_state.is_buff_unlocked(buff_data.id): + _button.disabled = true + _button.text = "Locked" + return + + var is_maxed: bool = _current_level >= buff_data.max_level if buff_data.max_level >= 0 else false + var can_buy: bool = not is_maxed + + var cost_currency_id: StringName = _resolve_buff_cost_currency_id(buff_data) + if cost_currency_id != &"": + var cost: BigNumber = buff_data.get_cost_for_level(_current_level) + var current_amount: BigNumber = _game_state.get_currency_amount_by_id(cost_currency_id) if _game_state else BigNumber.from_float(0.0) + can_buy = can_buy and (current_amount.is_greater_than(cost) or current_amount.is_equal_to(cost)) + + _button.disabled = not can_buy + + if is_maxed: + _button.text = "Maxed" + else: + var cost: BigNumber = buff_data.get_cost_for_level(_current_level) + _button.text = "Buy (%s)" % cost.to_string_suffix(2) + + +func _resolve_buff_cost_currency_id(buff: GeneratorBuffData) -> StringName: + if buff.cost_currency == null: + return &"" + + if _game_state == null: + return &"" + return _game_state.get_currency_id(buff.cost_currency) diff --git a/docs/gyms/tiny_sword/buildings/castle/test_buff.gd.uid b/docs/gyms/tiny_sword/buildings/castle/test_buff.gd.uid new file mode 100644 index 0000000..4fedb91 --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/castle/test_buff.gd.uid @@ -0,0 +1 @@ +uid://cpfmctd4xsfho diff --git a/docs/gyms/tiny_sword/buildings/farm/House1.png b/docs/gyms/tiny_sword/buildings/farm/House1.png new file mode 100644 index 0000000..c3f5364 Binary files /dev/null and b/docs/gyms/tiny_sword/buildings/farm/House1.png differ diff --git a/docs/gyms/tiny_sword/buildings/farm/House1.png.import b/docs/gyms/tiny_sword/buildings/farm/House1.png.import new file mode 100644 index 0000000..a32825f --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/farm/House1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dfdh8r03sj7qv" +path="res://.godot/imported/House1.png-8d2a4466ed180272dd4fd5a1906cd2d6.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/gyms/tiny_sword/buildings/farm/House1.png" +dest_files=["res://.godot/imported/House1.png-8d2a4466ed180272dd4fd5a1906cd2d6.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/docs/gyms/tiny_sword/buildings/farm/farm.tscn b/docs/gyms/tiny_sword/buildings/farm/farm.tscn new file mode 100644 index 0000000..8abd88b --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/farm/farm.tscn @@ -0,0 +1,38 @@ +[gd_scene format=3 uid="uid://djedqovgngrx5"] + +[ext_resource type="PackedScene" uid="uid://jeoiinukrrsp" path="res://core/generator/currency_generator.tscn" id="1_rvsna"] +[ext_resource type="Resource" uid="uid://bxg2au0ijp242" path="res://docs/gyms/tiny_sword/currencies/food.tres" id="2_djlc5"] +[ext_resource type="Resource" uid="uid://d08h51y0pnsnf" path="res://docs/gyms/tiny_sword/buildings/farm/farm_generator.tres" id="3_82rmq"] +[ext_resource type="Texture2D" uid="uid://dfdh8r03sj7qv" path="res://docs/gyms/tiny_sword/buildings/farm/House1.png" id="4_djlc5"] +[ext_resource type="PackedScene" uid="uid://ckos7f22pnmyh" path="res://core/generator/generator_container.tscn" id="5_82rmq"] + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_82rmq"] +size = Vector2(106, 157) + +[node name="Farm" type="Node2D" unique_id=283335851] + +[node name="CurrencyGenerator" parent="." unique_id=967969064 node_paths=PackedStringArray("info_generator_container") instance=ExtResource("1_rvsna")] +currency = ExtResource("2_djlc5") +data = ExtResource("3_82rmq") +press_buys_generator = false +info_generator_container = NodePath("../GeneratorContainer") + +[node name="Sprite2D" type="Sprite2D" parent="." unique_id=1103280443] +texture = ExtResource("4_djlc5") + +[node name="Area2D" type="Area2D" parent="." unique_id=529600485] + +[node name="CollisionShape2D" type="CollisionShape2D" parent="Area2D" unique_id=204675694] +position = Vector2(0, -2.5) +shape = SubResource("RectangleShape2D_82rmq") + +[node name="GeneratorContainer" parent="." unique_id=1451609580 node_paths=PackedStringArray("_generator") instance=ExtResource("5_82rmq")] +visible = false +offset_left = 138.0 +offset_top = -100.0 +offset_right = 402.0 +offset_bottom = 110.0 +_generator = NodePath("../CurrencyGenerator") + +[connection signal="mouse_entered" from="Area2D" to="CurrencyGenerator" method="_on_area_2d_mouse_entered"] +[connection signal="mouse_exited" from="Area2D" to="CurrencyGenerator" method="_on_area_2d_mouse_exited"] diff --git a/docs/gyms/tiny_sword/buildings/farm/farm_generator.tres b/docs/gyms/tiny_sword/buildings/farm/farm_generator.tres new file mode 100644 index 0000000..2e779bf --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/farm/farm_generator.tres @@ -0,0 +1,21 @@ +[gd_resource type="Resource" script_class="CurrencyGeneratorData" format=3 uid="uid://d08h51y0pnsnf"] + +[ext_resource type="Resource" uid="uid://50yq2hl3wfwq" path="res://docs/gyms/tiny_sword/research/farm_research.tres" id="2_hprai"] +[ext_resource type="Resource" uid="uid://dfxk30o34qe2s" path="res://docs/gyms/tiny_sword/currencies/worker.tres" id="3_bbypn"] +[ext_resource type="Script" uid="uid://b00tqsuhxdy0d" path="res://core/generator/currency_generator_data.gd" id="4_0kvfm"] +[ext_resource type="Resource" uid="uid://cts0407h130d6" path="res://docs/gyms/tiny_sword/goals/gold_total_1300_goal.tres" id="5_qiy1b"] + +[resource] +script = ExtResource("4_0kvfm") +id = &"farm" +name = "Farm" +starts_unlocked = false +starts_available = false +initial_owned = 1 +purchase_currency = ExtResource("3_bbypn") +unlock_goal = ExtResource("5_qiy1b") +initial_cost = 1.0 +coefficient = 1.0 +initial_productivity = 20.0 +research_data = ExtResource("2_hprai") +metadata/_custom_type_script = "uid://b00tqsuhxdy0d" diff --git a/docs/gyms/tiny_sword/buildings/forestry/House3.png b/docs/gyms/tiny_sword/buildings/forestry/House3.png new file mode 100644 index 0000000..f410672 Binary files /dev/null and b/docs/gyms/tiny_sword/buildings/forestry/House3.png differ diff --git a/docs/gyms/tiny_sword/buildings/forestry/House3.png.import b/docs/gyms/tiny_sword/buildings/forestry/House3.png.import new file mode 100644 index 0000000..7b45513 --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/forestry/House3.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://doxauaa3vn3lm" +path="res://.godot/imported/House3.png-86e0e955279bc70e5c6c622bf20a793e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/gyms/tiny_sword/buildings/forestry/House3.png" +dest_files=["res://.godot/imported/House3.png-86e0e955279bc70e5c6c622bf20a793e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/docs/gyms/tiny_sword/buildings/forestry/forestry.gd b/docs/gyms/tiny_sword/buildings/forestry/forestry.gd new file mode 100644 index 0000000..1e243a3 --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/forestry/forestry.gd @@ -0,0 +1,13 @@ +extends Node2D + +@onready var _generator_container: GeneratorPanel = $GeneratorContainer + +# Called when the node enters the scene tree for the first time. +func _ready() -> void: + _generator_container.visible = false + +func _on_area_2d_mouse_entered() -> void: + _generator_container.visible = true + +func _on_area_2d_mouse_exited() -> void: + _generator_container.visible = false diff --git a/docs/gyms/tiny_sword/buildings/forestry/forestry.gd.uid b/docs/gyms/tiny_sword/buildings/forestry/forestry.gd.uid new file mode 100644 index 0000000..9b18cfb --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/forestry/forestry.gd.uid @@ -0,0 +1 @@ +uid://cckf4nacd4qcr diff --git a/docs/gyms/tiny_sword/buildings/forestry/forestry.tscn b/docs/gyms/tiny_sword/buildings/forestry/forestry.tscn new file mode 100644 index 0000000..d96e43a --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/forestry/forestry.tscn @@ -0,0 +1,40 @@ +[gd_scene format=3 uid="uid://cf01wvy3f17i"] + +[ext_resource type="PackedScene" uid="uid://jeoiinukrrsp" path="res://core/generator/currency_generator.tscn" id="1_7j1xc"] +[ext_resource type="Script" uid="uid://cckf4nacd4qcr" path="res://docs/gyms/tiny_sword/buildings/forestry/forestry.gd" id="1_l4bxt"] +[ext_resource type="Resource" uid="uid://bgsk8h4w80h45" path="res://docs/gyms/tiny_sword/currencies/wood.tres" id="2_23x2t"] +[ext_resource type="Resource" uid="uid://v6hjoa2vky5k" path="res://docs/gyms/tiny_sword/buildings/forestry/forestry_generator.tres" id="3_pby1g"] +[ext_resource type="Texture2D" uid="uid://doxauaa3vn3lm" path="res://docs/gyms/tiny_sword/buildings/forestry/House3.png" id="4_5yp33"] +[ext_resource type="PackedScene" uid="uid://ckos7f22pnmyh" path="res://core/generator/generator_container.tscn" id="5_rh3m6"] + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_v4aq3"] +size = Vector2(123, 157) + +[node name="Forestry" type="Node2D" unique_id=726284577] +script = ExtResource("1_l4bxt") + +[node name="CurrencyGenerator" parent="." unique_id=967969064 node_paths=PackedStringArray("info_generator_container") instance=ExtResource("1_7j1xc")] +currency = ExtResource("2_23x2t") +data = ExtResource("3_pby1g") +press_buys_generator = false +info_generator_container = NodePath("../GeneratorContainer") + +[node name="Sprite2D" type="Sprite2D" parent="." unique_id=1575721003] +texture = ExtResource("4_5yp33") + +[node name="Area2D" type="Area2D" parent="." unique_id=987240531] + +[node name="CollisionShape2D" type="CollisionShape2D" parent="Area2D" unique_id=849105822] +position = Vector2(0.5, -2.5) +shape = SubResource("RectangleShape2D_v4aq3") + +[node name="GeneratorContainer" parent="." unique_id=1451609580 node_paths=PackedStringArray("_generator") instance=ExtResource("5_rh3m6")] +visible = false +offset_left = 128.0 +offset_top = -100.0 +offset_right = 392.0 +offset_bottom = 110.0 +_generator = NodePath("../CurrencyGenerator") + +[connection signal="mouse_entered" from="Area2D" to="." method="_on_area_2d_mouse_entered"] +[connection signal="mouse_exited" from="Area2D" to="." method="_on_area_2d_mouse_exited"] diff --git a/docs/gyms/tiny_sword/buildings/forestry/forestry_generator.tres b/docs/gyms/tiny_sword/buildings/forestry/forestry_generator.tres new file mode 100644 index 0000000..e954ea0 --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/forestry/forestry_generator.tres @@ -0,0 +1,21 @@ +[gd_resource type="Resource" script_class="CurrencyGeneratorData" format=3 uid="uid://v6hjoa2vky5k"] + +[ext_resource type="Resource" uid="uid://dfxk30o34qe2s" path="res://docs/gyms/tiny_sword/currencies/worker.tres" id="2_fs80u"] +[ext_resource type="Resource" uid="uid://cvow4sj6a5h23" path="res://docs/gyms/tiny_sword/research/forestry_research.tres" id="2_n4bxc"] +[ext_resource type="Resource" uid="uid://bo463s6jt0ep7" path="res://docs/gyms/tiny_sword/goals/gold_total_13k_goal.tres" id="4_mgk3v"] +[ext_resource type="Script" uid="uid://b00tqsuhxdy0d" path="res://core/generator/currency_generator_data.gd" id="4_rij12"] + +[resource] +script = ExtResource("4_rij12") +id = &"forestry" +name = "Forestry" +starts_unlocked = false +starts_available = false +initial_owned = 1 +purchase_currency = ExtResource("2_fs80u") +unlock_goal = ExtResource("4_mgk3v") +initial_cost = 1.0 +coefficient = 1.0 +initial_productivity = 20.0 +research_data = ExtResource("2_n4bxc") +metadata/_custom_type_script = "uid://b00tqsuhxdy0d" diff --git a/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 1.png b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 1.png new file mode 100644 index 0000000..ad35201 Binary files /dev/null and b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 1.png differ diff --git a/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 1.png.import b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 1.png.import new file mode 100644 index 0000000..5c96762 --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cfj3i3o3fkyb3" +path="res://.godot/imported/Gold Stone 1.png-7042dbb8b3bf15fcc9c607c8de62d4ac.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 1.png" +dest_files=["res://.godot/imported/Gold Stone 1.png-7042dbb8b3bf15fcc9c607c8de62d4ac.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 1_Highlight.png b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 1_Highlight.png new file mode 100644 index 0000000..7ecde6b Binary files /dev/null and b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 1_Highlight.png differ diff --git a/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 1_Highlight.png.import b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 1_Highlight.png.import new file mode 100644 index 0000000..7262d8f --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 1_Highlight.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dv047glnug81l" +path="res://.godot/imported/Gold Stone 1_Highlight.png-7a880d843c9328a50dd550238ae23ff6.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 1_Highlight.png" +dest_files=["res://.godot/imported/Gold Stone 1_Highlight.png-7a880d843c9328a50dd550238ae23ff6.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 2.png b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 2.png new file mode 100644 index 0000000..9145e26 Binary files /dev/null and b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 2.png differ diff --git a/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 2.png.import b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 2.png.import new file mode 100644 index 0000000..19769c8 --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dgejk5scadnlt" +path="res://.godot/imported/Gold Stone 2.png-477006e016721179f332d1fec75a9ce7.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 2.png" +dest_files=["res://.godot/imported/Gold Stone 2.png-477006e016721179f332d1fec75a9ce7.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 2_Highlight.png b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 2_Highlight.png new file mode 100644 index 0000000..489950d Binary files /dev/null and b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 2_Highlight.png differ diff --git a/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 2_Highlight.png.import b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 2_Highlight.png.import new file mode 100644 index 0000000..2b7d8dd --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 2_Highlight.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bdp6vy36svbvn" +path="res://.godot/imported/Gold Stone 2_Highlight.png-35700856718370fa3e199b6e9fe3407e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 2_Highlight.png" +dest_files=["res://.godot/imported/Gold Stone 2_Highlight.png-35700856718370fa3e199b6e9fe3407e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 3.png b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 3.png new file mode 100644 index 0000000..ee11a03 Binary files /dev/null and b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 3.png differ diff --git a/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 3.png.import b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 3.png.import new file mode 100644 index 0000000..a9eb892 --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 3.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://qimeviwtwkxx" +path="res://.godot/imported/Gold Stone 3.png-3c5063559feb891e83e5e47c1b0198e2.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 3.png" +dest_files=["res://.godot/imported/Gold Stone 3.png-3c5063559feb891e83e5e47c1b0198e2.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 3_Highlight.png b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 3_Highlight.png new file mode 100644 index 0000000..882fa45 Binary files /dev/null and b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 3_Highlight.png differ diff --git a/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 3_Highlight.png.import b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 3_Highlight.png.import new file mode 100644 index 0000000..e732bb3 --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 3_Highlight.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bjqe3efu2aat6" +path="res://.godot/imported/Gold Stone 3_Highlight.png-539c0e6cdbe76410543390c2f48e7dd9.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 3_Highlight.png" +dest_files=["res://.godot/imported/Gold Stone 3_Highlight.png-539c0e6cdbe76410543390c2f48e7dd9.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 4.png b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 4.png new file mode 100644 index 0000000..1178f7c Binary files /dev/null and b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 4.png differ diff --git a/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 4.png.import b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 4.png.import new file mode 100644 index 0000000..0a14263 --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 4.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d3grr0fulpeje" +path="res://.godot/imported/Gold Stone 4.png-acf1f6375643d67fba247db251a84a6a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 4.png" +dest_files=["res://.godot/imported/Gold Stone 4.png-acf1f6375643d67fba247db251a84a6a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 4_Highlight.png b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 4_Highlight.png new file mode 100644 index 0000000..4fdfc16 Binary files /dev/null and b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 4_Highlight.png differ diff --git a/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 4_Highlight.png.import b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 4_Highlight.png.import new file mode 100644 index 0000000..fe79e4f --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 4_Highlight.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://befcjyx8tmc4a" +path="res://.godot/imported/Gold Stone 4_Highlight.png-f9cc5b1396d5ea96bb589d305b45bc4a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 4_Highlight.png" +dest_files=["res://.godot/imported/Gold Stone 4_Highlight.png-f9cc5b1396d5ea96bb589d305b45bc4a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 5.png b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 5.png new file mode 100644 index 0000000..c633394 Binary files /dev/null and b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 5.png differ diff --git a/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 5.png.import b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 5.png.import new file mode 100644 index 0000000..16041a1 --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 5.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bjaqlfshs823o" +path="res://.godot/imported/Gold Stone 5.png-c2b9b25484a63f524f46419e5fcf3e8f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 5.png" +dest_files=["res://.godot/imported/Gold Stone 5.png-c2b9b25484a63f524f46419e5fcf3e8f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 5_Highlight.png b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 5_Highlight.png new file mode 100644 index 0000000..af98329 Binary files /dev/null and b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 5_Highlight.png differ diff --git a/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 5_Highlight.png.import b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 5_Highlight.png.import new file mode 100644 index 0000000..3572b50 --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 5_Highlight.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c35nxuw4x1go4" +path="res://.godot/imported/Gold Stone 5_Highlight.png-aecadbd44ed80072138aa4cfe3ab79cc.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 5_Highlight.png" +dest_files=["res://.godot/imported/Gold Stone 5_Highlight.png-aecadbd44ed80072138aa4cfe3ab79cc.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 6.png b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 6.png new file mode 100644 index 0000000..7cb2b8d Binary files /dev/null and b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 6.png differ diff --git a/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 6.png.import b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 6.png.import new file mode 100644 index 0000000..d36c5fd --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 6.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bqf6yhtsnmjad" +path="res://.godot/imported/Gold Stone 6.png-0e048b3da07c839cd632ec6705baf4dc.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 6.png" +dest_files=["res://.godot/imported/Gold Stone 6.png-0e048b3da07c839cd632ec6705baf4dc.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 6_Highlight.png b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 6_Highlight.png new file mode 100644 index 0000000..735bbe0 Binary files /dev/null and b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 6_Highlight.png differ diff --git a/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 6_Highlight.png.import b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 6_Highlight.png.import new file mode 100644 index 0000000..4b1b558 --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 6_Highlight.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c3ssnuxmsqyuq" +path="res://.godot/imported/Gold Stone 6_Highlight.png-782ec372f9d78c1068ab0c8f07008eeb.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 6_Highlight.png" +dest_files=["res://.godot/imported/Gold Stone 6_Highlight.png-782ec372f9d78c1068ab0c8f07008eeb.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stones.aseprite b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stones.aseprite new file mode 100644 index 0000000..d019ceb Binary files /dev/null and b/docs/gyms/tiny_sword/buildings/gold_mine/Gold Stones.aseprite differ diff --git a/docs/gyms/tiny_sword/buildings/gold_mine/gold_mine.tscn b/docs/gyms/tiny_sword/buildings/gold_mine/gold_mine.tscn new file mode 100644 index 0000000..51d9601 --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/gold_mine/gold_mine.tscn @@ -0,0 +1,37 @@ +[gd_scene format=3 uid="uid://brgkkw2n3o1ox"] + +[ext_resource type="PackedScene" uid="uid://jeoiinukrrsp" path="res://core/generator/currency_generator.tscn" id="1_dgq4l"] +[ext_resource type="Resource" uid="uid://w4u1hddplb4e" path="res://docs/gyms/tiny_sword/currencies/gold.tres" id="2_d2x03"] +[ext_resource type="PackedScene" uid="uid://ckos7f22pnmyh" path="res://core/generator/generator_container.tscn" id="2_lnbxc"] +[ext_resource type="Texture2D" uid="uid://bqf6yhtsnmjad" path="res://docs/gyms/tiny_sword/buildings/gold_mine/Gold Stone 6.png" id="2_n0fgx"] +[ext_resource type="Resource" uid="uid://dliilvvjvksk1" path="res://docs/gyms/tiny_sword/buildings/gold_mine/gold_mine_generator.tres" id="2_sbi6i"] + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_sbi6i"] +size = Vector2(82, 84) + +[node name="GoldMine" type="Node2D" unique_id=341660167] + +[node name="CurrencyGenerator" parent="." unique_id=967969064 node_paths=PackedStringArray("info_generator_container") instance=ExtResource("1_dgq4l")] +currency = ExtResource("2_d2x03") +data = ExtResource("2_sbi6i") +info_generator_container = NodePath("../GeneratorContainer") + +[node name="Sprite2D" type="Sprite2D" parent="." unique_id=115969252] +texture = ExtResource("2_n0fgx") + +[node name="Area2D" type="Area2D" parent="." unique_id=1667681021] + +[node name="CollisionShape2D" type="CollisionShape2D" parent="Area2D" unique_id=549597501] +position = Vector2(-1, -11) +shape = SubResource("RectangleShape2D_sbi6i") + +[node name="GeneratorContainer" parent="." unique_id=1451609580 node_paths=PackedStringArray("_generator") instance=ExtResource("2_lnbxc")] +visible = false +offset_left = 127.0 +offset_top = -103.0 +offset_right = 391.0 +offset_bottom = 107.0 +_generator = NodePath("../CurrencyGenerator") + +[connection signal="mouse_entered" from="Area2D" to="CurrencyGenerator" method="_on_area_2d_mouse_entered"] +[connection signal="mouse_exited" from="Area2D" to="CurrencyGenerator" method="_on_area_2d_mouse_exited"] diff --git a/docs/gyms/tiny_sword/buildings/gold_mine/gold_mine_generator.tres b/docs/gyms/tiny_sword/buildings/gold_mine/gold_mine_generator.tres new file mode 100644 index 0000000..8de8fa6 --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/gold_mine/gold_mine_generator.tres @@ -0,0 +1,19 @@ +[gd_resource type="Resource" script_class="CurrencyGeneratorData" format=3 uid="uid://dliilvvjvksk1"] + +[ext_resource type="Resource" uid="uid://dfxk30o34qe2s" path="res://docs/gyms/tiny_sword/currencies/worker.tres" id="2_j1lw5"] +[ext_resource type="Resource" uid="uid://dif6w6kesmw6u" path="res://docs/gyms/tiny_sword/research/gold_mine_research.tres" id="2_qkhnt"] +[ext_resource type="Script" uid="uid://b00tqsuhxdy0d" path="res://core/generator/currency_generator_data.gd" id="3_cnabh"] + +[resource] +script = ExtResource("3_cnabh") +id = &"goldmine" +name = "Gold Mine" +purchase_currency = ExtResource("2_j1lw5") +initial_cost = 1.0 +coefficient = 1.0 +initial_productivity = 20.0 +click_mantissa = 1.0 +click_exponent = 0 +click_cooldown_seconds = 0.2 +research_data = ExtResource("2_qkhnt") +metadata/_custom_type_script = "uid://b00tqsuhxdy0d" diff --git a/docs/gyms/tiny_sword/buildings/monastery/Monastery.png b/docs/gyms/tiny_sword/buildings/monastery/Monastery.png new file mode 100644 index 0000000..1b97b48 Binary files /dev/null and b/docs/gyms/tiny_sword/buildings/monastery/Monastery.png differ diff --git a/docs/gyms/tiny_sword/buildings/monastery/Monastery.png.import b/docs/gyms/tiny_sword/buildings/monastery/Monastery.png.import new file mode 100644 index 0000000..0ed877d --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/monastery/Monastery.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dlmjd2berkb6v" +path="res://.godot/imported/Monastery.png-5f49b9547f0b0c767155d570a4d6cc55.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/gyms/tiny_sword/buildings/monastery/Monastery.png" +dest_files=["res://.godot/imported/Monastery.png-5f49b9547f0b0c767155d570a4d6cc55.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/docs/gyms/tiny_sword/buildings/monastery/monastery.gd b/docs/gyms/tiny_sword/buildings/monastery/monastery.gd new file mode 100644 index 0000000..ad93604 --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/monastery/monastery.gd @@ -0,0 +1,14 @@ +extends Node2D + +@onready var _research_panel: ResearchPanel = $ResearchPanel + +# Called when the node enters the scene tree for the first time. +func _ready() -> void: + _research_panel.visible = false + +func _on_area_2d_mouse_entered() -> void: + _research_panel.visible = true + + +func _on_area_2d_mouse_exited() -> void: + _research_panel.visible = false diff --git a/docs/gyms/tiny_sword/buildings/monastery/monastery.gd.uid b/docs/gyms/tiny_sword/buildings/monastery/monastery.gd.uid new file mode 100644 index 0000000..d951735 --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/monastery/monastery.gd.uid @@ -0,0 +1 @@ +uid://cj0sxoceqqyrm diff --git a/docs/gyms/tiny_sword/buildings/monastery/monastery.tscn b/docs/gyms/tiny_sword/buildings/monastery/monastery.tscn new file mode 100644 index 0000000..c5ceae6 --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/monastery/monastery.tscn @@ -0,0 +1,30 @@ +[gd_scene format=3 uid="uid://bf8lqbexvnx6e"] + +[ext_resource type="Script" uid="uid://cj0sxoceqqyrm" path="res://docs/gyms/tiny_sword/buildings/monastery/monastery.gd" id="1_e17qd"] +[ext_resource type="Texture2D" uid="uid://dlmjd2berkb6v" path="res://docs/gyms/tiny_sword/buildings/monastery/Monastery.png" id="1_yyw6r"] +[ext_resource type="PackedScene" uid="uid://dd8roif0mqirl" path="res://core/research/research_panel.tscn" id="3_c6gi6"] + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_yyw6r"] +size = Vector2(160, 254) + +[node name="Monastery" type="Node2D" unique_id=1545930496] +script = ExtResource("1_e17qd") + +[node name="Sprite2D" type="Sprite2D" parent="." unique_id=2097167417] +texture = ExtResource("1_yyw6r") + +[node name="Area2D" type="Area2D" parent="." unique_id=926221537] + +[node name="CollisionShape2D" type="CollisionShape2D" parent="Area2D" unique_id=1198096629] +position = Vector2(1, 16) +shape = SubResource("RectangleShape2D_yyw6r") + +[node name="ResearchPanel" parent="." unique_id=1105274967 instance=ExtResource("3_c6gi6")] +visible = false +offset_left = 78.0 +offset_top = -98.0 +offset_right = 478.0 +offset_bottom = 128.0 + +[connection signal="mouse_entered" from="Area2D" to="." method="_on_area_2d_mouse_entered"] +[connection signal="mouse_exited" from="Area2D" to="." method="_on_area_2d_mouse_exited"] diff --git a/docs/gyms/tiny_sword/buildings/monastery/monastery_generator.tres b/docs/gyms/tiny_sword/buildings/monastery/monastery_generator.tres new file mode 100644 index 0000000..a132f84 --- /dev/null +++ b/docs/gyms/tiny_sword/buildings/monastery/monastery_generator.tres @@ -0,0 +1,7 @@ +[gd_resource type="Resource" script_class="CurrencyGeneratorData" format=3 uid="uid://56bnik1oe3ao"] + +[ext_resource type="Script" uid="uid://b00tqsuhxdy0d" path="res://core/generator/currency_generator_data.gd" id="1_mnd3i"] + +[resource] +script = ExtResource("1_mnd3i") +metadata/_custom_type_script = "uid://b00tqsuhxdy0d" diff --git a/docs/gyms/tiny_sword/currencies/ascension.tres b/docs/gyms/tiny_sword/currencies/ascension.tres new file mode 100644 index 0000000..453a21b --- /dev/null +++ b/docs/gyms/tiny_sword/currencies/ascension.tres @@ -0,0 +1,11 @@ +[gd_resource type="Resource" script_class="Currency" format=3 uid="uid://bfrb0ayrljac2"] + +[ext_resource type="Texture2D" uid="uid://d3nymghus6x3" path="res://sandbox/tiny_swords/UI Elements/UI Elements/Icons/Icon_03.png" id="1_icon"] +[ext_resource type="Script" uid="uid://dtgqjf3bl7pm8" path="res://core/currency/currency.gd" id="2_script"] + +[resource] +script = ExtResource("2_script") +id = &"ascension" +display_name = "Ascension" +icon = ExtResource("1_icon") +metadata/_custom_type_script = "uid://dtgqjf3bl7pm8" diff --git a/docs/gyms/tiny_sword/currencies/cognite.tres b/docs/gyms/tiny_sword/currencies/cognite.tres new file mode 100644 index 0000000..c8d9cbd --- /dev/null +++ b/docs/gyms/tiny_sword/currencies/cognite.tres @@ -0,0 +1,11 @@ +[gd_resource type="Resource" script_class="Currency" format=3 uid="uid://t6du7gm2ywbi"] + +[ext_resource type="Texture2D" uid="uid://c1gnh8jgctrcx" path="res://sandbox/tiny_swords/UI Elements/UI Elements/Icons/Icon_07.png" id="1_axihf"] +[ext_resource type="Script" uid="uid://dtgqjf3bl7pm8" path="res://core/currency/currency.gd" id="2_fxby5"] + +[resource] +script = ExtResource("2_fxby5") +id = &"cognite" +display_name = "Cognite" +icon = ExtResource("1_axihf") +metadata/_custom_type_script = "uid://dtgqjf3bl7pm8" diff --git a/docs/gyms/tiny_sword/currencies/food.tres b/docs/gyms/tiny_sword/currencies/food.tres new file mode 100644 index 0000000..ae2b84e --- /dev/null +++ b/docs/gyms/tiny_sword/currencies/food.tres @@ -0,0 +1,10 @@ +[gd_resource type="Resource" script_class="Currency" format=3 uid="uid://bxg2au0ijp242"] + +[ext_resource type="Texture2D" uid="uid://dkt4s1eyw8puy" path="res://sandbox/tiny_swords/UI Elements/UI Elements/Icons/Icon_04.png" id="1_4u6o6"] +[ext_resource type="Script" uid="uid://dtgqjf3bl7pm8" path="res://core/currency/currency.gd" id="2_anstu"] + +[resource] +script = ExtResource("2_anstu") +id = &"food" +display_name = "Food" +icon = ExtResource("1_4u6o6") diff --git a/docs/gyms/tiny_sword/currencies/gold.tres b/docs/gyms/tiny_sword/currencies/gold.tres new file mode 100644 index 0000000..b075373 --- /dev/null +++ b/docs/gyms/tiny_sword/currencies/gold.tres @@ -0,0 +1,11 @@ +[gd_resource type="Resource" script_class="Currency" format=3 uid="uid://w4u1hddplb4e"] + +[ext_resource type="Texture2D" uid="uid://d3nymghus6x3" path="res://sandbox/tiny_swords/UI Elements/UI Elements/Icons/Icon_03.png" id="1_n656e"] +[ext_resource type="Script" uid="uid://dtgqjf3bl7pm8" path="res://core/currency/currency.gd" id="2_vohw6"] + +[resource] +script = ExtResource("2_vohw6") +id = &"gold" +display_name = "Gold" +icon = ExtResource("1_n656e") +always_visible = true diff --git a/docs/gyms/tiny_sword/currencies/magic_gold.tres b/docs/gyms/tiny_sword/currencies/magic_gold.tres new file mode 100644 index 0000000..4565024 --- /dev/null +++ b/docs/gyms/tiny_sword/currencies/magic_gold.tres @@ -0,0 +1,11 @@ +[gd_resource type="Resource" script_class="Currency" format=3 uid="uid://dpbndqxvsffa0"] + +[ext_resource type="Script" uid="uid://dtgqjf3bl7pm8" path="res://core/currency/currency.gd" id="1_v6rd4"] +[ext_resource type="Texture2D" uid="uid://c1gnh8jgctrcx" path="res://sandbox/tiny_swords/UI Elements/UI Elements/Icons/Icon_07.png" id="1_wi1w3"] + +[resource] +script = ExtResource("1_v6rd4") +id = &"magic_gold" +display_name = "Magic Gold" +icon = ExtResource("1_wi1w3") +metadata/_custom_type_script = "uid://dtgqjf3bl7pm8" diff --git a/docs/gyms/tiny_sword/currencies/mana_stone.tres b/docs/gyms/tiny_sword/currencies/mana_stone.tres new file mode 100644 index 0000000..1e256c0 --- /dev/null +++ b/docs/gyms/tiny_sword/currencies/mana_stone.tres @@ -0,0 +1,11 @@ +[gd_resource type="Resource" script_class="Currency" format=3 uid="uid://brctmnpmhjas6"] + +[ext_resource type="Texture2D" uid="uid://c1gnh8jgctrcx" path="res://sandbox/tiny_swords/UI Elements/UI Elements/Icons/Icon_07.png" id="1_c7yxi"] +[ext_resource type="Script" uid="uid://dtgqjf3bl7pm8" path="res://core/currency/currency.gd" id="1_fahg0"] + +[resource] +script = ExtResource("1_fahg0") +id = &"mana_stone" +display_name = "Mana Stone" +icon = ExtResource("1_c7yxi") +metadata/_custom_type_script = "uid://dtgqjf3bl7pm8" diff --git a/docs/gyms/tiny_sword/currencies/philosoper_stone.tres b/docs/gyms/tiny_sword/currencies/philosoper_stone.tres new file mode 100644 index 0000000..ea75161 --- /dev/null +++ b/docs/gyms/tiny_sword/currencies/philosoper_stone.tres @@ -0,0 +1,11 @@ +[gd_resource type="Resource" script_class="Currency" format=3 uid="uid://co4fiqgluwit0"] + +[ext_resource type="Texture2D" uid="uid://c1gnh8jgctrcx" path="res://sandbox/tiny_swords/UI Elements/UI Elements/Icons/Icon_07.png" id="1_cks7i"] +[ext_resource type="Script" uid="uid://dtgqjf3bl7pm8" path="res://core/currency/currency.gd" id="2_ih4no"] + +[resource] +script = ExtResource("2_ih4no") +id = &"philosoper_stone" +display_name = "Philosoper Stone" +icon = ExtResource("1_cks7i") +metadata/_custom_type_script = "uid://dtgqjf3bl7pm8" diff --git a/docs/gyms/tiny_sword/currencies/ts_currency_catalogue.tres b/docs/gyms/tiny_sword/currencies/ts_currency_catalogue.tres new file mode 100644 index 0000000..ff00aa2 --- /dev/null +++ b/docs/gyms/tiny_sword/currencies/ts_currency_catalogue.tres @@ -0,0 +1,18 @@ +[gd_resource type="Resource" script_class="CurrencyCatalogue" format=3 uid="uid://iqphilgfp2i7"] + +[ext_resource type="Script" uid="uid://dtgqjf3bl7pm8" path="res://core/currency/currency.gd" id="1_501l6"] +[ext_resource type="Script" uid="uid://621tus0uvbrd" path="res://core/currency/currency_catalogue.gd" id="2_hmju3"] +[ext_resource type="Resource" uid="uid://w4u1hddplb4e" path="res://docs/gyms/tiny_sword/currencies/gold.tres" id="2_ucsji"] +[ext_resource type="Resource" uid="uid://dfxk30o34qe2s" path="res://docs/gyms/tiny_sword/currencies/worker.tres" id="3_7hx6u"] +[ext_resource type="Resource" uid="uid://bxg2au0ijp242" path="res://docs/gyms/tiny_sword/currencies/food.tres" id="4_c77gh"] +[ext_resource type="Resource" uid="uid://bgsk8h4w80h45" path="res://docs/gyms/tiny_sword/currencies/wood.tres" id="5_gyp05"] +[ext_resource type="Resource" uid="uid://bfrb0ayrljac2" path="res://docs/gyms/tiny_sword/currencies/ascension.tres" id="6_ascension"] +[ext_resource type="Resource" uid="uid://dpbndqxvsffa0" path="res://docs/gyms/tiny_sword/currencies/magic_gold.tres" id="7_vu05v"] +[ext_resource type="Resource" uid="uid://brctmnpmhjas6" path="res://docs/gyms/tiny_sword/currencies/mana_stone.tres" id="8_p3urk"] +[ext_resource type="Resource" uid="uid://t6du7gm2ywbi" path="res://docs/gyms/tiny_sword/currencies/cognite.tres" id="9_ejnoj"] +[ext_resource type="Resource" uid="uid://co4fiqgluwit0" path="res://docs/gyms/tiny_sword/currencies/philosoper_stone.tres" id="10_ejnoj"] + +[resource] +script = ExtResource("2_hmju3") +currencies = Array[ExtResource("1_501l6")]([ExtResource("2_ucsji"), ExtResource("3_7hx6u"), ExtResource("4_c77gh"), ExtResource("5_gyp05"), ExtResource("6_ascension"), ExtResource("7_vu05v"), ExtResource("8_p3urk"), ExtResource("9_ejnoj"), ExtResource("10_ejnoj")]) +metadata/_custom_type_script = "uid://621tus0uvbrd" diff --git a/docs/gyms/tiny_sword/currencies/wood.tres b/docs/gyms/tiny_sword/currencies/wood.tres new file mode 100644 index 0000000..729262d --- /dev/null +++ b/docs/gyms/tiny_sword/currencies/wood.tres @@ -0,0 +1,10 @@ +[gd_resource type="Resource" script_class="Currency" format=3 uid="uid://bgsk8h4w80h45"] + +[ext_resource type="Texture2D" uid="uid://bovjusa7o0l1m" path="res://sandbox/tiny_swords/UI Elements/UI Elements/Icons/Icon_02.png" id="1_gm6cf"] +[ext_resource type="Script" uid="uid://dtgqjf3bl7pm8" path="res://core/currency/currency.gd" id="2_jj4s4"] + +[resource] +script = ExtResource("2_jj4s4") +id = &"wood" +display_name = "Wood" +icon = ExtResource("1_gm6cf") diff --git a/docs/gyms/tiny_sword/currencies/worker.tres b/docs/gyms/tiny_sword/currencies/worker.tres new file mode 100644 index 0000000..18f416e --- /dev/null +++ b/docs/gyms/tiny_sword/currencies/worker.tres @@ -0,0 +1,11 @@ +[gd_resource type="Resource" script_class="Currency" format=3 uid="uid://dfxk30o34qe2s"] + +[ext_resource type="Texture2D" uid="uid://bx7tj1rd3vo3a" path="res://sandbox/tiny_swords/UI Elements/UI Elements/Icons/Icon_05.png" id="1_cbwhh"] +[ext_resource type="Script" uid="uid://dtgqjf3bl7pm8" path="res://core/currency/currency.gd" id="1_ovrsl"] + +[resource] +script = ExtResource("1_ovrsl") +id = &"worker" +display_name = "Worker" +icon = ExtResource("1_cbwhh") +metadata/_custom_type_script = "uid://dtgqjf3bl7pm8" diff --git a/docs/gyms/tiny_sword/currency_panel.tscn b/docs/gyms/tiny_sword/currency_panel.tscn new file mode 100644 index 0000000..7635dd7 --- /dev/null +++ b/docs/gyms/tiny_sword/currency_panel.tscn @@ -0,0 +1,15 @@ +[gd_scene format=3 uid="uid://cur_panel_scene_uid"] + +[ext_resource type="Script" uid="uid://9_vbox" path="res://core/currency/currency_panel.gd" id="1_cpnl"] + +[node name="CurrencyPanel" type="PanelContainer" unique_id=3000000001] +custom_minimum_size = Vector2(250, 340) +offset_left = 5.0 +offset_top = 5.0 +offset_right = 255.0 +offset_bottom = 345.0 +script = ExtResource("1_cpnl") + +[node name="CurrencyTiles" type="VBoxContainer" parent="." unique_id=3000000002] +layout_mode = 2 +theme_override_constants/separation = 4 diff --git a/docs/gyms/tiny_sword/goals/gold_350k_wood_20k_goal.tres b/docs/gyms/tiny_sword/goals/gold_350k_wood_20k_goal.tres new file mode 100644 index 0000000..ca2dbf7 --- /dev/null +++ b/docs/gyms/tiny_sword/goals/gold_350k_wood_20k_goal.tres @@ -0,0 +1,24 @@ +[gd_resource type="Resource" script_class="GoalData" format=3 uid="uid://b6gb6vk0kgxnw"] + +[ext_resource type="Script" uid="uid://r4js5eajolio" path="res://core/goals/goal_requirement_data.gd" id="1_68ddp"] +[ext_resource type="Resource" uid="uid://w4u1hddplb4e" path="res://docs/gyms/tiny_sword/currencies/gold.tres" id="2_2b0j8"] +[ext_resource type="Resource" uid="uid://bgsk8h4w80h45" path="res://docs/gyms/tiny_sword/currencies/wood.tres" id="3_7tb0t"] +[ext_resource type="Script" uid="uid://dx4kmh33rrkpy" path="res://core/goals/goal_data.gd" id="4_20y5g"] + +[sub_resource type="Resource" id="Resource_tvl3d"] +script = ExtResource("1_68ddp") +currency = ExtResource("2_2b0j8") +amount_mantissa = 3.5 +amount_exponent = 5 + +[sub_resource type="Resource" id="Resource_hyry2"] +script = ExtResource("1_68ddp") +currency = ExtResource("3_7tb0t") +amount_mantissa = 2.0 +amount_exponent = 4 +metadata/_custom_type_script = "uid://r4js5eajolio" + +[resource] +script = ExtResource("4_20y5g") +id = &"gold_350k_wood_20k" +requirements = Array[ExtResource("1_68ddp")]([SubResource("Resource_tvl3d"), SubResource("Resource_hyry2")]) diff --git a/docs/gyms/tiny_sword/goals/gold_total_1300_goal.tres b/docs/gyms/tiny_sword/goals/gold_total_1300_goal.tres new file mode 100644 index 0000000..f4ef655 --- /dev/null +++ b/docs/gyms/tiny_sword/goals/gold_total_1300_goal.tres @@ -0,0 +1,16 @@ +[gd_resource type="Resource" script_class="GoalData" format=3 uid="uid://cts0407h130d6"] + +[ext_resource type="Script" uid="uid://r4js5eajolio" path="res://core/goals/goal_requirement_data.gd" id="1_pahmu"] +[ext_resource type="Resource" uid="uid://w4u1hddplb4e" path="res://docs/gyms/tiny_sword/currencies/gold.tres" id="2_oek0g"] +[ext_resource type="Script" uid="uid://dx4kmh33rrkpy" path="res://core/goals/goal_data.gd" id="3_3fgtm"] + +[sub_resource type="Resource" id="Resource_tvl3d"] +script = ExtResource("1_pahmu") +currency = ExtResource("2_oek0g") +amount_mantissa = 1.3 +amount_exponent = 3 + +[resource] +script = ExtResource("3_3fgtm") +id = &"gold_total_1300" +requirements = Array[ExtResource("1_pahmu")]([SubResource("Resource_tvl3d")]) diff --git a/docs/gyms/tiny_sword/goals/gold_total_13k_goal.tres b/docs/gyms/tiny_sword/goals/gold_total_13k_goal.tres new file mode 100644 index 0000000..7fe32b8 --- /dev/null +++ b/docs/gyms/tiny_sword/goals/gold_total_13k_goal.tres @@ -0,0 +1,16 @@ +[gd_resource type="Resource" script_class="GoalData" format=3 uid="uid://bo463s6jt0ep7"] + +[ext_resource type="Script" uid="uid://r4js5eajolio" path="res://core/goals/goal_requirement_data.gd" id="1_hj5d1"] +[ext_resource type="Resource" uid="uid://w4u1hddplb4e" path="res://docs/gyms/tiny_sword/currencies/gold.tres" id="2_w5qg8"] +[ext_resource type="Script" uid="uid://dx4kmh33rrkpy" path="res://core/goals/goal_data.gd" id="3_daoxn"] + +[sub_resource type="Resource" id="Resource_tvl3d"] +script = ExtResource("1_hj5d1") +currency = ExtResource("2_w5qg8") +amount_mantissa = 1.3 +amount_exponent = 4 + +[resource] +script = ExtResource("3_daoxn") +id = &"gold_total_13k" +requirements = Array[ExtResource("1_hj5d1")]([SubResource("Resource_tvl3d")]) diff --git a/docs/gyms/tiny_sword/goals/gold_total_30_goal.tres b/docs/gyms/tiny_sword/goals/gold_total_30_goal.tres new file mode 100644 index 0000000..985d28c --- /dev/null +++ b/docs/gyms/tiny_sword/goals/gold_total_30_goal.tres @@ -0,0 +1,16 @@ +[gd_resource type="Resource" script_class="GoalData" format=3 uid="uid://ik7t0r3su633"] + +[ext_resource type="Script" uid="uid://r4js5eajolio" path="res://core/goals/goal_requirement_data.gd" id="1_udubw"] +[ext_resource type="Resource" uid="uid://w4u1hddplb4e" path="res://docs/gyms/tiny_sword/currencies/gold.tres" id="2_lde8k"] +[ext_resource type="Script" uid="uid://dx4kmh33rrkpy" path="res://core/goals/goal_data.gd" id="3_ilpcf"] + +[sub_resource type="Resource" id="Resource_v4v16"] +script = ExtResource("1_udubw") +currency = ExtResource("2_lde8k") +amount_mantissa = 30.0 + +[resource] +script = ExtResource("3_ilpcf") +id = &"gold_total_30" +requirements = Array[ExtResource("1_udubw")]([SubResource("Resource_v4v16")]) +unlock_behavior = 0 diff --git a/docs/gyms/tiny_sword/goals/ts_goal_catalogue.tres b/docs/gyms/tiny_sword/goals/ts_goal_catalogue.tres new file mode 100644 index 0000000..d5067d8 --- /dev/null +++ b/docs/gyms/tiny_sword/goals/ts_goal_catalogue.tres @@ -0,0 +1,12 @@ +[gd_resource type="Resource" script_class="GoalCatalogue" format=3 uid="uid://cgt1mjir1v4br"] + +[ext_resource type="Script" uid="uid://dx4kmh33rrkpy" path="res://core/goals/goal_data.gd" id="1_6p6ue"] +[ext_resource type="Script" uid="uid://bfbp4mo8ys5p8" path="res://core/goals/goal_catalogue.gd" id="2_84pbn"] +[ext_resource type="Resource" uid="uid://ik7t0r3su633" path="res://docs/gyms/tiny_sword/goals/gold_total_30_goal.tres" id="2_xvtf2"] +[ext_resource type="Resource" uid="uid://cts0407h130d6" path="res://docs/gyms/tiny_sword/goals/gold_total_1300_goal.tres" id="3_wb626"] +[ext_resource type="Resource" uid="uid://bo463s6jt0ep7" path="res://docs/gyms/tiny_sword/goals/gold_total_13k_goal.tres" id="4_3oaoj"] +[ext_resource type="Resource" uid="uid://b6gb6vk0kgxnw" path="res://docs/gyms/tiny_sword/goals/gold_350k_wood_20k_goal.tres" id="5_or88d"] + +[resource] +script = ExtResource("2_84pbn") +goals = Array[ExtResource("1_6p6ue")]([ExtResource("2_xvtf2"), ExtResource("3_wb626"), ExtResource("4_3oaoj"), ExtResource("5_or88d")]) diff --git a/docs/gyms/tiny_sword/prestige/prestige_buff_catalogue.tres b/docs/gyms/tiny_sword/prestige/prestige_buff_catalogue.tres new file mode 100644 index 0000000..33ecc25 --- /dev/null +++ b/docs/gyms/tiny_sword/prestige/prestige_buff_catalogue.tres @@ -0,0 +1,10 @@ +[gd_resource type="Resource" script_class="PrestigeBuffCatalogue" format=3] + +[ext_resource type="Script" path="res://core/prestige/prestige_buff_node.gd" id="1_node"] +[ext_resource type="Script" path="res://core/prestige/prestige_buff_catalogue.gd" id="2_cat"] +[ext_resource type="Resource" path="res://docs/gyms/tiny_sword/prestige/prestige_buff_gold_boost.tres" id="3_gold"] +[ext_resource type="Resource" path="res://docs/gyms/tiny_sword/prestige/prestige_buff_farm_boost.tres" id="4_farm"] + +[resource] +script = ExtResource("2_cat") +nodes = Array[ExtResource("1_node")]([ExtResource("3_gold"), ExtResource("4_farm")]) diff --git a/docs/gyms/tiny_sword/prestige/prestige_buff_farm_boost.tres b/docs/gyms/tiny_sword/prestige/prestige_buff_farm_boost.tres new file mode 100644 index 0000000..d9bd1e2 --- /dev/null +++ b/docs/gyms/tiny_sword/prestige/prestige_buff_farm_boost.tres @@ -0,0 +1,13 @@ +[gd_resource type="Resource" script_class="PrestigeBuffNode" format=3 uid="uid://cvogxo0msis2w"] + +[ext_resource type="Script" uid="uid://mjyig8xlgki0" path="res://core/prestige/prestige_buff_node.gd" id="1_script"] + +[resource] +script = ExtResource("1_script") +id = &"farm_boost" +display_name = "Fertile Grounds" +description = "Permanently increases farm food production." +parent_ids = Array[StringName]([&"gold_boost"]) +effect_value = 2.0 +target_id = &"farm" +x_position = 1.0 diff --git a/docs/gyms/tiny_sword/prestige/prestige_buff_gold_boost.tres b/docs/gyms/tiny_sword/prestige/prestige_buff_gold_boost.tres new file mode 100644 index 0000000..9fab50b --- /dev/null +++ b/docs/gyms/tiny_sword/prestige/prestige_buff_gold_boost.tres @@ -0,0 +1,11 @@ +[gd_resource type="Resource" script_class="PrestigeBuffNode" format=3 uid="uid://vl3ot4t876pc"] + +[ext_resource type="Script" uid="uid://mjyig8xlgki0" path="res://core/prestige/prestige_buff_node.gd" id="1_script"] + +[resource] +script = ExtResource("1_script") +id = &"gold_boost" +display_name = "Golden Touch" +description = "Permanently increases gold mine production." +effect_value = 2.0 +target_id = &"goldmine" diff --git a/docs/gyms/tiny_sword/prestige/prestige_buff_graph_panel.tscn b/docs/gyms/tiny_sword/prestige/prestige_buff_graph_panel.tscn new file mode 100644 index 0000000..f05637f --- /dev/null +++ b/docs/gyms/tiny_sword/prestige/prestige_buff_graph_panel.tscn @@ -0,0 +1,69 @@ +[gd_scene format=3 uid="uid://dprbgokqitaav"] + +[ext_resource type="Script" uid="uid://n01pkakxllnj" path="res://core/prestige/prestige_buff_graph_panel.gd" id="1_panel"] +[ext_resource type="Script" uid="uid://uv5lxj6hmpk" path="res://core/prestige/prestige_buff_connection_overlay.gd" id="2_overlay"] +[ext_resource type="PackedScene" uid="uid://bqk8rwia7lpbg" path="res://core/prestige/prestige_buff_graph_tile.tscn" id="3_tile"] +[ext_resource type="Resource" uid="uid://vl3ot4t876pc" path="res://docs/gyms/tiny_sword/prestige/prestige_buff_gold_boost.tres" id="4_gold"] +[ext_resource type="Resource" uid="uid://cvogxo0msis2w" path="res://docs/gyms/tiny_sword/prestige/prestige_buff_farm_boost.tres" id="5_farm"] + +[node name="PrestigeBuffGraphPanel" type="PanelContainer" unique_id=947835716] +custom_minimum_size = Vector2(600, 400) +script = ExtResource("1_panel") + +[node name="MarginContainer" type="MarginContainer" parent="." unique_id=829451203] +layout_mode = 2 +theme_override_constants/margin_left = 16 +theme_override_constants/margin_top = 12 +theme_override_constants/margin_right = 16 +theme_override_constants/margin_bottom = 12 + +[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer" unique_id=637192845] +layout_mode = 2 + +[node name="Header" type="HBoxContainer" parent="MarginContainer/VBoxContainer" unique_id=372819465] +layout_mode = 2 + +[node name="TitleLabel" type="Label" parent="MarginContainer/VBoxContainer/Header" unique_id=518293746] +layout_mode = 2 +theme_override_font_sizes/font_size = 20 +text = "Prestige Buffs" + +[node name="CloseButton" type="Button" parent="MarginContainer/VBoxContainer/Header" unique_id=184756392] +layout_mode = 2 +size_flags_horizontal = 10 +theme_override_font_sizes/font_size = 16 +text = "✕" + +[node name="BalanceLabel" type="Label" parent="MarginContainer/VBoxContainer" unique_id=293847561] +layout_mode = 2 +theme_override_font_sizes/font_size = 14 +text = "Prestige Currency: 0" + +[node name="TilesArea" type="Control" parent="MarginContainer/VBoxContainer" unique_id=847291034] +layout_mode = 2 +size_flags_vertical = 3 +mouse_filter = 1 + +[node name="ConnectionOverlay" type="Control" parent="MarginContainer/VBoxContainer/TilesArea" unique_id=563829104] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +script = ExtResource("2_overlay") + +[node name="GoldBoostTile" parent="MarginContainer/VBoxContainer/TilesArea" unique_id=756192834 instance=ExtResource("3_tile")] +layout_mode = 0 +offset_right = 220.0 +offset_bottom = 100.0 +prestige_buff = ExtResource("4_gold") + +[node name="FarmBoostTile" parent="MarginContainer/VBoxContainer/TilesArea" unique_id=619283475 instance=ExtResource("3_tile")] +layout_mode = 0 +offset_left = 3.0 +offset_top = 121.0 +offset_right = 223.0 +offset_bottom = 221.0 +prestige_buff = ExtResource("5_farm") diff --git a/docs/gyms/tiny_sword/prestige/prestige_buff_wood_boost.tres b/docs/gyms/tiny_sword/prestige/prestige_buff_wood_boost.tres new file mode 100644 index 0000000..7a07a44 --- /dev/null +++ b/docs/gyms/tiny_sword/prestige/prestige_buff_wood_boost.tres @@ -0,0 +1,12 @@ +[gd_resource type="Resource" script_class="PrestigeBuffNode" format=3 uid="uid://bif136o485oqh"] + +[ext_resource type="Script" uid="uid://mjyig8xlgki0" path="res://core/prestige/prestige_buff_node.gd" id="1_42hk5"] + +[resource] +script = ExtResource("1_42hk5") +id = &"wood_boost" +display_name = "Wood Touch" +description = "Permanently increases forestry production." +parent_ids = Array[StringName]([&"farm_boost"]) +effect_value = 2.0 +target_id = &"forestry" diff --git a/docs/gyms/tiny_sword/prestige/primary_prestige.tres b/docs/gyms/tiny_sword/prestige/primary_prestige.tres new file mode 100644 index 0000000..60e81a8 --- /dev/null +++ b/docs/gyms/tiny_sword/prestige/primary_prestige.tres @@ -0,0 +1,9 @@ +[gd_resource type="Resource" script_class="PrestigeConfig" format=3 uid="uid://dwmfvmusfskk6"] + +[ext_resource type="Script" uid="uid://w34t58rpkoui" path="res://core/prestige/prestige_config.gd" id="1_3tg3a"] + +[resource] +script = ExtResource("1_3tg3a") +id = &"ascension" +prestige_currency_id = &"ascension" +basis = 3 diff --git a/docs/gyms/tiny_sword/research/farm_research.tres b/docs/gyms/tiny_sword/research/farm_research.tres new file mode 100644 index 0000000..8ed9eea --- /dev/null +++ b/docs/gyms/tiny_sword/research/farm_research.tres @@ -0,0 +1,13 @@ +[gd_resource type="Resource" script_class="ResearchData" format=3 uid="uid://50yq2hl3wfwq"] + +[ext_resource type="Texture2D" uid="uid://bgtt3wu43tajh" path="res://icon.svg" id="1_supp3"] +[ext_resource type="Script" uid="uid://m7baywrfnpn0" path="res://core/research/research_data.gd" id="2_rgx73"] + +[resource] +script = ExtResource("2_rgx73") +id = &"farm_research" +generator_id = &"farm" +name = "Farm Research" +description = "Research that improves farm production" +icon = ExtResource("1_supp3") +metadata/_custom_type_script = "uid://m7baywrfnpn0" diff --git a/docs/gyms/tiny_sword/research/forestry_research.tres b/docs/gyms/tiny_sword/research/forestry_research.tres new file mode 100644 index 0000000..d7c2741 --- /dev/null +++ b/docs/gyms/tiny_sword/research/forestry_research.tres @@ -0,0 +1,13 @@ +[gd_resource type="Resource" script_class="ResearchData" format=3 uid="uid://cvow4sj6a5h23"] + +[ext_resource type="Texture2D" uid="uid://bgtt3wu43tajh" path="res://icon.svg" id="1_qvnpw"] +[ext_resource type="Script" uid="uid://m7baywrfnpn0" path="res://core/research/research_data.gd" id="2_6lndp"] + +[resource] +script = ExtResource("2_6lndp") +id = &"forestry_research" +generator_id = &"forestry" +name = "Forestry Research" +description = "Research that improves forestry production" +icon = ExtResource("1_qvnpw") +metadata/_custom_type_script = "uid://m7baywrfnpn0" diff --git a/docs/gyms/tiny_sword/research/gold_mine_research.tres b/docs/gyms/tiny_sword/research/gold_mine_research.tres new file mode 100644 index 0000000..31640e6 --- /dev/null +++ b/docs/gyms/tiny_sword/research/gold_mine_research.tres @@ -0,0 +1,13 @@ +[gd_resource type="Resource" script_class="ResearchData" format=3 uid="uid://dif6w6kesmw6u"] + +[ext_resource type="Texture2D" uid="uid://bgtt3wu43tajh" path="res://icon.svg" id="1_2tci1"] +[ext_resource type="Script" uid="uid://m7baywrfnpn0" path="res://core/research/research_data.gd" id="1_yn72k"] + +[resource] +script = ExtResource("1_yn72k") +id = &"gold_mine_research" +generator_id = &"goldmine" +name = "Gold Mine Research" +description = "Research that improves gold mine production" +icon = ExtResource("1_2tci1") +metadata/_custom_type_script = "uid://m7baywrfnpn0" diff --git a/docs/gyms/tiny_sword/research/ts_research_catalogue.tres b/docs/gyms/tiny_sword/research/ts_research_catalogue.tres new file mode 100644 index 0000000..432e718 --- /dev/null +++ b/docs/gyms/tiny_sword/research/ts_research_catalogue.tres @@ -0,0 +1,12 @@ +[gd_resource type="Resource" script_class="ResearchCatalogue" format=3 uid="uid://umi37hotcq8m"] + +[ext_resource type="Script" uid="uid://m7baywrfnpn0" path="res://core/research/research_data.gd" id="1_wscpr"] +[ext_resource type="Resource" uid="uid://dif6w6kesmw6u" path="res://docs/gyms/tiny_sword/research/gold_mine_research.tres" id="2_migxn"] +[ext_resource type="Script" uid="uid://d2v6t6w2todfy" path="res://core/research/research_catalogue.gd" id="2_yh8dx"] +[ext_resource type="Resource" uid="uid://50yq2hl3wfwq" path="res://docs/gyms/tiny_sword/research/farm_research.tres" id="3_pdfmu"] +[ext_resource type="Resource" uid="uid://cvow4sj6a5h23" path="res://docs/gyms/tiny_sword/research/forestry_research.tres" id="4_jnaqq"] + +[resource] +script = ExtResource("2_yh8dx") +research_entries = Array[ExtResource("1_wscpr")]([ExtResource("2_migxn"), ExtResource("3_pdfmu"), ExtResource("4_jnaqq")]) +metadata/_custom_type_script = "uid://d2v6t6w2todfy" diff --git a/docs/gyms/tiny_sword/tiny_sword.tscn b/docs/gyms/tiny_sword/tiny_sword.tscn new file mode 100644 index 0000000..91b15d9 --- /dev/null +++ b/docs/gyms/tiny_sword/tiny_sword.tscn @@ -0,0 +1,125 @@ +[gd_scene format=3 uid="uid://dadeowsvaywpp"] + +[ext_resource type="Script" uid="uid://cv2132o4hi7q3" path="res://core/level_game_state.gd" id="1_tma5j"] +[ext_resource type="PackedScene" uid="uid://brgkkw2n3o1ox" path="res://docs/gyms/tiny_sword/buildings/gold_mine/gold_mine.tscn" id="2_2j2oc"] +[ext_resource type="Resource" uid="uid://iqphilgfp2i7" path="res://docs/gyms/tiny_sword/currencies/ts_currency_catalogue.tres" id="2_dwrof"] +[ext_resource type="PackedScene" uid="uid://cl05bdri38mxf" path="res://docs/gyms/tiny_sword/buildings/castle/castle.tscn" id="3_5ru58"] +[ext_resource type="Resource" uid="uid://bgmj3lep0wmtf" path="res://docs/gyms/tiny_sword/buffs/ts_buff_catalogue.tres" id="3_wl838"] +[ext_resource type="Resource" uid="uid://cgt1mjir1v4br" path="res://docs/gyms/tiny_sword/goals/ts_goal_catalogue.tres" id="4_x77df"] +[ext_resource type="Resource" uid="uid://umi37hotcq8m" path="res://docs/gyms/tiny_sword/research/ts_research_catalogue.tres" id="5_v0pty"] +[ext_resource type="Script" uid="uid://srkiu4qe8s2m" path="res://core/prestige/prestige_manager.gd" id="5_x77df"] +[ext_resource type="Resource" path="res://docs/gyms/tiny_sword/prestige/prestige_buff_catalogue.tres" id="6_asc"] +[ext_resource type="Resource" uid="uid://dwmfvmusfskk6" path="res://docs/gyms/tiny_sword/prestige/primary_prestige.tres" id="6_xnhlc"] +[ext_resource type="PackedScene" path="res://docs/gyms/tiny_sword/currency_panel.tscn" id="9_cpnl"] +[ext_resource type="PackedScene" uid="uid://djedqovgngrx5" path="res://docs/gyms/tiny_sword/buildings/farm/farm.tscn" id="10_1lv5i"] +[ext_resource type="PackedScene" uid="uid://dirvi76rkoowf" path="res://goals_debug_ui.tscn" id="10_qifrv"] +[ext_resource type="PackedScene" uid="uid://cf01wvy3f17i" path="res://docs/gyms/tiny_sword/buildings/forestry/forestry.tscn" id="11_pyqyw"] +[ext_resource type="PackedScene" uid="uid://bf8lqbexvnx6e" path="res://docs/gyms/tiny_sword/buildings/monastery/monastery.tscn" id="13_no27p"] +[ext_resource type="PackedScene" uid="uid://rejxvjwybkll" path="res://sandbox/tiny_swords/Terrain/Resources/Wood/Trees/tree_1.tscn" id="14_0cs5o"] +[ext_resource type="PackedScene" uid="uid://bp5ng4vu4ot4a" path="res://docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_tower.tscn" id="14_hum8s"] +[ext_resource type="Script" uid="uid://b6systw05frh0" path="res://core/edge_scroll_camera.gd" id="17_x77df"] +[ext_resource type="PackedScene" uid="uid://dprbgokqitaav" path="res://docs/gyms/tiny_sword/prestige/prestige_buff_graph_panel.tscn" id="18_pnl"] + +[node name="TinySwords" type="Node" unique_id=498237642] + +[node name="LevelGameState" type="Node" parent="." unique_id=1257082478] +script = ExtResource("1_tma5j") +currency_catalogue = ExtResource("2_dwrof") +buff_catalogue = ExtResource("3_wl838") +goal_catalogue = ExtResource("4_x77df") +research_catalogue = ExtResource("5_v0pty") +prestige_buff_catalogue = ExtResource("6_asc") +save_file_path = "user://tiny_sword_save.json" + +[node name="PrestigeManager" type="Node" parent="LevelGameState" unique_id=1019028144 node_paths=PackedStringArray("game_state")] +script = ExtResource("5_x77df") +config = ExtResource("6_xnhlc") +game_state = NodePath("..") + +[node name="CanvasLayer" type="CanvasLayer" parent="LevelGameState" unique_id=993804078] + +[node name="UI" type="Control" parent="LevelGameState/CanvasLayer" unique_id=1299828389] +layout_mode = 3 +anchors_preset = 0 + +[node name="CurrencyPanel" parent="LevelGameState/CanvasLayer/UI" unique_id=2000000001 instance=ExtResource("9_cpnl")] +layout_mode = 1 +offset_left = 0.0 +offset_top = 429.0 +offset_right = 250.0 +offset_bottom = 769.0 + +[node name="GoalsDebugUI" parent="LevelGameState/CanvasLayer/UI" unique_id=1494700185 instance=ExtResource("10_qifrv")] +visible = false +layout_mode = 1 +offset_left = 1433.0 +offset_top = 5.0 +offset_right = 1913.0 +offset_bottom = 263.0 + +[node name="PrestigeBuffToggle" type="Button" parent="LevelGameState/CanvasLayer/UI" unique_id=293847102] +layout_mode = 1 +offset_left = 1730.0 +offset_top = 64.0 +offset_right = 1857.0 +offset_bottom = 100.0 +toggle_mode = true +text = "Prestige Buffs" + +[node name="PrestigeBuffGraphPanel" parent="LevelGameState/CanvasLayer/UI" unique_id=847291035 instance=ExtResource("18_pnl")] +visible = false +layout_mode = 1 +offset_left = 400.0 +offset_top = 200.0 +offset_right = 1000.0 +offset_bottom = 700.0 + +[node name="World" type="Node2D" parent="LevelGameState" unique_id=2118297724] + +[node name="ForestProps" type="Node2D" parent="LevelGameState/World" unique_id=649424542] + +[node name="Tree1" parent="LevelGameState/World/ForestProps" unique_id=1917119095 instance=ExtResource("14_0cs5o")] +position = Vector2(1486, 543) + +[node name="Tree2" parent="LevelGameState/World/ForestProps" unique_id=1429193881 instance=ExtResource("14_0cs5o")] +position = Vector2(1510, 586) + +[node name="Tree3" parent="LevelGameState/World/ForestProps" unique_id=856028883 instance=ExtResource("14_0cs5o")] +position = Vector2(1600, 586) + +[node name="Tree4" parent="LevelGameState/World/ForestProps" unique_id=547138642 instance=ExtResource("14_0cs5o")] +position = Vector2(1482, 655) + +[node name="Tree5" parent="LevelGameState/World/ForestProps" unique_id=660535343 instance=ExtResource("14_0cs5o")] +position = Vector2(1568, 656) + +[node name="Tree6" parent="LevelGameState/World/ForestProps" unique_id=894701042 instance=ExtResource("14_0cs5o")] +position = Vector2(1665, 650) + +[node name="GoldMine" parent="LevelGameState/World" unique_id=341660167 instance=ExtResource("2_2j2oc")] +position = Vector2(274, 429) + +[node name="Castle" parent="LevelGameState/World" unique_id=1966557957 instance=ExtResource("3_5ru58")] +position = Vector2(853, 357) + +[node name="Farm" parent="LevelGameState/World" unique_id=283335851 instance=ExtResource("10_1lv5i")] +position = Vector2(380, 684) + +[node name="Forestry" parent="LevelGameState/World" unique_id=726284577 instance=ExtResource("11_pyqyw")] +position = Vector2(825, 915) + +[node name="Monastery" parent="LevelGameState/World" unique_id=1545930496 instance=ExtResource("13_no27p")] +position = Vector2(105, 920) + +[node name="AlchemyTower" parent="LevelGameState/World" unique_id=51852160 instance=ExtResource("14_hum8s")] +position = Vector2(1239, 775) + +[node name="Camera2D" type="Camera2D" parent="." unique_id=1494908331] +position = Vector2(5, 0) +offset = Vector2(960, 540) +script = ExtResource("17_x77df") +clamp_enabled = true +clamp_min = Vector2(-1920, -1080) +clamp_max = Vector2(1920, 1080) + +[connection signal="pressed" from="LevelGameState/CanvasLayer/UI/PrestigeBuffToggle" to="LevelGameState/CanvasLayer/UI/PrestigeBuffGraphPanel" method="_on_prestige_buff_toggle_pressed"] diff --git a/docs/museums/big_number_museum.tscn b/docs/museums/big_number_museum.tscn index f5ce5fe..d8d394b 100644 --- a/docs/museums/big_number_museum.tscn +++ b/docs/museums/big_number_museum.tscn @@ -2,7 +2,7 @@ [ext_resource type="Script" uid="uid://coasop1lyw5rh" path="res://docs/museums/big_number_museum.gd" id="1_muwei"] [ext_resource type="PackedScene" uid="uid://btkxru2gdjsgc" path="res://currency_tile.tscn" id="2_a7k0t"] -[ext_resource type="PackedScene" uid="uid://ckos7f22pnmyh" path="res://generator_container.tscn" id="2_h2bxk"] +[ext_resource type="PackedScene" uid="uid://ckos7f22pnmyh" path="res://core/generator/generator_container.tscn" id="2_h2bxk"] [node name="BigNumberMuseum" type="Control" unique_id=1249411984] layout_mode = 3 diff --git a/docs/museums/edge_scroll_camera.tscn b/docs/museums/edge_scroll_camera.tscn new file mode 100644 index 0000000..108e56f --- /dev/null +++ b/docs/museums/edge_scroll_camera.tscn @@ -0,0 +1,42 @@ +[gd_scene format=3 uid="uid://5vli1vqiqdrl"] + +[ext_resource type="Script" uid="uid://b6systw05frh0" path="res://core/edge_scroll_camera.gd" id="1_08uhj"] +[ext_resource type="Texture2D" uid="uid://bgtt3wu43tajh" path="res://icon.svg" id="2_c5cli"] + +[node name="EdgeScrollCamera" type="Node2D" unique_id=1977543223] + +[node name="Camera2D" type="Camera2D" parent="." unique_id=455629291] +offset = Vector2(960, 540) +script = ExtResource("1_08uhj") +clamp_enabled = true +clamp_max = Vector2(3000, 2000) + +[node name="Icon" type="Sprite2D" parent="." unique_id=629901180] +position = Vector2(988, 485) +texture = ExtResource("2_c5cli") + +[node name="Icon2" type="Sprite2D" parent="." unique_id=919808260] +position = Vector2(1714, 130) +texture = ExtResource("2_c5cli") + +[node name="Icon3" type="Sprite2D" parent="." unique_id=368045162] +position = Vector2(1750, 957) +texture = ExtResource("2_c5cli") + +[node name="Icon6" type="Sprite2D" parent="." unique_id=1755994416] +position = Vector2(2641, 1655) +rotation = 0.9242716 +texture = ExtResource("2_c5cli") + +[node name="Icon7" type="Sprite2D" parent="." unique_id=460486605] +position = Vector2(895.00024, 1641) +rotation = 0.9242716 +texture = ExtResource("2_c5cli") + +[node name="Icon4" type="Sprite2D" parent="." unique_id=957351194] +position = Vector2(134, 962) +texture = ExtResource("2_c5cli") + +[node name="Icon5" type="Sprite2D" parent="." unique_id=2077463270] +position = Vector2(137, 129) +texture = ExtResource("2_c5cli") diff --git a/goals_debug_ui.gd b/goals_debug_ui.gd index 6cfa94e..e6443d1 100644 --- a/goals_debug_ui.gd +++ b/goals_debug_ui.gd @@ -1,11 +1,9 @@ extends PanelContainer class GoalDefinition extends RefCounted: - var target_generator_id: StringName var goal: GoalData - func _init(target_id: StringName, goal_data: GoalData) -> void: - target_generator_id = target_id + func _init(goal_data: GoalData) -> void: goal = goal_data const GOAL_DATA_SCRIPT: Script = preload("res://core/goals/goal_data.gd") @@ -23,69 +21,59 @@ class GoalRow extends RefCounted: requirements_label = requirements button = unlock_button -@onready var _summary_label: Label = $MarginContainer/VBoxContainer/SummaryLabel -@onready var _goals_list: VBoxContainer = $MarginContainer/VBoxContainer/ScrollContainer/GoalsList +@onready var _summary_label: Label = $MarginContainer/ScrollContainer/VBoxContainer/SummaryLabel +@onready var _goals_list: VBoxContainer = $MarginContainer/ScrollContainer/VBoxContainer/ScrollContainer/GoalsList +@onready var game_state: LevelGameState = find_parent("LevelGameState") 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() - _load_goals() - _build_goal_rows() - GameState.currency_changed.connect(_on_currency_changed) - GameState.generator_state_changed.connect(_on_generator_state_changed) + if game_state: + game_state.currency_changed.connect(_on_currency_changed) + game_state.generator_state_changed.connect(_on_generator_state_changed) + game_state.goal_completed.connect(_on_goal_completed) + + if not game_state.get_all_goals().is_empty(): + _load_goals() + _build_goal_rows() + else: + game_state.ready.connect(_on_level_state_ready) + _refresh_ui() _refresh_ui.call_deferred() func _on_currency_changed(_currency_id: StringName, _new_amount: BigNumber) -> void: _refresh_ui() -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 - _refresh_generator_lookup(generator_id) +func _on_goal_completed(goal_id: StringName) -> void: + _refresh_ui() + +func _on_generator_state_changed(_generator_id: StringName, _state: Dictionary) -> void: + _refresh_ui() + +func _on_level_state_ready() -> void: + _load_goals() + _build_goal_rows() _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 all_goals_raw: Array = game_state.get_all_goals() if game_state else [] var seen_goal_ids: Dictionary = {} - for generator_node in generator_nodes: - var generator: CurrencyGenerator = generator_node as CurrencyGenerator - if generator == null: + + for goal_raw in all_goals_raw: + var goal: GoalData = goal_raw as GoalData + if goal == null or not goal.has_id(): continue - - var generator_id: StringName = generator.get_generator_id() - if generator_id == &"": - continue - _generators_by_id[String(generator_id)] = generator - - if generator.data == null: - continue - if not generator.data.has_unlock_goal(): - continue - - var resolved_goal: GoalData = generator.data.unlock_goal - if resolved_goal == null: - continue - if not bool(resolved_goal.is_valid()): - continue - var goal_key: String = String(resolved_goal.get("id")) + + 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 - _loaded_goals.append(GoalDefinition.new(generator_id, resolved_goal)) + _loaded_goals.append(GoalDefinition.new(goal)) func _build_goal_rows() -> void: _rows_by_goal_id.clear() @@ -102,7 +90,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.id), String(goal.target_generator_id)] + title_label.text = String(goal.goal.id) var status_label: Label = Label.new() status_label.custom_minimum_size = Vector2(95.0, 0.0) @@ -116,10 +104,10 @@ func _build_goal_rows() -> void: unlock_button.disabled = true unlock_button.pressed.connect(_on_unlock_pressed.bind(goal.goal.id)) + row_container.add_child(unlock_button) 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.goal.id)] = GoalRow.new(goal, status_label, requirements_label, unlock_button) @@ -150,29 +138,20 @@ func _refresh_ui() -> void: func _refresh_goal_row(row: GoalRow) -> void: var goal: GoalDefinition = row.goal 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 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 + var can_click: bool = met and not already_unlocked row.requirements_label.text = _get_requirements_text(goal) - row.button.disabled = not can_unlock + row.button.disabled = not can_click if already_unlocked: row.button.text = "Done" - elif is_manual_goal: - row.button.text = "Unlock" else: - row.button.text = "Auto" + row.button.text = "Unlock" if already_unlocked: row.status_label.text = "Unlocked" return - if not can_resolve_target: - row.status_label.text = "Missing" - return - if met: row.status_label.text = "Ready" return @@ -184,14 +163,17 @@ func _is_goal_met(goal: GoalDefinition) -> bool: return false if goal.goal == null: return false + if game_state == null: + return false - return bool(goal.goal.is_met()) + return bool(game_state.is_goal_met(goal.goal)) func _is_goal_completed(goal: GoalDefinition) -> bool: - return ( - GameState.is_generator_unlocked(goal.target_generator_id) - and GameState.is_generator_available(goal.target_generator_id) - ) + if goal.goal == null or goal.goal.id.is_empty(): + return false + if game_state == null: + return false + return game_state.is_goal_completed(goal.goal.id) func _on_unlock_pressed(goal_id: StringName) -> void: var row: GoalRow = _rows_by_goal_id.get(String(goal_id)) @@ -201,91 +183,18 @@ func _on_unlock_pressed(goal_id: StringName) -> void: var goal: GoalDefinition = row.goal if _is_goal_completed(goal): return - if not _can_resolve_target(goal.target_generator_id): - return if not _is_goal_met(goal): return - var generator: CurrencyGenerator = _get_generator_for_goal(goal) - if generator == null: - return - - if not generator.try_unlock_from_goal(): + if game_state == null: return - print("[GoalsDebugUI] Unlocked goal '%s' -> %s" % [String(goal.goal.id), String(goal.target_generator_id)]) + if goal.goal.unlock_behavior == GoalData.UnlockBehavior.MANUAL: + game_state._complete_goal_manually(goal.goal.id) + + print("[GoalsDebugUI] Unlocked goal '%s'" % String(goal.goal.id)) _refresh_ui() -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("Goals debug UI target generator not found in scene/state: '%s'" % target_key) - return false - -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 - 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 - _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: return "" @@ -293,16 +202,22 @@ func _get_requirements_text(goal: GoalDefinition) -> String: return "" if goal.goal.get_script() != GOAL_DATA_SCRIPT: return "" + if game_state == null: + return "" var parts: Array[String] = [] - for requirement_resource in goal.goal.get_valid_requirements(): + for requirement_resource in goal.goal.get_requirements(): if requirement_resource == null: continue if requirement_resource.get_script() != GOAL_REQUIREMENT_SCRIPT: continue - 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 currency: Currency = requirement_resource.get_currency() + if currency == null: + continue + + var requirement_currency_id: StringName = game_state.get_currency_id(currency) + var total_amount: BigNumber = game_state.get_total_currency_acquired_by_id(requirement_currency_id) var required_amount: BigNumber = requirement_resource.get_amount() parts.append( "%s %s / %s" % [ @@ -311,7 +226,19 @@ func _get_requirements_text(goal: GoalDefinition) -> String: required_amount.to_string_suffix(2) ] ) + + if parts.is_empty(): + return "No requirements" return " | ".join(parts) func _currency_label(currency_id: StringName) -> String: - return GameState.get_currency_name(currency_id) + if game_state == null: + return "Unknown" + return game_state.get_currency_name(currency_id) + +func _exit_tree() -> void: + if game_state: + game_state.currency_changed.disconnect(_on_currency_changed) + game_state.generator_state_changed.disconnect(_on_generator_state_changed) + game_state.goal_completed.disconnect(_on_goal_completed) + game_state.ready.disconnect(_on_level_state_ready) diff --git a/goals_debug_ui.tscn b/goals_debug_ui.tscn index 23306f7..667f40e 100644 --- a/goals_debug_ui.tscn +++ b/goals_debug_ui.tscn @@ -14,26 +14,29 @@ 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" unique_id=1555356181] +[node name="ScrollContainer" type="ScrollContainer" parent="MarginContainer" unique_id=2143335122] +layout_mode = 2 + +[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/ScrollContainer" unique_id=1555356181] layout_mode = 2 size_flags_horizontal = 3 size_flags_vertical = 3 theme_override_constants/separation = 6 -[node name="TitleLabel" type="Label" parent="MarginContainer/VBoxContainer" unique_id=811777252] +[node name="TitleLabel" type="Label" parent="MarginContainer/ScrollContainer/VBoxContainer" unique_id=811777252] layout_mode = 2 text = "Goals Debug" -[node name="SummaryLabel" type="Label" parent="MarginContainer/VBoxContainer" unique_id=17196021] +[node name="SummaryLabel" type="Label" parent="MarginContainer/ScrollContainer/VBoxContainer" unique_id=17196021] layout_mode = 2 text = "Goals: 0 | Ready: 0 | Unlocked: 0" -[node name="ScrollContainer" type="ScrollContainer" parent="MarginContainer/VBoxContainer" unique_id=1105060075] +[node name="ScrollContainer" type="ScrollContainer" parent="MarginContainer/ScrollContainer/VBoxContainer" unique_id=1105060075] custom_minimum_size = Vector2(0, 170) layout_mode = 2 size_flags_vertical = 3 -[node name="GoalsList" type="VBoxContainer" parent="MarginContainer/VBoxContainer/ScrollContainer" unique_id=1684633480] +[node name="GoalsList" type="VBoxContainer" parent="MarginContainer/ScrollContainer/VBoxContainer/ScrollContainer" unique_id=1684633480] layout_mode = 2 size_flags_horizontal = 3 size_flags_vertical = 3 diff --git a/idles/buffs/bigger_forest.tres b/idles/buffs/bigger_forest.tres deleted file mode 100644 index 5b4050e..0000000 --- a/idles/buffs/bigger_forest.tres +++ /dev/null @@ -1,18 +0,0 @@ -[gd_resource type="Resource" script_class="GeneratorBuffData" format=3 uid="uid://mvgfe3nc7uwa"] - -[ext_resource type="Resource" uid="uid://bnhqk8b31mm4e" path="res://idles/currencies/knowledge.tres" id="1_1db6v"] -[ext_resource type="Resource" uid="uid://brqaojindcxa5" path="res://idles/currencies/magic.tres" id="2_pg1j1"] -[ext_resource type="Script" uid="uid://dnhocfsuvmeyb" path="res://core/generator/generator_buff_data.gd" id="3_t80he"] -[ext_resource type="Resource" uid="uid://bmlhoeasl7xor" path="res://idles/goals/magic_total_13k.tres" id="4_83vra"] - -[resource] -script = ExtResource("3_t80he") -id = &"bigger_forest" -text = "Bigger Forest" -max_level = 10 -unlock_goal = ExtResource("4_83vra") -effect_increment = 1.0 -cost_currency = ExtResource("1_1db6v") -base_cost_mantissa = 250.0 -cost_multiplier = 1.7 -resource_target_currency = ExtResource("2_pg1j1") diff --git a/idles/buffs/library_auto_flux.tres b/idles/buffs/library_auto_flux.tres deleted file mode 100644 index 7608580..0000000 --- a/idles/buffs/library_auto_flux.tres +++ /dev/null @@ -1,18 +0,0 @@ -[gd_resource type="Resource" script_class="GeneratorBuffData" format=3 uid="uid://tu63jy51yigb"] - -[ext_resource type="Resource" uid="uid://bnhqk8b31mm4e" path="res://idles/currencies/knowledge.tres" id="1_78qkq"] -[ext_resource type="Resource" uid="uid://brqaojindcxa5" path="res://idles/currencies/magic.tres" id="2_b57xf"] -[ext_resource type="Script" uid="uid://dnhocfsuvmeyb" path="res://core/generator/generator_buff_data.gd" id="3_nc8nr"] -[ext_resource type="Resource" uid="uid://bmlhoeasl7xor" path="res://idles/goals/magic_total_13k.tres" id="4_78qkq"] - -[resource] -script = ExtResource("3_nc8nr") -id = &"library_auto_flux" -text = "Library Dynamo" -max_level = 10 -unlock_goal = ExtResource("4_78qkq") -effect_increment = 1.0 -cost_currency = ExtResource("1_78qkq") -base_cost_mantissa = 100.0 -cost_multiplier = 1.7 -resource_target_currency = ExtResource("2_b57xf") diff --git a/idles/buffs/orb_auto_flux.tres b/idles/buffs/orb_auto_flux.tres deleted file mode 100644 index 858388e..0000000 --- a/idles/buffs/orb_auto_flux.tres +++ /dev/null @@ -1,18 +0,0 @@ -[gd_resource type="Resource" script_class="GeneratorBuffData" format=3 uid="uid://ceugcxmassmpk"] - -[ext_resource type="Resource" uid="uid://bnhqk8b31mm4e" path="res://idles/currencies/knowledge.tres" id="1_lnp8f"] -[ext_resource type="Script" uid="uid://dnhocfsuvmeyb" path="res://core/generator/generator_buff_data.gd" id="1_r7ak1"] -[ext_resource type="Resource" uid="uid://brqaojindcxa5" path="res://idles/currencies/magic.tres" id="2_h3we5"] -[ext_resource type="Resource" uid="uid://qyxct5gbrxwa" path="res://idles/goals/magic_total_30.tres" id="4_000a0"] - -[resource] -script = ExtResource("1_r7ak1") -id = &"magic_auto_flux" -text = "Arcane Dynamo" -max_level = 10 -unlock_goal = ExtResource("4_000a0") -effect_increment = 1.0 -cost_currency = ExtResource("1_lnp8f") -base_cost_mantissa = 25.0 -cost_multiplier = 1.7 -resource_target_currency = ExtResource("2_h3we5") diff --git a/idles/buffs/orb_click_focus.tres b/idles/buffs/orb_click_focus.tres deleted file mode 100644 index f7b11e6..0000000 --- a/idles/buffs/orb_click_focus.tres +++ /dev/null @@ -1,18 +0,0 @@ -[gd_resource type="Resource" script_class="GeneratorBuffData" format=3 uid="uid://6i3fcygusuqf"] - -[ext_resource type="Script" uid="uid://dnhocfsuvmeyb" path="res://core/generator/generator_buff_data.gd" id="1_1wyaq"] -[ext_resource type="Resource" uid="uid://brqaojindcxa5" path="res://idles/currencies/magic.tres" id="2_0j56j"] -[ext_resource type="Resource" uid="uid://qyxct5gbrxwa" path="res://idles/goals/magic_total_30.tres" id="3_bsqao"] - -[resource] -script = ExtResource("1_1wyaq") -id = &"magic_click_focus" -kind = 1 -text = "Apprentice Gloves" -max_level = 4 -unlock_goal = ExtResource("3_bsqao") -effect_increment = 1.0 -cost_currency = ExtResource("2_0j56j") -base_cost_mantissa = 8.0 -cost_multiplier = 1.55 -resource_target_currency = ExtResource("2_0j56j") diff --git a/idles/buffs/orb_summon_spirit.tres b/idles/buffs/orb_summon_spirit.tres deleted file mode 100644 index e160870..0000000 --- a/idles/buffs/orb_summon_spirit.tres +++ /dev/null @@ -1,20 +0,0 @@ -[gd_resource type="Resource" script_class="GeneratorBuffData" format=3 uid="uid://coi7k1cx4p4hr"] - -[ext_resource type="Resource" uid="uid://brqaojindcxa5" path="res://idles/currencies/magic.tres" id="1_aawxd"] -[ext_resource type="Resource" uid="uid://l0pn6mlcer7t" path="res://idles/currencies/spirit.tres" id="2_0eqxt"] -[ext_resource type="Script" uid="uid://dnhocfsuvmeyb" path="res://core/generator/generator_buff_data.gd" id="3_0ov1n"] -[ext_resource type="Resource" uid="uid://qyxct5gbrxwa" path="res://idles/goals/magic_total_30.tres" id="4_ifsqd"] - -[resource] -script = ExtResource("3_0ov1n") -id = &"summon_spirit" -kind = 2 -text = "Summon Spirit" -unlock_goal = ExtResource("4_ifsqd") -effect_increment = 1.0 -cost_currency = ExtResource("1_aawxd") -base_cost_mantissa = 200.0 -cost_multiplier = 1.15 -resource_target_currency = ExtResource("2_0eqxt") -resource_purchase_base_mantissa = 1.0 -resource_purchase_increment_multiplier = 1.0 diff --git a/idles/currencies/knowledge.tres b/idles/currencies/knowledge.tres deleted file mode 100644 index 0164e38..0000000 --- a/idles/currencies/knowledge.tres +++ /dev/null @@ -1,10 +0,0 @@ -[gd_resource type="Resource" script_class="Currency" format=3 uid="uid://bnhqk8b31mm4e"] - -[ext_resource type="Script" uid="uid://dtgqjf3bl7pm8" path="res://core/currency/currency.gd" id="1_72iuq"] -[ext_resource type="Texture2D" uid="uid://bgtt3wu43tajh" path="res://icon.svg" id="2_x2h5x"] - -[resource] -script = ExtResource("1_72iuq") -id = &"knowledge" -display_name = "Knowledge" -icon = ExtResource("2_x2h5x") diff --git a/idles/currencies/magic.tres b/idles/currencies/magic.tres deleted file mode 100644 index 5b7d307..0000000 --- a/idles/currencies/magic.tres +++ /dev/null @@ -1,10 +0,0 @@ -[gd_resource type="Resource" script_class="Currency" format=3 uid="uid://brqaojindcxa5"] - -[ext_resource type="Script" uid="uid://dtgqjf3bl7pm8" path="res://core/currency/currency.gd" id="1_x4uiu"] -[ext_resource type="Texture2D" uid="uid://bgtt3wu43tajh" path="res://icon.svg" id="2_52ar0"] - -[resource] -script = ExtResource("1_x4uiu") -id = &"magic" -display_name = "Magic" -icon = ExtResource("2_52ar0") diff --git a/idles/currencies/spirit.tres b/idles/currencies/spirit.tres deleted file mode 100644 index 6497a2b..0000000 --- a/idles/currencies/spirit.tres +++ /dev/null @@ -1,9 +0,0 @@ -[gd_resource type="Resource" script_class="Currency" format=3 uid="uid://l0pn6mlcer7t"] - -[ext_resource type="Script" uid="uid://dtgqjf3bl7pm8" path="res://core/currency/currency.gd" id="1_m14p5"] - -[resource] -script = ExtResource("1_m14p5") -id = &"spirit" -display_name = "Spirit" -metadata/_custom_type_script = "uid://dtgqjf3bl7pm8" diff --git a/idles/currencies/wood.tres b/idles/currencies/wood.tres deleted file mode 100644 index 41b6ffa..0000000 --- a/idles/currencies/wood.tres +++ /dev/null @@ -1,10 +0,0 @@ -[gd_resource type="Resource" script_class="Currency" format=3 uid="uid://cythfovqgqlyh"] - -[ext_resource type="Texture2D" uid="uid://bgtt3wu43tajh" path="res://icon.svg" id="1_t8m5x"] -[ext_resource type="Script" uid="uid://dtgqjf3bl7pm8" path="res://core/currency/currency.gd" id="2_137dc"] - -[resource] -script = ExtResource("2_137dc") -id = &"wood" -display_name = "Wood" -icon = ExtResource("1_t8m5x") diff --git a/idles/generators/forestry.tres b/idles/generators/forestry.tres deleted file mode 100644 index d51df71..0000000 --- a/idles/generators/forestry.tres +++ /dev/null @@ -1,22 +0,0 @@ -[gd_resource type="Resource" script_class="CurrencyGeneratorData" format=3 uid="uid://df5k58yu1g6rf"] - -[ext_resource type="Script" uid="uid://dnhocfsuvmeyb" path="res://core/generator/generator_buff_data.gd" id="1_ffn26"] -[ext_resource type="Resource" uid="uid://mvgfe3nc7uwa" path="res://idles/buffs/bigger_forest.tres" id="2_rptf4"] -[ext_resource type="Resource" uid="uid://l0pn6mlcer7t" path="res://idles/currencies/spirit.tres" id="3_rptf4"] -[ext_resource type="Script" uid="uid://b00tqsuhxdy0d" path="res://core/generator/currency_generator_data.gd" id="5_43n1y"] -[ext_resource type="Resource" uid="uid://bmlhoeasl7xor" path="res://idles/goals/magic_total_13k.tres" id="6_ffn26"] - -[resource] -script = ExtResource("5_43n1y") -id = &"Wood" -name = "Forestry" -starts_unlocked = false -starts_available = false -initial_owned = 1 -purchase_currency = ExtResource("3_rptf4") -unlock_goal = ExtResource("6_ffn26") -unlock_goal_behavior = 1 -initial_cost = 1.0 -initial_productivity = 20.0 -buffs = Array[ExtResource("1_ffn26")]([ExtResource("2_rptf4")]) -metadata/_custom_type_script = "uid://b00tqsuhxdy0d" diff --git a/idles/generators/library.tres b/idles/generators/library.tres deleted file mode 100644 index 0e5100a..0000000 --- a/idles/generators/library.tres +++ /dev/null @@ -1,25 +0,0 @@ -[gd_resource type="Resource" script_class="CurrencyGeneratorData" format=3 uid="uid://04pmc034qupd"] - -[ext_resource type="Script" uid="uid://dnhocfsuvmeyb" path="res://core/generator/generator_buff_data.gd" id="1_jemvk"] -[ext_resource type="Resource" uid="uid://tu63jy51yigb" path="res://idles/buffs/library_auto_flux.tres" id="2_fcbji"] -[ext_resource type="Resource" uid="uid://l0pn6mlcer7t" path="res://idles/currencies/spirit.tres" id="3_fcbji"] -[ext_resource type="Script" uid="uid://b00tqsuhxdy0d" path="res://core/generator/currency_generator_data.gd" id="3_xj12v"] -[ext_resource type="Resource" uid="uid://c4mkxj4ubhsi0" path="res://idles/goals/magic_total_1300.tres" id="4_1h03m"] - -[resource] -script = ExtResource("3_xj12v") -id = &"Knowledge" -name = "Library" -starts_unlocked = false -starts_available = false -initial_owned = 1 -purchase_currency = ExtResource("3_fcbji") -unlock_goal = ExtResource("4_1h03m") -unlock_goal_behavior = 1 -initial_cost = 1.0 -coefficient = 1.0 -initial_time = 3.0 -initial_revenue = 60.0 -initial_productivity = 20.0 -buffs = Array[ExtResource("1_jemvk")]([ExtResource("2_fcbji")]) -metadata/_custom_type_script = "uid://b00tqsuhxdy0d" diff --git a/idles/generators/orb.tres b/idles/generators/orb.tres deleted file mode 100644 index 0420434..0000000 --- a/idles/generators/orb.tres +++ /dev/null @@ -1,19 +0,0 @@ -[gd_resource type="Resource" script_class="CurrencyGeneratorData" format=3 uid="uid://co0mcc2kvcpo5"] - -[ext_resource type="Script" uid="uid://b00tqsuhxdy0d" path="res://core/generator/currency_generator_data.gd" id="1_c6y77"] -[ext_resource type="Script" uid="uid://dnhocfsuvmeyb" path="res://core/generator/generator_buff_data.gd" id="1_t6lg0"] -[ext_resource type="Resource" uid="uid://ceugcxmassmpk" path="res://idles/buffs/orb_auto_flux.tres" id="2_x505b"] -[ext_resource type="Resource" uid="uid://6i3fcygusuqf" path="res://idles/buffs/orb_click_focus.tres" id="3_fsxdm"] -[ext_resource type="Resource" uid="uid://coi7k1cx4p4hr" path="res://idles/buffs/orb_summon_spirit.tres" id="4_5v0af"] -[ext_resource type="Resource" uid="uid://l0pn6mlcer7t" path="res://idles/currencies/spirit.tres" id="4_jpwus"] - -[resource] -script = ExtResource("1_c6y77") -id = &"Magic" -name = "Magic Orb" -purchase_currency = ExtResource("4_jpwus") -initial_cost = 1.0 -coefficient = 1.0 -initial_productivity = 20.0 -buffs = Array[ExtResource("1_t6lg0")]([ExtResource("2_x505b"), ExtResource("3_fsxdm"), ExtResource("4_5v0af")]) -metadata/_custom_type_script = "uid://b00tqsuhxdy0d" diff --git a/idles/generators/spirit_factory.tres b/idles/generators/spirit_factory.tres deleted file mode 100644 index 61ab4b3..0000000 --- a/idles/generators/spirit_factory.tres +++ /dev/null @@ -1,18 +0,0 @@ -[gd_resource type="Resource" script_class="CurrencyGeneratorData" format=3 uid="uid://ri1ggb756753"] - -[ext_resource type="Script" uid="uid://dnhocfsuvmeyb" path="res://core/generator/generator_buff_data.gd" id="1_tgm58"] -[ext_resource type="Script" uid="uid://b00tqsuhxdy0d" path="res://core/generator/currency_generator_data.gd" id="2_1qpvc"] -[ext_resource type="Resource" uid="uid://brqaojindcxa5" path="res://idles/currencies/magic.tres" id="2_chq14"] -[ext_resource type="Resource" uid="uid://qyxct5gbrxwa" path="res://idles/goals/magic_total_30.tres" id="4_tj1lt"] - -[resource] -script = ExtResource("2_1qpvc") -id = &"spirit_factory" -name = "Spirit Factory" -starts_unlocked = false -starts_available = false -purchase_currency = ExtResource("2_chq14") -unlock_goal = ExtResource("4_tj1lt") -unlock_goal_behavior = 1 -initial_cost = 200.0 -metadata/_custom_type_script = "uid://b00tqsuhxdy0d" diff --git a/idles/goals/magic_350k_wood_20k.tres b/idles/goals/magic_350k_wood_20k.tres deleted file mode 100644 index 227e33a..0000000 --- a/idles/goals/magic_350k_wood_20k.tres +++ /dev/null @@ -1,24 +0,0 @@ -[gd_resource type="Resource" script_class="GoalData" format=3 uid="uid://oh1a4tneuons"] - -[ext_resource type="Script" uid="uid://r4js5eajolio" path="res://core/goals/goal_requirement_data.gd" id="1_pj6se"] -[ext_resource type="Resource" uid="uid://brqaojindcxa5" path="res://idles/currencies/magic.tres" id="2_lsxf0"] -[ext_resource type="Script" uid="uid://dx4kmh33rrkpy" path="res://core/goals/goal_data.gd" id="3_hyry2"] -[ext_resource type="Resource" uid="uid://cythfovqgqlyh" path="res://idles/currencies/wood.tres" id="3_lsxf0"] - -[sub_resource type="Resource" id="Resource_tvl3d"] -script = ExtResource("1_pj6se") -currency = ExtResource("2_lsxf0") -amount_mantissa = 3.5 -amount_exponent = 5 - -[sub_resource type="Resource" id="Resource_hyry2"] -script = ExtResource("1_pj6se") -currency = ExtResource("3_lsxf0") -amount_mantissa = 2.0 -amount_exponent = 4 -metadata/_custom_type_script = "uid://r4js5eajolio" - -[resource] -script = ExtResource("3_hyry2") -id = &"magic_350k_wood_20k" -requirements = Array[ExtResource("1_pj6se")]([SubResource("Resource_tvl3d"), SubResource("Resource_hyry2")]) diff --git a/idles/goals/magic_total_1300.tres b/idles/goals/magic_total_1300.tres deleted file mode 100644 index 49e8568..0000000 --- a/idles/goals/magic_total_1300.tres +++ /dev/null @@ -1,16 +0,0 @@ -[gd_resource type="Resource" script_class="GoalData" format=3 uid="uid://c4mkxj4ubhsi0"] - -[ext_resource type="Script" uid="uid://dx4kmh33rrkpy" path="res://core/goals/goal_data.gd" id="1_b11ou"] -[ext_resource type="Script" uid="uid://r4js5eajolio" path="res://core/goals/goal_requirement_data.gd" id="2_d507t"] -[ext_resource type="Resource" uid="uid://brqaojindcxa5" path="res://idles/currencies/magic.tres" id="3_tu3fx"] - -[sub_resource type="Resource" id="Resource_tvl3d"] -script = ExtResource("2_d507t") -currency = ExtResource("3_tu3fx") -amount_mantissa = 1.3 -amount_exponent = 3 - -[resource] -script = ExtResource("1_b11ou") -id = &"magic_total_1300" -requirements = Array[ExtResource("2_d507t")]([SubResource("Resource_tvl3d")]) diff --git a/idles/goals/magic_total_13k.tres b/idles/goals/magic_total_13k.tres deleted file mode 100644 index c2eb65e..0000000 --- a/idles/goals/magic_total_13k.tres +++ /dev/null @@ -1,16 +0,0 @@ -[gd_resource type="Resource" script_class="GoalData" format=3 uid="uid://bmlhoeasl7xor"] - -[ext_resource type="Script" uid="uid://r4js5eajolio" path="res://core/goals/goal_requirement_data.gd" id="1_185pw"] -[ext_resource type="Resource" uid="uid://brqaojindcxa5" path="res://idles/currencies/magic.tres" id="2_ox6xg"] -[ext_resource type="Script" uid="uid://dx4kmh33rrkpy" path="res://core/goals/goal_data.gd" id="3_ef7ki"] - -[sub_resource type="Resource" id="Resource_tvl3d"] -script = ExtResource("1_185pw") -currency = ExtResource("2_ox6xg") -amount_mantissa = 1.3 -amount_exponent = 4 - -[resource] -script = ExtResource("3_ef7ki") -id = &"magic_total_13k" -requirements = Array[ExtResource("1_185pw")]([SubResource("Resource_tvl3d")]) diff --git a/idles/goals/magic_total_30.tres b/idles/goals/magic_total_30.tres deleted file mode 100644 index bf525b4..0000000 --- a/idles/goals/magic_total_30.tres +++ /dev/null @@ -1,15 +0,0 @@ -[gd_resource type="Resource" script_class="GoalData" format=3 uid="uid://qyxct5gbrxwa"] - -[ext_resource type="Script" uid="uid://dx4kmh33rrkpy" path="res://core/goals/goal_data.gd" id="1_rh4uj"] -[ext_resource type="Script" uid="uid://r4js5eajolio" path="res://core/goals/goal_requirement_data.gd" id="2_58n5q"] -[ext_resource type="Resource" uid="uid://brqaojindcxa5" path="res://idles/currencies/magic.tres" id="3_qm5e7"] - -[sub_resource type="Resource" id="Resource_v4v16"] -script = ExtResource("2_58n5q") -currency = ExtResource("3_qm5e7") -amount_mantissa = 30.0 - -[resource] -script = ExtResource("1_rh4uj") -id = &"magic_total_30" -requirements = Array[ExtResource("2_58n5q")]([SubResource("Resource_v4v16")]) diff --git a/idles/prestige/primary_prestige.tres b/idles/prestige/primary_prestige.tres deleted file mode 100644 index 19a8794..0000000 --- a/idles/prestige/primary_prestige.tres +++ /dev/null @@ -1,25 +0,0 @@ -[gd_resource type="Resource" script_class="PrestigeConfig" format=3] - -[ext_resource type="Script" path="res://core/prestige/prestige_config.gd" id="1_3tg3a"] - -[resource] -script = ExtResource("1_3tg3a") -id = &"ascension" -display_name = "Ascension" -prestige_currency_id = &"ascension" -source_currency_id = &"magic" -basis = 0 -formula = 0 -threshold_mantissa = 1.0 -threshold_exponent = 4 -scale = 1.0 -exponent = 0.5 -flat_bonus = 0.0 -minimum_gain = 0 -rounding_mode = 0 -allow_prestige_without_gain = false -multiplier_mode = 0 -base_multiplier = 1.0 -multiplier_per_prestige = 0.05 -multiplier_exponent = 1.0 -metadata/_custom_type_script = "res://core/prestige/prestige_config.gd" diff --git a/project.godot b/project.godot index 14f2d76..fe3dde7 100644 --- a/project.godot +++ b/project.godot @@ -12,15 +12,10 @@ config_version=5 config/name="Idles" config/version="0.0.1" +run/main_scene="uid://dadeowsvaywpp" config/features=PackedStringArray("4.6", "Forward Plus") config/icon="res://icon.svg" -[autoload] - -CurrencyDatabase="*res://core/currency_database.gd" -GameState="*uid://d2j7tvlgxr2jp" -PrestigeManager="*res://core/prestige/prestige_manager.gd" - [display] window/size/viewport_width=1920 diff --git a/scripts/check_syntax.sh b/scripts/check_syntax.sh new file mode 100755 index 0000000..1dbbfdc --- /dev/null +++ b/scripts/check_syntax.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# Godot Syntax Checker +# Usage: ./scripts/check_syntax.sh +# Example: ./scripts/check_syntax.sh res://tests/test_gold_mine_click.gd + +SCRIPT="$1" +GODOT_BIN="${GODOT_BIN:-godot}" + +if [ -z "$SCRIPT" ]; then + echo "Usage: $0 " + echo "Example: $0 res://tests/test_gold_mine_click.gd" + exit 1 +fi + +if [ ! -f "$SCRIPT" ]; then + echo "Error: Script not found: $SCRIPT" + exit 1 +fi + +echo "==========================================" +echo "Checking syntax: $SCRIPT" +echo "==========================================" +echo "" + +"$GODOT_BIN" --headless --path . --check-only --script "$SCRIPT" +EXIT_CODE=$? + +echo "" +echo "==========================================" +if [ $EXIT_CODE -eq 0 ]; then + echo "✓ Syntax valid" +else + echo "✗ Syntax errors found" +fi +echo "==========================================" + +exit $EXIT_CODE diff --git a/scripts/run_all.sh b/scripts/run_all.sh new file mode 100755 index 0000000..40032d2 --- /dev/null +++ b/scripts/run_all.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Godot Test Runner - All Tests +# Usage: ./scripts/run_all.sh + +set -e + +GODOT_BIN="${GODOT_BIN:-godot}" + +echo "==========================================" +echo "Running All Tests" +echo "==========================================" +echo "" + +"$GODOT_BIN" --headless --path . -s res://tests/test_runner.gd +EXIT_CODE=$? + +echo "" +echo "==========================================" +if [ $EXIT_CODE -eq 0 ]; then + echo "✓ All tests passed" +else + echo "✗ Some tests failed" +fi +echo "==========================================" + +exit $EXIT_CODE diff --git a/scripts/run_test.sh b/scripts/run_test.sh new file mode 100755 index 0000000..80d52f7 --- /dev/null +++ b/scripts/run_test.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Godot Test Runner - Single Test +# Usage: ./scripts/run_test.sh +# Example: ./scripts/run_test.sh res://tests/test_gold_mine_click.gd + +set -e + +TEST_SCRIPT="$1" +GODOT_BIN="${GODOT_BIN:-godot}" + +if [ -z "$TEST_SCRIPT" ]; then + echo "Usage: $0 " + echo "Example: $0 res://tests/test_gold_mine_click.gd" + exit 1 +fi + +if [ ! -f "$TEST_SCRIPT" ]; then + echo "Error: Test script not found: $TEST_SCRIPT" + exit 1 +fi + +echo "==========================================" +echo "Running test: $TEST_SCRIPT" +echo "==========================================" +echo "" + +"$GODOT_BIN" --headless --path . -s "$TEST_SCRIPT" +EXIT_CODE=$? + +echo "" +echo "==========================================" +if [ $EXIT_CODE -eq 0 ]; then + echo "✓ Test passed" +else + echo "✗ Test failed with exit code: $EXIT_CODE" +fi +echo "==========================================" + +exit $EXIT_CODE diff --git a/test_multi_currency_prestige.gd b/test_multi_currency_prestige.gd new file mode 100644 index 0000000..984221b --- /dev/null +++ b/test_multi_currency_prestige.gd @@ -0,0 +1,61 @@ +@tool +extends Node + +func _ready() -> void: + if not Engine.is_editor_hint(): + test_multi_currency_prestige() + get_tree().quit() + +func test_multi_currency_prestige() -> void: + var game_state: LevelGameState = find_child("LevelGameState") + var prestige_manager: PrestigeManager = game_state.find_child("PrestigeManager") + + print("\n=== MULTI-CURRENCY PRESTIGE TEST ===\n") + + # Check config + var config = prestige_manager.get_config() + var basis_type = config.get("basis", 0) + print("Basis type: %d (expected 3 for ALL_CURRENCIES)" % basis_type) + + # Check tracked currencies + var tracked_ids = prestige_manager.get_tracked_currency_ids() + print("Tracked currency IDs: %s" % str(tracked_ids)) + + # Add some test currency amounts + var gold = game_state.get_currency_amount_by_id(&"gold") + print("Initial gold: %s" % gold.to_string_suffix(2)) + + # Simulate earning currencies + game_state.add_currency_by_id(&"gold", BigNumber.from_float(1000.0)) + game_state.add_currency_by_id(&"wood", BigNumber.from_float(2000.0)) + game_state.add_currency_by_id(&"food", BigNumber.from_float(1500.0)) + game_state.add_currency_by_id(&"worker", BigNumber.from_float(500.0)) + + # Check total + var total = prestige_manager.get_basis_value_for_display() + print("Total currency sum: %s" % total.to_string_suffix(2)) + + # Check threshold + var threshold = prestige_manager.get_next_prestige_threshold() + print("Prestige threshold: %s" % threshold.to_string_suffix(2)) + + # Check if can prestige + var can_prestige = prestige_manager.can_prestige() + print("Can prestige: %s" % str(can_prestige)) + + # Check pending gain + var pending_gain = prestige_manager.calculate_pending_gain() + print("Pending gain: %s" % pending_gain.to_string_suffix(2)) + + # Perform prestige + if can_prestige: + var result = prestige_manager.perform_prestige() + print("Prestige performed: %s" % str(result)) + + var total_prestige = prestige_manager.get_total_prestige() + print("Total prestige earned: %s" % total_prestige.to_string_suffix(2)) + + var ascension = game_state.get_currency_amount_by_id(&"ascension") + print("Ascension currency: %s" % ascension.to_string_suffix(2)) + + print("\n=== TEST COMPLETE ===\n") diff --git a/test_multi_currency_prestige.gd.uid b/test_multi_currency_prestige.gd.uid new file mode 100644 index 0000000..92ef00e --- /dev/null +++ b/test_multi_currency_prestige.gd.uid @@ -0,0 +1 @@ +uid://c5ubwdte41tsf diff --git a/tests/test_ascension.gd b/tests/test_ascension.gd new file mode 100644 index 0000000..cb9ec39 --- /dev/null +++ b/tests/test_ascension.gd @@ -0,0 +1,318 @@ +extends Node + +var passed: int = 0 +var failed: int = 0 +var _game_root: Node = null + + +func _ready() -> void: + await run() + + +func run() -> void: + print("\n=== TEST: Ascension Buff System ===\n") + TestUtils.set_test_name("ascension") + + _test_node_resource() + _test_node_validation() + _test_node_parent_checking() + _test_catalogue_queries() + _test_purchase_flow() + _test_prestige_does_not_reset_prestige_buffs() + _test_multiplier_computation() + _test_save_load_roundtrip() + + _print_summary() + + +#region Node Resource Tests +func _test_node_resource() -> void: + print("--- Node Resource ---") + + var node: PrestigeBuffNode = PrestigeBuffNode.new() + node.id = &"test_node" + node.display_name = "Test Node" + node.effect_type = PrestigeBuffNode.EffectType.CURRENCY_PRODUCTION_MULTIPLIER + node.effect_value = 2.0 + node.cost_mantissa = 5.0 + node.cost_exponent = 2 # cost = 5e2 = 500 + + var cost: BigNumber = node.get_cost() + TestUtils.assert_equals(5.0, cost.mantissa, "cost mantissa matches") + TestUtils.assert_equals(2, cost.exponent, "cost exponent matches") + TestUtils.assert_false(node.has_parents(), "node with no parents returns false") + + +func _test_node_validation() -> void: + print("--- Node Validation ---") + + var valid: PrestigeBuffNode = PrestigeBuffNode.new() + valid.id = &"valid_id" + valid.cost_mantissa = 1.0 + TestUtils.assert_true(valid.is_valid(), "node with id and positive cost is valid") + + var no_id: PrestigeBuffNode = PrestigeBuffNode.new() + no_id.cost_mantissa = 1.0 + TestUtils.assert_false(no_id.is_valid(), "node with empty id is invalid") + + var zero_cost: PrestigeBuffNode = PrestigeBuffNode.new() + zero_cost.id = &"has_id" + zero_cost.cost_mantissa = 0.0 + TestUtils.assert_false(zero_cost.is_valid(), "node with zero cost is invalid") + + +func _test_node_parent_checking() -> void: + print("--- Parent Checking ---") + + var unlocked: Dictionary = {} + unlocked[&"parent_a"] = true + unlocked[&"parent_b"] = false + + var node: PrestigeBuffNode = PrestigeBuffNode.new() + node.id = &"child" + node.parent_ids = [&"parent_a"] + + TestUtils.assert_true(node.all_parents_unlocked(unlocked), "all parents unlocked when all are true") + + node.parent_ids = [&"parent_a", &"parent_b"] + TestUtils.assert_false(node.all_parents_unlocked(unlocked), "not all parents unlocked when one is false") + + node.parent_ids = [&"nonexistent"] + TestUtils.assert_false(node.all_parents_unlocked(unlocked), "missing parent treated as not unlocked") +#endregion + + +#region Catalogue Tests +func _test_catalogue_queries() -> void: + print("--- Catalogue Queries ---") + + var root: PrestigeBuffNode = _make_node(&"root", [], 0, 0.0) + var child_a: PrestigeBuffNode = _make_node(&"child_a", [&"root"], 1, 0.0) + var child_b: PrestigeBuffNode = _make_node(&"child_b", [&"root"], 1, 1.0) + var grandchild: PrestigeBuffNode = _make_node(&"grandchild", [&"child_a", &"child_b"], 2, 0.0) + + var cat: PrestigeBuffCatalogue = PrestigeBuffCatalogue.new() + cat.nodes = [root, child_a, child_b, grandchild] + + TestUtils.assert_equals(root, cat.get_node_by_id(&"root"), "get_node_by_id finds root") + TestUtils.assert_equals(null, cat.get_node_by_id(&"nonexistent"), "get_node_by_id returns null for unknown id") + + var ids: Array[StringName] = cat.get_all_ids() + TestUtils.assert_equals(4, ids.size(), "get_all_ids returns 4 ids") + + var roots: Array[PrestigeBuffNode] = cat.get_root_nodes() + TestUtils.assert_equals(1, roots.size(), "one root node") + TestUtils.assert_equals(&"root", roots[0].id, "root node id is 'root'") + + var children: Array[PrestigeBuffNode] = cat.get_children_of(&"root") + TestUtils.assert_equals(2, children.size(), "root has 2 children") + + var grandkids: Array[PrestigeBuffNode] = cat.get_children_of(&"child_a") + TestUtils.assert_equals(1, grandkids.size(), "child_a has 1 child") +#endregion + + +#region Purchase Flow Tests +func _test_purchase_flow() -> void: + print("--- Purchase Flow ---") + + var scene: PackedScene = load("res://docs/gyms/tiny_sword/tiny_sword.tscn") + _game_root = scene.instantiate() + add_child(_game_root) + await _wait() + + var gs: LevelGameState = _game_root.find_child("LevelGameState") + if gs == null: + print("[ERROR] LevelGameState not found") + return + + # Give ascension currency for purchasing + gs.add_currency_by_id(&"ascension", BigNumber.from_float(100.0)) + await _wait() + + # Verify catalogue has our two nodes + var cat: PrestigeBuffCatalogue = gs.prestige_buff_catalogue + TestUtils.assert_not_null(cat, "prestige_buff_catalogue is set") + if cat == null: + return + + TestUtils.assert_true(cat.nodes.size() >= 2, "catalogue has at least 2 nodes") + + # Verify nodes initialized in state + TestUtils.assert_false(gs.is_prestige_buff_unlocked(&"gold_boost"), "gold_boost starts locked") + TestUtils.assert_false(gs.is_prestige_buff_unlocked(&"farm_boost"), "farm_boost starts locked") + + # Check can_purchase (both are root nodes with no parents, should be purchaseable) + TestUtils.assert_true(gs.can_purchase_prestige_buff(&"gold_boost"), "gold_boost is purchasable") + TestUtils.assert_true(gs.can_purchase_prestige_buff(&"farm_boost"), "farm_boost is purchasable") + + # Check cost + var cost: BigNumber = gs.get_prestige_buff_cost(&"gold_boost") + TestUtils.assert_greater_than(cost.mantissa, 0.0, "gold_boost cost > 0") + + # Purchase gold_boost + var purchased: bool = gs.purchase_prestige_buff(&"gold_boost") + TestUtils.assert_true(purchased, "purchase_prestige_buff returns true") + TestUtils.assert_true(gs.is_prestige_buff_unlocked(&"gold_boost"), "gold_boost is now unlocked") + TestUtils.assert_false(gs.can_purchase_prestige_buff(&"gold_boost"), "cannot repurchase already unlocked node") + + # farm_boost should still be available (both are roots, independent) + TestUtils.assert_true(gs.can_purchase_prestige_buff(&"farm_boost"), "farm_boost is still purchasable") + + # Verify available buffs + var available: Array[PrestigeBuffNode] = gs.get_available_prestige_buffs() + var found_farm: bool = false + var found_gold: bool = false + for node in available: + if node.id == &"farm_boost": + found_farm = true + if node.id == &"gold_boost": + found_gold = true + TestUtils.assert_true(found_farm, "farm_boost appears in available buffs") + TestUtils.assert_false(found_gold, "gold_boost does NOT appear in available buffs (already unlocked)") + + # Test cannot purchase with insufficient currency + gs._prestige_buff_unlocked.clear() + gs._initialize_prestige_buffs() + # Drain ascension currency + var bal: BigNumber = gs.get_currency_amount_by_id(&"ascension") + gs.spend_currency_by_id(&"ascension", bal) + TestUtils.assert_false(gs.can_purchase_prestige_buff(&"gold_boost"), "cannot purchase with no currency") + + # Restore currency, re-unlock gold_boost for later tests + gs.add_currency_by_id(&"ascension", BigNumber.from_float(100.0)) + gs.purchase_prestige_buff(&"gold_boost") + await _wait() +#endregion + + +func _test_prestige_does_not_reset_prestige_buffs() -> void: + print("--- Prestige Does Not Reset Prestige ---") + + if _game_root == null: + return + + var gs: LevelGameState = _game_root.find_child("LevelGameState") + if gs == null: + return + + # gold_boost should be unlocked from previous test + TestUtils.assert_true(gs.is_prestige_buff_unlocked(&"gold_boost"), "gold_boost unlocked before prestige") + + # Give enough currency to prestige + gs.add_currency_by_id(&"gold", BigNumber.new(1.0, 10)) # 1e10 gold + await _wait() + + var pm: PrestigeManager = _game_root.find_child("PrestigeManager") as PrestigeManager + if pm == null: + print("[WARN] PrestigeManager not found; skipping prestige test") + return + + # Perform prestige if possible + if pm.can_prestige(): + pm.perform_prestige() + await _wait() + + # After prestige, gold_boost should STILL be unlocked + TestUtils.assert_true(gs.is_prestige_buff_unlocked(&"gold_boost"), "gold_boost still unlocked after prestige") + else: + # Manual simulation: call reset_for_prestige directly and verify ascension survives + gs.reset_for_prestige(true, false, [&"ascension"]) + await _wait() + TestUtils.assert_true(gs.is_prestige_buff_unlocked(&"gold_boost"), "gold_boost still unlocked after reset_for_prestige") +#endregion + + +func _test_multiplier_computation() -> void: + print("--- Multiplier Computation ---") + + if _game_root == null: + return + + var gs: LevelGameState = _game_root.find_child("LevelGameState") + if gs == null: + return + + # gold_boost is already unlocked (from purchase test). Effect type 1 = GENERATOR_PRODUCTION_MULTIPLIER + var mult: float = gs.get_prestige_buff_multiplier(PrestigeBuffNode.EffectType.CURRENCY_PRODUCTION_MULTIPLIER, &"goldmine") + TestUtils.assert_greater_than(mult, 1.0, "multiplier for goldmine is > 1.0 with gold_boost unlocked") + + # farm_boost is NOT unlocked, multiplier for farm should be 1.0 + var farm_mult: float = gs.get_prestige_buff_multiplier(PrestigeBuffNode.EffectType.CURRENCY_PRODUCTION_MULTIPLIER, &"farm") + TestUtils.assert_equals(1.0, farm_mult, "multiplier for farm is 1.0 (farm_boost not unlocked)") + + # Unlock farm_boost and check again + gs.add_currency_by_id(&"ascension", BigNumber.from_float(100.0)) + gs.purchase_prestige_buff(&"farm_boost") + await _wait() + + var farm_mult2: float = gs.get_prestige_buff_multiplier(PrestigeBuffNode.EffectType.CURRENCY_PRODUCTION_MULTIPLIER, &"farm") + TestUtils.assert_greater_than(farm_mult2, 1.0, "multiplier for farm is > 1.0 after unlocking farm_boost") + + # Check that unrelated effect type returns 1.0 + var unrelated: float = gs.get_prestige_buff_multiplier(PrestigeBuffNode.EffectType.RESEARCH_XP_MULTIPLIER, &"") + TestUtils.assert_equals(1.0, unrelated, "unrelated effect type returns 1.0") +#endregion + + +func _test_save_load_roundtrip() -> void: + print("--- Save/Load Roundtrip ---") + + if _game_root == null: + return + + var gs: LevelGameState = _game_root.find_child("LevelGameState") + if gs == null: + return + + # Both gold_boost and farm_boost should be unlocked + TestUtils.assert_true(gs.is_prestige_buff_unlocked(&"gold_boost"), "gold_boost unlocked before save") + TestUtils.assert_true(gs.is_prestige_buff_unlocked(&"farm_boost"), "farm_boost unlocked before save") + + # Save + gs.save_game() + await _wait() + + # Clear state manually to simulate fresh load + gs._prestige_buff_unlocked.clear() + TestUtils.assert_false(gs.is_prestige_buff_unlocked(&"gold_boost"), "gold_boost false after clear") + TestUtils.assert_false(gs.is_prestige_buff_unlocked(&"farm_boost"), "farm_boost false after clear") + + # Load + gs.load_game() + await _wait() + + # Both should be restored + TestUtils.assert_true(gs.is_prestige_buff_unlocked(&"gold_boost"), "gold_boost restored after load") + TestUtils.assert_true(gs.is_prestige_buff_unlocked(&"farm_boost"), "farm_boost restored after load") +#endregion + + +#region Helpers +func _make_node(id: StringName, parent_ids: Array[StringName], tier: int, x_pos: float) -> PrestigeBuffNode: + var n: PrestigeBuffNode = PrestigeBuffNode.new() + n.id = id + n.display_name = String(id) + n.parent_ids = parent_ids + n.tier = tier + n.x_position = x_pos + n.cost_mantissa = 1.0 + return n + + +func _wait() -> void: + await get_tree().create_timer(0.3).timeout + + +func _print_summary() -> void: + TestUtils.print_result() + passed = TestUtils.get_passed() + failed = TestUtils.get_failed() + + +func get_passed() -> int: + return passed + + +func get_failed() -> int: + return failed diff --git a/tests/test_ascension.gd.uid b/tests/test_ascension.gd.uid new file mode 100644 index 0000000..8f55de3 --- /dev/null +++ b/tests/test_ascension.gd.uid @@ -0,0 +1 @@ +uid://cwmf4h7be356h diff --git a/tests/test_goals_prestige_reset.gd b/tests/test_goals_prestige_reset.gd new file mode 100644 index 0000000..75a7d64 --- /dev/null +++ b/tests/test_goals_prestige_reset.gd @@ -0,0 +1,146 @@ +@tool +extends Node + +var _game_root: Node = null + +func _ready() -> void: + if not Engine.is_editor_hint(): + test_goals_prestige_reset() + get_tree().quit() + +func test_goals_prestige_reset() -> void: + print("\n=== TEST: Goals Prestige Reset ===\n") + TestUtils.set_test_name("goals_prestige_reset") + + # Load the tiny_sword scene directly + var scene = load("res://docs/gyms/tiny_sword/tiny_sword.tscn") + _game_root = scene.instantiate() + add_child(_game_root) + + print("[TARGET] children_after_load: %d" % get_child_count()) + + var game_state: LevelGameState = _game_root.find_child("LevelGameState") + if game_state == null: + print("[ERROR] LevelGameState not found") + _print_summary(0, 1) + return + + print("[TARGET] game_state_found: true") + + var goals: Array[GoalData] = game_state.get_all_goals() + if goals.is_empty(): + print("[ERROR] No goals found in game state") + _print_summary(0, 1) + return + + var test_goal: GoalData = goals[0] + print("[TARGET] test_goal_id: %s" % String(test_goal.id)) + + var gold: BigNumber = BigNumber.from_float(1000000.0) + game_state.add_currency_by_id(&"gold", gold) + + var is_met_before: bool = game_state.is_goal_met(test_goal) + print("[TARGET] goal_met_before_prestige: %s" % str(is_met_before)) + + TestUtils.assert_true( + is_met_before, + "Goal should be met before prestige (with high currency)" + ) + + var is_completed_before: bool = game_state.is_goal_completed(test_goal.id) + print("[TARGET] goal_completed_before_prestige: %s" % str(is_completed_before)) + + game_state.reset_for_prestige(true, true, []) + + var is_completed_after_reset: bool = game_state.is_goal_completed(test_goal.id) + print("[TARGET] goal_completed_after_prestige: %s" % str(is_completed_after_reset)) + + TestUtils.assert_true( + not is_completed_after_reset, + "Goal should NOT be completed after prestige reset (BUG FIX VERIFICATION)" + ) + + var gold_after: BigNumber = game_state.get_currency_amount_by_id(&"gold") + print("[TARGET] gold_after_prestige: %s" % gold_after.to_string_suffix(2)) + + TestUtils.assert_equals( + 0.0, + gold_after.mantissa, + "Gold should be reset to 0 after prestige" + ) + + var total_gold_after: BigNumber = game_state.get_total_currency_acquired_by_id(&"gold") + print("[TARGET] total_gold_after_prestige: %s" % total_gold_after.to_string_suffix(2)) + + TestUtils.assert_equals( + 0.0, + total_gold_after.mantissa, + "Total gold should be reset to 0 after prestige" + ) + + var is_met_after: bool = game_state.is_goal_met(test_goal) + print("[TARGET] goal_met_after_prestige: %s" % str(is_met_after)) + + TestUtils.assert_true( + not is_met_after, + "Goal should NOT be met after prestige (currency reset to 0)" + ) + + var gold_for_unlock: BigNumber = BigNumber.from_float(1000000.0) + game_state.add_currency_by_id(&"gold", gold_for_unlock) + + var is_met_again: bool = game_state.is_goal_met(test_goal) + print("[TARGET] goal_met_after_reaching_currency: %s" % str(is_met_again)) + + TestUtils.assert_true( + is_met_again, + "Goal should be met again after adding sufficient currency" + ) + + var is_completed_auto: bool = game_state.is_goal_completed(test_goal.id) + print("[TARGET] goal_completed_auto_check: %s" % str(is_completed_auto)) + + # This goal has AUTOMATIC unlock behavior, so it should be auto-completed when met + if test_goal.unlock_behavior == GoalData.UnlockBehavior.AUTOMATIC: + TestUtils.assert_true( + is_completed_auto, + "Goal with AUTOMATIC unlock should be auto-completed when met" + ) + +# Try manual completion (should be a no-op if already auto-completed) + game_state._complete_goal_manually(test_goal.id) + + var is_completed: bool = game_state.is_goal_completed(test_goal.id) + print("[TARGET] goal_completed_after_attempt: %s" % str(is_completed)) + + TestUtils.assert_true( + is_completed, + "Goal should be completed (either auto or manual)" + ) + + game_state.reset_for_prestige(true, true, []) + + var is_completed_after_second_prestige: bool = game_state.is_goal_completed(test_goal.id) + print("[TARGET] goal_completed_after_second_prestige: %s" % str(is_completed_after_second_prestige)) + + TestUtils.assert_true( + not is_completed_after_second_prestige, + "Goal should NOT be completed after second prestige reset" + ) + + var is_met_final: bool = game_state.is_goal_met(test_goal) + print("[TARGET] goal_met_final: %s" % str(is_met_final)) + + TestUtils.assert_true( + not is_met_final, + "Goal should NOT be met after second prestige reset" + ) + + _print_summary(TestUtils.get_passed(), TestUtils.get_failed()) + +func _print_summary(passed_count: int, failed_count: int) -> void: + TestUtils.print_result() + if failed_count == 0: + print("[RESULT] PASS") + else: + print("[RESULT] FAIL") diff --git a/tests/test_goals_prestige_reset.gd.uid b/tests/test_goals_prestige_reset.gd.uid new file mode 100644 index 0000000..88880be --- /dev/null +++ b/tests/test_goals_prestige_reset.gd.uid @@ -0,0 +1 @@ +uid://d23axeulmeeih diff --git a/tests/test_goals_prestige_reset.tscn b/tests/test_goals_prestige_reset.tscn new file mode 100644 index 0000000..d604b14 --- /dev/null +++ b/tests/test_goals_prestige_reset.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3 uid="uid://test_goals_prestige_reset"] + +[ext_resource type="Script" path="res://tests/test_goals_prestige_reset.gd" id="1_test"] + +[node name="TestRoot" type="Node"] +script = ExtResource("1_test") diff --git a/tests/test_gold_mine_click.gd b/tests/test_gold_mine_click.gd new file mode 100644 index 0000000..8c64b7c --- /dev/null +++ b/tests/test_gold_mine_click.gd @@ -0,0 +1,75 @@ +extends Node + +var passed: int = 0 +var failed: int = 0 +var _game_root: Node = null + +func _ready(): + await run() + # Don't quit here - let test_runner handle it + +func run(): + print("\n=== TEST: Gold Mine Click ===\n") + TestUtils.set_test_name("gold_mine_click") + + # Load tiny_sword scene + var scene = load("res://docs/gyms/tiny_sword/tiny_sword.tscn") + _game_root = scene.instantiate() + add_child(_game_root) + + await _wait() + + var game_state: LevelGameState = _game_root.find_child("LevelGameState") + var gold_mine = _game_root.find_child("GoldMine") as CurrencyGenerator + + if game_state == null: + print("[ERROR] LevelGameState not found") + _print_summary() + return + + if gold_mine == null: + print("[ERROR] GoldMine node not found") + _print_summary() + return + + # Record initial gold + var initial_gold: BigNumber = game_state.get_currency_amount_by_id(&"gold") + print("[TARGET] initial_gold: %s" % initial_gold.to_string_suffix(2)) + + # Simulate clicking the generator + gold_mine._on_pressed() + + await _wait() + + # Validate gold increased + var final_gold: BigNumber = game_state.get_currency_amount_by_id(&"gold") + print("[TARGET] final_gold: %s" % final_gold.to_string_suffix(2)) + + TestUtils.assert_greater_than( + final_gold.mantissa, + initial_gold.mantissa, + "Gold increased after generator click" + ) + + # Check specific value (adjust based on click_mantissa in data) + var expected_min: float = initial_gold.mantissa + 1.0 + TestUtils.assert_true( + final_gold.mantissa >= expected_min, + "Gold increased by at least 1.0" + ) + + _print_summary() + +func _wait() -> void: + await get_tree().create_timer(0.5).timeout + +func _print_summary(): + TestUtils.print_result() + passed = TestUtils.get_passed() + failed = TestUtils.get_failed() + +func get_passed() -> int: + return passed + +func get_failed() -> int: + return failed diff --git a/tests/test_gold_mine_click.gd.uid b/tests/test_gold_mine_click.gd.uid new file mode 100644 index 0000000..f0103d4 --- /dev/null +++ b/tests/test_gold_mine_click.gd.uid @@ -0,0 +1 @@ +uid://c2xborf2klkc4 diff --git a/tests/test_prestige.gd b/tests/test_prestige.gd new file mode 100644 index 0000000..0569763 --- /dev/null +++ b/tests/test_prestige.gd @@ -0,0 +1,92 @@ +extends Node + +var passed: int = 0 +var failed: int = 0 +var _game_root: Node = null + +func _ready(): + await run() + # Let test_runner handle exit + pass + +func run(): + print("\n=== TEST: Prestige Mechanics ===\n") + TestUtils.set_test_name("prestige") + + var scene = load("res://docs/gyms/tiny_sword/tiny_sword.tscn") + _game_root = scene.instantiate() + add_child(_game_root) + + await _wait() + + var game_state: LevelGameState = _game_root.find_child("LevelGameState") + var prestige_manager = _game_root.find_child("PrestigeManager") as PrestigeManager + + if game_state == null: + print("[ERROR] LevelGameState not found") + _print_summary() + return + + if prestige_manager == null: + print("[ERROR] PrestigeManager not found") + _print_summary() + return + + # Setup: Accumulate currencies to meet prestige threshold + game_state.add_currency_by_id(&"gold", BigNumber.from_float(100000.0)) + game_state.add_currency_by_id(&"gems", BigNumber.from_float(100.0)) + + await _wait() + + # Check if can prestige + var can_prestige: bool = prestige_manager.can_prestige() + print("[TARGET] can_prestige: %s" % str(can_prestige)) + + TestUtils.assert_true(can_prestige, "Should be able to prestige after accumulating currencies") + + if can_prestige: + # Perform prestige + prestige_manager.perform_prestige() + + await _wait() + + # Check ascension currency + var ascension: BigNumber = game_state.get_currency_amount_by_id(&"ascension") + print("[TARGET] ascension_currency: %s" % ascension.to_string_suffix(2)) + + TestUtils.assert_true( + ascension.mantissa > 0, + "Should receive ascension currency after prestige" + ) + + # Check prestige multiplier increased + var total_multiplier = prestige_manager.get_total_multiplier() + print("[TARGET] total_multiplier: %s" % str(total_multiplier)) + + var mult_value: float + if total_multiplier is float: + mult_value = total_multiplier + else: + mult_value = float(total_multiplier) + + TestUtils.assert_greater_than( + mult_value, + 1.0, + "Prestige multiplier should be > 1.0" + ) + + _print_summary() + +func _wait() -> void: + await get_tree().create_timer(0.5).timeout + +func _print_summary(): + TestUtils.print_result() + passed = TestUtils.get_passed() + failed = TestUtils.get_failed() + +func get_passed() -> int: + return passed + +func get_failed() -> int: + return failed diff --git a/tests/test_prestige.gd.uid b/tests/test_prestige.gd.uid new file mode 100644 index 0000000..dfec39d --- /dev/null +++ b/tests/test_prestige.gd.uid @@ -0,0 +1 @@ +uid://mm5vcjq44wf1 diff --git a/tests/test_research_activation.gd b/tests/test_research_activation.gd new file mode 100644 index 0000000..74c0c6b --- /dev/null +++ b/tests/test_research_activation.gd @@ -0,0 +1,159 @@ +extends SceneTree + +var passed: int = 0 +var failed: int = 0 +var _game_root: Node = null +var _local_passed: int = 0 +var _local_failed: int = 0 + +func _init(): + await run() + +func run(): + print("\n=== TEST: Research Activation ===\n") + TestUtils.set_test_name("research_activation") + + var scene = load("res://docs/gyms/tiny_sword/tiny_sword.tscn") + _game_root = scene.instantiate() + root.add_child(_game_root) + + await _wait() + + var game_state: LevelGameState = _game_root.find_child("LevelGameState") + var research_panel: ResearchPanel = _game_root.find_child("ResearchPanel") as ResearchPanel + + if game_state == null or research_panel == null: + print("[ERROR] Missing LevelGameState or ResearchPanel node") + _print_summary() + quit(1) + return + + var gold_mine_node = game_state.find_child("GoldMine") + var gold_mine: CurrencyGenerator = gold_mine_node.find_child("CurrencyGenerator") as CurrencyGenerator if gold_mine_node else null + + if gold_mine == null: + print("[ERROR] GoldMine node not found") + _print_summary() + quit(1) + return + + # Test 1: Verify initial state - no research should be active + print("[TARGET] initial_active_research_id: %s" % research_panel.get_active_research_id()) + TestUtils.assert_true( + research_panel.get_active_research_id() == &"", + "No research active initially" + ) + + # Test 2: Verify gold mine research exists and is linked to goldmine generator + var gold_research = game_state.research_catalogue.get_research_by_id(&"gold_research") + TestUtils.assert_not_null( + gold_research, + "Gold research exists in catalogue" + ) + + if gold_research != null: + TestUtils.assert_true( + gold_research.generator_id == &"goldmine", + "Gold research is linked to goldmine generator" + ) + + # Test 3: Activate gold research + research_panel.activate_research(&"gold_research") + await _wait() + + print("[TARGET] active_after_gold_activation: %s" % research_panel.get_active_research_id()) + TestUtils.assert_true( + research_panel.get_active_research_id() == &"gold_research", + "Gold research is active after activation" + ) + + TestUtils.assert_true( + research_panel.is_research_active(&"gold_research"), + "is_research_active returns true for gold_research" + ) + + # Test 4: Verify research XP is granted when research is active + var initial_gold_research_xp = game_state.get_research_xp(&"gold_research") + print("[TARGET] initial_gold_research_xp: %s" % initial_gold_research_xp.to_string_suffix(2)) + + # Simulate gold mine production (gold mine grants XP to gold_research) + gold_mine._on_pressed() + await _wait() + + var final_gold_research_xp_active = game_state.get_research_xp(&"gold_research") + print("[TARGET] final_gold_research_xp_active: %s" % final_gold_research_xp_active.to_string_suffix(2)) + + TestUtils.assert_true( + final_gold_research_xp_active.mantissa > initial_gold_research_xp.mantissa, + "Gold research XP increases when active (gold mine produces)" + ) + + # Test 5: Deactivate gold research by activating another research + var farm_research = game_state.research_catalogue.get_research_by_id(&"farm_research") + TestUtils.assert_not_null( + farm_research, + "Farm research exists in catalogue" + ) + + if farm_research != null: + # Activate farm research (should deactivate gold research) + research_panel.activate_research(&"farm_research") + await _wait() + + print("[TARGET] active_after_farm_activation: %s" % research_panel.get_active_research_id()) + TestUtils.assert_true( + research_panel.get_active_research_id() == &"farm_research", + "Farm research is active after activation" + ) + + TestUtils.assert_false( + research_panel.is_research_active(&"gold_research"), + "Gold research is deactivated when farm is activated" + ) + + # Test 6: Verify research XP is NOT granted when research is inactive + var initial_gold_research_xp_inactive = game_state.get_research_xp(&"gold_research") + print("[TARGET] initial_gold_research_xp_inactive: %s" % initial_gold_research_xp_inactive.to_string_suffix(2)) + + # Simulate gold mine production (gold mine should NOT grant XP to inactive gold_research) + gold_mine._on_pressed() + await _wait() + + var final_gold_research_xp_inactive = game_state.get_research_xp(&"gold_research") + print("[TARGET] final_gold_research_xp_inactive: %s" % final_gold_research_xp_inactive.to_string_suffix(2)) + + TestUtils.assert_equals( + final_gold_research_xp_inactive.mantissa, + initial_gold_research_xp_inactive.mantissa, + "Gold research XP does not increase when inactive (gold mine produces)" + ) + + # Test 7: Test mutual exclusivity with multiple activations + research_panel.activate_research(&"gold_research") + await _wait() + research_panel.activate_research(&"farm_research") + await _wait() + research_panel.activate_research(&"gold_research") + await _wait() + + print("[TARGET] final_active_research_id: %s" % research_panel.get_active_research_id()) + TestUtils.assert_true( + research_panel.get_active_research_id() == &"gold_research", + "Only one research can be active at a time" + ) + + _print_summary() + if failed == 0: + quit(0) + else: + quit(1) + +func _wait() -> void: + await create_timer(0.5).timeout + +func _print_summary(): + TestUtils.print_result() + _local_passed = TestUtils.get_passed() + _local_failed = TestUtils.get_failed() + passed = _local_passed + failed = _local_failed diff --git a/tests/test_research_activation.gd.uid b/tests/test_research_activation.gd.uid new file mode 100644 index 0000000..fd20761 --- /dev/null +++ b/tests/test_research_activation.gd.uid @@ -0,0 +1 @@ +uid://cf23bjqannrog diff --git a/tests/test_research_prestige_reset.gd b/tests/test_research_prestige_reset.gd new file mode 100644 index 0000000..63a6e4a --- /dev/null +++ b/tests/test_research_prestige_reset.gd @@ -0,0 +1,128 @@ +extends SceneTree + +var passed: int = 0 +var failed: int = 0 +var _game_root: Node = null +var _test_complete: bool = false + +func _init(): + print("\n=== TEST: Research Level and XP Reset After Prestige ===\n") + TestUtils.set_test_name("research_prestige_reset") + + var scene = load("res://docs/gyms/tiny_sword/tiny_sword.tscn") + change_scene_to_packed(scene) + + await _wait() + + _game_root = root.get_child(0) + + var game_state: LevelGameState = _game_root.find_child("LevelGameState") + var prestige_manager = _game_root.find_child("PrestigeManager") as PrestigeManager + + if game_state == null: + print("[ERROR] LevelGameState not found") + _print_summary() + _test_complete = true + quit(1) + return + + if prestige_manager == null: + print("[ERROR] PrestigeManager not found") + _print_summary() + _test_complete = true + quit(1) + return + + var research_id: StringName = &"gold_research" + + var initial_xp: BigNumber = game_state.get_research_xp(research_id) + var initial_level: int = game_state.get_research_level(research_id) + print("[TARGET] initial_research_xp: mantissa=%s, exponent=%s" % [initial_xp.mantissa, initial_xp.exponent]) + print("[TARGET] initial_research_level: %d" % initial_level) + + TestUtils.assert_equals( + 0, + initial_xp.mantissa, + "Initial research XP should be 0" + ) + + TestUtils.assert_equals( + 0, + initial_level, + "Initial research level should be 0" + ) + + game_state.add_currency_by_id(&"gold", BigNumber.from_float(100000.0)) + game_state.add_currency_by_id(&"gems", BigNumber.from_float(100.0)) + + await _wait() + + for i in range(10): + game_state.add_research_xp(research_id, BigNumber.from_float(500.0)) + await _wait(0.05) + + var pre_prestige_xp: BigNumber = game_state.get_research_xp(research_id) + var pre_prestige_level: int = game_state.get_research_level(research_id) + print("[TARGET] pre_prestige_research_xp: mantissa=%s, exponent=%s" % [pre_prestige_xp.mantissa, pre_prestige_xp.exponent]) + print("[TARGET] pre_prestige_research_level: %d" % pre_prestige_level) + + TestUtils.assert_true( + pre_prestige_xp.mantissa > 0, + "Research XP should be > 0 before prestige" + ) + + TestUtils.assert_true( + pre_prestige_level > 0, + "Research level should be > 0 before prestige" + ) + + var can_prestige: bool = prestige_manager.can_prestige() + print("[TARGET] can_prestige: %s" % str(can_prestige)) + + TestUtils.assert_true( + can_prestige, + "Should be able to prestige after accumulating currencies" + ) + + if can_prestige: + prestige_manager.perform_prestige() + + await _wait() + + var post_prestige_xp: BigNumber = game_state.get_research_xp(research_id) + var post_prestige_level: int = game_state.get_research_level(research_id) + print("[TARGET] post_prestige_research_xp: mantissa=%s, exponent=%s" % [post_prestige_xp.mantissa, post_prestige_xp.exponent]) + print("[TARGET] post_prestige_research_level: %d" % post_prestige_level) + + TestUtils.assert_equals( + 0, + post_prestige_xp.mantissa, + "Research XP should be reset to 0 after prestige" + ) + + TestUtils.assert_equals( + 0, + post_prestige_level, + "Research level should be reset to 0 after prestige" + ) + + _print_summary() + _test_complete = true + if failed == 0: + quit(0) + else: + quit(1) + +func _wait(seconds: float = 0.1) -> void: + await create_timer(seconds).timeout + +func _print_summary(): + TestUtils.print_result() + passed = TestUtils.get_passed() + failed = TestUtils.get_failed() + +func get_passed() -> int: + return passed + +func get_failed() -> int: + return failed diff --git a/tests/test_research_prestige_reset.gd.uid b/tests/test_research_prestige_reset.gd.uid new file mode 100644 index 0000000..467ed3e --- /dev/null +++ b/tests/test_research_prestige_reset.gd.uid @@ -0,0 +1 @@ +uid://j2a4c8w1qrgj diff --git a/tests/test_research_xp_acquisition.gd b/tests/test_research_xp_acquisition.gd new file mode 100644 index 0000000..8593383 --- /dev/null +++ b/tests/test_research_xp_acquisition.gd @@ -0,0 +1,146 @@ +extends SceneTree + +var passed: int = 0 +var failed: int = 0 +var _game_root: Node = null +var _test_complete: bool = false + +func _init(): + print("\n=== TEST: Research XP Acquisition ===\n") + TestUtils.set_test_name("research_xp_acquisition") + + # Load tiny_sword scene + var scene = load("res://docs/gyms/tiny_sword/tiny_sword.tscn") + change_scene_to_packed(scene) + + await _wait() + + _game_root = root.get_child(0) + + var game_state: LevelGameState = _game_root.find_child("LevelGameState") + + if game_state == null: + print("[ERROR] LevelGameState not found") + _print_summary() + _test_complete = true + quit(1) + return + + # Find GoldMine CurrencyGenerator + var gold_mine: CurrencyGenerator = null + var world = game_state.get_node_or_null("World") + if world: + for child in world.get_children(): + if child.get_name() == "GoldMine": + for gc_child in child.get_children(): + if gc_child is CurrencyGenerator: + gold_mine = gc_child + break + + if gold_mine == null: + print("[ERROR] GoldMine CurrencyGenerator node not found") + _print_summary() + _test_complete = true + quit(1) + return + + # Give the generator initial ownership so clicks work properly + if gold_mine.owned <= 0: + game_state.add_currency_by_id(&"worker", BigNumber.from_float(100.0)) + await _wait(0.05) + gold_mine.buy(1) + await _wait(0.05) + + # Get initial gold currency + var initial_gold: BigNumber = game_state.get_currency_amount_by_id(&"gold") + print("[TARGET] initial_gold: mantissa=%s, exponent=%s" % [initial_gold.mantissa, initial_gold.exponent]) + + # Get initial research XP + var initial_xp: BigNumber = game_state.get_research_xp(&"gold_research") + print("[TARGET] initial_research_xp: mantissa=%s, exponent=%s" % [initial_xp.mantissa, initial_xp.exponent]) + + # Set press_buys_generator to false so clicks grant currency instead of buying + gold_mine.press_buys_generator = false + + # Simulate clicking the generator multiple times to produce currency + var click_count: int = 5 + for i in range(click_count): + gold_mine._on_pressed() + await _wait(0.1) + + # Check gold increased + var final_gold: BigNumber = game_state.get_currency_amount_by_id(&"gold") + print("[TARGET] final_gold: mantissa=%s, exponent=%s" % [final_gold.mantissa, final_gold.exponent]) + + # Check owned count + var owned: int = gold_mine.owned + print("[TARGET] gold_mine_owned: %d" % owned) + + # Validate research XP increased + var final_xp: BigNumber = game_state.get_research_xp(&"gold_research") + print("[TARGET] final_research_xp: mantissa=%s, exponent=%s" % [final_xp.mantissa, final_xp.exponent]) + + TestUtils.assert_true( + final_xp.is_greater_than(initial_xp), + "Research XP increased after generator clicks" + ) + + # Test level calculation + var level: int = game_state.get_research_level(&"gold_research") + print("[TARGET] research_level: %d" % level) + + TestUtils.assert_true( + level >= 0, + "Research level is non-negative" + ) + + # Test research multiplier + var multiplier: float = game_state.get_research_multiplier(&"gold_research") + print("[TARGET] research_multiplier: %f" % multiplier) + + TestUtils.assert_greater_than( + multiplier, + 0.0, + "Research multiplier is positive" + ) + + # Test BigNumber XP progress calculation + var research_data: ResearchData = game_state.research_catalogue.get_research_by_id(&"gold_research") + if research_data != null: + var progress: float = research_data.get_xp_progress(final_xp) + print("[TARGET] xp_progress: %f" % progress) + + TestUtils.assert_true( + progress >= 0.0 and progress <= 1.0, + "XP progress is between 0.0 and 1.0" + ) + + # Test level calculation with BigNumber + var calculated_level: int = research_data.get_level_for_xp(final_xp) + print("[TARGET] calculated_level: %d" % calculated_level) + + TestUtils.assert_true( + calculated_level >= 0, + "Calculated level is non-negative" + ) + + _print_summary() + _test_complete = true + if failed == 0: + quit(0) + else: + quit(1) + +func _wait(seconds: float = 0.1) -> void: + await create_timer(seconds).timeout + +func _print_summary(): + TestUtils.print_result() + passed = TestUtils.get_passed() + failed = TestUtils.get_failed() + +func get_passed() -> int: + return passed + +func get_failed() -> int: + return failed diff --git a/tests/test_research_xp_acquisition.gd.uid b/tests/test_research_xp_acquisition.gd.uid new file mode 100644 index 0000000..d04c8b8 --- /dev/null +++ b/tests/test_research_xp_acquisition.gd.uid @@ -0,0 +1 @@ +uid://cq65acistj7ar diff --git a/tests/test_runner.gd b/tests/test_runner.gd new file mode 100644 index 0000000..2ffd46a --- /dev/null +++ b/tests/test_runner.gd @@ -0,0 +1,79 @@ +extends SceneTree + +var test_scripts: Array[String] = [] +var test_index: int = 0 +var total_passed: int = 0 +var total_failed: int = 0 + +func _init(): + print("\n=== GODOT TEST RUNNER ===\n") + _discover_tests() + + if test_scripts.is_empty(): + print("[ERROR] No test scripts found in res://tests/") + print("[RESULT] FAIL") + quit(1) + return + + print("Found %d test(s): %s\n" % [test_scripts.size(), str(test_scripts)]) + + await _run_next_test() + + print("\n=== TEST SUMMARY ===") + print("Total passed: %d" % total_passed) + print("Total failed: %d" % total_failed) + + if total_failed == 0: + print("[OVERALL_RESULT] PASS") + quit(0) + else: + print("[OVERALL_RESULT] FAIL") + quit(1) + +func _discover_tests() -> void: + var dir = DirAccess.open("res://tests") + if dir: + dir.list_dir_begin() + var file_name = dir.get_next() + while file_name != "": + if file_name.ends_with(".gd") and file_name.begins_with("test_") and \ + file_name != "test_runner.gd" and file_name != "test_utils.gd": + test_scripts.append("res://tests/%s" % file_name) + file_name = dir.get_next() + dir.list_dir_end() + test_scripts.sort() + +func _run_next_test() -> void: + if test_index >= test_scripts.size(): + return + + var test_script = test_scripts[test_index] + print("--- Running test %d/%d: %s ---" % [test_index + 1, test_scripts.size(), test_script]) + print("") + + # Load and run test + var test_instance = load(test_script).new() + get_root().add_child(test_instance) + + # Check if test has run() method (new pattern) or uses _ready() (old pattern) + if test_instance.has_method("run"): + await test_instance.run() + # Clean up test instance + test_instance.queue_free() + else: + # For old pattern, wait a bit for _ready() to complete + await create_timer(0.2).timeout + # Clean up test instance + test_instance.queue_free() + + # Get results if available + if test_instance.has_method("get_passed"): + total_passed += test_instance.get_passed() + total_failed += test_instance.get_failed() + + # Wait for cleanup + await create_timer(0.1).timeout + + print("") + test_index += 1 + await _run_next_test() diff --git a/tests/test_runner.gd.uid b/tests/test_runner.gd.uid new file mode 100644 index 0000000..06fc00f --- /dev/null +++ b/tests/test_runner.gd.uid @@ -0,0 +1 @@ +uid://chame3fmeexw6 diff --git a/tests/test_utils.gd b/tests/test_utils.gd new file mode 100644 index 0000000..a1a8519 --- /dev/null +++ b/tests/test_utils.gd @@ -0,0 +1,74 @@ +class_name TestUtils +extends RefCounted + +static var _test_name: String = "" +static var _passed: int = 0 +static var _failed: int = 0 + +static func set_test_name(name: String) -> void: + _test_name = name + _passed = 0 + _failed = 0 + +static func assert_equals(expected: Variant, actual: Variant, message: String) -> void: + if expected == actual: + print("[PASS] %s: %s" % [_test_name, message]) + _passed += 1 + else: + print("[FAIL] %s: %s (expected %s, got %s)" % [ + _test_name, message, str(expected), str(actual) + ]) + _failed += 1 + +static func assert_true(condition: bool, message: String) -> void: + if condition: + print("[PASS] %s: %s" % [_test_name, message]) + _passed += 1 + else: + print("[FAIL] %s: %s" % [_test_name, message]) + _failed += 1 + +static func assert_false(condition: bool, message: String) -> void: + if not condition: + print("[PASS] %s: %s" % [_test_name, message]) + _passed += 1 + else: + print("[FAIL] %s: %s (expected false, got true)" % [_test_name, message]) + _failed += 1 + +static func assert_greater_than(a: float, b: float, message: String) -> void: + if a > b: + print("[PASS] %s: %s" % [_test_name, message]) + _passed += 1 + else: + print("[FAIL] %s: %s (expected %s > %s)" % [_test_name, message, str(a), str(b)]) + _failed += 1 + +static func assert_greater_or_equal(a: float, b: float, message: String) -> void: + if a >= b: + print("[PASS] %s: %s" % [_test_name, message]) + _passed += 1 + else: + print("[FAIL] %s: %s (expected %s >= %s)" % [_test_name, message, str(a), str(b)]) + _failed += 1 + +static func assert_not_null(value: Variant, message: String) -> void: + if value != null: + print("[PASS] %s: %s" % [_test_name, message]) + _passed += 1 + else: + print("[FAIL] %s: %s" % [_test_name, message]) + _failed += 1 + +static func get_passed() -> int: + return _passed + +static func get_failed() -> int: + return _failed + +static func print_result() -> void: + print("[SUMMARY] %s: %d passed, %d failed" % [_test_name, _passed, _failed]) + if _failed == 0: + print("[RESULT] PASS") + else: + print("[RESULT] FAIL") diff --git a/tests/test_utils.gd.uid b/tests/test_utils.gd.uid new file mode 100644 index 0000000..da74c32 --- /dev/null +++ b/tests/test_utils.gd.uid @@ -0,0 +1 @@ +uid://ous5m4c7jotn