buff #1

Merged
m.rossi merged 34 commits from buff into main 2026-05-07 20:36:22 +00:00
11 changed files with 186 additions and 263 deletions
Showing only changes of commit 3f49bb42c8 - Show all commits

View File

@@ -1,5 +1,6 @@
# AGENTS.md # AGENTS.md
Godot 4.6 idle-game prototype; code is GDScript + `.tscn` scenes + JSON data at repo root. Godot 4.6 idle-game; code is GDScript + `.tscn` scenes.
## Build, Lint, And Test Commands ## Build, Lint, And Test Commands
Use `GODOT_BIN` for your local editor binary (e.g. `godot`, `godot4`, or full path). Use `GODOT_BIN` for your local editor binary (e.g. `godot`, `godot4`, or full path).
Run project: `"$GODOT_BIN" --path .` Run project: `"$GODOT_BIN" --path .`
@@ -7,12 +8,14 @@ Headless smoke/lint parse: `"$GODOT_BIN" --headless --path . --quit`
Script syntax check (single script): `"$GODOT_BIN" --headless --check-only --script res://big_number.gd` Script syntax check (single script): `"$GODOT_BIN" --headless --check-only --script res://big_number.gd`
Tests: no test framework is currently committed (`tests/` and `addons/gut` are absent). Tests: no test framework is currently committed (`tests/` and `addons/gut` are absent).
Single-test command: N/A right now; if GUT is added, use `"$GODOT_BIN" --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/<file>.gd`. Single-test command: N/A right now; if GUT is added, use `"$GODOT_BIN" --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/<file>.gd`.
## Architecture And Structure ## Architecture And Structure
`project.godot` defines autoload singletons: `GameState` (runtime currency state + save/load) and `CurrencyGeneratorDatabase` (loads `generator_data.json`). `project.godot` defines autoload singletons: `GameState` (runtime currency state + save/load) and `CurrencyGeneratorDatabase` (loads `generator_data.json`).
`big_number.gd` (`class_name BigNumber`) is the internal numeric API for huge-value math, comparisons, formatting, and serialization. `big_number.gd` (`class_name BigNumber`) is the internal numeric API for huge-value math, comparisons, formatting, and serialization.
`big_number_museum.tscn` + `big_number_museum.gd` is the current main playable scene/prototype UI. `big_number_museum.tscn` + `big_number_museum.gd` is the current main playable scene/prototype UI.
`currency_generator.gd`, `currency_label.gd`, and `big_number_progress_bar.gd` are UI/gameplay adapters that react to `GameState` signals. `currency_generator.gd`, `currency_label.gd`, and `big_number_progress_bar.gd` are UI/gameplay adapters that react to `GameState` signals.
Persistence is local JSON only: `user://idle_save.json`; there is no external DB/server/backend. Persistence is local JSON only: `user://idle_save.json`; there is no external DB/server/backend.
## Code Style And Conventions ## Code Style And Conventions
Prefer typed GDScript (`var x: Type`, `func f() -> void`) and keep shared models/components as `class_name` scripts. Prefer typed GDScript (`var x: Type`, `func f() -> void`) and keep shared models/components as `class_name` scripts.
Naming: `snake_case` for vars/functions/signals, `PascalCase` for classes, `UPPER_SNAKE_CASE` for constants. Naming: `snake_case` for vars/functions/signals, `PascalCase` for classes, `UPPER_SNAKE_CASE` for constants.
@@ -20,3 +23,4 @@ Follow existing Godot style: tabs/consistent indentation, early returns, and sho
When reading files/JSON, always guard `FileAccess.open(...)` and validate parsed types before indexing. When reading files/JSON, always guard `FileAccess.open(...)` and validate parsed types before indexing.
Use signals (already in `GameState`) for UI updates instead of polling or cross-node direct mutation. Use signals (already in `GameState`) for UI updates instead of polling or cross-node direct mutation.
No Cursor/Claude/Windsurf/Cline/Goose/Copilot rule files were found in this repository, so this file is the canonical agent guidance. No Cursor/Claude/Windsurf/Cline/Goose/Copilot rule files were found in this repository, so this file is the canonical agent guidance.
Avoid trivial one-line wrapper/helper functions that only forward or repack data. Inline the logic at the real call site unless the wrapper adds meaningful abstraction or is reused enough to justify it.

