Add goals

This commit is contained in:
Michele Rossi
2026-03-14 11:12:05 +01:00
parent 1322903637
commit 1041fc917d
13 changed files with 897 additions and 29 deletions

View File

@@ -123,22 +123,35 @@ func divide(other: BigNumber) -> BigNumber:
## Returns 1 if this > other, -1 if this < other, 0 if equal ## Returns 1 if this > other, -1 if this < other, 0 if equal
func compare_to(other: BigNumber) -> int: func compare_to(other: BigNumber) -> int:
if mantissa == 0.0 and other.mantissa == 0.0: return 0 if mantissa == 0.0 and other.mantissa == 0.0:
return 0
# Handle zero explicitly before sign/exponent checks.
if mantissa == 0.0:
return -1 if other.mantissa > 0.0 else 1
if other.mantissa == 0.0:
return 1 if mantissa > 0.0 else -1
# Handle signs # Handle signs
if mantissa > 0 and other.mantissa <= 0: return 1 if mantissa > 0 and other.mantissa < 0:
if mantissa < 0 and other.mantissa >= 0: return -1 return 1
if mantissa < 0 and other.mantissa > 0:
return -1
# Both are same sign. Compare exponents first. # Both are same sign. Compare exponents first.
var sign_mult: int = 1 if mantissa > 0 else -1 var sign_mult: int = 1 if mantissa > 0 else -1
if exponent > other.exponent: return sign_mult if exponent > other.exponent:
if exponent < other.exponent: return -sign_mult return sign_mult
if exponent < other.exponent:
return -sign_mult
# Exponents are equal, compare mantissas # Exponents are equal, compare mantissas
if mantissa > other.mantissa: return 1 if mantissa > other.mantissa:
if mantissa < other.mantissa: return -1 return 1
if mantissa < other.mantissa:
return -1
return 0 return 0
func is_greater_than(other: BigNumber) -> bool: func is_greater_than(other: BigNumber) -> bool:

View File

