Fix research

This commit is contained in:
2026-04-15 12:01:28 +02:00
parent 0e8c682ae7
commit 45e1df5a46
37 changed files with 2848 additions and 20 deletions

View File

@@ -39,6 +39,9 @@ const HUGE_COST_EXPONENT: int = 1000000
## Reference to generator UI
@export var info_generator_container: GeneratorPanel
## Reference to research tracker
var research_tracker: Node = null
## True while the pointer is inside the generator Area2D.
var _mouse_entered: bool = false
## Time left until next click/hover grant is allowed.
@@ -105,6 +108,8 @@ func _ready() -> void:
game_state.currency_changed.connect(_on_currency_changed)
game_state.generator_state_changed.connect(_on_generated_state_changed)
game_state.goal_completed.connect(_on_goal_completed)
# Don't cache research_tracker here - it may not be initialized yet
# Access it lazily when needed instead
_ensure_registered()
@@ -142,6 +147,14 @@ func _grant_cycle_income(cycle_count: int) -> void:
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)
if data != null and data.research_data != null:
var amount_float: float = produced.mantissa * pow(10.0, float(produced.exponent))
var research_xp_amount: BigNumber = BigNumber.from_float(data.research_data.xp_per_currency_produced * amount_float)
if game_state != null and research_xp_amount.mantissa > 0.0:
var tracker: ResearchXPTracker = game_state.research_tracker as ResearchXPTracker
if tracker:
tracker.add_xp_with_buffs(data.research_data.id, research_xp_amount)
## Convenience helper for the next single-unit purchase cost.
func get_next_cost() -> BigNumber:
@@ -337,7 +350,14 @@ func get_automatic_production_multiplier() -> float:
return _get_multiplier_for_buff_kind(GeneratorBuffData.BuffKind.AUTO_PRODUCTION_MULTIPLIER)
func get_effective_auto_run_multiplier() -> float:
return run_multiplier * get_automatic_production_multiplier() * _get_prestige_multiplier()
return run_multiplier * get_research_multiplier() * get_automatic_production_multiplier() * _get_prestige_multiplier()
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)
func reset_runtime_state_for_prestige() -> void:
_is_registered = false
@@ -383,7 +403,18 @@ func _try_grant_click_currency() -> void:
if _remaining_click_cooldown_seconds > 0.0:
return
_add_currency(_get_click_value())
var click_value: BigNumber = _get_click_value()
_add_currency(click_value)
# Grant research XP for manual clicks
if data.research_data != null and click_value.mantissa > 0.0:
var amount_float: float = click_value.mantissa * pow(10.0, float(click_value.exponent))
var research_xp_amount: BigNumber = BigNumber.from_float(data.research_data.xp_per_currency_produced * amount_float)
if game_state != null and research_xp_amount.mantissa > 0.0:
var tracker: ResearchXPTracker = game_state.research_tracker as ResearchXPTracker
if tracker:
tracker.add_xp_with_buffs(data.research_data.id, research_xp_amount)
_remaining_click_cooldown_seconds = _get_click_cooldown_seconds()
## Resolves click reward from data.

View File

@@ -31,6 +31,8 @@ extends Resource
@export var click_exponent: int = 0
@export var click_cooldown_seconds: float = 0.2
@export var research_data: ResearchData
func has_unlock_goal() -> bool:
if unlock_goal == null:
return false

View File

@@ -5,6 +5,7 @@ enum BuffKind {
AUTO_PRODUCTION_MULTIPLIER,
MANUAL_CLICK_MULTIPLIER,
RESOURCE_PURCHASE,
RESEARCH_XP_MULTIPLIER,
}
const HUGE_EXPONENT: int = 1000000
@@ -24,6 +25,10 @@ const HUGE_EXPONENT: int = 1000000
@export var base_effect: float = 1.0
@export var effect_increment: float = 0.1
@export_group("Research XP Multiplier")
@export var research_target_id: StringName = &""
@export var xp_multiplier_per_level: float = 0.1
@export_group("Cost")
@export var cost_currency: Currency
@export var base_cost_mantissa: float = 10.0
@@ -133,7 +138,9 @@ func get_effect_description(level: int) -> String:
BuffKind.AUTO_PRODUCTION_MULTIPLIER, BuffKind.MANUAL_CLICK_MULTIPLIER:
return "x%.2f" % get_effect_multiplier(safe_level)
BuffKind.RESOURCE_PURCHASE:
return "+%s" % get_resource_purchase_amount_for_level(safe_level).to_string_suffix(2)
return "+%s" % get_resource_purchase_amount_for_level(level).to_string_suffix(2)
BuffKind.RESEARCH_XP_MULTIPLIER:
return "+%.0f%% XP" % ((get_effect_multiplier(safe_level) - 1.0) * 100)
_:
return "-"