View File

@@ -37,7 +37,6 @@ A modular idle/incremental game prototype featuring a decoupled buff system, mul
| `LevelGameState` | `core/level_game_state.gd` | Central state management, save/load | | `LevelGameState` | `core/level_game_state.gd` | Central state management, save/load |
| `CurrencyCatalogue` | `core/currency_catalogue.gd` | Currency catalog resource | | `CurrencyCatalogue` | `core/currency_catalogue.gd` | Currency catalog resource |
| `PrestigeManager` | `core/prestige/prestige_manager.gd` | Prestige system | | `PrestigeManager` | `core/prestige/prestige_manager.gd` | Prestige system |
| `ResearchXPTracker` | `core/research/research_xp_tracker.gd` | Research XP accumulation |
### Data Directory Structure ### Data Directory Structure

View File

@@ -39,9 +39,6 @@ const HUGE_COST_EXPONENT: int = 1000000
## Reference to generator UI ## Reference to generator UI
@export var info_generator_container: GeneratorPanel @export var info_generator_container: GeneratorPanel
## Reference to research tracker
var research_tracker: Node = null
## True while the pointer is inside the generator Area2D. ## True while the pointer is inside the generator Area2D.
var _mouse_entered: bool = false var _mouse_entered: bool = false
## Time left until next click/hover grant is allowed. ## Time left until next click/hover grant is allowed.
@@ -108,8 +105,7 @@ func _ready() -> void:
game_state.currency_changed.connect(_on_currency_changed) game_state.currency_changed.connect(_on_currency_changed)
game_state.generator_state_changed.connect(_on_generated_state_changed) game_state.generator_state_changed.connect(_on_generated_state_changed)
game_state.goal_completed.connect(_on_goal_completed) game_state.goal_completed.connect(_on_goal_completed)
# Don't cache research_tracker here - it may not be initialized yet # Research XP is awarded via game_state.add_research_xp() - buff calculation handled statically
# Access it lazily when needed instead
_ensure_registered() _ensure_registered()
@@ -155,11 +151,7 @@ func _grant_cycle_income(cycle_count: int) -> void:
var amount_float: float = produced.mantissa * pow(10.0, float(produced.exponent)) 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) 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: if game_state != null and research_xp_amount.mantissa > 0.0:
var tracker: ResearchXPTracker = game_state.research_tracker as ResearchXPTracker game_state.add_research_xp(data.research_data.id, research_xp_amount)
if tracker:
tracker.add_xp_with_buffs(data.research_data.id, research_xp_amount)
else:
game_state.add_research_xp(data.research_data.id, research_xp_amount)
## Convenience helper for the next single-unit purchase cost. ## Convenience helper for the next single-unit purchase cost.
func get_next_cost() -> BigNumber: func get_next_cost() -> BigNumber:
@@ -444,11 +436,7 @@ func _try_grant_click_currency() -> void:
var amount_float: float = click_value.mantissa * pow(10.0, float(click_value.exponent)) 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) 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: if game_state != null and research_xp_amount.mantissa > 0.0:
var tracker: ResearchXPTracker = game_state.research_tracker as ResearchXPTracker game_state.add_research_xp(data.research_data.id, research_xp_amount)
if tracker:
tracker.add_xp_with_buffs(data.research_data.id, research_xp_amount)
else:
game_state.add_research_xp(data.research_data.id, research_xp_amount)
## Resolves click reward from data. ## Resolves click reward from data.

View File

@@ -0,0 +1,22 @@
## Static utility class for research XP buff calculations
class_name ResearchBuffCalculator
static func apply_buffs(game_state: LevelGameState, research_id: StringName, base_xp: BigNumber) -> BigNumber:
if game_state == null:
return base_xp
var multiplier: float = _calculate_buff_multiplier(game_state, research_id)
var multiplier_bn: BigNumber = BigNumber.from_float(multiplier)
return base_xp.multiply(multiplier_bn)
static func _calculate_buff_multiplier(game_state: LevelGameState, research_id: StringName) -> float:
var research: ResearchData = game_state.research_catalogue.get_research_by_id(research_id)
if research == null or research.associated_buff_id.is_empty():
return 1.0
var buff: GeneratorBuffData = game_state.get_buff(research.associated_buff_id)
if buff == null or not game_state.is_buff_active(buff.id):
return 1.0
var level: int = game_state.get_buff_level(buff.id)
return buff.get_effect_multiplier(level)