@@ -12,8 +12,33 @@ enum CurrencyType { gold, gems }
# ========================================== # ==========================================
# STATE VARIABLES # STATE VARIABLES
# ========================================== # ==========================================
var gold: BigNumber = BigNumber.from_float(0.0) var _current_currency: Dictionary = {}
var gems: BigNumber = BigNumber.from_float(0.0) var _total_currency_acquired: Dictionary = {}
var gold: BigNumber:
get:
return _get_current_currency_ref(CurrencyType.gold)
set(value):
_set_current_currency(CurrencyType.gold, value)
var gems: BigNumber:
get:
return _get_current_currency_ref(CurrencyType.gems)
set(value):
_set_current_currency(CurrencyType.gems, value)
var total_gold_acquired: BigNumber:
get:
return _get_total_currency_ref(CurrencyType.gold)
set(value):
_set_total_currency(CurrencyType.gold, value)
var total_gems_acquired: BigNumber:
get:
return _get_total_currency_ref(CurrencyType.gems)
set(value):
_set_total_currency(CurrencyType.gems, value)
var generator_states: Dictionary = {} var generator_states: Dictionary = {}
var last_save_time: int = 0 # Unix timestamp var last_save_time: int = 0 # Unix timestamp
@@ -26,35 +51,47 @@ const GENERATOR_UNLOCKED_KEY: String = "unlocked"
const GENERATOR_AVAILABLE_KEY: String = "available" const GENERATOR_AVAILABLE_KEY: String = "available"
func _ready() -> void: func _ready() -> void:
_initialize_currency_maps()
load_game() load_game()
# ========================================== # ==========================================
# STATE MODIFIERS # STATE MODIFIERS
# ========================================== # ==========================================
func add_gold(amount: BigNumber) -> void: func add_gold(amount: BigNumber) -> void:
gold.add_in_place(amount) add_currency(CurrencyType.gold, amount)
currency_changed.emit(CurrencyType.gold, gold)
func spend_gold(cost: BigNumber) -> bool: func spend_gold(cost: BigNumber) -> bool:
if gold.is_greater_than(cost) or gold.is_equal_to(cost): return spend_currency(CurrencyType.gold, cost)
var negative_cost = BigNumber.new(-cost.mantissa, cost.exponent)
gold.add_in_place(negative_cost)
currency_changed.emit(CurrencyType.gold, gold)
return true
return false
func add_gems(amount: BigNumber) -> void: func add_gems(amount: BigNumber) -> void:
gems.add_in_place(amount) add_currency(CurrencyType.gems, amount)
currency_changed.emit(CurrencyType.gems, gems)
func spend_gems(cost: BigNumber) -> bool: func spend_gems(cost: BigNumber) -> bool:
if gems.is_greater_than(cost) or gems.is_equal_to(cost): return spend_currency(CurrencyType.gems, cost)
var negative_cost = BigNumber.new(-cost.mantissa, cost.exponent)
gems.add_in_place(negative_cost) func add_currency(currency: CurrencyType, amount: BigNumber) -> void:
currency_changed.emit(CurrencyType.gems, gems) if amount.mantissa > 0.0:
_get_total_currency_ref(currency).add_in_place(amount)
var current: BigNumber = _get_current_currency_ref(currency)
current.add_in_place(amount)
currency_changed.emit(currency, current)
func spend_currency(currency: CurrencyType, cost: BigNumber) -> bool:
var current: BigNumber = _get_current_currency_ref(currency)
if current.is_greater_than(cost) or current.is_equal_to(cost):
var negative_cost: BigNumber = BigNumber.new(-cost.mantissa, cost.exponent)
current.add_in_place(negative_cost)
currency_changed.emit(currency, current)
return true return true
return false return false
func get_currency_amount(currency: CurrencyType) -> BigNumber:
return _get_current_currency_ref(currency)
func get_total_currency_acquired(currency: CurrencyType) -> BigNumber:
return _get_total_currency_ref(currency)
func register_generator(generator_id: StringName, unlocked: bool = false, available: bool = false) -> void: func register_generator(generator_id: StringName, unlocked: bool = false, available: bool = false) -> void:
var key: String = String(generator_id) var key: String = String(generator_id)
if key.is_empty(): if key.is_empty():
@@ -132,6 +169,8 @@ func save_game() -> void:
var save_data = { var save_data = {
"gold": gold.serialize(), "gold": gold.serialize(),
"gems": gems.serialize(), "gems": gems.serialize(),
"total_gold_acquired": total_gold_acquired.serialize(),
"total_gems_acquired": total_gems_acquired.serialize(),
"generator_states": _serialize_generator_states(), "generator_states": _serialize_generator_states(),
"last_save_time": Time.get_unix_time_from_system() "last_save_time": Time.get_unix_time_from_system()
} }
@@ -150,9 +189,59 @@ func load_game() -> void:
if parsed is Dictionary: if parsed is Dictionary:
gold = BigNumber.deserialize(parsed.get("gold", {})) gold = BigNumber.deserialize(parsed.get("gold", {}))
gems = BigNumber.deserialize(parsed.get("gems", {})) gems = BigNumber.deserialize(parsed.get("gems", {}))
total_gold_acquired = _deserialize_total_currency(parsed.get("total_gold_acquired", null), gold)
total_gems_acquired = _deserialize_total_currency(parsed.get("total_gems_acquired", null), gems)
generator_states = _deserialize_generator_states(parsed.get("generator_states", {})) generator_states = _deserialize_generator_states(parsed.get("generator_states", {}))
last_save_time = parsed.get("last_save_time", Time.get_unix_time_from_system()) last_save_time = parsed.get("last_save_time", Time.get_unix_time_from_system())
func _deserialize_total_currency(raw_total: Variant, current_amount: BigNumber) -> BigNumber:
var minimum_total: BigNumber = _copy_big_number(current_amount)
if minimum_total.mantissa < 0.0:
minimum_total = BigNumber.from_float(0.0)
if not (raw_total is Dictionary):
return minimum_total
var parsed_total: BigNumber = BigNumber.deserialize(raw_total)
if parsed_total.mantissa < 0.0:
return minimum_total
if parsed_total.is_less_than(minimum_total):
return minimum_total
return parsed_total
func _copy_big_number(value: BigNumber) -> BigNumber:
return BigNumber.new(value.mantissa, value.exponent)
func _initialize_currency_maps() -> void:
_set_current_currency(CurrencyType.gold, BigNumber.from_float(0.0))
_set_current_currency(CurrencyType.gems, BigNumber.from_float(0.0))
_set_total_currency(CurrencyType.gold, BigNumber.from_float(0.0))
_set_total_currency(CurrencyType.gems, BigNumber.from_float(0.0))
func _get_current_currency_ref(currency: CurrencyType) -> BigNumber:
var value: Variant = _current_currency.get(currency)
if value is BigNumber:
return value
var fallback: BigNumber = BigNumber.from_float(0.0)
_current_currency[currency] = fallback
return fallback
func _get_total_currency_ref(currency: CurrencyType) -> BigNumber:
var value: Variant = _total_currency_acquired.get(currency)
if value is BigNumber:
return value
var fallback: BigNumber = BigNumber.from_float(0.0)
_total_currency_acquired[currency] = fallback
return fallback
func _set_current_currency(currency: CurrencyType, value: BigNumber) -> void:
_current_currency[currency] = _copy_big_number(value)
func _set_total_currency(currency: CurrencyType, value: BigNumber) -> void:
_total_currency_acquired[currency] = _copy_big_number(value)
func _default_generator_state(unlocked: bool, available: bool) -> Dictionary: func _default_generator_state(unlocked: bool, available: bool) -> Dictionary:
return { return {
GENERATOR_OWNED_KEY: 0, GENERATOR_OWNED_KEY: 0,

View File

@@ -0,0 +1,197 @@
# Goal-Based Generator Unlocks - Technical Specification
## Document Status
- Version: 1.0
- Date: 2026-03-14
- Scope: Unlock generators when goal thresholds are reached using one or more currencies.
- Context sources: Amp threads `T-019ce93a-d410-70ad-aaa0-4697dafb0148` and `T-019c846f-2556-75bc-a1aa-fb26cd9ff52a`.
## Problem Statement
The project already supports generator state (`unlocked` + `available`) in `GameState`, but unlock
transitions are currently configured only by static defaults (`starts_unlocked`, `starts_available`).
We need progression goals so that a locked generator becomes usable only after the player reaches
specific currency quantities. Goals must support one or more currencies per objective.
## Current Baseline
1. Generator state is persisted in `GameState.generator_states` with keys `owned`, `purchased_count`,
`unlocked`, `available`.
2. `CurrencyGenerator` already exposes `is_unlocked`, `is_available`, and `is_available_to_player()`
and uses them to gate click, auto-production, and purchases.
3. `GeneratorContainer` listens to `GameState.generator_state_changed` and updates button enabled/disabled state.
4. Save/load sanitization already handles generator state schema evolution safely.
This means the unlock feature can be added without changing core purchase/production mechanics.
## Goals And Non-Goals
## Goals
1. Define unlock goals that depend on one or multiple currency thresholds.
2. Evaluate goals automatically when relevant currency values change.
3. Unlock and make target generator available exactly once when a goal is met.
4. Keep behavior deterministic after save/load.
## Non-Goals
1. Adding quests, timed objectives, or repeatable missions.
2. Adding reward types other than generator unlock/availability.
3. Rebalancing generator economy values in this feature.
## Functional Requirements
1. A goal targets exactly one generator (`target_generator_id`).
2. A goal contains one or more currency requirements (`requirements[]`).
3. Goal completion rule is logical AND across requirements (all thresholds must be met).
4. Supported currencies must map to existing `GameState.CurrencyType` values (`gold`, `gems`).
5. When completed, a goal sets both:
- `GameState.set_generator_unlocked(target, true)`
- `GameState.set_generator_available(target, true)`
6. Goal completion must be idempotent (re-evaluating does not re-apply side effects).
7. Goal checks must run:
- at runtime on `currency_changed`
- once at startup after loading save data
8. Invalid goal entries (unknown currency, missing target id, malformed amount) must be ignored with a warning,
not a crash.
## Data Model
## New Config File
Add a root JSON file:
- `res://generator_unlock_goals.json`
Schema (v1):
```json
{
"version": 1,
"goals": [
{
"id": "unlock_gems_generator",
"target_generator_id": "Gems",
"requirements": [
{
"currency": "gold",
"amount": { "m": 1.0, "e": 3 }
}
]
}
]
}
```
Notes:
1. `amount` uses existing `BigNumber` serialization (`m`, `e`) to avoid float precision/overflow issues.
2. `id` must be unique and stable.
3. `requirements` must contain at least one entry.
## Runtime Structures (GDScript)
Recommended internal structs/classes:
1. `UnlockGoalRequirement`
- `currency: GameState.CurrencyType`
- `amount: BigNumber`
2. `UnlockGoal`
- `id: StringName`
- `target_generator_id: StringName`
- `requirements: Array[UnlockGoalRequirement]`
No persistent `completed_goals` storage is required for v1 because completion is derived from generator unlock state.
## System Design
## New Runtime Component
Add a scene-level controller script (recommended name: `generator_unlock_system.gd`) responsible for:
1. Loading + validating `generator_unlock_goals.json`.
2. Subscribing to currency change signals.
3. Evaluating pending goals.
4. Triggering generator unlock transitions.
Placement options:
1. Attach to the main gameplay scene root (preferred for prototype).
2. Promote to autoload later if multiple gameplay scenes require shared unlock logic.
## Evaluation Flow
1. On `_ready()`:
- load goals
- connect to `GameState.currency_changed`
- run `evaluate_all_goals()` once
2. On currency change:
- run `evaluate_all_goals()` (or an optimized subset)
3. For each goal:
- skip if target generator already unlocked and available
- verify all requirement thresholds
- if satisfied, unlock target generator and emit debug/info log
## Pseudocode
```gdscript
func _evaluate_goal(goal: UnlockGoal) -> bool:
if GameState.is_generator_unlocked(goal.target_generator_id) and GameState.is_generator_available(goal.target_generator_id):
return false
for requirement in goal.requirements:
var current: BigNumber = _get_currency_amount(requirement.currency)
if current.is_less_than(requirement.amount):
return false
GameState.set_generator_unlocked(goal.target_generator_id, true)
GameState.set_generator_available(goal.target_generator_id, true)
return true
```
## UI/UX Behavior
1. Existing `GeneratorContainer` behavior already prevents interaction while locked.
2. Optional v1.1 enhancement: show a compact "Unlock requirement" line for locked generators.
3. On unlock, UI updates automatically through existing `generator_state_changed` signal path.
## Error Handling And Validation
1. File open failure: log warning and disable unlock processing for that session.
2. JSON parse failure or wrong root type: log warning and disable unlock processing.
3. Invalid goal entries: skip only invalid entries, continue loading valid ones.
4. Duplicate goal IDs: keep first entry, ignore duplicates with warning.
5. Unknown `target_generator_id`: allow config load, but warn when evaluation cannot find/resolve state.
## Save/Load Behavior
1. Unlock result persists naturally via existing generator state persistence.
2. On load, if a goal was completed previously, no duplicate effect occurs because state is already unlocked.
3. If config thresholds are reduced in future versions, startup evaluation can unlock additional generators from existing balances.
## Performance Considerations
1. v1 can safely evaluate all goals on every currency change (small goal count).
2. If goals scale up, add currency-to-goal index to evaluate only affected goals.
3. BigNumber comparisons are lightweight for this usage profile.
## Implementation Plan
1. Add `generator_unlock_goals.json` with initial goals.
2. Implement `generator_unlock_system.gd` loader/validator/evaluator.
3. Attach unlock system to gameplay scene (`generator_museum.tscn` or current active scene).
4. Configure at least one generator as initially locked (`starts_unlocked = false`, `starts_available = false`) in its `.tres` data.
5. Add runtime logs for unlock events and invalid config entries.
6. Run headless project parse smoke check.
## Acceptance Criteria
1. A locked generator becomes usable immediately after all configured currency thresholds are reached.
2. Goals with multiple requirements unlock only when every requirement is satisfied.
3. Unlock state persists after save/load and app restart.
4. No crashes occur when goals file is missing or malformed.
5. Existing generator purchase/production behavior is unchanged for already unlocked generators.
## Manual Test Cases
1. Start with a generator configured as locked; verify buy buttons are disabled.
2. Grant required currency via debug controls; verify automatic unlock and enabled buttons.
3. Save/restart; verify generator remains unlocked.
4. Use a multi-currency goal; verify partial progress does not unlock.
5. Corrupt one goal entry in JSON; verify warning is logged and other valid goals still work.

View File

@@ -0,0 +1,25 @@
{
"version": 1,
"goals": [
{
"id": "first_goal",
"target_generator_id": "Gems",
"requirements": [
{
"currency": "gold",
"amount": { "m": 30, "e": 0 }
}
]
},
{
"id": "second_goal",
"target_generator_id": "Gems",
"requirements": [
{
"currency": "gold",
"amount": { "m": 1.3, "e": 3 }
}
]
}
]
}

View File

@@ -0,0 +1,211 @@
extends Node
class UnlockGoalRequirement extends RefCounted:
var currency: GameState.CurrencyType
var amount: BigNumber
func _init(req_currency: GameState.CurrencyType, req_amount: BigNumber) -> void:
currency = req_currency
amount = req_amount
class UnlockGoal extends RefCounted:
var id: StringName
var target_generator_id: StringName
var requirements: Array[UnlockGoalRequirement]
func _init(goal_id: StringName, target_id: StringName, goal_requirements: Array[UnlockGoalRequirement]) -> void:
id = goal_id
target_generator_id = target_id
requirements = goal_requirements
const GOALS_FILE_PATH: String = "res://generator-unlock-goals/generator_unlock_goals.json"
const SCHEMA_VERSION: int = 1
var _goals: Array[UnlockGoal] = []
var _known_generator_ids: Dictionary = {}
var _warned_unknown_target_ids: Dictionary = {}
func _ready() -> void:
_collect_known_generator_ids()
_load_goals()
GameState.currency_changed.connect(_on_currency_changed)
_evaluate_all_goals()
func _on_currency_changed(_changed_currency: GameState.CurrencyType, _new_amount: BigNumber) -> void:
_evaluate_all_goals()
func _evaluate_all_goals() -> void:
if _goals.is_empty():
return
for goal in _goals:
_evaluate_goal(goal)
func _evaluate_goal(goal: UnlockGoal) -> bool:
if _is_goal_completed(goal):
return false
if not _can_resolve_target(goal.target_generator_id):
return false
for requirement in goal.requirements:
var total_amount: BigNumber = _get_total_currency_amount(requirement.currency)
if total_amount.is_less_than(requirement.amount):
return false
GameState.set_generator_unlocked(goal.target_generator_id, true)
GameState.set_generator_available(goal.target_generator_id, true)
print("[UnlockSystem] Goal completed: %s -> %s" % [String(goal.id), String(goal.target_generator_id)])
return true
func _is_goal_completed(goal: UnlockGoal) -> bool:
return (
GameState.is_generator_unlocked(goal.target_generator_id)
and GameState.is_generator_available(goal.target_generator_id)
)
func _can_resolve_target(target_generator_id: StringName) -> bool:
var target_key: String = String(target_generator_id)
if target_key.is_empty():
return false
if _known_generator_ids.has(target_key) or GameState.generator_states.has(target_key):
return true
if not _warned_unknown_target_ids.has(target_key):
_warned_unknown_target_ids[target_key] = true
push_warning("Unlock goal target generator not found in scene/state: '%s'" % target_key)
return false
func _load_goals() -> void:
_goals.clear()
if not FileAccess.file_exists(GOALS_FILE_PATH):
push_warning("Unlock goals file not found: %s" % GOALS_FILE_PATH)
return
var file: FileAccess = FileAccess.open(GOALS_FILE_PATH, FileAccess.READ)
if file == null:
push_warning("Unable to open unlock goals file: %s" % GOALS_FILE_PATH)
return
var parsed: Variant = JSON.parse_string(file.get_as_text())
if not (parsed is Dictionary):
push_warning("Unlock goals root must be a Dictionary: %s" % GOALS_FILE_PATH)
return
var root: Dictionary = parsed
var version: int = int(root.get("version", SCHEMA_VERSION))
if version != SCHEMA_VERSION:
push_warning("Unlock goals schema version %d is different from expected %d" % [version, SCHEMA_VERSION])
var raw_goals: Variant = root.get("goals", [])
if not (raw_goals is Array):
push_warning("Unlock goals file has invalid 'goals' field (expected Array)")
return
var seen_goal_ids: Dictionary = {}
for raw_goal in raw_goals:
var goal: UnlockGoal = _parse_goal(raw_goal)
if goal == null:
continue
var goal_key: String = String(goal.id)
if seen_goal_ids.has(goal_key):
push_warning("Duplicate unlock goal id skipped: '%s'" % goal_key)
continue
seen_goal_ids[goal_key] = true
_goals.append(goal)
if _goals.is_empty():
push_warning("Unlock goals loaded, but no valid entries were found.")
func _parse_goal(raw_goal: Variant) -> UnlockGoal:
if not (raw_goal is Dictionary):
push_warning("Skipping unlock goal: entry is not a Dictionary")
return null
var goal_dict: Dictionary = raw_goal
var goal_id: String = String(goal_dict.get("id", "")).strip_edges()
var target_id: String = String(goal_dict.get("target_generator_id", "")).strip_edges()
if goal_id.is_empty():
push_warning("Skipping unlock goal: missing 'id'")
return null
if target_id.is_empty():
push_warning("Skipping unlock goal '%s': missing 'target_generator_id'" % goal_id)
return null
var raw_requirements: Variant = goal_dict.get("requirements", [])
if not (raw_requirements is Array):
push_warning("Skipping unlock goal '%s': 'requirements' must be an Array" % goal_id)
return null
var parsed_requirements: Array[UnlockGoalRequirement] = []
for raw_requirement in raw_requirements:
var requirement: UnlockGoalRequirement = _parse_requirement(raw_requirement, goal_id)
if requirement != null:
parsed_requirements.append(requirement)
if parsed_requirements.is_empty():
push_warning("Skipping unlock goal '%s': no valid requirements" % goal_id)
return null
return UnlockGoal.new(StringName(goal_id), StringName(target_id), parsed_requirements)
func _parse_requirement(raw_requirement: Variant, goal_id: String) -> UnlockGoalRequirement:
if not (raw_requirement is Dictionary):
push_warning("Skipping requirement in goal '%s': entry is not a Dictionary" % goal_id)
return null
var requirement_dict: Dictionary = raw_requirement
var currency_value: int = _parse_currency(requirement_dict.get("currency", ""))
if currency_value < 0:
push_warning("Skipping requirement in goal '%s': unknown currency '%s'" % [goal_id, String(requirement_dict.get("currency", ""))])
return null
var raw_amount: Variant = requirement_dict.get("amount", {})
if not (raw_amount is Dictionary):
push_warning("Skipping requirement in goal '%s': 'amount' must be a Dictionary" % goal_id)
return null
var amount: BigNumber = BigNumber.deserialize(raw_amount)
if amount.mantissa < 0.0:
push_warning("Skipping requirement in goal '%s': amount must be non-negative" % goal_id)
return null
return UnlockGoalRequirement.new(currency_value, amount)
func _parse_currency(raw_currency: Variant) -> int:
if raw_currency is int:
var currency_int: int = raw_currency
if currency_int == GameState.CurrencyType.gold or currency_int == GameState.CurrencyType.gems:
return currency_int
return -1
var currency_text: String = String(raw_currency).to_lower().strip_edges()
match currency_text:
"gold":
return GameState.CurrencyType.gold
"gems", "gem":
return GameState.CurrencyType.gems
_:
return -1
func _get_total_currency_amount(currency: GameState.CurrencyType) -> BigNumber:
return GameState.get_total_currency_acquired(currency)
func _collect_known_generator_ids() -> void:
_known_generator_ids.clear()
var generator_nodes: Array[Node] = find_children("*", "CurrencyGenerator", true, false)
for generator_node in generator_nodes:
var generator: CurrencyGenerator = generator_node as CurrencyGenerator
if generator == null:
continue
var generator_key: String = String(generator.get_generator_id())
if generator_key.is_empty():
continue
_known_generator_ids[generator_key] = true

View File

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

View File

@@ -0,0 +1,273 @@
extends PanelContainer
class GoalRequirement extends RefCounted:
var currency: GameState.CurrencyType
var amount: BigNumber
func _init(req_currency: GameState.CurrencyType, req_amount: BigNumber) -> void:
currency = req_currency
amount = req_amount
class GoalDefinition extends RefCounted:
var id: StringName
var target_generator_id: StringName
var requirements: Array[GoalRequirement]
func _init(goal_id: StringName, target_id: StringName, goal_requirements: Array[GoalRequirement]) -> void:
id = goal_id
target_generator_id = target_id
requirements = goal_requirements
class GoalRow extends RefCounted:
var goal: GoalDefinition
var status_label: Label
var requirements_label: Label
var button: Button
func _init(goal_definition: GoalDefinition, status: Label, requirements: Label, unlock_button: Button) -> void:
goal = goal_definition
status_label = status
requirements_label = requirements
button = unlock_button
const GOALS_FILE_PATH: String = "res://generator-unlock-goals/generator_unlock_goals.json"
@onready var _summary_label: Label = $MarginContainer/VBoxContainer/SummaryLabel
@onready var _goals_list: VBoxContainer = $MarginContainer/VBoxContainer/ScrollContainer/GoalsList
var _goals: Array[GoalDefinition] = []
var _rows_by_goal_id: Dictionary = {}
var _completed_goal_ids: Dictionary = {}
func _ready() -> void:
_load_goals()
_build_goal_rows()
GameState.currency_changed.connect(_on_currency_changed)
GameState.generator_state_changed.connect(_on_generator_state_changed)
_refresh_ui()
_refresh_ui.call_deferred()
func _on_currency_changed(_currency_type: GameState.CurrencyType, _new_amount: BigNumber) -> void:
_refresh_ui()
func _on_generator_state_changed(_generator_id: StringName, _state: Dictionary) -> void:
_refresh_ui()
func _load_goals() -> void:
_goals.clear()
if not FileAccess.file_exists(GOALS_FILE_PATH):
push_warning("Goals debug UI: goals file not found at %s" % GOALS_FILE_PATH)
return
var file: FileAccess = FileAccess.open(GOALS_FILE_PATH, FileAccess.READ)
if file == null:
push_warning("Goals debug UI: could not open goals file at %s" % GOALS_FILE_PATH)
return
var parsed: Variant = JSON.parse_string(file.get_as_text())
if not (parsed is Dictionary):
push_warning("Goals debug UI: goals root must be a Dictionary")
return
var goals_raw: Variant = parsed.get("goals", [])
if not (goals_raw is Array):
push_warning("Goals debug UI: goals must be an Array")
return
var seen_goal_ids: Dictionary = {}
for raw_goal in goals_raw:
var goal: GoalDefinition = _parse_goal(raw_goal)
if goal == null:
continue
var goal_key: String = String(goal.id)
if seen_goal_ids.has(goal_key):
push_warning("Goals debug UI: duplicate goal id '%s' skipped" % goal_key)
continue
seen_goal_ids[goal_key] = true
_goals.append(goal)
func _parse_goal(raw_goal: Variant) -> GoalDefinition:
if not (raw_goal is Dictionary):
return null
var goal_dict: Dictionary = raw_goal
var goal_id: String = String(goal_dict.get("id", "")).strip_edges()
var target_id: String = String(goal_dict.get("target_generator_id", "")).strip_edges()
if goal_id.is_empty() or target_id.is_empty():
return null
var raw_requirements: Variant = goal_dict.get("requirements", [])
if not (raw_requirements is Array):
return null
var requirements: Array[GoalRequirement] = []
for raw_requirement in raw_requirements:
var requirement: GoalRequirement = _parse_requirement(raw_requirement)
if requirement != null:
requirements.append(requirement)
if requirements.is_empty():
return null
return GoalDefinition.new(StringName(goal_id), StringName(target_id), requirements)
func _parse_requirement(raw_requirement: Variant) -> GoalRequirement:
if not (raw_requirement is Dictionary):
return null
var requirement_dict: Dictionary = raw_requirement
var currency_value: int = _parse_currency(requirement_dict.get("currency", ""))
if currency_value < 0:
return null
var raw_amount: Variant = requirement_dict.get("amount", {})
if not (raw_amount is Dictionary):
return null
var amount: BigNumber = BigNumber.deserialize(raw_amount)
if amount.mantissa < 0.0:
return null
return GoalRequirement.new(currency_value, amount)
func _parse_currency(raw_currency: Variant) -> int:
if raw_currency is int:
var currency_int: int = raw_currency
if currency_int == GameState.CurrencyType.gold or currency_int == GameState.CurrencyType.gems:
return currency_int
return -1
var currency_text: String = String(raw_currency).to_lower().strip_edges()
match currency_text:
"gold":
return GameState.CurrencyType.gold
"gems", "gem":
return GameState.CurrencyType.gems
_:
return -1
func _build_goal_rows() -> void:
_rows_by_goal_id.clear()
for child in _goals_list.get_children():
child.queue_free()
for goal in _goals:
var row_container: HBoxContainer = HBoxContainer.new()
row_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row_container.add_theme_constant_override("separation", 12)
var title_label: Label = Label.new()
title_label.custom_minimum_size = Vector2(220.0, 0.0)
title_label.text = "%s -> %s" % [String(goal.id), String(goal.target_generator_id)]
var status_label: Label = Label.new()
status_label.custom_minimum_size = Vector2(95.0, 0.0)
var requirements_label: Label = Label.new()
requirements_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var unlock_button: Button = Button.new()
unlock_button.custom_minimum_size = Vector2(90.0, 0.0)
unlock_button.text = "Unlock"
unlock_button.disabled = true
unlock_button.pressed.connect(_on_unlock_pressed.bind(goal.id))
row_container.add_child(title_label)
row_container.add_child(status_label)
row_container.add_child(requirements_label)
row_container.add_child(unlock_button)
_goals_list.add_child(row_container)
_rows_by_goal_id[String(goal.id)] = GoalRow.new(goal, status_label, requirements_label, unlock_button)
func _refresh_ui() -> void:
if _goals.is_empty():
_summary_label.text = "No valid goals loaded"
return
var ready_to_unlock: int = 0
var unlocked: int = 0
for goal in _goals:
var row: GoalRow = _rows_by_goal_id.get(String(goal.id))
if row == null:
continue
_refresh_goal_row(row)
if _is_goal_completed(goal.id):
unlocked += 1
elif _is_goal_met(goal):
ready_to_unlock += 1
_summary_label.text = "Goals: %d | Ready: %d | Unlocked: %d" % [_goals.size(), ready_to_unlock, unlocked]
func _refresh_goal_row(row: GoalRow) -> void:
var goal: GoalDefinition = row.goal
var already_unlocked: bool = _is_goal_completed(goal.id)
var met: bool = _is_goal_met(goal)
var can_unlock: bool = met and not already_unlocked
row.requirements_label.text = _get_requirements_text(goal)
row.button.disabled = not can_unlock
row.button.text = "Done" if already_unlocked else "Unlock"
if already_unlocked:
row.status_label.text = "Unlocked"
return
if met:
row.status_label.text = "Ready"
return
row.status_label.text = "Locked"
func _is_goal_met(goal: GoalDefinition) -> bool:
for requirement in goal.requirements:
var total_amount: BigNumber = GameState.get_total_currency_acquired(requirement.currency)
if total_amount.is_less_than(requirement.amount):
return false
return true
func _is_goal_completed(goal_id: StringName) -> bool:
return _completed_goal_ids.get(String(goal_id), false)
func _on_unlock_pressed(goal_id: StringName) -> void:
var row: GoalRow = _rows_by_goal_id.get(String(goal_id))
if row == null:
return
var goal: GoalDefinition = row.goal
if _is_goal_completed(goal.id):
return
if not _is_goal_met(goal):
return
_completed_goal_ids[String(goal.id)] = true
GameState.set_generator_unlocked(goal.target_generator_id, true)
GameState.set_generator_available(goal.target_generator_id, true)
print("[GoalsDebugUI] Unlocked goal '%s' -> %s" % [String(goal.id), String(goal.target_generator_id)])
_refresh_ui()
func _get_requirements_text(goal: GoalDefinition) -> String:
var parts: Array[String] = []
for requirement in goal.requirements:
var total_amount: BigNumber = GameState.get_total_currency_acquired(requirement.currency)
parts.append(
"%s %s / %s" % [
_currency_label(requirement.currency),
total_amount.to_string_suffix(2),
requirement.amount.to_string_suffix(2)
]
)
return " | ".join(parts)
func _currency_label(currency: GameState.CurrencyType) -> String:
match currency:
GameState.CurrencyType.gold:
return "Gold"
GameState.CurrencyType.gems:
return "Gems"
_:
return "Unknown"

View File

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

View File

@@ -0,0 +1,45 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://generator-unlock-goals/goals_debug_ui.gd" id="1_s7s8v"]
[node name="GoalsDebugUI" type="PanelContainer"]
offset_right = 640.0
offset_bottom = 260.0
script = ExtResource("1_s7s8v")
[node name="MarginContainer" type="MarginContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/margin_left = 8
theme_override_constants/margin_top = 8
theme_override_constants/margin_right = 8
theme_override_constants/margin_bottom = 8
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_constants/separation = 6
[node name="TitleLabel" type="Label" parent="MarginContainer/VBoxContainer"]
layout_mode = 2
text = "Goals Debug"
[node name="SummaryLabel" type="Label" parent="MarginContainer/VBoxContainer"]
layout_mode = 2
text = "Goals: 0 | Ready: 0 | Unlocked: 0"
[node name="ScrollContainer" type="ScrollContainer" parent="MarginContainer/VBoxContainer"]
custom_minimum_size = Vector2(0, 170)
layout_mode = 2
size_flags_vertical = 3
[node name="GoalsList" type="VBoxContainer" parent="MarginContainer/VBoxContainer/ScrollContainer"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_constants/separation = 6

View File

@@ -8,5 +8,4 @@ id = &"Gold"
name = "Gold Mine" name = "Gold Mine"
initial_time = 0.6 initial_time = 0.6
initial_productivity = 1.67 initial_productivity = 1.67
grants_click_while_hovering = true
metadata/_custom_type_script = "uid://b00tqsuhxdy0d" metadata/_custom_type_script = "uid://b00tqsuhxdy0d"

View File

@@ -6,6 +6,8 @@
script = ExtResource("1_s3g28") script = ExtResource("1_s3g28")
id = &"Gems" id = &"Gems"
name = "Gems Mine" name = "Gems Mine"
starts_unlocked = false
starts_available = false
initial_cost = 60.0 initial_cost = 60.0
initial_time = 3.0 initial_time = 3.0
initial_revenue = 60.0 initial_revenue = 60.0

View File

@@ -6,6 +6,7 @@
[ext_resource type="Texture2D" uid="uid://bgtt3wu43tajh" path="res://icon.svg" id="3_mhw8n"] [ext_resource type="Texture2D" uid="uid://bgtt3wu43tajh" path="res://icon.svg" id="3_mhw8n"]
[ext_resource type="PackedScene" uid="uid://ckos7f22pnmyh" path="res://generator_container.tscn" id="3_pw2a0"] [ext_resource type="PackedScene" uid="uid://ckos7f22pnmyh" path="res://generator_container.tscn" id="3_pw2a0"]
[ext_resource type="PackedScene" uid="uid://btkxru2gdjsgc" path="res://currency_tile.tscn" id="3_x5lt1"] [ext_resource type="PackedScene" uid="uid://btkxru2gdjsgc" path="res://currency_tile.tscn" id="3_x5lt1"]
[ext_resource type="PackedScene" path="res://generator-unlock-goals/goals_debug_ui.tscn" id="4_63gjq"]
[sub_resource type="RectangleShape2D" id="RectangleShape2D_y5m1q"] [sub_resource type="RectangleShape2D" id="RectangleShape2D_y5m1q"]
size = Vector2(128, 128) size = Vector2(128, 128)
@@ -16,6 +17,7 @@ size = Vector2(128, 128)
position = Vector2(566, 455) position = Vector2(566, 455)
script = ExtResource("1_pl17p") script = ExtResource("1_pl17p")
data = ExtResource("2_x5lt1") data = ExtResource("2_x5lt1")
grants_click_while_hovering = true
[node name="Sprite2D" type="Sprite2D" parent="GoldGenerator" unique_id=381586216] [node name="Sprite2D" type="Sprite2D" parent="GoldGenerator" unique_id=381586216]
texture = ExtResource("3_mhw8n") texture = ExtResource("3_mhw8n")
@@ -29,6 +31,8 @@ shape = SubResource("RectangleShape2D_y5m1q")
script = ExtResource("1_pl17p") script = ExtResource("1_pl17p")
currency = 1 currency = 1
data = ExtResource("3_dc82g") data = ExtResource("3_dc82g")
starts_unlocked = false
starts_available = false
[node name="UI" type="Control" parent="." unique_id=452530906] [node name="UI" type="Control" parent="." unique_id=452530906]
layout_mode = 3 layout_mode = 3
@@ -66,5 +70,12 @@ offset_right = 247.0
offset_bottom = 380.0 offset_bottom = 380.0
_generator = NodePath("../../GemsGenerator") _generator = NodePath("../../GemsGenerator")
[node name="GoalsDebugUI" parent="UI" unique_id=4710697 instance=ExtResource("4_63gjq")]
layout_mode = 1
offset_left = 577.0
offset_top = 3.0
offset_right = 1150.0
offset_bottom = 262.0
[connection signal="mouse_entered" from="GoldGenerator/Area2D" to="GoldGenerator" method="_on_area_2d_mouse_entered"] [connection signal="mouse_entered" from="GoldGenerator/Area2D" to="GoldGenerator" method="_on_area_2d_mouse_entered"]
[connection signal="mouse_exited" from="GoldGenerator/Area2D" to="GoldGenerator" method="_on_area_2d_mouse_exited"] [connection signal="mouse_exited" from="GoldGenerator/Area2D" to="GoldGenerator" method="_on_area_2d_mouse_exited"]

View File

@@ -11,6 +11,7 @@ config_version=5
[application] [application]
config/name="Idles" config/name="Idles"
config/version="0.0.1"
config/features=PackedStringArray("4.6", "Forward Plus") config/features=PackedStringArray("4.6", "Forward Plus")
config/icon="res://icon.svg" config/icon="res://icon.svg"