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:
642
.agents/skills/godot-test-instrumentation/SKILL.md
Normal file
642
.agents/skills/godot-test-instrumentation/SKILL.md
Normal 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
|
||||||
97
.opencode/plans/currency-panel.md
Normal file
97
.opencode/plans/currency-panel.md
Normal 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
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
# AGENTS.md
|
# 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
|
## Build, Lint, And Test Commands
|
||||||
Use `GODOT_BIN` for your local editor binary (e.g. `godot`, `godot4`, or full path).
|
Use `GODOT_BIN` for your local editor binary (e.g. `godot`, `godot4`, or full path).
|
||||||
Run project: `"$GODOT_BIN" --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`
|
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).
|
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`.
|
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
|
## Architecture And Structure
|
||||||
`project.godot` defines autoload singletons: `GameState` (runtime currency state + save/load) and `CurrencyGeneratorDatabase` (loads `generator_data.json`).
|
`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.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.
|
`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.
|
`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.
|
Persistence is local JSON only: `user://idle_save.json`; there is no external DB/server/backend.
|
||||||
|
|
||||||
## Code Style And Conventions
|
## Code Style And Conventions
|
||||||
Prefer typed GDScript (`var x: Type`, `func f() -> void`) and keep shared models/components as `class_name` scripts.
|
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.
|
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.
|
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.
|
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.
|
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
546
README.md
@@ -1,261 +1,313 @@
|
|||||||
# Idles - Systems Architecture Report
|
# Idles — Godot 4.6 Idle Game
|
||||||
|
|
||||||
|
A modular idle/incremental game prototype featuring a decoupled buff system, multi-target buffs with wildcard support, goal-based progression, prestige resets, and production-driven research.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run the project
|
||||||
|
"$GODOT_BIN" --path .
|
||||||
|
|
||||||
|
# Headless mode (lint/smoke test)
|
||||||
|
"$GODOT_BIN" --headless --path . --quit
|
||||||
|
|
||||||
|
# Check single script syntax
|
||||||
|
"$GODOT_BIN" --headless --check-only --script res://core/big_number.gd
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
### Core Systems
|
||||||
|
|
||||||
|
| System | Location | Purpose |
|
||||||
|
|--------|----------|---------|
|
||||||
|
| **BigNumber** | `core/big_number.gd` | Handles huge numbers (mantissa + exponent) for idle game math |
|
||||||
|
| **LevelGameState** | `core/level_game_state.gd` | Central state authority — currencies, generators, buffs, goals, research, persistence |
|
||||||
|
| **Currency** | `core/currency/`, `core/currency_catalogue.gd` | Catalog system for all in-game currencies |
|
||||||
|
| **Generators** | `core/generator/` | Production mechanics (auto cycles, clicks, purchases) |
|
||||||
|
| **Buffs** | `core/generator/generator_buff_data.gd` | Global, multi-target buffs applied to generators |
|
||||||
|
| **Goals** | `core/goals/` | Unlock conditions and achievement tracking |
|
||||||
|
| **Prestige** | `core/prestige/` | Reset-with-bonus mechanics |
|
||||||
|
| **Research** | `core/research/` | XP-based progression with production multipliers |
|
||||||
|
|
||||||
|
### Key Nodes
|
||||||
|
|
||||||
|
| Node | Script | Description |
|
||||||
|
|------|--------|-------------|
|
||||||
|
| `LevelGameState` | `core/level_game_state.gd` | Central state management, save/load, catalogs, all signals |
|
||||||
|
| `PrestigeManager` | `core/prestige/prestige_manager.gd` | Prestige calculation, reset orchestration |
|
||||||
|
| `CurrencyGenerator` | `core/generator/currency_generator.gd` | Per-generator production, purchasing, buff interaction |
|
||||||
|
| `GeneratorPanel` | `core/generator/generator_container.gd` | UI panel for a single generator |
|
||||||
|
|
||||||
|
**Important:** There are **no autoload singletons**. `LevelGameState` and `PrestigeManager` are regular nodes instantiated in the scene (e.g., `docs/gyms/tiny_sword/tiny_sword.tscn`). All code accesses them through `@export` references or `get_node()`, never through an autoload name like `/root/GameState`.
|
||||||
|
|
||||||
## Scope Covered
|
### Data Directory Structure
|
||||||
|
|
||||||
This report covers all tracked files in the repository (62 total at time of analysis), including:
|
All gameplay resources (`.tres` files) live under:
|
||||||
|
|
||||||
1. Project configuration.
|
```
|
||||||
2. Core runtime scripts.
|
docs/gyms/tiny_sword/
|
||||||
3. Gameplay/economy systems.
|
├── currencies/ # Currency resources (gold, food, wood, worker, etc.)
|
||||||
4. Goal/unlock systems.
|
├── buildings/ # Generator configuration data
|
||||||
5. UI adapter scripts and scenes.
|
│ ├── farm/ # farm_generator.tres
|
||||||
6. Data resources (`.tres`).
|
│ ├── forestry/ # forestry_generator.tres
|
||||||
7. Technical docs and metadata/import files.
|
│ ├── 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`.
|
### Buff Multiplier Stacking
|
||||||
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.
|
|
||||||
|
|
||||||
## 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`).
|
## License
|
||||||
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.
|
|
||||||
|
|
||||||
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`
|
*Godot version: 4.6*
|
||||||
|
|
||||||
`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.
|
|
||||||
|
|||||||
283
TODO.md
Normal file
283
TODO.md
Normal 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.
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
extends HBoxContainer
|
extends HBoxContainer
|
||||||
|
|
||||||
@export var currency: Resource
|
@export var currency: Currency
|
||||||
|
@export var game_state: LevelGameState
|
||||||
|
|
||||||
@onready var _label_start = $LabelStart
|
@onready var _label_start = $LabelStart
|
||||||
@onready var _label_end = $LabelEnd
|
@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)
|
_progress_bar.max_value = _end.get_ratio(_start)
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
if currency == null:
|
|
||||||
currency = CurrencyDatabase.get_currency_resource(GameState.GOLD_CURRENCY_ID)
|
|
||||||
if currency == null:
|
if currency == null:
|
||||||
push_warning("BigNumberProgressBar '%s' has no currency configured." % String(name))
|
push_warning("BigNumberProgressBar '%s' has no currency configured." % String(name))
|
||||||
return
|
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)
|
game_state.currency_changed.connect(_on_currency_changed)
|
||||||
_current = GameState.get_currency_amount_by_id(_currency_id)
|
_current = game_state.get_currency_amount_by_id(_currency_id)
|
||||||
|
|
||||||
set_limits(_current, _current.add(BigNumber.new(1, 1)))
|
set_limits(_current, _current.add(BigNumber.new(1, 1)))
|
||||||
|
|
||||||
|
|||||||
266
core/README.md
Normal file
266
core/README.md
Normal 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
|
||||||
@@ -195,6 +195,16 @@ func get_ratio(target: BigNumber) -> float:
|
|||||||
static func from_float(val: float) -> BigNumber:
|
static func from_float(val: float) -> BigNumber:
|
||||||
return BigNumber.new(val, 0)
|
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")
|
## Outputs a UI-friendly string (e.g., "1.50e12")
|
||||||
func to_string_sci(decimals: int = 2) -> String:
|
func to_string_sci(decimals: int = 2) -> String:
|
||||||
if exponent < 3:
|
if exponent < 3:
|
||||||
|
|||||||
24
core/buff_catalogue.gd
Normal file
24
core/buff_catalogue.gd
Normal 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
|
||||||
1
core/buff_catalogue.gd.uid
Normal file
1
core/buff_catalogue.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://ctc5yjlnvi0ok
|
||||||
111
core/currency/README.md
Normal file
111
core/currency/README.md
Normal 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
|
||||||
@@ -4,3 +4,4 @@ extends Resource
|
|||||||
@export var id: StringName = &""
|
@export var id: StringName = &""
|
||||||
@export var display_name: String = ""
|
@export var display_name: String = ""
|
||||||
@export var icon: Texture2D
|
@export var icon: Texture2D
|
||||||
|
@export var always_visible: bool = false
|
||||||
|
|||||||
17
core/currency/currency_catalogue.gd
Normal file
17
core/currency/currency_catalogue.gd
Normal 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
|
||||||
1
core/currency/currency_catalogue.gd.uid
Normal file
1
core/currency/currency_catalogue.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://621tus0uvbrd
|
||||||
35
core/currency/currency_panel.gd
Normal file
35
core/currency/currency_panel.gd
Normal 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
|
||||||
1
core/currency/currency_panel.gd.uid
Normal file
1
core/currency/currency_panel.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://blfou5xiynxqf
|
||||||
@@ -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()
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://dmv83fnq26xao
|
|
||||||
118
core/edge_scroll_camera.gd
Normal file
118
core/edge_scroll_camera.gd
Normal 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()
|
||||||
1
core/edge_scroll_camera.gd.uid
Normal file
1
core/edge_scroll_camera.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://b6systw05frh0
|
||||||
@@ -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
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://d2j7tvlgxr2jp
|
|
||||||
@@ -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
225
core/generator/README.md
Normal 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
|
||||||
@@ -24,7 +24,7 @@ signal goal_achieved(generator_id: StringName, goal_id: StringName)
|
|||||||
const HUGE_COST_EXPONENT: int = 1000000
|
const HUGE_COST_EXPONENT: int = 1000000
|
||||||
|
|
||||||
## Currency target updated by this generator.
|
## Currency target updated by this generator.
|
||||||
@export var currency: Resource
|
@export var currency: Currency
|
||||||
## Data resource containing balancing values and defaults (required).
|
## Data resource containing balancing values and defaults (required).
|
||||||
@export var data: CurrencyGeneratorData
|
@export var data: CurrencyGeneratorData
|
||||||
## Enables per-cycle automatic production when true.
|
## Enables per-cycle automatic production when true.
|
||||||
@@ -37,49 +37,52 @@ const HUGE_COST_EXPONENT: int = 1000000
|
|||||||
@export var run_multiplier: float = 1.0
|
@export var run_multiplier: float = 1.0
|
||||||
|
|
||||||
## Reference to generator UI
|
## 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.
|
## True while the pointer is inside the generator Area2D.
|
||||||
var _mouse_entered: bool = false
|
var _mouse_entered: bool = false
|
||||||
## Time left until next click/hover grant is allowed.
|
## Time left until next click/hover grant is allowed.
|
||||||
var _remaining_click_cooldown_seconds: float = 0.0
|
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.
|
## 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:
|
var owned: int:
|
||||||
get:
|
get:
|
||||||
_ensure_registered()
|
_ensure_registered()
|
||||||
return GameState.get_generator_owned(_generator_id)
|
return game_state.get_generator_owned(_generator_id)
|
||||||
set(value):
|
set(value):
|
||||||
_ensure_registered()
|
_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).
|
## Lifetime purchased generators (does not decrease if ownership drops).
|
||||||
var purchased_count: int:
|
var purchased_count: int:
|
||||||
get:
|
get:
|
||||||
_ensure_registered()
|
_ensure_registered()
|
||||||
return GameState.get_generator_purchased_count(_generator_id)
|
return game_state.get_generator_purchased_count(_generator_id)
|
||||||
set(value):
|
set(value):
|
||||||
_ensure_registered()
|
_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.
|
## Whether this generator is unlocked for the player.
|
||||||
var is_unlocked: bool:
|
var is_unlocked: bool:
|
||||||
get:
|
get:
|
||||||
_ensure_registered()
|
_ensure_registered()
|
||||||
return GameState.is_generator_unlocked(_generator_id)
|
return game_state.is_generator_unlocked(_generator_id)
|
||||||
set(value):
|
set(value):
|
||||||
_ensure_registered()
|
_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.
|
## Whether this unlocked generator is currently visible/usable to the player.
|
||||||
var is_available: bool:
|
var is_available: bool:
|
||||||
get:
|
get:
|
||||||
_ensure_registered()
|
_ensure_registered()
|
||||||
return GameState.is_generator_available(_generator_id)
|
return game_state.is_generator_available(_generator_id)
|
||||||
set(value):
|
set(value):
|
||||||
_ensure_registered()
|
_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.
|
## Accumulated elapsed time toward the next automatic production cycle.
|
||||||
var cycle_progress_seconds: float = 0.0
|
var cycle_progress_seconds: float = 0.0
|
||||||
@@ -96,12 +99,15 @@ func _ready() -> void:
|
|||||||
assert(data != null, "Data cannot be null")
|
assert(data != null, "Data cannot be null")
|
||||||
|
|
||||||
_generator_id = _resolve_generator_id()
|
_generator_id = _resolve_generator_id()
|
||||||
#_ensure_registered()
|
|
||||||
cycle_progress_seconds = 0.0
|
cycle_progress_seconds = 0.0
|
||||||
|
|
||||||
GameState.currency_changed.connect(_on_currency_changed)
|
if game_state:
|
||||||
GameState.generator_state_changed.connect(_on_generated_state_changed)
|
game_state.currency_changed.connect(_on_currency_changed)
|
||||||
_evaluate_generator_unlock_goal(false)
|
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.
|
## Updates click cooldown/click grants and runs automatic production cycles.
|
||||||
func _process(delta: float) -> void:
|
func _process(delta: float) -> void:
|
||||||
@@ -138,6 +144,23 @@ func _grant_cycle_income(cycle_count: int) -> void:
|
|||||||
_add_currency(produced)
|
_add_currency(produced)
|
||||||
production_tick.emit(produced, cycle_count, owned)
|
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.
|
## Convenience helper for the next single-unit purchase cost.
|
||||||
func get_next_cost() -> BigNumber:
|
func get_next_cost() -> BigNumber:
|
||||||
return get_cost_for_amount(1)
|
return get_cost_for_amount(1)
|
||||||
@@ -195,7 +218,7 @@ func buy_max() -> int:
|
|||||||
if purchase_currency_id == &"":
|
if purchase_currency_id == &"":
|
||||||
return 0
|
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)
|
var estimated_max: int = data.max_affordable(owned, available_currency)
|
||||||
if estimated_max <= 0:
|
if estimated_max <= 0:
|
||||||
return 0
|
return 0
|
||||||
@@ -219,18 +242,27 @@ func buy_max() -> int:
|
|||||||
func get_buffs() -> Array[GeneratorBuffData]:
|
func get_buffs() -> Array[GeneratorBuffData]:
|
||||||
if data == null:
|
if data == null:
|
||||||
return []
|
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:
|
func get_buff_level(buff_id: StringName) -> int:
|
||||||
_ensure_registered()
|
_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:
|
func is_buff_unlocked(buff_id: StringName) -> bool:
|
||||||
_ensure_registered()
|
_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:
|
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:
|
if buff == null:
|
||||||
return BigNumber.from_float(0.0)
|
return BigNumber.from_float(0.0)
|
||||||
if not is_buff_unlocked(buff.id):
|
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)
|
return buff.get_cost_for_level(level)
|
||||||
|
|
||||||
func get_buff_cost_currency_id(buff_id: StringName) -> StringName:
|
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:
|
if buff == null:
|
||||||
return &""
|
return &""
|
||||||
return _resolve_buff_cost_currency_id(buff)
|
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():
|
if not is_available_to_player():
|
||||||
return false
|
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:
|
if buff == null:
|
||||||
return false
|
return false
|
||||||
|
|
||||||
@@ -265,21 +303,24 @@ func can_buy_buff(buff_id: StringName) -> bool:
|
|||||||
return false
|
return false
|
||||||
|
|
||||||
var cost: BigNumber = buff.get_cost_for_level(current_level)
|
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)
|
return current_amount.is_greater_than(cost) or current_amount.is_equal_to(cost)
|
||||||
|
|
||||||
func buy_buff(buff_id: StringName) -> bool:
|
func buy_buff(buff_id: StringName) -> bool:
|
||||||
if not is_available_to_player():
|
if not is_available_to_player():
|
||||||
return false
|
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:
|
if buff == null:
|
||||||
return false
|
return false
|
||||||
|
|
||||||
if not is_buff_unlocked(buff.id):
|
if not game_state.is_buff_unlocked(buff.id):
|
||||||
return false
|
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):
|
if not buff.can_purchase_next_level(current_level):
|
||||||
return false
|
return false
|
||||||
|
|
||||||
@@ -293,25 +334,58 @@ func buy_buff(buff_id: StringName) -> bool:
|
|||||||
return false
|
return false
|
||||||
|
|
||||||
var next_level: int = current_level + 1
|
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)
|
_apply_resource_purchase_buff(buff, next_level)
|
||||||
buff_purchased.emit(buff.id, next_level, cost, cost_currency_id)
|
buff_purchased.emit(buff.id, next_level, cost, cost_currency_id)
|
||||||
return true
|
return true
|
||||||
|
|
||||||
func is_buff_maxed(buff_id: StringName) -> bool:
|
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:
|
if buff == null:
|
||||||
return true
|
return true
|
||||||
if not is_buff_unlocked(buff.id):
|
if not game_state.is_buff_unlocked(buff.id):
|
||||||
return false
|
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:
|
func get_automatic_production_multiplier() -> float:
|
||||||
return _get_multiplier_for_buff_kind(GeneratorBuffData.BuffKind.AUTO_PRODUCTION_MULTIPLIER)
|
return _get_multiplier_for_buff_kind(GeneratorBuffData.BuffKind.AUTO_PRODUCTION_MULTIPLIER)
|
||||||
|
|
||||||
func get_effective_auto_run_multiplier() -> float:
|
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:
|
func reset_runtime_state_for_prestige() -> void:
|
||||||
_is_registered = false
|
_is_registered = false
|
||||||
@@ -319,8 +393,6 @@ func reset_runtime_state_for_prestige() -> void:
|
|||||||
cycle_progress_seconds = 0.0
|
cycle_progress_seconds = 0.0
|
||||||
_remaining_click_cooldown_seconds = 0.0
|
_remaining_click_cooldown_seconds = 0.0
|
||||||
_ensure_registered()
|
_ensure_registered()
|
||||||
_evaluate_generator_unlock_goal(false)
|
|
||||||
_evaluate_buff_unlock_goals()
|
|
||||||
visible = is_available_to_player()
|
visible = is_available_to_player()
|
||||||
|
|
||||||
func get_manual_click_multiplier() -> float:
|
func get_manual_click_multiplier() -> float:
|
||||||
@@ -359,9 +431,30 @@ func _try_grant_click_currency() -> void:
|
|||||||
if _remaining_click_cooldown_seconds > 0.0:
|
if _remaining_click_cooldown_seconds > 0.0:
|
||||||
return
|
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()
|
_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.
|
## Resolves click reward from data.
|
||||||
func _get_click_value() -> BigNumber:
|
func _get_click_value() -> BigNumber:
|
||||||
if data == null:
|
if data == null:
|
||||||
@@ -384,20 +477,10 @@ func _get_click_cooldown_seconds() -> float:
|
|||||||
func _grants_click_while_hovering() -> bool:
|
func _grants_click_while_hovering() -> bool:
|
||||||
return grants_click_while_hovering
|
return grants_click_while_hovering
|
||||||
|
|
||||||
func _get_prestige_multiplier() -> float:
|
func _get_prestige_buff_production_multiplier() -> float:
|
||||||
var manager: PrestigeManager = get_node_or_null("/root/PrestigeManager")
|
if game_state == null:
|
||||||
if manager == null:
|
|
||||||
return 1.0
|
return 1.0
|
||||||
if not manager.get_total_multiplier():
|
return game_state.get_prestige_buff_multiplier(PrestigeBuffNode.EffectType.CURRENCY_PRODUCTION_MULTIPLIER, _generator_id)
|
||||||
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
|
|
||||||
|
|
||||||
## Returns this generator's resolved state id.
|
## Returns this generator's resolved state id.
|
||||||
func get_generator_id() -> StringName:
|
func get_generator_id() -> StringName:
|
||||||
@@ -406,14 +489,16 @@ func get_generator_id() -> StringName:
|
|||||||
|
|
||||||
## Returns the configured currency id.
|
## Returns the configured currency id.
|
||||||
func get_currency_id() -> StringName:
|
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:
|
func get_purchase_currency_id() -> StringName:
|
||||||
var purchase_currency: Currency = _resolve_purchase_currency()
|
var purchase_currency: Currency = _resolve_purchase_currency()
|
||||||
if purchase_currency == null:
|
if purchase_currency == null:
|
||||||
return &""
|
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.
|
## True when the generator is both unlocked and available.
|
||||||
func is_available_to_player() -> bool:
|
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))
|
push_error("CurrencyGenerator '%s' cannot add currency: currency resource is null." % String(name))
|
||||||
return
|
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:
|
func _add_currency_by_id(currency_id: StringName, amount: BigNumber) -> void:
|
||||||
if currency_id == &"":
|
if currency_id == &"":
|
||||||
return
|
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.
|
## Attempts to spend target currency; returns false when insufficient.
|
||||||
func _spend_currency(cost: BigNumber) -> bool:
|
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))
|
push_error("CurrencyGenerator '%s' cannot spend currency: currency resource is null." % String(name))
|
||||||
return false
|
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:
|
func _spend_currency_by_id(currency_id: StringName, cost: BigNumber) -> bool:
|
||||||
if currency_id == &"":
|
if currency_id == &"":
|
||||||
return false
|
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.
|
## Returns the current amount for the configured currency type.
|
||||||
func _get_currency_amount() -> BigNumber:
|
func _get_currency_amount() -> BigNumber:
|
||||||
if currency == null:
|
if currency == null:
|
||||||
return BigNumber.from_float(0.0)
|
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:
|
func _get_currency_amount_by_id(currency_id: StringName) -> BigNumber:
|
||||||
if currency_id == &"":
|
if currency_id == &"":
|
||||||
return BigNumber.from_float(0.0)
|
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.
|
## Safely converts finite float values into BigNumber costs.
|
||||||
func _float_to_big_number(value: float) -> BigNumber:
|
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)
|
return BigNumber.from_float(value)
|
||||||
|
|
||||||
## Converts BigNumber to float for approximate estimate math.
|
## 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:
|
func _resolve_buff_cost_currency_id(buff: GeneratorBuffData) -> StringName:
|
||||||
if buff == null:
|
if buff == null:
|
||||||
return &""
|
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:
|
if cost_currency == null:
|
||||||
return &""
|
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:
|
func _resolve_purchase_currency() -> Currency:
|
||||||
if data == null:
|
if data == null:
|
||||||
@@ -502,25 +589,18 @@ func _resolve_buff_target_currency_id(buff: GeneratorBuffData) -> StringName:
|
|||||||
if buff == null:
|
if buff == null:
|
||||||
return &""
|
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:
|
if target_currency == null:
|
||||||
return &""
|
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:
|
func _get_multiplier_for_buff_kind(kind: GeneratorBuffData.BuffKind) -> float:
|
||||||
var multiplier: float = 1.0
|
if game_state == null:
|
||||||
for buff in get_buffs():
|
return 1.0
|
||||||
if buff == null:
|
return game_state.get_effective_multiplier(_generator_id, kind)
|
||||||
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)
|
|
||||||
|
|
||||||
func _apply_resource_purchase_buff(buff: GeneratorBuffData, level: int) -> void:
|
func _apply_resource_purchase_buff(buff: GeneratorBuffData, level: int) -> void:
|
||||||
if buff == null:
|
if buff == null:
|
||||||
@@ -550,7 +630,7 @@ func _resolve_generator_id() -> StringName:
|
|||||||
|
|
||||||
return &"generator"
|
return &"generator"
|
||||||
|
|
||||||
## Ensures this generator has a state entry in GameState.
|
## Ensures this generator has a state entry in LevelGameState.
|
||||||
func _ensure_registered() -> void:
|
func _ensure_registered() -> void:
|
||||||
if _is_registered or _is_registering:
|
if _is_registered or _is_registering:
|
||||||
return
|
return
|
||||||
@@ -560,8 +640,11 @@ func _ensure_registered() -> void:
|
|||||||
if _generator_id == &"":
|
if _generator_id == &"":
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if game_state == null:
|
||||||
|
return
|
||||||
|
|
||||||
_is_registering = true
|
_is_registering = true
|
||||||
GameState.register_generator(
|
game_state.register_generator(
|
||||||
_generator_id,
|
_generator_id,
|
||||||
_default_unlocked_state(),
|
_default_unlocked_state(),
|
||||||
_default_available_state(),
|
_default_available_state(),
|
||||||
@@ -572,6 +655,9 @@ func _ensure_registered() -> void:
|
|||||||
_is_registering = false
|
_is_registering = false
|
||||||
|
|
||||||
func _register_buffs() -> void:
|
func _register_buffs() -> void:
|
||||||
|
if game_state == null:
|
||||||
|
return
|
||||||
|
|
||||||
for buff in get_buffs():
|
for buff in get_buffs():
|
||||||
if buff == null:
|
if buff == null:
|
||||||
continue
|
continue
|
||||||
@@ -581,58 +667,34 @@ func _register_buffs() -> void:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
var buff_id: StringName = StringName(buff_id_text)
|
var buff_id: StringName = StringName(buff_id_text)
|
||||||
GameState.register_generator_buff(_generator_id, buff_id, 0)
|
game_state.register_buff(buff)
|
||||||
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)
|
|
||||||
|
|
||||||
_evaluate_buff_unlock_goals()
|
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)
|
||||||
|
|
||||||
func _evaluate_buff_unlock_goals() -> void:
|
game_state.try_unlock_buff_from_goal(buff)
|
||||||
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)
|
|
||||||
|
|
||||||
func _on_currency_changed(_changed_currency_id: StringName, _new_amount: BigNumber) -> void:
|
func _on_currency_changed(_changed_currency_id: StringName, _new_amount: BigNumber) -> void:
|
||||||
_evaluate_generator_unlock_goal(false)
|
if game_state == null:
|
||||||
_evaluate_buff_unlock_goals()
|
return
|
||||||
|
game_state.evaluate_all_goals()
|
||||||
|
|
||||||
func try_unlock_from_goal() -> bool:
|
func _on_goal_completed(goal_id: StringName) -> void:
|
||||||
return _evaluate_generator_unlock_goal(true)
|
if data == null or not data.has_unlock_goal():
|
||||||
|
return
|
||||||
func _evaluate_generator_unlock_goal(allow_manual_unlock: bool) -> bool:
|
if data.get_unlock_goal_id() != goal_id:
|
||||||
if data == null:
|
return
|
||||||
return false
|
|
||||||
if is_available_to_player():
|
if is_available_to_player():
|
||||||
return false
|
return
|
||||||
if not data.has_unlock_goal():
|
|
||||||
return false
|
|
||||||
if not data.unlocks_automatically_from_goal() and not allow_manual_unlock:
|
|
||||||
return false
|
|
||||||
if not data.is_unlock_goal_met():
|
|
||||||
return false
|
|
||||||
|
|
||||||
GameState.set_generator_unlocked(_generator_id, true)
|
if game_state == null:
|
||||||
GameState.set_generator_available(_generator_id, true)
|
return
|
||||||
goal_achieved.emit(_generator_id, data.get_unlock_goal_id())
|
game_state.set_generator_unlocked(_generator_id, true)
|
||||||
return 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.
|
## Default unlocked state loaded from generator data.
|
||||||
func _default_unlocked_state() -> bool:
|
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:
|
func _on_generated_state_changed(generator_id: StringName, _state: Dictionary) -> void:
|
||||||
if generator_id == _generator_id:
|
if generator_id == _generator_id:
|
||||||
visible = is_available_to_player()
|
visible = is_available_to_player()
|
||||||
|
if info_generator_container != null:
|
||||||
|
info_generator_container._refresh_generator_ui()
|
||||||
|
|||||||
@@ -1,34 +1,6 @@
|
|||||||
[gd_scene format=3 uid="uid://jeoiinukrrsp"]
|
[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="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]
|
[node name="CurrencyGenerator" type="Node2D" unique_id=967969064]
|
||||||
script = ExtResource("1_4n4ca")
|
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"]
|
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
class_name CurrencyGeneratorData
|
class_name CurrencyGeneratorData
|
||||||
extends Resource
|
extends Resource
|
||||||
|
|
||||||
enum UnlockGoalBehavior {
|
|
||||||
AUTOMATIC,
|
|
||||||
MANUAL_BUTTON,
|
|
||||||
}
|
|
||||||
|
|
||||||
@export var id: StringName = &""
|
@export var id: StringName = &""
|
||||||
@export var name: String = ""
|
@export var name: String = ""
|
||||||
@export var starts_unlocked: bool = true
|
@export var starts_unlocked: bool = true
|
||||||
@@ -13,7 +8,6 @@ enum UnlockGoalBehavior {
|
|||||||
@export var initial_owned: int = 0
|
@export var initial_owned: int = 0
|
||||||
@export var purchase_currency: Currency
|
@export var purchase_currency: Currency
|
||||||
@export var unlock_goal: GoalData
|
@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
|
## Base cost and exponential growth (part I): cost_next = b * r^owned
|
||||||
@export var initial_cost: float = 10.0
|
@export var initial_cost: float = 10.0
|
||||||
@@ -37,8 +31,7 @@ enum UnlockGoalBehavior {
|
|||||||
@export var click_exponent: int = 0
|
@export var click_exponent: int = 0
|
||||||
@export var click_cooldown_seconds: float = 0.2
|
@export var click_cooldown_seconds: float = 0.2
|
||||||
|
|
||||||
## Data-driven buffs purchasable for this generator.
|
@export var research_data: ResearchData
|
||||||
@export var buffs: Array[GeneratorBuffData] = []
|
|
||||||
|
|
||||||
func has_unlock_goal() -> bool:
|
func has_unlock_goal() -> bool:
|
||||||
if unlock_goal == null:
|
if unlock_goal == null:
|
||||||
@@ -46,12 +39,6 @@ func has_unlock_goal() -> bool:
|
|||||||
|
|
||||||
return bool(unlock_goal.is_valid())
|
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:
|
func get_unlock_goal_id() -> StringName:
|
||||||
if not has_unlock_goal():
|
if not has_unlock_goal():
|
||||||
return &""
|
return &""
|
||||||
@@ -62,22 +49,6 @@ func get_unlock_goal_id() -> StringName:
|
|||||||
|
|
||||||
return StringName(goal_id_text)
|
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
|
## Returns cost of next generator
|
||||||
func cost_next(owned: int) -> float:
|
func cost_next(owned: int) -> float:
|
||||||
return cost_for_amount(owned, 1)
|
return cost_for_amount(owned, 1)
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ enum BuffKind {
|
|||||||
AUTO_PRODUCTION_MULTIPLIER,
|
AUTO_PRODUCTION_MULTIPLIER,
|
||||||
MANUAL_CLICK_MULTIPLIER,
|
MANUAL_CLICK_MULTIPLIER,
|
||||||
RESOURCE_PURCHASE,
|
RESOURCE_PURCHASE,
|
||||||
|
RESEARCH_XP_MULTIPLIER,
|
||||||
}
|
}
|
||||||
|
|
||||||
const HUGE_EXPONENT: int = 1000000
|
const HUGE_EXPONENT: int = 1000000
|
||||||
|
|
||||||
@export var id: StringName = &""
|
@export var id: StringName = &""
|
||||||
|
@export var target_ids: Array[StringName] = []
|
||||||
@export var kind: BuffKind = BuffKind.AUTO_PRODUCTION_MULTIPLIER
|
@export var kind: BuffKind = BuffKind.AUTO_PRODUCTION_MULTIPLIER
|
||||||
@export_multiline var text: String = ""
|
@export_multiline var text: String = ""
|
||||||
@export var icon: Texture2D
|
@export var icon: Texture2D
|
||||||
@@ -23,6 +25,10 @@ const HUGE_EXPONENT: int = 1000000
|
|||||||
@export var base_effect: float = 1.0
|
@export var base_effect: float = 1.0
|
||||||
@export var effect_increment: float = 0.1
|
@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_group("Cost")
|
||||||
@export var cost_currency: Currency
|
@export var cost_currency: Currency
|
||||||
@export var base_cost_mantissa: float = 10.0
|
@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_base_exponent: int = 0
|
||||||
@export var resource_purchase_increment_multiplier: float = 1.2
|
@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:
|
func has_unlock_goal() -> bool:
|
||||||
if unlock_goal == null:
|
if unlock_goal == null:
|
||||||
return false
|
return false
|
||||||
@@ -45,10 +63,10 @@ func get_unlock_goal_amount() -> BigNumber:
|
|||||||
if unlock_goal == null:
|
if unlock_goal == null:
|
||||||
return BigNumber.from_float(0.0)
|
return BigNumber.from_float(0.0)
|
||||||
|
|
||||||
var valid_requirements: Array[GoalRequirementData] = unlock_goal.get_valid_requirements()
|
var requirements: Array[GoalRequirementData] = unlock_goal.get_requirements()
|
||||||
if valid_requirements.is_empty():
|
if requirements.is_empty():
|
||||||
return BigNumber.from_float(0.0)
|
return BigNumber.from_float(0.0)
|
||||||
var requirement_resource: GoalRequirementData = valid_requirements[0]
|
var requirement_resource: GoalRequirementData = requirements[0]
|
||||||
if requirement_resource == null:
|
if requirement_resource == null:
|
||||||
return BigNumber.from_float(0.0)
|
return BigNumber.from_float(0.0)
|
||||||
|
|
||||||
@@ -62,21 +80,15 @@ func get_unlock_goal_currency() -> Currency:
|
|||||||
if unlock_goal == null:
|
if unlock_goal == null:
|
||||||
return null
|
return null
|
||||||
|
|
||||||
var valid_requirements: Array[GoalRequirementData] = unlock_goal.get_valid_requirements()
|
var requirements: Array[GoalRequirementData] = unlock_goal.get_requirements()
|
||||||
if valid_requirements.is_empty():
|
if requirements.is_empty():
|
||||||
return null
|
return null
|
||||||
var requirement_resource: GoalRequirementData = valid_requirements[0]
|
var requirement_resource: GoalRequirementData = requirements[0]
|
||||||
if requirement_resource == null:
|
if requirement_resource == null:
|
||||||
return null
|
return null
|
||||||
|
|
||||||
return requirement_resource.currency
|
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:
|
func can_purchase_next_level(current_level: int) -> bool:
|
||||||
if max_level < 0:
|
if max_level < 0:
|
||||||
return true
|
return true
|
||||||
@@ -126,7 +138,9 @@ func get_effect_description(level: int) -> String:
|
|||||||
BuffKind.AUTO_PRODUCTION_MULTIPLIER, BuffKind.MANUAL_CLICK_MULTIPLIER:
|
BuffKind.AUTO_PRODUCTION_MULTIPLIER, BuffKind.MANUAL_CLICK_MULTIPLIER:
|
||||||
return "x%.2f" % get_effect_multiplier(safe_level)
|
return "x%.2f" % get_effect_multiplier(safe_level)
|
||||||
BuffKind.RESOURCE_PURCHASE:
|
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 "-"
|
return "-"
|
||||||
|
|
||||||
|
|||||||
@@ -37,11 +37,10 @@ func _ready() -> void:
|
|||||||
_generator.purchase_failed.connect(_on_generator_updated)
|
_generator.purchase_failed.connect(_on_generator_updated)
|
||||||
_generator.buff_purchased.connect(_on_generator_updated)
|
_generator.buff_purchased.connect(_on_generator_updated)
|
||||||
_generator.buff_purchase_failed.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)
|
if _generator.game_state:
|
||||||
GameState.generator_buff_level_changed.connect(_on_generator_buff_level_changed)
|
_generator.game_state.currency_changed.connect(_on_currency_changed)
|
||||||
GameState.generator_buff_unlocked_changed.connect(_on_generator_buff_unlocked_changed)
|
_generator.game_state.generator_state_changed.connect(_on_generator_state_changed)
|
||||||
|
|
||||||
_name_label.text = _generator.data.name if _generator.data != null else "Generator"
|
_name_label.text = _generator.data.name if _generator.data != null else "Generator"
|
||||||
_build_buff_rows()
|
_build_buff_rows()
|
||||||
@@ -70,18 +69,6 @@ func _on_generator_state_changed(generator_id: StringName, _state: Dictionary) -
|
|||||||
visible = true
|
visible = true
|
||||||
_refresh_generator_ui()
|
_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:
|
func _on_buy_buff_pressed(buff_id: StringName) -> void:
|
||||||
_generator.buy_buff(buff_id)
|
_generator.buy_buff(buff_id)
|
||||||
_refresh_generator_ui()
|
_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)
|
var buff_unlocked: bool = _generator.is_buff_unlocked(buff.id)
|
||||||
if not buff_unlocked:
|
if not buff_unlocked:
|
||||||
var locked_hint: String = "Locked"
|
var locked_hint: String = "Locked"
|
||||||
var unlock_goal_currency: Resource = buff.get_unlock_goal_currency()
|
var unlock_goal_currency: Currency = buff.get_unlock_goal_currency()
|
||||||
if buff.has_unlock_goal() and unlock_goal_currency != null:
|
if buff.has_unlock_goal() and unlock_goal_currency != null and _generator.game_state:
|
||||||
var goal_currency_id: StringName = GameState.get_currency_id(unlock_goal_currency)
|
var goal_currency_id: StringName = _generator.game_state.get_currency_id(unlock_goal_currency)
|
||||||
var currency_name: String = GameState.get_currency_name(goal_currency_id)
|
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)
|
var required_text: String = buff.get_unlock_goal_amount().to_string_suffix(2)
|
||||||
locked_hint = "Unlock at %s %s" % [required_text, currency_name]
|
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: BigNumber = _generator.get_buff_cost(buff.id)
|
||||||
var cost_currency_id: StringName = _generator.get_buff_cost_currency_id(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 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)
|
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)
|
row.tile.set_runtime(level, effect_text, cost_text, can_buy, false)
|
||||||
|
|
||||||
func _refresh_generator_ui() -> void:
|
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()
|
var can_interact: bool = _generator.is_available_to_player()
|
||||||
|
|
||||||
_buy_button.disabled = not can_interact
|
_buy_button.disabled = not can_interact
|
||||||
_buy_max_button.disabled = not can_interact
|
_buy_max_button.disabled = not can_interact
|
||||||
|
|
||||||
_owned_value.text = str(_generator.owned)
|
_owned_value.text = str(_generator.owned)
|
||||||
if can_interact:
|
if can_interact:
|
||||||
var purchase_currency_id: StringName = _generator.get_purchase_currency_id()
|
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():
|
if purchase_currency_name.is_empty():
|
||||||
purchase_currency_name = "Unconfigured"
|
purchase_currency_name = "Unconfigured"
|
||||||
_next_cost_value.text = "%s %s" % [_generator.get_next_cost().to_string_suffix(2), purchase_currency_name]
|
_next_cost_value.text = "%s %s" % [_generator.get_next_cost().to_string_suffix(2), purchase_currency_name]
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[gd_scene format=3 uid="uid://ckos7f22pnmyh"]
|
[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]
|
[node name="GeneratorContainer" type="PanelContainer" unique_id=1451609580]
|
||||||
anchors_preset = 8
|
anchors_preset = 8
|
||||||
22
core/generator/research_buff_calculator.gd
Normal file
22
core/generator/research_buff_calculator.gd
Normal 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)
|
||||||
1
core/generator/research_buff_calculator.gd.uid
Normal file
1
core/generator/research_buff_calculator.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://bhrcgp2dna0mb
|
||||||
192
core/goals/README.md
Normal file
192
core/goals/README.md
Normal 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
|
||||||
153
core/goals/current_goal_panel.gd
Normal file
153
core/goals/current_goal_panel.gd
Normal 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)
|
||||||
1
core/goals/current_goal_panel.gd.uid
Normal file
1
core/goals/current_goal_panel.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dfn06haxb2yhr
|
||||||
26
core/goals/current_goal_panel.tscn
Normal file
26
core/goals/current_goal_panel.tscn
Normal 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"
|
||||||
17
core/goals/goal_catalogue.gd
Normal file
17
core/goals/goal_catalogue.gd
Normal 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
|
||||||
1
core/goals/goal_catalogue.gd.uid
Normal file
1
core/goals/goal_catalogue.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://bfbp4mo8ys5p8
|
||||||
@@ -1,43 +1,34 @@
|
|||||||
class_name GoalData
|
class_name GoalData
|
||||||
extends Resource
|
extends Resource
|
||||||
|
|
||||||
|
enum UnlockBehavior {
|
||||||
|
AUTOMATIC,
|
||||||
|
MANUAL,
|
||||||
|
}
|
||||||
|
|
||||||
@export var id: StringName = &""
|
@export var id: StringName = &""
|
||||||
@export var requirements: Array[GoalRequirementData] = []
|
@export var requirements: Array[GoalRequirementData] = []
|
||||||
|
@export var unlock_behavior: UnlockBehavior = UnlockBehavior.MANUAL
|
||||||
|
|
||||||
func has_id() -> bool:
|
func has_id() -> bool:
|
||||||
return not String(id).strip_edges().is_empty()
|
return not String(id).strip_edges().is_empty()
|
||||||
|
|
||||||
func get_valid_requirements() -> Array[GoalRequirementData]:
|
func get_requirements() -> Array[GoalRequirementData]:
|
||||||
var result: Array[GoalRequirementData] = []
|
return requirements.duplicate()
|
||||||
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 is_valid() -> bool:
|
func is_valid() -> bool:
|
||||||
if not has_id():
|
if not has_id():
|
||||||
return false
|
return false
|
||||||
|
|
||||||
return not get_valid_requirements().is_empty()
|
if requirements.is_empty():
|
||||||
|
|
||||||
func is_met() -> bool:
|
|
||||||
var valid_requirements: Array[GoalRequirementData] = get_valid_requirements()
|
|
||||||
if valid_requirements.is_empty():
|
|
||||||
return false
|
return false
|
||||||
|
|
||||||
for requirement_resource in valid_requirements:
|
for req in requirements:
|
||||||
if not bool(requirement_resource.is_met()):
|
if req == null:
|
||||||
return false
|
continue
|
||||||
|
if req.get_currency() == null:
|
||||||
|
continue
|
||||||
|
if not req.has_valid_amount():
|
||||||
|
continue
|
||||||
|
|
||||||
return true
|
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())
|
|
||||||
|
|||||||
@@ -5,38 +5,11 @@ extends Resource
|
|||||||
@export var amount_mantissa: float = 0.0
|
@export var amount_mantissa: float = 0.0
|
||||||
@export var amount_exponent: int = 0
|
@export var amount_exponent: int = 0
|
||||||
|
|
||||||
func get_currency_id() -> StringName:
|
func get_currency() -> Currency:
|
||||||
if currency == null:
|
return currency
|
||||||
return &""
|
|
||||||
|
|
||||||
return GameState.get_currency_id(currency)
|
|
||||||
|
|
||||||
func get_amount() -> BigNumber:
|
func get_amount() -> BigNumber:
|
||||||
return BigNumber.new(amount_mantissa, amount_exponent)
|
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:
|
func has_valid_amount() -> bool:
|
||||||
return get_amount().mantissa >= 0.0
|
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
1534
core/level_game_state.gd
Normal file
File diff suppressed because it is too large
Load Diff
1
core/level_game_state.gd.uid
Normal file
1
core/level_game_state.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cv2132o4hi7q3
|
||||||
467
core/prestige/README.md
Normal file
467
core/prestige/README.md
Normal 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
|
||||||
40
core/prestige/prestige_buff_catalogue.gd
Normal file
40
core/prestige/prestige_buff_catalogue.gd
Normal 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
|
||||||
1
core/prestige/prestige_buff_catalogue.gd.uid
Normal file
1
core/prestige/prestige_buff_catalogue.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://bjcvnyh2bc6l5
|
||||||
43
core/prestige/prestige_buff_connection_overlay.gd
Normal file
43
core/prestige/prestige_buff_connection_overlay.gd
Normal 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
|
||||||
1
core/prestige/prestige_buff_connection_overlay.gd.uid
Normal file
1
core/prestige/prestige_buff_connection_overlay.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://uv5lxj6hmpk
|
||||||
85
core/prestige/prestige_buff_graph_panel.gd
Normal file
85
core/prestige/prestige_buff_graph_panel.gd
Normal 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()
|
||||||
1
core/prestige/prestige_buff_graph_panel.gd.uid
Normal file
1
core/prestige/prestige_buff_graph_panel.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://n01pkakxllnj
|
||||||
122
core/prestige/prestige_buff_graph_tile.gd
Normal file
122
core/prestige/prestige_buff_graph_tile.gd
Normal 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()
|
||||||
1
core/prestige/prestige_buff_graph_tile.gd.uid
Normal file
1
core/prestige/prestige_buff_graph_tile.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://w8ogrp1s6qsf
|
||||||
40
core/prestige/prestige_buff_graph_tile.tscn
Normal file
40
core/prestige/prestige_buff_graph_tile.tscn
Normal 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
|
||||||
69
core/prestige/prestige_buff_node.gd
Normal file
69
core/prestige/prestige_buff_node.gd
Normal 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
|
||||||
1
core/prestige/prestige_buff_node.gd.uid
Normal file
1
core/prestige/prestige_buff_node.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://mjyig8xlgki0
|
||||||
@@ -5,6 +5,7 @@ enum BasisType {
|
|||||||
LIFETIME_TOTAL,
|
LIFETIME_TOTAL,
|
||||||
RUN_TOTAL,
|
RUN_TOTAL,
|
||||||
RUN_MAX,
|
RUN_MAX,
|
||||||
|
ALL_CURRENCIES,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum FormulaType {
|
enum FormulaType {
|
||||||
@@ -41,6 +42,7 @@ enum MultiplierMode {
|
|||||||
@export var base_multiplier: float = 1.0
|
@export var base_multiplier: float = 1.0
|
||||||
@export var multiplier_per_prestige: float = 0.05
|
@export var multiplier_per_prestige: float = 0.05
|
||||||
@export var multiplier_exponent: float = 1.0
|
@export var multiplier_exponent: float = 1.0
|
||||||
|
@export var include_currency_ids: Array[StringName] = []
|
||||||
|
|
||||||
func get_threshold() -> BigNumber:
|
func get_threshold() -> BigNumber:
|
||||||
return BigNumber.new(threshold_mantissa, threshold_exponent)
|
return BigNumber.new(threshold_mantissa, threshold_exponent)
|
||||||
@@ -48,7 +50,7 @@ func get_threshold() -> BigNumber:
|
|||||||
func is_valid() -> bool:
|
func is_valid() -> bool:
|
||||||
if String(id).strip_edges().is_empty():
|
if String(id).strip_edges().is_empty():
|
||||||
return false
|
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
|
return false
|
||||||
if threshold_mantissa <= 0.0:
|
if threshold_mantissa <= 0.0:
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
class_name PrestigeManager
|
||||||
extends Node
|
extends Node
|
||||||
|
|
||||||
signal prestige_state_changed(total_prestige: BigNumber, pending_gain: BigNumber)
|
signal prestige_state_changed(total_prestige: BigNumber, pending_gain: BigNumber)
|
||||||
signal prestige_performed(gain: BigNumber, total_prestige: BigNumber)
|
signal prestige_performed(gain: BigNumber, total_prestige: BigNumber)
|
||||||
|
signal prestige_threshold_changed(next_threshold: BigNumber)
|
||||||
|
|
||||||
const SAVE_KEY: String = "prestige_state"
|
const SAVE_KEY: String = "prestige_state"
|
||||||
const TOTAL_PRESTIGE_KEY: String = "total_prestige_earned"
|
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_LIFETIME_TOTAL: int = 0
|
||||||
const BASIS_RUN_TOTAL: int = 1
|
const BASIS_RUN_TOTAL: int = 1
|
||||||
const BASIS_RUN_MAX: int = 2
|
const BASIS_RUN_MAX: int = 2
|
||||||
|
const BASIS_ALL_CURRENCIES: int = 3
|
||||||
|
|
||||||
const FORMULA_POWER: int = 0
|
const FORMULA_POWER: int = 0
|
||||||
const FORMULA_TRIANGULAR_INVERSE: int = 1
|
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_ADDITIVE: int = 0
|
||||||
const MULTIPLIER_MODE_MULTIPLICATIVE_POWER: int = 1
|
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 total_prestige_earned: BigNumber = BigNumber.from_float(0.0)
|
||||||
var current_prestige_unspent: 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:
|
func _ready() -> void:
|
||||||
if config == null:
|
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():
|
if not _is_config_valid():
|
||||||
push_warning("PrestigeManager has invalid or missing config; prestige is disabled.")
|
push_warning("PrestigeManager has invalid or missing config; prestige is disabled.")
|
||||||
return
|
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():
|
if loaded_state.is_empty():
|
||||||
_initialize_run_tracking_from_current_state()
|
_initialize_run_tracking_from_current_state()
|
||||||
else:
|
else:
|
||||||
@@ -46,8 +54,12 @@ func _ready() -> void:
|
|||||||
_validate_loaded_run_tracking()
|
_validate_loaded_run_tracking()
|
||||||
_save_state_to_game_state()
|
_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_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:
|
func get_source_currency_id() -> StringName:
|
||||||
if not _is_config_valid():
|
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", &""))
|
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:
|
func get_total_prestige() -> BigNumber:
|
||||||
return _copy_big_number(total_prestige_earned)
|
return _copy_big_number(total_prestige_earned)
|
||||||
|
|
||||||
@@ -66,7 +110,7 @@ func get_total_multiplier() -> float:
|
|||||||
return 1.0
|
return 1.0
|
||||||
|
|
||||||
var base: float = maxf(_get_config_float("base_multiplier", 1.0), 0.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:
|
if prestige_value <= 0.0:
|
||||||
return base
|
return base
|
||||||
|
|
||||||
@@ -126,21 +170,49 @@ func perform_prestige() -> bool:
|
|||||||
total_prestige_earned.add_in_place(gain)
|
total_prestige_earned.add_in_place(gain)
|
||||||
current_prestige_unspent.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
|
prestige_resets_count += 1
|
||||||
last_reset_time = Time.get_unix_time_from_system()
|
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()
|
_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()
|
_initialize_run_tracking_from_current_state()
|
||||||
_save_state_to_game_state()
|
_save_state_to_game_state()
|
||||||
GameState.save_game()
|
if game_state:
|
||||||
|
game_state.save_game()
|
||||||
|
|
||||||
var total: BigNumber = get_total_prestige()
|
var total: BigNumber = get_total_prestige()
|
||||||
|
var next_threshold: BigNumber = get_next_prestige_threshold()
|
||||||
prestige_performed.emit(gain, total)
|
prestige_performed.emit(gain, total)
|
||||||
prestige_state_changed.emit(total, calculate_pending_gain())
|
prestige_state_changed.emit(total, calculate_pending_gain())
|
||||||
|
prestige_threshold_changed.emit(next_threshold)
|
||||||
return true
|
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:
|
func get_basis_value_for_display() -> BigNumber:
|
||||||
return _get_basis_value()
|
return _get_basis_value()
|
||||||
|
|
||||||
@@ -148,11 +220,15 @@ func get_basis_label() -> String:
|
|||||||
if not _is_config_valid():
|
if not _is_config_valid():
|
||||||
return "-"
|
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:
|
BASIS_RUN_TOTAL:
|
||||||
return "Run Total"
|
return "Run Total"
|
||||||
BASIS_RUN_MAX:
|
BASIS_RUN_MAX:
|
||||||
return "Run Max"
|
return "Run Max"
|
||||||
|
BASIS_ALL_CURRENCIES:
|
||||||
|
return "Total Currency"
|
||||||
_:
|
_:
|
||||||
return "Lifetime Total"
|
return "Lifetime Total"
|
||||||
|
|
||||||
@@ -160,17 +236,52 @@ func _on_currency_changed(changed_currency_id: StringName, new_amount: BigNumber
|
|||||||
if config == null:
|
if config == null:
|
||||||
return
|
return
|
||||||
|
|
||||||
var source_currency_id: StringName = get_source_currency_id()
|
var basis_type: int = _get_config_int("basis", BASIS_LIFETIME_TOTAL)
|
||||||
if source_currency_id == &"":
|
|
||||||
return
|
|
||||||
if changed_currency_id != source_currency_id:
|
|
||||||
return
|
|
||||||
|
|
||||||
if new_amount.is_greater_than(run_peak_source_currency):
|
if basis_type == BASIS_ALL_CURRENCIES:
|
||||||
run_peak_source_currency = _copy_big_number(new_amount)
|
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()
|
_save_state_to_game_state()
|
||||||
|
var next_threshold: BigNumber = get_next_prestige_threshold()
|
||||||
prestige_state_changed.emit(get_total_prestige(), calculate_pending_gain())
|
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:
|
func _calculate_raw_gain_float(basis_value: BigNumber) -> float:
|
||||||
if config == null:
|
if config == null:
|
||||||
@@ -178,11 +289,11 @@ func _calculate_raw_gain_float(basis_value: BigNumber) -> float:
|
|||||||
|
|
||||||
match _get_config_int("formula", FORMULA_POWER):
|
match _get_config_int("formula", FORMULA_POWER):
|
||||||
FORMULA_TRIANGULAR_INVERSE:
|
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:
|
if threshold_value <= 0.0:
|
||||||
return 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:
|
if basis_float <= 0.0:
|
||||||
return 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
|
var target_total: float = (sqrt(1.0 + 8.0 * k) - 1.0) * 0.5
|
||||||
if _is_cumulative_basis():
|
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
|
return target_total
|
||||||
_:
|
_:
|
||||||
var ratio: float = _big_number_ratio_to_float(basis_value, _get_config_threshold())
|
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)
|
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():
|
if _is_cumulative_basis():
|
||||||
return value - _big_number_to_float(total_prestige_earned)
|
return value - total_prestige_earned.to_float()
|
||||||
return value
|
return value
|
||||||
|
|
||||||
func _get_basis_value() -> BigNumber:
|
func _get_basis_value() -> BigNumber:
|
||||||
if config == null:
|
if config == null:
|
||||||
return BigNumber.from_float(0.0)
|
return BigNumber.from_float(0.0)
|
||||||
|
|
||||||
var source_currency_id: StringName = get_source_currency_id()
|
if game_state == null:
|
||||||
if source_currency_id == &"":
|
|
||||||
return BigNumber.from_float(0.0)
|
return BigNumber.from_float(0.0)
|
||||||
|
|
||||||
match _get_config_int("basis", BASIS_LIFETIME_TOTAL):
|
var basis_type: int = _get_config_int("basis", BASIS_LIFETIME_TOTAL)
|
||||||
|
|
||||||
|
match basis_type:
|
||||||
|
BASIS_ALL_CURRENCIES:
|
||||||
|
return _get_total_all_currencies()
|
||||||
BASIS_RUN_TOTAL:
|
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)
|
var run_total: BigNumber = lifetime_now.subtract(run_start_total_source_currency)
|
||||||
if run_total.mantissa < 0.0:
|
if run_total.mantissa < 0.0:
|
||||||
return BigNumber.from_float(0.0)
|
return BigNumber.from_float(0.0)
|
||||||
@@ -222,37 +339,84 @@ func _get_basis_value() -> BigNumber:
|
|||||||
BASIS_RUN_MAX:
|
BASIS_RUN_MAX:
|
||||||
return _copy_big_number(run_peak_source_currency)
|
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:
|
func _initialize_run_tracking_from_current_state() -> void:
|
||||||
if config == null:
|
if config == null:
|
||||||
return
|
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()
|
var source_currency_id: StringName = get_source_currency_id()
|
||||||
if source_currency_id == &"":
|
if source_currency_id == &"":
|
||||||
run_start_total_source_currency = BigNumber.from_float(0.0)
|
run_start_total_source_currency = BigNumber.from_float(0.0)
|
||||||
run_peak_source_currency = BigNumber.from_float(0.0)
|
run_peak_source_currency = BigNumber.from_float(0.0)
|
||||||
return
|
return
|
||||||
|
|
||||||
run_start_total_source_currency = _copy_big_number(GameState.get_total_currency_acquired_by_id(source_currency_id))
|
if game_state == null:
|
||||||
run_peak_source_currency = _copy_big_number(GameState.get_currency_amount_by_id(source_currency_id))
|
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()
|
_save_state_to_game_state()
|
||||||
|
|
||||||
func _validate_loaded_run_tracking() -> void:
|
func _validate_loaded_run_tracking() -> void:
|
||||||
if config == null:
|
if config == null:
|
||||||
return
|
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()
|
var source_currency_id: StringName = get_source_currency_id()
|
||||||
if source_currency_id == &"":
|
if source_currency_id == &"":
|
||||||
run_start_total_source_currency = BigNumber.from_float(0.0)
|
run_start_total_source_currency = BigNumber.from_float(0.0)
|
||||||
run_peak_source_currency = BigNumber.from_float(0.0)
|
run_peak_source_currency = BigNumber.from_float(0.0)
|
||||||
return
|
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):
|
if current_currency.is_greater_than(run_peak_source_currency):
|
||||||
run_peak_source_currency = _copy_big_number(current_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):
|
if run_start_total_source_currency.is_greater_than(lifetime_now):
|
||||||
run_start_total_source_currency = _copy_big_number(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)
|
current_prestige_unspent = _copy_big_number(total_prestige_earned)
|
||||||
|
|
||||||
func _save_state_to_game_state() -> void:
|
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:
|
func _reinitialize_generators_after_prestige_reset() -> void:
|
||||||
var scene_root: Node = get_tree().current_scene
|
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))
|
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:
|
func _normalize_currency_id(currency_id: StringName) -> StringName:
|
||||||
var normalized: String = String(currency_id).to_lower().strip_edges()
|
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:
|
func _is_config_valid() -> bool:
|
||||||
if config == null:
|
if config == null:
|
||||||
return false
|
return false
|
||||||
if not config.has_method("is_valid"):
|
return config.is_valid()
|
||||||
return false
|
|
||||||
|
|
||||||
return bool(config.call("is_valid"))
|
|
||||||
|
|
||||||
func _is_cumulative_basis() -> bool:
|
func _is_cumulative_basis() -> bool:
|
||||||
if config != null and config.has_method("is_cumulative_basis"):
|
var basis_type: int = _get_config_int("basis", BASIS_LIFETIME_TOTAL)
|
||||||
return bool(config.call("is_cumulative_basis"))
|
return basis_type != BASIS_RUN_TOTAL and basis_type != BASIS_ALL_CURRENCIES
|
||||||
|
|
||||||
return _get_config_int("basis", BASIS_LIFETIME_TOTAL) != BASIS_RUN_TOTAL
|
|
||||||
|
|
||||||
func _round_gain(value: float) -> float:
|
func _round_gain(value: float) -> float:
|
||||||
if config != null and config.has_method("round_value"):
|
return config.round_value(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)
|
|
||||||
|
|
||||||
func _get_config_threshold() -> BigNumber:
|
func _get_config_threshold() -> BigNumber:
|
||||||
if config != null and config.has_method("get_threshold"):
|
if config != null:
|
||||||
var threshold: Variant = config.call("get_threshold")
|
return config.get_threshold()
|
||||||
if threshold is BigNumber:
|
|
||||||
return threshold
|
|
||||||
|
|
||||||
return BigNumber.from_float(1.0)
|
return BigNumber.from_float(1.0)
|
||||||
|
|
||||||
func _get_config_string_name(property_name: String, fallback: StringName = &"") -> StringName:
|
func _get_config_string_name(property_name: String, fallback: StringName = &"") -> StringName:
|
||||||
|
|||||||
@@ -5,44 +5,44 @@ extends PanelContainer
|
|||||||
@onready var _pending_value: Label = $MarginContainer/VBoxContainer/StatsGrid/PendingValue
|
@onready var _pending_value: Label = $MarginContainer/VBoxContainer/StatsGrid/PendingValue
|
||||||
@onready var _multiplier_value: Label = $MarginContainer/VBoxContainer/StatsGrid/MultiplierValue
|
@onready var _multiplier_value: Label = $MarginContainer/VBoxContainer/StatsGrid/MultiplierValue
|
||||||
@onready var _basis_label: Label = $MarginContainer/VBoxContainer/StatsGrid/BasisValue
|
@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 _reset_button: Button = $MarginContainer/VBoxContainer/Buttons/ResetButton
|
||||||
@onready var _save_button: Button = $MarginContainer/VBoxContainer/Buttons/SaveButton
|
@onready var _save_button: Button = $MarginContainer/VBoxContainer/Buttons/SaveButton
|
||||||
@onready var _confirm_dialog: ConfirmationDialog = $ConfirmDialog
|
@onready var _confirm_dialog: ConfirmationDialog = $ConfirmDialog
|
||||||
|
@onready var game_state: LevelGameState = find_parent("LevelGameState")
|
||||||
|
|
||||||
var _is_waiting_for_confirm: bool = false
|
var _is_waiting_for_confirm: bool = false
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
var manager: Node = _get_prestige_manager()
|
game_state.ready.connect(_on_game_state_ready)
|
||||||
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)
|
|
||||||
|
|
||||||
|
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()
|
_refresh_ui()
|
||||||
|
|
||||||
|
|
||||||
func _on_reset_button_pressed() -> void:
|
func _on_reset_button_pressed() -> void:
|
||||||
var manager: Node = _get_prestige_manager()
|
var manager: PrestigeManager = _get_prestige_manager()
|
||||||
if manager == null or not manager.has_method("can_prestige"):
|
|
||||||
|
if not bool(manager.can_prestige()):
|
||||||
return
|
return
|
||||||
|
|
||||||
if not bool(manager.call("can_prestige")):
|
var pending: BigNumber = manager.calculate_pending_gain()
|
||||||
return
|
var basis_label: String = String(manager.get_basis_label())
|
||||||
|
var basis_value: BigNumber = manager.get_basis_value_for_display()
|
||||||
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 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)
|
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: %s" % [confirm_message, basis_label, basis_value.to_string_suffix(2)]
|
||||||
confirm_message = "%s\n\nBased on %s progression." % [confirm_message, source_currency_name]
|
|
||||||
|
|
||||||
_confirm_dialog.dialog_text = confirm_message
|
_confirm_dialog.dialog_text = confirm_message
|
||||||
_is_waiting_for_confirm = true
|
_is_waiting_for_confirm = true
|
||||||
_confirm_dialog.popup_centered_ratio(0.4)
|
_confirm_dialog.popup_centered_ratio(0.4)
|
||||||
|
|
||||||
func _on_save_button_pressed() -> void:
|
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:
|
func _on_prestige_state_changed(_total: BigNumber, _pending: BigNumber) -> void:
|
||||||
_refresh_ui()
|
_refresh_ui()
|
||||||
@@ -58,33 +58,33 @@ func _on_confirm_dialog_confirmed() -> void:
|
|||||||
return
|
return
|
||||||
|
|
||||||
_is_waiting_for_confirm = false
|
_is_waiting_for_confirm = false
|
||||||
var manager: Node = _get_prestige_manager()
|
var manager: PrestigeManager = _get_prestige_manager()
|
||||||
if manager == null:
|
if manager == null:
|
||||||
return
|
return
|
||||||
|
|
||||||
manager.call("perform_prestige")
|
manager.perform_prestige()
|
||||||
|
|
||||||
func _on_confirm_dialog_canceled() -> void:
|
func _on_confirm_dialog_canceled() -> void:
|
||||||
_is_waiting_for_confirm = false
|
_is_waiting_for_confirm = false
|
||||||
|
|
||||||
func _refresh_ui() -> void:
|
func _refresh_ui() -> void:
|
||||||
var manager: Node = _get_prestige_manager()
|
var manager: PrestigeManager = _get_prestige_manager()
|
||||||
if manager == null:
|
if manager == null:
|
||||||
visible = false
|
visible = false
|
||||||
return
|
return
|
||||||
|
|
||||||
visible = true
|
visible = true
|
||||||
var total: BigNumber = manager.call("get_total_prestige")
|
var total: BigNumber = manager.get_total_prestige()
|
||||||
var pending: BigNumber = manager.call("calculate_pending_gain")
|
var pending: BigNumber = manager.calculate_pending_gain()
|
||||||
var multiplier: float = float(manager.call("get_total_multiplier"))
|
var multiplier: float = float(manager.get_total_multiplier())
|
||||||
var basis_text: String = String(manager.call("get_basis_label"))
|
var basis_text: String = String(manager.get_basis_label())
|
||||||
var basis_value: BigNumber = manager.call("get_basis_value_for_display")
|
var basis_value: BigNumber = manager.get_basis_value_for_display()
|
||||||
|
|
||||||
_total_value.text = total.to_string_suffix(2)
|
_total_value.text = total.to_string_suffix(2)
|
||||||
_pending_value.text = pending.to_string_suffix(2)
|
_pending_value.text = pending.to_string_suffix(2)
|
||||||
_multiplier_value.text = "x%.2f" % multiplier
|
_multiplier_value.text = "x%.2f" % multiplier
|
||||||
_basis_label.text = "%s (%s)" % [basis_text, basis_value.to_string_suffix(2)]
|
_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:
|
func _get_prestige_manager() -> PrestigeManager:
|
||||||
return get_node_or_null("/root/PrestigeManager")
|
return game_state.prestige_manager
|
||||||
|
|||||||
@@ -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_right = 420.0
|
||||||
offset_bottom = 206.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
|
layout_mode = 2
|
||||||
theme_override_constants/margin_left = 10
|
theme_override_constants/margin_left = 10
|
||||||
theme_override_constants/margin_top = 10
|
theme_override_constants/margin_top = 10
|
||||||
theme_override_constants/margin_right = 10
|
theme_override_constants/margin_right = 10
|
||||||
theme_override_constants/margin_bottom = 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
|
layout_mode = 2
|
||||||
theme_override_constants/separation = 8
|
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
|
layout_mode = 2
|
||||||
text = "Prestige"
|
text = "Prestige"
|
||||||
|
|
||||||
[node name="StatsGrid" type="GridContainer" parent="MarginContainer/VBoxContainer"]
|
[node name="StatsGrid" type="GridContainer" parent="MarginContainer/VBoxContainer" unique_id=1121463897]
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
columns = 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
|
layout_mode = 2
|
||||||
text = "Total"
|
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
|
layout_mode = 2
|
||||||
text = "0"
|
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
|
layout_mode = 2
|
||||||
text = "Pending"
|
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
|
layout_mode = 2
|
||||||
text = "0"
|
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
|
layout_mode = 2
|
||||||
text = "Multiplier"
|
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
|
layout_mode = 2
|
||||||
text = "x1.00"
|
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
|
layout_mode = 2
|
||||||
text = "Basis"
|
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
|
layout_mode = 2
|
||||||
text = "-"
|
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
|
layout_mode = 2
|
||||||
theme_override_constants/separation = 8
|
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
|
layout_mode = 2
|
||||||
text = "Prestige Reset"
|
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
|
layout_mode = 2
|
||||||
text = "Save"
|
text = "Save"
|
||||||
|
|
||||||
[node name="ConfirmDialog" type="ConfirmationDialog" parent="."]
|
[node name="ConfirmDialog" type="ConfirmationDialog" parent="." unique_id=1893210048]
|
||||||
|
oversampling_override = 1.0
|
||||||
title = "Prestige Reset"
|
title = "Prestige Reset"
|
||||||
ok_button_text = "Prestige"
|
ok_button_text = "Prestige"
|
||||||
dialog_text = "Reset this run?"
|
dialog_text = "Reset this run?"
|
||||||
|
|||||||
72
core/prestige/prestige_progress_bar.gd
Normal file
72
core/prestige/prestige_progress_bar.gd
Normal 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)
|
||||||
1
core/prestige/prestige_progress_bar.gd.uid
Normal file
1
core/prestige/prestige_progress_bar.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dsq8yix7kkyaj
|
||||||
22
core/prestige/prestige_progress_bar.tscn
Normal file
22
core/prestige/prestige_progress_bar.tscn
Normal 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
488
core/research/README.md
Normal 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
616
core/research/TODO.md
Normal 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
|
||||||
19
core/research/research_catalogue.gd
Normal file
19
core/research/research_catalogue.gd
Normal 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()
|
||||||
1
core/research/research_catalogue.gd.uid
Normal file
1
core/research/research_catalogue.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://d2v6t6w2todfy
|
||||||
70
core/research/research_data.gd
Normal file
70
core/research/research_data.gd
Normal 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)
|
||||||
1
core/research/research_data.gd.uid
Normal file
1
core/research/research_data.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://m7baywrfnpn0
|
||||||
155
core/research/research_panel.gd
Normal file
155
core/research/research_panel.gd
Normal 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]
|
||||||
1
core/research/research_panel.gd.uid
Normal file
1
core/research/research_panel.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://fdl7ftxw8w1e
|
||||||
34
core/research/research_panel.tscn
Normal file
34
core/research/research_panel.tscn
Normal 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"]
|
||||||
98
core/research/research_row.gd
Normal file
98
core/research/research_row.gd
Normal 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
|
||||||
1
core/research/research_row.gd.uid
Normal file
1
core/research/research_row.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://gk3mr3k5yvp7
|
||||||
42
core/research/research_row.tscn
Normal file
42
core/research/research_row.tscn
Normal 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"
|
||||||
27
core/research/worker_summary_label.gd
Normal file
27
core/research/worker_summary_label.gd
Normal 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]
|
||||||
1
core/research/worker_summary_label.gd.uid
Normal file
1
core/research/worker_summary_label.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://brvoy607b3nft
|
||||||
8
core/research/worker_summary_label.tscn
Normal file
8
core/research/worker_summary_label.tscn
Normal 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"
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
class_name CurrencyTile
|
class_name CurrencyTile
|
||||||
extends HBoxContainer
|
extends HBoxContainer
|
||||||
|
|
||||||
@export var currency: Resource
|
@export var currency: Currency
|
||||||
|
@export var game_state: LevelGameState
|
||||||
|
|
||||||
@onready var _icon: TextureRect = $CurrencyIcon
|
@onready var _icon: TextureRect = $CurrencyIcon
|
||||||
@onready var _label: Label = $Label
|
@onready var _label: Label = $Label
|
||||||
@@ -11,26 +12,36 @@ extends HBoxContainer
|
|||||||
var _currency_id: StringName = &""
|
var _currency_id: StringName = &""
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
if currency == null:
|
|
||||||
currency = CurrencyDatabase.get_currency_resource(GameState.GOLD_CURRENCY_ID)
|
|
||||||
if currency == null:
|
if currency == null:
|
||||||
push_warning("CurrencyTile '%s' has no currency configured." % String(name))
|
push_warning("CurrencyTile '%s' has no currency configured." % String(name))
|
||||||
return
|
return
|
||||||
|
|
||||||
_currency_id = GameState.get_currency_id(currency)
|
if game_state == null:
|
||||||
_label.text = "%s:" % GameState.get_currency_name(_currency_id)
|
push_error("CurrencyTile '%s' missing game_state reference" % String(name))
|
||||||
_icon.texture = GameState.get_currency_icon(_currency_id)
|
return
|
||||||
_debug_button.text = "+100 %s" % GameState.get_currency_name(_currency_id)
|
|
||||||
|
|
||||||
GameState.currency_changed.connect(_on_currency_changed)
|
game_state.ready.connect(_on_level_game_state_ready)
|
||||||
_on_currency_changed(_currency_id, GameState.get_currency_amount_by_id(_currency_id))
|
|
||||||
|
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:
|
func _on_currency_changed(changed_currency_id: StringName, amount: BigNumber) -> void:
|
||||||
if changed_currency_id != _currency_id:
|
if changed_currency_id != _currency_id:
|
||||||
return
|
return
|
||||||
_currency_label.text = amount.to_string_suffix(2)
|
_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:
|
func _on_debug_income_button_pressed() -> void:
|
||||||
if currency == null:
|
if currency == null:
|
||||||
return
|
return
|
||||||
GameState.add_currency(currency, BigNumber.from_float(100))
|
if game_state == null:
|
||||||
|
return
|
||||||
|
game_state.add_currency(currency, BigNumber.from_float(100))
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
[gd_scene format=3 uid="uid://btkxru2gdjsgc"]
|
[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="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]
|
[node name="CurrencyTile" type="HBoxContainer" unique_id=1440583137]
|
||||||
theme_override_constants/separation = 12
|
theme_override_constants/separation = 12
|
||||||
script = ExtResource("1_0hv07")
|
script = ExtResource("1_0hv07")
|
||||||
|
|
||||||
[node name="CurrencyIcon" type="TextureRect" parent="." unique_id=935299369]
|
[node name="CurrencyIcon" type="TextureRect" parent="." unique_id=935299369]
|
||||||
visible = false
|
|
||||||
custom_minimum_size = Vector2(20, 20)
|
custom_minimum_size = Vector2(20, 20)
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
|
texture = ExtResource("2_otq0d")
|
||||||
|
expand_mode = 2
|
||||||
stretch_mode = 5
|
stretch_mode = 5
|
||||||
|
|
||||||
[node name="Label" type="Label" parent="." unique_id=89234840]
|
[node name="Label" type="Label" parent="." unique_id=89234840]
|
||||||
|
|||||||
@@ -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
|
|
||||||
19
docs/gyms/tiny_sword/buffs/farm_auto_flux_buff.tres
Normal file
19
docs/gyms/tiny_sword/buffs/farm_auto_flux_buff.tres
Normal 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")
|
||||||
20
docs/gyms/tiny_sword/buffs/forestry_auto_flux_buff.tres
Normal file
20
docs/gyms/tiny_sword/buffs/forestry_auto_flux_buff.tres
Normal 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")
|
||||||
19
docs/gyms/tiny_sword/buffs/gold_auto_flux_buff.tres
Normal file
19
docs/gyms/tiny_sword/buffs/gold_auto_flux_buff.tres
Normal 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")
|
||||||
20
docs/gyms/tiny_sword/buffs/gold_click_buff.tres
Normal file
20
docs/gyms/tiny_sword/buffs/gold_click_buff.tres
Normal 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")
|
||||||
21
docs/gyms/tiny_sword/buffs/spawn_worker_buff.tres
Normal file
21
docs/gyms/tiny_sword/buffs/spawn_worker_buff.tres
Normal 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
|
||||||
14
docs/gyms/tiny_sword/buffs/ts_buff_catalogue.tres
Normal file
14
docs/gyms/tiny_sword/buffs/ts_buff_catalogue.tres
Normal 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"
|
||||||
55
docs/gyms/tiny_sword/buildings/alchemy_tower/README.md
Normal file
55
docs/gyms/tiny_sword/buildings/alchemy_tower/README.md
Normal 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.0–1.0 |
|
||||||
|
| `production_completed(amount: BigNumber)` | A production cycle finishes |
|
||||||
|
| `magic_gold_balance_updated(amount: BigNumber)` | Magic gold balance changes |
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `alchemy_tower.gd` | Main tower logic: production loop, worker management, crafting |
|
||||||
|
| `alchemy_tower_data.gd` | Configuration resource (`AlchemyTowerData`) |
|
||||||
|
| `alchemy_craftable_panel.gd` | UI panel (`AlchemyCurrenciesPanel`) for progress bar, balance, workers, and recipe list |
|
||||||
|
| `alchemy_craftable_panel_tile.gd` | Individual recipe tile in the crafting list |
|
||||||
|
| `recipes/alchemy_craft_catalogue.gd` | Catalogue resource holding all available recipes |
|
||||||
|
| `recipes/alchemy_craft_recipe.gd` | Single recipe resource |
|
||||||
|
| `recipes/currency_cost_entry.gd` | Cost entry for a single currency in a recipe |
|
||||||
BIN
docs/gyms/tiny_sword/buildings/alchemy_tower/Tower.png
Normal file
BIN
docs/gyms/tiny_sword/buildings/alchemy_tower/Tower.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.3 KiB |
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://do1i3hrd0ueb6
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c63g772y4kxwm
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bsfgq4apkidak
|
||||||
202
docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_tower.gd
Normal file
202
docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_tower.gd
Normal 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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bddaaj76msmvj
|
||||||
@@ -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
Reference in New Issue
Block a user