View File

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

View File

@@ -46,7 +46,6 @@ var _goal_completed: Dictionary = {}
var research_xp: Dictionary = {} var research_xp: Dictionary = {}
var research_levels: Dictionary = {} var research_levels: Dictionary = {}
var research_tracker: Node = null
var last_save_time: int = 0 var last_save_time: int = 0
@@ -80,6 +79,8 @@ var prestige_manager: PrestigeManager
func _ready() -> void: func _ready() -> void:
prestige_manager = find_child("PrestigeManager") prestige_manager = find_child("PrestigeManager")
assert(prestige_manager != null)
_initialize_currency_maps() _initialize_currency_maps()
_initialize_catalogues() _initialize_catalogues()
_load_catalogue_goals() _load_catalogue_goals()
@@ -482,9 +483,11 @@ func get_research_multiplier(research_id: StringName) -> float:
return research.get_multiplier_for_level(level) return research.get_multiplier_for_level(level)
func add_research_xp(research_id: StringName, xp: BigNumber) -> void: func add_research_xp(research_id: StringName, xp: BigNumber) -> void:
var actual_xp: BigNumber = ResearchBuffCalculator.apply_buffs(self, research_id, xp)
var old_level: int = get_research_level(research_id) var old_level: int = get_research_level(research_id)
var current_xp: BigNumber = get_research_xp(research_id) var current_xp: BigNumber = get_research_xp(research_id)
research_xp[research_id] = current_xp.add(xp) research_xp[research_id] = current_xp.add(actual_xp)
var research: ResearchData = _get_research_data(research_id) var research: ResearchData = _get_research_data(research_id)
if research != null: if research != null:
@@ -496,20 +499,6 @@ func add_research_xp(research_id: StringName, xp: BigNumber) -> void:
research_xp_changed.emit(research_id, research_xp[research_id]) 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: func _get_research_data(research_id: StringName) -> ResearchData:
if research_catalogue == null: if research_catalogue == null:
return null return null

View File

