16 KiB
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
@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
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
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
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():
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
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
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
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
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
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
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
enum BuffKind {
AUTO_PRODUCTION_MULTIPLIER,
MANUAL_CLICK_MULTIPLIER,
RESOURCE_PURCHASE,
RESEARCH_XP_MULTIPLIER # Increases research XP gain
}
New Properties (for RESEARCH_XP_MULTIPLIER buffs)
@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
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 barsresearch_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
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
- Generator produces currency →
production_ticksignal emitted - CurrencyGenerator calls →
game_state.add_research_xp()with base XP - LevelGameState applies buffs →
ResearchBuffCalculator.apply_buffs()calculates multiplier - XP awarded → stored in
research_xpdictionary - Level check → if XP exceeds threshold, auto-level and emit
research_level_up - Multiplier applied →
get_research_multiplier()returns updated production bonus - UI refresh →
ResearchPanelupdates progress bars and level displays
Prestige Integration
On prestige reset:
research_xp.clear()- All XP lostresearch_levels.clear()- All levels reset to 0- Buff purchases persist (separate state)
- Research tracks remain unlocked
See Also
core/level_game_state.gd- Research state storage and managementcore/generator/currency_generator_data.gd- Links to ResearchDatacore/generator/generator_buff_data.gd- RESEARCH_XP_MULTIPLIER buff typecore/generator/research_buff_calculator.gd- Static buff calculation utilitycore/research/research_catalogue.gd- Research configuration resource