View File

@@ -12,6 +12,8 @@ signal buff_level_changed(buff_id: StringName, new_level: int)
signal buff_unlocked_changed(buff_id: StringName, unlocked: bool)
signal goal_completed(goal_id: StringName)
signal goal_progress_changed(goal_id: StringName, progress: float)
signal research_xp_changed(research_id: StringName, new_xp: BigNumber)
signal research_level_up(research_id: StringName, old_level: int, new_level: int)
# ==========================================
# EXPORTED REFERENCES
@@ -19,6 +21,7 @@ signal goal_progress_changed(goal_id: StringName, progress: float)
@export var currency_catalogue: CurrencyCatalogue
@export var buff_catalogue: BuffCatalogue
@export var goal_catalogue: GoalCatalogue
@export var research_catalogue: ResearchCatalogue
@export var save_file_path: String = "user://level_save.json"
# ==========================================
@@ -41,13 +44,17 @@ var _buff_active: Dictionary = {}
var _goal_definitions: Dictionary = {}
var _goal_completed: Dictionary = {}
var research_xp: Dictionary = {}
var research_levels: Dictionary = {}
var research_tracker: Node = null
var last_save_time: int = 0
# ==========================================
# Save / Load
# ==========================================
const SAVE_FORMAT_VERSION_KEY: String = "save_format_version"
const CURRENT_SAVE_FORMAT_VERSION: int = 3
const CURRENT_SAVE_FORMAT_VERSION: int = 5
const GENERATOR_OWNED_KEY: String = "owned"
const GENERATOR_PURCHASED_COUNT_KEY: String = "purchased_count"
const GENERATOR_UNLOCKED_KEY: String = "unlocked"
@@ -66,6 +73,8 @@ const CURRENT_KEY: String = "current"
const TOTAL_KEY: String = "total"
const ALL_TIME_KEY: String = "all_time"
const LAST_SAVE_TIME_KEY: String = "last_save_time"
const RESEARCH_XP_KEY: String = "research_xp"
const RESEARCH_LEVELS_KEY: String = "research_levels"
var prestige_manager: PrestigeManager
@@ -76,6 +85,7 @@ func _ready() -> void:
_load_catalogue_goals()
load_game()
_try_unlock_all_buffs_from_goals()
_initialize_research()
# ==========================================
# STATE MODIFIERS
@@ -429,6 +439,19 @@ func get_effective_multiplier(generator_id: StringName, kind: int) -> float:
return multiplier
func _initialize_research() -> void:
research_tracker = Node.new()
research_tracker.set_script(load("res://core/research/research_xp_tracker.gd"))
add_child(research_tracker)
var tracker: ResearchXPTracker = research_tracker as ResearchXPTracker
if tracker:
tracker.game_state = self
if research_catalogue:
for research in research_catalogue.get_all_research():
register_research(research.id)
func _try_unlock_all_buffs_from_goals() -> void:
for buff in get_all_buffs():
if buff == null:
@@ -446,6 +469,60 @@ func _try_unlock_buff_from_goal(buff: GeneratorBuffData) -> void:
if not goal_id.is_empty() and is_goal_completed(goal_id):
set_buff_unlocked(buff.id, true)
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:
var val: Variant = research_xp.get(research_id, BigNumber.new(0.0, 0))
return val as BigNumber
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:
var old_level: int = get_research_level(research_id)
var current_xp: BigNumber = get_research_xp(research_id)
research_xp[research_id] = current_xp.add(xp)
var research: ResearchData = _get_research_data(research_id)
if research != null:
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_xp_multiplier(research_id: StringName) -> float:
var multiplier: float = 1.0
var research: ResearchData = _get_research_data(research_id)
if research == null:
return 1.0
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)
func set_external_save_data(section_key: StringName, payload: Dictionary) -> void:
var key: String = String(section_key).strip_edges()
if key.is_empty():
@@ -473,12 +550,14 @@ func reset_for_prestige(reset_total_currency: bool = false, emit_currency_signal
if reset_total_currency:
_set_total_currency(currency_id, BigNumber.from_float(0.0))
generator_states.clear()
generator_buff_levels.clear()
generator_buff_unlocked.clear()
_goal_completed.clear()
for goal_id in _goal_definitions.keys():
_goal_completed[goal_id] = false
generator_states.clear()
generator_buff_levels.clear()
generator_buff_unlocked.clear()
_goal_completed.clear()
for goal_id in _goal_completed.keys():
_goal_completed[goal_id] = false
research_xp.clear()
research_levels.clear()
if not emit_currency_signals:
return
@@ -506,6 +585,8 @@ func save_game() -> void:
BUFF_UNLOCKED_KEY: _serialize_buff_unlocked(),
BUFF_ACTIVE_KEY: _serialize_buff_active(),
GOALS_KEY: _serialize_goals(),
RESEARCH_XP_KEY: _serialize_research_xp(),
RESEARCH_LEVELS_KEY: research_levels.duplicate(),
LAST_SAVE_TIME_KEY: Time.get_unix_time_from_system()
}
@@ -551,12 +632,18 @@ func load_game() -> void:
generator_states = _deserialize_generator_states(parsed_data.get(GENERATOR_STATES_KEY, {}))
_deserialize_buff_states(parsed_data)
_deserialize_goals(parsed_data.get(GOALS_KEY, {}))
if parsed_data.has(RESEARCH_XP_KEY):
research_xp = _deserialize_research_xp(parsed_data.get(RESEARCH_XP_KEY, {}))
if parsed_data.has(RESEARCH_LEVELS_KEY):
research_levels = parsed_data.get(RESEARCH_LEVELS_KEY, {})
last_save_time = parsed_data.get(LAST_SAVE_TIME_KEY, Time.get_unix_time_from_system())
for save_key_variant in parsed_data.keys():
var save_key: String = String(save_key_variant)
if save_key.is_empty():
continue
if save_key in [SAVE_FORMAT_VERSION_KEY, CURRENCIES_KEY, GENERATOR_STATES_KEY, BUFF_LEVELS_KEY, BUFF_UNLOCKED_KEY, BUFF_ACTIVE_KEY, GOALS_KEY, LAST_SAVE_TIME_KEY]:
if save_key in [SAVE_FORMAT_VERSION_KEY, CURRENCIES_KEY, GENERATOR_STATES_KEY, BUFF_LEVELS_KEY, BUFF_UNLOCKED_KEY, BUFF_ACTIVE_KEY, GOALS_KEY, RESEARCH_XP_KEY, RESEARCH_LEVELS_KEY, LAST_SAVE_TIME_KEY]:
continue
var section_payload: Variant = parsed_data.get(save_key_variant)
@@ -1294,3 +1381,19 @@ func _humanize_currency_id(currency_id: StringName) -> String:
if normalized.is_empty():
return "Unknown"
return normalized.replace("_", " ").capitalize()
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
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