@@ -10,30 +10,26 @@ specific generators and can be enhanced with purchasable buffs that increase XP
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `research_xp_tracker.gd` | Accumulates XP from production and handles auto-leveling (Node) |
| `research_catalogue.gd` | Resource holding all research configurations | | `research_catalogue.gd` | Resource holding all research configurations |
| `research_panel.gd` | UI panel showing all research tracks | | `research_panel.gd` | UI panel showing all research tracks |
| `research_row.gd` | UI row component for individual research display | | `research_row.gd` | UI row component for individual research display |
| `../generator/research_buff_calculator.gd` | Static utility for buff multiplier calculations |
## Architecture ## Architecture
``` ```
LevelGameState (Node2D) 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_xp: Dictionary
├── research_levels: Dictionary ├── research_levels: Dictionary
── research_catalogue: ResearchCatalogue ── research_catalogue: ResearchCatalogue
└── ResearchPanel (UI, always visible) └── ResearchPanel (UI, always visible)
├── ResearchRow (for each research track) ├── ResearchRow (for each research track)
│ ├── Name & icon │ ├── Name & icon
│ ├── Level display │ ├── Level display
│ ├── Progress bar (percentage) │ ├── Progress bar (percentage)
│ ├── Multiplier indicator │ ├── Multiplier indicator
│ └── Active buff display │ └── Active buff display
└── (buff calculation delegated to ResearchBuffCalculator static methods)
``` ```
## Data Flow ## Data Flow
@@ -42,12 +38,13 @@ LevelGameState (Node2D)
Generator Production (currency_per_cycle) Generator Production (currency_per_cycle)
ResearchXPTracker LevelGameState.add_research_xp()
1. base_xp = currency_produced * xp_per_currency_produced 1. base_xp = currency_produced * xp_per_currency_produced
2. actual_xp = base_xp * buff_multiplier 2. buff_multiplier = ResearchBuffCalculator.apply_buffs(research, buffs)
3. research_xp += actual_xp 3. actual_xp = base_xp * buff_multiplier
4. Check level threshold → auto-level 4. research_xp += actual_xp
5. Emit research_level_up signal 5. Check level threshold → auto-level
6. Emit research_level_up signal
ResearchState {xp: 150.5, level: 3} ResearchState {xp: 150.5, level: 3}
@@ -143,38 +140,61 @@ func get_all_research() -> Array[ResearchData]
Referenced directly by `LevelGameState` via `@export var research_catalogue: ResearchCatalogue`. Referenced directly by `LevelGameState` via `@export var research_catalogue: ResearchCatalogue`.
## ResearchXPTracker ## ResearchBuffCalculator
Child node of `LevelGameState` that accumulates XP from production and applies buff multipliers. Static utility class for calculating research XP buff multipliers.
### Signals ### Location
```gdscript `res://core/generator/research_buff_calculator.gd`
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 ### Key Methods
```gdscript ```gdscript
func add_xp_with_buffs(research_id: StringName, base_xp: BigNumber) -> BigNumber: static func apply_buffs(research: ResearchData, game_state: LevelGameState) -> float:
"""Apply buff multipliers and award XP. Returns actual XP after buffs (BigNumber).""" """Calculate total buff multiplier for research XP gain. Returns multiplicative factor."""
var buff_multiplier: float = game_state.get_research_xp_multiplier(research_id) var multiplier: float = 1.0
var multiplier_bn: BigNumber = BigNumber.from_float(buff_multiplier) if research.associated_buff_id.is_empty():
var actual_xp: BigNumber = base_xp.multiply(multiplier_bn) return multiplier
game_state.add_research_xp(research_id, actual_xp)
return actual_xp
func _on_generator_produced(generator_id: StringName, amount: BigNumber) -> void: var buff: GeneratorBuffData = game_state.get_buff(research.associated_buff_id)
var research: ResearchData = _get_research_for_generator(generator_id) if buff != null and game_state.is_buff_active(buff.id):
var amount_float: float = amount.mantissa * pow(10.0, float(amount.exponent)) var buff_level: int = game_state.get_buff_level(buff.id)
var base_xp: BigNumber = BigNumber.from_float(research.xp_per_currency_produced * amount_float) multiplier *= ResearchBuffCalculator._calculate_buff_multiplier(buff, buff_level)
add_xp_with_buffs(research.id, base_xp)
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)
``` ```
### Connection ### Usage
Connected to `LevelGameState` via `generator_produced` signal. 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 ## LevelGameState Extensions
@@ -182,7 +202,7 @@ Connected to `LevelGameState` via `generator_produced` signal.
```gdscript ```gdscript
var research_xp: Dictionary = {} # {research_id: BigNumber} var research_xp: Dictionary = {} # {research_id: BigNumber}
var research_levels: Dictionary = {} # {research_id: level_int} var research_levels: Dictionary = {} # {research_id: level_int}
var research_tracker: ResearchXPTracker # research_tracker removed - buff calculation moved to static utility
``` ```
### Signals ### Signals
@@ -208,21 +228,27 @@ func get_research_multiplier(research_id: StringName) -> float:
return research.get_multiplier_for_level(level) return research.get_multiplier_for_level(level)
func add_research_xp(research_id: StringName, xp: BigNumber) -> void: func add_research_xp(research_id: StringName, xp: BigNumber) -> void:
"""Add XP (BigNumber) and auto-level if threshold reached""" """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) 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]) # 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: if new_level > old_level:
research_levels[research_id] = new_level research_levels[research_id] = new_level
research_level_up.emit(research_id, old_level, new_level) research_level_up.emit(research_id, old_level, new_level)
func get_research_xp_multiplier(research_id: StringName) -> float: research_xp_changed.emit(research_id, research_xp[research_id])
"""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 ### Save/Load Integration
@@ -272,11 +298,11 @@ func _grant_cycle_income(cycle_count: int) -> void:
_add_currency(produced) _add_currency(produced)
production_tick.emit(produced, cycle_count, owned) production_tick.emit(produced, cycle_count, owned)
# Award research XP (BigNumber) # Award research XP (BigNumber) - buff multipliers applied in LevelGameState
if data.research_data != null: if data.research_data != null:
var amount_float: float = produced.mantissa * pow(10.0, float(produced.exponent)) 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) 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) game_state.add_research_xp(data.research_data.id, xp)
``` ```
### Research Multiplier ### Research Multiplier
@@ -425,7 +451,7 @@ actual_xp = base_xp * xp_multiplier
| Decision | Value | | Decision | Value |
|----------|-------| |----------|-------|
| ResearchCatalogue reference | Direct `@export var research_catalogue: ResearchCatalogue` in LevelGameState | | ResearchCatalogue reference | Direct `@export var research_catalogue: ResearchCatalogue` in LevelGameState |
| ResearchXPTracker location | Child of `LevelGameState` (receives signals via connection) | | Buff calculation | Static utility (`ResearchBuffCalculator`) - no dedicated tracker node |
| Progress bar display | Percentage format (e.g., "75%") | | Progress bar display | Percentage format (e.g., "75%") |
| Buff display | Shows buff name and effect (e.g., "Mining Expert: +20% XP") | | Buff display | Shows buff name and effect (e.g., "Mining Expert: +20% XP") |
| Research unlock | Starts unlocked | | Research unlock | Starts unlocked |
@@ -438,8 +464,8 @@ actual_xp = base_xp * xp_multiplier
## System Behavior ## System Behavior
1. **Generator produces currency**`production_tick` signal emitted 1. **Generator produces currency**`production_tick` signal emitted
2. **ResearchXPTracker receives signal** → calculates base XP from production amount 2. **CurrencyGenerator calls**`game_state.add_research_xp()` with base XP
3. **Buff multipliers applied** → checks for active RESEARCH_XP_MULTIPLIER buffs 3. **LevelGameState applies buffs**`ResearchBuffCalculator.apply_buffs()` calculates multiplier
4. **XP awarded** → stored in `research_xp` dictionary 4. **XP awarded** → stored in `research_xp` dictionary
5. **Level check** → if XP exceeds threshold, auto-level and emit `research_level_up` 5. **Level check** → if XP exceeds threshold, auto-level and emit `research_level_up`
6. **Multiplier applied**`get_research_multiplier()` returns updated production bonus 6. **Multiplier applied**`get_research_multiplier()` returns updated production bonus
@@ -458,4 +484,5 @@ On prestige reset:
- `core/level_game_state.gd` - Research state storage and management - `core/level_game_state.gd` - Research state storage and management
- `core/generator/currency_generator_data.gd` - Links to ResearchData - `core/generator/currency_generator_data.gd` - Links to ResearchData
- `core/generator/generator_buff_data.gd` - RESEARCH_XP_MULTIPLIER buff type - `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 - `core/research/research_catalogue.gd` - Research configuration resource

View File

@@ -18,16 +18,10 @@ A production-based research system where:
``` ```
┌─────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────┐
│ LevelGameState │ │ 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_xp: Dictionary │
│ - research_levels: Dictionary │ │ - research_levels: Dictionary │
│ - research_catalogue: ResearchCatalogue │ │ - research_catalogue: ResearchCatalogue │
│ - add_research_xp() applies buffs via ResearchBuffCalculator │
└─────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────┘
┌───────────────────┼───────────────────┐ ┌───────────────────┼───────────────────┐
@@ -39,6 +33,12 @@ A production-based research system where:
│ - emits signal │ │ - Multiplier │ │ research │ │ - emits signal │ │ - Multiplier │ │ research │
└─────────────────┘ └─────────────────┘ │ - Progress bars │ └─────────────────┘ └─────────────────┘ │ - Progress bars │
└─────────────────┘ └─────────────────┘
┌───────────────────────────────────────────────────────────────┐
│ ResearchBuffCalculator (static utility) │
│ - apply_buffs() calculates XP multiplier from active buffs │
└───────────────────────────────────────────────────────────────┘
``` ```
--- ---
@@ -50,15 +50,22 @@ Generator Production (currency_per_cycle)
┌──────────────────────┐ ┌──────────────────────┐
ResearchXPTracker CurrencyGen
1. base_xp = currency_produced * xp_per_currency_produced emits production_tick │
│ 2. actual_xp = base_xp * buff_multiplier
│ 3. research_xp += actual_xp
│ 4. Check level threshold → auto-level
│ 5. Emit research_level_up signal
└──────────────────────┘ └──────────────────────┘
┌─────────────────────────────────────────┐
│ 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 │ │ ResearchState │
│ {xp: 150.5, level: 3}│ │ {xp: 150.5, level: 3}│
@@ -188,53 +195,33 @@ func get_all_research() -> Array[ResearchData]:
--- ---
### 3. ResearchXPTracker (core/research/research_xp_tracker.gd) ### 3. ResearchBuffCalculator (core/generator/research_buff_calculator.gd)
**Purpose:** Child of LevelGameState; accumulates XP from production and applies buff multipliers **Purpose:** Static utility class for calculating research XP buff multipliers
**Location:** `res://core/research/research_xp_tracker.gd` **Location:** `res://core/generator/research_buff_calculator.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:** **Key Methods:**
```gdscript ```gdscript
func add_xp_with_buffs(research_id: StringName, base_xp: float) -> float: static func apply_buffs(research: ResearchData, game_state: LevelGameState) -> float:
"""Apply buff multipliers and award XP. Returns actual XP after buffs.""" """Calculate total buff multiplier for research XP gain. Returns multiplicative factor."""
if game_state == null: var multiplier: float = 1.0
return 0.0 if research.associated_buff_id.is_empty():
return multiplier
var actual_xp: float = base_xp 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)
# Apply research-specific buff multiplier return multiplier
var buff_multiplier: float = game_state.get_research_xp_multiplier(research_id)
actual_xp *= buff_multiplier
# Award XP (handles auto-leveling internally) static func _calculate_buff_multiplier(buff: GeneratorBuffData, level: int) -> float:
game_state.add_research_xp(research_id, actual_xp) """Calculate buff effect multiplier at given level."""
return buff.get_effect_multiplier(level)
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 **Usage:** Called from `LevelGameState.add_research_xp()` to apply buffs before storing XP
--- ---
@@ -242,14 +229,14 @@ func _on_generator_produced(generator_id: StringName, amount: BigNumber) -> void
**New State Variables:** **New State Variables:**
```gdscript ```gdscript
var research_xp: Dictionary = {} # {research_id: xp_float} var research_xp: Dictionary = {} # {research_id: BigNumber}
var research_levels: Dictionary = {} # {research_id: level_int} var research_levels: Dictionary = {} # {research_id: level_int}
var research_tracker: ResearchXPTracker # research_tracker removed - buff calculation moved to static utility
``` ```
**New Signals:** **New Signals:**
```gdscript ```gdscript
signal research_xp_changed(research_id: StringName, new_xp: float) signal research_xp_changed(research_id: StringName, new_xp: BigNumber)
signal research_level_up(research_id: StringName, old_level: int, new_level: int) signal research_level_up(research_id: StringName, old_level: int, new_level: int)
``` ```
@@ -262,12 +249,12 @@ signal research_level_up(research_id: StringName, old_level: int, new_level: int
```gdscript ```gdscript
func register_research(research_id: StringName) -> void: func register_research(research_id: StringName) -> void:
if not research_xp.has(research_id): if not research_xp.has(research_id):
research_xp[research_id] = 0.0 research_xp[research_id] = BigNumber.new(0.0, 0)
if not research_levels.has(research_id): if not research_levels.has(research_id):
research_levels[research_id] = 0 research_levels[research_id] = 0
func get_research_xp(research_id: StringName) -> float: func get_research_xp(research_id: StringName) -> BigNumber:
return research_xp.get(research_id, 0.0) return research_xp.get(research_id, BigNumber.new(0.0, 0))
func get_research_level(research_id: StringName) -> int: func get_research_level(research_id: StringName) -> int:
return research_levels.get(research_id, 0) return research_levels.get(research_id, 0)
@@ -279,11 +266,22 @@ func get_research_multiplier(research_id: StringName) -> float:
return 1.0 return 1.0
return research.get_multiplier_for_level(level) return research.get_multiplier_for_level(level)
func add_research_xp(research_id: StringName, xp: float) -> void: func add_research_xp(research_id: StringName, xp: BigNumber) -> void:
var old_level: int = get_research_level(research_id) """Add XP with buff multipliers applied, auto-level if threshold reached"""
research_xp[research_id] = research_xp.get(research_id, 0.0) + xp var research: ResearchData = research_catalogue.get_research_by_id(research_id)
if research == null:
return
var new_level: int = _get_research_data(research_id).get_level_for_xp(research_xp[research_id]) 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: if new_level > old_level:
research_levels[research_id] = new_level research_levels[research_id] = new_level
@@ -291,22 +289,6 @@ func add_research_xp(research_id: StringName, xp: float) -> void:
research_xp_changed.emit(research_id, research_xp[research_id]) 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: func _get_research_data(research_id: StringName) -> ResearchData:
if research_catalogue == null: if research_catalogue == null:
return null return null
@@ -338,11 +320,6 @@ research_levels.clear()
**Initialization in _ready():** **Initialization in _ready():**
```gdscript ```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 # Initialize research state from catalogue
if research_catalogue: if research_catalogue:
for research in research_catalogue.get_all_research(): for research in research_catalogue.get_all_research():
@@ -377,10 +354,10 @@ func _grant_cycle_income(cycle_count: int) -> void:
_add_currency(produced) _add_currency(produced)
production_tick.emit(produced, cycle_count, owned) production_tick.emit(produced, cycle_count, owned)
# NEW: Award research XP # Award research XP - buff multipliers applied in LevelGameState
if data.research_data != null: if data.research_data != null:
var xp: float = data.research_data.xp_per_currency_produced * produced.to_float() var xp: float = data.research_data.xp_per_currency_produced * produced.to_float()
research_tracker.add_xp_with_buffs(data.research_data.id, xp) game_state.add_research_xp(data.research_data.id, BigNumber.from_float(xp))
``` ```
**New Method:** **New Method:**
@@ -399,13 +376,6 @@ func get_effective_auto_run_multiplier() -> float:
return run_multiplier * get_research_multiplier() * get_automatic_production_multiplier() * _get_prestige_multiplier() 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) ### 7. GeneratorBuffData Extensions (core/generator/generator_buff_data.gd)
@@ -549,11 +519,11 @@ func _on_research_level_up(research_id: StringName, _old_level: int, _new_level:
|------|------|-------|----------|--------| |------|------|-------|----------|--------|
| 1 | Create `ResearchData` resource class | `core/generator/research_data.gd` | High | ⬜ | | 1 | Create `ResearchData` resource class | `core/generator/research_data.gd` | High | ⬜ |
| 2 | Create `ResearchCatalogue` resource class | `core/research/research_catalogue.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 | | | 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 | | | 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 | ⬜ | | 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 | ⬜ | | 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 | | | 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 | ⬜ | | 8 | Create `ResearchRow` scene & script | `core/research/research_row.tscn`, `.gd` | Medium | ⬜ |
| 9 | Create `ResearchPanel` scene & script | `core/research/research_panel.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 | ⬜ | | 10 | Add save/load support for research state | `core/level_game_state.gd` | Medium | ⬜ |
@@ -569,7 +539,7 @@ func _on_research_level_up(research_id: StringName, _old_level: int, _new_level:
|----------|-------| |----------|-------|
| ResearchCatalogue reference | Direct `@export var research_catalogue: ResearchCatalogue` in LevelGameState | | ResearchCatalogue reference | Direct `@export var research_catalogue: ResearchCatalogue` in LevelGameState |
| ResearchCatalogue location | `core/research/research_catalogue.gd` (resource, referenced directly) | | ResearchCatalogue location | `core/research/research_catalogue.gd` (resource, referenced directly) |
| ResearchXPTracker location | Child of `LevelGameState` (receives signals via connection) | | Buff calculation | Static utility (`ResearchBuffCalculator`) - no dedicated tracker node |
| Progress bar display | Percentage format (e.g., "75%") | | Progress bar display | Percentage format (e.g., "75%") |
| Buff display | Shows buff name and effect (e.g., "Mining Expert: +20% XP") | | Buff display | Shows buff name and effect (e.g., "Mining Expert: +20% XP") |
| Research unlock | Starts unlocked | | Research unlock | Starts unlocked |
@@ -619,7 +589,8 @@ actual_xp = base_xp * xp_multiplier
- **Associated buffs** should be created as `GeneratorBuffData` with `BuffKind.RESEARCH_XP_MULTIPLIER` - **Associated buffs** should be created as `GeneratorBuffData` with `BuffKind.RESEARCH_XP_MULTIPLIER`
- **Buff names** are configured in the `text` field of each `GeneratorBuffData` - **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) - **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 - **Save format version** should be incremented to 5 when adding research support (BigNumber XP)
- **ResearchXPTracker removed** - buff calculation moved to static `ResearchBuffCalculator` utility
--- ---

View File

@@ -1,72 +0,0 @@
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
if not _is_research_active(research.id):
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)
func _is_research_active(research_id: StringName) -> bool:
if game_state == null:
return false
var research_panel: Node = game_state.find_child("ResearchPanel", true, false)
if research_panel == null:
return true
if research_panel.has_method("is_research_active"):
return research_panel.is_research_active(research_id)
return true
func activate_research(research_id: StringName) -> void:
if game_state == null:
return
var research_panel: Node = game_state.find_child("ResearchPanel", true, false)
if research_panel != null and research_panel.has_method("activate_research"):
research_panel.activate_research(research_id)

