22 KiB
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 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 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 │
└─────────────────────────────────────────────────────────────────┘
│
┌───────────────────┼───────────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ CurrencyGen │ │ ResearchData │ │ ResearchPanel │
│ (Gold Mine) │──│ (Resource) │──│ (Always visible)│
│ - produces gold │ │ - XP config │ │ - Shows all │
│ - emits signal │ │ - Multiplier │ │ research │
└─────────────────┘ └─────────────────┘ │ - Progress bars │
└─────────────────┘
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}│
└──────────────────────┘
│
▼
┌──────────────────────┐
│ 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:
@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:
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:
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. ResearchXPTracker (core/research/research_xp_tracker.gd)
Purpose: Child of LevelGameState; accumulates XP from production and applies buff multipliers
Location: res://core/research/research_xp_tracker.gd
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: float) -> float:
"""Apply buff multipliers and award XP. Returns actual XP after buffs."""
if game_state == null:
return 0.0
var actual_xp: float = base_xp
# Apply research-specific buff multiplier
var buff_multiplier: float = game_state.get_research_xp_multiplier(research_id)
actual_xp *= buff_multiplier
# Award XP (handles auto-leveling internally)
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)
if research == null:
return
var amount_float: float = amount.to_float()
if amount_float <= 0.0:
return
var base_xp: float = research.xp_per_currency_produced * amount_float
if base_xp <= 0.0:
return
add_xp_with_buffs(research.id, base_xp)
Connection: Connected to LevelGameState via generator_produced signal
4. LevelGameState Extensions (core/level_game_state.gd)
New State Variables:
var research_xp: Dictionary = {} # {research_id: xp_float}
var research_levels: Dictionary = {} # {research_id: level_int}
var research_tracker: ResearchXPTracker
New Signals:
signal research_xp_changed(research_id: StringName, new_xp: float)
signal research_level_up(research_id: StringName, old_level: int, new_level: int)
New Properties:
@export var research_catalogue: ResearchCatalogue
New Methods:
func register_research(research_id: StringName) -> void:
if not research_xp.has(research_id):
research_xp[research_id] = 0.0
if not research_levels.has(research_id):
research_levels[research_id] = 0
func get_research_xp(research_id: StringName) -> float:
return research_xp.get(research_id, 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: float) -> void:
var old_level: int = get_research_level(research_id)
research_xp[research_id] = research_xp.get(research_id, 0.0) + xp
var new_level: int = _get_research_data(research_id).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_xp_multiplier(research_id: StringName) -> float:
"""Returns multiplicative buff for research XP gain"""
var multiplier: float = 1.0
var research: ResearchData = _get_research_data(research_id)
if research == null:
return 1.0
# Find associated buff and apply its multiplier
if not research.associated_buff_id.is_empty():
var buff: GeneratorBuffData = get_buff(research.associated_buff_id)
if buff != null and is_buff_active(buff.id):
var level: int = get_buff_level(buff.id)
multiplier *= buff.get_effect_multiplier(level)
return multiplier
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:
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():
# After initializing other state, create research tracker
research_tracker = ResearchXPTracker.new()
add_child(research_tracker)
research_tracker.game_state = self
# 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:
@export var research_data: ResearchData # Link to research configuration
6. CurrencyGenerator Extensions (core/generator/currency_generator.gd)
In _grant_cycle_income():
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)
# NEW: Award research XP
if data.research_data != null:
var xp: float = data.research_data.xp_per_currency_produced * produced.to_float()
research_tracker.add_xp_with_buffs(data.research_data.id, xp)
New Method:
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():
func get_effective_auto_run_multiplier() -> float:
return run_multiplier * get_research_multiplier() * get_automatic_production_multiplier() * _get_prestige_multiplier()
In _ready():
# Get reference to research tracker
if game_state:
research_tracker = game_state.research_tracker
7. GeneratorBuffData Extensions (core/generator/generator_buff_data.gd)
New Enum Value:
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):
@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:
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:
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 ResearchXPTracker node class |
core/research/research_xp_tracker.gd |
High | ⬜ |
| 4 | Add research state to LevelGameState |
core/level_game_state.gd |
High | ⬜ |
| 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 | ⬜ |
| 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) |
| 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 |
| 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
.tresresources indoc/gyms/tiny_sword/resources/ - Associated buffs should be created as
GeneratorBuffDatawithBuffKind.RESEARCH_XP_MULTIPLIER - Buff names are configured in the
textfield of eachGeneratorBuffData - ResearchPanel should be added as a child node in the main game scene (always visible)
- Save format version should be incremented to 4 when adding research support
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
CurrencyGeneratormust emitproduction_ticksignalGeneratorBuffDatamust supportRESEARCH_XP_MULTIPLIERkindLevelGameStatemust support buff tracking and signals- Save format version must be updated