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:
192
core/goals/README.md
Normal file
192
core/goals/README.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# Goals Module Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The `goals/` subfolder contains the achievement system. Goals track player progress and can unlock generators or buffs when conditions are met.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `goal_data.gd` | Goal definition resource |
|
||||
| `goal_requirement_data.gd` | Individual requirement criteria |
|
||||
|
||||
## GoalData
|
||||
|
||||
Resource class defining an achievement.
|
||||
|
||||
### Properties
|
||||
|
||||
```gdscript
|
||||
class_name GoalData
|
||||
extends Resource
|
||||
|
||||
@export var id: StringName = &"" # Unique identifier
|
||||
@export var requirements: Array[GoalRequirementData] # Conditions to complete
|
||||
@export var unlock_behavior: UnlockBehavior = MANUAL # AUTOMATIC or MANUAL
|
||||
```
|
||||
|
||||
### Enum
|
||||
|
||||
```gdscript
|
||||
enum UnlockBehavior {
|
||||
AUTOMATIC, # Goal completes immediately when met
|
||||
MANUAL, # Goal requires player to click unlock button
|
||||
}
|
||||
```
|
||||
|
||||
### Methods
|
||||
|
||||
```gdscript
|
||||
func has_id() -> bool # Has non-empty id
|
||||
func is_valid() -> bool # Has valid id and requirements
|
||||
func get_requirements() -> Array[GoalRequirementData] # Access requirements
|
||||
```
|
||||
|
||||
**Note:** State-dependent methods (`is_goal_met()`, `get_goal_progress()`, `get_goal_requirement_summary()`) are implemented in `LevelGameState` for proper decoupling.
|
||||
|
||||
## GoalRequirementData
|
||||
|
||||
Single condition within a goal.
|
||||
|
||||
### Properties
|
||||
|
||||
```gdscript
|
||||
class_name GoalRequirementData
|
||||
extends Resource
|
||||
|
||||
@export var currency: Currency # Currency to track
|
||||
@export var amount_mantissa: float = 0.0 # Required amount (scientific notation)
|
||||
@export var amount_exponent: int = 0
|
||||
```
|
||||
|
||||
### Methods
|
||||
|
||||
```gdscript
|
||||
func get_currency() -> Currency # Currency to track
|
||||
func get_amount() -> BigNumber # Required amount
|
||||
func has_valid_amount() -> bool # Amount is non-negative
|
||||
```
|
||||
|
||||
**Note:** State-dependent methods (`is_met()`, `get_progress()`, `get_summary_text()`) are implemented in `LevelGameState` for proper decoupling.
|
||||
|
||||
### Progress Calculation
|
||||
|
||||
Uses **logarithmic scaling** for better visual feedback on huge numbers:
|
||||
|
||||
```gdscript
|
||||
# Convert to log space
|
||||
current_log = log(current_mantissa) / log(10) + current_exponent
|
||||
required_log = log(required_mantissa) / log(10) + required_exponent
|
||||
|
||||
# Progress ratio
|
||||
progress = clampf(current_log / required_log, 0.0, 1.0)
|
||||
```
|
||||
|
||||
This means reaching 50% progress on a 1e100 goal requires ~1e50 currency.
|
||||
|
||||
## Goal Evaluation
|
||||
|
||||
### Automatic Checking
|
||||
|
||||
`LevelGameState` evaluates goals whenever currency changes:
|
||||
|
||||
```gdscript
|
||||
func _on_currency_changed(currency_id, new_amount):
|
||||
evaluate_all_goals()
|
||||
```
|
||||
|
||||
### Completion Flow
|
||||
|
||||
1. Currency updated → signal emitted
|
||||
2. `LevelGameState.evaluate_all_goals()` called
|
||||
3. Each uncompleted goal checked via `is_goal_met(goal)`
|
||||
4. If met → `goal_completed` signal emitted
|
||||
5. Buffs with unlock goals checked → unlocked if goal complete
|
||||
|
||||
### Manual Unlock vs Automatic
|
||||
|
||||
Goals control their own unlock behavior via `unlock_behavior`:
|
||||
- **AUTOMATIC**: Goal completes immediately when requirements are met
|
||||
- **MANUAL**: Goal stays incomplete until player clicks unlock button
|
||||
|
||||
Generators with `unlock_goal` automatically unlock when the goal completes.
|
||||
|
||||
### Goal Methods in LevelGameState
|
||||
|
||||
```gdscript
|
||||
func is_goal_requirement_met(requirement: GoalRequirementData) -> bool
|
||||
func get_goal_requirement_progress(requirement: GoalRequirementData) -> float
|
||||
func get_goal_requirement_summary(requirement: GoalRequirementData) -> String
|
||||
func is_goal_met(goal: GoalData) -> bool
|
||||
func get_goal_progress(goal: GoalData) -> float
|
||||
```
|
||||
|
||||
## Usage Example
|
||||
|
||||
### Defining a Goal
|
||||
|
||||
Create `res://sandbox/goals/first_million.tres`:
|
||||
|
||||
```
|
||||
[gd_resource type="Resource" script_class="GoalData"]
|
||||
[resource]
|
||||
id = &"first_million"
|
||||
requirements = [
|
||||
preload("res://sandbox/goals/first_million_req.tres")
|
||||
]
|
||||
```
|
||||
|
||||
### Defining a Requirement
|
||||
|
||||
Create `res://sandbox/goals/first_million_req.tres`:
|
||||
|
||||
```
|
||||
[gd_resource type="Resource" script_class="GoalRequirementData"]
|
||||
[resource]
|
||||
currency = preload("res://sandbox/currencies/gold.tres")
|
||||
amount_mantissa = 1.0
|
||||
amount_exponent = 6 # 1,000,000
|
||||
```
|
||||
|
||||
### Connecting to Generator
|
||||
|
||||
In `CurrencyGeneratorData`:
|
||||
```gdscript
|
||||
@export var unlock_goal: GoalData # Assign goal resource
|
||||
```
|
||||
|
||||
The goal's `unlock_behavior` determines if the generator unlocks automatically or requires manual button click.
|
||||
|
||||
## Signals
|
||||
|
||||
```gdscript
|
||||
signal goal_completed(goal_id: StringName)
|
||||
signal goal_progress_changed(goal_id: StringName, progress: float)
|
||||
```
|
||||
|
||||
## Integration Points
|
||||
|
||||
### With LevelGameState
|
||||
- Goals registered from `GoalCatalogue` assigned to `LevelGameState`
|
||||
- Completion state saved to JSON
|
||||
- Buff unlock goals checked automatically
|
||||
|
||||
### With Buffs
|
||||
```gdscript
|
||||
# In GeneratorBuffData
|
||||
@export var unlock_goal: GoalData
|
||||
|
||||
# When goal completes, buff auto-unlocks:
|
||||
level_game_state._try_unlock_buff_from_goal(buff)
|
||||
```
|
||||
|
||||
### With Prestige
|
||||
- Goal completion state persists through prestige
|
||||
- Reset via `LevelGameState.reset_for_prestige()`
|
||||
|
||||
## See Also
|
||||
|
||||
- `core/level_game_state.gd` - Goal state management
|
||||
- `core/generator/currency_generator_data.gd` - Generator unlock goals
|
||||
- `core/generator/generator_buff_data.gd` - Buff unlock goals
|
||||
153
core/goals/current_goal_panel.gd
Normal file
153
core/goals/current_goal_panel.gd
Normal file
@@ -0,0 +1,153 @@
|
||||
## Single-goal tile showing the current (first uncompleted) goal from GoalCatalogue.
|
||||
## Mirrors a single row from goals_debug_ui: goal name, requirements text, and a resolve button.
|
||||
extends PanelContainer
|
||||
|
||||
@onready var _game_state: LevelGameState = find_parent("LevelGameState")
|
||||
@onready var _goal_label: Label = $VBoxContainer/GoalLabel
|
||||
@onready var _requirements_label: Label = $VBoxContainer/RequirementsLabel
|
||||
@onready var _resolve_button: Button = $VBoxContainer/ResolveButton
|
||||
|
||||
var _current_goal: GoalData
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_resolve_button.pressed.connect(_on_resolve_pressed)
|
||||
if _game_state:
|
||||
_game_state.currency_changed.connect(_on_currency_changed)
|
||||
_game_state.generator_state_changed.connect(_on_generator_state_changed)
|
||||
_game_state.goal_completed.connect(_on_goal_completed)
|
||||
if not _game_state.goal_catalogue or _game_state.goal_catalogue.goals.is_empty():
|
||||
_game_state.ready.connect(_on_level_state_ready)
|
||||
|
||||
_refresh_current_goal()
|
||||
_refresh_ui()
|
||||
_refresh_ui.call_deferred()
|
||||
|
||||
|
||||
func _get_current_goal() -> GoalData:
|
||||
if _game_state == null:
|
||||
return null
|
||||
var catalogue: GoalCatalogue = _game_state.goal_catalogue
|
||||
if catalogue == null:
|
||||
return null
|
||||
|
||||
for goal in catalogue.goals:
|
||||
if goal == null:
|
||||
continue
|
||||
if not goal.has_id():
|
||||
continue
|
||||
if _game_state.is_goal_completed(goal.id):
|
||||
continue
|
||||
return goal
|
||||
|
||||
return null
|
||||
|
||||
|
||||
func _refresh_current_goal() -> void:
|
||||
_current_goal = _get_current_goal()
|
||||
|
||||
|
||||
func _refresh_ui() -> void:
|
||||
if _current_goal == null:
|
||||
_goal_label.text = "All goals complete!"
|
||||
_requirements_label.text = ""
|
||||
_resolve_button.disabled = true
|
||||
_resolve_button.text = "Done"
|
||||
return
|
||||
|
||||
_goal_label.text = String(_current_goal.id)
|
||||
|
||||
if _game_state == null:
|
||||
return
|
||||
|
||||
var already_completed: bool = _game_state.is_goal_completed(_current_goal.id)
|
||||
|
||||
_requirements_label.text = _get_requirements_text(_current_goal)
|
||||
|
||||
var met: bool = _game_state.is_goal_met(_current_goal)
|
||||
var can_click: bool = met and not already_completed
|
||||
|
||||
_resolve_button.disabled = not can_click
|
||||
_resolve_button.text = "Done" if already_completed else "Resolve"
|
||||
|
||||
|
||||
func _get_requirements_text(goal: GoalData) -> String:
|
||||
if goal == null or _game_state == null:
|
||||
return ""
|
||||
|
||||
var parts: Array[String] = []
|
||||
for req in goal.get_requirements():
|
||||
if req == null:
|
||||
continue
|
||||
|
||||
var currency: Currency = req.get_currency()
|
||||
if currency == null:
|
||||
continue
|
||||
|
||||
var currency_id: StringName = _game_state.get_currency_id(currency)
|
||||
var total_amount: BigNumber = _game_state.get_total_currency_acquired_by_id(currency_id)
|
||||
var required_amount: BigNumber = req.get_amount()
|
||||
var currency_name: String = _game_state.get_currency_name(currency_id)
|
||||
|
||||
parts.append(
|
||||
"%s %s / %s" % [
|
||||
currency_name,
|
||||
total_amount.to_string_suffix(2),
|
||||
required_amount.to_string_suffix(2),
|
||||
]
|
||||
)
|
||||
|
||||
if parts.is_empty():
|
||||
return "No requirements"
|
||||
return " | ".join(parts)
|
||||
|
||||
|
||||
func _on_resolve_pressed() -> void:
|
||||
if _current_goal == null:
|
||||
return
|
||||
if _game_state == null:
|
||||
return
|
||||
if _game_state.is_goal_completed(_current_goal.id):
|
||||
return
|
||||
if not _game_state.is_goal_met(_current_goal):
|
||||
return
|
||||
|
||||
if _current_goal.unlock_behavior == GoalData.UnlockBehavior.MANUAL:
|
||||
_game_state._complete_goal_manually(_current_goal.id)
|
||||
|
||||
_refresh_current_goal()
|
||||
_refresh_ui()
|
||||
|
||||
|
||||
func _on_currency_changed(_currency_id: StringName, _new_amount: BigNumber) -> void:
|
||||
_refresh_current_goal()
|
||||
_refresh_ui()
|
||||
|
||||
|
||||
func _on_generator_state_changed(_generator_id: StringName, _state: Dictionary) -> void:
|
||||
_refresh_current_goal()
|
||||
_refresh_ui()
|
||||
|
||||
|
||||
func _on_goal_completed(_goal_id: StringName) -> void:
|
||||
_refresh_current_goal()
|
||||
_refresh_ui()
|
||||
|
||||
|
||||
func _on_level_state_ready() -> void:
|
||||
if _game_state:
|
||||
_game_state.ready.disconnect(_on_level_state_ready)
|
||||
_refresh_current_goal()
|
||||
_refresh_ui()
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
if _game_state:
|
||||
if _game_state.currency_changed.is_connected(_on_currency_changed):
|
||||
_game_state.currency_changed.disconnect(_on_currency_changed)
|
||||
if _game_state.generator_state_changed.is_connected(_on_generator_state_changed):
|
||||
_game_state.generator_state_changed.disconnect(_on_generator_state_changed)
|
||||
if _game_state.goal_completed.is_connected(_on_goal_completed):
|
||||
_game_state.goal_completed.disconnect(_on_goal_completed)
|
||||
if _game_state.ready.is_connected(_on_level_state_ready):
|
||||
_game_state.ready.disconnect(_on_level_state_ready)
|
||||
1
core/goals/current_goal_panel.gd.uid
Normal file
1
core/goals/current_goal_panel.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dfn06haxb2yhr
|
||||
26
core/goals/current_goal_panel.tscn
Normal file
26
core/goals/current_goal_panel.tscn
Normal file
@@ -0,0 +1,26 @@
|
||||
[gd_scene format=3 uid="uid://dudfvxilbydas"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://dfn06haxb2yhr" path="res://core/goals/current_goal_panel.gd" id="1_70l2h"]
|
||||
|
||||
[node name="CurrentGoalPanel" type="PanelContainer" unique_id=1791509101]
|
||||
offset_left = 155.0
|
||||
offset_top = 210.0
|
||||
offset_right = 500.0
|
||||
offset_bottom = 320.0
|
||||
script = ExtResource("1_70l2h")
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="." unique_id=933762288]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 4
|
||||
|
||||
[node name="GoalLabel" type="Label" parent="VBoxContainer" unique_id=1859068341]
|
||||
layout_mode = 2
|
||||
text = "Goal"
|
||||
|
||||
[node name="RequirementsLabel" type="Label" parent="VBoxContainer" unique_id=2092922827]
|
||||
layout_mode = 2
|
||||
text = "Requirements"
|
||||
|
||||
[node name="ResolveButton" type="Button" parent="VBoxContainer" unique_id=760904344]
|
||||
layout_mode = 2
|
||||
text = "Resolve"
|
||||
17
core/goals/goal_catalogue.gd
Normal file
17
core/goals/goal_catalogue.gd
Normal file
@@ -0,0 +1,17 @@
|
||||
class_name GoalCatalogue
|
||||
extends Resource
|
||||
|
||||
@export var goals: Array[GoalData] = []
|
||||
|
||||
func get_goal_by_id(id: StringName) -> GoalData:
|
||||
for goal in goals:
|
||||
if goal.id == id:
|
||||
return goal
|
||||
return null
|
||||
|
||||
func get_all_ids() -> Array[StringName]:
|
||||
var ids: Array[StringName] = []
|
||||
for goal in goals:
|
||||
if goal.has_id():
|
||||
ids.append(goal.id)
|
||||
return ids
|
||||
1
core/goals/goal_catalogue.gd.uid
Normal file
1
core/goals/goal_catalogue.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bfbp4mo8ys5p8
|
||||
@@ -1,43 +1,34 @@
|
||||
class_name GoalData
|
||||
extends Resource
|
||||
|
||||
enum UnlockBehavior {
|
||||
AUTOMATIC,
|
||||
MANUAL,
|
||||
}
|
||||
|
||||
@export var id: StringName = &""
|
||||
@export var requirements: Array[GoalRequirementData] = []
|
||||
@export var unlock_behavior: UnlockBehavior = UnlockBehavior.MANUAL
|
||||
|
||||
func has_id() -> bool:
|
||||
return not String(id).strip_edges().is_empty()
|
||||
|
||||
func get_valid_requirements() -> Array[GoalRequirementData]:
|
||||
var result: Array[GoalRequirementData] = []
|
||||
for requirement_resource in requirements:
|
||||
if requirement_resource == null:
|
||||
continue
|
||||
if not bool(requirement_resource.is_valid()):
|
||||
continue
|
||||
|
||||
result.append(requirement_resource)
|
||||
|
||||
return result
|
||||
func get_requirements() -> Array[GoalRequirementData]:
|
||||
return requirements.duplicate()
|
||||
|
||||
func is_valid() -> bool:
|
||||
if not has_id():
|
||||
return false
|
||||
|
||||
return not get_valid_requirements().is_empty()
|
||||
|
||||
func is_met() -> bool:
|
||||
var valid_requirements: Array[GoalRequirementData] = get_valid_requirements()
|
||||
if valid_requirements.is_empty():
|
||||
|
||||
if requirements.is_empty():
|
||||
return false
|
||||
|
||||
for requirement_resource in valid_requirements:
|
||||
if not bool(requirement_resource.is_met()):
|
||||
return false
|
||||
|
||||
|
||||
for req in requirements:
|
||||
if req == null:
|
||||
continue
|
||||
if req.get_currency() == null:
|
||||
continue
|
||||
if not req.has_valid_amount():
|
||||
continue
|
||||
|
||||
return true
|
||||
|
||||
func get_first_requirement_summary() -> String:
|
||||
var valid_requirements: Array[GoalRequirementData] = get_valid_requirements()
|
||||
if valid_requirements.is_empty():
|
||||
return ""
|
||||
return String(valid_requirements[0].get_summary_text())
|
||||
|
||||
@@ -5,38 +5,11 @@ extends Resource
|
||||
@export var amount_mantissa: float = 0.0
|
||||
@export var amount_exponent: int = 0
|
||||
|
||||
func get_currency_id() -> StringName:
|
||||
if currency == null:
|
||||
return &""
|
||||
|
||||
return GameState.get_currency_id(currency)
|
||||
func get_currency() -> Currency:
|
||||
return currency
|
||||
|
||||
func get_amount() -> BigNumber:
|
||||
return BigNumber.new(amount_mantissa, amount_exponent)
|
||||
|
||||
func has_valid_currency() -> bool:
|
||||
var currency_id: StringName = get_currency_id()
|
||||
if currency_id == &"":
|
||||
return false
|
||||
|
||||
return GameState.is_known_currency_id(currency_id)
|
||||
|
||||
func has_valid_amount() -> bool:
|
||||
return get_amount().mantissa >= 0.0
|
||||
|
||||
func is_valid() -> bool:
|
||||
return has_valid_currency() and has_valid_amount()
|
||||
|
||||
func is_met() -> bool:
|
||||
if not is_valid():
|
||||
return false
|
||||
|
||||
var total_amount: BigNumber = GameState.get_total_currency_acquired_by_id(get_currency_id())
|
||||
return not total_amount.is_less_than(get_amount())
|
||||
|
||||
func get_summary_text() -> String:
|
||||
if not is_valid():
|
||||
return ""
|
||||
|
||||
var currency_name: String = GameState.get_currency_name(get_currency_id())
|
||||
return "%s %s" % [get_amount().to_string_suffix(2), currency_name]
|
||||
|
||||
Reference in New Issue
Block a user