View File

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

View File

@@ -10,7 +10,6 @@
[ext_resource type="Script" uid="uid://srkiu4qe8s2m" path="res://core/prestige/prestige_manager.gd" id="5_x77df"] [ext_resource type="Script" uid="uid://srkiu4qe8s2m" path="res://core/prestige/prestige_manager.gd" id="5_x77df"]
[ext_resource type="Resource" uid="uid://dwmfvmusfskk6" path="res://docs/gyms/tiny_sword/prestige/primary_prestige.tres" id="6_xnhlc"] [ext_resource type="Resource" uid="uid://dwmfvmusfskk6" path="res://docs/gyms/tiny_sword/prestige/primary_prestige.tres" id="6_xnhlc"]
[ext_resource type="PackedScene" uid="uid://btkxru2gdjsgc" path="res://currency_tile.tscn" id="7_0cs5o"] [ext_resource type="PackedScene" uid="uid://btkxru2gdjsgc" path="res://currency_tile.tscn" id="7_0cs5o"]
[ext_resource type="Script" uid="uid://bv3a4utlnn2fg" path="res://core/research/research_xp_tracker.gd" id="8_no27p"]
[ext_resource type="Resource" uid="uid://w4u1hddplb4e" path="res://docs/gyms/tiny_sword/currencies/gold.tres" id="9_1363k"] [ext_resource type="Resource" uid="uid://w4u1hddplb4e" path="res://docs/gyms/tiny_sword/currencies/gold.tres" id="9_1363k"]
[ext_resource type="PackedScene" uid="uid://djedqovgngrx5" path="res://docs/gyms/tiny_sword/buildings/farm/farm.tscn" id="10_1lv5i"] [ext_resource type="PackedScene" uid="uid://djedqovgngrx5" path="res://docs/gyms/tiny_sword/buildings/farm/farm.tscn" id="10_1lv5i"]
[ext_resource type="Resource" uid="uid://dfxk30o34qe2s" path="res://docs/gyms/tiny_sword/currencies/worker.tres" id="10_i1cck"] [ext_resource type="Resource" uid="uid://dfxk30o34qe2s" path="res://docs/gyms/tiny_sword/currencies/worker.tres" id="10_i1cck"]
@@ -37,10 +36,6 @@ script = ExtResource("5_x77df")
config = ExtResource("6_xnhlc") config = ExtResource("6_xnhlc")
game_state = NodePath("..") game_state = NodePath("..")
[node name="ResearchXpTracker" type="Node" parent="LevelGameState" unique_id=229654635 node_paths=PackedStringArray("game_state")]
script = ExtResource("8_no27p")
game_state = NodePath("..")
[node name="World" type="Node2D" parent="LevelGameState" unique_id=2118297724] [node name="World" type="Node2D" parent="LevelGameState" unique_id=2118297724]
[node name="ForestProps" type="Node2D" parent="LevelGameState/World" unique_id=649424542] [node name="ForestProps" type="Node2D" parent="LevelGameState/World" unique_id=649424542]