systems rework: buffs, prestige graph, research, modular architecture

Decouples core systems from centralized globals in favor of catalogue-based architecture and data-driven content.

   Key changes:
   - Prestige: node-based buff tech tree, graph panel, progress bar
   - Research: multi-worker system, per-generator research, xp generation
   - Ascension: meta-currency layer via multi-currency prestige resets
   - Buffs: refactored as generator-independent via catalogues
   - UI: currency panel, edge-scrolling camera + zoom, current-goal panel
   - Alchemy Tower: crafting building with recipe/cost system
   - Testing: 7 test suites (ascension, prestige, research, goals)
   - Content: migrated from hardcoded idles/ to gym format (tiny_sword)
This commit was merged in pull request #1.
This commit is contained in:
2026-05-07 20:36:21 +00:00
parent 2743fd314a
commit 81a4058b04
242 changed files with 11226 additions and 2242 deletions

225
core/generator/README.md Normal file
View File

@@ -0,0 +1,225 @@
# Generator Module Documentation
## Overview
The `generator/` subfolder contains the core idle game mechanics: automated resource production with scaling costs, upgrades (buffs), and manual interaction.
## Files
| File | Purpose |
|------|---------|
| `currency_generator.gd` | Runtime generator logic (Node2D) |
| `currency_generator_data.gd` | Balancing data resource |
| `generator_buff_data.gd` | Buff/upgrade definitions |
| `generator_container.gd` | UI panel for generator stats |
## Architecture
```
CurrencyGenerator (Node2D)
├── Handles production cycles
├── Manages purchases
├── Processes clicks/hover
└── GeneratorPanel (UI)
├── Shows stats/costs
└── Buff upgrade buttons
```
## CurrencyGeneratorData
Resource class for generator balancing data.
### Key Properties
```gdscript
@export var id: StringName = &"" # Unique identifier
@export var name: String = "" # Display name
@export var starts_unlocked: bool = true # Visible at game start
@export var purchase_currency: Currency # What to spend
@export var unlock_goal: GoalData # Goal to unlock this (behavior defined in GoalData)
# Cost formula: cost = base * coefficient^owned
@export var initial_cost: float = 10.0 # Base cost
@export var coefficient: float = 1.15 # Growth factor
# Production
@export var initial_time: float = 1.0 # Seconds per cycle
@export var initial_productivity: float = 1.0 # Resources per cycle
# Milestones
@export var milestone_step: int = 25 # Every N units
@export var milestone_multiplier: float = 2.0 # x multiplier
# Manual click
@export var click_mantissa: float = 1.0
@export var click_exponent: int = 0
@export var click_cooldown_seconds: float = 0.2
```
### Cost Formula
**Bulk Buy Cost** (geometric series):
```
total_cost = base * r^owned * ((r^n - 1) / (r - 1))
```
Where:
- `base` = initial_cost
- `r` = coefficient
- `owned` = current count
- `n` = amount to buy
**Max Buy** (inverse formula):
```
max = floor(log((currency * (r-1) / (base * r^owned)) + 1) / log(r))
```
### Production Formula
```gdscript
production_per_cycle = initial_productivity * owned * run_multiplier * milestone_mult * purchased_mult
production_per_second = production_per_cycle / initial_time
```
## CurrencyGenerator
Runtime node instance attached to generator scenes.
### Signals
```gdscript
signal purchase_completed(amount, total_owned, total_purchased, total_cost)
signal production_tick(amount, cycle_count, total_owned)
signal buff_purchased(buff_id, new_level, cost, cost_currency_id)
signal goal_achieved(generator_id, goal_id) # Emitted when generator unlocks from goal
```
### Key Methods
```gdscript
# Purchase
func buy(amount: int = 1) -> bool
func buy_max() -> int
func get_cost_for_amount(amount: int) -> BigNumber
func can_buy(amount: int) -> bool
# Goal-based unlock (automatic when goal completes)
# Generator listens to GameState.goal_completed signal
```
# Production
func grant_currency(amount: BigNumber)
func get_effective_auto_run_multiplier() -> float
# Buffs
func buy_buff(buff_id: StringName) -> bool
func get_buff_level(buff_id: StringName) -> int
func is_buff_unlocked(buff_id: StringName) -> bool
```
### Production Cycle
1. Accumulate `cycle_progress_seconds` in `_process()`
2. When `>= initial_time`, grant production
3. Emit `production_tick` signal
4. Multiply by:
- Owned count
- Run multiplier (export)
- Buff multipliers (auto_production)
- Milestone bonuses
- Purchase boost
## GeneratorBuffData
Defines upgradeable modifiers for generators.
### Buff Kinds
```gdscript
enum BuffKind {
AUTO_PRODUCTION_MULTIPLIER, # x output per cycle
MANUAL_CLICK_MULTIPLIER, # x click reward
RESOURCE_PURCHASE # Instant currency grant
}
```
### Key Properties
```gdscript
@export var id: StringName = &""
@export var target_ids: Array[StringName] = [] # Generators affected
@export var kind: BuffKind
@export var max_level: int = -1 # -1 = unlimited
@export var base_effect: float = 1.0 # Starting multiplier
@export var effect_increment: float = 0.1 # +per level
@export var base_cost_mantissa: float = 10.0
@export var base_cost_exponent: int = 0
@export var cost_multiplier: float = 1.5 # Cost growth
# Effect: base_effect + (effect_increment * level)
func get_effect_multiplier(level: int) -> float
```
### Cost Formula
```gdscript
cost_for_level = base_cost * cost_multiplier^level
```
### Resource Purchase Buff
Special buff type that grants instant currency:
```gdscript
resource_purchase_amount = base_amount * (resource_purchase_increment_multiplier^level)
```
## GeneratorPanel
UI component showing generator information.
### Displays
- Generator name and owned count
- Next purchase cost
- Production per second
- Buff upgrade buttons with costs
### Signals Connected
- `purchase_completed` - Refresh UI
- `production_tick` - Update production display
- `generator_buff_level_changed` - Refresh buff rows
## Goal-Based Unlock
Generators with `unlock_goal` automatically listen to `LevelGameState.goal_completed` signal:
- When goal completes, generator unlocks automatically
- Goal's `unlock_behavior` (AUTOMATIC/MANUAL) controls when goal completes
- Manual goals require player to click "Unlock" button first
```gdscript
# In CurrencyGenerator._ready():
level_game_state.goal_completed.connect(_on_goal_completed)
func _on_goal_completed(goal_id: StringName) -> void:
if data.has_unlock_goal() and data.get_unlock_goal_id() == goal_id:
unlock_generator()
```
## Prestige Integration
Generators automatically apply prestige multipliers:
```gdscript
effective_multiplier = run_multiplier * buff_multiplier * prestige_multiplier
```
On prestige via `LevelGameState.reset_for_prestige()`:
1. Current currencies reset to 0
2. `cycle_progress_seconds` reset
3. Buff levels reset to 0
4. Goal completion state cleared and re-initialized
5. Research XP and levels reset to 0
## See Also
- `core/level_game_state.gd` - Generator state storage
- `core/goals/goal_data.gd` - Unlock conditions
- `core/prestige/prestige_manager.gd` - Multiplier application

View File

@@ -24,7 +24,7 @@ signal goal_achieved(generator_id: StringName, goal_id: StringName)
const HUGE_COST_EXPONENT: int = 1000000
## Currency target updated by this generator.
@export var currency: Resource
@export var currency: Currency
## Data resource containing balancing values and defaults (required).
@export var data: CurrencyGeneratorData
## Enables per-cycle automatic production when true.
@@ -37,49 +37,52 @@ const HUGE_COST_EXPONENT: int = 1000000
@export var run_multiplier: float = 1.0
## Reference to generator UI
@onready var info_generator_container: GeneratorPanel = $GeneratorPanel
@export var info_generator_container: GeneratorPanel
## True while the pointer is inside the generator Area2D.
var _mouse_entered: bool = false
## Time left until next click/hover grant is allowed.
var _remaining_click_cooldown_seconds: float = 0.0
## Reference to LevelGameState instance.
@onready var game_state: LevelGameState = find_parent("LevelGameState")
## Number of currently owned generators.
## Backed by GameState via this generator's resolved id.
## Backed by LevelGameState via this generator's resolved id.
var owned: int:
get:
_ensure_registered()
return GameState.get_generator_owned(_generator_id)
return game_state.get_generator_owned(_generator_id)
set(value):
_ensure_registered()
GameState.set_generator_owned(_generator_id, value)
game_state.set_generator_owned(_generator_id, value)
## Lifetime purchased generators (does not decrease if ownership drops).
var purchased_count: int:
get:
_ensure_registered()
return GameState.get_generator_purchased_count(_generator_id)
return game_state.get_generator_purchased_count(_generator_id)
set(value):
_ensure_registered()
GameState.set_generator_purchased_count(_generator_id, value)
game_state.set_generator_purchased_count(_generator_id, value)
## Whether this generator is unlocked for the player.
var is_unlocked: bool:
get:
_ensure_registered()
return GameState.is_generator_unlocked(_generator_id)
return game_state.is_generator_unlocked(_generator_id)
set(value):
_ensure_registered()
GameState.set_generator_unlocked(_generator_id, value)
game_state.set_generator_unlocked(_generator_id, value)
## Whether this unlocked generator is currently visible/usable to the player.
var is_available: bool:
get:
_ensure_registered()
return GameState.is_generator_available(_generator_id)
return game_state.is_generator_available(_generator_id)
set(value):
_ensure_registered()
GameState.set_generator_available(_generator_id, value)
game_state.set_generator_available(_generator_id, value)
## Accumulated elapsed time toward the next automatic production cycle.
var cycle_progress_seconds: float = 0.0
@@ -96,12 +99,15 @@ func _ready() -> void:
assert(data != null, "Data cannot be null")
_generator_id = _resolve_generator_id()
#_ensure_registered()
cycle_progress_seconds = 0.0
GameState.currency_changed.connect(_on_currency_changed)
GameState.generator_state_changed.connect(_on_generated_state_changed)
_evaluate_generator_unlock_goal(false)
if game_state:
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)
# Research XP is awarded via game_state.add_research_xp() - buff calculation handled statically
_ensure_registered()
## Updates click cooldown/click grants and runs automatic production cycles.
func _process(delta: float) -> void:
@@ -137,6 +143,23 @@ 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:
if not _is_research_active(data.research_data.id):
return
var research_workers: int = game_state.get_research_workers()
if research_workers < data.research_data.min_workers_for_xp:
return
var amount_float: float = produced.mantissa * pow(10.0, float(produced.exponent))
var base_xp: BigNumber = BigNumber.from_float(data.research_data.xp_per_currency_produced * amount_float)
var worker_multiplier: float = 1.0 + (float(research_workers) * data.research_data.worker_scaling_factor)
var scaled_xp: BigNumber = base_xp.multiply(BigNumber.from_float(worker_multiplier))
if game_state != null and scaled_xp.mantissa > 0.0:
game_state.add_research_xp(data.research_data.id, scaled_xp)
## Convenience helper for the next single-unit purchase cost.
func get_next_cost() -> BigNumber:
@@ -195,7 +218,7 @@ func buy_max() -> int:
if purchase_currency_id == &"":
return 0
var available_currency: float = _big_number_to_float(_get_currency_amount_by_id(purchase_currency_id))
var available_currency: float = _get_currency_amount_by_id(purchase_currency_id).to_float()
var estimated_max: int = data.max_affordable(owned, available_currency)
if estimated_max <= 0:
return 0
@@ -219,18 +242,27 @@ func buy_max() -> int:
func get_buffs() -> Array[GeneratorBuffData]:
if data == null:
return []
return data.buffs
if game_state == null:
return []
return game_state.get_buffs_for_generator(_generator_id)
func get_buff_level(buff_id: StringName) -> int:
_ensure_registered()
return GameState.get_generator_buff_level(_generator_id, buff_id)
if game_state == null:
return 0
return game_state.get_buff_level(buff_id)
func is_buff_unlocked(buff_id: StringName) -> bool:
_ensure_registered()
return GameState.is_generator_buff_unlocked(_generator_id, buff_id)
if game_state == null:
return false
return game_state.is_buff_unlocked(buff_id)
func get_buff_cost(buff_id: StringName) -> BigNumber:
var buff: GeneratorBuffData = _find_buff(buff_id)
if game_state == null:
return BigNumber.from_float(0.0)
var buff: GeneratorBuffData = game_state.get_buff(buff_id)
if buff == null:
return BigNumber.from_float(0.0)
if not is_buff_unlocked(buff.id):
@@ -240,7 +272,10 @@ func get_buff_cost(buff_id: StringName) -> BigNumber:
return buff.get_cost_for_level(level)
func get_buff_cost_currency_id(buff_id: StringName) -> StringName:
var buff: GeneratorBuffData = _find_buff(buff_id)
if game_state == null:
return &""
var buff: GeneratorBuffData = game_state.get_buff(buff_id)
if buff == null:
return &""
return _resolve_buff_cost_currency_id(buff)
@@ -249,7 +284,10 @@ func can_buy_buff(buff_id: StringName) -> bool:
if not is_available_to_player():
return false
var buff: GeneratorBuffData = _find_buff(buff_id)
if game_state == null:
return false
var buff: GeneratorBuffData = game_state.get_buff(buff_id)
if buff == null:
return false
@@ -265,21 +303,24 @@ func can_buy_buff(buff_id: StringName) -> bool:
return false
var cost: BigNumber = buff.get_cost_for_level(current_level)
var current_amount: BigNumber = GameState.get_currency_amount_by_id(cost_currency_id)
var current_amount: BigNumber = game_state.get_currency_amount_by_id(cost_currency_id)
return current_amount.is_greater_than(cost) or current_amount.is_equal_to(cost)
func buy_buff(buff_id: StringName) -> bool:
if not is_available_to_player():
return false
var buff: GeneratorBuffData = _find_buff(buff_id)
if game_state == null:
return false
var buff: GeneratorBuffData = game_state.get_buff(buff_id)
if buff == null:
return false
if not is_buff_unlocked(buff.id):
if not game_state.is_buff_unlocked(buff.id):
return false
var current_level: int = get_buff_level(buff.id)
var current_level: int = game_state.get_buff_level(buff.id)
if not buff.can_purchase_next_level(current_level):
return false
@@ -293,25 +334,58 @@ func buy_buff(buff_id: StringName) -> bool:
return false
var next_level: int = current_level + 1
GameState.set_generator_buff_level(_generator_id, buff.id, next_level)
game_state.set_buff_level(buff.id, next_level)
_apply_resource_purchase_buff(buff, next_level)
buff_purchased.emit(buff.id, next_level, cost, cost_currency_id)
return true
func is_buff_maxed(buff_id: StringName) -> bool:
var buff: GeneratorBuffData = _find_buff(buff_id)
if game_state == null:
return true
var buff: GeneratorBuffData = game_state.get_buff(buff_id)
if buff == null:
return true
if not is_buff_unlocked(buff.id):
if not game_state.is_buff_unlocked(buff.id):
return false
return not buff.can_purchase_next_level(get_buff_level(buff.id))
return not buff.can_purchase_next_level(game_state.get_buff_level(buff.id))
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_buff_production_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 _is_research_active(research_id: StringName) -> bool:
if game_state == null:
return true
var research_panel: Node = null
var parent: Node = get_parent()
while parent != null and parent != game_state.get_tree().root:
if parent.has_method("is_research_active"):
research_panel = parent
break
parent = parent.get_parent()
if research_panel == null:
research_panel = 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 reset_runtime_state_for_prestige() -> void:
_is_registered = false
@@ -319,8 +393,6 @@ func reset_runtime_state_for_prestige() -> void:
cycle_progress_seconds = 0.0
_remaining_click_cooldown_seconds = 0.0
_ensure_registered()
_evaluate_generator_unlock_goal(false)
_evaluate_buff_unlock_goals()
visible = is_available_to_player()
func get_manual_click_multiplier() -> float:
@@ -359,9 +431,30 @@ 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)
_remaining_click_cooldown_seconds = _get_click_cooldown_seconds()
# Grant research XP for manual clicks
if data.research_data != null and click_value.mantissa > 0.0:
if not _is_research_active(data.research_data.id):
return
var research_workers: int = game_state.get_research_workers()
if research_workers < data.research_data.min_workers_for_xp:
return
var amount_float: float = click_value.mantissa * pow(10.0, float(click_value.exponent))
var base_xp: BigNumber = BigNumber.from_float(data.research_data.xp_per_currency_produced * amount_float)
var worker_multiplier: float = 1.0 + (float(research_workers) * data.research_data.worker_scaling_factor)
var scaled_xp: BigNumber = base_xp.multiply(BigNumber.from_float(worker_multiplier))
if game_state != null and scaled_xp.mantissa > 0.0:
game_state.add_research_xp(data.research_data.id, scaled_xp)
## Resolves click reward from data.
func _get_click_value() -> BigNumber:
if data == null:
@@ -384,20 +477,10 @@ func _get_click_cooldown_seconds() -> float:
func _grants_click_while_hovering() -> bool:
return grants_click_while_hovering
func _get_prestige_multiplier() -> float:
var manager: PrestigeManager = get_node_or_null("/root/PrestigeManager")
if manager == null:
func _get_prestige_buff_production_multiplier() -> float:
if game_state == null:
return 1.0
if not manager.get_total_multiplier():
return 1.0
var raw_multiplier: Variant = manager.get_total_multiplier()
if raw_multiplier is float:
return maxf(raw_multiplier, 0.0)
if raw_multiplier is int:
return maxf(float(raw_multiplier), 0.0)
return 1.0
return game_state.get_prestige_buff_multiplier(PrestigeBuffNode.EffectType.CURRENCY_PRODUCTION_MULTIPLIER, _generator_id)
## Returns this generator's resolved state id.
func get_generator_id() -> StringName:
@@ -406,14 +489,16 @@ func get_generator_id() -> StringName:
## Returns the configured currency id.
func get_currency_id() -> StringName:
return GameState.get_currency_id(currency)
if game_state == null:
return &""
return game_state.get_currency_id(currency)
func get_purchase_currency_id() -> StringName:
var purchase_currency: Currency = _resolve_purchase_currency()
if purchase_currency == null:
return &""
return GameState.get_currency_id(purchase_currency)
return game_state.get_currency_id(purchase_currency)
## True when the generator is both unlocked and available.
func is_available_to_player() -> bool:
@@ -425,13 +510,17 @@ func _add_currency(amount: BigNumber) -> void:
push_error("CurrencyGenerator '%s' cannot add currency: currency resource is null." % String(name))
return
GameState.add_currency(currency, amount)
if game_state == null:
return
game_state.add_currency(currency, amount)
func _add_currency_by_id(currency_id: StringName, amount: BigNumber) -> void:
if currency_id == &"":
return
GameState.add_currency_by_id(currency_id, amount)
if game_state == null:
return
game_state.add_currency_by_id(currency_id, amount)
## Attempts to spend target currency; returns false when insufficient.
func _spend_currency(cost: BigNumber) -> bool:
@@ -439,25 +528,33 @@ func _spend_currency(cost: BigNumber) -> bool:
push_error("CurrencyGenerator '%s' cannot spend currency: currency resource is null." % String(name))
return false
return GameState.spend_currency(currency, cost)
if game_state == null:
return false
return game_state.spend_currency(currency, cost)
func _spend_currency_by_id(currency_id: StringName, cost: BigNumber) -> bool:
if currency_id == &"":
return false
return GameState.spend_currency_by_id(currency_id, cost)
if game_state == null:
return false
return game_state.spend_currency_by_id(currency_id, cost)
## Returns the current amount for the configured currency type.
func _get_currency_amount() -> BigNumber:
if currency == null:
return BigNumber.from_float(0.0)
return GameState.get_currency_amount(currency)
if game_state == null:
return BigNumber.from_float(0.0)
return game_state.get_currency_amount(currency)
func _get_currency_amount_by_id(currency_id: StringName) -> BigNumber:
if currency_id == &"":
return BigNumber.from_float(0.0)
return GameState.get_currency_amount_by_id(currency_id)
if game_state == null:
return BigNumber.from_float(0.0)
return game_state.get_currency_amount_by_id(currency_id)
## Safely converts finite float values into BigNumber costs.
func _float_to_big_number(value: float) -> BigNumber:
@@ -468,29 +565,19 @@ func _float_to_big_number(value: float) -> BigNumber:
return BigNumber.from_float(value)
## Converts BigNumber to float for approximate estimate math.
func _big_number_to_float(value: BigNumber) -> float:
if value.mantissa <= 0.0:
return 0.0
if value.exponent > 308:
return INF
if value.exponent < -308:
return 0.0
return value.mantissa * pow(10.0, float(value.exponent))
func _find_buff(buff_id: StringName) -> GeneratorBuffData:
if data == null:
return null
return data.find_buff(buff_id)
func _resolve_buff_cost_currency_id(buff: GeneratorBuffData) -> StringName:
if buff == null:
return &""
var cost_currency: Resource = buff.cost_currency if buff.cost_currency != null else currency
var cost_currency: Currency = buff.cost_currency if buff.cost_currency != null else currency
if cost_currency == null:
return &""
return GameState.get_currency_id(cost_currency)
if game_state == null:
return &""
return game_state.get_currency_id(cost_currency)
func _resolve_purchase_currency() -> Currency:
if data == null:
@@ -502,25 +589,18 @@ func _resolve_buff_target_currency_id(buff: GeneratorBuffData) -> StringName:
if buff == null:
return &""
var target_currency: Resource = buff.resource_target_currency if buff.resource_target_currency != null else currency
var target_currency: Currency = buff.resource_target_currency if buff.resource_target_currency != null else currency
if target_currency == null:
return &""
return GameState.get_currency_id(target_currency)
if game_state == null:
return &""
return game_state.get_currency_id(target_currency)
func _get_multiplier_for_buff_kind(kind: GeneratorBuffData.BuffKind) -> float:
var multiplier: float = 1.0
for buff in get_buffs():
if buff == null:
continue
if buff.kind != kind:
continue
if not is_buff_unlocked(buff.id):
continue
multiplier *= buff.get_effect_multiplier(get_buff_level(buff.id))
return maxf(multiplier, 0.0)
if game_state == null:
return 1.0
return game_state.get_effective_multiplier(_generator_id, kind)
func _apply_resource_purchase_buff(buff: GeneratorBuffData, level: int) -> void:
if buff == null:
@@ -550,7 +630,7 @@ func _resolve_generator_id() -> StringName:
return &"generator"
## Ensures this generator has a state entry in GameState.
## Ensures this generator has a state entry in LevelGameState.
func _ensure_registered() -> void:
if _is_registered or _is_registering:
return
@@ -560,8 +640,11 @@ func _ensure_registered() -> void:
if _generator_id == &"":
return
if game_state == null:
return
_is_registering = true
GameState.register_generator(
game_state.register_generator(
_generator_id,
_default_unlocked_state(),
_default_available_state(),
@@ -572,6 +655,9 @@ func _ensure_registered() -> void:
_is_registering = false
func _register_buffs() -> void:
if game_state == null:
return
for buff in get_buffs():
if buff == null:
continue
@@ -581,58 +667,34 @@ func _register_buffs() -> void:
continue
var buff_id: StringName = StringName(buff_id_text)
GameState.register_generator_buff(_generator_id, buff_id, 0)
var persisted_level: int = GameState.get_generator_buff_level(_generator_id, buff_id)
var starts_unlocked: bool = buff.unlocked or persisted_level > 0
GameState.register_generator_buff_unlocked(_generator_id, buff_id, starts_unlocked)
game_state.register_buff(buff)
if not game_state.is_buff_unlocked(buff_id):
var persisted_level: int = game_state.get_buff_level(buff_id)
var starts_unlocked: bool = buff.unlocked or persisted_level > 0
game_state.set_buff_unlocked(buff_id, starts_unlocked)
_evaluate_buff_unlock_goals()
func _evaluate_buff_unlock_goals() -> void:
for buff in get_buffs():
_try_unlock_buff_from_goal(buff)
func _try_unlock_buff_from_goal(buff: GeneratorBuffData) -> void:
if buff == null:
return
var buff_id_text: String = String(buff.id).strip_edges()
if buff_id_text.is_empty():
return
var buff_id: StringName = StringName(buff_id_text)
if GameState.is_generator_buff_unlocked(_generator_id, buff_id):
return
if not buff.has_unlock_goal():
return
if not buff.is_unlock_goal_met():
return
GameState.set_generator_buff_unlocked(_generator_id, buff_id, true)
game_state.try_unlock_buff_from_goal(buff)
func _on_currency_changed(_changed_currency_id: StringName, _new_amount: BigNumber) -> void:
_evaluate_generator_unlock_goal(false)
_evaluate_buff_unlock_goals()
if game_state == null:
return
game_state.evaluate_all_goals()
func try_unlock_from_goal() -> bool:
return _evaluate_generator_unlock_goal(true)
func _evaluate_generator_unlock_goal(allow_manual_unlock: bool) -> bool:
if data == null:
return false
func _on_goal_completed(goal_id: StringName) -> void:
if data == null or not data.has_unlock_goal():
return
if data.get_unlock_goal_id() != goal_id:
return
if is_available_to_player():
return false
if not data.has_unlock_goal():
return false
if not data.unlocks_automatically_from_goal() and not allow_manual_unlock:
return false
if not data.is_unlock_goal_met():
return false
return
GameState.set_generator_unlocked(_generator_id, true)
GameState.set_generator_available(_generator_id, true)
goal_achieved.emit(_generator_id, data.get_unlock_goal_id())
return true
if game_state == null:
return
game_state.set_generator_unlocked(_generator_id, true)
game_state.set_generator_available(_generator_id, true)
goal_achieved.emit(_generator_id, goal_id)
_on_generated_state_changed(_generator_id, game_state._get_generator_state(_generator_id))
## Default unlocked state loaded from generator data.
func _default_unlocked_state() -> bool:
@@ -665,3 +727,5 @@ func _on_area_2d_mouse_exited() -> void:
func _on_generated_state_changed(generator_id: StringName, _state: Dictionary) -> void:
if generator_id == _generator_id:
visible = is_available_to_player()
if info_generator_container != null:
info_generator_container._refresh_generator_ui()

View File

@@ -1,34 +1,6 @@
[gd_scene format=3 uid="uid://jeoiinukrrsp"]
[ext_resource type="Script" uid="uid://dtbxopw6ulhl8" path="res://core/generator/currency_generator.gd" id="1_4n4ca"]
[ext_resource type="Resource" uid="uid://brqaojindcxa5" path="res://idles/currencies/magic.tres" id="2_5tmvy"]
[ext_resource type="Resource" uid="uid://co0mcc2kvcpo5" path="res://idles/generators/orb.tres" id="3_wx13b"]
[ext_resource type="Texture2D" uid="uid://bgtt3wu43tajh" path="res://icon.svg" id="4_ruf1h"]
[ext_resource type="PackedScene" uid="uid://ckos7f22pnmyh" path="res://generator_container.tscn" id="5_bb14m"]
[sub_resource type="RectangleShape2D" id="RectangleShape2D_y5m1q"]
size = Vector2(128, 128)
[node name="CurrencyGenerator" type="Node2D" unique_id=967969064]
script = ExtResource("1_4n4ca")
currency = ExtResource("2_5tmvy")
data = ExtResource("3_wx13b")
[node name="Sprite2D" type="Sprite2D" parent="." unique_id=807584513]
texture = ExtResource("4_ruf1h")
[node name="Area2D" type="Area2D" parent="." unique_id=707349247]
[node name="CollisionShape2D" type="CollisionShape2D" parent="Area2D" unique_id=1572620025]
shape = SubResource("RectangleShape2D_y5m1q")
[node name="GeneratorPanel" parent="." unique_id=1129190041 node_paths=PackedStringArray("_generator") instance=ExtResource("5_bb14m")]
visible = false
offset_left = 33.0
offset_top = -65.0
offset_right = 474.0
offset_bottom = 200.5
_generator = NodePath("..")
[connection signal="mouse_entered" from="Area2D" to="." method="_on_area_2d_mouse_entered"]
[connection signal="mouse_exited" from="Area2D" to="." method="_on_area_2d_mouse_exited"]

View File

@@ -1,11 +1,6 @@
class_name CurrencyGeneratorData
extends Resource
enum UnlockGoalBehavior {
AUTOMATIC,
MANUAL_BUTTON,
}
@export var id: StringName = &""
@export var name: String = ""
@export var starts_unlocked: bool = true
@@ -13,7 +8,6 @@ enum UnlockGoalBehavior {
@export var initial_owned: int = 0
@export var purchase_currency: Currency
@export var unlock_goal: GoalData
@export var unlock_goal_behavior: UnlockGoalBehavior = UnlockGoalBehavior.AUTOMATIC
## Base cost and exponential growth (part I): cost_next = b * r^owned
@export var initial_cost: float = 10.0
@@ -37,8 +31,7 @@ enum UnlockGoalBehavior {
@export var click_exponent: int = 0
@export var click_cooldown_seconds: float = 0.2
## Data-driven buffs purchasable for this generator.
@export var buffs: Array[GeneratorBuffData] = []
@export var research_data: ResearchData
func has_unlock_goal() -> bool:
if unlock_goal == null:
@@ -46,12 +39,6 @@ func has_unlock_goal() -> bool:
return bool(unlock_goal.is_valid())
func is_unlock_goal_met() -> bool:
if not has_unlock_goal():
return false
return bool(unlock_goal.is_met())
func get_unlock_goal_id() -> StringName:
if not has_unlock_goal():
return &""
@@ -62,22 +49,6 @@ func get_unlock_goal_id() -> StringName:
return StringName(goal_id_text)
func unlocks_automatically_from_goal() -> bool:
return unlock_goal_behavior == UnlockGoalBehavior.AUTOMATIC
func find_buff(buff_id: StringName) -> GeneratorBuffData:
var expected_id: String = String(buff_id).strip_edges()
if expected_id.is_empty():
return null
for buff in buffs:
if buff == null:
continue
if String(buff.id) == expected_id:
return buff
return null
## Returns cost of next generator
func cost_next(owned: int) -> float:
return cost_for_amount(owned, 1)

View File

@@ -5,11 +5,13 @@ enum BuffKind {
AUTO_PRODUCTION_MULTIPLIER,
MANUAL_CLICK_MULTIPLIER,
RESOURCE_PURCHASE,
RESEARCH_XP_MULTIPLIER,
}
const HUGE_EXPONENT: int = 1000000
@export var id: StringName = &""
@export var target_ids: Array[StringName] = []
@export var kind: BuffKind = BuffKind.AUTO_PRODUCTION_MULTIPLIER
@export_multiline var text: String = ""
@export var icon: Texture2D
@@ -23,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
@@ -35,6 +41,18 @@ const HUGE_EXPONENT: int = 1000000
@export var resource_purchase_base_exponent: int = 0
@export var resource_purchase_increment_multiplier: float = 1.2
func targets_generator(generator_id: StringName) -> bool:
if target_ids.is_empty():
return false
if target_ids.has("*"):
return true
return target_ids.has(generator_id)
func get_target_generators() -> Array[StringName]:
return target_ids.duplicate()
func has_unlock_goal() -> bool:
if unlock_goal == null:
return false
@@ -45,10 +63,10 @@ func get_unlock_goal_amount() -> BigNumber:
if unlock_goal == null:
return BigNumber.from_float(0.0)
var valid_requirements: Array[GoalRequirementData] = unlock_goal.get_valid_requirements()
if valid_requirements.is_empty():
var requirements: Array[GoalRequirementData] = unlock_goal.get_requirements()
if requirements.is_empty():
return BigNumber.from_float(0.0)
var requirement_resource: GoalRequirementData = valid_requirements[0]
var requirement_resource: GoalRequirementData = requirements[0]
if requirement_resource == null:
return BigNumber.from_float(0.0)
@@ -62,21 +80,15 @@ func get_unlock_goal_currency() -> Currency:
if unlock_goal == null:
return null
var valid_requirements: Array[GoalRequirementData] = unlock_goal.get_valid_requirements()
if valid_requirements.is_empty():
var requirements: Array[GoalRequirementData] = unlock_goal.get_requirements()
if requirements.is_empty():
return null
var requirement_resource: GoalRequirementData = valid_requirements[0]
var requirement_resource: GoalRequirementData = requirements[0]
if requirement_resource == null:
return null
return requirement_resource.currency
func is_unlock_goal_met() -> bool:
if unlock_goal == null:
return false
return bool(unlock_goal.is_met())
func can_purchase_next_level(current_level: int) -> bool:
if max_level < 0:
return true
@@ -126,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

@@ -0,0 +1,167 @@
class_name GeneratorPanel
extends Container
const GENERATOR_BUFF_TILE_SCENE: PackedScene = preload("res://generator_buff_tile.tscn")
class BuffRow extends RefCounted:
var buff: GeneratorBuffData
var tile: GeneratorBuffTile
func _init(row_buff: GeneratorBuffData, row_tile: GeneratorBuffTile) -> void:
buff = row_buff
tile = row_tile
@export var _generator: CurrencyGenerator
@onready var _buy_button: Button = $PanelContainer/VBoxContainer/HBoxContainerGenerators/BuyOneButton
@onready var _buy_max_button: Button = $PanelContainer/VBoxContainer/HBoxContainerGenerators/BuyMaxButton
@onready var _name_label: Label = $PanelContainer/VBoxContainer/Label
@onready var _owned_value: Label = $PanelContainer/VBoxContainer/GeneratorStatsGrid/OwnedValue
@onready var _next_cost_value: Label = $PanelContainer/VBoxContainer/GeneratorStatsGrid/NextCostValue
@onready var _production_value: Label = $PanelContainer/VBoxContainer/GeneratorStatsGrid/ProductionValue
@onready var _buffs_section: VBoxContainer = $PanelContainer/VBoxContainer/BuffsSection
@onready var _buffs_list: VBoxContainer = $PanelContainer/VBoxContainer/BuffsSection/BuffsList
var _buff_rows: Array[BuffRow] = []
var _generator_id: StringName = &""
func _ready() -> void:
if _generator == null:
push_warning("GeneratorContainer '%s' has no generator assigned." % String(name))
return
_generator_id = _generator.get_generator_id()
_generator.purchase_completed.connect(_on_generator_updated)
_generator.production_tick.connect(_on_generator_updated)
_generator.purchase_failed.connect(_on_generator_updated)
_generator.buff_purchased.connect(_on_generator_updated)
_generator.buff_purchase_failed.connect(_on_generator_updated)
if _generator.game_state:
_generator.game_state.currency_changed.connect(_on_currency_changed)
_generator.game_state.generator_state_changed.connect(_on_generator_state_changed)
_name_label.text = _generator.data.name if _generator.data != null else "Generator"
_build_buff_rows()
_refresh_generator_ui()
func _on_buy_pressed() -> void:
_generator.buy(1)
_refresh_generator_ui()
func _on_buy_max_pressed() -> void:
_generator.buy_max()
_refresh_generator_ui()
func _on_debug_income_pressed() -> void:
_generator.grant_currency(BigNumber.from_float(100.0))
_refresh_generator_ui()
func _on_currency_changed(_changed_currency_id: StringName, _new_value: BigNumber) -> void:
_refresh_generator_ui()
func _on_generator_updated(_a = null, _b = null, _c = null, _d = null) -> void:
_refresh_generator_ui()
func _on_generator_state_changed(generator_id: StringName, _state: Dictionary) -> void:
if generator_id == _generator_id:
visible = true
_refresh_generator_ui()
func _on_buy_buff_pressed(buff_id: StringName) -> void:
_generator.buy_buff(buff_id)
_refresh_generator_ui()
func _build_buff_rows() -> void:
_buff_rows.clear()
for child in _buffs_list.get_children():
child.queue_free()
for buff in _generator.get_buffs():
if buff == null:
continue
var buff_id_text: String = String(buff.id).strip_edges()
if buff_id_text.is_empty():
continue
var tile: GeneratorBuffTile = GENERATOR_BUFF_TILE_SCENE.instantiate() as GeneratorBuffTile
if tile == null:
push_warning("Failed to instantiate GeneratorBuffTile for buff '%s'." % buff_id_text)
continue
_buffs_list.add_child(tile)
tile.set_buff(buff)
tile.buy_pressed.connect(_on_buy_buff_pressed)
_buff_rows.append(BuffRow.new(buff, tile))
func _refresh_buff_rows(can_interact: bool) -> void:
_buffs_section.visible = not _buff_rows.is_empty()
if _buff_rows.is_empty():
return
for row in _buff_rows:
if row == null or row.buff == null or row.tile == null:
continue
var buff: GeneratorBuffData = row.buff
var buff_unlocked: bool = _generator.is_buff_unlocked(buff.id)
if not buff_unlocked:
var locked_hint: String = "Locked"
var unlock_goal_currency: Currency = buff.get_unlock_goal_currency()
if buff.has_unlock_goal() and unlock_goal_currency != null and _generator.game_state:
var goal_currency_id: StringName = _generator.game_state.get_currency_id(unlock_goal_currency)
var currency_name: String = _generator.game_state.get_currency_name(goal_currency_id)
var required_text: String = buff.get_unlock_goal_amount().to_string_suffix(2)
locked_hint = "Unlock at %s %s" % [required_text, currency_name]
row.tile.set_runtime(0, "Locked", locked_hint, false, false)
continue
var level: int = _generator.get_buff_level(buff.id)
var effect_text: String = buff.get_effect_description(level)
if _generator.is_buff_maxed(buff.id):
row.tile.set_runtime(level, effect_text, "", false, true)
continue
var cost: BigNumber = _generator.get_buff_cost(buff.id)
var cost_currency_id: StringName = _generator.get_buff_cost_currency_id(buff.id)
var currency_label: String = _generator.game_state.get_currency_name(cost_currency_id) if _generator.game_state else "Unknown"
var cost_text: String = "%s %s" % [cost.to_string_suffix(2), currency_label]
var can_buy: bool = can_interact and _generator.can_buy_buff(buff.id)
row.tile.set_runtime(level, effect_text, cost_text, can_buy, false)
func _refresh_generator_ui() -> void:
if _buy_button == null or _buy_max_button == null:
return
if _generator == null:
return
var can_interact: bool = _generator.is_available_to_player()
_buy_button.disabled = not can_interact
_buy_max_button.disabled = not can_interact
_owned_value.text = str(_generator.owned)
if can_interact:
var purchase_currency_id: StringName = _generator.get_purchase_currency_id()
var purchase_currency_name: String = _generator.game_state.get_currency_name(purchase_currency_id) if _generator.game_state else "Unknown"
if purchase_currency_name.is_empty():
purchase_currency_name = "Unconfigured"
_next_cost_value.text = "%s %s" % [_generator.get_next_cost().to_string_suffix(2), purchase_currency_name]
else:
_next_cost_value.text = "-"
var production_per_second: float = 0.0
if _generator.data != null and can_interact:
production_per_second = _generator.data.production_per_second(
_generator.owned,
_generator.get_effective_auto_run_multiplier(),
_generator.purchased_count
)
_production_value.text = BigNumber.from_float(production_per_second).to_string_suffix(2)
_refresh_buff_rows(can_interact)

View File

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

View File

@@ -0,0 +1,85 @@
[gd_scene format=3 uid="uid://ckos7f22pnmyh"]
[ext_resource type="Script" uid="uid://es14nqk6vrrk" path="res://core/generator/generator_container.gd" id="1_8wouw"]
[node name="GeneratorContainer" type="PanelContainer" unique_id=1451609580]
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -132.0
offset_top = -82.5
offset_right = 132.0
offset_bottom = 87.5
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_8wouw")
[node name="PanelContainer" type="MarginContainer" parent="." unique_id=370221865]
layout_mode = 2
theme_override_constants/margin_left = 20
theme_override_constants/margin_top = 20
theme_override_constants/margin_right = 20
theme_override_constants/margin_bottom = 20
[node name="VBoxContainer" type="VBoxContainer" parent="PanelContainer" unique_id=1139263929]
layout_mode = 2
[node name="Label" type="Label" parent="PanelContainer/VBoxContainer" unique_id=1867673612]
layout_mode = 2
text = "Generator"
[node name="HBoxContainerGenerators" type="HBoxContainer" parent="PanelContainer/VBoxContainer" unique_id=2033456807]
layout_mode = 2
theme_override_constants/separation = 8
[node name="BuyOneButton" type="Button" parent="PanelContainer/VBoxContainer/HBoxContainerGenerators" unique_id=1171095139]
layout_mode = 2
text = "Buy x1"
[node name="BuyMaxButton" type="Button" parent="PanelContainer/VBoxContainer/HBoxContainerGenerators" unique_id=871628534]
layout_mode = 2
text = "Buy Max"
[node name="GeneratorStatsGrid" type="GridContainer" parent="PanelContainer/VBoxContainer" unique_id=745078076]
layout_mode = 2
columns = 2
[node name="OwnedTitle" type="Label" parent="PanelContainer/VBoxContainer/GeneratorStatsGrid" unique_id=891417119]
layout_mode = 2
text = "Owned"
[node name="OwnedValue" type="Label" parent="PanelContainer/VBoxContainer/GeneratorStatsGrid" unique_id=549086894]
layout_mode = 2
text = "0"
[node name="NextCostTitle" type="Label" parent="PanelContainer/VBoxContainer/GeneratorStatsGrid" unique_id=303459589]
layout_mode = 2
text = "Next Cost"
[node name="NextCostValue" type="Label" parent="PanelContainer/VBoxContainer/GeneratorStatsGrid" unique_id=50480604]
layout_mode = 2
text = "0"
[node name="ProductionTitle" type="Label" parent="PanelContainer/VBoxContainer/GeneratorStatsGrid" unique_id=2139535774]
layout_mode = 2
text = "Production / sec"
[node name="ProductionValue" type="Label" parent="PanelContainer/VBoxContainer/GeneratorStatsGrid" unique_id=938669936]
layout_mode = 2
text = "0"
[node name="BuffsSection" type="VBoxContainer" parent="PanelContainer/VBoxContainer" unique_id=640102105]
layout_mode = 2
[node name="BuffsTitle" type="Label" parent="PanelContainer/VBoxContainer/BuffsSection" unique_id=1169271241]
layout_mode = 2
text = "Buffs"
[node name="BuffsList" type="VBoxContainer" parent="PanelContainer/VBoxContainer/BuffsSection" unique_id=1221547661]
layout_mode = 2
theme_override_constants/separation = 4
[connection signal="pressed" from="PanelContainer/VBoxContainer/HBoxContainerGenerators/BuyOneButton" to="." method="_on_buy_pressed"]
[connection signal="pressed" from="PanelContainer/VBoxContainer/HBoxContainerGenerators/BuyMaxButton" to="." method="_on_buy_max_pressed"]

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