459
core/research/README.md Normal file
View File

@@ -0,0 +1,459 @@
# 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
```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`.
## ResearchXPTracker
Child node of `LevelGameState` that accumulates XP from production and applies buff multipliers.
### Signals
```gdscript
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
```gdscript
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
```gdscript
var research_xp: Dictionary = {} # {research_id: BigNumber}
var research_levels: Dictionary = {} # {research_id: level_int}
var research_tracker: ResearchXPTracker
```
### 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) 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
```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)
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
```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 |
| 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
1. **Generator produces currency**`production_tick` signal emitted
2. **ResearchXPTracker receives signal** → calculates base XP from production amount
3. **Buff multipliers applied** → checks for active RESEARCH_XP_MULTIPLIER buffs
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/research/research_catalogue.gd` - Research configuration resource

645
core/research/TODO.md Normal file
View File

@@ -0,0 +1,645 @@
# 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:**
```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. 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:**
```gdscript
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:**
```gdscript
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:**
```gdscript
var research_xp: Dictionary = {} # {research_id: xp_float}
var research_levels: Dictionary = {} # {research_id: level_int}
var research_tracker: ResearchXPTracker
```
**New Signals:**
```gdscript
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:**
```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] = 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:**
```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
# 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:**
```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)
# 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:**
```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()
```
**In `_ready()`:**
```gdscript
# 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:**
```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 `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 `.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 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
- `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

View 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()

View File

@@ -0,0 +1 @@
uid://d2v6t6w2todfy

View File

@@ -0,0 +1,66 @@
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 = &""
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)

View File

@@ -0,0 +1 @@
uid://m7baywrfnpn0

View File

@@ -0,0 +1,60 @@
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
var _game_state: LevelGameState
var _research_rows: Dictionary = {}
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)
_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: ResearchRow = 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: 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])

View File

@@ -0,0 +1 @@
uid://fdl7ftxw8w1e

View File

@@ -0,0 +1,26 @@
[gd_scene format=3 uid="uid://dd8roif0mqirl"]
[ext_resource type="Script" uid="uid://fdl7ftxw8w1e" path="res://core/research/research_panel.gd" id="1_script"]
[ext_resource type="PackedScene" uid="uid://bpo8pl5tipav" path="res://core/research/research_row.tscn" id="2_3m5ee"]
[node name="ResearchPanel" type="PanelContainer" unique_id=1105274967]
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=1099596602]
layout_mode = 2
[node name="VBoxContainer" type="VBoxContainer" parent="ScrollContainer" unique_id=692886617]
layout_mode = 2
[node name="TitleLabel" type="Label" parent="ScrollContainer/VBoxContainer" unique_id=1565843659]
layout_mode = 2
text = "Research"
[node name="ResearchRows" type="VBoxContainer" parent="ScrollContainer/VBoxContainer" unique_id=1200240263]
layout_mode = 2
[node name="ResearchRow" parent="ScrollContainer/VBoxContainer/ResearchRows" unique_id=245375198 instance=ExtResource("2_3m5ee")]
layout_mode = 2

View File

@@ -0,0 +1,74 @@
class_name ResearchRow
extends HBoxContainer
@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
var _research: ResearchData
var _game_state: LevelGameState
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)
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)

View File

@@ -0,0 +1 @@
uid://gk3mr3k5yvp7

View File

@@ -0,0 +1,38 @@
[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

View File

@@ -0,0 +1,48 @@
class_name ResearchXPTracker
extends Node
@export var game_state: LevelGameState
signal research_xp_changed(research_id: StringName, new_xp: BigNumber)
signal research_level_up(research_id: StringName, old_level: int, new_level: int)
func add_xp_with_buffs(research_id: StringName, base_xp: BigNumber) -> BigNumber:
if game_state == null:
return BigNumber.new(0.0, 0)
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: Variant) -> void:
var research: ResearchData = _get_research_for_generator(generator_id)
if research == null:
return
var research_catalogue: ResearchCatalogue = game_state.research_catalogue
if research_catalogue == null:
return
research = research_catalogue.get_research_by_generator_id(generator_id)
if research == null:
return
var amount_big: BigNumber = _amount
var amount_float: float = amount_big.mantissa * pow(10.0, float(amount_big.exponent))
if amount_float <= 0.0:
return
var base_xp: BigNumber = BigNumber.from_float(research.xp_per_currency_produced * amount_float)
if base_xp.mantissa <= 0.0:
return
add_xp_with_buffs(research.id, base_xp)
func _get_research_for_generator(generator_id: StringName) -> ResearchData:
if game_state == null or game_state.research_catalogue == null:
return null
return game_state.research_catalogue.get_research_by_generator_id(generator_id)

View File

@@ -0,0 +1 @@
uid://bv3a4utlnn2fg