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:
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
|
||||
Reference in New Issue
Block a user