15 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_xp_tracker.gd |
Accumulates XP from production and handles auto-leveling (Node) |
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 |
Architecture
LevelGameState (Node2D)
├── ResearchXPTracker (child node)
│ ├── Receives production_tick signals
│ ├── Applies buff multipliers
│ ├── Awards XP to research tracks
│ └── Auto-levels when thresholds reached
├── 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
Data Flow
Generator Production (currency_per_cycle)
│
▼
ResearchXPTracker
1. base_xp = currency_produced * xp_per_currency_produced
2. actual_xp = base_xp * buff_multiplier
3. research_xp += actual_xp
4. Check level threshold → auto-level
5. 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.
ResearchXPTracker
Child node of LevelGameState that accumulates XP from production and applies buff multipliers.
Signals
signal research_xp_changed(research_id: StringName, new_xp: float)
signal research_level_up(research_id: StringName, old_level: int, new_level: int)
Key Methods
func add_xp_with_buffs(research_id: StringName, base_xp: BigNumber) -> BigNumber:
"""Apply buff multipliers and award XP. Returns actual XP after buffs (BigNumber)."""
var buff_multiplier: float = game_state.get_research_xp_multiplier(research_id)
var multiplier_bn: BigNumber = BigNumber.from_float(buff_multiplier)
var actual_xp: BigNumber = base_xp.multiply(multiplier_bn)
game_state.add_research_xp(research_id, actual_xp)
return actual_xp
func _on_generator_produced(generator_id: StringName, amount: BigNumber) -> void:
var research: ResearchData = _get_research_for_generator(generator_id)
var amount_float: float = amount.mantissa * pow(10.0, float(amount.exponent))
var base_xp: BigNumber = BigNumber.from_float(research.xp_per_currency_produced * amount_float)
add_xp_with_buffs(research.id, base_xp)
Connection
Connected to LevelGameState via generator_produced signal.
LevelGameState Extensions
State Variables
var research_xp: Dictionary = {} # {research_id: BigNumber}
var research_levels: Dictionary = {} # {research_id: level_int}
var research_tracker: ResearchXPTracker
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) and auto-level if threshold reached"""
var current_xp: BigNumber = get_research_xp(research_id)
research_xp[research_id] = current_xp.add(xp)
new_level = 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)
func get_research_xp_multiplier(research_id: StringName) -> float:
"""Returns multiplicative buff for research XP gain"""
var multiplier: float = 1.0
var buff: GeneratorBuffData = get_buff(research.associated_buff_id)
if is_buff_active(buff.id):
multiplier *= buff.get_effect_multiplier(get_buff_level(buff.id))
return multiplier
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)
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)
research_tracker.add_xp_with_buffs(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 |
| ResearchXPTracker location | Child of LevelGameState (receives signals via connection) |
| 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 - ResearchXPTracker receives signal → calculates base XP from production amount
- Buff multipliers applied → checks for active RESEARCH_XP_MULTIPLIER buffs
- 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/research/research_catalogue.gd- Research configuration resource