systems rework: buffs, prestige graph, research, modular architecture

Decouples core systems from centralized globals in favor of catalogue-based architecture and data-driven content.

   Key changes:
   - Prestige: node-based buff tech tree, graph panel, progress bar
   - Research: multi-worker system, per-generator research, xp generation
   - Ascension: meta-currency layer via multi-currency prestige resets
   - Buffs: refactored as generator-independent via catalogues
   - UI: currency panel, edge-scrolling camera + zoom, current-goal panel
   - Alchemy Tower: crafting building with recipe/cost system
   - Testing: 7 test suites (ascension, prestige, research, goals)
   - Content: migrated from hardcoded idles/ to gym format (tiny_sword)
This commit was merged in pull request #1.
This commit is contained in:
2026-05-07 20:36:21 +00:00
parent 2743fd314a
commit 81a4058b04
242 changed files with 11226 additions and 2242 deletions

View File

@@ -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("<test_name>")
# Load scene
var scene = load("<path_to_gym_scene>") # 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("<GeneratorNodeName>") 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(&"<currency_id>")
print("[TARGET] initial_<currency>: %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(&"<currency_id>")
print("[TARGET] final_<currency>: %s" % final_value.to_string_suffix(2))
TestUtils.assert_greater_than(
final_value.mantissa,
initial_value.mantissa,
"<currency> 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 <test_script_path>
# 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 <test_script_path>"
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_path>
SCRIPT="$1"
GODOT_BIN="${GODOT_BIN:-godot}"
if [ -z "$SCRIPT" ]; then
echo "Usage: $0 <script_path>"
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_<feature>.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)
</content>
<parameter=filePath>
/home/mikymod/work/jmp/idle/.agents/skills/godot-test-instrumentation/SKILL.md

0
.codex Normal file
View File

View File

@@ -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

View File

@@ -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/<file>.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.

546
README.md
View File

@@ -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": {
"<currency_id>": {
"current": {"m": 1.0, "e": 0},
"total": {"m": 1.0, "e": 0},
"all_time": {"m": 1.0, "e": 0}
}
},
"generator_states": {
"<generator_id>": {
"owned": 0,
"purchased_count": 0,
"unlocked": false,
"available": false
}
},
"buff_levels": { "<buff_id>": 0 },
"buff_unlocked": { "<buff_id>": false },
"buff_active": { "<buff_id>": false },
"goals": { "completed": ["<goal_id>"] },
"research_xp": { "<research_id>": {"m": 0.0, "e": 0} },
"research_levels": { "<research_id>": 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/<name>.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/<name>/<name>_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/<name>.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/<name>.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*

283
TODO.md Normal file
View File

@@ -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.

View File

@@ -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)))

266
core/README.md Normal file
View File

@@ -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": {
"<currency_id>": {
"current": {"m": float, "e": int},
"total": {"m": float, "e": int},
"all_time": {"m": float, "e": int}
}
},
"generator_states": {
"<generator_id>": {
"owned": int,
"purchased_count": int,
"unlocked": bool,
"available": bool
}
},
"buff_levels": {
"<buff_id>": int
},
"buff_unlocked": {
"<buff_id>": true
},
"buff_active": {
"<buff_id>": true
},
"goals": {
"completed": ["<goal_id>", ...]
},
"research_xp": {
"<research_id>": {"m": float, "e": int}
},
"research_levels": {
"<research_id>": 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/<name>.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/<name>.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/<name>.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/<name>.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

View File

@@ -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:

24
core/buff_catalogue.gd Normal file
View File

@@ -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

View File

@@ -0,0 +1 @@
uid://ctc5yjlnvi0ok

111
core/currency/README.md Normal file
View File

@@ -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
</content>
<parameter=filePath>
/home/mikymod/work/jmp/idle/core/currency/README.md

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1 @@
uid://621tus0uvbrd

View File

@@ -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

View File

@@ -0,0 +1 @@
uid://blfou5xiynxqf

View File

@@ -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()

View File

@@ -1 +0,0 @@
uid://dmv83fnq26xao

118
core/edge_scroll_camera.gd Normal file
View File

@@ -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()

View File

@@ -0,0 +1 @@
uid://b6systw05frh0

View File

@@ -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

View File

@@ -1 +0,0 @@
uid://d2j7tvlgxr2jp

View File

@@ -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

225
core/generator/README.md Normal file
View File

@@ -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

View File

@@ -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()

View File

@@ -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"]

View File

@@ -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)

View File

@@ -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 "-"

View File

@@ -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]

View File

@@ -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

View File

@@ -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)

View File

@@ -0,0 +1 @@
uid://bhrcgp2dna0mb

192
core/goals/README.md Normal file
View File

@@ -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

View File

@@ -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)

View File

@@ -0,0 +1 @@
uid://dfn06haxb2yhr

View File

@@ -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"

View File

@@ -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

View File

@@ -0,0 +1 @@
uid://bfbp4mo8ys5p8

View File

@@ -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())

View File

@@ -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]

1534
core/level_game_state.gd Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
uid://cv2132o4hi7q3

467
core/prestige/README.md Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1 @@
uid://bjcvnyh2bc6l5

View File

@@ -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

View File

@@ -0,0 +1 @@
uid://uv5lxj6hmpk

View File

@@ -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()

View File

@@ -0,0 +1 @@
uid://n01pkakxllnj

View File

@@ -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()

View File

@@ -0,0 +1 @@
uid://w8ogrp1s6qsf

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1 @@
uid://mjyig8xlgki0

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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?"

View File

@@ -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)

View File

@@ -0,0 +1 @@
uid://dsq8yix7kkyaj

View File

@@ -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"

488
core/research/README.md Normal file
View File

@@ -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

616
core/research/TODO.md Normal file
View File

@@ -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

View File

@@ -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()

View File

@@ -0,0 +1 @@
uid://d2v6t6w2todfy

View File

@@ -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)

View File

@@ -0,0 +1 @@
uid://m7baywrfnpn0

View File

@@ -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]

View File

@@ -0,0 +1 @@
uid://fdl7ftxw8w1e

View File

@@ -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"]

View File

@@ -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

View File

@@ -0,0 +1 @@
uid://gk3mr3k5yvp7

View File

@@ -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"

View File

@@ -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]

View File

@@ -0,0 +1 @@
uid://brvoy607b3nft

View File

@@ -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"

View File

@@ -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))

View File

@@ -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]

View File

@@ -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

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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

View File

@@ -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"

View File

@@ -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.01.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 |

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

@@ -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

View File

@@ -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)

View File

@@ -0,0 +1 @@
uid://do1i3hrd0ueb6

View File

@@ -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

View File

@@ -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()

View File

@@ -0,0 +1 @@
uid://c63g772y4kxwm

View File

@@ -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"]

View File

@@ -0,0 +1 @@
uid://bsfgq4apkidak

View File

@@ -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

View File

@@ -0,0 +1 @@
uid://bddaaj76msmvj

View File

@@ -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"]

Some files were not shown because too many files have changed in this diff Show More