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